返回 DeepSeek-TUI-2026
context_inspector.rs
根目录 / crates / tui / src / tui / context_inspector.rs
1 //! Compact session context inspector.
2
3 use std::collections::HashSet;
4 use std::fmt::Write;
5
6 use crate::compaction::estimate_input_tokens_conservative;
7 use crate::models::{
8 LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS, SystemPrompt, context_window_for_model,
9 };
10 use crate::session_manager::SessionContextReference;
11 use crate::tui::app::{App, ToolDetailRecord};
12 use crate::tui::file_mention::ContextReferenceSource;
13 use crate::utils::estimate_message_chars;
14
15 /// Marker used by per-turn working-set metadata. Replicated here so the
16 /// context inspector can distinguish stable prompt blocks from volatile
17 /// working-set context without importing engine internals.
18 const WORKING_SET_MARKER: &str = "## Repo Working Set";
19
20 const CONTEXT_WARNING_THRESHOLD_PERCENT: f64 = 85.0;
21 const CONTEXT_CRITICAL_THRESHOLD_PERCENT: f64 = 95.0;
22 const MAX_REFERENCE_ROWS: usize = 12;
23 const MAX_TOOL_ROWS: usize = 8;
24
25 #[must_use]
26 pub fn build_context_inspector_text(app: &App) -> String {
27 let mut out = String::new();
28 let usage = context_usage(app);
29 let status = context_status(usage.2);
30
31 let _ = writeln!(out, "Session Context");
32 let _ = writeln!(out, "---------------");
33 let _ = writeln!(out, "Model: {}", app.model);
34 let _ = writeln!(
35 out,
36 "Workspace: {}",
37 crate::utils::display_path(&app.workspace)
38 );
39 if let Some(session_id) = app.current_session_id.as_deref() {
40 let _ = writeln!(out, "Session: {}", session_id);
41 }
42 let (used, max, percent) = usage;
43 let _ = writeln!(
44 out,
45 "Context: {status} - ~{used}/{max} tokens ({percent:.1}%)"
46 );
47 let _ = writeln!(
48 out,
49 "Transcript: {} cells, {} API messages",
50 app.history.len(),
51 app.api_messages.len()
52 );
53 let _ = writeln!(
54 out,
55 "Workspace status: {}",
56 app.workspace_context
57 .as_deref()
58 .unwrap_or("not sampled yet")
59 );
60
61 let _ = writeln!(out);
62 push_system_prompt_structure(&mut out, app);
63 let _ = writeln!(out);
64 push_references(&mut out, &app.session_context_references);
65 let _ = writeln!(out);
66 push_tools(&mut out, app);
67
68 out
69 }
70
71 fn context_usage(app: &App) -> (usize, u32, f64) {
72 let max = context_window_for_model(&app.model).unwrap_or(LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS);
73 let estimated =
74 estimate_input_tokens_conservative(&app.api_messages, app.system_prompt.as_ref());
75 let total_chars = estimate_message_chars(&app.api_messages);
76 let used = estimated.max(total_chars / 4);
77 let percent = ((used as f64 / f64::from(max)) * 100.0).clamp(0.0, 100.0);
78 (used, max, percent)
79 }
80
81 fn context_status(percent: f64) -> &'static str {
82 if percent >= CONTEXT_CRITICAL_THRESHOLD_PERCENT {
83 "critical"
84 } else if percent >= CONTEXT_WARNING_THRESHOLD_PERCENT {
85 "high"
86 } else {
87 "ok"
88 }
89 }
90
91 /// Inspect the system prompt structure, split into cache-friendly stable
92 /// prefix blocks and the volatile working-set tail block.
93 fn push_system_prompt_structure(out: &mut String, app: &App) {
94 let _ = writeln!(out, "System Prompt Structure");
95 let _ = writeln!(out, "-----------------------");
96
97 // Conservative token estimate: ~3 chars per token (consistent with
98 // compaction.rs internal helpers — replicated here to avoid depending
99 // on a private function).
100 let text_tokens = |text: &str| text.chars().count().div_ceil(3);
101
102 let total_est = match &app.system_prompt {
103 Some(SystemPrompt::Text(t)) => text_tokens(t),
104 Some(SystemPrompt::Blocks(blocks)) => blocks.iter().map(|b| text_tokens(&b.text)).sum(),
105 None => 0,
106 };
107
108 match &app.system_prompt {
109 Some(SystemPrompt::Blocks(blocks)) => {
110 let working_set_idx = blocks
111 .iter()
112 .position(|b| b.text.contains(WORKING_SET_MARKER));
113 let (stable_count, working_block) = match working_set_idx {
114 Some(idx) => (idx, Some(&blocks[idx])),
115 None => (blocks.len(), None),
116 };
117
118 let stable_tokens: usize = blocks
119 .iter()
120 .take(stable_count)
121 .map(|b| text_tokens(&b.text))
122 .sum();
123 let working_tokens = working_block.map(|b| text_tokens(&b.text)).unwrap_or(0);
124
125 let _ = writeln!(
126 out,
127 " Stable prefix: {stable_count} block(s), ~{stable_tokens} tokens [cache-friendly]"
128 );
129 if let Some(block) = working_block {
130 let _ = writeln!(
131 out,
132 " Volatile working set: 1 block, ~{working_tokens} tokens [changes every turn]"
133 );
134 let _ = writeln!(
135 out,
136 " First line: {}",
137 block.text.lines().next().unwrap_or("(empty)")
138 );
139 } else {
140 let _ = writeln!(out, " Volatile working set: none");
141 }
142 let _ = writeln!(
143 out,
144 " Total: {} block(s), ~{total_est} tokens",
145 blocks.len()
146 );
147 }
148 Some(SystemPrompt::Text(text)) => {
149 // Single text blob — stable/volatile not distinguishable
150 let has_working = text.contains(WORKING_SET_MARKER);
151 if has_working {
152 let _ = writeln!(
153 out,
154 " Single text blob (~{total_est} tokens) [contains working-set marker — structure unclear]"
155 );
156 } else {
157 let _ = writeln!(
158 out,
159 " Single text blob (~{total_est} tokens) [stable prefix only]"
160 );
161 }
162 }
163 None => {
164 let _ = writeln!(out, " No system prompt set.");
165 }
166 }
167
168 // Cache-economics hint
169 let _ = writeln!(
170 out,
171 " Tip: Stable prefix blocks are DeepSeek V4 prefix-cache eligible. \
172 Volatile working-set changes break the cache only for the tail."
173 );
174 }
175
176 fn push_references(out: &mut String, references: &[SessionContextReference]) {
177 let _ = writeln!(out, "References");
178 let _ = writeln!(out, "----------");
179
180 let mut seen = HashSet::new();
181 let mut rendered = 0usize;
182 for record in references {
183 let reference = &record.reference;
184 let key = format!(
185 "{:?}:{:?}:{}:{}",
186 reference.source, reference.kind, reference.target, reference.label
187 );
188 if !seen.insert(key) {
189 continue;
190 }
191 if rendered >= MAX_REFERENCE_ROWS {
192 let remaining = references.len().saturating_sub(rendered);
193 if remaining > 0 {
194 let _ = writeln!(out, "- ... {remaining} more reference(s)");
195 }
196 break;
197 }
198
199 let prefix = match reference.source {
200 ContextReferenceSource::AtMention => "@",
201 ContextReferenceSource::Attachment => "/attach ",
202 };
203 let state = if reference.included {
204 if reference.expanded {
205 "included"
206 } else {
207 "attached"
208 }
209 } else {
210 "not included"
211 };
212 let detail = reference
213 .detail
214 .as_deref()
215 .filter(|detail| !detail.trim().is_empty())
216 .map(|detail| format!(" - {detail}"))
217 .unwrap_or_default();
218 let _ = writeln!(
219 out,
220 "- [{}] {prefix}{} -> {} ({state}{detail})",
221 reference.badge, reference.label, reference.target
222 );
223 rendered += 1;
224 }
225
226 if rendered == 0 {
227 let _ = writeln!(
228 out,
229 "- No file, directory, or media references recorded yet."
230 );
231 }
232 }
233
234 fn push_tools(out: &mut String, app: &App) {
235 let _ = writeln!(out, "Recent Tools");
236 let _ = writeln!(out, "------------");
237
238 let mut rows: Vec<(usize, &ToolDetailRecord)> = app
239 .tool_details_by_cell
240 .iter()
241 .map(|(idx, detail)| (*idx, detail))
242 .collect();
243 rows.sort_by_key(|(idx, _)| std::cmp::Reverse(*idx));
244
245 let mut rendered = 0usize;
246 for detail in app.active_tool_details.values() {
247 push_tool_row(out, "active", detail);
248 rendered += 1;
249 if rendered >= MAX_TOOL_ROWS {
250 return;
251 }
252 }
253 for (cell_idx, detail) in rows
254 .into_iter()
255 .take(MAX_TOOL_ROWS.saturating_sub(rendered))
256 {
257 let location = format!("cell {cell_idx}");
258 push_tool_row(out, &location, detail);
259 rendered += 1;
260 }
261
262 if rendered == 0 {
263 let _ = writeln!(out, "- No tool activity recorded yet.");
264 } else {
265 let _ = writeln!(
266 out,
267 "- Open the matching card and press Alt+V for full details."
268 );
269 }
270 }
271
272 fn push_tool_row(out: &mut String, location: &str, detail: &ToolDetailRecord) {
273 let output_state = if detail.output.as_deref().is_some_and(|out| !out.is_empty()) {
274 "output captured"
275 } else {
276 "no output yet"
277 };
278 let _ = writeln!(
279 out,
280 "- [{}] {} {} ({output_state})",
281 location,
282 detail.tool_name,
283 short_tool_id(&detail.tool_id)
284 );
285 }
286
287 fn short_tool_id(id: &str) -> String {
288 if id.len() <= 8 {
289 id.to_string()
290 } else {
291 format!("{}...", &id[..8])
292 }
293 }
294
295 #[cfg(test)]
296 mod tests {
297 use super::*;
298 use crate::config::Config;
299 use crate::models::{ContentBlock, Message};
300 use crate::session_manager::SessionContextReference;
301 use crate::tui::app::TuiOptions;
302 use crate::tui::file_mention::{
303 ContextReference, ContextReferenceKind, ContextReferenceSource,
304 };
305 use crate::tui::history::HistoryCell;
306 use std::path::PathBuf;
307
308 fn test_app() -> App {
309 App::new(
310 TuiOptions {
311 model: "unknown-model".to_string(),
312 workspace: PathBuf::from("/tmp/project"),
313 config_path: None,
314 config_profile: None,
315 allow_shell: false,
316 use_alt_screen: true,
317 use_mouse_capture: false,
318 use_bracketed_paste: true,
319 max_subagents: 1,
320 skills_dir: PathBuf::from("/tmp/skills"),
321 memory_path: PathBuf::from("memory.md"),
322 notes_path: PathBuf::from("notes.md"),
323 mcp_config_path: PathBuf::from("mcp.json"),
324 use_memory: false,
325 start_in_agent_mode: false,
326 skip_onboarding: true,
327 yolo: false,
328 resume_session_id: None,
329 initial_input: None,
330 },
331 &Config::default(),
332 )
333 }
334
335 #[test]
336 fn inspector_formats_empty_state() {
337 let app = test_app();
338 let text = build_context_inspector_text(&app);
339 assert!(text.contains("Session Context"));
340 assert!(text.contains("No file, directory, or media references recorded yet."));
341 assert!(text.contains("No tool activity recorded yet."));
342 }
343
344 #[test]
345 fn inspector_lists_context_references() {
346 let mut app = test_app();
347 app.history.push(HistoryCell::User {
348 content: "read @src/main.rs".to_string(),
349 });
350 app.session_context_references
351 .push(SessionContextReference {
352 message_index: 0,
353 reference: ContextReference {
354 kind: ContextReferenceKind::File,
355 source: ContextReferenceSource::AtMention,
356 badge: "file".to_string(),
357 label: "src/main.rs".to_string(),
358 target: "/tmp/project/src/main.rs".to_string(),
359 included: true,
360 expanded: true,
361 detail: Some("included".to_string()),
362 },
363 });
364
365 let text = build_context_inspector_text(&app);
366 assert!(text.contains("[file] @src/main.rs -> /tmp/project/src/main.rs"));
367 }
368
369 #[test]
370 fn inspector_marks_high_context_pressure() {
371 let mut app = test_app();
372 app.api_messages.push(Message {
373 role: "user".to_string(),
374 content: vec![ContentBlock::Text {
375 text: "x".repeat(4_000_000),
376 cache_control: None,
377 }],
378 });
379
380 let text = build_context_inspector_text(&app);
381 assert!(text.contains("Context: critical"), "{text}");
382 }
383
384 #[test]
385 fn inspector_no_system_prompt_shows_section() {
386 let app = test_app();
387 let text = build_context_inspector_text(&app);
388 assert!(text.contains("System Prompt Structure"));
389 assert!(text.contains("No system prompt set."));
390 }
391
392 #[test]
393 fn inspector_blocks_format_shows_stable_prefix_and_working_set() {
394 let mut app = test_app();
395 use crate::models::SystemBlock;
396 app.system_prompt = Some(SystemPrompt::Blocks(vec![
397 SystemBlock {
398 block_type: "text".to_string(),
399 text: "## Stable Base\n\nYou are DeepSeek TUI.".to_string(),
400 cache_control: None,
401 },
402 SystemBlock {
403 block_type: "text".to_string(),
404 text: format!("{WORKING_SET_MARKER}\nsrc/main.rs changed"),
405 cache_control: None,
406 },
407 ]));
408
409 let text = build_context_inspector_text(&app);
410 assert!(text.contains("System Prompt Structure"));
411 assert!(
412 text.contains("Stable prefix: 1 block"),
413 "stable prefix count: {text}"
414 );
415 assert!(
416 text.contains("Volatile working set: 1 block"),
417 "working set section: {text}"
418 );
419 assert!(
420 text.contains("[cache-friendly]"),
421 "cache hint for stable: {text}"
422 );
423 assert!(
424 text.contains("[changes every turn]"),
425 "volatile marker: {text}"
426 );
427 assert!(
428 text.contains("First line: ## Repo Working Set"),
429 "first line of working set: {text}"
430 );
431 }
432
433 #[test]
434 fn inspector_blocks_without_working_set_shows_stable_only() {
435 let mut app = test_app();
436 use crate::models::SystemBlock;
437 app.system_prompt = Some(SystemPrompt::Blocks(vec![
438 SystemBlock {
439 block_type: "text".to_string(),
440 text: "## Stable Base".to_string(),
441 cache_control: None,
442 },
443 SystemBlock {
444 block_type: "text".to_string(),
445 text: "## Personality\nCalm".to_string(),
446 cache_control: None,
447 },
448 ]));
449
450 let text = build_context_inspector_text(&app);
451 assert!(text.contains("Stable prefix: 2 block(s)"));
452 assert!(text.contains("Volatile working set: none"));
453 }
454
455 #[test]
456 fn inspector_text_prompt_shows_single_blob() {
457 let mut app = test_app();
458 app.system_prompt = Some(SystemPrompt::Text(
459 "You are DeepSeek TUI.\n## Repo Working Set\nsrc/".to_string(),
460 ));
461
462 let text = build_context_inspector_text(&app);
463 assert!(text.contains("System Prompt Structure"));
464 assert!(text.contains("Single text blob"));
465 assert!(text.contains("working-set marker"));
466 }
467 }
468
468 lines RUST