Add compact context inspector metadata (#154)

This commit is contained in:
Hunter Bown
2026-04-28 18:14:29 -05:00
committed by GitHub
parent 6396bffcd4
commit 0e96928f35
10 changed files with 663 additions and 89 deletions
+8 -47
View File
@@ -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]
+7 -1
View File
@@ -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),
+46
View File
@@ -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<Message>,
/// System prompt if any
pub system_prompt: Option<String>,
/// 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<SessionContextReference>,
}
/// 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");
+67
View File
@@ -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<String, usize>,
/// Full tool input/output keyed by history cell index.
pub tool_details_by_cell: HashMap<usize, ToolDetailRecord>,
/// Linked context references keyed by the visible user history cell that
/// introduced them.
pub context_references_by_cell: HashMap<usize, Vec<SessionContextReference>>,
/// Session-wide context references persisted with saved sessions.
pub session_context_references: Vec<SessionContextReference>,
/// 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<ContextReference>,
) {
if references.is_empty() {
return;
}
let records: Vec<SessionContextReference> = 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<usize, usize>,
) {
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<SessionContextReference> = 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<String>,
},
UpdateCompaction(CompactionConfig),
OpenContextInspector,
CompactContext,
TaskAdd {
prompt: String,
+282
View File
@@ -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}");
}
}
+146 -28
View File
@@ -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<String>,
}
#[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<PathBuf>,
) -> Vec<FileMentionPreview> {
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<PathBuf>,
) -> Vec<ContextReference> {
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);
}
}
+5
View File
@@ -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)",
+1
View File
@@ -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;
+93 -12
View File
@@ -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<PathBuf>,
) -> 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
+8 -1
View File
@@ -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:\n<file>large</file>";
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");