@@ -19,6 +19,7 @@ use ratatui::layout::Rect;
19
19
use ratatui:: style:: Stylize as _;
20
20
use ratatui:: text:: Line ;
21
21
use ratatui:: text:: Span ;
22
+ use std:: env;
22
23
use tokio:: sync:: mpsc;
23
24
use tokio_stream:: StreamExt ;
24
25
use tokio_stream:: wrappers:: UnboundedReceiverStream ;
@@ -30,6 +31,7 @@ use crate::tui::TuiEvent;
30
31
use codex_protocol:: models:: ContentItem ;
31
32
use codex_protocol:: models:: ResponseItem ;
32
33
use codex_protocol:: protocol:: InputMessageKind ;
34
+ use codex_protocol:: protocol:: SessionMetaLine ;
33
35
use codex_protocol:: protocol:: USER_MESSAGE_BEGIN ;
34
36
35
37
const PAGE_SIZE : usize = 25 ;
@@ -64,6 +66,41 @@ enum BackgroundEvent {
64
66
/// search and pagination. Shows the first user input as the preview, relative
65
67
/// time (e.g., "5 seconds ago"), and the absolute path.
66
68
pub async fn run_resume_picker ( tui : & mut Tui , codex_home : & Path ) -> Result < ResumeSelection > {
69
+ // Plain, non-interactive verification mode: print rows and exit.
70
+ // Enables checking the '[project]' tag & preview without onboarding/TUI.
71
+ if env:: var ( "CODEX_TUI_PLAIN" ) . as_deref ( ) == Ok ( "1" ) {
72
+ match RolloutRecorder :: list_conversations ( codex_home, PAGE_SIZE , None ) . await {
73
+ Ok ( page) => {
74
+ let rows: Vec < Row > = page. items . iter ( ) . map ( |it| head_to_row ( it) ) . collect ( ) ;
75
+ let no_color = env:: var ( "NO_COLOR" ) . is_ok ( ) ;
76
+ let dumb = env:: var ( "TERM" ) . unwrap_or_default ( ) == "dumb" ;
77
+ let use_color = !no_color && !dumb;
78
+ for ( i, r) in rows. iter ( ) . enumerate ( ) {
79
+ let mark = if i == 0 { "> " } else { " " } ;
80
+ let ts =
81
+ r. ts . as_ref ( )
82
+ . map ( |dt| human_time_ago ( dt. clone ( ) ) )
83
+ . unwrap_or_else ( || "-" . to_string ( ) ) ;
84
+ let tag = r. project . as_deref ( ) . unwrap_or ( "<cwd>" ) ;
85
+ // Sanitize preview to a single line, limited length similar to TUI
86
+ let mut pv = r. preview . replace ( '\n' , " " ) ;
87
+ if pv. len ( ) > 80 {
88
+ pv. truncate ( 79 ) ;
89
+ pv. push ( '…' ) ;
90
+ }
91
+ if use_color {
92
+ println ! ( "{mark}{ts:<12} \x1b [36;1m[{tag}]\x1b [0m {pv}" ) ;
93
+ } else {
94
+ println ! ( "{mark}{ts:<12} [{tag}] {pv}" ) ;
95
+ }
96
+ }
97
+ }
98
+ Err ( e) => {
99
+ eprintln ! ( "Failed to list conversations: {e}" ) ;
100
+ }
101
+ }
102
+ return Ok ( ResumeSelection :: StartFresh ) ;
103
+ }
67
104
let alt = AltScreenGuard :: enter ( tui) ;
68
105
let ( bg_tx, bg_rx) = mpsc:: unbounded_channel ( ) ;
69
106
@@ -91,6 +128,12 @@ pub async fn run_resume_picker(tui: &mut Tui, codex_home: &Path) -> Result<Resum
91
128
page_loader,
92
129
) ;
93
130
state. load_initial_page ( ) . await ?;
131
+ if let Ok ( q) = env:: var ( "CODEX_TUI_FILTER" ) {
132
+ let q = q. trim ( ) ;
133
+ if !q. is_empty ( ) {
134
+ state. set_query ( q. to_string ( ) ) ;
135
+ }
136
+ }
94
137
state. request_frame ( ) ;
95
138
96
139
let mut tui_events = alt. tui . event_stream ( ) . fuse ( ) ;
@@ -219,6 +262,7 @@ struct Row {
219
262
path : PathBuf ,
220
263
preview : String ,
221
264
ts : Option < DateTime < Utc > > ,
265
+ project : Option < String > ,
222
266
}
223
267
224
268
impl PickerState {
@@ -565,13 +609,27 @@ fn rows_from_items(items: Vec<ConversationItem>) -> Vec<Row> {
565
609
566
610
fn head_to_row ( item : & ConversationItem ) -> Row {
567
611
let mut ts: Option < DateTime < Utc > > = None ;
612
+ let mut project: Option < String > = None ;
568
613
if let Some ( first) = item. head . first ( )
569
614
&& let Some ( t) = first. get ( "timestamp" ) . and_then ( |v| v. as_str ( ) )
570
615
&& let Ok ( parsed) = chrono:: DateTime :: parse_from_rfc3339 ( t)
571
616
{
572
617
ts = Some ( parsed. with_timezone ( & Utc ) ) ;
573
618
}
574
619
620
+ // Attempt to derive the project tag from the SessionMeta line (cwd basename).
621
+ for value in & item. head {
622
+ if let Ok ( meta_line) = serde_json:: from_value :: < SessionMetaLine > ( value. clone ( ) ) {
623
+ let cwd = meta_line. meta . cwd ;
624
+ if let Some ( name) = cwd. file_name ( ) . and_then ( |s| s. to_str ( ) ) {
625
+ if !name. is_empty ( ) {
626
+ project = Some ( name. to_string ( ) ) ;
627
+ }
628
+ }
629
+ break ;
630
+ }
631
+ }
632
+
575
633
let preview = preview_from_head ( & item. head )
576
634
. map ( |s| s. trim ( ) . to_string ( ) )
577
635
. filter ( |s| !s. is_empty ( ) )
@@ -581,6 +639,7 @@ fn head_to_row(item: &ConversationItem) -> Row {
581
639
path : item. path . clone ( ) ,
582
640
preview,
583
641
ts,
642
+ project,
584
643
}
585
644
}
586
645
@@ -696,10 +755,21 @@ fn render_list(frame: &mut crate::custom_terminal::Frame, area: Rect, state: &Pi
696
755
. map ( human_time_ago)
697
756
. unwrap_or_else ( || "" . to_string ( ) )
698
757
. dim ( ) ;
699
- let max_cols = area. width . saturating_sub ( 6 ) as usize ;
758
+ // Calculate remaining width for preview text after fixed columns.
759
+ let mut max_cols = area. width . saturating_sub ( 6 ) as usize ;
760
+ if let Some ( tag) = & row. project {
761
+ max_cols = max_cols. saturating_sub ( tag. len ( ) + 4 ) ;
762
+ }
700
763
let preview = truncate_text ( & row. preview , max_cols) ;
701
764
702
- let line: Line = vec ! [ marker, ts, " " . into( ) , preview. into( ) ] . into ( ) ;
765
+ // Build line: marker, time, optional [project], preview
766
+ let mut spans: Vec < Span < ' static > > = vec ! [ marker, ts, " " . into( ) ] ;
767
+ if let Some ( tag) = & row. project {
768
+ spans. push ( format ! ( "[{}]" , tag) . cyan ( ) . bold ( ) ) ;
769
+ spans. push ( " " . into ( ) ) ;
770
+ }
771
+ spans. push ( preview. into ( ) ) ;
772
+ let line: Line = spans. into ( ) ;
703
773
let rect = Rect :: new ( area. x , y, area. width , 1 ) ;
704
774
frame. render_widget_ref ( line, rect) ;
705
775
y = y. saturating_add ( 1 ) ;
0 commit comments