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:
@@ -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<Option<Session>, 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<Vec<harness_core::engine::JobRecord>, 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<String> {
|
||||
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).
|
||||
|
||||
@@ -24,6 +24,10 @@ pub struct RunConfig {
|
||||
pub cost: Option<crate::types::ModelCost>,
|
||||
/// 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<String>,
|
||||
/// Optional user-provided reminder injected on the turn after a file tool ran.
|
||||
pub reminder_after_file_tool: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<String> = 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Message>,
|
||||
used_file_tool: bool,
|
||||
next_idx: u32,
|
||||
active_text: Option<PartId>,
|
||||
active_reasoning: Option<PartId>,
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
+2
-2
@@ -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:
|
||||
+2
-2
@@ -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:
|
||||
|
||||
+2
-2
@@ -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:
|
||||
|
||||
+2
-2
@@ -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:
|
||||
|
||||
+2
-2
@@ -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:
|
||||
|
||||
+2
-2
@@ -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:
|
||||
|
||||
+2
-2
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user