feat: merge 2nd round from all rcc/* sessions

- api: tool_use parsing, message_delta, request_id tracking, retry logic
- tools: extended tool suite (WebSearch, WebFetch, Agent, etc.)
- cli: live streamed conversations, session restore, compact commands
- runtime: config loading, system prompt builder, token usage, compaction
This commit is contained in:
Yeachan-Heo
2026-03-31 17:43:25 +00:00
parent 44e4758078
commit 450556559a
23 changed files with 2388 additions and 3560 deletions

View File

@@ -1,11 +1,10 @@
use std::io::{self, Write};
use std::path::PathBuf;
use std::thread;
use std::time::Duration;
use crate::args::{OutputFormat, PermissionMode};
use crate::input::LineEditor;
use crate::render::{Spinner, TerminalRenderer};
use runtime::{ConversationClient, ConversationMessage, RuntimeError, StreamEvent, UsageSummary};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionConfig {
@@ -20,6 +19,7 @@ pub struct SessionState {
pub turns: usize,
pub compacted_messages: usize,
pub last_model: String,
pub last_usage: UsageSummary,
}
impl SessionState {
@@ -29,6 +29,7 @@ impl SessionState {
turns: 0,
compacted_messages: 0,
last_model: model.into(),
last_usage: UsageSummary::default(),
}
}
}
@@ -92,17 +93,21 @@ pub struct CliApp {
config: SessionConfig,
renderer: TerminalRenderer,
state: SessionState,
conversation_client: ConversationClient,
conversation_history: Vec<ConversationMessage>,
}
impl CliApp {
#[must_use]
pub fn new(config: SessionConfig) -> Self {
pub fn new(config: SessionConfig) -> Result<Self, RuntimeError> {
let state = SessionState::new(config.model.clone());
Self {
let conversation_client = ConversationClient::from_env(config.model.clone())?;
Ok(Self {
config,
renderer: TerminalRenderer::new(),
state,
}
conversation_client,
conversation_history: Vec::new(),
})
}
pub fn run_repl(&mut self) -> io::Result<()> {
@@ -172,11 +177,13 @@ impl CliApp {
fn handle_status(&mut self, out: &mut impl Write) -> io::Result<CommandResult> {
writeln!(
out,
"status: turns={} model={} permission-mode={:?} output-format={:?} config={}",
"status: turns={} model={} permission-mode={:?} output-format={:?} last-usage={} in/{} out config={}",
self.state.turns,
self.state.last_model,
self.config.permission_mode,
self.config.output_format,
self.state.last_usage.input_tokens,
self.state.last_usage.output_tokens,
self.config
.config
.as_ref()
@@ -188,6 +195,7 @@ impl CliApp {
fn handle_compact(&mut self, out: &mut impl Write) -> io::Result<CommandResult> {
self.state.compacted_messages += self.state.turns;
self.state.turns = 0;
self.conversation_history.clear();
writeln!(
out,
"Compacted session history into a local summary ({} messages total compacted).",
@@ -196,46 +204,147 @@ impl CliApp {
Ok(CommandResult::Continue)
}
fn render_response(&mut self, input: &str, out: &mut impl Write) -> io::Result<()> {
let mut spinner = Spinner::new();
for label in [
"Planning response",
"Running tool execution",
"Rendering markdown output",
] {
spinner.tick(label, self.renderer.color_theme(), out)?;
thread::sleep(Duration::from_millis(24));
fn handle_stream_event(
renderer: &TerminalRenderer,
event: StreamEvent,
stream_spinner: &mut Spinner,
tool_spinner: &mut Spinner,
saw_text: &mut bool,
turn_usage: &mut UsageSummary,
out: &mut impl Write,
) {
match event {
StreamEvent::TextDelta(delta) => {
if !*saw_text {
let _ =
stream_spinner.finish("Streaming response", renderer.color_theme(), out);
*saw_text = true;
}
let _ = write!(out, "{delta}");
let _ = out.flush();
}
StreamEvent::ToolCallStart { name, input } => {
if *saw_text {
let _ = writeln!(out);
}
let _ = tool_spinner.tick(
&format!("Running tool `{name}` with {input}"),
renderer.color_theme(),
out,
);
}
StreamEvent::ToolCallResult {
name,
output,
is_error,
} => {
let label = if is_error {
format!("Tool `{name}` failed")
} else {
format!("Tool `{name}` completed")
};
let _ = tool_spinner.finish(&label, renderer.color_theme(), out);
let rendered_output = format!("### Tool `{name}`\n\n```text\n{output}\n```\n");
let _ = renderer.stream_markdown(&rendered_output, out);
}
StreamEvent::Usage(usage) => {
*turn_usage = usage;
}
}
spinner.finish("Streaming response", self.renderer.color_theme(), out)?;
}
let response = demo_response(input, &self.config);
fn write_turn_output(
&self,
summary: &runtime::TurnSummary,
out: &mut impl Write,
) -> io::Result<()> {
match self.config.output_format {
OutputFormat::Text => self.renderer.stream_markdown(&response, out)?,
OutputFormat::Json => writeln!(out, "{{\"message\":{response:?}}}")?,
OutputFormat::Text => {
writeln!(
out,
"\nToken usage: {} input / {} output",
self.state.last_usage.input_tokens, self.state.last_usage.output_tokens
)?;
}
OutputFormat::Json => {
writeln!(
out,
"{}",
serde_json::json!({
"message": summary.assistant_text,
"usage": {
"input_tokens": self.state.last_usage.input_tokens,
"output_tokens": self.state.last_usage.output_tokens,
}
})
)?;
}
OutputFormat::Ndjson => {
writeln!(out, "{{\"type\":\"message\",\"text\":{response:?}}}")?;
writeln!(
out,
"{}",
serde_json::json!({
"type": "message",
"text": summary.assistant_text,
"usage": {
"input_tokens": self.state.last_usage.input_tokens,
"output_tokens": self.state.last_usage.output_tokens,
}
})
)?;
}
}
Ok(())
}
}
#[must_use]
pub fn demo_response(input: &str, config: &SessionConfig) -> String {
format!(
"## Assistant\n\nModel: `{}` \nPermission mode: `{}`\n\nYou said:\n\n> {}\n\nThis renderer now supports **bold**, *italic*, inline `code`, and syntax-highlighted blocks:\n\n```rust\nfn main() {{\n println!(\"streaming from rusty-claude-cli\");\n}}\n```",
config.model,
permission_mode_label(config.permission_mode),
input.trim()
)
}
fn render_response(&mut self, input: &str, out: &mut impl Write) -> io::Result<()> {
let mut stream_spinner = Spinner::new();
stream_spinner.tick(
"Opening conversation stream",
self.renderer.color_theme(),
out,
)?;
#[must_use]
pub fn permission_mode_label(mode: PermissionMode) -> &'static str {
match mode {
PermissionMode::ReadOnly => "read-only",
PermissionMode::WorkspaceWrite => "workspace-write",
PermissionMode::DangerFullAccess => "danger-full-access",
let mut turn_usage = UsageSummary::default();
let mut tool_spinner = Spinner::new();
let mut saw_text = false;
let renderer = &self.renderer;
let result =
self.conversation_client
.run_turn(&mut self.conversation_history, input, |event| {
Self::handle_stream_event(
renderer,
event,
&mut stream_spinner,
&mut tool_spinner,
&mut saw_text,
&mut turn_usage,
out,
);
});
let summary = match result {
Ok(summary) => summary,
Err(error) => {
stream_spinner.fail(
"Streaming response failed",
self.renderer.color_theme(),
out,
)?;
return Err(io::Error::other(error));
}
};
self.state.last_usage = summary.usage.clone();
if saw_text {
writeln!(out)?;
} else {
stream_spinner.finish("Streaming response", self.renderer.color_theme(), out)?;
}
self.write_turn_output(&summary, out)?;
let _ = turn_usage;
Ok(())
}
}
@@ -245,7 +354,7 @@ mod tests {
use crate::args::{OutputFormat, PermissionMode};
use super::{CliApp, CommandResult, SessionConfig, SlashCommand};
use super::{CommandResult, SessionConfig, SlashCommand};
#[test]
fn parses_required_slash_commands() {
@@ -258,33 +367,27 @@ mod tests {
}
#[test]
fn help_status_and_compact_commands_are_wired() {
fn help_output_lists_commands() {
let mut out = Vec::new();
let result = super::CliApp::handle_help(&mut out).expect("help succeeds");
assert_eq!(result, CommandResult::Continue);
let output = String::from_utf8_lossy(&out);
assert!(output.contains("/help"));
assert!(output.contains("/status"));
assert!(output.contains("/compact"));
}
#[test]
fn session_state_tracks_config_values() {
let config = SessionConfig {
model: "claude".into(),
permission_mode: PermissionMode::WorkspaceWrite,
config: Some(PathBuf::from("settings.toml")),
output_format: OutputFormat::Text,
};
let mut app = CliApp::new(config);
let mut out = Vec::new();
let result = app
.handle_submission("/help", &mut out)
.expect("help succeeds");
assert_eq!(result, CommandResult::Continue);
app.handle_submission("hello", &mut out)
.expect("submission succeeds");
app.handle_submission("/status", &mut out)
.expect("status succeeds");
app.handle_submission("/compact", &mut out)
.expect("compact succeeds");
let output = String::from_utf8_lossy(&out);
assert!(output.contains("/help"));
assert!(output.contains("/status"));
assert!(output.contains("/compact"));
assert!(output.contains("status: turns=1"));
assert!(output.contains("Compacted session history"));
assert_eq!(config.model, "claude");
assert_eq!(config.permission_mode, PermissionMode::WorkspaceWrite);
assert_eq!(config.config, Some(PathBuf::from("settings.toml")));
}
}

View File

@@ -1,59 +1,123 @@
mod app;
mod args;
mod input;
mod render;
use std::env;
use std::path::{Path, PathBuf};
use std::path::PathBuf;
use app::{CliApp, SessionConfig};
use args::{Cli, Command};
use clap::Parser;
use commands::handle_slash_command;
use compat_harness::{extract_manifest, UpstreamPaths};
use runtime::BootstrapPlan;
use runtime::{load_system_prompt, BootstrapPlan, CompactionConfig, Session};
fn main() {
let cli = Cli::parse();
let args: Vec<String> = env::args().skip(1).collect();
let result = match &cli.command {
Some(Command::DumpManifests) => dump_manifests(),
Some(Command::BootstrapPlan) => {
print_bootstrap_plan();
Ok(())
match parse_args(&args) {
Ok(CliAction::DumpManifests) => dump_manifests(),
Ok(CliAction::BootstrapPlan) => print_bootstrap_plan(),
Ok(CliAction::PrintSystemPrompt { cwd, date }) => print_system_prompt(cwd, date),
Ok(CliAction::ResumeSession {
session_path,
command,
}) => resume_session(&session_path, command),
Ok(CliAction::Help) => print_help(),
Err(error) => {
eprintln!("{error}");
print_help();
std::process::exit(2);
}
Some(Command::Prompt { prompt }) => {
let joined = prompt.join(" ");
let mut app = CliApp::new(build_session_config(&cli));
app.run_prompt(&joined, &mut std::io::stdout())
}
None => {
let mut app = CliApp::new(build_session_config(&cli));
app.run_repl()
}
};
if let Err(error) = result {
eprintln!("{error}");
std::process::exit(1);
}
}
fn build_session_config(cli: &Cli) -> SessionConfig {
SessionConfig {
model: cli.model.clone(),
permission_mode: cli.permission_mode,
config: cli.config.clone(),
output_format: cli.output_format,
#[derive(Debug, Clone, PartialEq, Eq)]
enum CliAction {
DumpManifests,
BootstrapPlan,
PrintSystemPrompt {
cwd: PathBuf,
date: String,
},
ResumeSession {
session_path: PathBuf,
command: Option<String>,
},
Help,
}
fn parse_args(args: &[String]) -> Result<CliAction, String> {
if args.is_empty() {
return Ok(CliAction::Help);
}
if matches!(args.first().map(String::as_str), Some("--help" | "-h")) {
return Ok(CliAction::Help);
}
if args.first().map(String::as_str) == Some("--resume") {
return parse_resume_args(&args[1..]);
}
match args[0].as_str() {
"dump-manifests" => Ok(CliAction::DumpManifests),
"bootstrap-plan" => Ok(CliAction::BootstrapPlan),
"system-prompt" => parse_system_prompt_args(&args[1..]),
other => Err(format!("unknown subcommand: {other}")),
}
}
fn dump_manifests() -> std::io::Result<()> {
fn parse_system_prompt_args(args: &[String]) -> Result<CliAction, String> {
let mut cwd = env::current_dir().map_err(|error| error.to_string())?;
let mut date = "2026-03-31".to_string();
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--cwd" => {
let value = args
.get(index + 1)
.ok_or_else(|| "missing value for --cwd".to_string())?;
cwd = PathBuf::from(value);
index += 2;
}
"--date" => {
let value = args
.get(index + 1)
.ok_or_else(|| "missing value for --date".to_string())?;
date.clone_from(value);
index += 2;
}
other => return Err(format!("unknown system-prompt option: {other}")),
}
}
Ok(CliAction::PrintSystemPrompt { cwd, date })
}
fn parse_resume_args(args: &[String]) -> Result<CliAction, String> {
let session_path = args
.first()
.ok_or_else(|| "missing session path for --resume".to_string())
.map(PathBuf::from)?;
let command = args.get(1).cloned();
if args.len() > 2 {
return Err("--resume accepts at most one trailing slash command".to_string());
}
Ok(CliAction::ResumeSession {
session_path,
command,
})
}
fn dump_manifests() {
let workspace_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
let paths = UpstreamPaths::from_workspace_dir(&workspace_dir);
let manifest = extract_manifest(&paths)?;
println!("commands: {}", manifest.commands.entries().len());
println!("tools: {}", manifest.tools.entries().len());
println!("bootstrap phases: {}", manifest.bootstrap.phases().len());
Ok(())
match extract_manifest(&paths) {
Ok(manifest) => {
println!("commands: {}", manifest.commands.entries().len());
println!("tools: {}", manifest.tools.entries().len());
println!("bootstrap phases: {}", manifest.bootstrap.phases().len());
}
Err(error) => {
eprintln!("failed to extract manifests: {error}");
std::process::exit(1);
}
}
}
fn print_bootstrap_plan() {
@@ -61,3 +125,108 @@ fn print_bootstrap_plan() {
println!("- {phase:?}");
}
}
fn print_system_prompt(cwd: PathBuf, date: String) {
match load_system_prompt(cwd, date, env::consts::OS, "unknown") {
Ok(sections) => println!("{}", sections.join("\n\n")),
Err(error) => {
eprintln!("failed to build system prompt: {error}");
std::process::exit(1);
}
}
}
fn resume_session(session_path: &Path, command: Option<String>) {
let session = match Session::load_from_path(session_path) {
Ok(session) => session,
Err(error) => {
eprintln!("failed to restore session: {error}");
std::process::exit(1);
}
};
match command {
Some(command) if command.starts_with('/') => {
let Some(result) = handle_slash_command(
&command,
&session,
CompactionConfig {
max_estimated_tokens: 0,
..CompactionConfig::default()
},
) else {
eprintln!("unknown slash command: {command}");
std::process::exit(2);
};
if let Err(error) = result.session.save_to_path(session_path) {
eprintln!("failed to persist resumed session: {error}");
std::process::exit(1);
}
println!("{}", result.message);
}
Some(other) => {
eprintln!("unsupported resumed command: {other}");
std::process::exit(2);
}
None => {
println!(
"Restored session from {} ({} messages).",
session_path.display(),
session.messages.len()
);
}
}
}
fn print_help() {
println!("rusty-claude-cli");
println!();
println!("Current scaffold commands:");
println!(
" dump-manifests Read upstream TS sources and print extracted counts"
);
println!(" bootstrap-plan Print the current bootstrap phase skeleton");
println!(" system-prompt [--cwd PATH] [--date YYYY-MM-DD]");
println!(" Build a Claude-style system prompt from CLAUDE.md and config files");
println!(" --resume SESSION.json [/compact] Restore a saved session and optionally run a slash command");
}
#[cfg(test)]
mod tests {
use super::{parse_args, CliAction};
use std::path::PathBuf;
#[test]
fn parses_system_prompt_options() {
let args = vec![
"system-prompt".to_string(),
"--cwd".to_string(),
"/tmp/project".to_string(),
"--date".to_string(),
"2026-04-01".to_string(),
];
assert_eq!(
parse_args(&args).expect("args should parse"),
CliAction::PrintSystemPrompt {
cwd: PathBuf::from("/tmp/project"),
date: "2026-04-01".to_string(),
}
);
}
#[test]
fn parses_resume_flag_with_slash_command() {
let args = vec![
"--resume".to_string(),
"session.json".to_string(),
"/compact".to_string(),
];
assert_eq!(
parse_args(&args).expect("args should parse"),
CliAction::ResumeSession {
session_path: PathBuf::from("session.json"),
command: Some("/compact".to_string()),
}
);
}
}

View File

@@ -23,6 +23,7 @@ pub struct ColorTheme {
quote: Color,
spinner_active: Color,
spinner_done: Color,
spinner_failed: Color,
}
impl Default for ColorTheme {
@@ -36,6 +37,7 @@ impl Default for ColorTheme {
quote: Color::DarkGrey,
spinner_active: Color::Blue,
spinner_done: Color::Green,
spinner_failed: Color::Red,
}
}
}
@@ -91,6 +93,24 @@ impl Spinner {
)?;
out.flush()
}
pub fn fail(
&mut self,
label: &str,
theme: &ColorTheme,
out: &mut impl Write,
) -> io::Result<()> {
self.frame_index = 0;
execute!(
out,
MoveToColumn(0),
Clear(ClearType::CurrentLine),
SetForegroundColor(theme.spinner_failed),
Print(format!("{label}\n")),
ResetColor
)?;
out.flush()
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]