Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@

- [Installation](#-installation)
- [Quick Start](#-quick-start)
- [Configuration](#%EF%B8%8F-configuration)
- [FAQ](#-faq)
- [Contributing](#-contributing)
- [References](#-references)
Expand Down
9 changes: 7 additions & 2 deletions crates/cli/src/agent_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use devo_core::ModelCatalog;
use devo_core::PresetModelCatalog;
use devo_core::ProviderConfigFile;
use devo_core::ResolvedProviderSettings;
use devo_core::SessionId;
use devo_core::load_config;
use devo_core::project_config_key;
use devo_core::resolve_provider_settings;
Expand All @@ -23,7 +24,11 @@ use devo_utils::find_devo_home;
/// when a provider config already exists. `log_level` is forwarded to the
/// background server process, and `model_override` replaces the resolved model
/// for this session without mutating the stored provider config.
pub(crate) async fn run_agent(force_onboarding: bool, log_level: Option<&str>) -> Result<()> {
pub(crate) async fn run_agent(
force_onboarding: bool,
log_level: Option<&str>,
initial_session_id: Option<SessionId>,
) -> Result<devo_tui::AppExit> {
let cwd = std::env::current_dir()?;
let config_home = find_devo_home().context("could not determine devo home directory")?;
let model_catalog = PresetModelCatalog::load_from_config(&config_home, Some(&cwd))?;
Expand Down Expand Up @@ -55,6 +60,7 @@ pub(crate) async fn run_agent(force_onboarding: bool, log_level: Option<&str>) -
run_interactive_tui(InteractiveTuiConfig {
// initial_session corresponding fields at top of `config.toml`.
initial_session: InitialTuiSession {
session_id: initial_session_id,
model,
provider: wire_api,
thinking_selection: model_thinking_selection,
Expand All @@ -68,7 +74,6 @@ pub(crate) async fn run_agent(force_onboarding: bool, log_level: Option<&str>) -
show_model_onboarding: onboarding_mode,
})
.await
.map(|_| ())
}

/// Resolves the initial provider settings and whether onboarding should be shown.
Expand Down
167 changes: 165 additions & 2 deletions crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use devo_core::AppConfigLoader;
use devo_core::FileSystemAppConfigLoader;
use devo_core::LoggingBootstrap;
use devo_core::LoggingRuntime;
use devo_core::SessionId;
use devo_core::UpdateCheckOutcome;
use devo_core::UpdateChecker;
use devo_core::format_update_notification;
Expand Down Expand Up @@ -52,6 +53,86 @@ fn main() -> Result<()> {
devo_arg0::run_as(|_paths| async { run_cli().await })
}

fn format_with_separators(value: usize) -> String {
let digits = value.to_string();
let mut out = String::new();
for (index, ch) in digits.chars().rev().enumerate() {
if index > 0 && index % 3 == 0 {
out.push(',');
}
out.push(ch);
}
out.chars().rev().collect()
}

fn format_token_usage_line(exit: &devo_tui::AppExit, color_enabled: bool) -> Option<String> {
let total = exit.total_input_tokens + exit.total_output_tokens;
let non_cached_input = exit
.total_input_tokens
.saturating_sub(exit.total_cache_read_tokens);
if total == 0 && exit.total_cache_read_tokens == 0 {
return None;
}
let total_value = format_with_separators(total);
let input_value = format_with_separators(non_cached_input);
let output_value = format_with_separators(exit.total_output_tokens);
let cached_suffix = if exit.total_cache_read_tokens > 0 {
let cached_value = format_with_separators(exit.total_cache_read_tokens);
if color_enabled {
format!(
" (+ {} {})",
"\u{1b}[1;33m".to_string() + &cached_value + "\u{1b}[0m",
"\u{1b}[33mcached\u{1b}[0m"
)
} else {
format!(" (+ {cached_value} cached)")
}
} else {
String::new()
};
Some(format!(
"Token usage: total={} input={}{} output={}",
if color_enabled {
format!("\u{1b}[1;36m{total_value}\u{1b}[0m")
} else {
total_value
},
if color_enabled {
format!("\u{1b}[1;32m{input_value}\u{1b}[0m")
} else {
input_value
},
cached_suffix,
if color_enabled {
format!("\u{1b}[1;35m{output_value}\u{1b}[0m")
} else {
output_value
},
))
}

fn exit_messages(exit: &devo_tui::AppExit, color_enabled: bool) -> Vec<String> {
let mut lines = Vec::new();
if let Some(line) = format_token_usage_line(exit, color_enabled) {
lines.push(line);
}
if let Some(session_id) = exit.session_id {
let command = format!("devo resume {session_id}");
let command = if color_enabled {
format!("\u{1b}[1;36m{command}\u{1b}[0m")
} else {
command
};
let prefix = if color_enabled {
"\u{1b}[2mTo continue this session, run\u{1b}[0m".to_string()
} else {
"To continue this session, run".to_string()
};
lines.push(format!("{prefix} {command}"));
}
lines
}

async fn run_cli() -> Result<()> {
let cli = Cli::parse();
let log_level = cli.log_level.map(|level| level.to_string());
Expand All @@ -62,7 +143,11 @@ async fn run_cli() -> Result<()> {
// Resolve logging config early, install the process-wide file subscriber,
// and keep its non-blocking writer guard alive for the command lifetime.
let _logging = install_logging(&cli)?;
run_agent(/*force_onboarding*/ true, log_level.as_deref()).await
let exit = run_agent(/*force_onboarding*/ true, log_level.as_deref(), None).await?;
for line in exit_messages(&exit, /*color_enabled*/ true) {
println!("{line}");
}
Ok(())
}
Some(Command::Prompt { input }) => {
maybe_print_startup_update(&cli).await;
Expand All @@ -73,6 +158,20 @@ async fn run_cli() -> Result<()> {
let _logging = install_logging(&cli)?;
run_doctor().await
}
Some(Command::Resume { session_id }) => {
maybe_print_startup_update(&cli).await;
let _logging = install_logging(&cli)?;
let exit = run_agent(
/*force_onboarding*/ false,
log_level.as_deref(),
Some(*session_id),
)
.await?;
for line in exit_messages(&exit, /*color_enabled*/ true) {
println!("{line}");
}
Ok(())
}
Some(Command::Server {
working_root,
transport,
Expand All @@ -87,7 +186,11 @@ async fn run_cli() -> Result<()> {
None => {
maybe_print_startup_update(&cli).await;
let _logging = install_logging(&cli)?;
run_agent(/*force_onboarding*/ false, log_level.as_deref()).await
let exit = run_agent(/*force_onboarding*/ false, log_level.as_deref(), None).await?;
for line in exit_messages(&exit, /*color_enabled*/ true) {
println!("{line}");
}
Ok(())
}
}
}
Expand All @@ -96,6 +199,11 @@ async fn run_cli() -> Result<()> {
enum Command {
/// Launch the interactive onboarding flow to configure a model provider.
Onboard,
/// Resume a saved interactive session by id.
Resume {
/// Session identifier printed by Devo at exit time.
session_id: SessionId,
},
/// Send a single prompt to the model and print the response (non-interactive).
Prompt {
/// The prompt text to send to the model.
Expand Down Expand Up @@ -193,12 +301,15 @@ fn cli_logging_overrides(cli: &Cli) -> toml::Value {
#[cfg(test)]
mod tests {
use clap::Parser;
use devo_core::SessionId;
use pretty_assertions::assert_eq;
use tracing_subscriber::filter::LevelFilter;

use super::Cli;
use super::Command;
use super::cli_logging_overrides;
use super::exit_messages;
use super::format_token_usage_line;

#[test]
fn cli_parses_supported_log_levels() {
Expand Down Expand Up @@ -328,4 +439,56 @@ mod tests {
false
);
}

#[test]
fn cli_parses_resume_subcommand() {
let session_id = SessionId::new();
let cli =
Cli::try_parse_from(["devo", "resume", &session_id.to_string()]).expect("parse resume");

match cli.command {
Some(Command::Resume { session_id: actual }) => assert_eq!(actual, session_id),
other => panic!("expected resume command, got {other:?}"),
}
}

#[test]
fn exit_messages_includes_usage_and_resume_hint() {
let session_id = SessionId::new();
let exit = devo_tui::AppExit {
session_id: Some(session_id),
turn_count: 1,
total_input_tokens: 10,
total_output_tokens: 2,
total_cache_read_tokens: 5,
};

let lines = exit_messages(&exit, /*color_enabled*/ false);
assert_eq!(
lines[0],
"Token usage: total=12 input=5 (+ 5 cached) output=2"
);
assert_eq!(
lines[1],
format!("To continue this session, run devo resume {session_id}")
);
}

#[test]
fn colorized_exit_messages_include_ansi_sequences() {
let session_id = SessionId::new();
let exit = devo_tui::AppExit {
session_id: Some(session_id),
turn_count: 1,
total_input_tokens: 10,
total_output_tokens: 2,
total_cache_read_tokens: 5,
};

let usage = format_token_usage_line(&exit, /*color_enabled*/ true).expect("usage line");
assert!(usage.contains("\u{1b}["));

let lines = exit_messages(&exit, /*color_enabled*/ true);
assert!(lines[1].contains("\u{1b}["));
}
}
Loading
Loading