feat(electrobun): terminal pane — discriminated union tabs, agent preview, collapse/expand, bash output ring buffer
This commit is contained in:
parent
ec12310801
commit
0e217b9dae
6 changed files with 221 additions and 104 deletions
10
.claude/settings.json
Normal file
10
.claude/settings.json
Normal file
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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<typeof setInterval> | 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();
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Wrapper: uses flex to push tab bar to bottom when terminal is collapsed -->
|
||||
<div class="term-wrapper" style="--accent: {accent}">
|
||||
<!-- Wrapper: flex shrinks to tab bar height when collapsed, fills space when expanded -->
|
||||
<div
|
||||
class="term-wrapper"
|
||||
style="--accent: {accent}"
|
||||
style:flex={getTerminals().expanded ? '1' : '0 0 auto'}
|
||||
>
|
||||
<!-- Tab bar: always visible, acts as divider -->
|
||||
<div class="term-bar" role="toolbar" aria-label="Terminal tabs" tabindex="-1" onmousedown={blurTerminal}>
|
||||
<button
|
||||
|
|
@ -71,6 +84,7 @@
|
|||
<div
|
||||
class="term-tab"
|
||||
class:active={getTerminals().activeTabId === tab.id}
|
||||
class:preview={tab.kind === 'agentPreview'}
|
||||
role="tab"
|
||||
tabindex={getTerminals().activeTabId === tab.id ? 0 : -1}
|
||||
aria-selected={getTerminals().activeTabId === tab.id}
|
||||
|
|
@ -78,7 +92,7 @@
|
|||
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') activateTab(tab.id); }}
|
||||
>
|
||||
<span class="tab-label">{tab.title}</span>
|
||||
{#if getTerminals().tabs.length > 1}
|
||||
{#if tab.kind === 'pty'}
|
||||
<button
|
||||
class="tab-close"
|
||||
aria-label="Close {tab.title}"
|
||||
|
|
@ -87,17 +101,37 @@
|
|||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<button class="tab-add" onclick={() => addTab()}>+</button>
|
||||
<button class="tab-add" onclick={() => addTab()} title="New terminal">+</button>
|
||||
<button
|
||||
class="tab-eye"
|
||||
class:active={hasPreview()}
|
||||
onclick={togglePreview}
|
||||
title={hasPreview() ? 'Hide agent preview' : 'Show agent preview'}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Terminal panes: always in DOM, display toggled.
|
||||
WebKitGTK corrupts hit-test tree on DOM add/remove during click. -->
|
||||
<div class="term-panes" style:display={getTerminals().expanded ? 'block' : 'none'}>
|
||||
<!-- Empty state placeholder (always in DOM, toggled via display) -->
|
||||
<div class="term-empty" style:display={getTerminals().tabs.length === 0 ? 'flex' : 'none'}>
|
||||
No terminals — click + to add one
|
||||
</div>
|
||||
{#each getTerminals().tabs as tab (tab.id)}
|
||||
{#if getTerminals().mounted.has(tab.id)}
|
||||
<div class="term-pane" style:display={getTerminals().activeTabId === tab.id ? 'flex' : 'none'}>
|
||||
<Terminal sessionId={tab.id} {cwd} />
|
||||
<Terminal
|
||||
sessionId={tab.id}
|
||||
{cwd}
|
||||
readonly={tab.kind === 'agentPreview'}
|
||||
{projectId}
|
||||
/>
|
||||
</div>
|
||||
{/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;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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<K extends keyof FileState>(
|
||||
|
|
|
|||
|
|
@ -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<string>;
|
||||
/** 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 ─────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue