mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-03 18:44:49 +08:00
Compare commits
3 Commits
rcc/git
...
rcc/runtim
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccebabe605 | ||
|
|
146260083c | ||
|
|
cd01d0e387 |
@@ -133,6 +133,7 @@ Inside the REPL, useful commands include:
|
|||||||
/diff
|
/diff
|
||||||
/version
|
/version
|
||||||
/export notes.txt
|
/export notes.txt
|
||||||
|
/sessions
|
||||||
/session list
|
/session list
|
||||||
/exit
|
/exit
|
||||||
```
|
```
|
||||||
@@ -143,14 +144,14 @@ Inspect or maintain a saved session file without entering the REPL:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd rust
|
cd rust
|
||||||
cargo run -p rusty-claude-cli -- --resume session.json /status /compact /cost
|
cargo run -p rusty-claude-cli -- --resume session-123456 /status /compact /cost
|
||||||
```
|
```
|
||||||
|
|
||||||
You can also inspect memory/config state for a restored session:
|
You can also inspect memory/config state for a restored session:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd rust
|
cd rust
|
||||||
cargo run -p rusty-claude-cli -- --resume session.json /memory /config
|
cargo run -p rusty-claude-cli -- --resume ~/.claude/sessions/session-123456.json /memory /config
|
||||||
```
|
```
|
||||||
|
|
||||||
## Available commands
|
## Available commands
|
||||||
@@ -158,7 +159,7 @@ cargo run -p rusty-claude-cli -- --resume session.json /memory /config
|
|||||||
### Top-level CLI commands
|
### Top-level CLI commands
|
||||||
|
|
||||||
- `prompt <text...>` — run one prompt non-interactively
|
- `prompt <text...>` — run one prompt non-interactively
|
||||||
- `--resume <session.json> [/commands...]` — inspect or maintain a saved session
|
- `--resume <session-id-or-path> [/commands...]` — inspect or maintain a saved session stored under `~/.claude/sessions/`
|
||||||
- `dump-manifests` — print extracted upstream manifest counts
|
- `dump-manifests` — print extracted upstream manifest counts
|
||||||
- `bootstrap-plan` — print the current bootstrap skeleton
|
- `bootstrap-plan` — print the current bootstrap skeleton
|
||||||
- `system-prompt [--cwd PATH] [--date YYYY-MM-DD]` — render the synthesized system prompt
|
- `system-prompt [--cwd PATH] [--date YYYY-MM-DD]` — render the synthesized system prompt
|
||||||
@@ -176,13 +177,14 @@ cargo run -p rusty-claude-cli -- --resume session.json /memory /config
|
|||||||
- `/permissions [read-only|workspace-write|danger-full-access]` — inspect or switch permissions
|
- `/permissions [read-only|workspace-write|danger-full-access]` — inspect or switch permissions
|
||||||
- `/clear [--confirm]` — clear the current local session
|
- `/clear [--confirm]` — clear the current local session
|
||||||
- `/cost` — show token usage totals
|
- `/cost` — show token usage totals
|
||||||
- `/resume <session-path>` — load a saved session into the REPL
|
- `/resume <session-id-or-path>` — load a saved session into the REPL
|
||||||
- `/config [env|hooks|model]` — inspect discovered Claude config
|
- `/config [env|hooks|model]` — inspect discovered Claude config
|
||||||
- `/memory` — inspect loaded instruction memory files
|
- `/memory` — inspect loaded instruction memory files
|
||||||
- `/init` — create a starter `CLAUDE.md`
|
- `/init` — create a starter `CLAUDE.md`
|
||||||
- `/diff` — show the current git diff for the workspace
|
- `/diff` — show the current git diff for the workspace
|
||||||
- `/version` — print version and build metadata locally
|
- `/version` — print version and build metadata locally
|
||||||
- `/export [file]` — export the current conversation transcript
|
- `/export [file]` — export the current conversation transcript
|
||||||
|
- `/sessions` — list recent managed local sessions from `~/.claude/sessions/`
|
||||||
- `/session [list|switch <session-id>]` — inspect or switch managed local sessions
|
- `/session [list|switch <session-id>]` — inspect or switch managed local sessions
|
||||||
- `/exit` — leave the REPL
|
- `/exit` — leave the REPL
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
|||||||
SlashCommandSpec {
|
SlashCommandSpec {
|
||||||
name: "resume",
|
name: "resume",
|
||||||
summary: "Load a saved session into the REPL",
|
summary: "Load a saved session into the REPL",
|
||||||
argument_hint: Some("<session-path>"),
|
argument_hint: Some("<session-id-or-path>"),
|
||||||
resume_supported: false,
|
resume_supported: false,
|
||||||
},
|
},
|
||||||
SlashCommandSpec {
|
SlashCommandSpec {
|
||||||
@@ -129,6 +129,12 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
|||||||
argument_hint: Some("[list|switch <session-id>]"),
|
argument_hint: Some("[list|switch <session-id>]"),
|
||||||
resume_supported: false,
|
resume_supported: false,
|
||||||
},
|
},
|
||||||
|
SlashCommandSpec {
|
||||||
|
name: "sessions",
|
||||||
|
summary: "List recent managed local sessions",
|
||||||
|
argument_hint: None,
|
||||||
|
resume_supported: false,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -163,6 +169,7 @@ pub enum SlashCommand {
|
|||||||
action: Option<String>,
|
action: Option<String>,
|
||||||
target: Option<String>,
|
target: Option<String>,
|
||||||
},
|
},
|
||||||
|
Sessions,
|
||||||
Unknown(String),
|
Unknown(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,6 +214,7 @@ impl SlashCommand {
|
|||||||
action: parts.next().map(ToOwned::to_owned),
|
action: parts.next().map(ToOwned::to_owned),
|
||||||
target: parts.next().map(ToOwned::to_owned),
|
target: parts.next().map(ToOwned::to_owned),
|
||||||
},
|
},
|
||||||
|
"sessions" => Self::Sessions,
|
||||||
other => Self::Unknown(other.to_string()),
|
other => Self::Unknown(other.to_string()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -291,6 +299,7 @@ pub fn handle_slash_command(
|
|||||||
| SlashCommand::Version
|
| SlashCommand::Version
|
||||||
| SlashCommand::Export { .. }
|
| SlashCommand::Export { .. }
|
||||||
| SlashCommand::Session { .. }
|
| SlashCommand::Session { .. }
|
||||||
|
| SlashCommand::Sessions
|
||||||
| SlashCommand::Unknown(_) => None,
|
| SlashCommand::Unknown(_) => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -365,6 +374,10 @@ mod tests {
|
|||||||
target: Some("abc123".to_string())
|
target: Some("abc123".to_string())
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
SlashCommand::parse("/sessions"),
|
||||||
|
Some(SlashCommand::Sessions)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -378,7 +391,7 @@ mod tests {
|
|||||||
assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
|
assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
|
||||||
assert!(help.contains("/clear [--confirm]"));
|
assert!(help.contains("/clear [--confirm]"));
|
||||||
assert!(help.contains("/cost"));
|
assert!(help.contains("/cost"));
|
||||||
assert!(help.contains("/resume <session-path>"));
|
assert!(help.contains("/resume <session-id-or-path>"));
|
||||||
assert!(help.contains("/config [env|hooks|model]"));
|
assert!(help.contains("/config [env|hooks|model]"));
|
||||||
assert!(help.contains("/memory"));
|
assert!(help.contains("/memory"));
|
||||||
assert!(help.contains("/init"));
|
assert!(help.contains("/init"));
|
||||||
@@ -386,7 +399,8 @@ mod tests {
|
|||||||
assert!(help.contains("/version"));
|
assert!(help.contains("/version"));
|
||||||
assert!(help.contains("/export [file]"));
|
assert!(help.contains("/export [file]"));
|
||||||
assert!(help.contains("/session [list|switch <session-id>]"));
|
assert!(help.contains("/session [list|switch <session-id>]"));
|
||||||
assert_eq!(slash_command_specs().len(), 15);
|
assert!(help.contains("/sessions"));
|
||||||
|
assert_eq!(slash_command_specs().len(), 16);
|
||||||
assert_eq!(resume_supported_slash_commands().len(), 11);
|
assert_eq!(resume_supported_slash_commands().len(), 11);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,6 +418,7 @@ mod tests {
|
|||||||
text: "recent".to_string(),
|
text: "recent".to_string(),
|
||||||
}]),
|
}]),
|
||||||
],
|
],
|
||||||
|
metadata: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = handle_slash_command(
|
let result = handle_slash_command(
|
||||||
@@ -468,5 +483,6 @@ mod tests {
|
|||||||
assert!(
|
assert!(
|
||||||
handle_slash_command("/session list", &session, CompactionConfig::default()).is_none()
|
handle_slash_command("/session list", &session, CompactionConfig::default()).is_none()
|
||||||
);
|
);
|
||||||
|
assert!(handle_slash_command("/sessions", &session, CompactionConfig::default()).is_none());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
|
|||||||
compacted_session: Session {
|
compacted_session: Session {
|
||||||
version: session.version,
|
version: session.version,
|
||||||
messages: compacted_messages,
|
messages: compacted_messages,
|
||||||
|
metadata: session.metadata.clone(),
|
||||||
},
|
},
|
||||||
removed_message_count: removed.len(),
|
removed_message_count: removed.len(),
|
||||||
}
|
}
|
||||||
@@ -393,6 +394,7 @@ mod tests {
|
|||||||
let session = Session {
|
let session = Session {
|
||||||
version: 1,
|
version: 1,
|
||||||
messages: vec![ConversationMessage::user_text("hello")],
|
messages: vec![ConversationMessage::user_text("hello")],
|
||||||
|
metadata: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = compact_session(&session, CompactionConfig::default());
|
let result = compact_session(&session, CompactionConfig::default());
|
||||||
@@ -420,6 +422,7 @@ mod tests {
|
|||||||
usage: None,
|
usage: None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
metadata: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = compact_session(
|
let result = compact_session(
|
||||||
|
|||||||
@@ -414,7 +414,6 @@ mod tests {
|
|||||||
cwd: PathBuf::from("/tmp/project"),
|
cwd: PathBuf::from("/tmp/project"),
|
||||||
current_date: "2026-03-31".to_string(),
|
current_date: "2026-03-31".to_string(),
|
||||||
git_status: None,
|
git_status: None,
|
||||||
git_diff: None,
|
|
||||||
instruction_files: Vec::new(),
|
instruction_files: Vec::new(),
|
||||||
})
|
})
|
||||||
.with_os("linux", "6.8")
|
.with_os("linux", "6.8")
|
||||||
|
|||||||
@@ -73,7 +73,9 @@ pub use remote::{
|
|||||||
RemoteSessionContext, UpstreamProxyBootstrap, UpstreamProxyState, DEFAULT_REMOTE_BASE_URL,
|
RemoteSessionContext, UpstreamProxyBootstrap, UpstreamProxyState, DEFAULT_REMOTE_BASE_URL,
|
||||||
DEFAULT_SESSION_TOKEN_PATH, DEFAULT_SYSTEM_CA_BUNDLE, NO_PROXY_HOSTS, UPSTREAM_PROXY_ENV_KEYS,
|
DEFAULT_SESSION_TOKEN_PATH, DEFAULT_SYSTEM_CA_BUNDLE, NO_PROXY_HOSTS, UPSTREAM_PROXY_ENV_KEYS,
|
||||||
};
|
};
|
||||||
pub use session::{ContentBlock, ConversationMessage, MessageRole, Session, SessionError};
|
pub use session::{
|
||||||
|
ContentBlock, ConversationMessage, MessageRole, Session, SessionError, SessionMetadata,
|
||||||
|
};
|
||||||
pub use usage::{
|
pub use usage::{
|
||||||
format_usd, pricing_for_model, ModelPricing, TokenUsage, UsageCostEstimate, UsageTracker,
|
format_usd, pricing_for_model, ModelPricing, TokenUsage, UsageCostEstimate, UsageTracker,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ pub struct ProjectContext {
|
|||||||
pub cwd: PathBuf,
|
pub cwd: PathBuf,
|
||||||
pub current_date: String,
|
pub current_date: String,
|
||||||
pub git_status: Option<String>,
|
pub git_status: Option<String>,
|
||||||
pub git_diff: Option<String>,
|
|
||||||
pub instruction_files: Vec<ContextFile>,
|
pub instruction_files: Vec<ContextFile>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +64,6 @@ impl ProjectContext {
|
|||||||
cwd,
|
cwd,
|
||||||
current_date: current_date.into(),
|
current_date: current_date.into(),
|
||||||
git_status: None,
|
git_status: None,
|
||||||
git_diff: None,
|
|
||||||
instruction_files,
|
instruction_files,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -76,7 +74,6 @@ impl ProjectContext {
|
|||||||
) -> std::io::Result<Self> {
|
) -> std::io::Result<Self> {
|
||||||
let mut context = Self::discover(cwd, current_date)?;
|
let mut context = Self::discover(cwd, current_date)?;
|
||||||
context.git_status = read_git_status(&context.cwd);
|
context.git_status = read_git_status(&context.cwd);
|
||||||
context.git_diff = read_git_diff(&context.cwd);
|
|
||||||
Ok(context)
|
Ok(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -242,38 +239,6 @@ fn read_git_status(cwd: &Path) -> Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_git_diff(cwd: &Path) -> Option<String> {
|
|
||||||
let mut sections = Vec::new();
|
|
||||||
|
|
||||||
let staged = read_git_output(cwd, &["diff", "--cached"])?;
|
|
||||||
if !staged.trim().is_empty() {
|
|
||||||
sections.push(format!("Staged changes:\n{}", staged.trim_end()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let unstaged = read_git_output(cwd, &["diff"])?;
|
|
||||||
if !unstaged.trim().is_empty() {
|
|
||||||
sections.push(format!("Unstaged changes:\n{}", unstaged.trim_end()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if sections.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(sections.join("\n\n"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_git_output(cwd: &Path, args: &[&str]) -> Option<String> {
|
|
||||||
let output = Command::new("git")
|
|
||||||
.args(args)
|
|
||||||
.current_dir(cwd)
|
|
||||||
.output()
|
|
||||||
.ok()?;
|
|
||||||
if !output.status.success() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
String::from_utf8(output.stdout).ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_project_context(project_context: &ProjectContext) -> String {
|
fn render_project_context(project_context: &ProjectContext) -> String {
|
||||||
let mut lines = vec!["# Project context".to_string()];
|
let mut lines = vec!["# Project context".to_string()];
|
||||||
let mut bullets = vec![
|
let mut bullets = vec![
|
||||||
@@ -292,11 +257,6 @@ fn render_project_context(project_context: &ProjectContext) -> String {
|
|||||||
lines.push("Git status snapshot:".to_string());
|
lines.push("Git status snapshot:".to_string());
|
||||||
lines.push(status.clone());
|
lines.push(status.clone());
|
||||||
}
|
}
|
||||||
if let Some(diff) = &project_context.git_diff {
|
|
||||||
lines.push(String::new());
|
|
||||||
lines.push("Git diff snapshot:".to_string());
|
|
||||||
lines.push(diff.clone());
|
|
||||||
}
|
|
||||||
lines.join("\n")
|
lines.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -617,49 +577,6 @@ mod tests {
|
|||||||
assert!(status.contains("## No commits yet on") || status.contains("## "));
|
assert!(status.contains("## No commits yet on") || status.contains("## "));
|
||||||
assert!(status.contains("?? CLAUDE.md"));
|
assert!(status.contains("?? CLAUDE.md"));
|
||||||
assert!(status.contains("?? tracked.txt"));
|
assert!(status.contains("?? tracked.txt"));
|
||||||
assert!(context.git_diff.is_none());
|
|
||||||
|
|
||||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn discover_with_git_includes_diff_snapshot_for_tracked_changes() {
|
|
||||||
let root = temp_dir();
|
|
||||||
fs::create_dir_all(&root).expect("root dir");
|
|
||||||
std::process::Command::new("git")
|
|
||||||
.args(["init", "--quiet"])
|
|
||||||
.current_dir(&root)
|
|
||||||
.status()
|
|
||||||
.expect("git init should run");
|
|
||||||
std::process::Command::new("git")
|
|
||||||
.args(["config", "user.email", "tests@example.com"])
|
|
||||||
.current_dir(&root)
|
|
||||||
.status()
|
|
||||||
.expect("git config email should run");
|
|
||||||
std::process::Command::new("git")
|
|
||||||
.args(["config", "user.name", "Runtime Prompt Tests"])
|
|
||||||
.current_dir(&root)
|
|
||||||
.status()
|
|
||||||
.expect("git config name should run");
|
|
||||||
fs::write(root.join("tracked.txt"), "hello\n").expect("write tracked file");
|
|
||||||
std::process::Command::new("git")
|
|
||||||
.args(["add", "tracked.txt"])
|
|
||||||
.current_dir(&root)
|
|
||||||
.status()
|
|
||||||
.expect("git add should run");
|
|
||||||
std::process::Command::new("git")
|
|
||||||
.args(["commit", "-m", "init", "--quiet"])
|
|
||||||
.current_dir(&root)
|
|
||||||
.status()
|
|
||||||
.expect("git commit should run");
|
|
||||||
fs::write(root.join("tracked.txt"), "hello\nworld\n").expect("rewrite tracked file");
|
|
||||||
|
|
||||||
let context =
|
|
||||||
ProjectContext::discover_with_git(&root, "2026-03-31").expect("context should load");
|
|
||||||
|
|
||||||
let diff = context.git_diff.expect("git diff should be present");
|
|
||||||
assert!(diff.contains("Unstaged changes:"));
|
|
||||||
assert!(diff.contains("tracked.txt"));
|
|
||||||
|
|
||||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,10 +39,19 @@ pub struct ConversationMessage {
|
|||||||
pub usage: Option<TokenUsage>,
|
pub usage: Option<TokenUsage>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SessionMetadata {
|
||||||
|
pub started_at: String,
|
||||||
|
pub model: String,
|
||||||
|
pub message_count: u32,
|
||||||
|
pub last_prompt: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct Session {
|
pub struct Session {
|
||||||
pub version: u32,
|
pub version: u32,
|
||||||
pub messages: Vec<ConversationMessage>,
|
pub messages: Vec<ConversationMessage>,
|
||||||
|
pub metadata: Option<SessionMetadata>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -82,6 +91,7 @@ impl Session {
|
|||||||
Self {
|
Self {
|
||||||
version: 1,
|
version: 1,
|
||||||
messages: Vec::new(),
|
messages: Vec::new(),
|
||||||
|
metadata: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +121,9 @@ impl Session {
|
|||||||
.collect(),
|
.collect(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
if let Some(metadata) = &self.metadata {
|
||||||
|
object.insert("metadata".to_string(), metadata.to_json());
|
||||||
|
}
|
||||||
JsonValue::Object(object)
|
JsonValue::Object(object)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +144,15 @@ impl Session {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(ConversationMessage::from_json)
|
.map(ConversationMessage::from_json)
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
Ok(Self { version, messages })
|
let metadata = object
|
||||||
|
.get("metadata")
|
||||||
|
.map(SessionMetadata::from_json)
|
||||||
|
.transpose()?;
|
||||||
|
Ok(Self {
|
||||||
|
version,
|
||||||
|
messages,
|
||||||
|
metadata,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +162,41 @@ impl Default for Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl SessionMetadata {
|
||||||
|
#[must_use]
|
||||||
|
pub fn to_json(&self) -> JsonValue {
|
||||||
|
let mut object = BTreeMap::new();
|
||||||
|
object.insert(
|
||||||
|
"started_at".to_string(),
|
||||||
|
JsonValue::String(self.started_at.clone()),
|
||||||
|
);
|
||||||
|
object.insert("model".to_string(), JsonValue::String(self.model.clone()));
|
||||||
|
object.insert(
|
||||||
|
"message_count".to_string(),
|
||||||
|
JsonValue::Number(i64::from(self.message_count)),
|
||||||
|
);
|
||||||
|
if let Some(last_prompt) = &self.last_prompt {
|
||||||
|
object.insert(
|
||||||
|
"last_prompt".to_string(),
|
||||||
|
JsonValue::String(last_prompt.clone()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
JsonValue::Object(object)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_json(value: &JsonValue) -> Result<Self, SessionError> {
|
||||||
|
let object = value.as_object().ok_or_else(|| {
|
||||||
|
SessionError::Format("session metadata must be an object".to_string())
|
||||||
|
})?;
|
||||||
|
Ok(Self {
|
||||||
|
started_at: required_string(object, "started_at")?,
|
||||||
|
model: required_string(object, "model")?,
|
||||||
|
message_count: required_u32(object, "message_count")?,
|
||||||
|
last_prompt: optional_string(object, "last_prompt"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl ConversationMessage {
|
impl ConversationMessage {
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn user_text(text: impl Into<String>) -> Self {
|
pub fn user_text(text: impl Into<String>) -> Self {
|
||||||
@@ -368,6 +424,13 @@ fn required_string(
|
|||||||
.ok_or_else(|| SessionError::Format(format!("missing {key}")))
|
.ok_or_else(|| SessionError::Format(format!("missing {key}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn optional_string(object: &BTreeMap<String, JsonValue>, key: &str) -> Option<String> {
|
||||||
|
object
|
||||||
|
.get(key)
|
||||||
|
.and_then(JsonValue::as_str)
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
}
|
||||||
|
|
||||||
fn required_u32(object: &BTreeMap<String, JsonValue>, key: &str) -> Result<u32, SessionError> {
|
fn required_u32(object: &BTreeMap<String, JsonValue>, key: &str) -> Result<u32, SessionError> {
|
||||||
let value = object
|
let value = object
|
||||||
.get(key)
|
.get(key)
|
||||||
@@ -378,7 +441,8 @@ fn required_u32(object: &BTreeMap<String, JsonValue>, key: &str) -> Result<u32,
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{ContentBlock, ConversationMessage, MessageRole, Session};
|
use super::{ContentBlock, ConversationMessage, MessageRole, Session, SessionMetadata};
|
||||||
|
use crate::json::JsonValue;
|
||||||
use crate::usage::TokenUsage;
|
use crate::usage::TokenUsage;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
@@ -386,6 +450,12 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn persists_and_restores_session_json() {
|
fn persists_and_restores_session_json() {
|
||||||
let mut session = Session::new();
|
let mut session = Session::new();
|
||||||
|
session.metadata = Some(SessionMetadata {
|
||||||
|
started_at: "2026-04-01T00:00:00Z".to_string(),
|
||||||
|
model: "claude-sonnet".to_string(),
|
||||||
|
message_count: 3,
|
||||||
|
last_prompt: Some("hello".to_string()),
|
||||||
|
});
|
||||||
session
|
session
|
||||||
.messages
|
.messages
|
||||||
.push(ConversationMessage::user_text("hello"));
|
.push(ConversationMessage::user_text("hello"));
|
||||||
@@ -428,5 +498,23 @@ mod tests {
|
|||||||
restored.messages[1].usage.expect("usage").total_tokens(),
|
restored.messages[1].usage.expect("usage").total_tokens(),
|
||||||
17
|
17
|
||||||
);
|
);
|
||||||
|
assert_eq!(restored.metadata, session.metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loads_legacy_session_without_metadata() {
|
||||||
|
let legacy = r#"{
|
||||||
|
"version": 1,
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"blocks": [{"type": "text", "text": "hello"}]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}"#;
|
||||||
|
let restored = Session::from_json(&JsonValue::parse(legacy).expect("legacy json"))
|
||||||
|
.expect("legacy session should parse");
|
||||||
|
assert_eq!(restored.messages.len(), 1);
|
||||||
|
assert!(restored.metadata.is_none());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,6 +300,7 @@ mod tests {
|
|||||||
cache_read_input_tokens: 0,
|
cache_read_input_tokens: 0,
|
||||||
}),
|
}),
|
||||||
}],
|
}],
|
||||||
|
metadata: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let tracker = UsageTracker::from_session(&session);
|
let tracker = UsageTracker::from_session(&session);
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ use runtime::{
|
|||||||
AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock,
|
AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock,
|
||||||
ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest,
|
ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest,
|
||||||
OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError,
|
OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError,
|
||||||
Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
|
Session, SessionMetadata, TokenUsage, ToolError, ToolExecutor, UsageTracker,
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tools::{execute_tool, mvp_tool_specs, ToolSpec};
|
use tools::{execute_tool, mvp_tool_specs, ToolSpec};
|
||||||
@@ -37,6 +37,7 @@ const DEFAULT_MAX_TOKENS: u32 = 32;
|
|||||||
const DEFAULT_DATE: &str = "2026-03-31";
|
const DEFAULT_DATE: &str = "2026-03-31";
|
||||||
const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545;
|
const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545;
|
||||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
const OLD_SESSION_COMPACTION_AGE_SECS: u64 = 60 * 60 * 24;
|
||||||
const BUILD_TARGET: Option<&str> = option_env!("TARGET");
|
const BUILD_TARGET: Option<&str> = option_env!("TARGET");
|
||||||
const GIT_SHA: Option<&str> = option_env!("GIT_SHA");
|
const GIT_SHA: Option<&str> = option_env!("GIT_SHA");
|
||||||
|
|
||||||
@@ -535,7 +536,14 @@ fn print_version() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn resume_session(session_path: &Path, commands: &[String]) {
|
fn resume_session(session_path: &Path, commands: &[String]) {
|
||||||
let session = match Session::load_from_path(session_path) {
|
let handle = match resolve_session_reference(&session_path.display().to_string()) {
|
||||||
|
Ok(handle) => handle,
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("failed to resolve session: {error}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let session = match Session::load_from_path(&handle.path) {
|
||||||
Ok(session) => session,
|
Ok(session) => session,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
eprintln!("failed to restore session: {error}");
|
eprintln!("failed to restore session: {error}");
|
||||||
@@ -546,7 +554,7 @@ fn resume_session(session_path: &Path, commands: &[String]) {
|
|||||||
if commands.is_empty() {
|
if commands.is_empty() {
|
||||||
println!(
|
println!(
|
||||||
"Restored session from {} ({} messages).",
|
"Restored session from {} ({} messages).",
|
||||||
session_path.display(),
|
handle.path.display(),
|
||||||
session.messages.len()
|
session.messages.len()
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -558,7 +566,7 @@ fn resume_session(session_path: &Path, commands: &[String]) {
|
|||||||
eprintln!("unsupported resumed command: {raw_command}");
|
eprintln!("unsupported resumed command: {raw_command}");
|
||||||
std::process::exit(2);
|
std::process::exit(2);
|
||||||
};
|
};
|
||||||
match run_resume_command(session_path, &session, &command) {
|
match run_resume_command(&handle.path, &session, &command) {
|
||||||
Ok(ResumeCommandOutcome {
|
Ok(ResumeCommandOutcome {
|
||||||
session: next_session,
|
session: next_session,
|
||||||
message,
|
message,
|
||||||
@@ -742,61 +750,27 @@ fn format_compact_report(removed: usize, resulting_messages: usize, skipped: boo
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn parse_git_status_metadata(status: Option<&str>) -> (Option<PathBuf>, Option<String>) {
|
fn parse_git_status_metadata(status: Option<&str>) -> (Option<PathBuf>, Option<String>) {
|
||||||
parse_git_status_metadata_for(
|
let Some(status) = status else {
|
||||||
&env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
|
return (None, None);
|
||||||
status,
|
};
|
||||||
)
|
let branch = status.lines().next().and_then(|line| {
|
||||||
|
line.strip_prefix("## ")
|
||||||
|
.map(|line| {
|
||||||
|
line.split(['.', ' '])
|
||||||
|
.next()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string()
|
||||||
|
})
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
});
|
||||||
|
let project_root = find_git_root().ok();
|
||||||
|
(project_root, branch)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_git_status_branch(status: Option<&str>) -> Option<String> {
|
fn find_git_root() -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||||
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 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")
|
let output = std::process::Command::new("git")
|
||||||
.args(["rev-parse", "--show-toplevel"])
|
.args(["rev-parse", "--show-toplevel"])
|
||||||
.current_dir(cwd)
|
.current_dir(env::current_dir()?)
|
||||||
.output()?;
|
.output()?;
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
return Err("not a git repository".into());
|
return Err("not a git repository".into());
|
||||||
@@ -808,15 +782,6 @@ fn find_git_root_in(cwd: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
|||||||
Ok(PathBuf::from(path))
|
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)]
|
#[allow(clippy::too_many_lines)]
|
||||||
fn run_resume_command(
|
fn run_resume_command(
|
||||||
session_path: &Path,
|
session_path: &Path,
|
||||||
@@ -904,9 +869,7 @@ fn run_resume_command(
|
|||||||
}),
|
}),
|
||||||
SlashCommand::Diff => Ok(ResumeCommandOutcome {
|
SlashCommand::Diff => Ok(ResumeCommandOutcome {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
message: Some(render_diff_report_for(
|
message: Some(render_diff_report()?),
|
||||||
session_path.parent().unwrap_or_else(|| Path::new(".")),
|
|
||||||
)?),
|
|
||||||
}),
|
}),
|
||||||
SlashCommand::Version => Ok(ResumeCommandOutcome {
|
SlashCommand::Version => Ok(ResumeCommandOutcome {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
@@ -928,6 +891,7 @@ fn run_resume_command(
|
|||||||
| SlashCommand::Model { .. }
|
| SlashCommand::Model { .. }
|
||||||
| SlashCommand::Permissions { .. }
|
| SlashCommand::Permissions { .. }
|
||||||
| SlashCommand::Session { .. }
|
| SlashCommand::Session { .. }
|
||||||
|
| SlashCommand::Sessions
|
||||||
| SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()),
|
| SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -984,6 +948,9 @@ struct ManagedSessionSummary {
|
|||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
modified_epoch_secs: u64,
|
modified_epoch_secs: u64,
|
||||||
message_count: usize,
|
message_count: usize,
|
||||||
|
model: Option<String>,
|
||||||
|
started_at: Option<String>,
|
||||||
|
last_prompt: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct LiveCli {
|
struct LiveCli {
|
||||||
@@ -1004,6 +971,7 @@ impl LiveCli {
|
|||||||
) -> Result<Self, Box<dyn std::error::Error>> {
|
) -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
let system_prompt = build_system_prompt()?;
|
let system_prompt = build_system_prompt()?;
|
||||||
let session = create_managed_session_handle()?;
|
let session = create_managed_session_handle()?;
|
||||||
|
auto_compact_inactive_sessions(&session.id)?;
|
||||||
let runtime = build_runtime(
|
let runtime = build_runtime(
|
||||||
Session::new(),
|
Session::new(),
|
||||||
model.clone(),
|
model.clone(),
|
||||||
@@ -1175,6 +1143,10 @@ impl LiveCli {
|
|||||||
SlashCommand::Session { action, target } => {
|
SlashCommand::Session { action, target } => {
|
||||||
self.handle_session_command(action.as_deref(), target.as_deref())?
|
self.handle_session_command(action.as_deref(), target.as_deref())?
|
||||||
}
|
}
|
||||||
|
SlashCommand::Sessions => {
|
||||||
|
println!("{}", render_session_list(&self.session.id)?);
|
||||||
|
false
|
||||||
|
}
|
||||||
SlashCommand::Unknown(name) => {
|
SlashCommand::Unknown(name) => {
|
||||||
eprintln!("unknown slash command: /{name}");
|
eprintln!("unknown slash command: /{name}");
|
||||||
false
|
false
|
||||||
@@ -1183,7 +1155,10 @@ impl LiveCli {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn persist_session(&self) -> Result<(), Box<dyn std::error::Error>> {
|
fn persist_session(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
self.runtime.session().save_to_path(&self.session.path)?;
|
let mut session = self.runtime.session().clone();
|
||||||
|
session.metadata = Some(derive_session_metadata(&session, &self.model));
|
||||||
|
session.save_to_path(&self.session.path)?;
|
||||||
|
auto_compact_inactive_sessions(&self.session.id)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1328,13 +1303,20 @@ impl LiveCli {
|
|||||||
session_path: Option<String>,
|
session_path: Option<String>,
|
||||||
) -> Result<bool, Box<dyn std::error::Error>> {
|
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||||
let Some(session_ref) = session_path else {
|
let Some(session_ref) = session_path else {
|
||||||
println!("Usage: /resume <session-path>");
|
println!("Usage: /resume <session-id-or-path>");
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
let handle = resolve_session_reference(&session_ref)?;
|
let handle = resolve_session_reference(&session_ref)?;
|
||||||
let session = Session::load_from_path(&handle.path)?;
|
let session = Session::load_from_path(&handle.path)?;
|
||||||
let message_count = session.messages.len();
|
let message_count = session.messages.len();
|
||||||
|
if let Some(model) = session
|
||||||
|
.metadata
|
||||||
|
.as_ref()
|
||||||
|
.map(|metadata| metadata.model.clone())
|
||||||
|
{
|
||||||
|
self.model = model;
|
||||||
|
}
|
||||||
self.runtime = build_runtime(
|
self.runtime = build_runtime(
|
||||||
session,
|
session,
|
||||||
self.model.clone(),
|
self.model.clone(),
|
||||||
@@ -1411,6 +1393,13 @@ impl LiveCli {
|
|||||||
let handle = resolve_session_reference(target)?;
|
let handle = resolve_session_reference(target)?;
|
||||||
let session = Session::load_from_path(&handle.path)?;
|
let session = Session::load_from_path(&handle.path)?;
|
||||||
let message_count = session.messages.len();
|
let message_count = session.messages.len();
|
||||||
|
if let Some(model) = session
|
||||||
|
.metadata
|
||||||
|
.as_ref()
|
||||||
|
.map(|metadata| metadata.model.clone())
|
||||||
|
{
|
||||||
|
self.model = model;
|
||||||
|
}
|
||||||
self.runtime = build_runtime(
|
self.runtime = build_runtime(
|
||||||
session,
|
session,
|
||||||
self.model.clone(),
|
self.model.clone(),
|
||||||
@@ -1455,8 +1444,10 @@ impl LiveCli {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn sessions_dir() -> Result<PathBuf, Box<dyn std::error::Error>> {
|
fn sessions_dir() -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||||
let cwd = env::current_dir()?;
|
let home = env::var_os("HOME")
|
||||||
let path = cwd.join(".claude").join("sessions");
|
.map(PathBuf::from)
|
||||||
|
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "HOME is not set"))?;
|
||||||
|
let path = home.join(".claude").join("sessions");
|
||||||
fs::create_dir_all(&path)?;
|
fs::create_dir_all(&path)?;
|
||||||
Ok(path)
|
Ok(path)
|
||||||
}
|
}
|
||||||
@@ -1477,8 +1468,19 @@ fn generate_session_id() -> String {
|
|||||||
|
|
||||||
fn resolve_session_reference(reference: &str) -> Result<SessionHandle, Box<dyn std::error::Error>> {
|
fn resolve_session_reference(reference: &str) -> Result<SessionHandle, Box<dyn std::error::Error>> {
|
||||||
let direct = PathBuf::from(reference);
|
let direct = PathBuf::from(reference);
|
||||||
|
let expanded = if let Some(stripped) = reference.strip_prefix("~/") {
|
||||||
|
sessions_dir()?
|
||||||
|
.parent()
|
||||||
|
.and_then(|claude| claude.parent())
|
||||||
|
.map(|home| home.join(stripped))
|
||||||
|
.unwrap_or(direct.clone())
|
||||||
|
} else {
|
||||||
|
direct.clone()
|
||||||
|
};
|
||||||
let path = if direct.exists() {
|
let path = if direct.exists() {
|
||||||
direct
|
direct
|
||||||
|
} else if expanded.exists() {
|
||||||
|
expanded
|
||||||
} else {
|
} else {
|
||||||
sessions_dir()?.join(format!("{reference}.json"))
|
sessions_dir()?.join(format!("{reference}.json"))
|
||||||
};
|
};
|
||||||
@@ -1508,9 +1510,11 @@ fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::er
|
|||||||
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
|
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
|
||||||
.map(|duration| duration.as_secs())
|
.map(|duration| duration.as_secs())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let message_count = Session::load_from_path(&path)
|
let session = Session::load_from_path(&path).ok();
|
||||||
.map(|session| session.messages.len())
|
let derived_message_count = session.as_ref().map_or(0, |session| session.messages.len());
|
||||||
.unwrap_or_default();
|
let stored = session
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|session| session.metadata.as_ref());
|
||||||
let id = path
|
let id = path
|
||||||
.file_stem()
|
.file_stem()
|
||||||
.and_then(|value| value.to_str())
|
.and_then(|value| value.to_str())
|
||||||
@@ -1520,7 +1524,12 @@ fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::er
|
|||||||
id,
|
id,
|
||||||
path,
|
path,
|
||||||
modified_epoch_secs,
|
modified_epoch_secs,
|
||||||
message_count,
|
message_count: stored.map_or(derived_message_count, |metadata| {
|
||||||
|
metadata.message_count as usize
|
||||||
|
}),
|
||||||
|
model: stored.map(|metadata| metadata.model.clone()),
|
||||||
|
started_at: stored.map(|metadata| metadata.started_at.clone()),
|
||||||
|
last_prompt: stored.and_then(|metadata| metadata.last_prompt.clone()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
sessions.sort_by(|left, right| right.modified_epoch_secs.cmp(&left.modified_epoch_secs));
|
sessions.sort_by(|left, right| right.modified_epoch_secs.cmp(&left.modified_epoch_secs));
|
||||||
@@ -1543,17 +1552,99 @@ fn render_session_list(active_session_id: &str) -> Result<String, Box<dyn std::e
|
|||||||
} else {
|
} else {
|
||||||
"○ saved"
|
"○ saved"
|
||||||
};
|
};
|
||||||
|
let model = session.model.as_deref().unwrap_or("unknown");
|
||||||
|
let started = session.started_at.as_deref().unwrap_or("unknown");
|
||||||
|
let last_prompt = session.last_prompt.as_deref().map_or_else(
|
||||||
|
|| "-".to_string(),
|
||||||
|
|prompt| truncate_for_summary(prompt, 36),
|
||||||
|
);
|
||||||
lines.push(format!(
|
lines.push(format!(
|
||||||
" {id:<20} {marker:<10} msgs={msgs:<4} modified={modified} path={path}",
|
" {id:<20} {marker:<10} msgs={msgs:<4} model={model:<24} started={started} modified={modified} last={last_prompt} path={path}",
|
||||||
id = session.id,
|
id = session.id,
|
||||||
msgs = session.message_count,
|
msgs = session.message_count,
|
||||||
|
model = model,
|
||||||
|
started = started,
|
||||||
modified = session.modified_epoch_secs,
|
modified = session.modified_epoch_secs,
|
||||||
|
last_prompt = last_prompt,
|
||||||
path = session.path.display(),
|
path = session.path.display(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Ok(lines.join("\n"))
|
Ok(lines.join("\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn current_epoch_secs() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.as_secs())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_timestamp_rfc3339ish() -> String {
|
||||||
|
format!("{}Z", current_epoch_secs())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn last_prompt_from_session(session: &Session) -> Option<String> {
|
||||||
|
session
|
||||||
|
.messages
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find(|message| message.role == MessageRole::User)
|
||||||
|
.and_then(|message| {
|
||||||
|
message.blocks.iter().find_map(|block| match block {
|
||||||
|
ContentBlock::Text { text } => Some(text.trim().to_string()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.filter(|text| !text.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn derive_session_metadata(session: &Session, model: &str) -> SessionMetadata {
|
||||||
|
let started_at = session
|
||||||
|
.metadata
|
||||||
|
.as_ref()
|
||||||
|
.map_or_else(current_timestamp_rfc3339ish, |metadata| {
|
||||||
|
metadata.started_at.clone()
|
||||||
|
});
|
||||||
|
SessionMetadata {
|
||||||
|
started_at,
|
||||||
|
model: model.to_string(),
|
||||||
|
message_count: session.messages.len().try_into().unwrap_or(u32::MAX),
|
||||||
|
last_prompt: last_prompt_from_session(session),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session_age_secs(modified_epoch_secs: u64) -> u64 {
|
||||||
|
current_epoch_secs().saturating_sub(modified_epoch_secs)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn auto_compact_inactive_sessions(
|
||||||
|
active_session_id: &str,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
for summary in list_managed_sessions()? {
|
||||||
|
if summary.id == active_session_id
|
||||||
|
|| session_age_secs(summary.modified_epoch_secs) < OLD_SESSION_COMPACTION_AGE_SECS
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let path = summary.path.clone();
|
||||||
|
let Ok(session) = Session::load_from_path(&path) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !runtime::should_compact(&session, CompactionConfig::default()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut compacted =
|
||||||
|
runtime::compact_session(&session, CompactionConfig::default()).compacted_session;
|
||||||
|
let model = compacted.metadata.as_ref().map_or_else(
|
||||||
|
|| DEFAULT_MODEL.to_string(),
|
||||||
|
|metadata| metadata.model.clone(),
|
||||||
|
);
|
||||||
|
compacted.metadata = Some(derive_session_metadata(&compacted, &model));
|
||||||
|
compacted.save_to_path(&path)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn render_repl_help() -> String {
|
fn render_repl_help() -> String {
|
||||||
[
|
[
|
||||||
"REPL".to_string(),
|
"REPL".to_string(),
|
||||||
@@ -1840,43 +1931,22 @@ fn normalize_permission_mode(mode: &str) -> Option<&'static str> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render_diff_report() -> Result<String, Box<dyn std::error::Error>> {
|
fn render_diff_report() -> Result<String, Box<dyn std::error::Error>> {
|
||||||
render_diff_report_for(&env::current_dir()?)
|
let output = std::process::Command::new("git")
|
||||||
}
|
.args(["diff", "--", ":(exclude).omx"])
|
||||||
|
.current_dir(env::current_dir()?)
|
||||||
fn render_diff_report_for(cwd: &Path) -> Result<String, Box<dyn std::error::Error>> {
|
.output()?;
|
||||||
let staged = run_git_diff_command_in(cwd, &["diff", "--cached"])?;
|
if !output.status.success() {
|
||||||
let unstaged = run_git_diff_command_in(cwd, &["diff"])?;
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||||
if staged.trim().is_empty() && unstaged.trim().is_empty() {
|
return Err(format!("git diff failed: {stderr}").into());
|
||||||
|
}
|
||||||
|
let diff = String::from_utf8(output.stdout)?;
|
||||||
|
if diff.trim().is_empty() {
|
||||||
return Ok(
|
return Ok(
|
||||||
"Diff\n Result clean working tree\n Detail no current changes"
|
"Diff\n Result clean working tree\n Detail no current changes"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
Ok(format!("Diff\n\n{}", diff.trim_end()))
|
||||||
let mut sections = Vec::new();
|
|
||||||
if !staged.trim().is_empty() {
|
|
||||||
sections.push(format!("Staged changes:\n{}", staged.trim_end()));
|
|
||||||
}
|
|
||||||
if !unstaged.trim().is_empty() {
|
|
||||||
sections.push(format!("Unstaged changes:\n{}", unstaged.trim_end()));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(format!("Diff\n\n{}", sections.join("\n\n")))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_git_diff_command_in(
|
|
||||||
cwd: &Path,
|
|
||||||
args: &[&str],
|
|
||||||
) -> Result<String, Box<dyn std::error::Error>> {
|
|
||||||
let output = std::process::Command::new("git")
|
|
||||||
.args(args)
|
|
||||||
.current_dir(cwd)
|
|
||||||
.output()?;
|
|
||||||
if !output.status.success() {
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
|
||||||
return Err(format!("git {} failed: {stderr}", args.join(" ")).into());
|
|
||||||
}
|
|
||||||
Ok(String::from_utf8(output.stdout)?)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_version_report() -> String {
|
fn render_version_report() -> String {
|
||||||
@@ -2455,56 +2525,71 @@ fn print_help() {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
filter_tool_specs, format_compact_report, format_cost_report, format_init_report,
|
derive_session_metadata, filter_tool_specs, format_compact_report, format_cost_report,
|
||||||
format_model_report, format_model_switch_report, format_permissions_report,
|
format_init_report, format_model_report, format_model_switch_report,
|
||||||
format_permissions_switch_report, format_resume_report, format_status_report,
|
format_permissions_report, format_permissions_switch_report, format_resume_report,
|
||||||
format_tool_call_start, format_tool_result, normalize_permission_mode, parse_args,
|
format_status_report, format_tool_call_start, format_tool_result, list_managed_sessions,
|
||||||
parse_git_status_branch, parse_git_status_metadata, render_config_report,
|
normalize_permission_mode, parse_args, parse_git_status_metadata, render_config_report,
|
||||||
render_diff_report, render_init_claude_md, render_memory_report, render_repl_help,
|
render_init_claude_md, render_memory_report, render_repl_help,
|
||||||
resume_supported_slash_commands, run_resume_command, status_context, CliAction,
|
resume_supported_slash_commands, sessions_dir, status_context, CliAction, CliOutputFormat,
|
||||||
CliOutputFormat, SlashCommand, StatusUsage, DEFAULT_MODEL,
|
SlashCommand, StatusUsage, DEFAULT_MODEL,
|
||||||
};
|
};
|
||||||
use runtime::{ContentBlock, ConversationMessage, MessageRole, PermissionMode, Session};
|
use runtime::{ContentBlock, ConversationMessage, MessageRole, PermissionMode, Session};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
|
||||||
use std::sync::{Mutex, MutexGuard, OnceLock};
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
fn temp_dir() -> PathBuf {
|
#[test]
|
||||||
let nanos = SystemTime::now()
|
fn derive_session_metadata_recomputes_prompt_and_count() {
|
||||||
.duration_since(UNIX_EPOCH)
|
let mut session = Session::new();
|
||||||
.expect("time should be after epoch")
|
session
|
||||||
.as_nanos();
|
.messages
|
||||||
std::env::temp_dir().join(format!("rusty-claude-cli-{nanos}"))
|
.push(ConversationMessage::user_text("first prompt"));
|
||||||
|
session
|
||||||
|
.messages
|
||||||
|
.push(ConversationMessage::assistant(vec![ContentBlock::Text {
|
||||||
|
text: "reply".to_string(),
|
||||||
|
}]));
|
||||||
|
let metadata = derive_session_metadata(&session, "claude-test");
|
||||||
|
assert_eq!(metadata.model, "claude-test");
|
||||||
|
assert_eq!(metadata.message_count, 2);
|
||||||
|
assert_eq!(metadata.last_prompt.as_deref(), Some("first prompt"));
|
||||||
|
assert!(metadata.started_at.ends_with('Z'));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn git(args: &[&str], cwd: &Path) {
|
#[test]
|
||||||
let status = Command::new("git")
|
fn managed_sessions_use_home_directory_and_list_metadata() {
|
||||||
.args(args)
|
let temp =
|
||||||
.current_dir(cwd)
|
std::env::temp_dir().join(format!("rusty-claude-cli-home-{}", std::process::id()));
|
||||||
.status()
|
let _ = fs::remove_dir_all(&temp);
|
||||||
.expect("git command should run");
|
fs::create_dir_all(&temp).expect("temp home should exist");
|
||||||
assert!(
|
let previous_home = std::env::var_os("HOME");
|
||||||
status.success(),
|
std::env::set_var("HOME", &temp);
|
||||||
"git command failed: git {}",
|
|
||||||
args.join(" ")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn env_lock() -> MutexGuard<'static, ()> {
|
let dir = sessions_dir().expect("sessions dir");
|
||||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
assert_eq!(dir, temp.join(".claude").join("sessions"));
|
||||||
LOCK.get_or_init(|| Mutex::new(()))
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn with_current_dir<T>(cwd: &Path, f: impl FnOnce() -> T) -> T {
|
let mut session = Session::new();
|
||||||
let previous = std::env::current_dir().expect("cwd should load");
|
session
|
||||||
std::env::set_current_dir(cwd).expect("cwd should change");
|
.messages
|
||||||
let result = f();
|
.push(ConversationMessage::user_text("persist me"));
|
||||||
std::env::set_current_dir(previous).expect("cwd should restore");
|
session.metadata = Some(derive_session_metadata(&session, "claude-home"));
|
||||||
result
|
let file = dir.join("session-test.json");
|
||||||
|
session.save_to_path(&file).expect("session save");
|
||||||
|
|
||||||
|
let listed = list_managed_sessions().expect("session list");
|
||||||
|
let found = listed
|
||||||
|
.into_iter()
|
||||||
|
.find(|entry| entry.id == "session-test")
|
||||||
|
.expect("saved session should be listed");
|
||||||
|
assert_eq!(found.message_count, 1);
|
||||||
|
assert_eq!(found.model.as_deref(), Some("claude-home"));
|
||||||
|
assert_eq!(found.last_prompt.as_deref(), Some("persist me"));
|
||||||
|
|
||||||
|
fs::remove_file(file).ok();
|
||||||
|
if let Some(previous_home) = previous_home {
|
||||||
|
std::env::set_var("HOME", previous_home);
|
||||||
|
}
|
||||||
|
fs::remove_dir_all(temp).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2712,7 +2797,8 @@ mod tests {
|
|||||||
assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
|
assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
|
||||||
assert!(help.contains("/clear [--confirm]"));
|
assert!(help.contains("/clear [--confirm]"));
|
||||||
assert!(help.contains("/cost"));
|
assert!(help.contains("/cost"));
|
||||||
assert!(help.contains("/resume <session-path>"));
|
assert!(help.contains("/resume <session-id-or-path>"));
|
||||||
|
assert!(help.contains("/sessions"));
|
||||||
assert!(help.contains("/config [env|hooks|model]"));
|
assert!(help.contains("/config [env|hooks|model]"));
|
||||||
assert!(help.contains("/memory"));
|
assert!(help.contains("/memory"));
|
||||||
assert!(help.contains("/init"));
|
assert!(help.contains("/init"));
|
||||||
@@ -2892,140 +2978,19 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_git_status_metadata() {
|
fn parses_git_status_metadata() {
|
||||||
let _guard = env_lock();
|
let (root, branch) = parse_git_status_metadata(Some(
|
||||||
let temp_root = temp_dir();
|
"## rcc/cli...origin/rcc/cli
|
||||||
fs::create_dir_all(&temp_root).expect("root dir");
|
|
||||||
let (project_root, branch) = with_current_dir(&temp_root, || {
|
|
||||||
parse_git_status_metadata(Some(
|
|
||||||
"## rcc/cli...origin/rcc/cli
|
|
||||||
M src/main.rs",
|
M src/main.rs",
|
||||||
))
|
));
|
||||||
});
|
|
||||||
assert_eq!(branch.as_deref(), Some("rcc/cli"));
|
assert_eq!(branch.as_deref(), Some("rcc/cli"));
|
||||||
assert!(project_root.is_none());
|
let _ = root;
|
||||||
fs::remove_dir_all(temp_root).expect("cleanup temp dir");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_detached_head_from_status_snapshot() {
|
|
||||||
let _guard = env_lock();
|
|
||||||
assert_eq!(
|
|
||||||
parse_git_status_branch(Some(
|
|
||||||
"## HEAD (no branch)
|
|
||||||
M src/main.rs"
|
|
||||||
)),
|
|
||||||
Some("detached HEAD".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn render_diff_report_shows_clean_tree_for_committed_repo() {
|
|
||||||
let _guard = env_lock();
|
|
||||||
let root = temp_dir();
|
|
||||||
fs::create_dir_all(&root).expect("root dir");
|
|
||||||
git(&["init", "--quiet"], &root);
|
|
||||||
git(&["config", "user.email", "tests@example.com"], &root);
|
|
||||||
git(&["config", "user.name", "Rusty Claude Tests"], &root);
|
|
||||||
fs::write(root.join("tracked.txt"), "hello\n").expect("write file");
|
|
||||||
git(&["add", "tracked.txt"], &root);
|
|
||||||
git(&["commit", "-m", "init", "--quiet"], &root);
|
|
||||||
|
|
||||||
let report = with_current_dir(&root, || {
|
|
||||||
render_diff_report().expect("diff report should render")
|
|
||||||
});
|
|
||||||
assert!(report.contains("clean working tree"));
|
|
||||||
|
|
||||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn render_diff_report_includes_staged_and_unstaged_sections() {
|
|
||||||
let _guard = env_lock();
|
|
||||||
let root = temp_dir();
|
|
||||||
fs::create_dir_all(&root).expect("root dir");
|
|
||||||
git(&["init", "--quiet"], &root);
|
|
||||||
git(&["config", "user.email", "tests@example.com"], &root);
|
|
||||||
git(&["config", "user.name", "Rusty Claude Tests"], &root);
|
|
||||||
fs::write(root.join("tracked.txt"), "hello\n").expect("write file");
|
|
||||||
git(&["add", "tracked.txt"], &root);
|
|
||||||
git(&["commit", "-m", "init", "--quiet"], &root);
|
|
||||||
|
|
||||||
fs::write(root.join("tracked.txt"), "hello\nstaged\n").expect("update file");
|
|
||||||
git(&["add", "tracked.txt"], &root);
|
|
||||||
fs::write(root.join("tracked.txt"), "hello\nstaged\nunstaged\n")
|
|
||||||
.expect("update file twice");
|
|
||||||
|
|
||||||
let report = with_current_dir(&root, || {
|
|
||||||
render_diff_report().expect("diff report should render")
|
|
||||||
});
|
|
||||||
assert!(report.contains("Staged changes:"));
|
|
||||||
assert!(report.contains("Unstaged changes:"));
|
|
||||||
assert!(report.contains("tracked.txt"));
|
|
||||||
|
|
||||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn render_diff_report_omits_ignored_files() {
|
|
||||||
let _guard = env_lock();
|
|
||||||
let root = temp_dir();
|
|
||||||
fs::create_dir_all(&root).expect("root dir");
|
|
||||||
git(&["init", "--quiet"], &root);
|
|
||||||
git(&["config", "user.email", "tests@example.com"], &root);
|
|
||||||
git(&["config", "user.name", "Rusty Claude Tests"], &root);
|
|
||||||
fs::write(root.join(".gitignore"), ".omx/\nignored.txt\n").expect("write gitignore");
|
|
||||||
fs::write(root.join("tracked.txt"), "hello\n").expect("write tracked");
|
|
||||||
git(&["add", ".gitignore", "tracked.txt"], &root);
|
|
||||||
git(&["commit", "-m", "init", "--quiet"], &root);
|
|
||||||
fs::create_dir_all(root.join(".omx")).expect("write omx dir");
|
|
||||||
fs::write(root.join(".omx").join("state.json"), "{}").expect("write ignored omx");
|
|
||||||
fs::write(root.join("ignored.txt"), "secret\n").expect("write ignored file");
|
|
||||||
fs::write(root.join("tracked.txt"), "hello\nworld\n").expect("write tracked change");
|
|
||||||
|
|
||||||
let report = with_current_dir(&root, || {
|
|
||||||
render_diff_report().expect("diff report should render")
|
|
||||||
});
|
|
||||||
assert!(report.contains("tracked.txt"));
|
|
||||||
assert!(!report.contains("+++ b/ignored.txt"));
|
|
||||||
assert!(!report.contains("+++ b/.omx/state.json"));
|
|
||||||
|
|
||||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resume_diff_command_renders_report_for_saved_session() {
|
|
||||||
let _guard = env_lock();
|
|
||||||
let root = temp_dir();
|
|
||||||
fs::create_dir_all(&root).expect("root dir");
|
|
||||||
git(&["init", "--quiet"], &root);
|
|
||||||
git(&["config", "user.email", "tests@example.com"], &root);
|
|
||||||
git(&["config", "user.name", "Rusty Claude Tests"], &root);
|
|
||||||
fs::write(root.join("tracked.txt"), "hello\n").expect("write tracked");
|
|
||||||
git(&["add", "tracked.txt"], &root);
|
|
||||||
git(&["commit", "-m", "init", "--quiet"], &root);
|
|
||||||
fs::write(root.join("tracked.txt"), "hello\nworld\n").expect("modify tracked");
|
|
||||||
let session_path = root.join("session.json");
|
|
||||||
Session::new()
|
|
||||||
.save_to_path(&session_path)
|
|
||||||
.expect("session should save");
|
|
||||||
|
|
||||||
let session = Session::load_from_path(&session_path).expect("session should load");
|
|
||||||
let outcome = with_current_dir(&root, || {
|
|
||||||
run_resume_command(&session_path, &session, &SlashCommand::Diff)
|
|
||||||
.expect("resume diff should work")
|
|
||||||
});
|
|
||||||
let message = outcome.message.expect("diff message should exist");
|
|
||||||
assert!(message.contains("Unstaged changes:"));
|
|
||||||
assert!(message.contains("tracked.txt"));
|
|
||||||
|
|
||||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn status_context_reads_real_workspace_metadata() {
|
fn status_context_reads_real_workspace_metadata() {
|
||||||
let context = status_context(None).expect("status context should load");
|
let context = status_context(None).expect("status context should load");
|
||||||
assert!(context.cwd.is_absolute());
|
assert!(context.cwd.is_absolute());
|
||||||
assert!(context.discovered_config_files >= context.loaded_config_files);
|
assert!(context.discovered_config_files >= 3);
|
||||||
assert!(context.loaded_config_files <= context.discovered_config_files);
|
assert!(context.loaded_config_files <= context.discovered_config_files);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user