diff --git a/src/app.rs b/src/app.rs index 900976a..57d1393 100644 --- a/src/app.rs +++ b/src/app.rs @@ -488,6 +488,9 @@ pub struct App { pub pending_proposal: Option, /// Token usage of the orchestrator's own API calls pub orchestrator_stats: crate::session::TokenStats, + /// Loaded context window of the API-class orchestrator's local backend + /// (LM Studio / llama-server), when probeable. + pub orchestrator_ctx_max: Option, /// Live progress of the orchestrator's current turn, plus when it was /// set (drives the chat-pane spinner). None while idle. pub orchestrator_status: Option<(String, std::time::Instant)>, @@ -585,6 +588,7 @@ impl App { pending_kill: None, pending_proposal: None, orchestrator_stats: crate::session::TokenStats::default(), + orchestrator_ctx_max: None, orchestrator_status: None, orch_event_cooldowns: HashMap::new(), last_permission_request: None, @@ -1153,6 +1157,47 @@ impl App { } } + /// Move pane focus spatially: pick the pane whose rendered rect center + /// lies in the requested direction from the focused pane's center, + /// nearest first. Uses `output_areas` (recorded each draw), so geometry + /// matches exactly what's on screen, including recursive splits. + /// (dx, dy) is the unit direction: left = (-1, 0), down = (0, 1), ... + pub fn focus_pane_dir(&mut self, dx: i32, dy: i32) { + if !self.is_split() || self.output_areas.len() < self.panes.len() { + return; + } + let center = |r: &Rect| { + ( + r.x as i32 + r.width as i32 / 2, + r.y as i32 + r.height as i32 / 2, + ) + }; + let (cx, cy) = center(&self.output_areas[self.focused_pane]); + let mut best: Option<(i64, usize)> = None; + for (i, r) in self.output_areas.iter().enumerate().take(self.panes.len()) { + if i == self.focused_pane { + continue; + } + let (px, py) = center(r); + let (ddx, ddy) = (px - cx, py - cy); + // Must lie strictly in the requested direction. + if (dx != 0 && ddx * dx <= 0) || (dy != 0 && ddy * dy <= 0) { + continue; + } + // Rank by distance, weighing off-axis drift heavier so the + // straight-across neighbor beats a nearer diagonal one. + let (along, across) = if dx != 0 { (ddx, ddy) } else { (ddy, ddx) }; + let score = (along as i64).pow(2) + 4 * (across as i64).pow(2); + if best.is_none_or(|(s, _)| score < s) { + best = Some((score, i)); + } + } + if let Some((_, i)) = best { + self.focused_pane = i; + self.needs_redraw = true; + } + } + /// Unified scrollback. Normal-screen apps (shells) use vt100's native /// scrollback; full-screen TUIs (claude, codex, opencode, ...) occupy the /// alternate screen where vt100 keeps none, so we scroll through our own @@ -2227,6 +2272,12 @@ impl App { cfg.name )); } + // Local backends (LM Studio / llama-server) can report the + // loaded model's context window; probe so the status row can + // show occupancy against the real limit. + if let Some(backend) = crate::ctx_probe::backend_for_provider(&cfg.provider) { + crate::ctx_probe::spawn_orchestrator_probe(backend, self.event_tx.clone()); + } self.orchestrator = Some(crate::orchestrator::spawn(cfg, self.event_tx.clone())); } crate::config::OrchestratorClass::Cli(kind_str) => { @@ -2520,6 +2571,8 @@ impl App { pub fn handle_orchestrator_usage(&mut self, input: u64, output: u64) { self.orchestrator_stats.input_tokens += input; self.orchestrator_stats.output_tokens += output; + // Input tokens of the most recent call == current context occupancy. + self.orchestrator_stats.context_tokens = input; } /// A line posted into the chat pane via IPC `chat_post`. If it came from @@ -6296,6 +6349,25 @@ mod tests { ); } + #[test] + fn focus_pane_dir_moves_by_geometry() { + let mut app = make_app(); + app.spawn_headless_session("one".into(), None).unwrap(); + app.spawn_headless_session("two".into(), None).unwrap(); + app.split_focused(SplitDir::Row); + // Simulate what draw records: pane 0 left, pane 1 right. + app.output_areas = vec![Rect::new(0, 0, 40, 20), Rect::new(40, 0, 40, 20)]; + assert_eq!(app.focused_pane, 1); + app.focus_pane_dir(-1, 0); // left + assert_eq!(app.focused_pane, 0); + app.focus_pane_dir(-1, 0); // nothing further left: no-op + assert_eq!(app.focused_pane, 0); + app.focus_pane_dir(0, 1); // no pane below: no-op + assert_eq!(app.focused_pane, 0); + app.focus_pane_dir(1, 0); // right + assert_eq!(app.focused_pane, 1); + } + #[test] fn rotate_split_flips_the_parent_split_direction() { let mut app = make_app(); diff --git a/src/ctx_probe.rs b/src/ctx_probe.rs index 64f040a..4b72faa 100644 --- a/src/ctx_probe.rs +++ b/src/ctx_probe.rs @@ -49,6 +49,22 @@ pub fn backend_for_command(base_name: &str) -> Option { } pub fn spawn_probe(session_id: usize, backend: Backend, tx: mpsc::Sender) { + spawn_probe_with(backend, tx, move |max| AppEvent::SessionContextMax { + session_id, + max, + }); +} + +/// Same probe, but for the API-class orchestrator (which is not a session). +pub fn spawn_orchestrator_probe(backend: Backend, tx: mpsc::Sender) { + spawn_probe_with(backend, tx, |max| AppEvent::OrchestratorContextMax { max }); +} + +fn spawn_probe_with( + backend: Backend, + tx: mpsc::Sender, + make_event: impl Fn(u64) -> AppEvent + Send + 'static, +) { tokio::spawn(async move { let client = match reqwest::Client::builder() .timeout(Duration::from_secs(2)) @@ -65,11 +81,7 @@ pub fn spawn_probe(session_id: usize, backend: Backend, tx: mpsc::Sender 0 && max != last_sent { - if tx - .send(AppEvent::SessionContextMax { session_id, max }) - .await - .is_err() - { + if tx.send(make_event(max)).await.is_err() { return; // main loop gone } last_sent = max; diff --git a/src/events.rs b/src/events.rs index b53a579..bda40e9 100644 --- a/src/events.rs +++ b/src/events.rs @@ -65,6 +65,10 @@ pub enum AppEvent { session_id: usize, max: u64, }, + /// Loaded context window of the API-class orchestrator's local backend. + OrchestratorContextMax { + max: u64, + }, /// Model ID parsed from the session's JSONL log (Claude or Codex) SessionModel { session_id: usize, diff --git a/src/keybindings.rs b/src/keybindings.rs index a95b744..452aa5a 100644 --- a/src/keybindings.rs +++ b/src/keybindings.rs @@ -24,6 +24,10 @@ pub enum Action { ClosePane, RotateSplit, FocusNextPane, + FocusPaneLeft, + FocusPaneRight, + FocusPaneUp, + FocusPaneDown, BroadcastToggle, Detach, } @@ -78,6 +82,10 @@ fn default_keymap() -> Keymap { let alt_shift = KeyModifiers::ALT | KeyModifiers::SHIFT; m.insert((alt_shift, KeyCode::PageUp), Action::ScrollUpPage); m.insert((alt_shift, KeyCode::PageDown), Action::ScrollDownPage); + m.insert((alt_shift, KeyCode::Left), Action::FocusPaneLeft); + m.insert((alt_shift, KeyCode::Right), Action::FocusPaneRight); + m.insert((alt_shift, KeyCode::Up), Action::FocusPaneUp); + m.insert((alt_shift, KeyCode::Down), Action::FocusPaneDown); m.insert((alt_shift, KeyCode::Up), Action::ScrollUpLine); m.insert((alt_shift, KeyCode::Down), Action::ScrollDownLine); @@ -164,6 +172,10 @@ fn parse_action(s: &str) -> Option { "close_pane" => Some(Action::ClosePane), "rotate_split" => Some(Action::RotateSplit), "focus_next_pane" => Some(Action::FocusNextPane), + "focus_pane_left" => Some(Action::FocusPaneLeft), + "focus_pane_right" => Some(Action::FocusPaneRight), + "focus_pane_up" => Some(Action::FocusPaneUp), + "focus_pane_down" => Some(Action::FocusPaneDown), "broadcast_toggle" => Some(Action::BroadcastToggle), "detach" => Some(Action::Detach), _ => None, diff --git a/src/main.rs b/src/main.rs index baeeb6c..6fcf997 100644 --- a/src/main.rs +++ b/src/main.rs @@ -630,6 +630,9 @@ fn handle_event(app: &mut App, event: AppEvent) { s.context_max = max; } } + AppEvent::OrchestratorContextMax { max } => { + app.orchestrator_ctx_max = Some(max); + } AppEvent::SessionModel { session_id, model } => { if let Some(s) = app.sessions.iter_mut().find(|s| s.id == session_id) { s.model = Some(model); @@ -815,6 +818,10 @@ fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) { Action::ClosePane => app.close_focused_pane(), Action::RotateSplit => app.rotate_split(), Action::FocusNextPane => app.focus_next_pane(), + Action::FocusPaneLeft => app.focus_pane_dir(-1, 0), + Action::FocusPaneRight => app.focus_pane_dir(1, 0), + Action::FocusPaneUp => app.focus_pane_dir(0, -1), + Action::FocusPaneDown => app.focus_pane_dir(0, 1), Action::BroadcastToggle => { app.broadcast_mode = !app.broadcast_mode; app.command_result = if app.broadcast_mode { diff --git a/src/ui.rs b/src/ui.rs index 1e2ac21..3c0da12 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -500,6 +500,7 @@ struct OrchRow { state: String, state_style: Style, tokens: String, + ctx: String, cost: String, } @@ -549,6 +550,20 @@ fn orchestrator_row(app: &App) -> Option { } else { format!("{total} tok") }, + ctx: { + fn short(n: u64) -> String { + if n >= 1000 { + format!("{:.1}k", n as f64 / 1000.0) + } else { + n.to_string() + } + } + match (stats.context_tokens, app.orchestrator_ctx_max) { + (0, None) => "—".into(), + (c, None) => format!("{} ctx", short(c)), + (c, Some(m)) => format!("{}/{}", short(c), short(m)), + } + }, cost: if stats.total_cost_usd > 0.0 { format!("${:.3}", stats.total_cost_usd) } else { @@ -577,6 +592,7 @@ fn orchestrator_row(app: &App) -> Option { Style::default().fg(Color::Green) }, tokens: s.tokens_display(), + ctx: s.context_display(), cost: s.cost_display(), }) } @@ -806,7 +822,10 @@ fn draw_status_panel(f: &mut Frame<'_>, app: &App, area: Rect) -> Vec { Style::default().fg(Color::Cyan), ), Span::raw("│ "), - Span::styled(format!("{:>8} ", "—"), Style::default().fg(Color::Magenta)), + Span::styled( + format!("{:>8} ", o.ctx), + Style::default().fg(Color::Magenta), + ), Span::raw("│ "), Span::styled(format!("{:>7}", o.cost), Style::default().fg(Color::Green)), ]; @@ -1161,7 +1180,8 @@ fn draw_help(f: &mut Frame<'_>, area: Rect) -> Rect { ("alt-tab", "Next session"), ("alt-shift-tab", "Previous session"), ("alt-1 … alt-8", "Switch to session by number"), - ("alt-← / alt-→", "Cycle sessions"), + ("alt-← / alt-→", "Cycle sessions in focused pane"), + ("alt-shift-arrows", "Focus pane in direction"), ("alt-x", "Kill active session"), ("alt-c", "Open command bar"), ("alt-t", "Toggle agent chat pane"),