@@ -35,6 +35,12 @@ use stitch::core::{
3535#[ cfg( feature = "ui" ) ]
3636const UI_OUTPUT_CHAR_LIMIT : usize = 50_000 ;
3737
38+ #[ cfg( all( feature = "ui" , feature = "tokens" ) ) ]
39+ use std:: sync:: OnceLock ;
40+
41+ #[ cfg( all( feature = "ui" , feature = "tokens" ) ) ]
42+ use tiktoken_rs:: { o200k_base, CoreBPE } ;
43+
3844#[ cfg( feature = "ui" ) ]
3945#[ derive( Default ) ]
4046struct AppState {
@@ -77,6 +83,7 @@ fn main() -> anyhow::Result<()> {
7783 app. set_output_text ( "" . into ( ) ) ;
7884 app. set_show_copy_toast ( false ) ;
7985 app. set_copy_toast_text ( "" . into ( ) ) ;
86+ app. set_output_stats ( "0 chars • 0 tokens" . into ( ) ) ;
8087
8188 let state = Rc :: new ( RefCell :: new ( AppState {
8289 poll_interval_ms : 45_000 ,
@@ -238,7 +245,7 @@ fn apply_selection_from_text(app: &AppWindow, state: &Rc<RefCell<AppState>>, tex
238245 for c in & node. children {
239246 walk_and_mark ( c, project_root, wanted, explicit) ;
240247 }
241- } else if let Some ( rel) = pathdiff :: diff_paths ( & node. path , project_root) {
248+ } else if let Ok ( rel) = node. path . strip_prefix ( project_root) {
242249 let key = rel
243250 . iter ( )
244251 . map ( |c| c. to_string_lossy ( ) )
@@ -349,18 +356,18 @@ fn on_generate_output(app: &AppWindow, state: &Rc<RefCell<AppState>>) {
349356 let mut rels = Vec :: new ( ) ;
350357 if want_dirs_only {
351358 for d in & dirs {
352- if let Some ( r) = pathdiff :: diff_paths ( d , selected_dir)
353- && r != PathBuf :: from ( "" )
354- {
355- rels . push ( path_to_unix ( & r ) ) ;
359+ if let Ok ( r) = d . strip_prefix ( selected_dir) {
360+ if !r . as_os_str ( ) . is_empty ( ) {
361+ rels . push ( path_to_unix ( r ) ) ;
362+ }
356363 }
357364 }
358365 } else {
359366 for f in & files {
360- if let Some ( r) = pathdiff :: diff_paths ( f , selected_dir)
361- && r != PathBuf :: from ( "" )
362- {
363- rels . push ( path_to_unix ( & r ) ) ;
367+ if let Ok ( r) = f . strip_prefix ( selected_dir) {
368+ if !r . as_os_str ( ) . is_empty ( ) {
369+ rels . push ( path_to_unix ( r ) ) ;
370+ }
364371 }
365372 }
366373 }
@@ -417,8 +424,10 @@ fn on_generate_output(app: &AppWindow, state: &Rc<RefCell<AppState>>) {
417424 let s_dir = { state. borrow ( ) . selected_directory . clone ( ) . unwrap ( ) } ;
418425
419426 for fp in selected_files {
420- let rel = pathdiff:: diff_paths ( & fp, & s_dir)
421- . unwrap_or_else ( || PathBuf :: from ( fp. file_name ( ) . unwrap_or_default ( ) ) ) ;
427+ let rel: PathBuf = fp
428+ . strip_prefix ( & s_dir)
429+ . map ( |p| p. to_path_buf ( ) )
430+ . unwrap_or_else ( |_| PathBuf :: from ( fp. file_name ( ) . unwrap_or_default ( ) ) ) ;
422431
423432 let mut contents = match fs:: read_to_string ( & fp) {
424433 Ok ( c) => c,
@@ -684,14 +693,18 @@ fn set_output(app: &AppWindow, state: &Rc<RefCell<AppState>>, s: &str) {
684693 st. full_output_text = normalized. clone ( ) ;
685694 }
686695
696+ // Count characters & tokens on the FULL output (not the truncated view)
687697 let total_chars = normalized. chars ( ) . count ( ) ;
698+ let total_tokens = count_tokens ( & normalized) ;
699+ app. set_output_stats ( format ! ( "{} chars • {} tokens" , total_chars, total_tokens) . into ( ) ) ;
688700
689701 // Build the displayed string (≤ limit) and add a concise footer if truncated
690702 let displayed: String = if total_chars <= UI_OUTPUT_CHAR_LIMIT {
691703 normalized. clone ( )
692704 } else {
705+ // Using a couple more /n than necessary because I couldn't get padding to work in the UI
693706 let footer = format ! (
694- "\n … [truncated: showing {} of {} chars — use “Copy Output” to copy all]\n " ,
707+ "\n … [truncated: showing {} of {} chars — use “Copy Output” to copy all]\n \n \n " ,
695708 UI_OUTPUT_CHAR_LIMIT , total_chars
696709 ) ;
697710 // Ensure we stay within the hard UI limit, including the footer itself
@@ -832,3 +845,16 @@ fn start_fs_watcher(app: &AppWindow, state: &Rc<RefCell<AppState>>) -> notify::R
832845
833846 Ok ( ( ) )
834847}
848+
849+ #[ cfg( all( feature = "ui" , feature = "tokens" ) ) ]
850+ fn count_tokens ( text : & str ) -> usize {
851+ // Build once, reuse forever
852+ static BPE : OnceLock < CoreBPE > = OnceLock :: new ( ) ;
853+ let bpe = BPE . get_or_init ( || o200k_base ( ) . expect ( "failed to load o200k_base BPE" ) ) ;
854+ bpe. encode_with_special_tokens ( text) . len ( )
855+ }
856+
857+ #[ cfg( all( feature = "ui" , not( feature = "tokens" ) ) ) ]
858+ fn count_tokens ( text : & str ) -> usize {
859+ text. split_whitespace ( ) . filter ( |s| !s. is_empty ( ) ) . count ( )
860+ }
0 commit comments