diff --git a/docs/tui-tabs.md b/docs/tui-tabs.md new file mode 100644 index 0000000..093eb16 --- /dev/null +++ b/docs/tui-tabs.md @@ -0,0 +1,48 @@ +# Native TUI tabs + +A TUI tab runs the **real interactive CLI** — `jucode`, `codex` or `claude` — +inside the desktop app: a pty (portable-pty) on the Rust side, xterm.js on the +front. Nothing is re-rendered or emulated; what you see is exactly what the +terminal would show. + +## Opening a tab + +- **Command palette** (⌘K): "Open TUI: JuCode / Codex / Claude Code". This + re-activates an existing tab for that backend if one is open. +- **Dock "+" menu** (mosaic leaf or classic tab bar): the `TUI · jucode` / + `TUI · codex` / `TUI · claude` entries. Each pick spawns a fresh tab, so you + can deliberately run several at once. + +The process starts in the active project's directory. Closing the tab kills +the process; restarting after exit spawns a new one. + +## v1 semantics + +- **Independent session.** A TUI tab is its own process with its own session + state. It is *not* another view of a GUI chat session, and there is no + handoff between the two. +- Tab kinds are `tui:` in the dock/mosaic layout (see + `src/lib/workbench/tuiTab.ts`). They persist with the layout and respawn a + fresh process on app restart, like terminal tabs. + +## Security model + +The webview never passes argv or a program path directly: + +- `pty_open` accepts a `command` **name** that must parse as one of the fixed + backends (`jucode` / `codex` / `claude`); anything else is rejected. +- Extra `args` are validated against a per-backend exact-token allowlist + (`backend::validate_tui_args`): none for jucode, `resume` for codex, + `--continue` / `--resume` for claude. No values, no free-form flags. +- The binary resolves exactly like engine spawns (`backend::resolve_backend_bin`): + env override (`JUCODE_BIN` …) → settings path (`bin_override`, validated) → + PATH → well-known install dirs. +- The TUI child gets the login-shell env snapshot overlaid (see + `shell_env.rs`) so PATH / proxy / CA vars match the user's terminal, plus + `TERM=xterm-256color`. + +## Missing binary + +If the CLI can't be found anywhere, `pty_open` fails fast with +`binary-missing:` and the tab shows install guidance with a shortcut to +Settings → Engine backends instead of a dead terminal. diff --git a/src-tauri/src/backend.rs b/src-tauri/src/backend.rs index ada88d3..3e9270d 100644 --- a/src-tauri/src/backend.rs +++ b/src-tauri/src/backend.rs @@ -322,6 +322,48 @@ pub fn build_args(kind: BackendKind, opts: &BackendOpts) -> Vec { } } +// --- native TUI tabs (pty-backed interactive CLI sessions) --- + +/// argv tokens `pty_open` accepts per backend when spawning the interactive +/// TUI. Exact-match tokens only — no values, no free-form flags — so the +/// webview can never smuggle arbitrary argv into a spawn. +pub fn tui_allowed_args(kind: BackendKind) -> &'static [&'static str] { + match kind { + // Bare `jucode` runs the TUI; it has no safe extra tokens. + BackendKind::Jucode => &[], + // `codex resume` opens the interactive session picker. + BackendKind::Codex => &["resume"], + // `claude --continue` resumes the last session; a bare `claude + // --resume` opens the interactive session picker. + BackendKind::Claude => &["--continue", "--resume"], + } +} + +/// Validates the extra argv for a TUI spawn against the backend's fixed +/// token allowlist. Anything else — flags, values, whitespace tricks — is +/// rejected outright. +pub fn validate_tui_args(kind: BackendKind, args: &[String]) -> Result<(), String> { + for arg in args { + if !tui_allowed_args(kind).contains(&arg.as_str()) { + return Err(format!( + "argument not allowed for {} TUI: {arg}", + kind.bin_name() + )); + } + } + Ok(()) +} + +/// Validates a settings-provided binary path for a TUI spawn (same rule as +/// the `bin_override` backend option: no leading dash, no control chars). +pub fn validate_bin_override(s: &str) -> Result<(), String> { + if is_valid_value(s) { + Ok(()) + } else { + Err(format!("invalid bin_override: {s}")) + } +} + /// Well-known install locations probed after PATH (a packaged app inherits a /// minimal PATH from launchd / the desktop session). fn well_known_paths(kind: BackendKind) -> Vec { @@ -766,6 +808,57 @@ mod tests { assert!(probed.iter().any(|c| c.ends_with(".local/bin/claude") || c.ends_with(".local\\bin\\claude.exe"))); } + // --- TUI spawn allowlists --- + + #[test] + fn tui_accepts_empty_args_for_every_backend() { + for kind in [BackendKind::Jucode, BackendKind::Codex, BackendKind::Claude] { + assert!(validate_tui_args(kind, &[]).is_ok(), "{kind:?}"); + } + } + + #[test] + fn tui_args_are_fixed_tokens_per_backend() { + assert!(validate_tui_args(BackendKind::Codex, &["resume".into()]).is_ok()); + assert!(validate_tui_args(BackendKind::Claude, &["--continue".into()]).is_ok()); + assert!(validate_tui_args(BackendKind::Claude, &["--resume".into()]).is_ok()); + // Tokens don't leak across backends. + assert!(validate_tui_args(BackendKind::Jucode, &["resume".into()]).is_err()); + assert!(validate_tui_args(BackendKind::Codex, &["--continue".into()]).is_err()); + assert!(validate_tui_args(BackendKind::Claude, &["resume".into()]).is_err()); + } + + #[test] + fn tui_rejects_arbitrary_and_dangerous_argv() { + for bad in [ + "-rf", + "--dangerously-skip-permissions", + "--resume=abc", + "--resume x", + "; rm -rf ~", + "serve", + "app-server", + "", + ] { + assert!( + validate_tui_args(BackendKind::Claude, &[bad.to_string()]).is_err(), + "{bad:?} must be rejected" + ); + } + // One good token doesn't smuggle a bad one through. + assert!( + validate_tui_args(BackendKind::Claude, &["--continue".into(), "-x".into()]).is_err() + ); + } + + #[test] + fn tui_bin_override_rejects_flag_like_and_control_values() { + assert!(validate_bin_override("/usr/local/bin/claude").is_ok()); + assert!(validate_bin_override("-rf").is_err()); + assert!(validate_bin_override("a\u{7}b").is_err()); + assert!(validate_bin_override("").is_err()); + } + #[test] fn jucode_probes_dev_build_before_bare_fallback() { let p = resolve_with(BackendKind::Jucode, None, &no_env, &no_which, &|c| { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8c5d53e..ceb510f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2435,18 +2435,59 @@ fn default_shell() -> String { "/bin/sh".to_string() } -/// Opens a pseudo-terminal running the user's shell in the project root. Output -/// is streamed to the webview as `pty-output` events tagged with `id`. +/// Opens a pseudo-terminal in the project root. Without `command` it runs the +/// user's shell (the embedded terminal panel), exactly as before. With +/// `command` it runs one of the allowlisted agent CLIs (`jucode` / `codex` / +/// `claude`) as a real interactive TUI: the name is parsed against the fixed +/// backend set, extra `args` are validated against a per-backend token +/// allowlist (see `backend::validate_tui_args` — raw argv from the webview is +/// never accepted), and the binary resolves exactly like engine spawns (env +/// override → settings `bin_override` → PATH → well-known dirs). A missing +/// binary fails fast with a `binary-missing:` error the frontend can +/// turn into install guidance. Output is streamed to the webview as +/// `pty-output` events tagged with `id`. #[tauri::command] +#[allow(clippy::too_many_arguments)] // tauri command: every webview arg is a parameter fn pty_open( id: String, cols: u16, rows: u16, cwd: Option, + command: Option, + args: Option>, + bin_override: Option, app: AppHandle, ptys: tauri::State, ) -> Result<(), String> { use portable_pty::{native_pty_system, CommandBuilder, PtySize}; + + let extra_args = args.unwrap_or_default(); + let program = match command.as_deref() { + None => { + if !extra_args.is_empty() || bin_override.is_some() { + return Err("args and bin_override require a command".to_string()); + } + PathBuf::from(default_shell()) + } + Some(name) => { + let kind = BackendKind::parse(name)?; + backend::validate_tui_args(kind, &extra_args)?; + if let Some(o) = bin_override.as_deref() { + backend::validate_bin_override(o)?; + } + let bin = backend::resolve_backend_bin(kind, bin_override.as_deref()); + // A bare name means resolution found nothing anywhere; an explicit + // path must exist. Fail fast with a matchable error instead of + // leaving the user a dead terminal. + let resolved = if bin.components().count() == 1 { + which(&bin.to_string_lossy()) + } else { + bin.is_file().then_some(bin) + }; + resolved.ok_or_else(|| format!("binary-missing:{}", kind.bin_name()))? + } + }; + let pair = native_pty_system() .openpty(PtySize { rows, @@ -2460,8 +2501,23 @@ fn pty_open( .map(PathBuf::from) .filter(|p| p.is_dir()) .unwrap_or_else(resolve_cwd); - let mut cmd = CommandBuilder::new(default_shell()); + let mut cmd = CommandBuilder::new(&program); + for arg in &extra_args { + cmd.arg(arg); + } cmd.cwd(dir); + if command.is_some() { + // Terminal-equivalent env for TUI CLIs (see shell_env): overlay the + // login-shell snapshot so PATH / proxies / CA vars match the user's + // terminal. The plain-shell branch stays untouched — that shell loads + // its own rc files anyway. + if let Some(vars) = shell_env::snapshot_vars() { + for (k, v) in vars { + cmd.env(k, v); + } + } + cmd.env("TERM", "xterm-256color"); + } let child = pair.slave.spawn_command(cmd).map_err(|e| e.to_string())?; drop(pair.slave); diff --git a/src-tauri/src/shell_env.rs b/src-tauri/src/shell_env.rs index 6b55876..c417e43 100644 --- a/src-tauri/src/shell_env.rs +++ b/src-tauri/src/shell_env.rs @@ -181,6 +181,11 @@ pub fn snapshot_path() -> Option { state().read().unwrap().as_ref().and_then(|e| e.vars.get("PATH").cloned()) } +/// 快照的完整变量表(PTY 里跑 TUI 后端时叠加使用,不做全清重建)。 +pub fn snapshot_vars() -> Option> { + state().read().unwrap().as_ref().map(|e| e.vars.clone()) +} + /// 在不清空现有环境的前提下合并快照(git/gh 这类命令用:既要终端 PATH/ /// 凭据环境,又不值得为它们做全清重建)。 pub fn merge_into(cmd: &mut Command) { diff --git a/src/lib/CommandPalette.svelte b/src/lib/CommandPalette.svelte index 867d134..628fc9f 100644 --- a/src/lib/CommandPalette.svelte +++ b/src/lib/CommandPalette.svelte @@ -3,10 +3,10 @@ import { Search, Plus, FolderPlus, Cpu, RotateCcw, History, Layers, Gauge, Activity, Stethoscope, GitBranch, GitBranchPlus, Store, Settings as SettingsIcon, - PanelRight, SunMoon, ChevronRight, Wrench + PanelRight, SunMoon, ChevronRight, Wrench, SquareTerminal } from 'lucide-svelte'; import type { ChatState } from '$lib/chat.svelte'; - import { caps, type BackendCaps } from '$lib/backends'; + import { caps, BACKEND_IDS, BACKEND_LABELS, type BackendCaps, type BackendId } from '$lib/backends'; import { focusTrap } from '$lib/focusTrap'; import { t } from '$lib/i18n'; @@ -23,7 +23,8 @@ onMarket, onTogglePanel, onToggleTheme, - onSetup + onSetup, + onOpenTui }: { chat: ChatState | undefined; hasProject: boolean; @@ -39,6 +40,8 @@ onTogglePanel: () => void; onToggleTheme: () => void; onSetup: () => void; + /** Open a native TUI tab (real interactive CLI in a pty) for a backend. */ + onOpenTui: (backend: BackendId) => void; } = $props(); type Action = { @@ -82,6 +85,14 @@ { id: 'stats', label: t('shell.cmd.stats'), icon: Activity, keywords: t('shell.cmd.statsKw'), cap: 'slashCommands', run: wrap(() => onRun('/stats')) }, { id: 'doctor', label: t('shell.cmd.doctor'), icon: Stethoscope, keywords: t('shell.cmd.doctorKw'), cap: 'slashCommands', run: wrap(() => onRun('/doctor')) }, { id: 'market', label: t('shell.cmd.market'), icon: Store, keywords: t('shell.cmd.marketKw'), cap: 'skills', run: wrap(onMarket) }, + ...BACKEND_IDS.map((b): Action => ({ + id: `tui-${b}`, + label: t('shell.cmd.openTui', { name: BACKEND_LABELS[b] }), + hint: t('shell.cmd.openTuiHint'), + icon: SquareTerminal, + keywords: `${t('shell.cmd.openTuiKw')} ${b}`, + run: wrap(() => onOpenTui(b)) + })), { id: 'settings', label: t('shell.cmd.settings'), keys: '⌘,', icon: SettingsIcon, keywords: t('shell.cmd.settingsKw'), run: wrap(onSettings) }, { id: 'setup', label: t('shell.cmd.setup'), hint: t('shell.cmd.setupHint'), icon: Wrench, keywords: t('shell.cmd.setupKw'), run: wrap(onSetup) }, { id: 'panel', label: t('shell.cmd.panel'), keys: '⌘B', icon: PanelRight, keywords: t('shell.cmd.panelKw'), run: wrap(onTogglePanel) }, diff --git a/src/lib/RightDock.svelte b/src/lib/RightDock.svelte index 1c927c4..896301d 100644 --- a/src/lib/RightDock.svelte +++ b/src/lib/RightDock.svelte @@ -1,6 +1,6 @@ {#snippet panelBody(kind: string)} + {@const tui = tuiBackendOf(kind)} {#if kind === 'plan'} {:else if kind === 'goal'} {:else if kind === 'changes'} @@ -247,7 +295,8 @@ {:else if kind === 'git'} {:else if kind === 'term'} {:else if kind === 'browser'} - {:else if kind === 'diag'}{/if} + {:else if kind === 'diag'} + {:else if tui}{/if} {/snippet} {#if prefs.mosaic} @@ -256,7 +305,7 @@ layout={tiles} onchange={setTiles} label={mosaicLabel} - addOptions={PANELS.map((p) => ({ key: p.key, label: labelOf(p.key) }))} + addOptions={[...PANELS, ...TUI_OPTIONS].map((p) => ({ key: p.key, label: labelOf(p.key) }))} onAdd={mosaicAdd} emptyText={t('dock.dock.empty')} > @@ -300,7 +349,7 @@ {#if addOpen}
- {#each PANELS as p (p.key)} + {#each [...PANELS, ...TUI_OPTIONS] as p (p.key)} {/each}
diff --git a/src/lib/TuiPanel.svelte b/src/lib/TuiPanel.svelte new file mode 100644 index 0000000..44f5e8c --- /dev/null +++ b/src/lib/TuiPanel.svelte @@ -0,0 +1,247 @@ + + +
+
+ {#if status === 'missing' || status === 'error'} +
+

+ {status === 'missing' ? t('dock.tui.missing', { bin: backend }) : t('dock.tui.failed')} +

+ {#if status === 'missing'} +

{t('dock.tui.missingHint', { bin: backend })}

+ {:else} +

{errMsg}

+ {/if} +
+ {#if status === 'missing' && onOpenSettings} + + {/if} + +
+
+ {:else if status === 'exited'} +
+ {t('dock.tui.exited')} + +
+ {/if} +
+ + diff --git a/src/lib/i18n/messages/dock.ts b/src/lib/i18n/messages/dock.ts index 9705f2b..0b64972 100644 --- a/src/lib/i18n/messages/dock.ts +++ b/src/lib/i18n/messages/dock.ts @@ -24,6 +24,15 @@ const dock = { maximize: '最大化', restore: '还原' }, + tui: { + missing: '没有找到 {bin} 命令', + missingHint: '先安装 {bin},或在「设置 → 引擎后端」中填写它的可执行文件路径。', + openSettings: '打开设置', + retry: '重试', + failed: '启动失败', + exited: '进程已退出', + restart: '重新启动' + }, turns: { empty: '本会话还没有文件改动', turnN: '第 {n} 回合', @@ -201,6 +210,15 @@ const dock = { maximize: 'Maximize', restore: 'Restore' }, + tui: { + missing: 'Could not find the {bin} command', + missingHint: 'Install {bin}, or set its binary path under Settings → Engine backends.', + openSettings: 'Open settings', + retry: 'Try again', + failed: 'Failed to start', + exited: 'Process exited', + restart: 'Restart' + }, turns: { empty: 'No file changes in this session yet', turnN: 'Turn {n}', diff --git a/src/lib/i18n/messages/shell.ts b/src/lib/i18n/messages/shell.ts index a5b5718..3af030e 100644 --- a/src/lib/i18n/messages/shell.ts +++ b/src/lib/i18n/messages/shell.ts @@ -189,7 +189,10 @@ const shell = { panel: '切换右侧面板', panelKw: 'panel dock 面板', theme: '切换主题', - themeKw: 'theme dark light 主题' + themeKw: 'theme dark light 主题', + openTui: '打开 TUI:{name}', + openTuiHint: '在面板中运行交互式命令行(独立会话)', + openTuiKw: 'tui terminal cli 终端 命令行' }, // session runtime (session.svelte.ts) @@ -405,7 +408,10 @@ const shell = { panel: 'Toggle right panel', panelKw: 'panel dock', theme: 'Toggle theme', - themeKw: 'theme dark light' + themeKw: 'theme dark light', + openTui: 'Open TUI: {name}', + openTuiHint: 'Run the interactive CLI in a panel (own session)', + openTuiKw: 'tui terminal cli' }, // session runtime (session.svelte.ts) diff --git a/src/lib/protocol.ts b/src/lib/protocol.ts index 1fb6756..8d597a2 100644 --- a/src/lib/protocol.ts +++ b/src/lib/protocol.ts @@ -321,8 +321,31 @@ export function git(args: string[], cwd?: string): Promise { export function worktreeBase(cwd: string): Promise { return invoke('worktree_base', { cwd }); } -export function ptyOpen(id: string, cols: number, rows: number, cwd?: string): Promise { - return invoke('pty_open', { id, cols, rows, cwd }); +/** Non-shell pty target: `command` must be an allowlisted backend name + * (jucode / codex / claude). The Rust side validates it and `args` against + * fixed allowlists and resolves the binary like engine spawns — a missing + * binary rejects with `binary-missing:`. */ +export interface PtyCommand { + command: string; + args?: string[]; + binOverride?: string; +} +export function ptyOpen( + id: string, + cols: number, + rows: number, + cwd?: string, + cmd?: PtyCommand +): Promise { + return invoke('pty_open', { + id, + cols, + rows, + cwd, + command: cmd?.command, + args: cmd?.args, + binOverride: cmd?.binOverride + }); } export function ptyWrite(id: string, data: string): Promise { return invoke('pty_write', { id, data }); diff --git a/src/lib/tuiOpen.svelte.ts b/src/lib/tuiOpen.svelte.ts new file mode 100644 index 0000000..29a826c --- /dev/null +++ b/src/lib/tuiOpen.svelte.ts @@ -0,0 +1,18 @@ +// Cross-component signal: "open a native TUI tab for this backend". The +// command palette raises it, the page reveals the right dock, and RightDock +// adds or re-activates the tab — same pattern as browser.openSignal. + +import type { BackendId } from '$lib/backends/types'; + +class TuiOpenSignal { + /** Bumped on every request so effects can react to repeated opens. */ + n = $state(0); + backend: BackendId = 'jucode'; + + open(backend: BackendId) { + this.backend = backend; + this.n++; + } +} + +export const tuiOpen = new TuiOpenSignal(); diff --git a/src/lib/workbench/tuiTab.test.ts b/src/lib/workbench/tuiTab.test.ts new file mode 100644 index 0000000..d7e4b13 --- /dev/null +++ b/src/lib/workbench/tuiTab.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { tuiBackendOf, tuiPanelKind, tuiTabTitle } from './tuiTab'; + +describe('tui tab kinds', () => { + it('round-trips every backend through the panel kind', () => { + for (const b of ['jucode', 'codex', 'claude'] as const) { + expect(tuiBackendOf(tuiPanelKind(b))).toBe(b); + } + }); + + it('kind strings are stable (persisted in layouts)', () => { + expect(tuiPanelKind('jucode')).toBe('tui:jucode'); + }); + + it('non-tui and malformed kinds map to null', () => { + for (const k of ['term', 'browser', 'tui', 'tui:', 'tui:bash', 'tui:jucode:x', 'TUI:jucode']) { + expect(tuiBackendOf(k)).toBeNull(); + } + }); + + it('titles read as "TUI · "', () => { + expect(tuiTabTitle('jucode')).toBe('TUI · jucode'); + expect(tuiTabTitle('claude')).toBe('TUI · claude'); + }); +}); diff --git a/src/lib/workbench/tuiTab.ts b/src/lib/workbench/tuiTab.ts new file mode 100644 index 0000000..c33b082 --- /dev/null +++ b/src/lib/workbench/tuiTab.ts @@ -0,0 +1,27 @@ +// Pure helpers for native TUI dock tabs: a tab whose panel kind encodes which +// agent CLI it runs (`tui:jucode`, `tui:codex`, `tui:claude`). Framework-free +// so the mapping stays unit-testable; every TUI tab is an independent +// interactive process, never a second view of a GUI session. + +import type { BackendId } from '$lib/backends/types'; +import { isBackendId } from '$lib/backends/types'; + +export const TUI_PANEL_PREFIX = 'tui:'; + +/** Panel kind string for a backend's TUI tab. */ +export function tuiPanelKind(backend: BackendId): string { + return `${TUI_PANEL_PREFIX}${backend}`; +} + +/** The backend a `tui:*` panel kind runs, or null for any other panel kind + * (including malformed / unknown `tui:*` strings from persisted layouts). */ +export function tuiBackendOf(panel: string): BackendId | null { + if (!panel.startsWith(TUI_PANEL_PREFIX)) return null; + const raw = panel.slice(TUI_PANEL_PREFIX.length); + return isBackendId(raw) ? raw : null; +} + +/** Tab title, e.g. "TUI · jucode" — the CLI's own name, not translated. */ +export function tuiTabTitle(backend: BackendId): string { + return `TUI · ${backend}`; +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 17b2208..2b66af3 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -29,7 +29,8 @@ type Op } from '$lib/protocol'; import { dispatch } from '$lib/backends/router'; - import { caps } from '$lib/backends'; + import { caps, type BackendId } from '$lib/backends'; + import { tuiOpen } from '$lib/tuiOpen.svelte'; import { onOpenUrl } from '@tauri-apps/plugin-deep-link'; import { updater } from '$lib/updater.svelte'; import { browser, type WebRef } from '$lib/browser.svelte'; @@ -268,6 +269,14 @@ autoCollapsedRight = false; } + // "Open TUI" (command palette): signal RightDock to add/activate the tab + // and make sure the dock it lives in is actually visible. + function openTui(backend: BackendId) { + tuiOpen.open(backend); + showRight = true; + autoCollapsedRight = false; + } + function toggleSidebar() { showSidebar = !showSidebar; localStorage.setItem('jucode-sidebar-visible', showSidebar ? '1' : '0'); @@ -1405,6 +1414,10 @@ onOpenFile={openChatFile} onOpenTask={(path, meta) => openTaskProject(path, meta)} onTaskRemoved={closeTaskProject} + onOpenSettings={() => { + settingsInitial = 'behavior'; + showSettings = true; + }} /> @@ -1511,6 +1524,7 @@ onTogglePanel={toggleRight} onToggleTheme={cycleTheme} onSetup={() => (showSetup = true)} + onOpenTui={openTui} /> {/if}