feat(v3): agent preview terminal — read-only xterm.js tracking agent activity

This commit is contained in:
Hibryda 2026-03-08 03:24:31 +01:00
parent 975f03e75d
commit 90c1fb94e2
4 changed files with 245 additions and 9 deletions

View file

@ -0,0 +1,197 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Terminal } from '@xterm/xterm';
import { CanvasAddon } from '@xterm/addon-canvas';
import { FitAddon } from '@xterm/addon-fit';
import { getXtermTheme, onThemeChange } from '../../stores/theme.svelte';
import { getAgentSession } from '../../stores/agents.svelte';
import type {
ToolCallContent,
ToolResultContent,
InitContent,
CostContent,
ErrorContent,
TextContent,
AgentMessage,
} from '../../adapters/sdk-messages';
import '@xterm/xterm/css/xterm.css';
interface Props {
sessionId: string;
}
let { sessionId }: Props = $props();
let terminalEl: HTMLDivElement;
let term: Terminal;
let fitAddon: FitAddon;
let resizeObserver: ResizeObserver | null = null;
let unsubTheme: (() => void) | null = null;
/** Track how many messages we've already rendered */
let renderedCount = 0;
let session = $derived(getAgentSession(sessionId));
// Watch for new messages and render them
$effect(() => {
if (!session || !term) return;
const msgs = session.messages;
if (msgs.length <= renderedCount) return;
const newMsgs = msgs.slice(renderedCount);
for (const msg of newMsgs) {
renderMessage(msg);
}
renderedCount = msgs.length;
});
// Reset when sessionId changes
$effect(() => {
// Access sessionId to track it
void sessionId;
renderedCount = 0;
if (term) {
term.clear();
term.write('\x1b[90m● Watching agent activity...\x1b[0m\r\n');
}
});
function renderMessage(msg: AgentMessage) {
switch (msg.type) {
case 'init': {
const c = msg.content as InitContent;
term.write(`\x1b[32m● Session started\x1b[0m \x1b[90m(${c.model})\x1b[0m\r\n`);
break;
}
case 'tool_call': {
const tc = msg.content as ToolCallContent;
if (tc.name === 'Bash') {
const cmd = (tc.input as { command?: string })?.command ?? '';
term.write(`\r\n\x1b[36m ${escapeForTerminal(cmd)}\x1b[0m\r\n`);
} else if (tc.name === 'Read' || tc.name === 'Write' || tc.name === 'Edit') {
const input = tc.input as { file_path?: string };
const path = input?.file_path ?? '';
term.write(`\x1b[33m[${tc.name}]\x1b[0m \x1b[90m${escapeForTerminal(path)}\x1b[0m\r\n`);
} else if (tc.name === 'Grep' || tc.name === 'Glob') {
const input = tc.input as { pattern?: string };
const pattern = input?.pattern ?? '';
term.write(`\x1b[33m[${tc.name}]\x1b[0m \x1b[90m${escapeForTerminal(pattern)}\x1b[0m\r\n`);
} else {
term.write(`\x1b[33m[${tc.name}]\x1b[0m\r\n`);
}
break;
}
case 'tool_result': {
const tr = msg.content as ToolResultContent;
const output = typeof tr.output === 'string'
? tr.output
: JSON.stringify(tr.output, null, 2);
if (output) {
// Truncate long outputs (show first 80 lines)
const lines = output.split('\n');
const truncated = lines.length > 80;
const display = truncated ? lines.slice(0, 80).join('\n') : output;
term.write(escapeForTerminal(display));
if (!display.endsWith('\n')) term.write('\r\n');
if (truncated) {
term.write(`\x1b[90m... (${lines.length - 80} more lines)\x1b[0m\r\n`);
}
}
break;
}
case 'text': {
const tc = msg.content as TextContent;
// Show brief text indicator (first line only)
const firstLine = tc.text.split('\n')[0].slice(0, 120);
term.write(`\x1b[37m${escapeForTerminal(firstLine)}\x1b[0m\r\n`);
break;
}
case 'error': {
const ec = msg.content as ErrorContent;
term.write(`\x1b[31m✗ ${escapeForTerminal(ec.message)}\x1b[0m\r\n`);
break;
}
case 'cost': {
const cc = msg.content as CostContent;
const cost = cc.totalCostUsd.toFixed(4);
const dur = (cc.durationMs / 1000).toFixed(1);
term.write(`\r\n\x1b[90m● Session complete ($${cost}, ${dur}s, ${cc.numTurns} turns)\x1b[0m\r\n`);
break;
}
// Skip thinking, status, unknown
}
}
/** Escape text for xterm — convert \n to \r\n */
function escapeForTerminal(text: string): string {
return text.replace(/\r?\n/g, '\r\n');
}
onMount(() => {
term = new Terminal({
theme: getXtermTheme(),
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace",
fontSize: 13,
lineHeight: 1.2,
cursorBlink: false,
cursorStyle: 'underline',
scrollback: 10000,
allowProposedApi: true,
disableStdin: true,
});
fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.loadAddon(new CanvasAddon());
term.open(terminalEl);
fitAddon.fit();
term.write('\x1b[90m● Watching agent activity...\x1b[0m\r\n');
// If session already has messages, render them
const s = getAgentSession(sessionId);
if (s && s.messages.length > 0) {
for (const msg of s.messages) {
renderMessage(msg);
}
renderedCount = s.messages.length;
}
// Resize handling with debounce
let resizeTimer: ReturnType<typeof setTimeout>;
resizeObserver = new ResizeObserver(() => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
fitAddon.fit();
}, 100);
});
resizeObserver.observe(terminalEl);
// Hot-swap theme
unsubTheme = onThemeChange(() => {
term.options.theme = getXtermTheme();
});
});
onDestroy(() => {
resizeObserver?.disconnect();
unsubTheme?.();
term?.dispose();
});
</script>
<div class="agent-preview-container" bind:this={terminalEl}></div>
<style>
.agent-preview-container {
width: 100%;
height: 100%;
overflow: hidden;
}
.agent-preview-container :global(.xterm) {
height: 100%;
padding: 4px;
}
</style>

View file

@ -74,7 +74,7 @@
</div>
<div class="project-terminal-area">
<TerminalTabs {project} />
<TerminalTabs {project} agentSessionId={mainSessionId} />
</div>
</div>

View file

@ -7,12 +7,14 @@
type TerminalTab,
} from '../../stores/workspace.svelte';
import TerminalPane from '../Terminal/TerminalPane.svelte';
import AgentPreviewPane from '../Terminal/AgentPreviewPane.svelte';
interface Props {
project: ProjectConfig;
agentSessionId?: string | null;
}
let { project }: Props = $props();
let { project, agentSessionId }: Props = $props();
let tabs = $derived(getTerminalTabs(project.id));
let activeTabId = $state<string | null>(null);
@ -38,6 +40,26 @@
activeTabId = id;
}
function addAgentPreviewTab() {
if (!agentSessionId) return;
// Don't create duplicate — check if one already exists for this session
const existing = tabs.find(
t => t.type === 'agent-preview' && t.agentSessionId === agentSessionId,
);
if (existing) {
activeTabId = existing.id;
return;
}
const id = crypto.randomUUID();
addTerminalTab(project.id, {
id,
title: 'Agent Preview',
type: 'agent-preview',
agentSessionId,
});
activeTabId = id;
}
function closeTab(tabId: string) {
removeTerminalTab(project.id, tabId);
}
@ -66,18 +88,29 @@
>×</button>
</div>
{/each}
<button class="tab-add" onclick={addShellTab} title="New shell (Ctrl+N)">+</button>
<button class="tab-add" onclick={addShellTab} title="New shell">+</button>
{#if agentSessionId}
<button
class="tab-add tab-agent-preview"
onclick={addAgentPreviewTab}
title="Watch agent activity"
>👁</button>
{/if}
</div>
<div class="tab-content">
{#each tabs as tab (tab.id)}
<div class="tab-pane" class:visible={activeTabId === tab.id}>
{#if activeTabId === tab.id}
<TerminalPane
cwd={project.cwd}
shell={tab.type === 'ssh' ? '/usr/bin/ssh' : undefined}
onExit={() => handleTabExit(tab.id)}
/>
{#if tab.type === 'agent-preview' && tab.agentSessionId}
<AgentPreviewPane sessionId={tab.agentSessionId} />
{:else}
<TerminalPane
cwd={project.cwd}
shell={tab.type === 'ssh' ? '/usr/bin/ssh' : undefined}
onExit={() => handleTabExit(tab.id)}
/>
{/if}
{/if}
</div>
{/each}
@ -171,6 +204,10 @@
background: var(--ctp-surface0);
}
.tab-agent-preview {
font-size: 0.7rem;
}
.tab-content {
flex: 1;
position: relative;

View file

@ -7,9 +7,11 @@ export type WorkspaceTab = 'sessions' | 'docs' | 'context' | 'settings';
export interface TerminalTab {
id: string;
title: string;
type: 'shell' | 'ssh' | 'agent-terminal';
type: 'shell' | 'ssh' | 'agent-terminal' | 'agent-preview';
/** SSH session ID if type === 'ssh' */
sshSessionId?: string;
/** Agent session ID if type === 'agent-preview' */
agentSessionId?: string;
}
// --- Core state ---