From 0e217b9daecb75db4a4d264453c3fa30641cea17 Mon Sep 17 00:00:00 2001 From: Hibryda Date: Fri, 27 Mar 2026 02:31:43 +0100 Subject: [PATCH] =?UTF-8?q?feat(electrobun):=20terminal=20pane=20=E2=80=94?= =?UTF-8?q?=20discriminated=20union=20tabs,=20agent=20preview,=20collapse/?= =?UTF-8?q?expand,=20bash=20output=20ring=20buffer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.json | 10 + ui-electrobun/src/mainview/Terminal.svelte | 199 ++++++++++-------- .../src/mainview/TerminalTabs.svelte | 62 +++++- .../src/mainview/app-state.svelte.ts | 3 + .../src/mainview/project-state.svelte.ts | 40 +++- .../src/mainview/project-state.types.ts | 11 +- 6 files changed, 221 insertions(+), 104 deletions(-) create mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..8537c2c --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "mcp__agor-launcher__agor-status", + "mcp__agor-launcher__agor-kill-stale", + "mcp__agor-launcher__agor-stop", + "mcp__agor-launcher__agor-start" + ] + } +} diff --git a/ui-electrobun/src/mainview/Terminal.svelte b/ui-electrobun/src/mainview/Terminal.svelte index b2fa5cc..d7cf397 100644 --- a/ui-electrobun/src/mainview/Terminal.svelte +++ b/ui-electrobun/src/mainview/Terminal.svelte @@ -2,23 +2,23 @@ import { onMount, onDestroy } from 'svelte'; import { Terminal } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; - // CanvasAddon and ImageAddon require xterm ^5.0.0 — disabled on xterm 6.x - // xterm 6's default renderer uses DOM (no Canvas/WebGL needed) - // TODO: re-enable when @xterm/addon-canvas releases a 6.x-compatible version - // import { CanvasAddon } from '@xterm/addon-canvas'; - // import { ImageAddon } from '@xterm/addon-image'; import { appRpc } from './rpc.ts'; import { fontStore } from './font-store.svelte.ts'; import { themeStore } from './theme-store.svelte.ts'; import { getXtermTheme } from './themes.ts'; + import { appState } from './app-state.svelte.ts'; interface Props { sessionId: string; /** Working directory to open the shell in. */ cwd?: string; + /** When true, terminal is read-only (agent preview). No PTY, no stdin. */ + readonly?: boolean; + /** Project ID — required for agent preview to read bash output. */ + projectId?: string; } - let { sessionId, cwd }: Props = $props(); + let { sessionId, cwd, readonly = false, projectId }: Props = $props(); let termEl: HTMLDivElement; let term: Terminal; @@ -26,8 +26,10 @@ let unsubFont: (() => void) | null = null; let ro: ResizeObserver | null = null; let destroyed = false; - // Fix #5: Store listener cleanup functions to prevent leaks let listenerCleanups: Array<() => void> = []; + /** Index into bashOutputLines — tracks how many lines have been written to xterm. */ + let writtenIndex = 0; + let bashPollTimer: ReturnType | null = null; /** Decode a base64 string from the daemon into a Uint8Array. */ function decodeBase64(b64: string): Uint8Array { @@ -37,6 +39,17 @@ return bytes; } + /** Write new bash output lines to the readonly terminal. */ + function flushBashLines(): void { + if (!projectId || !term) return; + const t = appState.project.getState(projectId).terminals; + const lines = t.bashOutputLines; + while (writtenIndex < lines.length) { + term.writeln(lines[writtenIndex]); + writtenIndex++; + } + } + onMount(() => { const currentTheme = themeStore.currentTheme; const termFamily = fontStore.termFontFamily || 'JetBrains Mono, Fira Code, monospace'; @@ -46,20 +59,21 @@ theme: getXtermTheme(currentTheme), fontFamily: termFamily, fontSize: termSize, - cursorBlink: true, + cursorBlink: !readonly, allowProposedApi: true, scrollback: 5000, + disableStdin: readonly, }); + if (readonly) { + term.attachCustomKeyEventHandler(() => false); + } + fitAddon = new FitAddon(); term.loadAddon(fitAddon); - // NOTE: CanvasAddon and ImageAddon MUST be loaded AFTER term.open() - // because they access _linkifier2 which is created during open() const openAndLoadAddons = () => { term.open(termEl); - // xterm 6.x uses improved default DOM renderer — no Canvas/WebGL addon needed - // Re-enable when addons release 6.x-compatible versions fitAddon.fit(); }; @@ -78,99 +92,102 @@ } // ── Read cursor/scrollback settings ───────────────────────────────── - - void (async () => { - try { - const { settings } = await appRpc.request['settings.getAll']({}); - if (settings['cursor_style']) { - const style = settings['cursor_style']; - if (style === 'block' || style === 'underline' || style === 'bar') { - term.options.cursorStyle = style; + if (!readonly) { + void (async () => { + try { + const { settings } = await appRpc.request['settings.getAll']({}); + if (settings['cursor_style']) { + const style = settings['cursor_style']; + if (style === 'block' || style === 'underline' || style === 'bar') { + term.options.cursorStyle = style; + } } - } - if (settings['cursor_blink'] === 'false') { - term.options.cursorBlink = false; - } - if (settings['scrollback']) { - const sb = parseInt(settings['scrollback'], 10); - if (!isNaN(sb) && sb >= 0) term.options.scrollback = sb; - } - } catch { /* non-critical — use defaults */ } - })(); + if (settings['cursor_blink'] === 'false') { + term.options.cursorBlink = false; + } + if (settings['scrollback']) { + const sb = parseInt(settings['scrollback'], 10); + if (!isNaN(sb) && sb >= 0) term.options.scrollback = sb; + } + } catch { /* non-critical — use defaults */ } + })(); + } // ── Subscribe to terminal font changes ───────────────────────────────── - unsubFont = fontStore.onTermFontChange((family: string, size: number) => { term.options.fontFamily = family || 'JetBrains Mono, Fira Code, monospace'; term.options.fontSize = size; fitAddon.fit(); - appRpc.request['pty.resize']({ sessionId, cols: term.cols, rows: term.rows }).catch(() => {}); + if (!readonly) { + appRpc.request['pty.resize']({ sessionId, cols: term.cols, rows: term.rows }).catch(() => {}); + } }); - // ── Connect to PTY daemon (fire-and-forget from onMount) ─────────────── + if (readonly) { + // ── Agent preview mode: read bash output from project state ────────── + flushBashLines(); + bashPollTimer = setInterval(flushBashLines, 500); + } else { + // ── Connect to PTY daemon (fire-and-forget from onMount) ───────────── + void (async () => { + let effectiveCwd = cwd; + try { + const { settings } = await appRpc.request['settings.getAll']({}); + if (!effectiveCwd && settings['default_cwd']) { + effectiveCwd = settings['default_cwd']; + } + } catch { /* use provided or defaults */ } - void (async () => { - // Read default_shell and default_cwd from settings if not provided - let effectiveCwd = cwd; - try { - const { settings } = await appRpc.request['settings.getAll']({}); - if (!effectiveCwd && settings['default_cwd']) { - effectiveCwd = settings['default_cwd']; + const { cols, rows } = term; + const result = await appRpc.request['pty.create']({ sessionId, cols, rows, cwd: effectiveCwd }); + if (!result?.ok) { + term.writeln(`\x1b[31m[agor] Failed to connect to PTY daemon: ${result?.error ?? 'unknown error'}\x1b[0m`); + term.writeln('\x1b[33m[agor] Is agor-ptyd running? Start it with: agor-ptyd\x1b[0m'); } - // default_shell is handled by agor-ptyd, not needed in create params - } catch { /* use provided or defaults */ } + })(); - const { cols, rows } = term; - const result = await appRpc.request['pty.create']({ sessionId, cols, rows, cwd: effectiveCwd }); - if (!result?.ok) { - term.writeln(`\x1b[31m[agor] Failed to connect to PTY daemon: ${result?.error ?? 'unknown error'}\x1b[0m`); - term.writeln('\x1b[33m[agor] Is agor-ptyd running? Start it with: agor-ptyd\x1b[0m'); - } - })(); + // ── Receive output from daemon ───────────────────────────────────────── + const outputHandler = ({ sessionId: sid, data }: { sessionId: string; data: string }) => { + if (destroyed || sid !== sessionId) return; + term.write(decodeBase64(data)); + }; + appRpc.addMessageListener('pty.output', outputHandler); - // ── Receive output from daemon ───────────────────────────────────────── + const closedHandler = ({ sessionId: sid, exitCode }: { sessionId: string; exitCode: number | null }) => { + if (destroyed || sid !== sessionId) return; + term.writeln(`\r\n\x1b[90m[Process exited${exitCode !== null ? ` with code ${exitCode}` : ''}]\x1b[0m`); + }; + appRpc.addMessageListener('pty.closed', closedHandler); - const outputHandler = ({ sessionId: sid, data }: { sessionId: string; data: string }) => { - if (destroyed || sid !== sessionId) return; - term.write(decodeBase64(data)); - }; - appRpc.addMessageListener('pty.output', outputHandler); + listenerCleanups.push( + () => appRpc.removeMessageListener?.('pty.output', outputHandler), + () => appRpc.removeMessageListener?.('pty.closed', closedHandler), + ); - const closedHandler = ({ sessionId: sid, exitCode }: { sessionId: string; exitCode: number | null }) => { - if (destroyed || sid !== sessionId) return; - term.writeln(`\r\n\x1b[90m[Process exited${exitCode !== null ? ` with code ${exitCode}` : ''}]\x1b[0m`); - }; - appRpc.addMessageListener('pty.closed', closedHandler); - - // Fix #5: Store cleanup functions for message listeners - listenerCleanups.push( - () => appRpc.removeMessageListener?.('pty.output', outputHandler), - () => appRpc.removeMessageListener?.('pty.closed', closedHandler), - ); - - // ── Send user input to daemon ────────────────────────────────────────── - - // Feature 5: Max terminal paste chunk (64KB) — truncate with warning - const MAX_PASTE_CHUNK = 64 * 1024; - term.onData((data: string) => { - let payload = data; - if (payload.length > MAX_PASTE_CHUNK) { - console.warn(`[terminal] Paste truncated from ${payload.length} to ${MAX_PASTE_CHUNK} bytes`); - payload = payload.slice(0, MAX_PASTE_CHUNK); - term.writeln('\r\n\x1b[33m[agor] Paste truncated to 64KB\x1b[0m'); - } - appRpc.request['pty.write']({ sessionId, data: payload }).catch((err: unknown) => { - console.error('[pty.write] error:', err); + // ── Send user input to daemon ────────────────────────────────────────── + const MAX_PASTE_CHUNK = 64 * 1024; + term.onData((data: string) => { + let payload = data; + if (payload.length > MAX_PASTE_CHUNK) { + console.warn(`[terminal] Paste truncated from ${payload.length} to ${MAX_PASTE_CHUNK} bytes`); + payload = payload.slice(0, MAX_PASTE_CHUNK); + term.writeln('\r\n\x1b[33m[agor] Paste truncated to 64KB\x1b[0m'); + } + appRpc.request['pty.write']({ sessionId, data: payload }).catch((err: unknown) => { + console.error('[pty.write] error:', err); + }); }); + + // ── Sync resize events to daemon ─────────────────────────────────────── + term.onResize(({ cols: c, rows: r }) => { + appRpc.request['pty.resize']({ sessionId, cols: c, rows: r }).catch(() => {}); + }); + } + + // ── ResizeObserver: re-fit on container resize + visibility ──────────── + ro = new ResizeObserver(() => { + requestAnimationFrame(() => { fitAddon.fit(); }); }); - - // ── Sync resize events to daemon ─────────────────────────────────────── - - term.onResize(({ cols: c, rows: r }) => { - appRpc.request['pty.resize']({ sessionId, cols: c, rows: r }).catch(() => {}); - }); - - ro = new ResizeObserver(() => { fitAddon.fit(); }); ro.observe(termEl); }); @@ -178,12 +195,14 @@ destroyed = true; unsubFont?.(); ro?.disconnect(); - // Fix #5: Clean up all message listeners to prevent leaks + if (bashPollTimer) clearInterval(bashPollTimer); for (const cleanup of listenerCleanups) { try { cleanup(); } catch { /* ignore */ } } listenerCleanups = []; - appRpc.request['pty.close']({ sessionId }).catch(() => {}); + if (!readonly) { + appRpc.request['pty.close']({ sessionId }).catch(() => {}); + } term?.dispose(); }); diff --git a/ui-electrobun/src/mainview/TerminalTabs.svelte b/ui-electrobun/src/mainview/TerminalTabs.svelte index 7eec51f..132191d 100644 --- a/ui-electrobun/src/mainview/TerminalTabs.svelte +++ b/ui-electrobun/src/mainview/TerminalTabs.svelte @@ -40,10 +40,23 @@ blurTerminal(); appState.project.terminals.toggleExpanded(projectId); } + + function togglePreview() { + blurTerminal(); + appState.project.terminals.toggleAgentPreview(projectId); + } + + function hasPreview(): boolean { + return getTerminals().tabs.some(t => t.kind === 'agentPreview'); + } - -
+ +
+ +
+ No terminals — click + to add one +
{#each getTerminals().tabs as tab (tab.id)} {#if getTerminals().mounted.has(tab.id)}
- +
{/if} {/each} @@ -108,7 +142,6 @@ .term-wrapper { display: flex; flex-direction: column; - flex: 1; min-height: 0; } @@ -169,6 +202,7 @@ } .term-tab:hover { color: var(--ctp-text); } .term-tab.active { color: var(--ctp-text); border-bottom-color: var(--accent); } + .term-tab.preview { font-style: italic; } .tab-label { pointer-events: none; } .tab-close { @@ -190,7 +224,7 @@ } .tab-close:hover { background: var(--ctp-surface1); color: var(--ctp-red); } - .tab-add { + .tab-add, .tab-eye { align-self: center; width: 1.25rem; height: 1.25rem; @@ -206,7 +240,9 @@ justify-content: center; margin-left: 0.125rem; } - .tab-add:hover { background: var(--ctp-surface0); color: var(--ctp-text); } + .tab-add:hover, .tab-eye:hover { background: var(--ctp-surface0); color: var(--ctp-text); } + .tab-eye svg { width: 0.75rem; height: 0.75rem; } + .tab-eye.active { color: var(--ctp-blue); border-color: var(--ctp-blue); } /* Terminal panes — fill remaining space below tab bar */ .term-panes { @@ -220,4 +256,14 @@ inset: 0; flex-direction: column; } + + .term-empty { + position: absolute; + inset: 0; + align-items: center; + justify-content: center; + color: var(--ctp-overlay0); + font-size: 0.8125rem; + font-style: italic; + } diff --git a/ui-electrobun/src/mainview/app-state.svelte.ts b/ui-electrobun/src/mainview/app-state.svelte.ts index 595b3a4..69be688 100644 --- a/ui-electrobun/src/mainview/app-state.svelte.ts +++ b/ui-electrobun/src/mainview/app-state.svelte.ts @@ -52,6 +52,7 @@ import { getProjectState, getActiveTab, isTabActivated, setActiveTab, addTerminalTab, closeTerminalTab, activateTerminalTab, toggleTerminalExpanded, + toggleAgentPreview, appendBashOutput, setFileState, setFileMulti, nextFileRequestToken, getFileRequestToken, setCommsState, setCommsMulti, setTaskState, setTaskMulti, nextTaskPollToken, getTaskPollToken, @@ -136,6 +137,8 @@ export const appState = { closeTab: closeTerminalTab, activateTab: activateTerminalTab, toggleExpanded: toggleTerminalExpanded, + toggleAgentPreview: toggleAgentPreview, + appendBashOutput: appendBashOutput, }, files: { diff --git a/ui-electrobun/src/mainview/project-state.svelte.ts b/ui-electrobun/src/mainview/project-state.svelte.ts index 755c664..355c7ac 100644 --- a/ui-electrobun/src/mainview/project-state.svelte.ts +++ b/ui-electrobun/src/mainview/project-state.svelte.ts @@ -26,15 +26,19 @@ let _version = $state(0); // ── Factory ─────────────────────────────────────────────────────────────── +const MAX_BASH_OUTPUT_LINES = 500; + function createProjectState(projectId: string): ProjectState { const firstTabId = `${projectId}-t1`; return { terminals: { - tabs: [{ id: firstTabId, title: 'shell 1' }], + tabs: [{ kind: 'pty', id: firstTabId, title: 'shell 1' }], activeTabId: firstTabId, expanded: true, nextId: 2, mounted: new Set([firstTabId]), + bashOutputLines: [], + bashLinesVersion: 0, }, files: { childrenCache: new Map(), @@ -127,7 +131,7 @@ export function setActiveTab(projectId: string, tab: ProjectTab): void { export function addTerminalTab(projectId: string): void { const t = ensureProject(projectId).terminals; const id = `${projectId}-t${t.nextId}`; - t.tabs = [...t.tabs, { id, title: `shell ${t.nextId}` }]; + t.tabs = [...t.tabs, { kind: 'pty', id, title: `shell ${t.nextId}` }]; t.nextId++; t.activeTabId = id; t.mounted.add(id); @@ -160,6 +164,38 @@ export function toggleTerminalExpanded(projectId: string): void { bump(); } +export function toggleAgentPreview(projectId: string): void { + const t = ensureProject(projectId).terminals; + const existing = t.tabs.find(tab => tab.kind === 'agentPreview'); + if (existing) { + // Remove the preview tab + t.tabs = t.tabs.filter(tab => tab.kind !== 'agentPreview'); + t.mounted.delete(existing.id); + if (t.activeTabId === existing.id) { + const next = t.tabs[0]; + t.activeTabId = next?.id ?? ''; + } + } else { + // Add a preview tab + const id = `${projectId}-preview`; + t.tabs = [...t.tabs, { kind: 'agentPreview', id, title: 'Agent Preview' }]; + t.activeTabId = id; + t.mounted.add(id); + if (!t.expanded) t.expanded = true; + } + bump(); +} + +export function appendBashOutput(projectId: string, line: string): void { + const t = ensureProject(projectId).terminals; + t.bashOutputLines.push(line); + if (t.bashOutputLines.length > MAX_BASH_OUTPUT_LINES) { + t.bashOutputLines.splice(0, t.bashOutputLines.length - MAX_BASH_OUTPUT_LINES); + } + t.bashLinesVersion++; + bump(); +} + // ── File actions ────────────────────────────────────────────────────────── export function setFileState( diff --git a/ui-electrobun/src/mainview/project-state.types.ts b/ui-electrobun/src/mainview/project-state.types.ts index d45fa74..4dd8cc9 100644 --- a/ui-electrobun/src/mainview/project-state.types.ts +++ b/ui-electrobun/src/mainview/project-state.types.ts @@ -10,10 +10,9 @@ export type { ProjectTab }; // ── Terminal ────────────────────────────────────────────────────────────── -export interface TermTab { - id: string; - title: string; -} +export type TermTab = + | { kind: 'pty'; id: string; title: string } + | { kind: 'agentPreview'; id: string; title: string }; export interface TerminalState { tabs: TermTab[]; @@ -21,6 +20,10 @@ export interface TerminalState { expanded: boolean; nextId: number; mounted: Set; + /** Ring buffer of bash tool_call output lines for agent preview. */ + bashOutputLines: string[]; + /** Bumped on every append — drives polling in readonly terminal. */ + bashLinesVersion: number; } // ── Files ─────────────────────────────────────────────────────────────────