Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
415 changes: 288 additions & 127 deletions packages/tui/src/component/pane-workspace.tsx

Large diffs are not rendered by default.

74 changes: 54 additions & 20 deletions packages/tui/src/component/persistent-terminal-pane.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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<string>()
Expand Down Expand Up @@ -133,20 +139,24 @@ 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()
props.onAutoFocus?.()
})

createEffect(() => {
terminalTheme = terminalPalette(themes.currentTokens(), themes.mode())
const tokens = themes.currentTokens().contextual.elevated
terminalTheme = terminalPalette(tokens, themes.mode(), tokens.background.default)
applyTerminalTheme()
})

Expand All @@ -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)
})

Expand All @@ -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
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -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
}
Expand All @@ -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.
Expand All @@ -279,12 +316,12 @@ export function PersistentTerminalPane(props: {
<Show when={!failure()} fallback={<text fg={theme.text.feedback.error.default}>{failure()}</text>}>
<>
<embeddedTerminal
ref={(value) => {
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
Expand All @@ -293,7 +330,7 @@ export function PersistentTerminalPane(props: {
applyTerminalTheme()
}}
position="absolute"
left={0}
left={1}
top={0}
width={80}
height={24}
Expand All @@ -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,
Expand All @@ -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(""),
)
}
Expand Down
10 changes: 6 additions & 4 deletions packages/tui/src/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
})
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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>
Expand Down
3 changes: 3 additions & 0 deletions packages/tui/src/config/keybind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
5 changes: 5 additions & 0 deletions packages/tui/src/context/keymap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down
70 changes: 0 additions & 70 deletions packages/tui/src/context/pane-layout-model.ts

This file was deleted.

33 changes: 20 additions & 13 deletions packages/tui/src/context/pane-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -24,19 +23,19 @@ export const { use: usePaneLayout, provider: PaneLayoutProvider } = createSimple
const data = useData()
const event = useEvent()
const [focus, setFocus] = createSignal<string>()
const [store, update] = useStorage().store<PaneLayoutState>("pane-layout-v1", {
const [store, update] = useStorage().store<PaneLayoutState>("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) => {
Expand Down Expand Up @@ -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<PersistentPtyInfo> {
const session = data.session.get(sessionID)
const terminal = await client.api["server.persistentPty"].create({
Expand All @@ -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) {
Expand Down
Loading
Loading