返回 DeepSeek-TUI-2026
cycle.rs
根目录 / crates / tui / src / commands / cycle.rs
1 //! Cycle commands: `/cycles` (list past cycle boundaries) and
2 //! `/cycle <n>` (show one cycle's briefing in detail).
3
4 use std::fmt::Write;
5
6 use crate::tui::app::App;
7
8 use super::CommandResult;
9
10 /// `/cycles` — list past cycle handoffs in compact form.
11 pub fn list_cycles(app: &App) -> CommandResult {
12 if app.cycle_briefings.is_empty() {
13 let msg = format!(
14 "No cycle boundaries have fired yet (current cycle: 1, threshold: {} tokens for {}).",
15 app.cycle.threshold_for(&app.model),
16 app.model
17 );
18 return CommandResult::message(msg);
19 }
20
21 let mut out = String::new();
22 let _ = writeln!(
23 out,
24 "Cycle handoffs in this session ({} total). Active cycle: {}.",
25 app.cycle_briefings.len(),
26 app.cycle_count.saturating_add(1),
27 );
28 out.push('\n');
29 for brief in &app.cycle_briefings {
30 let preview = first_line(&brief.briefing_text, 80);
31 let _ = writeln!(
32 out,
33 " cycle {n} @ {ts} briefing: {tokens} tokens ─ {preview}",
34 n = brief.cycle,
35 ts = brief.timestamp.to_rfc3339(),
36 tokens = brief.token_estimate,
37 preview = preview,
38 );
39 }
40 out.push('\n');
41 out.push_str("Use `/cycle <n>` to show the full briefing for a specific cycle.\n");
42 CommandResult::message(out)
43 }
44
45 /// `/cycle <n>` — print the full briefing for cycle `n`.
46 pub fn show_cycle(app: &App, arg: Option<&str>) -> CommandResult {
47 let Some(raw) = arg.map(str::trim) else {
48 return CommandResult::error(
49 "Usage: /cycle <n> — n is the cycle number from /cycles".to_string(),
50 );
51 };
52 if raw.is_empty() {
53 return CommandResult::error("Usage: /cycle <n>".to_string());
54 }
55 let Ok(n) = raw.parse::<u32>() else {
56 return CommandResult::error(format!(
57 "Cycle number must be a positive integer (got '{raw}')."
58 ));
59 };
60
61 let Some(brief) = app.cycle_briefings.iter().find(|b| b.cycle == n) else {
62 let known: Vec<String> = app
63 .cycle_briefings
64 .iter()
65 .map(|b| b.cycle.to_string())
66 .collect();
67 let known_str = if known.is_empty() {
68 "(none)".to_string()
69 } else {
70 known.join(", ")
71 };
72 return CommandResult::error(format!(
73 "Cycle {n} not found in this session. Known cycles: {known_str}."
74 ));
75 };
76
77 let mut out = String::new();
78 let _ = writeln!(
79 out,
80 "── Cycle {n} ({ts}) briefing: {tokens} tokens ──",
81 n = brief.cycle,
82 ts = brief.timestamp.to_rfc3339(),
83 tokens = brief.token_estimate,
84 );
85 out.push('\n');
86 out.push_str(brief.briefing_text.trim());
87 out.push('\n');
88 CommandResult::message(out)
89 }
90
91 /// `/recall <query>` — user-initiated BM25 search of cycle archives.
92 ///
93 /// Synchronous wrapper around `tools::recall_archive::RecallArchiveTool` so
94 /// users can probe the archive without invoking the model. Output is the
95 /// same JSON payload the agent would see; the assistant pretty-prints
96 /// short results and dumps long ones inline.
97 pub fn recall_archive(app: &App, arg: Option<&str>) -> CommandResult {
98 use crate::tools::recall_archive::RecallArchiveTool;
99 use crate::tools::spec::{ToolContext, ToolSpec};
100
101 let Some(raw) = arg.map(str::trim) else {
102 return CommandResult::error("Usage: /recall <query>".to_string());
103 };
104 if raw.is_empty() {
105 return CommandResult::error("Usage: /recall <query>".to_string());
106 }
107
108 let session_id = app
109 .current_session_id
110 .clone()
111 .unwrap_or_else(|| "workspace".to_string());
112
113 let context = ToolContext::new(app.workspace.clone()).with_state_namespace(session_id);
114 let tool = RecallArchiveTool;
115 let input = serde_json::json!({"query": raw});
116
117 let result = tokio::task::block_in_place(|| {
118 tokio::runtime::Handle::current().block_on(tool.execute(input, &context))
119 });
120
121 match result {
122 Ok(res) => CommandResult::message(res.content),
123 Err(err) => CommandResult::error(format!("recall_archive failed: {err}")),
124 }
125 }
126
127 /// Truncate `text` to its first non-empty line, capped at `max_chars`.
128 fn first_line(text: &str, max_chars: usize) -> String {
129 let line = text
130 .lines()
131 .map(str::trim)
132 .find(|l| !l.is_empty())
133 .unwrap_or("");
134 if line.chars().count() <= max_chars {
135 line.to_string()
136 } else {
137 let prefix: String = line.chars().take(max_chars).collect();
138 format!("{prefix}…")
139 }
140 }
141
142 #[cfg(test)]
143 mod tests {
144 use super::*;
145 use crate::cycle_manager::CycleBriefing;
146 use crate::tui::app::{App, TuiOptions};
147 use chrono::Utc;
148 use std::path::PathBuf;
149
150 fn test_options() -> TuiOptions {
151 TuiOptions {
152 model: "deepseek-v4-pro".to_string(),
153 workspace: PathBuf::from("."),
154 config_path: None,
155 config_profile: None,
156 allow_shell: false,
157 use_alt_screen: true,
158 use_mouse_capture: false,
159 use_bracketed_paste: true,
160 max_subagents: 1,
161 skills_dir: PathBuf::from("."),
162 memory_path: PathBuf::from("memory.md"),
163 notes_path: PathBuf::from("notes.txt"),
164 mcp_config_path: PathBuf::from("mcp.json"),
165 use_memory: false,
166 start_in_agent_mode: false,
167 skip_onboarding: true,
168 yolo: false,
169 resume_session_id: None,
170 initial_input: None,
171 }
172 }
173
174 #[test]
175 fn list_cycles_reports_no_boundaries_yet() {
176 let app = App::new(test_options(), &crate::config::Config::default());
177 let res = list_cycles(&app);
178 assert!(res.message.is_some());
179 assert!(
180 res.message
181 .as_deref()
182 .unwrap()
183 .contains("No cycle boundaries")
184 );
185 }
186
187 #[test]
188 fn show_cycle_rejects_nonexistent_cycle() {
189 let app = App::new(test_options(), &crate::config::Config::default());
190 let res = show_cycle(&app, Some("3"));
191 let msg = res.message.expect("error message");
192 assert!(msg.contains("Cycle 3 not found"), "got: {msg}");
193 }
194
195 #[test]
196 fn list_and_show_cycles_render_briefings() {
197 let mut app = App::new(test_options(), &crate::config::Config::default());
198 app.cycle_briefings.push(CycleBriefing {
199 cycle: 1,
200 timestamp: Utc::now(),
201 briefing_text: "Decision: chose A; constraint: no async.".to_string(),
202 token_estimate: 12,
203 });
204 app.cycle_count = 1;
205
206 let listed = list_cycles(&app).message.expect("list message");
207 assert!(listed.contains("cycle 1"));
208 assert!(listed.contains("12 tokens"));
209
210 let shown = show_cycle(&app, Some("1")).message.expect("show message");
211 assert!(shown.contains("Decision: chose A"));
212 }
213
214 #[test]
215 fn show_cycle_validates_argument() {
216 let app = App::new(test_options(), &crate::config::Config::default());
217 let res = show_cycle(&app, None);
218 let msg = res.message.expect("error message");
219 assert!(msg.contains("Usage: /cycle"));
220
221 let res = show_cycle(&app, Some("not-a-number"));
222 let msg = res.message.expect("error message");
223 assert!(msg.contains("must be a positive integer"));
224 }
225 }
226
226 lines RUST