diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ed2bf03..c6d8350 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 diff --git a/bun.lock b/bun.lock index 344cfbc..132fd4b 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index 7a987a4..8ef0489 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index c021aa6..5a8aec0 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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(); 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 ? ( + + ) : undefined + } sidebar={ void api.addProject()} diff --git a/src/renderer/components/AppShell.tsx b/src/renderer/components/AppShell.tsx index 237c2ba..db7be9e 100644 --- a/src/renderer/components/AppShell.tsx +++ b/src/renderer/components/AppShell.tsx @@ -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 (
{/* Full-width drag region matching the title bar overlay height */} @@ -16,7 +17,10 @@ export function AppShell({ sidebar, content, detailPanel, overlay }: AppShellPro -
{content}
+
+
{content}
+ {terminalPanel} +
{detailPanel && (