diff --git a/bun.lock b/bun.lock index 43d3a81c20ed..0e02d9c7543b 100644 --- a/bun.lock +++ b/bun.lock @@ -125,7 +125,7 @@ "@effect/platform-node": "catalog:", "@opencode-ai/client": "workspace:*", "@opencode-ai/plugin": "workspace:*", - "@opencode-ai/pty": "0.1.8", + "@opencode-ai/pty": "0.1.9", "@opencode-ai/schema": "workspace:*", "@opencode-ai/server": "workspace:*", "@opencode-ai/tui": "workspace:*", @@ -365,7 +365,7 @@ "@opencode-ai/ai": "workspace:*", "@opencode-ai/codemode": "workspace:*", "@opencode-ai/plugin": "workspace:*", - "@opencode-ai/pty": "0.1.8", + "@opencode-ai/pty": "0.1.9", "@opencode-ai/schema": "workspace:*", "@opencode-ai/util": "workspace:*", "@parcel/watcher": "2.5.1", @@ -2157,19 +2157,19 @@ "@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"], - "@opencode-ai/pty": ["@opencode-ai/pty@0.1.8", "", { "optionalDependencies": { "@opencode-ai/pty-darwin-arm64": "0.1.8", "@opencode-ai/pty-darwin-x64": "0.1.8", "@opencode-ai/pty-linux-arm64-gnu": "0.1.8", "@opencode-ai/pty-linux-arm64-musl": "0.1.8", "@opencode-ai/pty-linux-x64-gnu": "0.1.8", "@opencode-ai/pty-linux-x64-musl": "0.1.8" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-+OHAANB6xgtO4zVMK5Q3l50Iot2fYcHL003rNpvxxCXLkpdO90UnnRCL9jrll1SWkWoDZKaUDlR/Scw6TUTN4A=="], + "@opencode-ai/pty": ["@opencode-ai/pty@0.1.9", "", { "optionalDependencies": { "@opencode-ai/pty-darwin-arm64": "0.1.9", "@opencode-ai/pty-darwin-x64": "0.1.9", "@opencode-ai/pty-linux-arm64-gnu": "0.1.9", "@opencode-ai/pty-linux-arm64-musl": "0.1.9", "@opencode-ai/pty-linux-x64-gnu": "0.1.9", "@opencode-ai/pty-linux-x64-musl": "0.1.9" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-9WysQgX9J3RXfZy/t/8MGqf1IGLeckyHQsT/eVnKiBnp+GnzOXtZePryj08CN/3GT2BuP5tWqMRMH0JzCMsrgg=="], - "@opencode-ai/pty-darwin-arm64": ["@opencode-ai/pty-darwin-arm64@0.1.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5DqVcCfa9P5zpM0QpvXD/QqZXFdXvtIoaBHVHjYnTt8oqlnmMWqG4OVowzPNQg07NudW6apwX1Jop1wuERgWGw=="], + "@opencode-ai/pty-darwin-arm64": ["@opencode-ai/pty-darwin-arm64@0.1.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-a2OZGutBdVGDO+X4t37L2K8wD1phTcLJBcNLj8j3LqCmaHbJmQyNYYFo6i8loZCTjjhb+8Wq0wMxSLny4tqdwA=="], - "@opencode-ai/pty-darwin-x64": ["@opencode-ai/pty-darwin-x64@0.1.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-keEDxfpy0mFJYmsTqea9IUbpLKpbe9yL4ISU8tYA0daWiKuR8ZlfLTlLGltaFNO7l7q6mURMeI4cZzwySOX/Kw=="], + "@opencode-ai/pty-darwin-x64": ["@opencode-ai/pty-darwin-x64@0.1.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-+vyRLwzNMzP/JFtYEIkMHxRC9Lkd7sUeuAqI5medtdgZmvTwS3NdkHbq6zQQG3rTQ9asObPKilvcpQDf+LYHRQ=="], - "@opencode-ai/pty-linux-arm64-gnu": ["@opencode-ai/pty-linux-arm64-gnu@0.1.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-NVJvlVtPbYRyjABwLkm1iboijpSZMXv4lfg4qda7UCzAgiDZ0cWauXeW5wtd9bbnUbHw4rdNVU6YweuwASgZXw=="], + "@opencode-ai/pty-linux-arm64-gnu": ["@opencode-ai/pty-linux-arm64-gnu@0.1.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-vjNKhCsw6mI6w+9bITCyCmb5nS+XiWA8xMGn02GOKE9AahcVX9JIbjUF1zAmPXZ1QD7nxWoanh4FvnqukbYPHw=="], - "@opencode-ai/pty-linux-arm64-musl": ["@opencode-ai/pty-linux-arm64-musl@0.1.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-GAhnCs7b7B8Hqa7AEN7k5ra4IqybFYePgbCSOHn3Y4mofp/5NjDlkOG/VFM7rHAy05iEmRi5WtOPRv0Mkca8zA=="], + "@opencode-ai/pty-linux-arm64-musl": ["@opencode-ai/pty-linux-arm64-musl@0.1.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-xpS0N6/uEiJPabv6Ib9BpOlfyZdcUES7sMVa4bCrgcxy6y4bnQMeGF/Ju4u7LBABD/rjBKZM0XhPMnvZ6qoX1w=="], - "@opencode-ai/pty-linux-x64-gnu": ["@opencode-ai/pty-linux-x64-gnu@0.1.8", "", { "os": "linux", "cpu": "x64" }, "sha512-vSbty1L0BuOQlK7DmukuhdxNKv2vzqyxOjHmh+TULptMDbDZwE1EybASoXB7dYog9as85adFCz8ZTF7Boyfxjg=="], + "@opencode-ai/pty-linux-x64-gnu": ["@opencode-ai/pty-linux-x64-gnu@0.1.9", "", { "os": "linux", "cpu": "x64" }, "sha512-N1Dx8rOLkpJd2DSetZZW9dPnpL3mxbRQagk/7K7TbS7M1rDnLazhq8/T9vulLET8KTwu6lmWcZYuSDfPLNWXnA=="], - "@opencode-ai/pty-linux-x64-musl": ["@opencode-ai/pty-linux-x64-musl@0.1.8", "", { "os": "linux", "cpu": "x64" }, "sha512-2GhmJLl+Ly/JjzMS2Eqvk7SgeuHIB0cQ4Wrmnj4WKhr2V7lS+pdUEApN6S0SG/FK0TYJlWeHeOIpNZgDqzEPPg=="], + "@opencode-ai/pty-linux-x64-musl": ["@opencode-ai/pty-linux-x64-musl@0.1.9", "", { "os": "linux", "cpu": "x64" }, "sha512-osb203LrlwXpQKQABrCfD5QRVK9Ajiq5atulC/VuMye6KkErcetDnjQvobjhaEjgnSEGPCH4U9sM84okGqip5Q=="], "@opencode-ai/schema": ["@opencode-ai/schema@workspace:packages/schema"], diff --git a/packages/cli/package.json b/packages/cli/package.json index 6f470ebbc17b..e5007fc3b085 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -27,7 +27,7 @@ "@effect/platform-node": "catalog:", "@opencode-ai/client": "workspace:*", "@opencode-ai/plugin": "workspace:*", - "@opencode-ai/pty": "0.1.8", + "@opencode-ai/pty": "0.1.9", "@opencode-ai/schema": "workspace:*", "@opencode-ai/server": "workspace:*", "@opencode-ai/tui": "workspace:*", diff --git a/packages/client/src/solid/pty.ts b/packages/client/src/solid/pty.ts index 93b424829395..04ac30d76cd3 100644 --- a/packages/client/src/solid/pty.ts +++ b/packages/client/src/solid/pty.ts @@ -1,4 +1,4 @@ -import type { OpenCodeClient, PtyConnectTokenInput } from "../promise" +import type { ExperimentalPersistentPtyConnectTokenInput, OpenCodeClient, PtyConnectTokenInput } from "../promise" export type PtyClientOptions = { readonly url: string @@ -11,6 +11,13 @@ export type PtyConnectInput = { readonly cursor?: number } +export type PersistentPtyConnectInput = { + readonly ptyID: ExperimentalPersistentPtyConnectTokenInput["ptyID"] + readonly cursor: number + readonly attachmentID: string + readonly takeover?: boolean +} + export function createPtyClient(api: OpenCodeClient, options: PtyClientOptions) { return { async connect(input: PtyConnectInput) { @@ -32,3 +39,25 @@ export function createPtyClient(api: OpenCodeClient, options: PtyClientOptions) }, } } + +export function createPersistentPtyClient(api: OpenCodeClient, options: PtyClientOptions) { + return { + async connect(input: PersistentPtyConnectInput) { + const token = await api.experimental.persistentPty.connectToken({ + ptyID: input.ptyID, + "x-opencode-ticket": "1", + }) + const url = new URL(`/api/experimental/persistent-pty/${encodeURIComponent(input.ptyID)}/connect`, options.url) + url.searchParams.set("ticket", token.ticket) + url.searchParams.set("cursor", String(input.cursor)) + url.searchParams.set("attachment_id", input.attachmentID) + url.searchParams.set("takeover", String(input.takeover ?? false)) + url.searchParams.set("input_protocol", "1") + url.protocol = url.protocol === "https:" ? "wss:" : "ws:" + + const socket = options.openSocket?.(url) ?? new WebSocket(url) + socket.binaryType = "arraybuffer" + return socket + }, + } +} diff --git a/packages/client/test/solid-pty.test.ts b/packages/client/test/solid-pty.test.ts index d7d1e943f2a8..eb63ccbbf038 100644 --- a/packages/client/test/solid-pty.test.ts +++ b/packages/client/test/solid-pty.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { OpenCode } from "../src/promise" -import { createPtyClient } from "../src/solid" +import { createPersistentPtyClient, createPtyClient } from "../src/solid" describe("createPtyClient", () => { test("mints an authenticated ticket before opening the terminal socket", async () => { @@ -66,3 +66,36 @@ describe("createPtyClient", () => { expect(opened).toBe(false) }) }) + +describe("createPersistentPtyClient", () => { + test("mints an authenticated ticket before opening the persistent terminal socket", async () => { + let request: Request | undefined + let socketURL: URL | undefined + const socket = { binaryType: "blob" } as unknown as WebSocket + const api = OpenCode.make({ + baseUrl: "https://server.example/base", + headers: { Authorization: "Basic credential" }, + fetch: async (input, init) => { + request = input instanceof Request ? input : new Request(input, init) + return Response.json({ data: { ticket: "persistent-ticket", expires_in: 60 } }) + }, + }) + const pty = createPersistentPtyClient(api, { + url: "https://server.example/base", + openSocket(url) { + socketURL = url + return socket + }, + }) + + expect(await pty.connect({ ptyID: "pty_1", cursor: 42, attachmentID: "attachment_1", takeover: true })).toBe(socket) + expect(request?.method).toBe("POST") + expect(request?.url).toBe("https://server.example/api/experimental/persistent-pty/pty_1/connect-token") + expect(request?.headers.get("authorization")).toBe("Basic credential") + expect(request?.headers.get("x-opencode-ticket")).toBe("1") + expect(socketURL?.toString()).toBe( + "wss://server.example/api/experimental/persistent-pty/pty_1/connect?ticket=persistent-ticket&cursor=42&attachment_id=attachment_1&takeover=true&input_protocol=1", + ) + expect(socket.binaryType).toBe("arraybuffer") + }) +}) diff --git a/packages/core/package.json b/packages/core/package.json index 65c20f14b67c..00407df27303 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -119,7 +119,7 @@ "@ff-labs/fff-node": "0.10.5", "@opencode-ai/codemode": "workspace:*", "@opencode-ai/ai": "workspace:*", - "@opencode-ai/pty": "0.1.8", + "@opencode-ai/pty": "0.1.9", "@opencode-ai/schema": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/util": "workspace:*", diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 1d4152b5a4c4..3e633056dc61 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -31,7 +31,6 @@ import { batch, Show, } from "solid-js" -import { createStore } from "solid-js/store" import { TuiLifecycleProvider, TuiAppProvider, @@ -77,7 +76,6 @@ import { clampSessionTabsWidth, sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH import { ThemeErrorToast } from "./component/theme-error-toast" import { createThemeSource, ThemeProvider, useTheme, useThemes } from "./context/theme" import { Home } from "./routes/home" -import { Session } from "./routes/session" import { PromptHistoryProvider } from "./prompt/history" import { FrecencyProvider } from "./prompt/frecency" import { PromptStashProvider } from "./prompt/stash" @@ -100,6 +98,8 @@ import { destroyRenderer } from "./util/renderer" import { cliErrorMessage, errorFormat } from "./util/error" import { AttentionProvider } from "./context/attention" import { StorageProvider, useStorage } from "./context/storage" +import { SessionTerminalsProvider } from "./context/session-terminals" +import { SessionFrame } from "./component/session-frame" import { createTuiClipboard } from "./clipboard" registerOpencodeSpinner() @@ -218,7 +218,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { reconnect: async (signal: AbortSignal) => { const endpoint = await managed.reconnect(signal) const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) } - return { api: OpenCode.make(next) } + return { api: OpenCode.make(next), url: endpoint.url } }, restart: managed.restart, } @@ -373,48 +373,50 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { : undefined } > - + - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + @@ -1318,7 +1320,12 @@ function App(props: { pair?: DialogPairCredentials }) { - {(_) => } + {(sessionID) => ( + + )} diff --git a/packages/tui/src/component/dialog-config.tsx b/packages/tui/src/component/dialog-config.tsx index 3165e7388c4d..0714fdd93e5a 100644 --- a/packages/tui/src/component/dialog-config.tsx +++ b/packages/tui/src/component/dialog-config.tsx @@ -265,21 +265,13 @@ export const settings: Setting[] = [ keywords: ["sound volume", "audio volume"], }, { - title: "Window title", + title: "Enabled", category: "Terminal", - path: ["terminal", "title"], - default: true, + path: ["terminal", "enabled"], + default: false, values: [false, true], labels: ["off", "on"], - keywords: ["terminal title", "tab title"], - }, - { - title: "Copy behavior", - category: "Terminal", - path: ["terminal", "copy"], - default: process.platform === "win32" ? "manual" : "select", - values: ["manual", "select"], - keywords: ["selection", "clipboard"], + keywords: ["pty", "shell", "terminal pane"], }, { title: "Developer tools", diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 882e95d05383..14fdab17d7c2 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -79,6 +79,7 @@ export type PromptProps = { sessionID?: string visible?: boolean disabled?: boolean + muted?: boolean onSubmit?: () => void onEmptySubmit?: () => boolean | Promise ref?: (ref: PromptRef | undefined) => void @@ -196,6 +197,7 @@ export function Prompt(props: PromptProps) { const [inputTarget, setInputTarget] = createSignal() const leader = Keymap.useLeaderActive() + const muted = () => leader() || props.muted const local = useLocal() const args = useArgs() const paths = useTuiPaths() @@ -1614,7 +1616,7 @@ export function Prompt(props: PromptProps) { }, ) const highlight = createMemo(() => { - if (leader()) return theme.border.default + if (muted()) return theme.border.default if (store.mode === "shell") return theme.text.action.primary.selected return promptDisplay().agentColor ?? theme.border.default }) @@ -1793,8 +1795,8 @@ export function Prompt(props: PromptProps) { width="100%" placeholder={placeholderText()} placeholderColor={theme.text.subdued} - textColor={leader() ? theme.text.subdued : theme.text.default} - focusedTextColor={leader() ? theme.text.subdued : theme.text.default} + textColor={muted() ? theme.text.subdued : theme.text.default} + focusedTextColor={muted() ? theme.text.subdued : theme.text.default} minHeight={1} maxHeight={maxHeight()} cursorStyle={config.cursor} @@ -1891,7 +1893,7 @@ export function Prompt(props: PromptProps) { minWidth={0} wrapMode="none" truncate - fg={fadeColor(leader() ? theme.text.subdued : theme.text.default, modelMetaAlpha())} + fg={fadeColor(muted() ? theme.text.subdued : theme.text.default, modelMetaAlpha())} > {promptDisplay().modelLabel} diff --git a/packages/tui/src/component/session-frame.tsx b/packages/tui/src/component/session-frame.tsx new file mode 100644 index 000000000000..1d59682adfa6 --- /dev/null +++ b/packages/tui/src/component/session-frame.tsx @@ -0,0 +1,178 @@ +import { RGBA } from "@opentui/core" +import { useTerminalDimensions } from "@opentui/solid" +import { batch, createEffect, createMemo, createResource, createSignal, on, Show } from "solid-js" +import { useConfig } from "../config" +import { useData } from "../context/data" +import { Keymap } from "../context/keymap" +import { useSessionTerminals } from "../context/session-terminals" +import { usePromptRef } from "../context/prompt" +import { Session } from "../routes/session" +import { Sidebar } from "../routes/session/sidebar" +import { SESSION_SIDEBAR_WIDTH } from "../ui/layout" +import { useToast } from "../ui/toast" +import { TerminalPane } from "./terminal-pane" + +export function SessionFrame(props: { sessionID: string; verticalTabsWidth: number }) { + const sessions = useSessionTerminals() + const prompt = usePromptRef() + const config = useConfig() + const data = useData() + const toast = useToast() + const dimensions = useTerminalDimensions() + const [sidebarOpen, setSidebarOpen] = createSignal(false) + const [sessionWidth, setSessionWidth] = createSignal() + const [terminalFocused, setTerminalFocused] = createSignal(false) + const [restoreTerminalFocus, setRestoreTerminalFocus] = createSignal(false) + let focusTerminal: (() => void) | undefined + createResource( + () => (config.data.terminal?.enabled ? props.sessionID : undefined), + (sessionID) => sessions.refresh(sessionID).catch(() => undefined), + ) + const session = () => sessions.get(props.sessionID) + const terminals = () => session()?.terminals ?? [] + const selectedTerminal = () => { + if (!config.data.terminal?.enabled) return + const value = session() + if (value?.hidden) return + return value?.terminals.find((terminal) => terminal.id === value.selectedTerminalID) ?? value?.terminals.at(-1) + } + createEffect( + on( + () => selectedTerminal()?.id, + (id) => { + if (id) setSidebarOpen(false) + }, + { defer: true }, + ), + ) + const wide = createMemo(() => dimensions().width - props.verticalTabsWidth > 120) + const sidebarVisible = createMemo(() => { + if (data.session.get(props.sessionID)?.parentID) return false + if (sidebarOpen()) return true + return (config.data.session?.sidebar ?? "auto") === "auto" && wide() + }) + const rightPane = createMemo(() => { + if (sidebarOpen() && sidebarVisible()) return "sidebar" + if (selectedTerminal()) return "terminal" + if (sidebarVisible() && !session()?.hidden) return "sidebar" + }) + const toggleSidebar = () => { + batch(() => { + const visible = rightPane() === "sidebar" + void config + .update((draft) => { + draft.session = { ...draft.session, sidebar: visible ? "hide" : "auto" } + }) + .catch(toast.error) + setSidebarOpen(!visible) + if (!visible && selectedTerminal()) void sessions.hideTerminal(props.sessionID).catch(toast.error) + }) + } + createEffect(() => { + if (!restoreTerminalFocus() || terminals().length > 0) return + setRestoreTerminalFocus(false) + prompt.current?.focus() + }) + Keymap.createLayer(() => ({ + enabled: () => config.data.terminal?.enabled === true, + commands: [ + { + id: "pane.focus.left", + title: "Focus session pane", + run: () => { + prompt.current?.focus() + }, + }, + { + id: "pane.focus.right", + title: "Focus terminal pane", + run: () => { + focusTerminal?.() + }, + }, + ], + })) + + return ( + + + + + prompt.current?.focus()} + /> + + + + + + {(terminal) => ( + { + sessions.clearFocus(terminal.id) + setRestoreTerminalFocus(false) + }} + onFocusChange={setTerminalFocused} + onFocusRequest={(value) => (focusTerminal = value)} + onDisconnect={() => setRestoreTerminalFocus(true)} + /> + )} + + } + > + + + + + + + + + + + ) +} diff --git a/packages/tui/src/component/terminal-pane.tsx b/packages/tui/src/component/terminal-pane.tsx new file mode 100644 index 000000000000..9fef6cb12c1b --- /dev/null +++ b/packages/tui/src/component/terminal-pane.tsx @@ -0,0 +1,377 @@ +import { CliRenderEvents, EmbeddedTerminalRenderable, type RGBA } from "@opentui/core" +import type { ResolvedThemeTokens } from "@opencode-ai/theme/tui" +import { extend, useRenderer } from "@opentui/solid" +import { createEffect, createSignal, onCleanup, onMount, Show } from "solid-js" +import { useClient } from "../context/client" +import { Keymap } from "../context/keymap" +import { useTheme, useThemes } from "../context/theme" +import { errorMessage } from "../util/error" + +declare module "@opentui/solid" { + interface OpenTUIComponents { + embeddedTerminal: typeof EmbeddedTerminalRenderable + } +} + +extend({ embeddedTerminal: EmbeddedTerminalRenderable }) + +type TerminalSize = { cols: number; rows: number } +type StreamItem = + | { type: "output"; data: Uint8Array } + | { type: "resize"; size: TerminalSize; checkpoint?: Uint8Array } + | { type: "ready" } + +export function TerminalPane(props: { + ptyID: string + autoFocus?: boolean + onAutoFocus?: () => void + onFocusRequest?: (focus: (() => void) | undefined) => void + onDisconnect?: () => void + onFocusChange?: (focused: boolean) => void +}) { + const client = useClient() + const keymap = Keymap.use() + const leader = Keymap.useLeaderActive() + const theme = useTheme("elevated") + const themes = useThemes() + const renderer = useRenderer() + const [failure, setFailure] = createSignal() + const attachmentID = crypto.randomUUID() + const stream: StreamItem[] = [] + const pendingInput: Uint8Array[] = [] + let terminal: EmbeddedTerminalRenderable | undefined + let socket: WebSocket | undefined + let attached = false + let controller = false + let restored = false + let wantsControl = false + let disposed = false + let size: TerminalSize | undefined + let canonicalSize: TerminalSize | undefined + let terminalSize: TerminalSize | undefined + let lastIntermediateRender = 0 + let terminalTheme: Uint8Array | undefined + let waitingSize: { size: TerminalSize; resolve: () => void } | undefined + + const setCanonicalSize = (value: TerminalSize) => { + canonicalSize = value + if (!terminal) return + terminal.width = value.cols + terminal.height = value.rows + } + + const applyTerminalTheme = () => { + if (terminalTheme) terminal?.write(terminalTheme) + } + + const send = (data: Uint8Array) => { + if (attached && socket?.readyState === WebSocket.OPEN) socket.send(data) + } + + const interact = () => { + if (!restored) { + wantsControl = true + return + } + if (!size) return + send(interactionFrame(size)) + } + + const sendInput = (data: Uint8Array) => { + if (!restored) { + pendingInput.push(data) + return + } + if (size) send(interactionFrame(size, data)) + } + + const processStream = () => { + if (disposed || !terminal || !sameSize(canonicalSize, terminalSize)) return + while (stream.length > 0) { + const item = stream[0]! + if (item.type === "output") { + stream.shift() + const output = [item.data] + while (true) { + const next = stream[0] + if (!next || next.type !== "output") break + output.push(next.data) + stream.shift() + } + terminal.write(output.length === 1 ? output[0] : Buffer.concat(output)) + continue + } + if (item.type === "resize") { + setCanonicalSize(item.size) + if (!sameSize(canonicalSize, terminalSize)) return + stream.shift() + if (item.checkpoint) { + terminal.write(Buffer.concat([Buffer.from("\x1bc"), Buffer.from(item.checkpoint)])) + applyTerminalTheme() + } + continue + } + stream.shift() + restored = true + const input = pendingInput.splice(0) + if (input.length > 0) input.forEach(sendInput) + if (input.length === 0 && (controller || wantsControl)) interact() + wantsControl = false + } + } + + const enqueue = (item: StreamItem) => { + stream.push(item) + processStream() + } + + const waitForTerminalSize = (value: TerminalSize) => { + if (sameSize(value, terminalSize)) return Promise.resolve() + return new Promise((resolve) => { + waitingSize = { size: value, resolve } + }) + } + + const offKeys = keymap.intercept( + "key", + ({ event }) => { + if (!terminal?.focused) return + if (keymap.isLeader(event) || leader()) return + event.preventDefault() + event.stopPropagation() + terminal.handleKeyPress(event) + }, + { priority: 100 }, + ) + const onFocused = () => props.onFocusChange?.(terminal?.focused === true) + renderer.on(CliRenderEvents.FOCUSED_RENDERABLE, onFocused) + createEffect(() => { + if (!props.autoFocus || !terminal) return + terminal.focus() + props.onAutoFocus?.() + }) + + createEffect(() => { + const tokens = themes.currentTokens().contextual.elevated + terminalTheme = terminalPalette(tokens, themes.mode(), tokens.background.default) + applyTerminalTheme() + }) + + onMount(() => { + void connect().catch((error) => setFailure(errorMessage(error))) + }) + + onCleanup(() => { + disposed = true + waitingSize?.resolve() + socket?.close() + offKeys() + renderer.off(CliRenderEvents.FOCUSED_RENDERABLE, onFocused) + props.onFocusChange?.(false) + props.onFocusRequest?.(undefined) + }) + + async function connect() { + const snapshot = await client.api.experimental.persistentPty.snapshot({ ptyID: props.ptyID }) + if (disposed) return + setCanonicalSize(snapshot.info.size) + await waitForTerminalSize(snapshot.info.size) + if (disposed) return + terminal?.write(Buffer.from(snapshot.checkpoint, "base64")) + applyTerminalTheme() + const next = await client.persistentPty.connect({ + ptyID: props.ptyID, + cursor: snapshot.info.output.tail, + attachmentID, + takeover: true, + }) + if (disposed) { + next.close() + return + } + next.addEventListener("message", (event) => { + if (disposed) return + if (event.data instanceof ArrayBuffer) { + enqueue({ type: "output", data: new Uint8Array(event.data) }) + const now = performance.now() + if (now - lastIntermediateRender >= 16) { + lastIntermediateRender = now + renderer.intermediateRender() + } + return + } + if (typeof event.data !== "string") return + const message: unknown = JSON.parse(event.data) + if (!message || typeof message !== "object" || !("type" in message)) return + if ( + message.type === "resized" && + "cols" in message && + typeof message.cols === "number" && + "rows" in message && + typeof message.rows === "number" && + "checkpoint" in message && + typeof message.checkpoint === "string" + ) { + enqueue({ + type: "resize", + size: { cols: message.cols, rows: message.rows }, + checkpoint: Buffer.from(message.checkpoint, "base64"), + }) + return + } + if (message.type === "replay_complete") { + enqueue({ type: "ready" }) + return + } + if ( + message.type === "controller_changed" && + "attachmentID" in message && + (typeof message.attachmentID === "string" || message.attachmentID === undefined) + ) { + const previous = controller + controller = message.attachmentID === attachmentID + if (controller && !previous && restored) interact() + return + } + if (message.type !== "attached") return + if (!("inputProtocol" in message) || message.inputProtocol !== 1) { + setFailure("Persistent terminal server is out of date; restart OpenCode") + next.close() + return + } + if ( + "info" in message && + message.info && + typeof message.info === "object" && + "size" in message.info && + message.info.size && + typeof message.info.size === "object" && + "cols" in message.info.size && + typeof message.info.size.cols === "number" && + "rows" in message.info.size && + typeof message.info.size.rows === "number" + ) + enqueue({ type: "resize", size: { cols: message.info.size.cols, rows: message.info.size.rows } }) + controller = "role" in message && message.role === "controller" + attached = true + }) + next.addEventListener("error", () => { + if (disposed) return + const focused = terminal?.focused + terminal = undefined + setFailure("Terminal connection failed") + if (focused) props.onDisconnect?.() + }) + next.addEventListener("close", () => { + if (disposed) return + const focused = terminal?.focused + terminal = undefined + setFailure("Terminal disconnected") + if (focused) props.onDisconnect?.() + }) + socket = next + } + + return ( + interact()} + > + {failure()}}> + <> + { + terminal = value + props.onFocusRequest?.(() => { + value.focus() + interact() + }) + terminalSize = { cols: 80, rows: 24 } + if (canonicalSize) { + value.width = canonicalSize.cols + value.height = canonicalSize.rows + } + applyTerminalTheme() + }} + position="absolute" + left={1} + top={0} + width={80} + height={24} + onData={(data, source) => { + if (source === "input") sendInput(data) + }} + onTerminalResize={(cols, rows) => { + terminalSize = { cols, rows } + if (waitingSize && sameSize(waitingSize.size, terminalSize)) { + waitingSize.resolve() + waitingSize = undefined + } + processStream() + }} + /> + + + + ) +} + +function sameSize(first: TerminalSize | undefined, second: TerminalSize | undefined) { + return !!first && !!second && first.cols === second.cols && first.rows === second.rows +} + +function terminalPalette(theme: ResolvedThemeTokens, mode: "dark" | "light", background: RGBA) { + const base = mode === "dark" ? 500 : 700 + const bright = mode === "dark" ? 300 : 500 + const colors = [ + background, + theme.text.feedback.error.default, + theme.text.feedback.success.default, + theme.text.feedback.warning.default, + theme.hue.blue[base], + theme.hue.purple[base], + theme.text.feedback.info.default, + theme.text.default, + theme.text.subdued, + theme.text.feedback.error.subdued, + theme.text.feedback.success.subdued, + theme.text.feedback.warning.subdued, + theme.hue.blue[bright], + theme.hue.purple[bright], + theme.hue.cyan[bright], + theme.hue.neutral[mode === "dark" ? 100 : 900], + ] + return Buffer.from( + colors + .map((color, index) => `\x1b]4;${index};${hex(color)}\x1b\\`) + .concat(`\x1b]10;${hex(theme.text.default)}\x1b\\`, `\x1b]11;${hex(background)}\x1b\\`) + .join(""), + ) +} + +function hex(color: RGBA) { + return `#${color + .toInts() + .slice(0, 3) + .map((value) => value.toString(16).padStart(2, "0")) + .join("")}` +} + +function interactionFrame(size: { cols: number; rows: number }, data?: Uint8Array) { + const frame = new Uint8Array(5 + (data?.byteLength ?? 0)) + const view = new DataView(frame.buffer) + frame[0] = data ? 1 : 0 + view.setUint16(1, size.cols) + view.setUint16(3, size.rows) + if (data) frame.set(data, 5) + return frame +} diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index d451eb6ce687..b13ccee2382e 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -102,6 +102,7 @@ export const Info = Schema.Struct({ ).annotate({ description: "Diff presentation settings" }), terminal: Schema.optional( Schema.Struct({ + enabled: Schema.optional(Schema.Boolean).annotate({ description: "Enable persistent terminal panes" }), title: Schema.optional(Schema.Boolean).annotate({ description: "Update the terminal window title" }), copy: Schema.optional(Schema.Literals(["manual", "select"])).annotate({ description: "Copy text manually or immediately after selecting it", @@ -231,6 +232,12 @@ export type Resolved = Omitb", "Toggle sidebar"), + "pane.focus.left": keybind("left", "Focus session pane"), + "pane.focus.right": keybind("right", "Focus terminal pane"), + "terminal.select": keybind("down", "Select terminal"), + "terminal.toggle": keybind("none", "Toggle terminal pane"), + "terminal.close": keybind("up", "Close terminal pane"), "session.toggle.scrollbar": keybind("none", "Toggle session scrollbar"), "opencode.status": keybind("s", "View status"), "opencode.debug": keybind("none", "View debug info"), @@ -234,6 +239,9 @@ export const Definitions = { "composer.shell.up": keybind("up", "Previous shell"), "composer.shell.down": keybind("down", "Next shell"), "composer.shell.kill": keybind("ctrl+d", "Kill shell command"), + "composer.terminal.up": keybind("up,k", "Previous terminal"), + "composer.terminal.down": keybind("down,j", "Next terminal"), + "composer.terminal.select": keybind("return", "Select terminal"), "dialog.select.prev": keybind("up,ctrl+p", "Move to previous dialog item"), "dialog.select.next": keybind("down,ctrl+n", "Move to next dialog item"), diff --git a/packages/tui/src/context/client.tsx b/packages/tui/src/context/client.tsx index bf49cdbe54ad..adf302fc13c3 100644 --- a/packages/tui/src/context/client.tsx +++ b/packages/tui/src/context/client.tsx @@ -1,12 +1,12 @@ import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client" -import { createClientConnection } from "@opencode-ai/client/solid" +import { createClientConnection, createPersistentPtyClient } from "@opencode-ai/client/solid" import { createGlobalEmitter } from "@solid-primitives/event-bus" import { onCleanup } from "solid-js" import { createSimpleContext } from "./helper" import { useLog } from "./log" type ManagedService = { - reconnect: (signal: AbortSignal) => Promise<{ api: OpenCodeClient }> + reconnect: (signal: AbortSignal) => Promise<{ api: OpenCodeClient; url?: string }> restart: () => Promise } @@ -14,16 +14,21 @@ type ClientEventMap = { [Type in OpenCodeEvent["type"]]: Extract { + init: (props: { api: OpenCodeClient; url?: string; service?: ManagedService }) => { const log = useLog({ component: "client" }) const service = props.service const events = createGlobalEmitter() let api = props.api + let url = props.url + let persistentPty = url ? createPersistentPtyClient(api, { url }) : undefined const connection = createClientConnection(api, { reconnect: service ? async (signal) => { - api = (await service.reconnect(signal)).api + const next = await service.reconnect(signal) + api = next.api + if (next.url) url = next.url + if (url) persistentPty = createPersistentPtyClient(api, { url }) return api } : undefined, @@ -41,6 +46,10 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext( get api() { return api }, + get persistentPty() { + if (!persistentPty) throw new Error("Persistent terminal server endpoint is unavailable") + return persistentPty + }, event: { on: events.on, listen: events.listen, diff --git a/packages/tui/src/context/keymap.tsx b/packages/tui/src/context/keymap.tsx index a7a28efa1272..21d7108ff458 100644 --- a/packages/tui/src/context/keymap.tsx +++ b/packages/tui/src/context/keymap.tsx @@ -169,16 +169,21 @@ export interface Keymap { } /** Registers a low-level keymap interceptor. */ intercept: OpenTuiKeymap["intercept"] + /** Returns whether an event matches the configured leader key. */ + isLeader(event: KeyEvent): boolean } function use(): Keymap { const value = useValue() + const leader = value.config.keybinds.get("leader")?.[0]?.key + const isLeader = leader ? value.keymap.createKeyMatcher(leader) : () => false return { dispatch(id, input) { value.dispatch(id, input) }, mode: value.mode, intercept: value.keymap.intercept.bind(value.keymap), + isLeader, } } diff --git a/packages/tui/src/context/session-terminals.tsx b/packages/tui/src/context/session-terminals.tsx new file mode 100644 index 000000000000..2e1430e909bc --- /dev/null +++ b/packages/tui/src/context/session-terminals.tsx @@ -0,0 +1,100 @@ +import type { PersistentPtyInfo } from "@opencode-ai/client" +import { createSignal, onCleanup } from "solid-js" +import { createSimpleContext } from "./helper" +import { useClient } from "./client" +import { useConfig } from "../config" +import { useData } from "./data" +import { useEvent } from "./event" +import { useStorage } from "./storage" + +type SessionTerminals = { + terminals: PersistentPtyInfo[] + selectedTerminalID?: string + hidden?: boolean +} + +type SessionTerminalsState = { + sessions: Record +} + +export const { use: useSessionTerminals, provider: SessionTerminalsProvider } = createSimpleContext({ + name: "SessionTerminals", + init: () => { + const client = useClient() + const config = useConfig().data + const data = useData() + const event = useEvent() + const [focus, setFocus] = createSignal() + const [store, update] = useStorage().store("session-terminals-v1", { + initial: { sessions: {} }, + }) + + const save = (sessionID: string, terminals: PersistentPtyInfo[], selectedTerminalID?: string) => + update((draft) => { + const current = draft.sessions[sessionID]?.selectedTerminalID + const selected = selectedTerminalID ?? current + draft.sessions[sessionID] = { + terminals, + selectedTerminalID: terminals.some((terminal) => terminal.id === selected) ? selected : terminals.at(-1)?.id, + ...(selectedTerminalID === undefined && draft.sessions[sessionID]?.hidden ? { hidden: true } : {}), + } + }) + + const refresh = async (sessionID: string) => { + await save(sessionID, await client.api.experimental.persistentPty.list({ sessionID })) + } + + for (const type of ["persistent-pty.added", "persistent-pty.removed"] as const) { + onCleanup( + event.on(type, (evt) => { + if (!config.terminal?.enabled || !store.sessions[evt.data.sessionID]) return + void refresh(evt.data.sessionID).catch((error) => + console.error("Failed to refresh persistent terminal panes", error), + ) + }), + ) + } + + return { + get(sessionID: string) { + return store.sessions[sessionID] + }, + refresh, + selectTerminal(sessionID: string, ptyID: string) { + setFocus(ptyID) + return update((draft) => { + const session = draft.sessions[sessionID] + if (!session?.terminals.some((terminal) => terminal.id === ptyID)) return + session.selectedTerminalID = ptyID + delete session.hidden + }) + }, + hideTerminal(sessionID: string) { + return update((draft) => { + const session = draft.sessions[sessionID] + if (session) session.hidden = true + }) + }, + async newTerminal(sessionID: string): Promise { + const session = data.session.get(sessionID) + const terminal = await client.api.experimental.persistentPty.create({ + sessionID, + command: process.env.SHELL || "/bin/sh", + args: [], + cwd: session?.location.directory ?? process.cwd(), + title: "Terminal", + env: {}, + }) + setFocus(terminal.id) + await save(sessionID, await client.api.experimental.persistentPty.list({ sessionID }), terminal.id) + return terminal + }, + shouldFocus(ptyID: string) { + return focus() === ptyID + }, + clearFocus(ptyID: string) { + setFocus((current) => (current === ptyID ? undefined : current)) + }, + } + }, +}) diff --git a/packages/tui/src/routes/session/composer/index.tsx b/packages/tui/src/routes/session/composer/index.tsx index f773668b1bc5..4ca00551fffe 100644 --- a/packages/tui/src/routes/session/composer/index.tsx +++ b/packages/tui/src/routes/session/composer/index.tsx @@ -6,6 +6,8 @@ import { SplitBorder } from "../../../ui/border" import { Keymap } from "../../../context/keymap" import { SubagentsTab } from "./subagents-tab" import { ShellTab } from "./shell-tab" +import { TerminalsTab } from "./terminals-tab" +import { useConfig } from "../../../config" export interface ComposerHint { label: string @@ -36,10 +38,12 @@ export type ComposerProps = { open: boolean defaultTab?: string onClose?: () => void + visibleTerminalID?: string } export function Composer(props: ComposerProps) { const theme = useTheme("elevated") + const config = useConfig().data const [store, setStore] = createStore({ tabs: {} as Record, @@ -148,6 +152,9 @@ export function Composer(props: ComposerProps) { + + + {(hint) => ( diff --git a/packages/tui/src/routes/session/composer/terminals-tab.tsx b/packages/tui/src/routes/session/composer/terminals-tab.tsx new file mode 100644 index 000000000000..e2d2714a4b27 --- /dev/null +++ b/packages/tui/src/routes/session/composer/terminals-tab.tsx @@ -0,0 +1,114 @@ +import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js" +import { TextAttributes } from "@opentui/core" +import { Keymap } from "../../../context/keymap" +import { useSessionTerminals } from "../../../context/session-terminals" +import { useTheme } from "../../../context/theme" +import { useToast } from "../../../ui/toast" +import { useComposerTab } from "./index" + +export function TerminalsTab(props: { sessionID: string; visibleTerminalID?: string }) { + const composer = useComposerTab() + const terminals = useSessionTerminals() + const theme = useTheme() + const toast = useToast() + const failure = () => toast.show({ variant: "error", message: "Unable to load terminal" }) + const [selected, setSelected] = createSignal() + const session = () => terminals.get(props.sessionID) + const entries = () => session()?.terminals ?? [] + + onMount(() => { + const cleanup = composer.register({ id: "terminals", label: "Terminals" }) + onCleanup(cleanup) + }) + + createEffect(() => { + if (!composer.active("terminals")) return + const index = entries().findIndex((terminal) => terminal.id === props.visibleTerminalID) + setSelected(index < 0 ? undefined : index) + }) + + const select = () => { + const index = selected() + if (index === undefined) return + const terminal = entries()[index] + composer.close() + if (terminal) { + void terminals.selectTerminal(props.sessionID, terminal.id).catch(failure) + return + } + void terminals.newTerminal(props.sessionID).catch(failure) + } + + Keymap.createLayer(() => ({ + mode: "composer", + enabled: () => composer.active("terminals"), + priority: 1, + commands: [ + { + id: "composer.terminal.up", + title: "Previous terminal", + group: "Composer", + run: () => setSelected((index) => ((index ?? 0) + entries().length) % (entries().length + 1)), + }, + { + id: "composer.terminal.down", + title: "Next terminal", + group: "Composer", + run: () => setSelected((index) => ((index ?? -1) + 1) % (entries().length + 1)), + }, + { + id: "composer.terminal.select", + title: "Select terminal", + group: "Composer", + run: select, + }, + ], + })) + + return ( + + + + {(terminal, index) => { + const active = createMemo(() => index() === selected()) + const current = () => terminal !== undefined && terminal.id === props.visibleTerminalID + return ( + setSelected(index())} + onMouseUp={() => { + setSelected(index()) + select() + }} + > + + {terminal?.foregroundProcess ?? terminal?.title ?? "+ New terminal"} + + + ) + }} + + + + ) +} diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 273f9ed06f7c..1b9c8b48749a 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1,5 +1,4 @@ import { - batch, createContext, createEffect, createMemo, @@ -60,7 +59,6 @@ import { DialogImagePreview } from "../../component/dialog-image-preview" import { DialogMessage } from "./dialog-message" import { DialogFork } from "./dialog-fork" import { DialogTimeline } from "./dialog-timeline" -import { Sidebar } from "./sidebar" import { Composer } from "./composer" import { filetype } from "../../util/filetype" import parsers from "../../parsers-config" @@ -110,6 +108,7 @@ import { createDelayedPresence } from "../../util/delayed-presence" import { SessionLocationMissing } from "./location-missing" import { isRecord } from "../../util/record" import { createHistoryPrepend } from "./history" +import { useSessionTerminals } from "../../context/session-terminals" addDefaultParsers(parsers.parsers) @@ -148,7 +147,14 @@ function use() { return ctx } -export function Session(props: { verticalTabsWidth: number }) { +export function Session(props: { + verticalTabsWidth: number + promptMuted?: boolean + sidebarVisible: boolean + onToggleSidebar: () => void + visibleTerminalID?: string + width?: number +}) { const setEpilogue = useEpilogue() const clipboard = useClipboard() const writeExport = async (file: string, content: string) => { @@ -219,6 +225,9 @@ export function Session(props: { verticalTabsWidth: number }) { open: false, tab: undefined as string | undefined, }) + createEffect(() => { + if (props.promptMuted && composer.open) setComposer("open", false) + }) const disabled = createMemo(() => promptedPermissions().length > 0 || forms().length > 0) const lastAssistant = createMemo(() => { @@ -226,32 +235,23 @@ export function Session(props: { verticalTabsWidth: number }) { }) const dimensions = useTerminalDimensions() - const sidebar = createMemo(() => config.session?.sidebar ?? "auto") - const [sidebarOpen, setSidebarOpen] = createSignal(false) const thinkingMode = createMemo(() => config.session?.thinking ?? "hide") const showScrollbar = createMemo(() => config.session?.scrollbar ?? false) const markdownMode = createMemo(() => config.session?.markdown ?? "rendered") const diffWrapMode = createMemo(() => config.diffs?.wrap ?? "word") const groupExploration = createMemo(() => config.session?.grouping !== "none") - const availableWidth = createMemo(() => dimensions().width - props.verticalTabsWidth) - const wide = createMemo(() => availableWidth() > 120) - const sidebarVisible = createMemo(() => { - if (session()?.parentID) return false - if (sidebarOpen()) return true - if (sidebar() === "auto" && wide()) return true - return false - }) Keymap.createLayer(() => ({ priority: 10, - enabled: () => sidebarOpen() && !wide() && !disabled(), - commands: [{ bind: "escape,ctrl+c", title: "Close sidebar", group: "Session", run: () => setSidebarOpen(false) }], + enabled: () => props.sidebarVisible && dimensions().width - props.verticalTabsWidth <= 120 && !disabled(), + commands: [{ bind: "escape,ctrl+c", title: "Close sidebar", group: "Session", run: props.onToggleSidebar }], })) - const contentWidth = createMemo(() => availableWidth() - (sidebarVisible() ? 42 : 0) - 4) + const contentWidth = createMemo(() => (props.width ?? dimensions().width - props.verticalTabsWidth) - 4) const models = createMemo(() => data.location.model.list(location()) ?? []) const scrollAcceleration = createMemo(() => getScrollAcceleration(config)) const toast = useToast() + const terminalError = () => toast.show({ variant: "error", message: "Unable to load terminal" }) const client = useClient() const autoApproved = new Set() createEffect(() => { @@ -285,6 +285,7 @@ export function Session(props: { verticalTabsWidth: number }) { const [navigationSlack, setNavigationSlack] = createSignal(0) const [synced, setSynced] = createSignal(false) const sessionTabs = useSessionTabs() + const terminals = useSessionTerminals() const [awayFromBottom, setAwayFromBottom] = createSignal(false) const [latestHovered, setLatestHovered] = createSignal(false) let ensureAllRowsPending: (() => void)[] | undefined @@ -885,22 +886,68 @@ export function Session(props: { verticalTabsWidth: number }) { }, }, { - title: sidebarVisible() ? "Hide sidebar" : "Show sidebar", + title: props.sidebarVisible ? "Hide sidebar" : "Show sidebar", id: "session.sidebar.toggle", group: "Session", run: () => { - batch(() => { - const isVisible = sidebarVisible() - void configState - .update((draft) => { - draft.session = { ...draft.session, sidebar: isVisible ? "hide" : "auto" } - }) - .catch(toast.error) - setSidebarOpen(!isVisible) - }) + props.onToggleSidebar() dialog.clear() }, }, + ...(config.terminal?.enabled + ? [ + { + title: props.visibleTerminalID ? "Hide terminal pane" : "Show terminal pane", + id: "terminal.toggle", + group: "Session", + run: () => { + if (props.visibleTerminalID) { + promptRef.current?.focus() + void terminals.hideTerminal(route.sessionID).catch(toast.error) + } else { + const state = terminals.get(route.sessionID) + const terminal = + state?.terminals.find((item) => item.id === state.selectedTerminalID) ?? state?.terminals.at(-1) + if (terminal) void terminals.selectTerminal(route.sessionID, terminal.id).catch(terminalError) + else void terminals.newTerminal(route.sessionID).catch(terminalError) + } + dialog.clear() + }, + }, + { + title: "Select terminal", + id: "terminal.select", + group: "Session", + run: () => { + promptRef.current?.focus() + setComposer({ open: true, tab: "terminals" }) + void terminals.refresh(route.sessionID).catch(terminalError) + dialog.clear() + }, + }, + { + title: "Close terminal pane", + id: "terminal.close", + group: "Session", + enabled: props.visibleTerminalID !== undefined, + run: () => { + promptRef.current?.focus() + void terminals.hideTerminal(route.sessionID).catch(toast.error) + dialog.clear() + }, + }, + { + title: "New terminal", + id: "session.terminal", + group: "Session", + slash: { name: "terminal" }, + run: async () => { + dialog.clear() + await terminals.newTerminal(route.sessionID).catch(terminalError) + }, + }, + ] + : []), { title: (() => { const next = nextThinkingMode(thinkingMode()) @@ -1120,7 +1167,7 @@ export function Session(props: { verticalTabsWidth: number }) { group: "Session", run: () => { if (composer.open || session()?.parentID) setComposer("open", false) - else setComposer("open", true) + else setComposer({ open: true, tab: "subagents" }) dialog.clear() }, }, @@ -1307,6 +1354,7 @@ export function Session(props: { verticalTabsWidth: number }) { } setComposer("open", false) }} + visibleTerminalID={props.visibleTerminalID} /> {null} @@ -1346,6 +1394,7 @@ export function Session(props: { verticalTabsWidth: number }) { visible={true} ref={bind} disabled={false} + muted={props.promptMuted} onSubmit={() => { toBottom() }} @@ -1361,26 +1410,6 @@ export function Session(props: { verticalTabsWidth: number }) { - - - - - - - - - - - - ) diff --git a/packages/tui/src/util/selection.ts b/packages/tui/src/util/selection.ts index 608977c2d0d6..1e21b93ceb71 100644 --- a/packages/tui/src/util/selection.ts +++ b/packages/tui/src/util/selection.ts @@ -11,7 +11,11 @@ type FocusableSelectionTarget = { } type Renderer = { - getSelection: () => { getSelectedText: () => string; selectedRenderables: FocusableSelectionTarget[] } | null + getSelection: () => { + getSelectedText: () => string + selectedRenderables: FocusableSelectionTarget[] + isStart: boolean + } | null clearSelection: () => void currentFocusedRenderable?: FocusableSelectionTarget | null } @@ -36,9 +40,16 @@ export function copyOnSelectRelease( export function copy(renderer: Renderer, toast: Toast, clipboard: ClipboardService): boolean { const selection = renderer.getSelection() if (!selection) return false + if (selection.isStart) { + renderer.clearSelection() + return false + } const text = selection.getSelectedText() - if (!text) return false + if (!text) { + renderer.clearSelection() + return false + } const focus = renderer.currentFocusedRenderable const clipboardText = diff --git a/packages/tui/test/config-v2.test.tsx b/packages/tui/test/config-v2.test.tsx index bea94cb14b51..2c9cbed8f778 100644 --- a/packages/tui/test/config-v2.test.tsx +++ b/packages/tui/test/config-v2.test.tsx @@ -66,10 +66,28 @@ test("validates terminal copy behavior", () => { expect(decodeInfo({ terminal: { copy: "manual" } })).toEqual({ terminal: { copy: "manual" } }) expect(decodeInfo({ terminal: { copy: "select" } })).toEqual({ terminal: { copy: "select" } }) expect(() => decodeInfo({ terminal: { copy: "always" } })).toThrow() +}) - const setting = settings.find((setting) => setting.path.join(".") === "terminal.copy") - expect(setting?.values).toEqual(["manual", "select"]) - expect(setting?.default).toBe(process.platform === "win32" ? "manual" : "select") +test("keeps persistent terminals disabled until explicitly enabled", () => { + const disabled = resolve({}, { terminalSuspend: true }) + expect(disabled.terminal?.enabled ?? false).toBe(false) + expect(disabled.keybinds.get("theme.switch")).toMatchObject([{ key: "t" }]) + expect(disabled.keybinds.get("terminal.toggle")).toEqual([]) + expect(settings.find((setting) => setting.path.join(".") === "terminal.enabled")?.default).toBe(false) + expect(settings.filter((setting) => setting.category === "Terminal").map((setting) => setting.title)).toEqual([ + "Enabled", + ]) + + const enabled = resolve({ terminal: { enabled: true } }, { terminalSuspend: true }) + expect(enabled.keybinds.get("terminal.toggle")).toMatchObject([{ key: "t" }]) + expect(enabled.keybinds.get("theme.switch")).toEqual([]) + + const customized = resolve( + { terminal: { enabled: true }, keybinds: { "theme.switch": "t", "terminal.toggle": "p" } }, + { terminalSuspend: true }, + ) + expect(customized.keybinds.get("theme.switch")).toMatchObject([{ key: "t" }]) + expect(customized.keybinds.get("terminal.toggle")).toMatchObject([{ key: "p" }]) }) test("uses command IDs as keybind keys", () => { diff --git a/packages/tui/test/util/selection.test.ts b/packages/tui/test/util/selection.test.ts index c03e460126db..c6dba3f43082 100644 --- a/packages/tui/test/util/selection.test.ts +++ b/packages/tui/test/util/selection.test.ts @@ -1,16 +1,38 @@ import { expect, test } from "bun:test" -import { copy, copyOnSelectRelease } from "../../src/util/selection" +import type { ClipboardService } from "../../src/context/clipboard" +import { Selection, copy, copyOnSelectRelease } from "../../src/util/selection" function renderer() { return { getSelection: () => ({ getSelectedText: () => "beta", selectedRenderables: [], + isStart: false, }), clearSelection: () => {}, } } +function setup(text: string, isStart: boolean) { + const writes: string[] = [] + let clears = 0 + const clipboard: ClipboardService = { + read: async () => undefined, + write: async (value) => { + writes.push(value) + }, + } + const renderer = { + getSelection: () => ({ getSelectedText: () => text, selectedRenderables: [], isStart }), + clearSelection: () => { + clears++ + }, + currentFocusedRenderable: null, + } + const toast = { show: () => {}, error: () => {} } + return { clipboard, renderer, toast, writes, clears: () => clears } +} + test("copy writes selected text without clearing the highlight", () => { let cleared = false const copied = copy( @@ -18,6 +40,7 @@ test("copy writes selected text without clearing the highlight", () => { getSelection: () => ({ getSelectedText: () => "beta", selectedRenderables: [], + isStart: false, }), clearSelection: () => { cleared = true @@ -51,3 +74,25 @@ test("copy-on-select ignores a later non-drag release", () => { expect(copyOnSelectRelease({ isDragging: true }, renderer(), toast, clipboard)).toBe(true) expect(writes).toEqual(["beta"]) }) + +test("clears a click-only selection without copying", () => { + const value = setup("x", true) + expect(Selection.copy(value.renderer, value.toast, value.clipboard)).toBeFalse() + expect(value.clears()).toBe(1) + expect(value.writes).toEqual([]) +}) + +test("clears an empty dragged selection without copying", () => { + const value = setup("", false) + expect(Selection.copy(value.renderer, value.toast, value.clipboard)).toBeFalse() + expect(value.clears()).toBe(1) + expect(value.writes).toEqual([]) +}) + +test("copies a non-empty dragged selection without clearing its highlight", async () => { + const value = setup("selected", false) + expect(Selection.copy(value.renderer, value.toast, value.clipboard)).toBeTrue() + await Promise.resolve() + expect(value.clears()).toBe(0) + expect(value.writes).toEqual(["selected"]) +})