diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 5214b0c1a9a..7eb89e57e69 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -2,7 +2,7 @@ import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentu import { Clipboard } from "@tui/util/clipboard" import { TextAttributes } from "@opentui/core" import { RouteProvider, useRoute } from "@tui/context/route" -import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js" +import { createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js" import { Installation } from "@/installation" import { Global } from "@/global" import { Flag } from "@/flag/flag" @@ -20,9 +20,12 @@ import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command import { DialogAgent } from "@tui/component/dialog-agent" import { DialogSessionList } from "@tui/component/dialog-session-list" import { KeybindProvider } from "@tui/context/keybind" +import { LayoutProvider, useLayout } from "@tui/context/layout" +import { WindowCommandsProvider } from "@tui/context/window-commands" +import { RouteLayoutBridgeProvider } from "@tui/context/route-layout-bridge" +import { LayoutRenderer } from "@tui/layout/renderer" import { ThemeProvider, useTheme } from "@tui/context/theme" -import { Home } from "@tui/routes/home" -import { Session } from "@tui/routes/session" + import { PromptHistoryProvider } from "./component/prompt/history" import { PromptStashProvider } from "./component/prompt/stash" import { DialogAlert } from "./ui/dialog-alert" @@ -121,17 +124,23 @@ export function tui(input: { url: string; args: Args; onExit?: () => Promise - - - - - - - - - - - + + + + + + + + + + + + + + + + + @@ -178,6 +187,7 @@ function App() { const sync = useSync() const exit = useExit() const promptRef = usePromptRef() + const layout = useLayout() // Wire up console copy-to-clipboard via opentui's onCopySelection callback renderer.console.onCopySelection = async (text: string) => { @@ -236,10 +246,7 @@ function App() { local.model.set({ providerID, modelID }, { recent: true }) } if (args.sessionID) { - route.navigate({ - type: "session", - sessionID: args.sessionID, - }) + layout.navigateFocusedWindow(`session:${args.sessionID}`) } }) }) @@ -253,7 +260,7 @@ function App() { .find((x) => x.parentID === undefined)?.id if (match) { continued = true - route.navigate({ type: "session", sessionID: match }) + layout.navigateFocusedWindow(`session:${match}`) } }) @@ -290,6 +297,7 @@ function App() { const current = promptRef.current // Don't require focus - if there's any text, preserve it const currentPrompt = current?.current?.input ? current.current : undefined + layout.navigateFocusedWindow("home") route.navigate({ type: "home", initialPrompt: currentPrompt, @@ -528,7 +536,7 @@ function App() { sdk.event.on(SessionApi.Event.Deleted.type, (evt) => { if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) { - route.navigate({ type: "home" }) + layout.navigateFocusedWindow("home") toast.show({ variant: "info", message: "The current session was deleted", @@ -599,14 +607,7 @@ function App() { } }} > - - - - - - - - + ) } 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 cb7b5d282ee..b96604db351 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 @@ -9,6 +9,7 @@ import { useTheme } from "../context/theme" import { useSDK } from "../context/sdk" import { DialogSessionRename } from "./dialog-session-rename" import { useKV } from "../context/kv" +import { useLayout } from "../context/layout" import "opentui-spinner/solid" export function DialogSessionList() { @@ -18,6 +19,7 @@ export function DialogSessionList() { const route = useRoute() const sdk = useSDK() const kv = useKV() + const layout = useLayout() const [toDelete, setToDelete] = createSignal() @@ -74,10 +76,8 @@ export function DialogSessionList() { setToDelete(undefined) }} onSelect={(option) => { - route.navigate({ - type: "session", - sessionID: option.value, - }) + // Update window view (layout system handles route sync via bridge) + layout.navigateFocusedWindow(`session:${option.value}`) dialog.clear() }} keybind={[ diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index f819746d53c..386ce917da5 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -30,6 +30,9 @@ import { DialogProvider as DialogProviderConnect } from "../dialog-provider" import { DialogAlert } from "../../ui/dialog-alert" import { useToast } from "../../ui/toast" import { useKV } from "../../context/kv" +import { useWindowID } from "../../context/window-id" +import { WindowFocusRegistry } from "../../window-focus-registry" +import { useLayout } from "../../context/layout" export type PromptProps = { sessionID?: string @@ -126,6 +129,8 @@ export function Prompt(props: PromptProps) { const renderer = useRenderer() const { theme, syntax } = useTheme() const kv = useKV() + const windowID = useWindowID() + const layout = useLayout() function promptModelWarning() { toast.show({ @@ -350,6 +355,11 @@ export function Prompt(props: PromptProps) { onMount(() => { promptPartTypeId = input.extmarks.registerType("prompt-part") + // Register with window focus system if in a window context + if (windowID) { + const unregister = WindowFocusRegistry.register(windowID, { focus: () => input.focus() }) + onCleanup(unregister) + } }) function restoreExtmarksFromParts(parts: PromptInfo["parts"]) { @@ -522,6 +532,14 @@ export function Prompt(props: PromptProps) { if (props.disabled) return if (autocomplete?.visible) return if (!store.prompt.input) return + if (autocomplete?.visible) { + toast.show({ message: "DEBUG: blocked by autocomplete", variant: "error", duration: 3000 }) + return + } + if (!store.prompt.input) { + toast.show({ message: "DEBUG: blocked by empty input", variant: "error", duration: 3000 }) + return + } const trimmed = store.prompt.input.trim() if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") { exit() @@ -623,14 +641,21 @@ export function Prompt(props: PromptProps) { setStore("extmarkToPartIndex", new Map()) props.onSubmit?.() - // temporary hack to make sure the message is sent - if (!props.sessionID) + // Navigate to the new session after submission + if (!props.sessionID) { setTimeout(() => { - route.navigate({ - type: "session", - sessionID, - }) + if (windowID) { + // Multi-window mode: update this window's view + layout.navigateFocusedWindow(`session:${sessionID}`) + } else { + // Single-window mode: use route navigation + route.navigate({ + type: "session", + sessionID, + }) + } }, 50) + } input.clear() } const exit = useExit() diff --git a/packages/opencode/src/cli/cmd/tui/context/layout.tsx b/packages/opencode/src/cli/cmd/tui/context/layout.tsx new file mode 100644 index 00000000000..d31df50fe7e --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/layout.tsx @@ -0,0 +1,184 @@ +// packages/opencode/src/cli/cmd/tui/context/layout.tsx +import { createMemo } from "solid-js" +import { createStore, produce } from "solid-js/store" +import { createSimpleContext } from "./helper" +import { Layout } from "../layout/types" +import { LayoutOps } from "../layout/operations" +import { WindowFocusRegistry } from "../window-focus-registry" + +export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({ + name: "Layout", + init: () => { + const [layout, setLayout] = createStore({ + root: Layout.Window.create({ id: "win-main", viewID: "home" }), + floats: [], + focusedID: "win-main", + }) + + const focusedWindow = createMemo(() => LayoutOps.findWindow(layout, layout.focusedID)) + const allWindows = createMemo(() => LayoutOps.getAllWindows(layout)) + + return { + get layout() { + return layout + }, + get focusedWindow() { + return focusedWindow() + }, + get allWindows() { + return allWindows() + }, + + splitHorizontal(viewID: string) { + const newID = LayoutOps.generateID("win") + setLayout( + produce((draft) => { + const result = LayoutOps.splitWindow(draft, draft.focusedID, "horizontal", { + id: newID, + viewID, + }) + Object.assign(draft, result) + }), + ) + WindowFocusRegistry.focus(newID) + return newID + }, + + splitVertical(viewID: string) { + const newID = LayoutOps.generateID("win") + setLayout( + produce((draft) => { + const result = LayoutOps.splitWindow(draft, draft.focusedID, "vertical", { + id: newID, + viewID, + }) + Object.assign(draft, result) + }), + ) + WindowFocusRegistry.focus(newID) + return newID + }, + + closeWindow(windowID?: string) { + const targetID = windowID ?? layout.focusedID + const result = LayoutOps.closeWindow(layout, targetID) + if (result === null) { + return false + } + setLayout(result) + return true + }, + + closeOtherWindows() { + const focused = focusedWindow() + if (!focused) return + setLayout({ + root: focused, + floats: [], + focusedID: focused.id, + }) + }, + + focusWindow(windowID: string) { + setLayout("focusedID", windowID) + WindowFocusRegistry.focus(windowID) + }, + + focusDirection(direction: "left" | "right" | "up" | "down") { + const result = LayoutOps.focusDirection(layout, direction) + if (result.focusedID !== layout.focusedID) { + setLayout("focusedID", result.focusedID) + WindowFocusRegistry.focus(result.focusedID) + } + }, + + resizeWindow(delta: number, dimension: "width" | "height") { + setLayout( + produce((draft) => { + const result = LayoutOps.resizeWindow(draft, draft.focusedID, delta, dimension) + Object.assign(draft, result) + }), + ) + }, + + equalizeWindows() { + setLayout( + produce((draft) => { + const result = LayoutOps.equalizeWindows(draft) + Object.assign(draft, result) + }), + ) + }, + + openFloat(viewID: string, options: { x: number; y: number; width: number; height: number }) { + const id = LayoutOps.generateID("float") + setLayout( + produce((draft) => { + draft.floats.push( + Layout.Float.create({ + id, + viewID, + ...options, + }), + ) + draft.focusedID = id + }), + ) + return id + }, + + closeFloat(floatID: string) { + setLayout( + produce((draft) => { + const idx = draft.floats.findIndex((f) => f.id === floatID) + if (idx !== -1) { + draft.floats.splice(idx, 1) + if (draft.focusedID === floatID) { + const windows = LayoutOps.getAllWindows(draft) + draft.focusedID = windows[0]?.id ?? "" + } + } + }), + ) + }, + + setWindowView(windowID: string, viewID: string) { + setLayout( + produce((draft) => { + function updateNode(node: Layout.Node): void { + if (node.type === "window" && node.id === windowID) { + node.viewID = viewID + return + } + if (node.type === "split") { + node.children.forEach(updateNode) + } + } + updateNode(draft.root) + }), + ) + }, + + // Navigate the focused window to a new view + navigateFocusedWindow(viewID: string) { + const windowID = layout.focusedID + setLayout( + produce((draft) => { + function updateNode(node: Layout.Node): void { + if (node.type === "window" && node.id === windowID) { + node.viewID = viewID + return + } + if (node.type === "split") { + node.children.forEach(updateNode) + } + } + updateNode(draft.root) + }), + ) + // Focus the window's prompt after view change + WindowFocusRegistry.focus(windowID) + }, + } + }, +}) diff --git a/packages/opencode/src/cli/cmd/tui/context/route-layout-bridge.tsx b/packages/opencode/src/cli/cmd/tui/context/route-layout-bridge.tsx new file mode 100644 index 00000000000..c3370dab61a --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/route-layout-bridge.tsx @@ -0,0 +1,32 @@ +import { createEffect } from "solid-js" +import { createSimpleContext } from "./helper" +import { useRoute } from "./route" +import { useLayout } from "./layout" + +export const { use: useRouteLayoutBridge, provider: RouteLayoutBridgeProvider } = createSimpleContext({ + name: "RouteLayoutBridge", + init: () => { + const route = useRoute() + const layout = useLayout() + + // Sync window→route: when focused window changes, update route to match + // (Disabled: route→window sync was overwriting window views on focus change) + createEffect(() => { + const focused = layout.focusedWindow + if (!focused) return + // Update route to reflect what the focused window is showing + if (focused.viewID === "home") { + if (route.data.type !== "home") { + route.navigate({ type: "home" }) + } + } else if (focused.viewID.startsWith("session:")) { + const sessionID = focused.viewID.slice(8) + if (route.data.type !== "session" || route.data.sessionID !== sessionID) { + route.navigate({ type: "session", sessionID }) + } + } + }) + + return {} + }, +}) diff --git a/packages/opencode/src/cli/cmd/tui/context/window-commands.tsx b/packages/opencode/src/cli/cmd/tui/context/window-commands.tsx new file mode 100644 index 00000000000..c8af4cf8881 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/window-commands.tsx @@ -0,0 +1,82 @@ +// packages/opencode/src/cli/cmd/tui/context/window-commands.tsx +import { useKeyboard } from "@opentui/solid" +import { createSimpleContext } from "./helper" +import { useKeybind } from "./keybind" +import { useLayout } from "./layout" +import { useExit } from "./exit" + +export const { use: useWindowCommands, provider: WindowCommandsProvider } = createSimpleContext({ + name: "WindowCommands", + init: () => { + const keybind = useKeybind() + const layout = useLayout() + const exit = useExit() + + useKeyboard((evt) => { + // Focus navigation + if (keybind.match("window_focus_left", evt)) { + layout.focusDirection("left") + return + } + if (keybind.match("window_focus_down", evt)) { + layout.focusDirection("down") + return + } + if (keybind.match("window_focus_up", evt)) { + layout.focusDirection("up") + return + } + if (keybind.match("window_focus_right", evt)) { + layout.focusDirection("right") + return + } + + // Split commands - new windows always start with home view + if (keybind.match("window_split_horizontal", evt)) { + layout.splitHorizontal("home") + return + } + if (keybind.match("window_split_vertical", evt)) { + layout.splitVertical("home") + return + } + + // Close commands + if (keybind.match("window_close", evt)) { + const closed = layout.closeWindow() + if (!closed) { + exit() + } + return + } + if (keybind.match("window_close_others", evt)) { + layout.closeOtherWindows() + return + } + + // Resize commands + if (keybind.match("window_equalize", evt)) { + layout.equalizeWindows() + return + } + if (keybind.match("window_increase_height", evt)) { + layout.resizeWindow(1, "height") + return + } + if (keybind.match("window_decrease_height", evt)) { + layout.resizeWindow(-1, "height") + return + } + if (keybind.match("window_increase_width", evt)) { + layout.resizeWindow(1, "width") + return + } + if (keybind.match("window_decrease_width", evt)) { + layout.resizeWindow(-1, "width") + return + } + }) + + return {} + }, +}) diff --git a/packages/opencode/src/cli/cmd/tui/context/window-id.tsx b/packages/opencode/src/cli/cmd/tui/context/window-id.tsx new file mode 100644 index 00000000000..8cc615a8fee --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/window-id.tsx @@ -0,0 +1,13 @@ +// Simple context to pass window ID down to children +// No reactivity dependencies - just a static value per window +import { createContext, useContext, type ParentProps } from "solid-js" + +const WindowIDContext = createContext("") + +export function WindowIDProvider(props: ParentProps<{ windowID: string }>) { + return {props.children} +} + +export function useWindowID(): string { + return useContext(WindowIDContext) +} diff --git a/packages/opencode/src/cli/cmd/tui/layout/index.ts b/packages/opencode/src/cli/cmd/tui/layout/index.ts new file mode 100644 index 00000000000..3dfd810c6a0 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/layout/index.ts @@ -0,0 +1,3 @@ +export { Layout } from "./types" +export { LayoutOps } from "./operations" +export { LayoutRenderer } from "./renderer" diff --git a/packages/opencode/src/cli/cmd/tui/layout/operations.ts b/packages/opencode/src/cli/cmd/tui/layout/operations.ts new file mode 100644 index 00000000000..a9d84a398c2 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/layout/operations.ts @@ -0,0 +1,270 @@ +// packages/opencode/src/cli/cmd/tui/layout/operations.ts +import { Layout } from "./types" + +export namespace LayoutOps { + export const generateID = (() => { + const counter = { value: 0 } + return (prefix: string): string => `${prefix}-${++counter.value}` + })() + + export function findWindow(root: Layout.Root.Info, windowID: string): Layout.Window.Info | undefined { + function search(node: Layout.Node): Layout.Window.Info | undefined { + if (node.type === "window") { + return node.id === windowID ? node : undefined + } + for (const child of node.children) { + const found = search(child) + if (found) return found + } + return undefined + } + return search(root.root) + } + + export function getAllWindows(root: Layout.Root.Info): Layout.Window.Info[] { + const windows: Layout.Window.Info[] = [] + function collect(node: Layout.Node): void { + if (node.type === "window") { + windows.push(node) + return + } + for (const child of node.children) { + collect(child) + } + } + collect(root.root) + return windows + } + + export function splitWindow( + root: Layout.Root.Info, + targetID: string, + direction: "horizontal" | "vertical", + newWindow: { id: string; viewID: string }, + ): Layout.Root.Info { + function splitNode(node: Layout.Node): Layout.Node { + if (node.type === "window") { + if (node.id === targetID) { + return Layout.Split.create({ + id: generateID("split"), + direction, + children: [node, Layout.Window.create({ id: newWindow.id, viewID: newWindow.viewID })], + ratios: [0.5, 0.5], + }) + } + return node + } + + return Layout.Split.create({ + id: node.id, + direction: node.direction, + children: node.children.map(splitNode), + ratios: node.ratios, + }) + } + + return { + ...root, + root: splitNode(root.root), + focusedID: newWindow.id, + } + } + + export function closeWindow(root: Layout.Root.Info, windowID: string): Layout.Root.Info | null { + const windows = getAllWindows(root) + if (windows.length === 1 && windows[0].id === windowID) { + return null + } + + function removeFromNode(node: Layout.Node): Layout.Node | null { + if (node.type === "window") { + return node.id === windowID ? null : node + } + + const newChildren: Layout.Node[] = [] + const newRatios: number[] = [] + + for (let i = 0; i < node.children.length; i++) { + const child = node.children[i] + const result = removeFromNode(child) + if (result !== null) { + newChildren.push(result) + newRatios.push(node.ratios[i]) + } + } + + if (newChildren.length === 0) return null + if (newChildren.length === 1) return newChildren[0] + + const totalRatio = newRatios.reduce((a, b) => a + b, 0) + const normalizedRatios = newRatios.map((r) => r / totalRatio) + + return Layout.Split.create({ + id: node.id, + direction: node.direction, + children: newChildren, + ratios: normalizedRatios, + }) + } + + const newRoot = removeFromNode(root.root) + if (!newRoot) return null + + const remainingWindows = getAllWindows({ ...root, root: newRoot }) + const newFocusedID = root.focusedID === windowID ? (remainingWindows[0]?.id ?? "") : root.focusedID + + return { + ...root, + root: newRoot, + focusedID: newFocusedID, + } + } + + export function focusDirection( + root: Layout.Root.Info, + direction: "left" | "right" | "up" | "down", + ): Layout.Root.Info { + function getFirstWindow(node: Layout.Node): string | null { + if (node.type === "window") return node.id + if (node.children.length === 0) return null + return getFirstWindow(node.children[0]) + } + + function getLastWindow(node: Layout.Node): string | null { + if (node.type === "window") return node.id + if (node.children.length === 0) return null + return getLastWindow(node.children[node.children.length - 1]) + } + + // Find path from root to target window + function findPath(node: Layout.Node, targetID: string, path: Layout.Node[] = []): Layout.Node[] | null { + if (node.type === "window") { + return node.id === targetID ? [...path, node] : null + } + for (const child of node.children) { + const found = findPath(child, targetID, [...path, node]) + if (found) return found + } + return null + } + + // Check if direction matches split orientation + function directionMatchesSplit(dir: "left" | "right" | "up" | "down", split: Layout.Split.SplitInfo): boolean { + if (split.direction === "vertical") return dir === "left" || dir === "right" + return dir === "up" || dir === "down" + } + + // Get sibling in direction within a split + function getSiblingInSplit( + split: Layout.Split.SplitInfo, + childNode: Layout.Node, + dir: "left" | "right" | "up" | "down", + ): Layout.Node | null { + const idx = split.children.indexOf(childNode) + if (idx === -1) return null + + const goForward = dir === "right" || dir === "down" + const nextIdx = goForward ? idx + 1 : idx - 1 + + if (nextIdx < 0 || nextIdx >= split.children.length) return null + return split.children[nextIdx] + } + + const path = findPath(root.root, root.focusedID) + if (!path || path.length === 0) return root + + // Walk up the path to find a split where we can move in the desired direction + for (let i = path.length - 2; i >= 0; i--) { + const node = path[i] + if (node.type !== "split") continue + + if (!directionMatchesSplit(direction, node)) continue + + const childInPath = path[i + 1] + const sibling = getSiblingInSplit(node, childInPath, direction) + + if (sibling) { + const goForward = direction === "right" || direction === "down" + const newFocusedID = goForward ? getFirstWindow(sibling) : getLastWindow(sibling) + if (newFocusedID) { + return { ...root, focusedID: newFocusedID } + } + } + } + + return root + } + + export function resizeWindow( + root: Layout.Root.Info, + windowID: string, + delta: number, + dimension: "width" | "height", + ): Layout.Root.Info { + function resizeInNode(node: Layout.Node): Layout.Node { + if (node.type === "window") return node + + const idx = node.children.findIndex((c) => { + if (c.type === "window") return c.id === windowID + return getAllWindows({ root: c, floats: [], focusedID: "" }).some((w) => w.id === windowID) + }) + + if (idx === -1) { + return Layout.Split.create({ + ...node, + children: node.children.map(resizeInNode), + }) + } + + const isRelevant = + (dimension === "width" && node.direction === "vertical") || + (dimension === "height" && node.direction === "horizontal") + + if (!isRelevant || node.children.length < 2) { + return Layout.Split.create({ + ...node, + children: node.children.map(resizeInNode), + }) + } + + const newRatios = [...node.ratios] + const change = delta * 0.05 + newRatios[idx] = Math.max(0.1, Math.min(0.9, newRatios[idx] + change)) + + const otherIdx = idx === 0 ? 1 : idx - 1 + newRatios[otherIdx] = Math.max(0.1, Math.min(0.9, newRatios[otherIdx] - change)) + + const total = newRatios.reduce((a, b) => a + b, 0) + const normalized = newRatios.map((r) => r / total) + + return Layout.Split.create({ + ...node, + ratios: normalized, + children: node.children.map(resizeInNode), + }) + } + + return { + ...root, + root: resizeInNode(root.root), + } + } + + export function equalizeWindows(root: Layout.Root.Info): Layout.Root.Info { + function equalize(node: Layout.Node): Layout.Node { + if (node.type === "window") return node + + const equalRatio = 1 / node.children.length + return Layout.Split.create({ + ...node, + ratios: node.children.map(() => equalRatio), + children: node.children.map(equalize), + }) + } + + return { + ...root, + root: equalize(root.root), + } + } +} diff --git a/packages/opencode/src/cli/cmd/tui/layout/renderer.tsx b/packages/opencode/src/cli/cmd/tui/layout/renderer.tsx new file mode 100644 index 00000000000..ca38916e534 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/layout/renderer.tsx @@ -0,0 +1,276 @@ +// packages/opencode/src/cli/cmd/tui/layout/renderer.tsx +import { For, Match, Show, Switch, createMemo, type Component, Index } from "solid-js" +import { useTerminalDimensions } from "@opentui/solid" +import { useTheme } from "../context/theme" +import { useLayout } from "../context/layout" +import { Layout } from "./types" +import { ViewRegistry } from "../view/registry" +import { View } from "../view/types" + +// Built-in view components +import { Session } from "../routes/session" +import { Home } from "../routes/home" +import { WindowIDProvider } from "../context/window-id" + +function parseViewID(viewID: string): { type: string; sessionID?: string } { + const idx = viewID.indexOf(":") + if (idx === -1) return { type: viewID } + return { type: viewID.slice(0, idx), sessionID: viewID.slice(idx + 1) } +} + +// Generic view renderers for plugin views +const TreeViewRenderer: Component<{ view: View.Tree.Info }> = (props) => { + const { theme } = useTheme() + + function renderNode(node: View.Tree.NodeInfo, depth: number) { + const indent = " ".repeat(depth) + const icon = node.children.length > 0 ? (node.expanded ? "▼" : "▶") : " " + + return ( + <> + + {indent} + {icon} {node.icon ? `${node.icon} ` : ""} + {node.label} + + + {(child) => renderNode(child, depth + 1)} + + + ) + } + + return ( + + + {props.view.title} + + {(node) => renderNode(node, 0)} + + ) +} + +const ListViewRenderer: Component<{ view: View.List.Info }> = (props) => { + const { theme } = useTheme() + + return ( + + + {props.view.title} + + + Search: {props.view.searchQuery ?? ""} + + + {(item) => ( + + {item.icon ? `${item.icon} ` : ""} + {item.label} + + - {item.description} + + + )} + + + ) +} + +const TextViewRenderer: Component<{ view: View.Text.Info }> = (props) => { + const { theme, syntax } = useTheme() + + return ( + + + {props.view.title} + + {props.view.content}}> + + + + ) +} + +const FormViewRenderer: Component<{ view: View.Form.Info }> = (props) => { + const { theme } = useTheme() + + return ( + + + {props.view.title} + + + {(field) => ( + + {field.label}: + + + {(f) => [{f().value ?? f().placeholder ?? ""}]} + + + {(f) => {f().value ? "[x]" : "[ ]"}} + + + {(f) => [{f().value ?? "select..."}]} + + + {(f) => [{f().value ?? 0}]} + + + + )} + + + ) +} + +// View renderer that dispatches to appropriate component +const ViewRenderer: Component<{ viewID: string }> = (props) => { + const parsed = createMemo(() => parseViewID(props.viewID)) + const view = createMemo(() => ViewRegistry.get(props.viewID)) + + return ( + + + + + + + {(sessionID) => } + + + + + + + + + + + + + + + + ) +} + +// Window renderer +const WindowRenderer: Component<{ + window: Layout.Window.Info + width: number + height: number +}> = (props) => { + const { theme } = useTheme() + const layout = useLayout() + const focused = createMemo(() => layout.layout.focusedID === props.window.id) + + return ( + + + + + + ) +} + +// Split renderer +const SplitRenderer: Component<{ + split: Layout.Split.SplitInfo + width: number + height: number +}> = (props) => { + const isHorizontal = () => props.split.direction === "horizontal" + + const childDimensions = createMemo(() => { + return props.split.children.map((_, i) => { + const ratio = props.split.ratios[i] ?? 1 / props.split.children.length + if (isHorizontal()) { + return { width: props.width, height: Math.floor(props.height * ratio) } + } + return { width: Math.floor(props.width * ratio), height: props.height } + }) + }) + + return ( + + + {(child, i) => ( + + + + + + + + + )} + + + ) +} + +// Float renderer +const FloatRenderer: Component<{ float: Layout.Float.Info }> = (props) => { + const { theme } = useTheme() + const layout = useLayout() + const focused = createMemo(() => layout.layout.focusedID === props.float.id) + + return ( + + + + ) +} + +// Main layout renderer +export const LayoutRenderer: Component = () => { + const dimensions = useTerminalDimensions() + const layout = useLayout() + const { theme } = useTheme() + + return ( + + + + + + + + + + {(float) => } + + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/layout/types.ts b/packages/opencode/src/cli/cmd/tui/layout/types.ts new file mode 100644 index 00000000000..b165c4eecd9 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/layout/types.ts @@ -0,0 +1,119 @@ +// packages/opencode/src/cli/cmd/tui/layout/types.ts +import z from "zod" + +export namespace Layout { + // Window: A rectangular area displaying a single view + export namespace Window { + export const Info = z.object({ + type: z.literal("window").default("window"), + id: z.string(), + viewID: z.string(), + focused: z.boolean().default(false), + }) + export type Info = z.output + + export function create(input: { id: string; viewID: string; focused?: boolean }): Info { + return { + type: "window", + id: input.id, + viewID: input.viewID, + focused: input.focused ?? false, + } + } + } + + // Split: A container dividing space between children + export namespace Split { + export const Info: z.ZodType = z.lazy(() => + z.object({ + type: z.literal("split").default("split"), + id: z.string(), + direction: z.enum(["horizontal", "vertical"]), + children: z.array(z.union([Window.Info, Info])), + ratios: z.array(z.number()), + }), + ) + + export type SplitInfo = { + type: "split" + id: string + direction: "horizontal" | "vertical" + children: Array + ratios: number[] + } + + export function create(input: { + id: string + direction: "horizontal" | "vertical" + children: Array + ratios: number[] + }): SplitInfo { + return { + type: "split", + id: input.id, + direction: input.direction, + children: input.children, + ratios: input.ratios, + } + } + } + + // Float: A window with absolute positioning + export namespace Float { + export const Info = z.object({ + id: z.string(), + viewID: z.string(), + x: z.number(), + y: z.number(), + width: z.number(), + height: z.number(), + focused: z.boolean().default(false), + }) + export type Info = z.output + + export function create(input: { + id: string + viewID: string + x: number + y: number + width: number + height: number + focused?: boolean + }): Info { + return { + id: input.id, + viewID: input.viewID, + x: input.x, + y: input.y, + width: input.width, + height: input.height, + focused: input.focused ?? false, + } + } + } + + // Root: The top-level layout container + export namespace Root { + export const Info = z.object({ + root: z.union([Window.Info, Split.Info]), + floats: z.array(Float.Info), + focusedID: z.string(), + }) + export type Info = z.output + + export function create(input: { + root: Window.Info | Split.SplitInfo + floats: Float.Info[] + focusedID: string + }): Info { + return { + root: input.root, + floats: input.floats, + focusedID: input.focusedID, + } + } + } + + // Node type union for tree traversal + export type Node = Window.Info | Split.SplitInfo +} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-fork-from-timeline.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-fork-from-timeline.tsx index 62154cce563..eb9a80c5820 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-fork-from-timeline.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-fork-from-timeline.tsx @@ -7,12 +7,14 @@ import { useSDK } from "@tui/context/sdk" import { useRoute } from "@tui/context/route" import { useDialog } from "../../ui/dialog" import type { PromptInfo } from "@tui/component/prompt/history" +import { useLayout } from "../../context/layout" export function DialogForkFromTimeline(props: { sessionID: string; onMove: (messageID: string) => void }) { const sync = useSync() const dialog = useDialog() const sdk = useSDK() const route = useRoute() + const layout = useLayout() onMount(() => { dialog.setSize("large") @@ -47,8 +49,11 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess }, { input: "", parts: [] as PromptInfo["parts"] }, ) + // Update window view first, then route for initialPrompt state + const newSessionID = forked.data!.id + layout.navigateFocusedWindow(`session:${newSessionID}`) route.navigate({ - sessionID: forked.data!.id, + sessionID: newSessionID, type: "session", initialPrompt, }) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx index ff17b5567eb..f60c20eb97e 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx @@ -5,6 +5,7 @@ import { useSDK } from "@tui/context/sdk" import { useRoute } from "@tui/context/route" import { Clipboard } from "@tui/util/clipboard" import type { PromptInfo } from "@tui/component/prompt/history" +import { useLayout } from "../../context/layout" export function DialogMessage(props: { messageID: string @@ -15,6 +16,7 @@ export function DialogMessage(props: { const sdk = useSDK() const message = createMemo(() => sync.data.message[props.sessionID]?.find((x) => x.id === props.messageID)) const route = useRoute() + const layout = useLayout() return ( { - route.navigate({ - type: "session", - sessionID: props.sessionID, - }) + layout.navigateFocusedWindow(`session:${props.sessionID}`) dialog.clear() }, }, 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 098ee83cce8..8c160386fe9 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx @@ -94,7 +94,7 @@ export function Header() { - + <ContextInfo context={context} cost={cost} /> @@ -103,9 +103,9 @@ export function Header() { <box flexDirection="row" justifyContent="space-between" gap={1}> <box flexGrow={1} flexShrink={1}> <Switch> - <Match when={session().share?.url}> + <Match when={session()?.share?.url}> <text fg={theme.textMuted} wrapMode="word"> - {session().share!.url} + {session()!.share!.url} </text> </Match> <Match when={true}> 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 d5298518700..0584c020bf2 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -98,16 +98,18 @@ function use() { return ctx } -export function Session() { +export function Session(props: { sessionID?: string } = {}) { const route = useRouteData("session") const { navigate } = useRoute() const sync = useSync() const kv = useKV() const { theme } = useTheme() const promptRef = usePromptRef() - const session = createMemo(() => sync.session.get(route.sessionID)!) - const messages = createMemo(() => sync.data.message[route.sessionID] ?? []) - const permissions = createMemo(() => sync.data.permission[route.sessionID] ?? []) + // Use prop if provided (from layout window), otherwise use route (single window mode) + const currentSessionID = createMemo(() => props.sessionID ?? route.sessionID) + const session = createMemo(() => sync.session.get(currentSessionID())!) + const messages = createMemo(() => sync.data.message[currentSessionID()] ?? []) + const permissions = createMemo(() => sync.data.permission[currentSessionID()] ?? []) const pending = createMemo(() => { return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id @@ -152,14 +154,14 @@ export function Session() { createEffect(async () => { await sync.session - .sync(route.sessionID) + .sync(currentSessionID()) .then(() => { if (scroll) scroll.scrollBy(100_000) }) .catch((e) => { console.error(e) toast.show({ - message: `Session not found: ${route.sessionID}`, + message: `Session not found: ${currentSessionID()}`, variant: "error", }) return navigate({ type: "home" }) @@ -251,6 +253,17 @@ export function Session() { useKeyboard((evt) => { if (dialog.stack.length > 0) return + // Handle Enter key for prompt submission + if (evt.name === "return" && permissions().length === 0) { + // Force reactive read (this somehow fixes timing issues) + void session() + if (prompt) { + prompt.focus() + prompt.submit() + } + return + } + const first = permissions()[0] if (first) { const response = iife(() => { @@ -264,7 +277,7 @@ export function Session() { if (response) { sdk.client.permission.respond({ permissionID: first.id, - sessionID: route.sessionID, + sessionID: currentSessionID(), response: response, }) } @@ -310,7 +323,7 @@ export function Session() { onSelect: async (dialog: any) => { await sdk.client.session .share({ - sessionID: route.sessionID, + sessionID: currentSessionID(), }) .then((res) => Clipboard.copy(res.data!.share!.url).catch(() => @@ -330,7 +343,7 @@ export function Session() { keybind: "session_rename", category: "Session", onSelect: (dialog) => { - dialog.replace(() => <DialogSessionRename session={route.sessionID} />) + dialog.replace(() => <DialogSessionRename session={currentSessionID()} />) }, }, { @@ -347,7 +360,7 @@ export function Session() { }) if (child) scroll.scrollBy(child.y - scroll.y - 1) }} - sessionID={route.sessionID} + sessionID={currentSessionID()} setPrompt={(promptInfo) => prompt.set(promptInfo)} /> )) @@ -367,7 +380,7 @@ export function Session() { }) if (child) scroll.scrollBy(child.y - scroll.y - 1) }} - sessionID={route.sessionID} + sessionID={currentSessionID()} /> )) }, @@ -388,7 +401,7 @@ export function Session() { return } sdk.client.session.summarize({ - sessionID: route.sessionID, + sessionID: currentSessionID(), modelID: selectedModel.modelID, providerID: selectedModel.providerID, }) @@ -404,7 +417,7 @@ export function Session() { onSelect: async (dialog) => { await sdk.client.session .unshare({ - sessionID: route.sessionID, + sessionID: currentSessionID(), }) .then(() => toast.show({ message: "Session unshared successfully", variant: "success" })) .catch(() => toast.show({ message: "Failed to unshare session", variant: "error" })) @@ -417,14 +430,14 @@ export function Session() { keybind: "messages_undo", category: "Session", onSelect: async (dialog) => { - const status = sync.data.session_status?.[route.sessionID] - if (status?.type !== "idle") await sdk.client.session.abort({ sessionID: route.sessionID }).catch(() => {}) + const status = sync.data.session_status?.[currentSessionID()] + if (status?.type !== "idle") await sdk.client.session.abort({ sessionID: currentSessionID() }).catch(() => {}) const revert = session().revert?.messageID const message = messages().findLast((x) => (!revert || x.id < revert) && x.role === "user") if (!message) return sdk.client.session .revert({ - sessionID: route.sessionID, + sessionID: currentSessionID(), messageID: message.id, }) .then(() => { @@ -459,13 +472,13 @@ export function Session() { const message = messages().find((x) => x.role === "user" && x.id > messageID) if (!message) { sdk.client.session.unrevert({ - sessionID: route.sessionID, + sessionID: currentSessionID(), }) prompt.set({ input: "", parts: [] }) return } sdk.client.session.revert({ - sessionID: route.sessionID, + sessionID: currentSessionID(), messageID: message.id, }) }, @@ -669,7 +682,7 @@ export function Session() { keybind: "messages_last_user", category: "Session", onSelect: () => { - const messages = sync.data.message[route.sessionID] + const messages = sync.data.message[currentSessionID()] if (!messages || !messages.length) return // Find the most recent user message with non-ignored, non-synthetic text parts @@ -982,7 +995,7 @@ export function Session() { const renderer = useRenderer() // snap to bottom when session changes - createEffect(on(() => route.sessionID, toBottom)) + createEffect(on(() => currentSessionID(), toBottom)) return ( <context.Provider @@ -1099,7 +1112,7 @@ export function Session() { dialog.replace(() => ( <DialogMessage messageID={message.id} - sessionID={route.sessionID} + sessionID={currentSessionID()} setPrompt={(promptInfo) => prompt.set(promptInfo)} /> )) @@ -1130,7 +1143,7 @@ export function Session() { onSubmit={() => { toBottom() }} - sessionID={route.sessionID} + sessionID={currentSessionID()} /> </box> <Show when={!sidebarVisible()}> @@ -1140,7 +1153,7 @@ export function Session() { <Toast /> </box> <Show when={sidebarVisible()}> - <Sidebar sessionID={route.sessionID} /> + <Sidebar sessionID={currentSessionID()} /> </Show> </box> </context.Provider> diff --git a/packages/opencode/src/cli/cmd/tui/view/index.ts b/packages/opencode/src/cli/cmd/tui/view/index.ts new file mode 100644 index 00000000000..6d4b25ccee2 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/view/index.ts @@ -0,0 +1,2 @@ +export { View } from "./types" +export { ViewRegistry } from "./registry" diff --git a/packages/opencode/src/cli/cmd/tui/view/registry.ts b/packages/opencode/src/cli/cmd/tui/view/registry.ts new file mode 100644 index 00000000000..7625ec25500 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/view/registry.ts @@ -0,0 +1,49 @@ +// packages/opencode/src/cli/cmd/tui/view/registry.ts +import { View } from "./types" + +type ViewChangeCallback = (view: View.Info) => void + +const views = new Map<string, View.Info>() +const subscribers = new Map<string, Set<ViewChangeCallback>>() + +export namespace ViewRegistry { + export function register(id: string, view: View.Info): void { + views.set(id, view) + notifySubscribers(id, view) + } + + export function get(id: string): View.Info | undefined { + return views.get(id) + } + + export function unregister(id: string): void { + views.delete(id) + } + + export function list(): View.Info[] { + return Array.from(views.values()) + } + + export function clear(): void { + views.clear() + subscribers.clear() + } + + export function subscribe(id: string, callback: ViewChangeCallback): () => void { + if (!subscribers.has(id)) { + subscribers.set(id, new Set()) + } + subscribers.get(id)!.add(callback) + + return () => { + subscribers.get(id)?.delete(callback) + } + } + + function notifySubscribers(id: string, view: View.Info): void { + const subs = subscribers.get(id) + if (subs) { + subs.forEach((callback) => callback(view)) + } + } +} diff --git a/packages/opencode/src/cli/cmd/tui/view/types.ts b/packages/opencode/src/cli/cmd/tui/view/types.ts new file mode 100644 index 00000000000..841a7eb1bf8 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/view/types.ts @@ -0,0 +1,168 @@ +// packages/opencode/src/cli/cmd/tui/view/types.ts +import z from "zod" + +export namespace View { + // Base view info shared by all view types + export const Base = z.object({ + id: z.string(), + title: z.string(), + }) + + // Tree view for hierarchical data (session explorer, file browser) + export namespace Tree { + export const Node: z.ZodType<NodeInfo> = z.lazy(() => + z.object({ + id: z.string(), + label: z.string(), + icon: z.string().optional(), + children: z.array(Node), + expanded: z.boolean().optional().default(false), + metadata: z.record(z.string(), z.any()).optional(), + }), + ) + + export type NodeInfo = { + id: string + label: string + icon?: string + children: NodeInfo[] + expanded?: boolean + metadata?: Record<string, any> + } + + export const Info = Base.extend({ + type: z.literal("tree"), + nodes: z.array(Node), + selectedID: z.string().optional(), + }) + export type Info = z.output<typeof Info> + + export function create(input: { id: string; title: string; nodes: NodeInfo[]; selectedID?: string }): Info { + return { + type: "tree", + id: input.id, + title: input.title, + nodes: input.nodes, + selectedID: input.selectedID, + } + } + } + + // List view for flat searchable items (command palette, session list) + export namespace List { + export const Item = z.object({ + id: z.string(), + label: z.string(), + description: z.string().optional(), + icon: z.string().optional(), + metadata: z.record(z.string(), z.any()).optional(), + }) + export type Item = z.output<typeof Item> + + export const Info = Base.extend({ + type: z.literal("list"), + items: z.array(Item), + searchable: z.boolean().optional().default(true), + selectedID: z.string().optional(), + searchQuery: z.string().optional(), + }) + export type Info = z.output<typeof Info> + + export function create(input: { + id: string + title: string + items: Item[] + searchable?: boolean + selectedID?: string + }): Info { + return { + type: "list", + id: input.id, + title: input.title, + items: input.items, + searchable: input.searchable ?? true, + selectedID: input.selectedID, + } + } + } + + // Text view for read-only styled content (logs, previews, help) + export namespace Text { + export const Info = Base.extend({ + type: z.literal("text"), + content: z.string(), + filetype: z.string().optional(), + scrollOffset: z.number().optional().default(0), + }) + export type Info = z.output<typeof Info> + + export function create(input: { id: string; title: string; content: string; filetype?: string }): Info { + return { + type: "text", + id: input.id, + title: input.title, + content: input.content, + filetype: input.filetype, + scrollOffset: 0, + } + } + } + + // Form view for settings and input + export namespace Form { + export const Field = z.discriminatedUnion("type", [ + z.object({ + id: z.string(), + type: z.literal("text"), + label: z.string(), + value: z.string().optional(), + placeholder: z.string().optional(), + }), + z.object({ + id: z.string(), + type: z.literal("toggle"), + label: z.string(), + value: z.boolean().optional(), + }), + z.object({ + id: z.string(), + type: z.literal("select"), + label: z.string(), + options: z.array(z.string()), + value: z.string().optional(), + }), + z.object({ + id: z.string(), + type: z.literal("number"), + label: z.string(), + value: z.number().optional(), + min: z.number().optional(), + max: z.number().optional(), + }), + ]) + export type Field = z.output<typeof Field> + + export const Info = Base.extend({ + type: z.literal("form"), + fields: z.array(Field), + focusedFieldID: z.string().optional(), + }) + export type Info = z.output<typeof Info> + + export function create(input: { id: string; title: string; fields: Field[] }): Info { + return { + type: "form", + id: input.id, + title: input.title, + fields: input.fields, + } + } + } + + // Union of all view types + export type Info = Tree.Info | List.Info | Text.Info | Form.Info + + // Built-in view identifiers (not replaceable by plugins) + export const BuiltIn = z.enum(["session", "home"]) + export type BuiltIn = z.infer<typeof BuiltIn> +} diff --git a/packages/opencode/src/cli/cmd/tui/window-focus-registry.ts b/packages/opencode/src/cli/cmd/tui/window-focus-registry.ts new file mode 100644 index 00000000000..4e9964d97c0 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/window-focus-registry.ts @@ -0,0 +1,29 @@ +// Plain JS registry - NO SolidJS reactivity +// This avoids render loops by keeping focus management outside the reactive system + +type Focusable = { focus: () => void } + +const registry = new Map<string, Focusable>() + +export const WindowFocusRegistry = { + register(windowID: string, focusable: Focusable) { + registry.set(windowID, focusable) + return () => { + registry.delete(windowID) + } + }, + + focus(windowID: string) { + // Small delay to ensure component is fully mounted/rendered + setTimeout(() => { + const element = registry.get(windowID) + if (element) { + element.focus() + } + }, 50) + }, + + has(windowID: string) { + return registry.has(windowID) + }, +} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 66f42e5a851..8249aa14ca5 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -447,7 +447,7 @@ export namespace Config { status_view: z.string().optional().default("<leader>s").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_list: z.string().optional().default("<leader>;").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("none").describe("Rename session"), @@ -474,7 +474,7 @@ export namespace Config { messages_toggle_conceal: z .string() .optional() - .default("<leader>h") + .default("<leader>.") .describe("Toggle code block concealment in messages"), tool_details: z.string().optional().default("none").describe("Toggle tool details visibility"), model_list: z.string().optional().default("<leader>m").describe("List available models"), @@ -574,7 +574,21 @@ export namespace Config { session_parent: z.string().optional().default("<leader>up").describe("Go to parent session"), 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"), + tips_toggle: z.string().optional().default("<leader>?").describe("Toggle tips on home screen"), + // Window commands (leader + key, default leader is ctrl+x) + window_focus_left: z.string().optional().default("<leader>h").describe("Focus window to the left"), + window_focus_down: z.string().optional().default("<leader>j").describe("Focus window below"), + window_focus_up: z.string().optional().default("<leader>k").describe("Focus window above"), + window_focus_right: z.string().optional().default("<leader>l").describe("Focus window to the right"), + window_split_horizontal: z.string().optional().default("<leader>-").describe("Split window horizontally"), + window_split_vertical: z.string().optional().default("<leader>|").describe("Split window vertically"), + window_close: z.string().optional().default("<leader>w").describe("Close current window"), + window_close_others: z.string().optional().default("<leader>shift+w").describe("Close all other windows"), + window_equalize: z.string().optional().default("<leader>=").describe("Equalize window sizes"), + window_increase_height: z.string().optional().default("<leader>+").describe("Increase window height"), + window_decrease_height: z.string().optional().default("<leader>_").describe("Decrease window height"), + window_increase_width: z.string().optional().default("<leader>>").describe("Increase window width"), + window_decrease_width: z.string().optional().default("<leader><").describe("Decrease window width"), }) .strict() .meta({ @@ -593,6 +607,64 @@ export namespace Config { .enum(["auto", "stacked"]) .optional() .describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"), + + // Component-specific settings + messages: z + .object({ + padding: z.number().int().min(0).optional().describe("Padding around messages"), + gap: z.number().int().min(0).optional().describe("Gap between messages"), + }) + .optional() + .describe("Message display settings"), + + sidebar: z + .object({ + padding: z.number().int().min(0).optional().describe("Padding inside sidebar"), + width: z.number().int().min(10).optional().describe("Sidebar width in characters"), + visible: z.boolean().optional().describe("Show sidebar by default"), + }) + .optional() + .describe("Sidebar settings"), + + header: z + .object({ + padding: z.number().int().min(0).optional().describe("Padding inside header"), + visible: z.boolean().optional().describe("Show header"), + show_title: z.boolean().optional().describe("Show session title"), + show_context: z.boolean().optional().describe("Show context info"), + show_cost: z.boolean().optional().describe("Show cost information"), + show_tokens: z.boolean().optional().describe("Show token count"), + }) + .optional() + .describe("Header settings"), + + footer: z + .object({ + padding: z.number().int().min(0).optional().describe("Padding inside footer"), + visible: z.boolean().optional().describe("Show footer"), + show_directory: z.boolean().optional().describe("Show current directory"), + show_lsp_status: z.boolean().optional().describe("Show LSP status"), + show_mcp_status: z.boolean().optional().describe("Show MCP status"), + show_version: z.boolean().optional().describe("Show version"), + show_keybind_hints: z.boolean().optional().describe("Show keybind hints"), + }) + .optional() + .describe("Footer settings"), + + prompt: z + .object({ + padding: z.number().int().min(0).optional().describe("Padding around prompt"), + }) + .optional() + .describe("Prompt settings"), + + window: z + .object({ + padding: z.number().int().min(0).optional().describe("Padding inside windows"), + border: z.boolean().optional().describe("Show window borders"), + }) + .optional() + .describe("Window settings"), }) export const Server = z diff --git a/packages/opencode/test/tui/layout/operations.test.ts b/packages/opencode/test/tui/layout/operations.test.ts new file mode 100644 index 00000000000..90cf65b53d6 --- /dev/null +++ b/packages/opencode/test/tui/layout/operations.test.ts @@ -0,0 +1,342 @@ +// packages/opencode/test/tui/layout/operations.test.ts +import { describe, expect, test } from "bun:test" +import { Layout } from "../../../src/cli/cmd/tui/layout/types" +import { LayoutOps } from "../../../src/cli/cmd/tui/layout/operations" + +describe("LayoutOps.findWindow", () => { + test("finds window in flat layout", () => { + const root = Layout.Root.create({ + root: Layout.Window.create({ id: "win-1", viewID: "session" }), + floats: [], + focusedID: "win-1", + }) + const window = LayoutOps.findWindow(root, "win-1") + expect(window?.id).toBe("win-1") + }) + + test("finds window in nested split", () => { + const root = Layout.Root.create({ + root: Layout.Split.create({ + id: "split-1", + direction: "horizontal", + children: [ + Layout.Window.create({ id: "win-1", viewID: "session" }), + Layout.Split.create({ + id: "split-2", + direction: "vertical", + children: [ + Layout.Window.create({ id: "win-2", viewID: "home" }), + Layout.Window.create({ id: "win-3", viewID: "explorer" }), + ], + ratios: [0.5, 0.5], + }), + ], + ratios: [0.3, 0.7], + }), + floats: [], + focusedID: "win-1", + }) + const window = LayoutOps.findWindow(root, "win-3") + expect(window?.id).toBe("win-3") + expect(window?.viewID).toBe("explorer") + }) + + test("returns undefined for non-existent window", () => { + const root = Layout.Root.create({ + root: Layout.Window.create({ id: "win-1", viewID: "session" }), + floats: [], + focusedID: "win-1", + }) + const window = LayoutOps.findWindow(root, "win-999") + expect(window).toBeUndefined() + }) +}) + +describe("LayoutOps.splitWindow", () => { + test("splits single window horizontally", () => { + const root = Layout.Root.create({ + root: Layout.Window.create({ id: "win-1", viewID: "session" }), + floats: [], + focusedID: "win-1", + }) + const result = LayoutOps.splitWindow(root, "win-1", "horizontal", { + id: "win-2", + viewID: "home", + }) + expect(result.root.type).toBe("split") + if (result.root.type === "split") { + expect(result.root.direction).toBe("horizontal") + expect(result.root.children).toHaveLength(2) + } + }) + + test("splits single window vertically", () => { + const root = Layout.Root.create({ + root: Layout.Window.create({ id: "win-1", viewID: "session" }), + floats: [], + focusedID: "win-1", + }) + const result = LayoutOps.splitWindow(root, "win-1", "vertical", { + id: "win-2", + viewID: "home", + }) + expect(result.root.type).toBe("split") + if (result.root.type === "split") { + expect(result.root.direction).toBe("vertical") + } + }) +}) + +describe("LayoutOps.closeWindow", () => { + test("closing last window returns null", () => { + const root = Layout.Root.create({ + root: Layout.Window.create({ id: "win-1", viewID: "session" }), + floats: [], + focusedID: "win-1", + }) + const result = LayoutOps.closeWindow(root, "win-1") + expect(result).toBeNull() + }) + + test("closing window in split removes it", () => { + const root = Layout.Root.create({ + root: Layout.Split.create({ + id: "split-1", + direction: "horizontal", + children: [ + Layout.Window.create({ id: "win-1", viewID: "session" }), + Layout.Window.create({ id: "win-2", viewID: "home" }), + ], + ratios: [0.5, 0.5], + }), + floats: [], + focusedID: "win-1", + }) + const result = LayoutOps.closeWindow(root, "win-2") + expect(result).not.toBeNull() + expect(result!.root.type).toBe("window") + if (result!.root.type === "window") { + expect(result!.root.id).toBe("win-1") + } + }) +}) + +describe("LayoutOps.focusDirection", () => { + test("focuses window to the right", () => { + const root = Layout.Root.create({ + root: Layout.Split.create({ + id: "split-1", + direction: "vertical", + children: [ + Layout.Window.create({ id: "win-1", viewID: "session" }), + Layout.Window.create({ id: "win-2", viewID: "home" }), + ], + ratios: [0.5, 0.5], + }), + floats: [], + focusedID: "win-1", + }) + const result = LayoutOps.focusDirection(root, "right") + expect(result.focusedID).toBe("win-2") + }) + + test("focuses window below", () => { + const root = Layout.Root.create({ + root: Layout.Split.create({ + id: "split-1", + direction: "horizontal", + children: [ + Layout.Window.create({ id: "win-1", viewID: "session" }), + Layout.Window.create({ id: "win-2", viewID: "home" }), + ], + ratios: [0.5, 0.5], + }), + floats: [], + focusedID: "win-1", + }) + const result = LayoutOps.focusDirection(root, "down") + expect(result.focusedID).toBe("win-2") + }) + + test("stays on same window when no neighbor", () => { + const root = Layout.Root.create({ + root: Layout.Window.create({ id: "win-1", viewID: "session" }), + floats: [], + focusedID: "win-1", + }) + const result = LayoutOps.focusDirection(root, "right") + expect(result.focusedID).toBe("win-1") + }) +}) + +describe("LayoutOps.resizeWindow", () => { + test("increases window width in vertical split", () => { + const root = Layout.Root.create({ + root: Layout.Split.create({ + id: "split-1", + direction: "vertical", + children: [ + Layout.Window.create({ id: "win-1", viewID: "session" }), + Layout.Window.create({ id: "win-2", viewID: "home" }), + ], + ratios: [0.5, 0.5], + }), + floats: [], + focusedID: "win-1", + }) + const result = LayoutOps.resizeWindow(root, "win-1", 1, "width") + expect(result.root.type).toBe("split") + if (result.root.type === "split") { + expect(result.root.ratios[0]).toBeGreaterThan(0.5) + expect(result.root.ratios[1]).toBeLessThan(0.5) + } + }) + + test("decreases window height in horizontal split", () => { + const root = Layout.Root.create({ + root: Layout.Split.create({ + id: "split-1", + direction: "horizontal", + children: [ + Layout.Window.create({ id: "win-1", viewID: "session" }), + Layout.Window.create({ id: "win-2", viewID: "home" }), + ], + ratios: [0.5, 0.5], + }), + floats: [], + focusedID: "win-1", + }) + const result = LayoutOps.resizeWindow(root, "win-1", -1, "height") + expect(result.root.type).toBe("split") + if (result.root.type === "split") { + expect(result.root.ratios[0]).toBeLessThan(0.5) + expect(result.root.ratios[1]).toBeGreaterThan(0.5) + } + }) + + test("does not resize single window", () => { + const root = Layout.Root.create({ + root: Layout.Window.create({ id: "win-1", viewID: "session" }), + floats: [], + focusedID: "win-1", + }) + const result = LayoutOps.resizeWindow(root, "win-1", 1, "width") + expect(result.root.type).toBe("window") + }) + + test("respects minimum ratio constraint", () => { + const root = Layout.Root.create({ + root: Layout.Split.create({ + id: "split-1", + direction: "vertical", + children: [ + Layout.Window.create({ id: "win-1", viewID: "session" }), + Layout.Window.create({ id: "win-2", viewID: "home" }), + ], + ratios: [0.15, 0.85], + }), + floats: [], + focusedID: "win-1", + }) + const result = LayoutOps.resizeWindow(root, "win-1", -5, "width") + expect(result.root.type).toBe("split") + if (result.root.type === "split") { + expect(result.root.ratios[0]).toBeGreaterThanOrEqual(0.1) + } + }) +}) + +describe("LayoutOps.equalizeWindows", () => { + test("equalizes two windows in split", () => { + const root = Layout.Root.create({ + root: Layout.Split.create({ + id: "split-1", + direction: "vertical", + children: [ + Layout.Window.create({ id: "win-1", viewID: "session" }), + Layout.Window.create({ id: "win-2", viewID: "home" }), + ], + ratios: [0.3, 0.7], + }), + floats: [], + focusedID: "win-1", + }) + const result = LayoutOps.equalizeWindows(root) + expect(result.root.type).toBe("split") + if (result.root.type === "split") { + expect(result.root.ratios[0]).toBe(0.5) + expect(result.root.ratios[1]).toBe(0.5) + } + }) + + test("equalizes three windows in split", () => { + const root = Layout.Root.create({ + root: Layout.Split.create({ + id: "split-1", + direction: "horizontal", + children: [ + Layout.Window.create({ id: "win-1", viewID: "session" }), + Layout.Window.create({ id: "win-2", viewID: "home" }), + Layout.Window.create({ id: "win-3", viewID: "explorer" }), + ], + ratios: [0.2, 0.5, 0.3], + }), + floats: [], + focusedID: "win-1", + }) + const result = LayoutOps.equalizeWindows(root) + expect(result.root.type).toBe("split") + if (result.root.type === "split") { + const expected = 1 / 3 + expect(result.root.ratios[0]).toBeCloseTo(expected) + expect(result.root.ratios[1]).toBeCloseTo(expected) + expect(result.root.ratios[2]).toBeCloseTo(expected) + } + }) + + test("equalizes nested splits", () => { + const root = Layout.Root.create({ + root: Layout.Split.create({ + id: "split-1", + direction: "vertical", + children: [ + Layout.Window.create({ id: "win-1", viewID: "session" }), + Layout.Split.create({ + id: "split-2", + direction: "horizontal", + children: [ + Layout.Window.create({ id: "win-2", viewID: "home" }), + Layout.Window.create({ id: "win-3", viewID: "explorer" }), + ], + ratios: [0.3, 0.7], + }), + ], + ratios: [0.2, 0.8], + }), + floats: [], + focusedID: "win-1", + }) + const result = LayoutOps.equalizeWindows(root) + expect(result.root.type).toBe("split") + if (result.root.type === "split") { + expect(result.root.ratios[0]).toBe(0.5) + expect(result.root.ratios[1]).toBe(0.5) + const nested = result.root.children[1] + if (nested.type === "split") { + expect(nested.ratios[0]).toBe(0.5) + expect(nested.ratios[1]).toBe(0.5) + } + } + }) + + test("does nothing for single window", () => { + const root = Layout.Root.create({ + root: Layout.Window.create({ id: "win-1", viewID: "session" }), + floats: [], + focusedID: "win-1", + }) + const result = LayoutOps.equalizeWindows(root) + expect(result.root.type).toBe("window") + expect(result.root.id).toBe("win-1") + }) +}) diff --git a/packages/opencode/test/tui/layout/types.test.ts b/packages/opencode/test/tui/layout/types.test.ts new file mode 100644 index 00000000000..869161fad6c --- /dev/null +++ b/packages/opencode/test/tui/layout/types.test.ts @@ -0,0 +1,79 @@ +// packages/opencode/test/tui/layout/types.test.ts +import { describe, expect, test } from "bun:test" +import { Layout } from "../../../src/cli/cmd/tui/layout/types" + +describe("Layout.Window", () => { + test("creates a window with view reference", () => { + const window = Layout.Window.create({ + id: "win-1", + viewID: "session", + }) + expect(window.id).toBe("win-1") + expect(window.viewID).toBe("session") + expect(window.focused).toBe(false) + }) + + test("validates window schema", () => { + const result = Layout.Window.Info.safeParse({ + id: "win-1", + viewID: "session", + focused: true, + }) + expect(result.success).toBe(true) + }) +}) + +describe("Layout.Split", () => { + test("creates horizontal split with children", () => { + const split = Layout.Split.create({ + id: "split-1", + direction: "horizontal", + children: [ + { type: "window", id: "win-1", viewID: "session", focused: false }, + { type: "window", id: "win-2", viewID: "home", focused: false }, + ], + ratios: [0.5, 0.5], + }) + expect(split.direction).toBe("horizontal") + expect(split.children).toHaveLength(2) + }) + + test("creates vertical split", () => { + const split = Layout.Split.create({ + id: "split-1", + direction: "vertical", + children: [], + ratios: [], + }) + expect(split.direction).toBe("vertical") + }) +}) + +describe("Layout.Float", () => { + test("creates float with position", () => { + const float = Layout.Float.create({ + id: "float-1", + viewID: "command-palette", + x: 10, + y: 5, + width: 60, + height: 20, + }) + expect(float.x).toBe(10) + expect(float.y).toBe(5) + expect(float.width).toBe(60) + expect(float.height).toBe(20) + }) +}) + +describe("Layout.Root", () => { + test("creates root layout with single window", () => { + const root = Layout.Root.create({ + root: { type: "window", id: "win-1", viewID: "session", focused: false }, + floats: [], + focusedID: "win-1", + }) + expect(root.focusedID).toBe("win-1") + expect(root.floats).toHaveLength(0) + }) +}) diff --git a/packages/opencode/test/tui/view/registry.test.ts b/packages/opencode/test/tui/view/registry.test.ts new file mode 100644 index 00000000000..12e49fc9e6f --- /dev/null +++ b/packages/opencode/test/tui/view/registry.test.ts @@ -0,0 +1,92 @@ +// packages/opencode/test/tui/view/registry.test.ts +import { describe, expect, test, beforeEach } from "bun:test" +import { ViewRegistry } from "../../../src/cli/cmd/tui/view/registry" +import { View } from "../../../src/cli/cmd/tui/view/types" + +describe("ViewRegistry", () => { + beforeEach(() => { + ViewRegistry.clear() + }) + + test("registers and retrieves a view", () => { + const treeView = View.Tree.create({ + id: "test-tree", + title: "Test Tree", + nodes: [], + }) + + ViewRegistry.register("test-tree", treeView) + const retrieved = ViewRegistry.get("test-tree") + + expect(retrieved).toBeDefined() + expect(retrieved?.id).toBe("test-tree") + expect(retrieved?.type).toBe("tree") + }) + + test("updates an existing view", () => { + const initial = View.List.create({ + id: "test-list", + title: "Test List", + items: [{ id: "item-1", label: "Item 1" }], + }) + + ViewRegistry.register("test-list", initial) + + const updated = View.List.create({ + id: "test-list", + title: "Test List", + items: [ + { id: "item-1", label: "Item 1" }, + { id: "item-2", label: "Item 2" }, + ], + }) + + ViewRegistry.register("test-list", updated) + const retrieved = ViewRegistry.get("test-list") as View.List.Info + + expect(retrieved?.items).toHaveLength(2) + }) + + test("unregisters a view", () => { + ViewRegistry.register( + "temp-view", + View.Text.create({ + id: "temp-view", + title: "Temp", + content: "test", + }), + ) + + expect(ViewRegistry.get("temp-view")).toBeDefined() + + ViewRegistry.unregister("temp-view") + + expect(ViewRegistry.get("temp-view")).toBeUndefined() + }) + + test("lists all registered views", () => { + ViewRegistry.register("view-1", View.Text.create({ id: "view-1", title: "View 1", content: "" })) + ViewRegistry.register("view-2", View.Text.create({ id: "view-2", title: "View 2", content: "" })) + + const all = ViewRegistry.list() + expect(all).toHaveLength(2) + }) + + test("subscribes to view changes", () => { + let changeCount = 0 + const unsub = ViewRegistry.subscribe("watched-view", () => { + changeCount++ + }) + + ViewRegistry.register("watched-view", View.Text.create({ id: "watched-view", title: "Watched", content: "v1" })) + expect(changeCount).toBe(1) + + ViewRegistry.register("watched-view", View.Text.create({ id: "watched-view", title: "Watched", content: "v2" })) + expect(changeCount).toBe(2) + + unsub() + + ViewRegistry.register("watched-view", View.Text.create({ id: "watched-view", title: "Watched", content: "v3" })) + expect(changeCount).toBe(2) + }) +}) diff --git a/packages/opencode/test/tui/view/types.test.ts b/packages/opencode/test/tui/view/types.test.ts new file mode 100644 index 00000000000..eb01651e6d0 --- /dev/null +++ b/packages/opencode/test/tui/view/types.test.ts @@ -0,0 +1,77 @@ +// packages/opencode/test/tui/view/types.test.ts +import { describe, expect, test } from "bun:test" +import { View } from "../../../src/cli/cmd/tui/view/types" + +describe("View.Tree", () => { + test("creates tree view data", () => { + const tree = View.Tree.create({ + id: "session-tree", + title: "Sessions", + nodes: [ + { + id: "session-1", + label: "Chat about TypeScript", + icon: "chat", + children: [], + expanded: false, + }, + ], + }) + expect(tree.type).toBe("tree") + expect(tree.nodes).toHaveLength(1) + }) + + test("validates tree node schema", () => { + const result = View.Tree.Node.safeParse({ + id: "node-1", + label: "Test Node", + children: [], + }) + expect(result.success).toBe(true) + }) +}) + +describe("View.List", () => { + test("creates list view data", () => { + const list = View.List.create({ + id: "command-palette", + title: "Commands", + items: [ + { id: "cmd-1", label: "New Session", description: "Create a new chat session" }, + { id: "cmd-2", label: "Switch Model", description: "Change the AI model" }, + ], + searchable: true, + }) + expect(list.type).toBe("list") + expect(list.items).toHaveLength(2) + expect(list.searchable).toBe(true) + }) +}) + +describe("View.Text", () => { + test("creates text view data", () => { + const text = View.Text.create({ + id: "help-view", + title: "Help", + content: "# OpenCode Help\n\nWelcome to OpenCode!", + filetype: "markdown", + }) + expect(text.type).toBe("text") + expect(text.filetype).toBe("markdown") + }) +}) + +describe("View.Form", () => { + test("creates form view data", () => { + const form = View.Form.create({ + id: "settings", + title: "Settings", + fields: [ + { id: "theme", type: "select", label: "Theme", options: ["dark", "light"] }, + { id: "autosave", type: "toggle", label: "Auto-save", value: true }, + ], + }) + expect(form.type).toBe("form") + expect(form.fields).toHaveLength(2) + }) +}) diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index fbc0e710c83..a97b347ffbf 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -207,3 +207,103 @@ export interface Hooks { output: { text: string }, ) => Promise<void> } + +// View primitive types for plugins +export type TreeNode = { + id: string + label: string + icon?: string + children: TreeNode[] + expanded?: boolean + metadata?: Record<string, any> +} + +export type TreeView = { + type: "tree" + id: string + title: string + nodes: TreeNode[] + selectedID?: string +} + +export type ListItem = { + id: string + label: string + description?: string + icon?: string + metadata?: Record<string, any> +} + +export type ListView = { + type: "list" + id: string + title: string + items: ListItem[] + searchable?: boolean + selectedID?: string +} + +export type TextView = { + type: "text" + id: string + title: string + content: string + filetype?: string +} + +export type FormField = + | { id: string; type: "text"; label: string; value?: string; placeholder?: string } + | { id: string; type: "toggle"; label: string; value?: boolean } + | { id: string; type: "select"; label: string; options: string[]; value?: string } + | { id: string; type: "number"; label: string; value?: number; min?: number; max?: number } + +export type FormView = { + type: "form" + id: string + title: string + fields: FormField[] +} + +export type PluginView = TreeView | ListView | TextView | FormView + +// Window API for plugins +export type WindowAPI = { + // Window operations + createSplit(options: { direction: "horizontal" | "vertical"; size?: number; viewID: string }): string + closeWindow(windowID?: string): boolean + focusWindow(windowID: string): void + getCurrentWindow(): { id: string; viewID: string } | undefined + getAllWindows(): Array<{ id: string; viewID: string }> + + // View operations + registerView(view: PluginView): void + updateView(viewID: string, view: Partial<PluginView>): void + unregisterView(viewID: string): void + + // Float operations + openFloat(options: { viewID: string; x?: number; y?: number; width: number; height: number }): string + closeFloat(floatID: string): void +} + +// Keybind registration for plugins +export type KeybindAPI = { + register(options: { key: string; description: string; scope?: "global" | "window"; handler: () => void }): () => void +} + +// Extended plugin input with window API +export type PluginInputWithWindow = PluginInput & { + window: WindowAPI + keybind: KeybindAPI +} + +// Extended hooks with window events +export interface WindowHooks { + "window.focused"?: (input: { windowID: string; viewID: string }) => Promise<void> + "window.closed"?: (input: { windowID: string }) => Promise<void> + "view.action"?: (input: { + viewID: string + action: string + itemID?: string + data?: Record<string, any> + }) => Promise<void> +} diff --git a/packages/sdk/js/openapi.json b/packages/sdk/js/openapi.json new file mode 100644 index 00000000000..eb8f2cbf6b4 --- /dev/null +++ b/packages/sdk/js/openapi.json @@ -0,0 +1,10823 @@ +{ + "openapi": "3.1.1", + "info": { + "title": "opencode", + "description": "opencode api", + "version": "1.0.0" + }, + "paths": { + "/global/health": { + "get": { + "operationId": "global.health", + "summary": "Get health", + "description": "Get health information about the OpenCode server.", + "responses": { + "200": { + "description": "Health information", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "healthy": { + "type": "boolean", + "const": true + }, + "version": { + "type": "string" + } + }, + "required": [ + "healthy", + "version" + ] + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.global.health({\n ...\n})" + } + ] + } + }, + "/global/event": { + "get": { + "operationId": "global.event", + "summary": "Get global events", + "description": "Subscribe to global events from the OpenCode system using server-sent events.", + "responses": { + "200": { + "description": "Event stream", + "content": { + "text/event-stream": { + "schema": { + "$ref": "#/components/schemas/GlobalEvent" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.global.event({\n ...\n})" + } + ] + } + }, + "/global/dispose": { + "post": { + "operationId": "global.dispose", + "summary": "Dispose instance", + "description": "Clean up and dispose all OpenCode instances, releasing all resources.", + "responses": { + "200": { + "description": "Global disposed", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.global.dispose({\n ...\n})" + } + ] + } + }, + "/project": { + "get": { + "operationId": "project.list", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "List all projects", + "description": "Get a list of projects that have been opened with OpenCode.", + "responses": { + "200": { + "description": "List of projects", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Project" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.list({\n ...\n})" + } + ] + } + }, + "/project/current": { + "get": { + "operationId": "project.current", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Get current project", + "description": "Retrieve the currently active project that OpenCode is working with.", + "responses": { + "200": { + "description": "Current project information", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.current({\n ...\n})" + } + ] + } + }, + "/project/{projectID}": { + "patch": { + "operationId": "project.update", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "projectID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "Update project", + "description": "Update project properties such as name, icon and color.", + "responses": { + "200": { + "description": "Updated project information", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "icon": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "color": { + "type": "string" + } + } + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.update({\n ...\n})" + } + ] + } + }, + "/pty": { + "get": { + "operationId": "pty.list", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "List PTY sessions", + "description": "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.", + "responses": { + "200": { + "description": "List of sessions", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pty" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.list({\n ...\n})" + } + ] + }, + "post": { + "operationId": "pty.create", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Create PTY session", + "description": "Create a new pseudo-terminal (PTY) session for running shell commands and processes.", + "responses": { + "200": { + "description": "Created session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pty" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "cwd": { + "type": "string" + }, + "title": { + "type": "string" + }, + "env": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.create({\n ...\n})" + } + ] + } + }, + "/pty/{ptyID}": { + "get": { + "operationId": "pty.get", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "ptyID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "Get PTY session", + "description": "Retrieve detailed information about a specific pseudo-terminal (PTY) session.", + "responses": { + "200": { + "description": "Session info", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pty" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.get({\n ...\n})" + } + ] + }, + "put": { + "operationId": "pty.update", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "ptyID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "Update PTY session", + "description": "Update properties of an existing pseudo-terminal (PTY) session.", + "responses": { + "200": { + "description": "Updated session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pty" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "size": { + "type": "object", + "properties": { + "rows": { + "type": "number" + }, + "cols": { + "type": "number" + } + }, + "required": [ + "rows", + "cols" + ] + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.update({\n ...\n})" + } + ] + }, + "delete": { + "operationId": "pty.remove", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "ptyID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "Remove PTY session", + "description": "Remove and terminate a specific pseudo-terminal (PTY) session.", + "responses": { + "200": { + "description": "Session removed", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.remove({\n ...\n})" + } + ] + } + }, + "/pty/{ptyID}/connect": { + "get": { + "operationId": "pty.connect", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "ptyID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "Connect to PTY session", + "description": "Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.", + "responses": { + "200": { + "description": "Connected session", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.connect({\n ...\n})" + } + ] + } + }, + "/config": { + "get": { + "operationId": "config.get", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Get configuration", + "description": "Retrieve the current OpenCode configuration settings and preferences.", + "responses": { + "200": { + "description": "Get config info", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Config" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.config.get({\n ...\n})" + } + ] + }, + "patch": { + "operationId": "config.update", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Update configuration", + "description": "Update OpenCode configuration settings and preferences.", + "responses": { + "200": { + "description": "Successfully updated config", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Config" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Config" + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.config.update({\n ...\n})" + } + ] + } + }, + "/experimental/tool/ids": { + "get": { + "operationId": "tool.ids", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "List tool IDs", + "description": "Get a list of all available tool IDs, including both built-in tools and dynamically registered tools.", + "responses": { + "200": { + "description": "Tool IDs", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToolIDs" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tool.ids({\n ...\n})" + } + ] + } + }, + "/experimental/tool": { + "get": { + "operationId": "tool.list", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "provider", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "query", + "name": "model", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "List tools", + "description": "Get a list of available tools with their JSON schema parameters for a specific provider and model combination.", + "responses": { + "200": { + "description": "Tools", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToolList" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tool.list({\n ...\n})" + } + ] + } + }, + "/instance/dispose": { + "post": { + "operationId": "instance.dispose", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Dispose instance", + "description": "Clean up and dispose the current OpenCode instance, releasing all resources.", + "responses": { + "200": { + "description": "Instance disposed", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.instance.dispose({\n ...\n})" + } + ] + } + }, + "/path": { + "get": { + "operationId": "path.get", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Get paths", + "description": "Retrieve the current working directory and related path information for the OpenCode instance.", + "responses": { + "200": { + "description": "Path", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Path" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.path.get({\n ...\n})" + } + ] + } + }, + "/vcs": { + "get": { + "operationId": "vcs.get", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Get VCS info", + "description": "Retrieve version control system (VCS) information for the current project, such as git branch.", + "responses": { + "200": { + "description": "VCS info", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VcsInfo" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.vcs.get({\n ...\n})" + } + ] + } + }, + "/session": { + "get": { + "operationId": "session.list", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "List sessions", + "description": "Get a list of all OpenCode sessions, sorted by most recently updated.", + "responses": { + "200": { + "description": "List of sessions", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Session" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.list({\n ...\n})" + } + ] + }, + "post": { + "operationId": "session.create", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Create session", + "description": "Create a new OpenCode session for interacting with AI assistants and managing conversations.", + "responses": { + "200": { + "description": "Successfully created session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Session" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "parentID": { + "type": "string", + "pattern": "^ses.*" + }, + "title": { + "type": "string" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.create({\n ...\n})" + } + ] + } + }, + "/session/status": { + "get": { + "operationId": "session.status", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Get session status", + "description": "Retrieve the current status of all sessions, including active, idle, and completed states.", + "responses": { + "200": { + "description": "Get session status", + "content": { + "application/json": { + "schema": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/SessionStatus" + } + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.status({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}": { + "get": { + "operationId": "session.get", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "summary": "Get session", + "description": "Retrieve detailed information about a specific OpenCode session.", + "tags": [ + "Session" + ], + "responses": { + "200": { + "description": "Get session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Session" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.get({\n ...\n})" + } + ] + }, + "delete": { + "operationId": "session.delete", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "summary": "Delete session", + "description": "Delete a session and permanently remove all associated data, including messages and history.", + "responses": { + "200": { + "description": "Successfully deleted session", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.delete({\n ...\n})" + } + ] + }, + "patch": { + "operationId": "session.update", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "Update session", + "description": "Update properties of an existing session, such as title or other metadata.", + "responses": { + "200": { + "description": "Successfully updated session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Session" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "time": { + "type": "object", + "properties": { + "archived": { + "type": "number" + } + } + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.update({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/children": { + "get": { + "operationId": "session.children", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "summary": "Get session children", + "tags": [ + "Session" + ], + "description": "Retrieve all child sessions that were forked from the specified parent session.", + "responses": { + "200": { + "description": "List of children", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Session" + } + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.children({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/todo": { + "get": { + "operationId": "session.todo", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string" + }, + "required": true, + "description": "Session ID" + } + ], + "summary": "Get session todos", + "description": "Retrieve the todo list associated with a specific session, showing tasks and action items.", + "responses": { + "200": { + "description": "Todo list", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Todo" + } + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.todo({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/init": { + "post": { + "operationId": "session.init", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string" + }, + "required": true, + "description": "Session ID" + } + ], + "summary": "Initialize session", + "description": "Analyze the current application and create an AGENTS.md file with project-specific agent configurations.", + "responses": { + "200": { + "description": "200", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "modelID": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "messageID": { + "type": "string", + "pattern": "^msg.*" + } + }, + "required": [ + "modelID", + "providerID", + "messageID" + ] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.init({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/fork": { + "post": { + "operationId": "session.fork", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "summary": "Fork session", + "description": "Create a new session by forking an existing session at a specific message point.", + "responses": { + "200": { + "description": "200", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Session" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg.*" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.fork({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/abort": { + "post": { + "operationId": "session.abort", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "Abort session", + "description": "Abort an active session and stop any ongoing AI processing or command execution.", + "responses": { + "200": { + "description": "Aborted session", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.abort({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/share": { + "post": { + "operationId": "session.share", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "Share session", + "description": "Create a shareable link for a session, allowing others to view the conversation.", + "responses": { + "200": { + "description": "Successfully shared session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Session" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.share({\n ...\n})" + } + ] + }, + "delete": { + "operationId": "session.unshare", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "summary": "Unshare session", + "description": "Remove the shareable link for a session, making it private again.", + "responses": { + "200": { + "description": "Successfully unshared session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Session" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.unshare({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/diff": { + "get": { + "operationId": "session.diff", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string" + }, + "required": true, + "description": "Session ID" + }, + { + "in": "query", + "name": "messageID", + "schema": { + "type": "string", + "pattern": "^msg.*" + } + } + ], + "summary": "Get session diff", + "description": "Get all file changes (diffs) made during this session.", + "responses": { + "200": { + "description": "List of diffs", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileDiff" + } + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.diff({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/summarize": { + "post": { + "operationId": "session.summarize", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string" + }, + "required": true, + "description": "Session ID" + } + ], + "summary": "Summarize session", + "description": "Generate a concise summary of the session using AI compaction to preserve key information.", + "responses": { + "200": { + "description": "Summarized session", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + }, + "auto": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "providerID", + "modelID" + ] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.summarize({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/message": { + "get": { + "operationId": "session.messages", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string" + }, + "required": true, + "description": "Session ID" + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "number" + } + } + ], + "summary": "Get session messages", + "description": "Retrieve all messages in a session, including user prompts and AI responses.", + "responses": { + "200": { + "description": "List of messages", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Message" + }, + "parts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Part" + } + } + }, + "required": [ + "info", + "parts" + ] + } + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.messages({\n ...\n})" + } + ] + }, + "post": { + "operationId": "session.prompt", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string" + }, + "required": true, + "description": "Session ID" + } + ], + "summary": "Send message", + "description": "Create and send a new message to a session, streaming the AI response.", + "responses": { + "200": { + "description": "Created message", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/AssistantMessage" + }, + "parts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Part" + } + } + }, + "required": [ + "info", + "parts" + ] + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg.*" + }, + "model": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + } + }, + "required": [ + "providerID", + "modelID" + ] + }, + "agent": { + "type": "string" + }, + "noReply": { + "type": "boolean" + }, + "tools": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "boolean" + } + }, + "system": { + "type": "string" + }, + "parts": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/TextPartInput" + }, + { + "$ref": "#/components/schemas/FilePartInput" + }, + { + "$ref": "#/components/schemas/AgentPartInput" + }, + { + "$ref": "#/components/schemas/SubtaskPartInput" + } + ] + } + } + }, + "required": [ + "parts" + ] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.prompt({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/message/{messageID}": { + "get": { + "operationId": "session.message", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string" + }, + "required": true, + "description": "Session ID" + }, + { + "in": "path", + "name": "messageID", + "schema": { + "type": "string" + }, + "required": true, + "description": "Message ID" + } + ], + "summary": "Get message", + "description": "Retrieve a specific message from a session by its message ID.", + "responses": { + "200": { + "description": "Message", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Message" + }, + "parts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Part" + } + } + }, + "required": [ + "info", + "parts" + ] + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.message({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/message/{messageID}/part/{partID}": { + "delete": { + "operationId": "part.delete", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string" + }, + "required": true, + "description": "Session ID" + }, + { + "in": "path", + "name": "messageID", + "schema": { + "type": "string" + }, + "required": true, + "description": "Message ID" + }, + { + "in": "path", + "name": "partID", + "schema": { + "type": "string" + }, + "required": true, + "description": "Part ID" + } + ], + "description": "Delete a part from a message", + "responses": { + "200": { + "description": "Successfully deleted part", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.part.delete({\n ...\n})" + } + ] + }, + "patch": { + "operationId": "part.update", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string" + }, + "required": true, + "description": "Session ID" + }, + { + "in": "path", + "name": "messageID", + "schema": { + "type": "string" + }, + "required": true, + "description": "Message ID" + }, + { + "in": "path", + "name": "partID", + "schema": { + "type": "string" + }, + "required": true, + "description": "Part ID" + } + ], + "description": "Update a part in a message", + "responses": { + "200": { + "description": "Successfully updated part", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Part" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Part" + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.part.update({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/prompt_async": { + "post": { + "operationId": "session.prompt_async", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string" + }, + "required": true, + "description": "Session ID" + } + ], + "summary": "Send async message", + "description": "Create and send a new message to a session asynchronously, starting the session if needed and returning immediately.", + "responses": { + "204": { + "description": "Prompt accepted" + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg.*" + }, + "model": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + } + }, + "required": [ + "providerID", + "modelID" + ] + }, + "agent": { + "type": "string" + }, + "noReply": { + "type": "boolean" + }, + "tools": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "boolean" + } + }, + "system": { + "type": "string" + }, + "parts": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/TextPartInput" + }, + { + "$ref": "#/components/schemas/FilePartInput" + }, + { + "$ref": "#/components/schemas/AgentPartInput" + }, + { + "$ref": "#/components/schemas/SubtaskPartInput" + } + ] + } + } + }, + "required": [ + "parts" + ] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.prompt_async({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/command": { + "post": { + "operationId": "session.command", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string" + }, + "required": true, + "description": "Session ID" + } + ], + "summary": "Send command", + "description": "Send a new command to a session for execution by the AI assistant.", + "responses": { + "200": { + "description": "Created message", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/AssistantMessage" + }, + "parts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Part" + } + } + }, + "required": [ + "info", + "parts" + ] + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg.*" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "string" + }, + "arguments": { + "type": "string" + }, + "command": { + "type": "string" + } + }, + "required": [ + "arguments", + "command" + ] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.command({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/shell": { + "post": { + "operationId": "session.shell", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string" + }, + "required": true, + "description": "Session ID" + } + ], + "summary": "Run shell command", + "description": "Execute a shell command within the session context and return the AI's response.", + "responses": { + "200": { + "description": "Created message", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssistantMessage" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + } + }, + "required": [ + "providerID", + "modelID" + ] + }, + "command": { + "type": "string" + } + }, + "required": [ + "agent", + "command" + ] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.shell({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/revert": { + "post": { + "operationId": "session.revert", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "Revert message", + "description": "Revert a specific message in a session, undoing its effects and restoring the previous state.", + "responses": { + "200": { + "description": "Updated session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Session" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg.*" + }, + "partID": { + "type": "string", + "pattern": "^prt.*" + } + }, + "required": [ + "messageID" + ] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.revert({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/unrevert": { + "post": { + "operationId": "session.unrevert", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "Restore reverted messages", + "description": "Restore all previously reverted messages in a session.", + "responses": { + "200": { + "description": "Updated session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Session" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.unrevert({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/permissions/{permissionID}": { + "post": { + "operationId": "permission.respond", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "permissionID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "Respond to permission", + "description": "Approve or deny a permission request from the AI assistant.", + "responses": { + "200": { + "description": "Permission processed successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "response": { + "type": "string", + "enum": [ + "once", + "always", + "reject" + ] + } + }, + "required": [ + "response" + ] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.permission.respond({\n ...\n})" + } + ] + } + }, + "/permission": { + "get": { + "operationId": "permission.list", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "List pending permissions", + "description": "Get all pending permission requests across all sessions.", + "responses": { + "200": { + "description": "List of pending permissions", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Permission" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.permission.list({\n ...\n})" + } + ] + } + }, + "/command": { + "get": { + "operationId": "command.list", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "List commands", + "description": "Get a list of all available commands in the OpenCode system.", + "responses": { + "200": { + "description": "List of commands", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Command" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.command.list({\n ...\n})" + } + ] + } + }, + "/config/providers": { + "get": { + "operationId": "config.providers", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "List config providers", + "description": "Get a list of all configured AI providers and their default models.", + "responses": { + "200": { + "description": "List of providers", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "providers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Provider" + } + }, + "default": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "providers", + "default" + ] + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.config.providers({\n ...\n})" + } + ] + } + }, + "/provider": { + "get": { + "operationId": "provider.list", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "List providers", + "description": "Get a list of all available AI providers, including both available and connected ones.", + "responses": { + "200": { + "description": "List of providers", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "all": { + "type": "array", + "items": { + "type": "object", + "properties": { + "api": { + "type": "string" + }, + "name": { + "type": "string" + }, + "env": { + "type": "array", + "items": { + "type": "string" + } + }, + "id": { + "type": "string" + }, + "npm": { + "type": "string" + }, + "models": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "family": { + "type": "string" + }, + "release_date": { + "type": "string" + }, + "attachment": { + "type": "boolean" + }, + "reasoning": { + "type": "boolean" + }, + "temperature": { + "type": "boolean" + }, + "tool_call": { + "type": "boolean" + }, + "interleaved": { + "anyOf": [ + { + "type": "boolean", + "const": true + }, + { + "type": "object", + "properties": { + "field": { + "type": "string", + "enum": [ + "reasoning_content", + "reasoning_details" + ] + } + }, + "required": [ + "field" + ], + "additionalProperties": false + } + ] + }, + "cost": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "cache_read": { + "type": "number" + }, + "cache_write": { + "type": "number" + }, + "context_over_200k": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "cache_read": { + "type": "number" + }, + "cache_write": { + "type": "number" + } + }, + "required": [ + "input", + "output" + ] + } + }, + "required": [ + "input", + "output" + ] + }, + "limit": { + "type": "object", + "properties": { + "context": { + "type": "number" + }, + "output": { + "type": "number" + } + }, + "required": [ + "context", + "output" + ] + }, + "modalities": { + "type": "object", + "properties": { + "input": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "text", + "audio", + "image", + "video", + "pdf" + ] + } + }, + "output": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "text", + "audio", + "image", + "video", + "pdf" + ] + } + } + }, + "required": [ + "input", + "output" + ] + }, + "experimental": { + "type": "boolean" + }, + "status": { + "type": "string", + "enum": [ + "alpha", + "beta", + "deprecated" + ] + }, + "options": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "headers": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "provider": { + "type": "object", + "properties": { + "npm": { + "type": "string" + } + }, + "required": [ + "npm" + ] + } + }, + "required": [ + "id", + "name", + "release_date", + "attachment", + "reasoning", + "temperature", + "tool_call", + "limit", + "options" + ] + } + } + }, + "required": [ + "name", + "env", + "id", + "models" + ] + } + }, + "default": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "connected": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "all", + "default", + "connected" + ] + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.list({\n ...\n})" + } + ] + } + }, + "/provider/auth": { + "get": { + "operationId": "provider.auth", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Get provider auth methods", + "description": "Retrieve available authentication methods for all AI providers.", + "responses": { + "200": { + "description": "Provider auth methods", + "content": { + "application/json": { + "schema": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProviderAuthMethod" + } + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.auth({\n ...\n})" + } + ] + } + }, + "/provider/{providerID}/oauth/authorize": { + "post": { + "operationId": "provider.oauth.authorize", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "providerID", + "schema": { + "type": "string" + }, + "required": true, + "description": "Provider ID" + } + ], + "summary": "OAuth authorize", + "description": "Initiate OAuth authorization for a specific AI provider to get an authorization URL.", + "responses": { + "200": { + "description": "Authorization URL and method", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderAuthAuthorization" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "method": { + "description": "Auth method index", + "type": "number" + } + }, + "required": [ + "method" + ] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.oauth.authorize({\n ...\n})" + } + ] + } + }, + "/provider/{providerID}/oauth/callback": { + "post": { + "operationId": "provider.oauth.callback", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "providerID", + "schema": { + "type": "string" + }, + "required": true, + "description": "Provider ID" + } + ], + "summary": "OAuth callback", + "description": "Handle the OAuth callback from a provider after user authorization.", + "responses": { + "200": { + "description": "OAuth callback processed successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "method": { + "description": "Auth method index", + "type": "number" + }, + "code": { + "description": "OAuth authorization code", + "type": "string" + } + }, + "required": [ + "method" + ] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.oauth.callback({\n ...\n})" + } + ] + } + }, + "/find": { + "get": { + "operationId": "find.text", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "pattern", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "Find text", + "description": "Search for text patterns across files in the project using ripgrep.", + "responses": { + "200": { + "description": "Matches", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ] + }, + "lines": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ] + }, + "line_number": { + "type": "number" + }, + "absolute_offset": { + "type": "number" + }, + "submatches": { + "type": "array", + "items": { + "type": "object", + "properties": { + "match": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ] + }, + "start": { + "type": "number" + }, + "end": { + "type": "number" + } + }, + "required": [ + "match", + "start", + "end" + ] + } + } + }, + "required": [ + "path", + "lines", + "line_number", + "absolute_offset", + "submatches" + ] + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.find.text({\n ...\n})" + } + ] + } + }, + "/find/file": { + "get": { + "operationId": "find.files", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "query", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "query", + "name": "dirs", + "schema": { + "type": "string", + "enum": [ + "true", + "false" + ] + } + } + ], + "summary": "Find files", + "description": "Search for files by name or pattern in the project directory.", + "responses": { + "200": { + "description": "File paths", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.find.files({\n ...\n})" + } + ] + } + }, + "/find/symbol": { + "get": { + "operationId": "find.symbols", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "query", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "Find symbols", + "description": "Search for workspace symbols like functions, classes, and variables using LSP.", + "responses": { + "200": { + "description": "Symbols", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Symbol" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.find.symbols({\n ...\n})" + } + ] + } + }, + "/file": { + "get": { + "operationId": "file.list", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "List files", + "description": "List files and directories in a specified path.", + "responses": { + "200": { + "description": "Files and directories", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileNode" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.file.list({\n ...\n})" + } + ] + } + }, + "/file/content": { + "get": { + "operationId": "file.read", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "Read file", + "description": "Read the content of a specified file.", + "responses": { + "200": { + "description": "File content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileContent" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.file.read({\n ...\n})" + } + ] + } + }, + "/file/status": { + "get": { + "operationId": "file.status", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Get file status", + "description": "Get the git status of all files in the project.", + "responses": { + "200": { + "description": "File status", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/File" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.file.status({\n ...\n})" + } + ] + } + }, + "/log": { + "post": { + "operationId": "app.log", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Write log", + "description": "Write a log entry to the server logs with specified level and metadata.", + "responses": { + "200": { + "description": "Log entry written successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "service": { + "description": "Service name for the log entry", + "type": "string" + }, + "level": { + "description": "Log level", + "type": "string", + "enum": [ + "debug", + "info", + "error", + "warn" + ] + }, + "message": { + "description": "Log message", + "type": "string" + }, + "extra": { + "description": "Additional metadata for the log entry", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": [ + "service", + "level", + "message" + ] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.app.log({\n ...\n})" + } + ] + } + }, + "/agent": { + "get": { + "operationId": "app.agents", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "List agents", + "description": "Get a list of all available AI agents in the OpenCode system.", + "responses": { + "200": { + "description": "List of agents", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Agent" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.app.agents({\n ...\n})" + } + ] + } + }, + "/mcp": { + "get": { + "operationId": "mcp.status", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Get MCP status", + "description": "Get the status of all Model Context Protocol (MCP) servers.", + "responses": { + "200": { + "description": "MCP server status", + "content": { + "application/json": { + "schema": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/MCPStatus" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.status({\n ...\n})" + } + ] + }, + "post": { + "operationId": "mcp.add", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Add MCP server", + "description": "Dynamically add a new Model Context Protocol (MCP) server to the system.", + "responses": { + "200": { + "description": "MCP server added successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/MCPStatus" + } + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "config": { + "anyOf": [ + { + "$ref": "#/components/schemas/McpLocalConfig" + }, + { + "$ref": "#/components/schemas/McpRemoteConfig" + } + ] + } + }, + "required": [ + "name", + "config" + ] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.add({\n ...\n})" + } + ] + } + }, + "/mcp/{name}/auth": { + "post": { + "operationId": "mcp.auth.start", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "name", + "required": true + } + ], + "summary": "Start MCP OAuth", + "description": "Start OAuth authentication flow for a Model Context Protocol (MCP) server.", + "responses": { + "200": { + "description": "OAuth flow started", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "authorizationUrl": { + "description": "URL to open in browser for authorization", + "type": "string" + } + }, + "required": [ + "authorizationUrl" + ] + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.start({\n ...\n})" + } + ] + }, + "delete": { + "operationId": "mcp.auth.remove", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "name", + "required": true + } + ], + "summary": "Remove MCP OAuth", + "description": "Remove OAuth credentials for an MCP server", + "responses": { + "200": { + "description": "OAuth credentials removed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "const": true + } + }, + "required": [ + "success" + ] + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.remove({\n ...\n})" + } + ] + } + }, + "/mcp/{name}/auth/callback": { + "post": { + "operationId": "mcp.auth.callback", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "name", + "required": true + } + ], + "summary": "Complete MCP OAuth", + "description": "Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code.", + "responses": { + "200": { + "description": "OAuth authentication completed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MCPStatus" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "description": "Authorization code from OAuth callback", + "type": "string" + } + }, + "required": [ + "code" + ] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.callback({\n ...\n})" + } + ] + } + }, + "/mcp/{name}/auth/authenticate": { + "post": { + "operationId": "mcp.auth.authenticate", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "name", + "required": true + } + ], + "summary": "Authenticate MCP OAuth", + "description": "Start OAuth flow and wait for callback (opens browser)", + "responses": { + "200": { + "description": "OAuth authentication completed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MCPStatus" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.authenticate({\n ...\n})" + } + ] + } + }, + "/mcp/{name}/connect": { + "post": { + "operationId": "mcp.connect", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "name", + "schema": { + "type": "string" + }, + "required": true + } + ], + "description": "Connect an MCP server", + "responses": { + "200": { + "description": "MCP server connected successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.connect({\n ...\n})" + } + ] + } + }, + "/mcp/{name}/disconnect": { + "post": { + "operationId": "mcp.disconnect", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "name", + "schema": { + "type": "string" + }, + "required": true + } + ], + "description": "Disconnect an MCP server", + "responses": { + "200": { + "description": "MCP server disconnected successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.disconnect({\n ...\n})" + } + ] + } + }, + "/lsp": { + "get": { + "operationId": "lsp.status", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Get LSP status", + "description": "Get LSP server status", + "responses": { + "200": { + "description": "LSP server status", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LSPStatus" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.lsp.status({\n ...\n})" + } + ] + } + }, + "/formatter": { + "get": { + "operationId": "formatter.status", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Get formatter status", + "description": "Get formatter status", + "responses": { + "200": { + "description": "Formatter status", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FormatterStatus" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.formatter.status({\n ...\n})" + } + ] + } + }, + "/tui/append-prompt": { + "post": { + "operationId": "tui.appendPrompt", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Append TUI prompt", + "description": "Append prompt to the TUI", + "responses": { + "200": { + "description": "Prompt processed successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tui.appendPrompt({\n ...\n})" + } + ] + } + }, + "/tui/open-help": { + "post": { + "operationId": "tui.openHelp", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Open help dialog", + "description": "Open the help dialog in the TUI to display user assistance information.", + "responses": { + "200": { + "description": "Help dialog opened successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tui.openHelp({\n ...\n})" + } + ] + } + }, + "/tui/open-sessions": { + "post": { + "operationId": "tui.openSessions", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Open sessions dialog", + "description": "Open the session dialog", + "responses": { + "200": { + "description": "Session dialog opened successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tui.openSessions({\n ...\n})" + } + ] + } + }, + "/tui/open-themes": { + "post": { + "operationId": "tui.openThemes", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Open themes dialog", + "description": "Open the theme dialog", + "responses": { + "200": { + "description": "Theme dialog opened successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tui.openThemes({\n ...\n})" + } + ] + } + }, + "/tui/open-models": { + "post": { + "operationId": "tui.openModels", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Open models dialog", + "description": "Open the model dialog", + "responses": { + "200": { + "description": "Model dialog opened successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tui.openModels({\n ...\n})" + } + ] + } + }, + "/tui/submit-prompt": { + "post": { + "operationId": "tui.submitPrompt", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Submit TUI prompt", + "description": "Submit the prompt", + "responses": { + "200": { + "description": "Prompt submitted successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tui.submitPrompt({\n ...\n})" + } + ] + } + }, + "/tui/clear-prompt": { + "post": { + "operationId": "tui.clearPrompt", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Clear TUI prompt", + "description": "Clear the prompt", + "responses": { + "200": { + "description": "Prompt cleared successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tui.clearPrompt({\n ...\n})" + } + ] + } + }, + "/tui/execute-command": { + "post": { + "operationId": "tui.executeCommand", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Execute TUI command", + "description": "Execute a TUI command (e.g. agent_cycle)", + "responses": { + "200": { + "description": "Command executed successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "command": { + "type": "string" + } + }, + "required": [ + "command" + ] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tui.executeCommand({\n ...\n})" + } + ] + } + }, + "/tui/show-toast": { + "post": { + "operationId": "tui.showToast", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Show TUI toast", + "description": "Show a toast notification in the TUI", + "responses": { + "200": { + "description": "Toast notification shown successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "variant": { + "type": "string", + "enum": [ + "info", + "success", + "warning", + "error" + ] + }, + "duration": { + "description": "Duration in milliseconds", + "default": 5000, + "type": "number" + } + }, + "required": [ + "message", + "variant" + ] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tui.showToast({\n ...\n})" + } + ] + } + }, + "/tui/publish": { + "post": { + "operationId": "tui.publish", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Publish TUI event", + "description": "Publish a TUI event", + "responses": { + "200": { + "description": "Event published successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/Event.tui.prompt.append" + }, + { + "$ref": "#/components/schemas/Event.tui.command.execute" + }, + { + "$ref": "#/components/schemas/Event.tui.toast.show" + } + ] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tui.publish({\n ...\n})" + } + ] + } + }, + "/tui/control/next": { + "get": { + "operationId": "tui.control.next", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Get next TUI request", + "description": "Retrieve the next TUI (Terminal User Interface) request from the queue for processing.", + "responses": { + "200": { + "description": "Next TUI request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "body": {} + }, + "required": [ + "path", + "body" + ] + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tui.control.next({\n ...\n})" + } + ] + } + }, + "/tui/control/response": { + "post": { + "operationId": "tui.control.response", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Submit TUI response", + "description": "Submit a response to the TUI request queue to complete a pending request.", + "responses": { + "200": { + "description": "Response submitted successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": {} + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tui.control.response({\n ...\n})" + } + ] + } + }, + "/auth/{providerID}": { + "put": { + "operationId": "auth.set", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "providerID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "Set auth credentials", + "description": "Set authentication credentials", + "responses": { + "200": { + "description": "Successfully set authentication credentials", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Auth" + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.auth.set({\n ...\n})" + } + ] + } + }, + "/event": { + "get": { + "operationId": "event.subscribe", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Subscribe to events", + "description": "Get events", + "responses": { + "200": { + "description": "Event stream", + "content": { + "text/event-stream": { + "schema": { + "$ref": "#/components/schemas/Event" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.event.subscribe({\n ...\n})" + } + ] + } + } + }, + "components": { + "schemas": { + "Event.installation.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "installation.updated" + }, + "properties": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + }, + "required": [ + "version" + ] + } + }, + "required": [ + "type", + "properties" + ] + }, + "Event.installation.update-available": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "installation.update-available" + }, + "properties": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + }, + "required": [ + "version" + ] + } + }, + "required": [ + "type", + "properties" + ] + }, + "Project": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "worktree": { + "type": "string" + }, + "vcs": { + "type": "string", + "const": "git" + }, + "name": { + "type": "string" + }, + "icon": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "color": { + "type": "string" + } + } + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "updated": { + "type": "number" + }, + "initialized": { + "type": "number" + } + }, + "required": [ + "created", + "updated" + ] + } + }, + "required": [ + "id", + "worktree", + "time" + ] + }, + "Event.project.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "project.updated" + }, + "properties": { + "$ref": "#/components/schemas/Project" + } + }, + "required": [ + "type", + "properties" + ] + }, + "Event.server.instance.disposed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "server.instance.disposed" + }, + "properties": { + "type": "object", + "properties": { + "directory": { + "type": "string" + } + }, + "required": [ + "directory" + ] + } + }, + "required": [ + "type", + "properties" + ] + }, + "Event.lsp.client.diagnostics": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "lsp.client.diagnostics" + }, + "properties": { + "type": "object", + "properties": { + "serverID": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "serverID", + "path" + ] + } + }, + "required": [ + "type", + "properties" + ] + }, + "Event.lsp.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "lsp.updated" + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": [ + "type", + "properties" + ] + }, + "FileDiff": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "before": { + "type": "string" + }, + "after": { + "type": "string" + }, + "additions": { + "type": "number" + }, + "deletions": { + "type": "number" + } + }, + "required": [ + "file", + "before", + "after", + "additions", + "deletions" + ] + }, + "UserMessage": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sessionID": { + "type": "string" + }, + "role": { + "type": "string", + "const": "user" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": [ + "created" + ] + }, + "summary": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "body": { + "type": "string" + }, + "diffs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileDiff" + } + } + }, + "required": [ + "diffs" + ] + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + } + }, + "required": [ + "providerID", + "modelID" + ] + }, + "system": { + "type": "string" + }, + "tools": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "boolean" + } + } + }, + "required": [ + "id", + "sessionID", + "role", + "time", + "agent", + "model" + ] + }, + "ProviderAuthError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "const": "ProviderAuthError" + }, + "data": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "providerID", + "message" + ] + } + }, + "required": [ + "name", + "data" + ] + }, + "UnknownError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "const": "UnknownError" + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ] + } + }, + "required": [ + "name", + "data" + ] + }, + "MessageOutputLengthError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "const": "MessageOutputLengthError" + }, + "data": { + "type": "object", + "properties": {} + } + }, + "required": [ + "name", + "data" + ] + }, + "MessageAbortedError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "const": "MessageAbortedError" + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ] + } + }, + "required": [ + "name", + "data" + ] + }, + "APIError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "const": "APIError" + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "statusCode": { + "type": "number" + }, + "isRetryable": { + "type": "boolean" + }, + "responseHeaders": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "responseBody": { + "type": "string" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "message", + "isRetryable" + ] + } + }, + "required": [ + "name", + "data" + ] + }, + "AssistantMessage": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sessionID": { + "type": "string" + }, + "role": { + "type": "string", + "const": "assistant" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "completed": { + "type": "number" + } + }, + "required": [ + "created" + ] + }, + "error": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProviderAuthError" + }, + { + "$ref": "#/components/schemas/UnknownError" + }, + { + "$ref": "#/components/schemas/MessageOutputLengthError" + }, + { + "$ref": "#/components/schemas/MessageAbortedError" + }, + { + "$ref": "#/components/schemas/APIError" + } + ] + }, + "parentID": { + "type": "string" + }, + "modelID": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "mode": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "path": { + "type": "object", + "properties": { + "cwd": { + "type": "string" + }, + "root": { + "type": "string" + } + }, + "required": [ + "cwd", + "root" + ] + }, + "summary": { + "type": "boolean" + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": [ + "read", + "write" + ] + } + }, + "required": [ + "input", + "output", + "reasoning", + "cache" + ] + }, + "finish": { + "type": "string" + } + }, + "required": [ + "id", + "sessionID", + "role", + "time", + "parentID", + "modelID", + "providerID", + "mode", + "agent", + "path", + "cost", + "tokens" + ] + }, + "Message": { + "anyOf": [ + { + "$ref": "#/components/schemas/UserMessage" + }, + { + "$ref": "#/components/schemas/AssistantMessage" + } + ] + }, + "Event.message.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "message.updated" + }, + "properties": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Message" + } + }, + "required": [ + "info" + ] + } + }, + "required": [ + "type", + "properties" + ] + }, + "Event.message.removed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "message.removed" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + } + }, + "required": [ + "sessionID", + "messageID" + ] + } + }, + "required": [ + "type", + "properties" + ] + }, + "TextPart": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "type": { + "type": "string", + "const": "text" + }, + "text": { + "type": "string" + }, + "synthetic": { + "type": "boolean" + }, + "ignored": { + "type": "boolean" + }, + "time": { + "type": "object", + "properties": { + "start": { + "type": "number" + }, + "end": { + "type": "number" + } + }, + "required": [ + "start" + ] + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "text" + ] + }, + "ReasoningPart": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "type": { + "type": "string", + "const": "reasoning" + }, + "text": { + "type": "string" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "time": { + "type": "object", + "properties": { + "start": { + "type": "number" + }, + "end": { + "type": "number" + } + }, + "required": [ + "start" + ] + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "text", + "time" + ] + }, + "FilePartSourceText": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "start": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "end": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "value", + "start", + "end" + ] + }, + "FileSource": { + "type": "object", + "properties": { + "text": { + "$ref": "#/components/schemas/FilePartSourceText" + }, + "type": { + "type": "string", + "const": "file" + }, + "path": { + "type": "string" + } + }, + "required": [ + "text", + "type", + "path" + ] + }, + "Range": { + "type": "object", + "properties": { + "start": { + "type": "object", + "properties": { + "line": { + "type": "number" + }, + "character": { + "type": "number" + } + }, + "required": [ + "line", + "character" + ] + }, + "end": { + "type": "object", + "properties": { + "line": { + "type": "number" + }, + "character": { + "type": "number" + } + }, + "required": [ + "line", + "character" + ] + } + }, + "required": [ + "start", + "end" + ] + }, + "SymbolSource": { + "type": "object", + "properties": { + "text": { + "$ref": "#/components/schemas/FilePartSourceText" + }, + "type": { + "type": "string", + "const": "symbol" + }, + "path": { + "type": "string" + }, + "range": { + "$ref": "#/components/schemas/Range" + }, + "name": { + "type": "string" + }, + "kind": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "text", + "type", + "path", + "range", + "name", + "kind" + ] + }, + "FilePartSource": { + "anyOf": [ + { + "$ref": "#/components/schemas/FileSource" + }, + { + "$ref": "#/components/schemas/SymbolSource" + } + ] + }, + "FilePart": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "type": { + "type": "string", + "const": "file" + }, + "mime": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "url": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/FilePartSource" + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "mime", + "url" + ] + }, + "ToolStatePending": { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "pending" + }, + "input": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "raw": { + "type": "string" + } + }, + "required": [ + "status", + "input", + "raw" + ] + }, + "ToolStateRunning": { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "running" + }, + "input": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "title": { + "type": "string" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "time": { + "type": "object", + "properties": { + "start": { + "type": "number" + } + }, + "required": [ + "start" + ] + } + }, + "required": [ + "status", + "input", + "time" + ] + }, + "ToolStateCompleted": { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "completed" + }, + "input": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "output": { + "type": "string" + }, + "title": { + "type": "string" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "time": { + "type": "object", + "properties": { + "start": { + "type": "number" + }, + "end": { + "type": "number" + }, + "compacted": { + "type": "number" + } + }, + "required": [ + "start", + "end" + ] + }, + "attachments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FilePart" + } + } + }, + "required": [ + "status", + "input", + "output", + "title", + "metadata", + "time" + ] + }, + "ToolStateError": { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "error" + }, + "input": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "error": { + "type": "string" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "time": { + "type": "object", + "properties": { + "start": { + "type": "number" + }, + "end": { + "type": "number" + } + }, + "required": [ + "start", + "end" + ] + } + }, + "required": [ + "status", + "input", + "error", + "time" + ] + }, + "ToolState": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolStatePending" + }, + { + "$ref": "#/components/schemas/ToolStateRunning" + }, + { + "$ref": "#/components/schemas/ToolStateCompleted" + }, + { + "$ref": "#/components/schemas/ToolStateError" + } + ] + }, + "ToolPart": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "type": { + "type": "string", + "const": "tool" + }, + "callID": { + "type": "string" + }, + "tool": { + "type": "string" + }, + "state": { + "$ref": "#/components/schemas/ToolState" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "callID", + "tool", + "state" + ] + }, + "StepStartPart": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "type": { + "type": "string", + "const": "step-start" + }, + "snapshot": { + "type": "string" + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type" + ] + }, + "StepFinishPart": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "type": { + "type": "string", + "const": "step-finish" + }, + "reason": { + "type": "string" + }, + "snapshot": { + "type": "string" + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": [ + "read", + "write" + ] + } + }, + "required": [ + "input", + "output", + "reasoning", + "cache" + ] + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "reason", + "cost", + "tokens" + ] + }, + "SnapshotPart": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "type": { + "type": "string", + "const": "snapshot" + }, + "snapshot": { + "type": "string" + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "snapshot" + ] + }, + "PatchPart": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "type": { + "type": "string", + "const": "patch" + }, + "hash": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "hash", + "files" + ] + }, + "AgentPart": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "type": { + "type": "string", + "const": "agent" + }, + "name": { + "type": "string" + }, + "source": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "start": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "end": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "value", + "start", + "end" + ] + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "name" + ] + }, + "RetryPart": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "type": { + "type": "string", + "const": "retry" + }, + "attempt": { + "type": "number" + }, + "error": { + "$ref": "#/components/schemas/APIError" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": [ + "created" + ] + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "attempt", + "error", + "time" + ] + }, + "CompactionPart": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "type": { + "type": "string", + "const": "compaction" + }, + "auto": { + "type": "boolean" + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "auto" + ] + }, + "Part": { + "anyOf": [ + { + "$ref": "#/components/schemas/TextPart" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "type": { + "type": "string", + "const": "subtask" + }, + "prompt": { + "type": "string" + }, + "description": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "command": { + "type": "string" + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "prompt", + "description", + "agent" + ] + }, + { + "$ref": "#/components/schemas/ReasoningPart" + }, + { + "$ref": "#/components/schemas/FilePart" + }, + { + "$ref": "#/components/schemas/ToolPart" + }, + { + "$ref": "#/components/schemas/StepStartPart" + }, + { + "$ref": "#/components/schemas/StepFinishPart" + }, + { + "$ref": "#/components/schemas/SnapshotPart" + }, + { + "$ref": "#/components/schemas/PatchPart" + }, + { + "$ref": "#/components/schemas/AgentPart" + }, + { + "$ref": "#/components/schemas/RetryPart" + }, + { + "$ref": "#/components/schemas/CompactionPart" + } + ] + }, + "Event.message.part.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "message.part.updated" + }, + "properties": { + "type": "object", + "properties": { + "part": { + "$ref": "#/components/schemas/Part" + }, + "delta": { + "type": "string" + } + }, + "required": [ + "part" + ] + } + }, + "required": [ + "type", + "properties" + ] + }, + "Event.message.part.removed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "message.part.removed" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "partID": { + "type": "string" + } + }, + "required": [ + "sessionID", + "messageID", + "partID" + ] + } + }, + "required": [ + "type", + "properties" + ] + }, + "Permission": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "pattern": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "title": { + "type": "string" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": [ + "created" + ] + } + }, + "required": [ + "id", + "type", + "sessionID", + "messageID", + "title", + "metadata", + "time" + ] + }, + "Event.permission.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "permission.updated" + }, + "properties": { + "$ref": "#/components/schemas/Permission" + } + }, + "required": [ + "type", + "properties" + ] + }, + "Event.permission.replied": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "permission.replied" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "permissionID": { + "type": "string" + }, + "response": { + "type": "string" + } + }, + "required": [ + "sessionID", + "permissionID", + "response" + ] + } + }, + "required": [ + "type", + "properties" + ] + }, + "Event.file.edited": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "file.edited" + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + } + }, + "required": [ + "file" + ] + } + }, + "required": [ + "type", + "properties" + ] + }, + "Todo": { + "type": "object", + "properties": { + "content": { + "description": "Brief description of the task", + "type": "string" + }, + "status": { + "description": "Current status of the task: pending, in_progress, completed, cancelled", + "type": "string" + }, + "priority": { + "description": "Priority level of the task: high, medium, low", + "type": "string" + }, + "id": { + "description": "Unique identifier for the todo item", + "type": "string" + } + }, + "required": [ + "content", + "status", + "priority", + "id" + ] + }, + "Event.todo.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "todo.updated" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "todos": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Todo" + } + } + }, + "required": [ + "sessionID", + "todos" + ] + } + }, + "required": [ + "type", + "properties" + ] + }, + "SessionStatus": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "idle" + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "retry" + }, + "attempt": { + "type": "number" + }, + "message": { + "type": "string" + }, + "next": { + "type": "number" + } + }, + "required": [ + "type", + "attempt", + "message", + "next" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "busy" + } + }, + "required": [ + "type" + ] + } + ] + }, + "Event.session.status": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "session.status" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/SessionStatus" + } + }, + "required": [ + "sessionID", + "status" + ] + } + }, + "required": [ + "type", + "properties" + ] + }, + "Event.session.idle": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "session.idle" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + } + }, + "required": [ + "sessionID" + ] + } + }, + "required": [ + "type", + "properties" + ] + }, + "Event.session.compacted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "session.compacted" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + } + }, + "required": [ + "sessionID" + ] + } + }, + "required": [ + "type", + "properties" + ] + }, + "Event.tui.prompt.append": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "tui.prompt.append" + }, + "properties": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ] + } + }, + "required": [ + "type", + "properties" + ] + }, + "Event.tui.command.execute": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "tui.command.execute" + }, + "properties": { + "type": "object", + "properties": { + "command": { + "anyOf": [ + { + "type": "string", + "enum": [ + "session.list", + "session.new", + "session.share", + "session.interrupt", + "session.compact", + "session.page.up", + "session.page.down", + "session.half.page.up", + "session.half.page.down", + "session.first", + "session.last", + "prompt.clear", + "prompt.submit", + "agent.cycle" + ] + }, + { + "type": "string" + } + ] + } + }, + "required": [ + "command" + ] + } + }, + "required": [ + "type", + "properties" + ] + }, + "Event.tui.toast.show": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "tui.toast.show" + }, + "properties": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "variant": { + "type": "string", + "enum": [ + "info", + "success", + "warning", + "error" + ] + }, + "duration": { + "description": "Duration in milliseconds", + "default": 5000, + "type": "number" + } + }, + "required": [ + "message", + "variant" + ] + } + }, + "required": [ + "type", + "properties" + ] + }, + "Event.mcp.tools.changed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "mcp.tools.changed" + }, + "properties": { + "type": "object", + "properties": { + "server": { + "type": "string" + } + }, + "required": [ + "server" + ] + } + }, + "required": [ + "type", + "properties" + ] + }, + "Event.command.executed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "command.executed" + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "arguments": { + "type": "string" + }, + "messageID": { + "type": "string", + "pattern": "^msg.*" + } + }, + "required": [ + "name", + "sessionID", + "arguments", + "messageID" + ] + } + }, + "required": [ + "type", + "properties" + ] + }, + "Session": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^ses.*" + }, + "projectID": { + "type": "string" + }, + "directory": { + "type": "string" + }, + "parentID": { + "type": "string", + "pattern": "^ses.*" + }, + "summary": { + "type": "object", + "properties": { + "additions": { + "type": "number" + }, + "deletions": { + "type": "number" + }, + "files": { + "type": "number" + }, + "diffs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileDiff" + } + } + }, + "required": [ + "additions", + "deletions", + "files" + ] + }, + "share": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + }, + "required": [ + "url" + ] + }, + "title": { + "type": "string" + }, + "version": { + "type": "string" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "updated": { + "type": "number" + }, + "compacting": { + "type": "number" + }, + "archived": { + "type": "number" + } + }, + "required": [ + "created", + "updated" + ] + }, + "revert": { + "type": "object", + "properties": { + "messageID": { + "type": "string" + }, + "partID": { + "type": "string" + }, + "snapshot": { + "type": "string" + }, + "diff": { + "type": "string" + } + }, + "required": [ + "messageID" + ] + } + }, + "required": [ + "id", + "projectID", + "directory", + "title", + "version", + "time" + ] + }, + "Event.session.created": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "session.created" + }, + "properties": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": [ + "info" + ] + } + }, + "required": [ + "type", + "properties" + ] + }, + "Event.session.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "session.updated" + }, + "properties": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": [ + "info" + ] + } + }, + "required": [ + "type", + "properties" + ] + }, + "Event.session.deleted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "session.deleted" + }, + "properties": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": [ + "info" + ] + } + }, + "required": [ + "type", + "properties" + ] + }, + "Event.session.diff": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "session.diff" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "diff": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileDiff" + } + } + }, + "required": [ + "sessionID", + "diff" + ] + } + }, + "required": [ + "type", + "properties" + ] + }, + "Event.session.error": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "session.error" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "error": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProviderAuthError" + }, + { + "$ref": "#/components/schemas/UnknownError" + }, + { + "$ref": "#/components/schemas/MessageOutputLengthError" + }, + { + "$ref": "#/components/schemas/MessageAbortedError" + }, + { + "$ref": "#/components/schemas/APIError" + } + ] + } + } + } + }, + "required": [ + "type", + "properties" + ] + }, + "Event.file.watcher.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "file.watcher.updated" + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "event": { + "anyOf": [ + { + "type": "string", + "const": "add" + }, + { + "type": "string", + "const": "change" + }, + { + "type": "string", + "const": "unlink" + } + ] + } + }, + "required": [ + "file", + "event" + ] + } + }, + "required": [ + "type", + "properties" + ] + }, + "Event.vcs.branch.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "vcs.branch.updated" + }, + "properties": { + "type": "object", + "properties": { + "branch": { + "type": "string" + } + } + } + }, + "required": [ + "type", + "properties" + ] + }, + "Pty": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^pty.*" + }, + "title": { + "type": "string" + }, + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "cwd": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "running", + "exited" + ] + }, + "pid": { + "type": "number" + } + }, + "required": [ + "id", + "title", + "command", + "args", + "cwd", + "status", + "pid" + ] + }, + "Event.pty.created": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "pty.created" + }, + "properties": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": [ + "info" + ] + } + }, + "required": [ + "type", + "properties" + ] + }, + "Event.pty.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "pty.updated" + }, + "properties": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": [ + "info" + ] + } + }, + "required": [ + "type", + "properties" + ] + }, + "Event.pty.exited": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "pty.exited" + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^pty.*" + }, + "exitCode": { + "type": "number" + } + }, + "required": [ + "id", + "exitCode" + ] + } + }, + "required": [ + "type", + "properties" + ] + }, + "Event.pty.deleted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "pty.deleted" + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^pty.*" + } + }, + "required": [ + "id" + ] + } + }, + "required": [ + "type", + "properties" + ] + }, + "Event.server.connected": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "server.connected" + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": [ + "type", + "properties" + ] + }, + "Event.global.disposed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "global.disposed" + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": [ + "type", + "properties" + ] + }, + "Event": { + "anyOf": [ + { + "$ref": "#/components/schemas/Event.installation.updated" + }, + { + "$ref": "#/components/schemas/Event.installation.update-available" + }, + { + "$ref": "#/components/schemas/Event.project.updated" + }, + { + "$ref": "#/components/schemas/Event.server.instance.disposed" + }, + { + "$ref": "#/components/schemas/Event.lsp.client.diagnostics" + }, + { + "$ref": "#/components/schemas/Event.lsp.updated" + }, + { + "$ref": "#/components/schemas/Event.message.updated" + }, + { + "$ref": "#/components/schemas/Event.message.removed" + }, + { + "$ref": "#/components/schemas/Event.message.part.updated" + }, + { + "$ref": "#/components/schemas/Event.message.part.removed" + }, + { + "$ref": "#/components/schemas/Event.permission.updated" + }, + { + "$ref": "#/components/schemas/Event.permission.replied" + }, + { + "$ref": "#/components/schemas/Event.file.edited" + }, + { + "$ref": "#/components/schemas/Event.todo.updated" + }, + { + "$ref": "#/components/schemas/Event.session.status" + }, + { + "$ref": "#/components/schemas/Event.session.idle" + }, + { + "$ref": "#/components/schemas/Event.session.compacted" + }, + { + "$ref": "#/components/schemas/Event.tui.prompt.append" + }, + { + "$ref": "#/components/schemas/Event.tui.command.execute" + }, + { + "$ref": "#/components/schemas/Event.tui.toast.show" + }, + { + "$ref": "#/components/schemas/Event.mcp.tools.changed" + }, + { + "$ref": "#/components/schemas/Event.command.executed" + }, + { + "$ref": "#/components/schemas/Event.session.created" + }, + { + "$ref": "#/components/schemas/Event.session.updated" + }, + { + "$ref": "#/components/schemas/Event.session.deleted" + }, + { + "$ref": "#/components/schemas/Event.session.diff" + }, + { + "$ref": "#/components/schemas/Event.session.error" + }, + { + "$ref": "#/components/schemas/Event.file.watcher.updated" + }, + { + "$ref": "#/components/schemas/Event.vcs.branch.updated" + }, + { + "$ref": "#/components/schemas/Event.pty.created" + }, + { + "$ref": "#/components/schemas/Event.pty.updated" + }, + { + "$ref": "#/components/schemas/Event.pty.exited" + }, + { + "$ref": "#/components/schemas/Event.pty.deleted" + }, + { + "$ref": "#/components/schemas/Event.server.connected" + }, + { + "$ref": "#/components/schemas/Event.global.disposed" + } + ] + }, + "GlobalEvent": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "payload": { + "$ref": "#/components/schemas/Event" + } + }, + "required": [ + "directory", + "payload" + ] + }, + "BadRequestError": { + "type": "object", + "properties": { + "data": {}, + "errors": { + "type": "array", + "items": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "success": { + "type": "boolean", + "const": false + } + }, + "required": [ + "data", + "errors", + "success" + ] + }, + "NotFoundError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "const": "NotFoundError" + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ] + } + }, + "required": [ + "name", + "data" + ] + }, + "KeybindsConfig": { + "description": "Custom keybind configurations", + "type": "object", + "properties": { + "leader": { + "description": "Leader key for keybind combinations", + "default": "ctrl+x", + "type": "string" + }, + "app_exit": { + "description": "Exit the application", + "default": "ctrl+c,ctrl+d,<leader>q", + "type": "string" + }, + "editor_open": { + "description": "Open external editor", + "default": "<leader>e", + "type": "string" + }, + "theme_list": { + "description": "List available themes", + "default": "<leader>t", + "type": "string" + }, + "sidebar_toggle": { + "description": "Toggle sidebar", + "default": "<leader>b", + "type": "string" + }, + "scrollbar_toggle": { + "description": "Toggle session scrollbar", + "default": "none", + "type": "string" + }, + "username_toggle": { + "description": "Toggle username visibility", + "default": "none", + "type": "string" + }, + "status_view": { + "description": "View status", + "default": "<leader>s", + "type": "string" + }, + "session_export": { + "description": "Export session to editor", + "default": "<leader>x", + "type": "string" + }, + "session_new": { + "description": "Create a new session", + "default": "<leader>n", + "type": "string" + }, + "session_list": { + "description": "List all sessions", + "default": "<leader>l", + "type": "string" + }, + "session_timeline": { + "description": "Show session timeline", + "default": "<leader>g", + "type": "string" + }, + "session_fork": { + "description": "Fork session from message", + "default": "none", + "type": "string" + }, + "session_rename": { + "description": "Rename session", + "default": "none", + "type": "string" + }, + "session_share": { + "description": "Share current session", + "default": "none", + "type": "string" + }, + "session_unshare": { + "description": "Unshare current session", + "default": "none", + "type": "string" + }, + "session_interrupt": { + "description": "Interrupt current session", + "default": "escape", + "type": "string" + }, + "session_compact": { + "description": "Compact the session", + "default": "<leader>c", + "type": "string" + }, + "messages_page_up": { + "description": "Scroll messages up by one page", + "default": "pageup", + "type": "string" + }, + "messages_page_down": { + "description": "Scroll messages down by one page", + "default": "pagedown", + "type": "string" + }, + "messages_half_page_up": { + "description": "Scroll messages up by half page", + "default": "ctrl+alt+u", + "type": "string" + }, + "messages_half_page_down": { + "description": "Scroll messages down by half page", + "default": "ctrl+alt+d", + "type": "string" + }, + "messages_first": { + "description": "Navigate to first message", + "default": "ctrl+g,home", + "type": "string" + }, + "messages_last": { + "description": "Navigate to last message", + "default": "ctrl+alt+g,end", + "type": "string" + }, + "messages_next": { + "description": "Navigate to next message", + "default": "none", + "type": "string" + }, + "messages_previous": { + "description": "Navigate to previous message", + "default": "none", + "type": "string" + }, + "messages_last_user": { + "description": "Navigate to last user message", + "default": "none", + "type": "string" + }, + "messages_copy": { + "description": "Copy message", + "default": "<leader>y", + "type": "string" + }, + "messages_undo": { + "description": "Undo message", + "default": "<leader>u", + "type": "string" + }, + "messages_redo": { + "description": "Redo message", + "default": "<leader>r", + "type": "string" + }, + "messages_toggle_conceal": { + "description": "Toggle code block concealment in messages", + "default": "<leader>h", + "type": "string" + }, + "tool_details": { + "description": "Toggle tool details visibility", + "default": "none", + "type": "string" + }, + "model_list": { + "description": "List available models", + "default": "<leader>m", + "type": "string" + }, + "model_cycle_recent": { + "description": "Next recently used model", + "default": "f2", + "type": "string" + }, + "model_cycle_recent_reverse": { + "description": "Previous recently used model", + "default": "shift+f2", + "type": "string" + }, + "model_cycle_favorite": { + "description": "Next favorite model", + "default": "none", + "type": "string" + }, + "model_cycle_favorite_reverse": { + "description": "Previous favorite model", + "default": "none", + "type": "string" + }, + "command_list": { + "description": "List available commands", + "default": "ctrl+p", + "type": "string" + }, + "agent_list": { + "description": "List agents", + "default": "<leader>a", + "type": "string" + }, + "agent_cycle": { + "description": "Next agent", + "default": "tab", + "type": "string" + }, + "agent_cycle_reverse": { + "description": "Previous agent", + "default": "shift+tab", + "type": "string" + }, + "input_clear": { + "description": "Clear input field", + "default": "ctrl+c", + "type": "string" + }, + "input_paste": { + "description": "Paste from clipboard", + "default": "ctrl+v", + "type": "string" + }, + "input_submit": { + "description": "Submit input", + "default": "return", + "type": "string" + }, + "input_newline": { + "description": "Insert newline in input", + "default": "shift+return,ctrl+return,alt+return,ctrl+j", + "type": "string" + }, + "input_move_left": { + "description": "Move cursor left in input", + "default": "left,ctrl+b", + "type": "string" + }, + "input_move_right": { + "description": "Move cursor right in input", + "default": "right,ctrl+f", + "type": "string" + }, + "input_move_up": { + "description": "Move cursor up in input", + "default": "up", + "type": "string" + }, + "input_move_down": { + "description": "Move cursor down in input", + "default": "down", + "type": "string" + }, + "input_select_left": { + "description": "Select left in input", + "default": "shift+left", + "type": "string" + }, + "input_select_right": { + "description": "Select right in input", + "default": "shift+right", + "type": "string" + }, + "input_select_up": { + "description": "Select up in input", + "default": "shift+up", + "type": "string" + }, + "input_select_down": { + "description": "Select down in input", + "default": "shift+down", + "type": "string" + }, + "input_line_home": { + "description": "Move to start of line in input", + "default": "ctrl+a", + "type": "string" + }, + "input_line_end": { + "description": "Move to end of line in input", + "default": "ctrl+e", + "type": "string" + }, + "input_select_line_home": { + "description": "Select to start of line in input", + "default": "ctrl+shift+a", + "type": "string" + }, + "input_select_line_end": { + "description": "Select to end of line in input", + "default": "ctrl+shift+e", + "type": "string" + }, + "input_visual_line_home": { + "description": "Move to start of visual line in input", + "default": "alt+a", + "type": "string" + }, + "input_visual_line_end": { + "description": "Move to end of visual line in input", + "default": "alt+e", + "type": "string" + }, + "input_select_visual_line_home": { + "description": "Select to start of visual line in input", + "default": "alt+shift+a", + "type": "string" + }, + "input_select_visual_line_end": { + "description": "Select to end of visual line in input", + "default": "alt+shift+e", + "type": "string" + }, + "input_buffer_home": { + "description": "Move to start of buffer in input", + "default": "home", + "type": "string" + }, + "input_buffer_end": { + "description": "Move to end of buffer in input", + "default": "end", + "type": "string" + }, + "input_select_buffer_home": { + "description": "Select to start of buffer in input", + "default": "shift+home", + "type": "string" + }, + "input_select_buffer_end": { + "description": "Select to end of buffer in input", + "default": "shift+end", + "type": "string" + }, + "input_delete_line": { + "description": "Delete line in input", + "default": "ctrl+shift+d", + "type": "string" + }, + "input_delete_to_line_end": { + "description": "Delete to end of line in input", + "default": "ctrl+k", + "type": "string" + }, + "input_delete_to_line_start": { + "description": "Delete to start of line in input", + "default": "ctrl+u", + "type": "string" + }, + "input_backspace": { + "description": "Backspace in input", + "default": "backspace,shift+backspace", + "type": "string" + }, + "input_delete": { + "description": "Delete character in input", + "default": "ctrl+d,delete,shift+delete", + "type": "string" + }, + "input_undo": { + "description": "Undo in input", + "default": "ctrl+-,super+z", + "type": "string" + }, + "input_redo": { + "description": "Redo in input", + "default": "ctrl+.,super+shift+z", + "type": "string" + }, + "input_word_forward": { + "description": "Move word forward in input", + "default": "alt+f,alt+right,ctrl+right", + "type": "string" + }, + "input_word_backward": { + "description": "Move word backward in input", + "default": "alt+b,alt+left,ctrl+left", + "type": "string" + }, + "input_select_word_forward": { + "description": "Select word forward in input", + "default": "alt+shift+f,alt+shift+right", + "type": "string" + }, + "input_select_word_backward": { + "description": "Select word backward in input", + "default": "alt+shift+b,alt+shift+left", + "type": "string" + }, + "input_delete_word_forward": { + "description": "Delete word forward in input", + "default": "alt+d,alt+delete,ctrl+delete", + "type": "string" + }, + "input_delete_word_backward": { + "description": "Delete word backward in input", + "default": "ctrl+w,ctrl+backspace,alt+backspace", + "type": "string" + }, + "history_previous": { + "description": "Previous history item", + "default": "up", + "type": "string" + }, + "history_next": { + "description": "Next history item", + "default": "down", + "type": "string" + }, + "session_child_cycle": { + "description": "Next child session", + "default": "<leader>right", + "type": "string" + }, + "session_child_cycle_reverse": { + "description": "Previous child session", + "default": "<leader>left", + "type": "string" + }, + "session_parent": { + "description": "Go to parent session", + "default": "<leader>up", + "type": "string" + }, + "terminal_suspend": { + "description": "Suspend terminal", + "default": "ctrl+z", + "type": "string" + }, + "terminal_title_toggle": { + "description": "Toggle terminal title", + "default": "none", + "type": "string" + }, + "tips_toggle": { + "description": "Toggle tips on home screen", + "default": "<leader>h", + "type": "string" + }, + "window_focus_left": { + "description": "Focus window to the left", + "default": "ctrl+w h", + "type": "string" + }, + "window_focus_down": { + "description": "Focus window below", + "default": "ctrl+w j", + "type": "string" + }, + "window_focus_up": { + "description": "Focus window above", + "default": "ctrl+w k", + "type": "string" + }, + "window_focus_right": { + "description": "Focus window to the right", + "default": "ctrl+w l", + "type": "string" + }, + "window_split_horizontal": { + "description": "Split window horizontally", + "default": "ctrl+w s", + "type": "string" + }, + "window_split_vertical": { + "description": "Split window vertically", + "default": "ctrl+w v", + "type": "string" + }, + "window_close": { + "description": "Close current window", + "default": "ctrl+w c", + "type": "string" + }, + "window_close_others": { + "description": "Close all other windows", + "default": "ctrl+w o", + "type": "string" + }, + "window_equalize": { + "description": "Equalize window sizes", + "default": "ctrl+w =", + "type": "string" + }, + "window_increase_height": { + "description": "Increase window height", + "default": "ctrl+w +", + "type": "string" + }, + "window_decrease_height": { + "description": "Decrease window height", + "default": "ctrl+w -", + "type": "string" + }, + "window_increase_width": { + "description": "Increase window width", + "default": "ctrl+w >", + "type": "string" + }, + "window_decrease_width": { + "description": "Decrease window width", + "default": "ctrl+w <", + "type": "string" + } + }, + "additionalProperties": false + }, + "LogLevel": { + "description": "Log level", + "type": "string", + "enum": [ + "DEBUG", + "INFO", + "WARN", + "ERROR" + ] + }, + "ServerConfig": { + "description": "Server configuration for opencode serve and web commands", + "type": "object", + "properties": { + "port": { + "description": "Port to listen on", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "hostname": { + "description": "Hostname to listen on", + "type": "string" + }, + "mdns": { + "description": "Enable mDNS service discovery", + "type": "boolean" + } + }, + "additionalProperties": false + }, + "AgentConfig": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "temperature": { + "type": "number" + }, + "top_p": { + "type": "number" + }, + "prompt": { + "type": "string" + }, + "tools": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "boolean" + } + }, + "disable": { + "type": "boolean" + }, + "description": { + "description": "Description of when to use the agent", + "type": "string" + }, + "mode": { + "type": "string", + "enum": [ + "subagent", + "primary", + "all" + ] + }, + "color": { + "description": "Hex color code for the agent (e.g., #FF5733)", + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$" + }, + "maxSteps": { + "description": "Maximum number of agentic iterations before forcing text-only response", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "permission": { + "type": "object", + "properties": { + "edit": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "bash": { + "anyOf": [ + { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + } + ] + }, + "skill": { + "anyOf": [ + { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + } + ] + }, + "webfetch": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "doom_loop": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "external_directory": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + } + } + }, + "additionalProperties": {} + }, + "ProviderConfig": { + "type": "object", + "properties": { + "api": { + "type": "string" + }, + "name": { + "type": "string" + }, + "env": { + "type": "array", + "items": { + "type": "string" + } + }, + "id": { + "type": "string" + }, + "npm": { + "type": "string" + }, + "models": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "family": { + "type": "string" + }, + "release_date": { + "type": "string" + }, + "attachment": { + "type": "boolean" + }, + "reasoning": { + "type": "boolean" + }, + "temperature": { + "type": "boolean" + }, + "tool_call": { + "type": "boolean" + }, + "interleaved": { + "anyOf": [ + { + "type": "boolean", + "const": true + }, + { + "type": "object", + "properties": { + "field": { + "type": "string", + "enum": [ + "reasoning_content", + "reasoning_details" + ] + } + }, + "required": [ + "field" + ], + "additionalProperties": false + } + ] + }, + "cost": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "cache_read": { + "type": "number" + }, + "cache_write": { + "type": "number" + }, + "context_over_200k": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "cache_read": { + "type": "number" + }, + "cache_write": { + "type": "number" + } + }, + "required": [ + "input", + "output" + ] + } + }, + "required": [ + "input", + "output" + ] + }, + "limit": { + "type": "object", + "properties": { + "context": { + "type": "number" + }, + "output": { + "type": "number" + } + }, + "required": [ + "context", + "output" + ] + }, + "modalities": { + "type": "object", + "properties": { + "input": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "text", + "audio", + "image", + "video", + "pdf" + ] + } + }, + "output": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "text", + "audio", + "image", + "video", + "pdf" + ] + } + } + }, + "required": [ + "input", + "output" + ] + }, + "experimental": { + "type": "boolean" + }, + "status": { + "type": "string", + "enum": [ + "alpha", + "beta", + "deprecated" + ] + }, + "options": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "headers": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "provider": { + "type": "object", + "properties": { + "npm": { + "type": "string" + } + }, + "required": [ + "npm" + ] + } + } + } + }, + "whitelist": { + "type": "array", + "items": { + "type": "string" + } + }, + "blacklist": { + "type": "array", + "items": { + "type": "string" + } + }, + "options": { + "type": "object", + "properties": { + "apiKey": { + "type": "string" + }, + "baseURL": { + "type": "string" + }, + "enterpriseUrl": { + "description": "GitHub Enterprise URL for copilot authentication", + "type": "string" + }, + "setCacheKey": { + "description": "Enable promptCacheKey for this provider (default false)", + "type": "boolean" + }, + "timeout": { + "description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.", + "anyOf": [ + { + "description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + { + "description": "Disable timeout for this provider entirely.", + "type": "boolean", + "const": false + } + ] + } + }, + "additionalProperties": {} + } + }, + "additionalProperties": false + }, + "McpLocalConfig": { + "type": "object", + "properties": { + "type": { + "description": "Type of MCP server connection", + "type": "string", + "const": "local" + }, + "command": { + "description": "Command and arguments to run the MCP server", + "type": "array", + "items": { + "type": "string" + } + }, + "environment": { + "description": "Environment variables to set when running the MCP server", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "enabled": { + "description": "Enable or disable the MCP server on startup", + "type": "boolean" + }, + "timeout": { + "description": "Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds) if not specified.", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "type", + "command" + ], + "additionalProperties": false + }, + "McpOAuthConfig": { + "type": "object", + "properties": { + "clientId": { + "description": "OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted.", + "type": "string" + }, + "clientSecret": { + "description": "OAuth client secret (if required by the authorization server)", + "type": "string" + }, + "scope": { + "description": "OAuth scopes to request during authorization", + "type": "string" + } + }, + "additionalProperties": false + }, + "McpRemoteConfig": { + "type": "object", + "properties": { + "type": { + "description": "Type of MCP server connection", + "type": "string", + "const": "remote" + }, + "url": { + "description": "URL of the remote MCP server", + "type": "string" + }, + "enabled": { + "description": "Enable or disable the MCP server on startup", + "type": "boolean" + }, + "headers": { + "description": "Headers to send with the request", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "oauth": { + "description": "OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection.", + "anyOf": [ + { + "$ref": "#/components/schemas/McpOAuthConfig" + }, + { + "type": "boolean", + "const": false + } + ] + }, + "timeout": { + "description": "Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds) if not specified.", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "type", + "url" + ], + "additionalProperties": false + }, + "LayoutConfig": { + "description": "@deprecated Always uses stretch layout.", + "type": "string", + "enum": [ + "auto", + "stretch" + ] + }, + "Config": { + "type": "object", + "properties": { + "$schema": { + "description": "JSON schema reference for configuration validation", + "type": "string" + }, + "theme": { + "description": "Theme name to use for the interface", + "type": "string" + }, + "keybinds": { + "$ref": "#/components/schemas/KeybindsConfig" + }, + "logLevel": { + "$ref": "#/components/schemas/LogLevel" + }, + "tui": { + "description": "TUI specific settings", + "type": "object", + "properties": { + "scroll_speed": { + "description": "TUI scroll speed", + "type": "number", + "minimum": 0.001 + }, + "scroll_acceleration": { + "description": "Scroll acceleration settings", + "type": "object", + "properties": { + "enabled": { + "description": "Enable scroll acceleration", + "type": "boolean" + } + }, + "required": [ + "enabled" + ] + }, + "diff_style": { + "description": "Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column", + "type": "string", + "enum": [ + "auto", + "stacked" + ] + }, + "messages": { + "description": "Message display settings", + "type": "object", + "properties": { + "padding": { + "description": "Padding around messages", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "gap": { + "description": "Gap between messages", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + } + }, + "sidebar": { + "description": "Sidebar settings", + "type": "object", + "properties": { + "padding": { + "description": "Padding inside sidebar", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "width": { + "description": "Sidebar width in characters", + "type": "integer", + "minimum": 10, + "maximum": 9007199254740991 + }, + "visible": { + "description": "Show sidebar by default", + "type": "boolean" + } + } + }, + "header": { + "description": "Header settings", + "type": "object", + "properties": { + "padding": { + "description": "Padding inside header", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "visible": { + "description": "Show header", + "type": "boolean" + }, + "show_title": { + "description": "Show session title", + "type": "boolean" + }, + "show_context": { + "description": "Show context info", + "type": "boolean" + }, + "show_cost": { + "description": "Show cost information", + "type": "boolean" + }, + "show_tokens": { + "description": "Show token count", + "type": "boolean" + } + } + }, + "footer": { + "description": "Footer settings", + "type": "object", + "properties": { + "padding": { + "description": "Padding inside footer", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "visible": { + "description": "Show footer", + "type": "boolean" + }, + "show_directory": { + "description": "Show current directory", + "type": "boolean" + }, + "show_lsp_status": { + "description": "Show LSP status", + "type": "boolean" + }, + "show_mcp_status": { + "description": "Show MCP status", + "type": "boolean" + }, + "show_version": { + "description": "Show version", + "type": "boolean" + }, + "show_keybind_hints": { + "description": "Show keybind hints", + "type": "boolean" + } + } + }, + "prompt": { + "description": "Prompt settings", + "type": "object", + "properties": { + "padding": { + "description": "Padding around prompt", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + } + }, + "window": { + "description": "Window settings", + "type": "object", + "properties": { + "padding": { + "description": "Padding inside windows", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "border": { + "description": "Show window borders", + "type": "boolean" + } + } + } + } + }, + "server": { + "$ref": "#/components/schemas/ServerConfig" + }, + "command": { + "description": "Command configuration, see https://opencode.ai/docs/commands", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "template": { + "type": "string" + }, + "description": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "string" + }, + "subtask": { + "type": "boolean" + } + }, + "required": [ + "template" + ] + } + }, + "watcher": { + "type": "object", + "properties": { + "ignore": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "plugin": { + "type": "array", + "items": { + "type": "string" + } + }, + "snapshot": { + "type": "boolean" + }, + "share": { + "description": "Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing", + "type": "string", + "enum": [ + "manual", + "auto", + "disabled" + ] + }, + "autoshare": { + "description": "@deprecated Use 'share' field instead. Share newly created sessions automatically", + "type": "boolean" + }, + "autoupdate": { + "description": "Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "const": "notify" + } + ] + }, + "disabled_providers": { + "description": "Disable providers that are loaded automatically", + "type": "array", + "items": { + "type": "string" + } + }, + "enabled_providers": { + "description": "When set, ONLY these providers will be enabled. All other providers will be ignored", + "type": "array", + "items": { + "type": "string" + } + }, + "model": { + "description": "Model to use in the format of provider/model, eg anthropic/claude-2", + "type": "string" + }, + "small_model": { + "description": "Small model to use for tasks like title generation in the format of provider/model", + "type": "string" + }, + "default_agent": { + "description": "Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid.", + "type": "string" + }, + "username": { + "description": "Custom username to display in conversations instead of system username", + "type": "string" + }, + "mode": { + "description": "@deprecated Use `agent` field instead.", + "type": "object", + "properties": { + "build": { + "$ref": "#/components/schemas/AgentConfig" + }, + "plan": { + "$ref": "#/components/schemas/AgentConfig" + } + }, + "additionalProperties": { + "$ref": "#/components/schemas/AgentConfig" + } + }, + "agent": { + "description": "Agent configuration, see https://opencode.ai/docs/agent", + "type": "object", + "properties": { + "plan": { + "$ref": "#/components/schemas/AgentConfig" + }, + "build": { + "$ref": "#/components/schemas/AgentConfig" + }, + "general": { + "$ref": "#/components/schemas/AgentConfig" + }, + "explore": { + "$ref": "#/components/schemas/AgentConfig" + }, + "title": { + "$ref": "#/components/schemas/AgentConfig" + }, + "summary": { + "$ref": "#/components/schemas/AgentConfig" + }, + "compaction": { + "$ref": "#/components/schemas/AgentConfig" + } + }, + "additionalProperties": { + "$ref": "#/components/schemas/AgentConfig" + } + }, + "provider": { + "description": "Custom provider configurations and model overrides", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/ProviderConfig" + } + }, + "mcp": { + "description": "MCP (Model Context Protocol) server configurations", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/components/schemas/McpLocalConfig" + }, + { + "$ref": "#/components/schemas/McpRemoteConfig" + } + ] + } + }, + "formatter": { + "anyOf": [ + { + "type": "boolean", + "const": false + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "disabled": { + "type": "boolean" + }, + "command": { + "type": "array", + "items": { + "type": "string" + } + }, + "environment": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "extensions": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + ] + }, + "lsp": { + "anyOf": [ + { + "type": "boolean", + "const": false + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "object", + "properties": { + "disabled": { + "type": "boolean", + "const": true + } + }, + "required": [ + "disabled" + ] + }, + { + "type": "object", + "properties": { + "command": { + "type": "array", + "items": { + "type": "string" + } + }, + "extensions": { + "type": "array", + "items": { + "type": "string" + } + }, + "disabled": { + "type": "boolean" + }, + "env": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "initialization": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": [ + "command" + ] + } + ] + } + } + ] + }, + "instructions": { + "description": "Additional instruction files or patterns to include", + "type": "array", + "items": { + "type": "string" + } + }, + "layout": { + "$ref": "#/components/schemas/LayoutConfig" + }, + "permission": { + "type": "object", + "properties": { + "edit": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "bash": { + "anyOf": [ + { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + } + ] + }, + "skill": { + "anyOf": [ + { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + } + ] + }, + "webfetch": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "doom_loop": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "external_directory": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + } + }, + "tools": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "boolean" + } + }, + "enterprise": { + "type": "object", + "properties": { + "url": { + "description": "Enterprise URL", + "type": "string" + } + } + }, + "compaction": { + "type": "object", + "properties": { + "auto": { + "description": "Enable automatic compaction when context is full (default: true)", + "type": "boolean" + }, + "prune": { + "description": "Enable pruning of old tool outputs (default: true)", + "type": "boolean" + } + } + }, + "experimental": { + "type": "object", + "properties": { + "hook": { + "type": "object", + "properties": { + "file_edited": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "properties": { + "command": { + "type": "array", + "items": { + "type": "string" + } + }, + "environment": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "command" + ] + } + } + }, + "session_completed": { + "type": "array", + "items": { + "type": "object", + "properties": { + "command": { + "type": "array", + "items": { + "type": "string" + } + }, + "environment": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "command" + ] + } + } + } + }, + "chatMaxRetries": { + "description": "Number of retries for chat completions on failure", + "type": "number" + }, + "disable_paste_summary": { + "type": "boolean" + }, + "batch_tool": { + "description": "Enable the batch tool", + "type": "boolean" + }, + "openTelemetry": { + "description": "Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag)", + "type": "boolean" + }, + "primary_tools": { + "description": "Tools that should only be available to primary agents.", + "type": "array", + "items": { + "type": "string" + } + }, + "continue_loop_on_deny": { + "description": "Continue the agent loop when a tool call is denied", + "type": "boolean" + } + } + } + }, + "additionalProperties": false + }, + "ToolIDs": { + "type": "array", + "items": { + "type": "string" + } + }, + "ToolListItem": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "description": { + "type": "string" + }, + "parameters": {} + }, + "required": [ + "id", + "description", + "parameters" + ] + }, + "ToolList": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ToolListItem" + } + }, + "Path": { + "type": "object", + "properties": { + "home": { + "type": "string" + }, + "state": { + "type": "string" + }, + "config": { + "type": "string" + }, + "worktree": { + "type": "string" + }, + "directory": { + "type": "string" + } + }, + "required": [ + "home", + "state", + "config", + "worktree", + "directory" + ] + }, + "VcsInfo": { + "type": "object", + "properties": { + "branch": { + "type": "string" + } + }, + "required": [ + "branch" + ] + }, + "TextPartInput": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "text" + }, + "text": { + "type": "string" + }, + "synthetic": { + "type": "boolean" + }, + "ignored": { + "type": "boolean" + }, + "time": { + "type": "object", + "properties": { + "start": { + "type": "number" + }, + "end": { + "type": "number" + } + }, + "required": [ + "start" + ] + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": [ + "type", + "text" + ] + }, + "FilePartInput": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "file" + }, + "mime": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "url": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/FilePartSource" + } + }, + "required": [ + "type", + "mime", + "url" + ] + }, + "AgentPartInput": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "agent" + }, + "name": { + "type": "string" + }, + "source": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "start": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "end": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "value", + "start", + "end" + ] + } + }, + "required": [ + "type", + "name" + ] + }, + "SubtaskPartInput": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "subtask" + }, + "prompt": { + "type": "string" + }, + "description": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "command": { + "type": "string" + } + }, + "required": [ + "type", + "prompt", + "description", + "agent" + ] + }, + "Command": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "string" + }, + "template": { + "type": "string" + }, + "subtask": { + "type": "boolean" + } + }, + "required": [ + "name", + "template" + ] + }, + "Model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "api": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + }, + "npm": { + "type": "string" + } + }, + "required": [ + "id", + "url", + "npm" + ] + }, + "name": { + "type": "string" + }, + "family": { + "type": "string" + }, + "capabilities": { + "type": "object", + "properties": { + "temperature": { + "type": "boolean" + }, + "reasoning": { + "type": "boolean" + }, + "attachment": { + "type": "boolean" + }, + "toolcall": { + "type": "boolean" + }, + "input": { + "type": "object", + "properties": { + "text": { + "type": "boolean" + }, + "audio": { + "type": "boolean" + }, + "image": { + "type": "boolean" + }, + "video": { + "type": "boolean" + }, + "pdf": { + "type": "boolean" + } + }, + "required": [ + "text", + "audio", + "image", + "video", + "pdf" + ] + }, + "output": { + "type": "object", + "properties": { + "text": { + "type": "boolean" + }, + "audio": { + "type": "boolean" + }, + "image": { + "type": "boolean" + }, + "video": { + "type": "boolean" + }, + "pdf": { + "type": "boolean" + } + }, + "required": [ + "text", + "audio", + "image", + "video", + "pdf" + ] + }, + "interleaved": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "object", + "properties": { + "field": { + "type": "string", + "enum": [ + "reasoning_content", + "reasoning_details" + ] + } + }, + "required": [ + "field" + ] + } + ] + } + }, + "required": [ + "temperature", + "reasoning", + "attachment", + "toolcall", + "input", + "output", + "interleaved" + ] + }, + "cost": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": [ + "read", + "write" + ] + }, + "experimentalOver200K": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": [ + "read", + "write" + ] + } + }, + "required": [ + "input", + "output", + "cache" + ] + } + }, + "required": [ + "input", + "output", + "cache" + ] + }, + "limit": { + "type": "object", + "properties": { + "context": { + "type": "number" + }, + "output": { + "type": "number" + } + }, + "required": [ + "context", + "output" + ] + }, + "status": { + "type": "string", + "enum": [ + "alpha", + "beta", + "deprecated", + "active" + ] + }, + "options": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "headers": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "release_date": { + "type": "string" + } + }, + "required": [ + "id", + "providerID", + "api", + "name", + "capabilities", + "cost", + "limit", + "status", + "options", + "headers", + "release_date" + ] + }, + "Provider": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "source": { + "type": "string", + "enum": [ + "env", + "config", + "custom", + "api" + ] + }, + "env": { + "type": "array", + "items": { + "type": "string" + } + }, + "key": { + "type": "string" + }, + "options": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "models": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/Model" + } + } + }, + "required": [ + "id", + "name", + "source", + "env", + "options", + "models" + ] + }, + "ProviderAuthMethod": { + "type": "object", + "properties": { + "type": { + "anyOf": [ + { + "type": "string", + "const": "oauth" + }, + { + "type": "string", + "const": "api" + } + ] + }, + "label": { + "type": "string" + } + }, + "required": [ + "type", + "label" + ] + }, + "ProviderAuthAuthorization": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "method": { + "anyOf": [ + { + "type": "string", + "const": "auto" + }, + { + "type": "string", + "const": "code" + } + ] + }, + "instructions": { + "type": "string" + } + }, + "required": [ + "url", + "method", + "instructions" + ] + }, + "Symbol": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "kind": { + "type": "number" + }, + "location": { + "type": "object", + "properties": { + "uri": { + "type": "string" + }, + "range": { + "$ref": "#/components/schemas/Range" + } + }, + "required": [ + "uri", + "range" + ] + } + }, + "required": [ + "name", + "kind", + "location" + ] + }, + "FileNode": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "absolute": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "file", + "directory" + ] + }, + "ignored": { + "type": "boolean" + } + }, + "required": [ + "name", + "path", + "absolute", + "type", + "ignored" + ] + }, + "FileContent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + }, + "content": { + "type": "string" + }, + "diff": { + "type": "string" + }, + "patch": { + "type": "object", + "properties": { + "oldFileName": { + "type": "string" + }, + "newFileName": { + "type": "string" + }, + "oldHeader": { + "type": "string" + }, + "newHeader": { + "type": "string" + }, + "hunks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "oldStart": { + "type": "number" + }, + "oldLines": { + "type": "number" + }, + "newStart": { + "type": "number" + }, + "newLines": { + "type": "number" + }, + "lines": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "oldStart", + "oldLines", + "newStart", + "newLines", + "lines" + ] + } + }, + "index": { + "type": "string" + } + }, + "required": [ + "oldFileName", + "newFileName", + "hunks" + ] + }, + "encoding": { + "type": "string", + "const": "base64" + }, + "mimeType": { + "type": "string" + } + }, + "required": [ + "type", + "content" + ] + }, + "File": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "added": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "removed": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "status": { + "type": "string", + "enum": [ + "added", + "deleted", + "modified" + ] + } + }, + "required": [ + "path", + "added", + "removed", + "status" + ] + }, + "Agent": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": [ + "subagent", + "primary", + "all" + ] + }, + "native": { + "type": "boolean" + }, + "hidden": { + "type": "boolean" + }, + "default": { + "type": "boolean" + }, + "topP": { + "type": "number" + }, + "temperature": { + "type": "number" + }, + "color": { + "type": "string" + }, + "permission": { + "type": "object", + "properties": { + "edit": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "bash": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + }, + "skill": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + }, + "webfetch": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "doom_loop": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "external_directory": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + }, + "required": [ + "edit", + "bash", + "skill" + ] + }, + "model": { + "type": "object", + "properties": { + "modelID": { + "type": "string" + }, + "providerID": { + "type": "string" + } + }, + "required": [ + "modelID", + "providerID" + ] + }, + "prompt": { + "type": "string" + }, + "tools": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "boolean" + } + }, + "options": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "maxSteps": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "name", + "mode", + "permission", + "tools", + "options" + ] + }, + "MCPStatusConnected": { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "connected" + } + }, + "required": [ + "status" + ] + }, + "MCPStatusDisabled": { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "disabled" + } + }, + "required": [ + "status" + ] + }, + "MCPStatusFailed": { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "failed" + }, + "error": { + "type": "string" + } + }, + "required": [ + "status", + "error" + ] + }, + "MCPStatusNeedsAuth": { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "needs_auth" + } + }, + "required": [ + "status" + ] + }, + "MCPStatusNeedsClientRegistration": { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "needs_client_registration" + }, + "error": { + "type": "string" + } + }, + "required": [ + "status", + "error" + ] + }, + "MCPStatus": { + "anyOf": [ + { + "$ref": "#/components/schemas/MCPStatusConnected" + }, + { + "$ref": "#/components/schemas/MCPStatusDisabled" + }, + { + "$ref": "#/components/schemas/MCPStatusFailed" + }, + { + "$ref": "#/components/schemas/MCPStatusNeedsAuth" + }, + { + "$ref": "#/components/schemas/MCPStatusNeedsClientRegistration" + } + ] + }, + "LSPStatus": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "root": { + "type": "string" + }, + "status": { + "anyOf": [ + { + "type": "string", + "const": "connected" + }, + { + "type": "string", + "const": "error" + } + ] + } + }, + "required": [ + "id", + "name", + "root", + "status" + ] + }, + "FormatterStatus": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "extensions": { + "type": "array", + "items": { + "type": "string" + } + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "name", + "extensions", + "enabled" + ] + }, + "OAuth": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "oauth" + }, + "refresh": { + "type": "string" + }, + "access": { + "type": "string" + }, + "expires": { + "type": "number" + }, + "enterpriseUrl": { + "type": "string" + } + }, + "required": [ + "type", + "refresh", + "access", + "expires" + ] + }, + "ApiAuth": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "api" + }, + "key": { + "type": "string" + } + }, + "required": [ + "type", + "key" + ] + }, + "WellKnownAuth": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "wellknown" + }, + "key": { + "type": "string" + }, + "token": { + "type": "string" + } + }, + "required": [ + "type", + "key", + "token" + ] + }, + "Auth": { + "anyOf": [ + { + "$ref": "#/components/schemas/OAuth" + }, + { + "$ref": "#/components/schemas/ApiAuth" + }, + { + "$ref": "#/components/schemas/WellKnownAuth" + } + ] + } + } + } +} \ No newline at end of file diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 5c4cc69423d..77f415c3cb5 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1149,6 +1149,58 @@ export type KeybindsConfig = { * Toggle tips on home screen */ tips_toggle?: string + /** + * Focus window to the left + */ + window_focus_left?: string + /** + * Focus window below + */ + window_focus_down?: string + /** + * Focus window above + */ + window_focus_up?: string + /** + * Focus window to the right + */ + window_focus_right?: string + /** + * Split window horizontally + */ + window_split_horizontal?: string + /** + * Split window vertically + */ + window_split_vertical?: string + /** + * Close current window + */ + window_close?: string + /** + * Close all other windows + */ + window_close_others?: string + /** + * Equalize window sizes + */ + window_equalize?: string + /** + * Increase window height + */ + window_increase_height?: string + /** + * Decrease window height + */ + window_decrease_height?: string + /** + * Increase window width + */ + window_increase_width?: string + /** + * Decrease window width + */ + window_decrease_width?: string } /** @@ -1433,6 +1485,120 @@ export type Config = { * Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column */ diff_style?: "auto" | "stacked" + /** + * Message display settings + */ + messages?: { + /** + * Padding around messages + */ + padding?: number + /** + * Gap between messages + */ + gap?: number + } + /** + * Sidebar settings + */ + sidebar?: { + /** + * Padding inside sidebar + */ + padding?: number + /** + * Sidebar width in characters + */ + width?: number + /** + * Show sidebar by default + */ + visible?: boolean + } + /** + * Header settings + */ + header?: { + /** + * Padding inside header + */ + padding?: number + /** + * Show header + */ + visible?: boolean + /** + * Show session title + */ + show_title?: boolean + /** + * Show context info + */ + show_context?: boolean + /** + * Show cost information + */ + show_cost?: boolean + /** + * Show token count + */ + show_tokens?: boolean + } + /** + * Footer settings + */ + footer?: { + /** + * Padding inside footer + */ + padding?: number + /** + * Show footer + */ + visible?: boolean + /** + * Show current directory + */ + show_directory?: boolean + /** + * Show LSP status + */ + show_lsp_status?: boolean + /** + * Show MCP status + */ + show_mcp_status?: boolean + /** + * Show version + */ + show_version?: boolean + /** + * Show keybind hints + */ + show_keybind_hints?: boolean + } + /** + * Prompt settings + */ + prompt?: { + /** + * Padding around prompt + */ + padding?: number + } + /** + * Window settings + */ + window?: { + /** + * Padding inside windows + */ + padding?: number + /** + * Show window borders + */ + border?: boolean + } } server?: ServerConfig /** diff --git a/thoughts/shared/designs/2025-12-29-tui-window-system-design.md b/thoughts/shared/designs/2025-12-29-tui-window-system-design.md new file mode 100644 index 00000000000..a967f8b4418 --- /dev/null +++ b/thoughts/shared/designs/2025-12-29-tui-window-system-design.md @@ -0,0 +1,265 @@ +--- +date: 2025-12-29 +topic: "TUI Window System" +status: validated +--- + +# TUI Window System Design + +## Problem Statement + +The current TUI has a fixed layout - header, messages, sidebar, footer. Users want: + +- A more compact interface with less padding +- Ability to hide UI elements they don't use +- A NERDTree-style session explorer +- General extensibility for custom views + +The goal is a Vim-like window system where plugins can create splits, render views, and users have full control over their layout. + +## Constraints + +- Must work within the existing `@opentui/solid` rendering system +- Chat session view remains built-in (not plugin-replaceable) +- Plugins should be simple to write (data-driven, not SolidJS components) +- Vim-style keybinds and behavior where applicable + +## Approach + +Adopt Vim's model: provide primitives (windows, splits, buffers) and let plugins compose them imperatively. Unlike Vim's raw text buffers, we use typed view primitives (tree, list, text, form) to keep plugin authoring simple. + +## Architecture + +### Core Primitives + +**View** +Content that can be displayed in a window. Two categories: + +- Built-in: `session`, `home` +- Plugin-provided: uses typed primitives (tree, list, text, form) + +**Window** +A rectangular area displaying a single view. Properties: + +- Dimensions (managed by parent split) +- Focus state +- Border styling +- View reference + +**Split** +A container dividing space between children. Properties: + +- Direction: horizontal or vertical +- Children: windows or nested splits +- Size ratios + +**Float** +A window with absolute positioning, rendered above the layout. Used for dialogs, popups, command palette. + +**Layout** +The root container: + +- A tree of splits and windows +- A list of floats +- Tracks which window has focus + +### Layout Structure + +``` +Layout +├── Split (vertical) +│ ├── Window [view: session-tree, width: 30] +│ └── Split (horizontal) +│ ├── Window [view: session, focused] +│ └── Window [view: file-preview] +└── Floats + └── Float [view: command-palette] +``` + +## Components + +### View Primitives (for plugins) + +**Tree** +Hierarchical navigation (session explorer, file browser). + +- Nodes with label, icon, children +- Expand/collapse state +- Actions: select, delete, rename + +**List** +Flat searchable items (command palette, session list). + +- Items with label, description, metadata +- Fuzzy search +- Actions: select + +**Text** +Read-only styled content (logs, previews, help). + +- Lines with styling +- Scrollable + +**Form** +Settings and input (preferences panel). + +- Field types: text, toggle, select, number +- Validation +- Submit action + +### Built-in Views + +**session** +The chat interface. Not replaceable by plugins. Renders messages, tool outputs, prompt input. + +**home** +Welcome screen shown on startup or when no session is active. + +### Window Commands + +Prefix: `<C-w>` (Vim-style) + +| Key | Action | +| --------- | ------------------------------- | +| `h/j/k/l` | Focus window left/down/up/right | +| `s` | Split horizontal | +| `v` | Split vertical | +| `c` | Close window | +| `o` | Close all other windows | +| `=` | Equalize window sizes | +| `+/-` | Increase/decrease height | +| `</>` | Increase/decrease width | + +Closing the last window exits OpenCode. + +### Opening Views + +Views are opened via keybinds. Each view can define: + +- Default keybind (e.g., `<leader>e` for explorer) +- Default position (e.g., left split, 30 chars wide) + +Opening behavior follows Vim: + +- Default position is per-view (explorer opens as left split) +- User can override with explicit split commands (`<C-w>v` then open) + +### Plugin API + +Plugins get imperative access to primitives: + +**Window operations** + +- Create split (direction, size) +- Close window +- Focus window +- Get current window +- Get all windows + +**Rendering** + +- Render tree/list/text/form into a window +- Update content reactively + +**Keybinds** + +- Register global keybind +- Register window-local keybind + +**Events** + +- Session created/changed/deleted +- Window focused/closed +- Existing event system + +Example: A session-tree plugin would: + +1. Register a global keybind (`<leader>e`) +2. On keypress, create a left split (30 chars) +3. Render a tree with session data +4. Set up local keybinds for navigation, delete, rename + +## Data Flow + +1. User presses keybind (e.g., `<leader>e`) +2. Plugin receives keybind event +3. Plugin calls `createSplit({ direction: "left", size: 30 })` +4. Plugin calls `render(window, { type: "tree", data: sessions })` +5. Layout manager updates split tree +6. Renderer draws new layout +7. User navigates tree, plugin receives selection events +8. Plugin calls `openSession(id)`, which opens session view in main window + +## Configuration + +### Component Visibility and Spacing + +Nested by component under `tui`: + +```yaml +tui: + # Existing options + scroll_speed: number + scroll_acceleration: + enabled: boolean + diff_style: "auto" | "stacked" + + # New component options + messages: + padding: number + gap: number + sidebar: + padding: number + width: number + visible: boolean + header: + padding: number + visible: boolean + footer: + padding: number + visible: boolean + prompt: + padding: number + window: + padding: number + border: boolean +``` + +### Granular Visibility Toggles + +Individual elements can be shown/hidden: + +```yaml +tui: + header: + visible: boolean + show_title: boolean + show_context: boolean + show_cost: boolean + show_tokens: boolean + footer: + visible: boolean + show_directory: boolean + show_lsp_status: boolean + show_mcp_status: boolean + show_version: boolean + show_keybind_hints: boolean +``` + +## Error Handling + +- Invalid split operations (e.g., close last window) exit gracefully +- Plugin render errors show error state in window, don't crash TUI +- Invalid config values fall back to defaults with warning + +## Testing Strategy + +- Unit tests for layout tree manipulation (split, close, resize) +- Unit tests for focus navigation logic +- Integration tests for plugin view rendering +- Snapshot tests for layout configurations +- Manual testing for keyboard navigation feel + +## Open Questions + +None - design validated through discussion. diff --git a/thoughts/shared/plans/2025-12-29-tui-window-system-plan.md b/thoughts/shared/plans/2025-12-29-tui-window-system-plan.md new file mode 100644 index 00000000000..23d8dd62d3d --- /dev/null +++ b/thoughts/shared/plans/2025-12-29-tui-window-system-plan.md @@ -0,0 +1,2169 @@ +# TUI Window System Implementation Plan + +**Goal:** Implement a Vim-style window system for the OpenCode TUI with splits, floats, and plugin-provided views. + +**Architecture:** A tree-based layout manager with Window, Split, and Float primitives. Views are either built-in (session, home) or plugin-provided using typed primitives (tree, list, text, form). Window commands use `<C-w>` prefix with Vim-style navigation. + +**Design:** [thoughts/shared/designs/2025-12-29-tui-window-system-design.md](./2025-12-29-tui-window-system-design.md) + +--- + +## Phase 1: Core Layout Primitives + +### Task 1.1: Layout Types and Schemas + +**Files:** + +- Create: `packages/opencode/src/cli/cmd/tui/layout/types.ts` +- Test: `packages/opencode/test/tui/layout/types.test.ts` + +**Step 1: Write the failing test** + +```typescript +// packages/opencode/test/tui/layout/types.test.ts +import { describe, expect, test } from "bun:test" +import { Layout } from "../../../src/cli/cmd/tui/layout/types" + +describe("Layout.Window", () => { + test("creates a window with view reference", () => { + const window = Layout.Window.create({ + id: "win-1", + viewID: "session", + }) + expect(window.id).toBe("win-1") + expect(window.viewID).toBe("session") + expect(window.focused).toBe(false) + }) + + test("validates window schema", () => { + const result = Layout.Window.Info.safeParse({ + id: "win-1", + viewID: "session", + focused: true, + }) + expect(result.success).toBe(true) + }) +}) + +describe("Layout.Split", () => { + test("creates horizontal split with children", () => { + const split = Layout.Split.create({ + id: "split-1", + direction: "horizontal", + children: [ + { type: "window", id: "win-1", viewID: "session" }, + { type: "window", id: "win-2", viewID: "home" }, + ], + ratios: [0.5, 0.5], + }) + expect(split.direction).toBe("horizontal") + expect(split.children).toHaveLength(2) + }) + + test("creates vertical split", () => { + const split = Layout.Split.create({ + id: "split-1", + direction: "vertical", + children: [], + ratios: [], + }) + expect(split.direction).toBe("vertical") + }) +}) + +describe("Layout.Float", () => { + test("creates float with position", () => { + const float = Layout.Float.create({ + id: "float-1", + viewID: "command-palette", + x: 10, + y: 5, + width: 60, + height: 20, + }) + expect(float.x).toBe(10) + expect(float.y).toBe(5) + expect(float.width).toBe(60) + expect(float.height).toBe(20) + }) +}) + +describe("Layout.Root", () => { + test("creates root layout with single window", () => { + const root = Layout.Root.create({ + root: { type: "window", id: "win-1", viewID: "session" }, + floats: [], + focusedID: "win-1", + }) + expect(root.focusedID).toBe("win-1") + expect(root.floats).toHaveLength(0) + }) +}) +``` + +**Step 2: Run test to verify it fails** + +Run: `bun test packages/opencode/test/tui/layout/types.test.ts` +Expected: FAIL with "Cannot find module" + +**Step 3: Write minimal implementation** + +```typescript +// packages/opencode/src/cli/cmd/tui/layout/types.ts +import z from "zod" + +export namespace Layout { + // Window: A rectangular area displaying a single view + export namespace Window { + export const Info = z.object({ + type: z.literal("window").default("window"), + id: z.string(), + viewID: z.string(), + focused: z.boolean().default(false), + }) + export type Info = z.output<typeof Info> + + export function create(input: { id: string; viewID: string; focused?: boolean }): Info { + return { + type: "window", + id: input.id, + viewID: input.viewID, + focused: input.focused ?? false, + } + } + } + + // Split: A container dividing space between children + export namespace Split { + export const Info: z.ZodType<SplitInfo> = z.lazy(() => + z.object({ + type: z.literal("split").default("split"), + id: z.string(), + direction: z.enum(["horizontal", "vertical"]), + children: z.array(z.union([Window.Info, Info])), + ratios: z.array(z.number()), + }), + ) + + export type SplitInfo = { + type: "split" + id: string + direction: "horizontal" | "vertical" + children: Array<Window.Info | SplitInfo> + ratios: number[] + } + + export function create(input: { + id: string + direction: "horizontal" | "vertical" + children: Array<Window.Info | SplitInfo> + ratios: number[] + }): SplitInfo { + return { + type: "split", + id: input.id, + direction: input.direction, + children: input.children, + ratios: input.ratios, + } + } + } + + // Float: A window with absolute positioning + export namespace Float { + export const Info = z.object({ + id: z.string(), + viewID: z.string(), + x: z.number(), + y: z.number(), + width: z.number(), + height: z.number(), + focused: z.boolean().default(false), + }) + export type Info = z.output<typeof Info> + + export function create(input: { + id: string + viewID: string + x: number + y: number + width: number + height: number + focused?: boolean + }): Info { + return { + id: input.id, + viewID: input.viewID, + x: input.x, + y: input.y, + width: input.width, + height: input.height, + focused: input.focused ?? false, + } + } + } + + // Root: The top-level layout container + export namespace Root { + export const Info = z.object({ + root: z.union([Window.Info, Split.Info]), + floats: z.array(Float.Info), + focusedID: z.string(), + }) + export type Info = z.output<typeof Info> + + export function create(input: { + root: Window.Info | Split.SplitInfo + floats: Float.Info[] + focusedID: string + }): Info { + return { + root: input.root, + floats: input.floats, + focusedID: input.focusedID, + } + } + } + + // Node type union for tree traversal + export type Node = Window.Info | Split.SplitInfo +} +``` + +**Step 4: Run test to verify it passes** + +Run: `bun test packages/opencode/test/tui/layout/types.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add packages/opencode/src/cli/cmd/tui/layout/types.ts packages/opencode/test/tui/layout/types.test.ts +git commit -m "feat(tui): add layout type definitions for window system" +``` + +--- + +### Task 1.2: Layout Tree Operations + +**Files:** + +- Create: `packages/opencode/src/cli/cmd/tui/layout/operations.ts` +- Test: `packages/opencode/test/tui/layout/operations.test.ts` + +**Step 1: Write the failing test** + +```typescript +// packages/opencode/test/tui/layout/operations.test.ts +import { describe, expect, test } from "bun:test" +import { Layout } from "../../../src/cli/cmd/tui/layout/types" +import { LayoutOps } from "../../../src/cli/cmd/tui/layout/operations" + +describe("LayoutOps.findWindow", () => { + test("finds window in flat layout", () => { + const root = Layout.Root.create({ + root: Layout.Window.create({ id: "win-1", viewID: "session" }), + floats: [], + focusedID: "win-1", + }) + const window = LayoutOps.findWindow(root, "win-1") + expect(window?.id).toBe("win-1") + }) + + test("finds window in nested split", () => { + const root = Layout.Root.create({ + root: Layout.Split.create({ + id: "split-1", + direction: "horizontal", + children: [ + Layout.Window.create({ id: "win-1", viewID: "session" }), + Layout.Split.create({ + id: "split-2", + direction: "vertical", + children: [ + Layout.Window.create({ id: "win-2", viewID: "home" }), + Layout.Window.create({ id: "win-3", viewID: "explorer" }), + ], + ratios: [0.5, 0.5], + }), + ], + ratios: [0.3, 0.7], + }), + floats: [], + focusedID: "win-1", + }) + const window = LayoutOps.findWindow(root, "win-3") + expect(window?.id).toBe("win-3") + expect(window?.viewID).toBe("explorer") + }) + + test("returns undefined for non-existent window", () => { + const root = Layout.Root.create({ + root: Layout.Window.create({ id: "win-1", viewID: "session" }), + floats: [], + focusedID: "win-1", + }) + const window = LayoutOps.findWindow(root, "win-999") + expect(window).toBeUndefined() + }) +}) + +describe("LayoutOps.splitWindow", () => { + test("splits single window horizontally", () => { + const root = Layout.Root.create({ + root: Layout.Window.create({ id: "win-1", viewID: "session" }), + floats: [], + focusedID: "win-1", + }) + const result = LayoutOps.splitWindow(root, "win-1", "horizontal", { + id: "win-2", + viewID: "home", + }) + expect(result.root.type).toBe("split") + if (result.root.type === "split") { + expect(result.root.direction).toBe("horizontal") + expect(result.root.children).toHaveLength(2) + } + }) + + test("splits single window vertically", () => { + const root = Layout.Root.create({ + root: Layout.Window.create({ id: "win-1", viewID: "session" }), + floats: [], + focusedID: "win-1", + }) + const result = LayoutOps.splitWindow(root, "win-1", "vertical", { + id: "win-2", + viewID: "home", + }) + expect(result.root.type).toBe("split") + if (result.root.type === "split") { + expect(result.root.direction).toBe("vertical") + } + }) +}) + +describe("LayoutOps.closeWindow", () => { + test("closing last window returns null", () => { + const root = Layout.Root.create({ + root: Layout.Window.create({ id: "win-1", viewID: "session" }), + floats: [], + focusedID: "win-1", + }) + const result = LayoutOps.closeWindow(root, "win-1") + expect(result).toBeNull() + }) + + test("closing window in split removes it", () => { + const root = Layout.Root.create({ + root: Layout.Split.create({ + id: "split-1", + direction: "horizontal", + children: [ + Layout.Window.create({ id: "win-1", viewID: "session" }), + Layout.Window.create({ id: "win-2", viewID: "home" }), + ], + ratios: [0.5, 0.5], + }), + floats: [], + focusedID: "win-1", + }) + const result = LayoutOps.closeWindow(root, "win-2") + expect(result).not.toBeNull() + expect(result!.root.type).toBe("window") + if (result!.root.type === "window") { + expect(result!.root.id).toBe("win-1") + } + }) +}) + +describe("LayoutOps.focusDirection", () => { + test("focuses window to the right", () => { + const root = Layout.Root.create({ + root: Layout.Split.create({ + id: "split-1", + direction: "vertical", + children: [ + Layout.Window.create({ id: "win-1", viewID: "session" }), + Layout.Window.create({ id: "win-2", viewID: "home" }), + ], + ratios: [0.5, 0.5], + }), + floats: [], + focusedID: "win-1", + }) + const result = LayoutOps.focusDirection(root, "right") + expect(result.focusedID).toBe("win-2") + }) + + test("focuses window below", () => { + const root = Layout.Root.create({ + root: Layout.Split.create({ + id: "split-1", + direction: "horizontal", + children: [ + Layout.Window.create({ id: "win-1", viewID: "session" }), + Layout.Window.create({ id: "win-2", viewID: "home" }), + ], + ratios: [0.5, 0.5], + }), + floats: [], + focusedID: "win-1", + }) + const result = LayoutOps.focusDirection(root, "down") + expect(result.focusedID).toBe("win-2") + }) + + test("stays on same window when no neighbor", () => { + const root = Layout.Root.create({ + root: Layout.Window.create({ id: "win-1", viewID: "session" }), + floats: [], + focusedID: "win-1", + }) + const result = LayoutOps.focusDirection(root, "right") + expect(result.focusedID).toBe("win-1") + }) +}) +``` + +**Step 2: Run test to verify it fails** + +Run: `bun test packages/opencode/test/tui/layout/operations.test.ts` +Expected: FAIL with "Cannot find module" + +**Step 3: Write minimal implementation** + +```typescript +// packages/opencode/src/cli/cmd/tui/layout/operations.ts +import { Layout } from "./types" + +export namespace LayoutOps { + let idCounter = 0 + export function generateID(prefix: string): string { + return `${prefix}-${++idCounter}` + } + + export function findWindow(root: Layout.Root.Info, windowID: string): Layout.Window.Info | undefined { + function search(node: Layout.Node): Layout.Window.Info | undefined { + if (node.type === "window") { + return node.id === windowID ? node : undefined + } + for (const child of node.children) { + const found = search(child) + if (found) return found + } + return undefined + } + return search(root.root) + } + + export function getAllWindows(root: Layout.Root.Info): Layout.Window.Info[] { + const windows: Layout.Window.Info[] = [] + function collect(node: Layout.Node): void { + if (node.type === "window") { + windows.push(node) + return + } + for (const child of node.children) { + collect(child) + } + } + collect(root.root) + return windows + } + + export function splitWindow( + root: Layout.Root.Info, + targetID: string, + direction: "horizontal" | "vertical", + newWindow: { id: string; viewID: string }, + ): Layout.Root.Info { + function splitNode(node: Layout.Node): Layout.Node { + if (node.type === "window") { + if (node.id === targetID) { + return Layout.Split.create({ + id: generateID("split"), + direction, + children: [node, Layout.Window.create({ id: newWindow.id, viewID: newWindow.viewID })], + ratios: [0.5, 0.5], + }) + } + return node + } + + return Layout.Split.create({ + id: node.id, + direction: node.direction, + children: node.children.map(splitNode), + ratios: node.ratios, + }) + } + + return { + ...root, + root: splitNode(root.root), + focusedID: newWindow.id, + } + } + + export function closeWindow(root: Layout.Root.Info, windowID: string): Layout.Root.Info | null { + const windows = getAllWindows(root) + if (windows.length === 1 && windows[0].id === windowID) { + return null + } + + function removeFromNode(node: Layout.Node): Layout.Node | null { + if (node.type === "window") { + return node.id === windowID ? null : node + } + + const newChildren: Layout.Node[] = [] + const newRatios: number[] = [] + let removedRatio = 0 + + for (let i = 0; i < node.children.length; i++) { + const child = node.children[i] + const result = removeFromNode(child) + if (result !== null) { + newChildren.push(result) + newRatios.push(node.ratios[i]) + } else { + removedRatio = node.ratios[i] + } + } + + if (newChildren.length === 0) return null + if (newChildren.length === 1) return newChildren[0] + + const totalRatio = newRatios.reduce((a, b) => a + b, 0) + const normalizedRatios = newRatios.map((r) => r / totalRatio) + + return Layout.Split.create({ + id: node.id, + direction: node.direction, + children: newChildren, + ratios: normalizedRatios, + }) + } + + const newRoot = removeFromNode(root.root) + if (!newRoot) return null + + const remainingWindows = getAllWindows({ ...root, root: newRoot }) + const newFocusedID = root.focusedID === windowID ? (remainingWindows[0]?.id ?? "") : root.focusedID + + return { + ...root, + root: newRoot, + focusedID: newFocusedID, + } + } + + export function focusDirection( + root: Layout.Root.Info, + direction: "left" | "right" | "up" | "down", + ): Layout.Root.Info { + const windows = getAllWindows(root) + const currentIndex = windows.findIndex((w) => w.id === root.focusedID) + if (currentIndex === -1) return root + + function findParentSplit(node: Layout.Node, targetID: string): Layout.Split.SplitInfo | null { + if (node.type === "window") return null + for (const child of node.children) { + if (child.type === "window" && child.id === targetID) return node + const found = findParentSplit(child, targetID) + if (found) return found + } + return null + } + + function findSiblingInDirection( + split: Layout.Split.SplitInfo, + currentID: string, + dir: "left" | "right" | "up" | "down", + ): string | null { + const isHorizontal = split.direction === "horizontal" + const isVertical = split.direction === "vertical" + + const currentIdx = split.children.findIndex((c) => c.type === "window" && c.id === currentID) + if (currentIdx === -1) return null + + if ((dir === "down" && isHorizontal) || (dir === "right" && isVertical)) { + const next = split.children[currentIdx + 1] + if (next?.type === "window") return next.id + if (next?.type === "split") return getFirstWindow(next) + } + + if ((dir === "up" && isHorizontal) || (dir === "left" && isVertical)) { + const prev = split.children[currentIdx - 1] + if (prev?.type === "window") return prev.id + if (prev?.type === "split") return getLastWindow(prev) + } + + return null + } + + function getFirstWindow(node: Layout.Node): string | null { + if (node.type === "window") return node.id + if (node.children.length === 0) return null + return getFirstWindow(node.children[0]) + } + + function getLastWindow(node: Layout.Node): string | null { + if (node.type === "window") return node.id + if (node.children.length === 0) return null + return getLastWindow(node.children[node.children.length - 1]) + } + + const parent = findParentSplit(root.root, root.focusedID) + if (!parent) return root + + const newFocusedID = findSiblingInDirection(parent, root.focusedID, direction) + if (!newFocusedID) return root + + return { + ...root, + focusedID: newFocusedID, + } + } + + export function resizeWindow( + root: Layout.Root.Info, + windowID: string, + delta: number, + dimension: "width" | "height", + ): Layout.Root.Info { + function resizeInNode(node: Layout.Node): Layout.Node { + if (node.type === "window") return node + + const idx = node.children.findIndex((c) => { + if (c.type === "window") return c.id === windowID + return getAllWindows({ root: c, floats: [], focusedID: "" }).some((w) => w.id === windowID) + }) + + if (idx === -1) { + return Layout.Split.create({ + ...node, + children: node.children.map(resizeInNode), + }) + } + + const isRelevant = + (dimension === "width" && node.direction === "vertical") || + (dimension === "height" && node.direction === "horizontal") + + if (!isRelevant || node.children.length < 2) { + return Layout.Split.create({ + ...node, + children: node.children.map(resizeInNode), + }) + } + + const newRatios = [...node.ratios] + const change = delta * 0.05 + newRatios[idx] = Math.max(0.1, Math.min(0.9, newRatios[idx] + change)) + + const otherIdx = idx === 0 ? 1 : idx - 1 + newRatios[otherIdx] = Math.max(0.1, Math.min(0.9, newRatios[otherIdx] - change)) + + const total = newRatios.reduce((a, b) => a + b, 0) + const normalized = newRatios.map((r) => r / total) + + return Layout.Split.create({ + ...node, + ratios: normalized, + children: node.children.map(resizeInNode), + }) + } + + return { + ...root, + root: resizeInNode(root.root), + } + } + + export function equalizeWindows(root: Layout.Root.Info): Layout.Root.Info { + function equalize(node: Layout.Node): Layout.Node { + if (node.type === "window") return node + + const equalRatio = 1 / node.children.length + return Layout.Split.create({ + ...node, + ratios: node.children.map(() => equalRatio), + children: node.children.map(equalize), + }) + } + + return { + ...root, + root: equalize(root.root), + } + } +} +``` + +**Step 4: Run test to verify it passes** + +Run: `bun test packages/opencode/test/tui/layout/operations.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add packages/opencode/src/cli/cmd/tui/layout/operations.ts packages/opencode/test/tui/layout/operations.test.ts +git commit -m "feat(tui): add layout tree operations for window management" +``` + +--- + +### Task 1.3: Layout Context Provider + +**Files:** + +- Create: `packages/opencode/src/cli/cmd/tui/context/layout.tsx` +- Modify: `packages/opencode/src/cli/cmd/tui/app.tsx` + +**Step 1: Write the layout context** + +```typescript +// packages/opencode/src/cli/cmd/tui/context/layout.tsx +import { createSignal, createMemo } from "solid-js" +import { createStore, produce } from "solid-js/store" +import { createSimpleContext } from "./helper" +import { Layout } from "../layout/types" +import { LayoutOps } from "../layout/operations" + +export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({ + name: "Layout", + init: () => { + const [layout, setLayout] = createStore<Layout.Root.Info>({ + root: Layout.Window.create({ id: "win-main", viewID: "session" }), + floats: [], + focusedID: "win-main", + }) + + const focusedWindow = createMemo(() => LayoutOps.findWindow(layout, layout.focusedID)) + const allWindows = createMemo(() => LayoutOps.getAllWindows(layout)) + + return { + get layout() { + return layout + }, + get focusedWindow() { + return focusedWindow() + }, + get allWindows() { + return allWindows() + }, + + splitHorizontal(viewID: string) { + const newID = LayoutOps.generateID("win") + setLayout( + produce((draft) => { + const result = LayoutOps.splitWindow(draft, draft.focusedID, "horizontal", { + id: newID, + viewID, + }) + Object.assign(draft, result) + }), + ) + return newID + }, + + splitVertical(viewID: string) { + const newID = LayoutOps.generateID("win") + setLayout( + produce((draft) => { + const result = LayoutOps.splitWindow(draft, draft.focusedID, "vertical", { + id: newID, + viewID, + }) + Object.assign(draft, result) + }), + ) + return newID + }, + + closeWindow(windowID?: string) { + const targetID = windowID ?? layout.focusedID + const result = LayoutOps.closeWindow(layout, targetID) + if (result === null) { + return false + } + setLayout(result) + return true + }, + + closeOtherWindows() { + const focused = focusedWindow() + if (!focused) return + setLayout({ + root: focused, + floats: [], + focusedID: focused.id, + }) + }, + + focusWindow(windowID: string) { + setLayout("focusedID", windowID) + }, + + focusDirection(direction: "left" | "right" | "up" | "down") { + setLayout( + produce((draft) => { + const result = LayoutOps.focusDirection(draft, direction) + draft.focusedID = result.focusedID + }), + ) + }, + + resizeWindow(delta: number, dimension: "width" | "height") { + setLayout( + produce((draft) => { + const result = LayoutOps.resizeWindow(draft, draft.focusedID, delta, dimension) + Object.assign(draft, result) + }), + ) + }, + + equalizeWindows() { + setLayout( + produce((draft) => { + const result = LayoutOps.equalizeWindows(draft) + Object.assign(draft, result) + }), + ) + }, + + openFloat(viewID: string, options: { x: number; y: number; width: number; height: number }) { + const id = LayoutOps.generateID("float") + setLayout( + produce((draft) => { + draft.floats.push( + Layout.Float.create({ + id, + viewID, + ...options, + }), + ) + draft.focusedID = id + }), + ) + return id + }, + + closeFloat(floatID: string) { + setLayout( + produce((draft) => { + const idx = draft.floats.findIndex((f) => f.id === floatID) + if (idx !== -1) { + draft.floats.splice(idx, 1) + if (draft.focusedID === floatID) { + const windows = LayoutOps.getAllWindows(draft) + draft.focusedID = windows[0]?.id ?? "" + } + } + }), + ) + }, + + setWindowView(windowID: string, viewID: string) { + setLayout( + produce((draft) => { + function updateNode(node: Layout.Node): void { + if (node.type === "window" && node.id === windowID) { + node.viewID = viewID + return + } + if (node.type === "split") { + node.children.forEach(updateNode) + } + } + updateNode(draft.root) + }), + ) + }, + } + }, +}) +``` + +**Step 2: Verify file compiles** + +Run: `bun typecheck` +Expected: No errors related to layout.tsx + +**Step 3: Commit** + +```bash +git add packages/opencode/src/cli/cmd/tui/context/layout.tsx +git commit -m "feat(tui): add layout context provider for window state management" +``` + +--- + +## Phase 2: View Primitives + +### Task 2.1: View Type Definitions + +**Files:** + +- Create: `packages/opencode/src/cli/cmd/tui/view/types.ts` +- Test: `packages/opencode/test/tui/view/types.test.ts` + +**Step 1: Write the failing test** + +```typescript +// packages/opencode/test/tui/view/types.test.ts +import { describe, expect, test } from "bun:test" +import { View } from "../../../src/cli/cmd/tui/view/types" + +describe("View.Tree", () => { + test("creates tree view data", () => { + const tree = View.Tree.create({ + id: "session-tree", + title: "Sessions", + nodes: [ + { + id: "session-1", + label: "Chat about TypeScript", + icon: "chat", + children: [], + expanded: false, + }, + ], + }) + expect(tree.type).toBe("tree") + expect(tree.nodes).toHaveLength(1) + }) + + test("validates tree node schema", () => { + const result = View.Tree.Node.safeParse({ + id: "node-1", + label: "Test Node", + children: [], + }) + expect(result.success).toBe(true) + }) +}) + +describe("View.List", () => { + test("creates list view data", () => { + const list = View.List.create({ + id: "command-palette", + title: "Commands", + items: [ + { id: "cmd-1", label: "New Session", description: "Create a new chat session" }, + { id: "cmd-2", label: "Switch Model", description: "Change the AI model" }, + ], + searchable: true, + }) + expect(list.type).toBe("list") + expect(list.items).toHaveLength(2) + expect(list.searchable).toBe(true) + }) +}) + +describe("View.Text", () => { + test("creates text view data", () => { + const text = View.Text.create({ + id: "help-view", + title: "Help", + content: "# OpenCode Help\n\nWelcome to OpenCode!", + filetype: "markdown", + }) + expect(text.type).toBe("text") + expect(text.filetype).toBe("markdown") + }) +}) + +describe("View.Form", () => { + test("creates form view data", () => { + const form = View.Form.create({ + id: "settings", + title: "Settings", + fields: [ + { id: "theme", type: "select", label: "Theme", options: ["dark", "light"] }, + { id: "autosave", type: "toggle", label: "Auto-save", value: true }, + ], + }) + expect(form.type).toBe("form") + expect(form.fields).toHaveLength(2) + }) +}) +``` + +**Step 2: Run test to verify it fails** + +Run: `bun test packages/opencode/test/tui/view/types.test.ts` +Expected: FAIL with "Cannot find module" + +**Step 3: Write minimal implementation** + +```typescript +// packages/opencode/src/cli/cmd/tui/view/types.ts +import z from "zod" + +export namespace View { + // Base view info shared by all view types + export const Base = z.object({ + id: z.string(), + title: z.string(), + }) + + // Tree view for hierarchical data (session explorer, file browser) + export namespace Tree { + export const Node: z.ZodType<NodeInfo> = z.lazy(() => + z.object({ + id: z.string(), + label: z.string(), + icon: z.string().optional(), + children: z.array(Node), + expanded: z.boolean().optional().default(false), + metadata: z.record(z.any()).optional(), + }), + ) + + export type NodeInfo = { + id: string + label: string + icon?: string + children: NodeInfo[] + expanded?: boolean + metadata?: Record<string, any> + } + + export const Info = Base.extend({ + type: z.literal("tree"), + nodes: z.array(Node), + selectedID: z.string().optional(), + }) + export type Info = z.output<typeof Info> + + export function create(input: { id: string; title: string; nodes: NodeInfo[]; selectedID?: string }): Info { + return { + type: "tree", + id: input.id, + title: input.title, + nodes: input.nodes, + selectedID: input.selectedID, + } + } + } + + // List view for flat searchable items (command palette, session list) + export namespace List { + export const Item = z.object({ + id: z.string(), + label: z.string(), + description: z.string().optional(), + icon: z.string().optional(), + metadata: z.record(z.any()).optional(), + }) + export type Item = z.output<typeof Item> + + export const Info = Base.extend({ + type: z.literal("list"), + items: z.array(Item), + searchable: z.boolean().optional().default(true), + selectedID: z.string().optional(), + searchQuery: z.string().optional(), + }) + export type Info = z.output<typeof Info> + + export function create(input: { + id: string + title: string + items: Item[] + searchable?: boolean + selectedID?: string + }): Info { + return { + type: "list", + id: input.id, + title: input.title, + items: input.items, + searchable: input.searchable ?? true, + selectedID: input.selectedID, + } + } + } + + // Text view for read-only styled content (logs, previews, help) + export namespace Text { + export const Info = Base.extend({ + type: z.literal("text"), + content: z.string(), + filetype: z.string().optional(), + scrollOffset: z.number().optional().default(0), + }) + export type Info = z.output<typeof Info> + + export function create(input: { id: string; title: string; content: string; filetype?: string }): Info { + return { + type: "text", + id: input.id, + title: input.title, + content: input.content, + filetype: input.filetype, + scrollOffset: 0, + } + } + } + + // Form view for settings and input + export namespace Form { + export const Field = z.discriminatedUnion("type", [ + z.object({ + id: z.string(), + type: z.literal("text"), + label: z.string(), + value: z.string().optional(), + placeholder: z.string().optional(), + }), + z.object({ + id: z.string(), + type: z.literal("toggle"), + label: z.string(), + value: z.boolean().optional(), + }), + z.object({ + id: z.string(), + type: z.literal("select"), + label: z.string(), + options: z.array(z.string()), + value: z.string().optional(), + }), + z.object({ + id: z.string(), + type: z.literal("number"), + label: z.string(), + value: z.number().optional(), + min: z.number().optional(), + max: z.number().optional(), + }), + ]) + export type Field = z.output<typeof Field> + + export const Info = Base.extend({ + type: z.literal("form"), + fields: z.array(Field), + focusedFieldID: z.string().optional(), + }) + export type Info = z.output<typeof Info> + + export function create(input: { id: string; title: string; fields: Field[] }): Info { + return { + type: "form", + id: input.id, + title: input.title, + fields: input.fields, + } + } + } + + // Union of all view types + export type Info = Tree.Info | List.Info | Text.Info | Form.Info + + // Built-in view identifiers (not replaceable by plugins) + export const BuiltIn = z.enum(["session", "home"]) + export type BuiltIn = z.infer<typeof BuiltIn> +} +``` + +**Step 4: Run test to verify it passes** + +Run: `bun test packages/opencode/test/tui/view/types.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add packages/opencode/src/cli/cmd/tui/view/types.ts packages/opencode/test/tui/view/types.test.ts +git commit -m "feat(tui): add view type definitions for tree, list, text, and form primitives" +``` + +--- + +### Task 2.2: View Registry + +**Files:** + +- Create: `packages/opencode/src/cli/cmd/tui/view/registry.ts` +- Test: `packages/opencode/test/tui/view/registry.test.ts` + +**Step 1: Write the failing test** + +```typescript +// packages/opencode/test/tui/view/registry.test.ts +import { describe, expect, test, beforeEach } from "bun:test" +import { ViewRegistry } from "../../../src/cli/cmd/tui/view/registry" +import { View } from "../../../src/cli/cmd/tui/view/types" + +describe("ViewRegistry", () => { + beforeEach(() => { + ViewRegistry.clear() + }) + + test("registers and retrieves a view", () => { + const treeView = View.Tree.create({ + id: "test-tree", + title: "Test Tree", + nodes: [], + }) + + ViewRegistry.register("test-tree", treeView) + const retrieved = ViewRegistry.get("test-tree") + + expect(retrieved).toBeDefined() + expect(retrieved?.id).toBe("test-tree") + expect(retrieved?.type).toBe("tree") + }) + + test("updates an existing view", () => { + const initial = View.List.create({ + id: "test-list", + title: "Test List", + items: [{ id: "item-1", label: "Item 1" }], + }) + + ViewRegistry.register("test-list", initial) + + const updated = View.List.create({ + id: "test-list", + title: "Test List", + items: [ + { id: "item-1", label: "Item 1" }, + { id: "item-2", label: "Item 2" }, + ], + }) + + ViewRegistry.register("test-list", updated) + const retrieved = ViewRegistry.get("test-list") as View.List.Info + + expect(retrieved?.items).toHaveLength(2) + }) + + test("unregisters a view", () => { + ViewRegistry.register( + "temp-view", + View.Text.create({ + id: "temp-view", + title: "Temp", + content: "test", + }), + ) + + expect(ViewRegistry.get("temp-view")).toBeDefined() + + ViewRegistry.unregister("temp-view") + + expect(ViewRegistry.get("temp-view")).toBeUndefined() + }) + + test("lists all registered views", () => { + ViewRegistry.register("view-1", View.Text.create({ id: "view-1", title: "View 1", content: "" })) + ViewRegistry.register("view-2", View.Text.create({ id: "view-2", title: "View 2", content: "" })) + + const all = ViewRegistry.list() + expect(all).toHaveLength(2) + }) + + test("subscribes to view changes", () => { + let changeCount = 0 + const unsub = ViewRegistry.subscribe("watched-view", () => { + changeCount++ + }) + + ViewRegistry.register("watched-view", View.Text.create({ id: "watched-view", title: "Watched", content: "v1" })) + expect(changeCount).toBe(1) + + ViewRegistry.register("watched-view", View.Text.create({ id: "watched-view", title: "Watched", content: "v2" })) + expect(changeCount).toBe(2) + + unsub() + + ViewRegistry.register("watched-view", View.Text.create({ id: "watched-view", title: "Watched", content: "v3" })) + expect(changeCount).toBe(2) + }) +}) +``` + +**Step 2: Run test to verify it fails** + +Run: `bun test packages/opencode/test/tui/view/registry.test.ts` +Expected: FAIL with "Cannot find module" + +**Step 3: Write minimal implementation** + +```typescript +// packages/opencode/src/cli/cmd/tui/view/registry.ts +import { View } from "./types" + +type ViewChangeCallback = (view: View.Info) => void + +const views = new Map<string, View.Info>() +const subscribers = new Map<string, Set<ViewChangeCallback>>() + +export namespace ViewRegistry { + export function register(id: string, view: View.Info): void { + views.set(id, view) + notifySubscribers(id, view) + } + + export function get(id: string): View.Info | undefined { + return views.get(id) + } + + export function unregister(id: string): void { + views.delete(id) + } + + export function list(): View.Info[] { + return Array.from(views.values()) + } + + export function clear(): void { + views.clear() + subscribers.clear() + } + + export function subscribe(id: string, callback: ViewChangeCallback): () => void { + if (!subscribers.has(id)) { + subscribers.set(id, new Set()) + } + subscribers.get(id)!.add(callback) + + return () => { + subscribers.get(id)?.delete(callback) + } + } + + function notifySubscribers(id: string, view: View.Info): void { + const subs = subscribers.get(id) + if (subs) { + for (const callback of subs) { + callback(view) + } + } + } +} +``` + +**Step 4: Run test to verify it passes** + +Run: `bun test packages/opencode/test/tui/view/registry.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add packages/opencode/src/cli/cmd/tui/view/registry.ts packages/opencode/test/tui/view/registry.test.ts +git commit -m "feat(tui): add view registry for managing plugin-provided views" +``` + +--- + +## Phase 3: Window Commands + +### Task 3.1: Window Command Keybinds + +**Files:** + +- Modify: `packages/opencode/src/config/config.ts` (add window keybinds to Keybinds schema) + +**Step 1: Add window keybinds to config schema** + +Add these fields to the `Keybinds` schema in `packages/opencode/src/config/config.ts` after line 576 (before the `.strict()`): + +```typescript +// packages/opencode/src/config/config.ts +// Add to Keybinds schema around line 576 + + // Window commands (Vim-style with <C-w> prefix) + window_focus_left: z.string().optional().default("ctrl+w h").describe("Focus window to the left"), + window_focus_down: z.string().optional().default("ctrl+w j").describe("Focus window below"), + window_focus_up: z.string().optional().default("ctrl+w k").describe("Focus window above"), + window_focus_right: z.string().optional().default("ctrl+w l").describe("Focus window to the right"), + window_split_horizontal: z.string().optional().default("ctrl+w s").describe("Split window horizontally"), + window_split_vertical: z.string().optional().default("ctrl+w v").describe("Split window vertically"), + window_close: z.string().optional().default("ctrl+w c").describe("Close current window"), + window_close_others: z.string().optional().default("ctrl+w o").describe("Close all other windows"), + window_equalize: z.string().optional().default("ctrl+w =").describe("Equalize window sizes"), + window_increase_height: z.string().optional().default("ctrl+w +").describe("Increase window height"), + window_decrease_height: z.string().optional().default("ctrl+w -").describe("Decrease window height"), + window_increase_width: z.string().optional().default("ctrl+w >").describe("Increase window width"), + window_decrease_width: z.string().optional().default("ctrl+w <").describe("Decrease window width"), +``` + +**Step 2: Verify config compiles** + +Run: `bun typecheck` +Expected: No errors + +**Step 3: Commit** + +```bash +git add packages/opencode/src/config/config.ts +git commit -m "feat(config): add window command keybinds with Vim-style defaults" +``` + +--- + +### Task 3.2: Window Command Handler + +**Files:** + +- Create: `packages/opencode/src/cli/cmd/tui/context/window-commands.tsx` + +**Step 1: Write the window command handler** + +```typescript +// packages/opencode/src/cli/cmd/tui/context/window-commands.tsx +import { useKeyboard } from "@opentui/solid" +import { createSimpleContext } from "./helper" +import { useKeybind } from "./keybind" +import { useLayout } from "./layout" +import { useExit } from "./exit" + +export const { use: useWindowCommands, provider: WindowCommandsProvider } = createSimpleContext({ + name: "WindowCommands", + init: () => { + const keybind = useKeybind() + const layout = useLayout() + const exit = useExit() + + useKeyboard((evt) => { + // Focus navigation + if (keybind.match("window_focus_left", evt)) { + layout.focusDirection("left") + return + } + if (keybind.match("window_focus_down", evt)) { + layout.focusDirection("down") + return + } + if (keybind.match("window_focus_up", evt)) { + layout.focusDirection("up") + return + } + if (keybind.match("window_focus_right", evt)) { + layout.focusDirection("right") + return + } + + // Split commands + if (keybind.match("window_split_horizontal", evt)) { + const focused = layout.focusedWindow + if (focused) { + layout.splitHorizontal(focused.viewID) + } + return + } + if (keybind.match("window_split_vertical", evt)) { + const focused = layout.focusedWindow + if (focused) { + layout.splitVertical(focused.viewID) + } + return + } + + // Close commands + if (keybind.match("window_close", evt)) { + const closed = layout.closeWindow() + if (!closed) { + exit() + } + return + } + if (keybind.match("window_close_others", evt)) { + layout.closeOtherWindows() + return + } + + // Resize commands + if (keybind.match("window_equalize", evt)) { + layout.equalizeWindows() + return + } + if (keybind.match("window_increase_height", evt)) { + layout.resizeWindow(1, "height") + return + } + if (keybind.match("window_decrease_height", evt)) { + layout.resizeWindow(-1, "height") + return + } + if (keybind.match("window_increase_width", evt)) { + layout.resizeWindow(1, "width") + return + } + if (keybind.match("window_decrease_width", evt)) { + layout.resizeWindow(-1, "width") + return + } + }) + + return {} + }, +}) +``` + +**Step 2: Verify file compiles** + +Run: `bun typecheck` +Expected: No errors + +**Step 3: Commit** + +```bash +git add packages/opencode/src/cli/cmd/tui/context/window-commands.tsx +git commit -m "feat(tui): add window command handler for Vim-style keybinds" +``` + +--- + +## Phase 4: TUI Config Extensions + +### Task 4.1: Component Visibility Config + +**Files:** + +- Modify: `packages/opencode/src/config/config.ts` (extend TUI schema) + +**Step 1: Extend TUI config schema** + +Replace the `TUI` schema in `packages/opencode/src/config/config.ts` (around line 584-596): + +```typescript +// packages/opencode/src/config/config.ts +// Replace the TUI schema definition + +export const TUI = z.object({ + scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"), + scroll_acceleration: z + .object({ + enabled: z.boolean().describe("Enable scroll acceleration"), + }) + .optional() + .describe("Scroll acceleration settings"), + diff_style: z + .enum(["auto", "stacked"]) + .optional() + .describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"), + + // Component-specific settings + messages: z + .object({ + padding: z.number().int().min(0).optional().describe("Padding around messages"), + gap: z.number().int().min(0).optional().describe("Gap between messages"), + }) + .optional() + .describe("Message display settings"), + + sidebar: z + .object({ + padding: z.number().int().min(0).optional().describe("Padding inside sidebar"), + width: z.number().int().min(10).optional().describe("Sidebar width in characters"), + visible: z.boolean().optional().describe("Show sidebar by default"), + }) + .optional() + .describe("Sidebar settings"), + + header: z + .object({ + padding: z.number().int().min(0).optional().describe("Padding inside header"), + visible: z.boolean().optional().describe("Show header"), + show_title: z.boolean().optional().describe("Show session title"), + show_context: z.boolean().optional().describe("Show context info"), + show_cost: z.boolean().optional().describe("Show cost information"), + show_tokens: z.boolean().optional().describe("Show token count"), + }) + .optional() + .describe("Header settings"), + + footer: z + .object({ + padding: z.number().int().min(0).optional().describe("Padding inside footer"), + visible: z.boolean().optional().describe("Show footer"), + show_directory: z.boolean().optional().describe("Show current directory"), + show_lsp_status: z.boolean().optional().describe("Show LSP status"), + show_mcp_status: z.boolean().optional().describe("Show MCP status"), + show_version: z.boolean().optional().describe("Show version"), + show_keybind_hints: z.boolean().optional().describe("Show keybind hints"), + }) + .optional() + .describe("Footer settings"), + + prompt: z + .object({ + padding: z.number().int().min(0).optional().describe("Padding around prompt"), + }) + .optional() + .describe("Prompt settings"), + + window: z + .object({ + padding: z.number().int().min(0).optional().describe("Padding inside windows"), + border: z.boolean().optional().describe("Show window borders"), + }) + .optional() + .describe("Window settings"), +}) +``` + +**Step 2: Verify config compiles** + +Run: `bun typecheck` +Expected: No errors + +**Step 3: Commit** + +```bash +git add packages/opencode/src/config/config.ts +git commit -m "feat(config): add component visibility and spacing options to TUI config" +``` + +--- + +## Phase 5: Plugin API Extensions + +### Task 5.1: Window API Types + +**Files:** + +- Modify: `packages/plugin/src/index.ts` (add window API types) + +**Step 1: Add window API types to plugin interface** + +Add these types to `packages/plugin/src/index.ts` after the existing type definitions (around line 200): + +```typescript +// packages/plugin/src/index.ts +// Add after existing types, before the closing of the file + +// View primitive types for plugins +export type TreeNode = { + id: string + label: string + icon?: string + children: TreeNode[] + expanded?: boolean + metadata?: Record<string, any> +} + +export type TreeView = { + type: "tree" + id: string + title: string + nodes: TreeNode[] + selectedID?: string +} + +export type ListItem = { + id: string + label: string + description?: string + icon?: string + metadata?: Record<string, any> +} + +export type ListView = { + type: "list" + id: string + title: string + items: ListItem[] + searchable?: boolean + selectedID?: string +} + +export type TextView = { + type: "text" + id: string + title: string + content: string + filetype?: string +} + +export type FormField = + | { id: string; type: "text"; label: string; value?: string; placeholder?: string } + | { id: string; type: "toggle"; label: string; value?: boolean } + | { id: string; type: "select"; label: string; options: string[]; value?: string } + | { id: string; type: "number"; label: string; value?: number; min?: number; max?: number } + +export type FormView = { + type: "form" + id: string + title: string + fields: FormField[] +} + +export type PluginView = TreeView | ListView | TextView | FormView + +// Window API for plugins +export type WindowAPI = { + // Window operations + createSplit(options: { direction: "horizontal" | "vertical"; size?: number; viewID: string }): string + closeWindow(windowID?: string): boolean + focusWindow(windowID: string): void + getCurrentWindow(): { id: string; viewID: string } | undefined + getAllWindows(): Array<{ id: string; viewID: string }> + + // View operations + registerView(view: PluginView): void + updateView(viewID: string, view: Partial<PluginView>): void + unregisterView(viewID: string): void + + // Float operations + openFloat(options: { viewID: string; x?: number; y?: number; width: number; height: number }): string + closeFloat(floatID: string): void +} + +// Keybind registration for plugins +export type KeybindAPI = { + register(options: { key: string; description: string; scope?: "global" | "window"; handler: () => void }): () => void +} + +// Extended plugin input with window API +export type PluginInputWithWindow = PluginInput & { + window: WindowAPI + keybind: KeybindAPI +} + +// Extended hooks with window events +export interface WindowHooks { + "window.focused"?: (input: { windowID: string; viewID: string }) => Promise<void> + "window.closed"?: (input: { windowID: string }) => Promise<void> + "view.action"?: (input: { + viewID: string + action: string + itemID?: string + data?: Record<string, any> + }) => Promise<void> +} +``` + +**Step 2: Verify plugin types compile** + +Run: `bun typecheck` +Expected: No errors + +**Step 3: Commit** + +```bash +git add packages/plugin/src/index.ts +git commit -m "feat(plugin): add window and view API types for plugin system" +``` + +--- + +## Phase 6: Layout Renderer + +### Task 6.1: Layout Renderer Component + +**Files:** + +- Create: `packages/opencode/src/cli/cmd/tui/layout/renderer.tsx` + +**Step 1: Write the layout renderer** + +```typescript +// packages/opencode/src/cli/cmd/tui/layout/renderer.tsx +import { For, Match, Show, Switch, createMemo, type Component } from "solid-js" +import { Dynamic } from "solid-js/web" +import { useTerminalDimensions } from "@opentui/solid" +import { useTheme } from "../context/theme" +import { useLayout } from "../context/layout" +import { Layout } from "./types" +import { ViewRegistry } from "../view/registry" +import { View } from "../view/types" + +// Built-in view components +import { Session } from "../routes/session" +import { Home } from "../routes/home" + +// View component registry +const VIEW_COMPONENTS: Record<string, Component<{ view?: View.Info }>> = { + session: () => <Session />, + home: () => <Home />, +} + +// Generic view renderers for plugin views +const TreeViewRenderer: Component<{ view: View.Tree.Info }> = (props) => { + const { theme } = useTheme() + + function renderNode(node: View.Tree.NodeInfo, depth: number) { + const indent = " ".repeat(depth) + const icon = node.children.length > 0 ? (node.expanded ? "▼" : "▶") : " " + + return ( + <> + <text fg={props.view.selectedID === node.id ? theme.accent : theme.text}> + {indent} + {icon} {node.icon ? `${node.icon} ` : ""} + {node.label} + </text> + <Show when={node.expanded}> + <For each={node.children}>{(child) => renderNode(child, depth + 1)}</For> + </Show> + </> + ) + } + + return ( + <box flexDirection="column"> + <text fg={theme.text} bold> + {props.view.title} + </text> + <For each={props.view.nodes}>{(node) => renderNode(node, 0)}</For> + </box> + ) +} + +const ListViewRenderer: Component<{ view: View.List.Info }> = (props) => { + const { theme } = useTheme() + + return ( + <box flexDirection="column"> + <text fg={theme.text} bold> + {props.view.title} + </text> + <Show when={props.view.searchable}> + <text fg={theme.textMuted}>Search: {props.view.searchQuery ?? ""}</text> + </Show> + <For each={props.view.items}> + {(item) => ( + <text fg={props.view.selectedID === item.id ? theme.accent : theme.text}> + {item.icon ? `${item.icon} ` : ""} + {item.label} + <Show when={item.description}> + <span style={{ fg: theme.textMuted }}> - {item.description}</span> + </Show> + </text> + )} + </For> + </box> + ) +} + +const TextViewRenderer: Component<{ view: View.Text.Info }> = (props) => { + const { theme, syntax } = useTheme() + + return ( + <box flexDirection="column"> + <text fg={theme.text} bold> + {props.view.title} + </text> + <Show + when={props.view.filetype} + fallback={<text fg={theme.text}>{props.view.content}</text>} + > + <code + filetype={props.view.filetype} + syntaxStyle={syntax()} + content={props.view.content} + fg={theme.text} + /> + </Show> + </box> + ) +} + +const FormViewRenderer: Component<{ view: View.Form.Info }> = (props) => { + const { theme } = useTheme() + + return ( + <box flexDirection="column"> + <text fg={theme.text} bold> + {props.view.title} + </text> + <For each={props.view.fields}> + {(field) => ( + <box flexDirection="row" gap={1}> + <text fg={theme.text}>{field.label}:</text> + <Switch> + <Match when={field.type === "text"}> + <text fg={theme.textMuted}>[{(field as any).value ?? (field as any).placeholder ?? ""}]</text> + </Match> + <Match when={field.type === "toggle"}> + <text fg={theme.accent}>{(field as any).value ? "[x]" : "[ ]"}</text> + </Match> + <Match when={field.type === "select"}> + <text fg={theme.textMuted}>[{(field as any).value ?? "select..."}]</text> + </Match> + <Match when={field.type === "number"}> + <text fg={theme.textMuted}>[{(field as any).value ?? 0}]</text> + </Match> + </Switch> + </box> + )} + </For> + </box> + ) +} + +// View renderer that dispatches to appropriate component +const ViewRenderer: Component<{ viewID: string }> = (props) => { + const view = createMemo(() => ViewRegistry.get(props.viewID)) + + return ( + <Switch> + <Match when={VIEW_COMPONENTS[props.viewID]}> + <Dynamic component={VIEW_COMPONENTS[props.viewID]} /> + </Match> + <Match when={view()?.type === "tree"}> + <TreeViewRenderer view={view() as View.Tree.Info} /> + </Match> + <Match when={view()?.type === "list"}> + <ListViewRenderer view={view() as View.List.Info} /> + </Match> + <Match when={view()?.type === "text"}> + <TextViewRenderer view={view() as View.Text.Info} /> + </Match> + <Match when={view()?.type === "form"}> + <FormViewRenderer view={view() as View.Form.Info} /> + </Match> + </Switch> + ) +} + +// Window renderer +const WindowRenderer: Component<{ + window: Layout.Window.Info + width: number + height: number +}> = (props) => { + const { theme } = useTheme() + const layout = useLayout() + const focused = createMemo(() => layout.layout.focusedID === props.window.id) + + return ( + <box + width={props.width} + height={props.height} + border={focused() ? ["left", "right", "top", "bottom"] : undefined} + borderColor={focused() ? theme.borderActive : theme.border} + > + <ViewRenderer viewID={props.window.viewID} /> + </box> + ) +} + +// Split renderer +const SplitRenderer: Component<{ + split: Layout.Split.SplitInfo + width: number + height: number +}> = (props) => { + const isHorizontal = () => props.split.direction === "horizontal" + + const childDimensions = createMemo(() => { + return props.split.children.map((_, i) => { + const ratio = props.split.ratios[i] ?? 1 / props.split.children.length + if (isHorizontal()) { + return { width: props.width, height: Math.floor(props.height * ratio) } + } + return { width: Math.floor(props.width * ratio), height: props.height } + }) + }) + + return ( + <box flexDirection={isHorizontal() ? "column" : "row"} width={props.width} height={props.height}> + <For each={props.split.children}> + {(child, i) => ( + <Switch> + <Match when={child.type === "window"}> + <WindowRenderer + window={child as Layout.Window.Info} + width={childDimensions()[i()].width} + height={childDimensions()[i()].height} + /> + </Match> + <Match when={child.type === "split"}> + <SplitRenderer + split={child as Layout.Split.SplitInfo} + width={childDimensions()[i()].width} + height={childDimensions()[i()].height} + /> + </Match> + </Switch> + )} + </For> + </box> + ) +} + +// Float renderer +const FloatRenderer: Component<{ float: Layout.Float.Info }> = (props) => { + const { theme } = useTheme() + const layout = useLayout() + const focused = createMemo(() => layout.layout.focusedID === props.float.id) + + return ( + <box + position="absolute" + left={props.float.x} + top={props.float.y} + width={props.float.width} + height={props.float.height} + border={["left", "right", "top", "bottom"]} + borderColor={focused() ? theme.borderActive : theme.border} + backgroundColor={theme.background} + zIndex={100} + > + <ViewRenderer viewID={props.float.viewID} /> + </box> + ) +} + +// Main layout renderer +export const LayoutRenderer: Component = () => { + const dimensions = useTerminalDimensions() + const layout = useLayout() + const { theme } = useTheme() + + return ( + <box width={dimensions().width} height={dimensions().height} backgroundColor={theme.background}> + <Switch> + <Match when={layout.layout.root.type === "window"}> + <WindowRenderer + window={layout.layout.root as Layout.Window.Info} + width={dimensions().width} + height={dimensions().height} + /> + </Match> + <Match when={layout.layout.root.type === "split"}> + <SplitRenderer + split={layout.layout.root as Layout.Split.SplitInfo} + width={dimensions().width} + height={dimensions().height} + /> + </Match> + </Switch> + <For each={layout.layout.floats}>{(float) => <FloatRenderer float={float} />}</For> + </box> + ) +} +``` + +**Step 2: Verify file compiles** + +Run: `bun typecheck` +Expected: No errors + +**Step 3: Commit** + +```bash +git add packages/opencode/src/cli/cmd/tui/layout/renderer.tsx +git commit -m "feat(tui): add layout renderer component for window system" +``` + +--- + +## Phase 7: Integration + +### Task 7.1: Wire Up Layout System to App + +**Files:** + +- Modify: `packages/opencode/src/cli/cmd/tui/app.tsx` + +**Step 1: Add layout providers to app** + +Modify `packages/opencode/src/cli/cmd/tui/app.tsx` to include the layout system. Add imports at the top: + +```typescript +// Add these imports near the top of the file (around line 22) +import { LayoutProvider } from "@tui/context/layout" +import { WindowCommandsProvider } from "@tui/context/window-commands" +``` + +Then wrap the App component with the new providers. Find the provider chain (around line 123-134) and add: + +```typescript +// Modify the provider chain to include LayoutProvider and WindowCommandsProvider +// Insert after KeybindProvider and before PromptStashProvider + +<KeybindProvider> + <LayoutProvider> + <WindowCommandsProvider> + <PromptStashProvider> + {/* ... rest of providers ... */} + </PromptStashProvider> + </WindowCommandsProvider> + </LayoutProvider> +</KeybindProvider> +``` + +**Step 2: Verify app compiles** + +Run: `bun typecheck` +Expected: No errors + +**Step 3: Test manually** + +Run: `bun dev` in `packages/opencode` +Expected: TUI launches without errors + +**Step 4: Commit** + +```bash +git add packages/opencode/src/cli/cmd/tui/app.tsx +git commit -m "feat(tui): integrate layout system into app" +``` + +--- + +### Task 7.2: Create Index Files + +**Files:** + +- Create: `packages/opencode/src/cli/cmd/tui/layout/index.ts` +- Create: `packages/opencode/src/cli/cmd/tui/view/index.ts` + +**Step 1: Create layout index** + +```typescript +// packages/opencode/src/cli/cmd/tui/layout/index.ts +export { Layout } from "./types" +export { LayoutOps } from "./operations" +export { LayoutRenderer } from "./renderer" +``` + +**Step 2: Create view index** + +```typescript +// packages/opencode/src/cli/cmd/tui/view/index.ts +export { View } from "./types" +export { ViewRegistry } from "./registry" +``` + +**Step 3: Commit** + +```bash +git add packages/opencode/src/cli/cmd/tui/layout/index.ts packages/opencode/src/cli/cmd/tui/view/index.ts +git commit -m "chore(tui): add index files for layout and view modules" +``` + +--- + +## Summary + +This implementation plan covers: + +1. **Phase 1**: Core layout primitives (types, operations, context) +2. **Phase 2**: View primitives (tree, list, text, form types and registry) +3. **Phase 3**: Window commands (keybinds and handler) +4. **Phase 4**: TUI config extensions (component visibility and spacing) +5. **Phase 5**: Plugin API extensions (window and view APIs) +6. **Phase 6**: Layout renderer (SolidJS components) +7. **Phase 7**: Integration (wiring everything together) + +Each task follows TDD with: + +- Failing test first +- Minimal implementation +- Verification +- Commit + +Total estimated time: ~4-6 hours for an engineer with zero codebase context. diff --git a/thoughts/shared/plans/2025-12-30-layout-integration-plan.md b/thoughts/shared/plans/2025-12-30-layout-integration-plan.md new file mode 100644 index 00000000000..e8f36175496 --- /dev/null +++ b/thoughts/shared/plans/2025-12-30-layout-integration-plan.md @@ -0,0 +1,1500 @@ +# TUI Window System Integration Plan + +**Goal:** Integrate the existing layout system into the OpenCode TUI so windows can be split, navigated, and each window can display a view. + +**Architecture:** The App component will render through a LayoutProvider and LayoutRenderer instead of directly rendering routes. The routing system will be adapted to work with windows - navigating to a session will update the focused window's view. Window commands (split, navigate, close) will be handled via keybinds. + +**Design:** [thoughts/shared/designs/2025-12-29-tui-window-system-design.md](../designs/2025-12-29-tui-window-system-design.md) + +--- + +## Phase 1: Layout Context and Operations + +### Task 1.1: Create Layout Operations Module + +**Files:** + +- Create: `packages/opencode/src/cli/cmd/tui/layout/operations.ts` +- Test: `packages/opencode/test/tui/layout/operations.test.ts` + +**Step 1: Write the failing test** + +```typescript +// packages/opencode/test/tui/layout/operations.test.ts +import { describe, expect, test } from "bun:test" +import { Layout } from "../../../src/cli/cmd/tui/layout/types" +import { LayoutOperations } from "../../../src/cli/cmd/tui/layout/operations" + +describe("LayoutOperations.createInitial", () => { + test("creates single window layout with home view", () => { + const layout = LayoutOperations.createInitial("home") + expect(layout.root.type).toBe("window") + expect((layout.root as Layout.Window.Info).viewID).toBe("home") + expect(layout.focusedID).toBe((layout.root as Layout.Window.Info).id) + expect(layout.floats).toEqual([]) + }) +}) + +describe("LayoutOperations.splitWindow", () => { + test("splits focused window vertically", () => { + const initial = LayoutOperations.createInitial("session") + const windowID = (initial.root as Layout.Window.Info).id + const result = LayoutOperations.splitWindow(initial, windowID, "vertical", "home") + + expect(result.root.type).toBe("split") + const split = result.root as Layout.Split.SplitInfo + expect(split.direction).toBe("vertical") + expect(split.children).toHaveLength(2) + expect(split.ratios).toEqual([0.5, 0.5]) + }) + + test("splits focused window horizontally", () => { + const initial = LayoutOperations.createInitial("session") + const windowID = (initial.root as Layout.Window.Info).id + const result = LayoutOperations.splitWindow(initial, windowID, "horizontal", "home") + + expect(result.root.type).toBe("split") + const split = result.root as Layout.Split.SplitInfo + expect(split.direction).toBe("horizontal") + }) + + test("focuses the new window after split", () => { + const initial = LayoutOperations.createInitial("session") + const windowID = (initial.root as Layout.Window.Info).id + const result = LayoutOperations.splitWindow(initial, windowID, "vertical", "home") + + const split = result.root as Layout.Split.SplitInfo + const newWindow = split.children[1] as Layout.Window.Info + expect(result.focusedID).toBe(newWindow.id) + }) +}) + +describe("LayoutOperations.closeWindow", () => { + test("returns undefined when closing last window", () => { + const initial = LayoutOperations.createInitial("session") + const windowID = (initial.root as Layout.Window.Info).id + const result = LayoutOperations.closeWindow(initial, windowID) + + expect(result).toBeUndefined() + }) + + test("removes window from split and collapses if one child remains", () => { + const initial = LayoutOperations.createInitial("session") + const windowID = (initial.root as Layout.Window.Info).id + const withSplit = LayoutOperations.splitWindow(initial, windowID, "vertical", "home") + + const split = withSplit.root as Layout.Split.SplitInfo + const newWindowID = (split.children[1] as Layout.Window.Info).id + const result = LayoutOperations.closeWindow(withSplit, newWindowID) + + expect(result).toBeDefined() + expect(result!.root.type).toBe("window") + }) + + test("focuses sibling window after close", () => { + const initial = LayoutOperations.createInitial("session") + const windowID = (initial.root as Layout.Window.Info).id + const withSplit = LayoutOperations.splitWindow(initial, windowID, "vertical", "home") + + const split = withSplit.root as Layout.Split.SplitInfo + const originalWindow = split.children[0] as Layout.Window.Info + const newWindowID = (split.children[1] as Layout.Window.Info).id + const result = LayoutOperations.closeWindow(withSplit, newWindowID) + + expect(result!.focusedID).toBe(originalWindow.id) + }) +}) + +describe("LayoutOperations.focusDirection", () => { + test("focuses window to the right in vertical split", () => { + const initial = LayoutOperations.createInitial("session") + const windowID = (initial.root as Layout.Window.Info).id + const withSplit = LayoutOperations.splitWindow(initial, windowID, "vertical", "home") + + // Focus is on the new (right) window, navigate left + const split = withSplit.root as Layout.Split.SplitInfo + const leftWindow = split.children[0] as Layout.Window.Info + const result = LayoutOperations.focusDirection(withSplit, "left") + + expect(result.focusedID).toBe(leftWindow.id) + }) + + test("returns same layout if no window in direction", () => { + const initial = LayoutOperations.createInitial("session") + const result = LayoutOperations.focusDirection(initial, "left") + + expect(result.focusedID).toBe(initial.focusedID) + }) +}) + +describe("LayoutOperations.updateWindowView", () => { + test("updates view of specified window", () => { + const initial = LayoutOperations.createInitial("home") + const windowID = (initial.root as Layout.Window.Info).id + const result = LayoutOperations.updateWindowView(initial, windowID, "session:abc123") + + expect((result.root as Layout.Window.Info).viewID).toBe("session:abc123") + }) +}) + +describe("LayoutOperations.findWindow", () => { + test("finds window by id in single window layout", () => { + const initial = LayoutOperations.createInitial("session") + const windowID = (initial.root as Layout.Window.Info).id + const found = LayoutOperations.findWindow(initial, windowID) + + expect(found).toBeDefined() + expect(found!.id).toBe(windowID) + }) + + test("finds window by id in nested split", () => { + const initial = LayoutOperations.createInitial("session") + const windowID = (initial.root as Layout.Window.Info).id + const withSplit = LayoutOperations.splitWindow(initial, windowID, "vertical", "home") + + const split = withSplit.root as Layout.Split.SplitInfo + const newWindowID = (split.children[1] as Layout.Window.Info).id + const found = LayoutOperations.findWindow(withSplit, newWindowID) + + expect(found).toBeDefined() + expect(found!.id).toBe(newWindowID) + }) +}) + +describe("LayoutOperations.getAllWindows", () => { + test("returns all windows in layout", () => { + const initial = LayoutOperations.createInitial("session") + const windowID = (initial.root as Layout.Window.Info).id + const withSplit = LayoutOperations.splitWindow(initial, windowID, "vertical", "home") + + const windows = LayoutOperations.getAllWindows(withSplit) + expect(windows).toHaveLength(2) + }) +}) +``` + +**Step 2: Run test to verify it fails** + +Run: `bun test packages/opencode/test/tui/layout/operations.test.ts` +Expected: FAIL with "Cannot find module" or "LayoutOperations is not defined" + +**Step 3: Write minimal implementation** + +```typescript +// packages/opencode/src/cli/cmd/tui/layout/operations.ts +import { Layout } from "./types" + +export namespace LayoutOperations { + function generateID(): string { + return Math.random().toString(36).substring(2, 10) + } + + export function createInitial(viewID: string): Layout.Root.Info { + const windowID = `win-${generateID()}` + return Layout.Root.create({ + root: Layout.Window.create({ + id: windowID, + viewID, + focused: true, + }), + floats: [], + focusedID: windowID, + }) + } + + export function splitWindow( + layout: Layout.Root.Info, + windowID: string, + direction: "horizontal" | "vertical", + newViewID: string, + ): Layout.Root.Info { + const newWindowID = `win-${generateID()}` + const newWindow = Layout.Window.create({ + id: newWindowID, + viewID: newViewID, + focused: true, + }) + + function splitNode(node: Layout.Node): Layout.Node { + if (node.type === "window" && node.id === windowID) { + return Layout.Split.create({ + id: `split-${generateID()}`, + direction, + children: [{ ...node, focused: false }, newWindow], + ratios: [0.5, 0.5], + }) + } + if (node.type === "split") { + return { + ...node, + children: node.children.map(splitNode), + } + } + return node + } + + return { + ...layout, + root: splitNode(layout.root), + focusedID: newWindowID, + } + } + + export function closeWindow(layout: Layout.Root.Info, windowID: string): Layout.Root.Info | undefined { + const windows = getAllWindows(layout) + if (windows.length <= 1) return undefined + + function removeFromNode(node: Layout.Node): Layout.Node | undefined { + if (node.type === "window") { + return node.id === windowID ? undefined : node + } + if (node.type === "split") { + const remaining = node.children.map(removeFromNode).filter((n): n is Layout.Node => n !== undefined) + + if (remaining.length === 0) return undefined + if (remaining.length === 1) return remaining[0] + return { + ...node, + children: remaining, + ratios: remaining.map(() => 1 / remaining.length), + } + } + return node + } + + const newRoot = removeFromNode(layout.root) + if (!newRoot) return undefined + + const remainingWindows = getAllWindowsFromNode(newRoot) + const newFocusedID = + layout.focusedID === windowID ? (remainingWindows[0]?.id ?? layout.focusedID) : layout.focusedID + + return { + ...layout, + root: newRoot, + focusedID: newFocusedID, + } + } + + export function focusDirection( + layout: Layout.Root.Info, + direction: "left" | "right" | "up" | "down", + ): Layout.Root.Info { + const windows = getAllWindows(layout) + const currentIndex = windows.findIndex((w) => w.id === layout.focusedID) + if (currentIndex === -1) return layout + + const splitDirection = direction === "left" || direction === "right" ? "vertical" : "horizontal" + const delta = direction === "left" || direction === "up" ? -1 : 1 + + const targetWindow = findAdjacentWindow(layout.root, layout.focusedID, splitDirection, delta) + if (!targetWindow) return layout + + return { + ...layout, + focusedID: targetWindow.id, + } + } + + function findAdjacentWindow( + node: Layout.Node, + currentID: string, + splitDirection: "horizontal" | "vertical", + delta: number, + ): Layout.Window.Info | undefined { + if (node.type === "window") return undefined + + const childIndex = node.children.findIndex((child) => { + if (child.type === "window") return child.id === currentID + return containsWindow(child, currentID) + }) + + if (childIndex === -1) return undefined + + if (node.direction === splitDirection) { + const targetIndex = childIndex + delta + if (targetIndex >= 0 && targetIndex < node.children.length) { + const target = node.children[targetIndex] + if (target.type === "window") return target + return getFirstWindow(target) + } + } + + for (const child of node.children) { + if (child.type === "split") { + const found = findAdjacentWindow(child, currentID, splitDirection, delta) + if (found) return found + } + } + + return undefined + } + + function containsWindow(node: Layout.Node, windowID: string): boolean { + if (node.type === "window") return node.id === windowID + return node.children.some((child) => containsWindow(child, windowID)) + } + + function getFirstWindow(node: Layout.Node): Layout.Window.Info | undefined { + if (node.type === "window") return node + for (const child of node.children) { + const found = getFirstWindow(child) + if (found) return found + } + return undefined + } + + export function updateWindowView(layout: Layout.Root.Info, windowID: string, viewID: string): Layout.Root.Info { + function updateNode(node: Layout.Node): Layout.Node { + if (node.type === "window" && node.id === windowID) { + return { ...node, viewID } + } + if (node.type === "split") { + return { + ...node, + children: node.children.map(updateNode), + } + } + return node + } + + return { + ...layout, + root: updateNode(layout.root), + } + } + + export function findWindow(layout: Layout.Root.Info, windowID: string): Layout.Window.Info | undefined { + return findWindowInNode(layout.root, windowID) + } + + function findWindowInNode(node: Layout.Node, windowID: string): Layout.Window.Info | undefined { + if (node.type === "window") { + return node.id === windowID ? node : undefined + } + for (const child of node.children) { + const found = findWindowInNode(child, windowID) + if (found) return found + } + return undefined + } + + export function getAllWindows(layout: Layout.Root.Info): Layout.Window.Info[] { + return getAllWindowsFromNode(layout.root) + } + + function getAllWindowsFromNode(node: Layout.Node): Layout.Window.Info[] { + if (node.type === "window") return [node] + return node.children.flatMap(getAllWindowsFromNode) + } +} +``` + +**Step 4: Run test to verify it passes** + +Run: `bun test packages/opencode/test/tui/layout/operations.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add packages/opencode/src/cli/cmd/tui/layout/operations.ts packages/opencode/test/tui/layout/operations.test.ts +git commit -m "feat(tui): add layout operations for window management" +``` + +--- + +### Task 1.2: Create Layout Context Provider + +**Files:** + +- Create: `packages/opencode/src/cli/cmd/tui/context/layout.tsx` +- Test: `packages/opencode/test/tui/layout/context.test.ts` + +**Step 1: Write the failing test** + +```typescript +// packages/opencode/test/tui/layout/context.test.ts +import { describe, expect, test } from "bun:test" +import { Layout } from "../../../src/cli/cmd/tui/layout/types" + +describe("Layout Context", () => { + test("layout types are correctly defined", () => { + const window = Layout.Window.create({ + id: "win-1", + viewID: "home", + focused: true, + }) + expect(window.type).toBe("window") + expect(window.viewID).toBe("home") + }) +}) +``` + +**Step 2: Run test to verify it passes (types already exist)** + +Run: `bun test packages/opencode/test/tui/layout/context.test.ts` +Expected: PASS (this is a sanity check) + +**Step 3: Write the context implementation** + +```typescript +// packages/opencode/src/cli/cmd/tui/context/layout.tsx +import { createStore } from "solid-js/store" +import { createSimpleContext } from "./helper" +import { Layout } from "../layout/types" +import { LayoutOperations } from "../layout/operations" + +export type ViewID = "home" | `session:${string}` + +export function parseViewID(viewID: string): { type: "home" } | { type: "session"; sessionID: string } { + if (viewID === "home") return { type: "home" } + if (viewID.startsWith("session:")) { + return { type: "session", sessionID: viewID.slice(8) } + } + return { type: "home" } +} + +export function createViewID(route: { type: "home" } | { type: "session"; sessionID: string }): ViewID { + if (route.type === "home") return "home" + return `session:${route.sessionID}` +} + +export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({ + name: "Layout", + init: () => { + const [store, setStore] = createStore<Layout.Root.Info>(LayoutOperations.createInitial("home")) + + return { + get layout() { + return store + }, + get focusedWindowID() { + return store.focusedID + }, + get focusedViewID() { + const window = LayoutOperations.findWindow(store, store.focusedID) + return window?.viewID ?? "home" + }, + splitVertical(viewID: ViewID = "home") { + setStore(LayoutOperations.splitWindow(store, store.focusedID, "vertical", viewID)) + }, + splitHorizontal(viewID: ViewID = "home") { + setStore(LayoutOperations.splitWindow(store, store.focusedID, "horizontal", viewID)) + }, + closeWindow(windowID?: string) { + const id = windowID ?? store.focusedID + const result = LayoutOperations.closeWindow(store, id) + if (result) { + setStore(result) + return true + } + return false + }, + focusLeft() { + setStore(LayoutOperations.focusDirection(store, "left")) + }, + focusRight() { + setStore(LayoutOperations.focusDirection(store, "right")) + }, + focusUp() { + setStore(LayoutOperations.focusDirection(store, "up")) + }, + focusDown() { + setStore(LayoutOperations.focusDirection(store, "down")) + }, + setView(viewID: ViewID, windowID?: string) { + const id = windowID ?? store.focusedID + setStore(LayoutOperations.updateWindowView(store, id, viewID)) + }, + getWindows() { + return LayoutOperations.getAllWindows(store) + }, + } + }, +}) + +export type LayoutContext = ReturnType<typeof useLayout> +``` + +**Step 4: Run typecheck to verify** + +Run: `bun run typecheck` +Expected: PASS + +**Step 5: Commit** + +```bash +git add packages/opencode/src/cli/cmd/tui/context/layout.tsx packages/opencode/test/tui/layout/context.test.ts +git commit -m "feat(tui): add layout context provider" +``` + +--- + +## Phase 2: Layout Renderer Component + +### Task 2.1: Create Layout Renderer + +**Files:** + +- Create: `packages/opencode/src/cli/cmd/tui/layout/renderer.tsx` +- Modify: `packages/opencode/src/cli/cmd/tui/layout/index.ts` (create barrel export) + +**Step 1: Write the renderer component** + +```typescript +// packages/opencode/src/cli/cmd/tui/layout/renderer.tsx +import { Match, Switch, For, createMemo } from "solid-js" +import { useTerminalDimensions } from "@opentui/solid" +import { Layout } from "./types" +import { useLayout } from "../context/layout" +import { useTheme } from "../context/theme" + +interface WindowRendererProps { + window: Layout.Window.Info + width: number + height: number +} + +interface SplitRendererProps { + split: Layout.Split.SplitInfo + width: number + height: number +} + +interface NodeRendererProps { + node: Layout.Node + width: number + height: number +} + +function WindowRenderer(props: WindowRendererProps) { + const layout = useLayout() + const { theme } = useTheme() + const focused = createMemo(() => props.window.id === layout.focusedWindowID) + + return ( + <box + width={props.width} + height={props.height} + border={focused() ? ["all"] : undefined} + borderColor={focused() ? theme.borderActive : theme.border} + > + <LayoutViewRenderer viewID={props.window.viewID} /> + </box> + ) +} + +function SplitRenderer(props: SplitRendererProps) { + const sizes = createMemo(() => { + const total = props.split.direction === "horizontal" ? props.height : props.width + return props.split.ratios.map((ratio) => Math.floor(total * ratio)) + }) + + return ( + <box + width={props.width} + height={props.height} + flexDirection={props.split.direction === "horizontal" ? "column" : "row"} + > + <For each={props.split.children}> + {(child, index) => ( + <NodeRenderer + node={child} + width={props.split.direction === "horizontal" ? props.width : sizes()[index()]} + height={props.split.direction === "horizontal" ? sizes()[index()] : props.height} + /> + )} + </For> + </box> + ) +} + +function NodeRenderer(props: NodeRendererProps) { + return ( + <Switch> + <Match when={props.node.type === "window"}> + <WindowRenderer + window={props.node as Layout.Window.Info} + width={props.width} + height={props.height} + /> + </Match> + <Match when={props.node.type === "split"}> + <SplitRenderer + split={props.node as Layout.Split.SplitInfo} + width={props.width} + height={props.height} + /> + </Match> + </Switch> + ) +} + +// This will be replaced with actual view rendering in Task 3 +function LayoutViewRenderer(props: { viewID: string }) { + const { theme } = useTheme() + return ( + <box flexGrow={1} justifyContent="center" alignItems="center"> + <text fg={theme.textMuted}>View: {props.viewID}</text> + </box> + ) +} + +export function LayoutRenderer() { + const layout = useLayout() + const dimensions = useTerminalDimensions() + + return ( + <NodeRenderer + node={layout.layout.root} + width={dimensions().width} + height={dimensions().height} + /> + ) +} +``` + +**Step 2: Create barrel export** + +```typescript +// packages/opencode/src/cli/cmd/tui/layout/index.ts +export { Layout } from "./types" +export { LayoutOperations } from "./operations" +export { LayoutRenderer } from "./renderer" +``` + +**Step 3: Run typecheck to verify** + +Run: `bun run typecheck` +Expected: PASS + +**Step 4: Commit** + +```bash +git add packages/opencode/src/cli/cmd/tui/layout/renderer.tsx packages/opencode/src/cli/cmd/tui/layout/index.ts +git commit -m "feat(tui): add layout renderer component" +``` + +--- + +## Phase 3: View Registry and Rendering + +### Task 3.1: Create View Registry + +**Files:** + +- Create: `packages/opencode/src/cli/cmd/tui/view/registry.tsx` +- Create: `packages/opencode/src/cli/cmd/tui/view/index.ts` + +**Step 1: Write the view registry** + +```typescript +// packages/opencode/src/cli/cmd/tui/view/registry.tsx +import type { Component } from "solid-js" + +export interface ViewDefinition { + id: string + component: Component<{ width: number; height: number }> +} + +const views = new Map<string, ViewDefinition>() + +export namespace ViewRegistry { + export function register(definition: ViewDefinition) { + views.set(definition.id, definition) + } + + export function get(id: string): ViewDefinition | undefined { + // Handle parameterized view IDs like "session:abc123" + const baseID = id.split(":")[0] + return views.get(baseID) + } + + export function getAll(): ViewDefinition[] { + return Array.from(views.values()) + } +} +``` + +**Step 2: Create barrel export** + +```typescript +// packages/opencode/src/cli/cmd/tui/view/index.ts +export { ViewRegistry, type ViewDefinition } from "./registry" +``` + +**Step 3: Run typecheck to verify** + +Run: `bun run typecheck` +Expected: PASS + +**Step 4: Commit** + +```bash +git add packages/opencode/src/cli/cmd/tui/view/registry.tsx packages/opencode/src/cli/cmd/tui/view/index.ts +git commit -m "feat(tui): add view registry for window content" +``` + +--- + +### Task 3.2: Register Built-in Views + +**Files:** + +- Create: `packages/opencode/src/cli/cmd/tui/view/builtin.tsx` +- Modify: `packages/opencode/src/cli/cmd/tui/layout/renderer.tsx` + +**Step 1: Create built-in view wrappers** + +```typescript +// packages/opencode/src/cli/cmd/tui/view/builtin.tsx +import { ViewRegistry } from "./registry" +import { Home } from "../routes/home" +import { Session } from "../routes/session" +import { parseViewID } from "../context/layout" +import { createMemo, Show } from "solid-js" +import { useSync } from "../context/sync" + +// Home view wrapper +function HomeView(props: { width: number; height: number }) { + return <Home /> +} + +// Session view wrapper - extracts sessionID from viewID +function SessionView(props: { width: number; height: number; viewID: string }) { + const parsed = createMemo(() => parseViewID(props.viewID)) + const sync = useSync() + + return ( + <Show + when={parsed().type === "session"} + fallback={<Home />} + > + <Session /> + </Show> + ) +} + +export function registerBuiltinViews() { + ViewRegistry.register({ + id: "home", + component: HomeView, + }) + + ViewRegistry.register({ + id: "session", + component: SessionView, + }) +} +``` + +**Step 2: Update renderer to use view registry** + +```typescript +// packages/opencode/src/cli/cmd/tui/layout/renderer.tsx +import { Match, Switch, For, createMemo, Show } from "solid-js" +import { Dynamic } from "solid-js/web" +import { useTerminalDimensions } from "@opentui/solid" +import { Layout } from "./types" +import { useLayout, parseViewID } from "../context/layout" +import { useTheme } from "../context/theme" +import { ViewRegistry } from "../view" +import { Home } from "../routes/home" +import { Session } from "../routes/session" + +interface WindowRendererProps { + window: Layout.Window.Info + width: number + height: number +} + +interface SplitRendererProps { + split: Layout.Split.SplitInfo + width: number + height: number +} + +interface NodeRendererProps { + node: Layout.Node + width: number + height: number +} + +function WindowRenderer(props: WindowRendererProps) { + const layout = useLayout() + const { theme } = useTheme() + const focused = createMemo(() => props.window.id === layout.focusedWindowID) + const windows = createMemo(() => layout.getWindows()) + const showBorder = createMemo(() => windows().length > 1) + + return ( + <box + width={props.width} + height={props.height} + border={showBorder() ? ["all"] : undefined} + borderColor={focused() ? theme.borderActive : theme.border} + > + <LayoutViewRenderer + viewID={props.window.viewID} + width={showBorder() ? props.width - 2 : props.width} + height={showBorder() ? props.height - 2 : props.height} + /> + </box> + ) +} + +function SplitRenderer(props: SplitRendererProps) { + const sizes = createMemo(() => { + const total = props.split.direction === "horizontal" ? props.height : props.width + return props.split.ratios.map((ratio) => Math.floor(total * ratio)) + }) + + return ( + <box + width={props.width} + height={props.height} + flexDirection={props.split.direction === "horizontal" ? "column" : "row"} + > + <For each={props.split.children}> + {(child, index) => ( + <NodeRenderer + node={child} + width={props.split.direction === "horizontal" ? props.width : sizes()[index()]} + height={props.split.direction === "horizontal" ? sizes()[index()] : props.height} + /> + )} + </For> + </box> + ) +} + +function NodeRenderer(props: NodeRendererProps) { + return ( + <Switch> + <Match when={props.node.type === "window"}> + <WindowRenderer + window={props.node as Layout.Window.Info} + width={props.width} + height={props.height} + /> + </Match> + <Match when={props.node.type === "split"}> + <SplitRenderer + split={props.node as Layout.Split.SplitInfo} + width={props.width} + height={props.height} + /> + </Match> + </Switch> + ) +} + +function LayoutViewRenderer(props: { viewID: string; width: number; height: number }) { + const parsed = createMemo(() => parseViewID(props.viewID)) + + return ( + <box width={props.width} height={props.height}> + <Switch> + <Match when={parsed().type === "home"}> + <Home /> + </Match> + <Match when={parsed().type === "session"}> + <Session /> + </Match> + </Switch> + </box> + ) +} + +export function LayoutRenderer() { + const layout = useLayout() + const dimensions = useTerminalDimensions() + + return ( + <NodeRenderer + node={layout.layout.root} + width={dimensions().width} + height={dimensions().height} + /> + ) +} +``` + +**Step 3: Run typecheck to verify** + +Run: `bun run typecheck` +Expected: PASS + +**Step 4: Commit** + +```bash +git add packages/opencode/src/cli/cmd/tui/view/builtin.tsx packages/opencode/src/cli/cmd/tui/layout/renderer.tsx +git commit -m "feat(tui): register built-in views and update renderer" +``` + +--- + +## Phase 4: Route-Layout Integration + +### Task 4.1: Bridge Route Context to Layout Context + +**Files:** + +- Modify: `packages/opencode/src/cli/cmd/tui/context/route.tsx` + +**Step 1: Read current route.tsx** + +Already read above - it's a simple store with navigate function. + +**Step 2: Update route context to work with layout** + +The route context will remain as-is for now. The integration happens in the App component where we sync route changes to the layout's focused window. + +**Step 3: Create route-layout bridge hook** + +```typescript +// packages/opencode/src/cli/cmd/tui/context/route-layout-bridge.tsx +import { createEffect, on } from "solid-js" +import { useRoute } from "./route" +import { useLayout, createViewID, parseViewID } from "./layout" + +export function useRouteLayoutBridge() { + const route = useRoute() + const layout = useLayout() + + // Sync route changes to focused window's view + createEffect( + on( + () => route.data, + (routeData) => { + const viewID = createViewID(routeData) + layout.setView(viewID) + }, + ), + ) + + // Sync focused window view changes to route + createEffect( + on( + () => layout.focusedViewID, + (viewID) => { + const parsed = parseViewID(viewID) + if (parsed.type !== route.data.type) { + route.navigate(parsed) + } + if (parsed.type === "session" && route.data.type === "session") { + if (parsed.sessionID !== route.data.sessionID) { + route.navigate(parsed) + } + } + }, + ), + ) +} +``` + +**Step 4: Run typecheck to verify** + +Run: `bun run typecheck` +Expected: PASS + +**Step 5: Commit** + +```bash +git add packages/opencode/src/cli/cmd/tui/context/route-layout-bridge.tsx +git commit -m "feat(tui): add route-layout bridge for syncing navigation" +``` + +--- + +## Phase 5: Window Commands + +### Task 5.1: Add Window Command Keybinds + +**Files:** + +- Create: `packages/opencode/src/cli/cmd/tui/context/window-commands.tsx` + +**Step 1: Write the window commands handler** + +```typescript +// packages/opencode/src/cli/cmd/tui/context/window-commands.tsx +import { useKeyboard } from "@opentui/solid" +import { useLayout } from "./layout" +import { useExit } from "./exit" +import { useDialog } from "../ui/dialog" + +export function useWindowCommands() { + const layout = useLayout() + const exit = useExit() + const dialog = useDialog() + + useKeyboard((evt) => { + // Skip if dialog is open + if (dialog.stack.length > 0) return + + // Window commands use Alt+Shift prefix + if (!evt.alt || !evt.shift) return + + switch (evt.name) { + case "v": + case "V": + // Alt+Shift+V: Split vertical + layout.splitVertical() + break + case "s": + case "S": + // Alt+Shift+S: Split horizontal + layout.splitHorizontal() + break + case "h": + case "H": + // Alt+Shift+H: Focus left + layout.focusLeft() + break + case "j": + case "J": + // Alt+Shift+J: Focus down + layout.focusDown() + break + case "k": + case "K": + // Alt+Shift+K: Focus up + layout.focusUp() + break + case "l": + case "L": + // Alt+Shift+L: Focus right + layout.focusRight() + break + case "c": + case "C": + // Alt+Shift+C: Close window + const closed = layout.closeWindow() + if (!closed) { + // Last window - exit app + exit() + } + break + } + }) +} +``` + +**Step 2: Run typecheck to verify** + +Run: `bun run typecheck` +Expected: PASS + +**Step 3: Commit** + +```bash +git add packages/opencode/src/cli/cmd/tui/context/window-commands.tsx +git commit -m "feat(tui): add window command keybinds" +``` + +--- + +## Phase 6: App Integration + +### Task 6.1: Integrate Layout System into App + +**Files:** + +- Modify: `packages/opencode/src/cli/cmd/tui/app.tsx` + +**Step 1: Update App component to use layout system** + +The key changes to `app.tsx`: + +1. Add `LayoutProvider` to the provider tree +2. Replace the `Switch/Match` for routes with `LayoutRenderer` +3. Add `useWindowCommands()` hook +4. Add `useRouteLayoutBridge()` hook + +```typescript +// In the provider tree (around line 118-144), add LayoutProvider: +// Before RouteProvider, add: +import { LayoutProvider } from "@tui/context/layout" + +// Update the provider nesting: +<RouteProvider> + <LayoutProvider> + <SDKProvider url={input.url}> + {/* ... rest of providers ... */} + </SDKProvider> + </LayoutProvider> +</RouteProvider> + +// In the App component, replace the Switch/Match (lines 602-609): +// Before: +<Switch> + <Match when={route.data.type === "home"}> + <Home /> + </Match> + <Match when={route.data.type === "session"}> + <Session /> + </Match> +</Switch> + +// After: +import { LayoutRenderer } from "@tui/layout" +import { useWindowCommands } from "@tui/context/window-commands" +import { useRouteLayoutBridge } from "@tui/context/route-layout-bridge" + +// Inside App function, add: +useWindowCommands() +useRouteLayoutBridge() + +// Replace the Switch with: +<LayoutRenderer /> +``` + +**Step 2: Full modified App component render section** + +The return statement in App (around line 578-611) should become: + +```tsx +return ( + <box + width={dimensions().width} + height={dimensions().height} + backgroundColor={theme.background} + onMouseUp={async () => { + if (Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) { + renderer.clearSelection() + return + } + const text = renderer.getSelection()?.getSelectedText() + if (text && text.length > 0) { + const base64 = Buffer.from(text).toString("base64") + const osc52 = `\x1b]52;c;${base64}\x07` + const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52 + /* @ts-expect-error */ + renderer.writeOut(finalOsc52) + await Clipboard.copy(text) + .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) + .catch(toast.error) + renderer.clearSelection() + } + }} + > + <LayoutRenderer /> + </box> +) +``` + +**Step 3: Run typecheck to verify** + +Run: `bun run typecheck` +Expected: PASS + +**Step 4: Manual test** + +Run: `bun dev` +Expected: App renders with single window showing home view + +**Step 5: Commit** + +```bash +git add packages/opencode/src/cli/cmd/tui/app.tsx +git commit -m "feat(tui): integrate layout system into app" +``` + +--- + +## Phase 7: Session View Adaptation + +### Task 7.1: Update Session Component for Window Context + +**Files:** + +- Modify: `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx` + +**Step 1: Update Session to get sessionID from layout context** + +The Session component currently uses `useRouteData("session")` to get the sessionID. We need to update it to work with the layout system while maintaining backward compatibility. + +Add at the top of the Session function: + +```typescript +import { useLayout, parseViewID } from "@tui/context/layout" + +export function Session() { + const layout = useLayout() + const viewID = layout.focusedViewID + const parsed = parseViewID(viewID) + + // Get sessionID from layout context + const sessionID = parsed.type === "session" ? parsed.sessionID : undefined + + // If no sessionID, this shouldn't render (handled by LayoutViewRenderer) + if (!sessionID) return null + + // ... rest of component uses sessionID instead of route.sessionID +} +``` + +However, this is a significant refactor. A simpler approach is to keep the route context as the source of truth and have the layout system sync with it. + +**Alternative approach - minimal changes:** + +Keep Session using `useRouteData("session")` as-is. The route-layout bridge ensures the route is always in sync with the focused window's view. + +**Step 2: Verify existing behavior works** + +Run: `bun dev` +Expected: Session view renders correctly when navigating to a session + +**Step 3: Commit (if changes made)** + +```bash +git add packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +git commit -m "feat(tui): adapt session component for window context" +``` + +--- + +## Phase 8: Testing and Polish + +### Task 8.1: Add Integration Tests + +**Files:** + +- Create: `packages/opencode/test/tui/layout/integration.test.ts` + +**Step 1: Write integration tests** + +```typescript +// packages/opencode/test/tui/layout/integration.test.ts +import { describe, expect, test } from "bun:test" +import { Layout } from "../../../src/cli/cmd/tui/layout/types" +import { LayoutOperations } from "../../../src/cli/cmd/tui/layout/operations" + +describe("Layout Integration", () => { + test("full workflow: create, split, navigate, close", () => { + // Create initial layout + const initial = LayoutOperations.createInitial("home") + expect(LayoutOperations.getAllWindows(initial)).toHaveLength(1) + + // Split vertically + const windowID = (initial.root as Layout.Window.Info).id + const afterSplit = LayoutOperations.splitWindow(initial, windowID, "vertical", "session:abc") + expect(LayoutOperations.getAllWindows(afterSplit)).toHaveLength(2) + + // Navigate left + const afterNav = LayoutOperations.focusDirection(afterSplit, "left") + const split = afterSplit.root as Layout.Split.SplitInfo + const leftWindow = split.children[0] as Layout.Window.Info + expect(afterNav.focusedID).toBe(leftWindow.id) + + // Close focused window + const afterClose = LayoutOperations.closeWindow(afterNav, afterNav.focusedID) + expect(afterClose).toBeDefined() + expect(LayoutOperations.getAllWindows(afterClose!)).toHaveLength(1) + }) + + test("nested splits work correctly", () => { + const initial = LayoutOperations.createInitial("home") + const windowID = (initial.root as Layout.Window.Info).id + + // First split + const split1 = LayoutOperations.splitWindow(initial, windowID, "vertical", "session:1") + + // Second split on new window + const newWindowID = ((split1.root as Layout.Split.SplitInfo).children[1] as Layout.Window.Info).id + const split2 = LayoutOperations.splitWindow(split1, newWindowID, "horizontal", "session:2") + + expect(LayoutOperations.getAllWindows(split2)).toHaveLength(3) + }) +}) +``` + +**Step 2: Run tests** + +Run: `bun test packages/opencode/test/tui/layout/integration.test.ts` +Expected: PASS + +**Step 3: Commit** + +```bash +git add packages/opencode/test/tui/layout/integration.test.ts +git commit -m "test(tui): add layout integration tests" +``` + +--- + +### Task 8.2: Add Window Resize Operations + +**Files:** + +- Modify: `packages/opencode/src/cli/cmd/tui/layout/operations.ts` +- Modify: `packages/opencode/test/tui/layout/operations.test.ts` + +**Step 1: Add resize tests** + +```typescript +// Add to packages/opencode/test/tui/layout/operations.test.ts + +describe("LayoutOperations.resizeWindow", () => { + test("increases window size in split", () => { + const initial = LayoutOperations.createInitial("session") + const windowID = (initial.root as Layout.Window.Info).id + const withSplit = LayoutOperations.splitWindow(initial, windowID, "vertical", "home") + + const result = LayoutOperations.resizeWindow(withSplit, withSplit.focusedID, 0.1) + const split = result.root as Layout.Split.SplitInfo + expect(split.ratios[1]).toBeCloseTo(0.6, 1) + expect(split.ratios[0]).toBeCloseTo(0.4, 1) + }) + + test("decreases window size in split", () => { + const initial = LayoutOperations.createInitial("session") + const windowID = (initial.root as Layout.Window.Info).id + const withSplit = LayoutOperations.splitWindow(initial, windowID, "vertical", "home") + + const result = LayoutOperations.resizeWindow(withSplit, withSplit.focusedID, -0.1) + const split = result.root as Layout.Split.SplitInfo + expect(split.ratios[1]).toBeCloseTo(0.4, 1) + expect(split.ratios[0]).toBeCloseTo(0.6, 1) + }) + + test("clamps resize to valid range", () => { + const initial = LayoutOperations.createInitial("session") + const windowID = (initial.root as Layout.Window.Info).id + const withSplit = LayoutOperations.splitWindow(initial, windowID, "vertical", "home") + + const result = LayoutOperations.resizeWindow(withSplit, withSplit.focusedID, 0.9) + const split = result.root as Layout.Split.SplitInfo + // Should clamp to max 0.9 + expect(split.ratios[1]).toBeLessThanOrEqual(0.9) + expect(split.ratios[0]).toBeGreaterThanOrEqual(0.1) + }) +}) +``` + +**Step 2: Run test to verify it fails** + +Run: `bun test packages/opencode/test/tui/layout/operations.test.ts` +Expected: FAIL with "resizeWindow is not a function" + +**Step 3: Implement resize** + +```typescript +// Add to packages/opencode/src/cli/cmd/tui/layout/operations.ts + +export function resizeWindow(layout: Layout.Root.Info, windowID: string, delta: number): Layout.Root.Info { + function resizeInNode(node: Layout.Node): Layout.Node { + if (node.type !== "split") return node + + const childIndex = node.children.findIndex((child) => { + if (child.type === "window") return child.id === windowID + return containsWindow(child, windowID) + }) + + if (childIndex === -1) { + return { + ...node, + children: node.children.map(resizeInNode), + } + } + + const newRatios = [...node.ratios] + const currentRatio = newRatios[childIndex] + const newRatio = Math.max(0.1, Math.min(0.9, currentRatio + delta)) + const ratioDiff = newRatio - currentRatio + + // Distribute the difference to other children + const otherCount = newRatios.length - 1 + newRatios[childIndex] = newRatio + for (let i = 0; i < newRatios.length; i++) { + if (i !== childIndex) { + newRatios[i] = Math.max(0.1, newRatios[i] - ratioDiff / otherCount) + } + } + + // Normalize ratios to sum to 1 + const sum = newRatios.reduce((a, b) => a + b, 0) + const normalizedRatios = newRatios.map((r) => r / sum) + + return { + ...node, + ratios: normalizedRatios, + children: node.children.map(resizeInNode), + } + } + + return { + ...layout, + root: resizeInNode(layout.root), + } +} +``` + +**Step 4: Run test to verify it passes** + +Run: `bun test packages/opencode/test/tui/layout/operations.test.ts` +Expected: PASS + +**Step 5: Add resize to context and commands** + +Update `packages/opencode/src/cli/cmd/tui/context/layout.tsx`: + +```typescript +// Add to the context return object: +resizeWidth(delta: number) { + setStore(LayoutOperations.resizeWindow(store, store.focusedID, delta)) +}, +resizeHeight(delta: number) { + setStore(LayoutOperations.resizeWindow(store, store.focusedID, delta)) +}, +``` + +Update `packages/opencode/src/cli/cmd/tui/context/window-commands.tsx`: + +```typescript +// Add cases for resize: +case "+": +case "=": + // Alt+Shift++: Increase size + layout.resizeWidth(0.05) + break +case "-": +case "_": + // Alt+Shift+-: Decrease size + layout.resizeWidth(-0.05) + break +``` + +**Step 6: Commit** + +```bash +git add packages/opencode/src/cli/cmd/tui/layout/operations.ts packages/opencode/test/tui/layout/operations.test.ts packages/opencode/src/cli/cmd/tui/context/layout.tsx packages/opencode/src/cli/cmd/tui/context/window-commands.tsx +git commit -m "feat(tui): add window resize operations" +``` + +--- + +## Summary + +This plan implements the TUI window system integration in 8 phases: + +1. **Layout Operations** - Core tree manipulation functions +2. **Layout Renderer** - SolidJS component for rendering the layout tree +3. **View Registry** - System for registering and rendering views +4. **Route-Layout Bridge** - Syncing between route and layout contexts +5. **Window Commands** - Keybinds for split/navigate/close +6. **App Integration** - Wiring everything into the main App component +7. **Session Adaptation** - Ensuring Session works with window context +8. **Testing and Polish** - Integration tests and resize operations + +Each task follows TDD with failing test first, minimal implementation, verification, and commit. + +**Key keybinds:** + +- `Alt+Shift+V` - Split vertical +- `Alt+Shift+S` - Split horizontal +- `Alt+Shift+H/J/K/L` - Navigate windows +- `Alt+Shift+C` - Close window +- `Alt+Shift++/-` - Resize window