diff --git a/apps/desktop/package.json b/apps/desktop/package.json index e200028..6f0ee36 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -28,6 +28,9 @@ "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-shell": "^2.0.0", "@tauri-apps/plugin-updater": "^2.0.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/xterm": "^6.0.0", "react": "^18.3.0", "react-dom": "^18.3.0" }, diff --git a/apps/desktop/src/components/Terminal.tsx b/apps/desktop/src/components/Terminal.tsx new file mode 100644 index 0000000..1eca07c --- /dev/null +++ b/apps/desktop/src/components/Terminal.tsx @@ -0,0 +1,202 @@ +// xterm.js-backed terminal component. +// Spec: docs/DEVELOPMENT_PLAN.md §4 — Mac client terminal embed +// +// MVP scope: a working shell prompt the user can run commands in. +// Commands execute via tool_bash (Rust side). Each command launches +// a fresh /bin/sh -c — no long-running PTY session (that needs +// node-pty equivalent, which is M6-rest+). +// +// Input handling: line-based. User types a full command + Enter → +// it runs → output streams back → next prompt. + +import { useEffect, useRef } from 'react'; +import { Terminal as XTerm } from '@xterm/xterm'; +import { FitAddon } from '@xterm/addon-fit'; +import { WebLinksAddon } from '@xterm/addon-web-links'; +import '@xterm/xterm/css/xterm.css'; +import { invoke } from '@tauri-apps/api/core'; + +interface BashOk { + stdout: string; + stderr: string; + exitCode: number; + timedOut: boolean; +} + +const THEME = { + background: '#0e0e10', + foreground: '#f4f4f5', + cursor: '#a3e635', + selectionBackground: '#27272a', + black: '#0e0e10', + red: '#f87171', + green: '#a3e635', + yellow: '#fcd34d', + blue: '#60a5fa', + magenta: '#c084fc', + cyan: '#67e8f9', + white: '#f4f4f5', + brightBlack: '#71717a', + brightRed: '#fca5a5', + brightGreen: '#bef264', + brightYellow: '#fde68a', + brightBlue: '#93c5fd', + brightMagenta: '#d8b4fe', + brightCyan: '#a5f3fc', + brightWhite: '#fafafa', +}; + +const PROMPT = '\x1b[1;32m$\x1b[0m '; + +export function Terminal(): JSX.Element { + const container = useRef(null); + const termRef = useRef(null); + const fitRef = useRef(null); + const inputBuf = useRef(''); + const cwd = useRef(''); + + useEffect(() => { + if (!container.current) return; + const term = new XTerm({ + theme: THEME, + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', + fontSize: 13, + cursorBlink: true, + cursorStyle: 'block', + scrollback: 5000, + }); + const fit = new FitAddon(); + term.loadAddon(fit); + term.loadAddon(new WebLinksAddon()); + term.open(container.current); + fit.fit(); + termRef.current = term; + fitRef.current = fit; + + // Capture cwd via Tauri + invoke<{ home_dir: string | null }>('get_app_info').then((info) => { + cwd.current = info.home_dir ?? '/'; + term.writeln( + '\x1b[1;36mDeepCode terminal\x1b[0m — each line runs as /bin/sh -c; type a command + Enter.', + ); + writePrompt(); + }); + + term.onData((data: string) => { + handleData(data); + }); + + const onResize = () => fit.fit(); + window.addEventListener('resize', onResize); + return () => { + window.removeEventListener('resize', onResize); + term.dispose(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + function writePrompt(): void { + const t = termRef.current; + if (!t) return; + const cwdShort = cwd.current.replace(/.*\//, ''); + t.write(`\r\n\x1b[1;34m${cwdShort || cwd.current}\x1b[0m ${PROMPT}`); + } + + function handleData(data: string): void { + const t = termRef.current; + if (!t) return; + // Handle Enter + if (data === '\r' || data === '\n') { + const cmd = inputBuf.current.trim(); + inputBuf.current = ''; + t.write('\r\n'); + if (!cmd) { + writePrompt(); + return; + } + if (cmd === 'clear' || cmd === 'cls') { + t.clear(); + writePrompt(); + return; + } + // Handle `cd` specially so subsequent commands inherit the dir + const cdMatch = /^cd(?:\s+(.+))?$/.exec(cmd); + if (cdMatch) { + const dir = (cdMatch[1] ?? '').trim() || '~'; + void resolveCdAndRun(dir); + return; + } + void runBash(cmd); + return; + } + // Handle backspace (DEL or BS) + if (data === '\x7f' || data === '\b') { + if (inputBuf.current.length > 0) { + inputBuf.current = inputBuf.current.slice(0, -1); + t.write('\b \b'); + } + return; + } + // Ignore other control chars for MVP + if (data.charCodeAt(0) < 32 && data !== '\t') { + return; + } + inputBuf.current += data; + t.write(data); + } + + async function runBash(command: string): Promise { + const t = termRef.current; + if (!t) return; + try { + const r = (await invoke('tool_bash', { + input: { command, cwd: cwd.current, timeout_ms: 60_000 }, + })) as BashOk; + if (r.stdout) t.write(r.stdout.replace(/\n/g, '\r\n')); + if (r.stderr) { + if (r.stdout && !r.stdout.endsWith('\n')) t.write('\r\n'); + t.write(`\x1b[1;31m${r.stderr.replace(/\n/g, '\r\n')}\x1b[0m`); + } + if (r.timedOut) { + t.write('\r\n\x1b[1;33m(timed out after 60s)\x1b[0m'); + } + } catch (err) { + t.write(`\r\n\x1b[1;31m${(err as Error).message ?? String(err)}\x1b[0m`); + } + writePrompt(); + } + + async function resolveCdAndRun(dir: string): Promise { + const t = termRef.current; + if (!t) return; + // Use shell to resolve ~ and relative paths atomically + const cmd = `cd ${shellQuote(dir)} && pwd`; + try { + const r = (await invoke('tool_bash', { + input: { command: cmd, cwd: cwd.current, timeout_ms: 10_000 }, + })) as BashOk; + if (r.exitCode === 0 && r.stdout.trim()) { + cwd.current = r.stdout.trim(); + } else { + t.write(`\x1b[1;31m${r.stderr || `cd: ${dir}: No such directory`}\x1b[0m`); + } + } catch (err) { + t.write(`\x1b[1;31m${(err as Error).message ?? String(err)}\x1b[0m`); + } + writePrompt(); + } + + return ( +
+
+ Terminal · /bin/sh +
+
+
+ ); +} + +function shellQuote(s: string): string { + if (s === '~' || s.startsWith('~/')) return s; // Let shell expand + return `'${s.replace(/'/g, "'\\''")}'`; +} diff --git a/apps/desktop/src/index.css b/apps/desktop/src/index.css index 233844f..7c12e1a 100644 --- a/apps/desktop/src/index.css +++ b/apps/desktop/src/index.css @@ -155,3 +155,10 @@ select:focus { outline: none; border-color: var(--accent); } } .mx-6 { margin-left: 1.5rem; margin-right: 1.5rem; } .disabled\:opacity-50:disabled { opacity: 0.5; } +.relative { position: relative; } +.absolute { position: absolute; } +.top-3 { top: 0.75rem; } +.right-3 { right: 0.75rem; } +.z-10 { z-index: 10; } +.w-1\/2 { width: 50%; } +.w-0 { width: 0; } diff --git a/apps/desktop/src/screens/Chat.tsx b/apps/desktop/src/screens/Chat.tsx index f575994..ed9ed9b 100644 --- a/apps/desktop/src/screens/Chat.tsx +++ b/apps/desktop/src/screens/Chat.tsx @@ -1,23 +1,35 @@ -// Chat screen — same shape as REPL but with split file panel. +// Chat screen — REPL + side terminal pane. // Spec: docs/VISUAL_DESIGN.html screen #2 + #8 -// Milestone: M6-rest (file panel itself is M7) +// Milestone: M6-rest (terminal embed) +import { useState } from 'react'; +import { Terminal } from '../components/Terminal.js'; import { ReplScreen } from './Repl.js'; export function ChatScreen(): JSX.Element { + const [showTerm, setShowTerm] = useState(false); return ( -
+
-
-
-

File panel

-

- Monaco-based file viewer · Source / Diff / History tabs — M7 -

-
+
+ {showTerm && }
+
); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c372bc2..c37c611 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -81,6 +81,15 @@ importers: '@tauri-apps/plugin-updater': specifier: ^2.0.0 version: 2.10.1 + '@xterm/addon-fit': + specifier: ^0.11.0 + version: 0.11.0 + '@xterm/addon-web-links': + specifier: ^0.12.0 + version: 0.12.0 + '@xterm/xterm': + specifier: ^6.0.0 + version: 6.0.0 react: specifier: ^18.3.0 version: 18.3.1 @@ -863,6 +872,15 @@ packages: '@vitest/utils@2.1.9': resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + '@xterm/addon-fit@0.11.0': + resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==} + + '@xterm/addon-web-links@0.12.0': + resolution: {integrity: sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==} + + '@xterm/xterm@6.0.0': + resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -2646,6 +2664,12 @@ snapshots: loupe: 3.2.1 tinyrainbow: 1.2.0 + '@xterm/addon-fit@0.11.0': {} + + '@xterm/addon-web-links@0.12.0': {} + + '@xterm/xterm@6.0.0': {} + accepts@2.0.0: dependencies: mime-types: 3.0.2