Skip to content

Commit ae17057

Browse files
code0xffclaude
andcommitted
feat: rebind focus jumps to F-keys and pick up OSC tab titles
F1/F2 now jump to the file list and diff viewer, freeing Ctrl+1/2/3 for terminal use; F3-F9 directly address terminal panes 1-7. Pane titles also update from OSC 0/2 escape sequences emitted by claude, vim, ssh, etc., so the tab bar reflects what is actually running. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 90e730f commit ae17057

10 files changed

Lines changed: 129 additions & 73 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ nightcrow --repo ~/projects/myapp
4848
| `Ctrl+L` | Toggle between status view and commit log view |
4949
| `Ctrl+T` | Open new terminal pane |
5050
| `Ctrl+W` | Close active terminal pane |
51-
| `F1``F9` | Jump to terminal pane N |
51+
| `F1` / `F2` | Focus file list / diff viewer |
52+
| `F3``F9` | Jump to terminal pane 1…7 |
5253
| `Ctrl+F` | Toggle fullscreen for the focused pane (file/commit list, diff viewer, or terminal) |
5354
| `Ctrl+P` | Cycle accent color (yellow → cyan → green → magenta → blue) |
5455
| `Ctrl+O` | Change repo path |

docs/architecture.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ trait TerminalBackend {
7676

7777
- **Upper panel focused**: Ratatui app이 모든 키 처리 (파일 탐색, diff/file subfocus 전환). `j`/`k`는 upper-pane handler 내부에서 vim navigation으로 변환되며, `map_key`는 plain character로 통과시킨다 — terminal focus에서 j/k가 PTY로 그대로 전달되도록 보장하기 위함.
7878
- **Lower panel focused**: 키 입력을 active backend의 stdin으로 직접 통과
79-
- **Global shortcuts** (`Shift+←/→`: 포커스 cycling, `Ctrl+T`: 터미널 생성, `F1``F9`: 터미널 pane jump 등)는 항상 앱이 먼저 처리
79+
- **Global shortcuts** (`Shift+←/→`: 포커스 cycling, `Ctrl+T`: 터미널 생성, `F1`/`F2`: 파일 리스트·diff 포커스 jump, `F3``F9`: 터미널 pane 1–7 jump 등)는 항상 앱이 먼저 처리
8080
- **Upper subfocus shortcuts** (`Left`/`Right`/`Shift+Tab`: 파일 리스트와 diff 뷰어 전환)는 상단 포커스에서만 앱이 처리한다.
8181

8282
## Critical Risk

src/app.rs

Lines changed: 17 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ impl App {
142142
#[cfg(test)]
143143
mod tests {
144144
use super::*;
145+
use crate::runtime::terminal::PaneCallbacks;
145146
use crate::git::diff::{
146147
ChangeStatus, CommitEntry, DiffHunk, DiffLine, LineKind, load_commit_log,
147148
};
@@ -336,7 +337,12 @@ mod tests {
336337
app.terminal.active = 0;
337338
app.terminal.size = (3, 10);
338339

339-
let mut parser = vt100::Parser::new(3, 10, SCROLLBACK_LINES);
340+
let mut parser = vt100::Parser::new_with_callbacks(
341+
3,
342+
10,
343+
SCROLLBACK_LINES,
344+
PaneCallbacks::default(),
345+
);
340346
parser.process(b"1\r\n2\r\n3\r\n4\r\n5\r\n6\r\n7\r\n8\r\n9\r\n");
341347
app.terminal.parsers.insert(1, parser);
342348
// Request scrolling well past screen height; vt100 supports
@@ -360,7 +366,12 @@ mod tests {
360366
app.terminal.active = 0;
361367
app.terminal.size = (3, 10);
362368

363-
let mut parser = vt100::Parser::new(3, 10, SCROLLBACK_LINES);
369+
let mut parser = vt100::Parser::new_with_callbacks(
370+
3,
371+
10,
372+
SCROLLBACK_LINES,
373+
PaneCallbacks::default(),
374+
);
364375
// Only a handful of buffered rows exist; an outsized request must
365376
// clamp to whatever vt100 actually has, never panic.
366377
parser.process(b"1\r\n2\r\n3\r\n4\r\n5\r\n");
@@ -432,36 +443,6 @@ mod tests {
432443
assert!(!app.terminal.fullscreen);
433444
}
434445

435-
#[test]
436-
fn focus_terminal_is_noop_without_panes() {
437-
let mut app = app_with_files(vec![]);
438-
assert!(app.terminal.panes.is_empty());
439-
app.focus = Focus::FileList;
440-
441-
app.focus_terminal();
442-
443-
// Without any terminal pane there is nothing to focus, so state stays
444-
// on FileList rather than landing on an empty Terminal focus.
445-
assert_eq!(app.focus, Focus::FileList);
446-
}
447-
448-
#[test]
449-
fn focus_terminal_jumps_and_exits_competing_fullscreens() {
450-
let mut app = app_with_files(vec![]);
451-
app.terminal.panes = vec![PaneInfo {
452-
id: 1,
453-
title: "shell".into(),
454-
}];
455-
app.toggle_diff_fullscreen();
456-
assert!(app.diff.fullscreen);
457-
458-
app.focus_terminal();
459-
460-
assert_eq!(app.focus, Focus::Terminal);
461-
assert!(!app.diff.fullscreen);
462-
assert!(!app.list_fullscreen);
463-
}
464-
465446
#[test]
466447
fn switch_pane_exits_diff_fullscreen() {
467448
let mut app = app_with_files(vec![]);
@@ -743,7 +724,10 @@ mod tests {
743724
app.focus = Focus::Terminal;
744725
app.terminal.scroll.insert(1, 3);
745726
app.terminal.prompt_bufs.insert(1, "cargo test".to_string());
746-
app.terminal.parsers.insert(1, vt100::Parser::new(3, 10, 0));
727+
app.terminal.parsers.insert(
728+
1,
729+
vt100::Parser::new_with_callbacks(3, 10, 0, PaneCallbacks::default()),
730+
);
747731

748732
app.close_active_pane();
749733

src/app/focus.rs

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -158,13 +158,4 @@ impl App {
158158
self.list_fullscreen = false;
159159
self.terminal.fullscreen = false;
160160
}
161-
162-
pub fn focus_terminal(&mut self) {
163-
if self.terminal.panes.is_empty() {
164-
return;
165-
}
166-
self.focus = Focus::Terminal;
167-
self.diff.fullscreen = false;
168-
self.list_fullscreen = false;
169-
}
170161
}

src/app/terminal_ctrl.rs

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use super::{App, Focus, PaneInfo, SCROLLBACK_LINES};
22
use crate::backend::{BackendEvent, PaneId};
3+
use crate::runtime::terminal::PaneCallbacks;
34

45
impl App {
56
pub(crate) fn ensure_initial_terminal(&mut self) {
@@ -23,8 +24,17 @@ impl App {
2324
for event in events {
2425
match event {
2526
BackendEvent::Output { pane, data } => {
26-
if let Some(parser) = self.terminal.parsers.get_mut(&pane) {
27+
let new_title = if let Some(parser) = self.terminal.parsers.get_mut(&pane) {
2728
parser.process(&data);
29+
parser.callbacks_mut().pending_title.take()
30+
} else {
31+
None
32+
};
33+
if let Some(title) = new_title
34+
&& let Some(info) =
35+
self.terminal.panes.iter_mut().find(|p| p.id == pane)
36+
{
37+
info.title = title;
2838
}
2939
}
3040
BackendEvent::Exited { pane } => {
@@ -59,11 +69,17 @@ impl App {
5969
.ok_or_else(|| anyhow::anyhow!("no terminal backend available"))?;
6070

6171
let id = backend.create_pane(rows.max(1), cols.max(1))?;
62-
let parser = vt100::Parser::new(rows.max(1), cols.max(1), SCROLLBACK_LINES);
72+
let parser = vt100::Parser::new_with_callbacks(
73+
rows.max(1),
74+
cols.max(1),
75+
SCROLLBACK_LINES,
76+
PaneCallbacks::default(),
77+
);
6378
self.terminal.parsers.insert(id, parser);
79+
let default_title = format!("shell {}", self.terminal.panes.len() + 1);
6480
self.terminal.panes.push(PaneInfo {
6581
id,
66-
title: "shell".to_string(),
82+
title: default_title,
6783
});
6884
self.terminal.active = self.terminal.panes.len() - 1;
6985
tracing::info!(pane = id, "terminal pane opened");

src/input/mod.rs

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ pub enum Action {
1414
SwitchPane(usize),
1515
FocusList,
1616
FocusDiff,
17-
FocusTerminal,
1817
CycleForward,
1918
CycleBackward,
2019
TermScrollUp,
@@ -38,16 +37,18 @@ pub fn map_key(event: KeyEvent) -> Action {
3837
KeyCode::Char('f') if ctrl => Action::ToggleFullscreen,
3938
KeyCode::Char('l') if ctrl => Action::ToggleLogView,
4039
KeyCode::Char('p') if ctrl => Action::CycleTheme,
41-
KeyCode::Char('1') if ctrl => Action::FocusList,
42-
KeyCode::Char('2') if ctrl => Action::FocusDiff,
43-
KeyCode::Char('3') if ctrl => Action::FocusTerminal,
4440
KeyCode::Left if shift => Action::CycleBackward,
4541
KeyCode::Right if shift => Action::CycleForward,
4642
KeyCode::Up if shift => Action::TermScrollLineUp,
4743
KeyCode::Down if shift => Action::TermScrollLineDown,
4844
KeyCode::PageUp if shift => Action::TermScrollUp,
4945
KeyCode::PageDown if shift => Action::TermScrollDown,
50-
KeyCode::F(n @ 1..=9) => Action::SwitchPane(n as usize - 1),
46+
// F-keys are universally distinct across terminals (no kitty protocol
47+
// dependency), so they own focus jumps: F1=list, F2=diff,
48+
// F3..=F9 = terminal panes 1..=7.
49+
KeyCode::F(1) => Action::FocusList,
50+
KeyCode::F(2) => Action::FocusDiff,
51+
KeyCode::F(n @ 3..=9) => Action::SwitchPane(n as usize - 3),
5152
KeyCode::Up => Action::Up,
5253
KeyCode::Down => Action::Down,
5354
KeyCode::PageUp => Action::PageUp,
@@ -217,21 +218,17 @@ mod tests {
217218
}
218219

219220
#[test]
220-
fn maps_switch_pane() {
221-
assert_eq!(map_key(key(KeyCode::F(1))), Action::SwitchPane(0));
222-
assert_eq!(map_key(key(KeyCode::F(2))), Action::SwitchPane(1));
223-
assert_eq!(map_key(key(KeyCode::F(9))), Action::SwitchPane(8));
221+
fn maps_focus_jump_shortcuts() {
222+
assert_eq!(map_key(key(KeyCode::F(1))), Action::FocusList);
223+
assert_eq!(map_key(key(KeyCode::F(2))), Action::FocusDiff);
224224
}
225225

226226
#[test]
227-
fn maps_focus_jump_shortcuts() {
228-
assert_eq!(map_key(ctrl(KeyCode::Char('1'))), Action::FocusList);
229-
assert_eq!(map_key(ctrl(KeyCode::Char('2'))), Action::FocusDiff);
230-
assert_eq!(map_key(ctrl(KeyCode::Char('3'))), Action::FocusTerminal);
231-
// Plain digits must not steal focus from the underlying view.
232-
assert_eq!(map_key(key(KeyCode::Char('1'))), Action::None);
233-
assert_eq!(map_key(key(KeyCode::Char('2'))), Action::None);
234-
assert_eq!(map_key(key(KeyCode::Char('3'))), Action::None);
227+
fn maps_switch_pane() {
228+
// F3..=F9 directly select terminal panes 0..=6.
229+
assert_eq!(map_key(key(KeyCode::F(3))), Action::SwitchPane(0));
230+
assert_eq!(map_key(key(KeyCode::F(4))), Action::SwitchPane(1));
231+
assert_eq!(map_key(key(KeyCode::F(9))), Action::SwitchPane(6));
235232
}
236233

237234
#[test]

src/main.rs

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -244,10 +244,6 @@ fn handle_global_action(app: &mut App, action: Action) -> Option<KeyOutcome> {
244244
app.focus_diff();
245245
Some(KeyOutcome::Continue)
246246
}
247-
Action::FocusTerminal => {
248-
app.focus_terminal();
249-
Some(KeyOutcome::Continue)
250-
}
251247
Action::CycleForward => {
252248
app.cycle_focus_forward();
253249
Some(KeyOutcome::Continue)

src/runtime/terminal.rs

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,35 @@ pub struct PaneInfo {
66
pub title: String,
77
}
88

9+
/// vt100 callbacks that capture OSC 0/2 window title updates so the tab bar
10+
/// can reflect what the running program (claude, vim, ssh, …) advertises.
11+
/// Bare shells without precmd hooks never emit OSC, so a sensible default
12+
/// title still lives on `PaneInfo`.
13+
#[derive(Default, Debug)]
14+
pub(crate) struct PaneCallbacks {
15+
pub(crate) pending_title: Option<String>,
16+
}
17+
18+
impl vt100::Callbacks for PaneCallbacks {
19+
fn set_window_title(&mut self, _: &mut vt100::Screen, title: &[u8]) {
20+
let cleaned: String = String::from_utf8_lossy(title)
21+
.chars()
22+
.filter(|c| !c.is_control())
23+
.collect();
24+
let trimmed = cleaned.trim();
25+
if !trimmed.is_empty() {
26+
self.pending_title = Some(trimmed.to_string());
27+
}
28+
}
29+
}
30+
931
pub struct TerminalState {
1032
pub panes: Vec<PaneInfo>,
1133
pub active: usize,
1234
pub size: (u16, u16),
1335
pub scroll: HashMap<PaneId, usize>,
1436
pub fullscreen: bool,
15-
pub(crate) parsers: HashMap<PaneId, vt100::Parser>,
37+
pub(crate) parsers: HashMap<PaneId, vt100::Parser<PaneCallbacks>>,
1638
pub(crate) prompt_bufs: HashMap<PaneId, String>,
1739
prompt_log_enabled: bool,
1840
pub(crate) backend: Option<Box<dyn TerminalBackend>>,
@@ -217,3 +239,45 @@ pub(crate) fn strip_escape_sequences(data: &[u8]) -> String {
217239
}
218240
result
219241
}
242+
243+
#[cfg(test)]
244+
mod tests {
245+
use super::*;
246+
247+
fn parser() -> vt100::Parser<PaneCallbacks> {
248+
vt100::Parser::new_with_callbacks(3, 20, 0, PaneCallbacks::default())
249+
}
250+
251+
#[test]
252+
fn captures_osc_two_window_title() {
253+
let mut p = parser();
254+
p.process(b"\x1b]2;claude\x07");
255+
assert_eq!(p.callbacks().pending_title.as_deref(), Some("claude"));
256+
}
257+
258+
#[test]
259+
fn captures_osc_zero_title_and_strips_controls() {
260+
let mut p = parser();
261+
// OSC 0 sets both icon name and window title; embedded tab/BS bytes
262+
// must not leak into the tab label.
263+
p.process(b"\x1b]0;cargo\t test\x08\x07");
264+
assert_eq!(p.callbacks().pending_title.as_deref(), Some("cargo test"));
265+
}
266+
267+
#[test]
268+
fn ignores_empty_title() {
269+
let mut p = parser();
270+
p.process(b"\x1b]2;\x07");
271+
assert!(p.callbacks().pending_title.is_none());
272+
}
273+
274+
#[test]
275+
fn later_title_replaces_earlier_until_taken() {
276+
let mut p = parser();
277+
p.process(b"\x1b]2;first\x07");
278+
p.process(b"\x1b]2;second\x07");
279+
let taken = p.callbacks_mut().pending_title.take();
280+
assert_eq!(taken.as_deref(), Some("second"));
281+
assert!(p.callbacks().pending_title.is_none());
282+
}
283+
}

src/ui/mod.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ fn render_hint_bar(app: &App, accent: Color) -> Paragraph<'_> {
239239
}
240240
let hint = match app.focus {
241241
Focus::Terminal => {
242-
" shift+↑/↓: scroll | shift+pgup/dn: page scroll | shift+←/→: cycle | ctrl+1/2/3: focus list/diff/term | ctrl+t: new pane | ctrl+w: close pane | F1-F9: switch pane | ctrl+f: fullscreen | ctrl+l: log view | ctrl+o: repo | ctrl+p: theme | ctrl+q: quit"
242+
" shift+↑/↓: scroll | shift+pgup/dn: page scroll | shift+←/→: cycle | F1/F2: focus list/diff | F3-F9: terminal pane 1-7 | ctrl+t: new pane | ctrl+w: close pane | ctrl+f: fullscreen | ctrl+l: log view | ctrl+o: repo | ctrl+p: theme | ctrl+q: quit"
243243
}
244244
Focus::FileList => match app.mode {
245245
ViewMode::Log => {
@@ -250,7 +250,7 @@ fn render_hint_bar(app: &App, accent: Color) -> Paragraph<'_> {
250250
}
251251
}
252252
ViewMode::Status => {
253-
" shift+←/→: cycle | j/k: navigate | /: search | ctrl+1/2/3: focus list/diff/term | F1-F9: switch pane | ctrl+f: fullscreen | ctrl+l: log view | ctrl+o: repo | ctrl+p: theme | ctrl+q: quit"
253+
" shift+←/→: cycle | j/k: navigate | /: search | F1/F2: focus list/diff | F3-F9: terminal pane 1-7 | ctrl+f: fullscreen | ctrl+l: log view | ctrl+o: repo | ctrl+p: theme | ctrl+q: quit"
254254
}
255255
},
256256
Focus::DiffViewer => {
@@ -261,7 +261,7 @@ fn render_hint_bar(app: &App, accent: Color) -> Paragraph<'_> {
261261
} else if !app.diff.search.query.is_empty() {
262262
" n: next match | shift+n: prev match | /: new search | esc: clear"
263263
} else {
264-
" shift+←/→: cycle | j/k: scroll | v: view file | /: search | ctrl+f: zoom | pgup/pgdn: scroll | ctrl+1/2/3: focus list/diff/term | F1-F9: switch pane | ctrl+l: log view | ctrl+o: repo | ctrl+p: theme | ctrl+q: quit"
264+
" shift+←/→: cycle | j/k: scroll | v: view file | /: search | ctrl+f: zoom | pgup/pgdn: scroll | F1/F2: focus list/diff | F3-F9: terminal pane 1-7 | ctrl+l: log view | ctrl+o: repo | ctrl+p: theme | ctrl+q: quit"
265265
}
266266
}
267267
};

src/ui/terminal_tab.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,14 @@ pub fn render(frame: &mut Frame, app: &mut App, area: Rect, accent: Color) {
5959
} else {
6060
Style::default().fg(Color::DarkGray)
6161
};
62-
Span::styled(format!(" {} {} ", i + 1, pane.title), style)
62+
// F3..=F9 are wired to panes 0..=6 in `input::map_key`; show
63+
// the binding so the tab bar doubles as a key legend.
64+
let key_hint = if i < 7 {
65+
format!("F{}", i + 3)
66+
} else {
67+
format!("{}", i + 1)
68+
};
69+
Span::styled(format!(" {} {} ", key_hint, pane.title), style)
6370
})
6471
.collect()
6572
};

0 commit comments

Comments
 (0)