mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-19 03:37:02 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b7fc2a9ac7 | |||
| 6484f1af52 |
+53
-42
@@ -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 { TerminalWorkspaceProvider } from "./context/terminal-workspace"
|
||||
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), endpoint }
|
||||
},
|
||||
restart: managed.restart,
|
||||
}
|
||||
@@ -373,48 +373,54 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<ClientProvider api={api} service={service}>
|
||||
<ClientProvider
|
||||
api={api}
|
||||
endpoint={input.server.endpoint}
|
||||
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>
|
||||
<TerminalWorkspaceProvider>
|
||||
<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>
|
||||
</TerminalWorkspaceProvider>
|
||||
</SessionTabsProvider>
|
||||
</LocationProvider>
|
||||
</DataProvider>
|
||||
@@ -1318,7 +1324,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"}>
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
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 PersistentTerminalPane(props: {
|
||||
ptyID: string
|
||||
autoFocus?: boolean
|
||||
onAutoFocus?: () => void
|
||||
onFocusRequest?: (focus: (() => void) | undefined) => void
|
||||
onInfo?: (info: { cwd: string; title: string; foregroundProcess?: string }) => void
|
||||
onTitleChange?: (title: string) => void
|
||||
onForegroundProcessChange?: (process: string | 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 endpoint = client.endpoint
|
||||
if (!endpoint) throw new Error("Persistent terminal server endpoint is unavailable")
|
||||
const snapshot = await client.api.experimental.persistentPty.snapshot({ ptyID: props.ptyID })
|
||||
if (disposed) return
|
||||
props.onInfo?.({
|
||||
cwd: snapshot.info.cwd,
|
||||
title: snapshot.info.title,
|
||||
foregroundProcess: snapshot.info.foregroundProcess ?? undefined,
|
||||
})
|
||||
setCanonicalSize(snapshot.info.size)
|
||||
await waitForTerminalSize(snapshot.info.size)
|
||||
if (disposed) return
|
||||
terminal?.write(Buffer.from(snapshot.checkpoint, "base64"))
|
||||
applyTerminalTheme()
|
||||
const token = await client.api.experimental.persistentPty.connectToken({
|
||||
ptyID: props.ptyID,
|
||||
"x-opencode-ticket": "1",
|
||||
})
|
||||
if (disposed) return
|
||||
const url = new URL(`/api/experimental/persistent-pty/${encodeURIComponent(props.ptyID)}/connect`, endpoint.url)
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
||||
url.searchParams.set("ticket", token.ticket)
|
||||
url.searchParams.set("cursor", String(snapshot.info.output.tail))
|
||||
url.searchParams.set("attachment_id", attachmentID)
|
||||
url.searchParams.set("takeover", "true")
|
||||
url.searchParams.set("input_protocol", "1")
|
||||
|
||||
const next = new WebSocket(url)
|
||||
next.binaryType = "arraybuffer"
|
||||
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 === "title_changed" && "title" in message && typeof message.title === "string") {
|
||||
props.onTitleChange?.(message.title)
|
||||
return
|
||||
}
|
||||
if (
|
||||
message.type === "foreground_process_changed" &&
|
||||
"process" in message &&
|
||||
(typeof message.process === "string" || message.process === null)
|
||||
) {
|
||||
props.onForegroundProcessChange?.(message.process ?? undefined)
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
@@ -195,6 +196,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()
|
||||
@@ -1601,7 +1603,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
|
||||
})
|
||||
@@ -1772,8 +1774,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}
|
||||
@@ -1870,7 +1872,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>
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
import type { PersistentPtyInfo } from "@opencode-ai/client"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { createEffect, createResource, createSignal, For, onCleanup, Show, type JSX } from "solid-js"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTerminalWorkspace } from "../context/terminal-workspace"
|
||||
import { usePromptRef } from "../context/prompt"
|
||||
import { useTheme, useThemes } from "../context/theme"
|
||||
import { Session } from "../routes/session"
|
||||
import { SplitBorder } from "../ui/border"
|
||||
import { PersistentTerminalPane } from "./persistent-terminal-pane"
|
||||
|
||||
type TerminalPickerState = {
|
||||
entries: { id: string; title: string }[]
|
||||
selected: number
|
||||
onMove: (index: number) => void
|
||||
onSelect: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function SessionFrame(props: { sessionID: string; verticalTabsWidth: number }) {
|
||||
const panes = useTerminalWorkspace()
|
||||
const keymap = Keymap.use()
|
||||
const prompt = usePromptRef()
|
||||
const [terminalTitles, setTerminalTitles] = createSignal<Record<string, string>>({})
|
||||
const [terminalFocused, setTerminalFocused] = createSignal(false)
|
||||
const [restoreTerminalFocus, setRestoreTerminalFocus] = createSignal(false)
|
||||
const [pickerOpen, setPickerOpen] = createSignal(false)
|
||||
const [pickerSelected, setPickerSelected] = createSignal(0)
|
||||
let focusTerminal: (() => void) | undefined
|
||||
createResource(
|
||||
() => props.sessionID,
|
||||
(sessionID) => panes.load(sessionID).catch(() => undefined),
|
||||
)
|
||||
const workspace = () => panes.get(props.sessionID)
|
||||
const terminals = () => workspace()?.terminals ?? []
|
||||
const selectedTerminal = () => {
|
||||
const value = workspace()
|
||||
return value?.terminals.find((terminal) => terminal.id === value.selectedTerminalID) ?? value?.terminals.at(-1)
|
||||
}
|
||||
createEffect(() => {
|
||||
if (!restoreTerminalFocus() || terminals().length > 0) return
|
||||
setRestoreTerminalFocus(false)
|
||||
prompt.current?.focus()
|
||||
})
|
||||
const picker = (): TerminalPickerState | undefined => {
|
||||
if (!pickerOpen()) return
|
||||
return {
|
||||
entries: terminals().map((terminal) => ({
|
||||
id: terminal.id,
|
||||
title: terminalTitles()[terminal.id] ?? terminal.foregroundProcess ?? terminal.title,
|
||||
})),
|
||||
selected: pickerSelected(),
|
||||
onMove: setPickerSelected,
|
||||
onSelect: selectTerminal,
|
||||
onClose: () => setPickerOpen(false),
|
||||
}
|
||||
}
|
||||
const selectTerminal = () => {
|
||||
const list = terminals()
|
||||
const selected = pickerSelected()
|
||||
setPickerOpen(false)
|
||||
if (selected < list.length) {
|
||||
void panes.selectTerminal(props.sessionID, list[selected]!.id)
|
||||
return
|
||||
}
|
||||
void panes.newTerminal(props.sessionID)
|
||||
}
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
commands: [
|
||||
{
|
||||
id: "pane.focus.left",
|
||||
title: "Focus session pane",
|
||||
run: () => {
|
||||
setPickerOpen(false)
|
||||
prompt.current?.focus()
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "pane.focus.right",
|
||||
title: "Focus terminal pane",
|
||||
run: () => {
|
||||
setPickerOpen(false)
|
||||
focusTerminal?.()
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "terminal.select",
|
||||
title: "Select terminal",
|
||||
run: () => {
|
||||
if (pickerOpen()) {
|
||||
setPickerOpen(false)
|
||||
return
|
||||
}
|
||||
const current = selectedTerminal()
|
||||
const index = terminals().findIndex((terminal) => terminal.id === current?.id)
|
||||
setPickerSelected(index < 0 ? 0 : index)
|
||||
setPickerOpen(true)
|
||||
void panes.refresh(props.sessionID)
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
const offPickerKeys = keymap.intercept(
|
||||
"key",
|
||||
({ event }) => {
|
||||
if (!pickerOpen()) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const count = terminals().length + 1
|
||||
if (event.name === "escape") {
|
||||
setPickerOpen(false)
|
||||
return
|
||||
}
|
||||
if (event.name === "up" || event.name === "k") {
|
||||
setPickerSelected((index) => (index + count - 1) % count)
|
||||
return
|
||||
}
|
||||
if (event.name === "down" || event.name === "j") {
|
||||
setPickerSelected((index) => (index + 1) % count)
|
||||
return
|
||||
}
|
||||
if (event.name === "enter" || event.name === "return") selectTerminal()
|
||||
},
|
||||
{ priority: 200 },
|
||||
)
|
||||
onCleanup(offPickerKeys)
|
||||
|
||||
return (
|
||||
<box flexGrow={1} minWidth={0} minHeight={0} flexDirection="row">
|
||||
<box flexGrow={selectedTerminal() ? 0.5 : 1} flexBasis={0} minWidth={0} minHeight={0} position="relative">
|
||||
<Session verticalTabsWidth={props.verticalTabsWidth} promptMuted={terminalFocused()} />
|
||||
<Show when={terminalFocused()}>
|
||||
<box
|
||||
position="absolute"
|
||||
left={0}
|
||||
top={0}
|
||||
width="100%"
|
||||
height="100%"
|
||||
zIndex={1}
|
||||
onMouseDown={() => prompt.current?.focus()}
|
||||
/>
|
||||
</Show>
|
||||
</box>
|
||||
<Show keyed when={selectedTerminal()}>
|
||||
{(terminal) => (
|
||||
<box flexGrow={0.5} flexBasis={0} minWidth={0} minHeight={0}>
|
||||
<TerminalPane
|
||||
info={terminal}
|
||||
picker={picker()}
|
||||
onFocusChange={setTerminalFocused}
|
||||
onFocusRequest={(value) => (focusTerminal = value)}
|
||||
restoreFocus={restoreTerminalFocus()}
|
||||
onAutoFocus={() => setRestoreTerminalFocus(false)}
|
||||
onDisconnect={() => setRestoreTerminalFocus(true)}
|
||||
onTitle={(title) => setTerminalTitles((titles) => ({ ...titles, [terminal.id]: title }))}
|
||||
/>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function TerminalPane(props: {
|
||||
info: PersistentPtyInfo
|
||||
picker?: TerminalPickerState
|
||||
onFocusChange: (focused: boolean) => void
|
||||
onFocusRequest: (focus: (() => void) | undefined) => void
|
||||
restoreFocus: boolean
|
||||
onAutoFocus: () => void
|
||||
onDisconnect: () => void
|
||||
onTitle: (title: string) => void
|
||||
}) {
|
||||
const panes = useTerminalWorkspace()
|
||||
const [terminalTitle, setTerminalTitle] = createSignal(props.info.title)
|
||||
const [foregroundProcess, setForegroundProcess] = createSignal(props.info.foregroundProcess ?? undefined)
|
||||
const [focused, setFocused] = createSignal(false)
|
||||
let focusTerminal: (() => void) | undefined
|
||||
return (
|
||||
<PaneSurface
|
||||
focus={() => focusTerminal?.()}
|
||||
title={foregroundProcess() ?? terminalTitle()}
|
||||
focused={focused()}
|
||||
picker={props.picker}
|
||||
>
|
||||
<PersistentTerminalPane
|
||||
ptyID={props.info.id}
|
||||
autoFocus={props.restoreFocus || panes.shouldFocus(props.info.id)}
|
||||
onAutoFocus={() => {
|
||||
panes.clearFocus(props.info.id)
|
||||
props.onAutoFocus()
|
||||
}}
|
||||
onFocusRequest={(value) => {
|
||||
focusTerminal = value
|
||||
props.onFocusRequest(value)
|
||||
}}
|
||||
onDisconnect={props.onDisconnect}
|
||||
onFocusChange={(value) => {
|
||||
setFocused(value)
|
||||
props.onFocusChange(value)
|
||||
}}
|
||||
onInfo={(info) => {
|
||||
setTerminalTitle(info.title)
|
||||
setForegroundProcess(info.foregroundProcess)
|
||||
props.onTitle(info.foregroundProcess ?? info.title)
|
||||
}}
|
||||
onTitleChange={(title) => {
|
||||
setTerminalTitle(title)
|
||||
if (!foregroundProcess()) props.onTitle(title)
|
||||
}}
|
||||
onForegroundProcessChange={(process) => {
|
||||
setForegroundProcess(process)
|
||||
props.onTitle(process ?? terminalTitle())
|
||||
}}
|
||||
/>
|
||||
</PaneSurface>
|
||||
)
|
||||
}
|
||||
|
||||
function PaneSurface(props: {
|
||||
focus: () => void
|
||||
title: string
|
||||
focused: boolean
|
||||
picker?: TerminalPickerState
|
||||
children: JSX.Element
|
||||
}) {
|
||||
const theme = useTheme()
|
||||
const themes = useThemes()
|
||||
const shortcut = Keymap.useShortcut("terminal.select")
|
||||
const background = () => themes.currentTokens().contextual.elevated.background.default
|
||||
return (
|
||||
<box flexGrow={1} minWidth={0} minHeight={0} flexDirection="column" backgroundColor={background()}>
|
||||
<box
|
||||
height={1}
|
||||
flexShrink={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
flexDirection="row"
|
||||
backgroundColor={background()}
|
||||
onMouseDown={props.focus}
|
||||
>
|
||||
<text
|
||||
fg={props.focused ? theme.text.formfield.selected : theme.text.subdued}
|
||||
bg={background()}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
flexGrow={1}
|
||||
minWidth={0}
|
||||
>
|
||||
Terminal: {props.title}
|
||||
</text>
|
||||
<Show when={shortcut()}>
|
||||
{(value) => (
|
||||
<>
|
||||
<text fg={theme.text.default} bg={background()} wrapMode="none" flexShrink={0}>
|
||||
{value()}
|
||||
</text>
|
||||
<text fg={theme.text.subdued} bg={background()} wrapMode="none" flexShrink={0}>
|
||||
{" "}
|
||||
terminals
|
||||
</text>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
<box flexGrow={1} minWidth={0} minHeight={0} position="relative" backgroundColor={background()}>
|
||||
{props.children}
|
||||
</box>
|
||||
<Show when={props.picker}>{(picker) => <TerminalPicker {...picker()} />}</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function TerminalPicker(props: TerminalPickerState) {
|
||||
const theme = useTheme("elevated")
|
||||
const options = () => [...props.entries, { id: "", title: "+ New terminal" }]
|
||||
const background = () => theme.raise(theme.background.default)
|
||||
return (
|
||||
<box
|
||||
{...SplitBorder}
|
||||
flexShrink={0}
|
||||
flexDirection="column"
|
||||
marginLeft={2}
|
||||
marginRight={2}
|
||||
marginBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
border={["left"]}
|
||||
borderColor={theme.border.default}
|
||||
backgroundColor={background()}
|
||||
>
|
||||
<box flexDirection="row" marginBottom={1}>
|
||||
<text fg={theme.text.default} attributes={TextAttributes.BOLD} flexGrow={1}>
|
||||
Terminals
|
||||
</text>
|
||||
<text fg={theme.text.subdued}>esc</text>
|
||||
</box>
|
||||
<For each={options()}>
|
||||
{(option, index) => {
|
||||
const selected = () => index() === props.selected
|
||||
return (
|
||||
<box
|
||||
height={1}
|
||||
backgroundColor={
|
||||
selected() ? theme.background.action.primary.focused : theme.background.action.primary.default
|
||||
}
|
||||
onMouseOver={() => props.onMove(index())}
|
||||
onMouseUp={() => {
|
||||
props.onMove(index())
|
||||
props.onSelect()
|
||||
}}
|
||||
>
|
||||
<text
|
||||
fg={selected() ? theme.text.action.primary.focused : theme.text.action.primary.default}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
>
|
||||
{option.title}
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -87,6 +87,9 @@ 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"),
|
||||
"session.toggle.scrollbar": keybind("none", "Toggle session scrollbar"),
|
||||
"opencode.status": keybind("<leader>s", "View status"),
|
||||
"opencode.debug": keybind("none", "View debug info"),
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
|
||||
import { createClientConnection } from "@opencode-ai/client/solid"
|
||||
import type { Endpoint } from "@opencode-ai/client/service"
|
||||
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; endpoint?: Endpoint }>
|
||||
restart: () => Promise<void>
|
||||
}
|
||||
|
||||
@@ -14,16 +15,19 @@ 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; endpoint?: Endpoint; service?: ManagedService }) => {
|
||||
const log = useLog({ component: "client" })
|
||||
const service = props.service
|
||||
const events = createGlobalEmitter<ClientEventMap>()
|
||||
let api = props.api
|
||||
let endpoint = props.endpoint
|
||||
|
||||
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.endpoint) endpoint = next.endpoint
|
||||
return api
|
||||
}
|
||||
: undefined,
|
||||
@@ -41,6 +45,9 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
|
||||
get api() {
|
||||
return api
|
||||
},
|
||||
get endpoint() {
|
||||
return endpoint
|
||||
},
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { PersistentPtyInfo } from "@opencode-ai/client"
|
||||
import { createSignal, onCleanup } from "solid-js"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useClient } from "./client"
|
||||
import { useData } from "./data"
|
||||
import { useEvent } from "./event"
|
||||
import { useStorage } from "./storage"
|
||||
|
||||
type TerminalWorkspace = {
|
||||
sessionID: string
|
||||
terminals: PersistentPtyInfo[]
|
||||
selectedTerminalID?: string
|
||||
}
|
||||
|
||||
type TerminalWorkspaceState = {
|
||||
workspaces: Record<string, TerminalWorkspace>
|
||||
}
|
||||
|
||||
export const { use: useTerminalWorkspace, provider: TerminalWorkspaceProvider } = createSimpleContext({
|
||||
name: "TerminalWorkspace",
|
||||
init: () => {
|
||||
const client = useClient()
|
||||
const data = useData()
|
||||
const event = useEvent()
|
||||
const [focus, setFocus] = createSignal<string>()
|
||||
const [store, update] = useStorage().store<TerminalWorkspaceState>("terminal-workspace-v1", {
|
||||
initial: { workspaces: {} },
|
||||
})
|
||||
|
||||
const save = (sessionID: string, terminals: PersistentPtyInfo[], selectedTerminalID?: string) =>
|
||||
update((draft) => {
|
||||
const current = draft.workspaces[sessionID]?.selectedTerminalID
|
||||
const selected = selectedTerminalID ?? current
|
||||
draft.workspaces[sessionID] = {
|
||||
sessionID,
|
||||
terminals,
|
||||
selectedTerminalID: terminals.some((terminal) => terminal.id === selected) ? selected : terminals.at(-1)?.id,
|
||||
}
|
||||
})
|
||||
|
||||
const refresh = async (sessionID: string) => {
|
||||
await save(sessionID, await client.api.experimental.persistentPty.list({ sessionID }))
|
||||
}
|
||||
|
||||
onCleanup(
|
||||
event.on("persistent-pty.added", (evt) => {
|
||||
if (!store.workspaces[evt.data.sessionID]) return
|
||||
void refresh(evt.data.sessionID).catch((error) =>
|
||||
console.error("Failed to add persistent terminal pane", error),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
onCleanup(
|
||||
event.on("persistent-pty.removed", (evt) => {
|
||||
if (!store.workspaces[evt.data.sessionID]) return
|
||||
void refresh(evt.data.sessionID).catch((error) =>
|
||||
console.error("Failed to remove persistent terminal pane", error),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
get(sessionID: string) {
|
||||
return store.workspaces[sessionID]
|
||||
},
|
||||
load: refresh,
|
||||
refresh,
|
||||
selectTerminal(sessionID: string, ptyID: string) {
|
||||
setFocus(ptyID)
|
||||
return update((draft) => {
|
||||
const workspace = draft.workspaces[sessionID]
|
||||
if (!workspace?.terminals.some((terminal) => terminal.id === ptyID)) return
|
||||
workspace.selectedTerminalID = ptyID
|
||||
})
|
||||
},
|
||||
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))
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -110,6 +110,7 @@ import { createDelayedPresence } from "../../util/delayed-presence"
|
||||
import { SessionLocationMissing } from "./location-missing"
|
||||
import { isRecord } from "../../util/record"
|
||||
import { createHistoryPrepend } from "./history"
|
||||
import { useTerminalWorkspace } from "../../context/terminal-workspace"
|
||||
|
||||
addDefaultParsers(parsers.parsers)
|
||||
|
||||
@@ -148,7 +149,7 @@ function use() {
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function Session(props: { verticalTabsWidth: number }) {
|
||||
export function Session(props: { verticalTabsWidth: number; promptMuted?: boolean }) {
|
||||
const setEpilogue = useEpilogue()
|
||||
const clipboard = useClipboard()
|
||||
const writeExport = async (file: string, content: string) => {
|
||||
@@ -280,6 +281,7 @@ export function Session(props: { verticalTabsWidth: number }) {
|
||||
const [navigationSlack, setNavigationSlack] = createSignal(0)
|
||||
const [synced, setSynced] = createSignal(false)
|
||||
const sessionTabs = useSessionTabs()
|
||||
const panes = useTerminalWorkspace()
|
||||
const [awayFromBottom, setAwayFromBottom] = createSignal(false)
|
||||
const [latestHovered, setLatestHovered] = createSignal(false)
|
||||
let ensureAllRowsPending: (() => void)[] | undefined
|
||||
@@ -896,6 +898,16 @@ export function Session(props: { verticalTabsWidth: number }) {
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "New terminal",
|
||||
id: "session.terminal",
|
||||
group: "Session",
|
||||
slash: { name: "terminal" },
|
||||
run: async () => {
|
||||
dialog.clear()
|
||||
await panes.newTerminal(route.sessionID).catch(toast.error)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: (() => {
|
||||
const next = nextThinkingMode(thinkingMode())
|
||||
@@ -1334,6 +1346,7 @@ export function Session(props: { verticalTabsWidth: number }) {
|
||||
visible={true}
|
||||
ref={bind}
|
||||
disabled={false}
|
||||
muted={props.promptMuted}
|
||||
onSubmit={() => {
|
||||
toBottom()
|
||||
}}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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"])
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user