mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-23 21:18:40 +02:00
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:
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user