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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **The Config screen's agents pane no longer hides agents in silence.** It drew
the rows it could fit and dropped the rest, and its bottom border looked the
same either way — on a short terminal with several agents configured, the ones
past the fold could only be read by resizing the terminal or running `voro
agent list` at the shell. The pane now scrolls with `J`/`K` and the page keys,
the same gesture the cockpit's focus card takes and for the same reason: this
pane has no selection of its own, `j`/`k` on that screen belonging to the
viewers list below it. When there is nothing hidden the border says nothing;
when there is, it carries the overflow and the keys that move it.

- **A capped session's badge now shows the reset time it actually named.** Real
cap messages end with an upgrade prompt that mentions a usage limit of its
own, and that trailing mention was winning: it carries no time, so every
Expand Down
25 changes: 25 additions & 0 deletions crates/voro/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,14 @@ pub struct App {
/// silently rendering an empty config.
pub config_error: Option<String>,
pub config_sel: usize,
/// Vertical scroll offset of the Config screen's agents pane (DESIGN.md §9),
/// driven by `J`/`K` and `PgDn`/`PgUp`. The pane carries no selection of its
/// own — `j`/`k` belong to the viewers list below it — so the scroll is the
/// only way past the fold on a terminal too short for every agent.
pub config_agents_scroll: u16,
/// The largest useful `config_agents_scroll` for the pane as last rendered,
/// recorded by `draw_config` for the same reason as `detail_max_scroll`.
pub config_agents_max_scroll: std::cell::Cell<u16>,

pub mode: Mode,
/// Whether the detail views fold the score decomposition (DESIGN.md §7) and
Expand Down Expand Up @@ -666,6 +674,8 @@ impl App {
config_anon_viewer: None,
config_error: None,
config_sel: 0,
config_agents_scroll: 0,
config_agents_max_scroll: std::cell::Cell::new(0),
mode: Mode::Normal,
show_score: false,
show_history: false,
Expand Down Expand Up @@ -1226,6 +1236,13 @@ impl App {
self.detail_scroll = (self.detail_scroll as i64 + delta).clamp(0, max) as u16;
}

/// Scroll the Config screen's agents pane, clamped the same way against the
/// overflow `draw_config` last measured.
fn scroll_config_agents(&mut self, delta: i64) {
let max = self.config_agents_max_scroll.get() as i64;
self.config_agents_scroll = (self.config_agents_scroll as i64 + delta).clamp(0, max) as u16;
}

/// Tab cycles cockpit → tasks → projects → config → cockpit; `alt-1` to
/// `alt-4` jump directly (DESIGN.md §9). Until a project is registered the
/// ring is the shorter Projects ↔ Config, the cockpit and the browser
Expand Down Expand Up @@ -3173,6 +3190,14 @@ impl App {
KeyCode::Char('d') => self.delete_selected_viewer(),
KeyCode::Char('V') => self.open_default_picker(DefaultKind::Viewer),
KeyCode::Char('A') => self.open_default_picker(DefaultKind::Agent),
// The agents pane takes the cockpit card's scroll keys for the same
// reason it has there: `j`/`k` are the list's — here the viewers'
// — so the pane below them is driven by the shifted pair and the
// page keys (DESIGN.md §9).
KeyCode::Char('J') => self.scroll_config_agents(1),
KeyCode::Char('K') => self.scroll_config_agents(-1),
KeyCode::PageDown => self.scroll_config_agents(DETAIL_PAGE_STEP),
KeyCode::PageUp => self.scroll_config_agents(-DETAIL_PAGE_STEP),
_ => {}
}
}
Expand Down
147 changes: 133 additions & 14 deletions crates/voro/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1752,19 +1752,34 @@ fn draw_config(frame: &mut Frame, app: &App, hits: &mut HitMap) {
}

// The pane takes the height its rows need, yielding only what the viewers
// list below needs for a border and a row: that list scrolls with its
// selection while this paragraph does not, so an agent hidden here is the
// more expensive truncation.
// list below needs for a border and a row: both panes scroll, so the split
// is about which one is read whole without a keypress, and that is this one.
let agents_h = (agent_lines.len() as u16 + 2).clamp(3, main.height.saturating_sub(3).max(3));
let [agents_area, viewers_area] =
Layout::vertical([Constraint::Length(agents_h), Constraint::Min(3)]).areas(main);

let agents = Paragraph::new(agent_lines).block(
Block::default()
.borders(Borders::ALL)
.title("Agents (read-only — * default)"),
// The rows the pane cannot fit are reached with `J`/`K` and the page keys,
// the cockpit card's gesture: the pane carries no selection to scroll with,
// and `j`/`k` here are the viewers list's. The count and the keys ride the
// bottom border, so a pane that is hiding agents says so.
let total = agent_lines.len() as u16;
let block = Block::default()
.borders(Borders::ALL)
.title("Agents (read-only — * default)");
let max_scroll = total.saturating_sub(agents_area.height.saturating_sub(2));
app.config_agents_max_scroll.set(max_scroll);
let scroll = app.config_agents_scroll.min(max_scroll);
let block = if max_scroll > 0 {
block.title_bottom(
Line::from(format!(" {scroll}/{max_scroll} ↕ J/K PgDn/PgUp ")).right_aligned(),
)
} else {
block
};
frame.render_widget(
Paragraph::new(agent_lines).scroll((scroll, 0)).block(block),
agents_area,
);
frame.render_widget(agents, agents_area);

// Viewers: every viewer `open` can run — the built-ins with the user's
// tables layered over them, each carrying its provenance like the agents
Expand Down Expand Up @@ -2004,19 +2019,22 @@ const NEW_KEYS: [(&str, &str); 2] = [

/// The uppercase keys DESIGN.md §9 names as standing outside the case
/// convention, because none is the shifted half of a pair: `C` and the projects
/// screen's `A` share a letter with an unrelated action, `J`/`K` scroll the
/// card, and the Config screen's `V`/`A` pick defaults. Every other uppercase
/// binding has to be the interactive half of a pair, which the test below
/// enforces screen by screen.
/// screen's `A` share a letter with an unrelated action, `J`/`K` scroll a pane
/// that has no selection to scroll with — the cockpit's card and the Config
/// screen's agents — and the Config screen's `V`/`A` pick defaults. Every other
/// uppercase binding has to be the interactive half of a pair, which the test
/// below enforces screen by screen.
#[cfg(test)]
const CASE_EXCEPTIONS: [(Screen, &str); 7] = [
const CASE_EXCEPTIONS: [(Screen, &str); 9] = [
(Screen::Cockpit, "C"),
(Screen::Cockpit, "J"),
(Screen::Cockpit, "K"),
(Screen::Tasks, "C"),
(Screen::Projects, "A"),
(Screen::Config, "V"),
(Screen::Config, "A"),
(Screen::Config, "J"),
(Screen::Config, "K"),
];
const MESSAGE_KEYS: [(&str, &str); 2] = [
("a", "message the task's session, headless"),
Expand Down Expand Up @@ -2158,6 +2176,8 @@ fn key_map(screen: Screen, no_projects: bool) -> Vec<KeySection> {
"Navigation",
vec![
("j/k", "move the selection"),
("J/K", "scroll the agents pane"),
("PgUp/PgDn", "page the agents pane"),
("?", "this key map"),
("q", "quit"),
],
Expand Down Expand Up @@ -2754,13 +2774,112 @@ mod tests {
assert!(rendered.contains(&format!("{{model}}: m{n}")), "{rendered}");
}
// The viewers list keeps a row of its own; what it gave up it can still
// scroll to, which the agents paragraph could not.
// scroll to.
assert!(rendered.contains("Viewers"), "{rendered}");
assert!(rendered.contains("code -n {path}"), "{rendered}");

std::fs::remove_dir_all(&dir).unwrap();
}

/// The bug (task #450): where the pane cannot fit its rows, the ones past
/// the fold were simply not drawn and nothing said so. Now the bottom
/// border carries the overflow and the keys that move it, and `J` walks the
/// hidden agents into view — on a terminal no larger than 80x24.
#[test]
fn config_agents_pane_scrolls_to_the_agents_it_cannot_fit() {
use crate::app::App;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::crossterm::event::{KeyCode, KeyEvent};
use voro_core::Store;

let dir = std::env::temp_dir().join(format!(
"voro-ui-config-scroll-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let agents_path = dir.join("voro.toml");
std::fs::create_dir_all(&dir).unwrap();
let mut toml = String::from("[viewers.zed]\ncmd = \"zed {path}\"\n");
for n in 1..=6 {
toml.push_str(&format!(
"\n[agents.mine{n}]\ndispatch = \"mine{n} run {{prompt_file}} --model {{model}}\"\n\
model = \"m{n}\"\n"
));
}
std::fs::write(&agents_path, toml).unwrap();

let store = Store::open_in_memory().unwrap();
let ctx = crate::dispatch::DispatchCtx {
db_path: dir.join("voro.db"),
agents_path,
runtime_dir: dir.join("sessions"),
ref_capture_timeout: std::time::Duration::ZERO,
message_grace: std::time::Duration::from_millis(300),
};
let mut app = App::new(store, ctx).unwrap();
alt_screen(&mut app, '4');

let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
let render = |terminal: &mut Terminal<TestBackend>, app: &App| {
terminal
.draw(|f| {
draw(f, app);
})
.unwrap();
terminal
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect::<String>()
};

let rendered = render(&mut terminal, &app);
let hidden = app.config_agents_max_scroll.get();
assert!(
hidden > 0,
"eight agents should overflow an 80x24 pane:\n{rendered}"
);
assert!(
rendered.contains(&format!("0/{hidden} ↕ J/K PgDn/PgUp")),
"the pane hides rows without saying so:\n{rendered}"
);

// Every agent is reachable: walk to the bottom a row at a time and the
// last one — the one the fold ate — is on screen.
let last = app
.config_agents
.last()
.expect("agents are configured")
.name
.clone();
assert!(!rendered.contains(&last), "{rendered}");
for _ in 0..hidden {
app.on_key(KeyEvent::from(KeyCode::Char('J')));
}
assert_eq!(app.config_agents_scroll, hidden, "J clamps at the bottom");
let rendered = render(&mut terminal, &app);
assert!(rendered.contains(&last), "{rendered}");
assert!(
rendered.contains(&format!("{hidden}/{hidden} ↕ J/K PgDn/PgUp")),
"{rendered}"
);

// `K` walks back, and the viewers list keeps its own `j`/`k`.
app.on_key(KeyEvent::from(KeyCode::PageUp));
assert!(app.config_agents_scroll < hidden);
let before = app.config_sel;
app.on_key(KeyEvent::from(KeyCode::Char('j')));
assert_ne!(app.config_sel, before, "j still moves the viewer selection");

std::fs::remove_dir_all(&dir).unwrap();
}

fn row(state: TaskState, blockers: Vec<DepRef>) -> TaskRow {
TaskRow {
task: Task {
Expand Down
Loading