Add compact context inspector metadata (#154)
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user