@@ -8,6 +8,7 @@ use devo_core::AppConfigLoader;
88use devo_core:: FileSystemAppConfigLoader ;
99use devo_core:: LoggingBootstrap ;
1010use devo_core:: LoggingRuntime ;
11+ use devo_core:: SessionId ;
1112use devo_core:: UpdateCheckOutcome ;
1213use devo_core:: UpdateChecker ;
1314use devo_core:: format_update_notification;
@@ -52,6 +53,86 @@ fn main() -> Result<()> {
5253 devo_arg0:: run_as ( |_paths| async { run_cli ( ) . await } )
5354}
5455
56+ fn format_with_separators ( value : usize ) -> String {
57+ let digits = value. to_string ( ) ;
58+ let mut out = String :: new ( ) ;
59+ for ( index, ch) in digits. chars ( ) . rev ( ) . enumerate ( ) {
60+ if index > 0 && index % 3 == 0 {
61+ out. push ( ',' ) ;
62+ }
63+ out. push ( ch) ;
64+ }
65+ out. chars ( ) . rev ( ) . collect ( )
66+ }
67+
68+ fn format_token_usage_line ( exit : & devo_tui:: AppExit , color_enabled : bool ) -> Option < String > {
69+ let total = exit. total_input_tokens + exit. total_output_tokens ;
70+ let non_cached_input = exit
71+ . total_input_tokens
72+ . saturating_sub ( exit. total_cache_read_tokens ) ;
73+ if total == 0 && exit. total_cache_read_tokens == 0 {
74+ return None ;
75+ }
76+ let total_value = format_with_separators ( total) ;
77+ let input_value = format_with_separators ( non_cached_input) ;
78+ let output_value = format_with_separators ( exit. total_output_tokens ) ;
79+ let cached_suffix = if exit. total_cache_read_tokens > 0 {
80+ let cached_value = format_with_separators ( exit. total_cache_read_tokens ) ;
81+ if color_enabled {
82+ format ! (
83+ " (+ {} {})" ,
84+ "\u{1b} [1;33m" . to_string( ) + & cached_value + "\u{1b} [0m" ,
85+ "\u{1b} [33mcached\u{1b} [0m"
86+ )
87+ } else {
88+ format ! ( " (+ {cached_value} cached)" )
89+ }
90+ } else {
91+ String :: new ( )
92+ } ;
93+ Some ( format ! (
94+ "Token usage: total={} input={}{} output={}" ,
95+ if color_enabled {
96+ format!( "\u{1b} [1;36m{total_value}\u{1b} [0m" )
97+ } else {
98+ total_value
99+ } ,
100+ if color_enabled {
101+ format!( "\u{1b} [1;32m{input_value}\u{1b} [0m" )
102+ } else {
103+ input_value
104+ } ,
105+ cached_suffix,
106+ if color_enabled {
107+ format!( "\u{1b} [1;35m{output_value}\u{1b} [0m" )
108+ } else {
109+ output_value
110+ } ,
111+ ) )
112+ }
113+
114+ fn exit_messages ( exit : & devo_tui:: AppExit , color_enabled : bool ) -> Vec < String > {
115+ let mut lines = Vec :: new ( ) ;
116+ if let Some ( line) = format_token_usage_line ( exit, color_enabled) {
117+ lines. push ( line) ;
118+ }
119+ if let Some ( session_id) = exit. session_id {
120+ let command = format ! ( "devo resume {session_id}" ) ;
121+ let command = if color_enabled {
122+ format ! ( "\u{1b} [1;36m{command}\u{1b} [0m" )
123+ } else {
124+ command
125+ } ;
126+ let prefix = if color_enabled {
127+ "\u{1b} [2mTo continue this session, run\u{1b} [0m" . to_string ( )
128+ } else {
129+ "To continue this session, run" . to_string ( )
130+ } ;
131+ lines. push ( format ! ( "{prefix} {command}" ) ) ;
132+ }
133+ lines
134+ }
135+
55136async fn run_cli ( ) -> Result < ( ) > {
56137 let cli = Cli :: parse ( ) ;
57138 let log_level = cli. log_level . map ( |level| level. to_string ( ) ) ;
@@ -62,7 +143,11 @@ async fn run_cli() -> Result<()> {
62143 // Resolve logging config early, install the process-wide file subscriber,
63144 // and keep its non-blocking writer guard alive for the command lifetime.
64145 let _logging = install_logging ( & cli) ?;
65- run_agent ( /*force_onboarding*/ true , log_level. as_deref ( ) ) . await
146+ let exit = run_agent ( /*force_onboarding*/ true , log_level. as_deref ( ) , None ) . await ?;
147+ for line in exit_messages ( & exit, /*color_enabled*/ true ) {
148+ println ! ( "{line}" ) ;
149+ }
150+ Ok ( ( ) )
66151 }
67152 Some ( Command :: Prompt { input } ) => {
68153 maybe_print_startup_update ( & cli) . await ;
@@ -73,6 +158,20 @@ async fn run_cli() -> Result<()> {
73158 let _logging = install_logging ( & cli) ?;
74159 run_doctor ( ) . await
75160 }
161+ Some ( Command :: Resume { session_id } ) => {
162+ maybe_print_startup_update ( & cli) . await ;
163+ let _logging = install_logging ( & cli) ?;
164+ let exit = run_agent (
165+ /*force_onboarding*/ false ,
166+ log_level. as_deref ( ) ,
167+ Some ( * session_id) ,
168+ )
169+ . await ?;
170+ for line in exit_messages ( & exit, /*color_enabled*/ true ) {
171+ println ! ( "{line}" ) ;
172+ }
173+ Ok ( ( ) )
174+ }
76175 Some ( Command :: Server {
77176 working_root,
78177 transport,
@@ -87,7 +186,11 @@ async fn run_cli() -> Result<()> {
87186 None => {
88187 maybe_print_startup_update ( & cli) . await ;
89188 let _logging = install_logging ( & cli) ?;
90- run_agent ( /*force_onboarding*/ false , log_level. as_deref ( ) ) . await
189+ let exit = run_agent ( /*force_onboarding*/ false , log_level. as_deref ( ) , None ) . await ?;
190+ for line in exit_messages ( & exit, /*color_enabled*/ true ) {
191+ println ! ( "{line}" ) ;
192+ }
193+ Ok ( ( ) )
91194 }
92195 }
93196}
@@ -96,6 +199,11 @@ async fn run_cli() -> Result<()> {
96199enum Command {
97200 /// Launch the interactive onboarding flow to configure a model provider.
98201 Onboard ,
202+ /// Resume a saved interactive session by id.
203+ Resume {
204+ /// Session identifier printed by Devo at exit time.
205+ session_id : SessionId ,
206+ } ,
99207 /// Send a single prompt to the model and print the response (non-interactive).
100208 Prompt {
101209 /// The prompt text to send to the model.
@@ -193,12 +301,15 @@ fn cli_logging_overrides(cli: &Cli) -> toml::Value {
193301#[ cfg( test) ]
194302mod tests {
195303 use clap:: Parser ;
304+ use devo_core:: SessionId ;
196305 use pretty_assertions:: assert_eq;
197306 use tracing_subscriber:: filter:: LevelFilter ;
198307
199308 use super :: Cli ;
200309 use super :: Command ;
201310 use super :: cli_logging_overrides;
311+ use super :: exit_messages;
312+ use super :: format_token_usage_line;
202313
203314 #[ test]
204315 fn cli_parses_supported_log_levels ( ) {
@@ -328,4 +439,56 @@ mod tests {
328439 false
329440 ) ;
330441 }
442+
443+ #[ test]
444+ fn cli_parses_resume_subcommand ( ) {
445+ let session_id = SessionId :: new ( ) ;
446+ let cli =
447+ Cli :: try_parse_from ( [ "devo" , "resume" , & session_id. to_string ( ) ] ) . expect ( "parse resume" ) ;
448+
449+ match cli. command {
450+ Some ( Command :: Resume { session_id : actual } ) => assert_eq ! ( actual, session_id) ,
451+ other => panic ! ( "expected resume command, got {other:?}" ) ,
452+ }
453+ }
454+
455+ #[ test]
456+ fn exit_messages_includes_usage_and_resume_hint ( ) {
457+ let session_id = SessionId :: new ( ) ;
458+ let exit = devo_tui:: AppExit {
459+ session_id : Some ( session_id) ,
460+ turn_count : 1 ,
461+ total_input_tokens : 10 ,
462+ total_output_tokens : 2 ,
463+ total_cache_read_tokens : 5 ,
464+ } ;
465+
466+ let lines = exit_messages ( & exit, /*color_enabled*/ false ) ;
467+ assert_eq ! (
468+ lines[ 0 ] ,
469+ "Token usage: total=12 input=5 (+ 5 cached) output=2"
470+ ) ;
471+ assert_eq ! (
472+ lines[ 1 ] ,
473+ format!( "To continue this session, run devo resume {session_id}" )
474+ ) ;
475+ }
476+
477+ #[ test]
478+ fn colorized_exit_messages_include_ansi_sequences ( ) {
479+ let session_id = SessionId :: new ( ) ;
480+ let exit = devo_tui:: AppExit {
481+ session_id : Some ( session_id) ,
482+ turn_count : 1 ,
483+ total_input_tokens : 10 ,
484+ total_output_tokens : 2 ,
485+ total_cache_read_tokens : 5 ,
486+ } ;
487+
488+ let usage = format_token_usage_line ( & exit, /*color_enabled*/ true ) . expect ( "usage line" ) ;
489+ assert ! ( usage. contains( "\u{1b} [" ) ) ;
490+
491+ let lines = exit_messages ( & exit, /*color_enabled*/ true ) ;
492+ assert ! ( lines[ 1 ] . contains( "\u{1b} [" ) ) ;
493+ }
331494}
0 commit comments