From 589ba4eebfb4a86934f6a2fd4fe31ae8d4808bf1 Mon Sep 17 00:00:00 2001 From: ris Date: Fri, 3 Apr 2026 23:52:40 +0600 Subject: [PATCH] feat: add integrated terminal panel --- bun.lock | 9 + electron.vite.config.ts | 2 +- electron/main/ipc-handlers.ts | 23 ++ electron/main/services/terminal-manager.ts | 85 +++++ electron/preload/index.ts | 20 ++ package.json | 3 + src/components/TerminalPanel.tsx | 348 +++++++++++++++++++++ src/lib/electron-api.ts | 42 +++ src/locales/en.ts | 9 + src/locales/ru.ts | 4 + src/locales/zh.ts | 4 + src/pages/Chat.tsx | 87 +++++- src/types/electron.d.ts | 9 + 13 files changed, 640 insertions(+), 5 deletions(-) create mode 100644 electron/main/services/terminal-manager.ts create mode 100644 src/components/TerminalPanel.tsx diff --git a/bun.lock b/bun.lock index b996054a..2e9d39d5 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,8 @@ "@solid-primitives/i18n": "^2.2.1", "@solidjs/router": "^0.14.10", "@tanstack/solid-virtual": "^3.13.19", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "diff": "^5.1.0", "electron-log": "^5.4.3", "electron-updater": "^6.8.3", @@ -23,6 +25,7 @@ "marked": "^11.1.0", "marked-shiki": "^1.1.2", "material-icon-theme": "^5.32.0", + "node-pty": "^1.1.0", "shiki": "^1.22.0", "solid-js": "^1.8.0", "vscode-languageserver-types": "^3.17.5", @@ -558,6 +561,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=="], + "abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="], "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], @@ -1158,6 +1165,8 @@ "node-html-parser": ["node-html-parser@7.1.0", "", { "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" } }, "sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ=="], + "node-pty": ["node-pty@1.1.0", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="], + "node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="], "nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="], diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 7107ba9b..557d7bc6 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -23,7 +23,7 @@ export default defineConfig({ entry: resolve(__dirname, 'electron/main/index.ts'), }, rollupOptions: { - external: ['electron', 'bufferutil', 'utf-8-validate'], + external: ['electron', 'bufferutil', 'utf-8-validate', 'node-pty'], output: { format: 'es', entryFileNames: '[name].mjs', diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index c9b30c1d..d4096844 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -10,6 +10,7 @@ import { getLogFilePath, getFileLogLevel, setFileLogLevel, loadSettings, saveSet import { isStartupReady } from "./index"; import { channelManager } from "./index"; import { GATEWAY_PORT } from "../../shared/ports"; +import { createTerminal, writeTerminal, resizeTerminal, destroyTerminal } from "./services/terminal-manager"; export function registerIpcHandlers(): void { // =========================================================================== @@ -335,6 +336,28 @@ export function registerIpcHandlers(): void { ipcMain.handle("startup:isReady", async () => { return isStartupReady(); }); + + // =========================================================================== + // Terminal + // =========================================================================== + + ipcMain.handle("terminal:create", async (event, cwd: string, cols: number, rows: number) => { + const win = BrowserWindow.fromWebContents(event.sender); + if (!win) throw new Error("No window found"); + return createTerminal(win, cwd, cols, rows); + }); + + ipcMain.handle("terminal:write", async (_, id: string, data: string) => { + writeTerminal(id, data); + }); + + ipcMain.handle("terminal:resize", async (_, id: string, cols: number, rows: number) => { + resizeTerminal(id, cols, rows); + }); + + ipcMain.handle("terminal:destroy", async (_, id: string) => { + destroyTerminal(id); + }); } // --- LAN IP helpers --- diff --git a/electron/main/services/terminal-manager.ts b/electron/main/services/terminal-manager.ts new file mode 100644 index 00000000..fba513cb --- /dev/null +++ b/electron/main/services/terminal-manager.ts @@ -0,0 +1,85 @@ +import * as pty from "node-pty"; +import os from "os"; +import { BrowserWindow } from "electron"; + +interface TerminalInstance { + pty: pty.IPty; + windowId: number; +} + +const terminals = new Map(); +let idCounter = 0; + +function getDefaultShell(): string { + if (process.platform === "win32") { + return process.env.COMSPEC || "powershell.exe"; + } + return process.env.SHELL || "/bin/bash"; +} + +export function createTerminal( + window: BrowserWindow, + cwd: string, + cols: number, + rows: number, +): string { + const id = `term-${++idCounter}`; + const shell = getDefaultShell(); + + const env: Record = { ...process.env } as Record; + if (!env.LANG) env.LANG = "ru_RU.UTF-8"; + if (!env.LC_CTYPE) env.LC_CTYPE = env.LANG; + if (!env.LC_ALL) env.LC_ALL = env.LANG; + + const ptyProcess = pty.spawn(shell, [], { + name: "xterm-256color", + cols, + rows, + cwd, + env, + }); + + ptyProcess.onData((data) => { + if (window.isDestroyed()) return; + window.webContents.send("terminal:data", id, data); + }); + + ptyProcess.onExit(() => { + terminals.delete(id); + if (!window.isDestroyed()) { + window.webContents.send("terminal:exit", id); + } + }); + + terminals.set(id, { pty: ptyProcess, windowId: window.id }); + return id; +} + +export function writeTerminal(id: string, data: string): void { + const term = terminals.get(id); + if (term) { + term.pty.write(data); + } +} + +export function resizeTerminal(id: string, cols: number, rows: number): void { + const term = terminals.get(id); + if (term) { + term.pty.resize(cols, rows); + } +} + +export function destroyTerminal(id: string): void { + const term = terminals.get(id); + if (term) { + term.pty.kill(); + terminals.delete(id); + } +} + +export function destroyAllTerminals(): void { + for (const [id, term] of terminals) { + term.pty.kill(); + terminals.delete(id); + } +} diff --git a/electron/preload/index.ts b/electron/preload/index.ts index f6fb854f..13ba5f9d 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -137,6 +137,26 @@ const electronAPI = { isEnabled: () => ipcRenderer.invoke("autostart:isEnabled") as Promise, setEnabled: (enabled: boolean) => ipcRenderer.invoke("autostart:setEnabled", enabled), }, + + // Terminal API + terminal: { + create: (cwd: string, cols: number, rows: number) => + ipcRenderer.invoke("terminal:create", cwd, cols, rows) as Promise, + write: (id: string, data: string) => ipcRenderer.invoke("terminal:write", id, data), + resize: (id: string, cols: number, rows: number) => + ipcRenderer.invoke("terminal:resize", id, cols, rows), + destroy: (id: string) => ipcRenderer.invoke("terminal:destroy", id), + onData: (callback: (id: string, data: string) => void) => { + const handler = (_: any, id: string, data: string) => callback(id, data); + ipcRenderer.on("terminal:data", handler); + return () => { ipcRenderer.removeListener("terminal:data", handler); }; + }, + onExit: (callback: (id: string) => void) => { + const handler = (_: any, id: string) => callback(id); + ipcRenderer.on("terminal:exit", handler); + return () => { ipcRenderer.removeListener("terminal:exit", handler); }; + }, + }, }; contextBridge.exposeInMainWorld("electronAPI", electronAPI); diff --git a/package.json b/package.json index 8953b50d..f7f28562 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,8 @@ "@solid-primitives/i18n": "^2.2.1", "@solidjs/router": "^0.14.10", "@tanstack/solid-virtual": "^3.13.19", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "diff": "^5.1.0", "electron-log": "^5.4.3", "electron-updater": "^6.8.3", @@ -54,6 +56,7 @@ "marked": "^11.1.0", "marked-shiki": "^1.1.2", "material-icon-theme": "^5.32.0", + "node-pty": "^1.1.0", "shiki": "^1.22.0", "solid-js": "^1.8.0", "vscode-languageserver-types": "^3.17.5", diff --git a/src/components/TerminalPanel.tsx b/src/components/TerminalPanel.tsx new file mode 100644 index 00000000..61cad482 --- /dev/null +++ b/src/components/TerminalPanel.tsx @@ -0,0 +1,348 @@ +import { createSignal, createEffect, onCleanup, For, Show } from "solid-js"; +import { createStore } from "solid-js/store"; +import { Terminal as XTerm } from "@xterm/xterm"; +import { FitAddon } from "@xterm/addon-fit"; +import { terminalAPI } from "../lib/electron-api"; +import "@xterm/xterm/css/xterm.css"; + +export interface TerminalPanelProps { + sessionId: string; + cwd: string; // cwd for the current session — used when creating new tabs + visible: boolean; + onClose: () => void; + /** Called once with an addTab function so the parent can open the first tab */ + onReady?: (addTab: (sessionId: string) => void) => void; +} + +interface TabEntry { + id: string; + sessionId: string; + cwd: string; + label: string; + exited: boolean; +} + +interface TabInstance { + xterm: XTerm; + fitAddon: FitAddon; + ptyId: string | null; + cleanupData: (() => void) | null; + cleanupExit: (() => void) | null; + resizeTimer: ReturnType | null; + resizeObserver: ResizeObserver; + themeObserver: MutationObserver; +} + +export function TerminalPanel(props: TerminalPanelProps) { + // Flat list of ALL tabs from ALL sessions — kept permanently for DOM persistence + const [allTabs, setAllTabs] = createSignal([]); + // Per-session active tab id + const [activeTabBySession, setActiveTabBySession] = createStore>({}); + // Per-session tab counter so numbering starts from 1 in each session + const tabCounterBySession: Record = {}; + + // Non-reactive xterm/PTY instances keyed by tabId + const instances = new Map(); + + // Expose addTab to parent via onReady (called once on mount) + // ensureFirstTab creates a tab only when the session has none yet + function ensureFirstTab(sessionId: string) { + const has = allTabs().some((t) => t.sessionId === sessionId); + if (!has) addTab(sessionId); + } + props.onReady?.(ensureFirstTab); + + const isDark = () => document.documentElement.classList.contains("dark"); + const getTheme = () => + isDark() + ? { background: "#0f172a", foreground: "#e2e8f0", cursor: "#e2e8f0", selectionBackground: "#334155" } + : { background: "#ffffff", foreground: "#1e293b", cursor: "#1e293b", selectionBackground: "#cbd5e1" }; + + const currentTabs = () => allTabs().filter((t) => t.sessionId === props.sessionId); + const currentActiveTab = () => activeTabBySession[props.sessionId] ?? ""; + + function addTab(sessionId: string = props.sessionId) { + tabCounterBySession[sessionId] = (tabCounterBySession[sessionId] ?? 0) + 1; + const n = tabCounterBySession[sessionId]; + const id = `tab-${sessionId}-${n}`; + const label = `Terminal ${n}`; + const cwd = props.cwd; + setAllTabs((prev) => [...prev, { id, sessionId, cwd, label, exited: false }]); + setActiveTabBySession(sessionId, id); + } + + function closeTab(tabId: string) { + const tab = allTabs().find((t) => t.id === tabId); + if (!tab) return; + const sid = tab.sessionId; + + const sidTabs = allTabs().filter((t) => t.sessionId === sid); + const idx = sidTabs.findIndex((t) => t.id === tabId); + const remaining = sidTabs.filter((t) => t.id !== tabId); + const nextTab = remaining[Math.min(idx, remaining.length - 1)]; + + destroyInstance(tabId); + setAllTabs((prev) => prev.filter((t) => t.id !== tabId)); + + if (remaining.length === 0) { + delete tabCounterBySession[sid]; + if (sid === props.sessionId) props.onClose(); + return; + } + + if (activeTabBySession[sid] === tabId && nextTab) { + setActiveTabBySession(sid, nextTab.id); + requestAnimationFrame(() => fitTab(nextTab.id)); + } + } + + function destroyInstance(tabId: string) { + const inst = instances.get(tabId); + if (!inst) return; + inst.cleanupData?.(); + inst.cleanupExit?.(); + if (inst.ptyId) terminalAPI.destroy(inst.ptyId); + if (inst.resizeTimer) clearTimeout(inst.resizeTimer); + inst.resizeObserver.disconnect(); + inst.themeObserver.disconnect(); + inst.xterm.dispose(); + instances.delete(tabId); + } + + function fitTab(tabId: string) { + const inst = instances.get(tabId); + if (!inst) return; + inst.fitAddon.fit(); + if (inst.ptyId) terminalAPI.resize(inst.ptyId, inst.xterm.cols, inst.xterm.rows); + } + + async function initTab(tabId: string, sessionId: string, el: HTMLDivElement) { + if (instances.has(tabId) || !terminalAPI.isAvailable()) return; + + const tabEntry = allTabs().find((t) => t.id === tabId); + const cwd = tabEntry?.cwd ?? props.cwd; + + const xterm = new XTerm({ + cursorBlink: true, + fontSize: 13, + fontFamily: + "'Hack Nerd Font', 'JetBrainsMono Nerd Font', 'FiraCode Nerd Font', 'MesloLGS NF', 'Hack', 'JetBrains Mono', 'Fira Code', 'DejaVu Sans Mono', monospace", + theme: getTheme(), + allowProposedApi: true, + }); + + const fitAddon = new FitAddon(); + xterm.loadAddon(fitAddon); + xterm.open(el); + + const themeObserver = new MutationObserver(() => { + xterm.options.theme = getTheme(); + }); + themeObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + + const inst: TabInstance = { + xterm, + fitAddon, + ptyId: null, + cleanupData: null, + cleanupExit: null, + resizeTimer: null, + resizeObserver: new ResizeObserver(() => {}), + themeObserver, + }; + instances.set(tabId, inst); + + const resizeObserver = new ResizeObserver(() => { + if (inst.resizeTimer) clearTimeout(inst.resizeTimer); + inst.resizeTimer = setTimeout(() => { + // Only fit the active tab of the currently visible session + if (props.visible && activeTabBySession[props.sessionId] === tabId) { + fitAddon.fit(); + if (inst.ptyId) terminalAPI.resize(inst.ptyId, xterm.cols, xterm.rows); + } + inst.resizeTimer = null; + }, 50); + }); + resizeObserver.observe(el); + inst.resizeObserver = resizeObserver; + + requestAnimationFrame(async () => { + // Only fit if this is the active visible tab + if (props.visible && activeTabBySession[sessionId] === tabId) { + fitAddon.fit(); + } + (document.activeElement as HTMLElement | null)?.blur?.(); + + const ptyId = await terminalAPI.create(cwd, xterm.cols, xterm.rows); + if (!ptyId) return; + inst.ptyId = ptyId; + + inst.cleanupData = terminalAPI.onData((tId: string, data: string) => { + if (tId === ptyId) xterm.write(data); + }); + + inst.cleanupExit = terminalAPI.onExit((tId: string) => { + if (tId === ptyId) { + setAllTabs((prev) => + prev.map((t) => (t.id === tabId ? { ...t, exited: true } : t)) + ); + } + }); + + xterm.onData((data) => { + terminalAPI.write(ptyId, data); + }); + }); + } + + createEffect(() => { + const vis = props.visible; + const tabId = activeTabBySession[props.sessionId]; + if (!vis || !tabId) return; + requestAnimationFrame(() => fitTab(tabId)); + }); + + onCleanup(() => { + for (const tabId of [...instances.keys()]) { + destroyInstance(tabId); + } + }); + + return ( +
+ {/* Header: tab bar + close panel button */} +
+
+ + {(tab) => ( + + )} + + + {/* New tab (+) */} + +
+ + {/* Close panel (hide only, does not destroy tabs) */} + +
+ + {/* All tab DOM containers — permanently in DOM across ALL sessions. + Only the active session's active tab is visible. */} +
+ + {(tab) => ( +
initTab(tab.id, tab.sessionId, el)} + class="absolute inset-0" + style={{ + display: + tab.sessionId === props.sessionId && tab.id === currentActiveTab() + ? "block" + : "none", + padding: "4px 0 4px 8px", + }} + /> + )} + +
+
+ ); +} diff --git a/src/lib/electron-api.ts b/src/lib/electron-api.ts index d7eb7cb9..d680594f 100644 --- a/src/lib/electron-api.ts +++ b/src/lib/electron-api.ts @@ -398,6 +398,48 @@ export const autostartAPI = { }, }; +// Terminal API +function getTerminalAPI(): any { + const api = getElectronAPI(); + return (api as any)?.terminal ?? null; +} + +export const terminalAPI = { + isAvailable(): boolean { + return getTerminalAPI() !== null; + }, + + async create(cwd: string, cols: number, rows: number): Promise { + const api = getTerminalAPI(); + return api ? api.create(cwd, cols, rows) : null; + }, + + write(id: string, data: string): void { + const api = getTerminalAPI(); + if (api) api.write(id, data); + }, + + resize(id: string, cols: number, rows: number): void { + const api = getTerminalAPI(); + if (api) api.resize(id, cols, rows); + }, + + destroy(id: string): void { + const api = getTerminalAPI(); + if (api) api.destroy(id); + }, + + onData(callback: (id: string, data: string) => void): (() => void) | null { + const api = getTerminalAPI(); + return api ? api.onData(callback) : null; + }, + + onExit(callback: (id: string) => void): (() => void) | null { + const api = getTerminalAPI(); + return api ? api.onExit(callback) : null; + }, +}; + /** * Get the OpenCode session storage folder path for a project. * OpenCode uses xdg-basedir: ~/.local/share/opencode/storage/session/{projectId}/ diff --git a/src/locales/en.ts b/src/locales/en.ts index d10078fc..984c425f 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -512,6 +512,11 @@ export interface LocaleDict { openInExplorer: string; }; + // Terminal + terminal: { + togglePanel: string; + }; + // Scheduled Tasks scheduledTask: { title: string; @@ -1106,6 +1111,10 @@ export const en: LocaleDict = { openInExplorer: "Open in file explorer", }, + terminal: { + togglePanel: "Toggle terminal", + }, + scheduledTask: { title: "Scheduled Tasks", create: "Create Task", diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 046912e6..1e77a977 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -513,6 +513,10 @@ export const ru: LocaleDict = { openInExplorer: "Открыть в проводнике", }, + terminal: { + togglePanel: "Переключить терминал", + }, + scheduledTask: { title: "Запланированные задачи", create: "Создать задачу", diff --git a/src/locales/zh.ts b/src/locales/zh.ts index da13fe57..76154024 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -510,6 +510,10 @@ export const zh: LocaleDict = { openInExplorer: "在文件管理器中打开", }, + terminal: { + togglePanel: "切换终端", + }, + scheduledTask: { title: "定时任务", create: "创建任务", diff --git a/src/pages/Chat.tsx b/src/pages/Chat.tsx index e407472f..c3e9ca13 100644 --- a/src/pages/Chat.tsx +++ b/src/pages/Chat.tsx @@ -11,6 +11,7 @@ import { Suspense, untrack, } from "solid-js"; +import { createStore } from "solid-js/store"; import { Auth } from "../lib/auth"; import { useNavigate } from "@solidjs/router"; import { gateway } from "../lib/gateway-api"; @@ -47,12 +48,14 @@ const FileExplorer = lazy(() => })), ); import { ResizeHandle } from "../components/ResizeHandle"; +import { TerminalPanel } from "../components/TerminalPanel"; import { fileStore, togglePanel, setPanelWidth, closePanel } from "../stores/file"; import { handleFileChanged, refreshGitStatus } from "../stores/file"; import { configStore, setConfigStore, getSelectedModelForEngine, restoreEngineModelSelections, isEngineEnabled, restoreEnabledEngines, getDefaultEngineType, restoreDefaultEngine } from "../stores/config"; import { scheduledTaskStore, setScheduledTaskStore } from "../stores/scheduled-task"; import { computeActiveSessions } from "../lib/active-sessions"; +import { terminalAPI } from "../lib/electron-api"; // Binary search helper (consistent with opencode desktop) function binarySearch( @@ -1821,6 +1824,38 @@ export default function Chat() { return session?.title || ""; }); + // Terminal panel state — per-session open/closed + const [terminalOpenBySession, setTerminalOpenBySession] = createStore>({}); + const [terminalHeight, setTerminalHeight] = createSignal(250); + + const currentSessionDir = createMemo(() => { + const sid = sessionStore.current; + if (!sid) return "."; + const session = sessionStore.list.find(s => s.id === sid); + return session?.directory || "."; + }); + + // Ref to TerminalPanel's addTab so we can trigger first-tab creation + let addTerminalTab: ((sessionId: string) => void) | undefined; + + const terminalOpen = () => { + const sid = sessionStore.current; + return !!sid && !!terminalOpenBySession[sid]; + }; + + const toggleTerminal = () => { + const sid = sessionStore.current; + if (!sid) return; + const willOpen = !terminalOpenBySession[sid]; + setTerminalOpenBySession(sid, willOpen); + // Ensure first tab exists for this session when opening + if (willOpen && addTerminalTab) addTerminalTab(sid); + }; + const closeTerminal = () => { + const sid = sessionStore.current; + if (sid) setTerminalOpenBySession(sid, false); + }; + createEffect(() => { initializeSession(); @@ -1893,8 +1928,24 @@ export default function Chat() {
- {/* Right: File explorer toggle + connection status */} + {/* Right: Terminal toggle + File explorer toggle + connection status */}
+ + +
-
+
{/* Mobile Sidebar Overlay */} @@ -2027,8 +2078,11 @@ export default function Chat() {
- {/* Main Chat Area */} -
+ {/* Main content area: chat + file explorer + terminal (sidebar stays full height) */} +
+ + {/* Chat + File Explorer row */} +
@@ -2256,6 +2310,31 @@ export default function Chat() { })()}
+ + {/* Terminal Panel — always mounted to preserve PTY state across sessions */} +
+ + { addTerminalTab = fn; }} + /> +
+
Promise; setEnabled: (enabled: boolean) => Promise<{ success: boolean }>; }; + + terminal: { + create: (cwd: string, cols: number, rows: number) => Promise; + write: (id: string, data: string) => Promise; + resize: (id: string, cols: number, rows: number) => Promise; + destroy: (id: string) => Promise; + onData: (callback: (id: string, data: string) => void) => () => void; + onExit: (callback: (id: string) => void) => () => void; + }; } interface UpdateState {