From d3221f90aff059d6657bda75da186f4f1e3894a8 Mon Sep 17 00:00:00 2001 From: Edwin Date: Tue, 21 Jul 2026 12:55:02 -0700 Subject: [PATCH] feat(daemon): annotate selection with fork's session clip on fork Runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A selection Run dispatched to an interactive fork now appends the fork's @{session:} clip to the selected text at dispatch time — the same convention selection verbs (spec 0089) and instant dispatch (spec 0066) already follow — so the Program shows where the work went and renders the fork's live state in place. The annotated block keeps its pending shimmer with a plain 'Running' status. Best-effort: a drifted/ambiguous anchor skips the clip instead of failing the run. Owner-targeted (Shift) runs and non-fork callers add no clip. Spec 0076 updated. --- crates/daemon/src/session.rs | 79 ++++++++++++++++++- crates/e2e/tests/program_view.rs | 87 +++++++++++++++++++++ specs/0076-program-selection-run-comment.md | 2 + 3 files changed, 164 insertions(+), 4 deletions(-) diff --git a/crates/daemon/src/session.rs b/crates/daemon/src/session.rs index 516a00c0..81565f98 100644 --- a/crates/daemon/src/session.rs +++ b/crates/daemon/src/session.rs @@ -2875,7 +2875,7 @@ impl SessionManager { self.deliver_text_to_session(¶ms.session_id, &prompt).await?; (params.session_id.clone(), prompt, queued) }; - let active_run = self.start_program_run_with_dispatch_state( + self.start_program_run_with_dispatch_state( ¶ms.session_id, &run_body, is_selection, @@ -2883,10 +2883,81 @@ impl SessionManager { queued_behind_current_turn, params.selection_block_ids.as_deref(), ); - self.broadcast_program_state(result.program.clone()); - let blocks = self.program_blocks_projection(¶ms.session_id, &result.program.markdown); + + // A selection Run dispatched to a fork annotates the selection with + // the fork's session clip — parity with verbs (spec 0089) and the + // instant-dispatch fast path: the program shows *where* the work + // went, and the clip renders the fork's live state in place. + // Owner-targeted runs (Shift) add no clip: the work stays in the + // Program-owning session the user is already looking at. Best + // effort — a drifted/ambiguous anchor skips the clip rather than + // failing a run whose fork already exists and is booting. + let annotated = if params.fork { + match params.selection.as_deref().filter(|s| !s.trim().is_empty()) { + Some(raw_selection) => { + let anchor = + format!("{} @{{session:{}}}", raw_selection, execution_session_id); + // The clip lands at the end of the selection, so the + // *last* block is the one whose content (and id) gained + // it and needs its shimmer re-declared. + let content_id = construct_protocol::program_block_spans(&anchor) + .into_iter() + .last() + .map(|span| span.id) + .unwrap_or_default(); + let edit_result = self + .program_edit_from_conn( + ProgramEditParams { + session_id: params.session_id.clone(), + edits: vec![construct_protocol::ProgramEdit { + old_string: raw_selection.to_string(), + new_string: anchor, + replace_all: false, + keep_pending: true, + }], + actor: construct_protocol::ProgramUpdateActor::Agent, + note: Some("selection run".to_string()), + shimmer: vec![construct_protocol::ProgramShimmerDecl { + id: content_id, + shimmer: true, + tooltip: Some("Running".to_string()), + }], + }, + None, + ) + .await; + match edit_result { + Ok(edit_result) => Some(edit_result), + Err(e) => { + tracing::warn!( + session = %params.session_id, error = %e, + "selection run fork: session-clip annotation skipped (anchor did not apply)", + ); + None + } + } + } + None => None, + } + } else { + None + }; + // The annotation edit broadcasts the updated program (with the + // seeded run) itself; only the un-annotated path still owes one. + let (program, blocks) = match annotated { + Some(edit_result) => (edit_result.program, edit_result.blocks), + None => { + self.broadcast_program_state(result.program.clone()); + let blocks = + self.program_blocks_projection(¶ms.session_id, &result.program.markdown); + (result.program, blocks) + } + }; + // Re-snapshot after the edit: `keep_pending` remaps the annotated + // block's pending ref to its post-clip id. + let active_run = self.program_run_snapshot(¶ms.session_id); Ok(ProgramExecuteResult { - program: result.program, + program, prompt, active_run, blocks, diff --git a/crates/e2e/tests/program_view.rs b/crates/e2e/tests/program_view.rs index dfd96be3..b1eeeca7 100644 --- a/crates/e2e/tests/program_view.rs +++ b/crates/e2e/tests/program_view.rs @@ -1527,6 +1527,32 @@ async fn program_selection_fork_run_delivers_and_submits_prompt() { "fork records its owner lineage" ); + // The selection is annotated with the fork's session clip at dispatch + // (parity with verbs and instant-dispatch), and the annotated block + // shimmers with a "Running" tooltip. + let expected_clip = format!("- say hello @{{session:{fork_id}}}"); + assert!( + result.program.markdown.contains(&expected_clip), + "fork run should annotate the selection with the fork's session clip: {}", + result.program.markdown + ); + let annotated_block = result + .blocks + .iter() + .find(|b| b.text.contains("say hello")) + .expect("annotated block present in projection"); + assert!(annotated_block.shimmer, "annotated block should shimmer"); + assert_eq!( + annotated_block.tooltip.as_deref(), + Some("Running"), + "annotated block should carry the 'Running' tooltip" + ); + let refetched = d.client.program_get(&owner).await.expect("program.get"); + assert!( + refetched.program.markdown.contains(&expected_clip), + "the clip annotation is persisted daemon-side, not response-local" + ); + // The forked-run prompt must reach the fork's PTY. The shell echoes // typed input, so a distinctive token from the prompt appearing in the // fork's PTY log proves the paste landed after the harness was ready @@ -1549,6 +1575,67 @@ async fn program_selection_fork_run_delivers_and_submits_prompt() { } } +/// The owner-targeted counterpart (Shift+Run / non-fork callers): the work +/// stays in the Program-owning session the user is already looking at, so +/// no session clip is added to the selection. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn program_selection_owner_run_adds_no_session_clip() { + let d = Daemon::spawn().await.expect("daemon"); + let cwd = d.dir.path().to_string_lossy().to_string(); + + let owner = d + .client + .create(shell_session_params(&cwd, "owner")) + .await + .expect("create owner session"); + + let md = "# Todo\n\n- say hello\n"; + let updated = d + .client + .program_update(construct_protocol::ProgramUpdateParams { + session_id: owner.clone(), + markdown: md.to_string(), + base_version: None, + actor: construct_protocol::ProgramUpdateActor::Human, + template_id: None, + note: None, + shimmer: None, + shimmer_tooltips: None, + }) + .await + .expect("program.update"); + + let result = d + .client + .program_execute(construct_protocol::ProgramExecuteParams { + session_id: owner.clone(), + selection: Some("- say hello".to_string()), + base_version: Some(updated.program.version), + shimmer: None, + selection_block_ids: None, + comment: None, + fork: false, + }) + .await + .expect("program.execute"); + + assert_eq!( + result.execution_session_id.as_deref(), + Some(owner.as_str()), + "owner run executes on the owning session" + ); + assert!( + !result.program.markdown.contains("@{session:"), + "owner run must not annotate the selection with a session clip: {}", + result.program.markdown + ); + let refetched = d.client.program_get(&owner).await.expect("program.get"); + assert!( + !refetched.program.markdown.contains("@{session:"), + "no clip annotation daemon-side either" + ); +} + fn shell_session_params(cwd: &str, title: &str) -> construct_protocol::CreateSessionParams { construct_protocol::CreateSessionParams { harness: "shell".to_string(), diff --git a/specs/0076-program-selection-run-comment.md b/specs/0076-program-selection-run-comment.md index 8101d2fa..188bdcf5 100644 --- a/specs/0076-program-selection-run-comment.md +++ b/specs/0076-program-selection-run-comment.md @@ -11,6 +11,8 @@ When Program text is selected, the TUI selection context menu offers one Run but Selection Run executes in a visible interactive same-harness fork by default, even when the optional instruction is populated. The fork receives the selected Run context but writes progress and results directly to the Program-owning session's document with an explicit target session id; it does not return work for the owner to merge or queue a follow-up turn onto the owner. Holding Shift reverses only the execution destination: Shift+Enter and Shift+click Run deliver the same Run to the Program-owning session. While Shift is held, the menu label and focused-row description preview that it will run on the main session. Full-document/title-bar Run and non-selection API callers retain their established owner-session behavior unless they explicitly request a fork. +When the Run is dispatched to a fork, the daemon annotates the selection with the fork's session clip at dispatch time — the same convention selection verbs and instant dispatch follow — so the Program document shows where the work went and renders the fork's live state in place. The annotated selection keeps its pending shimmer with a plain "Running" status. The annotation is best-effort: if the selection anchor no longer applies verbatim (concurrent edit, ambiguity), the Run proceeds without a clip rather than failing. Owner-targeted Runs (Shift, or callers not requesting a fork) add no session clip: the work happens in the session the user is already looking at, and a self-referential clip would be noise. + The focused instruction editor supports the same basic single-line movement and deletion keys users expect elsewhere in the TUI: C-a, C-e, C-b, C-f, C-d, and C-k. Because the field can wrap visually, C-p, C-n, Up, and Down move between wrapped visual rows. Its Run button remains visually distinct from typed text, is aligned to the right edge, and is highlighted only while the context menu is focused or hovered. The context menu content has one-column horizontal padding inside the border. The extra instruction is run metadata, not Program content. It must not alter the selected markdown, selection block identity, or optimistic shimmer scope. The daemon appends the instruction to the generated Program Run prompt and disables mechanical fast paths that cannot interpret it.