Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ afterSign: scripts/after-sign.js
asarUnpack:
- "**/*.node"
- "**/better-sqlite3/**"
- "**/node-pty/**"
mac:
icon: build/icon.icns
category: public.app-category.developer-tools
Expand Down
62 changes: 24 additions & 38 deletions electron/terminal-manager.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { spawn, type ChildProcessWithoutNullStreams } from 'child_process';
import * as pty from 'node-pty';

export interface TerminalCreateOptions {
cwd: string;
Expand All @@ -8,20 +8,16 @@ export interface TerminalCreateOptions {
}

interface TerminalInstance {
process: ChildProcessWithoutNullStreams;
pty: pty.IPty;
cwd: string;
}

// TerminalManager — manages terminal processes.
//
// KNOWN LIMITATION: Uses child_process.spawn with stdio: 'pipe' instead of
// a real PTY. This means:
// - resize() is a no-op (COLUMNS/LINES env vars are set at creation only)
// - Full-screen programs (vim, htop) won't render correctly
// - readline line-editing may behave differently
//
// TODO: Upgrade to a real PTY library for full support. See
// docs/handover/git-terminal-layout.md for the 4-step upgrade path.
/**
* TerminalManager — manages real PTY terminal sessions via node-pty.
*
* Supports full terminal emulation: resize, full-screen programs (vim, htop),
* proper readline editing, and true xterm-256color escape sequences.
*/
export class TerminalManager {
private terminals = new Map<string, TerminalInstance>();
private onData: ((id: string, data: string) => void) | null = null;
Expand All @@ -48,57 +44,47 @@ export class TerminalManager {
...opts.env,
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
COLUMNS: String(opts.cols),
LINES: String(opts.rows),
};
// Allow launching Claude Code inside the terminal
delete env.CLAUDECODE;

const child = spawn(shell, shellArgs, {
const ptyProcess = pty.spawn(shell, shellArgs, {
name: 'xterm-256color',
cols: opts.cols,
rows: opts.rows,
cwd: opts.cwd,
env,
stdio: ['pipe', 'pipe', 'pipe'],
env: env as Record<string, string>,
});

child.stdout.on('data', (data: Buffer) => {
this.onData?.(id, data.toString());
ptyProcess.onData((data: string) => {
this.onData?.(id, data);
});

child.stderr.on('data', (data: Buffer) => {
this.onData?.(id, data.toString());
});

child.on('exit', (code) => {
ptyProcess.onExit(({ exitCode }) => {
this.terminals.delete(id);
this.onExit?.(id, code ?? 0);
this.onExit?.(id, exitCode);
});

child.on('error', (err) => {
console.error(`[terminal:${id}] error:`, err);
this.terminals.delete(id);
this.onExit?.(id, 1);
});

this.terminals.set(id, { process: child, cwd: opts.cwd });
this.terminals.set(id, { pty: ptyProcess, cwd: opts.cwd });
}

write(id: string, data: string): void {
const terminal = this.terminals.get(id);
if (!terminal) return;
terminal.process.stdin.write(data);
terminal.pty.write(data);
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
resize(id: string, _cols: number, _rows: number): void {
// With spawn (non-PTY), resize is a no-op.
// Would use pty.resize() with a real PTY library.
resize(id: string, cols: number, rows: number): void {
const terminal = this.terminals.get(id);
if (!terminal) return;
terminal.pty.resize(cols, rows);
}

kill(id: string): void {
const terminal = this.terminals.get(id);
if (!terminal) return;
try {
terminal.process.kill();
terminal.pty.kill();
} catch {
// already dead
}
Expand Down
83 changes: 62 additions & 21 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@
"@streamdown/mermaid": "1.0.1",
"@types/pngjs": "^6.0.5",
"@types/qrcode": "^1.5.6",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"ai": "^6.0.169",
"ansi-to-react": "^6.2.6",
"better-sqlite3": "^12.6.2",
Expand All @@ -94,6 +97,7 @@
"nanoid": "^5.1.6",
"next": "16.2.1",
"next-themes": "^0.4.6",
"node-pty": "^1.1.0",
"papaparse": "^5.5.3",
"pngjs": "^7.0.0",
"qrcode": "^1.5.4",
Expand Down Expand Up @@ -133,7 +137,7 @@
"electron": "^40.2.1",
"electron-builder": "^26.8.1",
"esbuild": "^0.27.3",
"eslint": "^9",
"eslint": "^9.39.4",
"eslint-config-next": "16.2.1",
"husky": "^9.1.7",
"lint-staged": "^16.3.2",
Expand Down
4 changes: 2 additions & 2 deletions scripts/after-pack.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ module.exports = async function afterPack(context) {

try {
// Use @electron/rebuild via npx (it's a dependency of electron-builder)
const rebuildCmd = `npx electron-rebuild -f -o better-sqlite3 -v ${electronVersion} -a ${archName}`;
const rebuildCmd = `npx electron-rebuild -f -o better-sqlite3,node-pty -v ${electronVersion} -a ${archName}`;
console.log(`[afterPack] Running: ${rebuildCmd}`);
execSync(rebuildCmd, {
cwd: projectDir,
Expand All @@ -52,7 +52,7 @@ module.exports = async function afterPack(context) {
buildPath: projectDir,
electronVersion: electronVersion,
arch: archName,
onlyModules: ['better-sqlite3'],
onlyModules: ['better-sqlite3', 'node-pty'],
force: true,
});
console.log('[afterPack] Rebuild via @electron/rebuild API succeeded');
Expand Down
2 changes: 1 addition & 1 deletion scripts/build-electron.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ async function buildElectron() {
bundle: true,
platform: 'node',
target: 'node18',
external: ['electron'],
external: ['electron', 'node-pty'],
sourcemap: true,
minify: false,
};
Expand Down
18 changes: 18 additions & 0 deletions src/components/layout/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ const FeatureAnnouncementDialog = dynamic(
() => import('./FeatureAnnouncementDialog').then((m) => ({ default: m.FeatureAnnouncementDialog })),
{ ssr: false },
);
const TerminalDrawer = dynamic(
() => import('@/components/terminal/TerminalDrawer').then((m) => ({ default: m.TerminalDrawer })),
{ ssr: false },
);

const SPLIT_SESSIONS_KEY = "codepilot:split-sessions";
const SPLIT_ACTIVE_COLUMN_KEY = "codepilot:split-active-column";
Expand Down Expand Up @@ -350,6 +354,19 @@ export function AppShell({ children }: { children: React.ReactNode }) {
const [fileTreeOpen, setFileTreeOpen] = useState(false);
const [terminalOpen, setTerminalOpen] = useState(false);
const [assistantPanelOpen, setAssistantPanelOpen] = useState(false);

// Terminal toggle: Cmd+` (macOS) / Ctrl+` (Windows/Linux)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const modifier = e.metaKey || e.ctrlKey;
if (modifier && e.key === '`') {
e.preventDefault();
setTerminalOpen(!terminalOpen);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [terminalOpen]);
const [isAssistantWorkspace, setIsAssistantWorkspace] = useState(false);

// --- Git summary (derived from polling hook, no setState needed) ---
Expand Down Expand Up @@ -759,6 +776,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
{children}
</ChatContentRow>
</div>
<TerminalDrawer />
</div>
{/* Phase A state gates: only mount when actually needed.
UpdateDialog gate (P3 review fix): require BOTH
Expand Down
Loading