返回 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::{
18 CompactionPath, estimate_input_tokens_for_pressure, inspect_compaction_keep,
19 last_round_kept_count, last_round_start, pinned_anchors_text,
20 };
21 use crate::session_manager::SessionContextReference;
22 use crate::tui::app::{App, ToolDetailRecord};
23 use crate::tui::menu_style;
24 use crate::tui::views::{
25 ActionHint, ModalKind, ModalView, ViewAction, ViewEvent, render_modal_footer,
26 render_underwater_surface,
27 };
28 use codewhale_core::ContextReferenceSource;
29 use codewhale_localization::{Locale, MessageId, tr};
30 use codewhale_models::{SystemPrompt, Tool};
31 use codewhale_palette as palette;
32
33 /// Marker used by per-turn working-set metadata. Replicated here so the
34 /// context inspector can distinguish stable prompt blocks from volatile
35 /// working-set context without importing engine internals.
36 const WORKING_SET_MARKER: &str = "## Repo Working Set";
37
38 pub(crate) const CONTEXT_WARNING_THRESHOLD_PERCENT: f64 = 85.0;
39 pub(crate) const CONTEXT_CRITICAL_THRESHOLD_PERCENT: f64 = 95.0;
40 const MAX_REFERENCE_ROWS: usize = 12;
41 const MAX_TOOL_ROWS: usize = 8;
42 const MAX_SCHEMA_COST_ROWS: usize = 24;
43 const SCHEMA_TOKEN_DIVISOR: usize = 4;
44
45 const SYSTEM_LAYER_MARKERS: &[(&str, &str, PromptLayerKind)] = &[
46 (
47 "Bundled constitution",
48 "## Codewhale",
49 PromptLayerKind::Static,
50 ),
51 ("Language policy", "## Language", PromptLayerKind::Static),
52 (
53 "Output formatting",
54 "## Output Formatting",
55 PromptLayerKind::Static,
56 ),
57 (
58 "User-global constitution",
59 "<codewhale_user_constitution",
60 PromptLayerKind::Static,
61 ),
62 (
63 "Repository constitution",
64 "<codewhale_repo_constitution",
65 PromptLayerKind::Static,
66 ),
67 (
68 "Project context",
69 "<project_instructions",
70 PromptLayerKind::Static,
71 ),
72 (
73 "Project context pack",
74 "## Project Context Pack",
75 PromptLayerKind::Static,
76 ),
77 ("Environment", "## Environment", PromptLayerKind::Static),
78 ("Skills", "## Skills", PromptLayerKind::Static),
79 (
80 "Core execution",
81 "## Core Execution",
82 PromptLayerKind::Static,
83 ),
84 ("Compact template", "## Compact", PromptLayerKind::Static),
85 (
86 "Configured instructions",
87 "<instructions ",
88 PromptLayerKind::Dynamic,
89 ),
90 ("User memory", "## User Memory", PromptLayerKind::Dynamic),
91 (
92 "Current session goal",
93 "## Current Session Goal",
94 PromptLayerKind::Dynamic,
95 ),
96 (
97 "Previous session relay",
98 "## Previous Session Relay",
99 PromptLayerKind::Dynamic,
100 ),
101 (
102 "Volatile working set",
103 WORKING_SET_MARKER,
104 PromptLayerKind::Dynamic,
105 ),
106 ];
107
108 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
109 enum PromptLayerKind {
110 Static,
111 Dynamic,
112 }
113
114 impl PromptLayerKind {
115 fn label(self, locale: Locale) -> Cow<'static, str> {
116 match self {
117 Self::Static => tr(locale, MessageId::CtxInspCacheFriendly),
118 Self::Dynamic => tr(locale, MessageId::CtxInspChangesByTurn),
119 }
120 }
121 }
122
123 /// Localize well-known layer labels that already have inspector MessageIds.
124 /// Other layer names stay as English product identifiers.
125 fn layer_display_name(name: &'static str, locale: Locale) -> Cow<'static, str> {
126 match name {
127 "Volatile working set" => tr(locale, MessageId::CtxInspVolatileWorkingSet),
128 other => Cow::Borrowed(other),
129 }
130 }
131
132 #[derive(Debug)]
133 struct PromptTextLayer<'a> {
134 name: &'static str,
135 kind: PromptLayerKind,
136 body: &'a str,
137 }
138
139 #[must_use]
140 pub fn build_context_inspector_text(app: &App, locale: Locale) -> String {
141 let mut out = String::new();
142 let usage = context_usage(app);
143 let (used, max, percent) = usage;
144
145 let _ = writeln!(out, "{}", tr(locale, MessageId::CtxInspSessionContext));
146 let _ = writeln!(out, "---------------");
147 let _ = writeln!(
148 out,
149 "{}: {}",
150 tr(locale, MessageId::CtxInspModel),
151 app.model
152 );
153 let _ = writeln!(
154 out,
155 "{}: {}",
156 tr(locale, MessageId::CtxInspWorkspace),
157 crate::utils::display_path(&app.workspace)
158 );
159 if let Some(session_id) = app.current_session_id.as_deref() {
160 let _ = writeln!(
161 out,
162 "{}: {}",
163 tr(locale, MessageId::CtxInspSession),
164 crate::session_manager::truncate_id(session_id)
165 );
166 }
167 // Real provider-token cache hit rate from the same records /cache
168 // aggregates (C3): only provider-reported cache telemetry counts, so the
169 // number is what the user actually paid to keep, not a predicted guess.
170 let mut cache_turns = 0u64;
171 let (cache_hit, cache_miss) =
172 app.session
173 .turn_cache_history
174 .iter()
175 .fold((0u64, 0u64), |(hit, miss), record| {
176 let Some(hit_tokens_u32) = record.cache_hit_tokens else {
177 return (hit, miss);
178 };
179 let hit_tokens = u64::from(hit_tokens_u32);
180 let miss_tokens = u64::from(
181 record
182 .cache_miss_tokens
183 .unwrap_or(record.input_tokens.saturating_sub(hit_tokens_u32)),
184 );
185 cache_turns += 1;
186 (hit + hit_tokens, miss + miss_tokens)
187 });
188 let cache_total = cache_hit + cache_miss;
189 if cache_turns > 0 && cache_total > 0 {
190 let cache_percent = (cache_hit as f64 / cache_total as f64 * 100.0).clamp(0.0, 100.0);
191 let _ = writeln!(
192 out,
193 "Provider cache hit rate: {cache_percent:.1}% over {cache_turns} cache-aware turn{}",
194 if cache_turns == 1 { "" } else { "s" },
195 );
196 } else {
197 let _ = writeln!(out, "Provider cache hit rate: no cache telemetry yet");
198 }
199 let status_label = match context_status(percent) {
200 ContextPressure::Critical => tr(locale, MessageId::CtxInspCritical),
201 ContextPressure::High => tr(locale, MessageId::CtxInspHigh),
202 ContextPressure::Ok => tr(locale, MessageId::CtxInspOk),
203 };
204 let tokens_unit = tr(locale, MessageId::CtxInspTokens);
205 let _ = writeln!(
206 out,
207 "{ctx_label}: {status_label} - ~{used}/{max} {tokens_unit} ({percent:.1}%)",
208 ctx_label = tr(locale, MessageId::CtxInspContext),
209 );
210 let cells = tr(locale, MessageId::CtxInspCells);
211 let api_msgs = tr(locale, MessageId::CtxInspApiMessages);
212 let _ = writeln!(
213 out,
214 "{label}: {} {cells}, {} {api_msgs}",
215 app.history.len(),
216 app.api_messages.len(),
217 label = tr(locale, MessageId::CtxInspTranscript),
218 );
219 if let Some(kept) = last_round_kept_count(&app.api_messages) {
220 let _ = writeln!(
221 out,
222 "Last compaction: kept last round verbatim ({kept} messages); earlier turns summarized."
223 );
224 }
225 let _ = writeln!(
226 out,
227 "{}: {}",
228 tr(locale, MessageId::CtxInspWorkspaceStatus),
229 app.workspace_context
230 .as_deref()
231 .unwrap_or(&*tr(locale, MessageId::CtxInspNotSampledYet))
232 );
233
234 let _ = writeln!(out);
235 push_compaction_and_anchors(&mut out, app, locale);
236 let _ = writeln!(out);
237 push_system_prompt_structure(&mut out, app, locale);
238 let _ = writeln!(out);
239 push_references(&mut out, &app.session_context_references, locale);
240 let _ = writeln!(out);
241 push_tools(&mut out, app, locale);
242 push_tool_schema_costs(&mut out, app, locale);
243
244 out
245 }
246
247 fn context_usage(app: &App) -> (usize, u32, f64) {
248 let max = crate::route_budget::route_context_window_tokens(
249 app.api_provider,
250 app.effective_model_for_budget(),
251 app.active_route_limits,
252 );
253 // The meter must show the SAME pressure signal the auto-compaction trigger
254 // decides on (compaction::estimate_input_tokens_for_pressure, the
255 // non-inflated estimate with framing overhead). The old conservative
256 // overflow estimator was ~1.5x larger, so the meter showed the trigger
257 // point as "free" while compaction was still far away (ops T1) — and vice
258 // versa the meter read "free" as negative when the engine was only halfway.
259 let estimated =
260 estimate_input_tokens_for_pressure(&app.api_messages, app.system_prompt.as_ref());
261 // The trigger decides on max(estimate, provider-billed prompt); the meter
262 // must too, or a provider billing above the local estimate (non-ASCII
263 // text, server-side framing) makes the meter under-show real pressure
264 // (#5577). The billed receipt is per model call, so it goes stale only
265 // until the next step or compaction updates it.
266 let used = estimated.max(
267 app.last_billed_input_tokens
268 .map(|tokens| tokens as usize)
269 .unwrap_or(0),
270 );
271 let percent = ((used as f64 / f64::from(max)) * 100.0).clamp(0.0, 100.0);
272 (used, max, percent)
273 }
274
275 enum ContextPressure {
276 Ok,
277 High,
278 Critical,
279 }
280
281 fn context_status(percent: f64) -> ContextPressure {
282 if percent >= CONTEXT_CRITICAL_THRESHOLD_PERCENT {
283 ContextPressure::Critical
284 } else if percent >= CONTEXT_WARNING_THRESHOLD_PERCENT {
285 ContextPressure::High
286 } else {
287 ContextPressure::Ok
288 }
289 }
290
291 fn compaction_path_label(path: CompactionPath, locale: Locale) -> Cow<'static, str> {
292 match path {
293 CompactionPath::Summary => tr(locale, MessageId::CtxInspCompactionPathSummary),
294 CompactionPath::PruneOnly => tr(locale, MessageId::CtxInspCompactionPathPrune),
295 }
296 }
297
298 fn compaction_assistant_clause(kept: bool, locale: Locale) -> Cow<'static, str> {
299 if kept {
300 tr(locale, MessageId::CtxInspCompactionAssistantKept)
301 } else {
302 Cow::Borrowed("")
303 }
304 }
305
306 fn last_round_messages(messages: &[codewhale_models::Message]) -> &[codewhale_models::Message] {
307 let start = last_round_start(messages).min(messages.len());
308 &messages[start..]
309 }
310
311 fn compaction_detail_for_app(app: &App, locale: Locale) -> (String, usize) {
312 let keep = inspect_compaction_keep(&app.api_messages);
313 let assistant = compaction_assistant_clause(keep.last_round_assistant, locale);
314 let last_round_tokens =
315 estimate_input_tokens_for_pressure(last_round_messages(&app.api_messages), None);
316 let detail = if let Some(snapshot) = app.last_compaction.as_ref() {
317 tr(locale, MessageId::CtxInspCompactionDetail)
318 .replace(
319 "{path}",
320 &compaction_path_label(snapshot.coverage.path, locale),
321 )
322 .replace("{before}", &snapshot.messages_before.to_string())
323 .replace("{after}", &snapshot.messages_after.to_string())
324 .replace(
325 "{round}",
326 &snapshot.coverage.last_round_messages.to_string(),
327 )
328 .replace(
329 "{tools}",
330 &snapshot.coverage.last_round_tool_results.to_string(),
331 )
332 .replace("{assistant}", &assistant)
333 } else if keep.has_checkpoint {
334 tr(locale, MessageId::CtxInspCompactionRestored)
335 .replace("{round}", &keep.last_round_messages.to_string())
336 .replace("{tools}", &keep.last_round_tool_results.to_string())
337 .replace("{assistant}", &assistant)
338 } else {
339 tr(locale, MessageId::CtxInspCompactionNever).into_owned()
340 };
341 (detail, last_round_tokens)
342 }
343
344 fn anchors_detail_for_app(app: &App, locale: Locale) -> (String, usize) {
345 match pinned_anchors_text(Some(&app.workspace)) {
346 Some(text) => {
347 let chars = text.chars().count();
348 (
349 tr(locale, MessageId::CtxInspAnchorsPresent).replace("{chars}", &chars.to_string()),
350 chars.div_ceil(3),
351 )
352 }
353 None => (tr(locale, MessageId::CtxInspAnchorsNone).into_owned(), 0),
354 }
355 }
356
357 fn push_compaction_and_anchors(out: &mut String, app: &App, locale: Locale) {
358 let (compaction_detail, _) = compaction_detail_for_app(app, locale);
359 let (anchors_detail, _) = anchors_detail_for_app(app, locale);
360 let _ = writeln!(out, "{}", tr(locale, MessageId::CtxInspRowCompaction));
361 let _ = writeln!(out, "----------");
362 let _ = writeln!(out, "{compaction_detail}");
363 let _ = writeln!(out);
364 let _ = writeln!(out, "{}", tr(locale, MessageId::CtxInspRowAnchors));
365 let _ = writeln!(out, "-------");
366 let _ = writeln!(out, "{anchors_detail}");
367 }
368
369 /// Inspect the system prompt structure, split into cache-friendly stable
370 /// prefix blocks and the volatile working-set tail block.
371 fn push_system_prompt_structure(out: &mut String, app: &App, locale: Locale) {
372 let _ = writeln!(out, "{}", tr(locale, MessageId::CtxInspSystemPrompt));
373 let _ = writeln!(out, "-----------------------");
374
375 // Conservative token estimate: ~3 chars per token (consistent with
376 // compaction.rs internal helpers — replicated here to avoid depending
377 // on a private function).
378 let text_tokens = |text: &str| text.chars().count().div_ceil(3);
379
380 let total_est = match &app.system_prompt {
381 Some(SystemPrompt::Text(t)) => text_tokens(t),
382 Some(SystemPrompt::Blocks(blocks)) => blocks.iter().map(|b| text_tokens(&b.text)).sum(),
383 None => 0,
384 };
385
386 let stable_lbl = tr(locale, MessageId::CtxInspStablePrefix);
387 let volatile_lbl = tr(locale, MessageId::CtxInspVolatileWorkingSet);
388 let first_line_lbl = tr(locale, MessageId::CtxInspFirstLine);
389 let total_lbl = tr(locale, MessageId::CtxInspTotal);
390 let text_prompt_lbl = tr(locale, MessageId::CtxInspTextPromptLayers);
391 let single_blob_lbl = tr(locale, MessageId::CtxInspSingleTextBlob);
392 let blocks_unit = tr(locale, MessageId::CtxInspBlocks);
393 let block_unit = tr(locale, MessageId::CtxInspBlock);
394 let tokens_unit = tr(locale, MessageId::CtxInspTokens);
395 let layers_unit = tr(locale, MessageId::CtxInspLayers);
396 let none_lbl = tr(locale, MessageId::CtxInspNone);
397 let empty_lbl = tr(locale, MessageId::CtxInspEmpty);
398 let cache_friendly = tr(locale, MessageId::CtxInspCacheFriendly);
399 let changes_by_turn = tr(locale, MessageId::CtxInspChangesByTurn);
400 let stable_only = tr(locale, MessageId::CtxInspStablePrefixOnly);
401 let no_system_prompt = tr(locale, MessageId::CtxInspNoSystemPrompt);
402 match &app.system_prompt {
403 Some(SystemPrompt::Blocks(blocks)) => {
404 let working_set_idx = blocks
405 .iter()
406 .position(|b| b.text.contains(WORKING_SET_MARKER));
407 let (stable_count, working_block) = match working_set_idx {
408 Some(idx) => (idx, Some(&blocks[idx])),
409 None => (blocks.len(), None),
410 };
411
412 let stable_tokens: usize = blocks
413 .iter()
414 .take(stable_count)
415 .map(|b| text_tokens(&b.text))
416 .sum();
417 let working_tokens = working_block.map(|b| text_tokens(&b.text)).unwrap_or(0);
418
419 let _ = writeln!(
420 out,
421 " {stable_lbl}: {stable_count} {blocks_unit}, ~{stable_tokens} {tokens_unit} [{cache_friendly}]"
422 );
423 if let Some(block) = working_block {
424 let _ = writeln!(
425 out,
426 " {volatile_lbl}: 1 {block_unit}, ~{working_tokens} {tokens_unit} [{changes_by_turn}]"
427 );
428 let _ = writeln!(
429 out,
430 " {first_line_lbl}: {}",
431 block.text.lines().next().unwrap_or(&*empty_lbl)
432 );
433 } else {
434 let _ = writeln!(out, " {volatile_lbl}: {none_lbl}");
435 }
436 let _ = writeln!(
437 out,
438 " {total_lbl}: {} {blocks_unit}, ~{total_est} {tokens_unit}",
439 blocks.len()
440 );
441 let layers = blocks
442 .iter()
443 .flat_map(|block| split_text_prompt_layers(&block.text))
444 .filter(|layer| !layer.body.is_empty())
445 .collect::<Vec<_>>();
446 if layers.iter().any(|layer| layer.name != "System prompt") {
447 let _ = writeln!(out, " {text_prompt_lbl}:");
448 for layer in layers {
449 let tokens = text_tokens(layer.body);
450 let kind_lbl = layer.kind.label(locale);
451 let layer_name = layer_display_name(layer.name, locale);
452 let _ = writeln!(
453 out,
454 " - {layer_name}: ~{tokens} {tokens_unit} [{kind_lbl}]",
455 );
456 }
457 }
458 }
459 Some(SystemPrompt::Text(text)) => {
460 let layers = split_text_prompt_layers(text);
461 if layers.len() > 1
462 || layers
463 .first()
464 .is_some_and(|layer| layer.name != "System prompt")
465 {
466 let _ = writeln!(
467 out,
468 " {text_prompt_lbl}: {} {layers_unit}, ~{total_est} {tokens_unit}",
469 layers.len()
470 );
471 for layer in layers {
472 let tokens = text_tokens(layer.body);
473 let kind_lbl = layer.kind.label(locale);
474 let layer_name = layer_display_name(layer.name, locale);
475 let _ = writeln!(
476 out,
477 " - {layer_name}: ~{tokens} {tokens_unit} [{kind_lbl}]",
478 );
479 }
480 } else {
481 let _ = writeln!(
482 out,
483 " {single_blob_lbl} (~{total_est} {tokens_unit}) [{stable_only}]"
484 );
485 }
486 }
487 None => {
488 let _ = writeln!(out, " {no_system_prompt}");
489 }
490 }
491
492 // Cache-economics hint
493 let _ = writeln!(out, " {}", tr(locale, MessageId::CtxInspCacheTip));
494 }
495
496 fn split_text_prompt_layers(text: &str) -> Vec<PromptTextLayer<'_>> {
497 let mut starts = SYSTEM_LAYER_MARKERS
498 .iter()
499 .filter_map(|(name, marker, kind)| text.find(marker).map(|idx| (idx, *name, *kind)))
500 .collect::<Vec<_>>();
501 starts.sort_by_key(|(idx, _, _)| *idx);
502
503 let Some((first_idx, _, _)) = starts.first().copied() else {
504 return vec![PromptTextLayer {
505 name: "System prompt",
506 kind: PromptLayerKind::Static,
507 body: text.trim(),
508 }];
509 };
510
511 let mut layers = Vec::new();
512 if first_idx > 0 {
513 layers.push(PromptTextLayer {
514 name: "Global system prefix",
515 kind: PromptLayerKind::Static,
516 body: text[..first_idx].trim(),
517 });
518 }
519
520 for (i, (start, name, kind)) in starts.iter().enumerate() {
521 let end = starts.get(i + 1).map_or(text.len(), |(idx, _, _)| *idx);
522 layers.push(PromptTextLayer {
523 name,
524 kind: *kind,
525 body: text[*start..end].trim(),
526 });
527 }
528
529 layers
530 }
531
532 fn push_references(out: &mut String, references: &[SessionContextReference], locale: Locale) {
533 let _ = writeln!(out, "{}", tr(locale, MessageId::CtxInspReferences));
534 let _ = writeln!(out, "----------");
535
536 let mut seen = HashSet::new();
537 let mut rendered = 0usize;
538 for record in references {
539 let reference = &record.reference;
540 let key = format!(
541 "{:?}:{:?}:{}:{}",
542 reference.source, reference.kind, reference.target, reference.label
543 );
544 if !seen.insert(key) {
545 continue;
546 }
547 if rendered >= MAX_REFERENCE_ROWS {
548 let remaining = references.len().saturating_sub(rendered);
549 if remaining > 0 {
550 let _ = writeln!(
551 out,
552 "- ... {remaining} {}",
553 tr(locale, MessageId::CtxInspMoreReferences)
554 );
555 }
556 break;
557 }
558
559 let prefix = match reference.source {
560 ContextReferenceSource::AtMention => "@",
561 ContextReferenceSource::Attachment => "/attach ",
562 };
563 let state = if reference.included {
564 if reference.expanded {
565 tr(locale, MessageId::CtxInspIncluded)
566 } else {
567 tr(locale, MessageId::CtxInspAttached)
568 }
569 } else {
570 tr(locale, MessageId::CtxInspNotIncluded)
571 };
572 let detail = reference
573 .detail
574 .as_deref()
575 .filter(|detail| !detail.trim().is_empty())
576 .map(|detail| format!(" - {detail}"))
577 .unwrap_or_default();
578 let _ = writeln!(
579 out,
580 "- [{}] {prefix}{} -> {} ({state}{detail})",
581 reference.badge, reference.label, reference.target
582 );
583 rendered += 1;
584 }
585
586 if rendered == 0 {
587 let _ = writeln!(out, "- {}", tr(locale, MessageId::CtxInspNoReferences));
588 }
589 }
590
591 fn push_tools(out: &mut String, app: &App, locale: Locale) {
592 let _ = writeln!(out, "{}", tr(locale, MessageId::CtxInspRecentTools));
593 let _ = writeln!(out, "------------");
594
595 let mut rows: Vec<(usize, &ToolDetailRecord)> = app
596 .tool_details_by_cell
597 .iter()
598 .map(|(idx, detail)| (*idx, detail))
599 .collect();
600 rows.sort_by_key(|(idx, _)| std::cmp::Reverse(*idx));
601
602 let mut rendered = 0usize;
603 for detail in app.active_tool_details.values() {
604 let location = tr(locale, MessageId::CtxInspActive);
605 push_tool_row(out, locale, &location, detail);
606 rendered += 1;
607 if rendered >= MAX_TOOL_ROWS {
608 return;
609 }
610 }
611 for (cell_idx, detail) in rows
612 .into_iter()
613 .take(MAX_TOOL_ROWS.saturating_sub(rendered))
614 {
615 let location = format!("{} {cell_idx}", tr(locale, MessageId::CtxInspCell));
616 push_tool_row(out, locale, &location, detail);
617 rendered += 1;
618 }
619
620 if rendered == 0 {
621 let _ = writeln!(out, "- {}", tr(locale, MessageId::CtxInspNoToolActivity));
622 } else {
623 let details = crate::tui::shell_key_routing::display_chord(
624 crate::tui::shell_key_routing::binding(
625 crate::tui::shell_key_routing::ShellBindingId::ToolDetails,
626 )
627 .footer_chord,
628 );
629 let _ = writeln!(
630 out,
631 "- {}",
632 tr(locale, MessageId::CtxInspVHint).replace("{details}", details.as_ref())
633 );
634 }
635 }
636
637 fn push_tool_row(out: &mut String, locale: Locale, location: &str, detail: &ToolDetailRecord) {
638 let output_state = if detail.output.as_deref().is_some_and(|out| !out.is_empty()) {
639 tr(locale, MessageId::CtxInspOutputCaptured)
640 } else {
641 tr(locale, MessageId::CtxInspNoOutputYet)
642 };
643 let _ = writeln!(
644 out,
645 "- [{}] {} {} ({output_state})",
646 location,
647 detail.tool_name,
648 short_tool_id(&detail.tool_id)
649 );
650 }
651
652 fn tool_schema_tokens(tool: &Tool) -> usize {
653 serde_json::to_string(tool)
654 .map(|schema| schema.chars().count().div_ceil(SCHEMA_TOKEN_DIVISOR))
655 .unwrap_or_default()
656 }
657
658 fn push_tool_schema_costs(out: &mut String, app: &App, locale: Locale) {
659 let Some(catalog) = app.session.last_tool_catalog.as_ref() else {
660 return;
661 };
662
663 let _ = writeln!(out);
664 let tokens = tr(locale, MessageId::CtxInspTokens);
665 let schema_costs_label = tr(locale, MessageId::CtxInspToolSchemaCosts);
666 let _ = writeln!(out, "{} ({})", schema_costs_label, tokens);
667 let _ = writeln!(out, "------------");
668
669 let mut built_in: Vec<(String, usize)> = catalog
670 .iter()
671 .filter(|tool| !crate::mcp::McpPool::is_mcp_tool(&tool.name))
672 .map(|tool| (tool.name.clone(), tool_schema_tokens(tool)))
673 .collect();
674 built_in.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0)));
675 let built_in_total: usize = built_in.iter().map(|(_, cost)| cost).sum();
676 let _ = writeln!(
677 out,
678 "- [catalog] ~{built_in_total} {tokens} ({} tools)",
679 built_in.len()
680 );
681 for (name, cost) in built_in.iter().take(MAX_SCHEMA_COST_ROWS) {
682 let _ = writeln!(out, " - {name}: ~{cost} {tokens}");
683 }
684 if built_in.len() > MAX_SCHEMA_COST_ROWS {
685 let _ = writeln!(
686 out,
687 " - ... {} more catalog tools",
688 built_in.len() - MAX_SCHEMA_COST_ROWS
689 );
690 }
691
692 let Some(snapshot) = app.mcp_snapshot.as_ref() else {
693 return;
694 };
695 for server in &snapshot.servers {
696 let mut server_tokens = 0usize;
697 let mut catalog_tools = 0usize;
698 for announced in &server.tools {
699 if let Some(tool) = catalog
700 .iter()
701 .find(|tool| tool.name == announced.model_name)
702 {
703 server_tokens += tool_schema_tokens(tool);
704 catalog_tools += 1;
705 }
706 }
707 let _ = writeln!(
708 out,
709 "- [mcp:{}] ~{server_tokens} {tokens} ({catalog_tools}/{} announced tools)",
710 server.name,
711 server.tools.len()
712 );
713 }
714 }
715
716 fn short_tool_id(id: &str) -> String {
717 // Slice by characters, not bytes: a tool id from a gateway can contain
718 // multibyte characters, and `&id[..8]` panics on a byte index that lands
719 // mid-codepoint (2026-08-04 review).
720 let mut chars = id.chars();
721 let head: String = chars.by_ref().take(8).collect();
722 if chars.next().is_some() {
723 format!("{head}...")
724 } else {
725 head
726 }
727 }
728
729 #[derive(Debug, Clone)]
730 struct ContextBucket {
731 label: String,
732 tokens: usize,
733 percent: f64,
734 detail: String,
735 }
736
737 /// Live context surface. The host refreshes its snapshot immediately before
738 /// every render, so opening it never freezes the underlying session facts.
739 pub(crate) struct ContextInspectorView {
740 used: usize,
741 max: u32,
742 percent: f64,
743 model: String,
744 workspace: String,
745 threshold: f64,
746 rows: Vec<ContextBucket>,
747 selected: usize,
748 hitboxes: RefCell<Vec<(u16, usize)>>,
749 locale: Locale,
750 }
751
752 impl ContextInspectorView {
753 #[must_use]
754 pub(crate) fn new(app: &App) -> Self {
755 let mut view = Self {
756 used: 0,
757 max: 0,
758 percent: 0.0,
759 model: String::new(),
760 workspace: String::new(),
761 threshold: 0.0,
762 rows: Vec::new(),
763 selected: 0,
764 hitboxes: RefCell::new(Vec::new()),
765 locale: app.ui_locale,
766 };
767 view.refresh_from_app(app);
768 view
769 }
770
771 pub(crate) fn refresh_from_app(&mut self, app: &App) {
772 let (used, max, percent) = context_usage(app);
773 let system_tokens = estimate_input_tokens_for_pressure(&[], app.system_prompt.as_ref());
774 let message_tokens = used.saturating_sub(system_tokens);
775 let free_tokens = usize::try_from(max)
776 .unwrap_or(usize::MAX)
777 .saturating_sub(used);
778 let full_detail = build_context_inspector_text(app, app.ui_locale);
779 self.used = used;
780 self.max = max;
781 self.percent = percent;
782 self.model = app.model_display_label();
783 self.workspace = crate::utils::display_path(&app.workspace);
784 self.threshold = app.auto_compact_threshold_percent;
785 self.locale = app.ui_locale;
786 let max_f = f64::from(max.max(1));
787 let (compaction_detail, compaction_tokens) = compaction_detail_for_app(app, self.locale);
788 let (anchors_detail, anchors_tokens) = anchors_detail_for_app(app, self.locale);
789 self.rows = vec![
790 ContextBucket {
791 label: tr(self.locale, MessageId::CtxInspRowSystemPrompt).into_owned(),
792 tokens: system_tokens,
793 percent: (system_tokens as f64 / max_f) * 100.0,
794 detail: full_detail.clone(),
795 },
796 ContextBucket {
797 label: tr(self.locale, MessageId::CtxInspRowMessages).into_owned(),
798 tokens: message_tokens,
799 percent: (message_tokens as f64 / max_f) * 100.0,
800 detail: full_detail,
801 },
802 ContextBucket {
803 label: tr(self.locale, MessageId::CtxInspRowFree).into_owned(),
804 tokens: free_tokens,
805 percent: (free_tokens as f64 / max_f) * 100.0,
806 detail: tr(self.locale, MessageId::CtxInspFreeTokensDetail)
807 .replace("{free}", &free_tokens.to_string())
808 .replace("{threshold}", &format!("{:.0}", self.threshold)),
809 },
810 ContextBucket {
811 label: tr(self.locale, MessageId::CtxInspRowCompaction).into_owned(),
812 tokens: compaction_tokens,
813 percent: (compaction_tokens as f64 / max_f) * 100.0,
814 detail: compaction_detail,
815 },
816 ContextBucket {
817 label: tr(self.locale, MessageId::CtxInspRowAnchors).into_owned(),
818 tokens: anchors_tokens,
819 percent: (anchors_tokens as f64 / max_f) * 100.0,
820 detail: anchors_detail,
821 },
822 ];
823 self.selected = self.selected.min(self.rows.len().saturating_sub(1));
824 }
825
826 #[cfg(test)]
827 fn row_labels(&self) -> Vec<String> {
828 self.rows.iter().map(|row| row.label.clone()).collect()
829 }
830
831 fn move_selection(&mut self, delta: isize) {
832 if self.rows.is_empty() {
833 return;
834 }
835 self.selected = if delta.is_negative() {
836 self.selected.saturating_sub(delta.unsigned_abs())
837 } else {
838 (self.selected + delta as usize).min(self.rows.len() - 1)
839 };
840 }
841
842 fn open_selected(&self) -> ViewAction {
843 let Some(row) = self.rows.get(self.selected) else {
844 return ViewAction::None;
845 };
846 ViewAction::Emit(ViewEvent::OpenTextPager {
847 title: tr(self.locale, MessageId::CtxInspDrillTitle).replace("{row}", &row.label),
848 content: row.detail.clone(),
849 })
850 }
851 }
852
853 impl ModalView for ContextInspectorView {
854 fn kind(&self) -> ModalKind {
855 ModalKind::ContextInspector
856 }
857
858 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
859 self
860 }
861
862 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
863 match key.code {
864 KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close,
865 KeyCode::Up | KeyCode::Char('k') => {
866 self.move_selection(-1);
867 ViewAction::None
868 }
869 KeyCode::Down | KeyCode::Char('j') => {
870 self.move_selection(1);
871 ViewAction::None
872 }
873 KeyCode::Enter => self.open_selected(),
874 _ => ViewAction::None,
875 }
876 }
877
878 fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
879 match mouse.kind {
880 MouseEventKind::ScrollUp => {
881 self.move_selection(-1);
882 ViewAction::None
883 }
884 MouseEventKind::ScrollDown => {
885 self.move_selection(1);
886 ViewAction::None
887 }
888 MouseEventKind::Down(MouseButton::Left) => {
889 let hit = self
890 .hitboxes
891 .borrow()
892 .iter()
893 .find_map(|(y, idx)| (*y == mouse.row).then_some(*idx));
894 let Some(idx) = hit else {
895 return ViewAction::None;
896 };
897 if idx == self.selected {
898 self.open_selected()
899 } else {
900 self.selected = idx;
901 ViewAction::None
902 }
903 }
904 _ => ViewAction::None,
905 }
906 }
907
908 fn render(&self, area: Rect, buf: &mut Buffer) {
909 let inner =
910 render_underwater_surface(area, buf, tr(self.locale, MessageId::CtxInspSurfaceTitle));
911 let content = render_modal_footer(
912 inner,
913 buf,
914 &[
915 ActionHint::new("↑/↓", tr(self.locale, MessageId::CtxInspActionSelect)),
916 ActionHint::new("Enter", tr(self.locale, MessageId::CtxInspActionDrillDown)),
917 ActionHint::new("Esc", tr(self.locale, MessageId::CtxInspActionClose)),
918 ],
919 );
920 let width = usize::from(content.width);
921 let mut lines = vec![
922 Line::from(vec![
923 Span::styled(
924 tr(self.locale, MessageId::CtxInspUsedTokens)
925 .replace("{used}", &self.used.to_string())
926 .replace("{max}", &self.max.to_string()),
927 Style::default()
928 .fg(palette::WHALE_ACTION)
929 .add_modifier(Modifier::BOLD),
930 ),
931 Span::styled(
932 format!(" · {:.1}% · {}", self.percent, self.model),
933 Style::default().fg(palette::TEXT_MUTED),
934 ),
935 ]),
936 Line::from(Span::styled(
937 crate::tui::ui_text::semantic_truncate(&self.workspace, width),
938 Style::default().fg(palette::TEXT_DIM),
939 )),
940 Line::from(""),
941 ];
942
943 if content.height >= 11 && content.width >= 24 {
944 let cells = usize::from(content.width.saturating_sub(2)).min(60);
945 let system_cells = ((self.rows[0].percent / 100.0) * cells as f64).round() as usize;
946 let message_cells = ((self.rows[1].percent / 100.0) * cells as f64).round() as usize;
947 let system_cells = system_cells.min(cells);
948 let message_cells = message_cells.min(cells.saturating_sub(system_cells));
949 let free_cells = cells.saturating_sub(system_cells + message_cells);
950 lines.push(Line::from(vec![
951 Span::styled(
952 "#".repeat(system_cells),
953 Style::default().fg(palette::WHALE_ACTION),
954 ),
955 Span::styled(
956 "=".repeat(message_cells),
957 Style::default().fg(palette::TEXT_PRIMARY),
958 ),
959 Span::styled(
960 ".".repeat(free_cells),
961 Style::default().fg(palette::TEXT_DIM),
962 ),
963 ]));
964 lines.push(Line::from(Span::styled(
965 tr(self.locale, MessageId::CtxInspAutoCompactAt)
966 .replace("{threshold}", &format!("{:.0}", self.threshold)),
967 Style::default().fg(palette::TEXT_HINT),
968 )));
969 lines.push(Line::from(""));
970 }
971
972 self.hitboxes.borrow_mut().clear();
973 for (idx, row) in self.rows.iter().enumerate() {
974 let selected = idx == self.selected;
975 let marker = crate::tui::glyphs::selection_marker(selected);
976 let style = if selected {
977 menu_style::selected_row_style()
978 } else {
979 Style::default().fg(palette::TEXT_PRIMARY)
980 };
981 let value = tr(self.locale, MessageId::CtxInspRowTokens)
982 .replace("{tokens}", &row.tokens.to_string())
983 .replace("{percent}", &format!("{:.1}", row.percent));
984 let label_width = width.saturating_sub(value.len() + 5);
985 let label = crate::tui::ui_text::semantic_truncate(&row.label, label_width);
986 let gap = width.saturating_sub(label.len() + value.len() + 3);
987 let y = content
988 .y
989 .saturating_add(u16::try_from(lines.len()).unwrap_or(u16::MAX));
990 self.hitboxes.borrow_mut().push((y, idx));
991 lines.push(Line::from(Span::styled(
992 format!("{marker} {label}{}{value}", " ".repeat(gap)),
993 style,
994 )));
995 }
996 Paragraph::new(lines).render(content, buf);
997 }
998 }
999
1000 #[cfg(test)]
1001 mod tests {
1002 use super::*;
1003 use crate::config::Config;
1004 use codewhale_models::Role;
1005
1006 #[test]
1007 fn short_tool_id_never_panics_on_multibyte() {
1008 // ASCII short/long behave as before.
1009 assert_eq!(short_tool_id("abc"), "abc");
1010 assert_eq!(short_tool_id("0123456789"), "01234567...");
1011 // A multibyte id must truncate on a char boundary, not panic.
1012 // 10 chars → first 8 kept, then the ellipsis.
1013 assert_eq!(short_tool_id("日本語のツールid名"), "日本語のツールi...");
1014 assert_eq!(short_tool_id("café"), "café");
1015 }
1016
1017 use crate::mcp::{McpDiscoveredItem, McpManagerSnapshot, McpServerSnapshot};
1018 use crate::session_manager::SessionContextReference;
1019 use crate::tui::app::TuiOptions;
1020 use crate::tui::history::HistoryCell;
1021 use codewhale_core::{ContextReference, ContextReferenceKind, ContextReferenceSource};
1022 use codewhale_models::{ContentBlock, Message, Tool};
1023 use std::path::PathBuf;
1024
1025 use codewhale_localization::Locale;
1026
1027 fn test_app() -> App {
1028 let mut app = App::new(
1029 TuiOptions {
1030 model: "unknown-model".to_string(),
1031 skills_dir: PathBuf::from("/tmp/skills"),
1032 notes_path: PathBuf::from("notes.md"),
1033 ..crate::test_support::test_tui_options(PathBuf::from("/tmp/project"))
1034 },
1035 &Config::default(),
1036 );
1037 // Pin the route identity: App::new consults the developer's real
1038 // saved settings, so on a machine with customized provider/model
1039 // the context-window assertions computed against a different route.
1040 app.api_provider = crate::config::ApiProvider::Deepseek;
1041 app.auto_model = false;
1042 app.last_effective_model = None;
1043 app.active_route_limits = None;
1044 app.active_context_window_override = None;
1045 app
1046 }
1047
1048 #[test]
1049 fn inspector_reports_the_provider_cache_hit_rate() {
1050 let mut app = test_app();
1051 for (input, hit) in [(1_000u32, 800u32), (1_000, 500)] {
1052 app.session
1053 .turn_cache_history
1054 .push_back(crate::tui::app::TurnCacheRecord {
1055 provider: None,
1056 provider_identity: None,
1057 model: None,
1058 auto_model: false,
1059 input_tokens: input,
1060 output_tokens: 0,
1061 cache_hit_tokens: Some(hit),
1062 cache_miss_tokens: None,
1063 reasoning_replay_tokens: None,
1064 cache_write_tokens: None,
1065 reasoning_tokens: None,
1066 cost_audit: None,
1067 recorded_at: std::time::Instant::now(),
1068 });
1069 }
1070 let text = build_context_inspector_text(&app, Locale::En);
1071 assert!(
1072 text.contains("Provider cache hit rate: 65.0% over 2 cache-aware turns"),
1073 "{text}"
1074 );
1075
1076 let empty = build_context_inspector_text(&test_app(), Locale::En);
1077 assert!(empty.contains("no cache telemetry yet"), "{empty}");
1078 }
1079
1080 #[test]
1081 fn inspector_formats_empty_state() {
1082 let app = test_app();
1083 let text = build_context_inspector_text(&app, Locale::En);
1084 assert!(text.contains("Session Context"));
1085 assert!(text.contains("No file, folder, or media references yet."));
1086 assert!(text.contains("No tool activity yet."));
1087 }
1088
1089 fn schema_tool(name: &str, property_count: usize) -> Tool {
1090 let properties = (0..property_count)
1091 .map(|index| {
1092 (
1093 format!("field_{index}"),
1094 serde_json::json!({"type": "string"}),
1095 )
1096 })
1097 .collect::<serde_json::Map<_, _>>();
1098 Tool {
1099 tool_type: Some("function".to_string()),
1100 name: name.to_string(),
1101 description: format!("schema for {name}"),
1102 input_schema: serde_json::json!({
1103 "type": "object",
1104 "properties": properties,
1105 }),
1106 allowed_callers: None,
1107 defer_loading: None,
1108 input_examples: None,
1109 strict: None,
1110 cache_control: None,
1111 }
1112 }
1113
1114 #[test]
1115 fn inspector_reports_catalog_and_mcp_schema_costs_with_bounded_rows() {
1116 let mut app = test_app();
1117 let mut catalog = (0..(MAX_SCHEMA_COST_ROWS + 2))
1118 .map(|index| schema_tool(&format!("tool_{index}"), index + 1))
1119 .collect::<Vec<_>>();
1120 catalog.push(schema_tool("mcp_local_echo", 3));
1121 app.session.last_tool_catalog = Some(catalog);
1122 app.mcp_snapshot = Some(McpManagerSnapshot {
1123 config_path: PathBuf::from("/tmp/mcp.json"),
1124 config_exists: true,
1125 reload_required: false,
1126 servers: vec![
1127 McpServerSnapshot {
1128 name: "local".to_string(),
1129 enabled: true,
1130 required: false,
1131 transport: "stdio".to_string(),
1132 command_or_url: "echo".to_string(),
1133 connect_timeout: 1,
1134 execute_timeout: 1,
1135 read_timeout: 1,
1136 connected: true,
1137 error: None,
1138 auth_required: false,
1139 capability_metadata: Default::default(),
1140 tools: vec![McpDiscoveredItem {
1141 name: "echo".to_string(),
1142 model_name: "mcp_local_echo".to_string(),
1143 description: Some("echoes input".to_string()),
1144 }],
1145 resources: Vec::new(),
1146 prompts: Vec::new(),
1147 },
1148 McpServerSnapshot {
1149 name: "empty".to_string(),
1150 enabled: true,
1151 required: false,
1152 transport: "stdio".to_string(),
1153 command_or_url: "empty".to_string(),
1154 connect_timeout: 1,
1155 execute_timeout: 1,
1156 read_timeout: 1,
1157 connected: true,
1158 error: None,
1159 auth_required: false,
1160 capability_metadata: Default::default(),
1161 tools: Vec::new(),
1162 resources: Vec::new(),
1163 prompts: Vec::new(),
1164 },
1165 ],
1166 });
1167
1168 let text = build_context_inspector_text(&app, Locale::En);
1169 assert_eq!(
1170 text.matches("Recent Tools").count(),
1171 1,
1172 "schema costs need a distinct section heading: {text}"
1173 );
1174 assert!(
1175 text.contains("\n\nTool schema costs (tokens)\n------------"),
1176 "schema costs need a separated localized section: {text}"
1177 );
1178 assert!(text.contains("[catalog]"), "catalog total missing: {text}");
1179 assert!(text.contains("tool_25"), "catalog row missing: {text}");
1180 assert!(
1181 text.contains("more catalog tools"),
1182 "catalog bound missing: {text}"
1183 );
1184 assert!(
1185 text.contains("[mcp:local]"),
1186 "MCP server row missing: {text}"
1187 );
1188 assert!(
1189 text.contains("1/1 announced tools"),
1190 "MCP tool cost missing: {text}"
1191 );
1192 assert!(
1193 text.contains("[mcp:empty] ~0"),
1194 "empty MCP row missing: {text}"
1195 );
1196 }
1197
1198 #[test]
1199 fn inspector_uses_compact_session_id() {
1200 let mut app = test_app();
1201 app.current_session_id = Some("1234567890abcdef".to_string());
1202
1203 let text = build_context_inspector_text(&app, Locale::En);
1204
1205 assert!(text.contains("Session: 12345678"), "{text}");
1206 assert!(!text.contains("1234567890abcdef"), "{text}");
1207 }
1208
1209 #[test]
1210 fn inspector_lists_context_references() {
1211 let mut app = test_app();
1212 app.history.push(HistoryCell::User {
1213 content: "read @src/main.rs".to_string(),
1214 });
1215 app.session_context_references
1216 .push(SessionContextReference {
1217 message_index: 0,
1218 reference: ContextReference {
1219 kind: ContextReferenceKind::File,
1220 source: ContextReferenceSource::AtMention,
1221 badge: "file".to_string(),
1222 label: "src/main.rs".to_string(),
1223 target: "/tmp/project/src/main.rs".to_string(),
1224 included: true,
1225 expanded: true,
1226 detail: Some("included".to_string()),
1227 },
1228 });
1229
1230 let text = build_context_inspector_text(&app, Locale::En);
1231 assert!(text.contains("[file] @src/main.rs -> /tmp/project/src/main.rs"));
1232 }
1233
1234 #[test]
1235 fn inspector_marks_high_context_pressure() {
1236 let mut app = test_app();
1237 app.api_messages_mut().push(Message {
1238 role: Role::User,
1239 content: vec![ContentBlock::Text {
1240 text: "x".repeat(4_000_000),
1241 cache_control: None,
1242 }],
1243 });
1244
1245 let text = build_context_inspector_text(&app, Locale::En);
1246 assert!(text.contains("Context: critical"), "{text}");
1247 }
1248
1249 #[test]
1250 fn inspector_uses_effective_auto_model_context_window() {
1251 let mut app = test_app();
1252 app.model = "auto".to_string();
1253 app.auto_model = true;
1254 app.last_effective_model = Some("deepseek-v4-pro".to_string());
1255
1256 let text = build_context_inspector_text(&app, Locale::En);
1257 assert!(text.contains("Model: auto"), "{text}");
1258 assert!(text.contains("/1000000 tokens"), "{text}");
1259 }
1260
1261 #[test]
1262 fn inspector_no_system_prompt_shows_section() {
1263 let app = test_app();
1264 let text = build_context_inspector_text(&app, Locale::En);
1265 assert!(text.contains("System Prompt Structure"));
1266 assert!(text.contains("No system prompt set."));
1267 }
1268
1269 #[test]
1270 fn inspector_blocks_format_shows_stable_prefix_and_working_set() {
1271 let mut app = test_app();
1272 use codewhale_models::SystemBlock;
1273 app.system_prompt = Some(SystemPrompt::Blocks(vec![
1274 SystemBlock {
1275 block_type: "text".to_string(),
1276 text: "## Stable Base\n\nYou are CodeWhale.".to_string(),
1277 cache_control: None,
1278 },
1279 SystemBlock {
1280 block_type: "text".to_string(),
1281 text: format!("{WORKING_SET_MARKER}\nsrc/main.rs changed"),
1282 cache_control: None,
1283 },
1284 ]));
1285
1286 let text = build_context_inspector_text(&app, Locale::En);
1287 assert!(text.contains("System Prompt Structure"));
1288 assert!(
1289 text.contains("Stable prefix: 1 block"),
1290 "stable prefix count: {text}"
1291 );
1292 assert!(
1293 text.contains("Volatile working set: 1 block"),
1294 "working set section: {text}"
1295 );
1296 assert!(
1297 text.contains("[cache-friendly]"),
1298 "cache hint for stable: {text}"
1299 );
1300 assert!(
1301 text.contains("[changes by session/turn]"),
1302 "volatile marker: {text}"
1303 );
1304 assert!(
1305 text.contains("First line: ## Repo Working Set"),
1306 "first line of working set: {text}"
1307 );
1308 }
1309
1310 #[test]
1311 fn inspector_blocks_without_working_set_shows_stable_only() {
1312 let mut app = test_app();
1313 use codewhale_models::SystemBlock;
1314 app.system_prompt = Some(SystemPrompt::Blocks(vec![
1315 SystemBlock {
1316 block_type: "text".to_string(),
1317 text: "## Stable Base".to_string(),
1318 cache_control: None,
1319 },
1320 SystemBlock {
1321 block_type: "text".to_string(),
1322 text: "## Personality\nCalm".to_string(),
1323 cache_control: None,
1324 },
1325 ]));
1326
1327 let text = build_context_inspector_text(&app, Locale::En);
1328 assert!(text.contains("Stable prefix: 2 block(s)"));
1329 assert!(text.contains("Volatile working set: none"));
1330 }
1331
1332 #[test]
1333 fn inspector_text_prompt_shows_layer_map() {
1334 let mut app = test_app();
1335 app.system_prompt = Some(SystemPrompt::Text(
1336 "## 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(),
1337 ));
1338
1339 let text = build_context_inspector_text(&app, Locale::En);
1340 assert!(text.contains("System Prompt Structure"));
1341 assert!(text.contains("Text prompt layers"));
1342 assert!(text.contains("Bundled constitution"));
1343 assert!(text.contains("Language policy"));
1344 assert!(text.contains("Output formatting"));
1345 assert!(text.contains("User-global constitution"));
1346 assert!(text.contains("Repository constitution"));
1347 assert!(text.contains("Project context"));
1348 assert!(text.contains("Project context pack"));
1349 assert!(text.contains("Environment"));
1350 assert!(text.contains("Skills"));
1351 assert!(text.contains("Core execution"));
1352 assert!(text.contains("Compact template"));
1353 assert!(text.contains("Volatile working set"));
1354 assert!(text.contains("changes by session/turn"));
1355 }
1356
1357 #[test]
1358 fn inspector_text_prompt_without_markers_shows_single_blob() {
1359 let mut app = test_app();
1360 app.system_prompt = Some(SystemPrompt::Text("You are CodeWhale.".to_string()));
1361
1362 let text = build_context_inspector_text(&app, Locale::En);
1363 assert!(text.contains("Single text blob"));
1364 assert!(text.contains("stable prefix only"));
1365 }
1366
1367 #[test]
1368 fn inspector_localizes_to_zh_hans() {
1369 use codewhale_models::SystemBlock;
1370 let mut app = test_app();
1371 app.system_prompt = Some(SystemPrompt::Blocks(vec![
1372 SystemBlock {
1373 block_type: "text".to_string(),
1374 text: "## Base\nYou are CodeWhale.".to_string(),
1375 cache_control: None,
1376 },
1377 SystemBlock {
1378 block_type: "text".to_string(),
1379 text: format!("{WORKING_SET_MARKER}\nsrc/main.rs changed"),
1380 cache_control: None,
1381 },
1382 ]));
1383 let text = build_context_inspector_text(&app, Locale::ZhHans);
1384
1385 // Positive: key ZhHans labels present
1386 assert!(text.contains("会话上下文"), "session header: {text}");
1387 assert!(text.contains("模型"), "model label: {text}");
1388 assert!(text.contains("工作区"), "workspace: {text}");
1389 assert!(text.contains("系统提示结构"), "sysprompt section: {text}");
1390 assert!(text.contains("稳定前缀"), "stable prefix: {text}");
1391 assert!(text.contains("易变工作集"), "volatile ws: {text}");
1392 assert!(text.contains("第一行"), "first line: {text}");
1393 assert!(text.contains("总计"), "total line: {text}");
1394 assert!(text.contains("引用"), "references: {text}");
1395 assert!(text.contains("最近使用的工具"), "tools: {text}");
1396 assert!(text.contains("个区块"), "blocks unit: {text}");
1397 assert!(text.contains("个 token"), "tokens unit: {text}");
1398 assert!(text.contains("缓存友好"), "cache-friendly: {text}");
1399 assert!(text.contains("提示"), "cache tip: {text}");
1400
1401 // Negative: no English labels leak
1402 assert!(!text.contains("Session Context"), "EN session leaked");
1403 assert!(!text.contains("Model:"), "EN model leaked");
1404 assert!(!text.contains("cells"), "EN cells leaked");
1405 assert!(!text.contains("API messages"), "EN API msgs leaked");
1406 assert!(!text.contains("Stable prefix"), "EN stable prefix leaked");
1407 assert!(
1408 !text.contains("Volatile working set"),
1409 "EN volatile ws leaked"
1410 );
1411 assert!(!text.contains("First line"), "EN first line leaked");
1412 assert!(!text.contains("Total:"), "EN total leaked");
1413 assert!(!text.contains("Text prompt layers"), "EN layers leaked");
1414 assert!(!text.contains("cache-friendly"), "EN cache-friendly leaked");
1415 assert!(!text.contains("more reference"), "EN more refs leaked");
1416 assert!(!text.contains("no output yet"), "EN no output leaked");
1417 assert!(text.contains("压缩"), "compaction row: {text}");
1418 assert!(text.contains("锚点"), "anchors row: {text}");
1419 }
1420
1421 #[test]
1422 fn inspector_meter_matches_compaction_pressure_signal() {
1423 let mut app = test_app();
1424 app.api_messages_mut().push(Message {
1425 role: Role::User,
1426 content: vec![ContentBlock::Text {
1427 text: "x".repeat(4_000),
1428 cache_control: None,
1429 }],
1430 });
1431 app.last_billed_input_tokens = Some(12_000);
1432 let estimated =
1433 estimate_input_tokens_for_pressure(&app.api_messages, app.system_prompt.as_ref());
1434 let (used, _, _) = context_usage(&app);
1435 assert_eq!(used, estimated.max(12_000));
1436 app.last_billed_input_tokens = None;
1437 let (estimated_only, _, _) = context_usage(&app);
1438 assert_eq!(estimated_only, estimated);
1439 assert_ne!(
1440 estimated_only,
1441 crate::compaction::estimate_input_tokens_conservative(
1442 &app.api_messages,
1443 app.system_prompt.as_ref()
1444 )
1445 );
1446 }
1447
1448 #[test]
1449 fn inspector_rows_name_compaction_and_anchors() {
1450 let mut app = test_app();
1451 app.last_compaction = Some(crate::compaction::LastCompactionSnapshot {
1452 auto: true,
1453 coverage: crate::compaction::CompactionCoverage {
1454 path: crate::compaction::CompactionPath::Summary,
1455 last_round_messages: 4,
1456 last_round_tool_results: 1,
1457 last_round_assistant: true,
1458 dropped_messages: 12,
1459 anchors_chars: 0,
1460 retained_user_message_tokens: 20_000,
1461 operator_instructions_applied: false,
1462 },
1463 messages_before: 16,
1464 messages_after: 4,
1465 });
1466 let text = build_context_inspector_text(&app, Locale::En);
1467 assert!(text.contains("compaction"), "{text}");
1468 assert!(text.contains("16 → 4 messages"), "{text}");
1469 let view = ContextInspectorView::new(&app);
1470 assert!(view.row_labels().iter().any(|label| label == "compaction"));
1471 assert!(view.row_labels().iter().any(|label| label == "anchors"));
1472 }
1473 }
1474
1474 lines RUST