diff --git a/apps/x/apps/main/bundle.mjs b/apps/x/apps/main/bundle.mjs index 976e8db3..69bb2e1b 100644 --- a/apps/x/apps/main/bundle.mjs +++ b/apps/x/apps/main/bundle.mjs @@ -11,6 +11,9 @@ import * as esbuild from 'esbuild'; import { readFile } from 'node:fs/promises'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; // In CommonJS, import.meta.url doesn't exist. We need to polyfill it. // The banner defines __import_meta_url at the top of the bundle, @@ -24,7 +27,11 @@ await esbuild.build({ platform: 'node', target: 'node20', outfile: './.package/dist/main.cjs', - external: ['electron'], // Provided by Electron runtime + // electron is provided by the runtime. node-pty is a NATIVE module: it can't + // be inlined (its loader requires .node binaries + a spawn-helper relative to + // its own package dir), so it stays external and is copied into + // .package/node_modules below, where require() from dist/main.cjs finds it. + external: ['electron', 'node-pty'], // Use CommonJS format - many dependencies use require() which doesn't work // well with esbuild's ESM shim. CJS handles dynamic requires natively. format: 'cjs', @@ -42,4 +49,23 @@ await esbuild.build({ }, }); +// Ship node-pty next to the bundle. Resolve through pnpm's symlink to the real +// package dir and copy only what's needed at runtime (compiled JS + prebuilt +// binaries). The macOS spawn-helper must be executable — pnpm extraction drops +// the bit, and a non-executable helper makes every PTY spawn fail. +const here = path.dirname(fileURLToPath(import.meta.url)); +const ptySrc = fs.realpathSync(path.join(here, 'node_modules', 'node-pty')); +const ptyDest = path.join(here, '.package', 'node_modules', 'node-pty'); +fs.rmSync(ptyDest, { recursive: true, force: true }); +fs.mkdirSync(ptyDest, { recursive: true }); +for (const item of ['package.json', 'lib', 'prebuilds']) { + fs.cpSync(path.join(ptySrc, item), path.join(ptyDest, item), { recursive: true, dereference: true }); +} +const prebuildsDir = path.join(ptyDest, 'prebuilds'); +for (const dir of fs.readdirSync(prebuildsDir)) { + const helper = path.join(prebuildsDir, dir, 'spawn-helper'); + if (fs.existsSync(helper)) fs.chmodSync(helper, 0o755); +} +console.log('✅ node-pty staged in .package/node_modules'); + console.log('✅ Main process bundled to .package/dist-bundle/main.js'); diff --git a/apps/x/apps/main/forge.config.cjs b/apps/x/apps/main/forge.config.cjs index 7806f6cd..855a4616 100644 --- a/apps/x/apps/main/forge.config.cjs +++ b/apps/x/apps/main/forge.config.cjs @@ -32,10 +32,12 @@ module.exports = { // Since we bundle everything with esbuild, we don't need node_modules at all. // These settings prevent Forge's dependency walker (flora-colossus) from trying // to analyze/copy node_modules, which fails with pnpm's symlinked workspaces. + // Regexes are ANCHORED to the app root: .package/node_modules (where + // bundle.mjs stages the native node-pty module) must survive packaging. prune: false, ignore: [ - /src\//, - /node_modules\//, + /^\/src\//, + /^\/node_modules\//, /.gitignore/, /bundle\.mjs/, /tsconfig.json/, diff --git a/apps/x/apps/main/package.json b/apps/x/apps/main/package.json index 3330c3c0..f11c2d62 100644 --- a/apps/x/apps/main/package.json +++ b/apps/x/apps/main/package.json @@ -21,6 +21,7 @@ "electron-squirrel-startup": "^1.0.1", "html-to-docx": "^1.8.0", "mammoth": "^1.11.0", + "node-pty": "^1.1.0", "papaparse": "^5.5.3", "pdf-parse": "^2.4.5", "update-electron-app": "^3.1.2", diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index dc335f24..4eefa39b 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -40,6 +40,7 @@ import { CodeSessionService } from '@x/core/dist/code-mode/sessions/service.js'; import { CodeSessionStatusTracker } from '@x/core/dist/code-mode/sessions/status-tracker.js'; import * as codeGit from '@x/core/dist/code-mode/git/service.js'; import { readProjectDir, readProjectFile } from '@x/core/dist/code-mode/projects/fs.js'; +import { ensureTerminal, writeTerminal, resizeTerminal, disposeTerminal } from './terminal.js'; import type { CodeSession } from '@x/shared/dist/code-sessions.js'; import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.js'; import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js'; @@ -724,6 +725,7 @@ export function setupIpcHandlers() { }, 'codeSession:delete': async (_event, args) => { const service = container.resolve('codeSessionService'); + disposeTerminal(args.sessionId); await service.delete(args.sessionId, { removeWorktree: args.removeWorktree, deleteBranch: args.deleteBranch, @@ -934,6 +936,21 @@ export function setupIpcHandlers() { } return { path: result.filePaths[0] ?? null }; }, + 'terminal:ensure': async (_event, args) => { + return ensureTerminal(args.id, args.cwd, args.cols, args.rows); + }, + 'terminal:input': async (_event, args) => { + writeTerminal(args.id, args.data); + return { success: true }; + }, + 'terminal:resize': async (_event, args) => { + resizeTerminal(args.id, args.cols, args.rows); + return { success: true }; + }, + 'terminal:dispose': async (_event, args) => { + disposeTerminal(args.id); + return { success: true }; + }, 'dialog:openFiles': async (event, args) => { const win = BrowserWindow.fromWebContents(event.sender); const result = await dialog.showOpenDialog(win!, { diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 53cf9e63..50f63cfe 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -12,6 +12,7 @@ import { stopServicesWatcher, stopWorkspaceWatcher } from "./ipc.js"; +import { disposeAllTerminals } from "./terminal.js"; import { fileURLToPath, pathToFileURL } from "node:url"; import { dirname } from "node:path"; import { updateElectronApp, UpdateSourceType } from "update-electron-app"; @@ -428,6 +429,8 @@ app.on("before-quit", () => { } catch { // nothing live to dispose } + // Kill embedded terminal shells. + disposeAllTerminals(); shutdownLocalSites().catch((error) => { console.error('[LocalSites] Failed to shut down cleanly:', error); }); diff --git a/apps/x/apps/main/src/terminal.ts b/apps/x/apps/main/src/terminal.ts new file mode 100644 index 00000000..83d5a7c9 --- /dev/null +++ b/apps/x/apps/main/src/terminal.ts @@ -0,0 +1,126 @@ +import { BrowserWindow } from 'electron'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +// node-pty is a NATIVE module: it stays external to the esbuild bundle and is +// shipped alongside it in .package/node_modules (see bundle.mjs). +import * as pty from 'node-pty'; + +// One PTY per coding session, kept alive while the app runs so the terminal +// survives pane collapses and session switches. The renderer view re-attaches +// via `terminal:ensure`, which replays the recent backlog. + +const BACKLOG_LIMIT = 400_000; // chars (~400KB) of scrollback replay + +interface TerminalEntry { + proc: pty.IPty; + cwd: string; + backlog: string; + running: boolean; +} + +const terminals = new Map(); + +function broadcast(channel: 'terminal:data' | 'terminal:exit', payload: unknown): void { + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed() && win.webContents) { + win.webContents.send(channel, payload); + } + } +} + +// pnpm extracts node-pty's prebuilt macOS spawn-helper without its executable +// bit, which makes every spawn fail with "posix_spawnp failed". Repair it once. +let helperFixed = false; +function ensureSpawnHelperExecutable(): void { + if (helperFixed || process.platform === 'win32') return; + helperFixed = true; + try { + const pkgDir = path.dirname(require.resolve('node-pty/package.json')); + const helper = path.join(pkgDir, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper'); + if (fs.existsSync(helper)) { + fs.chmodSync(helper, 0o755); + } + } catch { + // best effort — spawn() will surface a real error if this mattered + } +} + +function defaultShell(): { file: string; args: string[] } { + if (process.platform === 'win32') { + return { file: 'powershell.exe', args: [] }; + } + // Login shell so the user's PATH/aliases match their normal terminal. + return { file: process.env.SHELL || '/bin/zsh', args: ['-l'] }; +} + +function spawnEntry(id: string, cwd: string, cols: number, rows: number): TerminalEntry { + ensureSpawnHelperExecutable(); + const { file, args } = defaultShell(); + const proc = pty.spawn(file, args, { + name: 'xterm-256color', + cwd, + cols, + rows, + env: { ...process.env, TERM_PROGRAM: 'rowboat' } as Record, + }); + const entry: TerminalEntry = { proc, cwd, backlog: '', running: true }; + proc.onData((data) => { + entry.backlog = (entry.backlog + data).slice(-BACKLOG_LIMIT); + broadcast('terminal:data', { id, data }); + }); + proc.onExit(({ exitCode }) => { + entry.running = false; + broadcast('terminal:exit', { id, exitCode }); + }); + terminals.set(id, entry); + return entry; +} + +// Create-or-attach. A cwd change (e.g. the session's worktree was removed) or +// an exited shell gets a fresh PTY; otherwise the live one is reused and the +// caller repaints from the backlog. +export function ensureTerminal(id: string, cwd: string, cols: number, rows: number): { backlog: string; running: boolean } { + const existing = terminals.get(id); + if (existing && existing.running && existing.cwd === cwd) { + existing.proc.resize(cols, rows); + return { backlog: existing.backlog, running: true }; + } + if (existing) { + disposeTerminal(id); + } + const fallbackCwd = fs.existsSync(cwd) ? cwd : os.homedir(); + const entry = spawnEntry(id, fallbackCwd, cols, rows); + return { backlog: entry.backlog, running: entry.running }; +} + +export function writeTerminal(id: string, data: string): void { + const entry = terminals.get(id); + if (entry?.running) entry.proc.write(data); +} + +export function resizeTerminal(id: string, cols: number, rows: number): void { + const entry = terminals.get(id); + if (entry?.running) { + try { + entry.proc.resize(cols, rows); + } catch { + // resizing a dying pty throws — harmless + } + } +} + +export function disposeTerminal(id: string): void { + const entry = terminals.get(id); + if (!entry) return; + terminals.delete(id); + try { + entry.proc.kill(); + } catch { + // already gone + } +} + +export function disposeAllTerminals(): void { + for (const id of [...terminals.keys()]) disposeTerminal(id); +} diff --git a/apps/x/apps/renderer/package.json b/apps/x/apps/renderer/package.json index 3482b58e..eec078d6 100644 --- a/apps/x/apps/renderer/package.json +++ b/apps/x/apps/renderer/package.json @@ -44,6 +44,8 @@ "@tiptap/starter-kit": "3.22.4", "@x/preload": "workspace:*", "@x/shared": "workspace:*", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "ai": "^5.0.117", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/apps/x/apps/renderer/src/components/code/code-view.tsx b/apps/x/apps/renderer/src/components/code/code-view.tsx index 277d6836..791086a5 100644 --- a/apps/x/apps/renderer/src/components/code/code-view.tsx +++ b/apps/x/apps/renderer/src/components/code/code-view.tsx @@ -1,5 +1,5 @@ -import { useCallback, useEffect, useState } from 'react' -import { Bot, Code2, GitBranch } from 'lucide-react' +import { useCallback, useEffect, useRef, useState } from 'react' +import { Bot, ChevronDown, ChevronUp, Code2, GitBranch, Terminal as TerminalIcon } from 'lucide-react' import type { CodeSession, CodeSessionStatus } from '@x/shared/src/code-sessions.js' import type { ApprovalPolicy } from '@x/shared/src/code-mode.js' import { toast } from 'sonner' @@ -25,6 +25,18 @@ import { useCodeSessions } from './use-code-sessions' import { SessionRail } from './session-rail' import { NewSessionDialog } from './new-session-dialog' import { WorkspacePane } from './workspace-pane' +import { TerminalPane } from './terminal-pane' + +const TERMINAL_HEIGHT_STORAGE_KEY = 'x:code-terminal-height' +const TERMINAL_MIN_HEIGHT = 120 +const TERMINAL_MAX_HEIGHT = 600 + +function readStoredTerminalHeight(): number { + if (typeof window === 'undefined') return 240 + const raw = Number(window.localStorage.getItem(TERMINAL_HEIGHT_STORAGE_KEY)) + if (!Number.isFinite(raw) || raw <= 0) return 240 + return Math.min(TERMINAL_MAX_HEIGHT, Math.max(TERMINAL_MIN_HEIGHT, raw)) +} const AGENT_LABEL: Record = { claude: 'Claude Code', codex: 'Codex' } const POLICY_LABEL: Record = { @@ -56,6 +68,32 @@ export function CodeView({ const [selectedSessionId, setSelectedSessionId] = useState(null) const [newSessionProjectId, setNewSessionProjectId] = useState(null) const [deleteTarget, setDeleteTarget] = useState(null) + const [terminalOpen, setTerminalOpen] = useState(false) + const [terminalHeight, setTerminalHeight] = useState(readStoredTerminalHeight) + const dragStateRef = useRef<{ startY: number; startHeight: number } | null>(null) + + useEffect(() => { + window.localStorage.setItem(TERMINAL_HEIGHT_STORAGE_KEY, String(terminalHeight)) + }, [terminalHeight]) + + const handleTerminalDragStart = useCallback((e: React.MouseEvent) => { + e.preventDefault() + dragStateRef.current = { startY: e.clientY, startHeight: terminalHeight } + const onMove = (event: MouseEvent) => { + const drag = dragStateRef.current + if (!drag) return + // Terminal sits at the bottom: dragging up grows it. + const next = drag.startHeight + (drag.startY - event.clientY) + setTerminalHeight(Math.min(TERMINAL_MAX_HEIGHT, Math.max(TERMINAL_MIN_HEIGHT, next))) + } + const onUp = () => { + dragStateRef.current = null + document.removeEventListener('mousemove', onMove) + document.removeEventListener('mouseup', onUp) + } + document.addEventListener('mousemove', onMove) + document.addEventListener('mouseup', onUp) + }, [terminalHeight]) const selectedSession = sessions.find((s) => s.id === selectedSessionId) ?? null const selectedStatus = selectedSession ? statusOf(selectedSession.id) : 'idle' @@ -191,6 +229,40 @@ export function CodeView({ onSessionChanged={() => void refresh()} /> + + {/* Embedded terminal — a real shell in the session's directory + (worktree included). The PTY lives in the main process and + survives collapsing this panel. */} +
+ {terminalOpen && ( +
+ )} + + {terminalOpen && ( +
+ +
+ )} +
) : (
diff --git a/apps/x/apps/renderer/src/components/code/terminal-pane.tsx b/apps/x/apps/renderer/src/components/code/terminal-pane.tsx new file mode 100644 index 00000000..f58fa5d9 --- /dev/null +++ b/apps/x/apps/renderer/src/components/code/terminal-pane.tsx @@ -0,0 +1,110 @@ +import { useEffect, useRef } from 'react' +import { Terminal } from '@xterm/xterm' +import { FitAddon } from '@xterm/addon-fit' +import '@xterm/xterm/css/xterm.css' +import { useTheme } from '@/contexts/theme-context' + +// xterm color schemes tuned to the app's light/dark backgrounds. +const DARK_THEME = { + background: '#1b1b1f', + foreground: '#d4d4d8', + cursor: '#d4d4d8', + selectionBackground: 'rgba(120, 140, 255, 0.3)', +} +const LIGHT_THEME = { + background: '#ffffff', + foreground: '#27272a', + cursor: '#27272a', + selectionBackground: 'rgba(60, 90, 220, 0.2)', +} + +// One embedded terminal view, attached to a per-session PTY in the main +// process. The PTY outlives this component (collapse/switch just detaches); +// on mount we re-attach and repaint from the backlog the main process keeps. +export function TerminalPane({ terminalId, cwd }: { terminalId: string; cwd: string }) { + const { resolvedTheme } = useTheme() + const containerRef = useRef(null) + const termRef = useRef(null) + + useEffect(() => { + const container = containerRef.current + if (!container) return + + const term = new Terminal({ + fontSize: 12, + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', + cursorBlink: true, + scrollback: 5000, + theme: resolvedTheme === 'dark' ? DARK_THEME : LIGHT_THEME, + }) + const fit = new FitAddon() + term.loadAddon(fit) + term.open(container) + fit.fit() + termRef.current = term + + let disposed = false + + // Attach (or spawn) the PTY at the current size, then repaint history. + void window.ipc.invoke('terminal:ensure', { + id: terminalId, + cwd, + cols: term.cols, + rows: term.rows, + }).then(({ backlog }) => { + if (disposed) return + if (backlog) term.write(backlog) + term.focus() + }) + + const dataDisposable = term.onData((data) => { + void window.ipc.invoke('terminal:input', { id: terminalId, data }) + }) + + const offData = window.ipc.on('terminal:data', (payload) => { + if (payload.id === terminalId) term.write(payload.data) + }) + const offExit = window.ipc.on('terminal:exit', (payload) => { + if (payload.id !== terminalId) return + term.write(`\r\n\x1b[2m[process exited with code ${payload.exitCode} — press Enter to restart]\x1b[0m\r\n`) + }) + + // Restart the shell on Enter after it exited (ensure() respawns dead PTYs). + const keyDisposable = term.onKey(({ domEvent }) => { + if (domEvent.key !== 'Enter') return + void window.ipc.invoke('terminal:ensure', { + id: terminalId, + cwd, + cols: term.cols, + rows: term.rows, + }) + }) + + const resizeObserver = new ResizeObserver(() => { + if (container.clientHeight === 0) return + fit.fit() + void window.ipc.invoke('terminal:resize', { id: terminalId, cols: term.cols, rows: term.rows }) + }) + resizeObserver.observe(container) + + return () => { + disposed = true + resizeObserver.disconnect() + offData() + offExit() + dataDisposable.dispose() + keyDisposable.dispose() + term.dispose() + termRef.current = null + } + // The PTY is keyed by terminalId; cwd changes (worktree cleanup) respawn via ensure. + }, [terminalId, cwd]) + + // Live theme switches restyle the existing terminal without a respawn. + useEffect(() => { + const term = termRef.current + if (term) term.options.theme = resolvedTheme === 'dark' ? DARK_THEME : LIGHT_THEME + }, [resolvedTheme]) + + return
+} diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index 54a4c61c..62185d63 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -449,6 +449,21 @@ export function SidebarContentPanel({ const [emailThreads, setEmailThreads] = useState([]) const [meetings, setMeetings] = useState([]) const [quickAccessExpanded, setQuickAccessExpanded] = useState(true) + // The Code section only makes sense with a coding agent available — same + // flag the chat composer's code chip uses (auto-on when Claude Code or + // Codex is installed + signed in; explicit toggle in settings wins). + const [codeModeEnabled, setCodeModeEnabled] = useState(false) + + useEffect(() => { + const load = () => { + window.ipc.invoke('codeMode:getConfig', null) + .then((r) => setCodeModeEnabled(r.enabled)) + .catch(() => setCodeModeEnabled(false)) + } + load() + window.addEventListener('code-mode-config-changed', load) + return () => window.removeEventListener('code-mode-config-changed', load) + }, []) useEffect(() => { let cancelled = false @@ -837,12 +852,14 @@ export function SidebarContentPanel({
) : null} - - - - Code - - + {codeModeEnabled && ( + + + + Code + + + )} =14.6'} + '@xterm/addon-fit@0.11.0': + resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==} + + '@xterm/xterm@6.0.0': + resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==} + '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -6659,6 +6674,9 @@ packages: resolution: {integrity: sha512-sn9Et4N3ynsetj3spsZR729DVlGH6iBG4RiDMV7HEp3guyOW6W3S0unGpLDxT50mXortGUMax/ykUNQXdqc/Xg==} engines: {node: '>=10'} + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + node-api-version@0.2.1: resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==} @@ -6687,6 +6705,9 @@ packages: node-html-parser@6.1.13: resolution: {integrity: sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==} + node-pty@1.1.0: + resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==} + node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} @@ -12951,6 +12972,10 @@ snapshots: '@xmldom/xmldom@0.9.10': {} + '@xterm/addon-fit@0.11.0': {} + + '@xterm/xterm@6.0.0': {} + '@xtuc/ieee754@1.2.0': {} '@xtuc/long@4.2.2': {} @@ -16119,6 +16144,8 @@ snapshots: dependencies: semver: 7.7.3 + node-addon-api@7.1.1: {} + node-api-version@0.2.1: dependencies: semver: 7.7.3 @@ -16146,6 +16173,10 @@ snapshots: css-select: 5.2.2 he: 1.2.0 + node-pty@1.1.0: + dependencies: + node-addon-api: 7.1.1 + node-releases@2.0.27: {} nopt@6.0.0: