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(),
|
instructions: self.inner.config.instructions.clone(),
|
||||||
cost: Some(self.inner.catalog.cost(provider_id, model_id)),
|
cost: Some(self.inner.catalog.cost(provider_id, model_id)),
|
||||||
inject_job_board,
|
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
|
// 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?)
|
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 {
|
pub fn is_running(&self, session_id: &SessionId) -> bool {
|
||||||
let runs = self.inner.runs.lock().unwrap();
|
let runs = self.inner.runs.lock().unwrap();
|
||||||
runs.contains_key(session_id)
|
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
|
/// 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).
|
/// (depth > 0), then global config permissions, then the agent's own rules (highest).
|
||||||
fn effective_static_rules(&self, agent: &AgentDef, depth: u8) -> Ruleset {
|
fn effective_static_rules(&self, agent: &AgentDef, depth: u8) -> Ruleset {
|
||||||
@@ -712,6 +736,8 @@ impl SubagentSpawner for EngineInner {
|
|||||||
&child_session.model.model_id,
|
&child_session.model.model_id,
|
||||||
)),
|
)),
|
||||||
inject_job_board: agent.mode.is_primary(),
|
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).
|
// 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>,
|
pub cost: Option<crate::types::ModelCost>,
|
||||||
/// Whether to append the background job board to requests (primary/delegating agents).
|
/// Whether to append the background job board to requests (primary/delegating agents).
|
||||||
pub inject_job_board: bool,
|
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
|
/// 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 {
|
) -> RunOutcome {
|
||||||
let mut doomloop = DoomLoopGuard::new();
|
let mut doomloop = DoomLoopGuard::new();
|
||||||
let mut steps = 0u32;
|
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 {
|
loop {
|
||||||
match should_continue(&ctx.store, &ctx.session_id).await {
|
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));
|
wire_messages.extend(convert_message(message, &parts));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append the (synthetic, non-persisted) job board to the last user message so the
|
// Collect synthetic (non-persisted) blocks to append to the last user message this
|
||||||
// orchestrator sees running/reusable subtasks. docs/04-multiagent.md.
|
// 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 run_config.inject_job_board {
|
||||||
if let Some(board) = &ctx.job_board {
|
if let Some(board) = &ctx.job_board {
|
||||||
if let Some(block) = board.format_for_prompt() {
|
if let Some(block) = board.format_for_prompt() {
|
||||||
|
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
|
if let Some(last_user) = wire_messages
|
||||||
.iter_mut()
|
.iter_mut()
|
||||||
.rev()
|
.rev()
|
||||||
.find(|m| m.role == WireRole::User)
|
.find(|m| m.role == WireRole::User)
|
||||||
{
|
{
|
||||||
last_user.content.push(WireContent::Text { text: block });
|
for text in synthetic {
|
||||||
}
|
last_user.content.push(WireContent::Text { text });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -278,6 +299,7 @@ pub async fn run_session(
|
|||||||
}
|
}
|
||||||
Ok(outcome) => {
|
Ok(outcome) => {
|
||||||
accumulate_session_usage(&ctx, &outcome.usage, outcome.cost, now_fn()).await;
|
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
|
// A completed step means the orchestrator has now seen any terminal jobs
|
||||||
// that were on the board this turn; mark them reconciled.
|
// that were on the board this turn; mark them reconciled.
|
||||||
if run_config.inject_job_board {
|
if run_config.inject_job_board {
|
||||||
@@ -494,6 +516,8 @@ mod tests {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
}),
|
}),
|
||||||
inject_job_board: false,
|
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;
|
let outcome = run_session(Arc::new(provider), ctx, &run_config, || 2).await;
|
||||||
@@ -602,6 +626,8 @@ mod tests {
|
|||||||
instructions: Vec::new(),
|
instructions: Vec::new(),
|
||||||
cost: None,
|
cost: None,
|
||||||
inject_job_board: false,
|
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;
|
let outcome = run_session(Arc::new(provider), ctx, &run_config, || 2).await;
|
||||||
@@ -707,6 +733,8 @@ mod tests {
|
|||||||
instructions: Vec::new(),
|
instructions: Vec::new(),
|
||||||
cost: None,
|
cost: None,
|
||||||
inject_job_board: true,
|
inject_job_board: true,
|
||||||
|
reminder_turn_start: None,
|
||||||
|
reminder_after_file_tool: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let provider = std::sync::Arc::new(CapturingProvider {
|
let provider = std::sync::Arc::new(CapturingProvider {
|
||||||
@@ -735,4 +763,63 @@ mod tests {
|
|||||||
assert!(text.contains("exp-1"), "got: {text}");
|
assert!(text.contains("exp-1"), "got: {text}");
|
||||||
assert!(text.contains("map the auth flow"), "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).
|
/// Dollar cost of this step's usage (0.0 when no pricing is available).
|
||||||
pub cost: f64,
|
pub cost: f64,
|
||||||
pub aborted: bool,
|
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 {
|
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> {
|
struct Run<'a> {
|
||||||
ctx: &'a StepContext,
|
ctx: &'a StepContext,
|
||||||
assistant: Option<Message>,
|
assistant: Option<Message>,
|
||||||
|
used_file_tool: bool,
|
||||||
next_idx: u32,
|
next_idx: u32,
|
||||||
active_text: Option<PartId>,
|
active_text: Option<PartId>,
|
||||||
active_reasoning: Option<PartId>,
|
active_reasoning: Option<PartId>,
|
||||||
@@ -117,6 +124,7 @@ impl<'a> Run<'a> {
|
|||||||
Self {
|
Self {
|
||||||
ctx,
|
ctx,
|
||||||
assistant: None,
|
assistant: None,
|
||||||
|
used_file_tool: false,
|
||||||
next_idx: 0,
|
next_idx: 0,
|
||||||
active_text: None,
|
active_text: None,
|
||||||
active_reasoning: None,
|
active_reasoning: None,
|
||||||
@@ -386,6 +394,9 @@ impl<'a> Run<'a> {
|
|||||||
input: serde_json::Value,
|
input: serde_json::Value,
|
||||||
doomloop: &mut DoomLoopGuard,
|
doomloop: &mut DoomLoopGuard,
|
||||||
) -> Result<(), ProviderError> {
|
) -> 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 part_id = self.pending_tools.remove(&call_id).unwrap_or_default();
|
||||||
let running = Part {
|
let running = Part {
|
||||||
id: part_id.clone(),
|
id: part_id.clone(),
|
||||||
@@ -607,6 +618,7 @@ pub async fn process_step(
|
|||||||
usage,
|
usage,
|
||||||
cost: step_cost,
|
cost: step_cost,
|
||||||
aborted: true,
|
aborted: true,
|
||||||
|
used_file_tool: run.used_file_tool,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -679,5 +691,6 @@ pub async fn process_step(
|
|||||||
usage,
|
usage,
|
||||||
cost: step_cost,
|
cost: step_cost,
|
||||||
aborted: false,
|
aborted: false,
|
||||||
|
used_file_tool: run.used_file_tool,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ pub enum InputAction {
|
|||||||
SessionPickerUp,
|
SessionPickerUp,
|
||||||
SessionPickerDown,
|
SessionPickerDown,
|
||||||
SessionPickerSelect,
|
SessionPickerSelect,
|
||||||
|
OpenJobs,
|
||||||
|
JobsUp,
|
||||||
|
JobsDown,
|
||||||
|
JobsDrillIn,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Translate a crossterm event into an action and/or mutate `state` directly.
|
/// 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 {
|
match &state.modal {
|
||||||
ModalState::Permission { .. } => handle_permission_key(key, state),
|
ModalState::Permission { .. } => handle_permission_key(key, state),
|
||||||
ModalState::SessionPicker { .. } => handle_session_picker_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),
|
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 {
|
fn handle_permission_key(key: KeyEvent, _state: &mut AppState) -> InputAction {
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Char('y') | KeyCode::Char('Y') => {
|
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('s') if ctrl => InputAction::LoadSessions,
|
||||||
|
KeyCode::Char('j') if ctrl => InputAction::OpenJobs,
|
||||||
KeyCode::Up => {
|
KeyCode::Up => {
|
||||||
let (row, _) = state.input.cursor();
|
let (row, _) = state.input.cursor();
|
||||||
if row == 0 {
|
if row == 0 {
|
||||||
@@ -158,6 +174,7 @@ fn parse_slash_command(text: &str) -> Option<InputAction> {
|
|||||||
"/model" if !rest.is_empty() => Some(InputAction::SetModel(rest)),
|
"/model" if !rest.is_empty() => Some(InputAction::SetModel(rest)),
|
||||||
"/agent" if !rest.is_empty() => Some(InputAction::SetAgent(rest)),
|
"/agent" if !rest.is_empty() => Some(InputAction::SetAgent(rest)),
|
||||||
"/sessions" => Some(InputAction::LoadSessions),
|
"/sessions" => Some(InputAction::LoadSessions),
|
||||||
|
"/jobs" => Some(InputAction::OpenJobs),
|
||||||
"/quit" => Some(InputAction::Quit),
|
"/quit" => Some(InputAction::Quit),
|
||||||
_ => None,
|
_ => 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 => {}
|
InputAction::None => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,13 +26,17 @@ pub async fn select_session(
|
|||||||
session: Session,
|
session: Session,
|
||||||
) -> Result<(), harness_app::AppError> {
|
) -> Result<(), harness_app::AppError> {
|
||||||
let session_id = session.id.clone();
|
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();
|
let mut all_parts = Vec::new();
|
||||||
for message in &messages {
|
for message in &messages {
|
||||||
let parts = engine.message_parts(message.id.clone()).await?;
|
let parts = engine.message_parts(message.id.clone()).await?;
|
||||||
all_parts.extend(parts);
|
all_parts.extend(parts);
|
||||||
}
|
}
|
||||||
state.set_session(session, messages, all_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;
|
state.modal = ModalState::None;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ pub fn render(frame: &mut Frame, state: &mut AppState) {
|
|||||||
ModalState::SessionPicker { sessions, selected } => {
|
ModalState::SessionPicker { sessions, selected } => {
|
||||||
render_session_picker(frame, sessions, *selected, area);
|
render_session_picker(frame, sessions, *selected, area);
|
||||||
}
|
}
|
||||||
|
ModalState::JobsPane { selected } => {
|
||||||
|
render_jobs_pane(frame, &state.jobs, *selected, area);
|
||||||
|
}
|
||||||
ModalState::None => {}
|
ModalState::None => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,7 +172,7 @@ fn render_status(frame: &mut Frame, state: &AppState, area: Rect) {
|
|||||||
let hints = if state.ctrl_c_pressed {
|
let hints = if state.ctrl_c_pressed {
|
||||||
"Press Ctrl+C again to quit"
|
"Press Ctrl+C again to quit"
|
||||||
} else {
|
} else {
|
||||||
"Ctrl+S: sessions | Esc: abort | Ctrl+C: quit"
|
"Ctrl+S: sessions | Ctrl+J: jobs | Esc: abort | Ctrl+C: quit"
|
||||||
};
|
};
|
||||||
let usage = format!(
|
let usage = format!(
|
||||||
"{} · ${:.4}",
|
"{} · ${:.4}",
|
||||||
@@ -242,6 +245,86 @@ fn render_session_picker(
|
|||||||
frame.render_widget(paragraph, popup);
|
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 {
|
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
|
||||||
let popup_layout = Layout::default()
|
let popup_layout = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
@@ -487,6 +570,59 @@ mod tests {
|
|||||||
insta::assert_snapshot!(buffer_to_string(terminal.backend()));
|
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]
|
#[test]
|
||||||
fn render_empty_state_produces_frame() {
|
fn render_empty_state_produces_frame() {
|
||||||
let backend = TestBackend::new(80, 24);
|
let backend = TestBackend::new(80, 24);
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
source: crates/harness-tui/src/render.rs
|
source: crates/harness-tui/src/render.rs
|
||||||
assertion_line: 300
|
assertion_line: 381
|
||||||
expression: buffer_to_string(terminal.backend())
|
expression: buffer_to_string(terminal.backend())
|
||||||
---
|
---
|
||||||
new session · orchestrator · anthropic/claude-sonnet-4-5
|
new session · orchestrator · anthropic/claude-sonnet-4-5
|
||||||
@@ -26,4 +26,4 @@ expression: buffer_to_string(terminal.backend())
|
|||||||
┌ input ───────────────────────────────────────────────────────────────────────┐
|
┌ 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
|
source: crates/harness-tui/src/render.rs
|
||||||
assertion_line: 430
|
assertion_line: 511
|
||||||
expression: buffer_to_string(terminal.backend())
|
expression: buffer_to_string(terminal.backend())
|
||||||
---
|
---
|
||||||
new session · orchestrator · anthropic/claude-sonnet-4-5
|
new session · orchestrator · anthropic/claude-sonnet-4-5
|
||||||
@@ -26,4 +26,4 @@ expression: buffer_to_string(terminal.backend())
|
|||||||
┌ input ───────────────────────────────────────────────────────────────────────┐
|
┌ 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
|
source: crates/harness-tui/src/render.rs
|
||||||
assertion_line: 487
|
assertion_line: 568
|
||||||
expression: buffer_to_string(terminal.backend())
|
expression: buffer_to_string(terminal.backend())
|
||||||
---
|
---
|
||||||
new session · orchestrator · anthropic/claude-sonnet-4-5
|
new session · orchestrator · anthropic/claude-sonnet-4-5
|
||||||
@@ -26,4 +26,4 @@ expression: buffer_to_string(terminal.backend())
|
|||||||
┌ input ───────────────────────────────────────────────────────────────────────┐
|
┌ 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
|
source: crates/harness-tui/src/render.rs
|
||||||
assertion_line: 357
|
assertion_line: 438
|
||||||
expression: buffer_to_string(terminal.backend())
|
expression: buffer_to_string(terminal.backend())
|
||||||
---
|
---
|
||||||
new session · orchestrator · anthropic/claude-sonnet-4-5
|
new session · orchestrator · anthropic/claude-sonnet-4-5
|
||||||
@@ -26,4 +26,4 @@ expression: buffer_to_string(terminal.backend())
|
|||||||
┌ input ───────────────────────────────────────────────────────────────────────┐
|
┌ 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
|
source: crates/harness-tui/src/render.rs
|
||||||
assertion_line: 407
|
assertion_line: 488
|
||||||
expression: buffer_to_string(terminal.backend())
|
expression: buffer_to_string(terminal.backend())
|
||||||
---
|
---
|
||||||
new session · orchestrator · anthropic/claude-sonnet-4-5
|
new session · orchestrator · anthropic/claude-sonnet-4-5
|
||||||
@@ -26,4 +26,4 @@ expression: buffer_to_string(terminal.backend())
|
|||||||
┌ input ───────────────────────────────────────────────────────────────────────┐
|
┌ 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
|
source: crates/harness-tui/src/render.rs
|
||||||
assertion_line: 382
|
assertion_line: 463
|
||||||
expression: buffer_to_string(terminal.backend())
|
expression: buffer_to_string(terminal.backend())
|
||||||
---
|
---
|
||||||
new session · orchestrator · anthropic/claude-sonnet-4-5
|
new session · orchestrator · anthropic/claude-sonnet-4-5
|
||||||
@@ -26,4 +26,4 @@ expression: buffer_to_string(terminal.backend())
|
|||||||
┌ input ───────────────────────────────────────────────────────────────────────┐
|
┌ 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
|
source: crates/harness-tui/src/render.rs
|
||||||
assertion_line: 331
|
assertion_line: 412
|
||||||
expression: buffer_to_string(terminal.backend())
|
expression: buffer_to_string(terminal.backend())
|
||||||
---
|
---
|
||||||
new session · orchestrator · anthropic/claude-sonnet-4-5
|
new session · orchestrator · anthropic/claude-sonnet-4-5
|
||||||
@@ -26,4 +26,4 @@ expression: buffer_to_string(terminal.backend())
|
|||||||
┌ input ───────────────────────────────────────────────────────────────────────┐
|
┌ 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_app::EngineHandle;
|
||||||
|
use harness_core::engine::JobRecord;
|
||||||
use harness_core::event::{AppEvent, PermissionRequest, RunOutcome};
|
use harness_core::event::{AppEvent, PermissionRequest, RunOutcome};
|
||||||
use harness_core::permission::PermissionReply;
|
use harness_core::permission::PermissionReply;
|
||||||
use harness_core::types::{Message, Part, PartBody, PartId, Role, Session, SessionId, ToolState};
|
use harness_core::types::{Message, Part, PartBody, PartId, Role, Session, SessionId, ToolState};
|
||||||
@@ -86,6 +87,10 @@ pub enum ModalState {
|
|||||||
sessions: Vec<Session>,
|
sessions: Vec<Session>,
|
||||||
selected: usize,
|
selected: usize,
|
||||||
},
|
},
|
||||||
|
/// Background job board for the current session, with a cursor for drill-in.
|
||||||
|
JobsPane {
|
||||||
|
selected: usize,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// All mutable UI state lives here.
|
/// All mutable UI state lives here.
|
||||||
@@ -109,6 +114,9 @@ pub struct AppState {
|
|||||||
pub session_cost: f64,
|
pub session_cost: f64,
|
||||||
/// Accumulated session tokens (input + output), for the status bar.
|
/// Accumulated session tokens (input + output), for the status bar.
|
||||||
pub session_tokens: u64,
|
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 {
|
impl AppState {
|
||||||
@@ -131,6 +139,7 @@ impl AppState {
|
|||||||
render_width: 0,
|
render_width: 0,
|
||||||
session_cost: 0.0,
|
session_cost: 0.0,
|
||||||
session_tokens: 0,
|
session_tokens: 0,
|
||||||
|
jobs: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,6 +165,8 @@ impl AppState {
|
|||||||
self.session_cost = session.cost;
|
self.session_cost = session.cost;
|
||||||
self.session_tokens = session.usage.input + session.usage.output;
|
self.session_tokens = session.usage.input + session.usage.output;
|
||||||
self.messages.clear();
|
self.messages.clear();
|
||||||
|
// Jobs are reloaded for the newly-selected session by the caller.
|
||||||
|
self.jobs.clear();
|
||||||
|
|
||||||
let mut messages = messages;
|
let mut messages = messages;
|
||||||
messages.sort_by_key(|m| m.created_at);
|
messages.sort_by_key(|m| m.created_at);
|
||||||
@@ -258,13 +269,49 @@ impl AppState {
|
|||||||
}
|
}
|
||||||
self.dirty = true;
|
self.dirty = true;
|
||||||
}
|
}
|
||||||
AppEvent::JobUpdated { .. }
|
AppEvent::JobUpdated { job } => {
|
||||||
| AppEvent::AuthPrompt { .. }
|
if let Ok(record) = serde_json::from_value::<JobRecord>(job.0) {
|
||||||
| AppEvent::ServerNotice { .. } => {
|
if self.session_id.as_ref() == Some(&record.parent_session) {
|
||||||
|
self.upsert_job(record);
|
||||||
self.dirty = true;
|
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) {
|
pub fn scroll_up(&mut self, n: u16) {
|
||||||
self.scroll_offset = self.scroll_offset.saturating_sub(n);
|
self.scroll_offset = self.scroll_offset.saturating_sub(n);
|
||||||
|
|||||||
Reference in New Issue
Block a user