Skip to content

Commit 2bd59ea

Browse files
committed
Added status line containing foldername:git-branch-name
The status line shows useful information but when multiple instances of codex is running it's hard to distinguish them. This adds folder-name:branch-name beside the token count and context percentage. Added snapshot tests
1 parent 7eee69d commit 2bd59ea

10 files changed

+434
-63
lines changed

codex-rs/core/src/git_info.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,17 @@ pub async fn collect_git_info(cwd: &Path) -> Option<GitInfo> {
9292
Some(git_info)
9393
}
9494

95+
/// Extract repository name from the current working directory.
96+
/// Returns the directory name, or "unknown" if it cannot be determined.
97+
pub fn extract_repo_name(cwd: &Path) -> String {
98+
cwd.file_name()
99+
.and_then(|name| name.to_str())
100+
.unwrap_or("unknown")
101+
.to_string()
102+
}
103+
104+
// tests live in the bottom test module
105+
95106
/// Returns the closest git sha to HEAD that is on a remote as well as the diff to that sha.
96107
pub async fn git_diff_to_remote(cwd: &Path) -> Option<GitDiffToRemote> {
97108
if !is_inside_git_repo(cwd) {
@@ -479,6 +490,19 @@ mod tests {
479490
use std::path::PathBuf;
480491
use tempfile::TempDir;
481492

493+
#[test]
494+
fn extract_repo_name_basic_paths() {
495+
assert_eq!(extract_repo_name(std::path::Path::new("")), "unknown");
496+
assert_eq!(extract_repo_name(std::path::Path::new("repo")), "repo");
497+
assert_eq!(extract_repo_name(std::path::Path::new("path/to/repo")), "repo");
498+
}
499+
500+
#[cfg(unix)]
501+
#[test]
502+
fn extract_repo_name_unix_root() {
503+
assert_eq!(extract_repo_name(std::path::Path::new("/")), "unknown");
504+
}
505+
482506
// Helper function to create a test git repository
483507
async fn create_test_git_repo(temp_dir: &TempDir) -> PathBuf {
484508
let repo_path = temp_dir.path().join("repo");

codex-rs/tui/src/app.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,25 @@ impl App {
177177
);
178178
tui.frame_requester().schedule_frame();
179179
}
180+
AppEvent::UpdateRepoInfo {
181+
repo_name,
182+
git_branch,
183+
} => {
184+
self.chat_widget.apply_repo_info(repo_name, git_branch);
185+
}
186+
AppEvent::ResumeSession(path) => {
187+
self.config.experimental_resume = Some(path);
188+
self.chat_widget = ChatWidget::new(
189+
self.config.clone(),
190+
self.server.clone(),
191+
tui.frame_requester(),
192+
self.app_event_tx.clone(),
193+
None,
194+
Vec::new(),
195+
self.enhanced_keys_supported,
196+
);
197+
tui.frame_requester().schedule_frame();
198+
}
180199
AppEvent::InsertHistoryLines(lines) => {
181200
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
182201
t.insert_lines(lines.clone());

codex-rs/tui/src/app_event.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ pub(crate) enum AppEvent {
2020
/// Request to exit the application gracefully.
2121
ExitRequest,
2222

23+
/// Resume a saved session by path to the rollout file.
24+
ResumeSession(std::path::PathBuf),
25+
2326
/// Forward an `Op` to the Agent. Using an `AppEvent` for this avoids
2427
/// bubbling channels through layers of widgets.
2528
CodexOp(codex_core::protocol::Op),
@@ -61,4 +64,11 @@ pub(crate) enum AppEvent {
6164

6265
/// Forwarded conversation history snapshot from the current conversation.
6366
ConversationHistory(ConversationHistoryResponseEvent),
67+
68+
/// Update repository information shown in the bottom pane footer.
69+
/// If not in a Git repo, both values are None and the footer hides them.
70+
UpdateRepoInfo {
71+
repo_name: Option<String>,
72+
git_branch: Option<String>,
73+
},
6474
}

codex-rs/tui/src/bottom_pane/chat_composer.rs

Lines changed: 197 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ use ratatui::widgets::BorderType;
2020
use ratatui::widgets::Borders;
2121
use ratatui::widgets::StatefulWidgetRef;
2222
use ratatui::widgets::WidgetRef;
23+
use unicode_width::UnicodeWidthStr;
24+
use crate::live_wrap::ellipsize_middle_by_width;
2325

2426
use super::chat_composer_history::ChatComposerHistory;
2527
use 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

Comments
 (0)