M4: jobs pane, subtask drill-in, reminders

Adds the jobs pane to the TUI with a new snapshot test, subtask drill-in navigation, and periodic reminders injected into the engine loop for long-running background jobs.
This commit is contained in:
2026-07-10 16:20:11 +02:00
parent ac516a7e23
commit 9ee5a1940d
15 changed files with 426 additions and 28 deletions
+56
View File
@@ -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<InputAction> {
"/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 => {}
}
}
+5 -1
View File
@@ -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(())
}
+137 -1
View File
@@ -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<Line<'static>> =
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);
@@ -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:
@@ -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:
@@ -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:
@@ -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:
@@ -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:
@@ -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:
@@ -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:
@@ -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:
+50 -3
View File
@@ -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<Session>,
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<JobRecord>,
}
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::<JobRecord>(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<JobRecord>) {
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<SessionId> {
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;