Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,9 @@ pub struct App {
pub pending_proposal: Option<PendingProposal>,
/// 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<u64>,
/// 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)>,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down
22 changes: 17 additions & 5 deletions src/ctx_probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,22 @@ pub fn backend_for_command(base_name: &str) -> Option<Backend> {
}

pub fn spawn_probe(session_id: usize, backend: Backend, tx: mpsc::Sender<AppEvent>) {
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<AppEvent>) {
spawn_probe_with(backend, tx, |max| AppEvent::OrchestratorContextMax { max });
}

fn spawn_probe_with(
backend: Backend,
tx: mpsc::Sender<AppEvent>,
make_event: impl Fn(u64) -> AppEvent + Send + 'static,
) {
tokio::spawn(async move {
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(2))
Expand All @@ -65,11 +81,7 @@ pub fn spawn_probe(session_id: usize, backend: Backend, tx: mpsc::Sender<AppEven
};
if let Some(max) = max {
if max > 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;
Expand Down
4 changes: 4 additions & 0 deletions src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions src/keybindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ pub enum Action {
ClosePane,
RotateSplit,
FocusNextPane,
FocusPaneLeft,
FocusPaneRight,
FocusPaneUp,
FocusPaneDown,
BroadcastToggle,
Detach,
}
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -164,6 +172,10 @@ fn parse_action(s: &str) -> Option<Action> {
"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,
Expand Down
7 changes: 7 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down
24 changes: 22 additions & 2 deletions src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,7 @@ struct OrchRow {
state: String,
state_style: Style,
tokens: String,
ctx: String,
cost: String,
}

Expand Down Expand Up @@ -549,6 +550,20 @@ fn orchestrator_row(app: &App) -> Option<OrchRow> {
} 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 {
Expand Down Expand Up @@ -577,6 +592,7 @@ fn orchestrator_row(app: &App) -> Option<OrchRow> {
Style::default().fg(Color::Green)
},
tokens: s.tokens_display(),
ctx: s.context_display(),
cost: s.cost_display(),
})
}
Expand Down Expand Up @@ -806,7 +822,10 @@ fn draw_status_panel(f: &mut Frame<'_>, app: &App, area: Rect) -> Vec<Rect> {
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)),
];
Expand Down Expand Up @@ -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"),
Expand Down
Loading