feat(v3): agent preview terminal — read-only xterm.js tracking agent activity
This commit is contained in:
parent
975f03e75d
commit
90c1fb94e2
4 changed files with 245 additions and 9 deletions
197
v2/src/lib/components/Terminal/AgentPreviewPane.svelte
Normal file
197
v2/src/lib/components/Terminal/AgentPreviewPane.svelte
Normal 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>
|
||||
|
|
@ -74,7 +74,7 @@
|
|||
</div>
|
||||
|
||||
<div class="project-terminal-area">
|
||||
<TerminalTabs {project} />
|
||||
<TerminalTabs {project} agentSessionId={mainSessionId} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue