feat(tui): add context source map report

Add an explicit /context report|json|summary source-map surface while preserving plain /context as the existing inspector.

Expose the same diagnostic map through doctor --context-json for headless checks. The report uses the existing compaction token heuristic and labels counts as conservative estimates rather than exact provider billing.

Modified harvest from PR #3150 by @idling11.

Co-authored-by: idling11 <8055620+idling11@users.noreply.github.com>
This commit is contained in:
Hunter B
2026-06-12 03:20:59 -07:00
parent 6e9baaa988
commit 0986cabb1d
8 changed files with 828 additions and 9 deletions
+48 -4
View File
@@ -129,9 +129,25 @@ pub fn system_prompt(app: &mut App) -> CommandResult {
))
}
/// Show context window usage
pub fn context(_app: &mut App) -> CommandResult {
CommandResult::action(AppAction::OpenContextInspector)
/// Show context window usage.
///
/// `/context` keeps opening the interactive inspector. `/context report`,
/// `/context json`, and `/context summary` expose the diagnostic source map
/// from #3143 without replacing the inspector surface.
pub fn context(app: &mut App, arg: Option<&str>) -> CommandResult {
let Some(subcommand) = arg.map(str::trim).filter(|arg| !arg.is_empty()) else {
return CommandResult::action(AppAction::OpenContextInspector);
};
let report = crate::context_report::build_context_report(app);
match subcommand {
"report" => CommandResult::message(crate::context_report::format_context_report(&report)),
"json" => CommandResult::message(crate::context_report::context_report_json(&report)),
"summary" => CommandResult::message(crate::context_report::format_context_summary(&report)),
other => CommandResult::error(format!(
"Unknown /context subcommand: {other}. Use report, json, or summary."
)),
}
}
/// Show per-turn DeepSeek prefix-cache telemetry for the last N turns (#263).
@@ -1424,7 +1440,7 @@ mod tests {
content: "Hello".to_string(),
});
let result = context(&mut app);
let result = context(&mut app, None);
assert!(matches!(
result.action,
Some(AppAction::OpenContextInspector)
@@ -1432,6 +1448,34 @@ mod tests {
assert!(result.message.is_none());
}
#[test]
fn test_context_report_subcommands_return_source_map() {
let mut app = create_test_app();
app.api_messages.push(Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: "Hello".to_string(),
cache_control: None,
}],
});
app.session.last_tool_catalog = Some(vec![test_tool("read_file")]);
let report = context(&mut app, Some("report"))
.message
.expect("report text");
assert!(report.contains("Context Source Map"));
assert!(report.contains("Tool schemas"));
let summary = context(&mut app, Some("summary"))
.message
.expect("summary text");
assert!(summary.contains("Context Summary"));
let json = context(&mut app, Some("json")).message.expect("json text");
let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid context json");
assert!(!parsed["entries"].as_array().unwrap().is_empty());
}
#[test]
fn test_undo_conversation_removes_last_exchange() {
let mut app = create_test_app();
+1 -1
View File
@@ -21,7 +21,7 @@ pub(in crate::commands) fn dispatch(
"cache" => debug::cache(app, arg),
"change" => change::change(app, arg),
"system" | "xitong" => debug::system_prompt(app),
"context" | "ctx" => debug::context(app),
"context" | "ctx" => debug::context(app, arg),
"edit" => debug::edit(app),
"diff" => debug::diff(app),
"undo" => {
+4
View File
@@ -505,6 +505,10 @@ mod tests {
result.action,
Some(AppAction::OpenContextInspector)
));
let report = execute("/context report", &mut app);
let message = report.message.expect("context report should return text");
assert!(message.contains("Context Source Map"));
}
#[test]
+1 -1
View File
@@ -248,7 +248,7 @@ pub const COMMANDS: &[CommandInfo] = &[
CommandInfo {
name: "context",
aliases: &["ctx"],
usage: "/context",
usage: "/context [report|json|summary]",
description_id: MessageId::CmdContextDescription,
},
CommandInfo {
+1 -1
View File
@@ -638,7 +638,7 @@ fn message_has_tool_use(message: &Message) -> bool {
.any(|block| matches!(block, ContentBlock::ToolUse { .. }))
}
fn estimate_text_tokens_conservative(text: &str) -> usize {
pub fn estimate_text_tokens_conservative(text: &str) -> usize {
text.chars().count().div_ceil(3)
}
+759
View File
@@ -0,0 +1,759 @@
//! Diagnostic prompt source map for context pressure reports.
//!
//! The report is intentionally approximate for v0.8.59. It uses the same
//! conservative token heuristic as compaction and describes the runtime sources
//! CodeWhale already tracks, without claiming provider-tokenizer parity.
use std::fmt::Write as _;
use std::path::Path;
use chrono::{SecondsFormat, Utc};
use serde::Serialize;
use crate::compaction::{estimate_input_tokens_conservative, estimate_text_tokens_conservative};
use crate::config::Config;
use crate::models::{ContentBlock, Message, context_window_for_model};
use crate::prompts::{COMPACT_TEMPLATE, Personality};
use crate::tui::app::App;
#[derive(Debug, Clone, Serialize)]
pub struct PromptSourceMap {
pub entries: Vec<SourceEntry>,
pub total_estimated_tokens: usize,
pub active_context_estimated_tokens: usize,
pub context_window_tokens: Option<u32>,
pub budget_used_percent: Option<f64>,
pub generated_at: String,
pub note: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct SourceEntry {
pub source_kind: SourceKind,
pub label: String,
pub source_path: Option<String>,
pub activation_reason: ActivationReason,
pub estimated_tokens: usize,
pub counting_confidence: CountingConfidence,
pub authority_tier: Option<u8>,
pub truncation_reason: Option<String>,
}
impl SourceEntry {
fn text(
source_kind: SourceKind,
label: impl Into<String>,
source_path: Option<String>,
activation_reason: ActivationReason,
text: &str,
counting_confidence: CountingConfidence,
authority_tier: Option<u8>,
) -> Self {
Self::estimate(
source_kind,
label,
source_path,
activation_reason,
estimate_text_tokens_conservative(text),
counting_confidence,
authority_tier,
)
}
fn estimate(
source_kind: SourceKind,
label: impl Into<String>,
source_path: Option<String>,
activation_reason: ActivationReason,
estimated_tokens: usize,
counting_confidence: CountingConfidence,
authority_tier: Option<u8>,
) -> Self {
Self {
source_kind,
label: label.into(),
source_path,
activation_reason,
estimated_tokens,
counting_confidence,
authority_tier,
truncation_reason: None,
}
}
fn omitted(
source_kind: SourceKind,
label: impl Into<String>,
source_path: Option<String>,
authority_tier: Option<u8>,
reason: impl Into<String>,
) -> Self {
Self {
source_kind,
label: label.into(),
source_path,
activation_reason: ActivationReason::Omitted,
estimated_tokens: 0,
counting_confidence: CountingConfidence::High,
authority_tier,
truncation_reason: Some(reason.into()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SourceKind {
Constitution,
ProjectContext,
ProjectContextPack,
SkillsBlock,
ContextManagement,
CompactionRelayTemplate,
RuntimePolicy,
EnvironmentBlock,
UserMemory,
SessionGoal,
HandoffRelay,
ToolSchemas,
UserRequest,
ConversationHistory,
ToolResult,
ModelProviderFact,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ActivationReason {
AlwaysOn,
FilePresent,
ConfigEnabled,
RuntimeState,
PerRequest,
Omitted,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum CountingConfidence {
High,
Approximate,
}
struct ReportBuilder {
entries: Vec<SourceEntry>,
}
impl ReportBuilder {
fn new() -> Self {
Self {
entries: Vec::new(),
}
}
fn push(&mut self, entry: SourceEntry) {
self.entries.push(entry);
}
fn finish(
self,
model: &str,
active_context_estimated_tokens: usize,
note: impl Into<String>,
) -> PromptSourceMap {
let total_estimated_tokens = self
.entries
.iter()
.map(|entry| entry.estimated_tokens)
.sum();
let context_window_tokens = context_window_for_model(model);
let budget_used_percent = context_window_tokens.map(|window| {
((active_context_estimated_tokens as f64 / f64::from(window)) * 100.0).clamp(0.0, 100.0)
});
PromptSourceMap {
entries: self.entries,
total_estimated_tokens,
active_context_estimated_tokens,
context_window_tokens,
budget_used_percent,
generated_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
note: note.into(),
}
}
}
pub fn build_context_report(app: &App) -> PromptSourceMap {
let mut builder = base_source_entries(&app.model, &app.workspace, Some(&app.skills_dir));
add_app_runtime_entries(&mut builder, app);
let active_context_estimated_tokens =
estimate_input_tokens_conservative(&app.api_messages, app.system_prompt.as_ref());
builder.finish(
&app.model,
active_context_estimated_tokens,
"Diagnostic source map. Token counts are conservative estimates and may differ from provider billing.",
)
}
pub fn build_headless_context_report(config: &Config, workspace: &Path) -> PromptSourceMap {
let model = config.default_model();
let global_skills_dir = config.skills_dir();
let selected_skills_dir =
crate::tui::app::resolve_skills_dir(workspace, &global_skills_dir, config);
let mut builder = base_source_entries(&model, workspace, Some(&selected_skills_dir));
let memory_path = config.memory_path();
if let Some(memory_block) = crate::memory::compose_block(config.memory_enabled(), &memory_path)
{
builder.push(SourceEntry::text(
SourceKind::UserMemory,
"User memory",
Some(memory_path.display().to_string()),
ActivationReason::ConfigEnabled,
&memory_block,
CountingConfidence::High,
Some(6),
));
} else {
builder.push(SourceEntry::omitted(
SourceKind::UserMemory,
"User memory",
Some(memory_path.display().to_string()),
Some(6),
"disabled, missing, or empty",
));
}
builder.push(SourceEntry::text(
SourceKind::ModelProviderFact,
format!("Provider facts ({})", config.api_provider().as_str()),
None,
ActivationReason::RuntimeState,
&format!(
"provider: {}\nmodel: {}\ncontext_window: {:?}",
config.api_provider().as_str(),
model,
context_window_for_model(&model)
),
CountingConfidence::Approximate,
None,
));
let active_context_estimated_tokens = builder
.entries
.iter()
.map(|entry| entry.estimated_tokens)
.sum();
builder.finish(
&model,
active_context_estimated_tokens,
"Headless diagnostic source map. Conversation, tool results, and live TUI state are unavailable in doctor mode.",
)
}
fn base_source_entries(model: &str, workspace: &Path, skills_dir: Option<&Path>) -> ReportBuilder {
let mut builder = ReportBuilder::new();
let constitution =
crate::prompts::compose_prompt_with_approval_model_and_shell(Personality::Calm, model);
builder.push(SourceEntry::text(
SourceKind::Constitution,
"Constitution and static prompt",
Some("crates/tui/src/prompts/constitution.md".to_string()),
ActivationReason::AlwaysOn,
&constitution,
CountingConfidence::High,
Some(1),
));
let project_context = crate::project_context::load_project_context_with_parents(workspace);
if let Some(block) = project_context.as_system_block() {
builder.push(SourceEntry::text(
SourceKind::ProjectContext,
"Project context and repository instructions",
Some(workspace.display().to_string()),
ActivationReason::FilePresent,
&block,
CountingConfidence::High,
Some(5),
));
} else {
builder.push(SourceEntry::omitted(
SourceKind::ProjectContext,
"Project context and repository instructions",
Some(workspace.display().to_string()),
Some(5),
"no project context block available",
));
}
if let Some(pack) = crate::project_context::generate_project_context_pack(workspace) {
builder.push(SourceEntry::text(
SourceKind::ProjectContextPack,
"Project context pack",
Some(workspace.display().to_string()),
ActivationReason::RuntimeState,
&pack,
CountingConfidence::Approximate,
Some(5),
));
}
let skills_block = match skills_dir {
Some(dir) => {
crate::skills::render_available_skills_context_for_workspace_and_dir(workspace, dir)
}
None => crate::skills::render_available_skills_context_for_workspace(workspace),
};
if let Some(block) = skills_block {
builder.push(SourceEntry::text(
SourceKind::SkillsBlock,
"Available skills",
skills_dir.map(|path| path.display().to_string()),
ActivationReason::FilePresent,
&block,
CountingConfidence::High,
Some(5),
));
} else {
builder.push(SourceEntry::omitted(
SourceKind::SkillsBlock,
"Available skills",
skills_dir.map(|path| path.display().to_string()),
Some(5),
"no skills discovered",
));
}
builder.push(SourceEntry::estimate(
SourceKind::ContextManagement,
"Context management guidance",
None,
ActivationReason::AlwaysOn,
430,
CountingConfidence::Approximate,
Some(3),
));
builder.push(SourceEntry::text(
SourceKind::CompactionRelayTemplate,
"Compaction relay template",
Some("crates/tui/src/prompts/compact.md".to_string()),
ActivationReason::AlwaysOn,
COMPACT_TEMPLATE,
CountingConfidence::High,
Some(3),
));
builder.push(SourceEntry::estimate(
SourceKind::RuntimePolicy,
"Runtime policy reference",
None,
ActivationReason::AlwaysOn,
650,
CountingConfidence::Approximate,
Some(3),
));
add_handoff_entry(&mut builder, workspace);
builder
}
fn add_app_runtime_entries(builder: &mut ReportBuilder, app: &App) {
builder.push(SourceEntry::text(
SourceKind::EnvironmentBlock,
"Runtime environment",
Some(app.workspace.display().to_string()),
ActivationReason::PerRequest,
&format!(
"workspace: {}\nmodel: {}\nprovider: {}\nmode: {}\napproval: {}",
app.workspace.display(),
app.model,
app.api_provider.as_str(),
app.mode.label(),
app.approval_mode.label()
),
CountingConfidence::Approximate,
Some(4),
));
if let Some(memory_block) = crate::memory::compose_block(app.use_memory, &app.memory_path) {
builder.push(SourceEntry::text(
SourceKind::UserMemory,
"User memory",
Some(app.memory_path.display().to_string()),
ActivationReason::ConfigEnabled,
&memory_block,
CountingConfidence::High,
Some(6),
));
} else {
builder.push(SourceEntry::omitted(
SourceKind::UserMemory,
"User memory",
Some(app.memory_path.display().to_string()),
Some(6),
"disabled, missing, or empty",
));
}
if let Some(goal) = app
.hunt
.quarry
.as_deref()
.filter(|goal| !goal.trim().is_empty())
{
builder.push(SourceEntry::text(
SourceKind::SessionGoal,
"Session goal",
None,
ActivationReason::RuntimeState,
goal,
CountingConfidence::High,
Some(6),
));
} else {
builder.push(SourceEntry::omitted(
SourceKind::SessionGoal,
"Session goal",
None,
Some(6),
"no active /goal objective",
));
}
if let Some(tools) = app.session.last_tool_catalog.as_ref() {
let rendered = serde_json::to_string(tools).unwrap_or_default();
builder.push(SourceEntry::text(
SourceKind::ToolSchemas,
format!("Tool schemas ({} tools)", tools.len()),
None,
ActivationReason::PerRequest,
&rendered,
CountingConfidence::Approximate,
Some(3),
));
} else {
builder.push(SourceEntry::omitted(
SourceKind::ToolSchemas,
"Tool schemas",
None,
Some(3),
"no tool catalog has been sent yet",
));
}
add_message_entries(builder, &app.api_messages);
}
fn add_handoff_entry(builder: &mut ReportBuilder, workspace: &Path) {
let primary = workspace.join(crate::prompts::HANDOFF_RELATIVE_PATH);
let legacy = workspace.join(".deepseek/handoff.md");
let path = if primary.exists() { primary } else { legacy };
let Some(raw) = std::fs::read_to_string(&path)
.ok()
.filter(|raw| !raw.trim().is_empty())
else {
builder.push(SourceEntry::omitted(
SourceKind::HandoffRelay,
"Previous session relay",
Some(
workspace
.join(crate::prompts::HANDOFF_RELATIVE_PATH)
.display()
.to_string(),
),
Some(6),
"no relay artifact found",
));
return;
};
builder.push(SourceEntry::text(
SourceKind::HandoffRelay,
"Previous session relay",
Some(path.display().to_string()),
ActivationReason::FilePresent,
&raw,
CountingConfidence::High,
Some(6),
));
}
fn add_message_entries(builder: &mut ReportBuilder, messages: &[Message]) {
if messages.is_empty() {
builder.push(SourceEntry::omitted(
SourceKind::ConversationHistory,
"Conversation history",
None,
None,
"no API messages yet",
));
return;
}
let latest_user = messages.iter().rposition(|message| message.role == "user");
let mut latest_user_tokens = 0usize;
let mut conversation_tokens = 0usize;
let mut tool_result_tokens = 0usize;
let mut tool_result_count = 0usize;
for (index, message) in messages.iter().enumerate() {
for block in &message.content {
let tokens = estimate_text_tokens_conservative(&content_block_text(block));
match block {
ContentBlock::ToolResult { .. }
| ContentBlock::ToolSearchToolResult { .. }
| ContentBlock::CodeExecutionToolResult { .. } => {
tool_result_tokens += tokens;
tool_result_count += 1;
}
ContentBlock::Text { .. } if Some(index) == latest_user => {
latest_user_tokens += tokens;
}
_ => {
conversation_tokens += tokens;
}
}
}
}
if latest_user_tokens > 0 {
builder.push(SourceEntry::estimate(
SourceKind::UserRequest,
"Latest user request",
None,
ActivationReason::PerRequest,
latest_user_tokens,
CountingConfidence::High,
Some(7),
));
}
if conversation_tokens > 0 {
builder.push(SourceEntry::estimate(
SourceKind::ConversationHistory,
"Conversation history",
None,
ActivationReason::RuntimeState,
conversation_tokens,
CountingConfidence::High,
None,
));
}
if tool_result_count > 0 {
builder.push(SourceEntry::estimate(
SourceKind::ToolResult,
format!("Tool results ({tool_result_count})"),
None,
ActivationReason::RuntimeState,
tool_result_tokens,
CountingConfidence::High,
None,
));
}
}
fn content_block_text(block: &ContentBlock) -> String {
match block {
ContentBlock::Text { text, .. } => text.clone(),
ContentBlock::Thinking { thinking, .. } => thinking.clone(),
ContentBlock::ToolResult { content, .. } => content.clone(),
ContentBlock::ToolSearchToolResult { content, .. }
| ContentBlock::CodeExecutionToolResult { content, .. } => content.to_string(),
ContentBlock::ToolUse { input, .. } | ContentBlock::ServerToolUse { input, .. } => {
input.to_string()
}
ContentBlock::ImageUrl { image_url } => image_url.url.clone(),
}
}
fn pressure_label(percent: Option<f64>) -> &'static str {
match percent {
Some(value) if value >= 90.0 => "critical",
Some(value) if value >= 70.0 => "high",
Some(value) if value >= 40.0 => "moderate",
Some(_) => "low",
None => "unknown",
}
}
pub fn format_context_report(report: &PromptSourceMap) -> String {
let mut out = String::new();
let _ = writeln!(out, "Context Source Map");
let _ = writeln!(
out,
"Estimated active context: {} tokens",
report.active_context_estimated_tokens
);
match (report.context_window_tokens, report.budget_used_percent) {
(Some(window), Some(percent)) => {
let _ = writeln!(
out,
"Window: {window} tokens ({percent:.1}% used, {})",
pressure_label(Some(percent))
);
}
_ => {
let _ = writeln!(out, "Window: unknown");
}
}
let _ = writeln!(
out,
"Source-entry total: {} tokens",
report.total_estimated_tokens
);
let _ = writeln!(out);
let _ = writeln!(out, "Sources:");
for entry in &report.entries {
let path = entry
.source_path
.as_deref()
.map(|path| format!(" [{path}]"))
.unwrap_or_default();
let tier = entry
.authority_tier
.map(|tier| format!(", tier {tier}"))
.unwrap_or_default();
let omitted = entry
.truncation_reason
.as_deref()
.map(|reason| format!(" - {reason}"))
.unwrap_or_default();
let _ = writeln!(
out,
"- {:?}: {}{} - {} tokens ({:?}{}){}",
entry.source_kind,
entry.label,
path,
entry.estimated_tokens,
entry.counting_confidence,
tier,
omitted
);
}
let _ = writeln!(out);
let _ = write!(out, "{}", report.note);
out
}
pub fn format_context_summary(report: &PromptSourceMap) -> String {
let mut entries = report.entries.clone();
entries.sort_by(|a, b| b.estimated_tokens.cmp(&a.estimated_tokens));
let top = entries
.iter()
.take(5)
.map(|entry| format!("{} ({})", entry.label, entry.estimated_tokens))
.collect::<Vec<_>>()
.join(", ");
let mut out = String::new();
let _ = writeln!(out, "Context Summary");
let _ = writeln!(
out,
"Pressure: {}",
pressure_label(report.budget_used_percent)
);
let _ = writeln!(
out,
"Estimated active context: {} tokens",
report.active_context_estimated_tokens
);
if let Some(percent) = report.budget_used_percent {
let _ = writeln!(out, "Budget used: {percent:.1}%");
}
let _ = write!(out, "Top sources: {top}");
out
}
pub fn context_report_json(report: &PromptSourceMap) -> String {
serde_json::to_string_pretty(report).unwrap_or_else(|err| {
format!("{{\"error\":\"failed to serialize context report: {err}\"}}")
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::Tool;
#[test]
fn context_report_json_contains_sources_and_tool_results() {
let messages = vec![
Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: "read src/lib.rs".to_string(),
cache_control: None,
}],
},
Message {
role: "assistant".to_string(),
content: vec![ContentBlock::ToolResult {
tool_use_id: "call_1".to_string(),
content: "large tool output".repeat(40),
is_error: None,
content_blocks: None,
}],
},
];
let mut builder = ReportBuilder::new();
builder.push(SourceEntry::text(
SourceKind::Constitution,
"Test static",
None,
ActivationReason::AlwaysOn,
"static",
CountingConfidence::High,
Some(1),
));
add_message_entries(&mut builder, &messages);
let report = builder.finish("deepseek-v4-pro", 123, "test");
let json = context_report_json(&report);
assert!(json.contains("\"source_kind\": \"tool_result\""));
assert!(json.contains("\"active_context_estimated_tokens\": 123"));
}
#[test]
fn format_summary_lists_largest_sources() {
let mut builder = ReportBuilder::new();
builder.push(SourceEntry::estimate(
SourceKind::ToolSchemas,
"Tool schemas",
None,
ActivationReason::PerRequest,
500,
CountingConfidence::Approximate,
Some(3),
));
builder.push(SourceEntry::estimate(
SourceKind::UserRequest,
"Latest user request",
None,
ActivationReason::PerRequest,
25,
CountingConfidence::High,
Some(7),
));
let report = builder.finish("deepseek-v4-pro", 525, "test");
let summary = format_context_summary(&report);
assert!(summary.contains("Context Summary"));
assert!(summary.contains("Tool schemas (500)"));
}
#[test]
fn tool_schema_entry_serializes_like_runtime_catalog() {
let tool = Tool {
tool_type: Some("function".to_string()),
name: "read_file".to_string(),
description: "read a file".to_string(),
input_schema: serde_json::json!({"type": "object"}),
allowed_callers: None,
defer_loading: None,
input_examples: None,
strict: Some(true),
cache_control: None,
};
let rendered = serde_json::to_string(&vec![tool]).expect("serialize tool");
assert!(rendered.contains("read_file"));
}
}
+1 -1
View File
@@ -1314,7 +1314,7 @@ fn english(id: MessageId) -> &'static str {
"Let the agent surgically prune conversation history to free context space"
}
MessageId::CmdConfigDescription => "Open interactive configuration editor",
MessageId::CmdContextDescription => "Open compact session context inspector",
MessageId::CmdContextDescription => "Open context inspector or source-map report",
MessageId::CmdCostDescription => "Show session cost breakdown",
MessageId::CmdDiffDescription => "Show file changes since session start",
MessageId::CmdEditDescription => "Revise and resubmit the last message",
+13 -1
View File
@@ -29,6 +29,7 @@ mod composer_stash;
mod config;
mod config_persistence;
mod config_ui;
mod context_report;
mod core;
mod cost_status;
mod deepseek_theme;
@@ -536,6 +537,9 @@ struct DoctorArgs {
/// Emit machine-readable JSON output (skips live API connectivity check)
#[arg(long, default_value_t = false)]
json: bool,
/// Emit only the diagnostic context source map as JSON
#[arg(long, default_value_t = false, conflicts_with = "json")]
context_json: bool,
}
#[derive(Args, Debug, Clone)]
@@ -954,7 +958,9 @@ async fn main() -> Result<()> {
Commands::Doctor(args) => {
let config = load_config_from_cli(&cli)?;
let workspace = resolve_workspace(&cli);
if args.json {
if args.context_json {
run_doctor_context_json(&config, &workspace)
} else if args.json {
run_doctor_json(&config, &workspace, cli.config.as_deref())
} else {
run_doctor(&config, &workspace, cli.config.as_deref()).await;
@@ -3462,6 +3468,12 @@ fn run_doctor_json(
Ok(())
}
fn run_doctor_context_json(config: &Config, workspace: &Path) -> Result<()> {
let report = crate::context_report::build_headless_context_report(config, workspace);
println!("{}", crate::context_report::context_report_json(&report));
Ok(())
}
/// Build the `capability` section for the machine-readable doctor report.
///
/// Returns a JSON value with the resolved provider, resolved model, context