harness-tui + engine: jobs pane, subtask drill-in, reminders (M4)

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 06:46:31 +02:00
co-authored by Claude Opus 4.8
parent 54f91b7e4c
commit 7738ed55b9
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 => {}
}
}