* feat(desktop): session-scoped status stack + kill new-window theme flash Stack subagents, background tasks, and the queue into one collapsible "sink" above the composer, reusing the queue's chrome so every status reads as one piece. Extracts shared StatusSection / StatusRow / TerminalOutput primitives and a unified $statusItemsBySession store (subagents mirrored, background owned here, merged + grouped for render). Renames BrailleSpinner → GlyphSpinner now that it drives more than braille. Separately, fix the white flash on every new/cmd-clicked window: macOS `vibrancy` paints an NSVisualEffectView that follows the OS appearance and ignores `backgroundColor`, so a dark app on a light-mode Mac flashed white until the renderer painted over it. Pin `nativeTheme.themeSource` to the app theme (persisted to userData so cold launches paint right before the renderer loads), hold windows with `show:false` until `ready-to-show`, and pre-paint the themed background via an inline script before the bundle runs. * feat(desktop): dock the slash popover to the composer via one shared fill var The slash·@ popover (and ? help) now docks onto the composer's edge with the same chrome as the queue/status stack — rounded outer corners, fused borderless edge, no shadow — but keeps its own narrow width. Surface + drawer paint a single --composer-fill var; the state ladder (rest / scrolled / focused / drawer-open) lives once in styles.css on [data-slot='composer-root']. The :has() drawer-open rule is last and forces an opaque fill, since translucent glass sampling different backdrops (thread vs fade gradient) can never match. This replaces the focus-within !important override that repainted the surface behind every previous matching attempt. Also drop the chevron column from the project file tree — the folder open/closed icon already carries the expand state. * feat(desktop): base inset for file tree rows (post-chevron alignment) * feat(desktop): wire the status stack's background tasks to the real process registry The background group was UI-only (dev-mock seeded). Now it's live e2e: - tui_gateway: new session-scoped `process.list` (registry snapshot filtered by the session's session_key, plus a 4KB output tail for the inline terminal viewer) and `process.kill` (single process, ownership-checked — unlike process.stop's kill_all). - Renderer: `reconcileBackgroundProcesses` syncs snapshots into the store layout-stably — rows keep their position when state flips (never re-sort), new processes append, unchanged rows keep object identity so memoised rows skip re-rendering, and a dismissed-set stops the registry's retained finished procs from resurrecting X-ed rows. - Refresh triggers: session open, terminal/process tool.complete, status.update(kind=process) from the gateway's notification poller, and a 5s poll armed only while a running row is visible (catches silent exits). - Stop = real `process.kill` + optimistic dismiss; Dismiss = client-side with resurrection guard. - Re-keyed the stack to the RUNTIME session id: it was keyed by the stored session id, where neither subagent events nor process.list would ever land. - Deleted dev-status-mocks.ts (__hermesStatusMocks) — no more seed shit. Reconcile invariants covered in store/composer-status.test.ts. * feat(desktop): todos + openable subagents in the status stack, self-healing file tree - todo lists move out of the inline chat panel into the composer status stack (checklist icon, dashed ring = pending, spinner = in progress, check = done), fed live from todo tool events and seeded from history on session open - subagent rows carry the child's real session id end-to-end (delegate_tool → gateway → renderer) so clicking one opens ITS session window - status stack publishes its measured height so the thread's bottom clearance grows with it; card paints the shared --composer-fill so focused/scrolled states match the composer exactly - file tree self-heals: ENOENT roots retry on a 3s cadence + Try again button, and the main process expands ~ in IPC paths (gateway cwds arrive as ~/...) - composer drag-drop of tree entries inserts inline refs instead of attachments * fix(desktop): file tree falls back to the workspace dir when a session's cwd is gone Sessions record their launch cwd; deleted worktrees leave that path dead, so opening such a session swapped the tree from the default workspace to a directory that ENOENTs forever — the 3s retry just spun on it. On a root read error the tree now asks main to sanitize the cwd (prefers the configured default project dir), displays that fallback, and quietly re-probes the original path so it switches back if the dir reappears. * feat(desktop): working restore-checkpoint button on past user prompts The discard icon on hover of a past user bubble was decorative — clicking did nothing. It's now a real control: a confirmation dialog explains that everything after the prompt is removed, then the session rewinds to that turn and reruns the same prompt (prompt.submit with truncate_before_user_ordinal, the same mechanism the edit composer uses). Failures rethrow into the dialog's inline error instead of toasting. * fix(desktop): show the restore-checkpoint button on the latest user prompt too Restoring the most recent prompt is just 'retry this turn' — no reason to exclude it. Stop still takes the slot while the turn is running. * fix(desktop): finished todo lists clear themselves out of the status stack A list whose every item is completed/cancelled lingers ~4s so the final checkmark is visible, then the todo group drops out of the stack. A fresh active list arriving within the linger cancels the scheduled clear. * chore(desktop): drop dead editableCheckpoint copy, terser restore confirm * fix(desktop): rewind clears the abandoned timeline's todos + background Restoring to (or editing) an earlier prompt rewinds the conversation, but the todos and background processes spawned by the now-discarded turns kept showing in the status stack — and the real background processes kept running. Both rewind paths now clear the session's todo rows and kill + drop its background processes before the fresh run repopulates them. Also drops the click-to-edit clamp transition, which flashed a half-expanded bubble on the way into the edit composer. * feat(desktop): user messages are always editable; edit/restore revert mid-stream The bubble is now always click-to-edit — even while a turn streams — instead of going inert during a run. Sending an edit acts like restore: it rewinds to that prompt and re-runs with the new text. Both edit and restore can fire mid-stream now; the gateway refuses prompt.submit while a turn runs (4009 "session busy"), so they interrupt the live turn first and retry the submit until the cooperative interrupt winds it down. Restore (re-run as-is) shows on every prompt except the latest running one, which keeps the Stop button. * fix(desktop): label preview-pane ⌘L selections with the filename, not "zsh" The terminal owns a global ⌘/Ctrl+L "send selection to composer" shortcut, so selecting text in the file preview pane and hitting it fell through to the terminal handler — which imported the right text but labelled the composer ref "zsh:N lines" off the shell name. When the selection isn't an xterm selection, label it with the previewed file instead. * fix(desktop): ⌘L on a preview line selection inserts the @line ref, like dragging The source preview lets you select lines in the gutter and drag them into the composer as an @line:path:start-end ref. ⌘/Ctrl+L now does the same when a line selection is active — it drops the identical ref instead of falling through to the terminal's global handler (which grabbed the native text selection and sent a bogus terminal block). Capture-phase + stopPropagation so it wins; with a line selection there's no native selection, so the terminal handler stays out of it. * chore: gitignore apps/desktop/demo/ scratch output The desktop demo prompt writes demo/*.txt during recorded walkthroughs; it's throwaway, never part of the app. Ignore it so it stops cluttering git status. * feat(desktop): subagent watch windows, hard stop, sidebar hygiene Child-session mirror for live subagent windows, delegate sessions tagged and excluded from the sidebar, composer focus/stop polish, and WS stall resilience on the gateway transport. * refactor: DRY delegate SQL + trim status-stack noise Extract shared listable-child and delegate-delete helpers in hermes_state, collapse cancelRun busy release, and cut comment bloat in resume/status paths. * fix(desktop): hide orphaned subagent sessions in sidebar Cascade-delete all ephemeral children on parent delete (not just tagged rows), run v16 backfill to tag legacy orphans, and record new delegates as source=subagent. * fix: restore orphan contract for untagged children + lazy session eviction Cascade-delete only _delegate_from-tagged rows (v16 backfill covers legacy), walk marker chains recursively with FK-safe orphaning, gate lazy watch sessions out of the still-starting eviction exemption via an explicit flag, pass session_id to _make_agent only when resuming, and hide source=subagent from session search. * fix(gateway): gate child mirror off upgraded sessions + age out stale run entries Review findings: the mirror could interleave synthetic events with a real native stream once a watch window upgrades (prompt.submit builds an agent), and a lost subagent.complete left _active_child_runs pinning running=true forever. Mirror now stops when the live session owns an agent; liveness reads ignore entries older than an hour. * fix(gateway): reject prompt.submit into a watch session while its child runs A lazy watch session's running flag is False (the run lives in the parent turn), so typing mid-run sailed past the busy guard and built a second agent racing the in-flight child on the same stored session. Busy error until the run completes; afterwards the submit upgrades into a normal conversation. * refactor(gateway): DRY watch-resume payload + compose listable-child SQL Fold the duplicated child-run busy overlay into one _reuse_live_payload helper across both resume reuse paths, collapse the twin mirror early-returns, and build _LISTABLE_CHILD_SQL from _BRANCH_CHILD_SQL instead of restating it. * fix(desktop): clip horizontal overflow on sidebar scroll areas Add overflow-x-hidden alongside overflow-y-auto on session list scrollers and the shared SidebarContent primitive — vertical scroll unchanged.
679 lines
20 KiB
TypeScript
679 lines
20 KiB
TypeScript
import { FitAddon } from '@xterm/addon-fit'
|
|
import { Unicode11Addon } from '@xterm/addon-unicode11'
|
|
import { WebLinksAddon } from '@xterm/addon-web-links'
|
|
import { WebglAddon } from '@xterm/addon-webgl'
|
|
import { Terminal } from '@xterm/xterm'
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import type { CSSProperties } from 'react'
|
|
|
|
import { triggerHaptic } from '@/lib/haptics'
|
|
import { $filePreviewTarget, $previewTarget } from '@/store/preview'
|
|
import { useTheme } from '@/themes/context'
|
|
|
|
import { makeTerminalReader, setActiveTerminalReader } from './buffer'
|
|
import {
|
|
isAddSelectionShortcut,
|
|
resolveSurfaceColor,
|
|
terminalSelectionAnchor,
|
|
terminalSelectionLabel,
|
|
terminalTheme
|
|
} from './selection'
|
|
|
|
type TerminalStatus = 'closed' | 'open' | 'starting'
|
|
|
|
// ⌘/Ctrl+L is a global shortcut, so a text selection in the file preview pane
|
|
// lands in this handler with no xterm selection. Label those with the previewed
|
|
// file's name instead of the shell, so the composer ref reads as a file quote
|
|
// rather than a bogus "zsh:N lines".
|
|
function previewSelectionLabel(): string {
|
|
const target = $filePreviewTarget.get() ?? $previewTarget.get()
|
|
const source = target?.path || target?.url || ''
|
|
|
|
return source.split(/[\\/]/).filter(Boolean).pop() || target?.label?.trim() || ''
|
|
}
|
|
|
|
const HERMES_PATHS_MIME = 'application/x-hermes-paths'
|
|
|
|
function readEscapeSequence(data: string, index: number) {
|
|
if (data.charCodeAt(index) !== 0x1b || index + 1 >= data.length) {
|
|
return null
|
|
}
|
|
|
|
const kind = data[index + 1]
|
|
|
|
if (kind === '[') {
|
|
for (let i = index + 2; i < data.length; i += 1) {
|
|
const code = data.charCodeAt(i)
|
|
|
|
if (code >= 0x40 && code <= 0x7e) {
|
|
return data.slice(index, i + 1)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (kind === ']') {
|
|
for (let i = index + 2; i < data.length; i += 1) {
|
|
if (data.charCodeAt(i) === 0x07) {
|
|
return data.slice(index, i + 1)
|
|
}
|
|
|
|
if (data.charCodeAt(i) === 0x1b && data[i + 1] === '\\') {
|
|
return data.slice(index, i + 2)
|
|
}
|
|
}
|
|
}
|
|
|
|
return data.slice(index, Math.min(index + 2, data.length))
|
|
}
|
|
|
|
function stripEscapeSequences(data: string) {
|
|
let index = 0
|
|
let text = ''
|
|
|
|
while (index < data.length) {
|
|
const sequence = readEscapeSequence(data, index)
|
|
|
|
if (sequence) {
|
|
index += sequence.length
|
|
} else {
|
|
text += data[index]
|
|
index += 1
|
|
}
|
|
}
|
|
|
|
return text
|
|
}
|
|
|
|
// Keep only the ANSI escape sequences from a chunk, dropping printable text. Lets
|
|
// us apply control codes (e.g. a clear-screen) while discarding boot spacers and
|
|
// zsh's reverse-video "%" partial-line marker.
|
|
function keepEscapeSequences(data: string) {
|
|
let index = 0
|
|
let out = ''
|
|
|
|
while (index < data.length) {
|
|
if (data.charCodeAt(index) === 0x1b) {
|
|
const sequence = readEscapeSequence(data, index)
|
|
|
|
if (sequence) {
|
|
out += sequence
|
|
index += sequence.length
|
|
|
|
continue
|
|
}
|
|
}
|
|
|
|
index += 1
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
function stripInitialPromptGap(data: string) {
|
|
let index = 0
|
|
let prefix = ''
|
|
|
|
while (index < data.length) {
|
|
const sequence = readEscapeSequence(data, index)
|
|
|
|
if (sequence) {
|
|
prefix += sequence
|
|
index += sequence.length
|
|
} else if (data[index] === '\r' || data[index] === '\n') {
|
|
index += 1
|
|
} else {
|
|
return prefix + data.slice(index)
|
|
}
|
|
}
|
|
|
|
return prefix
|
|
}
|
|
|
|
interface UseTerminalSessionOptions {
|
|
cwd: string
|
|
onAddSelectionToChat: (text: string, label?: string) => void
|
|
}
|
|
|
|
// Bind the palette to the live skin surface so the terminal blends with the app
|
|
// (and the contrast clamp has a real background to work against).
|
|
function withSurface(theme: ReturnType<typeof terminalTheme>) {
|
|
const surface = resolveSurfaceColor(theme.background ?? '#ffffff')
|
|
|
|
return { ...theme, background: surface, cursorAccent: surface }
|
|
}
|
|
|
|
function transferHasDropCandidates(t: DataTransfer): boolean {
|
|
if (t.types?.includes(HERMES_PATHS_MIME)) {
|
|
return true
|
|
}
|
|
|
|
if ((t.files?.length ?? 0) > 0) {
|
|
return true
|
|
}
|
|
|
|
for (let i = 0; i < (t.items?.length ?? 0); i += 1) {
|
|
if (t.items[i]?.kind === 'file') {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
function collectDroppedPaths(t: DataTransfer): string[] {
|
|
const seen = new Set<string>()
|
|
|
|
const push = (value: unknown) => {
|
|
if (typeof value !== 'string') {
|
|
return
|
|
}
|
|
|
|
const path = value.trim()
|
|
|
|
if (path) {
|
|
seen.add(path)
|
|
}
|
|
}
|
|
|
|
try {
|
|
const raw = t.getData(HERMES_PATHS_MIME)
|
|
|
|
if (raw) {
|
|
for (const entry of JSON.parse(raw) as { path?: unknown }[]) {
|
|
push(entry?.path)
|
|
}
|
|
}
|
|
} catch {
|
|
// Malformed in-app drag payload — fall through to OS files.
|
|
}
|
|
|
|
const getPath = window.hermesDesktop?.getPathForFile
|
|
|
|
const addFile = (file: File | null) => {
|
|
if (!file || !getPath) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
push(getPath(file))
|
|
} catch {
|
|
// File handle unavailable.
|
|
}
|
|
}
|
|
|
|
for (let i = 0; i < (t.files?.length ?? 0); i += 1) {
|
|
addFile(t.files.item(i))
|
|
}
|
|
|
|
for (let i = 0; i < (t.items?.length ?? 0); i += 1) {
|
|
const item = t.items[i]
|
|
|
|
if (item?.kind === 'file') {
|
|
addFile(item.getAsFile())
|
|
}
|
|
}
|
|
|
|
return [...seen]
|
|
}
|
|
|
|
function quotePathForShell(path: string, shellName: string): string {
|
|
const shell = shellName.toLowerCase()
|
|
|
|
if (shell.includes('powershell') || shell.includes('pwsh')) {
|
|
return `'${path.replace(/'/g, "''")}'`
|
|
}
|
|
|
|
if (shell.includes('cmd')) {
|
|
return `"${path.replace(/"/g, '""')}"`
|
|
}
|
|
|
|
return `'${path.replace(/'/g, "'\\''")}'`
|
|
}
|
|
|
|
export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSessionOptions) {
|
|
// Key off renderedMode (the painted surface type), not resolvedMode (the
|
|
// clicked switch) — a skin can keep a light surface in "dark" mode, and we
|
|
// must match the surface or the ANSI palette inverts against it. themeName
|
|
// re-resolves the canvas surface on skin switches (same mode, new tint).
|
|
const { renderedMode, theme, themeName } = useTheme()
|
|
// Adopt the skin's ANSI palette when it ships one (imported VS Code themes do),
|
|
// matched to the painted variant; built-in skins carry none, so the terminal
|
|
// keeps its VS Code defaults. withSurface still owns the background, so this
|
|
// never touches transparency.
|
|
const ansiPalette = renderedMode === 'dark' ? (theme.darkTerminal ?? theme.terminal) : theme.terminal
|
|
const activeTheme = useMemo(() => terminalTheme(renderedMode, ansiPalette), [renderedMode, ansiPalette])
|
|
const initialThemeRef = useRef(activeTheme)
|
|
const hostRef = useRef<HTMLDivElement | null>(null)
|
|
const termRef = useRef<Terminal | null>(null)
|
|
const webglRef = useRef<WebglAddon | null>(null)
|
|
const sessionIdRef = useRef<string | null>(null)
|
|
const shellNameRef = useRef('shell')
|
|
const selectionLabelRef = useRef('')
|
|
const selectionRef = useRef('')
|
|
const onAddSelectionToChatRef = useRef(onAddSelectionToChat)
|
|
const [status, setStatus] = useState<TerminalStatus>('starting')
|
|
const [selection, setSelection] = useState('')
|
|
const [selectionStyle, setSelectionStyle] = useState<CSSProperties | null>(null)
|
|
const [shellName, setShellName] = useState('shell')
|
|
|
|
useEffect(() => {
|
|
onAddSelectionToChatRef.current = onAddSelectionToChat
|
|
}, [onAddSelectionToChat])
|
|
|
|
// Live selection at call time. A redraw-heavy TUI (spinners, clocks) outruns
|
|
// onSelectionChange, so trust xterm directly — fall back to the native
|
|
// selection — rather than the cached ref / React state.
|
|
const readSelection = useCallback(
|
|
() => termRef.current?.getSelection() || window.getSelection()?.toString() || '',
|
|
[]
|
|
)
|
|
|
|
const addSelectionToChat = useCallback(() => {
|
|
const termSelection = (termRef.current?.getSelection() || selectionRef.current).trim()
|
|
const selectedText = termSelection || window.getSelection()?.toString() || ''
|
|
const trimmed = selectedText.trim()
|
|
|
|
if (!trimmed) {
|
|
return
|
|
}
|
|
|
|
// Terminal selection → shell-anchored label; anything else came from the
|
|
// preview pane sharing this global shortcut → label it with the file.
|
|
const label = termSelection
|
|
? selectionLabelRef.current ||
|
|
(termRef.current ? terminalSelectionLabel(termRef.current, shellNameRef.current, selectedText) : 'selection')
|
|
: previewSelectionLabel() || 'selection'
|
|
|
|
onAddSelectionToChatRef.current(trimmed, label)
|
|
termRef.current?.clearSelection()
|
|
selectionRef.current = ''
|
|
selectionLabelRef.current = ''
|
|
setSelection('')
|
|
setSelectionStyle(null)
|
|
triggerHaptic('selection')
|
|
}, [])
|
|
|
|
// Always listen — gating on the React selection state misses selections the
|
|
// TUI redraw races. Only swallow ⌘/Ctrl+L when there's text to send, else it
|
|
// must reach the shell as clear-screen.
|
|
useEffect(() => {
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
if (!isAddSelectionShortcut(event) || !readSelection().trim()) {
|
|
return
|
|
}
|
|
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
addSelectionToChat()
|
|
}
|
|
|
|
window.addEventListener('keydown', onKeyDown, { capture: true })
|
|
|
|
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
|
|
}, [addSelectionToChat, readSelection])
|
|
|
|
useEffect(() => {
|
|
const host = hostRef.current
|
|
const terminalApi = window.hermesDesktop?.terminal
|
|
|
|
if (!host || !terminalApi) {
|
|
setStatus('closed')
|
|
|
|
return
|
|
}
|
|
|
|
let disposed = false
|
|
const cleanup: Array<() => void> = []
|
|
let lastSentSize: { cols: number; rows: number } | null = null
|
|
|
|
const term = new Terminal({
|
|
allowProposedApi: true,
|
|
allowTransparency: true,
|
|
convertEol: true,
|
|
cursorBlink: true,
|
|
fontFamily: "'JetBrains Mono', 'Cascadia Code', 'SF Mono', Menlo, Consolas, monospace",
|
|
fontSize: 11,
|
|
fontWeight: '400',
|
|
fontWeightBold: '700',
|
|
letterSpacing: 0,
|
|
lineHeight: 1.12,
|
|
// Full-screen TUIs (hermes --tui, vim) grab the mouse, so a plain drag
|
|
// can't select — ⌥-drag (macOS) / Shift-drag (else) forces a native
|
|
// selection over mouse-mode apps, which ⌘/Ctrl+L then sends to chat.
|
|
macOptionClickForcesSelection: true,
|
|
macOptionIsMeta: true,
|
|
// VS Code/Cursor's secret sauce: terminal.integrated.minimumContrastRatio
|
|
// defaults to 4.5 there. xterm defaults to 1 (off), which paints the raw
|
|
// saturated ANSI palette — vivid green/cyan on white reads as candy.
|
|
// Clamping to 4.5:1 darkens/lightens foregrounds against the background
|
|
// at render time, matching the muted ink-like look of their terminal.
|
|
minimumContrastRatio: 4.5,
|
|
scrollback: 1000,
|
|
theme: withSurface(initialThemeRef.current)
|
|
})
|
|
|
|
const fit = new FitAddon()
|
|
|
|
termRef.current = term
|
|
term.loadAddon(fit)
|
|
term.loadAddon(new Unicode11Addon())
|
|
term.loadAddon(new WebLinksAddon())
|
|
term.unicode.activeVersion = '11'
|
|
|
|
// Let the GUI chat agent read this pane via the `read_terminal` tool: the
|
|
// gateway's terminal.read.request handler serializes the buffer through this.
|
|
setActiveTerminalReader(makeTerminalReader(term))
|
|
|
|
const onDragOver = (e: DragEvent) => {
|
|
if (!e.dataTransfer || !transferHasDropCandidates(e.dataTransfer)) {
|
|
return
|
|
}
|
|
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
e.dataTransfer.dropEffect = 'copy'
|
|
}
|
|
|
|
const onDrop = (e: DragEvent) => {
|
|
const id = sessionIdRef.current
|
|
|
|
if (!id || !e.dataTransfer || !transferHasDropCandidates(e.dataTransfer)) {
|
|
return
|
|
}
|
|
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
const paths = collectDroppedPaths(e.dataTransfer)
|
|
|
|
if (!paths.length) {
|
|
return
|
|
}
|
|
|
|
void terminalApi.write(id, `${paths.map(p => quotePathForShell(p, shellNameRef.current)).join(' ')} `)
|
|
term.focus()
|
|
triggerHaptic('selection')
|
|
}
|
|
|
|
host.addEventListener('dragenter', onDragOver)
|
|
host.addEventListener('dragover', onDragOver)
|
|
host.addEventListener('drop', onDrop)
|
|
cleanup.push(() => {
|
|
host.removeEventListener('dragenter', onDragOver)
|
|
host.removeEventListener('dragover', onDragOver)
|
|
host.removeEventListener('drop', onDrop)
|
|
})
|
|
|
|
// A fresh prompt should sit at the top. Every resize SIGWINCHes the shell,
|
|
// which reprints its prompt and can leave stale blank rows above it. While
|
|
// the session is pristine (nothing run yet) we ask the shell to clear +
|
|
// redraw via Ctrl-L (\f) after the resize settles. Ctrl-L preserves
|
|
// multi-line prompts (term.clear() would drop all but the cursor row) and we
|
|
// stop the moment real output exists, so command scrollback is never wiped.
|
|
let promptPristine = true
|
|
let gapCleanupTimer = 0
|
|
|
|
// While armed, strip leading blank rows so the prompt lands at the very top
|
|
// (no starship `add_newline` gap). Re-armed before each Ctrl-L redraw so the
|
|
// resize cleanup doesn't reintroduce the blank line.
|
|
let stripLeading = true
|
|
|
|
const armedWrite = (data: string) => {
|
|
if (!stripLeading) {
|
|
term.write(data)
|
|
|
|
return
|
|
}
|
|
|
|
const next = stripInitialPromptGap(data)
|
|
const visible = stripEscapeSequences(next).replace(/[\s%]/g, '')
|
|
|
|
if (!visible) {
|
|
// Spacer / lone clear-screen / zsh `%` marker: apply control codes but
|
|
// drop the blank text and stay armed so the prompt still lands at top.
|
|
const controls = keepEscapeSequences(next)
|
|
|
|
if (controls) {
|
|
term.write(controls)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
stripLeading = false
|
|
term.write(next)
|
|
}
|
|
|
|
const scheduleGapCleanup = () => {
|
|
if (!promptPristine) {
|
|
return
|
|
}
|
|
|
|
if (gapCleanupTimer) {
|
|
window.clearTimeout(gapCleanupTimer)
|
|
}
|
|
|
|
gapCleanupTimer = window.setTimeout(() => {
|
|
gapCleanupTimer = 0
|
|
const id = sessionIdRef.current
|
|
|
|
if (disposed || !id || !promptPristine) {
|
|
return
|
|
}
|
|
|
|
stripLeading = true
|
|
void terminalApi.write(id, '\f')
|
|
term.clearSelection()
|
|
}, 120)
|
|
}
|
|
|
|
cleanup.push(() => {
|
|
if (gapCleanupTimer) {
|
|
window.clearTimeout(gapCleanupTimer)
|
|
}
|
|
})
|
|
|
|
const fitAndResize = () => {
|
|
if (disposed || !host.isConnected || host.clientWidth <= 0 || host.clientHeight <= 0) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
fit.fit()
|
|
} catch {
|
|
return
|
|
}
|
|
|
|
const id = sessionIdRef.current
|
|
|
|
if (id && (lastSentSize?.cols !== term.cols || lastSentSize?.rows !== term.rows)) {
|
|
lastSentSize = { cols: term.cols, rows: term.rows }
|
|
void terminalApi.resize(id, { cols: term.cols, rows: term.rows })
|
|
scheduleGapCleanup()
|
|
}
|
|
}
|
|
|
|
// Coalesce ResizeObserver bursts through rAF — running fit.fit()
|
|
// synchronously while sibling panes are mid-transition (e.g. file browser
|
|
// collapsing to 0px) crashes the WebGL renderer mid texture-atlas rebuild.
|
|
let pendingFrame = 0
|
|
|
|
const scheduleResize = () => {
|
|
if (pendingFrame) {
|
|
return
|
|
}
|
|
|
|
pendingFrame = window.requestAnimationFrame(() => {
|
|
pendingFrame = 0
|
|
|
|
if (!disposed) {
|
|
fitAndResize()
|
|
}
|
|
})
|
|
}
|
|
|
|
const resizeObserver = new ResizeObserver(scheduleResize)
|
|
resizeObserver.observe(host)
|
|
cleanup.push(() => {
|
|
resizeObserver.disconnect()
|
|
|
|
if (pendingFrame) {
|
|
window.cancelAnimationFrame(pendingFrame)
|
|
}
|
|
})
|
|
|
|
const dataDisposable = term.onData(data => {
|
|
const id = sessionIdRef.current
|
|
|
|
if (id) {
|
|
// Once the user submits a line, real output may follow — stop the
|
|
// pristine-prompt gap cleanup so we never clear command scrollback.
|
|
if (promptPristine && data.includes('\r')) {
|
|
promptPristine = false
|
|
}
|
|
|
|
void terminalApi.write(id, data)
|
|
}
|
|
})
|
|
|
|
cleanup.push(() => dataDisposable.dispose())
|
|
|
|
const selectionDisposable = term.onSelectionChange(() => {
|
|
const next = term.getSelection()
|
|
selectionRef.current = next
|
|
selectionLabelRef.current = next.trim() ? terminalSelectionLabel(term, shellNameRef.current, next) : ''
|
|
setSelection(next)
|
|
setSelectionStyle(next.trim() ? terminalSelectionAnchor(host) : null)
|
|
})
|
|
|
|
cleanup.push(() => selectionDisposable.dispose())
|
|
|
|
const startSession = () =>
|
|
void terminalApi
|
|
.start({ cols: term.cols, cwd, rows: term.rows })
|
|
.then(session => {
|
|
if (disposed) {
|
|
void terminalApi.dispose(session.id)
|
|
|
|
return
|
|
}
|
|
|
|
sessionIdRef.current = session.id
|
|
lastSentSize = { cols: term.cols, rows: term.rows }
|
|
shellNameRef.current = session.shell || 'shell'
|
|
setShellName(session.shell || 'shell')
|
|
|
|
const initial = term.hasSelection() ? term.getSelection() : ''
|
|
selectionRef.current = initial
|
|
selectionLabelRef.current = initial ? terminalSelectionLabel(term, shellNameRef.current, initial) : ''
|
|
|
|
setStatus('open')
|
|
|
|
cleanup.push(
|
|
terminalApi.onData(session.id, armedWrite),
|
|
terminalApi.onExit(session.id, ({ code, signal }) => {
|
|
setStatus('closed')
|
|
term.write(`\r\n[terminal exited${signal ? `: ${signal}` : code !== null ? `: ${code}` : ''}]\r\n`)
|
|
})
|
|
)
|
|
|
|
window.requestAnimationFrame(() => {
|
|
fitAndResize()
|
|
term.clearSelection() // drop any selection painted over transient boot rows
|
|
term.focus()
|
|
})
|
|
})
|
|
.catch(error => {
|
|
setStatus('closed')
|
|
term.write(`Terminal failed to start: ${error instanceof Error ? error.message : String(error)}\r\n`)
|
|
})
|
|
|
|
// Open + fit + start only once webfonts settle. Fitting with fallback metrics
|
|
// picks the wrong row count, the shell boots at that size, then the real font
|
|
// loads -> refit -> SIGWINCH -> the shell reprints its prompt lower, leaving
|
|
// stale blank rows (and a stray selection) above it.
|
|
const mount = () => {
|
|
if (disposed || !host.isConnected) {
|
|
return
|
|
}
|
|
|
|
term.open(host)
|
|
term.focus()
|
|
|
|
// WebGL renderer matches the dashboard ChatPage path; xterm's default DOM
|
|
// renderer paints SGR via CSS classes that visibly mute against our skins.
|
|
try {
|
|
const webgl = new WebglAddon()
|
|
webgl.onContextLoss(() => {
|
|
webgl.dispose()
|
|
webglRef.current = null
|
|
})
|
|
term.loadAddon(webgl)
|
|
webglRef.current = webgl
|
|
} catch (err) {
|
|
console.warn('[hermes-terminal] WebGL unavailable; falling back to DOM', err)
|
|
}
|
|
|
|
fitAndResize()
|
|
startSession()
|
|
}
|
|
|
|
// fonts.ready settles only already-requested faces; bold/italic aren't asked
|
|
// for until styled output paints (past atlas init), so warm them up front.
|
|
const warm = document.fonts?.load
|
|
? Promise.allSettled(['400', '700', 'italic 400'].map(v => document.fonts.load(`${v} 11px 'JetBrains Mono'`)))
|
|
: Promise.resolve()
|
|
|
|
void warm.then(mount, mount)
|
|
|
|
return () => {
|
|
disposed = true
|
|
cleanup.forEach(run => run())
|
|
setActiveTerminalReader(null)
|
|
|
|
const id = sessionIdRef.current
|
|
sessionIdRef.current = null
|
|
|
|
if (id) {
|
|
void terminalApi.dispose(id)
|
|
}
|
|
|
|
term.dispose()
|
|
termRef.current = null
|
|
webglRef.current = null
|
|
shellNameRef.current = 'shell'
|
|
selectionRef.current = ''
|
|
selectionLabelRef.current = ''
|
|
}
|
|
}, [addSelectionToChat, cwd])
|
|
|
|
useEffect(() => {
|
|
const term = termRef.current
|
|
|
|
if (!term) {
|
|
return
|
|
}
|
|
|
|
// Re-resolve the surface in a rAF: ThemeProvider's applyTheme repaints the
|
|
// CSS vars in a sibling effect that runs after this one, so reading now
|
|
// would lag a mode behind. By the next frame the vars are current.
|
|
const raf = requestAnimationFrame(() => {
|
|
term.options.theme = withSurface(activeTheme)
|
|
// The WebGL renderer caches glyph colors in a texture atlas, so a
|
|
// light/dark switch leaves already-drawn cells stale until the atlas is
|
|
// cleared. No-op for the DOM fallback.
|
|
webglRef.current?.clearTextureAtlas()
|
|
})
|
|
|
|
return () => cancelAnimationFrame(raf)
|
|
}, [activeTheme, themeName])
|
|
|
|
return {
|
|
addSelectionToChat,
|
|
hostRef,
|
|
selection,
|
|
selectionStyle,
|
|
shellName,
|
|
status
|
|
}
|
|
}
|