@@ -20,6 +20,8 @@ use ratatui::widgets::BorderType;
2020use ratatui:: widgets:: Borders ;
2121use ratatui:: widgets:: StatefulWidgetRef ;
2222use ratatui:: widgets:: WidgetRef ;
23+ use unicode_width:: UnicodeWidthStr ;
24+ use crate :: live_wrap:: ellipsize_middle_by_width;
2325
2426use super :: chat_composer_history:: ChatComposerHistory ;
2527use super :: command_popup:: CommandItem ;
@@ -98,6 +100,9 @@ pub(crate) struct ChatComposer {
98100 // When true, disables paste-burst logic and inserts characters immediately.
99101 disable_paste_burst : bool ,
100102 custom_prompts : Vec < CustomPrompt > ,
103+ // Repository and branch information for status display
104+ repo_name : Option < String > ,
105+ git_branch : Option < String > ,
101106}
102107
103108/// Popup state – at most one can be visible at any time.
@@ -136,6 +141,8 @@ impl ChatComposer {
136141 paste_burst : PasteBurst :: default ( ) ,
137142 disable_paste_burst : false ,
138143 custom_prompts : Vec :: new ( ) ,
144+ repo_name : None ,
145+ git_branch : None ,
139146 } ;
140147 // Apply configuration via the setter to keep side-effects centralized.
141148 this. set_disable_paste_burst ( disable_paste_burst) ;
@@ -151,6 +158,120 @@ impl ChatComposer {
151158 }
152159 }
153160
161+ // Footer composition returns the full list of spans for the bottom line, respecting width.
162+ fn build_footer_spans ( & self , total_width : u16 ) -> Vec < Span < ' static > > {
163+ let mut spans: Vec < Span < ' static > > = Vec :: new ( ) ;
164+
165+ // Base key-hint spans
166+ let key_hint_style = Style :: default ( ) . fg ( Color :: Cyan ) ;
167+ spans. push ( " " . into ( ) ) ;
168+ if self . ctrl_c_quit_hint {
169+ spans. push ( "Ctrl+C again" . set_style ( key_hint_style) ) ;
170+ spans. push ( " to quit" . into ( ) ) ;
171+ } else {
172+ let newline_hint_key = if self . use_shift_enter_hint { "Shift+⏎" } else { "Ctrl+J" } ;
173+ spans. push ( "⏎" . set_style ( key_hint_style) ) ;
174+ spans. push ( " send " . into ( ) ) ;
175+ spans. push ( newline_hint_key. set_style ( key_hint_style) ) ;
176+ spans. push ( " newline " . into ( ) ) ;
177+ spans. push ( "Ctrl+T" . set_style ( key_hint_style) ) ;
178+ spans. push ( " transcript " . into ( ) ) ;
179+ spans. push ( "Ctrl+C" . set_style ( key_hint_style) ) ;
180+ spans. push ( " quit" . into ( ) ) ;
181+ }
182+
183+ if !self . ctrl_c_quit_hint && self . esc_backtrack_hint {
184+ spans. push ( " " . into ( ) ) ;
185+ spans. push ( "Esc" . set_style ( key_hint_style) ) ;
186+ spans. push ( " edit prev" . into ( ) ) ;
187+ }
188+
189+ // Token/context usage spans (computed now so we reserve width before repo/branch)
190+ let mut token_spans: Vec < Span < ' static > > = Vec :: new ( ) ;
191+ if let Some ( token_usage_info) = & self . token_usage_info {
192+ let token_usage = & token_usage_info. total_token_usage ;
193+ token_spans. push ( " " . into ( ) ) ;
194+ let tokens = token_usage. blended_total ( ) ;
195+ token_spans. push ( format ! ( "{tokens} tokens used" ) . dim ( ) ) ;
196+ let last_token_usage = & token_usage_info. last_token_usage ;
197+ if let Some ( context_window) = token_usage_info. model_context_window {
198+ let percent_remaining: u8 = if context_window > 0 {
199+ last_token_usage. percent_of_context_window_remaining (
200+ context_window,
201+ token_usage_info. initial_prompt_tokens ,
202+ )
203+ } else {
204+ 100
205+ } ;
206+ token_spans. push ( " " . into ( ) ) ;
207+ token_spans. push ( format ! ( "{percent_remaining}% context left" ) . dim ( ) ) ;
208+ }
209+ }
210+
211+ // Compute reserved width for base + tokens
212+ let base_width: usize = spans
213+ . iter ( )
214+ . map ( |s| s. content . width ( ) )
215+ . sum ( ) ;
216+ let token_width: usize = token_spans. iter ( ) . map ( |s| s. content . width ( ) ) . sum ( ) ;
217+ let mut remaining = ( total_width as usize ) . saturating_sub ( base_width + token_width) ;
218+
219+ // Repo/branch spans, only if there is space and repo info is present
220+ if let Some ( repo_name) = & self . repo_name {
221+ if remaining > 4 {
222+ // Reserve leading spacing before repo
223+ let sep = " " ;
224+ let sep_w = sep. width ( ) ;
225+ if remaining > sep_w {
226+ remaining -= sep_w;
227+ let mut repo_branch_spans: Vec < Span < ' static > > = Vec :: new ( ) ;
228+ repo_branch_spans. push ( sep. into ( ) ) ;
229+
230+ // If not enough for minimal repo, skip entirely.
231+ let min_repo = 8usize ;
232+ let min_branch = 6usize ;
233+ let colon_w = 1usize ; // ":"
234+
235+ if remaining >= min_repo {
236+ // First assume we can show both
237+ let mut repo_budget = ( ( remaining as f32 ) * 0.6 ) as usize ;
238+ repo_budget = repo_budget. clamp ( min_repo, remaining) ;
239+ let mut branch_budget = remaining. saturating_sub ( repo_budget + colon_w) ;
240+
241+ if branch_budget < min_branch {
242+ // Not enough space for branch alongside repo; use all for repo
243+ repo_budget = remaining;
244+ branch_budget = 0 ;
245+ }
246+
247+ // Truncate and push repo
248+ let repo_disp = ellipsize_middle_by_width ( repo_name, repo_budget) ;
249+ repo_branch_spans. push ( repo_disp. bold ( ) ) ;
250+ let used_repo_w = repo_branch_spans. last ( ) . unwrap ( ) . content . width ( ) ;
251+ let mut leftover = remaining. saturating_sub ( used_repo_w) ;
252+
253+ // If space permits, push branch with preceding ':'
254+ if branch_budget > 0 && leftover > colon_w {
255+ repo_branch_spans. push ( Span :: from ( ":" ) ) ;
256+ leftover = leftover. saturating_sub ( colon_w) ;
257+ if let Some ( branch) = & self . git_branch {
258+ let branch_disp = ellipsize_middle_by_width ( branch, leftover) ;
259+ repo_branch_spans. push ( branch_disp. dim ( ) ) ;
260+ }
261+ }
262+
263+ // Append repo/branch spans
264+ spans. extend ( repo_branch_spans) ;
265+ }
266+ }
267+ }
268+ }
269+
270+ // Finally append tokens (always fits because we reserved width earlier)
271+ spans. extend ( token_spans) ;
272+ spans
273+ }
274+
154275 pub fn cursor_pos ( & self , area : Rect ) -> Option < ( u16 , u16 ) > {
155276 let popup_height = match & self . active_popup {
156277 ActivePopup :: Command ( popup) => popup. calculate_required_height ( ) ,
@@ -217,6 +338,12 @@ impl ChatComposer {
217338 true
218339 }
219340
341+ /// Update repository and branch information for status display.
342+ pub ( crate ) fn set_repo_info ( & mut self , repo_name : Option < String > , git_branch : Option < String > ) {
343+ self . repo_name = repo_name;
344+ self . git_branch = git_branch;
345+ }
346+
220347 pub fn handle_paste ( & mut self , pasted : String ) -> bool {
221348 let char_count = pasted. chars ( ) . count ( ) ;
222349 if char_count > LARGE_PASTE_CHAR_THRESHOLD {
@@ -1235,65 +1362,8 @@ impl WidgetRef for ChatComposer {
12351362 }
12361363 ActivePopup :: None => {
12371364 let bottom_line_rect = popup_rect;
1238- let key_hint_style = Style :: default ( ) . fg ( Color :: Cyan ) ;
1239- let mut hint = if self . ctrl_c_quit_hint {
1240- vec ! [
1241- Span :: from( " " ) ,
1242- "Ctrl+C again" . set_style( key_hint_style) ,
1243- Span :: from( " to quit" ) ,
1244- ]
1245- } else {
1246- let newline_hint_key = if self . use_shift_enter_hint {
1247- "Shift+⏎"
1248- } else {
1249- "Ctrl+J"
1250- } ;
1251- vec ! [
1252- Span :: from( " " ) ,
1253- "⏎" . set_style( key_hint_style) ,
1254- Span :: from( " send " ) ,
1255- newline_hint_key. set_style( key_hint_style) ,
1256- Span :: from( " newline " ) ,
1257- "Ctrl+T" . set_style( key_hint_style) ,
1258- Span :: from( " transcript " ) ,
1259- "Ctrl+C" . set_style( key_hint_style) ,
1260- Span :: from( " quit" ) ,
1261- ]
1262- } ;
1263-
1264- if !self . ctrl_c_quit_hint && self . esc_backtrack_hint {
1265- hint. push ( Span :: from ( " " ) ) ;
1266- hint. push ( "Esc" . set_style ( key_hint_style) ) ;
1267- hint. push ( Span :: from ( " edit prev" ) ) ;
1268- }
1269-
1270- // Append token/context usage info to the footer hints when available.
1271- if let Some ( token_usage_info) = & self . token_usage_info {
1272- let token_usage = & token_usage_info. total_token_usage ;
1273- hint. push ( Span :: from ( " " ) ) ;
1274- hint. push (
1275- Span :: from ( format ! ( "{} tokens used" , token_usage. blended_total( ) ) )
1276- . style ( Style :: default ( ) . add_modifier ( Modifier :: DIM ) ) ,
1277- ) ;
1278- let last_token_usage = & token_usage_info. last_token_usage ;
1279- if let Some ( context_window) = token_usage_info. model_context_window {
1280- let percent_remaining: u8 = if context_window > 0 {
1281- last_token_usage. percent_of_context_window_remaining (
1282- context_window,
1283- token_usage_info. initial_prompt_tokens ,
1284- )
1285- } else {
1286- 100
1287- } ;
1288- hint. push ( Span :: from ( " " ) ) ;
1289- hint. push (
1290- Span :: from ( format ! ( "{percent_remaining}% context left" ) )
1291- . style ( Style :: default ( ) . add_modifier ( Modifier :: DIM ) ) ,
1292- ) ;
1293- }
1294- }
1295-
1296- Line :: from ( hint)
1365+ let hint_spans = self . build_footer_spans ( bottom_line_rect. width ) ;
1366+ Line :: from ( hint_spans)
12971367 . style ( Style :: default ( ) . dim ( ) )
12981368 . render_ref ( bottom_line_rect, buf) ;
12991369 }
@@ -1342,6 +1412,41 @@ mod tests {
13421412 use crate :: bottom_pane:: textarea:: TextArea ;
13431413 use tokio:: sync:: mpsc:: unbounded_channel;
13441414
1415+ #[ test]
1416+ fn footer_truncates_repo_and_branch_for_narrow_width ( ) {
1417+ let ( tx_raw, _rx) = unbounded_channel :: < AppEvent > ( ) ;
1418+ let tx = AppEventSender :: new ( tx_raw) ;
1419+ let mut composer = ChatComposer :: new (
1420+ true ,
1421+ tx,
1422+ false ,
1423+ "placeholder" . to_string ( ) ,
1424+ false ,
1425+ ) ;
1426+
1427+ // Long names to force truncation
1428+ let repo = "very-long-repository-name-with-many-segments" . to_string ( ) ;
1429+ let branch = "feature/super/long/branch/name" . to_string ( ) ;
1430+ composer. set_repo_info ( Some ( repo. clone ( ) ) , Some ( branch. clone ( ) ) ) ;
1431+
1432+ // Width large enough to include repo and branch after key hints
1433+ // width 100 -> repo_budget 25, branch_budget 16
1434+ let area = Rect :: new ( 0 , 0 , 100 , 3 ) ;
1435+ let mut buf = Buffer :: empty ( area) ;
1436+ composer. render_ref ( area, & mut buf) ;
1437+
1438+ // Read last row where the footer is rendered
1439+ let mut row = String :: new ( ) ;
1440+ for x in 0 ..area. width {
1441+ row. push ( buf[ ( x, area. height - 1 ) ] . symbol ( ) . chars ( ) . next ( ) . unwrap_or ( ' ' ) ) ;
1442+ }
1443+
1444+ assert ! ( row. contains( "..." ) , "footer should contain ellipsis for truncation: {row}" ) ;
1445+ assert ! ( row. contains( " " ) , "footer should include space-separated segments: {row}" ) ;
1446+ assert ! ( !row. contains( & repo) , "original repo should not fully appear: {row}" ) ;
1447+ assert ! ( !row. contains( & branch) , "original branch should not fully appear: {row}" ) ;
1448+ }
1449+
13451450 #[ test]
13461451 fn test_current_at_token_basic_cases ( ) {
13471452 let test_cases = vec ! [
@@ -1640,6 +1745,39 @@ mod tests {
16401745 }
16411746 }
16421747
1748+ #[ test]
1749+ fn footer_repo_branch_snapshots_various_widths ( ) {
1750+ use insta:: assert_snapshot;
1751+ use ratatui:: Terminal ;
1752+ use ratatui:: backend:: TestBackend ;
1753+
1754+ let ( tx, _rx) = unbounded_channel :: < AppEvent > ( ) ;
1755+ let sender = AppEventSender :: new ( tx) ;
1756+ let widths = [ 40u16 , 80u16 , 120u16 ] ;
1757+
1758+ for & w in & widths {
1759+ let mut composer = ChatComposer :: new (
1760+ true ,
1761+ sender. clone ( ) ,
1762+ false ,
1763+ "Ask Codex to do anything" . to_string ( ) ,
1764+ false ,
1765+ ) ;
1766+ composer. set_repo_info (
1767+ Some ( "very-long-repository-name-with-many-segments" . to_string ( ) ) ,
1768+ Some ( "feature/super/long/branch/name" . to_string ( ) ) ,
1769+ ) ;
1770+
1771+ let mut terminal = Terminal :: new ( TestBackend :: new ( w, 4 ) ) . expect ( "terminal" ) ;
1772+ terminal
1773+ . draw ( |f| f. render_widget_ref ( composer, f. area ( ) ) )
1774+ . expect ( "draw" ) ;
1775+
1776+ let name = format ! ( "footer_repo_branch_w{w}" ) ;
1777+ assert_snapshot ! ( name, terminal. backend( ) ) ;
1778+ }
1779+ }
1780+
16431781 // Test helper: simulate human typing with a brief delay and flush the paste-burst buffer
16441782 fn type_chars_humanlike ( composer : & mut ChatComposer , chars : & [ char ] ) {
16451783 use crossterm:: event:: KeyCode ;
0 commit comments