diff --git a/packages/tui/src/component/pane-workspace.tsx b/packages/tui/src/component/pane-workspace.tsx index ad0ef1b80533..63e62af8bb54 100644 --- a/packages/tui/src/component/pane-workspace.tsx +++ b/packages/tui/src/component/pane-workspace.tsx @@ -1,175 +1,336 @@ -import { CliRenderEvents, RGBA, TextAttributes, type BoxRenderable, type Renderable } from "@opentui/core" -import { useRenderer } from "@opentui/solid" -import { createResource, createSignal, Match, onCleanup, Show, Switch, type JSX } from "solid-js" +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 { usePaneLayout } from "../context/pane-layout" -import type { PaneLayoutNode } from "../context/pane-layout-model" -import { useData } from "../context/data" import { usePromptRef } from "../context/prompt" -import { useTheme } from "../context/theme" +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 PaneWorkspace(props: { sessionID: string; verticalTabsWidth: number }) { const panes = usePaneLayout() + const keymap = Keymap.use() + const prompt = usePromptRef() + const [terminalTitles, setTerminalTitles] = createSignal>({}) + 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 ( }> - {(value) => ( - - )} + }> + {(terminal) => { + return ( + + + + + prompt.current?.focus()} + /> + + + + (focusTerminal = value)} + restoreFocus={restoreTerminalFocus()} + onAutoFocus={() => setRestoreTerminalFocus(false)} + onDisconnect={() => setRestoreTerminalFocus(true)} + onTitle={(title) => setTerminalTitles((titles) => ({ ...titles, [terminal.id]: title }))} + /> + + + ) + }} + ) } -function PaneNode(props: { node: PaneLayoutNode; rootSessionID?: string; verticalTabsWidth: number }) { +function TerminalPane(props: { + info: PersistentPtyInfo + rootSessionID?: string + picker?: TerminalPickerState + onFocusChange: (focused: boolean) => void + onFocusRequest: (focus: (() => void) | undefined) => void + restoreFocus: boolean + onAutoFocus: () => void + onDisconnect: () => void + onTitle: (title: string) => void +}) { const panes = usePaneLayout() - const prompt = usePromptRef() - const theme = useTheme() - const data = useData() + 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 ( - - - {(item) => { - let focusTerminal: (() => void) | undefined - const focus = () => { - if (item().type === "session" && item().id === props.rootSessionID) { - prompt.current?.focus() - return - } - focusTerminal?.() - } - return ( - - - - - - - - - - panes.clearFocus(item().id)} - onFocusRequest={(value) => (focusTerminal = value)} - /> - - - - ) + focusTerminal?.()} + title={foregroundProcess() ?? terminalTitle()} + focused={focused()} + picker={props.picker} + > + { + panes.clearFocus(props.info.id) + props.onAutoFocus() }} - - - {(node) => ( - - - - - - - - - )} - - + 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()) + }} + /> + ) } -function PaneSurface(props: { focus: () => void; title: string; children: JSX.Element }) { - const renderer = useRenderer() +function PaneSurface(props: { + focus: () => void + title: string + focused: boolean + picker?: TerminalPickerState + children: JSX.Element +}) { const theme = useTheme() - const [focused, setFocused] = createSignal(false) - let pane: BoxRenderable | undefined - const contains = (current: Renderable | null) => { - while (current) { - if (current === pane) return true - current = current.parent - } - return false - } - const onFocused = (current: Renderable | null) => setFocused(contains(current)) - renderer.on(CliRenderEvents.FOCUSED_RENDERABLE, onFocused) - onCleanup(() => renderer.off(CliRenderEvents.FOCUSED_RENDERABLE, onFocused)) + const themes = useThemes() + const shortcut = Keymap.useShortcut("terminal.select") + const background = () => themes.currentTokens().contextual.elevated.background.default return ( - { - pane = value - setFocused(contains(renderer.currentFocusedRenderable)) - }} - flexGrow={1} - minWidth={0} - minHeight={0} - position="relative" - flexDirection="column" - > + - {props.title} + Terminal: {props.title} + + {(value) => ( + <> + + {value()} + + + {" "} + terminals + + + )} + - + {props.children} - - - + {(picker) => } ) } -function UnavailablePane(props: { label: string }) { - const theme = useTheme() +function TerminalPicker(props: TerminalPickerState) { + const theme = useTheme("elevated") + const options = () => [...props.entries, { id: "", title: "+ New terminal" }] + const background = () => theme.raise(theme.background.default) return ( - - {props.label} is unavailable in this prototype. + + + + Terminals + + esc + + + {(option, index) => { + const selected = () => index() === props.selected + return ( + props.onMove(index())} + onMouseUp={() => { + props.onMove(index()) + props.onSelect() + }} + > + + {option.title} + + + ) + }} + ) } diff --git a/packages/tui/src/component/persistent-terminal-pane.tsx b/packages/tui/src/component/persistent-terminal-pane.tsx index 781de280a4b5..f2d2c6dedda1 100644 --- a/packages/tui/src/component/persistent-terminal-pane.tsx +++ b/packages/tui/src/component/persistent-terminal-pane.tsx @@ -1,4 +1,4 @@ -import { EmbeddedTerminalRenderable, type RGBA } from "@opentui/core" +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" @@ -26,10 +26,16 @@ export function PersistentTerminalPane(props: { 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 theme = useTheme() + const leader = Keymap.useLeaderActive() + const theme = useTheme("elevated") const themes = useThemes() const renderer = useRenderer() const [failure, setFailure] = createSignal() @@ -133,12 +139,15 @@ export function PersistentTerminalPane(props: { "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() @@ -146,7 +155,8 @@ export function PersistentTerminalPane(props: { }) createEffect(() => { - terminalTheme = terminalPalette(themes.currentTokens(), themes.mode()) + const tokens = themes.currentTokens().contextual.elevated + terminalTheme = terminalPalette(tokens, themes.mode(), tokens.background.default) applyTerminalTheme() }) @@ -159,6 +169,8 @@ export function PersistentTerminalPane(props: { waitingSize?.resolve() socket?.close() offKeys() + renderer.off(CliRenderEvents.FOCUSED_RENDERABLE, onFocused) + props.onFocusChange?.(false) props.onFocusRequest?.(undefined) }) @@ -167,6 +179,11 @@ export function PersistentTerminalPane(props: { if (!endpoint) throw new Error("Persistent terminal server endpoint is unavailable") const snapshot = await client.api["server.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 @@ -201,6 +218,18 @@ export function PersistentTerminalPane(props: { 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 && @@ -254,10 +283,18 @@ export function PersistentTerminalPane(props: { attached = true }) next.addEventListener("error", () => { - if (!disposed) setFailure("Terminal connection failed") + if (disposed) return + const focused = terminal?.focused + terminal = undefined + setFailure("Terminal connection failed") + if (focused) props.onDisconnect?.() }) next.addEventListener("close", () => { - if (!disposed) setFailure("Terminal disconnected") + if (disposed) return + const focused = terminal?.focused + terminal = undefined + setFailure("Terminal disconnected") + if (focused) props.onDisconnect?.() }) socket = next } @@ -268,9 +305,9 @@ export function PersistentTerminalPane(props: { minWidth={0} minHeight={0} overflow="hidden" - backgroundColor={theme.background.default} + backgroundColor={themes.currentTokens().contextual.elevated.background.default} onSizeChange={function () { - size = { cols: this.width, rows: this.height } + 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. @@ -279,12 +316,12 @@ export function PersistentTerminalPane(props: { {failure()}}> <> { - terminal = value - props.onFocusRequest?.(() => { - value.focus() - interact() - }) + ref={(value) => { + terminal = value + props.onFocusRequest?.(() => { + value.focus() + interact() + }) terminalSize = { cols: 80, rows: 24 } if (canonicalSize) { value.width = canonicalSize.cols @@ -293,7 +330,7 @@ export function PersistentTerminalPane(props: { applyTerminalTheme() }} position="absolute" - left={0} + left={1} top={0} width={80} height={24} @@ -319,11 +356,11 @@ function sameSize(first: TerminalSize | undefined, second: TerminalSize | undefi return !!first && !!second && first.cols === second.cols && first.rows === second.rows } -function terminalPalette(theme: ResolvedThemeTokens, mode: "dark" | "light") { +function terminalPalette(theme: ResolvedThemeTokens, mode: "dark" | "light", background: RGBA) { const base = mode === "dark" ? 500 : 700 const bright = mode === "dark" ? 300 : 500 const colors = [ - theme.background.default, + background, theme.text.feedback.error.default, theme.text.feedback.success.default, theme.text.feedback.warning.default, @@ -343,10 +380,7 @@ function terminalPalette(theme: ResolvedThemeTokens, mode: "dark" | "light") { return Buffer.from( colors .map((color, index) => `\x1b]4;${index};${hex(color)}\x1b\\`) - .concat( - `\x1b]10;${hex(theme.text.default)}\x1b\\`, - `\x1b]11;${hex(theme.background.default)}\x1b\\`, - ) + .concat(`\x1b]10;${hex(theme.text.default)}\x1b\\`, `\x1b]11;${hex(background)}\x1b\\`) .join(""), ) } diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 1838023bf046..cb5a01acbb64 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -79,6 +79,7 @@ export type PromptProps = { sessionID?: string visible?: boolean disabled?: boolean + muted?: boolean onSubmit?: () => void onEmptySubmit?: () => boolean | Promise ref?: (ref: PromptRef | undefined) => void @@ -195,6 +196,7 @@ export function Prompt(props: PromptProps) { const [inputTarget, setInputTarget] = createSignal() const leader = Keymap.useLeaderActive() + const muted = () => leader() || props.muted const local = useLocal() const args = useArgs() const paths = useTuiPaths() @@ -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} diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index c026b5663892..6696572227ff 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -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("b", "Toggle sidebar"), + "pane.focus.left": keybind("left", "Focus session pane"), + "pane.focus.right": keybind("right", "Focus terminal pane"), + "terminal.select": keybind("down", "Select terminal"), "session.toggle.scrollbar": keybind("none", "Toggle session scrollbar"), "opencode.status": keybind("s", "View status"), "opencode.debug": keybind("none", "View debug info"), diff --git a/packages/tui/src/context/keymap.tsx b/packages/tui/src/context/keymap.tsx index a7a28efa1272..21d7108ff458 100644 --- a/packages/tui/src/context/keymap.tsx +++ b/packages/tui/src/context/keymap.tsx @@ -169,16 +169,21 @@ export interface Keymap { } /** Registers a low-level keymap interceptor. */ intercept: OpenTuiKeymap["intercept"] + /** Returns whether an event matches the configured leader key. */ + isLeader(event: KeyEvent): boolean } function use(): Keymap { const value = useValue() + const leader = value.config.keybinds.get("leader")?.[0]?.key + const isLeader = leader ? value.keymap.createKeyMatcher(leader) : () => false return { dispatch(id, input) { value.dispatch(id, input) }, mode: value.mode, intercept: value.keymap.intercept.bind(value.keymap), + isLeader, } } diff --git a/packages/tui/src/context/pane-layout-model.ts b/packages/tui/src/context/pane-layout-model.ts deleted file mode 100644 index d8a78b8faead..000000000000 --- a/packages/tui/src/context/pane-layout-model.ts +++ /dev/null @@ -1,70 +0,0 @@ -export type PaneItem = { type: "session"; id: string } | { type: "terminal"; id: string } - -export type PaneLayoutNode = - | { type: "item"; item: PaneItem } - | { - type: "split" - direction: "horizontal" | "vertical" - ratio: number - first: PaneLayoutNode - second: PaneLayoutNode - } - -export function defaultPaneLayout(items: readonly PaneItem[]): PaneLayoutNode | undefined { - const master = items[0] - if (!master) return undefined - const stack = items.slice(1) - if (stack.length === 0) return { type: "item", item: master } - return { - type: "split", - direction: "horizontal", - ratio: 0.5, - first: { type: "item", item: master }, - second: stackLayout(stack), - } -} - -function stackLayout(items: readonly PaneItem[]): PaneLayoutNode { - const first = items[0] - if (items.length === 1) return { type: "item", item: first } - return { - type: "split", - direction: "vertical", - ratio: 1 / items.length, - first: { type: "item", item: first }, - second: stackLayout(items.slice(1)), - } -} - -export function paneLayoutItems(node: PaneLayoutNode): PaneItem[] { - if (node.type === "item") return [node.item] - return paneLayoutItems(node.first).concat(paneLayoutItems(node.second)) -} - -export function removePaneLayoutItem(node: PaneLayoutNode, item: PaneItem): PaneLayoutNode | undefined { - if (node.type === "item") return itemKey(node.item) === itemKey(item) ? undefined : node - const first = removePaneLayoutItem(node.first, item) - const second = removePaneLayoutItem(node.second, item) - if (!first) return second - if (!second) return first - if (first === node.first && second === node.second) return node - return { ...node, first, second } -} - -export function reconcilePaneLayout(node: PaneLayoutNode | undefined, items: readonly PaneItem[]) { - if (!node) return defaultPaneLayout(items) - const wanted = new Map(items.map((item) => [itemKey(item), item])) - const kept = paneLayoutItems(node).filter((item) => wanted.has(itemKey(item))) - if (kept.length !== items.length || kept.some((item, index) => itemKey(item) !== itemKey(items[index]))) - return defaultPaneLayout(items) - return replaceItems(node, wanted) -} - -function replaceItems(node: PaneLayoutNode, items: ReadonlyMap): PaneLayoutNode { - if (node.type === "item") return { type: "item", item: items.get(itemKey(node.item)) ?? node.item } - return { ...node, first: replaceItems(node.first, items), second: replaceItems(node.second, items) } -} - -function itemKey(item: PaneItem) { - return `${item.type}:${item.id}` -} diff --git a/packages/tui/src/context/pane-layout.tsx b/packages/tui/src/context/pane-layout.tsx index 2ba3b4395f60..698579e5c1be 100644 --- a/packages/tui/src/context/pane-layout.tsx +++ b/packages/tui/src/context/pane-layout.tsx @@ -4,13 +4,12 @@ import { createSimpleContext } from "./helper" import { useClient } from "./client" import { useData } from "./data" import { useEvent } from "./event" -import { reconcilePaneLayout, type PaneItem, type PaneLayoutNode } from "./pane-layout-model" import { useStorage } from "./storage" type PaneWorkspace = { sessionID: string - items: PaneItem[] - layout: PaneLayoutNode + terminals: PersistentPtyInfo[] + selectedTerminalID?: string } type PaneLayoutState = { @@ -24,19 +23,19 @@ export const { use: usePaneLayout, provider: PaneLayoutProvider } = createSimple const data = useData() const event = useEvent() const [focus, setFocus] = createSignal() - const [store, update] = useStorage().store("pane-layout-v1", { + const [store, update] = useStorage().store("pane-workspace-v1", { initial: { workspaces: {} }, }) - const save = (sessionID: string, terminals: readonly PersistentPtyInfo[]) => + const save = (sessionID: string, terminals: PersistentPtyInfo[], selectedTerminalID?: string) => update((draft) => { - const items: PaneItem[] = [ - { type: "session", id: sessionID }, - ...terminals.map((terminal) => ({ type: "terminal" as const, id: terminal.id })), - ] - const layout = reconcilePaneLayout(draft.workspaces[sessionID]?.layout, items) - if (!layout) return - draft.workspaces[sessionID] = { sessionID, items, layout } + 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) => { @@ -67,6 +66,14 @@ export const { use: usePaneLayout, provider: PaneLayoutProvider } = createSimple }, 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, options?: { focus?: boolean }): Promise { const session = data.session.get(sessionID) const terminal = await client.api["server.persistentPty"].create({ @@ -78,7 +85,7 @@ export const { use: usePaneLayout, provider: PaneLayoutProvider } = createSimple env: {}, }) if (options?.focus !== false) setFocus(terminal.id) - await refresh(sessionID) + await save(sessionID, await client.api["server.persistentPty"].list({ sessionID }), terminal.id) return terminal }, shouldFocus(ptyID: string) { diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 0d1a15c20883..80e452e7dca8 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -149,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) => { @@ -900,7 +900,7 @@ export function Session(props: { verticalTabsWidth: number }) { slash: { name: "terminal" }, run: async () => { dialog.clear() - await panes.newTerminal(route.sessionID, { focus: false }).catch(toast.error) + await panes.newTerminal(route.sessionID).catch(toast.error) }, }, { @@ -1341,6 +1341,7 @@ export function Session(props: { verticalTabsWidth: number }) { visible={true} ref={bind} disabled={false} + muted={props.promptMuted} onSubmit={() => { toBottom() }} diff --git a/packages/tui/src/util/selection.ts b/packages/tui/src/util/selection.ts index 608977c2d0d6..1e21b93ceb71 100644 --- a/packages/tui/src/util/selection.ts +++ b/packages/tui/src/util/selection.ts @@ -11,7 +11,11 @@ type FocusableSelectionTarget = { } type Renderer = { - getSelection: () => { getSelectedText: () => string; selectedRenderables: FocusableSelectionTarget[] } | null + getSelection: () => { + getSelectedText: () => string + selectedRenderables: FocusableSelectionTarget[] + isStart: boolean + } | null clearSelection: () => void currentFocusedRenderable?: FocusableSelectionTarget | null } @@ -36,9 +40,16 @@ export function copyOnSelectRelease( export function copy(renderer: Renderer, toast: Toast, clipboard: ClipboardService): boolean { const selection = renderer.getSelection() if (!selection) return false + if (selection.isStart) { + renderer.clearSelection() + return false + } const text = selection.getSelectedText() - if (!text) return false + if (!text) { + renderer.clearSelection() + return false + } const focus = renderer.currentFocusedRenderable const clipboardText = diff --git a/packages/tui/test/context/pane-layout-model.test.ts b/packages/tui/test/context/pane-layout-model.test.ts deleted file mode 100644 index 40cc4d5595da..000000000000 --- a/packages/tui/test/context/pane-layout-model.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { - defaultPaneLayout, - paneLayoutItems, - reconcilePaneLayout, - removePaneLayoutItem, - type PaneItem, -} from "../../src/context/pane-layout-model" - -const session = (id: string): PaneItem => ({ type: "session", id }) -const terminal = (id: string): PaneItem => ({ type: "terminal", id }) - -describe("pane layout model", () => { - test("builds a master pane with an evenly divided right stack", () => { - const items = [session("ses_1"), terminal("pty_1"), terminal("pty_2"), terminal("pty_3")] - const layout = defaultPaneLayout(items) - - expect(layout).toEqual({ - type: "split", - direction: "horizontal", - ratio: 0.5, - first: { type: "item", item: items[0] }, - second: { - type: "split", - direction: "vertical", - ratio: 1 / 3, - first: { type: "item", item: items[1] }, - second: { - type: "split", - direction: "vertical", - ratio: 0.5, - first: { type: "item", item: items[2] }, - second: { type: "item", item: items[3] }, - }, - }, - }) - expect(paneLayoutItems(layout!)).toEqual(items) - }) - - test("preserves stored split ratios when backend items still match", () => { - const items = [session("ses_1"), terminal("pty_1")] - const layout = defaultPaneLayout(items)! - if (layout.type !== "split") throw new Error("Expected a split") - layout.ratio = 0.65 - - expect(reconcilePaneLayout(layout, items)).toMatchObject({ ratio: 0.65 }) - }) - - test("rebuilds the default layout when backend order changes", () => { - const items = [session("ses_1"), terminal("pty_1")] - const layout = defaultPaneLayout(items)! - if (layout.type !== "split") throw new Error("Expected a split") - layout.ratio = 0.65 - - expect(reconcilePaneLayout(layout, items.toReversed())).toMatchObject({ ratio: 0.5 }) - }) - - test("removes a pane and preserves the remaining BSP layout", () => { - const items = [session("ses_1"), terminal("pty_1"), terminal("pty_2")] - const layout = defaultPaneLayout(items)! - if (layout.type !== "split" || layout.second.type !== "split") throw new Error("Expected nested splits") - layout.ratio = 0.65 - layout.second.ratio = 0.3 - - expect(removePaneLayoutItem(layout, items[1])).toEqual({ - type: "split", - direction: "horizontal", - ratio: 0.65, - first: { type: "item", item: items[0] }, - second: { type: "item", item: items[2] }, - }) - }) -}) diff --git a/packages/tui/test/util/selection.test.ts b/packages/tui/test/util/selection.test.ts index c03e460126db..c6dba3f43082 100644 --- a/packages/tui/test/util/selection.test.ts +++ b/packages/tui/test/util/selection.test.ts @@ -1,16 +1,38 @@ import { expect, test } from "bun:test" -import { copy, copyOnSelectRelease } from "../../src/util/selection" +import type { ClipboardService } from "../../src/context/clipboard" +import { Selection, copy, copyOnSelectRelease } from "../../src/util/selection" function renderer() { return { getSelection: () => ({ getSelectedText: () => "beta", selectedRenderables: [], + isStart: false, }), clearSelection: () => {}, } } +function setup(text: string, isStart: boolean) { + const writes: string[] = [] + let clears = 0 + const clipboard: ClipboardService = { + read: async () => undefined, + write: async (value) => { + writes.push(value) + }, + } + const renderer = { + getSelection: () => ({ getSelectedText: () => text, selectedRenderables: [], isStart }), + clearSelection: () => { + clears++ + }, + currentFocusedRenderable: null, + } + const toast = { show: () => {}, error: () => {} } + return { clipboard, renderer, toast, writes, clears: () => clears } +} + test("copy writes selected text without clearing the highlight", () => { let cleared = false const copied = copy( @@ -18,6 +40,7 @@ test("copy writes selected text without clearing the highlight", () => { getSelection: () => ({ getSelectedText: () => "beta", selectedRenderables: [], + isStart: false, }), clearSelection: () => { cleared = true @@ -51,3 +74,25 @@ test("copy-on-select ignores a later non-drag release", () => { expect(copyOnSelectRelease({ isDragging: true }, renderer(), toast, clipboard)).toBe(true) expect(writes).toEqual(["beta"]) }) + +test("clears a click-only selection without copying", () => { + const value = setup("x", true) + expect(Selection.copy(value.renderer, value.toast, value.clipboard)).toBeFalse() + expect(value.clears()).toBe(1) + expect(value.writes).toEqual([]) +}) + +test("clears an empty dragged selection without copying", () => { + const value = setup("", false) + expect(Selection.copy(value.renderer, value.toast, value.clipboard)).toBeFalse() + expect(value.clears()).toBe(1) + expect(value.writes).toEqual([]) +}) + +test("copies a non-empty dragged selection without clearing its highlight", async () => { + const value = setup("selected", false) + expect(Selection.copy(value.renderer, value.toast, value.clipboard)).toBeTrue() + await Promise.resolve() + expect(value.clears()).toBe(0) + expect(value.writes).toEqual(["selected"]) +})