返回 CodeWhale
context_inspector.rs
根目录 / crates / tui / src / tui / context_inspector.rs
1 //! Compact session context inspector.
2
3 use std::borrow::Cow;
4 use std::cell::RefCell;
5 use std::collections::HashSet;
6 use std::fmt::Write;
7
8 use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
9 use ratatui::{
10 buffer::Buffer,
11 layout::Rect,
12 style::{Modifier, Style},
13 text::{Line, Span},
14 widgets::{Paragraph, Widget},
15 };
16
17 use crate::compaction::estimate_input_tokens_conservative;
18 use crate::localization::{Locale, MessageId, tr};
19 use crate::models::SystemPrompt;
20 use crate::palette;
21 use crate::session_manager::SessionContextReference;
22 use crate::tui::app::{App, ToolDetailRecord};
23 use crate::tui::file_mention::ContextReferenceSource;
24 use crate::tui::menu_style;
25 use crate::tui::views::{
26 ActionHint, ModalKind, ModalView, ViewAction, ViewEvent, render_modal_footer,
27 render_underwater_surface,
28 };
29 use crate::utils::estimate_message_chars;
30
31 /// Marker used by per-turn working-set metadata. Replicated here so the
32 /// context inspector can distinguish stable prompt blocks from volatile
33 /// working-set context without importing engine internals.
34 const WORKING_SET_MARKER: &str = "## Repo Working Set";
35
36 pub(crate) const CONTEXT_WARNING_THRESHOLD_PERCENT: f64 = 85.0;
37 pub(crate) const CONTEXT_CRITICAL_THRESHOLD_PERCENT: f64 = 95.0;
38 const MAX_REFERENCE_ROWS: usize = 12;
39 const MAX_TOOL_ROWS: usize = 8;
40
41 const SYSTEM_LAYER_MARKERS: &[(&str, &str, PromptLayerKind)] = &[
42 (
43 "Bundled constitution",
44 "## Codewhale",
45 PromptLayerKind::Static,
46 ),
47 ("Language policy", "## Language", PromptLayerKind::Static),
48 (
49 "Output formatting",
50 "## Output Formatting",
51 PromptLayerKind::Static,
52 ),
53 (
54 "User-global constitution",
55 "<codewhale_user_constitution",
56 PromptLayerKind::Static,
57 ),
58 (
59 "Repository constitution",
60 "<codewhale_repo_constitution",
61 PromptLayerKind::Static,
62 ),
63 (
64 "Project context",
65 "<project_instructions",
66 PromptLayerKind::Static,
67 ),
68 (
69 "Project context pack",
70 "## Project Context Pack",
71 PromptLayerKind::Static,
72 ),
73 ("Environment", "## Environment", PromptLayerKind::Static),
74 ("Skills", "## Skills", PromptLayerKind::Static),
75 (
76 "Core execution",
77 "## Core Execution",
78 PromptLayerKind::Static,
79 ),
80 ("Compact template", "## Compact", PromptLayerKind::Static),
81 (
82 "Configured instructions",
83 "<instructions ",
84 PromptLayerKind::Dynamic,
85 ),
86 ("User memory", "## User Memory", PromptLayerKind::Dynamic),
87 (
88 "Current session goal",
89 "## Current Session Goal",
90 PromptLayerKind::Dynamic,
91 ),
92 (
93 "Previous session relay",
94 "## Previous Session Relay",
95 PromptLayerKind::Dynamic,
96 ),
97 (
98 "Volatile working set",
99 WORKING_SET_MARKER,
100 PromptLayerKind::Dynamic,
101 ),
102 ];
103
104 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
105 enum PromptLayerKind {
106 Static,
107 Dynamic,
108 }
109
110 impl PromptLayerKind {
111 fn label(self, locale: Locale) -> Cow<'static, str> {
112 match self {
113 Self::Static => tr(locale, MessageId::CtxInspCacheFriendly),
114 Self::Dynamic => tr(locale, MessageId::CtxInspChangesByTurn),
115 }
116 }
117 }
118
119 /// Localize well-known layer labels that already have inspector MessageIds.
120 /// Other layer names stay as English product identifiers.
121 fn layer_display_name(name: &'static str, locale: Locale) -> Cow<'static, str> {
122 match name {
123 "Volatile working set" => tr(locale, MessageId::CtxInspVolatileWorkingSet),
124 other => Cow::Borrowed(other),
125 }
126 }
127
128 #[derive(Debug)]
129 struct PromptTextLayer<'a> {
130 name: &'static str,
131 kind: PromptLayerKind,
132 body: &'a str,
133 }
134
135 #[must_use]
136 pub fn build_context_inspector_text(app: &App, locale: Locale) -> String {
137 let mut out = String::new();
138 let usage = context_usage(app);
139 let (used, max, percent) = usage;
140
141 let _ = writeln!(out, "{}", tr(locale, MessageId::CtxInspSessionContext));
142 let _ = writeln!(out, "---------------");
143 let _ = writeln!(
144 out,
145 "{}: {}",
146 tr(locale, MessageId::CtxInspModel),
147 app.model
148 );
149 let _ = writeln!(
150 out,
151 "{}: {}",
152 tr(locale, MessageId::CtxInspWorkspace),
153 crate::utils::display_path(&app.workspace)
154 );
155 if let Some(session_id) = app.current_session_id.as_deref() {
156 let _ = writeln!(
157 out,
158 "{}: {}",
159 tr(locale, MessageId::CtxInspSession),
160 crate::session_manager::truncate_id(session_id)
161 );
162 }
163 let status_label = match context_status(percent) {
164 ContextPressure::Critical => tr(locale, MessageId::CtxInspCritical),
165 ContextPressure::High => tr(locale, MessageId::CtxInspHigh),
166 ContextPressure::Ok => tr(locale, MessageId::CtxInspOk),
167 };
168 let tokens_unit = tr(locale, MessageId::CtxInspTokens);
169 let _ = writeln!(
170 out,
171 "{ctx_label}: {status_label} - ~{used}/{max} {tokens_unit} ({percent:.1}%)",
172 ctx_label = tr(locale, MessageId::CtxInspContext),
173 );
174 let cells = tr(locale, MessageId::CtxInspCells);
175 let api_msgs = tr(locale, MessageId::CtxInspApiMessages);
176 let _ = writeln!(
177 out,
178 "{label}: {} {cells}, {} {api_msgs}",
179 app.history.len(),
180 app.api_messages.len(),
181 label = tr(locale, MessageId::CtxInspTranscript),
182 );
183 let _ = writeln!(
184 out,
185 "{}: {}",
186 tr(locale, MessageId::CtxInspWorkspaceStatus),
187 app.workspace_context
188 .as_deref()
189 .unwrap_or(&*tr(locale, MessageId::CtxInspNotSampledYet))
190 );
191
192 let _ = writeln!(out);
193 push_system_prompt_structure(&mut out, app, locale);
194 let _ = writeln!(out);
195 push_references(&mut out, &app.session_context_references, locale);
196 let _ = writeln!(out);
197 push_tools(&mut out, app, locale);
198
199 out
200 }
201
202 fn context_usage(app: &App) -> (usize, u32, f64) {
203 let max = crate::route_budget::route_context_window_tokens(
204 app.api_provider,
205 app.effective_model_for_budget(),
206 app.active_route_limits,
207 );
208 let estimated =
209 estimate_input_tokens_conservative(&app.api_messages, app.system_prompt.as_ref());
210 let total_chars = estimate_message_chars(&app.api_messages);
211 let used = estimated.max(total_chars / 4);
212 let percent = ((used as f64 / f64::from(max)) * 100.0).clamp(0.0, 100.0);
213 (used, max, percent)
214 }
215
216 enum ContextPressure {
217 Ok,
218 High,
219 Critical,
220 }
221
222 fn context_status(percent: f64) -> ContextPressure {
223 if percent >= CONTEXT_CRITICAL_THRESHOLD_PERCENT {
224 ContextPressure::Critical
225 } else if percent >= CONTEXT_WARNING_THRESHOLD_PERCENT {
226 ContextPressure::High
227 } else {
228 ContextPressure::Ok
229 }
230 }
231
232 /// Inspect the system prompt structure, split into cache-friendly stable
233 /// prefix blocks and the volatile working-set tail block.
234 fn push_system_prompt_structure(out: &mut String, app: &App, locale: Locale) {
235 let _ = writeln!(out, "{}", tr(locale, MessageId::CtxInspSystemPrompt));
236 let _ = writeln!(out, "-----------------------");
237
238 // Conservative token estimate: ~3 chars per token (consistent with
239 // compaction.rs internal helpers — replicated here to avoid depending
240 // on a private function).
241 let text_tokens = |text: &str| text.chars().count().div_ceil(3);
242
243 let total_est = match &app.system_prompt {
244 Some(SystemPrompt::Text(t)) => text_tokens(t),
245 Some(SystemPrompt::Blocks(blocks)) => blocks.iter().map(|b| text_tokens(&b.text)).sum(),
246 None => 0,
247 };
248
249 let stable_lbl = tr(locale, MessageId::CtxInspStablePrefix);
250 let volatile_lbl = tr(locale, MessageId::CtxInspVolatileWorkingSet);
251 let first_line_lbl = tr(locale, MessageId::CtxInspFirstLine);
252 let total_lbl = tr(locale, MessageId::CtxInspTotal);
253 let text_prompt_lbl = tr(locale, MessageId::CtxInspTextPromptLayers);
254 let single_blob_lbl = tr(locale, MessageId::CtxInspSingleTextBlob);
255 let blocks_unit = tr(locale, MessageId::CtxInspBlocks);
256 let block_unit = tr(locale, MessageId::CtxInspBlock);
257 let tokens_unit = tr(locale, MessageId::CtxInspTokens);
258 let layers_unit = tr(locale, MessageId::CtxInspLayers);
259 let none_lbl = tr(locale, MessageId::CtxInspNone);
260 let empty_lbl = tr(locale, MessageId::CtxInspEmpty);
261 let cache_friendly = tr(locale, MessageId::CtxInspCacheFriendly);
262 let changes_by_turn = tr(locale, MessageId::CtxInspChangesByTurn);
263 let stable_only = tr(locale, MessageId::CtxInspStablePrefixOnly);
264 let no_system_prompt = tr(locale, MessageId::CtxInspNoSystemPrompt);
265 match &app.system_prompt {
266 Some(SystemPrompt::Blocks(blocks)) => {
267 let working_set_idx = blocks
268 .iter()
269 .position(|b| b.text.contains(WORKING_SET_MARKER));
270 let (stable_count, working_block) = match working_set_idx {
271 Some(idx) => (idx, Some(&blocks[idx])),
272 None => (blocks.len(), None),
273 };
274
275 let stable_tokens: usize = blocks
276 .iter()
277 .take(stable_count)
278 .map(|b| text_tokens(&b.text))
279 .sum();
280 let working_tokens = working_block.map(|b| text_tokens(&b.text)).unwrap_or(0);
281
282 let _ = writeln!(
283 out,
284 " {stable_lbl}: {stable_count} {blocks_unit}, ~{stable_tokens} {tokens_unit} [{cache_friendly}]"
285 );
286 if let Some(block) = working_block {
287 let _ = writeln!(
288 out,
289 " {volatile_lbl}: 1 {block_unit}, ~{working_tokens} {tokens_unit} [{changes_by_turn}]"
290 );
291 let _ = writeln!(
292 out,
293 " {first_line_lbl}: {}",
294 block.text.lines().next().unwrap_or(&*empty_lbl)
295 );
296 } else {
297 let _ = writeln!(out, " {volatile_lbl}: {none_lbl}");
298 }
299 let _ = writeln!(
300 out,
301 " {total_lbl}: {} {blocks_unit}, ~{total_est} {tokens_unit}",
302 blocks.len()
303 );
304 let layers = blocks
305 .iter()
306 .flat_map(|block| split_text_prompt_layers(&block.text))
307 .filter(|layer| !layer.body.is_empty())
308 .collect::<Vec<_>>();
309 if layers.iter().any(|layer| layer.name != "System prompt") {
310 let _ = writeln!(out, " {text_prompt_lbl}:");
311 for layer in layers {
312 let tokens = text_tokens(layer.body);
313 let kind_lbl = layer.kind.label(locale);
314 let layer_name = layer_display_name(layer.name, locale);
315 let _ = writeln!(
316 out,
317 " - {layer_name}: ~{tokens} {tokens_unit} [{kind_lbl}]",
318 );
319 }
320 }
321 }
322 Some(SystemPrompt::Text(text)) => {
323 let layers = split_text_prompt_layers(text);
324 if layers.len() > 1
325 || layers
326 .first()
327 .is_some_and(|layer| layer.name != "System prompt")
328 {
329 let _ = writeln!(
330 out,
331 " {text_prompt_lbl}: {} {layers_unit}, ~{total_est} {tokens_unit}",
332 layers.len()
333 );
334 for layer in layers {
335 let tokens = text_tokens(layer.body);
336 let kind_lbl = layer.kind.label(locale);
337 let layer_name = layer_display_name(layer.name, locale);
338 let _ = writeln!(
339 out,
340 " - {layer_name}: ~{tokens} {tokens_unit} [{kind_lbl}]",
341 );
342 }
343 } else {
344 let _ = writeln!(
345 out,
346 " {single_blob_lbl} (~{total_est} {tokens_unit}) [{stable_only}]"
347 );
348 }
349 }
350 None => {
351 let _ = writeln!(out, " {no_system_prompt}");
352 }
353 }
354
355 // Cache-economics hint
356 let _ = writeln!(out, " {}", tr(locale, MessageId::CtxInspCacheTip));
357 }
358
359 fn split_text_prompt_layers(text: &str) -> Vec<PromptTextLayer<'_>> {
360 let mut starts = SYSTEM_LAYER_MARKERS
361 .iter()
362 .filter_map(|(name, marker, kind)| text.find(marker).map(|idx| (idx, *name, *kind)))
363 .collect::<Vec<_>>();
364 starts.sort_by_key(|(idx, _, _)| *idx);
365
366 let Some((first_idx, _, _)) = starts.first().copied() else {
367 return vec![PromptTextLayer {
368 name: "System prompt",
369 kind: PromptLayerKind::Static,
370 body: text.trim(),
371 }];
372 };
373
374 let mut layers = Vec::new();
375 if first_idx > 0 {
376 layers.push(PromptTextLayer {
377 name: "Global system prefix",
378 kind: PromptLayerKind::Static,
379 body: text[..first_idx].trim(),
380 });
381 }
382
383 for (i, (start, name, kind)) in starts.iter().enumerate() {
384 let end = starts.get(i + 1).map_or(text.len(), |(idx, _, _)| *idx);
385 layers.push(PromptTextLayer {
386 name,
387 kind: *kind,
388 body: text[*start..end].trim(),
389 });
390 }
391
392 layers
393 }
394
395 fn push_references(out: &mut String, references: &[SessionContextReference], locale: Locale) {
396 let _ = writeln!(out, "{}", tr(locale, MessageId::CtxInspReferences));
397 let _ = writeln!(out, "----------");
398
399 let mut seen = HashSet::new();
400 let mut rendered = 0usize;
401 for record in references {
402 let reference = &record.reference;
403 let key = format!(
404 "{:?}:{:?}:{}:{}",
405 reference.source, reference.kind, reference.target, reference.label
406 );
407 if !seen.insert(key) {
408 continue;
409 }
410 if rendered >= MAX_REFERENCE_ROWS {
411 let remaining = references.len().saturating_sub(rendered);
412 if remaining > 0 {
413 let _ = writeln!(
414 out,
415 "- ... {remaining} {}",
416 tr(locale, MessageId::CtxInspMoreReferences)
417 );
418 }
419 break;
420 }
421
422 let prefix = match reference.source {
423 ContextReferenceSource::AtMention => "@",
424 ContextReferenceSource::Attachment => "/attach ",
425 };
426 let state = if reference.included {
427 if reference.expanded {
428 tr(locale, MessageId::CtxInspIncluded)
429 } else {
430 tr(locale, MessageId::CtxInspAttached)
431 }
432 } else {
433 tr(locale, MessageId::CtxInspNotIncluded)
434 };
435 let detail = reference
436 .detail
437 .as_deref()
438 .filter(|detail| !detail.trim().is_empty())
439 .map(|detail| format!(" - {detail}"))
440 .unwrap_or_default();
441 let _ = writeln!(
442 out,
443 "- [{}] {prefix}{} -> {} ({state}{detail})",
444 reference.badge, reference.label, reference.target
445 );
446 rendered += 1;
447 }
448
449 if rendered == 0 {
450 let _ = writeln!(out, "- {}", tr(locale, MessageId::CtxInspNoReferences));
451 }
452 }
453
454 fn push_tools(out: &mut String, app: &App, locale: Locale) {
455 let _ = writeln!(out, "{}", tr(locale, MessageId::CtxInspRecentTools));
456 let _ = writeln!(out, "------------");
457
458 let mut rows: Vec<(usize, &ToolDetailRecord)> = app
459 .tool_details_by_cell
460 .iter()
461 .map(|(idx, detail)| (*idx, detail))
462 .collect();
463 rows.sort_by_key(|(idx, _)| std::cmp::Reverse(*idx));
464
465 let mut rendered = 0usize;
466 for detail in app.active_tool_details.values() {
467 let location = tr(locale, MessageId::CtxInspActive);
468 push_tool_row(out, locale, &location, detail);
469 rendered += 1;
470 if rendered >= MAX_TOOL_ROWS {
471 return;
472 }
473 }
474 for (cell_idx, detail) in rows
475 .into_iter()
476 .take(MAX_TOOL_ROWS.saturating_sub(rendered))
477 {
478 let location = format!("{} {cell_idx}", tr(locale, MessageId::CtxInspCell));
479 push_tool_row(out, locale, &location, detail);
480 rendered += 1;
481 }
482
483 if rendered == 0 {
484 let _ = writeln!(out, "- {}", tr(locale, MessageId::CtxInspNoToolActivity));
485 } else {
486 let details = crate::tui::shell_key_routing::display_chord(
487 crate::tui::shell_key_routing::binding(
488 crate::tui::shell_key_routing::ShellBindingId::ToolDetails,
489 )
490 .footer_chord,
491 );
492 let _ = writeln!(
493 out,
494 "- {}",
495 tr(locale, MessageId::CtxInspVHint).replace("{details}", details.as_ref())
496 );
497 }
498 }
499
500 fn push_tool_row(out: &mut String, locale: Locale, location: &str, detail: &ToolDetailRecord) {
501 let output_state = if detail.output.as_deref().is_some_and(|out| !out.is_empty()) {
502 tr(locale, MessageId::CtxInspOutputCaptured)
503 } else {
504 tr(locale, MessageId::CtxInspNoOutputYet)
505 };
506 let _ = writeln!(
507 out,
508 "- [{}] {} {} ({output_state})",
509 location,
510 detail.tool_name,
511 short_tool_id(&detail.tool_id)
512 );
513 }
514
515 fn short_tool_id(id: &str) -> String {
516 // Slice by characters, not bytes: a tool id from a gateway can contain
517 // multibyte characters, and `&id[..8]` panics on a byte index that lands
518 // mid-codepoint (2026-08-04 review).
519 let mut chars = id.chars();
520 let head: String = chars.by_ref().take(8).collect();
521 if chars.next().is_some() {
522 format!("{head}...")
523 } else {
524 head
525 }
526 }
527
528 #[derive(Debug, Clone)]
529 struct ContextBucket {
530 label: String,
531 tokens: usize,
532 percent: f64,
533 detail: String,
534 }
535
536 /// Live context surface. The host refreshes its snapshot immediately before
537 /// every render, so opening it never freezes the underlying session facts.
538 pub(crate) struct ContextInspectorView {
539 used: usize,
540 max: u32,
541 percent: f64,
542 model: String,
543 workspace: String,
544 threshold: f64,
545 rows: Vec<ContextBucket>,
546 selected: usize,
547 hitboxes: RefCell<Vec<(u16, usize)>>,
548 locale: Locale,
549 }
550
551 impl ContextInspectorView {
552 #[must_use]
553 pub(crate) fn new(app: &App) -> Self {
554 let mut view = Self {
555 used: 0,
556 max: 0,
557 percent: 0.0,
558 model: String::new(),
559 workspace: String::new(),
560 threshold: 0.0,
561 rows: Vec::new(),
562 selected: 0,
563 hitboxes: RefCell::new(Vec::new()),
564 locale: app.ui_locale,
565 };
566 view.refresh_from_app(app);
567 view
568 }
569
570 pub(crate) fn refresh_from_app(&mut self, app: &App) {
571 let (used, max, percent) = context_usage(app);
572 let system_tokens = estimate_input_tokens_conservative(&[], app.system_prompt.as_ref());
573 let message_tokens = used.saturating_sub(system_tokens);
574 let free_tokens = usize::try_from(max)
575 .unwrap_or(usize::MAX)
576 .saturating_sub(used);
577 let full_detail = build_context_inspector_text(app, app.ui_locale);
578 self.used = used;
579 self.max = max;
580 self.percent = percent;
581 self.model = app.model_display_label();
582 self.workspace = crate::utils::display_path(&app.workspace);
583 self.threshold = app.auto_compact_threshold_percent;
584 self.locale = app.ui_locale;
585 let max_f = f64::from(max.max(1));
586 self.rows = vec![
587 ContextBucket {
588 label: tr(self.locale, MessageId::CtxInspRowSystemPrompt).into_owned(),
589 tokens: system_tokens,
590 percent: (system_tokens as f64 / max_f) * 100.0,
591 detail: full_detail.clone(),
592 },
593 ContextBucket {
594 label: tr(self.locale, MessageId::CtxInspRowMessages).into_owned(),
595 tokens: message_tokens,
596 percent: (message_tokens as f64 / max_f) * 100.0,
597 detail: full_detail,
598 },
599 ContextBucket {
600 label: tr(self.locale, MessageId::CtxInspRowFree).into_owned(),
601 tokens: free_tokens,
602 percent: (free_tokens as f64 / max_f) * 100.0,
603 detail: tr(self.locale, MessageId::CtxInspFreeTokensDetail)
604 .replace("{free}", &free_tokens.to_string())
605 .replace("{threshold}", &format!("{:.0}", self.threshold)),
606 },
607 ];
608 self.selected = self.selected.min(self.rows.len().saturating_sub(1));
609 }
610
611 fn move_selection(&mut self, delta: isize) {
612 if self.rows.is_empty() {
613 return;
614 }
615 self.selected = if delta.is_negative() {
616 self.selected.saturating_sub(delta.unsigned_abs())
617 } else {
618 (self.selected + delta as usize).min(self.rows.len() - 1)
619 };
620 }
621
622 fn open_selected(&self) -> ViewAction {
623 let Some(row) = self.rows.get(self.selected) else {
624 return ViewAction::None;
625 };
626 ViewAction::Emit(ViewEvent::OpenTextPager {
627 title: tr(self.locale, MessageId::CtxInspDrillTitle).replace("{row}", &row.label),
628 content: row.detail.clone(),
629 })
630 }
631 }
632
633 impl ModalView for ContextInspectorView {
634 fn kind(&self) -> ModalKind {
635 ModalKind::ContextInspector
636 }
637
638 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
639 self
640 }
641
642 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
643 match key.code {
644 KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close,
645 KeyCode::Up | KeyCode::Char('k') => {
646 self.move_selection(-1);
647 ViewAction::None
648 }
649 KeyCode::Down | KeyCode::Char('j') => {
650 self.move_selection(1);
651 ViewAction::None
652 }
653 KeyCode::Enter => self.open_selected(),
654 _ => ViewAction::None,
655 }
656 }
657
658 fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
659 match mouse.kind {
660 MouseEventKind::ScrollUp => {
661 self.move_selection(-1);
662 ViewAction::None
663 }
664 MouseEventKind::ScrollDown => {
665 self.move_selection(1);
666 ViewAction::None
667 }
668 MouseEventKind::Down(MouseButton::Left) => {
669 let hit = self
670 .hitboxes
671 .borrow()
672 .iter()
673 .find_map(|(y, idx)| (*y == mouse.row).then_some(*idx));
674 let Some(idx) = hit else {
675 return ViewAction::None;
676 };
677 if idx == self.selected {
678 self.open_selected()
679 } else {
680 self.selected = idx;
681 ViewAction::None
682 }
683 }
684 _ => ViewAction::None,
685 }
686 }
687
688 fn render(&self, area: Rect, buf: &mut Buffer) {
689 let inner =
690 render_underwater_surface(area, buf, tr(self.locale, MessageId::CtxInspSurfaceTitle));
691 let content = render_modal_footer(
692 inner,
693 buf,
694 &[
695 ActionHint::new("↑/↓", tr(self.locale, MessageId::CtxInspActionSelect)),
696 ActionHint::new("Enter", tr(self.locale, MessageId::CtxInspActionDrillDown)),
697 ActionHint::new("Esc", tr(self.locale, MessageId::CtxInspActionClose)),
698 ],
699 );
700 let width = usize::from(content.width);
701 let mut lines = vec![
702 Line::from(vec![
703 Span::styled(
704 tr(self.locale, MessageId::CtxInspUsedTokens)
705 .replace("{used}", &self.used.to_string())
706 .replace("{max}", &self.max.to_string()),
707 Style::default()
708 .fg(palette::WHALE_INFO)
709 .add_modifier(Modifier::BOLD),
710 ),
711 Span::styled(
712 format!(" · {:.1}% · {}", self.percent, self.model),
713 Style::default().fg(palette::TEXT_MUTED),
714 ),
715 ]),
716 Line::from(Span::styled(
717 crate::tui::ui_text::semantic_truncate(&self.workspace, width),
718 Style::default().fg(palette::TEXT_DIM),
719 )),
720 Line::from(""),
721 ];
722
723 if content.height >= 11 && content.width >= 24 {
724 let cells = usize::from(content.width.saturating_sub(2)).min(60);
725 let system_cells = ((self.rows[0].percent / 100.0) * cells as f64).round() as usize;
726 let message_cells = ((self.rows[1].percent / 100.0) * cells as f64).round() as usize;
727 let system_cells = system_cells.min(cells);
728 let message_cells = message_cells.min(cells.saturating_sub(system_cells));
729 let free_cells = cells.saturating_sub(system_cells + message_cells);
730 lines.push(Line::from(vec![
731 Span::styled(
732 "#".repeat(system_cells),
733 Style::default().fg(palette::WHALE_INFO),
734 ),
735 Span::styled(
736 "=".repeat(message_cells),
737 Style::default().fg(palette::TEXT_PRIMARY),
738 ),
739 Span::styled(
740 ".".repeat(free_cells),
741 Style::default().fg(palette::TEXT_DIM),
742 ),
743 ]));
744 lines.push(Line::from(Span::styled(
745 tr(self.locale, MessageId::CtxInspAutoCompactAt)
746 .replace("{threshold}", &format!("{:.0}", self.threshold)),
747 Style::default().fg(palette::TEXT_HINT),
748 )));
749 lines.push(Line::from(""));
750 }
751
752 self.hitboxes.borrow_mut().clear();
753 for (idx, row) in self.rows.iter().enumerate() {
754 let selected = idx == self.selected;
755 let marker = crate::tui::glyphs::selection_marker(selected);
756 let style = if selected {
757 menu_style::selected_row_style()
758 } else {
759 Style::default().fg(palette::TEXT_PRIMARY)
760 };
761 let value = tr(self.locale, MessageId::CtxInspRowTokens)
762 .replace("{tokens}", &row.tokens.to_string())
763 .replace("{percent}", &format!("{:.1}", row.percent));
764 let label_width = width.saturating_sub(value.len() + 5);
765 let label = crate::tui::ui_text::semantic_truncate(&row.label, label_width);
766 let gap = width.saturating_sub(label.len() + value.len() + 3);
767 let y = content
768 .y
769 .saturating_add(u16::try_from(lines.len()).unwrap_or(u16::MAX));
770 self.hitboxes.borrow_mut().push((y, idx));
771 lines.push(Line::from(Span::styled(
772 format!("{marker} {label}{}{value}", " ".repeat(gap)),
773 style,
774 )));
775 }
776 Paragraph::new(lines).render(content, buf);
777 }
778 }
779
780 #[cfg(test)]
781 mod tests {
782 use super::*;
783 use crate::config::Config;
784
785 #[test]
786 fn short_tool_id_never_panics_on_multibyte() {
787 // ASCII short/long behave as before.
788 assert_eq!(short_tool_id("abc"), "abc");
789 assert_eq!(short_tool_id("0123456789"), "01234567...");
790 // A multibyte id must truncate on a char boundary, not panic.
791 // 10 chars → first 8 kept, then the ellipsis.
792 assert_eq!(short_tool_id("日本語のツールid名"), "日本語のツールi...");
793 assert_eq!(short_tool_id("café"), "café");
794 }
795
796 use crate::models::{ContentBlock, Message};
797 use crate::session_manager::SessionContextReference;
798 use crate::tui::app::TuiOptions;
799 use crate::tui::file_mention::{
800 ContextReference, ContextReferenceKind, ContextReferenceSource,
801 };
802 use crate::tui::history::HistoryCell;
803 use std::path::PathBuf;
804
805 use crate::localization::Locale;
806
807 fn test_app() -> App {
808 let mut app = App::new(
809 TuiOptions {
810 model: "unknown-model".to_string(),
811 skills_dir: PathBuf::from("/tmp/skills"),
812 notes_path: PathBuf::from("notes.md"),
813 ..crate::test_support::test_tui_options(PathBuf::from("/tmp/project"))
814 },
815 &Config::default(),
816 );
817 // Pin the route identity: App::new consults the developer's real
818 // saved settings, so on a machine with customized provider/model
819 // the context-window assertions computed against a different route.
820 app.api_provider = crate::config::ApiProvider::Deepseek;
821 app.auto_model = false;
822 app.last_effective_model = None;
823 app.active_route_limits = None;
824 app.active_context_window_override = None;
825 app
826 }
827
828 #[test]
829 fn inspector_formats_empty_state() {
830 let app = test_app();
831 let text = build_context_inspector_text(&app, Locale::En);
832 assert!(text.contains("Session Context"));
833 assert!(text.contains("No file, directory, or media references recorded yet."));
834 assert!(text.contains("No tool activity recorded yet."));
835 }
836
837 #[test]
838 fn inspector_uses_compact_session_id() {
839 let mut app = test_app();
840 app.current_session_id = Some("1234567890abcdef".to_string());
841
842 let text = build_context_inspector_text(&app, Locale::En);
843
844 assert!(text.contains("Session: 12345678"), "{text}");
845 assert!(!text.contains("1234567890abcdef"), "{text}");
846 }
847
848 #[test]
849 fn inspector_lists_context_references() {
850 let mut app = test_app();
851 app.history.push(HistoryCell::User {
852 content: "read @src/main.rs".to_string(),
853 });
854 app.session_context_references
855 .push(SessionContextReference {
856 message_index: 0,
857 reference: ContextReference {
858 kind: ContextReferenceKind::File,
859 source: ContextReferenceSource::AtMention,
860 badge: "file".to_string(),
861 label: "src/main.rs".to_string(),
862 target: "/tmp/project/src/main.rs".to_string(),
863 included: true,
864 expanded: true,
865 detail: Some("included".to_string()),
866 },
867 });
868
869 let text = build_context_inspector_text(&app, Locale::En);
870 assert!(text.contains("[file] @src/main.rs -> /tmp/project/src/main.rs"));
871 }
872
873 #[test]
874 fn inspector_marks_high_context_pressure() {
875 let mut app = test_app();
876 app.api_messages.push(Message {
877 role: "user".to_string(),
878 content: vec![ContentBlock::Text {
879 text: "x".repeat(4_000_000),
880 cache_control: None,
881 }],
882 });
883
884 let text = build_context_inspector_text(&app, Locale::En);
885 assert!(text.contains("Context: critical"), "{text}");
886 }
887
888 #[test]
889 fn inspector_uses_effective_auto_model_context_window() {
890 let mut app = test_app();
891 app.model = "auto".to_string();
892 app.auto_model = true;
893 app.last_effective_model = Some("deepseek-v4-pro".to_string());
894
895 let text = build_context_inspector_text(&app, Locale::En);
896 assert!(text.contains("Model: auto"), "{text}");
897 assert!(text.contains("/1000000 tokens"), "{text}");
898 }
899
900 #[test]
901 fn inspector_no_system_prompt_shows_section() {
902 let app = test_app();
903 let text = build_context_inspector_text(&app, Locale::En);
904 assert!(text.contains("System Prompt Structure"));
905 assert!(text.contains("No system prompt set."));
906 }
907
908 #[test]
909 fn inspector_blocks_format_shows_stable_prefix_and_working_set() {
910 let mut app = test_app();
911 use crate::models::SystemBlock;
912 app.system_prompt = Some(SystemPrompt::Blocks(vec![
913 SystemBlock {
914 block_type: "text".to_string(),
915 text: "## Stable Base\n\nYou are CodeWhale.".to_string(),
916 cache_control: None,
917 },
918 SystemBlock {
919 block_type: "text".to_string(),
920 text: format!("{WORKING_SET_MARKER}\nsrc/main.rs changed"),
921 cache_control: None,
922 },
923 ]));
924
925 let text = build_context_inspector_text(&app, Locale::En);
926 assert!(text.contains("System Prompt Structure"));
927 assert!(
928 text.contains("Stable prefix: 1 block"),
929 "stable prefix count: {text}"
930 );
931 assert!(
932 text.contains("Volatile working set: 1 block"),
933 "working set section: {text}"
934 );
935 assert!(
936 text.contains("[cache-friendly]"),
937 "cache hint for stable: {text}"
938 );
939 assert!(
940 text.contains("[changes by session/turn]"),
941 "volatile marker: {text}"
942 );
943 assert!(
944 text.contains("First line: ## Repo Working Set"),
945 "first line of working set: {text}"
946 );
947 }
948
949 #[test]
950 fn inspector_blocks_without_working_set_shows_stable_only() {
951 let mut app = test_app();
952 use crate::models::SystemBlock;
953 app.system_prompt = Some(SystemPrompt::Blocks(vec![
954 SystemBlock {
955 block_type: "text".to_string(),
956 text: "## Stable Base".to_string(),
957 cache_control: None,
958 },
959 SystemBlock {
960 block_type: "text".to_string(),
961 text: "## Personality\nCalm".to_string(),
962 cache_control: None,
963 },
964 ]));
965
966 let text = build_context_inspector_text(&app, Locale::En);
967 assert!(text.contains("Stable prefix: 2 block(s)"));
968 assert!(text.contains("Volatile working set: none"));
969 }
970
971 #[test]
972 fn inspector_text_prompt_shows_layer_map() {
973 let mut app = test_app();
974 app.system_prompt = Some(SystemPrompt::Text(
975 "## Codewhale\nBundled base law.\n\n## Language\nUse English.\n\n## Output Formatting\nBe clear.\n\n<codewhale_user_constitution>\nUser law\n</codewhale_user_constitution>\n\n<codewhale_repo_constitution>\nRepo law\n</codewhale_repo_constitution>\n\n<project_instructions source=\"AGENTS.md\">\nRules\n</project_instructions>\n\n## Project Context Pack\n{}\n\n## Environment\n- lang: en\n\n## Skills\n- rust\n\n## Core Execution\nInspect, edit, verify.\n\n## Compact\nTemplate\n\n## Repo Working Set\nsrc/".to_string(),
976 ));
977
978 let text = build_context_inspector_text(&app, Locale::En);
979 assert!(text.contains("System Prompt Structure"));
980 assert!(text.contains("Text prompt layers"));
981 assert!(text.contains("Bundled constitution"));
982 assert!(text.contains("Language policy"));
983 assert!(text.contains("Output formatting"));
984 assert!(text.contains("User-global constitution"));
985 assert!(text.contains("Repository constitution"));
986 assert!(text.contains("Project context"));
987 assert!(text.contains("Project context pack"));
988 assert!(text.contains("Environment"));
989 assert!(text.contains("Skills"));
990 assert!(text.contains("Core execution"));
991 assert!(text.contains("Compact template"));
992 assert!(text.contains("Volatile working set"));
993 assert!(text.contains("changes by session/turn"));
994 }
995
996 #[test]
997 fn inspector_text_prompt_without_markers_shows_single_blob() {
998 let mut app = test_app();
999 app.system_prompt = Some(SystemPrompt::Text("You are CodeWhale.".to_string()));
1000
1001 let text = build_context_inspector_text(&app, Locale::En);
1002 assert!(text.contains("Single text blob"));
1003 assert!(text.contains("stable prefix only"));
1004 }
1005
1006 #[test]
1007 fn inspector_localizes_to_zh_hans() {
1008 use crate::models::SystemBlock;
1009 let mut app = test_app();
1010 app.system_prompt = Some(SystemPrompt::Blocks(vec![
1011 SystemBlock {
1012 block_type: "text".to_string(),
1013 text: "## Base\nYou are CodeWhale.".to_string(),
1014 cache_control: None,
1015 },
1016 SystemBlock {
1017 block_type: "text".to_string(),
1018 text: format!("{WORKING_SET_MARKER}\nsrc/main.rs changed"),
1019 cache_control: None,
1020 },
1021 ]));
1022 let text = build_context_inspector_text(&app, Locale::ZhHans);
1023
1024 // Positive: key ZhHans labels present
1025 assert!(text.contains("会话上下文"), "session header: {text}");
1026 assert!(text.contains("模型"), "model label: {text}");
1027 assert!(text.contains("工作区"), "workspace: {text}");
1028 assert!(text.contains("系统提示结构"), "sysprompt section: {text}");
1029 assert!(text.contains("稳定前缀"), "stable prefix: {text}");
1030 assert!(text.contains("易变工作集"), "volatile ws: {text}");
1031 assert!(text.contains("第一行"), "first line: {text}");
1032 assert!(text.contains("总计"), "total line: {text}");
1033 assert!(text.contains("引用"), "references: {text}");
1034 assert!(text.contains("最近使用的工具"), "tools: {text}");
1035 assert!(text.contains("个区块"), "blocks unit: {text}");
1036 assert!(text.contains("个 token"), "tokens unit: {text}");
1037 assert!(text.contains("缓存友好"), "cache-friendly: {text}");
1038 assert!(text.contains("提示"), "cache tip: {text}");
1039
1040 // Negative: no English labels leak
1041 assert!(!text.contains("Session Context"), "EN session leaked");
1042 assert!(!text.contains("Model:"), "EN model leaked");
1043 assert!(!text.contains("cells"), "EN cells leaked");
1044 assert!(!text.contains("API messages"), "EN API msgs leaked");
1045 assert!(!text.contains("Stable prefix"), "EN stable prefix leaked");
1046 assert!(
1047 !text.contains("Volatile working set"),
1048 "EN volatile ws leaked"
1049 );
1050 assert!(!text.contains("First line"), "EN first line leaked");
1051 assert!(!text.contains("Total:"), "EN total leaked");
1052 assert!(!text.contains("Text prompt layers"), "EN layers leaked");
1053 assert!(!text.contains("cache-friendly"), "EN cache-friendly leaked");
1054 assert!(!text.contains("more reference"), "EN more refs leaked");
1055 assert!(!text.contains("no output yet"), "EN no output leaked");
1056 }
1057 }
1058
1058 lines RUST