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