feat: add integrated terminal frontend

- Add TerminalPanel component with xterm.js, FitAddon, drag-to-resize,
  header bar (status dot, shell label, cwd, restart/minimize/close)
- Modify AppShell to accept terminal panel in vertical flex layout
- Add terminal state management in App.tsx (open/height/running state,
  Ctrl+backtick toggle, height persistence via IPC)
- Add InlineTerminalPill to composer pill row (both single/multi-agent)
- Install @xterm/xterm and @xterm/addon-fit as devDependencies
- Update ARCHITECTURE.md with frontend terminal component details
- Add Integrated Terminal feature card to marketing website

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-28 23:29:18 +01:00
co-authored by Copilot
parent 251316596c
commit edd4c7381a
9 changed files with 429 additions and 5 deletions
+1 -1
View File
@@ -193,7 +193,7 @@ Typical examples:
The renderer does not reach into Electron or the filesystem directly. It talks through a constrained API surface.
The integrated terminal uses the same boundary. The renderer never opens a shell directly; it asks the main process to create or restart a PTY, sends fire-and-forget input and resize messages over IPC, and listens for streamed terminal data and exit events pushed back through preload.
The integrated terminal uses the same boundary. The renderer never opens a shell directly; it asks the main process to create or restart a PTY, sends fire-and-forget input and resize messages over IPC, and listens for streamed terminal data and exit events pushed back through preload. The `TerminalPanel` component manages an xterm.js terminal instance with a FitAddon, a drag-to-resize handle, and a header bar showing shell status.
### 2. Main process <-> sidecar
+6
View File
@@ -24,6 +24,8 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "5.1.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"@xyflow/react": "^12.10.1",
"bun-types": "^1.3.11",
"electron": "^41.0.3",
@@ -369,6 +371,10 @@
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="],
"@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="],
"@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="],
"@xyflow/react": ["@xyflow/react@12.10.1", "", { "dependencies": { "@xyflow/system": "0.0.75", "classcat": "^5.0.3", "zustand": "^4.4.0" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-5eSWtIK/+rkldOuFbOOz44CRgQRjtS9v5nufk77DV+XBnfCGL9HAQ8PG00o2ZYKqkEU/Ak6wrKC95Tu+2zuK3Q=="],
"@xyflow/system": ["@xyflow/system@0.0.75", "", { "dependencies": { "@types/d3-drag": "^3.0.7", "@types/d3-interpolate": "^3.0.4", "@types/d3-selection": "^3.0.10", "@types/d3-transition": "^3.0.8", "@types/d3-zoom": "^3.0.8", "d3-drag": "^3.0.0", "d3-interpolate": "^3.0.1", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0" } }, "sha512-iXs+AGFLi8w/VlAoc/iSxk+CxfT6o64Uw/k0CKASOPqjqz6E0rb5jFZgJtXGZCpfQI6OQpu5EnumP5fGxQheaQ=="],
+2
View File
@@ -47,6 +47,8 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "5.1.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"@xyflow/react": "^12.10.1",
"bun-types": "^1.3.11",
"electron": "^41.0.3",
+61
View File
@@ -8,6 +8,7 @@ import { NewSessionModal } from '@renderer/components/NewSessionModal';
import { ProjectSettingsPanel } from '@renderer/components/ProjectSettingsPanel';
import { SettingsPanel } from '@renderer/components/SettingsPanel';
import { Sidebar } from '@renderer/components/Sidebar';
import { TerminalPanel, DEFAULT_HEIGHT as DEFAULT_TERMINAL_HEIGHT, MIN_HEIGHT as MIN_TERMINAL_HEIGHT } from '@renderer/components/TerminalPanel';
import { resolveChatToolingSettings } from '@renderer/lib/chatTooling';
import {
applySessionEventActivity,
@@ -106,6 +107,13 @@ export default function App() {
const [newSessionProjectId, setNewSessionProjectId] = useState<string>();
const [showDiscoveryModal, setShowDiscoveryModal] = useState(false);
// Terminal state
const [terminalOpen, setTerminalOpen] = useState(false);
const [terminalHeight, setTerminalHeight] = useState(
() => workspace?.settings.terminalHeight ?? DEFAULT_TERMINAL_HEIGHT,
);
const [terminalRunning, setTerminalRunning] = useState(false);
// Load workspace on mount
useEffect(() => {
let disposed = false;
@@ -228,6 +236,46 @@ export default function App() {
if (hasPendingDiscoveries) setShowDiscoveryModal(true);
}, [hasPendingDiscoveries]);
// Terminal: Ctrl+` toggle + track running state
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.ctrlKey && e.key === '`') {
e.preventDefault();
setTerminalOpen((prev) => !prev);
}
};
window.addEventListener('keydown', handleKeyDown);
// Track terminal running state via exit events
const offExit = api.onTerminalExit(() => setTerminalRunning(false));
return () => {
window.removeEventListener('keydown', handleKeyDown);
offExit();
};
}, [api]);
// Sync terminalHeight from workspace settings when workspace loads
useEffect(() => {
if (workspace?.settings.terminalHeight) {
setTerminalHeight(workspace.settings.terminalHeight);
}
}, [workspace?.settings.terminalHeight]);
const handleTerminalHeightChange = useCallback((newHeight: number) => {
const clamped = Math.max(MIN_TERMINAL_HEIGHT, Math.round(newHeight));
setTerminalHeight(clamped);
void api.setTerminalHeight({ height: clamped });
}, [api]);
const handleTerminalClose = useCallback(() => {
setTerminalOpen(false);
}, []);
const handleTerminalToggle = useCallback(() => {
setTerminalOpen((prev) => !prev);
}, []);
const jumpToMessage = useCallback((messageId: string) => {
const element = document.querySelector(`[data-message-id="${CSS.escape(messageId)}"]`);
if (element) {
@@ -334,11 +382,14 @@ export default function App() {
}}
availableModels={availableModels}
mcpProbingServerIds={workspace.mcpProbingServerIds}
onTerminalToggle={handleTerminalToggle}
pattern={patternForSession}
project={projectForSession}
runtimeTools={sidecarCapabilities?.runtimeTools}
session={selectedSession}
sessionUsage={usageForSession}
terminalOpen={terminalOpen}
terminalRunning={terminalRunning}
toolingSettings={chatToolingSettings ?? workspace.settings.tooling}
/>
);
@@ -422,6 +473,16 @@ export default function App() {
content={content}
detailPanel={detailPanel}
overlay={overlay}
terminalPanel={
terminalOpen ? (
<TerminalPanel
height={terminalHeight}
onHeightChange={handleTerminalHeightChange}
onClose={handleTerminalClose}
onMinimize={handleTerminalClose}
/>
) : undefined
}
sidebar={
<Sidebar
onAddProject={() => void api.addProject()}
+6 -2
View File
@@ -4,10 +4,11 @@ interface AppShellProps {
sidebar: ReactNode;
content: ReactNode;
detailPanel?: ReactNode;
terminalPanel?: ReactNode;
overlay?: ReactNode;
}
export function AppShell({ sidebar, content, detailPanel, overlay }: AppShellProps) {
export function AppShell({ sidebar, content, detailPanel, terminalPanel, overlay }: AppShellProps) {
return (
<div className="relative flex h-screen bg-[var(--color-surface-0)] text-zinc-100">
{/* Full-width drag region matching the title bar overlay height */}
@@ -16,7 +17,10 @@ export function AppShell({ sidebar, content, detailPanel, overlay }: AppShellPro
<aside className="flex w-72 shrink-0 flex-col border-r border-[var(--color-border)] bg-[var(--color-surface-1)]">
{sidebar}
</aside>
<main className="relative min-w-0 flex-1">{content}</main>
<main className="relative flex min-w-0 flex-1 flex-col">
<div className="min-h-0 flex-1">{content}</div>
{terminalPanel}
</main>
{detailPanel && (
<aside className="flex w-64 shrink-0 flex-col border-l border-[var(--color-border)] bg-[var(--color-surface-1)]">
{detailPanel}
+23 -1
View File
@@ -7,7 +7,7 @@ import { ApprovalBanner, QueuedApprovalsList } from '@renderer/components/chat/A
import { PlanReviewBanner } from '@renderer/components/chat/PlanReviewBanner';
import { McpAuthBanner } from '@renderer/components/chat/McpAuthBanner';
import { UserInputBanner } from '@renderer/components/chat/UserInputBanner';
import { InlineApprovalPill, InlineModelPill, InlineThinkingPill, InlineToolsPill } from '@renderer/components/chat/InlinePills';
import { InlineApprovalPill, InlineModelPill, InlineTerminalPill, InlineThinkingPill, InlineToolsPill } from '@renderer/components/chat/InlinePills';
import { InlinePromptPill } from '@renderer/components/chat/InlinePromptPill';
import { ThinkingDots } from '@renderer/components/chat/ThinkingDots';
import { getAssistantMessagePhase } from '@renderer/lib/messagePhase';
@@ -44,6 +44,8 @@ interface ChatPaneProps {
mcpProbingServerIds?: string[];
runtimeTools?: ReadonlyArray<RuntimeToolDefinition>;
sessionUsage?: SessionUsageState;
terminalOpen?: boolean;
terminalRunning?: boolean;
onSend: (content: string, attachments?: ChatMessageAttachment[], messageMode?: MessageMode) => Promise<void>;
onCancelTurn?: () => void;
onResolveApproval?: (approvalId: string, decision: ApprovalDecision, alwaysApprove?: boolean) => Promise<unknown>;
@@ -52,6 +54,7 @@ interface ChatPaneProps {
onDismissPlanReview?: () => void;
onDismissMcpAuth?: () => void;
onAuthenticateMcp?: () => void;
onTerminalToggle?: () => void;
onUpdateSessionModelConfig?: (config: {
model: string;
reasoningEffort?: ReasoningEffort;
@@ -69,6 +72,8 @@ export function ChatPane({
mcpProbingServerIds,
runtimeTools,
sessionUsage,
terminalOpen,
terminalRunning,
onSend,
onCancelTurn,
onResolveApproval,
@@ -77,6 +82,7 @@ export function ChatPane({
onDismissPlanReview,
onDismissMcpAuth,
onAuthenticateMcp,
onTerminalToggle,
onUpdateSessionModelConfig,
onUpdateSessionTooling,
onUpdateSessionApprovalSettings,
@@ -482,6 +488,14 @@ export function ChatPane({
{/* Session config pills — tools/approval left, model/reasoning right */}
{isSingleAgent && (
<div className="mb-2 flex items-center gap-2">
{onTerminalToggle && (
<InlineTerminalPill
disabled={false}
isOpen={!!terminalOpen}
isRunning={!!terminalRunning}
onToggle={onTerminalToggle}
/>
)}
{hasConfigurableTools && onUpdateSessionTooling && (
<InlineToolsPill
disabled={isComposerDisabled}
@@ -539,6 +553,14 @@ export function ChatPane({
{/* Session config pills — tool & approval controls (multi-agent) */}
{!isSingleAgent && (hasConfigurableTools || hasToolCallApproval) && (
<div className="mb-2 flex items-center gap-2">
{onTerminalToggle && (
<InlineTerminalPill
disabled={false}
isOpen={!!terminalOpen}
isRunning={!!terminalRunning}
onToggle={onTerminalToggle}
/>
)}
{hasConfigurableTools && onUpdateSessionTooling && (
<InlineToolsPill
disabled={isComposerDisabled}
+284
View File
@@ -0,0 +1,284 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { RotateCcw, Minus, X } from 'lucide-react';
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import '@xterm/xterm/css/xterm.css';
import { getElectronApi } from '@renderer/lib/electronApi';
import type { TerminalSnapshot } from '@shared/domain/terminal';
import type { TerminalExitInfo } from '@shared/domain/terminal';
/* ── Theme ────────────────────────────────────────────────── */
const terminalTheme = {
background: '#09090b', // zinc-950
foreground: '#e4e4e7', // zinc-200
cursor: '#818cf8', // indigo-400
cursorAccent: '#09090b',
selectionBackground: '#6366f14d', // indigo-500/30
selectionForeground: '#e4e4e7',
black: '#27272a', // zinc-800
red: '#f87171', // red-400
green: '#4ade80', // green-400
yellow: '#facc15', // yellow-400
blue: '#60a5fa', // blue-400
magenta: '#c084fc', // purple-400
cyan: '#22d3ee', // cyan-400
white: '#e4e4e7', // zinc-200
brightBlack: '#52525b', // zinc-600
brightRed: '#fca5a5', // red-300
brightGreen: '#86efac', // green-300
brightYellow: '#fde047', // yellow-300
brightBlue: '#93c5fd', // blue-300
brightMagenta: '#d8b4fe',// purple-300
brightCyan: '#67e8f9', // cyan-300
brightWhite: '#fafafa', // zinc-50
};
/* ── Constants ────────────────────────────────────────────── */
const MIN_HEIGHT = 120;
const MAX_HEIGHT_FRACTION = 0.7;
const DEFAULT_HEIGHT = 280;
/* ── TerminalPanel ────────────────────────────────────────── */
interface TerminalPanelProps {
height: number;
onHeightChange: (height: number) => void;
onClose: () => void;
onMinimize: () => void;
}
export function TerminalPanel({
height,
onHeightChange,
onClose,
onMinimize,
}: TerminalPanelProps) {
const api = getElectronApi();
const containerRef = useRef<HTMLDivElement>(null);
const terminalRef = useRef<Terminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const [snapshot, setSnapshot] = useState<TerminalSnapshot>();
const [isRunning, setIsRunning] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const dragStartRef = useRef<{ y: number; height: number } | null>(null);
// Create or recover terminal on mount
useEffect(() => {
let disposed = false;
void api.describeTerminal().then((existing) => {
if (disposed) return;
if (existing) {
setSnapshot(existing);
setIsRunning(true);
} else {
void api.createTerminal().then((created) => {
if (disposed) return;
setSnapshot(created);
setIsRunning(true);
});
}
});
return () => {
disposed = true;
};
}, [api]);
// Initialize xterm.js
useEffect(() => {
if (!containerRef.current) return;
const terminal = new Terminal({
theme: terminalTheme,
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
fontSize: 13,
lineHeight: 1.4,
cursorBlink: true,
cursorStyle: 'bar',
scrollback: 5000,
allowProposedApi: true,
});
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.open(containerRef.current);
terminalRef.current = terminal;
fitAddonRef.current = fitAddon;
// Send keystrokes to the backend
const dataDisposable = terminal.onData((data) => {
api.writeTerminal(data);
});
// Initial fit
requestAnimationFrame(() => {
fitAddon.fit();
api.resizeTerminal({ cols: terminal.cols, rows: terminal.rows });
});
return () => {
dataDisposable.dispose();
terminal.dispose();
terminalRef.current = null;
fitAddonRef.current = null;
};
}, [api]);
// Subscribe to terminal data and exit events
useEffect(() => {
const offData = api.onTerminalData((data) => {
terminalRef.current?.write(data);
});
const offExit = api.onTerminalExit((_info: TerminalExitInfo) => {
setIsRunning(false);
terminalRef.current?.write('\r\n\x1b[90m[Process exited]\x1b[0m\r\n');
});
return () => {
offData();
offExit();
};
}, [api]);
// Refit on height changes
useEffect(() => {
if (!fitAddonRef.current || !terminalRef.current) return;
requestAnimationFrame(() => {
fitAddonRef.current?.fit();
const terminal = terminalRef.current;
if (terminal) {
api.resizeTerminal({ cols: terminal.cols, rows: terminal.rows });
}
});
}, [height, api]);
// ResizeObserver for container width changes
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const observer = new ResizeObserver(() => {
requestAnimationFrame(() => {
fitAddonRef.current?.fit();
const terminal = terminalRef.current;
if (terminal) {
api.resizeTerminal({ cols: terminal.cols, rows: terminal.rows });
}
});
});
observer.observe(container);
return () => observer.disconnect();
}, [api]);
// Drag-to-resize
const handleDragStart = useCallback((e: React.MouseEvent) => {
e.preventDefault();
dragStartRef.current = { y: e.clientY, height };
setIsDragging(true);
const handleDragMove = (moveEvent: MouseEvent) => {
if (!dragStartRef.current) return;
const maxHeight = window.innerHeight * MAX_HEIGHT_FRACTION;
const delta = dragStartRef.current.y - moveEvent.clientY;
const nextHeight = Math.max(MIN_HEIGHT, Math.min(maxHeight, dragStartRef.current.height + delta));
onHeightChange(nextHeight);
};
const handleDragEnd = () => {
setIsDragging(false);
dragStartRef.current = null;
document.removeEventListener('mousemove', handleDragMove);
document.removeEventListener('mouseup', handleDragEnd);
};
document.addEventListener('mousemove', handleDragMove);
document.addEventListener('mouseup', handleDragEnd);
}, [height, onHeightChange]);
const handleRestart = useCallback(() => {
void api.restartTerminal().then((restarted) => {
setSnapshot(restarted);
setIsRunning(true);
terminalRef.current?.clear();
});
}, [api]);
const handleClose = useCallback(() => {
void api.killTerminal();
onClose();
}, [api, onClose]);
return (
<div
className="flex flex-col border-t border-[var(--color-border)] bg-[#09090b]"
style={{ height, minHeight: MIN_HEIGHT }}
>
{/* Resize handle */}
<div
className={`h-1 shrink-0 cursor-row-resize transition-colors ${isDragging ? 'bg-indigo-500/40' : 'hover:bg-zinc-700/60'}`}
onMouseDown={handleDragStart}
role="separator"
aria-orientation="horizontal"
aria-label="Resize terminal"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'ArrowUp') {
e.preventDefault();
onHeightChange(Math.min(window.innerHeight * MAX_HEIGHT_FRACTION, height + 20));
} else if (e.key === 'ArrowDown') {
e.preventDefault();
onHeightChange(Math.max(MIN_HEIGHT, height - 20));
}
}}
/>
{/* Header bar */}
<div className="flex h-7 shrink-0 items-center gap-2 border-b border-zinc-800/60 px-3">
<span className={`size-1.5 shrink-0 rounded-full ${isRunning ? 'bg-emerald-400' : 'bg-zinc-600'}`} />
<span className="min-w-0 flex-1 truncate text-[11px] text-zinc-500">
{snapshot ? `${snapshot.shell}${snapshot.cwd}` : 'Terminal'}
</span>
<div className="flex items-center gap-0.5">
<button
aria-label="Restart terminal"
className="rounded p-0.5 text-zinc-600 transition hover:bg-zinc-800 hover:text-zinc-400"
onClick={handleRestart}
type="button"
>
<RotateCcw className="size-3" />
</button>
<button
aria-label="Minimize terminal"
className="rounded p-0.5 text-zinc-600 transition hover:bg-zinc-800 hover:text-zinc-400"
onClick={onMinimize}
type="button"
>
<Minus className="size-3" />
</button>
<button
aria-label="Close terminal"
className="rounded p-0.5 text-zinc-600 transition hover:bg-zinc-800 hover:text-red-400"
onClick={handleClose}
type="button"
>
<X className="size-3" />
</button>
</div>
</div>
{/* Terminal body */}
<div
className="min-h-0 flex-1 px-1 py-0.5"
ref={containerRef}
role="application"
aria-label="Terminal"
/>
</div>
);
}
export { DEFAULT_HEIGHT, MIN_HEIGHT };
+33 -1
View File
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react';
import { ChevronDown, ChevronRight, Loader2, Minus, Search, Sparkles } from 'lucide-react';
import { ChevronDown, ChevronRight, Loader2, Minus, Search, Sparkles, TerminalSquare } from 'lucide-react';
import { ProviderIcon } from '@renderer/components/ProviderIcons';
import { PopoverToggleRow } from '@renderer/components/ui';
@@ -660,3 +660,35 @@ function GroupToggle({
</button>
);
}
/* ── InlineTerminalPill ────────────────────────────────────── */
export function InlineTerminalPill({
disabled,
isRunning,
isOpen,
onToggle,
}: {
disabled: boolean;
isRunning: boolean;
isOpen: boolean;
onToggle: () => void;
}) {
return (
<button
aria-pressed={isOpen}
className={`inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-pill font-medium transition ${
isOpen
? 'border-indigo-500/40 bg-indigo-500/10 text-indigo-300'
: 'border-zinc-700/60 bg-zinc-800/40 text-zinc-400 hover:border-zinc-600 hover:text-zinc-300'
} disabled:cursor-not-allowed disabled:opacity-50`}
disabled={disabled}
onClick={onToggle}
type="button"
>
{isRunning && <span className="size-1.5 shrink-0 rounded-full bg-emerald-400" />}
<TerminalSquare className="size-2.5" />
<span>Terminal</span>
</button>
);
}
+13
View File
@@ -275,6 +275,19 @@ import Base from '../layouts/Base.astro';
Automatically discovers <code class="text-zinc-300">.github/copilot-instructions.md</code>, <code class="text-zinc-300">AGENTS.md</code>, custom agent profiles, and prompt files from your repository. Zero configuration needed.
</p>
</div>
<!-- Feature 15: Integrated Terminal -->
<div class="group rounded-2xl border border-zinc-800 bg-zinc-900/40 p-6 transition hover:border-zinc-700 hover:bg-zinc-900/60">
<div class="mb-4 flex size-10 items-center justify-center rounded-xl bg-emerald-500/10 text-emerald-400">
<svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<h3 class="text-base font-semibold">Integrated Terminal</h3>
<p class="mt-2 text-sm leading-relaxed text-zinc-400">
A full PTY-backed terminal panel right inside the workspace. Toggle with <kbd class="text-zinc-300">Ctrl+`</kbd>, resize by dragging, and run commands alongside your agent sessions.
</p>
</div>
</div>
</div>
</section>