返回 CodeWhale
shell_job_routing.rs
根目录 / crates / tui / src / tui / shell_job_routing.rs
1 //! Background shell job-center helpers for slash commands and pagers.
2
3 use crate::tools::shell::{ShellJobDetail, ShellJobSnapshot, ShellResult, ShellStatus};
4 use crate::tui::app::App;
5 use crate::tui::history::HistoryCell;
6 use crate::tui::pager::PagerView;
7
8 fn status_label(status: &ShellStatus, stale: bool) -> &'static str {
9 if stale {
10 return "stale";
11 }
12 match status {
13 ShellStatus::Running => "running",
14 ShellStatus::Completed => "complete",
15 ShellStatus::Failed => "failed",
16 ShellStatus::Killed => "killed",
17 ShellStatus::TimedOut => "timeout",
18 }
19 }
20
21 pub(super) fn format_shell_job_list(jobs: &[ShellJobSnapshot]) -> String {
22 if jobs.is_empty() {
23 return "No live Bash jobs. Bash jobs are process-local; after a restart, inspect durable task artifacts for prior command output.".to_string();
24 }
25
26 let mut lines = vec![
27 format!("Bash jobs ({})", jobs.len()),
28 "----------------------------------------".to_string(),
29 ];
30 for job in jobs {
31 let task = job
32 .linked_task_id
33 .as_ref()
34 .map(|id| format!(" task={id}"))
35 .unwrap_or_default();
36 lines.push(format!(
37 "{} {:8} {} exit={:?}{}",
38 job.id,
39 status_label(&job.status, job.stale),
40 crate::elapsed::format_elapsed_ms(job.elapsed_ms),
41 job.exit_code,
42 task
43 ));
44 lines.push(format!(" cwd: {}", crate::utils::display_path(&job.cwd)));
45 lines.push(format!(" cmd: {}", job.command));
46 let tail = if !job.stderr_tail.trim().is_empty() {
47 job.stderr_tail.trim()
48 } else {
49 job.stdout_tail.trim()
50 };
51 if !tail.is_empty() {
52 lines.push(format!(" tail: {}", tail.replace('\n', "\\n")));
53 }
54 }
55 lines.push(
56 "Controls: /jobs show <id>, /jobs poll <id>, /jobs wait <id>, /jobs stdin <id> <input>, /jobs cancel <id>, /jobs cancel-all."
57 .to_string(),
58 );
59 lines.join("\n")
60 }
61
62 pub(super) fn format_shell_poll(result: &ShellResult) -> String {
63 let mut lines = vec![
64 format!(
65 "Bash job {}: {} exit={:?} elapsed={}",
66 result.task_id.as_deref().unwrap_or("(unknown)"),
67 status_label(&result.status, false),
68 result.exit_code,
69 crate::elapsed::format_elapsed_ms(result.duration_ms)
70 ),
71 String::new(),
72 ];
73 if result.stdout.is_empty() && result.stderr.is_empty() {
74 lines.push("(no new output)".to_string());
75 } else {
76 if !result.stdout.is_empty() {
77 lines.push("STDOUT:".to_string());
78 lines.push(result.stdout.clone());
79 }
80 if !result.stderr.is_empty() {
81 lines.push("STDERR:".to_string());
82 lines.push(result.stderr.clone());
83 }
84 }
85 lines.join("\n")
86 }
87
88 pub(super) fn open_shell_job_pager(app: &mut App, detail: &ShellJobDetail) {
89 let width = app
90 .viewport
91 .last_transcript_area
92 .map(|area| area.width)
93 .unwrap_or(100)
94 .saturating_sub(4);
95 app.view_stack.push(PagerView::from_text(
96 format!("Bash Job {}", detail.snapshot.id),
97 &format_shell_job_detail(detail),
98 width.max(60),
99 ));
100 }
101
102 fn format_shell_job_detail(detail: &ShellJobDetail) -> String {
103 let job = &detail.snapshot;
104 let mut lines = vec![
105 format!("Job: {}", job.id),
106 format!("Status: {}", status_label(&job.status, job.stale)),
107 format!("Command: {}", job.command),
108 format!("Cwd: {}", crate::utils::display_path(&job.cwd)),
109 format!(
110 "Elapsed: {}",
111 crate::elapsed::format_elapsed_ms(job.elapsed_ms)
112 ),
113 format!("Exit Code: {:?}", job.exit_code),
114 format!("Stdin Available: {}", job.stdin_available),
115 ];
116 if let Some(task_id) = job.linked_task_id.as_ref() {
117 lines.push(format!("Linked Task: {task_id}"));
118 }
119 if job.stale {
120 lines.push(
121 "Completion state: stale after restart; the process is no longer running.".to_string(),
122 );
123 } else {
124 lines.push("Completion State: live in this TUI process.".to_string());
125 }
126 lines.push(String::new());
127 lines.push(format!("STDOUT ({} bytes):", job.stdout_len));
128 lines.push(if detail.stdout.is_empty() {
129 "(empty)".to_string()
130 } else {
131 detail.stdout.clone()
132 });
133 lines.push(String::new());
134 lines.push(format!("STDERR ({} bytes):", job.stderr_len));
135 lines.push(if detail.stderr.is_empty() {
136 "(empty)".to_string()
137 } else {
138 detail.stderr.clone()
139 });
140 lines.join("\n")
141 }
142
143 pub(super) fn add_shell_job_message(app: &mut App, content: String) {
144 app.add_message(HistoryCell::System { content });
145 }
146
147 #[cfg(test)]
148 mod tests {
149 use super::*;
150 use std::path::PathBuf;
151
152 #[test]
153 fn list_shows_controls_and_stale_state() {
154 let jobs = vec![ShellJobSnapshot {
155 id: "shell_dead".to_string(),
156 job_id: "shell_dead".to_string(),
157 command: "cargo test".to_string(),
158 cwd: PathBuf::from("/tmp/repo"),
159 status: ShellStatus::Killed,
160 exit_code: None,
161 elapsed_ms: 0,
162 stdout_tail: String::new(),
163 stderr_tail: "detached".to_string(),
164 stdout_len: 0,
165 stderr_len: 8,
166 stdin_available: false,
167 stale: true,
168 elapsed_since_output_ms: None,
169 linked_task_id: Some("task_1".to_string()),
170 owner_agent_id: None,
171 owner_agent_name: None,
172 }];
173 let formatted = format_shell_job_list(&jobs);
174 assert!(formatted.contains("Bash jobs (1)"));
175 assert!(!formatted.contains("Background commands"));
176 assert!(formatted.contains("shell_dead"));
177 assert!(formatted.contains("stale"));
178 assert!(formatted.contains("/jobs poll <id>"));
179 assert!(formatted.contains("task=task_1"));
180 }
181 }
182
182 lines RUST