From 29322f45257c1b8d6185de882b24bc90efb252ab Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 29 Aug 2026 11:51:43 +0000 Subject: [PATCH 1/3] Add GUI and TUI session handoff via resume id Co-authored-by: Gao Yu --- src-tauri/src/backend.rs | 76 +++++++++++++++++--- src/lib/ChatPane.svelte | 11 +++ src/lib/Composer.svelte | 28 +++++++- src/lib/TuiPanel.svelte | 57 +++++++++++++-- src/lib/i18n/messages/chat.ts | 6 ++ src/lib/i18n/messages/dock.ts | 8 ++- src/lib/session.svelte.ts | 64 ++++++++++++++++- src/lib/session.test.ts | 126 +++++++++++++++++++++++++++++++++- src/lib/tuiHandoff.test.ts | 60 ++++++++++++++++ src/lib/tuiHandoff.ts | 35 ++++++++++ src/lib/types.ts | 5 ++ src/routes/+page.svelte | 32 ++++++--- 12 files changed, 478 insertions(+), 30 deletions(-) create mode 100644 src/lib/tuiHandoff.test.ts create mode 100644 src/lib/tuiHandoff.ts diff --git a/src-tauri/src/backend.rs b/src-tauri/src/backend.rs index 6b10fd3..8664859 100644 --- a/src-tauri/src/backend.rs +++ b/src-tauri/src/backend.rs @@ -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=`, 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 @@ -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 [ diff --git a/src/lib/ChatPane.svelte b/src/lib/ChatPane.svelte index 3c9a003..60355db 100644 --- a/src/lib/ChatPane.svelte +++ b/src/lib/ChatPane.svelte @@ -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'; @@ -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(''); @@ -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} diff --git a/src/lib/Composer.svelte b/src/lib/Composer.svelte index cc3c339..6d87b49 100644 --- a/src/lib/Composer.svelte +++ b/src/lib/Composer.svelte @@ -1,5 +1,5 @@
+ {#if onBackToGui} +
+ {t('dock.tui.handoff')} + +
+ {/if}
{#if status === 'missing' || status === 'error'}
@@ -151,6 +176,9 @@
{t('dock.tui.exited')} + {#if onBackToGui} + + {/if}
{/if}
@@ -158,11 +186,30 @@