From 7738ed55b9cbe146d6a0b7064e8fb84d66d858be Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Thu, 9 Jul 2026 06:46:31 +0200 Subject: [PATCH] harness-tui + engine: jobs pane, subtask drill-in, reminders (M4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TUI: Ctrl+J (or /jobs) opens a jobs pane listing the current session's board — alias, agent, state, objective, files read — populated on load and kept live via JobUpdated events; Enter drills into a subtask's child session. Snapshot test added. Engine: optional orchestration reminders (off by default) injected as synthetic, non-persisted turn-start blocks and, after a file tool runs, an after-file-tool block on the following turn. Processor reports file-tool usage via StepOutcome. This completes M4. Co-Authored-By: Claude Opus 4.8 --- crates/harness-app/src/lib.rs | 26 ++++ crates/harness-core/src/engine/loop.rs | 105 +++++++++++-- crates/harness-core/src/engine/processor.rs | 13 ++ crates/harness-tui/src/input.rs | 56 +++++++ crates/harness-tui/src/modal.rs | 6 +- crates/harness-tui/src/render.rs | 138 +++++++++++++++++- ...render__tests__snapshot_empty_session.snap | 4 +- ...ss__render__tests__snapshot_jobs_pane.snap | 29 ++++ ...der__tests__snapshot_permission_modal.snap | 4 +- ..._tests__snapshot_session_picker_modal.snap | 4 +- ...__tests__snapshot_tool_card_completed.snap | 4 +- ...nder__tests__snapshot_tool_card_error.snap | 4 +- ...er__tests__snapshot_tool_card_running.snap | 4 +- ..._snapshot_user_and_assistant_messages.snap | 4 +- crates/harness-tui/src/state.rs | 53 ++++++- 15 files changed, 426 insertions(+), 28 deletions(-) create mode 100644 crates/harness-tui/src/snapshots/harness__render__tests__snapshot_jobs_pane.snap diff --git a/crates/harness-app/src/lib.rs b/crates/harness-app/src/lib.rs index d9822d3..64e964d 100644 --- a/crates/harness-app/src/lib.rs +++ b/crates/harness-app/src/lib.rs @@ -325,6 +325,8 @@ impl EngineHandle { instructions: self.inner.config.instructions.clone(), cost: Some(self.inner.catalog.cost(provider_id, model_id)), inject_job_board, + reminder_turn_start: self.inner.reminder("turn_start"), + reminder_after_file_tool: self.inner.reminder("after_file_tool"), }; // Reserve the run slot *before* spawning. If we inserted after spawning, a run that @@ -386,6 +388,18 @@ impl EngineHandle { Ok(self.inner.store.parts(message_id).await?) } + pub async fn get_session(&self, session_id: SessionId) -> Result, AppError> { + Ok(self.inner.store.session(session_id).await?) + } + + /// Background jobs spawned by `session_id` (the parent), for the TUI jobs pane / drill-in. + pub async fn jobs( + &self, + session_id: SessionId, + ) -> Result, AppError> { + Ok(self.inner.store.jobs_for_parent(session_id).await?) + } + pub fn is_running(&self, session_id: &SessionId) -> bool { let runs = self.inner.runs.lock().unwrap(); runs.contains_key(session_id) @@ -477,6 +491,16 @@ impl EngineInner { } } + /// An optional orchestration reminder string by hook key (`turn_start`/`after_file_tool`). + fn reminder(&self, key: &str) -> Option { + self.config + .orchestration + .reminders + .as_ref()? + .get(key) + .cloned() + } + /// The ruleset a session evaluates its own tool calls against: low-priority depth guards /// (depth > 0), then global config permissions, then the agent's own rules (highest). fn effective_static_rules(&self, agent: &AgentDef, depth: u8) -> Ruleset { @@ -712,6 +736,8 @@ impl SubagentSpawner for EngineInner { &child_session.model.model_id, )), inject_job_board: agent.mode.is_primary(), + reminder_turn_start: self.reminder("turn_start"), + reminder_after_file_tool: self.reminder("after_file_tool"), }; // Register the launch on the parent board (also makes foreground results reusable). diff --git a/crates/harness-core/src/engine/loop.rs b/crates/harness-core/src/engine/loop.rs index c0f7619..97580f7 100644 --- a/crates/harness-core/src/engine/loop.rs +++ b/crates/harness-core/src/engine/loop.rs @@ -24,6 +24,10 @@ pub struct RunConfig { pub cost: Option, /// Whether to append the background job board to requests (primary/delegating agents). pub inject_job_board: bool, + /// Optional user-provided reminder injected at the start of every turn (off by default). + pub reminder_turn_start: Option, + /// Optional user-provided reminder injected on the turn after a file tool ran. + pub reminder_after_file_tool: Option, } /// Adds a step's usage/cost onto the persisted session and republishes it. Cost accounting is @@ -162,6 +166,8 @@ pub async fn run_session( ) -> RunOutcome { let mut doomloop = DoomLoopGuard::new(); let mut steps = 0u32; + // Whether the previous step ran a file tool, gating the `after_file_tool` reminder. + let mut prev_used_file_tool = false; loop { match should_continue(&ctx.store, &ctx.session_id).await { @@ -200,18 +206,33 @@ pub async fn run_session( wire_messages.extend(convert_message(message, &parts)); } - // Append the (synthetic, non-persisted) job board to the last user message so the - // orchestrator sees running/reusable subtasks. docs/04-multiagent.md. + // Collect synthetic (non-persisted) blocks to append to the last user message this + // turn: the optional turn-start reminder, the job board, and — if the previous step + // ran a file tool — the optional after-file-tool reminder. docs/04-multiagent.md. + let mut synthetic: Vec = Vec::new(); + if let Some(reminder) = &run_config.reminder_turn_start { + synthetic.push(reminder.clone()); + } if run_config.inject_job_board { if let Some(board) = &ctx.job_board { if let Some(block) = board.format_for_prompt() { - if let Some(last_user) = wire_messages - .iter_mut() - .rev() - .find(|m| m.role == WireRole::User) - { - last_user.content.push(WireContent::Text { text: block }); - } + synthetic.push(block); + } + } + } + if prev_used_file_tool { + if let Some(reminder) = &run_config.reminder_after_file_tool { + synthetic.push(reminder.clone()); + } + } + if !synthetic.is_empty() { + if let Some(last_user) = wire_messages + .iter_mut() + .rev() + .find(|m| m.role == WireRole::User) + { + for text in synthetic { + last_user.content.push(WireContent::Text { text }); } } } @@ -278,6 +299,7 @@ pub async fn run_session( } Ok(outcome) => { accumulate_session_usage(&ctx, &outcome.usage, outcome.cost, now_fn()).await; + prev_used_file_tool = outcome.used_file_tool; // A completed step means the orchestrator has now seen any terminal jobs // that were on the board this turn; mark them reconciled. if run_config.inject_job_board { @@ -494,6 +516,8 @@ mod tests { ..Default::default() }), inject_job_board: false, + reminder_turn_start: None, + reminder_after_file_tool: None, }; let outcome = run_session(Arc::new(provider), ctx, &run_config, || 2).await; @@ -602,6 +626,8 @@ mod tests { instructions: Vec::new(), cost: None, inject_job_board: false, + reminder_turn_start: None, + reminder_after_file_tool: None, }; let outcome = run_session(Arc::new(provider), ctx, &run_config, || 2).await; @@ -707,6 +733,8 @@ mod tests { instructions: Vec::new(), cost: None, inject_job_board: true, + reminder_turn_start: None, + reminder_after_file_tool: None, }; let provider = std::sync::Arc::new(CapturingProvider { @@ -735,4 +763,63 @@ mod tests { assert!(text.contains("exp-1"), "got: {text}"); assert!(text.contains("map the auth flow"), "got: {text}"); } + + #[tokio::test] + async fn turn_start_reminder_is_injected_into_the_request() { + let store = Store::open_in_memory().unwrap(); + let bus = EventBus::new(); + let model = ModelRef::new("mock", "mock-model"); + let session = Session::new_root("orchestrator", model.clone(), 1); + let session_id = session.id.clone(); + store.upsert_session(session).await.unwrap(); + + let user_message = Message::new_user(session_id.clone(), 1); + store.upsert_message(user_message.clone()).await.unwrap(); + store + .upsert_part(Part { + id: crate::types::PartId::new(), + message_id: user_message.id.clone(), + session_id: session_id.clone(), + idx: 0, + body: PartBody::Text { + text: "do the thing".into(), + synthetic: false, + }, + }) + .await + .unwrap(); + + let cwd = tempfile::tempdir().unwrap(); + let ctx = make_ctx(store, bus, session_id, cwd.path().to_path_buf()).await; + let run_config = RunConfig { + agent_name: "orchestrator".into(), + agent_prompt: "You orchestrate.".into(), + model, + temperature: None, + max_steps: 1, + instructions: Vec::new(), + cost: None, + inject_job_board: false, + reminder_turn_start: Some("REMEMBER: stay on task.".into()), + reminder_after_file_tool: None, + }; + + let provider = std::sync::Arc::new(CapturingProvider { + last: StdMutex::new(None), + }); + let outcome = run_session(provider.clone(), ctx, &run_config, || 2).await; + assert!(matches!(outcome, RunOutcome::Stopped)); + + let req = provider.last.lock().unwrap().clone().expect("a request"); + let last_user = req + .messages + .iter() + .rev() + .find(|m| m.role == WireRole::User) + .expect("a user message"); + let has_reminder = last_user.content.iter().any( + |c| matches!(c, WireContent::Text { text } if text.contains("REMEMBER: stay on task.")), + ); + assert!(has_reminder, "turn-start reminder should be injected"); + } } diff --git a/crates/harness-core/src/engine/processor.rs b/crates/harness-core/src/engine/processor.rs index 8d0e472..c29636c 100644 --- a/crates/harness-core/src/engine/processor.rs +++ b/crates/harness-core/src/engine/processor.rs @@ -33,6 +33,9 @@ pub struct StepOutcome { /// Dollar cost of this step's usage (0.0 when no pricing is available). pub cost: f64, pub aborted: bool, + /// Whether a file-mutating tool (`edit`/`write`) ran this step — drives the optional + /// `after_file_tool` reminder injection on the next turn. + pub used_file_tool: bool, } pub struct StepError { @@ -98,9 +101,13 @@ impl FlushTracker { } } +/// Tool names that mutate files — after one runs, the optional `after_file_tool` reminder fires. +const FILE_TOOLS: &[&str] = &["edit", "write"]; + struct Run<'a> { ctx: &'a StepContext, assistant: Option, + used_file_tool: bool, next_idx: u32, active_text: Option, active_reasoning: Option, @@ -117,6 +124,7 @@ impl<'a> Run<'a> { Self { ctx, assistant: None, + used_file_tool: false, next_idx: 0, active_text: None, active_reasoning: None, @@ -386,6 +394,9 @@ impl<'a> Run<'a> { input: serde_json::Value, doomloop: &mut DoomLoopGuard, ) -> Result<(), ProviderError> { + if FILE_TOOLS.contains(&name.as_str()) { + self.used_file_tool = true; + } let part_id = self.pending_tools.remove(&call_id).unwrap_or_default(); let running = Part { id: part_id.clone(), @@ -607,6 +618,7 @@ pub async fn process_step( usage, cost: step_cost, aborted: true, + used_file_tool: run.used_file_tool, }); } }; @@ -679,5 +691,6 @@ pub async fn process_step( usage, cost: step_cost, aborted: false, + used_file_tool: run.used_file_tool, }) } diff --git a/crates/harness-tui/src/input.rs b/crates/harness-tui/src/input.rs index ee8097c..e4309f8 100644 --- a/crates/harness-tui/src/input.rs +++ b/crates/harness-tui/src/input.rs @@ -23,6 +23,10 @@ pub enum InputAction { SessionPickerUp, SessionPickerDown, SessionPickerSelect, + OpenJobs, + JobsUp, + JobsDown, + JobsDrillIn, } /// Translate a crossterm event into an action and/or mutate `state` directly. @@ -49,10 +53,21 @@ fn handle_key(key: KeyEvent, state: &mut AppState) -> InputAction { match &state.modal { ModalState::Permission { .. } => handle_permission_key(key, state), ModalState::SessionPicker { .. } => handle_session_picker_key(key, state), + ModalState::JobsPane { .. } => handle_jobs_pane_key(key, state), ModalState::None => handle_normal_key(key, state), } } +fn handle_jobs_pane_key(key: KeyEvent, _state: &mut AppState) -> InputAction { + match key.code { + KeyCode::Up => InputAction::JobsUp, + KeyCode::Down => InputAction::JobsDown, + KeyCode::Enter => InputAction::JobsDrillIn, + KeyCode::Esc => InputAction::CloseModal, + _ => InputAction::None, + } +} + fn handle_permission_key(key: KeyEvent, _state: &mut AppState) -> InputAction { match key.code { KeyCode::Char('y') | KeyCode::Char('Y') => { @@ -119,6 +134,7 @@ fn handle_normal_key(key: KeyEvent, state: &mut AppState) -> InputAction { } } KeyCode::Char('s') if ctrl => InputAction::LoadSessions, + KeyCode::Char('j') if ctrl => InputAction::OpenJobs, KeyCode::Up => { let (row, _) = state.input.cursor(); if row == 0 { @@ -158,6 +174,7 @@ fn parse_slash_command(text: &str) -> Option { "/model" if !rest.is_empty() => Some(InputAction::SetModel(rest)), "/agent" if !rest.is_empty() => Some(InputAction::SetAgent(rest)), "/sessions" => Some(InputAction::LoadSessions), + "/jobs" => Some(InputAction::OpenJobs), "/quit" => Some(InputAction::Quit), _ => None, } @@ -235,6 +252,45 @@ pub async fn apply_action(action: InputAction, state: &mut AppState, engine: &En } } } + InputAction::OpenJobs => { + if let Some(session_id) = state.session_id.clone() { + match engine.jobs(session_id).await { + Ok(jobs) => { + state.set_jobs(jobs); + state.open_jobs_pane(); + } + Err(e) => tracing::error!(error = %e, "failed to load jobs"), + } + } + } + InputAction::JobsUp => { + if let ModalState::JobsPane { selected } = &mut state.modal { + *selected = selected.saturating_sub(1); + } + state.dirty = true; + } + InputAction::JobsDown => { + let job_count = state.jobs.len(); + if let ModalState::JobsPane { selected } = &mut state.modal { + if *selected + 1 < job_count { + *selected += 1; + } + } + state.dirty = true; + } + InputAction::JobsDrillIn => { + if let Some(child) = state.selected_job_child() { + match engine.get_session(child).await { + Ok(Some(session)) => { + if let Err(e) = modal::select_session(state, engine, session).await { + tracing::error!(error = %e, "failed to open subtask session"); + } + } + Ok(None) => tracing::warn!("subtask session no longer exists"), + Err(e) => tracing::error!(error = %e, "failed to load subtask session"), + } + } + } InputAction::None => {} } } diff --git a/crates/harness-tui/src/modal.rs b/crates/harness-tui/src/modal.rs index bcda0c7..6946c3f 100644 --- a/crates/harness-tui/src/modal.rs +++ b/crates/harness-tui/src/modal.rs @@ -26,13 +26,17 @@ pub async fn select_session( session: Session, ) -> Result<(), harness_app::AppError> { let session_id = session.id.clone(); - let messages = engine.session_messages(session_id).await?; + let messages = engine.session_messages(session_id.clone()).await?; let mut all_parts = Vec::new(); for message in &messages { let parts = engine.message_parts(message.id.clone()).await?; all_parts.extend(parts); } state.set_session(session, messages, all_parts); + // Surface any subtasks this session spawned (drill-in and session-switch both land here). + if let Ok(jobs) = engine.jobs(session_id).await { + state.set_jobs(jobs); + } state.modal = ModalState::None; Ok(()) } diff --git a/crates/harness-tui/src/render.rs b/crates/harness-tui/src/render.rs index 43994da..6cfce68 100644 --- a/crates/harness-tui/src/render.rs +++ b/crates/harness-tui/src/render.rs @@ -33,6 +33,9 @@ pub fn render(frame: &mut Frame, state: &mut AppState) { ModalState::SessionPicker { sessions, selected } => { render_session_picker(frame, sessions, *selected, area); } + ModalState::JobsPane { selected } => { + render_jobs_pane(frame, &state.jobs, *selected, area); + } ModalState::None => {} } } @@ -169,7 +172,7 @@ fn render_status(frame: &mut Frame, state: &AppState, area: Rect) { let hints = if state.ctrl_c_pressed { "Press Ctrl+C again to quit" } else { - "Ctrl+S: sessions | Esc: abort | Ctrl+C: quit" + "Ctrl+S: sessions | Ctrl+J: jobs | Esc: abort | Ctrl+C: quit" }; let usage = format!( "{} · ${:.4}", @@ -242,6 +245,86 @@ fn render_session_picker( frame.render_widget(paragraph, popup); } +fn render_jobs_pane( + frame: &mut Frame, + jobs: &[harness_core::engine::JobRecord], + selected: usize, + area: Rect, +) { + let popup = centered_rect(70, 70, area); + frame.render_widget(Clear, popup); + + let mut text: Vec> = + vec![Line::from("Background jobs").style(Style::new().add_modifier(Modifier::BOLD))]; + + if jobs.is_empty() { + text.push(Line::default()); + text.push(Line::from("No subtasks spawned yet.").style(Style::new().fg(Color::DarkGray))); + } else { + for (i, job) in jobs.iter().enumerate() { + let marker = if i == selected { "> " } else { " " }; + let (icon, color) = job_state_style(job.state); + let header = format!( + "{marker}{icon} {} · {} · {}", + job.alias, + job.agent, + job_state_label(job.state), + ); + let style = if i == selected { + Style::new().bg(Color::Blue).fg(Color::White) + } else { + Style::new().fg(color) + }; + text.push(Line::from(Span::styled(header, style))); + if let Some(objective) = &job.objective { + text.push( + Line::from(format!(" {objective}")).style(Style::new().fg(Color::Gray)), + ); + } + if !job.context_files.is_empty() { + let files: Vec<&str> = job + .context_files + .iter() + .take(8) + .map(|f| f.path.as_str()) + .collect(); + text.push( + Line::from(format!(" read: {}", files.join(", "))) + .style(Style::new().fg(Color::DarkGray)), + ); + } + } + text.push(Line::default()); + text.push( + Line::from("Enter: open subtask · Esc: close").style(Style::new().fg(Color::DarkGray)), + ); + } + + let block = Block::default().borders(Borders::ALL).title(" jobs "); + let paragraph = Paragraph::new(text).block(block).wrap(Wrap { trim: false }); + frame.render_widget(paragraph, popup); +} + +fn job_state_label(state: harness_core::engine::JobState) -> &'static str { + use harness_core::engine::JobState; + match state { + JobState::Running => "running", + JobState::Completed => "completed", + JobState::Error => "error", + JobState::Cancelled => "cancelled", + } +} + +fn job_state_style(state: harness_core::engine::JobState) -> (&'static str, Color) { + use harness_core::engine::JobState; + match state { + JobState::Running => ("⚙", Color::Yellow), + JobState::Completed => ("✓", Color::Green), + JobState::Error => ("✗", Color::Red), + JobState::Cancelled => ("∅", Color::DarkGray), + } +} + fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { let popup_layout = Layout::default() .direction(Direction::Vertical) @@ -487,6 +570,59 @@ mod tests { insta::assert_snapshot!(buffer_to_string(terminal.backend())); } + #[test] + fn snapshot_jobs_pane() { + use harness_core::engine::{ContextFile, JobRecord, JobState}; + + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + let mut state = AppState::new( + "anthropic/claude-sonnet-4-5".to_string(), + "orchestrator".to_string(), + ); + state.session_id = Some(SessionId("ses_test_001".to_string())); + state.jobs = vec![ + JobRecord { + task_id: "t1".to_string(), + alias: "exp-1".to_string(), + parent_session: SessionId("ses_test_001".to_string()), + child_session: SessionId("ses_child_001".to_string()), + agent: "explorer".to_string(), + description: "map auth".to_string(), + objective: Some("map the auth flow".to_string()), + state: JobState::Completed, + reconciled: true, + result_summary: Some("done".to_string()), + context_files: vec![ContextFile { + path: "src/auth.rs".to_string(), + lines: 42, + }], + launched_at: 1, + updated_at: 2, + last_used_at: 2, + }, + JobRecord { + task_id: "t2".to_string(), + alias: "fix-1".to_string(), + parent_session: SessionId("ses_test_001".to_string()), + child_session: SessionId("ses_child_002".to_string()), + agent: "fixer".to_string(), + description: "patch bug".to_string(), + objective: Some("fix the null deref".to_string()), + state: JobState::Running, + reconciled: false, + result_summary: None, + context_files: Vec::new(), + launched_at: 3, + updated_at: 3, + last_used_at: 3, + }, + ]; + state.modal = ModalState::JobsPane { selected: 1 }; + terminal.draw(|frame| render(frame, &mut state)).unwrap(); + insta::assert_snapshot!(buffer_to_string(terminal.backend())); + } + #[test] fn render_empty_state_produces_frame() { let backend = TestBackend::new(80, 24); diff --git a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_empty_session.snap b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_empty_session.snap index bc0e440..39bf51c 100644 --- a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_empty_session.snap +++ b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_empty_session.snap @@ -1,6 +1,6 @@ --- source: crates/harness-tui/src/render.rs -assertion_line: 300 +assertion_line: 381 expression: buffer_to_string(terminal.backend()) --- new session · orchestrator · anthropic/claude-sonnet-4-5 @@ -26,4 +26,4 @@ expression: buffer_to_string(terminal.backend()) ┌ input ───────────────────────────────────────────────────────────────────────┐ │ │ └──────────────────────────────────────────────────────────────────────────────┘ -idle · 0 tok · $0.0000 · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit +idle · 0 tok · $0.0000 · Ctrl+S: sessions | Ctrl+J: jobs | Esc: abort | Ctrl+C: diff --git a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_jobs_pane.snap b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_jobs_pane.snap new file mode 100644 index 0000000..05caa97 --- /dev/null +++ b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_jobs_pane.snap @@ -0,0 +1,29 @@ +--- +source: crates/harness-tui/src/render.rs +assertion_line: 621 +expression: buffer_to_string(terminal.backend()) +--- + new session · orchestrator · anthropic/claude-sonnet-4-5 +┌ chat ────────────────────────────────────────────────────────────────────────┐ +│ │ +│ │ +│ ┌ jobs ────────────────────────────────────────────────┐ │ +│ │Background jobs │ │ +│ │ ✓ exp-1 · explorer · completed │ │ +│ │ map the auth flow │ │ +│ │ read: src/auth.rs │ │ +│ │> ⚙ fix-1 · fixer · running │ │ +│ │ fix the null deref │ │ +│ │ │ │ +│ │Enter: open subtask · Esc: close │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +└───────────└──────────────────────────────────────────────────────┘───────────┘ +┌ input ───────────────────────────────────────────────────────────────────────┐ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ +idle · 0 tok · $0.0000 · Ctrl+S: sessions | Ctrl+J: jobs | Esc: abort | Ctrl+C: diff --git a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_permission_modal.snap b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_permission_modal.snap index 348638e..014844f 100644 --- a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_permission_modal.snap +++ b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_permission_modal.snap @@ -1,6 +1,6 @@ --- source: crates/harness-tui/src/render.rs -assertion_line: 430 +assertion_line: 511 expression: buffer_to_string(terminal.backend()) --- new session · orchestrator · anthropic/claude-sonnet-4-5 @@ -26,4 +26,4 @@ expression: buffer_to_string(terminal.backend()) ┌ input ───────────────────────────────────────────────────────────────────────┐ │ │ └──────────────────────────────────────────────────────────────────────────────┘ -idle · 0 tok · $0.0000 · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit +idle · 0 tok · $0.0000 · Ctrl+S: sessions | Ctrl+J: jobs | Esc: abort | Ctrl+C: diff --git a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_session_picker_modal.snap b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_session_picker_modal.snap index 7411661..c6bfbc3 100644 --- a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_session_picker_modal.snap +++ b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_session_picker_modal.snap @@ -1,6 +1,6 @@ --- source: crates/harness-tui/src/render.rs -assertion_line: 487 +assertion_line: 568 expression: buffer_to_string(terminal.backend()) --- new session · orchestrator · anthropic/claude-sonnet-4-5 @@ -26,4 +26,4 @@ expression: buffer_to_string(terminal.backend()) ┌ input ───────────────────────────────────────────────────────────────────────┐ │ │ └──────────────────────────────────────────────────────────────────────────────┘ -idle · 0 tok · $0.0000 · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit +idle · 0 tok · $0.0000 · Ctrl+S: sessions | Ctrl+J: jobs | Esc: abort | Ctrl+C: diff --git a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_completed.snap b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_completed.snap index f7c0a66..8e0a0fa 100644 --- a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_completed.snap +++ b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_completed.snap @@ -1,6 +1,6 @@ --- source: crates/harness-tui/src/render.rs -assertion_line: 357 +assertion_line: 438 expression: buffer_to_string(terminal.backend()) --- new session · orchestrator · anthropic/claude-sonnet-4-5 @@ -26,4 +26,4 @@ expression: buffer_to_string(terminal.backend()) ┌ input ───────────────────────────────────────────────────────────────────────┐ │ │ └──────────────────────────────────────────────────────────────────────────────┘ -idle · 0 tok · $0.0000 · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit +idle · 0 tok · $0.0000 · Ctrl+S: sessions | Ctrl+J: jobs | Esc: abort | Ctrl+C: diff --git a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_error.snap b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_error.snap index c0ec904..fb47b48 100644 --- a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_error.snap +++ b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_error.snap @@ -1,6 +1,6 @@ --- source: crates/harness-tui/src/render.rs -assertion_line: 407 +assertion_line: 488 expression: buffer_to_string(terminal.backend()) --- new session · orchestrator · anthropic/claude-sonnet-4-5 @@ -26,4 +26,4 @@ expression: buffer_to_string(terminal.backend()) ┌ input ───────────────────────────────────────────────────────────────────────┐ │ │ └──────────────────────────────────────────────────────────────────────────────┘ -idle · 0 tok · $0.0000 · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit +idle · 0 tok · $0.0000 · Ctrl+S: sessions | Ctrl+J: jobs | Esc: abort | Ctrl+C: diff --git a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_running.snap b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_running.snap index f5f4ce9..0ea0af7 100644 --- a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_running.snap +++ b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_running.snap @@ -1,6 +1,6 @@ --- source: crates/harness-tui/src/render.rs -assertion_line: 382 +assertion_line: 463 expression: buffer_to_string(terminal.backend()) --- new session · orchestrator · anthropic/claude-sonnet-4-5 @@ -26,4 +26,4 @@ expression: buffer_to_string(terminal.backend()) ┌ input ───────────────────────────────────────────────────────────────────────┐ │ │ └──────────────────────────────────────────────────────────────────────────────┘ -idle · 0 tok · $0.0000 · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit +idle · 0 tok · $0.0000 · Ctrl+S: sessions | Ctrl+J: jobs | Esc: abort | Ctrl+C: diff --git a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_user_and_assistant_messages.snap b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_user_and_assistant_messages.snap index 4cc4add..73f4168 100644 --- a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_user_and_assistant_messages.snap +++ b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_user_and_assistant_messages.snap @@ -1,6 +1,6 @@ --- source: crates/harness-tui/src/render.rs -assertion_line: 331 +assertion_line: 412 expression: buffer_to_string(terminal.backend()) --- new session · orchestrator · anthropic/claude-sonnet-4-5 @@ -26,4 +26,4 @@ expression: buffer_to_string(terminal.backend()) ┌ input ───────────────────────────────────────────────────────────────────────┐ │ │ └──────────────────────────────────────────────────────────────────────────────┘ -idle · 0 tok · $0.0000 · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit +idle · 0 tok · $0.0000 · Ctrl+S: sessions | Ctrl+J: jobs | Esc: abort | Ctrl+C: diff --git a/crates/harness-tui/src/state.rs b/crates/harness-tui/src/state.rs index cb507e1..a9f9905 100644 --- a/crates/harness-tui/src/state.rs +++ b/crates/harness-tui/src/state.rs @@ -1,4 +1,5 @@ use harness_app::EngineHandle; +use harness_core::engine::JobRecord; use harness_core::event::{AppEvent, PermissionRequest, RunOutcome}; use harness_core::permission::PermissionReply; use harness_core::types::{Message, Part, PartBody, PartId, Role, Session, SessionId, ToolState}; @@ -86,6 +87,10 @@ pub enum ModalState { sessions: Vec, selected: usize, }, + /// Background job board for the current session, with a cursor for drill-in. + JobsPane { + selected: usize, + }, } /// All mutable UI state lives here. @@ -109,6 +114,9 @@ pub struct AppState { pub session_cost: f64, /// Accumulated session tokens (input + output), for the status bar. pub session_tokens: u64, + /// Background jobs spawned by the current session, newest activity last. Populated on + /// session load and kept live via `JobUpdated` events; surfaced in the jobs pane. + pub jobs: Vec, } impl AppState { @@ -131,6 +139,7 @@ impl AppState { render_width: 0, session_cost: 0.0, session_tokens: 0, + jobs: Vec::new(), } } @@ -156,6 +165,8 @@ impl AppState { self.session_cost = session.cost; self.session_tokens = session.usage.input + session.usage.output; self.messages.clear(); + // Jobs are reloaded for the newly-selected session by the caller. + self.jobs.clear(); let mut messages = messages; messages.sort_by_key(|m| m.created_at); @@ -258,14 +269,50 @@ impl AppState { } self.dirty = true; } - AppEvent::JobUpdated { .. } - | AppEvent::AuthPrompt { .. } - | AppEvent::ServerNotice { .. } => { + AppEvent::JobUpdated { job } => { + if let Ok(record) = serde_json::from_value::(job.0) { + if self.session_id.as_ref() == Some(&record.parent_session) { + self.upsert_job(record); + self.dirty = true; + } + } + } + AppEvent::AuthPrompt { .. } | AppEvent::ServerNotice { .. } => { self.dirty = true; } } } + /// Replaces this session's job list (e.g. after loading a session). + pub fn set_jobs(&mut self, jobs: Vec) { + self.jobs = jobs; + self.dirty = true; + } + + /// Inserts or replaces a job by `task_id`, preserving list order for stable rendering. + fn upsert_job(&mut self, record: JobRecord) { + if let Some(existing) = self.jobs.iter_mut().find(|j| j.task_id == record.task_id) { + *existing = record; + } else { + self.jobs.push(record); + } + } + + /// Opens the jobs pane (cursor at the top). + pub fn open_jobs_pane(&mut self) { + self.modal = ModalState::JobsPane { selected: 0 }; + self.dirty = true; + } + + /// Child session of the currently-selected job in the jobs pane, for drill-in. + pub fn selected_job_child(&self) -> Option { + if let ModalState::JobsPane { selected } = &self.modal { + self.jobs.get(*selected).map(|j| j.child_session.clone()) + } else { + None + } + } + pub fn scroll_up(&mut self, n: u16) { self.scroll_offset = self.scroll_offset.saturating_sub(n); self.dirty = true;