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
76 changes: 67 additions & 9 deletions src-tauri/src/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -384,23 +384,39 @@ pub fn tui_allowed_args(kind: BackendKind) -> &'static [&'static str] {
}
}

/// Validates the extra argv for a TUI spawn against the backend's fixed
/// token allowlist. Anything else — flags, values, whitespace tricks — is
/// The one TUI token that may be followed by a session id (GUI → TUI session
/// handoff resumes by id). jucode has no resume argv — its TUI resumes via
/// the `/resume` slash command after spawn.
fn tui_resume_flag(kind: BackendKind) -> Option<&'static str> {
match kind {
BackendKind::Claude => Some("--resume"),
BackendKind::Codex => Some("resume"),
BackendKind::Jucode | BackendKind::Acp => None,
}
}

/// Validates the extra argv for a TUI spawn. Accepted shapes only:
/// nothing, exactly one allowlisted token, or the backend's resume flag
/// followed by one id that passes `is_valid_session_id`. Anything else —
/// free-form flags, `--resume=<id>`, flag-like "ids", extra tokens — is
/// rejected outright. ACP backends are rejected entirely: their command
/// comes from the registry, not a resolvable well-known binary.
pub fn validate_tui_args(kind: BackendKind, args: &[String]) -> Result<(), String> {
if kind == BackendKind::Acp {
return Err("ACP agents cannot be opened as a TUI tab".to_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()
));
match args {
[] => Ok(()),
[tok] if tui_allowed_args(kind).contains(&tok.as_str()) => Ok(()),
[flag, id] if tui_resume_flag(kind) == Some(flag.as_str()) && is_valid_session_id(id) => {
Ok(())
}
_ => Err(format!(
"arguments not allowed for {} TUI: {}",
kind.bin_name(),
args.join(" ")
)),
}
Ok(())
}

/// Validates a settings-provided binary path for a TUI spawn (same rule as
Expand Down Expand Up @@ -985,6 +1001,48 @@ mod tests {
assert!(validate_tui_args(BackendKind::Claude, &["resume".into()]).is_err());
}

#[test]
fn tui_resume_accepts_a_validated_session_id() {
let sid = "0f3d7a1c-9e2b-4b7e-9d4d-2a1b3c4d5e6f";
assert!(validate_tui_args(BackendKind::Claude, &["--resume".into(), sid.into()]).is_ok());
assert!(validate_tui_args(BackendKind::Codex, &["resume".into(), sid.into()]).is_ok());
// The resume-with-id shape doesn't leak across backends, and jucode
// has no resume argv at all (it resumes via /resume after spawn).
assert!(validate_tui_args(BackendKind::Claude, &["resume".into(), sid.into()]).is_err());
assert!(validate_tui_args(BackendKind::Codex, &["--resume".into(), sid.into()]).is_err());
assert!(validate_tui_args(BackendKind::Jucode, &["resume".into(), sid.into()]).is_err());
assert!(validate_tui_args(BackendKind::Jucode, &["--resume".into(), sid.into()]).is_err());
// Only the resume flag takes a value.
assert!(validate_tui_args(BackendKind::Claude, &["--continue".into(), sid.into()]).is_err());
}

#[test]
fn tui_resume_rejects_invalid_ids_and_extra_tokens() {
let sid = "0f3d7a1c-9e2b-4b7e-9d4d-2a1b3c4d5e6f";
for bad_id in ["--help", "-x", "a b", "../etc/passwd", "", "a".repeat(65).as_str()] {
assert!(
validate_tui_args(BackendKind::Claude, &["--resume".into(), bad_id.into()])
.is_err(),
"{bad_id:?} must be rejected as a resume id"
);
assert!(
validate_tui_args(BackendKind::Codex, &["resume".into(), bad_id.into()]).is_err(),
"{bad_id:?} must be rejected as a resume id"
);
}
// No third token, ever.
assert!(validate_tui_args(
BackendKind::Claude,
&["--resume".into(), sid.into(), "-x".into()]
)
.is_err());
assert!(validate_tui_args(
BackendKind::Claude,
&["--continue".into(), "--resume".into(), sid.into()]
)
.is_err());
}

#[test]
fn tui_rejects_arbitrary_and_dangerous_argv() {
for bad in [
Expand Down
11 changes: 11 additions & 0 deletions src/lib/ChatPane.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
type Op
} from '$lib/protocol';
import { buildModelRows } from '$lib/composer/modelRows';
import { canHandOffToTui, isValidResumeSessionId } from '$lib/tuiHandoff';
import { dispatch } from '$lib/backends/router';
import { browser } from '$lib/browser.svelte';
import { prefs } from '$lib/prefs.svelte';
Expand Down Expand Up @@ -176,6 +177,14 @@
// yet (an optimistic push counts) and not a resumed conversation.
const backendLocked = $derived(!!session.restored || chat.userTurns > 0);

// GUI → TUI handoff: offered for the native CLIs only (never ACP), enabled
// once the engine holds a resumable conversation under a valid session id
// (same gate as SessionStore.openInTui).
const tuiCapable = $derived(canHandOffToTui(session.backendId));
const tuiReady = $derived(
isValidResumeSessionId(chat.sessionId) && (chat.resumable || !!session.restored)
);

// Current git branch for the composer's footer strip. A detached HEAD reads
// "detached"; a failed probe (not a git repo) hides the chip.
let gitBranch = $state('');
Expand Down Expand Up @@ -776,6 +785,8 @@
modelSearch={showPickerSearch}
{backendLocked}
{gitBranch}
{tuiReady}
onOpenTui={tuiCapable ? () => store.openInTui(session.id) : undefined}
onBackend={(b, acpAgent) => store.switchBackend(session.id, b, acpAgent)}
bind:pickerQuery
bind:pickerSelIdx={selIdx}
Expand Down
19 changes: 18 additions & 1 deletion src/lib/Composer.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts">
import { Send, Square, Paperclip, FastForward, ShieldCheck, CircleStop, Mic, LoaderCircle, GitBranch } from 'lucide-svelte';
import { Send, Square, Paperclip, FastForward, ShieldCheck, CircleStop, Mic, LoaderCircle, GitBranch, SquareTerminal } from 'lucide-svelte';
import { message } from '@tauri-apps/plugin-dialog';
import IconButton from '$lib/ui/IconButton.svelte';
import BackendIcon from '$lib/BackendIcon.svelte';
Expand Down Expand Up @@ -32,6 +32,8 @@
modelSearch = false,
backendLocked = true,
gitBranch = '',
tuiReady = false,
onOpenTui,
onBackend,
onSubmit,
onStop,
Expand Down Expand Up @@ -59,6 +61,12 @@
backendLocked?: boolean;
/** Current git branch for the footer strip ('' hides the chip). */
gitBranch?: string;
/** The session holds a resumable engine conversation the native TUI
* can continue (gates the "continue in TUI" chip). */
tuiReady?: boolean;
/** Hand the conversation to the native TUI. Absent (e.g. ACP) hides
* the chip entirely. */
onOpenTui?: () => void;
onBackend?: (b: BackendId, acpAgent?: { id: string; name: string }) => void | Promise<void>;
onSubmit: () => void;
onStop: () => void;
Expand Down Expand Up @@ -588,6 +596,15 @@
{/if}
</div>
{/if}
{#if onOpenTui && tuiReady}
<button
class="foot-chip"
onclick={onOpenTui}
title={t('chat.tuiContinueTitle')}
>
<SquareTerminal size={12} /><span>{t('chat.tuiContinue')}</span>
</button>
{/if}
<div class="fspace"></div>
{#if bcaps.contextUsage && ctxLimit > 0}
<div class="foot-ctx">
Expand Down
107 changes: 98 additions & 9 deletions src/lib/TuiPanel.svelte
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
<script lang="ts">
// A native TUI tab: the real interactive CLI (jucode / codex / claude)
// running in a pty, rendered by xterm. Independent of any GUI session —
// closing the tab kills the process. The backend name is the only thing
// sent to Rust; argv and binary resolution are validated there.
// A native TUI panel: the real interactive CLI (jucode / codex / claude)
// running in a pty, rendered by xterm. Two uses: a standalone `tui:*` tab
// (no args, independent of any GUI session) and a session handoff, where
// the chat tile hands its conversation over via resume argv / a `/resume`
// line (`onBackToGui` present). Closing the panel kills the process. Only
// the backend name + allowlisted args reach Rust; argv and binary
// resolution are validated there.
import { onMount, onDestroy } from 'svelte';
import { listen } from '@tauri-apps/api/event';
import { Terminal } from '@xterm/xterm';
Expand All @@ -17,10 +20,22 @@
let {
backend,
cwd = '',
args = [],
resumeCommand,
onBackToGui,
onOpenSettings
}: {
backend: BackendId;
cwd?: string;
/** Session-handoff resume argv (must match the Rust TUI allowlist,
* e.g. `['--resume', '<id>']`). Empty for standalone TUI tabs. */
args?: string[];
/** Line written into the pty once it is running — the jucode TUI has
* no resume argv and resumes via `/resume <id>\n` instead. */
resumeCommand?: string;
/** Present only for session handoffs: hand the conversation back to
* the GUI chat (shows the "back to GUI" bar). */
onBackToGui?: () => void | Promise<void>;
onOpenSettings?: () => void;
} = $props();

Expand All @@ -33,6 +48,9 @@
// pty-output/pty-exit events from the old process can't leak in.
let id = newId();
let cleanups: Array<() => void> = [];
let disposed = false;
let closing = $state(false);
let launchTask: Promise<void> | undefined;

function newId() {
return `tui-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
Expand All @@ -46,31 +64,66 @@

async function launch() {
if (!term) return;
const launchId = id;
status = 'starting';
errMsg = '';
try {
fit?.fit();
await ptyOpen(id, term.cols, term.rows, cwd || undefined, {
await ptyOpen(launchId, term.cols, term.rows, cwd || undefined, {
command: backend,
args: args.length ? args : undefined,
binOverride: loadBackendSettings().paths[backend]
});
// An unmount/back-to-GUI request may race an in-flight ptyOpen. Close
// the child it just created before allowing any GUI owner to start.
if (disposed || closing || launchId !== id) {
await ptyClose(launchId);
return;
}
status = 'running';
// Session handoff into the jucode TUI: resume the conversation with
// its slash command (the pty buffers the line until the TUI reads).
if (resumeCommand) ptyWrite(launchId, resumeCommand).catch(() => {});
} catch (e) {
if (disposed || closing || launchId !== id) return;
const msg = String(e);
status = msg.includes('binary-missing:') ? 'missing' : 'error';
errMsg = msg;
}
}

function restart() {
if (disposed || closing) return;
ptyClose(id).catch(() => {});
id = newId();
term?.reset();
launch();
launchTask = launch();
}

/** Establish exclusive ownership in the other direction too: the callback
* flips the session to GUI and respawns its engine, so it must not run
* until the current (or still-opening) pty has definitely been reaped. */
async function backToGui() {
if (!onBackToGui || closing) return;
closing = true;
const ptyId = id;
try {
await ptyClose(ptyId);
await launchTask;
// If ptyOpen was still crossing the IPC boundary, the first close
// may have found nothing. launch() also closes in that case; this is
// a final idempotent barrier before the GUI process can start.
await ptyClose(ptyId);
await onBackToGui();
} catch (e) {
if (disposed) return;
closing = false;
status = 'error';
errMsg = String(e);
}
}

onMount(() => {
let disposed = false;
(async () => {
term = new Terminal({
fontFamily:
Expand All @@ -97,7 +150,12 @@
ptyWrite(id, d).catch(() => {});
});

await launch();
if (disposed || closing) {
cleanups.forEach((f) => f());
return;
}
launchTask = launch();
await launchTask;

const ro = new ResizeObserver(() => {
try {
Expand All @@ -114,6 +172,7 @@
})();
return () => {
disposed = true;
closing = true;
};
});

Expand All @@ -122,13 +181,21 @@
});

onDestroy(() => {
disposed = true;
closing = true;
cleanups.forEach((f) => f());
ptyClose(id).catch(() => {});
term?.dispose();
});
</script>

<div class="tui-wrap">
{#if onBackToGui}
<div class="handoffbar">
<span class="hb-text">{t('dock.tui.handoff')}</span>
<button class="btn sm" disabled={closing} onclick={backToGui}>{t('dock.tui.backToGui')}</button>
</div>
{/if}
<div class="term-host" bind:this={host}></div>
{#if status === 'missing' || status === 'error'}
<div class="notice">
Expand All @@ -151,18 +218,40 @@
<div class="exitbar">
<span>{t('dock.tui.exited')}</span>
<button class="btn sm" onclick={restart}>{t('dock.tui.restart')}</button>
{#if onBackToGui}
<button class="btn sm" disabled={closing} onclick={backToGui}>{t('dock.tui.backToGui')}</button>
{/if}
</div>
{/if}
</div>

<style>
.tui-wrap {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
}
.handoffbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 4px 10px;
font-size: 12px;
color: var(--dim);
background: var(--surface);
border-bottom: 1px solid var(--hairline);
}
.hb-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.term-host {
height: 100%;
flex: 1;
min-height: 0;
width: 100%;
padding: 8px 6px 6px 10px;
background: var(--panel);
Expand Down
4 changes: 4 additions & 0 deletions src/lib/i18n/messages/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ const chat = {
statusTitle: '状态',
approvalModeTitle: '工具审批模式',
gitBranch: '当前 git 分支',
tuiContinue: '在 TUI 中继续',
tuiContinueTitle: '关闭 GUI 引擎,在原生 TUI 中恢复此会话',
context: '上下文',
toCompaction: '{pct}% · 到压缩点',
contextUsed: '{pct}% · 上下文占用',
Expand Down Expand Up @@ -91,6 +93,8 @@ const chat = {
statusTitle: 'Status',
approvalModeTitle: 'Tool approval mode',
gitBranch: 'Current git branch',
tuiContinue: 'Continue in TUI',
tuiContinueTitle: 'Close the GUI engine and resume this session in the native TUI',
context: 'Context',
toCompaction: '{pct}% · to compaction',
contextUsed: '{pct}% · context used',
Expand Down
Loading
Loading