mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-30 09:52:23 +00:00
feat(tui): add persistent session terminals (#44971)
This commit is contained in:
parent
91028a690b
commit
9cca8dd6e0
21 changed files with 1099 additions and 128 deletions
18
bun.lock
18
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"],
|
||||
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
>
|
||||
<ClientProvider api={api} service={service}>
|
||||
<ClientProvider api={api} url={input.server.endpoint.url} service={service}>
|
||||
<PermissionProvider>
|
||||
<DataProvider>
|
||||
<LocationProvider>
|
||||
<SessionTabsProvider>
|
||||
<ThemeProvider
|
||||
mode={mode}
|
||||
source={createThemeSource(global.config)}
|
||||
>
|
||||
<ThemeErrorToast />
|
||||
<LocalProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<AttentionProvider>
|
||||
<PluginProvider
|
||||
packages={input.packages}
|
||||
directories={pluginDirectories}
|
||||
>
|
||||
<App
|
||||
pair={
|
||||
input.server.endpoint.auth
|
||||
? input.server.endpoint.auth
|
||||
: {
|
||||
username: "opencode",
|
||||
password: "",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</PluginProvider>
|
||||
</AttentionProvider>
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
<SessionTerminalsProvider>
|
||||
<ThemeProvider
|
||||
mode={mode}
|
||||
source={createThemeSource(global.config)}
|
||||
>
|
||||
<ThemeErrorToast />
|
||||
<LocalProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<AttentionProvider>
|
||||
<PluginProvider
|
||||
packages={input.packages}
|
||||
directories={pluginDirectories}
|
||||
>
|
||||
<App
|
||||
pair={
|
||||
input.server.endpoint.auth
|
||||
? input.server.endpoint.auth
|
||||
: {
|
||||
username: "opencode",
|
||||
password: "",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</PluginProvider>
|
||||
</AttentionProvider>
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</SessionTerminalsProvider>
|
||||
</SessionTabsProvider>
|
||||
</LocationProvider>
|
||||
</DataProvider>
|
||||
|
|
@ -1318,7 +1320,12 @@ function App(props: { pair?: DialogPairCredentials }) {
|
|||
</Match>
|
||||
<Match when={route.data.type === "session"}>
|
||||
<Show when={route.data.type === "session" ? route.data.sessionID : undefined} keyed>
|
||||
{(_) => <Session verticalTabsWidth={verticalTabsVisible() ? verticalTabsWidth() : 0} />}
|
||||
{(sessionID) => (
|
||||
<SessionFrame
|
||||
sessionID={sessionID}
|
||||
verticalTabsWidth={verticalTabsVisible() ? verticalTabsWidth() : 0}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={route.data.type === "plugin"}>
|
||||
|
|
|
|||
|
|
@ -274,21 +274,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",
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ export type PromptProps = {
|
|||
sessionID?: string
|
||||
visible?: boolean
|
||||
disabled?: boolean
|
||||
muted?: boolean
|
||||
onSubmit?: () => void
|
||||
onEmptySubmit?: () => boolean | Promise<boolean>
|
||||
ref?: (ref: PromptRef | undefined) => void
|
||||
|
|
@ -196,6 +197,7 @@ export function Prompt(props: PromptProps) {
|
|||
const [inputTarget, setInputTarget] = createSignal<TextareaRenderable | undefined>()
|
||||
|
||||
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}
|
||||
</text>
|
||||
|
|
|
|||
178
packages/tui/src/component/session-frame.tsx
Normal file
178
packages/tui/src/component/session-frame.tsx
Normal file
|
|
@ -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<number>()
|
||||
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 (
|
||||
<box flexGrow={1} minWidth={0} minHeight={0} flexDirection="row" position="relative">
|
||||
<box
|
||||
flexGrow={1}
|
||||
flexBasis={0}
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
position="relative"
|
||||
onSizeChange={function () {
|
||||
setSessionWidth(this.width)
|
||||
}}
|
||||
>
|
||||
<Session
|
||||
verticalTabsWidth={props.verticalTabsWidth}
|
||||
promptMuted={terminalFocused()}
|
||||
sidebarVisible={rightPane() === "sidebar"}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
visibleTerminalID={rightPane() === "terminal" ? selectedTerminal()?.id : undefined}
|
||||
width={sessionWidth()}
|
||||
/>
|
||||
<Show when={terminalFocused()}>
|
||||
<box
|
||||
position="absolute"
|
||||
left={0}
|
||||
top={0}
|
||||
width="100%"
|
||||
height="100%"
|
||||
zIndex={1}
|
||||
onMouseDown={() => prompt.current?.focus()}
|
||||
/>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={rightPane() === "terminal" || (rightPane() === "sidebar" && wide())}>
|
||||
<box
|
||||
flexShrink={0}
|
||||
width={
|
||||
rightPane() === "terminal"
|
||||
? Math.max(1, Math.floor((dimensions().width - props.verticalTabsWidth) / 2))
|
||||
: SESSION_SIDEBAR_WIDTH
|
||||
}
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
>
|
||||
<Show
|
||||
when={rightPane() === "sidebar"}
|
||||
fallback={
|
||||
<Show keyed when={selectedTerminal()}>
|
||||
{(terminal) => (
|
||||
<TerminalPane
|
||||
ptyID={terminal.id}
|
||||
autoFocus={restoreTerminalFocus() || sessions.shouldFocus(terminal.id)}
|
||||
onAutoFocus={() => {
|
||||
sessions.clearFocus(terminal.id)
|
||||
setRestoreTerminalFocus(false)
|
||||
}}
|
||||
onFocusChange={setTerminalFocused}
|
||||
onFocusRequest={(value) => (focusTerminal = value)}
|
||||
onDisconnect={() => setRestoreTerminalFocus(true)}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Sidebar sessionID={props.sessionID} />
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={rightPane() === "sidebar" && !wide()}>
|
||||
<box
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
alignItems="flex-end"
|
||||
backgroundColor={RGBA.fromInts(0, 0, 0, 70)}
|
||||
>
|
||||
<Sidebar sessionID={props.sessionID} />
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
377
packages/tui/src/component/terminal-pane.tsx
Normal file
377
packages/tui/src/component/terminal-pane.tsx
Normal file
|
|
@ -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<string>()
|
||||
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<void>((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 (
|
||||
<box
|
||||
flexGrow={1}
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
overflow="hidden"
|
||||
backgroundColor={themes.currentTokens().contextual.elevated.background.default}
|
||||
onSizeChange={function () {
|
||||
size = { cols: Math.max(1, this.width - 2), rows: this.height }
|
||||
if (controller && restored) interact()
|
||||
}}
|
||||
// TODO: Revisit when embedded terminal mouse handlers can compose without replacing its internal focus handler.
|
||||
onMouseDown={() => interact()}
|
||||
>
|
||||
<Show when={!failure()} fallback={<text fg={theme.text.feedback.error.default}>{failure()}</text>}>
|
||||
<>
|
||||
<embeddedTerminal
|
||||
ref={(value) => {
|
||||
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()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
@ -235,6 +236,12 @@ export type Resolved = Omit<Info, "attention" | "cursor" | "keybinds" | "leader"
|
|||
|
||||
export function resolve(input: Info, options: { terminalSuspend: boolean }): Resolved {
|
||||
const keybinds: TuiKeybind.KeybindOverrides = { ...input.keybinds }
|
||||
if (input.terminal?.enabled) {
|
||||
if (input.keybinds?.["terminal.toggle"] === undefined && input.keybinds?.["theme.switch"] === undefined) {
|
||||
keybinds["terminal.toggle"] = "<leader>t"
|
||||
keybinds["theme.switch"] = "none"
|
||||
}
|
||||
}
|
||||
if (!options.terminalSuspend) {
|
||||
keybinds["terminal.suspend"] = "none"
|
||||
if (keybinds["input.undo"] === undefined) {
|
||||
|
|
|
|||
|
|
@ -87,6 +87,11 @@ export const Definitions = {
|
|||
"theme.switch_mode": keybind("none", "Switch between light and dark theme mode"),
|
||||
"theme.mode.lock": keybind("none", "Lock or unlock theme mode"),
|
||||
"session.sidebar.toggle": keybind("<leader>b", "Toggle sidebar"),
|
||||
"pane.focus.left": keybind("<leader>left", "Focus session pane"),
|
||||
"pane.focus.right": keybind("<leader>right", "Focus terminal pane"),
|
||||
"terminal.select": keybind("<leader>down", "Select terminal"),
|
||||
"terminal.toggle": keybind("none", "Toggle terminal pane"),
|
||||
"terminal.close": keybind("<leader>up", "Close terminal pane"),
|
||||
"session.toggle.scrollbar": keybind("none", "Toggle session scrollbar"),
|
||||
"opencode.status": keybind("<leader>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"),
|
||||
|
|
|
|||
|
|
@ -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<void>
|
||||
}
|
||||
|
||||
|
|
@ -14,16 +14,21 @@ type ClientEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent,
|
|||
|
||||
export const { use: useClient, provider: ClientProvider } = createSimpleContext({
|
||||
name: "Client",
|
||||
init: (props: { api: OpenCodeClient; service?: ManagedService }) => {
|
||||
init: (props: { api: OpenCodeClient; url?: string; service?: ManagedService }) => {
|
||||
const log = useLog({ component: "client" })
|
||||
const service = props.service
|
||||
const events = createGlobalEmitter<ClientEventMap>()
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
100
packages/tui/src/context/session-terminals.tsx
Normal file
100
packages/tui/src/context/session-terminals.tsx
Normal file
|
|
@ -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<string, SessionTerminals>
|
||||
}
|
||||
|
||||
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<string>()
|
||||
const [store, update] = useStorage().store<SessionTerminalsState>("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<PersistentPtyInfo> {
|
||||
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))
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -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<string, Tab>,
|
||||
|
|
@ -148,6 +152,9 @@ export function Composer(props: ComposerProps) {
|
|||
</box>
|
||||
<SubagentsTab sessionID={props.sessionID} />
|
||||
<ShellTab sessionID={props.sessionID} />
|
||||
<Show when={config.terminal?.enabled}>
|
||||
<TerminalsTab sessionID={props.sessionID} visibleTerminalID={props.visibleTerminalID} />
|
||||
</Show>
|
||||
<box flexDirection="row" gap={2} paddingLeft={1} flexShrink={0}>
|
||||
<For each={footerHints()}>
|
||||
{(hint) => (
|
||||
|
|
|
|||
114
packages/tui/src/routes/session/composer/terminals-tab.tsx
Normal file
114
packages/tui/src/routes/session/composer/terminals-tab.tsx
Normal file
|
|
@ -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<number>()
|
||||
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 (
|
||||
<Show when={composer.active("terminals")}>
|
||||
<scrollbox scrollbarOptions={{ visible: false }} maxHeight={5}>
|
||||
<For each={[...entries(), undefined]}>
|
||||
{(terminal, index) => {
|
||||
const active = createMemo(() => index() === selected())
|
||||
const current = () => terminal !== undefined && terminal.id === props.visibleTerminalID
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={
|
||||
active()
|
||||
? theme.background.action.primary.focused
|
||||
: current()
|
||||
? theme.background.action.primary.selected
|
||||
: theme.background.action.primary.default
|
||||
}
|
||||
onMouseOver={() => setSelected(index())}
|
||||
onMouseUp={() => {
|
||||
setSelected(index())
|
||||
select()
|
||||
}}
|
||||
>
|
||||
<text
|
||||
fg={
|
||||
active()
|
||||
? theme.text.action.primary.focused
|
||||
: current()
|
||||
? theme.text.action.primary.selected
|
||||
: theme.text.action.primary.default
|
||||
}
|
||||
attributes={active() ? TextAttributes.BOLD : undefined}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
>
|
||||
{terminal?.foregroundProcess ?? terminal?.title ?? "+ New terminal"}
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</scrollbox>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -111,6 +109,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)
|
||||
|
||||
|
|
@ -149,7 +148,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) => {
|
||||
|
|
@ -220,6 +226,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(() => {
|
||||
|
|
@ -227,32 +236,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<ThinkingMode>(() => 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<string>()
|
||||
createEffect(() => {
|
||||
|
|
@ -286,6 +286,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
|
||||
|
|
@ -886,22 +887,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())
|
||||
|
|
@ -1121,7 +1168,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()
|
||||
},
|
||||
},
|
||||
|
|
@ -1308,6 +1355,7 @@ export function Session(props: { verticalTabsWidth: number }) {
|
|||
}
|
||||
setComposer("open", false)
|
||||
}}
|
||||
visibleTerminalID={props.visibleTerminalID}
|
||||
/>
|
||||
<Switch>
|
||||
<Match when={composer.open || (!!session()?.parentID && forms().length === 0)}>{null}</Match>
|
||||
|
|
@ -1347,6 +1395,7 @@ export function Session(props: { verticalTabsWidth: number }) {
|
|||
visible={true}
|
||||
ref={bind}
|
||||
disabled={false}
|
||||
muted={props.promptMuted}
|
||||
onSubmit={() => {
|
||||
toBottom()
|
||||
}}
|
||||
|
|
@ -1362,26 +1411,6 @@ export function Session(props: { verticalTabsWidth: number }) {
|
|||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={sidebarVisible()}>
|
||||
<Switch>
|
||||
<Match when={wide()}>
|
||||
<Sidebar sessionID={route.sessionID} />
|
||||
</Match>
|
||||
<Match when={!wide()}>
|
||||
<box
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
alignItems="flex-end"
|
||||
backgroundColor={RGBA.fromInts(0, 0, 0, 70)}
|
||||
>
|
||||
<Sidebar sessionID={route.sessionID} />
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Show>
|
||||
</box>
|
||||
</context.Provider>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -74,10 +74,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: "<leader>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: "<leader>t" }])
|
||||
expect(enabled.keybinds.get("theme.switch")).toEqual([])
|
||||
|
||||
const customized = resolve(
|
||||
{ terminal: { enabled: true }, keybinds: { "theme.switch": "<leader>t", "terminal.toggle": "<leader>p" } },
|
||||
{ terminalSuspend: true },
|
||||
)
|
||||
expect(customized.keybinds.get("theme.switch")).toMatchObject([{ key: "<leader>t" }])
|
||||
expect(customized.keybinds.get("terminal.toggle")).toMatchObject([{ key: "<leader>p" }])
|
||||
})
|
||||
|
||||
test("uses command IDs as keybind keys", () => {
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue