From 0e96928f3576a4f90f9f4bfb1427aeccb42314e0 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 28 Apr 2026 18:14:29 -0500 Subject: [PATCH] Add compact context inspector metadata (#154) --- crates/tui/src/commands/debug.rs | 55 +---- crates/tui/src/commands/mod.rs | 8 +- crates/tui/src/session_manager.rs | 46 ++++ crates/tui/src/tui/app.rs | 67 ++++++ crates/tui/src/tui/context_inspector.rs | 282 ++++++++++++++++++++++++ crates/tui/src/tui/file_mention.rs | 174 ++++++++++++--- crates/tui/src/tui/keybindings.rs | 5 + crates/tui/src/tui/mod.rs | 1 + crates/tui/src/tui/ui.rs | 105 ++++++++- crates/tui/src/tui/ui/tests.rs | 9 +- 10 files changed, 663 insertions(+), 89 deletions(-) create mode 100644 crates/tui/src/tui/context_inspector.rs diff --git a/crates/tui/src/commands/debug.rs b/crates/tui/src/commands/debug.rs index afeaf36b..fa6215ed 100644 --- a/crates/tui/src/commands/debug.rs +++ b/crates/tui/src/commands/debug.rs @@ -3,11 +3,9 @@ //! Debug commands: tokens, cost, system, context, undo, retry use super::CommandResult; -use crate::compaction::estimate_input_tokens_conservative; -use crate::models::{DEFAULT_CONTEXT_WINDOW_TOKENS, SystemPrompt, context_window_for_model}; +use crate::models::SystemPrompt; use crate::tui::app::{App, AppAction}; use crate::tui::history::HistoryCell; -use crate::utils::estimate_message_chars; /// Show token usage for session pub fn tokens(app: &mut App) -> CommandResult { @@ -76,41 +74,8 @@ pub fn system_prompt(app: &mut App) -> CommandResult { } /// Show context window usage -pub fn context(app: &mut App) -> CommandResult { - let mut total_chars = estimate_message_chars(&app.api_messages); - let estimated_tokens = - estimate_input_tokens_conservative(&app.api_messages, app.system_prompt.as_ref()); - - // System prompt - if let Some(SystemPrompt::Text(text)) = &app.system_prompt { - total_chars += text.len(); - } else if let Some(SystemPrompt::Blocks(blocks)) = &app.system_prompt { - for block in blocks { - total_chars += block.text.len(); - } - } - - let context_size = - context_window_for_model(&app.model).unwrap_or(DEFAULT_CONTEXT_WINDOW_TOKENS); - let estimated_tokens_u32 = u32::try_from(estimated_tokens).unwrap_or(u32::MAX); - let usage_pct = (f64::from(estimated_tokens_u32) / f64::from(context_size) * 100.0).min(100.0); - - CommandResult::message(format!( - "Context Usage:\n\ - ─────────────────────────────\n\ - Characters: {}\n\ - Estimated tokens: ~{}\n\ - Context window: {}\n\ - Usage: {:.1}%\n\n\ - Messages: {}\n\ - API messages: {}", - total_chars, - estimated_tokens, - context_size, - usage_pct, - app.history.len(), - app.api_messages.len(), - )) +pub fn context(_app: &mut App) -> CommandResult { + CommandResult::action(AppAction::OpenContextInspector) } #[cfg(test)] @@ -250,15 +215,11 @@ mod tests { }); let result = context(&mut app); - assert!(result.message.is_some()); - let msg = result.message.unwrap(); - assert!(msg.contains("Context Usage")); - assert!(msg.contains("Characters:")); - assert!(msg.contains("Estimated tokens:")); - assert!(msg.contains("Context window:")); - assert!(msg.contains("Usage:")); - assert!(msg.contains("Messages:")); - assert!(msg.contains("API messages:")); + assert!(matches!( + result.action, + Some(AppAction::OpenContextInspector) + )); + assert!(result.message.is_none()); } #[test] diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index c834a3da..a1091c05 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -209,6 +209,12 @@ pub const COMMANDS: &[CommandInfo] = &[ description: "Trigger context compaction to free up space (legacy; v0.6.6 prefers cycle restart)", usage: "/compact", }, + CommandInfo { + name: "context", + aliases: &["ctx"], + description: "Open compact session context inspector", + usage: "/context", + }, CommandInfo { name: "cycles", aliases: &[], @@ -408,7 +414,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { "tokens" => debug::tokens(app), "cost" => debug::cost(app), "system" => debug::system_prompt(app), - "context" => debug::context(app), + "context" | "ctx" => debug::context(app), "undo" => debug::undo(app), "retry" => debug::retry(app), diff --git a/crates/tui/src/session_manager.rs b/crates/tui/src/session_manager.rs index f853c052..9d463b8d 100644 --- a/crates/tui/src/session_manager.rs +++ b/crates/tui/src/session_manager.rs @@ -7,6 +7,7 @@ //! - Managing session lifecycle use crate::models::{ContentBlock, Message, SystemPrompt}; +use crate::tui::file_mention::ContextReference; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::fs; @@ -55,6 +56,13 @@ impl Default for OfflineQueueState { } } +/// Durable context-reference metadata attached to a user message. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionContextReference { + pub message_index: usize, + pub reference: ContextReference, +} + /// Session metadata stored with each saved session #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SessionMetadata { @@ -91,6 +99,10 @@ pub struct SavedSession { pub messages: Vec, /// System prompt if any pub system_prompt: Option, + /// Compact linked context references for user-visible `@path` and + /// `/attach` mentions. Optional for backward-compatible session loads. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub context_references: Vec, } /// Manager for session persistence operations @@ -443,6 +455,7 @@ pub fn create_saved_session_with_mode( }, messages: messages.to_vec(), system_prompt: system_prompt_to_string(system_prompt), + context_references: Vec::new(), } } @@ -732,6 +745,39 @@ mod tests { ); } + #[test] + fn test_session_context_references_round_trip() { + let tmp = tempdir().expect("tempdir"); + let manager = SessionManager::new(tmp.path().join("sessions")).expect("new"); + let mut session = create_saved_session( + &[make_test_message("user", "read @src/main.rs")], + "deepseek-v4-pro", + tmp.path(), + 0, + None, + ); + session.context_references.push(SessionContextReference { + message_index: 0, + reference: ContextReference { + kind: crate::tui::file_mention::ContextReferenceKind::File, + source: crate::tui::file_mention::ContextReferenceSource::AtMention, + badge: "file".to_string(), + label: "src/main.rs".to_string(), + target: tmp.path().join("src/main.rs").display().to_string(), + included: true, + expanded: true, + detail: Some("included".to_string()), + }, + }); + + let path = manager.save_session(&session).expect("save session"); + let loaded = manager + .load_session(&session.metadata.id) + .expect("load session"); + assert!(path.exists()); + assert_eq!(loaded.context_references, session.context_references); + } + #[test] fn test_checkpoint_rejects_newer_schema() { let tmp = tempdir().expect("tempdir"); diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 5ff70aee..8a3818bc 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -18,6 +18,7 @@ use crate::models::{ compaction_threshold_for_model_and_effort, }; use crate::palette::{self, UiTheme}; +use crate::session_manager::SessionContextReference; use crate::settings::Settings; use crate::tools::plan::{SharedPlanState, new_shared_plan_state}; use crate::tools::subagent::SubAgentResult; @@ -25,6 +26,7 @@ use crate::tools::todo::{SharedTodoList, new_shared_todo_list}; use crate::tui::active_cell::ActiveCell; use crate::tui::approval::ApprovalMode; use crate::tui::clipboard::{ClipboardContent, ClipboardHandler}; +use crate::tui::file_mention::ContextReference; use crate::tui::history::{HistoryCell, TranscriptRenderOptions}; use crate::tui::paste_burst::{FlushResult, PasteBurst}; use crate::tui::scrolling::{MouseScrollState, TranscriptLineMeta, TranscriptScroll}; @@ -526,6 +528,11 @@ pub struct App { pub tool_cells: HashMap, /// Full tool input/output keyed by history cell index. pub tool_details_by_cell: HashMap, + /// Linked context references keyed by the visible user history cell that + /// introduced them. + pub context_references_by_cell: HashMap>, + /// Session-wide context references persisted with saved sessions. + pub session_context_references: Vec, /// In-flight tool/exec group for the current turn. Mutated in place as /// parallel tool calls start and complete; flushed into `history` on /// `TurnComplete`. @@ -919,6 +926,8 @@ impl App { active_skill: None, tool_cells: HashMap::new(), tool_details_by_cell: HashMap::new(), + context_references_by_cell: HashMap::new(), + session_context_references: Vec::new(), active_cell: None, active_cell_revision: 0, active_tool_details: HashMap::new(), @@ -1189,6 +1198,8 @@ impl App { pub fn clear_history(&mut self) { self.history.clear(); self.history_revisions.clear(); + self.context_references_by_cell.clear(); + self.session_context_references.clear(); self.history_version = self.history_version.wrapping_add(1); self.needs_redraw = true; } @@ -1198,6 +1209,8 @@ impl App { let cell = self.history.pop(); if cell.is_some() { self.history_revisions.pop(); + self.context_references_by_cell.remove(&self.history.len()); + self.rebuild_session_context_references(); self.history_version = self.history_version.wrapping_add(1); self.needs_redraw = true; } @@ -1223,6 +1236,9 @@ impl App { // cells continue to render correctly. self.tool_cells.retain(|_, idx| *idx < new_len); self.tool_details_by_cell.retain(|idx, _| *idx < new_len); + self.context_references_by_cell + .retain(|idx, _| *idx < new_len); + self.rebuild_session_context_references(); self.subagent_card_index.retain(|_, idx| *idx < new_len); if self .last_fanout_card_index @@ -1341,6 +1357,56 @@ impl App { .find(|&idx| self.cell_has_detail_target(idx)) } + pub fn record_context_references( + &mut self, + history_cell: usize, + message_index: usize, + references: Vec, + ) { + if references.is_empty() { + return; + } + let records: Vec = references + .into_iter() + .map(|reference| SessionContextReference { + message_index, + reference, + }) + .collect(); + self.context_references_by_cell + .insert(history_cell, records.clone()); + self.rebuild_session_context_references(); + self.needs_redraw = true; + } + + pub fn sync_context_references_from_session( + &mut self, + references: &[SessionContextReference], + message_to_cell: &HashMap, + ) { + self.context_references_by_cell.clear(); + for record in references { + let Some(&cell_index) = message_to_cell.get(&record.message_index) else { + continue; + }; + self.context_references_by_cell + .entry(cell_index) + .or_default() + .push(record.clone()); + } + self.rebuild_session_context_references(); + } + + fn rebuild_session_context_references(&mut self) { + let mut records: Vec = self + .context_references_by_cell + .values() + .flat_map(|records| records.iter().cloned()) + .collect(); + records.sort_by_key(|record| record.message_index); + self.session_context_references = records; + } + /// Mutable variant of [`Self::cell_at_virtual_index`]. Bumps the /// appropriate revision counter (active-cell revision when targeting an /// in-flight entry, history version otherwise). @@ -2218,6 +2284,7 @@ pub enum AppAction { model: Option, }, UpdateCompaction(CompactionConfig), + OpenContextInspector, CompactContext, TaskAdd { prompt: String, diff --git a/crates/tui/src/tui/context_inspector.rs b/crates/tui/src/tui/context_inspector.rs new file mode 100644 index 00000000..34c99760 --- /dev/null +++ b/crates/tui/src/tui/context_inspector.rs @@ -0,0 +1,282 @@ +//! Compact session context inspector. + +use std::collections::HashSet; +use std::fmt::Write; + +use crate::compaction::estimate_input_tokens_conservative; +use crate::models::{DEFAULT_CONTEXT_WINDOW_TOKENS, context_window_for_model}; +use crate::session_manager::SessionContextReference; +use crate::tui::app::{App, ToolDetailRecord}; +use crate::tui::file_mention::ContextReferenceSource; +use crate::utils::estimate_message_chars; + +const CONTEXT_WARNING_THRESHOLD_PERCENT: f64 = 85.0; +const CONTEXT_CRITICAL_THRESHOLD_PERCENT: f64 = 95.0; +const MAX_REFERENCE_ROWS: usize = 12; +const MAX_TOOL_ROWS: usize = 8; + +#[must_use] +pub fn build_context_inspector_text(app: &App) -> String { + let mut out = String::new(); + let usage = context_usage(app); + let status = context_status(usage.2); + + let _ = writeln!(out, "Session Context"); + let _ = writeln!(out, "---------------"); + let _ = writeln!(out, "Model: {}", app.model); + let _ = writeln!(out, "Workspace: {}", app.workspace.display()); + if let Some(session_id) = app.current_session_id.as_deref() { + let _ = writeln!(out, "Session: {}", session_id); + } + let (used, max, percent) = usage; + let _ = writeln!( + out, + "Context: {status} - ~{used}/{max} tokens ({percent:.1}%)" + ); + let _ = writeln!( + out, + "Transcript: {} cells, {} API messages", + app.history.len(), + app.api_messages.len() + ); + let _ = writeln!( + out, + "Workspace status: {}", + app.workspace_context + .as_deref() + .unwrap_or("not sampled yet") + ); + + let _ = writeln!(out); + push_references(&mut out, &app.session_context_references); + let _ = writeln!(out); + push_tools(&mut out, app); + + out +} + +fn context_usage(app: &App) -> (usize, u32, f64) { + let max = context_window_for_model(&app.model).unwrap_or(DEFAULT_CONTEXT_WINDOW_TOKENS); + let estimated = + estimate_input_tokens_conservative(&app.api_messages, app.system_prompt.as_ref()); + let total_chars = estimate_message_chars(&app.api_messages); + let used = estimated.max(total_chars / 4); + let percent = ((used as f64 / f64::from(max)) * 100.0).clamp(0.0, 100.0); + (used, max, percent) +} + +fn context_status(percent: f64) -> &'static str { + if percent >= CONTEXT_CRITICAL_THRESHOLD_PERCENT { + "critical" + } else if percent >= CONTEXT_WARNING_THRESHOLD_PERCENT { + "high" + } else { + "ok" + } +} + +fn push_references(out: &mut String, references: &[SessionContextReference]) { + let _ = writeln!(out, "References"); + let _ = writeln!(out, "----------"); + + let mut seen = HashSet::new(); + let mut rendered = 0usize; + for record in references { + let reference = &record.reference; + let key = format!( + "{:?}:{:?}:{}:{}", + reference.source, reference.kind, reference.target, reference.label + ); + if !seen.insert(key) { + continue; + } + if rendered >= MAX_REFERENCE_ROWS { + let remaining = references.len().saturating_sub(rendered); + if remaining > 0 { + let _ = writeln!(out, "- ... {remaining} more reference(s)"); + } + break; + } + + let prefix = match reference.source { + ContextReferenceSource::AtMention => "@", + ContextReferenceSource::Attachment => "/attach ", + }; + let state = if reference.included { + if reference.expanded { + "included" + } else { + "attached" + } + } else { + "not included" + }; + let detail = reference + .detail + .as_deref() + .filter(|detail| !detail.trim().is_empty()) + .map(|detail| format!(" - {detail}")) + .unwrap_or_default(); + let _ = writeln!( + out, + "- [{}] {prefix}{} -> {} ({state}{detail})", + reference.badge, reference.label, reference.target + ); + rendered += 1; + } + + if rendered == 0 { + let _ = writeln!( + out, + "- No file, directory, or media references recorded yet." + ); + } +} + +fn push_tools(out: &mut String, app: &App) { + let _ = writeln!(out, "Recent Tools"); + let _ = writeln!(out, "------------"); + + let mut rows: Vec<(usize, &ToolDetailRecord)> = app + .tool_details_by_cell + .iter() + .map(|(idx, detail)| (*idx, detail)) + .collect(); + rows.sort_by_key(|(idx, _)| std::cmp::Reverse(*idx)); + + let mut rendered = 0usize; + for detail in app.active_tool_details.values() { + push_tool_row(out, "active", detail); + rendered += 1; + if rendered >= MAX_TOOL_ROWS { + return; + } + } + for (cell_idx, detail) in rows + .into_iter() + .take(MAX_TOOL_ROWS.saturating_sub(rendered)) + { + let location = format!("cell {cell_idx}"); + push_tool_row(out, &location, detail); + rendered += 1; + } + + if rendered == 0 { + let _ = writeln!(out, "- No tool activity recorded yet."); + } else { + let _ = writeln!( + out, + "- Open the matching card and press Alt+V for full details." + ); + } +} + +fn push_tool_row(out: &mut String, location: &str, detail: &ToolDetailRecord) { + let output_state = if detail.output.as_deref().is_some_and(|out| !out.is_empty()) { + "output captured" + } else { + "no output yet" + }; + let _ = writeln!( + out, + "- [{}] {} {} ({output_state})", + location, + detail.tool_name, + short_tool_id(&detail.tool_id) + ); +} + +fn short_tool_id(id: &str) -> String { + if id.len() <= 8 { + id.to_string() + } else { + format!("{}...", &id[..8]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::models::{ContentBlock, Message}; + use crate::session_manager::SessionContextReference; + use crate::tui::app::TuiOptions; + use crate::tui::file_mention::{ + ContextReference, ContextReferenceKind, ContextReferenceSource, + }; + use crate::tui::history::HistoryCell; + use std::path::PathBuf; + + fn test_app() -> App { + App::new( + TuiOptions { + model: "unknown-model".to_string(), + workspace: PathBuf::from("/tmp/project"), + allow_shell: false, + use_alt_screen: true, + use_mouse_capture: false, + use_bracketed_paste: true, + max_subagents: 1, + skills_dir: PathBuf::from("/tmp/skills"), + memory_path: PathBuf::from("memory.md"), + notes_path: PathBuf::from("notes.md"), + mcp_config_path: PathBuf::from("mcp.json"), + use_memory: false, + start_in_agent_mode: false, + skip_onboarding: true, + yolo: false, + resume_session_id: None, + }, + &Config::default(), + ) + } + + #[test] + fn inspector_formats_empty_state() { + let app = test_app(); + let text = build_context_inspector_text(&app); + assert!(text.contains("Session Context")); + assert!(text.contains("No file, directory, or media references recorded yet.")); + assert!(text.contains("No tool activity recorded yet.")); + } + + #[test] + fn inspector_lists_context_references() { + let mut app = test_app(); + app.history.push(HistoryCell::User { + content: "read @src/main.rs".to_string(), + }); + app.session_context_references + .push(SessionContextReference { + message_index: 0, + reference: ContextReference { + kind: ContextReferenceKind::File, + source: ContextReferenceSource::AtMention, + badge: "file".to_string(), + label: "src/main.rs".to_string(), + target: "/tmp/project/src/main.rs".to_string(), + included: true, + expanded: true, + detail: Some("included".to_string()), + }, + }); + + let text = build_context_inspector_text(&app); + assert!(text.contains("[file] @src/main.rs -> /tmp/project/src/main.rs")); + } + + #[test] + fn inspector_marks_high_context_pressure() { + let mut app = test_app(); + app.api_messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "x".repeat(4_000_000), + cache_control: None, + }], + }); + + let text = build_context_inspector_text(&app); + assert!(text.contains("Context: critical"), "{text}"); + } +} diff --git a/crates/tui/src/tui/file_mention.rs b/crates/tui/src/tui/file_mention.rs index e977c2b1..b04f7d62 100644 --- a/crates/tui/src/tui/file_mention.rs +++ b/crates/tui/src/tui/file_mention.rs @@ -25,6 +25,8 @@ use std::fmt::Write; use std::io::Read; use std::path::{Path, PathBuf}; +use serde::{Deserialize, Serialize}; + use crate::tui::app::App; use crate::working_set::Workspace; @@ -51,6 +53,46 @@ pub struct FileMentionPreview { pub included: bool, } +/// Durable, compact metadata for a user-visible context reference. +/// +/// The transcript keeps the user's compact text (`@path` or `[Attached ...]`) +/// readable. This record preserves the exact target and inclusion state for +/// the context inspector and for session resume without leaking raw metadata +/// into the visible history cell. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ContextReference { + pub kind: ContextReferenceKind, + pub source: ContextReferenceSource, + /// Short badge for terminal display, e.g. `file`, `dir`, `image`. + pub badge: String, + /// Compact display label from the transcript, without the leading `@`. + pub label: String, + /// Resolved target path or URI-equivalent string. + pub target: String, + pub included: bool, + pub expanded: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ContextReferenceKind { + File, + Directory, + Missing, + Unsupported, + MediaMention, + MediaAttachment, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ContextReferenceSource { + AtMention, + Attachment, +} + // --------------------------------------------------------------------------- // Tab-completion // --------------------------------------------------------------------------- @@ -289,7 +331,24 @@ pub fn pending_context_previews( workspace: &Path, cwd: Option, ) -> Vec { - let mut previews = Vec::new(); + context_references_from_input(input, workspace, cwd) + .into_iter() + .map(|reference| FileMentionPreview { + kind: reference.badge, + label: reference.label, + detail: reference.detail, + included: reference.included, + }) + .collect() +} + +#[must_use] +pub fn context_references_from_input( + input: &str, + workspace: &Path, + cwd: Option, +) -> Vec { + let mut references = Vec::new(); let mut seen = std::collections::HashSet::new(); let ws = Workspace::with_cwd(workspace.to_path_buf(), cwd); @@ -307,63 +366,94 @@ pub fn pending_context_previews( (path, display, false) } }; - if !seen.insert(format!("mention:{display_path}")) { + let reference = context_reference_for_mention(&mention, &path, &display_path, exists); + if !seen.insert(format!( + "{:?}:{:?}:{}:{}", + reference.source, reference.kind, reference.target, reference.label + )) { continue; } - previews.push(preview_for_mention(&mention, &path, &display_path, exists)); + references.push(reference); } for reference in extract_media_attachment_references(input) { - if !seen.insert(format!("media:{}", reference.path)) { + let context_reference = ContextReference { + kind: ContextReferenceKind::MediaAttachment, + source: ContextReferenceSource::Attachment, + badge: reference.kind, + label: reference.path.clone(), + target: reference.path, + included: true, + expanded: false, + detail: Some("attached media".to_string()), + }; + if !seen.insert(format!( + "{:?}:{:?}:{}:{}", + context_reference.source, + context_reference.kind, + context_reference.target, + context_reference.label + )) { continue; } - previews.push(FileMentionPreview { - kind: reference.kind, - label: reference.path, - detail: Some("attached media".to_string()), - included: true, - }); + references.push(context_reference); } - previews + references } -fn preview_for_mention( +fn context_reference_for_mention( raw: &str, path: &Path, display_path: &str, exists: bool, -) -> FileMentionPreview { +) -> ContextReference { if !exists { - return FileMentionPreview { - kind: "missing".to_string(), + return ContextReference { + kind: ContextReferenceKind::Missing, + source: ContextReferenceSource::AtMention, + badge: "missing".to_string(), label: raw.to_string(), - detail: Some("not found".to_string()), + target: display_path.to_string(), included: false, + expanded: false, + detail: Some("not found".to_string()), }; } if path.is_dir() { - return FileMentionPreview { - kind: "dir".to_string(), + return ContextReference { + kind: ContextReferenceKind::Directory, + source: ContextReferenceSource::AtMention, + badge: "dir".to_string(), label: raw.to_string(), - detail: Some("directory listing".to_string()), + target: display_path.to_string(), included: true, + expanded: true, + detail: Some("directory listing".to_string()), }; } if !path.is_file() { - return FileMentionPreview { - kind: "skipped".to_string(), + return ContextReference { + kind: ContextReferenceKind::Unsupported, + source: ContextReferenceSource::AtMention, + badge: "skipped".to_string(), label: raw.to_string(), - detail: Some("unsupported path".to_string()), + target: display_path.to_string(), included: false, + expanded: false, + detail: Some("unsupported path".to_string()), }; } if is_media_path(path) { - return FileMentionPreview { - kind: "media".to_string(), + return ContextReference { + kind: ContextReferenceKind::MediaMention, + source: ContextReferenceSource::AtMention, + badge: "media".to_string(), label: raw.to_string(), - detail: Some("use /attach for media bytes".to_string()), + target: display_path.to_string(), included: false, + expanded: false, + detail: Some("use /attach for media bytes".to_string()), }; } @@ -375,11 +465,15 @@ fn preview_for_mention( Err(err) => Some(format!("metadata: {err}")), }; - FileMentionPreview { - kind: "file".to_string(), + ContextReference { + kind: ContextReferenceKind::File, + source: ContextReferenceSource::AtMention, + badge: "file".to_string(), label: raw.to_string(), - detail: detail.or_else(|| Some(display_path.to_string())), + target: display_path.to_string(), included: true, + expanded: true, + detail: detail.or_else(|| Some(display_path.to_string())), } } @@ -824,4 +918,28 @@ mod tests { "/attach media should be included: {previews:?}" ); } + + #[test] + fn context_references_preserve_exact_targets_and_roundtrip() { + let tmp = TempDir::new().expect("tempdir"); + std::fs::create_dir_all(tmp.path().join("src")).expect("mkdir"); + std::fs::write(tmp.path().join("src/main.rs"), "fn main() {}").expect("write"); + let input = "read @src/main.rs"; + + let references = + context_references_from_input(input, tmp.path(), Some(tmp.path().to_path_buf())); + + assert_eq!(references.len(), 1); + let reference = &references[0]; + assert_eq!(reference.kind, ContextReferenceKind::File); + assert_eq!(reference.source, ContextReferenceSource::AtMention); + assert_eq!(reference.label, "src/main.rs"); + assert!(reference.target.ends_with("src/main.rs")); + assert!(reference.included); + assert!(reference.expanded); + + let encoded = serde_json::to_string(reference).expect("serialize"); + let decoded: ContextReference = serde_json::from_str(&encoded).expect("deserialize"); + assert_eq!(&decoded, reference); + } } diff --git a/crates/tui/src/tui/keybindings.rs b/crates/tui/src/tui/keybindings.rs index f20e33a5..124c2357 100644 --- a/crates/tui/src/tui/keybindings.rs +++ b/crates/tui/src/tui/keybindings.rs @@ -168,6 +168,11 @@ pub const KEYBINDINGS: &[KeybindingEntry] = &[ description: "Open the fuzzy file picker (insert @path on Enter)", section: KeybindingSection::Submission, }, + KeybindingEntry { + chord: "Alt+C", + description: "Open compact session context inspector", + section: KeybindingSection::Submission, + }, KeybindingEntry { chord: "l", description: "Open pager for the last message (when input is empty)", diff --git a/crates/tui/src/tui/mod.rs b/crates/tui/src/tui/mod.rs index cdaca726..b7c9b980 100644 --- a/crates/tui/src/tui/mod.rs +++ b/crates/tui/src/tui/mod.rs @@ -8,6 +8,7 @@ pub mod approval; pub mod backtrack; pub mod clipboard; pub mod command_palette; +pub mod context_inspector; pub mod diff_render; pub mod event_broker; pub mod external_editor; diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index de8b3148..d703d1ea 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -53,6 +53,7 @@ use crate::tools::subagent::{MailboxMessage, SubAgentResult, SubAgentStatus}; use crate::tui::command_palette::{ CommandPaletteView, build_entries as build_command_palette_entries, }; +use crate::tui::context_inspector::build_context_inspector_text; use crate::tui::event_broker::EventBroker; use crate::tui::live_transcript::LiveTranscriptOverlay; use crate::tui::onboarding; @@ -1352,6 +1353,16 @@ async fn run_event_loop( continue; } + if matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C')) + && key.modifiers.contains(KeyModifiers::ALT) + && !key.modifiers.contains(KeyModifiers::CONTROL) + && !key.modifiers.contains(KeyModifiers::SUPER) + && app.view_stack.is_empty() + { + open_context_inspector(app); + continue; + } + if !app.view_stack.is_empty() { let events = app.view_stack.handle_key(key); if handle_view_events(app, config, &task_manager, &mut engine_handle, events) @@ -1999,16 +2010,19 @@ fn build_session_snapshot(app: &App, manager: &SessionManager) -> SavedSession { app.system_prompt.as_ref(), ); updated.metadata.mode = Some(app.mode.as_setting().to_string()); + updated.context_references = app.session_context_references.clone(); updated } else { - create_saved_session_with_mode( + let mut session = create_saved_session_with_mode( &app.api_messages, &app.model, &app.workspace, u64::from(app.total_tokens), app.system_prompt.as_ref(), Some(app.mode.as_setting()), - ) + ); + session.context_references = app.session_context_references.clone(); + session } } @@ -2302,14 +2316,18 @@ fn build_queued_message(app: &mut App, input: String) -> QueuedMessage { QueuedMessage::new(input, skill_instruction) } -fn queued_message_content_for_app(app: &App, message: &QueuedMessage) -> String { +fn queued_message_content_for_app( + app: &App, + message: &QueuedMessage, + cwd: Option, +) -> String { // Pass the process CWD explicitly so the resolver's two-pass logic can // honor the user's launch directory when it differs from `--workspace` // (issue #101 — file mentions silently routing to the wrong root). let user_request = crate::tui::file_mention::user_request_with_file_mentions( &message.display, &app.workspace, - std::env::current_dir().ok(), + cwd, ); if let Some(skill_instruction) = message.skill_instruction.as_ref() { format!("{skill_instruction}\n\n---\n\nUser request: {user_request}") @@ -2327,7 +2345,14 @@ async fn dispatch_user_message( app.is_loading = true; app.last_send_at = Some(Instant::now()); - let content = queued_message_content_for_app(app, &message); + let cwd = std::env::current_dir().ok(); + let references = crate::tui::file_mention::context_references_from_input( + &message.display, + &app.workspace, + cwd.clone(), + ); + let content = queued_message_content_for_app(app, &message, cwd); + let message_index = app.api_messages.len(); app.system_prompt = Some(prompts::system_prompt_for_mode_with_context( app.mode, &app.workspace, @@ -2336,6 +2361,8 @@ async fn dispatch_user_message( app.add_message(HistoryCell::User { content: message.display.clone(), }); + let history_cell = app.history.len().saturating_sub(1); + app.record_context_references(history_cell, message_index, references); app.scroll_to_bottom(); app.api_messages.push(Message { role: "user".to_string(), @@ -2576,6 +2603,19 @@ fn open_text_pager(app: &mut App, title: String, content: String) { )); } +fn open_context_inspector(app: &mut App) { + let width = app + .last_transcript_area + .map(|area| area.width) + .unwrap_or(80); + let content = build_context_inspector_text(app); + app.view_stack.push(PagerView::from_text( + "Context inspector", + &content, + width.saturating_sub(2), + )); +} + async fn apply_command_result( app: &mut App, engine_handle: &mut EngineHandle, @@ -2697,6 +2737,9 @@ async fn apply_command_result( )); } } + AppAction::OpenContextInspector => { + open_context_inspector(app); + } AppAction::CompactContext => { app.status_message = Some("Compacting context...".to_string()); let _ = engine_handle.send(Op::CompactContext).await; @@ -2796,12 +2839,21 @@ async fn steer_user_message( engine_handle: &EngineHandle, message: QueuedMessage, ) -> Result<()> { - let content = queued_message_content_for_app(app, &message); + let cwd = std::env::current_dir().ok(); + let references = crate::tui::file_mention::context_references_from_input( + &message.display, + &app.workspace, + cwd.clone(), + ); + let content = queued_message_content_for_app(app, &message, cwd); + let message_index = app.api_messages.len(); // Mirror steer input in local transcript/session state. app.add_message(HistoryCell::User { content: format!("+ {}", message.display), }); + let history_cell = app.history.len().saturating_sub(1); + app.record_context_references(history_cell, message_index, references); app.api_messages.push(Message { role: "user".to_string(), content: vec![ContentBlock::Text { @@ -3715,12 +3767,33 @@ fn apply_loaded_session(app: &mut App, session: &SavedSession) { app.pending_tool_uses.clear(); app.last_exec_wait_command = None; - let cells_to_add: Vec<_> = app - .api_messages - .iter() - .flat_map(history_cells_from_message) - .collect(); - app.extend_history(cells_to_add); + let messages = app.api_messages.clone(); + let mut message_to_cell = std::collections::HashMap::new(); + for (message_index, msg) in messages.iter().enumerate() { + let mut cells = history_cells_from_message(msg); + if msg.role == "user" + && session + .context_references + .iter() + .any(|record| record.message_index == message_index) + { + for cell in &mut cells { + if let HistoryCell::User { content } = cell { + *content = compact_user_context_display(content); + } + } + } + let base = app.history.len(); + if msg.role == "user" + && let Some(offset) = cells + .iter() + .position(|cell| matches!(cell, HistoryCell::User { .. })) + { + message_to_cell.insert(message_index, base + offset); + } + app.extend_history(cells); + } + app.sync_context_references_from_session(&session.context_references, &message_to_cell); app.mark_history_updated(); app.transcript_selection.clear(); app.model.clone_from(&session.metadata.model); @@ -3743,6 +3816,14 @@ fn apply_loaded_session(app: &mut App, session: &SavedSession) { app.scroll_to_bottom(); } +fn compact_user_context_display(content: &str) -> String { + content + .split("\n\n---\n\nLocal context from @mentions:") + .next() + .unwrap_or(content) + .to_string() +} + fn refresh_workspace_context_if_needed(app: &mut App, now: Instant, allow_blocking_refresh: bool) { if app .workspace_context_refreshed_at diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index ba55697c..84c2082f 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -185,7 +185,7 @@ fn file_mentions_add_local_text_context_to_model_payload() { app.workspace = tmpdir.path().to_path_buf(); let message = QueuedMessage::new("Summarize @guide.md".to_string(), None); - let content = queued_message_content_for_app(&app, &message); + let content = queued_message_content_for_app(&app, &message, None); assert!(content.starts_with("Summarize @guide.md")); assert!(content.contains("Local context from @mentions:")); @@ -194,6 +194,13 @@ fn file_mentions_add_local_text_context_to_model_payload() { assert_eq!(message.display, "Summarize @guide.md"); } +#[test] +fn compact_user_context_display_hides_persisted_mention_block() { + let content = "Summarize @guide.md\n\n---\n\nLocal context from @mentions:\nlarge"; + + assert_eq!(compact_user_context_display(content), "Summarize @guide.md"); +} + #[test] fn file_mentions_do_not_trigger_inside_email_addresses() { let tmpdir = TempDir::new().expect("tempdir");