mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-26 21:03:58 +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 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
|
### 2. Main process <-> sidecar
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@
|
|||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "5.1.0",
|
"@vitejs/plugin-react": "5.1.0",
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/xterm": "^6.0.0",
|
||||||
"@xyflow/react": "^12.10.1",
|
"@xyflow/react": "^12.10.1",
|
||||||
"bun-types": "^1.3.11",
|
"bun-types": "^1.3.11",
|
||||||
"electron": "^41.0.3",
|
"electron": "^41.0.3",
|
||||||
@@ -369,6 +371,10 @@
|
|||||||
|
|
||||||
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="],
|
"@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/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=="],
|
"@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": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "5.1.0",
|
"@vitejs/plugin-react": "5.1.0",
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/xterm": "^6.0.0",
|
||||||
"@xyflow/react": "^12.10.1",
|
"@xyflow/react": "^12.10.1",
|
||||||
"bun-types": "^1.3.11",
|
"bun-types": "^1.3.11",
|
||||||
"electron": "^41.0.3",
|
"electron": "^41.0.3",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { NewSessionModal } from '@renderer/components/NewSessionModal';
|
|||||||
import { ProjectSettingsPanel } from '@renderer/components/ProjectSettingsPanel';
|
import { ProjectSettingsPanel } from '@renderer/components/ProjectSettingsPanel';
|
||||||
import { SettingsPanel } from '@renderer/components/SettingsPanel';
|
import { SettingsPanel } from '@renderer/components/SettingsPanel';
|
||||||
import { Sidebar } from '@renderer/components/Sidebar';
|
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 { resolveChatToolingSettings } from '@renderer/lib/chatTooling';
|
||||||
import {
|
import {
|
||||||
applySessionEventActivity,
|
applySessionEventActivity,
|
||||||
@@ -106,6 +107,13 @@ export default function App() {
|
|||||||
const [newSessionProjectId, setNewSessionProjectId] = useState<string>();
|
const [newSessionProjectId, setNewSessionProjectId] = useState<string>();
|
||||||
const [showDiscoveryModal, setShowDiscoveryModal] = useState(false);
|
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
|
// Load workspace on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
@@ -228,6 +236,46 @@ export default function App() {
|
|||||||
if (hasPendingDiscoveries) setShowDiscoveryModal(true);
|
if (hasPendingDiscoveries) setShowDiscoveryModal(true);
|
||||||
}, [hasPendingDiscoveries]);
|
}, [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 jumpToMessage = useCallback((messageId: string) => {
|
||||||
const element = document.querySelector(`[data-message-id="${CSS.escape(messageId)}"]`);
|
const element = document.querySelector(`[data-message-id="${CSS.escape(messageId)}"]`);
|
||||||
if (element) {
|
if (element) {
|
||||||
@@ -334,11 +382,14 @@ export default function App() {
|
|||||||
}}
|
}}
|
||||||
availableModels={availableModels}
|
availableModels={availableModels}
|
||||||
mcpProbingServerIds={workspace.mcpProbingServerIds}
|
mcpProbingServerIds={workspace.mcpProbingServerIds}
|
||||||
|
onTerminalToggle={handleTerminalToggle}
|
||||||
pattern={patternForSession}
|
pattern={patternForSession}
|
||||||
project={projectForSession}
|
project={projectForSession}
|
||||||
runtimeTools={sidecarCapabilities?.runtimeTools}
|
runtimeTools={sidecarCapabilities?.runtimeTools}
|
||||||
session={selectedSession}
|
session={selectedSession}
|
||||||
sessionUsage={usageForSession}
|
sessionUsage={usageForSession}
|
||||||
|
terminalOpen={terminalOpen}
|
||||||
|
terminalRunning={terminalRunning}
|
||||||
toolingSettings={chatToolingSettings ?? workspace.settings.tooling}
|
toolingSettings={chatToolingSettings ?? workspace.settings.tooling}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -422,6 +473,16 @@ export default function App() {
|
|||||||
content={content}
|
content={content}
|
||||||
detailPanel={detailPanel}
|
detailPanel={detailPanel}
|
||||||
overlay={overlay}
|
overlay={overlay}
|
||||||
|
terminalPanel={
|
||||||
|
terminalOpen ? (
|
||||||
|
<TerminalPanel
|
||||||
|
height={terminalHeight}
|
||||||
|
onHeightChange={handleTerminalHeightChange}
|
||||||
|
onClose={handleTerminalClose}
|
||||||
|
onMinimize={handleTerminalClose}
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
sidebar={
|
sidebar={
|
||||||
<Sidebar
|
<Sidebar
|
||||||
onAddProject={() => void api.addProject()}
|
onAddProject={() => void api.addProject()}
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ interface AppShellProps {
|
|||||||
sidebar: ReactNode;
|
sidebar: ReactNode;
|
||||||
content: ReactNode;
|
content: ReactNode;
|
||||||
detailPanel?: ReactNode;
|
detailPanel?: ReactNode;
|
||||||
|
terminalPanel?: ReactNode;
|
||||||
overlay?: ReactNode;
|
overlay?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppShell({ sidebar, content, detailPanel, overlay }: AppShellProps) {
|
export function AppShell({ sidebar, content, detailPanel, terminalPanel, overlay }: AppShellProps) {
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-screen bg-[var(--color-surface-0)] text-zinc-100">
|
<div className="relative flex h-screen bg-[var(--color-surface-0)] text-zinc-100">
|
||||||
{/* Full-width drag region matching the title bar overlay height */}
|
{/* 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)]">
|
<aside className="flex w-72 shrink-0 flex-col border-r border-[var(--color-border)] bg-[var(--color-surface-1)]">
|
||||||
{sidebar}
|
{sidebar}
|
||||||
</aside>
|
</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 && (
|
{detailPanel && (
|
||||||
<aside className="flex w-64 shrink-0 flex-col border-l border-[var(--color-border)] bg-[var(--color-surface-1)]">
|
<aside className="flex w-64 shrink-0 flex-col border-l border-[var(--color-border)] bg-[var(--color-surface-1)]">
|
||||||
{detailPanel}
|
{detailPanel}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { ApprovalBanner, QueuedApprovalsList } from '@renderer/components/chat/A
|
|||||||
import { PlanReviewBanner } from '@renderer/components/chat/PlanReviewBanner';
|
import { PlanReviewBanner } from '@renderer/components/chat/PlanReviewBanner';
|
||||||
import { McpAuthBanner } from '@renderer/components/chat/McpAuthBanner';
|
import { McpAuthBanner } from '@renderer/components/chat/McpAuthBanner';
|
||||||
import { UserInputBanner } from '@renderer/components/chat/UserInputBanner';
|
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 { InlinePromptPill } from '@renderer/components/chat/InlinePromptPill';
|
||||||
import { ThinkingDots } from '@renderer/components/chat/ThinkingDots';
|
import { ThinkingDots } from '@renderer/components/chat/ThinkingDots';
|
||||||
import { getAssistantMessagePhase } from '@renderer/lib/messagePhase';
|
import { getAssistantMessagePhase } from '@renderer/lib/messagePhase';
|
||||||
@@ -44,6 +44,8 @@ interface ChatPaneProps {
|
|||||||
mcpProbingServerIds?: string[];
|
mcpProbingServerIds?: string[];
|
||||||
runtimeTools?: ReadonlyArray<RuntimeToolDefinition>;
|
runtimeTools?: ReadonlyArray<RuntimeToolDefinition>;
|
||||||
sessionUsage?: SessionUsageState;
|
sessionUsage?: SessionUsageState;
|
||||||
|
terminalOpen?: boolean;
|
||||||
|
terminalRunning?: boolean;
|
||||||
onSend: (content: string, attachments?: ChatMessageAttachment[], messageMode?: MessageMode) => Promise<void>;
|
onSend: (content: string, attachments?: ChatMessageAttachment[], messageMode?: MessageMode) => Promise<void>;
|
||||||
onCancelTurn?: () => void;
|
onCancelTurn?: () => void;
|
||||||
onResolveApproval?: (approvalId: string, decision: ApprovalDecision, alwaysApprove?: boolean) => Promise<unknown>;
|
onResolveApproval?: (approvalId: string, decision: ApprovalDecision, alwaysApprove?: boolean) => Promise<unknown>;
|
||||||
@@ -52,6 +54,7 @@ interface ChatPaneProps {
|
|||||||
onDismissPlanReview?: () => void;
|
onDismissPlanReview?: () => void;
|
||||||
onDismissMcpAuth?: () => void;
|
onDismissMcpAuth?: () => void;
|
||||||
onAuthenticateMcp?: () => void;
|
onAuthenticateMcp?: () => void;
|
||||||
|
onTerminalToggle?: () => void;
|
||||||
onUpdateSessionModelConfig?: (config: {
|
onUpdateSessionModelConfig?: (config: {
|
||||||
model: string;
|
model: string;
|
||||||
reasoningEffort?: ReasoningEffort;
|
reasoningEffort?: ReasoningEffort;
|
||||||
@@ -69,6 +72,8 @@ export function ChatPane({
|
|||||||
mcpProbingServerIds,
|
mcpProbingServerIds,
|
||||||
runtimeTools,
|
runtimeTools,
|
||||||
sessionUsage,
|
sessionUsage,
|
||||||
|
terminalOpen,
|
||||||
|
terminalRunning,
|
||||||
onSend,
|
onSend,
|
||||||
onCancelTurn,
|
onCancelTurn,
|
||||||
onResolveApproval,
|
onResolveApproval,
|
||||||
@@ -77,6 +82,7 @@ export function ChatPane({
|
|||||||
onDismissPlanReview,
|
onDismissPlanReview,
|
||||||
onDismissMcpAuth,
|
onDismissMcpAuth,
|
||||||
onAuthenticateMcp,
|
onAuthenticateMcp,
|
||||||
|
onTerminalToggle,
|
||||||
onUpdateSessionModelConfig,
|
onUpdateSessionModelConfig,
|
||||||
onUpdateSessionTooling,
|
onUpdateSessionTooling,
|
||||||
onUpdateSessionApprovalSettings,
|
onUpdateSessionApprovalSettings,
|
||||||
@@ -482,6 +488,14 @@ export function ChatPane({
|
|||||||
{/* Session config pills — tools/approval left, model/reasoning right */}
|
{/* Session config pills — tools/approval left, model/reasoning right */}
|
||||||
{isSingleAgent && (
|
{isSingleAgent && (
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
{onTerminalToggle && (
|
||||||
|
<InlineTerminalPill
|
||||||
|
disabled={false}
|
||||||
|
isOpen={!!terminalOpen}
|
||||||
|
isRunning={!!terminalRunning}
|
||||||
|
onToggle={onTerminalToggle}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{hasConfigurableTools && onUpdateSessionTooling && (
|
{hasConfigurableTools && onUpdateSessionTooling && (
|
||||||
<InlineToolsPill
|
<InlineToolsPill
|
||||||
disabled={isComposerDisabled}
|
disabled={isComposerDisabled}
|
||||||
@@ -539,6 +553,14 @@ export function ChatPane({
|
|||||||
{/* Session config pills — tool & approval controls (multi-agent) */}
|
{/* Session config pills — tool & approval controls (multi-agent) */}
|
||||||
{!isSingleAgent && (hasConfigurableTools || hasToolCallApproval) && (
|
{!isSingleAgent && (hasConfigurableTools || hasToolCallApproval) && (
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
{onTerminalToggle && (
|
||||||
|
<InlineTerminalPill
|
||||||
|
disabled={false}
|
||||||
|
isOpen={!!terminalOpen}
|
||||||
|
isRunning={!!terminalRunning}
|
||||||
|
onToggle={onTerminalToggle}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{hasConfigurableTools && onUpdateSessionTooling && (
|
{hasConfigurableTools && onUpdateSessionTooling && (
|
||||||
<InlineToolsPill
|
<InlineToolsPill
|
||||||
disabled={isComposerDisabled}
|
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 { 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 { ProviderIcon } from '@renderer/components/ProviderIcons';
|
||||||
import { PopoverToggleRow } from '@renderer/components/ui';
|
import { PopoverToggleRow } from '@renderer/components/ui';
|
||||||
@@ -660,3 +660,35 @@ function GroupToggle({
|
|||||||
</button>
|
</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.
|
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>
|
</p>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user