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
79 changes: 75 additions & 4 deletions crates/daemon/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2875,18 +2875,89 @@ impl SessionManager {
self.deliver_text_to_session(&params.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(
&params.session_id,
&run_body,
is_selection,
params.shimmer.as_deref(),
queued_behind_current_turn,
params.selection_block_ids.as_deref(),
);
self.broadcast_program_state(result.program.clone());
let blocks = self.program_blocks_projection(&params.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(&params.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(&params.session_id);
Ok(ProgramExecuteResult {
program: result.program,
program,
prompt,
active_run,
blocks,
Expand Down
87 changes: 87 additions & 0 deletions crates/e2e/tests/program_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(),
Expand Down
2 changes: 2 additions & 0 deletions specs/0076-program-selection-run-comment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading