refactor: extract status.rs from main.rs
This commit is contained in:
parent
ae70e77f14
commit
bd4c618332
|
|
@ -14,6 +14,7 @@ mod constants;
|
||||||
mod doctor;
|
mod doctor;
|
||||||
mod error;
|
mod error;
|
||||||
mod formatting;
|
mod formatting;
|
||||||
|
mod status;
|
||||||
mod session;
|
mod session;
|
||||||
mod init;
|
mod init;
|
||||||
mod input;
|
mod input;
|
||||||
|
|
@ -29,6 +30,7 @@ pub(crate) use constants::*;
|
||||||
pub(crate) use doctor::*;
|
pub(crate) use doctor::*;
|
||||||
pub(crate) use error::*;
|
pub(crate) use error::*;
|
||||||
pub(crate) use formatting::*;
|
pub(crate) use formatting::*;
|
||||||
|
pub(crate) use status::*;
|
||||||
pub(crate) use session::*;
|
pub(crate) use session::*;
|
||||||
pub(crate) use model::*;
|
pub(crate) use model::*;
|
||||||
pub(crate) use models::*;
|
pub(crate) use models::*;
|
||||||
|
|
@ -790,17 +792,17 @@ struct ResumeCommandOutcome {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct StatusContext {
|
pub(crate) struct StatusContext {
|
||||||
cwd: PathBuf,
|
pub(crate) cwd: PathBuf,
|
||||||
session_path: Option<PathBuf>,
|
pub(crate) session_path: Option<PathBuf>,
|
||||||
loaded_config_files: usize,
|
pub(crate) loaded_config_files: usize,
|
||||||
discovered_config_files: usize,
|
pub(crate) discovered_config_files: usize,
|
||||||
memory_file_count: usize,
|
pub(crate) memory_file_count: usize,
|
||||||
project_root: Option<PathBuf>,
|
pub(crate) project_root: Option<PathBuf>,
|
||||||
git_branch: Option<String>,
|
pub(crate) git_branch: Option<String>,
|
||||||
git_summary: GitWorkspaceSummary,
|
pub(crate) git_summary: GitWorkspaceSummary,
|
||||||
session_lifecycle: SessionLifecycleSummary,
|
pub(crate) session_lifecycle: SessionLifecycleSummary,
|
||||||
sandbox_status: runtime::SandboxStatus,
|
pub(crate) sandbox_status: runtime::SandboxStatus,
|
||||||
/// #143: when `.claw.json` (or another loaded config file) fails to parse,
|
/// #143: when `.claw.json` (or another loaded config file) fails to parse,
|
||||||
/// we capture the parse error here and still populate every field that
|
/// we capture the parse error here and still populate every field that
|
||||||
/// doesn't depend on runtime config (workspace, git, sandbox defaults,
|
/// doesn't depend on runtime config (workspace, git, sandbox defaults,
|
||||||
|
|
@ -861,121 +863,6 @@ impl GitWorkspaceSummary {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
pub(crate) fn parse_git_status_metadata(status: Option<&str>) -> (Option<PathBuf>, Option<String>) {
|
|
||||||
parse_git_status_metadata_for(
|
|
||||||
&env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
|
|
||||||
status,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_git_status_branch(status: Option<&str>) -> Option<String> {
|
|
||||||
let status = status?;
|
|
||||||
let first_line = status.lines().next()?;
|
|
||||||
let line = first_line.strip_prefix("## ")?;
|
|
||||||
if line.starts_with("HEAD") {
|
|
||||||
return Some("detached HEAD".to_string());
|
|
||||||
}
|
|
||||||
let branch = line.split(['.', ' ']).next().unwrap_or_default().trim();
|
|
||||||
if branch.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(branch.to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_git_workspace_summary(status: Option<&str>) -> GitWorkspaceSummary {
|
|
||||||
let mut summary = GitWorkspaceSummary::default();
|
|
||||||
let Some(status) = status else {
|
|
||||||
return summary;
|
|
||||||
};
|
|
||||||
|
|
||||||
for line in status.lines() {
|
|
||||||
if line.starts_with("## ") || line.trim().is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
summary.changed_files += 1;
|
|
||||||
let mut chars = line.chars();
|
|
||||||
let index_status = chars.next().unwrap_or(' ');
|
|
||||||
let worktree_status = chars.next().unwrap_or(' ');
|
|
||||||
|
|
||||||
if index_status == '?' && worktree_status == '?' {
|
|
||||||
summary.untracked_files += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if index_status != ' ' {
|
|
||||||
summary.staged_files += 1;
|
|
||||||
}
|
|
||||||
if worktree_status != ' ' {
|
|
||||||
summary.unstaged_files += 1;
|
|
||||||
}
|
|
||||||
if (matches!(index_status, 'U' | 'A') && matches!(worktree_status, 'U' | 'A'))
|
|
||||||
|| index_status == 'U'
|
|
||||||
|| worktree_status == 'U'
|
|
||||||
{
|
|
||||||
summary.conflicted_files += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
summary
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_git_branch_for(cwd: &Path) -> Option<String> {
|
|
||||||
let branch = run_git_capture_in(cwd, &["branch", "--show-current"])?;
|
|
||||||
let branch = branch.trim();
|
|
||||||
if !branch.is_empty() {
|
|
||||||
return Some(branch.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let fallback = run_git_capture_in(cwd, &["rev-parse", "--abbrev-ref", "HEAD"])?;
|
|
||||||
let fallback = fallback.trim();
|
|
||||||
if fallback.is_empty() {
|
|
||||||
None
|
|
||||||
} else if fallback == "HEAD" {
|
|
||||||
Some("detached HEAD".to_string())
|
|
||||||
} else {
|
|
||||||
Some(fallback.to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_git_capture_in(cwd: &Path, args: &[&str]) -> Option<String> {
|
|
||||||
let output = std::process::Command::new("git")
|
|
||||||
.args(args)
|
|
||||||
.current_dir(cwd)
|
|
||||||
.output()
|
|
||||||
.ok()?;
|
|
||||||
if !output.status.success() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
String::from_utf8(output.stdout).ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn find_git_root_in(cwd: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
|
||||||
let output = std::process::Command::new("git")
|
|
||||||
.args(["rev-parse", "--show-toplevel"])
|
|
||||||
.current_dir(cwd)
|
|
||||||
.output()?;
|
|
||||||
if !output.status.success() {
|
|
||||||
return Err("not a git repository".into());
|
|
||||||
}
|
|
||||||
let path = String::from_utf8(output.stdout)?.trim().to_string();
|
|
||||||
if path.is_empty() {
|
|
||||||
return Err("empty git root".into());
|
|
||||||
}
|
|
||||||
Ok(PathBuf::from(path))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_git_status_metadata_for(
|
|
||||||
cwd: &Path,
|
|
||||||
status: Option<&str>,
|
|
||||||
) -> (Option<PathBuf>, Option<String>) {
|
|
||||||
let branch = resolve_git_branch_for(cwd).or_else(|| parse_git_status_branch(status));
|
|
||||||
let project_root = find_git_root_in(cwd).ok();
|
|
||||||
(project_root, branch)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::too_many_lines)]
|
|
||||||
fn run_resume_command(
|
fn run_resume_command(
|
||||||
session_path: &Path,
|
session_path: &Path,
|
||||||
session: &Session,
|
session: &Session,
|
||||||
|
|
@ -1963,499 +1850,6 @@ impl HookAbortMonitor {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn print_status_snapshot(
|
|
||||||
model: &str,
|
|
||||||
model_flag_raw: Option<&str>,
|
|
||||||
permission_mode: PermissionMode,
|
|
||||||
output_format: CliOutputFormat,
|
|
||||||
allowed_tools: Option<&AllowedToolSet>,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let usage = StatusUsage {
|
|
||||||
message_count: 0,
|
|
||||||
turns: 0,
|
|
||||||
latest: TokenUsage::default(),
|
|
||||||
cumulative: TokenUsage::default(),
|
|
||||||
estimated_tokens: 0,
|
|
||||||
};
|
|
||||||
let context = status_context(None)?;
|
|
||||||
// #148: resolve model provenance. If user passed --model, source is
|
|
||||||
// "flag" with the raw input preserved. Otherwise probe env -> config
|
|
||||||
// -> default and record the winning source.
|
|
||||||
let provenance = match model_flag_raw {
|
|
||||||
Some(raw) => ModelProvenance {
|
|
||||||
resolved: model.to_string(),
|
|
||||||
raw: Some(raw.to_string()),
|
|
||||||
source: ModelSource::Flag,
|
|
||||||
},
|
|
||||||
None => ModelProvenance::from_env_or_config_or_default(model),
|
|
||||||
};
|
|
||||||
match output_format {
|
|
||||||
CliOutputFormat::Text => println!(
|
|
||||||
"{}",
|
|
||||||
format_status_report(
|
|
||||||
&provenance.resolved,
|
|
||||||
usage,
|
|
||||||
permission_mode.as_str(),
|
|
||||||
&context,
|
|
||||||
Some(&provenance)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
CliOutputFormat::Json => println!(
|
|
||||||
"{}",
|
|
||||||
serde_json::to_string_pretty(&status_json_value(
|
|
||||||
Some(&provenance.resolved),
|
|
||||||
usage,
|
|
||||||
permission_mode.as_str(),
|
|
||||||
&context,
|
|
||||||
Some(&provenance),
|
|
||||||
allowed_tools,
|
|
||||||
))?
|
|
||||||
),
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn status_json_value(
|
|
||||||
model: Option<&str>,
|
|
||||||
usage: StatusUsage,
|
|
||||||
permission_mode: &str,
|
|
||||||
context: &StatusContext,
|
|
||||||
// #148: optional provenance for `model` field. Surfaces `model_source`
|
|
||||||
// ("flag" | "env" | "config" | "default") and `model_raw` (user input
|
|
||||||
// before alias resolution, or null when source is "default"). Callers
|
|
||||||
// that don't have provenance (legacy resume paths) pass None, in which
|
|
||||||
// case both new fields are omitted.
|
|
||||||
provenance: Option<&ModelProvenance>,
|
|
||||||
allowed_tools: Option<&AllowedToolSet>,
|
|
||||||
) -> serde_json::Value {
|
|
||||||
// #143: top-level `status` marker so claws can distinguish
|
|
||||||
// a clean run from a degraded run (config parse failed but other fields
|
|
||||||
// are still populated). `config_load_error` carries the parse-error string
|
|
||||||
// when present; it's a string rather than a typed object in Phase 1 and
|
|
||||||
// will join the typed-error taxonomy in Phase 2 (ROADMAP §4.44).
|
|
||||||
let degraded = context.config_load_error.is_some();
|
|
||||||
let model_source = provenance.map(|p| p.source.as_str());
|
|
||||||
let model_raw = provenance.and_then(|p| p.raw.clone());
|
|
||||||
let allowed_tool_entries = allowed_tools.map(|tools| tools.iter().cloned().collect::<Vec<_>>());
|
|
||||||
json!({
|
|
||||||
"kind": "status",
|
|
||||||
"status": if degraded { "degraded" } else { "ok" },
|
|
||||||
"config_load_error": context.config_load_error,
|
|
||||||
"model": model,
|
|
||||||
"model_source": model_source,
|
|
||||||
"model_raw": model_raw,
|
|
||||||
"permission_mode": permission_mode,
|
|
||||||
"allowed_tools": {
|
|
||||||
"source": if allowed_tools.is_some() { "flag" } else { "default" },
|
|
||||||
"restricted": allowed_tools.is_some(),
|
|
||||||
"entries": allowed_tool_entries,
|
|
||||||
},
|
|
||||||
"usage": {
|
|
||||||
"messages": usage.message_count,
|
|
||||||
"turns": usage.turns,
|
|
||||||
"latest_total": usage.latest.total_tokens(),
|
|
||||||
"cumulative_input": usage.cumulative.input_tokens,
|
|
||||||
"cumulative_output": usage.cumulative.output_tokens,
|
|
||||||
"cumulative_total": usage.cumulative.total_tokens(),
|
|
||||||
"estimated_tokens": usage.estimated_tokens,
|
|
||||||
},
|
|
||||||
"workspace": {
|
|
||||||
"cwd": context.cwd,
|
|
||||||
"project_root": context.project_root,
|
|
||||||
"git_branch": context.git_branch,
|
|
||||||
"git_state": context.git_summary.headline(),
|
|
||||||
"changed_files": context.git_summary.changed_files,
|
|
||||||
"staged_files": context.git_summary.staged_files,
|
|
||||||
"unstaged_files": context.git_summary.unstaged_files,
|
|
||||||
"untracked_files": context.git_summary.untracked_files,
|
|
||||||
"session": context.session_path.as_ref().map_or_else(|| "live-repl".to_string(), |path| path.display().to_string()),
|
|
||||||
"session_id": context.session_path.as_ref().and_then(|path| {
|
|
||||||
// Session files are named <session-id>.jsonl directly under
|
|
||||||
// .claw/sessions/. Extract the stem (drop the .jsonl extension).
|
|
||||||
path.file_stem().map(|n| n.to_string_lossy().into_owned())
|
|
||||||
}),
|
|
||||||
"loaded_config_files": context.loaded_config_files,
|
|
||||||
"discovered_config_files": context.discovered_config_files,
|
|
||||||
"memory_file_count": context.memory_file_count,
|
|
||||||
},
|
|
||||||
"sandbox": {
|
|
||||||
"enabled": context.sandbox_status.enabled,
|
|
||||||
"active": context.sandbox_status.active,
|
|
||||||
"supported": context.sandbox_status.supported,
|
|
||||||
"in_container": context.sandbox_status.in_container,
|
|
||||||
"requested_namespace": context.sandbox_status.requested.namespace_restrictions,
|
|
||||||
"active_namespace": context.sandbox_status.namespace_active,
|
|
||||||
"requested_network": context.sandbox_status.requested.network_isolation,
|
|
||||||
"active_network": context.sandbox_status.network_active,
|
|
||||||
"filesystem_mode": context.sandbox_status.filesystem_mode.as_str(),
|
|
||||||
"filesystem_active": context.sandbox_status.filesystem_active,
|
|
||||||
"allowed_mounts": context.sandbox_status.allowed_mounts,
|
|
||||||
"markers": context.sandbox_status.container_markers,
|
|
||||||
"fallback_reason": context.sandbox_status.fallback_reason,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn status_context(
|
|
||||||
session_path: Option<&Path>,
|
|
||||||
) -> Result<StatusContext, Box<dyn std::error::Error>> {
|
|
||||||
let cwd = env::current_dir()?;
|
|
||||||
let loader = ConfigLoader::default_for(&cwd);
|
|
||||||
let discovered_config_files = loader.discover().len();
|
|
||||||
// #143: degrade gracefully on config parse failure rather than hard-fail.
|
|
||||||
// `claw doctor` already does this; `claw status` now matches that contract
|
|
||||||
// so that one malformed `mcpServers.*` entry doesn't take down the whole
|
|
||||||
// health surface (workspace, git, model, permission, sandbox can still be
|
|
||||||
// reported independently).
|
|
||||||
let (loaded_config_files, sandbox_status, config_load_error) = match loader.load() {
|
|
||||||
Ok(runtime_config) => (
|
|
||||||
runtime_config.loaded_entries().len(),
|
|
||||||
resolve_sandbox_status(runtime_config.sandbox(), &cwd),
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
Err(err) => (
|
|
||||||
0,
|
|
||||||
// Fall back to defaults for sandbox resolution so claws still see
|
|
||||||
// a populated sandbox section instead of a missing field. Defaults
|
|
||||||
// produce the same output as a runtime config with no sandbox
|
|
||||||
// overrides, which is the right degraded-mode shape: we cannot
|
|
||||||
// report what the user *intended*, only what is actually in effect.
|
|
||||||
resolve_sandbox_status(&runtime::SandboxConfig::default(), &cwd),
|
|
||||||
Some(err.to_string()),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
let project_context = ProjectContext::discover_with_git(&cwd, DEFAULT_DATE)?;
|
|
||||||
let (project_root, git_branch) =
|
|
||||||
parse_git_status_metadata(project_context.git_status.as_deref());
|
|
||||||
let git_summary = parse_git_workspace_summary(project_context.git_status.as_deref());
|
|
||||||
let lifecycle = classify_session_lifecycle_for(&cwd);
|
|
||||||
Ok(StatusContext {
|
|
||||||
cwd,
|
|
||||||
session_path: session_path.map(Path::to_path_buf),
|
|
||||||
loaded_config_files,
|
|
||||||
discovered_config_files,
|
|
||||||
memory_file_count: project_context.instruction_files.len(),
|
|
||||||
project_root,
|
|
||||||
git_branch,
|
|
||||||
git_summary,
|
|
||||||
session_lifecycle: lifecycle,
|
|
||||||
sandbox_status,
|
|
||||||
config_load_error,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn classify_session_lifecycle_for(workspace: &Path) -> SessionLifecycleSummary {
|
|
||||||
classify_session_lifecycle_from_panes(workspace, discover_tmux_panes())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn classify_session_lifecycle_from_panes(
|
|
||||||
workspace: &Path,
|
|
||||||
panes: Vec<TmuxPaneSnapshot>,
|
|
||||||
) -> SessionLifecycleSummary {
|
|
||||||
let workspace_dirty = git_worktree_is_dirty(workspace);
|
|
||||||
let mut idle_shell = None;
|
|
||||||
for pane in panes {
|
|
||||||
if !pane_path_matches_workspace(&pane.current_path, workspace) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if is_idle_shell_command(&pane.current_command) {
|
|
||||||
idle_shell.get_or_insert(pane);
|
|
||||||
} else {
|
|
||||||
return SessionLifecycleSummary {
|
|
||||||
kind: SessionLifecycleKind::RunningProcess,
|
|
||||||
pane_id: Some(pane.pane_id),
|
|
||||||
pane_command: Some(pane.current_command),
|
|
||||||
pane_path: Some(pane.current_path),
|
|
||||||
workspace_dirty,
|
|
||||||
abandoned: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(pane) = idle_shell {
|
|
||||||
SessionLifecycleSummary {
|
|
||||||
kind: SessionLifecycleKind::IdleShell,
|
|
||||||
pane_id: Some(pane.pane_id),
|
|
||||||
pane_command: Some(pane.current_command),
|
|
||||||
pane_path: Some(pane.current_path),
|
|
||||||
workspace_dirty,
|
|
||||||
abandoned: workspace_dirty,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
SessionLifecycleSummary {
|
|
||||||
kind: SessionLifecycleKind::SavedOnly,
|
|
||||||
pane_id: None,
|
|
||||||
pane_command: None,
|
|
||||||
pane_path: None,
|
|
||||||
workspace_dirty,
|
|
||||||
abandoned: workspace_dirty,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn discover_tmux_panes() -> Vec<TmuxPaneSnapshot> {
|
|
||||||
let output = Command::new("tmux")
|
|
||||||
.args([
|
|
||||||
"list-panes",
|
|
||||||
"-a",
|
|
||||||
"-F",
|
|
||||||
"#{pane_id}\t#{pane_current_command}\t#{pane_current_path}",
|
|
||||||
])
|
|
||||||
.output();
|
|
||||||
let Ok(output) = output else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
if !output.status.success() {
|
|
||||||
return Vec::new();
|
|
||||||
}
|
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
||||||
parse_tmux_pane_snapshots(&stdout)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_tmux_pane_snapshots(output: &str) -> Vec<TmuxPaneSnapshot> {
|
|
||||||
output
|
|
||||||
.lines()
|
|
||||||
.filter_map(|line| {
|
|
||||||
let mut fields = line.splitn(3, '\t');
|
|
||||||
let pane_id = fields.next()?.trim();
|
|
||||||
let current_command = fields.next()?.trim();
|
|
||||||
let current_path = fields.next()?.trim();
|
|
||||||
if pane_id.is_empty() || current_path.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(TmuxPaneSnapshot {
|
|
||||||
pane_id: pane_id.to_string(),
|
|
||||||
current_command: current_command.to_string(),
|
|
||||||
current_path: PathBuf::from(current_path),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pane_path_matches_workspace(pane_path: &Path, workspace: &Path) -> bool {
|
|
||||||
let pane_path = fs::canonicalize(pane_path).unwrap_or_else(|_| pane_path.to_path_buf());
|
|
||||||
let workspace = fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf());
|
|
||||||
pane_path == workspace || pane_path.starts_with(&workspace)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_idle_shell_command(command: &str) -> bool {
|
|
||||||
let command = command.rsplit('/').next().unwrap_or(command);
|
|
||||||
matches!(
|
|
||||||
command,
|
|
||||||
"bash" | "zsh" | "sh" | "fish" | "nu" | "pwsh" | "powershell" | "cmd"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn git_worktree_is_dirty(workspace: &Path) -> bool {
|
|
||||||
let output = Command::new("git")
|
|
||||||
.arg("-C")
|
|
||||||
.arg(workspace)
|
|
||||||
.args(["status", "--porcelain"])
|
|
||||||
.output();
|
|
||||||
output
|
|
||||||
.ok()
|
|
||||||
.filter(|output| output.status.success())
|
|
||||||
.is_some_and(|output| !output.stdout.is_empty())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_status_report(
|
|
||||||
model: &str,
|
|
||||||
usage: StatusUsage,
|
|
||||||
permission_mode: &str,
|
|
||||||
context: &StatusContext,
|
|
||||||
// #148: optional model provenance to surface in a `Model source` line.
|
|
||||||
// Callers without provenance (legacy resume paths) pass None and the
|
|
||||||
// source line is omitted for backward compat.
|
|
||||||
provenance: Option<&ModelProvenance>,
|
|
||||||
) -> String {
|
|
||||||
// #143: if config failed to parse, surface a degraded banner at the top
|
|
||||||
// of the text report so humans see the parse error before the body, while
|
|
||||||
// the body below still reports everything that could be resolved without
|
|
||||||
// config (workspace, git, sandbox defaults, etc.).
|
|
||||||
let status_line = if context.config_load_error.is_some() {
|
|
||||||
"Status (degraded)"
|
|
||||||
} else {
|
|
||||||
"Status"
|
|
||||||
};
|
|
||||||
let mut blocks: Vec<String> = Vec::new();
|
|
||||||
if let Some(err) = context.config_load_error.as_deref() {
|
|
||||||
blocks.push(format!(
|
|
||||||
"Config load error\n Status fail\n Summary runtime config failed to load; reporting partial status\n Details {err}\n Hint `claw doctor` classifies config parse errors; fix the listed field and rerun"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
// #148: render Model source line after Model, showing where the string
|
|
||||||
// came from (flag / env / config / default) and the raw input if any.
|
|
||||||
let model_source_line = provenance
|
|
||||||
.map(|p| match &p.raw {
|
|
||||||
Some(raw) if raw != model => {
|
|
||||||
format!("\n Model source {} (raw: {raw})", p.source.as_str())
|
|
||||||
}
|
|
||||||
_ => format!("\n Model source {}", p.source.as_str()),
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
|
||||||
blocks.extend([
|
|
||||||
format!(
|
|
||||||
"{status_line}
|
|
||||||
Model {model}{model_source_line}
|
|
||||||
Permission mode {permission_mode}
|
|
||||||
Messages {}
|
|
||||||
Turns {}
|
|
||||||
Estimated tokens {}",
|
|
||||||
usage.message_count, usage.turns, usage.estimated_tokens,
|
|
||||||
),
|
|
||||||
format!(
|
|
||||||
"Usage
|
|
||||||
Latest total {}
|
|
||||||
Cumulative input {}
|
|
||||||
Cumulative output {}
|
|
||||||
Cumulative total {}",
|
|
||||||
usage.latest.total_tokens(),
|
|
||||||
usage.cumulative.input_tokens,
|
|
||||||
usage.cumulative.output_tokens,
|
|
||||||
usage.cumulative.total_tokens(),
|
|
||||||
),
|
|
||||||
format!(
|
|
||||||
"Workspace
|
|
||||||
Cwd {}
|
|
||||||
Project root {}
|
|
||||||
Git branch {}
|
|
||||||
Git state {}
|
|
||||||
Changed files {}
|
|
||||||
Staged {}
|
|
||||||
Unstaged {}
|
|
||||||
Untracked {}
|
|
||||||
Session {}
|
|
||||||
Config files loaded {}/{}
|
|
||||||
Memory files {}
|
|
||||||
Suggested flow /status → /diff → /commit",
|
|
||||||
context.cwd.display(),
|
|
||||||
context
|
|
||||||
.project_root
|
|
||||||
.as_ref()
|
|
||||||
.map_or_else(|| "unknown".to_string(), |path| path.display().to_string()),
|
|
||||||
context.git_branch.as_deref().unwrap_or("unknown"),
|
|
||||||
context.git_summary.headline(),
|
|
||||||
context.git_summary.changed_files,
|
|
||||||
context.git_summary.staged_files,
|
|
||||||
context.git_summary.unstaged_files,
|
|
||||||
context.git_summary.untracked_files,
|
|
||||||
context.session_path.as_ref().map_or_else(
|
|
||||||
|| "live-repl".to_string(),
|
|
||||||
|path| path.display().to_string()
|
|
||||||
),
|
|
||||||
context.loaded_config_files,
|
|
||||||
context.discovered_config_files,
|
|
||||||
context.memory_file_count,
|
|
||||||
),
|
|
||||||
format_sandbox_report(&context.sandbox_status),
|
|
||||||
]);
|
|
||||||
blocks.join("\n\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_sandbox_report(status: &runtime::SandboxStatus) -> String {
|
|
||||||
format!(
|
|
||||||
"Sandbox
|
|
||||||
Enabled {}
|
|
||||||
Active {}
|
|
||||||
Supported {}
|
|
||||||
In container {}
|
|
||||||
Requested ns {}
|
|
||||||
Active ns {}
|
|
||||||
Requested net {}
|
|
||||||
Active net {}
|
|
||||||
Filesystem mode {}
|
|
||||||
Filesystem active {}
|
|
||||||
Allowed mounts {}
|
|
||||||
Markers {}
|
|
||||||
Fallback reason {}",
|
|
||||||
status.enabled,
|
|
||||||
status.active,
|
|
||||||
status.supported,
|
|
||||||
status.in_container,
|
|
||||||
status.requested.namespace_restrictions,
|
|
||||||
status.namespace_active,
|
|
||||||
status.requested.network_isolation,
|
|
||||||
status.network_active,
|
|
||||||
status.filesystem_mode.as_str(),
|
|
||||||
status.filesystem_active,
|
|
||||||
if status.allowed_mounts.is_empty() {
|
|
||||||
"<none>".to_string()
|
|
||||||
} else {
|
|
||||||
status.allowed_mounts.join(", ")
|
|
||||||
},
|
|
||||||
if status.container_markers.is_empty() {
|
|
||||||
"<none>".to_string()
|
|
||||||
} else {
|
|
||||||
status.container_markers.join(", ")
|
|
||||||
},
|
|
||||||
status
|
|
||||||
.fallback_reason
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| "<none>".to_string()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_commit_preflight_report(branch: Option<&str>, summary: GitWorkspaceSummary) -> String {
|
|
||||||
format!(
|
|
||||||
"Commit
|
|
||||||
Result ready
|
|
||||||
Branch {}
|
|
||||||
Workspace {}
|
|
||||||
Changed files {}
|
|
||||||
Action create a git commit from the current workspace changes",
|
|
||||||
branch.unwrap_or("unknown"),
|
|
||||||
summary.headline(),
|
|
||||||
summary.changed_files,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_commit_skipped_report() -> String {
|
|
||||||
"Commit
|
|
||||||
Result skipped
|
|
||||||
Reason no workspace changes
|
|
||||||
Action create a git commit from the current workspace changes
|
|
||||||
Next /status to inspect context · /diff to inspect repo changes"
|
|
||||||
.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_sandbox_status_snapshot(
|
|
||||||
output_format: CliOutputFormat,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let cwd = env::current_dir()?;
|
|
||||||
let loader = ConfigLoader::default_for(&cwd);
|
|
||||||
let runtime_config = loader
|
|
||||||
.load()
|
|
||||||
.unwrap_or_else(|_| runtime::RuntimeConfig::empty());
|
|
||||||
let status = resolve_sandbox_status(runtime_config.sandbox(), &cwd);
|
|
||||||
match output_format {
|
|
||||||
CliOutputFormat::Text => println!("{}", format_sandbox_report(&status)),
|
|
||||||
CliOutputFormat::Json => println!(
|
|
||||||
"{}",
|
|
||||||
serde_json::to_string_pretty(&sandbox_json_value(&status))?
|
|
||||||
),
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sandbox_json_value(status: &runtime::SandboxStatus) -> serde_json::Value {
|
|
||||||
json!({
|
|
||||||
"kind": "sandbox",
|
|
||||||
"enabled": status.enabled,
|
|
||||||
"active": status.active,
|
|
||||||
"supported": status.supported,
|
|
||||||
"in_container": status.in_container,
|
|
||||||
"requested_namespace": status.requested.namespace_restrictions,
|
|
||||||
"active_namespace": status.namespace_active,
|
|
||||||
"requested_network": status.requested.network_isolation,
|
|
||||||
"active_network": status.network_active,
|
|
||||||
"filesystem_mode": status.filesystem_mode.as_str(),
|
|
||||||
"filesystem_active": status.filesystem_active,
|
|
||||||
"allowed_mounts": status.allowed_mounts,
|
|
||||||
"markers": status.container_markers,
|
|
||||||
"fallback_reason": status.fallback_reason,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_help_topic(topic: LocalHelpTopic) -> String {
|
fn render_help_topic(topic: LocalHelpTopic) -> String {
|
||||||
match topic {
|
match topic {
|
||||||
LocalHelpTopic::Status => "Status
|
LocalHelpTopic::Status => "Status
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,619 @@
|
||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
use serde_json::{json, Map, Value};
|
||||||
|
use runtime::{ConfigLoader, PermissionMode, ProjectContext, SandboxConfig, SandboxStatus, TokenUsage};
|
||||||
|
use runtime::resolve_sandbox_status;
|
||||||
|
use crate::args::*;
|
||||||
|
use crate::model::*;
|
||||||
|
use crate::*;
|
||||||
|
|
||||||
|
pub(crate) fn parse_git_status_metadata(status: Option<&str>) -> (Option<PathBuf>, Option<String>) {
|
||||||
|
parse_git_status_metadata_for(
|
||||||
|
&env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_git_status_branch(status: Option<&str>) -> Option<String> {
|
||||||
|
let status = status?;
|
||||||
|
let first_line = status.lines().next()?;
|
||||||
|
let line = first_line.strip_prefix("## ")?;
|
||||||
|
if line.starts_with("HEAD") {
|
||||||
|
return Some("detached HEAD".to_string());
|
||||||
|
}
|
||||||
|
let branch = line.split(['.', ' ']).next().unwrap_or_default().trim();
|
||||||
|
if branch.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(branch.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_git_workspace_summary(status: Option<&str>) -> GitWorkspaceSummary {
|
||||||
|
let mut summary = GitWorkspaceSummary::default();
|
||||||
|
let Some(status) = status else {
|
||||||
|
return summary;
|
||||||
|
};
|
||||||
|
|
||||||
|
for line in status.lines() {
|
||||||
|
if line.starts_with("## ") || line.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.changed_files += 1;
|
||||||
|
let mut chars = line.chars();
|
||||||
|
let index_status = chars.next().unwrap_or(' ');
|
||||||
|
let worktree_status = chars.next().unwrap_or(' ');
|
||||||
|
|
||||||
|
if index_status == '?' && worktree_status == '?' {
|
||||||
|
summary.untracked_files += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if index_status != ' ' {
|
||||||
|
summary.staged_files += 1;
|
||||||
|
}
|
||||||
|
if worktree_status != ' ' {
|
||||||
|
summary.unstaged_files += 1;
|
||||||
|
}
|
||||||
|
if (matches!(index_status, 'U' | 'A') && matches!(worktree_status, 'U' | 'A'))
|
||||||
|
|| index_status == 'U'
|
||||||
|
|| worktree_status == 'U'
|
||||||
|
{
|
||||||
|
summary.conflicted_files += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
summary
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn resolve_git_branch_for(cwd: &Path) -> Option<String> {
|
||||||
|
let branch = run_git_capture_in(cwd, &["branch", "--show-current"])?;
|
||||||
|
let branch = branch.trim();
|
||||||
|
if !branch.is_empty() {
|
||||||
|
return Some(branch.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let fallback = run_git_capture_in(cwd, &["rev-parse", "--abbrev-ref", "HEAD"])?;
|
||||||
|
let fallback = fallback.trim();
|
||||||
|
if fallback.is_empty() {
|
||||||
|
None
|
||||||
|
} else if fallback == "HEAD" {
|
||||||
|
Some("detached HEAD".to_string())
|
||||||
|
} else {
|
||||||
|
Some(fallback.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn run_git_capture_in(cwd: &Path, args: &[&str]) -> Option<String> {
|
||||||
|
let output = std::process::Command::new("git")
|
||||||
|
.args(args)
|
||||||
|
.current_dir(cwd)
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
String::from_utf8(output.stdout).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn find_git_root_in(cwd: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||||
|
let output = std::process::Command::new("git")
|
||||||
|
.args(["rev-parse", "--show-toplevel"])
|
||||||
|
.current_dir(cwd)
|
||||||
|
.output()?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err("not a git repository".into());
|
||||||
|
}
|
||||||
|
let path = String::from_utf8(output.stdout)?.trim().to_string();
|
||||||
|
if path.is_empty() {
|
||||||
|
return Err("empty git root".into());
|
||||||
|
}
|
||||||
|
Ok(PathBuf::from(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_git_status_metadata_for(
|
||||||
|
cwd: &Path,
|
||||||
|
status: Option<&str>,
|
||||||
|
) -> (Option<PathBuf>, Option<String>) {
|
||||||
|
let branch = resolve_git_branch_for(cwd).or_else(|| parse_git_status_branch(status));
|
||||||
|
let project_root = find_git_root_in(cwd).ok();
|
||||||
|
(project_root, branch)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
|
pub(crate) fn print_status_snapshot(
|
||||||
|
model: &str,
|
||||||
|
model_flag_raw: Option<&str>,
|
||||||
|
permission_mode: PermissionMode,
|
||||||
|
output_format: CliOutputFormat,
|
||||||
|
allowed_tools: Option<&AllowedToolSet>,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let usage = StatusUsage {
|
||||||
|
message_count: 0,
|
||||||
|
turns: 0,
|
||||||
|
latest: TokenUsage::default(),
|
||||||
|
cumulative: TokenUsage::default(),
|
||||||
|
estimated_tokens: 0,
|
||||||
|
};
|
||||||
|
let context = status_context(None)?;
|
||||||
|
// #148: resolve model provenance. If user passed --model, source is
|
||||||
|
// "flag" with the raw input preserved. Otherwise probe env -> config
|
||||||
|
// -> default and record the winning source.
|
||||||
|
let provenance = match model_flag_raw {
|
||||||
|
Some(raw) => ModelProvenance {
|
||||||
|
resolved: model.to_string(),
|
||||||
|
raw: Some(raw.to_string()),
|
||||||
|
source: ModelSource::Flag,
|
||||||
|
},
|
||||||
|
None => ModelProvenance::from_env_or_config_or_default(model),
|
||||||
|
};
|
||||||
|
match output_format {
|
||||||
|
CliOutputFormat::Text => println!(
|
||||||
|
"{}",
|
||||||
|
format_status_report(
|
||||||
|
&provenance.resolved,
|
||||||
|
usage,
|
||||||
|
permission_mode.as_str(),
|
||||||
|
&context,
|
||||||
|
Some(&provenance)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
CliOutputFormat::Json => println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::to_string_pretty(&status_json_value(
|
||||||
|
Some(&provenance.resolved),
|
||||||
|
usage,
|
||||||
|
permission_mode.as_str(),
|
||||||
|
&context,
|
||||||
|
Some(&provenance),
|
||||||
|
allowed_tools,
|
||||||
|
))?
|
||||||
|
),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn status_json_value(
|
||||||
|
model: Option<&str>,
|
||||||
|
usage: StatusUsage,
|
||||||
|
permission_mode: &str,
|
||||||
|
context: &StatusContext,
|
||||||
|
// #148: optional provenance for `model` field. Surfaces `model_source`
|
||||||
|
// ("flag" | "env" | "config" | "default") and `model_raw` (user input
|
||||||
|
// before alias resolution, or null when source is "default"). Callers
|
||||||
|
// that don't have provenance (legacy resume paths) pass None, in which
|
||||||
|
// case both new fields are omitted.
|
||||||
|
provenance: Option<&ModelProvenance>,
|
||||||
|
allowed_tools: Option<&AllowedToolSet>,
|
||||||
|
) -> serde_json::Value {
|
||||||
|
// #143: top-level `status` marker so claws can distinguish
|
||||||
|
// a clean run from a degraded run (config parse failed but other fields
|
||||||
|
// are still populated). `config_load_error` carries the parse-error string
|
||||||
|
// when present; it's a string rather than a typed object in Phase 1 and
|
||||||
|
// will join the typed-error taxonomy in Phase 2 (ROADMAP §4.44).
|
||||||
|
let degraded = context.config_load_error.is_some();
|
||||||
|
let model_source = provenance.map(|p| p.source.as_str());
|
||||||
|
let model_raw = provenance.and_then(|p| p.raw.clone());
|
||||||
|
let allowed_tool_entries = allowed_tools.map(|tools| tools.iter().cloned().collect::<Vec<_>>());
|
||||||
|
json!({
|
||||||
|
"kind": "status",
|
||||||
|
"status": if degraded { "degraded" } else { "ok" },
|
||||||
|
"config_load_error": context.config_load_error,
|
||||||
|
"model": model,
|
||||||
|
"model_source": model_source,
|
||||||
|
"model_raw": model_raw,
|
||||||
|
"permission_mode": permission_mode,
|
||||||
|
"allowed_tools": {
|
||||||
|
"source": if allowed_tools.is_some() { "flag" } else { "default" },
|
||||||
|
"restricted": allowed_tools.is_some(),
|
||||||
|
"entries": allowed_tool_entries,
|
||||||
|
},
|
||||||
|
"usage": {
|
||||||
|
"messages": usage.message_count,
|
||||||
|
"turns": usage.turns,
|
||||||
|
"latest_total": usage.latest.total_tokens(),
|
||||||
|
"cumulative_input": usage.cumulative.input_tokens,
|
||||||
|
"cumulative_output": usage.cumulative.output_tokens,
|
||||||
|
"cumulative_total": usage.cumulative.total_tokens(),
|
||||||
|
"estimated_tokens": usage.estimated_tokens,
|
||||||
|
},
|
||||||
|
"workspace": {
|
||||||
|
"cwd": context.cwd,
|
||||||
|
"project_root": context.project_root,
|
||||||
|
"git_branch": context.git_branch,
|
||||||
|
"git_state": context.git_summary.headline(),
|
||||||
|
"changed_files": context.git_summary.changed_files,
|
||||||
|
"staged_files": context.git_summary.staged_files,
|
||||||
|
"unstaged_files": context.git_summary.unstaged_files,
|
||||||
|
"untracked_files": context.git_summary.untracked_files,
|
||||||
|
"session": context.session_path.as_ref().map_or_else(|| "live-repl".to_string(), |path| path.display().to_string()),
|
||||||
|
"session_id": context.session_path.as_ref().and_then(|path| {
|
||||||
|
// Session files are named <session-id>.jsonl directly under
|
||||||
|
// .claw/sessions/. Extract the stem (drop the .jsonl extension).
|
||||||
|
path.file_stem().map(|n| n.to_string_lossy().into_owned())
|
||||||
|
}),
|
||||||
|
"loaded_config_files": context.loaded_config_files,
|
||||||
|
"discovered_config_files": context.discovered_config_files,
|
||||||
|
"memory_file_count": context.memory_file_count,
|
||||||
|
},
|
||||||
|
"sandbox": {
|
||||||
|
"enabled": context.sandbox_status.enabled,
|
||||||
|
"active": context.sandbox_status.active,
|
||||||
|
"supported": context.sandbox_status.supported,
|
||||||
|
"in_container": context.sandbox_status.in_container,
|
||||||
|
"requested_namespace": context.sandbox_status.requested.namespace_restrictions,
|
||||||
|
"active_namespace": context.sandbox_status.namespace_active,
|
||||||
|
"requested_network": context.sandbox_status.requested.network_isolation,
|
||||||
|
"active_network": context.sandbox_status.network_active,
|
||||||
|
"filesystem_mode": context.sandbox_status.filesystem_mode.as_str(),
|
||||||
|
"filesystem_active": context.sandbox_status.filesystem_active,
|
||||||
|
"allowed_mounts": context.sandbox_status.allowed_mounts,
|
||||||
|
"markers": context.sandbox_status.container_markers,
|
||||||
|
"fallback_reason": context.sandbox_status.fallback_reason,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn status_context(
|
||||||
|
session_path: Option<&Path>,
|
||||||
|
) -> Result<StatusContext, Box<dyn std::error::Error>> {
|
||||||
|
let cwd = env::current_dir()?;
|
||||||
|
let loader = ConfigLoader::default_for(&cwd);
|
||||||
|
let discovered_config_files = loader.discover().len();
|
||||||
|
// #143: degrade gracefully on config parse failure rather than hard-fail.
|
||||||
|
// `claw doctor` already does this; `claw status` now matches that contract
|
||||||
|
// so that one malformed `mcpServers.*` entry doesn't take down the whole
|
||||||
|
// health surface (workspace, git, model, permission, sandbox can still be
|
||||||
|
// reported independently).
|
||||||
|
let (loaded_config_files, sandbox_status, config_load_error) = match loader.load() {
|
||||||
|
Ok(runtime_config) => (
|
||||||
|
runtime_config.loaded_entries().len(),
|
||||||
|
resolve_sandbox_status(runtime_config.sandbox(), &cwd),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
Err(err) => (
|
||||||
|
0,
|
||||||
|
// Fall back to defaults for sandbox resolution so claws still see
|
||||||
|
// a populated sandbox section instead of a missing field. Defaults
|
||||||
|
// produce the same output as a runtime config with no sandbox
|
||||||
|
// overrides, which is the right degraded-mode shape: we cannot
|
||||||
|
// report what the user *intended*, only what is actually in effect.
|
||||||
|
resolve_sandbox_status(&runtime::SandboxConfig::default(), &cwd),
|
||||||
|
Some(err.to_string()),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let project_context = ProjectContext::discover_with_git(&cwd, DEFAULT_DATE)?;
|
||||||
|
let (project_root, git_branch) =
|
||||||
|
parse_git_status_metadata(project_context.git_status.as_deref());
|
||||||
|
let git_summary = parse_git_workspace_summary(project_context.git_status.as_deref());
|
||||||
|
let lifecycle = classify_session_lifecycle_for(&cwd);
|
||||||
|
Ok(StatusContext {
|
||||||
|
cwd,
|
||||||
|
session_path: session_path.map(Path::to_path_buf),
|
||||||
|
loaded_config_files,
|
||||||
|
discovered_config_files,
|
||||||
|
memory_file_count: project_context.instruction_files.len(),
|
||||||
|
project_root,
|
||||||
|
git_branch,
|
||||||
|
git_summary,
|
||||||
|
session_lifecycle: lifecycle,
|
||||||
|
sandbox_status,
|
||||||
|
config_load_error,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn classify_session_lifecycle_for(workspace: &Path) -> SessionLifecycleSummary {
|
||||||
|
classify_session_lifecycle_from_panes(workspace, discover_tmux_panes())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn classify_session_lifecycle_from_panes(
|
||||||
|
workspace: &Path,
|
||||||
|
panes: Vec<TmuxPaneSnapshot>,
|
||||||
|
) -> SessionLifecycleSummary {
|
||||||
|
let workspace_dirty = git_worktree_is_dirty(workspace);
|
||||||
|
let mut idle_shell = None;
|
||||||
|
for pane in panes {
|
||||||
|
if !pane_path_matches_workspace(&pane.current_path, workspace) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if is_idle_shell_command(&pane.current_command) {
|
||||||
|
idle_shell.get_or_insert(pane);
|
||||||
|
} else {
|
||||||
|
return SessionLifecycleSummary {
|
||||||
|
kind: SessionLifecycleKind::RunningProcess,
|
||||||
|
pane_id: Some(pane.pane_id),
|
||||||
|
pane_command: Some(pane.current_command),
|
||||||
|
pane_path: Some(pane.current_path),
|
||||||
|
workspace_dirty,
|
||||||
|
abandoned: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(pane) = idle_shell {
|
||||||
|
SessionLifecycleSummary {
|
||||||
|
kind: SessionLifecycleKind::IdleShell,
|
||||||
|
pane_id: Some(pane.pane_id),
|
||||||
|
pane_command: Some(pane.current_command),
|
||||||
|
pane_path: Some(pane.current_path),
|
||||||
|
workspace_dirty,
|
||||||
|
abandoned: workspace_dirty,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
SessionLifecycleSummary {
|
||||||
|
kind: SessionLifecycleKind::SavedOnly,
|
||||||
|
pane_id: None,
|
||||||
|
pane_command: None,
|
||||||
|
pane_path: None,
|
||||||
|
workspace_dirty,
|
||||||
|
abandoned: workspace_dirty,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn discover_tmux_panes() -> Vec<TmuxPaneSnapshot> {
|
||||||
|
let output = Command::new("tmux")
|
||||||
|
.args([
|
||||||
|
"list-panes",
|
||||||
|
"-a",
|
||||||
|
"-F",
|
||||||
|
"#{pane_id}\t#{pane_current_command}\t#{pane_current_path}",
|
||||||
|
])
|
||||||
|
.output();
|
||||||
|
let Ok(output) = output else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
if !output.status.success() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
parse_tmux_pane_snapshots(&stdout)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_tmux_pane_snapshots(output: &str) -> Vec<TmuxPaneSnapshot> {
|
||||||
|
output
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| {
|
||||||
|
let mut fields = line.splitn(3, '\t');
|
||||||
|
let pane_id = fields.next()?.trim();
|
||||||
|
let current_command = fields.next()?.trim();
|
||||||
|
let current_path = fields.next()?.trim();
|
||||||
|
if pane_id.is_empty() || current_path.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(TmuxPaneSnapshot {
|
||||||
|
pane_id: pane_id.to_string(),
|
||||||
|
current_command: current_command.to_string(),
|
||||||
|
current_path: PathBuf::from(current_path),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn pane_path_matches_workspace(pane_path: &Path, workspace: &Path) -> bool {
|
||||||
|
let pane_path = fs::canonicalize(pane_path).unwrap_or_else(|_| pane_path.to_path_buf());
|
||||||
|
let workspace = fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf());
|
||||||
|
pane_path == workspace || pane_path.starts_with(&workspace)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_idle_shell_command(command: &str) -> bool {
|
||||||
|
let command = command.rsplit('/').next().unwrap_or(command);
|
||||||
|
matches!(
|
||||||
|
command,
|
||||||
|
"bash" | "zsh" | "sh" | "fish" | "nu" | "pwsh" | "powershell" | "cmd"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn git_worktree_is_dirty(workspace: &Path) -> bool {
|
||||||
|
let output = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(workspace)
|
||||||
|
.args(["status", "--porcelain"])
|
||||||
|
.output();
|
||||||
|
output
|
||||||
|
.ok()
|
||||||
|
.filter(|output| output.status.success())
|
||||||
|
.is_some_and(|output| !output.stdout.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn format_status_report(
|
||||||
|
model: &str,
|
||||||
|
usage: StatusUsage,
|
||||||
|
permission_mode: &str,
|
||||||
|
context: &StatusContext,
|
||||||
|
// #148: optional model provenance to surface in a `Model source` line.
|
||||||
|
// Callers without provenance (legacy resume paths) pass None and the
|
||||||
|
// source line is omitted for backward compat.
|
||||||
|
provenance: Option<&ModelProvenance>,
|
||||||
|
) -> String {
|
||||||
|
// #143: if config failed to parse, surface a degraded banner at the top
|
||||||
|
// of the text report so humans see the parse error before the body, while
|
||||||
|
// the body below still reports everything that could be resolved without
|
||||||
|
// config (workspace, git, sandbox defaults, etc.).
|
||||||
|
let status_line = if context.config_load_error.is_some() {
|
||||||
|
"Status (degraded)"
|
||||||
|
} else {
|
||||||
|
"Status"
|
||||||
|
};
|
||||||
|
let mut blocks: Vec<String> = Vec::new();
|
||||||
|
if let Some(err) = context.config_load_error.as_deref() {
|
||||||
|
blocks.push(format!(
|
||||||
|
"Config load error\n Status fail\n Summary runtime config failed to load; reporting partial status\n Details {err}\n Hint `claw doctor` classifies config parse errors; fix the listed field and rerun"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// #148: render Model source line after Model, showing where the string
|
||||||
|
// came from (flag / env / config / default) and the raw input if any.
|
||||||
|
let model_source_line = provenance
|
||||||
|
.map(|p| match &p.raw {
|
||||||
|
Some(raw) if raw != model => {
|
||||||
|
format!("\n Model source {} (raw: {raw})", p.source.as_str())
|
||||||
|
}
|
||||||
|
_ => format!("\n Model source {}", p.source.as_str()),
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
blocks.extend([
|
||||||
|
format!(
|
||||||
|
"{status_line}
|
||||||
|
Model {model}{model_source_line}
|
||||||
|
Permission mode {permission_mode}
|
||||||
|
Messages {}
|
||||||
|
Turns {}
|
||||||
|
Estimated tokens {}",
|
||||||
|
usage.message_count, usage.turns, usage.estimated_tokens,
|
||||||
|
),
|
||||||
|
format!(
|
||||||
|
"Usage
|
||||||
|
Latest total {}
|
||||||
|
Cumulative input {}
|
||||||
|
Cumulative output {}
|
||||||
|
Cumulative total {}",
|
||||||
|
usage.latest.total_tokens(),
|
||||||
|
usage.cumulative.input_tokens,
|
||||||
|
usage.cumulative.output_tokens,
|
||||||
|
usage.cumulative.total_tokens(),
|
||||||
|
),
|
||||||
|
format!(
|
||||||
|
"Workspace
|
||||||
|
Cwd {}
|
||||||
|
Project root {}
|
||||||
|
Git branch {}
|
||||||
|
Git state {}
|
||||||
|
Changed files {}
|
||||||
|
Staged {}
|
||||||
|
Unstaged {}
|
||||||
|
Untracked {}
|
||||||
|
Session {}
|
||||||
|
Config files loaded {}/{}
|
||||||
|
Memory files {}
|
||||||
|
Suggested flow /status → /diff → /commit",
|
||||||
|
context.cwd.display(),
|
||||||
|
context
|
||||||
|
.project_root
|
||||||
|
.as_ref()
|
||||||
|
.map_or_else(|| "unknown".to_string(), |path| path.display().to_string()),
|
||||||
|
context.git_branch.as_deref().unwrap_or("unknown"),
|
||||||
|
context.git_summary.headline(),
|
||||||
|
context.git_summary.changed_files,
|
||||||
|
context.git_summary.staged_files,
|
||||||
|
context.git_summary.unstaged_files,
|
||||||
|
context.git_summary.untracked_files,
|
||||||
|
context.session_path.as_ref().map_or_else(
|
||||||
|
|| "live-repl".to_string(),
|
||||||
|
|path| path.display().to_string()
|
||||||
|
),
|
||||||
|
context.loaded_config_files,
|
||||||
|
context.discovered_config_files,
|
||||||
|
context.memory_file_count,
|
||||||
|
),
|
||||||
|
format_sandbox_report(&context.sandbox_status),
|
||||||
|
]);
|
||||||
|
blocks.join("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn format_sandbox_report(status: &runtime::SandboxStatus) -> String {
|
||||||
|
format!(
|
||||||
|
"Sandbox
|
||||||
|
Enabled {}
|
||||||
|
Active {}
|
||||||
|
Supported {}
|
||||||
|
In container {}
|
||||||
|
Requested ns {}
|
||||||
|
Active ns {}
|
||||||
|
Requested net {}
|
||||||
|
Active net {}
|
||||||
|
Filesystem mode {}
|
||||||
|
Filesystem active {}
|
||||||
|
Allowed mounts {}
|
||||||
|
Markers {}
|
||||||
|
Fallback reason {}",
|
||||||
|
status.enabled,
|
||||||
|
status.active,
|
||||||
|
status.supported,
|
||||||
|
status.in_container,
|
||||||
|
status.requested.namespace_restrictions,
|
||||||
|
status.namespace_active,
|
||||||
|
status.requested.network_isolation,
|
||||||
|
status.network_active,
|
||||||
|
status.filesystem_mode.as_str(),
|
||||||
|
status.filesystem_active,
|
||||||
|
if status.allowed_mounts.is_empty() {
|
||||||
|
"<none>".to_string()
|
||||||
|
} else {
|
||||||
|
status.allowed_mounts.join(", ")
|
||||||
|
},
|
||||||
|
if status.container_markers.is_empty() {
|
||||||
|
"<none>".to_string()
|
||||||
|
} else {
|
||||||
|
status.container_markers.join(", ")
|
||||||
|
},
|
||||||
|
status
|
||||||
|
.fallback_reason
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "<none>".to_string()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn format_commit_preflight_report(branch: Option<&str>, summary: GitWorkspaceSummary) -> String {
|
||||||
|
format!(
|
||||||
|
"Commit
|
||||||
|
Result ready
|
||||||
|
Branch {}
|
||||||
|
Workspace {}
|
||||||
|
Changed files {}
|
||||||
|
Action create a git commit from the current workspace changes",
|
||||||
|
branch.unwrap_or("unknown"),
|
||||||
|
summary.headline(),
|
||||||
|
summary.changed_files,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn format_commit_skipped_report() -> String {
|
||||||
|
"Commit
|
||||||
|
Result skipped
|
||||||
|
Reason no workspace changes
|
||||||
|
Action create a git commit from the current workspace changes
|
||||||
|
Next /status to inspect context · /diff to inspect repo changes"
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn print_sandbox_status_snapshot(
|
||||||
|
output_format: CliOutputFormat,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let cwd = env::current_dir()?;
|
||||||
|
let loader = ConfigLoader::default_for(&cwd);
|
||||||
|
let runtime_config = loader
|
||||||
|
.load()
|
||||||
|
.unwrap_or_else(|_| runtime::RuntimeConfig::empty());
|
||||||
|
let status = resolve_sandbox_status(runtime_config.sandbox(), &cwd);
|
||||||
|
match output_format {
|
||||||
|
CliOutputFormat::Text => println!("{}", format_sandbox_report(&status)),
|
||||||
|
CliOutputFormat::Json => println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::to_string_pretty(&sandbox_json_value(&status))?
|
||||||
|
),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn sandbox_json_value(status: &runtime::SandboxStatus) -> serde_json::Value {
|
||||||
|
json!({
|
||||||
|
"kind": "sandbox",
|
||||||
|
"enabled": status.enabled,
|
||||||
|
"active": status.active,
|
||||||
|
"supported": status.supported,
|
||||||
|
"in_container": status.in_container,
|
||||||
|
"requested_namespace": status.requested.namespace_restrictions,
|
||||||
|
"active_namespace": status.namespace_active,
|
||||||
|
"requested_network": status.requested.network_isolation,
|
||||||
|
"active_network": status.network_active,
|
||||||
|
"filesystem_mode": status.filesystem_mode.as_str(),
|
||||||
|
"filesystem_active": status.filesystem_active,
|
||||||
|
"allowed_mounts": status.allowed_mounts,
|
||||||
|
"markers": status.container_markers,
|
||||||
|
"fallback_reason": status.fallback_reason,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Reference in New Issue