Skip to content
Merged
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
48 changes: 48 additions & 0 deletions docs/tui-tabs.md
Original file line number Diff line number Diff line change
@@ -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:<backend>` 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:<name>` and the tab shows install guidance with a shortcut to
Settings → Engine backends instead of a dead terminal.
93 changes: 93 additions & 0 deletions src-tauri/src/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,48 @@ pub fn build_args(kind: BackendKind, opts: &BackendOpts) -> Vec<String> {
}
}

// --- 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<PathBuf> {
Expand Down Expand Up @@ -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| {
Expand Down
62 changes: 59 additions & 3 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:<name>` 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<String>,
command: Option<String>,
args: Option<Vec<String>>,
bin_override: Option<String>,
app: AppHandle,
ptys: tauri::State<Ptys>,
) -> 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,
Expand All @@ -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);

Expand Down
5 changes: 5 additions & 0 deletions src-tauri/src/shell_env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,11 @@ pub fn snapshot_path() -> Option<String> {
state().read().unwrap().as_ref().and_then(|e| e.vars.get("PATH").cloned())
}

/// 快照的完整变量表(PTY 里跑 TUI 后端时叠加使用,不做全清重建)。
pub fn snapshot_vars() -> Option<HashMap<String, String>> {
state().read().unwrap().as_ref().map(|e| e.vars.clone())
}

/// 在不清空现有环境的前提下合并快照(git/gh 这类命令用:既要终端 PATH/
/// 凭据环境,又不值得为它们做全清重建)。
pub fn merge_into(cmd: &mut Command) {
Expand Down
17 changes: 14 additions & 3 deletions src/lib/CommandPalette.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -23,7 +23,8 @@
onMarket,
onTogglePanel,
onToggleTheme,
onSetup
onSetup,
onOpenTui
}: {
chat: ChatState | undefined;
hasProject: boolean;
Expand All @@ -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 = {
Expand Down Expand Up @@ -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) },
Expand Down
Loading
Loading