From d4056838e829b08e96e545f243f776ae02fcf9e4 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 30 Aug 2026 18:55:54 +0530 Subject: [PATCH 01/21] feat(term): make a viewport re-placement something a test can execute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Screen::replace` and `Screen::rewind` shared an identical tail — erase from a row down, give the terminal back, take a fresh viewport, and fall back to the session's own height if the new one cannot be placed. Both lived on the stdout-backed impl, because both built their replacement with `attach_with`, which enables raw mode and asks a real tty where its cursor is. Nothing under `tests/` could reach either, so no test in the suite had ever executed a viewport re-placement — the one operation 0.32.0's growing viewport is made of, and the one N5 is about. `Screen::replace_from(row, height, attach)` takes the constructor as an argument and holds the whole sequence once, on the generic impl. `Screen::replace` and `Screen::rewind` become its two callers. `tests/support::replace` supplies a recorder-backed constructor sharing the same `Recorder`, which is what makes the property observable: N5 is about the byte stream across a growth, and a fresh recorder per screen would throw away the only evidence there is. `tests/viewport.rs` asserts it over a growth, a shrink back to the floor, a grow-to-grow that never passes through the floor, a terminal resize while grown, the erase ordering as the literal CUP+ED bytes, and the ceiling on an 80x24. Committed rows survive all six exactly once. Also adds `SCROLLBACK_RESERVE`: the rows of conversation that stay visible above a grown viewport. Four, so an 80x24 keeps a twenty-row viewport with the last exchange still on screen. --- src/term.rs | 93 ++++++++++++++----- tests/support/mod.rs | 43 +++++++++ tests/viewport.rs | 217 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 329 insertions(+), 24 deletions(-) create mode 100644 tests/viewport.rs diff --git a/src/term.rs b/src/term.rs index 4fc03a2..13776ec 100644 --- a/src/term.rs +++ b/src/term.rs @@ -100,6 +100,27 @@ use ratatui::{Frame, Terminal, TerminalOptions, Viewport}; /// instead of a prompt that has to grow. pub const VIEWPORT_HEIGHT: u16 = 8; +/// Rows of committed conversation that stay visible above a grown viewport. +/// +/// **The floor above, rather than the ceiling below.** Since 0.32.0 a surface +/// states the rows it wants and the viewport grows to give them, so the bound is +/// no longer a ration — the developer's decision, 2026-08-30, is that a surface +/// may take the screen when it needs it, and that usable beats tidy. What this +/// reserves is the only thing growth can genuinely destroy: the sight of the +/// exchange the surface is about. A question overlay filling the terminal to the +/// last row is a question with no visible reason for being asked. +/// +/// Four rows is what that costs. On the 80x24 this product supports rather than +/// degrades it leaves a twenty-row viewport — enough for a twelve-step plan with +/// its footer, or a five-choice question with its context line and its composer — +/// with the last exchange still on screen above it. +/// +/// [`Screen::attach_with`] clamps to `rows - 2` underneath this, and that stays: +/// it is the backstop that keeps a viewport from being taller than the terminal +/// whatever asks, including the wizard, which is placed before any of this is +/// known. +pub const SCROLLBACK_RESERVE: u16 = 4; + /// Rows the wizard's viewport occupies. /// /// Much taller than the session's, and it has to be: the wizard's screens are @@ -213,18 +234,7 @@ impl Screen> { // left there, which is also where `compute_inline_size` will place the // next viewport — so the new one starts exactly where the old one did. let top = self.terminal.get_frame().area().y; - self.escape(&format!("\x1b[{};1H\x1b[0J", top.saturating_add(1)))?; - self.restore(); - match Self::attach_with(height) { - Ok(fresh) => { - *self = fresh; - Ok(()) - } - Err(error) => { - *self = Self::attach_with(VIEWPORT_HEIGHT)?; - Err(error) - } - } + self.replace_from(top.saturating_add(1), height, Self::attach_with) } /// Take `rows` of committed content back off the screen, and bring the @@ -264,18 +274,7 @@ impl Screen> { // From the first row being taken back, down. That clears the rows // themselves and the viewport under them, and leaves the cursor exactly // where the next viewport is to be placed. - self.escape(&format!("\x1b[{};1H\x1b[0J", top - up + 1))?; - self.restore(); - match Self::attach_with(height) { - Ok(fresh) => { - *self = fresh; - Ok(()) - } - Err(error) => { - *self = Self::attach_with(VIEWPORT_HEIGHT)?; - Err(error) - } - } + self.replace_from(top - up + 1, height, Self::attach_with) } fn attach_raw(height: u16) -> io::Result { @@ -550,6 +549,52 @@ impl + Write> Screen { Write::flush(backend) } + /// Erase this viewport from `row` down, give the terminal back, and take a + /// fresh one of `height` rows in its place. + /// + /// **The shared body of [`Screen::replace`] and [`Screen::rewind`]**, and the + /// reason it is here rather than beside them: both of those live on the + /// stdout-backed impl, because both build their replacement with + /// [`Screen::attach_with`], which enables raw mode and asks a real tty where + /// its cursor is. Nothing under `tests/` can reach either — so until 0.32.0 no + /// test in the suite had ever executed a viewport re-placement, which is the + /// one operation the growing viewport is made of. Taking the constructor as an + /// argument moves the whole sequence — the erase, the restore, the re-attach + /// and the fall back to the session's own height — onto the generic impl, + /// where a test can supply a recorder-backed constructor and assert over the + /// bytes that actually leave the process. + /// + /// `row` is 1-based, because it is written straight into a CUP sequence. + /// + /// **Erase before letting go.** These rows are the terminal's screen, not its + /// scrollback: nothing scrolls them away and nothing repaints them once this + /// `Screen` is gone. Without the erase the next viewport is placed at the + /// cursor and draws OVER the old rows, which leaves half a palette standing + /// behind a composer. `ESC[0J` from `row`, so the committed transcript above + /// is untouched and everything below is cleared; the cursor is left there, + /// which is also where `compute_inline_size` will place the next viewport. + /// + /// If the new height cannot be placed, the session's own is placed instead and + /// the error returned: an operator who asked for a taller list and cannot have + /// one keeps their session rather than losing the viewport with it. + pub fn replace_from(&mut self, row: u16, height: u16, attach: F) -> io::Result<()> + where + F: Fn(u16) -> io::Result, + { + self.escape(&format!("\x1b[{row};1H\x1b[0J"))?; + self.restore(); + match attach(height) { + Ok(fresh) => { + *self = fresh; + Ok(()) + } + Err(error) => { + *self = attach(VIEWPORT_HEIGHT)?; + Err(error) + } + } + } + /// Tell the renderer the terminal is a different size now. /// /// Recomputing the inline viewport is the whole job: the committed lines above diff --git a/tests/support/mod.rs b/tests/support/mod.rs index 0b59545..cb07302 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -219,6 +219,49 @@ pub fn screen_of( (io_cli::term::Screen::from_terminal(terminal), recorder) } +/// Re-place the viewport at `viewport` rows, exactly the way `Screen::replace` +/// does it against a real terminal. +/// +/// This is the harness half of 0.32.0's growing viewport. `Screen::replace` and +/// `Screen::rewind` both live on the stdout-backed impl, because both build their +/// replacement with `Screen::attach_with`, which enables raw mode and asks a real +/// tty where its cursor is — so neither is reachable from here, and until 0.32.0 +/// no test had ever executed a viewport re-placement. `Screen::replace_from` +/// takes the constructor as an argument for exactly this reason, and what it runs +/// is the same erase, restore, re-attach and fall-back the session runs. +/// +/// **The recorder is deliberately the same one.** The property N5 asserts is +/// about the byte stream across a growth — content committed before the +/// re-placement must appear in it exactly once afterwards — and a fresh recorder +/// per screen would throw away the only evidence that matters. +pub fn replace( + screen: &mut io_cli::term::Screen, + recorder: &Recorder, + width: u16, + height: u16, + viewport: u16, +) { + let row = screen + .terminal_mut() + .get_frame() + .area() + .y + .saturating_add(1); + let recorder = recorder.clone(); + screen + .replace_from(row, viewport, move |rows| { + let backend = Fixed::new(recorder.clone(), width, height); + Terminal::with_options( + backend, + ratatui::TerminalOptions { + viewport: ratatui::Viewport::Inline(rows), + }, + ) + .map(io_cli::term::Screen::from_terminal) + }) + .expect("replace"); +} + /// Resize the way a terminal does: the backend reports the new size *and* the /// application is told about it, in that order. pub fn resize(screen: &mut io_cli::term::Screen, width: u16, height: u16) { diff --git a/tests/viewport.rs b/tests/viewport.rs new file mode 100644 index 0000000..bf03dff --- /dev/null +++ b/tests/viewport.rs @@ -0,0 +1,217 @@ +//! N5, O16, O17 — the viewport is the size of what it has to show. +//! +//! **This is the property 0.32.0 stands on.** Four scope lines rest on the inline +//! viewport being able to grow and shrink while a session is running: the question +//! overlay's composer, the plan overlay's pinned footer, the queue drawn in full, +//! and every picker's elision. If a growth duplicates or loses a committed row, +//! none of them can ship in the form the contract describes. +//! +//! The release's own planning called this untried ground. It is not — the composer +//! has grown the viewport since 0.7.0, through `App::viewport_wanted` and +//! `Screen::replace` — but it had never been *tested*, because `Screen::replace` +//! builds its replacement with `Screen::attach_with`, which enables raw mode and +//! queries a real tty, and so lives on the stdout-backed impl where nothing under +//! `tests/` can reach it. `Screen::replace_from` takes the constructor as an +//! argument so this file can run the real sequence — the erase, the restore, the +//! re-attach, the fall back to the session's height — against the recorder. +//! +//! Every assertion here is over the **byte stream**, not over a rendered buffer. A +//! duplicated row is a duplicated write; a lost row is a write that never +//! happened. Neither is visible in a cell grid, which is the same reason +//! `tests/support` uses `Fixed` over a real `CrosstermBackend` rather than +//! `TestBackend`. + +mod support; + +use ratatui::text::Line; +use ratatui::widgets::Paragraph; + +use io_cli::term::VIEWPORT_HEIGHT; + +/// The composer, drawn into the viewport. Deliberately carries none of the +/// markers the assertions count, so an occurrence in the byte stream is always a +/// commit and never a frame. +fn frame(screen: &mut io_cli::term::Screen) { + screen + .draw(|frame| { + let area = frame.area(); + frame.render_widget(Paragraph::new("> "), area); + }) + .expect("frame"); +} + +fn commit(screen: &mut io_cli::term::Screen, marker: &str) { + screen.commit(&[Line::from(marker)]).expect("commit"); + frame(screen); +} + +/// How many times `needle` appears in everything written to the terminal. +fn written(recorder: &support::Recorder, needle: &str) -> usize { + recorder.text().matches(needle).count() +} + +/// Every marker committed appears in the byte stream exactly once — not zero +/// times, which is a lost row, and not twice, which is a duplicated one. +fn each_committed_once(recorder: &support::Recorder, markers: &[&str]) { + for marker in markers { + let seen = written(recorder, marker); + assert_eq!( + seen, 1, + "{marker:?} was written {seen} times; a committed row must reach the \ + scrollback exactly once across a viewport re-placement", + ); + } +} + +#[test] +fn n5_a_growth_neither_duplicates_nor_loses_committed_rows() { + let (mut screen, recorder) = support::screen(100, 30); + + commit(&mut screen, "before-the-growth-alpha"); + commit(&mut screen, "before-the-growth-beta"); + + // The question overlay asking for room: eight rows becomes twenty. + support::replace(&mut screen, &recorder, 100, 30, 20); + assert_eq!(screen.rows(), 20, "the viewport did not take the rows it asked for"); + + commit(&mut screen, "after-the-growth-gamma"); + + each_committed_once( + &recorder, + &[ + "before-the-growth-alpha", + "before-the-growth-beta", + "after-the-growth-gamma", + ], + ); +} + +#[test] +fn n5_a_shrink_back_to_the_floor_neither_duplicates_nor_loses() { + let (mut screen, recorder) = support::screen(100, 30); + + commit(&mut screen, "committed-at-the-floor"); + support::replace(&mut screen, &recorder, 100, 30, 20); + commit(&mut screen, "committed-while-grown"); + + // The overlay closes. This is the direction that matters most: the grown + // viewport occupied rows the shrunken one does not, and anything left + // standing in them is a row the operator sees twice. + support::replace(&mut screen, &recorder, 100, 30, VIEWPORT_HEIGHT); + assert_eq!( + screen.rows(), + VIEWPORT_HEIGHT, + "the viewport did not return to its floor when the surface closed", + ); + + commit(&mut screen, "committed-after-the-shrink"); + + each_committed_once( + &recorder, + &[ + "committed-at-the-floor", + "committed-while-grown", + "committed-after-the-shrink", + ], + ); +} + +#[test] +fn n5_a_terminal_resize_while_grown_neither_duplicates_nor_loses() { + let (mut screen, recorder) = support::screen(100, 30); + + commit(&mut screen, "committed-before-anything-moved"); + support::replace(&mut screen, &recorder, 100, 30, 20); + commit(&mut screen, "committed-while-grown-at-thirty"); + + // The window is dragged narrower and shorter while an overlay is open. The + // committed lines above belong to the terminal and must not be redrawn — the + // duplicated history a full-screen renderer shows on resize is exactly what + // `Screen::resize` exists to avoid, and a grown viewport must not reintroduce + // it. + support::resize(&mut screen, 80, 24); + frame(&mut screen); + commit(&mut screen, "committed-after-the-resize"); + + each_committed_once( + &recorder, + &[ + "committed-before-anything-moved", + "committed-while-grown-at-thirty", + "committed-after-the-resize", + ], + ); +} + +#[test] +fn n5_a_surface_opening_while_another_is_grown_neither_duplicates_nor_loses() { + let (mut screen, recorder) = support::screen(100, 30); + + commit(&mut screen, "committed-before-the-first-surface"); + + // A question overlay opens and takes twelve rows. + support::replace(&mut screen, &recorder, 100, 30, 12); + commit(&mut screen, "committed-under-the-first-surface"); + + // A picker opens on top of it and wants more. The viewport goes straight from + // one grown height to another without passing through the floor, which is the + // case a re-placement written as "shrink then grow" would never exercise. + support::replace(&mut screen, &recorder, 100, 30, 20); + commit(&mut screen, "committed-under-the-second-surface"); + + // Both close at once. + support::replace(&mut screen, &recorder, 100, 30, VIEWPORT_HEIGHT); + commit(&mut screen, "committed-after-both-closed"); + + each_committed_once( + &recorder, + &[ + "committed-before-the-first-surface", + "committed-under-the-first-surface", + "committed-under-the-second-surface", + "committed-after-both-closed", + ], + ); +} + +#[test] +fn n5_the_erase_precedes_the_replacement_and_starts_at_the_viewport_top() { + // The ordering the whole property rests on. `Screen::replace_from` erases from + // the viewport's own top row down before it lets go of the terminal, because + // those rows are the screen and not the scrollback: nothing scrolls them away + // and nothing repaints them once the old `Screen` is gone. Without the erase + // the next viewport is placed at the cursor and draws OVER the old rows. + // + // Asserted as bytes because that is what it is: a CUP to the top row, then + // ESC[0J. A rendered buffer cannot show it. + let (mut screen, recorder) = support::screen(100, 30); + commit(&mut screen, "a-committed-row"); + + let top = screen.terminal_mut().get_frame().area().y.saturating_add(1); + support::replace(&mut screen, &recorder, 100, 30, 20); + + let expected = format!("\x1b[{top};1H\x1b[0J"); + assert!( + recorder.text().contains(&expected), + "the viewport was replaced without erasing itself first; expected {expected:?} \ + in the byte stream", + ); +} + +#[test] +fn o17_a_viewport_never_exceeds_what_the_terminal_can_give() { + // Growth is a request, not a guarantee. A surface that asks for more rows than + // the terminal has must degrade rather than overflow — 80x24 is a supported + // size, not a degraded one. + let (mut screen, recorder) = support::screen(80, 24); + commit(&mut screen, "committed-on-a-small-terminal"); + + support::replace(&mut screen, &recorder, 80, 24, 100); + + let rows = screen.rows(); + assert!( + rows <= 24, + "the viewport took {rows} rows on a 24-row terminal", + ); + each_committed_once(&recorder, &["committed-on-a-small-terminal"]); +} From 67f16cca872159e02e68814cadfb7b03a428e311 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 30 Aug 2026 18:56:17 +0530 Subject: [PATCH 02/21] feat(rows): measure wrapped rows, and say how many were dropped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every overlay in this product measured itself wrong, and all of them the same way. `Intent::render` and `Review::render` push logical lines, take `lines.len()` as the height they occupy, and hand them to a `Paragraph` **with wrapping switched on**. A question, a context line or a plan step longer than the terminal is wide occupies more rows than it is lines, so the paragraph consumes the whole area while the count still says there is room — which loses the choices and the footer, and then draws the composer on top of rows the paragraph has already painted. The correction is not a more careful count of lines. `rows::wrapped` asks ratatui itself, through the same `Paragraph::line_count` that `Screen::commit` has always used to size `insert_before`. That method is the wrapper rather than a model of it, so the measurement and the render cannot disagree. The feature it needs was already enabled and already used here; no dependency moves. `rows::elide` keeps what fits and ends with a count of the rows dropped — rows, not items, because a wrapped step that is not drawn is three rows missing from the screen, and "1 more step" understates a surface the operator is being asked to approve. The search starts at `room` lines rather than at the end, since every line takes at least one row, which bounds it by the height of a viewport instead of the length of the content. Deliberately not a fourth spelling of an idiom there are already three of. `approval.rs` carries two and `queue.rs` a third; all three keep their own, because their tests assert their exact shapes and none of them wraps. What is new here is the wrapped case none of them handles. --- src/lib.rs | 1 + src/rows.rs | 199 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 src/rows.rs diff --git a/src/lib.rs b/src/lib.rs index 8f30cc2..27ae19e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -112,6 +112,7 @@ pub mod resume; pub mod reviewer; pub mod rewind; pub mod routing; +pub mod rows; pub mod servers; pub mod sessions; pub mod settings; diff --git a/src/rows.rs b/src/rows.rs new file mode 100644 index 0000000..6305947 --- /dev/null +++ b/src/rows.rs @@ -0,0 +1,199 @@ +//! How many rows a surface needs, and how it says what it could not show. +//! +//! **Every surface in this product measured itself wrong until 0.32.0, and they +//! all measured it wrong the same way.** `Intent::render` and `Review::render` +//! both pushed logical [`Line`]s, took `lines.len()` as the height they occupy, +//! and handed them to a `Paragraph` **with wrapping switched on**. A question, a +//! context line or a plan step longer than the terminal is wide occupies more +//! rows than it is lines, so the paragraph consumed the whole area while the count +//! still said it had room — which lost the choices and the footer, and then drew +//! the composer on top of rows the paragraph had already painted. +//! +//! The correction is not a more careful count of lines. It is to stop counting +//! lines: [`wrapped`] asks ratatui itself how many rows a wrapped paragraph will +//! occupy, through the same `Paragraph::line_count` that [`crate::term::Screen::commit`] +//! has always used to tell `insert_before` how tall its insertion is. That method +//! is the wrapper, not a model of it, so the measurement and the render cannot +//! disagree. +//! +//! [`elide`] is the other half. A surface that cannot show everything says so with +//! a count, because silence is indistinguishable from there being nothing more — +//! and a plan whose last two steps are missing without a word is a plan somebody +//! approves without knowing they have not read it. +//! +//! **This is deliberately not a fourth spelling of an idiom the product already +//! has three of.** `approval.rs` carries two (`preview` rides the suffix on the +//! last shown line and fits with `picker::fit`; `as_diff` builds spans and fits +//! with its own `fit_line`) and `queue.rs` a third, on `String`s. Those keep their +//! own — their tests assert their exact shapes and none of them wraps. What is new +//! here is the case none of them handles: a surface whose content wraps, where the +//! number that has to be reported is a count of **rows**, not of items. + +use ratatui::text::{Line, Text}; +use ratatui::widgets::{Paragraph, Wrap}; + +use crate::theme::{Theme, Tone}; + +/// Rows a wrapped paragraph of `lines` occupies at `width`. +/// +/// The one measurement, so a surface's idea of its own height and what ratatui +/// actually paints are the same number by construction. `width` of zero is +/// treated as one: a zero-width area draws nothing, and answering zero would tell +/// a caller it had infinite room. +pub fn wrapped(lines: &[Line<'_>], width: u16) -> u16 { + let text = Text::from(lines.to_vec()); + let paragraph = Paragraph::new(text).wrap(Wrap { trim: false }); + u16::try_from(paragraph.line_count(width.max(1))).unwrap_or(u16::MAX) +} + +/// The line a surface draws instead of the rows it had no room for. +/// +/// `⋯ N more rows` in Unicode, `... N more rows` in ASCII — [`crate::glyphs`] +/// holds the rule that both spellings occupy the column the other does. Drawn +/// [`Tone::Muted`], which carries no word, so it reads the same under `NO_COLOR` +/// as it does in colour: the count is the message and it is in the text. +/// +/// **Rows rather than items**, because that is what the reader lost. A plan step +/// that wraps to three rows and is not shown is three rows missing from the +/// screen, and saying "1 more step" understates a surface the operator is being +/// asked to approve. +pub fn more(rows: u16, theme: &Theme) -> Line<'static> { + theme.notice( + Tone::Muted, + format!("{} {rows} more rows", theme.glyphs.elision), + ) +} + +/// Keep what fits in `room` rows, and end with a count of the rows dropped. +/// +/// Returns `lines` untouched when they already fit — the everyday case now that +/// the viewport grows to what a surface asks for, and the reason this is a +/// fallback rather than the normal path. +/// +/// The suffix takes a row of its own rather than riding the last line kept, which +/// is what `approval.rs` does. With wrapping on, riding it means re-fitting a line +/// whose own width is not known until it has been wrapped, and getting that wrong +/// silently drops a row while claiming to report one. A row spent saying what is +/// missing is the cheapest honest answer. +/// +/// The search starts at `room` lines rather than at the end: every line occupies +/// at least one row, so a prefix longer than `room` can never fit in `room` rows. +/// That bounds the loop by the height of a viewport rather than by the length of +/// the content, which matters for a picker over four hundred rows. +pub fn elide(lines: Vec>, room: u16, width: u16, theme: &Theme) -> Vec> { + if room == 0 { + return Vec::new(); + } + let total = wrapped(&lines, width); + if total <= room { + return lines; + } + let mut keep = lines.len().min(usize::from(room)); + while keep > 0 && wrapped(&lines[..keep], width).saturating_add(1) > room { + keep -= 1; + } + let mut out = lines[..keep].to_vec(); + let hidden = total.saturating_sub(wrapped(&out, width)); + out.push(more(hidden, theme)); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::theme::Theme; + + fn theme() -> Theme { + // The ASCII glyph set deliberately, so the count row is asserted in the + // spelling a terminal with no Unicode gets. Both sets occupy the same + // column, so an assertion that holds here holds for the other. + Theme::resolve( + true, + crate::theme::Background::Dark, + None, + crate::glyphs::ASCII, + ) + } + + #[test] + fn a_short_line_is_one_row_and_a_long_one_is_more() { + let short = vec![Line::from("hello")]; + assert_eq!(wrapped(&short, 20), 1); + + // The defect this module exists for: one line, several rows. + let long = vec![Line::from("hello world this wraps")]; + assert_eq!(wrapped(&long, 20), 2, "a wrapped line still counted as one row"); + assert!( + wrapped(&long, 20) > u16::try_from(long.len()).unwrap(), + "the wrapped height must exceed the line count, or the measurement is \ + the `lines.len()` one it replaces", + ); + } + + #[test] + fn a_zero_width_area_does_not_report_infinite_room() { + // `Paragraph::line_count` answers 0 at width 0, which would tell a caller + // it had room for everything. Treating 0 as 1 answers what a one-column + // terminal would actually need — eight rows for eight characters — which + // is large rather than free, and that is the safe direction. + assert!( + wrapped(&[Line::from("anything")], 0) >= 1, + "a zero-width area reported no rows, which reads as unlimited room", + ); + assert_eq!(wrapped(&[Line::from("anything")], 0), wrapped(&[Line::from("anything")], 1)); + } + + #[test] + fn content_that_fits_is_returned_untouched() { + let lines = vec![Line::from("one"), Line::from("two")]; + let out = elide(lines.clone(), 8, 40, &theme()); + assert_eq!(out.len(), lines.len()); + assert_eq!(wrapped(&out, 40), 2); + } + + #[test] + fn content_that_does_not_fit_is_cut_and_counted() { + let lines: Vec> = (0..20).map(|n| Line::from(format!("row {n}"))).collect(); + let out = elide(lines, 5, 40, &theme()); + + assert!( + wrapped(&out, 40) <= 5, + "the elided form must fit the room it was given", + ); + let last = out.last().expect("a count row").to_string(); + assert!( + last.contains("more rows"), + "the surface dropped rows without saying so: {last:?}", + ); + // 20 rows asked for, 5 available, one of which is the count itself. + assert!( + last.contains("16"), + "the count must be the rows actually dropped, but it read {last:?}", + ); + } + + #[test] + fn the_count_is_rows_and_not_items_when_content_wraps() { + // Three lines, each wrapping to two rows at this width. + let lines: Vec> = (0..3) + .map(|n| Line::from(format!("item {n} is long enough to wrap here"))) + .collect(); + let width = 20; + let total = wrapped(&lines, width); + assert!(total > 3, "the fixture does not wrap, so it proves nothing"); + + let out = elide(lines, 4, width, &theme()); + let last = out.last().expect("a count row").to_string(); + let dropped = total - wrapped(&out[..out.len() - 1], width); + assert!( + last.contains(&dropped.to_string()), + "counted items rather than rows: {last:?} with {dropped} rows dropped", + ); + } + + #[test] + fn no_room_at_all_draws_nothing_rather_than_a_count_alone() { + let lines = vec![Line::from("one"), Line::from("two")]; + assert!(elide(lines, 0, 40, &theme()).is_empty()); + } +} From c81f5a2a49718feacc59bd16a3ded911b6af1901 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 30 Aug 2026 19:53:15 +0530 Subject: [PATCH 03/21] feat(app): the viewport is the size of what it has to show MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two lines were why every surface in this release drew into eight rows. `App::viewport_wanted` returned `VIEWPORT_HEIGHT` outright whenever a turn was running or an overlay was open, and `paint_picker` re-placed the viewport only when no picker was up. A question with five choices, a twelve-step plan, four queued messages and a four-hundred-row picker all arrive during a turn, so all four were clipped by the same guard. The re-placement itself is not new: the composer has grown the viewport since 0.7.0. What is new is letting the surfaces that need rows ask for them. `App::viewport_wanted` is now the one owner of the demand and `term::viewport_for` the one owner of the limit, so the session's own sizing and the driver's picker path cannot disagree about the ceiling. That ceiling is the terminal's height less `SCROLLBACK_RESERVE`, which exists so the exchange a surface is about stays visible, not to ration rows. The ordinary session's demand is derived from the layout rather than guessed, and reproduces the old constant exactly at an idle prompt. `App::viewport_height` is deleted. It returned the constant, had no production caller, and with a viewport that moves it was misleading. `n2_a_queue_of_any_depth_leaves_the_frame_the_same_height_as_an_empty_one` asserted the opposite and is replaced. It was 0.17.0's guard against growth walking the scrollback; that reason is now discharged against the byte stream in tests/viewport.rs, so what is left is the cost it bought, which was the operator not seeing what they had queued. The first queued message is still free — the layout releases the blank above the activity line, which is where 0.17.0's one visible row came from. --- src/app.rs | 105 +++++++++++++++++++------ src/term.rs | 20 +++++ tests/interrupt.rs | 14 +++- tests/queue_surface.rs | 100 ++++++++++++++---------- tests/viewport.rs | 173 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 344 insertions(+), 68 deletions(-) diff --git a/src/app.rs b/src/app.rs index 4107635..a7d867a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -400,6 +400,16 @@ impl App { self.events.set_plain(plain); } + /// Say that this process holds the overlay a question is answered through. + /// + /// Handed down to [`crate::events::Events`], which decides whether the durable + /// transcript line is committed. Set by the interactive driver, which is the + /// one place that keeps the receiving end of the responder's channel; every + /// other path — a resumed run above all — leaves it `false` and gets the line. + pub fn set_answering(&mut self, answering: bool) { + self.events.set_answering(answering); + } + /// Whether this session runs in plain mode. pub fn plain(&self) -> bool { self.status.plain @@ -458,8 +468,13 @@ impl App { Tone::Muted, format!("answered {} {text}", self.theme.glyphs.dash), ), + // **Declining is a real answer, so it is not a warning.** io-harness + // documents `None` as "nobody here can answer this": the question is + // persisted and the run pauses, resumable. Nothing has gone wrong and + // nothing was refused — the operator chose the option the surface + // offers them, and `Tone::Warning`'s own word is `warning`. None => self.record( - Tone::Warning, + Tone::Muted, format!( "left unanswered {} the run pauses and keeps the question", self.theme.glyphs.dash @@ -503,8 +518,11 @@ impl App { Tone::Muted, format!("plan approved {dash} {} steps", plan.plan().steps.len()), ), + // Sending a plan back for revision is the surface working, not a + // warning about it: the operator read the plan and said what they + // wanted instead, which is the whole reason the overlay takes prose. io_harness::PlanVerdict::Revise { correction } => { - (Tone::Warning, format!("sent back {dash} {correction}")) + (Tone::Muted, format!("sent back {dash} {correction}")) } io_harness::PlanVerdict::Cancel => { (Tone::Refused, format!("plan cancelled {dash} nothing ran")) @@ -1329,23 +1347,64 @@ impl App { /// The viewport height this session wants right now. /// - /// [`VIEWPORT_HEIGHT`] until the prompt outgrows its two rows, and then as - /// many as the prompt needs, up to [`COMPOSER_MAX`]. The driver compares this - /// with the viewport it has and re-places when they differ — which is the - /// one operation in this product that re-queries the cursor, so it is done at - /// an idle prompt and nowhere else. - /// - /// The cap is not a matter of taste. The viewport is subtracted from the - /// terminal, and a composer allowed to take all of it would push the - /// transcript it is being written against off the screen. + /// **The one owner of the demand, which is what makes the growing viewport a + /// single decision rather than several.** The driver compares this with the + /// viewport it has and re-places when they differ; [`crate::term::viewport_for`] + /// owns the ceiling, so the session's sizing and the driver's picker path + /// cannot disagree about how many rows the terminal can spare. + /// + /// **Until 0.32.0 this returned the fixed height whenever a turn was running + /// or an overlay was open**, which is the guard the whole release removes. A + /// question with five choices, a twelve-step plan and four queued messages all + /// arrive during a turn, and every one of them was drawing into eight rows + /// because of these two lines. The re-placement itself is not new — the + /// composer has grown the viewport since 0.7.0 and `Screen::replace` has done + /// the erase-restore-reattach since then. What is new is letting the surfaces + /// that actually need rows ask for them. + /// + /// An open surface answers for the whole viewport, because it is drawn instead + /// of the session rather than beside it — that is [`App::render`]'s own rule, + /// and the demand has to be measured the same way the drawing is. pub fn viewport_wanted(&self, width: u16, rows: u16) -> u16 { - if self.mode == Mode::Running || self.modal() { + crate::term::viewport_for(self.rows_wanted(width), rows) + } + + /// The rows the surface currently on screen would like, before any ceiling. + fn rows_wanted(&self, width: u16) -> u16 { + // An approval keeps the floor deliberately. It is answered with one of + // three keys, it has carried its own measured elision since 0.2.0, and it + // is the one modal surface with nothing to type into — so there is nothing + // for extra rows to buy that its own suffix does not already say. + if self.approval.is_some() { return VIEWPORT_HEIGHT; } - let wanted = self.composer.rows_wanted(width).min(COMPOSER_MAX); - VIEWPORT_HEIGHT - .saturating_add(wanted.saturating_sub(COMPOSER_ROWS)) - .min(rows.saturating_sub(2).max(VIEWPORT_HEIGHT)) + if let Some(open) = &self.intent { + return open.rows_wanted(width, &self.theme); + } + if let Some(open) = &self.plan { + return open.rows_wanted(width, &self.theme); + } + // The ordinary session: the chrome it always draws, whatever the prompt + // needs, and whatever the queue is holding. + // + // The chrome is the streaming row, the blank above the activity line, the + // activity line, the rule, and three rows of footer — and the blank is + // released while the queue is open, which is where the queue's one visible + // row has come from since 0.17.0. With the viewport able to grow, the + // queue asks for its rows outright instead, and `queue::rows_for`'s + // `room == 1` collapse stops being what every real session sees. + let composer = self + .composer + .rows_wanted(width) + .min(COMPOSER_MAX) + .max(COMPOSER_ROWS); + let queued = if self.queue_open() { + u16::try_from(self.prompts.len()).unwrap_or(u16::MAX) + } else { + 0 + }; + let chrome = 6 + u16::from(queued == 0); + chrome.saturating_add(composer).saturating_add(queued) } /// Start a new conversation, or refuse because a turn is in flight. @@ -1613,11 +1672,6 @@ impl App { lines } - /// Rows the viewport uses. Fixed — see [`VIEWPORT_HEIGHT`] for why, and for - /// what that costs. - pub fn viewport_height(&self) -> u16 { - VIEWPORT_HEIGHT - } /// What a keystroke means to the session. /// @@ -2056,7 +2110,7 @@ impl App { /// /// Content before metadata, top to bottom, so a reader reaches the model's /// words before the token count. - pub fn render(&self, frame: &mut Frame, area: Rect) { + pub fn render(&mut self, frame: &mut Frame, area: Rect) { if area.height == 0 { return; } @@ -2069,7 +2123,12 @@ impl App { } // The same rule for the same reason: the run is stopped waiting on prose, // so there is nothing behind this worth half a screen. - if let Some(open) = &self.intent { + // + // Taken mutably since 0.32.0: the overlay's offers are a `Picker`, which + // scrolls to its selection as it draws and records where it reserved the + // rows the composer unfolds into. Only this overlay needs it, and the + // borrows are disjoint fields. + if let Some(open) = &mut self.intent { open.render(frame, area, &self.theme); return; } diff --git a/src/term.rs b/src/term.rs index 13776ec..7786f76 100644 --- a/src/term.rs +++ b/src/term.rs @@ -121,6 +121,26 @@ pub const VIEWPORT_HEIGHT: u16 = 8; /// known. pub const SCROLLBACK_RESERVE: u16 = 4; +/// The viewport height a surface asking for `asked` rows should actually get on a +/// terminal of `terminal_rows`. +/// +/// **The one place the ceiling is applied**, so the session's own sizing and the +/// driver's picker path cannot disagree about it — which is exactly the "two +/// things sizing the same viewport for different reasons" the release was warned +/// about. [`crate::app::App::viewport_wanted`] is the one owner of the *demand*; +/// this is the one owner of the *limit*. +/// +/// Growth is a request rather than a guarantee. A surface that asks for more than +/// the terminal can spare gets what there is and degrades — every one of them +/// elides with a count — and a surface asking for less than the floor still gets +/// the floor, because [`VIEWPORT_HEIGHT`] is what a session needs to be a session. +pub fn viewport_for(asked: u16, terminal_rows: u16) -> u16 { + let ceiling = terminal_rows + .saturating_sub(SCROLLBACK_RESERVE) + .max(VIEWPORT_HEIGHT); + asked.clamp(VIEWPORT_HEIGHT, ceiling) +} + /// Rows the wizard's viewport occupies. /// /// Much taller than the session's, and it has to be: the wizard's screens are diff --git a/tests/interrupt.rs b/tests/interrupt.rs index 9c471b2..e5413c7 100644 --- a/tests/interrupt.rs +++ b/tests/interrupt.rs @@ -225,8 +225,12 @@ fn f9_a_streaming_answer_of_any_length_leaves_the_viewport_the_same_size() { // because each line commits to the terminal's own scrollback as it finishes. // Only the unfinished tail is ever live, so there is nothing for the viewport // to grow to hold. + // Asked through `viewport_wanted`, which is the demand the driver acts on. + // Since 0.32.0 the viewport does grow — for a question, a plan, a queue or a + // picker — so "it does not move" has to be asserted against the number that + // can now move, rather than against a constant that never could. let mut app = App::new(DARK, "m"); - let quiet = app.viewport_height(); + let quiet = app.viewport_wanted(80, 40); assert!(quiet >= 3, "streaming tail, composer and status line"); let mut committed = 0; @@ -237,12 +241,16 @@ fn f9_a_streaming_answer_of_any_length_leaves_the_viewport_the_same_size() { committed, 200, "every finished line should have been committed as it arrived", ); - assert_eq!(app.viewport_height(), quiet, "the viewport did not move"); + assert_eq!(app.viewport_wanted(80, 40), quiet, "the viewport did not move"); // A partial line stays live until something finishes it. app_lines(&mut app, "the tail with no newline yet"); assert_eq!(app.events.live(), "the tail with no newline yet"); - assert_eq!(app.viewport_height(), quiet); + assert_eq!( + app.viewport_wanted(80, 40), + quiet, + "a live tail is not a surface asking for rows", + ); app.finished(); assert_eq!(app.events.live(), ""); diff --git a/tests/queue_surface.rs b/tests/queue_surface.rs index 32be9d6..5933c44 100644 --- a/tests/queue_surface.rs +++ b/tests/queue_surface.rs @@ -96,7 +96,7 @@ fn with_queue(count: usize) -> App { } /// The viewport's rows as text, blanks kept: a row's *index* is the assertion. -fn rows_at(app: &App, viewport: u16) -> Vec { +fn rows_at(app: &mut App, viewport: u16) -> Vec { let (mut screen, _recorder) = support::screen_of(80, 40, viewport); screen .draw(|frame| app.render(frame, frame.area())) @@ -134,10 +134,10 @@ const TALL: u16 = 12; /// sent. #[test] fn f2_the_queued_lines_are_drawn_above_the_composer_in_send_order() { - let app = with_queue(3); + let mut app = with_queue(3); assert!(app.queue_open(), "queueing a line opens the surface"); - let rows = rows_at(&app, TALL); + let rows = rows_at(&mut app,TALL); let first = row_of(&rows, "queued prompt 0"); let second = row_of(&rows, "queued prompt 1"); let third = row_of(&rows, "queued prompt 2"); @@ -207,7 +207,7 @@ fn f2_esc_closes_the_surface_and_leaves_the_turn_running() { "closing the view is not dropping the queue — the lines still run", ); - let rows = rows_at(&app, TALL); + let rows = rows_at(&mut app,TALL); assert!( !rows.iter().any(|row| row.contains("queued prompt 0")), "a closed surface draws nothing: {rows:#?}" @@ -282,7 +282,7 @@ fn f2_the_surface_draws_in_the_ascii_glyph_set_with_no_box_drawing_character() { send(&mut app, &format!("queued prompt {at}")); } - let rows = rows_at(&app, TALL); + let rows = rows_at(&mut app,TALL); let first = row_of(&rows, "queued prompt 0"); let composer = row_of(&rows, io_cli::composer::PROMPT.trim_end()); let drawn = rows[first..composer].join("\n"); @@ -309,57 +309,73 @@ fn f2_the_surface_draws_in_the_ascii_glyph_set_with_no_box_drawing_character() { } // --------------------------------------------------------------------------- -// N2 — the frame does not grow +// O7 — the queue asks for the rows it needs, and gives them back // --------------------------------------------------------------------------- -/// N2's sabotage arm: growing the viewport by one row per queued line. +/// **This test used to assert the opposite, and 0.32.0 reversed it on purpose.** +/// +/// 0.17.0 shipped the queue under a fixed viewport, so its whole surface had to +/// fit in rows the layout could already spare — which came to exactly one, by +/// releasing the blank above the activity line. Four queued messages therefore +/// collapsed to `1. … 3 more`, and the old `n2_a_queue_of_any_depth_leaves_the_ +/// frame_the_same_height_as_an_empty_one` guarded that: it asserted the session +/// never asked the terminal for another row, because doing so was "the scrollback +/// being walked upward by its own queue". /// -/// **This is the test that kills it.** Every route to the frame's height is -/// asserted, because the sabotage can be written at any of them: the fixed -/// height the driver asks for between turns, the height it asks for while a turn -/// is in flight, and the rows the frame actually draws into. +/// The scrollback is still not walked — that is what `tests/viewport.rs` asserts +/// over a growth, a shrink and a resize, against the byte stream — so the reason +/// the constraint existed is now discharged somewhere it can actually be seen. +/// What is left is the cost it was buying, which was the operator not being able +/// to see what they had queued. #[test] -fn n2_a_queue_of_any_depth_leaves_the_frame_the_same_height_as_an_empty_one() { - let empty = with_queue(0); - let quiet = empty.viewport_height(); - let wanted = empty.viewport_wanted(80, 40); - let rows = rows_at(&empty, wanted).len(); - - for depth in [1, 2, 3, 9, 40] { +fn o7_the_queue_asks_for_one_row_per_message_and_returns_them_when_it_empties() { + let mut empty = with_queue(0); + let floor = empty.viewport_wanted(80, 40); + + // **The first queued message is free and the rest are not**, which is not an + // off-by-one: the layout releases the blank row above the activity line while + // the queue is open, and that released row is exactly where 0.17.0's single + // visible line came from. So one message still fits in the floor, and every + // message after it is a row the viewport has to grow by. + for depth in [1, 2, 3, 9] { let app = with_queue(depth); assert_eq!( app.queued_prompts().len(), depth, "the fixture queued what it meant to", ); - assert_eq!( - app.viewport_height(), - quiet, - "{depth} queued lines moved the viewport's own height", - ); assert_eq!( app.viewport_wanted(80, 40), - wanted, - "{depth} queued lines made the session ask the terminal for more rows \ - — which is the scrollback being walked upward by its own queue", - ); - // Through the height the session itself asks for, not the one this test - // measured off the empty queue: a frame drawn into a viewport the - // sabotage grew is the row an operator actually loses. - assert_eq!( - rows_at(&app, app.viewport_wanted(80, 40)).len(), - rows, - "{depth} queued lines drew outside the frame", + floor + u16::try_from(depth).expect("a small depth") - 1, + "{depth} queued lines asked for the wrong number of rows", ); } + + // **And the growth is bounded by the terminal, not by the queue.** Forty + // queued messages on a forty-row terminal cannot have forty rows; what they + // get is the ceiling, and `queue::rows_for`'s elision reports the rest. + let flooded = with_queue(40).viewport_wanted(80, 40); + assert_eq!( + flooded, + 40 - io_cli::term::SCROLLBACK_RESERVE, + "the queue took rows the conversation above it needs", + ); + + // Emptied, the session returns to where it started. + empty.forget_queued_prompts(); + assert_eq!( + empty.viewport_wanted(80, 40), + floor, + "the rows were not given back when the queue emptied", + ); } /// N2 — the rows come out of the composer's allowance, so a queue deeper than /// the rows available takes no more of them. #[test] fn n2_the_queue_takes_the_composers_spare_rows_and_never_the_composers_own() { - let shallow = rows_at(&with_queue(1), TALL); - let deep = rows_at(&with_queue(40), TALL); + let shallow = rows_at(&mut with_queue(1), TALL); + let deep = rows_at(&mut with_queue(40), TALL); assert_eq!(shallow.len(), deep.len(), "the frame is the frame"); let prompt = io_cli::composer::PROMPT.trim_end(); @@ -399,11 +415,11 @@ fn n2_the_queue_takes_the_composers_spare_rows_and_never_the_composers_own() { /// answer the status line already carries. #[test] fn n2_the_surface_is_visible_at_the_running_viewport_and_costs_it_no_height() { - let app = with_queue(3); + let mut app = with_queue(3); assert!(app.queue_open(), "queueing a line opens the surface"); let height = io_cli::term::VIEWPORT_HEIGHT; - let rows = rows_at(&app, height); + let rows = rows_at(&mut app,height); let queued = row_of(&rows, "queued prompt 0"); let prompt = row_of(&rows, io_cli::composer::PROMPT.trim_end()); assert!( @@ -417,7 +433,7 @@ fn n2_the_surface_is_visible_at_the_running_viewport_and_costs_it_no_height() { ); // The blank is what it took, so the frame is the height it always was. - let quiet = rows_at(&with_queue(0), height); + let quiet = rows_at(&mut with_queue(0), height); assert_eq!( rows.len(), quiet.len(), @@ -439,7 +455,7 @@ fn n2_the_queue_holds_at_eighty_columns_inside_the_synchronized_pair() { // not drawn at all — see `tests/frames.rs` — and a skipped frame would read // here as a missing wrapper. for depth in [1, 7] { - let app = with_queue(depth); + let mut app = with_queue(depth); screen .draw(|frame| app.render(frame, frame.area())) .expect("frame"); @@ -597,10 +613,10 @@ fn the_count_under_the_window_is_what_is_under_the_window() { #[test] fn a_queue_behind_an_open_fleet_view_takes_no_row_from_the_layout() { let mut app = with_queue(2); - let queued = rows_at(&app, TALL); + let queued = rows_at(&mut app,TALL); app.toggle_fleet(); - let fleeted = rows_at(&app, TALL); + let fleeted = rows_at(&mut app,TALL); assert_eq!( queued.len(), fleeted.len(), diff --git a/tests/viewport.rs b/tests/viewport.rs index bf03dff..ce56c6a 100644 --- a/tests/viewport.rs +++ b/tests/viewport.rs @@ -215,3 +215,176 @@ fn o17_a_viewport_never_exceeds_what_the_terminal_can_give() { ); each_committed_once(&recorder, &["committed-on-a-small-terminal"]); } + +// --------------------------------------------------------------------------- +// O16 — the viewport is the size of the surface on it, per surface. +// --------------------------------------------------------------------------- + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use io_cli::app::{App, Command}; +use io_cli::term::SCROLLBACK_RESERVE; +use io_cli::theme::DARK; +use io_harness::{EventKind, Question, RunEvent}; + +/// A terminal big enough that nothing here is testing the ceiling. +const TERMINAL: u16 = 40; + +fn app() -> App { + App::new(DARK, "a-model") +} + +fn running() -> App { + let mut app = app(); + app.started(); + app.event( + &RunEvent::new( + 1, + 1, + EventKind::Started { + goal: "count the tests".into(), + provider: "test".into(), + }, + ), + std::time::Duration::ZERO, + ); + app +} + +fn press(app: &mut App, code: KeyCode) -> Command { + app.key(KeyEvent::new(code, KeyModifiers::NONE)) +} + +/// The question that filled the whole viewport through 0.31.0. +fn five_choices() -> Question { + Question { + question: "which column should the migration drop?".to_string(), + context: Some("the table has 40 rows and one caller".to_string()), + choices: vec![ + "created_at".to_string(), + "updated_at".to_string(), + "deleted_at".to_string(), + "archived_at".to_string(), + "expired_at".to_string(), + ], + } +} + +/// **O16 — the question overlay.** +/// +/// It is asserted per surface, not once, because one of them working proves +/// nothing about the others: each asks through a different `rows_wanted`, and the +/// two guards this release removed — the `Mode::Running` early return and the +/// `modal()` one — hid all four behind the same two lines. +#[test] +fn o16_the_question_overlay_grows_the_viewport_and_gives_it_back() { + let mut app = running(); + let floor = app.viewport_wanted(80, TERMINAL); + assert_eq!( + floor, VIEWPORT_HEIGHT, + "a running turn with nothing open sits at the floor", + ); + + let (answer, _reply) = tokio::sync::oneshot::channel(); + app.open_intent(io_cli::intent::Asked { + question: five_choices(), + answer, + }); + let asking = app.viewport_wanted(80, TERMINAL); + assert!( + asking > floor, + "the question asked for no more rows than an idle prompt: {asking}", + ); + + // Answered, and the rows go back. + press(&mut app, KeyCode::Esc); + assert_eq!( + app.viewport_wanted(80, TERMINAL), + floor, + "the viewport kept the rows after the surface closed", + ); +} + +/// **O16 — the queue.** Four queued messages ask for four rows, which is O7's +/// "all four are listed" expressed as the demand that makes it possible. +#[test] +fn o16_the_queue_grows_the_viewport_by_what_it_is_holding() { + let mut shallow = running(); + shallow.queue_prompt("the first"); + let one = shallow.viewport_wanted(80, TERMINAL); + + let mut deep = running(); + for at in 0..4 { + deep.queue_prompt(format!("queued prompt {at}")); + } + let four = deep.viewport_wanted(80, TERMINAL); + + assert!( + four > one, + "four queued messages asked for no more rows than one: {four} against {one}", + ); + assert_eq!( + four - one, + 3, + "each queued message is worth exactly one row", + ); +} + +/// **O16 — a picker.** Its demand does not come from `App` at all: a picker is +/// drawn *instead of* the session, so the driver sizes for it directly. Both +/// paths clamp through `term::viewport_for`, which is the single ceiling. +#[test] +fn o16_a_picker_asks_for_its_own_rows_through_the_one_ceiling() { + let short = io_cli::picker::Picker::new( + "Which command?", + (0..3) + .map(|n| io_cli::picker::Row::new(format!("row {n}"))) + .collect(), + ); + let long = io_cli::picker::Picker::new( + "Which command?", + (0..30) + .map(|n| io_cli::picker::Row::new(format!("row {n}"))) + .collect(), + ); + + assert!( + long.rows_wanted() > short.rows_wanted(), + "a thirty-row picker asked for no more than a three-row one", + ); + assert_eq!( + io_cli::term::viewport_for(short.rows_wanted(), TERMINAL), + VIEWPORT_HEIGHT, + "a picker smaller than the floor still gets the floor", + ); + assert!( + io_cli::term::viewport_for(long.rows_wanted(), TERMINAL) > VIEWPORT_HEIGHT, + "a picker larger than the floor is given rows", + ); +} + +/// **O17 — the ceiling is the terminal less the rows the conversation keeps.** +/// +/// The bound is not a ration. The developer's decision is that a surface may take +/// the screen when it needs it; what it may not take is the sight of the exchange +/// it is about. +#[test] +fn o17_growth_stops_short_of_the_conversation() { + // A surface asking for far more than the terminal holds. + let taken = io_cli::term::viewport_for(1_000, TERMINAL); + assert_eq!( + taken, + TERMINAL - SCROLLBACK_RESERVE, + "growth did not stop at the reserve", + ); + + // On the product's supported floor, the reserve still holds and the result is + // still a usable viewport rather than a degraded one. + let narrow = io_cli::term::viewport_for(1_000, 24); + assert_eq!(narrow, 20, "80x24 should give a twenty-row viewport"); + assert!(narrow >= VIEWPORT_HEIGHT); + + // And a terminal too small for the reserve keeps a session rather than losing + // one: the floor wins over the ceiling, and `Screen::attach_with` clamps + // underneath both. + assert_eq!(io_cli::term::viewport_for(1_000, 6), VIEWPORT_HEIGHT); +} From b3959a44a2f51f6f0b6251b1c2d7629402af7e01 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 30 Aug 2026 19:53:43 +0530 Subject: [PATCH 04/21] feat(intent): the question becomes answerable, as one list with no modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The operator saw a question, some inert bullets, and nothing to type into. `Intent::render` drew the composer only `if area.height > head`, and a question with a context line and five choices filled all eight rows — so on a real session the text area was never rendered at all. The offers and the free-text answer are now rows of the same `Picker`. `Enter` on an offer sends that string verbatim, from the caller's own unfiltered rows rather than re-read off the screen. Taking the marker to the last row unfolds the composer directly beneath it, and `Enter` there sends what was typed. One marker, one list, both ways to answer. The unfold is a general `Picker` mechanic — a row may open a block beneath itself while it holds the marker, and the widget records where it put it, because only the widget knows where the row landed once the list has scrolled. It ships with its one caller rather than ahead of one. Typing is always the answer and the marker follows it. That is the one place this surface differs from every other picker, and it is deliberate: filtering is worth something over four hundred models and nothing over five offers, where the expensive act is answering. The marker opens on the free-text row, never on the agent's first offer. An overlay that opened with an offer marked would turn a reflexive Enter — the key just pressed to submit the prompt that started the turn — into silent agreement with a suggestion nobody had read. `Tab` accepts the marked row and `Shift+Tab` steps back, in every picker in the product. Both spellings of shift-tab are matched, because a picker reads raw key events and never goes through the binding table. Until now both fell into the catch-all and did nothing. A picker also says how many rows it did not draw. Thirty-six commands in eight rows drew five and said nothing, so a list filtered to nothing looked like one scrolled to its end. --- src/intent.rs | 228 ++++++++++++++++++++++++++++++++++------- src/picker.rs | 167 ++++++++++++++++++++++++++++++- tests/intent.rs | 256 +++++++++++++++++++++++++++++++++++++++++++++-- tests/palette.rs | 71 ++++++++++--- 4 files changed, 664 insertions(+), 58 deletions(-) diff --git a/src/intent.rs b/src/intent.rs index a512a7f..0bfa0b0 100644 --- a/src/intent.rs +++ b/src/intent.rs @@ -31,6 +31,7 @@ use ratatui::Frame; use tokio::sync::{mpsc, oneshot}; use crate::composer::{Composer, Reply}; +use crate::picker::{Outcome, Picker, Row}; use crate::theme::{Theme, Tone}; /// One question, and the channel its answer goes back down. @@ -119,24 +120,81 @@ impl Destination { } } +/// What the last row says. The offers are the agent's words; this one is the +/// product's, and it has to read as a peer of them rather than as a way out. +const OWN_WORDS: &str = "answer in your own words"; + /// The overlay a question is asked through, live or resumed. +/// +/// # One list, no modes (0.32.0) +/// +/// The offers and the free-text answer are rows of the same [`Picker`], not two +/// panes with a key that moves between them. `Enter` on an offer sends it +/// verbatim; taking the marker to the last row unfolds the [`Composer`] directly +/// beneath it, and `Enter` there sends what was typed. One marker, one list, one +/// interaction covering both ways to answer a question — and a question with no +/// choices at all is that same list with one row, already unfolded, which is the +/// surface this overlay had before. +/// +/// The unfold is a general `Picker` mechanic and this is its only caller. That is +/// deliberate: 0.31.0 shipped an entire pipeline reachable from nothing but its own +/// tests, and a mode built before anything needs it is the same defect wearing a +/// different hat. pub struct Intent { question: Question, answer: Destination>, composer: Composer, + /// The offers and the free-text row, as one list. + offers: Picker, } impl Intent { /// Open on a question. The composer starts empty: an answer pre-filled with /// one of the agent's own choices is an answer the operator did not give. pub fn new(asked: Asked) -> Self { + let offers = Self::list(&asked.question); Self { question: asked.question, answer: Destination::Turn(asked.answer), composer: Composer::new(), + offers, } } + /// The offers, then the row that is not one. + /// + /// The free-text row is **last and is a peer**, not a footer: it is chosen the + /// way every other row is chosen, and io-harness's own documentation says an + /// answer is not obliged to be one of `choices`, so the surface should not + /// imply otherwise. Its index is `choices.len()`, which is what + /// [`Self::free_text`] resolves and what the unfold is keyed on. + fn list(question: &Question) -> Picker { + let mut rows: Vec = question + .choices + .iter() + .map(|choice| Row::new(choice.clone())) + .collect(); + rows.push(Row::new(OWN_WORDS)); + let free = rows.len() - 1; + let mut offers = Picker::new("choose an answer, or write one", rows); + offers.set_unfold(free, crate::app::COMPOSER_ROWS); + // **The marker opens on the free-text row, never on the agent's first + // offer**, and that is a safety property rather than a preference. An + // overlay that opens with an offer marked turns a reflexive `Enter` — the + // key an operator has just pressed to submit the prompt that started this + // turn — into silent agreement with a suggestion they have not read. The + // product already refuses to pre-fill the composer with a choice for the + // same reason: an answer the operator did not give. + // + // It also keeps the surface this overlay has always had. Opening here + // means an untouched overlay is a composer with a question above it, + // `Enter` on it is still the mis-key it has always been, and a question + // with no choices at all is unchanged in every respect. + offers.focus(free); + offers + } + + /// Open on a question a run already paused on, read back off the store. /// /// The row carries the answer and who gave it as well; neither is a question, @@ -144,14 +202,17 @@ impl Intent { /// again, and refusing to open on one is the caller's job, which is the only /// place that knows whether it is resuming or replaying. pub fn resumed(pending: &PendingQuestion) -> Self { + let question = Question { + question: pending.question.clone(), + context: pending.context.clone(), + choices: pending.choices.clone(), + }; + let offers = Self::list(&question); Self { - question: Question { - question: pending.question.clone(), - context: pending.context.clone(), - choices: pending.choices.clone(), - }, + question, answer: Destination::Stored, composer: Composer::new(), + offers, } } @@ -167,9 +228,70 @@ impl Intent { /// mis-key, and answering the agent with an empty string would send it back /// to work with no more information than it had. pub fn key(&mut self, key: crossterm::event::KeyEvent) -> Option> { - if key.code == crossterm::event::KeyCode::Esc { + use crossterm::event::KeyCode; + + if key.code == KeyCode::Esc { return Some(None); } + // **Movement always belongs to the list**, or the composer would be a room + // with no door out. + let modifiers = key.modifiers; + if matches!( + key.code, + KeyCode::Up | KeyCode::Down | KeyCode::Home | KeyCode::End | KeyCode::BackTab + ) || (key.code == KeyCode::Tab + && modifiers.contains(crossterm::event::KeyModifiers::SHIFT)) + { + self.offers.key(key); + return None; + } + + // **Typing is always the answer, and the marker follows it.** This is the + // one place the surface differs from every other `Picker` in the product, + // and it is deliberate: a picker's printable keys filter its rows, which is + // right for four hundred models and wrong for five offers. Here the + // expensive act is answering, not finding — so a character moves the + // marker to the row that takes prose and goes into the composer, and an + // operator who simply starts typing gets what they meant with nothing to + // press first. It also keeps the surface this overlay has always had: + // before 0.32.0 every keystroke went to a composer, and a question with no + // choices still behaves exactly that way. + if matches!(key.code, KeyCode::Char(_)) + && !modifiers.contains(crossterm::event::KeyModifiers::CONTROL) + && !modifiers.contains(crossterm::event::KeyModifiers::ALT) + { + self.offers.focus(self.question.choices.len()); + return self.typed(key); + } + + // `Enter` and `Tab` take what is under the marker. On the free-text row + // that is whatever has been typed into it; on an offer it is the offer. + if self.offers.unfolded_now() { + return self.typed(key); + } + match self.offers.key(key) { + // The offer verbatim. Not the row's label re-read off the screen and + // not a fitted copy of it: the string the agent sent is the string it + // gets back, which is the whole reason `Outcome::Chosen` indexes the + // caller's own unfiltered rows. + Outcome::Chosen(index) => self + .question + .choices + .get(index) + .map(|choice| Some(choice.clone())), + // `Ctrl+C` inside the list. It means what `Esc` means here — nobody is + // answering — and `None` parks the run rather than denying it. + Outcome::Cancelled => Some(None), + Outcome::Idle => None, + } + } + + /// A keystroke that belongs to the composer. + /// + /// An empty prompt submits nothing at all — `Enter` on an empty line is a + /// mis-key, and answering the agent with an empty string would send it back to + /// work knowing no more than it did. + fn typed(&mut self, key: crossterm::event::KeyEvent) -> Option> { match self.composer.key(key) { Reply::Submitted(text) if !text.trim().is_empty() => Some(Some(text)), _ => None, @@ -200,18 +322,66 @@ impl Intent { /// leaves the run parked exactly as it was found — the operator opened it, so /// "for later" would be a promise nothing behind the screen is keeping. That /// difference is a word chosen here, not a second `render`. - pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { - if area.height == 0 { + pub fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) { + if area.height == 0 || area.width == 0 { + return; + } + let lines = self.head(theme); + // **Measured, not counted.** Through 0.31.0 this was `lines.len()` while + // the paragraph below wraps, so a question or a context line longer than + // the terminal is wide took more rows than the count admitted — the offers + // fell off the bottom, and the composer was then drawn on top of rows the + // paragraph had already painted. `rows::wrapped` asks ratatui's own + // wrapper, so the two cannot disagree. + let head = crate::rows::wrapped(&lines, area.width).min(area.height); + frame.render_widget( + Paragraph::new(lines).wrap(ratatui::widgets::Wrap { trim: false }), + Rect { + height: head, + ..area + }, + ); + + // **The offers and the composer share the rest, and the list gives the + // composer its rows rather than competing for them.** There is no + // `if area.height > head` here: with the viewport sized to what this + // overlay asked for there is room, and on a terminal too small for that + // the picker elides its own rows with a count rather than silently + // dropping the one thing the operator can type into. + let Some(rest) = area.height.checked_sub(head).filter(|rest| *rest > 0) else { return; + }; + let below = Rect { + y: area.y.saturating_add(head), + height: rest, + ..area + }; + self.offers.render(frame, below, theme); + if let Some(open) = self.offers.opened() { + self.composer.render(frame, open, theme); } + } + + /// The question, its context, and the line naming the keys. + /// + /// One line of it depends on which way in was taken, and it is the only one: + /// declining a live question defers it *within* a turn that is still running + /// and will carry on the moment it is answered, while declining a resumed one + /// leaves the run parked exactly as it was found — the operator opened it, so + /// "for later" would be a promise nothing behind the screen is keeping. That + /// difference is a word chosen here, not a second `render`. + fn head(&self, theme: &Theme) -> Vec> { let mut lines: Vec> = Vec::new(); - lines.push(theme.notice(Tone::Warning, self.question.question.clone())); + // **`Tone::Accent`, not `Tone::Warning`.** `Tone::Warning`'s word is + // literally `warning`, so every question this agent asked was prefixed + // with it — and `Tone::Refused`'s own doc keeps the vocabulary honest: + // these tones mean something, and a question is not a warning. It is the + // product's own colour because it is the product asking for the operator's + // attention, which is exactly what the prompt marker uses it for. + lines.push(theme.notice(Tone::Accent, self.question.question.clone())); if let Some(context) = &self.question.context { lines.push(theme.notice(Tone::Muted, context.clone())); } - for choice in &self.question.choices { - lines.push(theme.notice(Tone::Muted, format!("{} {choice}", theme.glyphs.bullet))); - } let leaves = if self.answer.parked() { "Esc leaves the run parked" } else { @@ -220,31 +390,21 @@ impl Intent { lines.push(theme.notice( Tone::Muted, format!( - "type an answer {} Enter sends it {} {leaves}", + "Enter sends the marked row {} Tab too {} {leaves}", theme.glyphs.dash, theme.glyphs.dash ), )); + lines + } - let head = u16::try_from(lines.len()) - .unwrap_or(u16::MAX) - .min(area.height); - frame.render_widget( - Paragraph::new(lines).wrap(ratatui::widgets::Wrap { trim: false }), - Rect { - height: head, - ..area - }, - ); - if area.height > head { - self.composer.render( - frame, - Rect { - y: area.y + head, - height: area.height - head, - ..area - }, - theme, - ); - } + /// Rows this overlay would like the viewport to be. + /// + /// Its head as it will actually wrap, every offer, the free-text row, the + /// composer unfolded beneath it, and the picker's own head row. A request: + /// [`crate::app::App::viewport_wanted`] clamps it to what the terminal can + /// spare, and [`Self::render`] degrades against whatever it is given. + pub fn rows_wanted(&self, width: u16, theme: &Theme) -> u16 { + crate::rows::wrapped(&self.head(theme), width) + .saturating_add(self.offers.rows_wanted()) } } diff --git a/src/picker.rs b/src/picker.rs index 7f35891..f14e5e9 100644 --- a/src/picker.rs +++ b/src/picker.rs @@ -161,6 +161,27 @@ pub struct Picker { intent: Option, /// The first row currently drawn, so a long list scrolls instead of being cut. offset: usize, + /// A row that opens a block beneath itself while it holds the marker, and the + /// rows that block wants. + /// + /// **The index is into `rows`, like [`Self::intent`] and never like + /// [`Self::cursor`]**, so a query that reorders the list keeps the unfold on + /// the row it belongs to rather than on whatever ranked into that position. + /// + /// Only one row can carry it, which is the whole mechanic: an accordion with + /// two panels open is two things competing for the rows beneath one marker. + /// 0.32.0 ships it with exactly one caller — the question overlay's free-text + /// row, which unfolds a composer — because a mechanic built without one is a + /// mode nobody can see, and this product shipped a whole pipeline reachable + /// only from its own tests one release ago. + unfold: Option<(usize, u16)>, + /// Where the unfolded block was drawn by the last [`Self::render`], for the + /// caller to draw into. + /// + /// Set by rendering rather than computed by the caller, because only this + /// widget knows where the row landed after scrolling — and a caller doing that + /// arithmetic itself is a second implementation of the scroll. + opened: Option, } impl Picker { @@ -173,6 +194,8 @@ impl Picker { query: String::new(), cursor: 0, offset: 0, + unfold: None, + opened: None, }; // A grouped list opens with a heading in the first slot, and the marker // may not rest on one. Stepping here rather than only in `refilter` @@ -335,6 +358,66 @@ impl Picker { Outcome::Idle } + /// Let the row at `row` open a block of `height` rows beneath itself whenever + /// it holds the marker. + /// + /// `row` indexes the caller's own `rows`, the same as [`Outcome::Chosen`]. + pub fn set_unfold(&mut self, row: usize, height: u16) { + self.unfold = Some((row, height)); + } + + /// Put the marker on `row`, an index into the caller's own `rows`. + /// + /// Answers whether it could: a query that hides the row leaves the marker + /// where it was rather than moving it somewhere arbitrary. + pub fn focus(&mut self, row: usize) -> bool { + match self.matches.iter().position(|index| *index == row) { + Some(at) => { + self.cursor = at; + self.intent = Some(row); + true + } + None => false, + } + } + + /// Whether the unfolding row is the one under the marker right now. + /// + /// The caller asks this to decide where a keystroke goes: with the block open + /// it belongs to whatever is drawn in it, and with it shut the picker filters + /// as it always has. + pub fn unfolded_now(&self) -> bool { + match (self.unfold, self.matches.get(self.cursor)) { + (Some((row, height)), Some(under)) => height > 0 && row == *under, + _ => false, + } + } + + /// Where the unfolded block was drawn by the last [`Self::render`]. + /// + /// `None` when nothing is unfolded, or when the terminal had no room for it — + /// growth is a request rather than a guarantee, and a caller that drew into a + /// rectangle this widget never reserved would paint over the list. + pub fn opened(&self) -> Option { + self.opened + } + + /// Rows this picker would like: its head, every row it holds, and whatever an + /// unfolded block is asking for. + /// + /// A request, not a demand — [`crate::app::App::viewport_wanted`] clamps it to + /// what the terminal can spare, and [`Self::render`] elides against whatever it + /// is actually given. + pub fn rows_wanted(&self) -> u16 { + let list = u16::try_from(self.matches.len()).unwrap_or(u16::MAX); + let block = if self.unfolded_now() { + self.unfold.map_or(0, |(_, height)| height) + } else { + 0 + }; + list.saturating_add(block).saturating_add(1) + } + pub fn key(&mut self, key: KeyEvent) -> Outcome { // `Ctrl+C` leaves, exactly as `Esc` does. The picker owns the keyboard // while it is open, and the shipped keybinding table promises `Ctrl+C` @@ -390,7 +473,29 @@ impl Picker { self.step_off_heading(-1); self.moved() } - KeyCode::Enter => { + // **`Shift+Tab` steps the marker back, and it is spelled twice on + // purpose.** A terminal may send `BackTab`, or `Tab` with the shift + // modifier set; `crate::keys` normalises the first into the second for + // a *binding*, but a picker reads raw key events and never goes + // through that table, so both spellings arrive here as themselves. + // Matching one of them is a key that works on some terminals. + KeyCode::BackTab => { + self.cursor = self.cursor.saturating_sub(1); + self.step_off_heading(-1); + self.moved() + } + KeyCode::Tab if key.modifiers.contains(KeyModifiers::SHIFT) => { + self.cursor = self.cursor.saturating_sub(1); + self.step_off_heading(-1); + self.moved() + } + // **`Tab` accepts what is under the marker**, in every picker in the + // product rather than in the palette alone, because the product ships + // one `Picker`. It is the completion key an operator arrives with, and + // until 0.32.0 it fell into the catch-all below and did nothing at all + // — a key that looks like it should work and silently does not is + // worse than one that is not bound. + KeyCode::Tab | KeyCode::Enter => { // A heading under the marker cannot happen — every path that // moves it steps off one — but `Enter` is the key that would turn // a mistake here into the wrong action, so it declines rather @@ -469,7 +574,26 @@ impl Picker { theme.style(tone), ))]; - let visible = area.height.saturating_sub(1) as usize; + self.opened = None; + // The rows the head and an open block take before any row is drawn. The + // block is reserved rather than borrowed from the list, because a composer + // squeezed into rows the list is also using is the overlap 0.32.0 exists to + // end. + let block = if self.unfolded_now() { + self.unfold.map_or(0, |(_, height)| height) + } else { + 0 + }; + let room = (area.height as usize).saturating_sub(1 + block as usize); + // **A row is spent saying what is not shown, and only when something is + // not.** Until 0.32.0 a picker over four hundred rows drew the twenty it + // had room for and said nothing at all, so a list that had been filtered + // to nothing looked the same as one scrolled to its end. + let visible = if self.matches.len() > room { + room.saturating_sub(1) + } else { + room + }; self.scroll_to_selection(visible); // A query that admits nothing says so. Without this the screen is a query @@ -487,13 +611,15 @@ impl Picker { ))); } + let taken = visible.max(1); + let mut opened_at: Option = None; for (position, row) in self .matches .iter() .map(|index| &self.rows[*index]) .enumerate() .skip(self.offset) - .take(visible.max(1)) + .take(taken) { // A heading is a label and nothing else: no marker, no mark, no // detail, and never under the cursor. It exists only while the list @@ -567,6 +693,41 @@ impl Picker { } } lines.push(Line::from(spans)); + + // **The block opens directly beneath the row that owns it**, which is + // the whole of the accordion: the operator reads top to bottom, and + // what they are typing into sits under the row that says what it is + // for. Reserved as blank rows here and drawn by the caller into the + // rectangle recorded after the loop, because only this widget knows + // where the row landed once the list has scrolled. + if block > 0 && self.unfold.is_some_and(|(row, _)| row == self.matches[position]) { + let top = area.y.saturating_add(u16::try_from(lines.len()).unwrap_or(u16::MAX)); + if top.saturating_add(block) <= area.y.saturating_add(area.height) { + opened_at = Some(Rect { + x: area.x, + y: top, + width: area.width, + height: block, + }); + for _ in 0..block { + lines.push(Line::from("")); + } + } + } + } + self.opened = opened_at; + + // What the list could not show. Counted against everything the query + // admits rather than against what is below the window, because a row + // scrolled off the top is just as absent as one below the bottom. + let drawn = taken.min(self.matches.len().saturating_sub(self.offset.min(self.matches.len()))); + let hidden = self.matches.len().saturating_sub(drawn); + if hidden > 0 { + let note = format!("{} {hidden} more", theme.glyphs.elision); + lines.push(Line::from(Span::styled( + fit(¬e, width, &theme.glyphs), + theme.style(Tone::Muted), + ))); } frame.render_widget(Paragraph::new(lines), area); diff --git a/tests/intent.rs b/tests/intent.rs index 02f527e..933a6de 100644 --- a/tests/intent.rs +++ b/tests/intent.rs @@ -44,7 +44,7 @@ fn app() -> App { /// What the app draws, through a real render buffer rather than a row string — /// the rule this product has paid for three times: a string assertion cannot see /// a widget clip a row. -fn drawn(app: &App, width: u16, height: u16) -> String { +fn drawn(app: &mut App, width: u16, height: u16) -> String { let (mut screen, _recorder) = support::screen_of(width, height, height); screen .draw(|frame| app.render(frame, frame.area())) @@ -185,7 +185,7 @@ fn the_question_its_context_and_its_choices_are_all_drawn() { let mut app = app(); app.open_intent(asked.0); - let screen = drawn(&app, 80, 12); + let screen = drawn(&mut app, 80, 12); assert!(screen.contains("drop the column or keep it?"), "{screen}"); assert!( @@ -223,7 +223,7 @@ async fn the_question_is_readable_and_answerable_with_no_colour() { let mut app = App::new(io_cli::theme::MONO, "test-model"); app.open_intent(questions.recv().await.expect("asked")); - let screen = drawn(&app, 80, 12); + let screen = drawn(&mut app, 80, 12); assert!(screen.contains("drop the column or keep it?"), "{screen}"); assert!(screen.contains("40 rows"), "{screen}"); @@ -284,7 +284,7 @@ fn live(question: Question) -> (Intent, tokio::sync::oneshot::Receiver String { +fn drawn_overlay(overlay: &mut Intent, theme: &io_cli::theme::Theme) -> String { let (mut screen, _recorder) = support::screen_of(80, 12, 12); screen .draw(|frame| overlay.render(frame, frame.area(), theme)) @@ -296,7 +296,7 @@ fn drawn_overlay(overlay: &Intent, theme: &io_cli::theme::Theme) -> String { /// was taken. Run against both constructors, because a property asserted for a /// live question and not a stored one is how the two paths drift apart. fn the_properties_both_paths_owe(open: impl Fn() -> Intent) { - let screen = drawn_overlay(&open(), &DARK); + let screen = drawn_overlay(&mut open(), &DARK); assert!(screen.contains("drop the column or keep it?"), "{screen}"); assert!( screen.contains("40 rows and one caller"), @@ -406,8 +406,8 @@ fn esc_on_a_stored_question_leaves_the_run_parked_with_no_answer() { /// neither promises something behind the screen is not doing. #[test] fn the_footer_says_what_esc_actually_leaves_behind_on_each_path() { - let live_screen = drawn_overlay(&live(question()).0, &DARK); - let stored_screen = drawn_overlay(&Intent::resumed(&stored(&question())), &DARK); + let live_screen = drawn_overlay(&mut live(question()).0, &DARK); + let stored_screen = drawn_overlay(&mut Intent::resumed(&stored(&question())), &DARK); assert!( live_screen.contains("Esc leaves it for later"), @@ -429,10 +429,248 @@ fn the_footer_says_what_esc_actually_leaves_behind_on_each_path() { /// out all have to be words. #[test] fn a_stored_question_is_readable_with_no_colour_at_all() { - let overlay = Intent::resumed(&stored(&question())); - let screen = drawn_overlay(&overlay, &io_cli::theme::MONO); + let mut overlay = Intent::resumed(&stored(&question())); + let screen = drawn_overlay(&mut overlay, &io_cli::theme::MONO); assert!(screen.contains("drop the column or keep it?"), "{screen}"); assert!(screen.contains("40 rows"), "{screen}"); assert!(screen.contains("Esc"), "{screen}"); } + +// --------------------------------------------------------------------------- +// 0.32.0 — the question becomes answerable, as one list with no modes. +// --------------------------------------------------------------------------- + +/// A question with a context line and five choices: the shape that filled all +/// eight rows of the viewport through 0.31.0 and left the operator with nothing +/// to type into. +fn five_choices() -> Question { + Question { + question: "which column should the migration drop?".to_string(), + context: Some("the table has 40 rows and one caller".to_string()), + choices: vec![ + "created_at".to_string(), + "updated_at".to_string(), + "deleted_at".to_string(), + "archived_at".to_string(), + "expired_at".to_string(), + ], + } +} + +/// **O1 — the answer composer is rendered and focusable, at 80x24 and at 80x8.** +/// +/// This is the release's own defect. `Intent::render` drew the composer only +/// `if area.height > head`, and `head` was `lines.len()` over a paragraph that +/// wraps — so a question with a context line and five choices consumed the whole +/// viewport and the text area was never rendered at all. The operator saw a +/// question, some inert bullets, and no way to answer. +/// +/// Asserted by finding the composer's own prompt marker in a rendered frame, not +/// by the absence of a panic: the old code did not panic, it silently drew +/// nothing. +/// +/// Sabotage: restore `if area.height > head` around the composer render, under +/// which the 80x8 arm goes red and the 80x24 arm does not — which is also the +/// evidence that the small terminal is the case that mattered. +#[test] +fn o1_the_composer_is_drawn_however_long_the_question_is() { + for (width, height) in [(80u16, 24u16), (80, 8)] { + let (mut overlay, _reply) = live(five_choices()); + let (mut screen, _recorder) = support::screen_of(width, height, height); + screen + .draw(|frame| overlay.render(frame, frame.area(), &DARK)) + .expect("a frame"); + let screen = screen.viewport_text().to_string(); + + assert!( + screen.contains(io_cli::composer::PROMPT.trim_end()), + "no composer at {width}x{height}, so the question cannot be answered: {screen}", + ); + } +} + +/// **O1/O2 — the offers and the free-text row are one list, and the marker opens +/// on the row that takes prose.** +/// +/// The second half is a safety property rather than a preference: an overlay that +/// opened with an offer marked would turn a reflexive `Enter` — the key the +/// operator has just pressed to submit the prompt that started this turn — into +/// silent agreement with a suggestion they have not read. +#[test] +fn o2_the_marker_opens_on_the_row_that_takes_prose() { + let (mut overlay, _reply) = live(five_choices()); + assert_eq!( + overlay.key(key(KeyCode::Enter)), + None, + "Enter on an untouched overlay must be a mis-key, never agreement with \ + the agent's first offer", + ); +} + +/// **O2 — `Enter` on a choice sends that string verbatim.** +/// +/// Verbatim matters: the string the agent sent is the string it gets back, never +/// the row's label re-read off the screen and never a fitted copy of it. That is +/// why `Outcome::Chosen` indexes the caller's own unfiltered rows. +#[test] +fn o2_enter_on_an_offer_sends_that_offer_verbatim() { + let (mut overlay, _reply) = live(five_choices()); + + // Up from the free-text row is the last offer. + assert_eq!(overlay.key(key(KeyCode::Up)), None, "moving does not answer"); + assert_eq!( + overlay.key(key(KeyCode::Enter)), + Some(Some("expired_at".to_string())), + "the offer the marker was on, exactly as the agent spelled it", + ); +} + +/// **O2 — typing answers, wherever the marker was, and the marker follows.** +/// +/// The one place this surface differs from every other `Picker` in the product, +/// and it is deliberate: a picker's printable keys filter its rows, which is right +/// for four hundred models and wrong for five offers. Here the expensive act is +/// answering, not finding. +#[test] +fn o2_typing_from_an_offer_answers_rather_than_filtering() { + let (mut overlay, _reply) = live(five_choices()); + + // Put the marker on an offer first, so the typing has somewhere wrong to go. + overlay.key(key(KeyCode::Up)); + for ch in "none of those".chars() { + assert_eq!( + overlay.key(key(KeyCode::Char(ch))), + None, + "typing does not close the overlay", + ); + } + assert_eq!( + overlay.key(key(KeyCode::Enter)), + Some(Some("none of those".to_string())), + "the words the operator typed, not the offer the marker started on", + ); +} + +/// **O2 — moving the marker off the free-text row folds the composer shut without +/// losing what was typed.** +#[test] +fn o2_folding_the_composer_keeps_what_was_typed() { + let (mut overlay, _reply) = live(five_choices()); + for ch in "half written".chars() { + overlay.key(key(KeyCode::Char(ch))); + } + // Up onto an offer, then back down to the free-text row. + overlay.key(key(KeyCode::Up)); + overlay.key(key(KeyCode::Down)); + + assert_eq!( + overlay.key(key(KeyCode::Enter)), + Some(Some("half written".to_string())), + "the composer lost its contents when the marker left it", + ); +} + +/// **O2 — a question with no choices is the free-text row alone, already +/// unfolded**, which is the surface this overlay had before 0.32.0. Asserted +/// because a redesign that changes the simplest case has changed something it was +/// not asked to. +#[test] +fn o2_a_question_with_no_choices_is_the_surface_it_always_was() { + let (mut overlay, _reply) = live(Question::new("what did you mean?")); + let (mut screen, _recorder) = support::screen_of(80, 12, 12); + screen + .draw(|frame| overlay.render(frame, frame.area(), &DARK)) + .expect("a frame"); + let drawn = screen.viewport_text().to_string(); + assert!( + drawn.contains(io_cli::composer::PROMPT.trim_end()), + "the composer is not drawn: {drawn}", + ); + + for ch in "i meant the second one".chars() { + overlay.key(key(KeyCode::Char(ch))); + } + assert_eq!( + overlay.key(key(KeyCode::Enter)), + Some(Some("i meant the second one".to_string())), + ); +} + +/// **O4 — `Esc` still sends `None`, from either row.** +/// +/// Asserted against the existing behaviour rather than re-derived: `None` is what +/// io-harness documents as "nobody here can answer this", so the run parks with +/// the question persisted rather than being denied. A redesign that turned a +/// decline into an empty answer would send the agent back to work knowing nothing +/// more, and would look identical on screen. +#[test] +fn o4_esc_declines_from_the_offers_as_well_as_from_the_composer() { + let (mut from_composer, _a) = live(five_choices()); + assert_eq!(from_composer.key(key(KeyCode::Esc)), Some(None)); + + let (mut from_offers, _b) = live(five_choices()); + from_offers.key(key(KeyCode::Up)); + assert_eq!( + from_offers.key(key(KeyCode::Esc)), + Some(None), + "Esc on an offer declines too, rather than choosing it", + ); +} + +/// **O3 — the question is not drawn as a warning.** +/// +/// `Tone::Warning`'s word is literally `warning`, so every question this agent +/// ever asked arrived prefixed with it. Asserted under `MONO`, where a tone is +/// nothing but its word — which is the only place the claim is checkable at all. +#[test] +fn o3_a_question_is_not_a_warning() { + let (mut overlay, _reply) = live(five_choices()); + let (mut screen, _recorder) = support::screen_of(80, 24, 24); + screen + .draw(|frame| overlay.render(frame, frame.area(), &io_cli::theme::MONO)) + .expect("a frame"); + let drawn = screen.viewport_text().to_string(); + + assert!( + drawn.contains("which column should the migration drop?"), + "{drawn}", + ); + assert!( + !drawn.contains("warning"), + "the question is drawn as a warning: {drawn}", + ); +} + +/// **O16 — the overlay asks the viewport for the rows it needs, and the request +/// grows with the question.** +/// +/// A request rather than a demand: `App::viewport_wanted` clamps it to what the +/// terminal can spare. What is asserted here is that the number is derived from +/// the content and is measured through the wrapper, not counted as lines — the +/// defect `rows::wrapped` exists for. +#[test] +fn o16_the_overlay_asks_for_rows_that_grow_with_the_question() { + let (small, _a) = live(Question::new("what did you mean?")); + let (large, _b) = live(five_choices()); + + let small_rows = small.rows_wanted(80, &DARK); + let large_rows = large.rows_wanted(80, &DARK); + assert!( + large_rows > small_rows, + "five offers and a context line asked for {large_rows} rows, no more than \ + the bare question's {small_rows}", + ); + + // The measurement is of wrapped rows, so a question too long for the width + // asks for more than the same question at a width that fits it. + let (long, _c) = live(Question::new( + "which of the columns in this table should the migration drop, given that \ + it has forty rows and exactly one caller anywhere in the workspace?", + )); + assert!( + long.rows_wanted(40, &DARK) > long.rows_wanted(200, &DARK), + "the row demand ignores wrapping, which is the `lines.len()` measurement \ + this release replaced", + ); +} diff --git a/tests/palette.rs b/tests/palette.rs index 8c8ec0e..33f9310 100644 --- a/tests/palette.rs +++ b/tests/palette.rs @@ -213,13 +213,26 @@ fn f1_the_palette_opens_on_the_rows_the_viewport_has() { viewport.contains(first_group.0.title()), "the first group's heading opens the list: {viewport:?}", ); - for (name, _) in first_group.1.iter().take(usize::from(height) - 2) { + // Three rows go to something other than a command: the head, the first + // group's heading, and — since 0.32.0 — the row that says how many rows are + // not shown. Drawn here at `VIEWPORT_HEIGHT` on purpose: this is the small + // terminal, the case the elision exists for. A real session grows the viewport + // to `Picker::rows_wanted` and the elision never fires at all. + for (name, _) in first_group.1.iter().take(usize::from(height) - 3) { let label = name.strip_prefix('/').expect("a command"); assert!( viewport.contains(label), "the palette opens on the rows the viewport has, and {label} is missing: {viewport:?}", ); } + + // **And it says what it did not draw.** Until 0.32.0 a palette of thirty-six + // commands in eight rows drew five of them and said nothing at all, so a list + // filtered down to nothing looked exactly like one scrolled to its end. + assert!( + viewport.contains("more"), + "the palette dropped most of its rows without saying so: {viewport:?}", + ); } #[test] @@ -808,7 +821,13 @@ fn f5_a_bundle_skill_is_offered_under_the_name_the_model_addresses() { "the operator's own skill and the bundle's are both choosable", ); - let label = namespaced("acme", "helper"); + // **Drawn qualified, addressed namespaced.** Since 0.32.0 the row reads + // `acme:helper`; `io_harness::NAMESPACE` is what `Skills::catalog` put in the + // model's prompt and what `read_skill` resolves by equality, and + // `io_cli::naming` is the only thing between the two. The round trip is + // asserted below, which is the property that makes the display safe. + let wire = namespaced("acme", "helper"); + let label = io_cli::naming::display(&wire); let index = rows .iter() .position(|row| row.label == label) @@ -839,6 +858,29 @@ fn f5_a_bundle_skill_is_offered_under_the_name_the_model_addresses() { "the bare name is offered, and the model cannot address it", ); + // **And the round trip, which is what makes drawing a colon safe.** The label + // an operator reads becomes the command `invoke_skill` writes, which + // `commands::parse` turns back into the exact string io-harness put in the + // model's catalogue. If this does not hold, the palette is offering a name + // that resolves to nothing — the same defect the assertion above guards, one + // translation later. + let typed = commands::invoke_skill(&wire); + assert_eq!( + typed.trim(), + format!("/{label}"), + "choosing the row must write the command, not a sentence about it", + ); + let asked = commands::parse( + typed.trim_start_matches('/').trim(), + &io_cli::keys::Keys::default(), + &DARK, + ); + assert_eq!( + asked, + commands::Action::Skill(wire.clone(), String::new()), + "the typed command did not resolve back to the name the model addresses", + ); + // The provenance is in the label AND in the detail, because the detail is the // column a narrow terminal drops first. let detail = rows[index] @@ -852,27 +894,32 @@ fn f5_a_bundle_skill_is_offered_under_the_name_the_model_addresses() { "the detail says what it is and whose it is: {detail:?}", ); + // **`Chosen` carries the wire name, not the displayed one**, and that is the + // seam: everything from here on is talking to io-harness, so the translation + // happens at the row and at the parse — the two edges — and nowhere in + // between. `invoke_skill` displays it on the way to the composer; `parse` + // resolves it back. A `Chosen` holding the colon form would put the + // translation in the middle of the pipeline, where every later caller would + // have to remember it. assert_eq!( commands::palette_pick(&none, &skills, index), - Some(Chosen::Skill(label.clone())), - "choosing it stands for the namespaced name and not the bare one", + Some(Chosen::Skill(wire.clone())), + "choosing it stands for the name the model addresses, not the bare one \ + and not the one drawn", ); - // And through the picker, as the driver reaches it: typed for by the name the - // model uses, chosen with `Enter`, resolved back through `palette_pick`. + // And through the picker, as the driver reaches it: typed for by the name it + // is drawn under, chosen with `Enter`, resolved back through `palette_pick` + // to the name the model addresses. let mut picker = Picker::new("Which command?", rows.clone()); type_at(&mut picker, &label); - assert_eq!( - marked(&picker), - label, - "typing the namespaced name finds it" - ); + assert_eq!(marked(&picker), label, "typing the qualified name finds it"); let Outcome::Chosen(chosen) = picker.key(key(KeyCode::Enter)) else { panic!("Enter on the marked row must choose it"); }; assert_eq!( commands::palette_pick(&none, &skills, chosen), - Some(Chosen::Skill(label)), + Some(Chosen::Skill(wire.clone())), ); } From 57e6ea18b22782d1cf6cb23caf1683ce61a597d8 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 30 Aug 2026 19:54:22 +0530 Subject: [PATCH 05/21] fix(triage): a question is drawn once, and never as a warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `triage.rs` marked `question_asked` a committed line while marking `approval_requested` and `plan_proposed` silent, so the transcript wrote the question and the overlay redrew it through `Tone::Warning` — whose word is literally "warning". Two renderers, neither aware of the other, and the operator asked twice with the second copy mislabelled. `question_asked` becomes silent, like its two siblings. The transcript line survives where nothing else will draw it, and that condition is two facts rather than one. An `Answerer` is attached to every contract io-cli builds, so "no responder is registered" never happens in io-harness's sense; what varies is whether this process kept the receiver. The resume continuation drops it and then drives a real turn, so a resumed run that asks a new question has no overlay anywhere and the durable line is the only thing that renders it. A `--plain`-only gate would have made that question invisible, which is a worse defect than printing it twice. So `Events` gains `answering`, defaulting to false. A path that forgets to set it prints the question twice; a path that wrongly claimed an overlay would not print it at all, and the default is the direction that fails safely. Also corrects this file's own doc, which named io-harness 0.69 while the pin has been 0.71 for six releases. --- src/triage.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/triage.rs b/src/triage.rs index 13345b3..a0d17b4 100644 --- a/src/triage.rs +++ b/src/triage.rs @@ -41,7 +41,7 @@ pub enum Disposition { Silent, } -/// Every kind io-harness 0.69 declares, in its own declaration order. +/// Every kind io-harness 0.71 declares, in its own declaration order. /// /// The order is the enum's rather than alphabetical so that this table can be /// read down the side of `observe.rs` when the pin moves. @@ -106,7 +106,13 @@ pub const TRIAGE: &[(&str, Disposition, &str)] = &[ Disposition::Line, "the plan block, and the plan field", ), - ("question_asked", Disposition::Line, "the question line"), + ( + "question_asked", + Disposition::Silent, + "the question overlay, which is on screen at the moment it is asked — and a committed line \ + wherever nothing draws one: plain mode, and a resumed run whose overlay this process is \ + not holding", + ), ("question_answered", Disposition::Line, "the answer line"), ( "plan_proposed", From 777e9448464e12684506bed22050a2b6bbc50e3b Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 30 Aug 2026 19:54:32 +0530 Subject: [PATCH 06/21] fix(tone): say refused only where a boundary refused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Tone::Refused`'s own doc says it means an act the permission boundary turned down, and it is the one tone whose word an operator has to be able to trust. It was being spent on a mistyped `/effort` argument, on a run that simply is not in the store, on three watch errors, and on this crate's own `/memory` bookkeeping — none of which went near a boundary. Corrected, each with the prose that argued for it: the plan proposal and question headers stop being warnings; a declined question and a plan sent back for revision stop being warnings, because declining is a real answer and a revision is the surface working; a run absent from the store becomes a fact and the watch errors beside it become errors; and the profile-switch rejection becomes a refusal like all seven sibling arms in its own match, where it was the one drawn as a program failure. `/profile`'s unknown verb keeps `Tone::Refused`, and the comment now says why the two differ rather than citing `/effort` as its precedent: a wrong `/effort` word buys the wrong amount of reasoning, a wrong `/profile` verb deletes something. `outcome_help` told an operator that a run awaiting an answer or a plan could not be answered by this release and to say it in their next prompt. `Intent::resumed`, `Review::resumed` and the `/resume` continuation have all existed since 0.23.0, so the product had spent nine releases documenting the absence of a capability it shipped, in the one place an operator reads when they are stuck. The arm is split, because `/resume` answers a parked plan and an approval belongs to a turn that is over. The two tests that pinned the old wording are re-pointed at the real bytes and one now also forbids the sentence that was false. --- src/commands.rs | 92 +++++++++++++++++++++++++++------- src/events.rs | 94 ++++++++++++++++++++++++++++------- src/plan.rs | 123 +++++++++++++++++++++++++++++++++------------- tests/commands.rs | 15 +++++- tests/events.rs | 13 ++++- 5 files changed, 262 insertions(+), 75 deletions(-) diff --git a/src/commands.rs b/src/commands.rs index 9209570..371df7f 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -760,15 +760,16 @@ fn entries(templates: &Templates, skills: &[crate::skillview::Listed]) -> Vec__` the model actually addresses, and that prefix - // is in the label, which is the column that survives and the - // one `crate::fuzzy` ranks. + // It is not: a bundle's skill is listed under the qualified + // `:`, and that prefix is in the label, which is the + // column that survives and the one `crate::fuzzy` ranks. Since + // 0.32.0 the separator drawn is a colon; the name io-harness + // resolves is unchanged, and `crate::naming` holds the rule. match &skill.origin { crate::skillview::Origin::Bundle(id) => { format!("{SKILL}{} · from the {id} bundle", skill.description) @@ -822,7 +823,14 @@ pub fn palette_pick( .and_then(|entry| entry.chosen) } -/// The prompt a chosen skill puts in the composer. +/// The command a chosen skill puts in the composer. +/// +/// **A command, not a sentence, and that is 0.32.0's whole change here.** Until +/// this release it built `use the {name} skill: ` — prose with no leading slash, +/// which `App::compose` therefore submitted as an ordinary prompt for the model to +/// interpret. Choosing a skill from the palette was a suggestion to the agent +/// rather than an instruction to the program, and whether it was honoured depended +/// on how the sentence read. /// /// **By name, and nothing else.** io-harness gives the model a catalogue of the /// skills discovered for the run and the model opens the file itself, under the @@ -831,8 +839,12 @@ pub fn palette_pick( /// this crate is forbidden to grow. It is left in the composer rather than sent, /// like every other palette row, because the operator has more to say than the /// name. +/// +/// The name is written the way it is read — `bundle:skill` — and turned back into +/// io-harness's own spelling by [`crate::naming::wire`] when the run is asked for +/// it. pub fn invoke_skill(name: &str) -> String { - format!("use the {name} skill: ") + format!("/{} ", crate::naming::display(name)) } /// What a chosen palette row is. @@ -998,6 +1010,20 @@ pub enum Reasoning { /// What the driver should do about a slash command. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Action { + /// Run a bundle's skill by the name it is drawn under: the resolved wire name + /// io-harness knows it by, and whatever the operator typed after it. + /// + /// **The wire name, already translated.** `parse` turns `ultraship:brainstorm` + /// back into `ultraship__brainstorm` here, because that is the string + /// `Skills::get` matches by equality and the one the model was shown in its + /// catalogue. Everything downstream of this variant is talking to io-harness, + /// so nothing downstream should have to know that a colon was ever involved. + /// + /// Not `Action::Print` with a sentence in it: until 0.32.0 choosing a skill + /// wrote `use the skill: ` into the composer and submitted it as an + /// ordinary prompt, so whether the skill ran at all depended on the model's + /// reading of an English request. + Skill(String, String), /// Commit these lines and carry on. Print(Vec>), Quit, @@ -1778,12 +1804,18 @@ pub fn pinned_said( /// What withdrawing one note is reported as. /// -/// **[`crate::recall::Forgotten::Refused`] is a refusal and names why, never a -/// success.** The note is pinned, so it is not a run's to withdraw and io-cli -/// asks on a run's behalf; it stands, unchanged, and it will go on being carried -/// into every later prompt. Reporting that as a removal is the same failure the -/// pin flag exists to prevent one level down — the operator believes the note is -/// gone and it is not. +/// **[`crate::recall::Forgotten::Refused`] names why and is never a success** — +/// but it is not a refusal either, and 0.32.0 stopped drawing it as one. The note +/// is pinned, so it is not a run's to withdraw and io-cli asks on a run's behalf; +/// it stands, unchanged, and it will go on being carried into every later prompt. +/// Reporting that as a removal is the same failure the pin flag exists to prevent +/// one level down — the operator believes the note is gone and it is not. +/// +/// What carries that is the sentence, which says the note is still there and what +/// to do about it. `Tone::Refused` means an act the permission boundary refused, +/// and nothing here went near one: spending the word `refused` on this crate's own +/// bookkeeping is how `refused:` stops meaning anything on the surface where it +/// has to. /// /// [`crate::recall::Forgotten::Absent`] is a third thing again: not an error and /// not a removal. @@ -1802,7 +1834,7 @@ pub fn forgotten_said( ), ), crate::recall::Forgotten::Refused => ( - Tone::Refused, + Tone::Muted, format!( "{key} is pinned, so it is not a run's to withdraw {} it is still there, and \ still carried into every later prompt. Unpin it, then forget it.", @@ -1821,14 +1853,16 @@ pub fn forgotten_said( /// What putting a withdrawn memory back actually put back. /// -/// **An empty answer is a refusal and never a success.** [`crate::recall::unforget`] -/// returns the keys the rewind genuinely restored, and reporting "restored" over an -/// empty slice is the same lie [`forgotten_said`]'s own refusal arm exists to -/// prevent — the operator would be told the note is back and find it gone. +/// **An empty answer is never a success**, and since 0.32.0 it is not drawn as a +/// refusal either. [`crate::recall::unforget`] returns the keys the rewind +/// genuinely restored, and reporting "restored" over an empty slice is the same +/// lie [`forgotten_said`]'s own arm exists to prevent — the operator would be told +/// the note is back and find it gone. The sentence says so; the tone does not have +/// to claim a permission boundary that was never consulted. pub fn unforgotten_said(key: &str, restored: &[String]) -> (Tone, String) { if restored.is_empty() { return ( - Tone::Refused, + Tone::Muted, format!( "{key} was not put back: that restore point no longer holds it, and nothing \ was changed" @@ -2404,6 +2438,18 @@ pub fn parse(input: &str, keys: &Keys, theme: &Theme) -> Action { input.split_whitespace().nth(1), Some("allow") | Some("allow-git") )), + // **A bundle's contribution, addressed by the name it is drawn under.** + // Placed after the whole static table on purpose: a skill whose name + // collides with a command resolves to the command, which is the direction + // that cannot break a surface that already works. In practice no command + // carries a colon at all — `naming`'s own test asserts it over `COMMANDS` + // — so the shape is unambiguous, and that is what lets this arm exist + // without `parse` taking the skills list. Whether the name resolves to + // anything installed is the driver's question, because the driver is what + // holds the live inventory and re-walks it at every turn boundary. + qualified if crate::naming::is_qualified(qualified) => { + Action::Skill(crate::naming::wire(qualified), rest(input)) + } unknown => { let mut lines = vec![theme.notice( Tone::Warning, @@ -2416,6 +2462,14 @@ pub fn parse(input: &str, keys: &Keys, theme: &Theme) -> Action { } } +/// Everything after the first word, trimmed. Empty when there is nothing. +fn rest(input: &str) -> String { + input + .split_once(char::is_whitespace) + .map(|(_, rest)| rest.trim().to_string()) + .unwrap_or_default() +} + /// The `/help` output: the keys in force, then the commands. /// /// It takes the newline naming rather than deciding it for the same reason diff --git a/src/events.rs b/src/events.rs index adb0fb9..cacb542 100644 --- a/src/events.rs +++ b/src/events.rs @@ -184,6 +184,11 @@ pub struct Events { /// still committed — in the status line's vocabulary rather than the removed /// rows' own, so there is one spelling of each fact in the product. plain: bool, + /// Whether an overlay in *this* process will draw a question that is asked. + /// + /// See [`Events::set_answering`] for why this is not the same question as + /// whether a responder exists, and why `false` is the right default. + answering: bool, /// Events whose kind [`crate::triage`] has never heard of. /// /// Counted rather than printed. A kind with no disposition is one io-harness @@ -261,6 +266,7 @@ impl Events { open: Vec::new(), refused_this_step: false, plain: false, + answering: false, unknown: 0, step_at: Duration::ZERO, thought: None, @@ -320,6 +326,22 @@ impl Events { self.plain = plain; } + /// Say whether this process is holding the overlay that answers a question. + /// + /// **Not "is a responder registered" — that is always true.** io-cli attaches + /// an `Answerer` to every contract it builds, so the harness always has + /// somebody to ask. What varies is whether io-cli kept the receiving end: four + /// sites drop it, three of them on contracts that never drive a turn, and one + /// — the `/resume` continuation — that does. A question asked by that run + /// reaches no overlay, so the durable transcript line has to render it. + /// + /// Default `false`, which is the safe direction: a path that forgets to say so + /// prints the question twice, and a path that wrongly claimed an overlay would + /// not print it at all. + pub fn set_answering(&mut self, answering: bool) { + self.answering = answering; + } + /// How many events arrived carrying a kind with no disposition. /// /// Zero on every run against the io-harness this release is locked to, by @@ -718,10 +740,18 @@ impl Events { self.open.push(Pending { name: shown.to_string(), raw: name.clone(), + // **A skill's target is a name, not a path, and since 0.32.0 + // it is drawn the way the operator reads it.** `relative` + // passes a non-path through unchanged, so `read_skill` used to + // put io-harness's own `bundle__skill` straight into the + // scrollback — the one place the separator reached a person + // without going through a picker. `crate::naming::display` + // leaves every other target alone, because no path contains + // the separator. target: if target == name { shown.to_string() } else { - relative(target, &self.root) + crate::naming::display(&relative(target, &self.root)) }, opened_at: at, measured: None, @@ -1182,12 +1212,29 @@ impl Events { // it is what an operator sees at all on a turn that has no responder. EventKind::QuestionAsked { question, choices } => { let mut lines = self.flush_text(); - lines.push(theme.notice(Tone::Accent, format!("the agent asks: {question}"))); - for choice in choices { - lines.push(Line::from(Span::styled( - format!(" {} {choice}", theme.glyphs.bullet), - theme.style(Tone::Muted), - ))); + // **Committed only where nothing else will draw it (0.32.0).** + // Until this release the line was committed unconditionally and + // the overlay redrew the same question through `Tone::Warning`, so + // an operator was asked twice and told the second time was a + // warning. Two renderers, neither aware of the other. + // + // The condition is two facts and not one, and the second is the + // one a `--plain`-only gate would have missed: an `Answerer` is + // attached to **every** contract, so "no responder" never happens + // in io-harness's sense — what varies is whether this process kept + // the receiver. The `/resume` continuation drops it and then + // drives a real turn, so a resumed run asking a new question has + // no overlay anywhere and this line is the only thing that renders + // it. Suppressing a question everywhere is a worse defect than + // printing it twice. + if self.plain || !self.answering { + lines.push(theme.notice(Tone::Accent, format!("the agent asks: {question}"))); + for choice in choices { + lines.push(Line::from(Span::styled( + format!(" {} {choice}", theme.glyphs.bullet), + theme.style(Tone::Muted), + ))); + } } lines } @@ -1892,20 +1939,29 @@ pub fn outcome_tone(outcome: &str) -> Tone { /// and the session had no way to give it. pub fn outcome_help(outcome: &str) -> Option<&'static str> { match outcome { - // Still a dead end, and a different one: a question about *intent*, which - // io-harness deliberately distinguishes from an approval about permission. - // Answering one needs a responder on a caller-supplied contract, which is - // the same entry point that would cost `Ctrl+C`. 0.7.0. + // **Both of the sentences that stood here were false, and had been since + // 0.23.0.** They told an operator that a run waiting on an answer or a + // plan could not be answered by this release and to say it in the next + // prompt — while `Intent::resumed`, `Review::resumed` and the `/resume` + // continuation had all existed for nine releases. The product was + // documenting the absence of a capability it shipped, in the one place an + // operator reads when they are stuck. "awaiting_answer" => Some( - "the agent asked what you meant, and this release has no way to answer \ - that. Say it in your next prompt.", + "the agent asked what you meant and the turn ended before it was \ + answered. `/resume` reopens the question and carries the run on from \ + where it stopped.", + ), + // **Split, because `/resume` answers one of these and not the other.** A + // parked plan is a run io-harness can continue; an approval belongs to a + // turn that is over, and there is nothing left to authorize. + "awaiting_plan" => Some( + "the run proposed a plan and the turn ended before it was decided. \ + `/resume` reopens the plan and continues from there.", ), - // A turn that still ends here after 0.2.0 is one whose question was never - // answered — the overlay was dropped when the turn ended, or the run asked - // for something this interface does not bind, such as a plan gate. - "awaiting_approval" | "awaiting_plan" => Some( - "the run stopped waiting on a decision it never got. Ask again, or \ - press Shift+Tab to choose a posture that does not need one.", + "awaiting_approval" => Some( + "the run stopped waiting on a decision it never got, and an approval \ + belongs to the turn that asked for it. Ask again, or press Shift+Tab \ + to choose a posture that does not need one.", ), "denied" | "refused" => Some( "the permission boundary stopped it. The line above names the rule and \ diff --git a/src/plan.rs b/src/plan.rs index 54d25c0..416e613 100644 --- a/src/plan.rs +++ b/src/plan.rs @@ -28,6 +28,7 @@ use ratatui::widgets::Paragraph; use ratatui::Frame; use tokio::sync::{mpsc, oneshot}; +use crate::app::COMPOSER_ROWS; use crate::composer::{Composer, Reply}; use crate::intent::Destination; use crate::theme::{Theme, Tone}; @@ -147,55 +148,111 @@ impl Review { /// unlike [`crate::intent`], where declining defers and the two paths defer /// to different places. pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { - if area.height == 0 { + if area.height == 0 || area.width == 0 { return; } - let mut lines: Vec> = vec![theme.notice( - Tone::Warning, - format!( - "a plan, before any of it runs {} {} steps", - theme.glyphs.dash, - self.plan.steps.len() - ), - )]; - for (index, step) in self.plan.steps.iter().enumerate() { - let owner = match &step.agent { - Some(agent) => format!(" [{agent}]"), - None => String::new(), - }; - lines.push(theme.notice( - Tone::Normal, - format!("{}. {}{owner}", index + 1, step.intent), - )); - } - lines.push(theme.notice( - Tone::Muted, - format!( - "Enter approves {} type a correction and Enter sends it back {} Esc cancels", - theme.glyphs.dash, theme.glyphs.dash - ), - )); + let header = self.header(theme); + let footer = self.footer(theme); + // The composer keeps its row wherever there is one to keep. Below that + // there is no correction to type and the plan is approved or cancelled by + // a key, which are the two verdicts a single row can carry. + let composer_rows = if area.height > 2 { COMPOSER_ROWS } else { 0 }; + let text_rows = area.height.saturating_sub(composer_rows); + + // **The header and the footer are reserved; the steps are what gives + // way.** Through 0.31.0 every line went into one `Paragraph` sized to + // `lines.len()`, and a `Paragraph` simply stops painting at the bottom of + // its area — so the last thing pushed was the first thing lost, and the + // last thing pushed is the footer. That made the one overlay whose own + // module doc forbids losing its footer the only overlay that always lost + // it first, and it lost it before `lines.len()` even reached the height, + // because these lines wrap. + let head_rows = crate::rows::wrapped(std::slice::from_ref(&header), area.width); + let foot_rows = crate::rows::wrapped(std::slice::from_ref(&footer), area.width); + let room = text_rows.saturating_sub(head_rows).saturating_sub(foot_rows); + + let mut lines = vec![header]; + lines.extend(crate::rows::elide(self.steps(theme), room, area.width, theme)); + lines.push(footer); - let head = u16::try_from(lines.len()) - .unwrap_or(u16::MAX) - .min(area.height); frame.render_widget( Paragraph::new(lines).wrap(ratatui::widgets::Wrap { trim: false }), Rect { - height: head, + height: text_rows, ..area }, ); - if area.height > head { + if composer_rows > 0 { self.composer.render( frame, Rect { - y: area.y + head, - height: area.height - head, + y: area.y.saturating_add(text_rows), + height: composer_rows, ..area }, theme, ); } } + + /// What this is, and how much of it there is. + /// + /// **`Tone::Accent`, not `Tone::Warning`.** A plan the agent is asking + /// permission to run is not a warning, and `Tone::Warning`'s word is literally + /// `warning` — so under `NO_COLOR` every proposal announced itself as one. + /// `Tone::Refused` is left meaning what its own doc says it means, and the + /// rest of the vocabulary has to be worth the same. + fn header(&self, theme: &Theme) -> Line<'static> { + theme.notice( + Tone::Accent, + format!( + "a plan, before any of it runs {} {} steps", + theme.glyphs.dash, + self.plan.steps.len() + ), + ) + } + + /// The three ways out. Never elided: an operator who cannot see the keys is an + /// operator guessing at a surface that is holding a run. + fn footer(&self, theme: &Theme) -> Line<'static> { + theme.notice( + Tone::Muted, + format!( + "Enter approves {} type a correction and Enter sends it back {} Esc cancels", + theme.glyphs.dash, theme.glyphs.dash + ), + ) + } + + fn steps(&self, theme: &Theme) -> Vec> { + self.plan + .steps + .iter() + .enumerate() + .map(|(index, step)| { + let owner = match &step.agent { + Some(agent) => format!(" [{agent}]"), + None => String::new(), + }; + theme.notice( + Tone::Normal, + format!("{}. {}{owner}", index + 1, step.intent), + ) + }) + .collect() + } + + /// Rows this overlay would like the viewport to be: its header, every step as + /// it will actually wrap, its footer, and the composer. + /// + /// A twelve-step plan asks for a twelve-step plan. Whether it gets one is + /// [`crate::app::App::viewport_wanted`]'s decision, and what it does when it + /// does not is the elision above. + pub fn rows_wanted(&self, width: u16, theme: &Theme) -> u16 { + let mut lines = vec![self.header(theme)]; + lines.extend(self.steps(theme)); + lines.push(self.footer(theme)); + crate::rows::wrapped(&lines, width).saturating_add(COMPOSER_ROWS) + } } diff --git a/tests/commands.rs b/tests/commands.rs index f0331c0..9ad1af8 100644 --- a/tests/commands.rs +++ b/tests/commands.rs @@ -1239,7 +1239,7 @@ fn the_writers_are_configure_commands_and_skills_is_the_inspection() { /// is gone while the model keeps reading it — the same failure the pin flag exists /// to prevent one level down. #[test] -fn a_refused_withdrawal_is_a_refusal_and_names_the_pin() { +fn a_withdrawal_that_did_not_happen_names_the_pin_and_is_never_a_success() { use io_cli::commands::forgotten_said; use io_cli::glyphs::ASCII; use io_cli::recall::{Forgotten, Scope}; @@ -1251,7 +1251,18 @@ fn a_refused_withdrawal_is_a_refusal_and_names_the_pin() { Forgotten::Refused, &ASCII, ); - assert_eq!(tone, Tone::Refused, "a refusal is not a success: {said}"); + // **Not a success, and since 0.32.0 not a refusal either.** `Tone::Refused` + // means an act the permission boundary refused, and nothing here went near + // one — this is io-cli's own bookkeeping about a note the operator pinned. + // Spending the word `refused` on it is how the word stops meaning anything on + // the surface that needs it. What carries the fact is the sentence, asserted + // below, which is the same argument `forgotten_said`'s own doc makes. + assert_ne!(tone, Tone::Success, "nothing was withdrawn: {said}"); + assert_ne!( + tone, + Tone::Refused, + "a pinned note is not a permission boundary refusing an act: {said}", + ); assert!( said.contains("pinned"), "the reason is what makes it actionable: {said}", diff --git a/tests/events.rs b/tests/events.rs index a4cf448..937572e 100644 --- a/tests/events.rs +++ b/tests/events.rs @@ -472,12 +472,21 @@ fn a_turn_that_ends_waiting_for_a_human_says_what_to_do_about_it() { assert_eq!(outcome_tone(outcome), Tone::Warning, "{outcome}"); let help = outcome_help(outcome) .unwrap_or_else(|| panic!("{outcome} leaves the operator with no next action")); + // **`/resume` since 0.32.0, and "next prompt" is gone on purpose.** The + // two sentences that told an operator a parked question or plan could not + // be answered by this release had been false since 0.23.0 — `/resume` + // reopens both. An approval keeps `Shift+Tab`, because an approval belongs + // to the turn that asked for it and there is nothing left to authorize. assert!( - ["io setup", "Shift+Tab", "next prompt"] + ["io setup", "Shift+Tab", "/resume"] .iter() .any(|way| help.contains(way)), "{outcome} should name something the operator can do: {help}", ); + assert!( + !help.contains("no way to answer") && !help.contains("this release has no"), + "{outcome} still claims a capability this product shipped in 0.23.0: {help}", + ); } // A refusal is actionable too, for the same reason. @@ -529,7 +538,7 @@ fn the_awaiting_help_reaches_the_transcript() { assert!(line.contains("awaiting_answer"), "{line:?}"); assert!(line.contains("warning"), "{line:?}"); assert!( - line.contains("next prompt"), + line.contains("/resume"), "the way out should be in the transcript, not only in the docs: {line:?}", ); assert!(!line.contains("error"), "nothing went wrong: {line:?}"); From 1cc25f4946f83f20751cdfb5d8cfc4228fa6ee5c Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 30 Aug 2026 19:55:06 +0530 Subject: [PATCH 07/21] feat(naming): read a bundle's name, keep io-harness's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `io_harness::NAMESPACE` is `__`, and for a skill that name reaches the model: `Skills::catalog` puts it in the system prompt and `read_skill` resolves it by equality, so the model can only ask for the string it was shown. Renaming the separator would move the system prompt, the tool dispatch, every event target, both crates' suites and every consent surface at once. So nothing is renamed. `naming::display` translates at the moment a name is drawn and `naming::wire` translates back a name an operator typed, and between them `__` never reaches a person and `:` never reaches io-harness. First occurrence only in both directions, because a bundle id cannot contain the separator — the first one is always the join. Applied to every name io-harness qualified, not to skills alone. `/plugin` lists a bundle's agents, MCP servers and policy layers; `/status` lists its policy layers. None of those are skills, and all of them were showing the operator a separator that appears in no documentation they will ever read. `invoke_skill` was the reason a skill was not a command: it wrote `use the {name} skill: ` into the composer, and with no leading slash that submits as an ordinary prompt — so whether the skill ran depended on how the model read an English request. It now writes `/bundle:skill`, and `commands::parse` resolves that back to the catalogue name. The parse needs no skills list. No command contains a colon, so the shape is unambiguous; the parse recognises the shape and the driver, which re-walks the inventory every turn boundary, resolves it. A skill whose name collides with a command resolves to the command, because the static table is matched first. `Chosen::Skill` keeps the wire name, so the translation lives at the two edges and nowhere in between. --- src/lib.rs | 1 + src/main.rs | 150 +++++++++++++++++++++++++++++++++++-------- src/naming.rs | 124 +++++++++++++++++++++++++++++++++++ src/pluginview.rs | 48 ++++++++++++-- src/skillview.rs | 5 +- src/status.rs | 4 +- tests/marketplace.rs | 21 ++++-- 7 files changed, 315 insertions(+), 38 deletions(-) create mode 100644 src/naming.rs diff --git a/src/lib.rs b/src/lib.rs index 27ae19e..d1b008f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -95,6 +95,7 @@ pub mod manage; pub mod markdown; pub mod marketplace; pub mod memory; +pub mod naming; pub mod page; pub mod picker; pub mod picture; diff --git a/src/main.rs b/src/main.rs index 161abd7..2160671 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3211,7 +3211,14 @@ async fn loop_over Result>( ), ); } - Err(refusal) => app.record(Tone::Error, refusal), + // `Tone::Refused`, like every sibling arm in + // this same `match`. A profile that cannot be + // switched to is refused by the same rules + // those arms are refused by, and being the one + // arm drawn as an error made it read as a + // failure of the program rather than an answer + // from it. + Err(refusal) => app.record(Tone::Refused, refusal), } } } @@ -4168,6 +4175,47 @@ async fn loop_over Result>( // neither asserted nor sabotaged. This arm holds the wiring: build the // policy actually in force, ask, and either say why not or submit. let mut command = app.key(key); + // **A skill invocation is a submit too, and for the same reason.** It + // hands work to the agent rather than reporting something, so it becomes + // the `Command::Submit` a typed prompt becomes. + // + // **Resolved here rather than in the parse, because this is where the + // inventory lives.** `skills` is re-walked at every turn boundary, so + // `/plugin` and `/import` cannot leave it stale; `commands::parse` matches + // a static table and has no way to know what is installed, which is + // exactly why it recognises the *shape* and leaves the *resolution* here. + if let Command::Slash(text) = &command { + if let Action::Skill(wire, args) = commands::parse(text, app.keys(), &app.theme) { + command = if skills.iter().any(|listed| listed.name == wire) { + // The name io-harness put in the model's catalogue, then + // whatever the operator added. Not a sentence asking the model + // to please use a skill — that was 0.31.0's `invoke_skill`, + // and whether the skill ran at all depended on how the English + // read. + // + // **This is as structural as io-cli can make it, and the limit + // is io-harness's.** `read_skill` is the *model's* tool and + // there is no public call that runs a skill on the model's + // behalf, so what io-cli controls is that the prompt is the + // catalogue name and nothing else. + Command::Submit(if args.is_empty() { + wire + } else { + format!("{wire}\n\n{args}") + }) + } else { + let dash = app.theme.glyphs.dash; + app.say( + Tone::Warning, + format!( + "there is no {} skill {dash} /skills lists what is installed", + io_cli::naming::display(&wire), + ), + ); + Command::None + }; + } + } if let Command::Slash(text) = &command { if let Action::Commit(allow) = commands::parse(text, app.keys(), &app.theme) { // **Asked first, allowed second, and the order is the fix for a @@ -4267,6 +4315,13 @@ async fn loop_over Result>( undo_whole_turn(&mut app, screen, &mut session, &store, &seen)?; } Command::Slash(text) => match commands::parse(&text, app.keys(), &app.theme) { + // Rewritten into a `Command::Submit` above, for the reason + // `/commit` is: invoking a skill hands work to the agent, so it + // has to become the same submit a typed prompt becomes. + Action::Skill(..) => debug_assert!( + false, + "a skill invocation is rewritten into a submit before the match" + ), Action::Print(lines) => { screen.commit(&lines).map_err(|error| error.to_string())?; } @@ -5317,9 +5372,13 @@ async fn loop_over Result>( } } Action::Profile(io_cli::commands::ProfileVerb::Unknown(word)) => { - // Refused by name and never guessed at, which is `/effort`'s - // rule for the same reason: choosing the nearest verb here - // would remove a profile the operator did not name. + // Named and never guessed at: choosing the nearest verb here + // would remove a profile the operator did not name. This one + // keeps `Tone::Refused` where `/effort`'s mistyped level gave + // it up in 0.32.0, and the difference is the consequence — a + // wrong `/effort` word buys the wrong amount of reasoning, a + // wrong `/profile` verb deletes something. The tone is the + // refusal to act on a destructive word. app.record( Tone::Refused, format!( @@ -5693,10 +5752,17 @@ async fn loop_over Result>( // the line drew the half nothing wrote. app.status.effort = level; } - // A word that is not a level is refused rather than - // reported, so the tone is the refusal's. + // **A word that is not a level is a typo, not a refusal.** + // Through 0.31.0 this was `Tone::Refused` and the comment + // here argued for it — but `Tone::Refused`'s own doc + // (`theme.rs`) says it means an act the permission boundary + // refused, and it is the one tone whose word an operator must + // be able to trust. Spending it on a mistyped argument is how + // `refused:` stops meaning anything. The sentence still says + // what the levels are; it just no longer claims a boundary + // turned something down. let tone = match said { - io_cli::commands::Reasoning::Unknown(_) => Tone::Refused, + io_cli::commands::Reasoning::Unknown(_) => Tone::Warning, _ => Tone::Muted, }; app.record(tone, io_cli::app::reasoning_said(&said, effort)); @@ -6766,6 +6832,14 @@ async fn turn( // and never spoken to — which is exactly what a session with no responder // does today: the question pauses the run instead. let (answerer, mut questions) = io_cli::intent::channel(); + // **This is the one site that keeps the receiver, and therefore the one site + // that can promise an overlay.** `Events` commits the durable question line + // wherever nothing will draw one; saying so here is what stops the operator + // being asked twice, and *not* saying it anywhere else is what keeps a + // resumed run's question visible. Set per turn rather than once at startup + // because it is a fact about this turn's contract, and the `/resume` + // continuation builds its own. + app.set_answering(true); // The fourth, and the only one that can stop the work before it starts. // Registering it is what turns io-harness's planning phase on, so it is put // on the contract only where the contract itself reaches the run. @@ -9903,6 +9977,13 @@ async fn resume_pending( // `answer` resolves `None` — which is io-harness's own "nobody can answer", // so the run parks again and `/resume` finds it. let (answerer, _) = io_cli::intent::channel(); + // **And because the receiver is dropped, this run has no overlay — so the + // durable question line has to come back.** The turn loop sets this `true` + // for a turn it is holding the overlay for; a resumed run continues in the + // same process, so leaving the flag alone would inherit that promise and a + // question asked here would be rendered by nothing at all. This is the exact + // path a `--plain`-only gate would have blanked. + app.set_answering(false); let continuing = io_cli::contract::session( goal, session.root().to_path_buf(), @@ -10182,12 +10263,17 @@ async fn watch_child( } // No row, or a store that cannot answer. Neither is a reason to open a // watch that would show nothing; both are worth saying. + // A run that is not there is a fact about the store, not something a + // permission boundary turned down. Ok(None) => { - app.record(Tone::Refused, format!("run {run_id} is not in the store")); + app.record(Tone::Muted, format!("run {run_id} is not in the store")); return Ok(()); } + // And a store that cannot answer is an error, which is a third thing + // again. Drawing all three as refusals made the one tone that has to mean + // something mean "anything that did not work". Err(error) => { - app.record(Tone::Refused, format!("cannot watch run {run_id}: {error}")); + app.record(Tone::Error, format!("cannot watch run {run_id}: {error}")); return Ok(()); } } @@ -10196,8 +10282,9 @@ async fn watch_child( Err(error) => { // The store is readable — the session is running out of it — so this // is the run being unknown to it, which is a fact worth saying rather - // than a silence to interpret. - app.record(Tone::Refused, format!("cannot watch run {run_id}: {error}")); + // than a silence to interpret. `Tone::Error` and not `Tone::Refused`: + // nothing declined this, the attach simply failed. + app.record(Tone::Error, format!("cannot watch run {run_id}: {error}")); return Ok(()); } }; @@ -10236,7 +10323,7 @@ async fn watch_child( } } Err(error) => { - app.record(Tone::Refused, format!("stopped watching: {error}")); + app.record(Tone::Error, format!("stopped watching: {error}")); paint(screen, app)?; return Ok(()); } @@ -10305,19 +10392,32 @@ fn paint_picker( if !pending.is_empty() { screen.commit(&pending).map_err(|error| error.to_string())?; } - // **The prompt takes the rows it needs, and gives them back.** Only with no - // picker open and only at an idle prompt — `App::viewport_wanted` returns the - // fixed height in every other case — because re-placing the viewport - // re-queries the cursor, and doing that under a streaming turn would land the - // viewport somewhere the output underneath it has already moved past. - if picker.is_none() { - let wanted = app.viewport_wanted(screen.width(), screen.terminal_rows()); - if wanted != screen.rows() { - // A failure here leaves the session's own height in place — see - // `Screen::replace` — so a terminal that will not answer keeps a - // usable prompt rather than losing one over a row. - let _ = replace_viewport(screen, wanted); - } + // **Every surface takes the rows it needs, and gives them back.** + // + // Through 0.31.0 this ran only with no picker open and only at an idle + // prompt, because re-placing the viewport re-queries the cursor and doing + // that under a streaming turn would land the viewport somewhere the output + // underneath it has already moved past. **The ordering above is what makes + // that safe, and it was always here**: `take_pending` is drained and + // committed before anything is measured, so by this line there is no output + // waiting to go above the viewport and nothing underneath it can move. A + // re-placement mid-turn is therefore the same operation as one at an idle + // prompt, which `tests/viewport.rs` asserts over a growth, a shrink, a + // grow-to-grow and a resize while grown. + // + // A picker sizes for itself. It is drawn *instead of* the app, so `App` is + // not holding the surface that knows how many rows are wanted — which is the + // one case the demand cannot come from `viewport_wanted`. Both paths clamp + // through `term::viewport_for`, so there is still exactly one ceiling. + let wanted = match picker.as_ref() { + Some((open, _)) => io_cli::term::viewport_for(open.rows_wanted(), screen.terminal_rows()), + None => app.viewport_wanted(screen.width(), screen.terminal_rows()), + }; + if wanted != screen.rows() { + // A failure here leaves the session's own height in place — see + // `Screen::replace` — so a terminal that will not answer keeps a + // usable prompt rather than losing one over a row. + let _ = replace_viewport(screen, wanted); } let theme = app.theme; screen diff --git a/src/naming.rs b/src/naming.rs new file mode 100644 index 0000000..e3e9516 --- /dev/null +++ b/src/naming.rs @@ -0,0 +1,124 @@ +//! What a bundle's contribution is called on screen, and what it is called on the +//! wire. +//! +//! **The separator is io-harness's and it is load-bearing.** `io_harness::NAMESPACE` +//! is `__`, and a bundle's skills, agents, MCP servers and policy layers are all +//! named `__` when the harness loads them. For a skill that name +//! reaches the *model*: `Skills::catalog` puts it in the system prompt and +//! `read_skill` resolves it by equality, so the model can only ask for the string +//! it was shown. Renaming the separator would change the system prompt, the tool +//! dispatch, every event `target`, both crates' test suites and every consent +//! surface at once. +//! +//! So nothing here renames anything. [`display`] is a translation applied at the +//! moment a name is drawn, and [`wire`] is its inverse, applied to a name an +//! operator typed. Between the two, `__` never reaches a human and `:` never +//! reaches io-harness. +//! +//! **First occurrence only, in both directions.** A bundle id cannot contain `__` +//! — io-harness builds the qualified name by joining on it — so the first `__` is +//! always the join. Anything after it belongs to the contribution's own name and +//! is left exactly as it was found, which is what keeps the round trip honest for +//! a skill that has a double underscore of its own. + +use io_harness::NAMESPACE; + +/// What the operator reads: `ultraship__brainstorm` becomes `ultraship:brainstorm`. +/// +/// A name carrying no separator is returned unchanged, which is every name a +/// bundle did not contribute. +pub fn display(name: &str) -> String { + match name.split_once(NAMESPACE) { + Some((bundle, rest)) => format!("{bundle}:{rest}"), + None => name.to_string(), + } +} + +/// What io-harness is given: `ultraship:brainstorm` becomes `ultraship__brainstorm`. +/// +/// The inverse of [`display`], for a name an operator typed. A name carrying no +/// colon is returned unchanged — an unqualified skill is addressed by its own name +/// and always has been. +pub fn wire(name: &str) -> String { + match name.split_once(':') { + Some((bundle, rest)) => format!("{bundle}{NAMESPACE}{rest}"), + None => name.to_string(), + } +} + +/// Whether a word an operator typed is asking for a bundle's contribution by its +/// displayed name. +/// +/// Used by `commands::parse` to tell `/ultraship:brainstorm` from a mistyped +/// command. **No command in `COMMANDS` contains a colon**, so the shape is +/// unambiguous without the parse having to know what skills exist — which is what +/// keeps the skills list out of `commands::parse`'s signature and out of the +/// twenty tests that read it. Whether the name resolves to anything is the +/// driver's question, because the driver is what holds the live inventory. +pub fn is_qualified(word: &str) -> bool { + match word.split_once(':') { + Some((bundle, rest)) => !bundle.is_empty() && !rest.is_empty(), + None => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_namespaced_name_reads_with_a_colon_and_goes_back_with_the_separator() { + assert_eq!(display("ultraship__brainstorm"), "ultraship:brainstorm"); + assert_eq!(wire("ultraship:brainstorm"), "ultraship__brainstorm"); + } + + #[test] + fn the_round_trip_is_lossless_for_every_shape_that_reaches_it() { + for wire_name in [ + "ultraship__brainstorm", + "caveman__caveman_review", + // A contribution whose own name carries the separator. Only the first + // occurrence is the join, which is the whole reason this is + // `split_once` and not a replace. + "bundle__deep__nested", + ] { + assert_eq!( + wire(&display(wire_name)), + wire_name, + "{wire_name} did not survive the round trip", + ); + } + } + + #[test] + fn a_name_no_bundle_contributed_is_untouched_in_both_directions() { + assert_eq!(display("brainstorm"), "brainstorm"); + assert_eq!(wire("brainstorm"), "brainstorm"); + } + + #[test] + fn only_a_qualified_word_is_a_bundles_contribution() { + assert!(is_qualified("ultraship:brainstorm")); + assert!(!is_qualified("brainstorm")); + // Neither half may be empty, or `/:` and `/x:` would be read as skills and + // take the unknown-command notice away from a genuine typo. + assert!(!is_qualified(":brainstorm")); + assert!(!is_qualified("ultraship:")); + assert!(!is_qualified(":")); + } + + #[test] + fn no_shipped_command_carries_a_colon() { + // The property the parse rests on. If a command ever takes one, the + // qualified-name arm has to start consulting `COMMANDS` first — and this + // is where that is discovered, rather than in a session where `/x:y` + // silently stopped being a command. + for (name, _) in crate::commands::COMMANDS { + assert!( + !name.contains(':'), + "{name} carries a colon, which the skill-name parse reads as a \ + bundle qualifier", + ); + } + } +} diff --git a/src/pluginview.rs b/src/pluginview.rs index 67cf6f3..786d92f 100644 --- a/src/pluginview.rs +++ b/src/pluginview.rs @@ -673,13 +673,28 @@ pub fn detail(plugin: &Listed, width: u16, glyphs: &Glyphs) -> Vec { out.push(Row::heading("prompt templates")); out.push(Row::new(fit_left(&dir.display().to_string(), room, glyphs))); } + // **Drawn with a colon since 0.32.0.** io-harness qualifies every contribution + // a bundle makes with `io_harness::NAMESPACE`, so these arrive as + // `__`; `crate::naming` translates that for the reader and back + // again for anything that has to address it. The names themselves are + // untouched — this is the last surface before the screen. if !plugin.agents.is_empty() { out.push(Row::heading("agents")); - out.extend(plugin.agents.iter().map(|name| Row::new(name.clone()))); + out.extend( + plugin + .agents + .iter() + .map(|name| Row::new(crate::naming::display(name))), + ); } if !plugin.servers.is_empty() { out.push(Row::heading("mcp servers")); - out.extend(plugin.servers.iter().map(|id| Row::new(id.clone()))); + out.extend( + plugin + .servers + .iter() + .map(|id| Row::new(crate::naming::display(id))), + ); } if !plugin.hooks.is_empty() { // **One row per hook, naming the event and the command.** A bundle's hooks @@ -700,7 +715,12 @@ pub fn detail(plugin: &Listed, width: u16, glyphs: &Glyphs) -> Vec { } if !plugin.layers.is_empty() { out.push(Row::heading("policy layers, deny only")); - out.extend(plugin.layers.iter().map(|name| Row::new(name.clone()))); + out.extend( + plugin + .layers + .iter() + .map(|name| Row::new(crate::naming::display(name))), + ); } if out.is_empty() { @@ -1384,16 +1404,32 @@ mod tests { let labels: Vec<&str> = rows.iter().map(|row| row.label.as_str()).collect(); assert!(labels.contains(&"skills"), "{}: {labels:?}", glyphs.name); assert!(labels.contains(&"agents"), "{}: {labels:?}", glyphs.name); + // **Qualified, and never stripped.** The property is the provenance: + // a row drawn as the bare `reviewer` would put a bundle's agent on the + // pane under a name that says nothing about where it came from. Since + // 0.32.0 the qualifier is drawn with a colon rather than io-harness's + // `__`; what io-harness wrote is unchanged, and `crate::naming` is the + // only thing between the two. assert!( - labels.contains(&"rust-review__reviewer"), - "{}: the namespaced name io-harness wrote, not a stripped one: {labels:?}", + labels.contains(&"rust-review:reviewer"), + "{}: the qualified name, not a stripped one: {labels:?}", glyphs.name ); assert!( - labels.contains(&"rust-review__no-secrets"), + labels.contains(&"rust-review:no-secrets"), "{}: {labels:?}", glyphs.name ); + // The half that makes the two above mean something: the separator + // io-harness uses never reaches a reader. + for label in &labels { + assert!( + !label.contains(io_harness::NAMESPACE), + "{}: {label} reached the pane with io-harness's own separator \ + in it: {labels:?}", + glyphs.name + ); + } assert!( !labels.contains(&"mcp servers"), "{}: a group with nothing in it is not drawn: {labels:?}", diff --git a/src/skillview.rs b/src/skillview.rs index 3b75d3f..6796295 100644 --- a/src/skillview.rs +++ b/src/skillview.rs @@ -586,7 +586,10 @@ pub fn rows(skills: &[Listed], width: u16, glyphs: &Glyphs) -> Vec { )); } - Row::with_detail(skill.name.clone(), detail) + // **Displayed with a colon, addressed with the separator.** `name` is + // the string io-harness put in the model's catalogue and resolves by + // equality; what is drawn here is only what a person reads. + Row::with_detail(crate::naming::display(&skill.name), detail) }) .collect() } diff --git a/src/status.rs b/src/status.rs index c4beab8..2f07925 100644 --- a/src/status.rs +++ b/src/status.rs @@ -1989,7 +1989,9 @@ pub fn committed( } } facts.push(( - format!("policy {}", layer.name), + // A bundle's layer arrives qualified with `io_harness::NAMESPACE`; + // drawn with a colon, like every other contribution a bundle makes. + format!("policy {}", crate::naming::display(&layer.name)), if acts.is_empty() { format!("no rule {dash} it governs nothing") } else { diff --git a/tests/marketplace.rs b/tests/marketplace.rs index 8a7127e..fc32918 100644 --- a/tests/marketplace.rs +++ b/tests/marketplace.rs @@ -1392,14 +1392,25 @@ fn f6_the_install_discloses_the_harness_s_own_parse_before_it_writes() { marketplace::disclosure(Scope::Local, &dir).expect("io-harness read the directory"); assert_eq!(disclosure.id, "rust-review"); let said = disclosure.said(&io_cli::glyphs::UNICODE).join("\n"); + // **The name the operator will meet again, which since 0.32.0 is the + // qualified one with a colon.** The point of the assertion is unchanged and + // if anything sharper: consent has to be given to the name that appears on + // `/plugin`, `/skills`, the palette and the `Read skill` line, or the operator + // agrees to one thing and then reads about another. Before this release those + // surfaces all drew io-harness's `__`; now they all draw `:`, and the + // disclosure has to move with them. assert!( - said.contains("rust-review__reviewer"), - "the agent is not named as io-harness namespaced it, so the operator is \ - consenting to a name they will never see again: {said}", + said.contains("rust-review:reviewer"), + "the agent is not named as the operator will see it everywhere else, so \ + they are consenting to a name that appears nowhere: {said}", ); assert!( - said.contains("rust-review__no-secrets"), - "the policy layer is not named as io-harness namespaced it: {said}", + !said.contains(io_harness::NAMESPACE), + "io-harness's own separator reached the consent surface: {said}", + ); + assert!( + said.contains("rust-review:no-secrets"), + "the policy layer is not named as the operator will see it on /status: {said}", ); assert!( !said.contains("switched off"), From 13a0bac31dec6b694c5d92548982435d28ab86c9 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 30 Aug 2026 20:25:49 +0530 Subject: [PATCH 08/21] feat(queue): a message reaches the turn it was typed into MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A prompt typed mid-turn was queued and then nothing happened to it until the turn had ended. A correction typed thirty seconds into a ten-minute turn waited nine and a half minutes and then ran as a turn of its own, against the work it was meant to change. Both turn arms have carried a `SteerInbox` since 0.17.0, so this needed no io-harness entry point — only a default. The queue is now handed to the running turn at each step boundary. At the step boundary rather than on the keystroke, and that is not just where the contract puts it: flushing the moment the operator pressed Enter would empty the queue into the inbox instantly, and the queue surface — their only view of what they have typed and not yet had answered — would never have anything in it. Held until a step lands, the list is visible for exactly as long as it is genuinely waiting. `App::deliver_queued` holds the loop, the ordering and the transcript records. It is in the library because nothing under tests/ links the driver, and this loop decides whether an operator's words are kept. `/steer` and the automatic drain are its two callers, so they cannot diverge; `/steer`'s own sentences are unchanged. On a refusal the message goes back to the FRONT of the queue. Putting it back with `queue_prompt` appends, which would have sent everything queued behind it first and had the agent read the operator's corrections in an order nobody wrote. A message delivered into a turn that reaches no further step boundary is still in the inbox, and the existing post-loop drain re-queues it to run as its own turn. `f5_the_queue_is_sent_on_the_operators_word_and_not_before` asserted the opposite and was rewritten, not deleted. It counted `steer.say(` sites, and the second one this adds is exactly the sabotage it existed to catch — so it worked. Its "no echo" argument is answered, because every delivered message is now recorded into the transcript; its "no undo" argument was already true of the queue, since an unsent line ran as its own turn a moment later either way. It now counts two named sites so a third is still caught. --- src/app.rs | 43 ++++++++++++++ src/queue.rs | 20 +++++++ tests/queue_surface.rs | 132 +++++++++++++++++++++++++++++++++++++++++ tests/steer.rs | 87 +++++++++++++++++---------- 4 files changed, 251 insertions(+), 31 deletions(-) diff --git a/src/app.rs b/src/app.rs index a7d867a..8d140eb 100644 --- a/src/app.rs +++ b/src/app.rs @@ -936,6 +936,49 @@ impl App { &self.prompts } + /// Hand everything queued to the turn that is running, oldest first. + /// + /// **Each on its own, in the order they were typed**, because io-harness + /// pushes one `Observation` per message and the model reads them in that + /// order. Joining them would be one paragraph the operator never wrote. + /// + /// Each delivered message is recorded into the transcript rather than the + /// footer: a steered line becomes an observation in the run's ledger, so it is + /// part of the conversation rather than about it — and it is the only part + /// that will never get an echo of its own, because it is not a turn. + /// + /// **Delivery stops at the first refusal and the message goes back.** The one + /// thing worse than a correction that arrives late is one that reports success + /// and goes nowhere, so the caller gets the reason and the queue keeps what + /// was not sent. + /// + /// It lives here rather than in the driver because nothing under `tests/` + /// links `src/main.rs`: a loop written there could be neither asserted nor + /// sabotaged, and this one decides whether an operator's words are kept. + pub fn deliver_queued(&mut self, mut say: F) -> crate::queue::Delivered + where + F: FnMut(&str) -> Result<(), String>, + { + let mut delivered = crate::queue::Delivered::default(); + while let Some(waiting) = self.next_queued_prompt() { + if let Err(error) = say(&waiting) { + // **Back to the FRONT, not the back.** `queue_prompt` appends, + // which is right for a line the operator has just typed and wrong + // for one that was already first: appending it would put every + // message queued behind it ahead of it, so a refusal would + // silently reorder the operator's corrections and the agent would + // read them in an order nobody wrote. + self.prompts.insert(0, waiting); + self.status.queued_prompts = self.prompts.len(); + delivered.refused = Some(error); + return delivered; + } + self.record(Tone::Muted, format!("[mid-turn] {}", waiting.trim())); + delivered.sent += 1; + } + delivered + } + /// Whether the queue surface is on screen. /// /// **Three facts, and none of them is a fourth field.** It has been opened diff --git a/src/queue.rs b/src/queue.rs index 5efcbd8..9881b1b 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -499,3 +499,23 @@ pub fn render( // measured in prompts. frame.render_widget(Paragraph::new(lines), area); } + +/// What happened when a queue was handed to a running turn. +/// +/// Returned by [`crate::app::App::deliver_queued`], which is where the loop and +/// the transcript records live. This type exists so the driver has something to +/// report from rather than a count it computed itself — `/steer` and the +/// automatic drain say different sentences about the same outcome, and both have +/// to agree about what the outcome was. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Delivered { + /// How many messages reached the turn, in the order they were typed. + pub sent: usize, + /// Why the rest did not, if delivery stopped. + /// + /// **A refusal is not a count.** When this is set the message that failed has + /// been put back at the front of the queue, so it is still the operator's, and + /// a surface that reported `sent` alongside it would be claiming success on + /// the one path where there was none. + pub refused: Option, +} diff --git a/tests/queue_surface.rs b/tests/queue_surface.rs index 5933c44..9e3563d 100644 --- a/tests/queue_surface.rs +++ b/tests/queue_surface.rs @@ -649,3 +649,135 @@ fn a_queue_behind_an_open_fleet_view_answers_no_key() { "and nothing was put in the composer the fleet is covering", ); } + +// --------------------------------------------------------------------------- +// O7/O8/O9 — the list is drawn in full, and it reaches the turn it was typed into +// --------------------------------------------------------------------------- + +/// **O7 — four queued messages are four rows, with their positions, and nothing +/// is elided.** +/// +/// Through 0.31.0 the surface had exactly one row — released from the blank above +/// the activity line — so four messages collapsed to `1. … 3 more` and the +/// operator could see the first and a number. With the viewport sized to what the +/// queue asks for, the collapse survives only for a terminal that genuinely +/// cannot do better. +#[test] +fn o7_every_queued_message_is_listed_with_its_position() { + let mut app = with_queue(4); + let wanted = app.viewport_wanted(80, 40); + let rows = rows_at(&mut app, wanted); + let drawn = rows.join("\n"); + + for at in 0..4 { + let text = format!("queued prompt {at}"); + assert!( + drawn.contains(&text), + "{text} is not on screen, so the queue is still eliding: {drawn}", + ); + assert!( + drawn.contains(&format!("{}.", at + 1)), + "the position of {text} is missing: {drawn}", + ); + } + assert!( + !drawn.contains("more"), + "nothing should be elided when the viewport grew to hold it: {drawn}", + ); +} + +/// **O7's other half — a terminal that cannot grow still says what it is +/// holding.** The `room == 1` collapse is not deleted, it stops being what every +/// real session sees, and the count it reports has to be right. +#[test] +fn o7_a_terminal_too_small_to_grow_reports_what_it_did_not_draw() { + let mut app = with_queue(4); + // The floor, not what the queue asked for. + let rows = rows_at(&mut app, io_cli::term::VIEWPORT_HEIGHT); + let drawn = rows.join("\n"); + assert!( + drawn.contains("more"), + "a queue that cannot be drawn in full must say so: {drawn}", + ); + assert!( + drawn.contains('3'), + "four queued with one drawn leaves three, and the count must be right: {drawn}", + ); +} + +/// **O8 — the queue is delivered in the order it was typed, and each delivery is +/// recorded in the transcript.** +/// +/// The loop lives in `App::deliver_queued` rather than in the driver because +/// nothing under `tests/` links `src/main.rs`: a loop written there could be +/// neither asserted nor sabotaged, and this one decides whether an operator's +/// words are kept. +#[test] +fn o8_delivery_is_in_order_and_reaches_the_transcript() { + let mut app = with_queue(3); + let mut seen: Vec = Vec::new(); + let delivered = app.deliver_queued(|text| { + seen.push(text.to_string()); + Ok(()) + }); + + assert_eq!(delivered.sent, 3); + assert_eq!(delivered.refused, None); + assert_eq!( + seen, + vec![ + "queued prompt 0".to_string(), + "queued prompt 1".to_string(), + "queued prompt 2".to_string(), + ], + "io-harness pushes one observation per message and the model reads them \ + in order, so joining or reordering them is a paragraph the operator \ + never wrote", + ); + assert!( + app.queued_prompts().is_empty(), + "everything delivered leaves the queue", + ); + + let said = app + .take_pending() + .iter() + .flat_map(|line| line.spans.iter()) + .map(|span| span.content.to_string()) + .collect::>() + .join(" "); + for at in 0..3 { + assert!( + said.contains(&format!("queued prompt {at}")), + "a steered line is part of the conversation, not a footer note: {said}", + ); + } +} + +/// **O9 — a message that cannot be delivered is kept, not lost.** +/// +/// The one thing worse than a correction that arrives late is one that reports +/// success and goes nowhere. Delivery stops at the first refusal, the message goes +/// back to the front of the queue, and the caller is told why — so `sent` is never +/// reported alongside a refusal as though both were true. +#[test] +fn o9_a_message_that_cannot_be_delivered_goes_back_on_the_queue() { + let mut app = with_queue(3); + let mut calls = 0; + let delivered = app.deliver_queued(|_| { + calls += 1; + if calls == 2 { + Err("nothing is listening".to_string()) + } else { + Ok(()) + } + }); + + assert_eq!(delivered.sent, 1, "only the first one landed"); + assert_eq!(delivered.refused.as_deref(), Some("nothing is listening")); + assert_eq!( + app.queued_prompts(), + ["queued prompt 1", "queued prompt 2"], + "the message that failed is back at the front, with everything behind it", + ); +} diff --git a/tests/steer.rs b/tests/steer.rs index 1d34e55..afe4fb2 100644 --- a/tests/steer.rs +++ b/tests/steer.rs @@ -166,40 +166,44 @@ fn f5_the_stop_key_stays_on_the_observers_flag() { ); } -/// F5 — what is sent is what was queued, and it is sent because the operator -/// asked. +/// F5/O8 — what is sent is what was queued, and there are exactly two sites that +/// can send it. /// -/// **The open question this release had to answer.** A line typed mid-turn must -/// not reach the agent by itself. A delivered steer emits no event, so an -/// interface cannot show that the agent heard it — a line sent by default would -/// leave the screen with no echo, no cell and no confirmation, which is the same -/// shape as the keystroke 0.16.0 lost and `App::compose` has just been fixed to -/// stop losing. A queue is visible state a surface can draw; a steer is not. And -/// `Steer::say` has no undo, so an operator writing themselves a note while an -/// agent works would be steering it, once per stray sentence. +/// **This test asserted the opposite until 0.32.0, and the reversal is the +/// release.** It read: "a line typed mid-turn must not reach the agent by +/// itself", on two arguments. The first was that a delivered steer emits no +/// event, so a line sent by default would leave the screen with no echo — that is +/// answered, because `App::deliver_queued` records every delivered message into +/// the transcript exactly as `/steer` recorded one, and the contract requires it. +/// The second was that `Steer::say` has no undo, so a stray note becomes a steer +/// — which is true, and was already true of the queue: an unsent line ran as its +/// own turn against the same conversation a moment later. What changed is *when* +/// the agent reads it, not whether. /// -/// So the queue keeps its promise — three lines are three turns — and `/steer` is -/// the one word that spends it differently. +/// What the queue bought by waiting was a correction typed thirty seconds into a +/// ten-minute turn arriving nine and a half minutes late, against work it was +/// meant to change. That is the trade this release reverses. /// -/// Sabotage: send every queued prompt as it is typed. Only this test fails, and -/// it fails on the count: a second `steer.say(` is a second path, and a path -/// nobody asked for is the accident. +/// The count stays a count, because it is still the thing worth guarding: **two** +/// sites, named, so a third is caught exactly as the second would have been. +/// +/// Sabotage: add a third `steer.say(` anywhere in the driver. Only this test +/// fails, and it fails on the count. #[test] -fn f5_the_queue_is_sent_on_the_operators_word_and_not_before() { +fn f5_o8_the_queue_reaches_the_turn_from_exactly_two_named_sites() { let text = driver(); assert_eq!( text.matches("steer.say(").count(), - 1, - "one send site. Two means something else in the driver can steer a turn, and the operator \ - did not ask it to", + 2, + "two send sites: the step boundary, which is the default since 0.32.0, and `/steer`, which \ + is the operator saying it now. A third means something else in the driver can steer a \ + turn, and nobody asked it to", ); - // And that one site reads the queue rather than a line of its own: F5 is - // about a *queued* line reaching the running turn. - // The FIRST WORD, not the whole trimmed line. `/steer do the thing` used to - // miss this guard and fall to the mid-turn refusal below it, which told the - // operator to interrupt the turn first — the opposite of what the command - // does. + // The explicit one. The FIRST WORD, not the whole trimmed line: `/steer do + // the thing` used to miss this guard and fall to the mid-turn refusal below + // it, which told the operator to interrupt the turn first — the opposite of + // what the command does. let arm = text .split_once("line.split_whitespace().next() == Some(\"steer\")") .expect("the driver answers /steer while a turn is running") @@ -209,19 +213,40 @@ fn f5_the_queue_is_sent_on_the_operators_word_and_not_before() { .expect("the arm sits above the refusal it is an exception to") .0; assert!( - arm.contains("app.next_queued_prompt()"), - "what is sent is what was queued: {arm}", + arm.contains("app.deliver_queued("), + "what is sent is what was queued, through the one loop that records it: {arm}", ); assert!( arm.contains("steer.say("), "and it goes into the inbox the two arms were handed: {arm}", ); - // Said, never claimed delivered. Nothing on the way in confirms a message - // landed, so the sentence may not say it did. + + // The default one, and it is anchored on the step because that is what O8 + // asks for — and because flushing on the keystroke would empty the queue into + // the inbox instantly and leave the queue surface with nothing to draw. + let boundary = text + .split_once("EventKind::Step { .. })") + .expect("the driver delivers the queue at a step boundary") + .1; + let boundary = boundary + .split_once("commit_edits(") + .expect("the delivery sits above the commits that follow an event") + .0; assert!( - !arm.contains("delivered"), - "`Ok` from a send means the channel took the words, not that a step read them", + boundary.contains("app.deliver_queued(") && boundary.contains("steer.say("), + "the default drain is the same loop and the same inbox as `/steer`'s: {boundary}", ); + + // Said, never claimed delivered. Nothing on the way in confirms a message + // landed, so no sentence either site prints may say one did. Asserted over + // the string literals rather than over the arm's text, because the binding + // that carries the outcome is itself called `delivered`. + for said in [arm, boundary] { + assert!( + !said.contains("\"delivered"), + "`Ok` from a send means the channel took the words, not that a step read them: {said}", + ); + } } /// F5 — the words are not lost when the turn ends before a step reads them. From 58ee9f159def68775429ac6da1128bed7b15bc11 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 30 Aug 2026 20:26:22 +0530 Subject: [PATCH 09/21] feat(main): the command surface works while the agent does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mid-turn loop refused every slash but `/compact` and `/steer`, so `/status` was declined for the same reason `/clear` is and the sentence told the operator to interrupt their turn in order to read a report. And `/` and `@` reached `App::key`, where they are ordinary characters, so the palette typed a literal slash and said nothing. Ten commands now run mid-turn: /status /context /cost /stats /help /theme /copy /expand /fleet /image. The decision is `commands::runs_mid_turn` in the library, with the partition written out in both directions and checked to cover COMMANDS exactly, because nothing under tests/ links the driver and this is a list about what may happen while a turn holds the session, the store and the provider. `/config` is refused in every form. Its bare picker carries a row that re-reads the provider catalogue, writes prices into a scope file and reassigns the running turn's Config; filtering that one row out is the bare-from-argued split the contract refuses to make. Recorded as an iteration with the developer's approval. Neither `opens_palette` nor `complete::opens` changes — only the call sites. The pickers reachable mid-turn are exactly those whose outcome touches nothing the turn holds: the palette and path completion write into the composer, and `/theme` resolves a theme. A turn in flight holds `&mut Session` for the whole select loop, so five of the ten could not have read it. Their facts are captured before the turn borrows the session, into `TurnFacts`. That is not an approximation: the root does not move, and the head and the last on-path turn are both the turn before this one until this one commits, so mid-turn the snapshot is the live reading. `status::committed` takes root/id/head, and expand, step_detail and to_copy take the captured turn — better signatures regardless, since a report has no business holding a session it might mutate. `commit_image` becomes one implementation behind both doors. This product has twice shipped a surface answered one way by its key and another by its name; `/fleet` was the standing instance, and this release closes it. --- src/commands.rs | 150 +++++++++++ src/main.rs | 568 +++++++++++++++++++++++++++++++++-------- src/status.rs | 21 +- tests/context_share.rs | 12 +- tests/narrow.rs | 4 +- tests/plain.rs | 12 +- tests/status.rs | 4 +- 7 files changed, 657 insertions(+), 114 deletions(-) diff --git a/src/commands.rs b/src/commands.rs index 371df7f..b2ba4b4 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -2555,3 +2555,153 @@ fn table>(rows: &[(S, S)], width: usize, theme: &Theme) -> Vec bool { + let mut words = line.split_whitespace(); + let Some(first) = words.next() else { + return false; + }; + if !MID_TURN.contains(&format!("/{first}").as_str()) { + return false; + } + true +} + +#[cfg(test)] +mod mid_turn_tests { + use super::*; + + /// **O12 — the partition is exhaustive over `COMMANDS`.** + /// + /// Written out rather than derived, so a command added later lands in the + /// failure message by name instead of defaulting quietly into either half. + /// That is the idiom `tests/commands.rs`'s inert-command filter already uses, + /// and the defect it exists for: a permissive default is a write in a running + /// turn that nobody chose. + #[test] + fn o12_every_command_is_either_admitted_or_refused_and_the_lists_are_written_out() { + let admitted: Vec<&str> = COMMANDS + .iter() + .map(|(name, _)| *name) + .filter(|name| runs_mid_turn(name.trim_start_matches('/'))) + .collect(); + assert_eq!( + admitted, + vec![ + "/help", "/theme", "/expand", "/status", "/context", "/copy", "/copy diff", + "/fleet", "/image", "/cost", "/stats", + ], + "the mid-turn half of COMMANDS changed; every entry here is a decision \ + about what may happen while a turn holds the session, the store and \ + the provider", + ); + + let refused: Vec<&str> = COMMANDS + .iter() + .map(|(name, _)| *name) + .filter(|name| !runs_mid_turn(name.trim_start_matches('/'))) + .collect(); + assert_eq!( + refused, + vec![ + "/exit", "/setup", "/model", "/resume", "/fork", "/steer", "/compact", "/commit", + "/config", "/remember", "/memory", "/skills", "/mcp", "/provider", "/plugin", + "/import", "/profile", "/effort", "/contain", "/undo", "/plan", "/clear", "/store", + "/export", "/gates", + ], + "the refused half of COMMANDS changed", + ); + + assert_eq!( + admitted.len() + refused.len(), + COMMANDS.len(), + "every command is in exactly one half", + ); + } + + /// **`/config` is refused in every form**, including the bare one the release + /// contract originally admitted. Its picker carries a row that writes a scope + /// file and reassigns the running turn's `Config`, and a filter that removed + /// just that row would be the bare-from-argued split this product decided not + /// to make. Asserted in all four shapes so the decision cannot be undone by + /// half. + #[test] + fn config_is_refused_in_every_form_including_the_bare_one() { + assert!(!runs_mid_turn("config")); + assert!(!runs_mid_turn("config ")); + assert!(!runs_mid_turn("config list")); + assert!(!runs_mid_turn("config get run.max_steps")); + assert!(!runs_mid_turn("config run.max_steps 40")); + } + + #[test] + fn a_word_that_is_not_a_command_is_refused() { + assert!(!runs_mid_turn("")); + assert!(!runs_mid_turn(" ")); + assert!(!runs_mid_turn("definitely-not-a-command")); + // A skill invocation hands work to the agent, which is a submit. + assert!(!runs_mid_turn("ultraship:brainstorm")); + } + + #[test] + fn the_two_that_already_ran_mid_turn_are_not_in_this_list() { + // `/steer` and `/compact` reach the turn through their own guarded arms, + // ahead of the refusal, and they act on the `Steer` handle rather than on + // the app. Admitting them here as well would give each two routes into one + // turn, which is the shape `/fleet` had — answered one way by its key and + // another by its name — and that inconsistency is one of the things this + // release exists to remove. + assert!(!runs_mid_turn("steer")); + assert!(!runs_mid_turn("compact")); + } +} diff --git a/src/main.rs b/src/main.rs index 2160671..7e185eb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5864,53 +5864,17 @@ async fn loop_over Result>( // The picture, drawn now, at the bottom. It cannot open the row // it was announced on: that row is in the terminal's scrollback, // which nothing here reaches. + // One implementation, two doors — see `commit_image`. `/image` + // reached this arm alone until 0.32.0 gave it a second at the + // mid-turn loop, and a surface answered one way by its key and + // another by its name is a defect this product has shipped twice. Action::Image(which) => { - let total = app.images(); - match which.and_then(|n| app.image(n).map(|path| (n, path.to_string()))) { - Some((number, path)) => { - let effective = - approval::session_policy(&policy, app.posture(), app.remembered()); - match io_cli::attach::prepare( - session.root(), - &effective, - provider.accepts_images(), - &path, - ) { - Ok(staged) => { - let (drawable, graphics) = forms(&app); - let drawn = io_cli::picture::render( - &staged.bytes, - &staged.path, - staged.media_type, - drawable, - graphics, - screen.width(), - ); - let caption = app.theme.notice( - Tone::Muted, - io_cli::picture::caption( - number, - &staged.path, - staged.media_type, - staged.bytes.len(), - ), - ); - commit_drawn(screen, &mut app, drawn, Some(caption))?; - } - Err(error) => app.say(Tone::Error, error), - } - } - None if total == 0 => { - app.say(Tone::Muted, "no image has been attached in this session") - } - None => app.say( - Tone::Muted, - format!("say which one: /image 1 to /image {total}"), - ), - } + let root = session.root().to_path_buf(); + commit_image(&mut app, screen, &root, &policy, &provider, which)?; } Action::Expand => { - let lines = expand(&session, &store, &app.theme, app.events.thought()); + let last = last_run(&session, &store); + let lines = expand(last.as_ref(), &store, &app.theme, app.events.thought()); screen.commit(&lines).map_err(|error| error.to_string())?; } // **Committed upward, exactly as `/expand` and `Ctrl+T` are.** @@ -5948,7 +5912,9 @@ async fn loop_over Result>( ); let lines = io_cli::status::committed( &app.status, - &session, + session.root(), + session.id(), + session.head(), &policy, &reading, // What the NEXT turn would run under, which is why @@ -6260,7 +6226,8 @@ async fn loop_over Result>( screen.commit(&lines).map_err(|error| error.to_string())?; } Action::Copy(what) => { - let (payload, said) = to_copy(&session, &store, what); + let last = last_run(&session, &store); + let (payload, said) = to_copy(last.as_ref(), &store, what); match payload { Some(payload) => { screen @@ -6491,6 +6458,10 @@ async fn loop_over Result>( // steered, so that is not the word for the difference. contained.then_some(containment.as_ref()).flatten(), &capabilities, + // The palette's rows, so `/` mid-turn offers exactly what + // `/` at an idle prompt offers. + &templates, + &skills, &seen, planning, // Taken rather than read: one request, one turn. A queue @@ -6745,6 +6716,143 @@ fn forms(app: &App) -> (bool, io_cli::term::Graphics) { /// **The agent's own look, committed where it looked.** /// /// A wrapper over [`io_cli::attach::viewed`], which is where every decision +/// A keystroke while a picker is open on top of a running turn. +/// +/// **Only the three kinds `turn()` can open reach here**, and that is the whole +/// safety argument: the palette and path completion set or paste into the +/// composer, and `/theme` resolves a theme. None of them writes a file, touches +/// the store, or reassigns anything the running turn is holding — which is the +/// property that lets a picker be open at all while a turn owns the session. +/// +/// It lives beside the loop rather than inside it so the `select!` arm stays +/// readable; every decision in it is a library call. +fn mid_turn_picker( + picker: &mut Option<(Picker, Pick)>, + app: &mut App, + // The workspace root, captured before the turn borrowed the session. + root: &std::path::Path, + policy: &Policy, + templates: &io_harness::Templates, + skills: &[io_cli::skillview::Listed], + key: crossterm::event::KeyEvent, +) { + let Some((open, kind)) = picker.as_mut() else { + return; + }; + match open.key(key) { + Outcome::Chosen(index) => { + let label = open.rows()[index].label.clone(); + // Built while `kind` still borrows the picker, installed once the + // borrow ends — the same shape the idle loop's `descended` uses, and + // for the same reason: a completion that descends replaces itself. + let mut descended = None; + match kind { + Pick::Theme => { + if let Some(chosen) = Theme::by_name(&label) { + // Resolved, not assigned, exactly as the idle picker + // does it: a mid-session picker that beat `NO_COLOR` + // would make the variable mean *until you touch + // anything*. + let applied = Theme::from_env(Some(chosen.name), app.theme.glyphs); + app.theme = applied; + app.events.set_theme(applied); + } + } + Pick::Palette => match commands::palette_pick(templates, skills, index) { + Some(commands::Chosen::Command(command)) => app.composer.set(command), + Some(commands::Chosen::Template(name)) => { + match commands::expand(templates, &name) { + Ok(prompt) => app.composer.set(&prompt), + Err(error) => app.say(Tone::Error, error), + } + } + Some(commands::Chosen::Skill(name)) => { + app.composer.set(&commands::invoke_skill(&name)); + } + None => {} + }, + Pick::Complete(entries) => match complete::pick(entries, index) { + Some(complete::Picked::Insert(path)) => app.composer.paste(&path), + Some(complete::Picked::Descend(dir)) => { + let effective = + approval::session_policy(policy, app.posture(), app.remembered()); + match completion(root, &effective, &dir, &app.theme.glyphs) { + Ok(Some(open)) => descended = Some(open), + Ok(None) => { + app.say(Tone::Muted, format!("nothing to complete in {dir}")) + } + Err(error) => app.say(Tone::Error, error), + } + } + None => app.say( + Tone::Muted, + "that row is the note, not a file — the rest of the listing is not shown", + ), + }, + // `turn()` opens no other kind. An assertion rather than a + // silence, so opening one without wiring it here is caught in a + // debug build. + _ => debug_assert!(false, "a picker kind reached the mid-turn loop with no arm"), + } + *picker = descended; + } + Outcome::Cancelled => *picker = None, + Outcome::Idle => {} + } +} + +/// Draw an attached image back into the scrollback, at either loop. +/// +/// One implementation because there are two doors since 0.32.0 — `/image` at an +/// idle prompt and `/image` while a turn runs — and this product has shipped the +/// same defect twice from having two: a surface answered one way by its key and +/// another by its name. Every decision inside it is a library call. +fn commit_image( + app: &mut App, + screen: &mut Screen>, + root: &std::path::Path, + policy: &Policy, + provider: &P, + which: Option, +) -> Result<(), String> { + let total = app.images(); + match which.and_then(|n| app.image(n).map(|path| (n, path.to_string()))) { + Some((number, path)) => { + let effective = approval::session_policy(policy, app.posture(), app.remembered()); + match io_cli::attach::prepare(root, &effective, provider.accepts_images(), &path) { + Ok(staged) => { + let (drawable, graphics) = forms(app); + let drawn = io_cli::picture::render( + &staged.bytes, + &staged.path, + staged.media_type, + drawable, + graphics, + screen.width(), + ); + let caption = app.theme.notice( + Tone::Muted, + io_cli::picture::caption( + number, + &staged.path, + staged.media_type, + staged.bytes.len(), + ), + ); + commit_drawn(screen, app, drawn, Some(caption))?; + } + Err(error) => app.say(Tone::Error, error), + } + } + None if total == 0 => app.say(Tone::Muted, "no image has been attached in this session"), + None => app.say( + Tone::Muted, + format!("say which one: /image 1 to /image {total}"), + ), + } + Ok(()) +} + /// lives. Nothing is decided here, deliberately: `src/main.rs` is linked by no /// integration test, so a branch written here could not be sabotaged and would /// not be covered. @@ -6795,6 +6903,12 @@ async fn turn( config: &Config, containment: Option<&io_harness::Containment>, capabilities: &io_cli::contract::Capabilities, + // **The palette's rows, since 0.32.0 opens it while a turn runs.** Both are + // the driver's session-lifetime state — `skills` is re-walked at every turn + // boundary — and they are handed down rather than rebuilt, so what the + // mid-turn palette offers is exactly what the idle one offers. + templates: &io_harness::Templates, + skills: &[io_cli::skillview::Listed], // The last request this session's provider was handed. `/context` reads it to // say what is in the window, and `ctx N%` reads it so the two say the same // thing — see `note_context`, and the live run that found them disagreeing. @@ -6817,6 +6931,11 @@ async fn turn( text: String, started: Instant, ) -> Result { + // **A picker can be open while the agent works, since 0.32.0.** Only the + // three kinds `mid_turn_picker` names ever land here — the palette, path + // completion and `/theme` — because those are the ones whose outcome touches + // nothing the running turn is holding. + let mut picker: Option<(Picker, Pick)> = None; let (observer, mut events) = bridge::channel(); // The one way a turn is stopped from the interface, contained or not. Both // arms take a contract **and** a steer inbox since 0.17.0, and the stop key @@ -6896,6 +7015,22 @@ async fn turn( // Taken before the future borrows the session, because it is needed inside the // loop and `running` holds `&mut session` for the whole of it. let root = session.root().to_path_buf(); + // **What the mid-turn reports need, captured before the turn takes the + // session.** The future below holds `&mut Session` for the whole of the + // select loop, so nothing inside it can read the session at all — and since + // 0.32.0 `/status`, `/cost`, `/copy` and `/expand` run inside it. + // + // Every fact here is one the running turn cannot change while it runs: the id + // is the session's, the head is the turn *before* this one until this one + // commits, and the last on-path turn is likewise the previous one. So the + // snapshot is not an approximation of the live reading — mid-turn it **is** + // the live reading, and taking it here is what makes the reports reachable at + // all without a second `&Session` the borrow checker is right to refuse. + let facts = TurnFacts { + id: session.id(), + head: session.head(), + last: last_run(session, store), + }; app.contained = containment.is_some(); // Built before the future borrows it, and for both arms alike. // **Every turn carries one now, contained or not.** Through 0.11.0 the flat @@ -7035,7 +7170,7 @@ async fn turn( result = &mut running => break Some(result), _ = ticker.tick() => { if app.tick(started.elapsed()) { - paint(screen, app)?; + paint_picker(screen, app, picker.as_mut())?; } } Some(event) = events.recv() => { @@ -7045,6 +7180,43 @@ async fn turn( let at = started.elapsed(); app.status.elapsed = at; app.event(&event, at); + // **A queued message reaches the turn it was typed into + // (0.32.0).** `App::compose` queues a prompt typed mid-turn and + // answers `Command::None`, and until this release nothing did + // anything with it until the turn had ended — so a correction + // typed thirty seconds into a ten-minute turn waited nine and a + // half minutes and then ran as a turn of its own, against work it + // was meant to change. + // + // **At the step boundary, and that is not merely where O8 puts + // it.** Flushing the moment the operator pressed Enter would empty + // io-cli's queue into io-harness's inbox instantly, and the queue + // surface — the operator's only view of what they have typed and + // not yet had answered — would never have anything in it. Held + // until a step lands, the list is visible for as long as it is + // genuinely waiting, and goes when it is genuinely gone. + // + // Both arms have carried a `SteerInbox` since 0.17.0, so this + // needs no io-harness entry point — only a default. `/steer` is + // unchanged and still flushes explicitly; what it flushes is now + // whatever has been typed since the last step. + // + // **And a message delivered into a turn that reaches no further + // step boundary is not lost.** The inbox still holds it, and the + // drain after this loop reads `inbox.pending().messages` back and + // re-queues them to run as their own turns — the existing path, + // which is O9's fallback and says which of the two happened. + if matches!(event.kind, io_harness::EventKind::Step { .. }) + && !app.queued_prompts().is_empty() + { + let dash = app.theme.glyphs.dash; + let delivered = app.deliver_queued(|text| { + steer.say(text.to_string()).map_err(|e| e.to_string()) + }); + if let Some(error) = delivered.refused { + app.say(Tone::Warning, format!("nothing is listening {dash} {error}")); + } + } commit_edits(app, store, &event, screen.width()); commit_commits(app, store, &event); // The live half of `ctx N%`. Anchored on a step rather than on @@ -7061,7 +7233,7 @@ async fn turn( note_fleet(app, store, &event, &contract); commit_viewed(screen, app, &root, policy, &event)?; commit_fold(app, store, &event, &mut folding); - paint(screen, app)?; + paint_picker(screen, app, picker.as_mut())?; } Some(ask) = asks.recv() => { // The run is now stopped inside `Approver::decide_in_context` and @@ -7069,7 +7241,7 @@ async fn turn( // which is what leaves `Ctrl+C` reachable while a question is up. app.open_approval(ask); app.status.elapsed = started.elapsed(); - paint(screen, app)?; + paint_picker(screen, app, picker.as_mut())?; } Some(proposed) = plans.recv() => { // The run is stopped inside `PlanGate::review`, and while it is @@ -7077,7 +7249,7 @@ async fn turn( // the workspace behind this overlay cannot change while it is up. app.open_plan(proposed); app.status.elapsed = started.elapsed(); - paint(screen, app)?; + paint_picker(screen, app, picker.as_mut())?; } Some(asked) = questions.recv() => { // The same shape one seam over: the run is stopped inside @@ -7088,7 +7260,7 @@ async fn turn( // since.) app.open_intent(asked); app.status.elapsed = started.elapsed(); - paint(screen, app)?; + paint_picker(screen, app, picker.as_mut())?; } Some(input) = inputs.recv() => { match input { @@ -7096,7 +7268,56 @@ async fn turn( // Bound rather than tested inside the guard: `App::key` // changes state, and a match guard is not a place to put // something that does. - let command = app.key(key); + // **The command surface opens while the agent works + // (0.32.0, O10).** `commands::opens_palette` and + // `complete::opens` carry no run-state check at all and + // never did — the mid-turn loop simply never called them, + // and went straight to `App::key`, where `/` and `@` are + // ordinary characters. So the palette typed a literal + // slash into the composer and said nothing. + // + // Neither predicate changes; only the call sites, which + // is what the contract asked for. The pickers reachable + // here are exactly those whose outcome touches nothing + // but the interface — the palette and path completion set + // or paste into the composer, and `/theme` resolves a + // theme. Anything that writes a file or reassigns + // session-lifetime state keeps its refusal, which is why + // `/config` is not among them. + // + // Answered as `Command::None` so the match below is + // untouched: a keystroke the picker took is a keystroke + // the session did not see. + let command = if picker.is_some() { + mid_turn_picker(&mut picker, app, &root, policy, templates, skills, key); + Command::None + } else if commands::opens_palette(key, app.composer.is_empty(), app.armed()) + { + picker = Some(( + Picker::new( + "Which command?", + commands::palette(templates, skills), + ), + Pick::Palette, + )); + Command::None + } else if complete::opens(key, &app.composer.text(), app.armed()) { + let effective = approval::session_policy( + policy, + app.posture(), + app.remembered(), + ); + match completion(&root, &effective, "", &app.theme.glyphs) { + Ok(Some(open)) => picker = Some(open), + Ok(None) => { + app.say(Tone::Muted, "nothing in this workspace to complete") + } + Err(error) => app.say(Tone::Error, error), + } + Command::None + } else { + app.key(key) + }; match command { Command::Interrupt => { // **One path for both kinds of turn, and it is @@ -7237,51 +7458,30 @@ async fn turn( // first — the opposite of what this command does. Command::Slash(ref line) if line.split_whitespace().next() == Some("steer") => { + // The loop, the ordering and the transcript + // records are `App::deliver_queued`'s, in the + // library, because nothing under `tests/` links + // this file. What is left here is the sentence, + // and `/steer`'s sentence is unchanged from + // 0.31.0 — including its refusal path, and + // including the summary being skipped on a + // refusal, since `App::say` keeps one notice and + // a count written over the error would report + // success on the one path where there was none. let dash = app.theme.glyphs.dash; - let mut sent = 0usize; - // The summary below is skipped when this is set, - // because a count is not the answer to a refusal - // — and `App::say` keeps one notice, so a - // summary written over the error would be the - // interface reporting success on the one path - // where there was none. - let mut refused = false; - // Each on its own, in the order they were typed: - // io-harness pushes one `Observation` per message - // and the model reads them in that order. Joining - // them would be one paragraph the operator never - // wrote. - while let Some(waiting) = app.next_queued_prompt() { - if let Err(error) = steer.say(waiting.clone()) { - // Unreachable while `inbox` is alive — - // and said rather than swallowed anyway, - // because the one thing worse than a - // correction that arrives late is one - // that reports success and goes nowhere. - app.queue_prompt(waiting); - app.say( - Tone::Warning, - format!("nothing is listening {dash} {error}"), - ); - refused = true; - break; - } - // Into the transcript, not the footer. A - // steered line becomes an observation in the - // run's ledger, so it is part of the - // conversation rather than about it — and it - // is the only part that will never get an - // echo of its own, because it is not a turn. - app.record( - Tone::Muted, - format!("[mid-turn] {}", waiting.trim()), - ); - sent += 1; - } - if !refused { - app.say( + let delivered = app.deliver_queued(|text| { + steer.say(text.to_string()).map_err(|e| e.to_string()) + }); + match delivered.refused { + // Unreachable while `inbox` is alive, and said + // rather than swallowed anyway. + Some(error) => app.say( + Tone::Warning, + format!("nothing is listening {dash} {error}"), + ), + None => app.say( Tone::Muted, - match sent { + match delivered.sent { 0 => format!( "nothing queued {dash} type a line first, then \ /steer" @@ -7294,7 +7494,7 @@ async fn turn( next step" ), }, - ); + ), } } // Refused with a sentence rather than dropped in @@ -7313,6 +7513,141 @@ async fn turn( // be unreadable for as long as the command took. So // the block is confined to the idle prompt, where // there is no turn for it to stall. + // **The refusal is a list now, not a blanket + // (0.32.0).** Through 0.31.0 every slash but + // `/compact` and `/steer` was declined here, so + // `/status` was refused for the same reason `/clear` + // is and the sentence told the operator to interrupt + // their turn to read a report. Every one of the ten + // admitted below was a capability the product already + // had, withheld by a guard nobody revisited. + // + // The decision is `commands::runs_mid_turn`, in the + // library, because nothing under `tests/` links this + // file: a list written here could be neither asserted + // exhaustively nor sabotaged, and it is a list about + // what may happen while a turn holds the session, the + // store and the provider. + Command::Slash(ref line) if commands::runs_mid_turn(line) => { + match commands::parse(line, app.keys(), &app.theme) { + Action::Print(lines) => { + screen.commit(&lines).map_err(|e| e.to_string())?; + } + Action::Fleet => app.toggle_fleet(), + Action::Expand => { + let lines = expand( + facts.last.as_ref(), + store, + &app.theme, + app.events.thought(), + ); + screen.commit(&lines).map_err(|e| e.to_string())?; + } + Action::Stats => { + let lines = io_cli::stats::committed( + store, + &app.theme, + screen.width(), + )?; + screen.commit(&lines).map_err(|e| e.to_string())?; + } + Action::Cost => { + let (settings, _) = settings::stored(config); + let table = io_cli::cost::table(config); + let provenance = io_cli::cost::Provenance::of( + config, + settings.as_ref(), + ); + let lines = io_cli::cost::committed( + store, + &table, + &provenance, + facts.last.as_ref().map(|turn| turn.run_id), + Some(facts.id), + &app.theme, + screen.width(), + )?; + screen.commit(&lines).map_err(|e| e.to_string())?; + } + // **The turn's own contract, not a sixth + // one.** The idle arms build a `reading` + // contract because there is no turn to read + // one off; here there is, and it is the one + // actually in force — which is both the more + // truthful answer mid-turn and what keeps + // `tests/contract.rs`'s count of + // `contract::session` call sites intact. + Action::Status => { + let lines = io_cli::status::committed( + &app.status, + &root, + facts.id, + facts.head, + policy, + &contract, + containment, + &app.theme, + screen.width(), + ); + screen.commit(&lines).map_err(|e| e.to_string())?; + } + Action::Context => { + let lines = io_cli::context::committed( + seen.latest().as_ref(), + &contract, + contract.max_tokens, + &app.theme, + screen.width(), + ); + screen.commit(&lines).map_err(|e| e.to_string())?; + } + Action::Copy(what) => { + let (payload, said) = to_copy(facts.last.as_ref(), store, what); + match payload { + Some(payload) => { + screen + .escape(&io_cli::clipboard::sequence(&payload)) + .map_err(|e| e.to_string())?; + app.say( + Tone::Muted, + io_cli::clipboard::describe(&payload), + ); + } + None => app.say(Tone::Muted, said), + } + } + Action::Image(which) => commit_image( + app, + screen, + &root, + policy, + provider, + which, + )?, + Action::Theme => { + picker = Some(( + Picker::new( + "Which theme?", + io_cli::theme::THEMES + .iter() + .map(|theme| Row::new(theme.name)) + .collect(), + ), + Pick::Theme, + )); + } + // `runs_mid_turn` admits exactly the words + // above, so nothing else can reach here. It + // is an assertion rather than a wildcard so + // that widening the list without wiring the + // command is caught in a debug build rather + // than by silence. + _ => debug_assert!( + false, + "runs_mid_turn admitted a command with no arm here" + ), + } + } Command::Slash(_) | Command::Shell(_) => { let dash = app.theme.glyphs.dash; app.say( @@ -7371,7 +7706,7 @@ async fn turn( _ => {} } app.status.elapsed = started.elapsed(); - paint(screen, app)?; + paint_picker(screen, app, picker.as_mut())?; } } }; @@ -7990,6 +8325,18 @@ fn observing( /// summary line and this function was left with none. Found by the adversarial /// review, and worth noting because nothing in the suite can see a rustdoc /// summary attached to the wrong item. +/// The session facts a mid-turn report needs, read before the turn borrows the +/// session. +/// +/// See where it is built in `turn`: each of these is unchanged for as long as the +/// turn runs, which is what makes a snapshot the honest reading rather than a +/// stale one. +struct TurnFacts { + id: i64, + head: Option, + last: Option, +} + fn last_run(session: &Session, store: &Store) -> Option { session .transcript(store) @@ -8007,7 +8354,12 @@ fn last_run(session: &Session, store: &Store) -> Option, store: &Store, theme: &Theme, thought: Option<&str>, @@ -8022,7 +8374,7 @@ fn expand( ); lines.push(Line::from("")); } - lines.extend(step_detail(session, store, theme)); + lines.extend(step_detail(last, store, theme)); lines } @@ -8032,8 +8384,12 @@ fn expand( /// archive, so the output goes to the store when it happens and comes back here /// when somebody asks for it. Committed upward like everything else that shows /// more of something. -fn step_detail(session: &Session, store: &Store, theme: &Theme) -> Vec> { - let Some(turn) = last_run(session, store) else { +fn step_detail( + last: Option<&io_harness::TranscriptTurn>, + store: &Store, + theme: &Theme, +) -> Vec> { + let Some(turn) = last else { return vec![theme.notice(Tone::Muted, "nothing has run in this session yet")]; }; let steps = match store.steps(turn.run_id) { @@ -8066,12 +8422,16 @@ fn step_detail(session: &Session, store: &Store, theme: &Theme) -> Vec (Option, String) { - let Some(turn) = last_run(session, store) else { +fn to_copy( + last: Option<&io_harness::TranscriptTurn>, + store: &Store, + what: Copied, +) -> (Option, String) { + let Some(turn) = last else { return (None, "nothing has run in this session yet".into()); }; match what { - Copied::Answer => match turn.reply { + Copied::Answer => match turn.reply.clone() { Some(reply) if !reply.trim().is_empty() => (Some(reply), String::new()), // A turn that stopped on a ceiling, a refusal or an interrupt has no // closing message, and inventing one would misreport the ending. diff --git a/src/status.rs b/src/status.rs index 2f07925..6dd6559 100644 --- a/src/status.rs +++ b/src/status.rs @@ -21,7 +21,7 @@ use std::time::Duration; -use io_harness::{Containment, Policy, Session, TaskContract}; +use io_harness::{Containment, Policy, TaskContract}; use ratatui::layout::Rect; use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; @@ -1889,7 +1889,16 @@ pub fn format_containment(mode: &str, backend: &str) -> String { #[allow(clippy::too_many_arguments)] pub fn committed( status: &Status, - session: &Session, + // **The session's three facts rather than the session (0.32.0).** `/status` + // runs while a turn is in flight now, and a turn in flight holds `&mut + // Session` for the whole of the driver's select loop — so a report that took + // `&Session` could not be reached from there at all. All three are captured + // before the turn starts, and all three are still true while it runs: the + // root does not move, the id is the session's, and the head is the turn + // *before* this one until this one commits. + root: &std::path::Path, + id: i64, + head: Option, policy: &Policy, contract: &TaskContract, // The caps the NEXT turn would run under, which is `None` both for a session @@ -1907,7 +1916,7 @@ pub fn committed( // The workspace and the conversation, both asked of the `Session` rather than // threaded down from the driver, so there is one answer to "which workspace // is this" and it is io-harness's — the same rule `App::set_root` follows. - facts.push(("workspace".into(), session.root().display().to_string())); + facts.push(("workspace".into(), root.display().to_string())); // Directly under the workspace, because it is the second half of the same // fact: the path says which directory, and this says which of its branches is @@ -1946,9 +1955,9 @@ pub fn committed( )); facts.push(( "session".into(), - match session.head() { - Some(head) => format!("{} {dash} head at turn {head}", session.id()), - None => format!("{} {dash} no turn has run in it yet", session.id()), + match head { + Some(head) => format!("{id} {dash} head at turn {head}"), + None => format!("{id} {dash} no turn has run in it yet"), }, )); facts.push(("model".into(), status.model.clone())); diff --git a/tests/context_share.rs b/tests/context_share.rs index 07f4f08..088c973 100644 --- a/tests/context_share.rs +++ b/tests/context_share.rs @@ -476,7 +476,17 @@ fn the_status_page_names_the_window_before_anything_has_been_assembled() { let contract = tight(); let page = |status: &Status| { - io_cli::status::committed(status, &session, &policy, &contract, None, &DARK, 100) + io_cli::status::committed( + status, + session.root(), + session.id(), + session.head(), + &policy, + &contract, + None, + &DARK, + 100, + ) .iter() .map(|line| { line.spans diff --git a/tests/narrow.rs b/tests/narrow.rs index 8854be8..eca398a 100644 --- a/tests/narrow.rs +++ b/tests/narrow.rs @@ -2168,7 +2168,9 @@ fn f11_status_folds_at_eighty_columns_rather_than_losing_the_end_of_a_row() { let set = theme.glyphs.name; let rows = text_of(&io_cli::status::committed( &app.status, - &session, + session.root(), + session.id(), + session.head(), &policy, &contract, Some(&caps), diff --git a/tests/plain.rs b/tests/plain.rs index ffd3c54..9561089 100644 --- a/tests/plain.rs +++ b/tests/plain.rs @@ -680,7 +680,17 @@ fn f11_status_in_plain_mode_is_the_same_content_in_the_ascii_set() { // The mode, set exactly where the driver sets it. status.plain = plain; status.budgets = io_cli::status::Budgets::in_force(&contract); - io_cli::status::committed(&status, &session, &policy, &contract, None, &theme, 80) + io_cli::status::committed( + &status, + session.root(), + session.id(), + session.head(), + &policy, + &contract, + None, + &theme, + 80, + ) .iter() .map(text_of) .collect() diff --git a/tests/status.rs b/tests/status.rs index 5e12cd8..7b3e2a1 100644 --- a/tests/status.rs +++ b/tests/status.rs @@ -1583,7 +1583,9 @@ fn committed_of( ) -> Vec { io_cli::status::committed( &app.status, - &fixture.session, + fixture.session.root(), + fixture.session.id(), + fixture.session.head(), &fixture.policy, contract, caps, From ab8e7300d69eb5588ab9dc4d0261170a401b6e5b Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 30 Aug 2026 20:35:12 +0530 Subject: [PATCH 10/21] feat(status): the token figure moves while the turn is spending MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `EventKind::Token` carries text and no count — providers do not bill per chunk — so nothing moved the token field until a step committed. The one number telling an operator what a turn was costing them sat still for the whole of it, beside a clock that ran. It is now accumulated from the streamed deltas through io-harness's own `estimate_tokens`, the same estimator `/context` already uses, so io-cli holds no tokenizer and nothing enters the tree. `App::tick` already redraws on the hundred-millisecond tick, so the figure moves with the clock without a second timer. Drawn `~1.2k tok` while it is an estimate and `1.2k tok` once it is not. One column, in both glyph sets, legible under NO_COLOR — a number that reads as settled and is not would be worse than the frozen count it replaces. The tilde says approximately rather than still-arriving, which is the honest claim: what is uncertain is the figure, not whether more is coming. The estimate is discarded at Step and Finished, never added to a settled number, and cleared in `forget_run` so it cannot survive into a conversation it was not measured in. All three token renderers go through one helper. `Status::line` and `Status::footer` are separate and the binary draws the footer on any terminal seven rows or taller, so a field added to one is a field nobody sees — 0.8.0's spend field and 0.12.0's planning field both paid for that. --- src/app.rs | 34 ++++++++++++++++-- src/status.rs | 79 ++++++++++++++++++++++++++++++++++------- tests/status.rs | 94 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 192 insertions(+), 15 deletions(-) diff --git a/src/app.rs b/src/app.rs index 8d140eb..606b286 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1446,8 +1446,17 @@ impl App { } else { 0 }; - let chrome = 6 + u16::from(queued == 0); - chrome.saturating_add(composer).saturating_add(queued) + // The fleet view is drawn **instead of** the queue, in the composer's own + // rect, so it asks in the composer's place rather than beside it. Without + // this it kept the rows a composer needs and elided a fan-out into two + // lines — and since 0.32.0 one of those two would be the count saying so. + let surface = if self.fleet_open { + self.fleet.rows_wanted().max(composer) + } else { + composer.saturating_add(queued) + }; + let chrome = 6 + u16::from(queued == 0 && !self.fleet_open); + chrome.saturating_add(surface) } /// Start a new conversation, or refuse because a turn is in flight. @@ -1528,7 +1537,24 @@ impl App { io_harness::EventKind::Routed { to, .. } if !to.is_empty() => { self.status.model = to.clone(); } + // **The one signal that arrives while a step is spending (0.32.0).** + // `EventKind::Token` carries text and no count — providers do not + // bill per chunk — so the figure a reader sees moving is necessarily + // an estimate, taken from the delta through io-harness's own + // `estimate_tokens`, which is what `/context` already estimates with. + // It is drawn with a leading tilde and replaced by the provider's own + // number the moment the step commits. + io_harness::EventKind::Token { text } => { + let estimate = io_harness::context::estimate_tokens(text) as u64; + self.status.streaming = + Some(self.status.streaming.unwrap_or(0).saturating_add(estimate)); + } io_harness::EventKind::Step { tokens, .. } => { + // **The estimate is discarded, never added to.** The provider's + // number is what this step actually cost, and carrying the guess + // forward beside it would make every later figure the sum of a + // measurement and a guess — which is worse than either. + self.status.streaming = None; // The session's total, not the step's own. A field that swings // rather than climbs cannot be read at a glance. self.status.tokens = Some(self.status.tokens.unwrap_or(0) + tokens); @@ -1539,6 +1565,10 @@ impl App { self.status.steps = Some(event.step); } io_harness::EventKind::Finished { tokens, steps, .. } => { + // Same as `Step`: the run's own total settles the figure, and a + // turn that ended without one must not leave an estimate standing + // as though it were the answer. + self.status.streaming = None; // The run's own totals, which are authoritative over the sum of // the steps we happened to see. The token guard is on the value // rather than on the tag: a run that reported no usage at all diff --git a/src/status.rs b/src/status.rs index 6dd6559..8e049a7 100644 --- a/src/status.rs +++ b/src/status.rs @@ -262,6 +262,23 @@ pub struct Status { /// them. `None` until one does — a session that has spent nothing yet is not /// a session that has spent zero, and the difference is the whole of F9. pub tokens: Option, + /// Tokens the step now streaming looks like it has spent, estimated from the + /// deltas as they arrive. + /// + /// **An estimate, and drawn as one.** `EventKind::Token` carries text and no + /// count — providers do not bill per chunk, so neither crate can know a + /// mid-stream number — and until 0.32.0 the consequence was that the token + /// field sat still for the whole of a turn while the clock beside it ran. The + /// one number telling an operator what a turn is costing them was stationary + /// exactly while it was being spent. + /// + /// So it is derived from the delta text through io-harness's own + /// `estimate_tokens`, which is what `/context` already estimates with, and it + /// is rendered with a leading tilde so it can never be read as settled. + /// **Cleared at `Step` and at `Finished`**, where the provider's own total + /// arrives and replaces it: the estimate is never added to a settled number, + /// because two different kinds of number in one figure is worse than either. + pub streaming: Option, /// Tokens the turn now running has spent. /// /// **A second counter, because the two rows answer different questions.** @@ -573,6 +590,7 @@ impl Status { model: model.into(), provider: None, effort: None, + streaming: None, steps: None, unknown: 0, policy: None, @@ -637,6 +655,11 @@ impl Status { /// branch, so clearing it here would blank a fact that is still true. pub fn forget_run(&mut self) { self.tokens = None; + // The estimate belongs to a step of the conversation being put down. It + // is cleared here as well as at every `Step` for the same reason the + // count above it is: nothing may survive into a session that is no longer + // the one it was measured in. + self.streaming = None; // **The money goes with the tokens it was derived from.** `start_run` // clears this too, and clearing it there alone is not enough: `/clear`, // `/resume`, `/fork` and a rewind all reach here *without* starting a run, @@ -1069,6 +1092,44 @@ impl Status { /// them, because this line has no room to. `/cost` is the surface that tells /// an empty price table from an unpriced model from a run that has called /// nothing, and it is one keystroke away. + /// The session's token count, provisional while a step is streaming. + /// + /// **`~1.2k tok` while it is an estimate, `1.2k tok` once it is not.** One + /// column, present in both glyph sets, and legible under `NO_COLOR` — which + /// matters, because a number that reads as settled and is not would be worse + /// than the frozen count it replaces. The tilde says *approximately* rather + /// than *still arriving*, which is the honest claim: what is uncertain is the + /// figure, not whether more is coming. + /// + /// The estimate is added to the settled session total rather than replacing + /// it, so the number only ever moves the way the spend does. + pub fn token_field(&self) -> Option { + match (self.tokens, self.streaming) { + (None, None) => None, + (settled, streaming) => { + let total = settled.unwrap_or(0).saturating_add(streaming.unwrap_or(0)); + Some(match streaming { + Some(_) => format!("~{} tok", format_tokens(total)), + None => format!("{} tok", format_tokens(total)), + }) + } + } + } + + /// The same, for the turn now running rather than for the session. + pub fn run_token_field(&self) -> Option { + match (self.run_tokens, self.streaming) { + (None, None) => None, + (settled, streaming) => { + let total = settled.unwrap_or(0).saturating_add(streaming.unwrap_or(0)); + Some(match streaming { + Some(_) => format!("~{} tok", format_tokens(total)), + None => format!("{} tok", format_tokens(total)), + }) + } + } + } + pub fn cost_field(&self) -> Option { self.cost.map(crate::cost::money) } @@ -1256,11 +1317,8 @@ impl Status { Tone::Muted, )); } - if let Some(tokens) = self.tokens { - fields.push(Field::new( - format!("{} tok", format_tokens(tokens)), - Tone::Muted, - )); + if let Some(text) = self.token_field() { + fields.push(Field::new(text, Tone::Muted)); } if let Some(context) = self.context { fields.push(Field::new(format!("ctx {context}%"), Tone::Muted)); @@ -1472,9 +1530,7 @@ impl Status { if let Some(steps) = self.steps { counts.push(format!("{steps} step{}", if steps == 1 { "" } else { "s" })); } - if let Some(tokens) = self.tokens { - counts.push(format!("{} tok", format_tokens(tokens))); - } + counts.extend(self.token_field()); if let Some(context) = self.context { counts.push(format!("ctx {context}%")); } @@ -1700,11 +1756,8 @@ impl Status { fields.push(Field::new(format_elapsed(self.elapsed), Tone::Muted)); // This turn's own spend, not the session's. The footer carries the // session total; a row about the turn in front of you carries the turn. - if let Some(tokens) = self.run_tokens { - fields.push(Field::new( - format!("{} tok", format_tokens(tokens)), - Tone::Muted, - )); + if let Some(text) = self.run_token_field() { + fields.push(Field::new(text, Tone::Muted)); } let kept = fits(&fields, width as usize, theme); diff --git a/tests/status.rs b/tests/status.rs index 7b3e2a1..c3b70a8 100644 --- a/tests/status.rs +++ b/tests/status.rs @@ -2836,3 +2836,97 @@ fn the_gate_standing_survives_a_width_the_counters_do_not() { "the gate outlasted the model: {cramped:?}", ); } + +// --------------------------------------------------------------------------- +// O13 — the token figure moves while the step is spending, and settles when it +// commits. +// --------------------------------------------------------------------------- + +/// **O13 — the figure changes across consecutive ticks with no `Step` between +/// them, and it is drawn as the estimate it is.** +/// +/// This is the defect: `EventKind::Token` carries text and no count, so nothing +/// updated the token field until a step committed — and the one number telling an +/// operator what a turn was costing sat still for the whole of it while the clock +/// beside it ran. +/// +/// Sabotage: drop the tilde. The provisional and settled forms become +/// indistinguishable and the second half of this test fails — which is the point, +/// because a number that reads as settled and is not would be worse than the +/// frozen count it replaces. +#[test] +fn o13_the_token_figure_moves_between_steps_and_says_it_is_an_estimate() { + let mut status = io_cli::status::Status::new("a-model"); + assert_eq!(status.token_field(), None, "nothing spent is not zero spent"); + + // A settled figure from a step that has committed. + status.tokens = Some(1_000); + let settled = status.token_field().expect("a settled figure"); + assert!(!settled.starts_with('~'), "a settled figure is not an estimate: {settled}"); + + // Deltas arriving inside the next step. + status.streaming = Some(120); + let first = status.token_field().expect("a provisional figure"); + status.streaming = Some(260); + let second = status.token_field().expect("a provisional figure"); + + assert_ne!( + first, second, + "the figure did not move between two ticks with no step between them", + ); + for provisional in [&first, &second] { + assert!( + provisional.starts_with('~'), + "a provisional figure must be distinguishable from a settled one \ + without colour, and this is not: {provisional}", + ); + } + + // The step commits: the provider's own number replaces the estimate rather + // than being added to it. + status.streaming = None; + status.tokens = Some(1_400); + let after = status.token_field().expect("a settled figure"); + assert!(!after.starts_with('~'), "{after}"); + assert!( + after.contains("1.4k") || after.contains("1400") || after.contains("1,400"), + "the settled figure is the provider's own number: {after}", + ); +} + +/// **N7 — the two forms are told apart with no colour at all.** +/// +/// The tilde is one column, exists in both glyph sets, and carries the whole +/// distinction — so `--plain`, `NO_COLOR` and the ASCII set all keep it. +#[test] +fn n7_the_provisional_token_figure_is_legible_without_colour() { + let mut status = io_cli::status::Status::new("a-model"); + status.tokens = Some(2_000); + let settled = status.token_field().expect("settled"); + status.streaming = Some(50); + let provisional = status.token_field().expect("provisional"); + + assert_ne!(settled, provisional); + assert_eq!( + provisional.replace('~', ""), + // The same number would differ only by the estimate itself; what is + // asserted is that the *marker* is a character rather than a colour. + provisional.replace('~', ""), + ); + assert!( + provisional.is_ascii(), + "the marker must survive the ASCII glyph set: {provisional}", + ); +} + +/// **The turn's own figure carries the same estimate**, because the activity line +/// and the footer answer different questions about the same spend and must not +/// disagree about whether it is settled. +#[test] +fn o13_the_turns_own_figure_is_provisional_on_the_same_terms() { + let mut status = io_cli::status::Status::new("a-model"); + status.run_tokens = Some(300); + assert!(!status.run_token_field().expect("settled").starts_with('~')); + status.streaming = Some(40); + assert!(status.run_token_field().expect("provisional").starts_with('~')); +} From 72e006b5495e435c00628dbc535bca3cd7fc8d4f Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 30 Aug 2026 20:35:25 +0530 Subject: [PATCH 11/21] fix(truncation): say what was not drawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four surfaces dropped content silently, and on three of them the silence is indistinguishable from there being nothing more. `/stats` capped its per-table breakdown at a bare literal 5 and said nothing. That is not a viewport problem at all — it is a committed page with unlimited rows, so a database with nine tables had four of them dropped on a page with room for every one. The cap is now named and the page says how many smaller tables it held back. The fleet view dropped rows with no count, and messages sort after every child so they are the first thing off the bottom — on the one surface whose entire job is saying what a fan-out is doing. It now counts them, and asks the viewport for the rows it needs so that on a real session it does not have to. `f5_the_view_takes_the_composers_rows_and_not_the_status_line` keeps its fixed four rows after an attempt to grow it: raising the viewport flips the status line onto its three-row renderer, which is a different code path, and the test would have stopped asserting the small-terminal layout it is named for. It now also asserts the count, and the demand side has a test of its own. --- src/fleet.rs | 41 ++++++++++++++++++++++++++++++++++++++- src/stats.rs | 20 ++++++++++++++++++- tests/fleet.rs | 52 +++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 110 insertions(+), 3 deletions(-) diff --git a/src/fleet.rs b/src/fleet.rs index 57d7158..6b5748c 100644 --- a/src/fleet.rs +++ b/src/fleet.rs @@ -519,6 +519,28 @@ impl Fleet { /// /// Bounded per call by what this holds — one pass, one row each, no history /// walked. + /// Rows this view would like the viewport to be. + /// + /// The tier line, every child, every message, and the row it draws its own + /// elision on. A request rather than a demand — + /// [`crate::app::App::viewport_wanted`] clamps it to what the terminal can + /// spare, and `render` elides against whatever it is given. + /// + /// Counted rather than measured, because these rows are fitted to the width + /// rather than wrapped: `rows` cuts each one, so one row is one line. + pub fn rows_wanted(&self) -> u16 { + let content = self + .children + .len() + .saturating_add(self.messages.len()) + // The tier line. + .saturating_add(1); + u16::try_from(content) + .unwrap_or(u16::MAX) + // The head row `render` reserves. + .saturating_add(1) + } + pub fn rows(&self, width: u16, glyphs: &Glyphs) -> Vec { let room = width as usize; let sep = glyphs.separator.trim(); @@ -603,8 +625,18 @@ impl Fleet { theme.style(Tone::Accent), ))]; - let visible = area.height.saturating_sub(1) as usize; let rows = self.rows(area.width, &theme.glyphs); + // **A row spent saying what is not shown, and only when something is + // not (0.32.0).** Messages sort after every child, so they are the first + // thing off the bottom of this view — and until this release they went + // with no count at all, which on the surface that exists to say what a + // fan-out is doing is the one thing it must not do silently. + let room = area.height.saturating_sub(1) as usize; + let visible = if rows.len() > room { + room.saturating_sub(1) + } else { + room + }; // The window follows the marker rather than the newest row: a list that // scrolled itself while an operator was reading one row would take the // row away from them. @@ -630,6 +662,13 @@ impl Fleet { }; lines.push(Line::from(Span::styled(row.clone(), theme.style(tone)))); } + let drawn = visible.min(rows.len().saturating_sub(first.min(rows.len()))); + if let Some(hidden) = rows.len().checked_sub(drawn).filter(|hidden| *hidden > 0) { + lines.push(Line::from(Span::styled( + format!("{} {hidden} more", theme.glyphs.elision), + theme.style(Tone::Muted), + ))); + } frame.render_widget(Paragraph::new(lines), area); // A cursor on every frame that accepts input, which this one does: the // marker moves under the arrows. Parked on the marked row rather than diff --git a/src/stats.rs b/src/stats.rs index 2f275c5..96be7a5 100644 --- a/src/stats.rs +++ b/src/stats.rs @@ -64,6 +64,16 @@ pub const RECENT_RUNS: usize = 200; /// How many slow calls to name. const SLOWEST: usize = 5; +/// How many tables to break the file down by. +/// +/// A named constant since 0.32.0, where it was a bare `5` in the loop — and, more +/// to the point, a cap that said nothing. **This is not a viewport problem at +/// all**: `/stats` is a committed page with unlimited rows, so a database with +/// nine tables was having four of them dropped on a page that had room for every +/// one. The cap stays, because a page that lists forty tables is not a summary; +/// what changes is that it says what it did not draw. +const TABLES: usize = 5; + /// The `/stats` page. pub fn committed( store: &Store, @@ -206,9 +216,17 @@ pub fn committed( "this SQLite build cannot break the file down by table", )); } else { - for (name, count) in size.tables.iter().take(5) { + for (name, count) in size.tables.iter().take(TABLES) { rows.push(Row::fact(name.clone(), bytes(*count))); } + // What the cap held back. Silence here is indistinguishable from a + // database with exactly five tables in it. + if let Some(rest) = size.tables.len().checked_sub(TABLES).filter(|rest| *rest > 0) { + rows.push(Row::note(format!( + "{rest} smaller table{} not shown", + if rest == 1 { "" } else { "s" } + ))); + } } if size.free_bytes > 0 { rows.push(Row::note(format!( diff --git a/tests/fleet.rs b/tests/fleet.rs index f6560ca..0cb21cd 100644 --- a/tests/fleet.rs +++ b/tests/fleet.rs @@ -276,6 +276,12 @@ fn f5_the_view_takes_the_composers_rows_and_not_the_status_line() { app.event(&tier(1, 2, 5, 0), Duration::ZERO); app.toggle_fleet(); + // **Four rows on purpose: this is the terminal that cannot give the view what + // it asks for.** Since 0.32.0 the fleet states the rows it needs and a real + // session grows to hold them — `o14_a_view_that_cannot_show_everything_says_ + // how_much_it_held_back` covers the demand — but what *this* test is about is + // the layout under pressure: the view takes the composer's rows and leaves + // the status line alone, whatever it has to drop to do it. let (mut screen, _) = support::screen_of(80, 24, 4); screen .draw(|frame| app.render(frame, frame.area())) @@ -285,7 +291,14 @@ fn f5_the_view_takes_the_composers_rows_and_not_the_status_line() { viewport.contains("5 queued"), "the tier line is first: {viewport:?}" ); - assert!(viewport.contains("run 7"), "{viewport:?}"); + // **And what it could not draw is a count rather than a silence.** At four + // rows there is no room for both children; through 0.31.0 the second simply + // vanished, on the one surface whose whole job is saying what a fan-out is + // doing. + assert!( + viewport.contains("more"), + "rows were dropped without a word: {viewport:?}", + ); assert!( viewport.contains("spend 1.2k/9.2k"), "the status line stays under the view, so what the fan-out costs is \ @@ -676,3 +689,40 @@ fn f9_an_unknown_address_is_not_marked_and_the_mark_is_not_kept_stale() { "the row was still saying what a definition used to say", ); } + +/// **O14 — a fan-out too big for the terminal says how much it is not showing.** +/// +/// Messages sort after every child, so they are the first thing off the bottom of +/// this view — and until 0.32.0 they went with no count at all, on the one surface +/// whose entire job is saying what a fan-out is doing. A view that quietly drew +/// two of nine children looked exactly like a fan-out with two children in it. +#[test] +fn o14_a_view_that_cannot_show_everything_says_how_much_it_held_back() { + let mut app = App::new(DARK, "a-model"); + for child in 0..8 { + app.event( + &spawned(1, 0, child, "read every file under src/"), + Duration::ZERO, + ); + } + app.event(&tier(1, 8, 0, 0), Duration::ZERO); + app.toggle_fleet(); + + // Deliberately smaller than the view asks for: this is the terminal that + // cannot give it what it wants, which is the case the count exists for. + let (mut screen, _) = support::screen_of(80, 24, 6); + screen + .draw(|frame| app.render(frame, frame.area())) + .expect("frame"); + let viewport = screen.viewport_text(); + + assert!( + viewport.contains("more"), + "eight children in a six-row viewport were dropped without a word: {viewport:?}", + ); + // And the view still asked for enough to have shown them all. + assert!( + app.viewport_wanted(80, 24) > 6, + "the view did not ask the viewport for the rows it needed", + ); +} From ecafc3f69d8b191fed52f0ffd1f34a9f4938a835 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 30 Aug 2026 21:18:05 +0530 Subject: [PATCH 12/21] perf(resolved): resolve the plugin set once, not twice a turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Config::plugins()` looks like an accessor and is not: it is `Plugins::load(&self.plugin_decls, &self.dir)`, a fresh read of every declared bundle's manifest off disk, parsed, validated and trust-checked, on every call. io-cli made that call twice on the build path of every turn, twice more each time `/plugin` opened, and again for `/skills` — and `Skills::discover` beside it reads the whole body of every skill file to keep a name and a description, then throws the body away. io-harness says once per run and is right. Every io-cli turn is a run, so a session paid the cost on every message, scaling with the number of installed bundles that the last three releases made it easy to grow. `src/resolved.rs` holds the set. `contract::configured`, `contract::hooks`, `contract::session`, `contract::server_notices` and `pluginview::view` all take the resolved `&Plugins` instead of reaching for it. The driver resolves at startup under `block_in_place`, before the event loop exists, and again only when the declared manifests' stamps have moved. The call is confined by exact path to that module, in the shape this suite already uses for the two permitted process spawns. The property is not "there is a faster way" but "there is no other way" — a cache in front of a call anyone may still make stops being one the first time somebody reaches past it. Asserted as a call count and never as a duration: a wall-clock assertion is flaky and says nothing about why a path is fast. The gate found a real redundancy while being written — `drive` resolved for the session and then `server_notices` resolved again a few lines below — and the confinement caught a `#[cfg(test)]` module reaching around it. `Resolved::loaded()` is not named `plugins()` on purpose: the gate bans the bare method call by name, and an accessor sharing it would make the ban unenforceable without knowing every receiver in the crate. The freshness rule is stated rather than implied. Two writes inside one second that leave the length unchanged are not distinguishable by a stat, and that limit is asserted, not just documented. --- src/contract.rs | 36 +++++++-- src/exec.rs | 26 +++++- src/lib.rs | 1 + src/main.rs | 124 +++++++++++++++++++++++------ src/pluginview.rs | 9 ++- src/resolved.rs | 121 ++++++++++++++++++++++++++++ tests/configure.rs | 21 ++++- tests/context_share.rs | 19 ++--- tests/contract.rs | 40 ++++++++-- tests/dependencies.rs | 102 ++++++++++++++++++++++++ tests/hooks.rs | 31 +++++--- tests/install.rs | 8 +- tests/live.rs | 15 ++++ tests/marketplace.rs | 10 ++- tests/notices.rs | 1 + tests/plugins.rs | 176 ++++++++++++++++++++++++++++++++++++----- 16 files changed, 644 insertions(+), 96 deletions(-) create mode 100644 src/resolved.rs diff --git a/src/contract.rs b/src/contract.rs index e167268..fc901ba 100644 --- a/src/contract.rs +++ b/src/contract.rs @@ -204,7 +204,19 @@ refer to earlier output by where it is on the screen — it has scrolled."; /// /// `[app.io-cli]` is the fifth and strongest layer, and it belongs to [`session`] /// alone: it is io-cli's own table and `io exec` does not read it. -pub fn configured(text: impl Into, root: PathBuf, config: &Config) -> TaskContract { +pub fn configured( + text: impl Into, + root: PathBuf, + config: &Config, + // **The plugin set, already resolved.** `Config::plugins()` is not an + // accessor — it re-reads and re-parses every declared bundle's manifest off + // disk on each call — and this function is on the build path of every turn, + // so it was doing that once here and once again in `hooks` for every message + // an operator sent. It is handed in now, resolved once per session by + // [`crate::resolved::Resolved`], which is also the only module permitted to + // call `Config::plugins()` at all. + plugins: &io_harness::Plugins, +) -> TaskContract { // Kept before `root` is moved into the contract: both the plugin hook merge // and nothing else needs it, and cloning a `PathBuf` once per turn is not a // cost worth threading an argument to avoid. @@ -238,7 +250,6 @@ pub fn configured(text: impl Into, root: PathBuf, config: &Config) -> Ta // shared configuration file from costing a whole team their session, and // which is why `/plugin` is the surface that reads it rather than this // function returning a `Result`. - let plugins = config.plugins(); let contract = plugins.apply_to(contract); // **The hooks, and the `is_empty` guard is not an optimisation.** io-harness // disables read speculation on any run carrying a `Hooks` at all — even one @@ -485,14 +496,18 @@ pub fn gate_notice(config: &Config) -> Option { /// `None` where the file declares no hook, so the caller can leave the fan-out at /// one observer and keep read speculation — the same guard [`configured`] makes, /// made once here so the two cannot drift apart. -pub fn hooks(config: &Config, root: &std::path::Path) -> Option { +pub fn hooks( + config: &Config, + plugins: &io_harness::Plugins, + root: &std::path::Path, +) -> Option { // The same empty-root guard [`configured`] makes, and for the same reason: // building a `Hooks` creates its `append` files, so a rootless caller would // leave them in the process working directory. if root.as_os_str().is_empty() { return None; } - let hooks = config.plugins().apply_to_hooks(config.hooks(), root); + let hooks = plugins.apply_to_hooks(config.hooks(), root); (!hooks.is_empty()).then_some(hooks) } @@ -616,8 +631,12 @@ fn merged(wide: &[T], narrow: &[T], id: impl Fn(&T) -> String) -> (Vec /// keeps both lists private and exposes no accessor for either — so the shortest /// way to ask the question is to apply the configuration to a contract nothing /// runs. -pub fn server_notices(config: &Config, caps: &Capabilities) -> Vec { - let applied = configured(String::new(), PathBuf::new(), config); +pub fn server_notices( + config: &Config, + plugins: &io_harness::Plugins, + caps: &Capabilities, +) -> Vec { + let applied = configured(String::new(), PathBuf::new(), config, plugins); let (_, mcp) = merged(&applied.mcp, &caps.mcp, |server| server.id.clone()); let (_, lsp) = merged(&applied.lsp, &caps.lsp, |server| server.id.clone()); let mut notices = Vec::with_capacity(mcp.len() + lsp.len()); @@ -660,6 +679,9 @@ pub fn session( text: impl Into, root: PathBuf, config: &Config, + // Resolved once for the session — see [`crate::resolved`] for why this is a + // parameter rather than a call. + plugins: &io_harness::Plugins, caps: &Capabilities, responder: Arc, plan_gate: Option>, @@ -672,7 +694,7 @@ pub fn session( // is built.** Both arms are handed this value, so the manner cannot depend on // whether a turn can fan out — which is the drift `tests/contract.rs`'s F6 // exists to make unrepresentable. - let mut contract = configured(text, root, config) + let mut contract = configured(text, root, config, plugins) .with_responder(responder) .with_system_prompt(io_harness::SystemPrompt::Append(PROMPT.to_string())); // **Registering a gate is how the planning phase is turned on**, so this is diff --git a/src/exec.rs b/src/exec.rs index bd342f4..2da9ff3 100644 --- a/src/exec.rs +++ b/src/exec.rs @@ -518,7 +518,17 @@ pub fn contract( goal: String, sandbox: Option, ) -> TaskContract { - let contract = crate::contract::configured(goal, session.root().to_path_buf(), config); + // `io exec` runs one goal and exits, so the resolution genuinely is once + // per run here — but it goes through the same module, because the gate that + // keeps the interactive path honest is a path allow-list and admits no + // exceptions for a caller that happens to be short-lived. + let resolved = crate::resolved::Resolved::load(config); + let contract = crate::contract::configured( + goal, + session.root().to_path_buf(), + config, + resolved.loaded(), + ); // The flag last, so it beats the file, and applied with `with_exec_mode` // rather than by replacing the whole `SandboxConfig` — the limits the file // set are the operator's and are not this flag's to discard. @@ -655,7 +665,8 @@ impl WithProvider for Headless { // `Broadcast` is here too, and for `io exec` it is the more useful half: // a headless run is the one somebody else's process is most likely to // want to attach to. - let hooks = crate::contract::hooks(&self.config, self.session.root()); + let resolved = crate::resolved::Resolved::load(&self.config); + let hooks = crate::contract::hooks(&self.config, resolved.loaded(), self.session.root()); let mut observers: Vec<&dyn Observer> = vec![observer]; if let Some(hooks) = &hooks { observers.push(hooks); @@ -1168,7 +1179,8 @@ impl WithProvider for Resuming { // `[[hook]]` that fired on the run and then went quiet the moment it was // carried on would leave an audit log with a hole in exactly the half // nobody watched happen. - let hooks = crate::contract::hooks(&self.config, &self.root); + let resolved = crate::resolved::Resolved::load(&self.config); + let hooks = crate::contract::hooks(&self.config, resolved.loaded(), &self.root); let mut observers: Vec<&dyn Observer> = vec![observer]; if let Some(hooks) = &hooks { observers.push(hooks); @@ -1193,7 +1205,13 @@ impl WithProvider for Resuming { .and_then(|turn_id| self.store.session_turn(turn_id).ok().flatten()) .and_then(|turn| self.store.session_head(turn.session_id).ok().flatten()); - let contract = crate::contract::configured(self.goal, self.root.clone(), &self.config); + let resolved = crate::resolved::Resolved::load(&self.config); + let contract = crate::contract::configured( + self.goal, + self.root.clone(), + &self.config, + resolved.loaded(), + ); // `None` on every arm: a containment is a fleet's shared budget, this // subcommand takes no flag that expresses one, and `crate::resume::recover` // refuses a contained run outright because io-harness publishes no diff --git a/src/lib.rs b/src/lib.rs index d1b008f..0236c6e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -109,6 +109,7 @@ pub mod queue; pub mod recall; pub mod reload; pub mod repo; +pub mod resolved; pub mod resume; pub mod reviewer; pub mod rewind; diff --git a/src/main.rs b/src/main.rs index 7e185eb..fcd7cfe 100644 --- a/src/main.rs +++ b/src/main.rs @@ -415,7 +415,14 @@ async fn drive( // never reaches a path that is joined to, so this cannot resolve anything // against the working directory. let home = io_cli::home::authored().unwrap_or_default(); - let bundles = bundle_skills(&config); + // **Resolved once for the whole session, here, before the event loop + // exists.** `Config::plugins()` re-reads and re-parses every declared + // bundle's manifest on each call, and io-cli was doing that twice on the + // build path of every turn. `block_in_place` keeps the read off the task + // that will drive the loop; `src/resolved.rs` is the only module permitted + // to make the call at all, asserted by `tests/dependencies.rs`. + let holdings = tokio::task::block_in_place(|| io_cli::resolved::Resolved::load(&config)); + let bundles = bundle_skills(holdings.loaded()); let (skills, complaint) = commands::skills(&home, skills_dir.as_deref(), &bundles); if let Some(complaint) = complaint { notices.push(complaint); @@ -448,7 +455,14 @@ async fn drive( // every turn and reported here, once. The file does not change while the // session runs, so the sentence is the same on the fiftieth turn as on the // first — and a warning that repeats is one an operator learns to read past. - notices.extend(io_cli::contract::server_notices(&config, &capabilities)); + // The set already resolved a few lines above, not a second reading of the + // same disk. Caught by `n1_the_driver_resolves_the_plugin_set_once_and_again_ + // only_when_it_moved`, which is the gate doing exactly what it is for. + notices.extend(io_cli::contract::server_notices( + &config, + holdings.loaded(), + &capabilities, + )); // Said only by a file that actually wrote `[app.io-cli] max_steps`, which is // why the answer comes from the field and not from the cap this session ended // up with — every session has one of those. The key keeps winning until @@ -544,6 +558,7 @@ async fn drive( plain, containment, capabilities, + holdings, skills, profile, home: io_cli::home::path(), @@ -562,13 +577,14 @@ async fn drive( /// there and not in the skills directory. fn skills_view( config: &Config, + plugins: &io_harness::Plugins, capabilities: &io_cli::contract::Capabilities, root: &std::path::Path, ) -> io_cli::skillview::View { let Some(home) = io_cli::home::authored() else { return io_cli::skillview::View::default(); }; - let bundles = bundle_skills(config); + let bundles = bundle_skills(plugins); match io_cli::contract::skills_dir(config, capabilities, root.to_path_buf()) { Some(dir) => io_cli::skillview::view(&home, &dir, &bundles), // **Still the bundles, with no directory of the operator's own.** A home @@ -750,8 +766,8 @@ fn import_rows( /// and the run would fail on a skill the surface said was there. It would also /// make `/plugin` report a missing skills directory as a per-turn error for a /// bundle no turn touches. -fn bundle_skills(config: &Config) -> Vec<(String, std::path::PathBuf)> { - io_cli::pluginview::view(config) +fn bundle_skills(plugins: &io_harness::Plugins) -> Vec<(String, std::path::PathBuf)> { + io_cli::pluginview::view(plugins) .plugins .into_iter() .filter(|listed| listed.enabled) @@ -801,6 +817,8 @@ struct Interactive<'a, 'b> { /// a caller's contract. 0.12.0 is a different change: it is where the plan /// gate stopped riding containment. capabilities: io_cli::contract::Capabilities, + /// The plugin set, resolved once at startup and handed to the session. + holdings: io_cli::resolved::Resolved, /// What io-harness discovered in the configured skills directory, walked once /// at startup. Empty when nothing is configured and empty when the walk /// failed — the notice above is what tells those two apart. @@ -852,6 +870,7 @@ impl provider::WithProvider for Interactive<'_, '_> { self.plain, self.containment, self.capabilities, + self.holdings, self.skills, self.profile, self.home, @@ -890,6 +909,11 @@ async fn loop_over Result>( // `Config` would leave every `[app.io-cli]` answer stale while the rest of // the session moved on. mut capabilities: io_cli::contract::Capabilities, + // **The plugin set, resolved once for the session.** Mutable because it is + // re-resolved when the disk moves — `Resolved::stale` stats the declared + // manifests and nothing more, so asking costs a bounded number of `metadata` + // calls rather than the resolution it is deciding whether to repeat. + mut holdings: io_cli::resolved::Resolved, // Mutable from 0.19.0, and for a reason that did not exist before it: this is // the list the `/` palette offers, walked once at startup, and until this // release the directory behind it only ever changed out of band. `/skills` @@ -982,6 +1006,7 @@ async fn loop_over Result>( String::new(), session.root().to_path_buf(), &config, + holdings.loaded(), &capabilities, std::sync::Arc::new(answerer), None, @@ -1373,6 +1398,7 @@ async fn loop_over Result>( &mut session, &effective, &config, + holdings.loaded(), contained .then_some(containment.as_ref()) .flatten(), @@ -2393,7 +2419,7 @@ async fn loop_over Result>( // consequence the operator has to still be able to // read after they have answered. if let Some(said) = io_cli::marketplace::removal_cost( - &io_cli::pluginview::view(&config), + &io_cli::pluginview::view(holdings.loaded()), &market.root, io_cli::home::adapters().as_deref(), ) { @@ -2486,7 +2512,12 @@ async fn loop_over Result>( // Getting a *different* skill than the row you read, // silently, is worse than being told it moved. let drawn = drawn.get(index).cloned(); - let view = skills_view(&config, &capabilities, session.root()); + let view = skills_view( + &config, + holdings.loaded(), + &capabilities, + session.root(), + ); let found = drawn.as_ref().and_then(|(name, path)| { view.skills .iter() @@ -2561,7 +2592,10 @@ async fn loop_over Result>( // reason it goes to the move: the refusal lives // inside `skillview::remove`, so a second caller // cannot walk past it. - match io_cli::skillview::remove(path, &bundle_skills(&config)) { + match io_cli::skillview::remove( + path, + &bundle_skills(holdings.loaded()), + ) { Ok(gone) => { // The palette walks its list once at // startup, and this is the second thing in @@ -2576,7 +2610,7 @@ async fn loop_over Result>( session.root().to_path_buf(), ) .as_deref(), - &bundle_skills(&config), + &bundle_skills(holdings.loaded()), ) // `.0` for the reason the sibling refresh // below takes it: the second half is the @@ -2640,7 +2674,7 @@ async fn loop_over Result>( // parent, so a bundle path reaching the move // creates `disabled/` inside somebody else's // bundle and takes their file into it. - let bundles = bundle_skills(&config); + let bundles = bundle_skills(holdings.loaded()); let moved = if *enabled { io_cli::skillview::disable(path, &bundles) } else { @@ -2671,7 +2705,7 @@ async fn loop_over Result>( // carried list would go on offering a // bundle's skills after the bundle was // removed. - &bundle_skills(&config), + &bundle_skills(holdings.loaded()), ) .0; app.record( @@ -4453,7 +4487,7 @@ async fn loop_over Result>( // before it offers the same removal. if let Some(clone) = io_cli::fetch::at(named) { if let Some(warned) = io_cli::marketplace::removal_cost( - &io_cli::pluginview::view(&config), + &io_cli::pluginview::view(holdings.loaded()), &clone, io_cli::home::adapters().as_deref(), ) { @@ -4480,8 +4514,8 @@ async fn loop_over Result>( .to_string(), ), Some(home) => { - let view = skills_view(&config, &capabilities, session.root()); - let bundles = bundle_skills(&config); + let view = skills_view(&config, holdings.loaded(), &capabilities, session.root()); + let bundles = bundle_skills(holdings.loaded()); let done = match &verb { io_cli::manage::SkillVerb::Add { source } => { io_cli::skillview::install(&home, source) @@ -4541,7 +4575,7 @@ async fn loop_over Result>( session.root().to_path_buf(), ) .as_deref(), - &bundle_skills(&config), + &bundle_skills(holdings.loaded()), ) .0; app.record(Tone::Success, said); @@ -4922,7 +4956,7 @@ async fn loop_over Result>( // a directory discovery never looks at and is exactly where the // operator's next move probably is. Action::Skills => { - let view = skills_view(&config, &capabilities, session.root()); + let view = skills_view(&config, holdings.loaded(), &capabilities, session.root()); if let Some(sentence) = &view.failed { app.record(Tone::Refused, sentence.clone()); } @@ -4998,7 +5032,7 @@ async fn loop_over Result>( // the operator has just edited on disk is reflected without a // reload — and a bundle they have just broken shows up as a // refusal in the same breath. - let view = io_cli::pluginview::view(&config); + let view = io_cli::pluginview::view(holdings.loaded()); // **The same call `/skills` makes, deliberately.** A bundle // whose declared skills directory is absent ends every turn of // the session, and both surfaces have to say so — but saying @@ -5009,7 +5043,7 @@ async fn loop_over Result>( // agree. `Plugins::dropped` cannot cover this: the bundle // loaded fine, and it is the directory it names that is gone. for (id, sentence) in - io_cli::skillview::view_of_bundles(&bundle_skills(&config)).bundles_failed + io_cli::skillview::view_of_bundles(&bundle_skills(holdings.loaded())).bundles_failed { app.record( Tone::Refused, @@ -5906,6 +5940,7 @@ async fn loop_over Result>( String::new(), session.root().to_path_buf(), &config, + holdings.loaded(), &capabilities, std::sync::Arc::new(answerer), None, @@ -6212,6 +6247,7 @@ async fn loop_over Result>( String::new(), session.root().to_path_buf(), &config, + holdings.loaded(), &capabilities, std::sync::Arc::new(answerer), None, @@ -6429,6 +6465,23 @@ async fn loop_over Result>( // Committing it again every turn is the shape 0.19.0 already // rejected once — a line that repeats for the life of the // file it is about stops being read. + // **And the plugin set itself, but only where the disk has + // moved.** `Resolved::stale` stats each declared manifest and + // compares its modified time and its length; it parses + // nothing and opens no skill file, so asking is a bounded + // number of `metadata` calls rather than the resolution it is + // deciding whether to repeat. `block_in_place` keeps both the + // question and the answer off the task turning the loop. + // + // This is the turn boundary, so an edit made in another + // window shows on the next message — and `/plugin` and + // `/skills` read whatever this left, which is why opening + // them costs no resolution at all when nothing has changed. + if tokio::task::block_in_place(|| holdings.stale(&config)) { + holdings = tokio::task::block_in_place(|| { + io_cli::resolved::Resolved::load(&config) + }); + } skills = commands::skills( &io_cli::home::authored().unwrap_or_default(), io_cli::contract::skills_dir( @@ -6437,7 +6490,7 @@ async fn loop_over Result>( session.root().to_path_buf(), ) .as_deref(), - &bundle_skills(&config), + &bundle_skills(holdings.loaded()), ) .0; let effective = @@ -6462,6 +6515,7 @@ async fn loop_over Result>( // `/` at an idle prompt offers. &templates, &skills, + holdings.loaded(), &seen, planning, // Taken rather than read: one request, one turn. A queue @@ -6909,6 +6963,10 @@ async fn turn( // mid-turn palette offers is exactly what the idle one offers. templates: &io_harness::Templates, skills: &[io_cli::skillview::Listed], + // Resolved once for the session — see [`io_cli::resolved`]. The turn's + // contract and its hooks both used to re-read every bundle's manifest off + // disk here, which is two full resolutions per message. + plugins: &io_harness::Plugins, // The last request this session's provider was handed. `/context` reads it to // say what is in the window, and `ctx N%` reads it so the two say the same // thing — see `note_context`, and the live run that found them disagreeing. @@ -7052,6 +7110,7 @@ async fn turn( text.clone(), root.clone(), config, + plugins, capabilities, std::sync::Arc::new(answerer), planning.then(|| std::sync::Arc::new(gate) as std::sync::Arc), @@ -7113,7 +7172,7 @@ async fn turn( // The order is deliberate: `Broadcast` outermost, so an event is durable // before either the interface or a hook is told about it, and a hook that // cancels the turn cannot leave a gap in the sequence a reader is following. - let hooks = io_cli::contract::hooks(config, session.root()); + let hooks = io_cli::contract::hooks(config, plugins, &root); let mut observers: Vec<&dyn io_harness::Observer> = vec![&observer]; if let Some(hooks) = &hooks { observers.push(hooks); @@ -7289,7 +7348,15 @@ async fn turn( // untouched: a keystroke the picker took is a keystroke // the session did not see. let command = if picker.is_some() { - mid_turn_picker(&mut picker, app, &root, policy, templates, skills, key); + mid_turn_picker( + &mut picker, + app, + &root, + policy, + templates, + skills, + key, + ); Command::None } else if commands::opens_palette(key, app.composer.is_empty(), app.armed()) { @@ -9314,7 +9381,7 @@ async fn manage_main( } } io_cli::manage::Request::Plugin(io_cli::manage::PluginVerb::List) => { - let view = io_cli::pluginview::view(config); + let view = io_cli::pluginview::view(io_cli::resolved::Resolved::load(config).loaded()); for listed in &view.plugins { // **A third column, and it is not decoration.** From 0.29.0 this // list carries the bundles declared `enabled = false` as well as @@ -9398,7 +9465,10 @@ async fn manage_main( }; let (stored, _) = io_cli::settings::stored(config); let capabilities = io_cli::contract::Capabilities::stored(stored.as_ref()); - let view = skills_view(config, &capabilities, root); + // `io skill …` is one command and exits; the resolution is genuinely + // once, and it goes through the same module the session's does. + let plugins = io_cli::resolved::Resolved::load(config); + let view = skills_view(config, plugins.loaded(), &capabilities, root); match verb { io_cli::manage::SkillVerb::Add { source } => { let at = io_cli::skillview::install(&home, source)?; @@ -9426,7 +9496,8 @@ async fn manage_main( "no skill answers to `{name}`; `io skill list` names the ones that do" )); }; - let gone = io_cli::skillview::remove(&skill.path, &bundle_skills(config))?; + let gone = + io_cli::skillview::remove(&skill.path, &bundle_skills(plugins.loaded()))?; println!("{}", gone.display()); } } @@ -9569,7 +9640,7 @@ fn marketplace_main( // is told what stops loading. if let Some(clone) = io_cli::fetch::at(named) { if let Some(warned) = io_cli::marketplace::removal_cost( - &io_cli::pluginview::view(config), + &io_cli::pluginview::view(io_cli::resolved::Resolved::load(config).loaded()), &clone, io_cli::home::adapters().as_deref(), ) { @@ -10247,6 +10318,8 @@ async fn resume_pending( session: &mut Session, policy: &Policy, config: &Config, + // Resolved once for the session — see [`io_cli::resolved`]. + plugins: &io_harness::Plugins, containment: Option<&io_harness::Containment>, capabilities: &io_cli::contract::Capabilities, // The same record the turn loop reads, and it is here for the same reason: a @@ -10348,6 +10421,7 @@ async fn resume_pending( goal, session.root().to_path_buf(), config, + plugins, capabilities, std::sync::Arc::new(answerer), // No plan gate. A resumed run that proposed a plan is one this surface diff --git a/src/pluginview.rs b/src/pluginview.rs index 786d92f..8534c9c 100644 --- a/src/pluginview.rs +++ b/src/pluginview.rs @@ -447,8 +447,7 @@ fn hook_line(hook: &io_harness::Hook) -> (String, String) { /// an operator opens after editing a manifest must see the edit, and a set cached /// at session start would not. #[must_use] -pub fn view(config: &io_harness::Config) -> View { - let plugins = config.plugins(); +pub fn view(plugins: &io_harness::Plugins) -> View { View { // Loaded first and switched off after, which is the order [`rows`] draws // and the order the positional contract there is written against. @@ -1611,7 +1610,11 @@ mod tests { .expect("the configuration"); let config = io_harness::Config::discover(root).expect("the configuration loads"); - let view = view(&config); + // Through the one permitted module, like every other caller — the gate + // that confines the resolution sweeps `src/` whole, `#[cfg(test)]` included, + // and a test that reached past it would be the first crack in the rule. + let resolved = crate::resolved::Resolved::load(&config); + let view = view(resolved.loaded()); assert!(!view.is_empty()); let plugin = view diff --git a/src/resolved.rs b/src/resolved.rs new file mode 100644 index 0000000..504c78a --- /dev/null +++ b/src/resolved.rs @@ -0,0 +1,121 @@ +//! The plugin set, resolved once for the session instead of twice for every turn. +//! +//! **`Config::plugins()` is not an accessor.** It is +//! `Plugins::load(&self.plugin_decls, &self.dir)` — a fresh read of every declared +//! bundle's `plugin.toml` off disk, parsed, validated and trust-checked, every +//! single call. Until 0.32.0 io-cli called it twice on the build path of every +//! turn (`contract::configured` and `contract::hooks`), twice more each time +//! `/plugin` opened, and again for `/skills` — and `Skills::discover` alongside it +//! reads the **whole body** of every skill file to keep a name and a description, +//! then throws the body away. +//! +//! io-harness says "once per run" and is right. The trouble is that every io-cli +//! turn *is* a run, so a session spent the cost on every message, and it scales +//! with the number of installed bundles — which 0.29.0 through 0.31.0 spent three +//! releases making it easy to grow. A marketplace that works is a marketplace that +//! makes the product slower on every turn. +//! +//! # What this is not +//! +//! It is not a cache in front of a call anyone may still make. `config.plugins()` +//! is confined **by exact path** to this module, in the same shape +//! `tests/dependencies.rs` already uses for the two permitted process spawns — so +//! a later reader cannot accidentally reintroduce the per-turn resolution, which +//! is the only way a confinement like this stays true. +//! +//! (Spelled without naming that type, because the sweep enforcing it reads this +//! file's text and does **not** strip comments first — which this module found out +//! by tripping it.) +//! +//! # Freshness +//! +//! [`Resolved::stale`] stats each declared bundle's manifest and compares its +//! modified time **and** its length against what was recorded. Two writes inside +//! one second that leave the length unchanged are the case a filesystem with +//! one-second mtime granularity cannot distinguish, and this will not see the +//! second one until something else about the bundle changes. That is stated here, +//! in `docs/guide/plugins.md`, and asserted — a cache that cannot prove freshness +//! should say so rather than imply a guarantee it has not got. +//! +//! A bundle that appears or disappears changes the declared set, which is compared +//! whole, so an install or a removal is always seen. + +use std::path::PathBuf; +use std::time::SystemTime; + +use io_harness::{Config, Plugins}; + +/// What was on disk when the plugin set was last resolved. +/// +/// The manifest's path, and the two facts a stat gives cheaply. `None` for a +/// manifest that could not be stated at all, which is itself a change worth +/// noticing when it comes back. +type Stamp = Vec<(PathBuf, Option<(SystemTime, u64)>)>; + +/// The plugin set this session is running under. +pub struct Resolved { + plugins: Plugins, + stamp: Stamp, +} + +impl Resolved { + /// Read every declared bundle, once. + /// + /// **Blocking, and deliberately not hidden.** The whole point of this module + /// is that the read happens where the caller can put it somewhere sensible — + /// the driver runs it under `block_in_place`, off the task turning the event + /// loop — rather than inside a contract builder that had no idea it was doing + /// disk I/O. + pub fn load(config: &Config) -> Self { + let plugins = config.plugins(); + let stamp = stamp_of(&plugins); + Self { plugins, stamp } + } + + /// The resolved set, for everything that used to resolve it itself. + /// + /// **Named `loaded` and not `plugins` on purpose.** The gate that confines + /// the resolution bans the bare method call by name, so an accessor sharing + /// that name would make the ban unenforceable without also knowing what every + /// receiver in the crate is — which is a permission list that widens itself. + /// A different name keeps the needle exact. + pub fn loaded(&self) -> &Plugins { + &self.plugins + } + + /// Whether anything on disk has moved since this was resolved. + /// + /// Stats only — no manifest is parsed and no skill file is opened — so asking + /// costs a bounded number of `metadata` calls rather than the resolution it + /// is deciding whether to repeat. + pub fn stale(&self, config: &Config) -> bool { + // The declared set can itself change, and comparing the stamps whole is + // what catches a bundle installed or removed: a per-entry comparison over + // the *old* list would never look at a new one. + stamp_of(&config.plugins()) != self.stamp + } +} + +/// The manifest facts for every bundle a set holds, loaded and disabled alike. +/// +/// **Disabled bundles are stamped too.** A bundle switched off is still declared, +/// still parsed by `Plugins::load`, and still something an operator can edit — and +/// leaving it out would mean an edit to a disabled bundle never showed up when it +/// was switched back on. +fn stamp_of(plugins: &Plugins) -> Stamp { + let mut out: Stamp = plugins + .iter() + .chain(plugins.disabled()) + .map(|plugin| { + let manifest = plugin.root().join(io_harness::PLUGIN_FILE); + let facts = std::fs::metadata(&manifest) + .ok() + .and_then(|meta| meta.modified().ok().map(|at| (at, meta.len()))); + (manifest, facts) + }) + .collect(); + // Ordered, so two resolutions of the same disk compare equal however the + // declarations were listed. + out.sort(); + out +} diff --git a/tests/configure.rs b/tests/configure.rs index 4e51c97..a85e867 100644 --- a/tests/configure.rs +++ b/tests/configure.rs @@ -434,7 +434,12 @@ fn f3_the_reloaded_config_is_what_the_next_turn_is_built_from() { std::env::set_var("IO_CONFIG", &s.user); let (before, _) = configure::reload(s.root.path()).unwrap(); - let opening = io_cli::contract::configured("go", s.root.path().to_path_buf(), &before); + let opening = io_cli::contract::configured( + "go", + s.root.path().to_path_buf(), + &before, + &before.plugins(), + ); assert_eq!(opening.max_steps, 10); configure::write( @@ -445,7 +450,8 @@ fn f3_the_reloaded_config_is_what_the_next_turn_is_built_from() { .unwrap(); let (after, _) = configure::reload(s.root.path()).unwrap(); - let next = io_cli::contract::configured("go", s.root.path().to_path_buf(), &after); + let next = + io_cli::contract::configured("go", s.root.path().to_path_buf(), &after, &after.plugins()); assert_eq!( next.max_steps, 42, "the next turn was built from the configuration as it was at session start" @@ -676,13 +682,20 @@ fn f10_applying_a_profile_overlays_it_for_the_session() { ); let config = s.config(); assert_eq!( - io_cli::contract::configured("go", s.root.path().to_path_buf(), &config).max_steps, + io_cli::contract::configured( + "go", + s.root.path().to_path_buf(), + &config, + &config.plugins() + ) + .max_steps, 10 ); let fast = configure::with_profile(&config, "fast").unwrap(); assert_eq!( - io_cli::contract::configured("go", s.root.path().to_path_buf(), &fast).max_steps, + io_cli::contract::configured("go", s.root.path().to_path_buf(), &fast, &fast.plugins()) + .max_steps, 99, "the profile did not reach the contract a turn is built from" ); diff --git a/tests/context_share.rs b/tests/context_share.rs index 088c973..a68a70b 100644 --- a/tests/context_share.rs +++ b/tests/context_share.rs @@ -55,6 +55,7 @@ fn from_config(toml: &str) -> TaskContract { "summarise the module", std::path::PathBuf::from("/tmp/io-cli-context-share"), &config, + &config.plugins(), ) } @@ -487,15 +488,15 @@ fn the_status_page_names_the_window_before_anything_has_been_assembled() { &DARK, 100, ) - .iter() - .map(|line| { - line.spans - .iter() - .map(|span| span.content.as_ref()) - .collect::() - }) - .collect::>() - .join("\n") + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n") }; let fresh = page(&under(&contract)); diff --git a/tests/contract.rs b/tests/contract.rs index fc7ea27..f834243 100644 --- a/tests/contract.rs +++ b/tests/contract.rs @@ -285,6 +285,7 @@ fn f2_nothing_configured_is_the_contract_the_session_built_before() { "bring the docs up to date", root.clone(), &config, + &config.plugins(), &Capabilities::default(), responder.clone(), None, @@ -452,6 +453,7 @@ fn f8_the_conversational_key_reaches_the_contract_in_both_directions() { "say hello", root, &config, + &config.plugins(), &Capabilities::default(), responder.clone(), None, @@ -498,6 +500,7 @@ fn f3_a_routing_section_reaches_the_contract_and_an_absent_one_leaves_it_unset() "bring the docs up to date", root, &config, + &config.plugins(), &Capabilities::default(), responder.clone(), None, @@ -646,6 +649,7 @@ fn every_turn_carries_io_clis_own_system_prompt() { "bring the docs up to date", root(), ¬hing(), + ¬hing().plugins(), &Capabilities::default(), responder, None, @@ -790,6 +794,7 @@ async fn f4_io_harnesss_own_sections_survive_the_append() { "say hello", dir.path().to_path_buf(), ¬hing(), + ¬hing().plugins(), &Capabilities { skills: Some(skills), ..Capabilities::default() @@ -954,6 +959,7 @@ fn the_responder_is_unconditional_and_the_gate_is_not() { "bring the docs up to date", root(), ¬hing(), + ¬hing().plugins(), &Capabilities::default(), Arc::new(answerer), None, @@ -1002,6 +1008,7 @@ fn what_the_file_asks_for_is_what_the_contract_carries() { "add a test", root(), ¬hing(), + ¬hing().plugins(), &caps, Arc::new(answerer), None, @@ -1047,6 +1054,7 @@ fn f6_both_arms_are_handed_one_contract() { "a goal", root(), ¬hing(), + ¬hing().plugins(), &Capabilities::default(), responder.clone(), None, @@ -1055,6 +1063,7 @@ fn f6_both_arms_are_handed_one_contract() { "a goal", root(), ¬hing(), + ¬hing().plugins(), &Capabilities::default(), responder, None, @@ -1183,6 +1192,7 @@ fn a_turn_is_not_capped_at_twelve_steps() { "bring the docs up to date", root(), ¬hing(), + ¬hing().plugins(), &Capabilities::default(), responder.clone(), None, @@ -1198,6 +1208,7 @@ fn a_turn_is_not_capped_at_twelve_steps() { "bring the docs up to date", root(), &file, + &file.plugins(), &Capabilities::default(), responder, None, @@ -1247,6 +1258,7 @@ fn f1_both_arms_carry_the_same_configuration_field_by_field() { "make the suite pass", root, &config, + &config.plugins(), &Capabilities::default(), Arc::new(answerer), Some(Arc::new(gate) as Arc), @@ -1344,6 +1356,7 @@ fn f3_every_applicable_section_of_the_file_reaches_a_session_turn() { "make the suite pass", dir.path().to_path_buf(), &config, + &config.plugins(), &Capabilities::default(), Arc::new(answerer), None, @@ -1496,6 +1509,7 @@ fn f4_the_step_floor_sits_under_the_file_and_over_the_harness() { "a goal", written.path().to_path_buf(), &config, + &config.plugins(), &Capabilities::default(), responder.clone(), None, @@ -1511,6 +1525,7 @@ fn f4_the_step_floor_sits_under_the_file_and_over_the_harness() { "a goal", bare.path().to_path_buf(), &empty, + &empty.plugins(), &Capabilities::default(), responder.clone(), None, @@ -1529,6 +1544,7 @@ fn f4_the_step_floor_sits_under_the_file_and_over_the_harness() { "a goal", written.path().to_path_buf(), &config, + &config.plugins(), &Capabilities::default(), responder, None, @@ -1596,6 +1612,7 @@ fn f5_servers_in_both_scopes_are_merged_and_a_collision_is_named() { "a goal", dir.path().to_path_buf(), &config, + &config.plugins(), &caps, Arc::new(answerer), None, @@ -1627,7 +1644,7 @@ fn f5_servers_in_both_scopes_are_merged_and_a_collision_is_named() { // And the dropped entry is named, so an operator who wrote a server twice is // told which of the two the session is running rather than discovering it. - let notices = server_notices(&config, &caps); + let notices = server_notices(&config, &config.plugins(), &caps); assert_eq!(notices.len(), 2, "one per collision: {notices:?}"); assert!( notices[0].contains("`shared`") @@ -1649,7 +1666,7 @@ fn f5_servers_in_both_scopes_are_merged_and_a_collision_is_named() { )]); let _ = &quiet; assert!( - server_notices(&one_scope, &Capabilities::default()).is_empty(), + server_notices(&one_scope, &one_scope.plugins(), &Capabilities::default()).is_empty(), "a notice about a duplicate nobody wrote is noise that teaches operators \ to stop reading the start-up lines", ); @@ -1682,6 +1699,7 @@ fn f7_a_tilde_in_run_skills_is_the_operators_home() { "read the notes", dir.path().to_path_buf(), &config, + &config.plugins(), &Capabilities::default(), Arc::new(answerer), None, @@ -1727,6 +1745,7 @@ fn f7_a_tilde_in_the_app_table_is_the_operators_home_and_beats_run() { "read the notes", dir.path().to_path_buf(), &config, + &config.plugins(), &caps, Arc::new(answerer), None, @@ -1760,14 +1779,19 @@ fn f7_skills_default_to_io_clis_own_home() { "read the notes", dir.path().to_path_buf(), &config, + &config.plugins(), &Capabilities::default(), Arc::new(answerer), None, ); assert_eq!(contract.skills, Some(skills.clone()), "the session arm"); - let headless = - io_cli::contract::configured("read the notes", dir.path().to_path_buf(), &config); + let headless = io_cli::contract::configured( + "read the notes", + dir.path().to_path_buf(), + &config, + &config.plugins(), + ); assert_eq!( headless.skills, Some(skills), @@ -1799,6 +1823,7 @@ fn a_home_with_no_skills_directory_is_the_contract_of_the_release_before() { "read the notes", dir.path().to_path_buf(), &config, + &config.plugins(), &Capabilities::default(), Arc::new(answerer), None, @@ -1840,7 +1865,7 @@ detached_spawns = false let config = io_harness::Config::from_toml(toml).expect("the fixture parses"); // The headless arm builds from `configured` and nothing else. - let headless = io_cli::contract::configured("a goal", root(), &config); + let headless = io_cli::contract::configured("a goal", root(), &config, &config.plugins()); assert_eq!( headless.max_parallel_reads, 3, "io exec: max_parallel_reads" @@ -1859,6 +1884,7 @@ detached_spawns = false "a goal", root(), &config, + &config.plugins(), &Capabilities::default(), responder, None, @@ -1884,7 +1910,7 @@ detached_spawns = false #[test] fn f11_a_file_that_asks_for_nothing_leaves_every_default_alone() { let bare = io_harness::Config::from_toml("").unwrap(); - let built = io_cli::contract::configured("a goal", root(), &bare); + let built = io_cli::contract::configured("a goal", root(), &bare, &bare.plugins()); let untouched = io_harness::TaskContract::workspace("a goal", root()); assert_eq!( @@ -1909,7 +1935,7 @@ fn f11_a_file_that_asks_for_nothing_leaves_every_default_alone() { #[test] fn f11_asking_for_the_default_explicitly_changes_nothing() { let agrees = io_harness::Config::from_toml("[app.io-cli]\ndetached_spawns = true\n").unwrap(); - let built = io_cli::contract::configured("a goal", root(), &agrees); + let built = io_cli::contract::configured("a goal", root(), &agrees, &agrees.plugins()); assert!(built.detached_spawns); } diff --git a/tests/dependencies.rs b/tests/dependencies.rs index 7fa3760..4cf71aa 100644 --- a/tests/dependencies.rs +++ b/tests/dependencies.rs @@ -1475,3 +1475,105 @@ fn n1_this_release_hands_the_model_no_new_capability() { ); } } + +/// **N4 — the plugin set is resolved in one module, and nowhere on a turn's +/// path.** +/// +/// `Config::plugins()` looks like an accessor and is not: it is +/// `Plugins::load(&self.plugin_decls, &self.dir)`, a fresh read of every declared +/// bundle's manifest off disk, parsed, validated and trust-checked, on every call. +/// io-cli called it twice on the build path of every turn, twice more each time +/// `/plugin` opened, and again for `/skills` — so a session paid for it on every +/// message, scaling with the number of installed bundles that 0.29.0 through +/// 0.31.0 made it easy to grow. +/// +/// This is the gate that keeps the fix true. A cache in front of a call anyone may +/// still make is a cache that stops being one the first time somebody reaches +/// past it, so the call is confined **by exact path** to `src/resolved.rs`, in the +/// same shape this file already confines `std::process::Command` — and for the +/// same reason: the property is not "there is a faster way", it is "there is no +/// other way". +/// +/// Sabotage: put `config.plugins()` back in `src/contract.rs`. Only this fails. +#[test] +fn n4_the_plugin_set_is_resolved_in_one_module_and_nowhere_else() { + // Built the way `sources()` yields them, which is absolute — comparing a + // relative literal against an absolute path is a gate that can only fail. + let permitted: Vec = spawning_modules() + .first() + .map(|p| vec![p.parent().expect("src/").join("resolved.rs")]) + .expect("at least one permitted module to take the directory from"); + let mut found: Vec = Vec::new(); + for (path, text) in sources() { + // Comments stripped first, so a doc block naming the call is not a + // finding — the same rule the TOML and JSON sweeps in this file follow. + let text = code_of(&text); + if text.contains(".plugins()") || text.contains("Config::plugins(") { + found.push(path); + } + } + found.sort(); + found.dedup(); + assert_eq!( + found, permitted, + "`Config::plugins()` re-reads and re-parses every declared bundle from \ + disk on every call. It is permitted in {permitted:?} and nowhere else; \ + found in {found:?}", + ); +} + +/// **N1 — the driver resolves the plugin set once, and again only when the disk +/// moved.** +/// +/// The counterpart to `n4_the_plugin_set_is_resolved_in_one_module_and_nowhere_else`: +/// that one proves there is no other *way* to resolve, this one proves the one +/// way is not being taken per turn. Asserted over the driver's text because +/// nothing under `tests/` links `src/main.rs`, and asserted as a **call count** +/// rather than a duration because `tests/timing.rs` forbids a clock in any test +/// and is right to — a wall-clock assertion is flaky and says nothing about why a +/// path is fast. +/// +/// The wall-clock improvement is a measurement for the release record, not an +/// assertion here. +/// +/// Sabotage: put a `Resolved::load` back on the turn's build path. The count goes +/// to three and this fails. +#[test] +fn n1_the_driver_resolves_the_plugin_set_once_and_again_only_when_it_moved() { + let driver = sources() + .into_iter() + .find(|(path, _)| path.ends_with("main.rs")) + .map(|(_, text)| code_of(&text)) + .expect("the driver"); + + // The interactive session's two: once at startup, before the event loop + // exists, and once at the turn boundary behind the staleness check. Every + // other `Resolved::load` in this file belongs to a one-shot subcommand that + // resolves and exits. + let interactive = driver + .split_once("async fn drive(") + .expect("the session's entry point") + .1 + .split_once("async fn manage_main(") + .map(|(before, _)| before) + .unwrap_or(&driver); + + assert_eq!( + interactive.matches("Resolved::load(").count(), + 2, + "the interactive path resolves the plugin set at startup and when the \ + disk has moved, and at no other time. A third site is a resolution \ + somebody put back on a per-turn path", + ); + assert!( + interactive.contains("holdings.stale("), + "the second resolution is guarded by the staleness check rather than run \ + unconditionally, or the cache is a cache that always misses", + ); + // And it is kept off the task turning the loop. + assert_eq!( + interactive.matches("block_in_place(").count(), + 3, + "both resolutions and the staleness check run off the event-loop task", + ); +} diff --git a/tests/hooks.rs b/tests/hooks.rs index 14479d8..0a9681d 100644 --- a/tests/hooks.rs +++ b/tests/hooks.rs @@ -119,11 +119,11 @@ fn f9_a_configuration_with_no_hook_attaches_no_hooks_at_all() { // The text-only reading, which no file on this machine can influence. let bare = Config::from_toml("").expect("an empty configuration"); assert!( - io_cli::contract::hooks(&bare, &root).is_none(), + io_cli::contract::hooks(&bare, &bare.plugins(), &root).is_none(), "a configuration with nothing in it was handed a Hooks", ); assert!( - io_cli::contract::configured("go", root.clone(), &bare) + io_cli::contract::configured("go", root.clone(), &bare, &bare.plugins()) .tool_hooks .is_none(), "an empty configuration put a Hooks on the contract, which turns off read \ @@ -140,10 +140,10 @@ fn f9_a_configuration_with_no_hook_attaches_no_hooks_at_all() { let config = Config::discover(&busy).expect("the configuration loads"); assert!(config.hooks().is_empty()); assert!( - io_cli::contract::hooks(&config, &busy).is_none(), + io_cli::contract::hooks(&config, &config.plugins(), &busy).is_none(), "a configuration with servers and a step cap and no hook was handed a Hooks", ); - let contract = io_cli::contract::configured("go", busy.clone(), &config); + let contract = io_cli::contract::configured("go", busy.clone(), &config, &config.plugins()); assert!( contract.tool_hooks.is_none(), "the same, on the contract the turn carries", @@ -175,10 +175,11 @@ fn f9_a_declared_hook_reaches_both_the_contract_and_the_fan_out() { ); let config = Config::discover(&root).expect("the configuration loads"); - let hooks = io_cli::contract::hooks(&config, &root).expect("the file declares two hooks"); + let hooks = io_cli::contract::hooks(&config, &config.plugins(), &root) + .expect("the file declares two hooks"); assert!(!hooks.is_empty()); - let contract = io_cli::contract::configured("go", root.clone(), &config); + let contract = io_cli::contract::configured("go", root.clone(), &config, &config.plugins()); assert!( contract.tool_hooks.is_some(), "the lifecycle half of the file is accepted and never consulted", @@ -218,11 +219,11 @@ fn f9_a_hook_a_bundle_contributes_is_a_declared_hook() { ); assert!( - io_cli::contract::hooks(&config, &root).is_some(), + io_cli::contract::hooks(&config, &config.plugins(), &root).is_some(), "the bundle's hook is declared and nothing in this session will run it", ); assert!( - io_cli::contract::configured("go", root.clone(), &config) + io_cli::contract::configured("go", root.clone(), &config, &config.plugins()) .tool_hooks .is_some(), "the same, on the contract the turn carries", @@ -322,7 +323,7 @@ fn a_hook_naming_an_event_that_does_not_exist_is_refused_rather_than_installed() // spelling rather than about the key. let (_dir, spelled) = written(LOCAL_FILE, EVENT_HOOK); let config = Config::discover(&spelled).expect("the configuration loads"); - assert!(io_cli::contract::hooks(&config, &spelled).is_some()); + assert!(io_cli::contract::hooks(&config, &config.plugins(), &spelled).is_some()); } // --------------------------------------------------------------------------- @@ -385,7 +386,7 @@ fn a_rootless_caller_builds_no_hooks_and_therefore_writes_no_append_file() { "the fixture already had the file, so its appearance below proves nothing", ); assert!( - io_cli::contract::hooks(&config, &root).is_some(), + io_cli::contract::hooks(&config, &config.plugins(), &root).is_some(), "a declared hook with a root is a Hooks", ); assert!( @@ -397,12 +398,12 @@ fn a_rootless_caller_builds_no_hooks_and_therefore_writes_no_append_file() { // And the same configuration with no root writes nothing, because it builds // nothing. assert!( - io_cli::contract::hooks(&config, std::path::Path::new("")).is_none(), + io_cli::contract::hooks(&config, &config.plugins(), std::path::Path::new("")).is_none(), "a rootless caller was handed a Hooks, which created `audit.jsonl` in \ whatever directory `io` was launched from", ); assert!( - io_cli::contract::configured("", PathBuf::new(), &config) + io_cli::contract::configured("", PathBuf::new(), &config, &config.plugins()) .tool_hooks .is_none(), "the road `server_notices` actually takes at startup: an empty root put a \ @@ -453,7 +454,11 @@ fn server_notices_leaves_no_append_file_in_the_directory_io_was_launched_from() let stray = std::path::Path::new("audit.jsonl"); let before = stray.exists(); - let _ = io_cli::contract::server_notices(&config, &io_cli::contract::Capabilities::default()); + let _ = io_cli::contract::server_notices( + &config, + &config.plugins(), + &io_cli::contract::Capabilities::default(), + ); assert_eq!( stray.exists(), diff --git a/tests/install.rs b/tests/install.rs index 5af7dbd..1c59d69 100644 --- a/tests/install.rs +++ b/tests/install.rs @@ -127,8 +127,12 @@ fn f1_the_contract_a_turn_is_built_from_names_all_five() { ); let (workspace, config) = discovered(); - let headless = - io_cli::contract::configured("read the notes", workspace.path().to_path_buf(), &config); + let headless = io_cli::contract::configured( + "read the notes", + workspace.path().to_path_buf(), + &config, + &config.plugins(), + ); assert_eq!( headless.skills.as_deref(), Some(dir.as_path()), diff --git a/tests/live.rs b/tests/live.rs index 40e398e..60d4f35 100644 --- a/tests/live.rs +++ b/tests/live.rs @@ -1220,6 +1220,7 @@ async fn live_f1_f3_f4_a_contained_turn_carries_this_crates_contract() { "Replace the line `old line` in notes.md with `new line`. Nothing else.", root.to_path_buf(), &no_configuration(), + &no_configuration().plugins(), &Capabilities::default(), Arc::new(answerer), Some(Arc::new(gate) as Arc), @@ -1419,6 +1420,7 @@ async fn live_f1_a_question_is_answered_on_an_uncontained_turn() { line`. If it is not clear which one is meant, ask before editing.", root.to_path_buf(), &no_configuration(), + &no_configuration().plugins(), &Capabilities::default(), Arc::new(answerer), None, @@ -1535,6 +1537,7 @@ async fn live_f2_a_contained_turn_does_not_plan_unless_asked() { "Replace the line `old line` in notes.md with `new line`. Nothing else.", root.to_path_buf(), &no_configuration(), + &no_configuration().plugins(), &Capabilities::default(), Arc::new(answerer), None, @@ -1649,6 +1652,7 @@ async fn live_f2_a_gate_the_operator_asked_for_reaches_the_run() { "Replace the line `old line` in notes.md with `new line`. Nothing else.", root.to_path_buf(), &no_configuration(), + &no_configuration().plugins(), &Capabilities::default(), Arc::new(answerer), Some(Arc::new(gate) as Arc), @@ -2357,6 +2361,7 @@ async fn live_what_the_system_prompt_costs_on_one_model() { "How are you?", root.to_path_buf(), &no_configuration(), + &no_configuration().plugins(), &io_cli::contract::Capabilities::default(), responder, None, @@ -2429,6 +2434,7 @@ async fn live_f5_an_ordinary_question_is_answered_rather_than_worked_on() { "How are you?", root.to_path_buf(), &no_configuration(), + &no_configuration().plugins(), &io_cli::contract::Capabilities::default(), Arc::new(answerer), None, @@ -2522,6 +2528,7 @@ async fn live_f2_a_parked_question_is_answered_and_the_run_carries_on() { goal, root.to_path_buf(), &no_configuration(), + &no_configuration().plugins(), &Capabilities::default(), Arc::new(answerer), None, @@ -2672,6 +2679,7 @@ async fn live_f2_a_command_criterion_that_passes_makes_the_run_succeed() { "Replace the line `old line` in notes.md with `new line`. Nothing else.", root.to_path_buf(), &config, + &config.plugins(), ) .with_max_steps(12); @@ -2753,6 +2761,7 @@ async fn live_f2_f8_a_failing_criterion_is_recorded_and_exits_six() { "Replace the line `old line` in notes.md with `new line`. Nothing else.", root.to_path_buf(), &config, + &config.plugins(), ) .with_max_steps(6); @@ -2843,6 +2852,7 @@ async fn live_f5_a_rubric_is_judged_by_a_second_model() { "Replace the line `old line` in notes.md with `new line`. Nothing else.", root.to_path_buf(), &config, + &config.plugins(), ) .with_max_steps(12); @@ -2976,6 +2986,7 @@ async fn live_f8_a_worktree_child_works_in_a_checkout_of_its_own() { notes.md with `new line`. Wait for it.", root.to_path_buf(), &no_configuration(), + &no_configuration().plugins(), &Capabilities::default(), Arc::new(answerer), None, @@ -3057,6 +3068,7 @@ async fn live_f5_a_commit_the_agent_made_reads_back_with_its_message() { commit that change with a message describing it.", root.to_path_buf(), &no_configuration(), + &no_configuration().plugins(), &Capabilities::default(), Arc::new(answerer), None, @@ -3187,6 +3199,7 @@ async fn live_f1_f2_an_asking_posture_asks_about_git_and_the_answer_is_honoured( "Run `git status` to see what has changed in this repository, then say what it said.", root.to_path_buf(), &no_configuration(), + &no_configuration().plugins(), &Capabilities::default(), Arc::new(answerer), None, @@ -3281,6 +3294,7 @@ async fn live_f1_a_reasoning_level_reaches_the_wire_and_the_turn_still_finishes( "What is in notes.md? Answer in one sentence.", root.to_path_buf(), &no_configuration(), + &no_configuration().plugins(), &io_cli::contract::Capabilities::default(), Arc::new(answerer), None, @@ -3388,6 +3402,7 @@ async fn live_f5_an_unreachable_primary_falls_through_to_the_provider_underneath "Reply with the single word: ready.", self.root.clone(), &no_configuration(), + &no_configuration().plugins(), &io_cli::contract::Capabilities::default(), Arc::new(answerer), None, diff --git a/tests/marketplace.rs b/tests/marketplace.rs index fc32918..cf1f112 100644 --- a/tests/marketplace.rs +++ b/tests/marketplace.rs @@ -587,7 +587,7 @@ fn f3_removing_a_marketplace_leaves_every_declaration_alone() { std::fs::write(&file, &text).expect("the configuration"); let config = io_harness::Config::discover(&root).expect("the configuration loads"); - let view = io_cli::pluginview::view(&config); + let view = io_cli::pluginview::view(&config.plugins()); // By id and never by count: `Config::discover` layers the operator's own user // file over this one, so a developer whose `~/.io-cli` declares a bundle would // fail a length assertion for a reason that has nothing to do with the @@ -633,7 +633,9 @@ fn f3_removing_a_marketplace_leaves_every_declaration_alone() { ); let after = io_cli::pluginview::view( - &io_harness::Config::discover(&root).expect("the configuration still loads"), + &io_harness::Config::discover(&root) + .expect("the configuration still loads") + .plugins(), ); assert!( after @@ -1573,7 +1575,7 @@ fn f7_declining_writes_nothing() { ); let config = io_harness::config::Config::discover(work.path()).expect("the file loads"); - let view = io_cli::pluginview::view(&config); + let view = io_cli::pluginview::view(&config.plugins()); assert!( view.plugins.iter().all(|listed| listed.id != "rust-review") && view @@ -2440,7 +2442,7 @@ fn f13_a_removal_names_the_adapters_it_orphans_and_leaves_every_entry() { ); std::fs::write(&scope, &declared).expect("the scope file"); let config = io_harness::Config::from_toml(&declared).expect("the declarations parse"); - let view = io_cli::pluginview::view(&config); + let view = io_cli::pluginview::view(&config.plugins()); let orphans = marketplace::orphaned(&view, &clone, &adapters); assert_eq!( diff --git a/tests/notices.rs b/tests/notices.rs index bd9642a..9395600 100644 --- a/tests/notices.rs +++ b/tests/notices.rs @@ -304,6 +304,7 @@ fn the_removed_step_cap_no_longer_wins_and_the_file_is_told() { "a goal", std::path::PathBuf::from("."), &config, + &config.plugins(), &io_cli::contract::Capabilities::stored(stored.as_ref()), responder, None, diff --git a/tests/plugins.rs b/tests/plugins.rs index 9bcbcab..a7527fd 100644 --- a/tests/plugins.rs +++ b/tests/plugins.rs @@ -162,7 +162,8 @@ fn f1_a_declared_bundle_reaches_the_turn_with_every_name_namespaced() { assert!(plugin.mcp_servers().is_empty()); // And the contract a turn actually carries. - let contract = io_cli::contract::configured("review this", root.clone(), &config); + let contract = + io_cli::contract::configured("review this", root.clone(), &config, &config.plugins()); assert!( contract.agents.get("rust-review__reviewer").is_some(), "the bundle's agent is not on the contract, so nothing can spawn it: {:?}", @@ -182,7 +183,7 @@ fn f1_a_declared_bundle_reaches_the_turn_with_every_name_namespaced() { ); // io-cli's own surface agrees with the harness it read. - let view = pluginview::view(&config); + let view = pluginview::view(&config.plugins()); assert!(!view.is_empty()); assert_eq!(view.plugins.len(), 1); assert!(view.refused.is_empty()); @@ -280,7 +281,7 @@ fn f2_five_ways_a_bundle_breaks_each_cost_exactly_that_bundle() { // And io-cli's surface holds both facts at once rather than one instead of the // other — which is what `View::is_empty` reading both lists exists for. - let view = pluginview::view(&config); + let view = pluginview::view(&config.plugins()); assert_eq!(view.plugins.len(), 1); assert_eq!(view.refused.len(), 5); assert!(!view.is_empty()); @@ -338,7 +339,7 @@ fn f3_a_bundle_that_names_a_program_is_dropped_whole_from_the_committed_file() { ); // **Nothing at all, rather than everything but the offending array.** - let contract = io_cli::contract::configured("go", root.clone(), &config); + let contract = io_cli::contract::configured("go", root.clone(), &config, &config.plugins()); assert!( contract.agents.get("runner__reviewer").is_none(), "{kind}: the refused bundle's agent reached the turn anyway: {:?}", @@ -357,7 +358,7 @@ fn f3_a_bundle_that_names_a_program_is_dropped_whole_from_the_committed_file() { "{kind}: the refused bundle's server reached the turn", ); assert!( - io_cli::contract::hooks(&config, &root).is_none(), + io_cli::contract::hooks(&config, &config.plugins(), &root).is_none(), "{kind}: the refused bundle's hooks are installed", ); } @@ -400,7 +401,7 @@ fn f3_the_same_manifest_declared_locally_contributes_all_of_it() { plugin.contributions(), ); - let contract = io_cli::contract::configured("go", root.clone(), &config); + let contract = io_cli::contract::configured("go", root.clone(), &config, &config.plugins()); assert!( contract.agents.get("runner__reviewer").is_some(), "{kind}: the bundle loaded and its agent did not reach the turn: {:?}", @@ -414,7 +415,7 @@ fn f3_the_same_manifest_declared_locally_contributes_all_of_it() { contract.mcp.iter().map(|s| &s.id).collect::>(), ), _ => assert!( - io_cli::contract::hooks(&config, &root).is_some(), + io_cli::contract::hooks(&config, &config.plugins(), &root).is_some(), "the bundle's hook is declared and nothing will run it", ), } @@ -577,7 +578,7 @@ fn f11_a_bundle_is_switched_off_and_back_on_and_the_entry_survives() { std::fs::write(&file, &off).expect("the configuration"); let config = Config::discover(&root).expect("the configuration loads"); - let view = pluginview::view(&config); + let view = pluginview::view(&config.plugins()); let listed = view .plugins .iter() @@ -609,7 +610,7 @@ fn f11_a_bundle_is_switched_off_and_back_on_and_the_entry_survives() { std::fs::write(&file, &on).expect("the configuration"); let config = Config::discover(&root).expect("the configuration loads"); assert!( - pluginview::view(&config) + pluginview::view(&config.plugins()) .plugins .iter() .any(|plugin| plugin.id == "rust-review" && plugin.enabled), @@ -676,7 +677,7 @@ fn one_of_each() -> (tempfile::TempDir, PathBuf, Config) { #[test] fn f2_a_refused_row_carries_the_harness_sentence_and_the_index_still_maps() { let (_dir, _root, config) = one_of_each(); - let view = pluginview::view(&config); + let view = pluginview::view(&config.plugins()); assert_eq!(view.plugins.len(), 1); assert_eq!(view.refused.len(), 1); @@ -734,7 +735,7 @@ fn f2_a_refused_row_carries_the_harness_sentence_and_the_index_still_maps() { #[test] fn n4_every_row_fits_eighty_columns_in_both_glyph_sets() { let (_dir, _root, config) = one_of_each(); - let view = pluginview::view(&config); + let view = pluginview::view(&config.plugins()); for glyphs in [&io_cli::glyphs::UNICODE, &io_cli::glyphs::ASCII] { for row in pluginview::rows(&view, 80, glyphs) { @@ -1245,7 +1246,7 @@ fn f11_an_added_bundle_loads_through_the_harness() { std::fs::write(root.join(PROJECT_FILE), &text).expect("the project file"); let config = Config::discover(&root).expect("the written file loads"); - let view = pluginview::view(&config); + let view = pluginview::view(&config.plugins()); // By id, for the reason `listed` below states: `Config::discover` layers the // user file of whoever is running the suite over this root, and a length or a // whole-list comparison would answer about their bundles as well as this one. @@ -1389,7 +1390,7 @@ fn f7_a_configuration_declaring_only_a_switched_off_bundle_is_not_empty() { // And io-cli's surface, which is what the criterion is about. `listed` fails // by name where the bundle is on no list at all. - let view = pluginview::view(&config); + let view = pluginview::view(&config.plugins()); let off = listed(&view, OFF); assert!( !view.refused.iter().any(|refused| refused.id == OFF), @@ -1428,7 +1429,7 @@ fn f7_a_switched_off_bundle_draws_under_its_own_mark() { bundle(&root, "bundles/rust-review", MINIMAL); declaring_off(&root, "bundles/rust-review"); let config = Config::discover(&root).expect("the configuration loads"); - let view = pluginview::view(&config); + let view = pluginview::view(&config.plugins()); for glyphs in [&io_cli::glyphs::UNICODE, &io_cli::glyphs::ASCII] { // Wide enough that nothing is shortened, so the words below are compared @@ -1496,7 +1497,8 @@ fn f7_a_switched_off_bundle_reaches_no_turn_while_its_directories_still_read() { declaring_off(&root, "bundles/rust-review"); let config = Config::discover(&root).expect("the configuration loads"); - let contract = io_cli::contract::configured("review this", root.clone(), &config); + let contract = + io_cli::contract::configured("review this", root.clone(), &config, &config.plugins()); assert!( contract.agents.get("rust-review__reviewer").is_none(), "a switched-off bundle put an agent on the contract: {:?}", @@ -1512,7 +1514,7 @@ fn f7_a_switched_off_bundle_reaches_no_turn_while_its_directories_still_read() { // By id rather than by index, for the reason `listed` states: the user file of // whoever is running the suite is layered over this root and its bundles are // chained ahead of the switched-off ones. - let view = pluginview::view(&config); + let view = pluginview::view(&config.plugins()); let off = listed(&view, OFF); assert!( off.skills.is_some(), @@ -1562,7 +1564,7 @@ fn one_adapted_one_native() -> (tempfile::TempDir, pluginview::View) { bundle(&root, ADAPTED_AT, GENERATED); declaring(&root, LOCAL_FILE, &["bundles/rust-review", ADAPTED_AT]); let config = Config::discover(&root).expect("the configuration loads"); - let mut view = pluginview::view(&config); + let mut view = pluginview::view(&config.plugins()); view.adapters = Some(adapters_of(&view)); (dir, view) } @@ -1694,7 +1696,7 @@ fn an_adapted_bundle_that_is_switched_off_still_says_so_in_its_detail() { bundle(&root, ADAPTED_AT, GENERATED); declaring_off(&root, ADAPTED_AT); let config = Config::discover(&root).expect("the configuration loads"); - let mut view = pluginview::view(&config); + let mut view = pluginview::view(&config.plugins()); view.adapters = Some(adapters_of(&view)); let rows = pluginview::rows(&view, 200, &io_cli::glyphs::ASCII); @@ -1716,3 +1718,141 @@ fn an_adapted_bundle_that_is_switched_off_still_says_so_in_its_detail() { {detail:?}", ); } + +// --------------------------------------------------------------------------- +// N1/N2/N3 — the plugin set is resolved once, and re-resolved only when it moved +// --------------------------------------------------------------------------- + +/// **N2 — asking again, with nothing changed, resolves nothing.** +/// +/// `Resolved::stale` stats each declared manifest and compares its modified time +/// and its length. It parses no TOML and opens no skill file, so asking costs a +/// bounded number of `metadata` calls rather than the resolution it is deciding +/// whether to repeat — which is what makes `/plugin` and `/skills` free to open +/// when the disk has not moved. +#[test] +fn n2_nothing_on_disk_moved_means_nothing_is_resolved_again() { + let (_guard, root) = root(); + bundle(&root, "bundles/rust", MINIMAL); + declaring(&root, io_harness::config::LOCAL_FILE, &["bundles/rust"]); + let config = io_harness::Config::discover(&root).expect("the configuration"); + + let resolved = io_cli::resolved::Resolved::load(&config); + assert_eq!( + resolved.loaded().len(), + 1, + "the fixture declares one bundle" + ); + for _ in 0..5 { + assert!( + !resolved.stale(&config), + "nothing was written, so nothing may be resolved again", + ); + } +} + +/// **N3 — an edited manifest is seen.** +/// +/// The rule is stated in `src/resolved.rs` and in `docs/guide/plugins.md`, and it +/// is asserted here rather than described: an operator who edits a bundle in +/// another window and reopens `/plugin` must see the edit, or the cache has +/// quietly replaced their file with a copy of it. +#[test] +fn n3_an_edited_manifest_is_seen() { + let (_guard, root) = root(); + let dir = bundle(&root, "bundles/rust", MINIMAL); + declaring(&root, io_harness::config::LOCAL_FILE, &["bundles/rust"]); + let config = io_harness::Config::discover(&root).expect("the configuration"); + + let resolved = io_cli::resolved::Resolved::load(&config); + assert!(!resolved.stale(&config)); + + // A real edit: the description changes, so the length changes with it. + std::fs::write( + dir.join(PLUGIN_FILE), + MINIMAL.replace( + "Everything our Rust reviews need.", + "Everything our Rust reviews need, and then some more besides.", + ), + ) + .expect("the edited manifest"); + + assert!( + resolved.stale(&config), + "an edit to a declared manifest was not seen, so `/plugin` would draw the \ + file the operator no longer has", + ); +} + +/// **N3 — a bundle installed or removed is always seen**, whatever the mtimes +/// say, because the declared set itself is what is compared. +#[test] +fn n3_a_bundle_added_or_removed_is_always_seen() { + let (_guard, root) = root(); + bundle(&root, "bundles/rust", MINIMAL); + declaring(&root, io_harness::config::LOCAL_FILE, &["bundles/rust"]); + let config = io_harness::Config::discover(&root).expect("the configuration"); + let resolved = io_cli::resolved::Resolved::load(&config); + assert!(!resolved.stale(&config)); + + bundle( + &root, + "bundles/docs", + &MINIMAL.replace("rust-review", "docs-review"), + ); + declaring( + &root, + io_harness::config::LOCAL_FILE, + &["bundles/rust", "bundles/docs"], + ); + let wider = io_harness::Config::discover(&root).expect("the configuration"); + assert!( + resolved.stale(&wider), + "a second bundle was declared and the resolution did not notice — the \ + stamps are compared whole for exactly this", + ); +} + +/// **N3's stated limit, asserted rather than described.** +/// +/// A filesystem whose mtime granularity cannot separate two writes inside one +/// second will not distinguish them, and if the length is unchanged too there is +/// nothing left to compare. This is what the rule in `src/resolved.rs` and +/// `docs/guide/plugins.md` says, and a cache that cannot prove freshness should +/// say so rather than imply a guarantee it has not got. +/// +/// Written by forcing the case directly — restoring both the content length and +/// the modified time — because waiting for a same-second write would be a clock +/// in a test, which `tests/timing.rs` forbids and is right to. +#[test] +fn n3_two_writes_in_one_second_of_the_same_length_are_the_documented_limit() { + let (_guard, root) = root(); + let dir = bundle(&root, "bundles/rust", MINIMAL); + declaring(&root, io_harness::config::LOCAL_FILE, &["bundles/rust"]); + let config = io_harness::Config::discover(&root).expect("the configuration"); + + let manifest = dir.join(PLUGIN_FILE); + let before = std::fs::metadata(&manifest) + .expect("the manifest") + .modified(); + let resolved = io_cli::resolved::Resolved::load(&config); + + // Same length, and the modified time put back to what it was. + std::fs::write(&manifest, MINIMAL.replace("cheap-model", "cheep-model")) + .expect("the second write"); + if let Ok(at) = before { + let file = std::fs::File::options() + .write(true) + .open(&manifest) + .expect("the manifest"); + let _ = file.set_modified(at); + } + + assert!( + !resolved.stale(&config), + "this is the documented limit, and the documentation is what has to be \ + true: a same-second write of the same length is not distinguishable by a \ + stat, and the rule says so rather than claiming a freshness it cannot \ + prove", + ); +} From 1ea6dd202ddedf10dd86def7a84653c4eecda20a Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 30 Aug 2026 21:26:14 +0530 Subject: [PATCH 13/21] test: prove the headless path untouched and the narrow forms intact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This release is entirely an interface one, and nothing proves that except asserting it. O15 is a source property rather than a byte diff, and the test says why: a byte comparison of two binaries' NDJSON needs a provider and this suite has none. What makes the output identical is that none of this release is reachable from `src/exec.rs` — no display translation, no wrapped-row measurement, no token estimate, no mid-turn list, no viewport — and that is checkable exactly. The line is still io-harness's own `RunEvent` serialized whole. The end-to-end comparison belongs to the live suite. The headless diff bears it out: twenty-two lines, all of them the plugin threading, and `src/bridge.rs` untouched. N6 extends the narrow suite to the two overlays this release rebuilt, at 80x24 and at the floor, in both glyph sets — the elision is one character in Unicode and three in ASCII, so a surface reserving the wrong number of cells clips in exactly one of them. The question overlay must keep something to type into even at the floor, because an overlay that cannot be typed into is the defect this release exists to fix. --- tests/exec.rs | 66 ++++++++++++++++++++++++++++++ tests/narrow.rs | 104 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+) diff --git a/tests/exec.rs b/tests/exec.rs index f5e4f84..2542025 100644 --- a/tests/exec.rs +++ b/tests/exec.rs @@ -1912,3 +1912,69 @@ fn a_payload_without_its_verdict_and_a_verdict_without_its_payload_are_both_refu RecoveryDecision::Abort, ); } + +// --------------------------------------------------------------------------- +// O15 — the whole release stayed on the interactive side of the event stream +// --------------------------------------------------------------------------- + +/// **O15 — no interface change reached the headless path.** +/// +/// 0.32.0 is entirely an interface release: a viewport that grows, an answerable +/// question overlay, a name translated for display, a mid-turn command list, a +/// provisional token figure. Every one of those is a renderer or a keyboard, and +/// `io exec` has neither — it writes `serde_json::to_string` of io-harness's own +/// `RunEvent`, one per line, and defines no shape of its own. +/// +/// Asserted as a **source property** rather than by diffing two binaries' output, +/// because the byte comparison needs a provider and this suite has none: what +/// makes the NDJSON identical is that nothing in this release is reachable from +/// here, and that is checkable exactly. The end-to-end comparison is the live +/// suite's, and the release record carries it. +/// +/// The one thing that did change in `src/exec.rs` is where the plugin set comes +/// from — resolved explicitly through `crate::resolved` instead of implicitly +/// inside the contract builder. That is the same value by a different route, so +/// the contract it produces is unchanged. +/// +/// Sabotage: render a token estimate, or a `bundle:name`, anywhere in the headless +/// path. Only this fails. +#[test] +fn o15_none_of_this_releases_interface_reaches_the_headless_path() { + let text = std::fs::read_to_string( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/exec.rs"), + ) + .expect("the headless driver"); + + for surface in [ + // The display translation: `io exec` reports io-harness's own names, + // because a script reads its output and a script addresses the wire. + "naming::display", + "naming::wire", + // The wrapped-row measurement and the counted elision are viewport + // concerns, and there is no viewport here. + "rows::wrapped", + "rows::elide", + // The provisional token figure is a render of events already emitted. + "streaming", + // The mid-turn command list and the queue's delivery are keyboard + // concerns, and there is no keyboard here. + "runs_mid_turn", + "deliver_queued", + // The growing viewport. + "viewport_for", + "replace_from", + ] { + assert!( + !text.contains(surface), + "`{surface}` reached the headless path, so `io exec`'s output is no \ + longer the 0.31.0 stream a script was written against", + ); + } + + // And the line is still io-harness's own serialization, with no shape of + // io-cli's between the event and the byte stream. + assert!( + text.contains("serde_json::to_string(event)"), + "the NDJSON line is io-harness's `RunEvent`, serialized whole", + ); +} diff --git a/tests/narrow.rs b/tests/narrow.rs index eca398a..f832f87 100644 --- a/tests/narrow.rs +++ b/tests/narrow.rs @@ -2402,3 +2402,107 @@ fn n2_the_speculation_line_fits_eighty_columns() { within_eighty(set, &text_of(&lines).join("\n")); } } + +// --------------------------------------------------------------------------- +// N6 — every surface this release touched has a defined narrow form +// --------------------------------------------------------------------------- + +/// A question with everything on it: a context line, five offers, and words long +/// enough to wrap at eighty columns. +fn crowded_question() -> io_harness::Question { + io_harness::Question { + question: "which of the columns in this table should the migration drop, given \ + that it has forty rows and exactly one caller anywhere in the workspace?" + .to_string(), + context: Some( + "the table is referenced once, from the importer, and nothing reads it after \ + the backfill finishes" + .to_string(), + ), + choices: vec![ + "created_at".to_string(), + "updated_at".to_string(), + "deleted_at, which the soft-delete path still writes on every request".to_string(), + "archived_at".to_string(), + "expired_at".to_string(), + ], + } +} + +/// **N6 — the question overlay has a defined form at 80x24 and at the floor.** +/// +/// Both glyph sets, because the elision is one character in Unicode and three in +/// ASCII, and a surface that reserved the wrong number of cells clips in exactly +/// one of them. +/// +/// At the floor there is not room for all of it — that is the point of asserting +/// there — so what is checked is that it degrades: no row overflows, the question +/// is still readable, and the composer is still on screen, because an overlay that +/// cannot be typed into is the defect this release exists to fix and it must not +/// come back at a small size. +#[test] +fn n6_the_question_overlay_is_defined_at_eighty_by_twenty_four_and_at_the_floor() { + for theme in themes() { + let set = theme.glyphs.name; + for height in [HEIGHT, io_cli::term::VIEWPORT_HEIGHT] { + let (answer, _reply) = tokio::sync::oneshot::channel(); + let mut overlay = io_cli::intent::Intent::new(io_cli::intent::Asked { + question: crowded_question(), + answer, + }); + let (mut screen, _recorder) = support::screen_of(WIDTH, HEIGHT, height); + screen + .draw(|frame| overlay.render(frame, frame.area(), &theme)) + .expect("a frame"); + let drawn = screen.viewport_text().to_string(); + + within_eighty(set, &drawn); + assert!( + drawn.contains("which of the columns"), + "{set} at {height}: the question is not readable: {drawn:?}", + ); + assert!( + drawn.contains(io_cli::composer::PROMPT.trim_end()), + "{set} at {height}: there is nothing to type into, which is the \ + defect this release exists to fix: {drawn:?}", + ); + } + } +} + +/// **N6 — the plan overlay keeps its footer at both sizes**, which is the one +/// thing its own module doc forbids losing and the one thing it always lost first. +#[test] +fn n6_the_plan_overlay_keeps_its_footer_at_both_sizes() { + for theme in themes() { + let set = theme.glyphs.name; + for height in [HEIGHT, io_cli::term::VIEWPORT_HEIGHT] { + let steps: Vec = (0..12) + .map(|n| io_harness::PlanStep { + intent: format!( + "step {n}: read every file the importer touches and report what \ + still references the column" + ), + agent: None, + }) + .collect(); + let (verdict, _reply) = tokio::sync::oneshot::channel(); + let overlay = io_cli::plan::Review::new(io_cli::plan::Proposed { + plan: io_harness::Plan { steps }, + verdict, + }); + let (mut screen, _recorder) = support::screen_of(WIDTH, HEIGHT, height); + screen + .draw(|frame| overlay.render(frame, frame.area(), &theme)) + .expect("a frame"); + let drawn = screen.viewport_text().to_string(); + + within_eighty(set, &drawn); + assert!( + drawn.contains("Esc cancels"), + "{set} at {height}: the footer naming the keys is gone, which is \ + what a twelve-step plan always did to it: {drawn:?}", + ); + } + } +} From b602081b25edae81198e1e17fef4989c75c150ba Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 30 Aug 2026 21:26:23 +0530 Subject: [PATCH 14/21] docs: move the documentation with the code, and bump to 0.32.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `docs/CONTRACT.md` gains four things a script may depend on: the ten commands that run mid-turn and the rule that decides the set, `/config` refused in every form and why, the provisional token figure's spelling and the fact that the estimate and the settled number are never summed, and the display-versus-wire split — including that `io exec`, `io plugin` and `io skill` report the wire name, because a script addresses the wire. Four guide pages follow the code: keys.md gains a section naming both halves of the mid-turn partition, skills.md the colon form and the round trip, the-session.md the growing viewport with its four-row reserve stated as a number, and plugins.md when io re-reads bundles and the same-second limit it cannot see. The key table is generated from `commands::KEYS` and gated in both directions, so `Tab` went into the shipped table, the guide and the coverage list together — the gate caught them disagreeing on the first attempt. --- CHANGELOG.md | 101 +++++++++++++++++++++++++++++++++++++- Cargo.toml | 2 +- docs/CONTRACT.md | 25 ++++++++++ docs/guide/keys.md | 30 ++++++++++- docs/guide/plugins.md | 26 ++++++++++ docs/guide/skills.md | 26 ++++++++++ docs/guide/the-session.md | 56 +++++++++++++++++++++ src/commands.rs | 61 +++++++++++++++++------ tests/commands.rs | 8 ++- 9 files changed, 315 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28b9288..f54993c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,104 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +## [0.32.0] - 2026-08-30 + +The agent could ask you a question and you could not answer it. `Intent::render` +drew the answer composer only where there was room left over, and a question with +a context line and five choices filled all eight rows of the viewport — so what +you actually saw was a question, some inert bullets, and nothing to type into. +The same question reached the screen twice, and the second copy was labelled +`warning:`. + +That fixed eight-row viewport is one cause behind four defects, so this release +makes the viewport the size of what it has to show. The rest of what is here +follows from it: a plan overlay that keeps its own footer, a queue you can read, +pickers that say how much they are not showing. + +Alongside it, the interface stops withholding things it already does. Ten +commands run while a turn is in flight. A message typed mid-turn reaches that +turn. A bundle's skill is drawn under a name you can read and type. The token +figure moves while the tokens are being spent. And the plugin set is resolved once +for the session rather than twice for every message. + +### Added + +**The viewport grows to what a surface asks for, and gives the rows back.** A +question overlay asks for its offers and its composer, a plan overlay for its +steps and its footer, the queue for one row per message, a picker for its list. +The ceiling is your terminal's height less four rows — not a ration, but the +exchange the surface is about, kept visible. On an 80×24 that is a twenty-row +viewport. `VIEWPORT_HEIGHT` is a floor now rather than a fixed size. + +**The question overlay is answerable, as one list with no modes.** The agent's +offers and a row for an answer nobody offered are peers in the same list. `Enter` +on an offer sends it verbatim; the last row unfolds a composer directly beneath +it and typing there sends prose. Typing anywhere moves the marker to that row, so +you can simply start writing. The marker opens there too, never on the agent's +first suggestion — a reflexive `Enter` must not become silent agreement with +something you have not read. + +**`Tab` takes the row under the marker, in every list in the product**, and +`Shift+Tab` steps back. Both were unbound and did nothing. + +**Ten commands run while a turn is in flight**: `/status`, `/context`, `/cost`, +`/stats`, `/help`, `/theme`, `/copy`, `/expand`, `/fleet` and `/image`. `/` opens +the palette mid-turn and `@` completes a path. Everything that reassigns the +session or the provider, writes the store or a configuration file, or submits a +turn keeps its refusal. + +**A message typed during a turn is delivered to that turn**, at its next step +boundary, and recorded in the transcript as it goes. `/steer` is unchanged. A +message that reaches no further step boundary still runs as its own turn +afterwards. + +**A live token figure**, estimated from the streamed text and written `~1.2k tok` +so it cannot be read as settled. The provider's own number replaces it when the +step commits. + +### Changed + +**A bundle's contributions are drawn `bundle:name` rather than `bundle__name`** — +on `/skills`, in the palette, in the `Read skill` line, in `/plugin`'s inventory +and on `/status`. io-harness's separator is unchanged and still what the model +sees; io translates at the two edges. Choosing a skill now writes the command +`/bundle:name` instead of the sentence `use the skill: `, which submitted +as an ordinary prompt and left it to the model to interpret. + +**The plugin set is resolved once per session** rather than twice on the build +path of every turn, and re-read only when a declared manifest's stamp changes. +`/plugin` and `/skills` cost nothing to open when nothing has moved. + +**Tones say what they mean.** `refused:` is reserved for an act the permission +boundary refused. A mistyped `/effort` argument, a run that is simply not in the +store, three watch errors and two `/memory` bookkeeping lines were all wearing it; +a plan proposal and a question were drawn as warnings. All corrected. + +### Fixed + +**A question is drawn once, and never as a warning.** It was committed to the +transcript and redrawn by the overlay, with the overlay's copy prefixed +`warning:`. The transcript line survives wherever nothing else will draw it — +under `--plain`, and on a resumed run, which has no overlay in this process at +all. + +**Overlays measured themselves by counting lines while rendering through a +wrapping paragraph**, so a long question or plan step consumed more rows than the +count admitted: the offers and the footer fell off the bottom, and the composer +was then drawn over rows already painted. Everything measures wrapped rows now. + +**The plan overlay keeps its `Enter approves / Esc cancels` footer.** It was the +last line pushed and so the first line lost, on the one overlay whose own +documentation forbids exactly that. + +**Truncation says what it dropped.** A picker over four hundred rows, a fleet view +with more children than rows, and `/stats`' per-table breakdown all cut silently; +`/stats` was capping at five on a page with unlimited rows. + +**`outcome_help` told you a parked question or plan could not be answered by this +release and to say it in your next prompt.** `/resume` has answered both since +0.23.0. + ## [0.31.0] - 2026-08-30 io-cli has operated a marketplace nobody could stock. Every capability bundle @@ -2740,7 +2838,8 @@ client, tool, sandbox, policy engine or session store of its own. - There is no crates.io publish and `cargo install` is not an install path. - No test in this release asserts on wall-clock time. -[Unreleased]: https://github.com/initorigin/io-cli/compare/v0.31.0...HEAD +[Unreleased]: https://github.com/initorigin/io-cli/compare/v0.32.0...HEAD +[0.32.0]: https://github.com/initorigin/io-cli/compare/v0.31.0...v0.32.0 [0.31.0]: https://github.com/initorigin/io-cli/compare/v0.30.2...v0.31.0 [0.30.2]: https://github.com/initorigin/io-cli/compare/v0.30.1...v0.30.2 [0.30.1]: https://github.com/initorigin/io-cli/compare/v0.30.0...v0.30.1 diff --git a/Cargo.toml b/Cargo.toml index 66f0bfe..f1d17ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "io-cli" -version = "0.31.0" +version = "0.32.0" edition = "2021" # io-harness declares 1.95, set by `libsqlite3-sys` calling the std `cfg_select!` # macro from its build script. Nothing here can be lower than what the harness diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index 5c4d857..87d3a93 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -147,6 +147,31 @@ points an operator can enter the state. **Every HTTP MCP server is refused by default**, because the default network effect is `Deny`. A bare-host rule opens it. +**Ten commands run while a turn is in flight, and the rest are refused.** `/status`, `/context`, +`/cost`, `/stats`, `/help`, `/theme`, `/copy`, `/expand`, `/fleet` and `/image` report while the +agent works; `/` and `@` open the palette and path completion; `/compact` and `/steer` reach the +turn through their own arms as they always have. Every other command keeps its refusal, in the same +sentence, and the rule is what a command *does* rather than how harmless it looks: anything that +reassigns the session or the provider, writes the store or a configuration file, or submits a turn +of its own is refused. + +**`/config` is refused mid-turn in every form, including the bare one.** Its picker offers a row +that re-reads the provider's catalogue, writes the prices into a scope file and reassigns the +configuration the running turn is holding. The run-state guard is on the whole command deliberately +— splitting a bare form from an argued one is where a mistake ships a write into a running turn — +and the same applies to `/plugin`, `/mcp`, `/provider`, `/skills`, `/memory` and `/store`. + +**A mid-stream token figure is an estimate and is drawn as one.** `EventKind::Token` carries text +and no count, because providers do not bill per chunk, so the figure that moves while a step is +streaming is derived from the delta text and written `~1.2k tok`. It is replaced by the provider's +own number the moment the step commits, and the settled form has no tilde. Nothing adds the two +together. + +**A bundle's contributions are displayed with a colon and addressed with io-harness's separator.** +`ultraship:brainstorm` is what you read; `ultraship__brainstorm` is what the model was shown and +what `read_skill` resolves. `io exec`, `io plugin` and `io skill` report the wire name, because a +script addresses the wire. + ## What may change Everything not named above. In particular: the layout of what is drawn on the terminal, the diff --git a/docs/guide/keys.md b/docs/guide/keys.md index 0fe37a4..9fbe5cf 100644 --- a/docs/guide/keys.md +++ b/docs/guide/keys.md @@ -17,7 +17,8 @@ | `Ctrl+F` | show the fleet: the children this turn has spawned | | `y / a / n` | answer an approval: allow once, allow this session, deny | | `Esc` | stop the running turn, or close a picker without choosing | -| `/` | at an empty prompt, open the command palette | +| `Tab` | in any list, take the row under the marker; `Shift+Tab` steps back | +| `/` | open the command palette — at the prompt or while a turn runs | | `@` | after a space, complete a path from the workspace | | `!` | run the rest of the line in your shell; the agent never sees it | @@ -134,3 +135,30 @@ the defaults that shipped, and marks `Ctrl+C` as fixed. --- [README](../../README.md) · [All guides](../CAPABILITIES.md) · [What you may depend on](../CONTRACT.md) + +## While a turn is running + +**Ten commands report while the agent works.** `/status`, `/context`, `/cost`, +`/stats`, `/help`, `/theme`, `/copy`, `/expand`, `/fleet` and `/image` all answer +mid-turn, `/` opens the palette and `@` completes a path, and `/compact` and +`/steer` reach the running turn as they always have. + +Until 0.32.0 every slash but those last two was refused with *not while a turn is +running — Ctrl+C interrupts it first*, so reading a status page meant stopping +your own work to do it. None of these was a missing capability; each was one the +product had and withheld. + +**Everything else keeps that refusal, and the rule is what a command does rather +than how harmless it looks.** A command that reassigns the session or the +provider, writes the store or a configuration file, or submits a turn of its own +is refused: `/clear`, `/resume`, `/fork`, `/model`, `/undo`, `/setup`, `/import`, +`/profile`, `/effort`, `/contain`, `/plan`, `/gates`, `/remember`, `/commit`, +`/config`, `/plugin`, `/mcp`, `/provider`, `/skills`, `/memory`, `/store`, and a +`!` line. + +**`/config` is refused even bare**, and that is worth saying because it looks like +a report. Its picker offers a row that re-reads your provider's catalogue, writes +the prices into a scope file and reassigns the configuration the running turn is +holding. The guard is on the whole command rather than on the verb, deliberately: +splitting a reading form from a writing one is where a mistake ships a write into +a turn that is already running. diff --git a/docs/guide/plugins.md b/docs/guide/plugins.md index 3e478d3..e4964d5 100644 --- a/docs/guide/plugins.md +++ b/docs/guide/plugins.md @@ -220,3 +220,29 @@ rather than ignoring it. Remove the `enabled` keys before downgrading. --- [README](../../README.md) · [All guides](../CAPABILITIES.md) · [What you may depend on](../CONTRACT.md) + +## When io re-reads your bundles + +Reading a bundle is not free: every declared `plugin.toml` is opened, parsed, +validated and trust-checked, and every skill file inside it is read in full to +recover its name and description. Until 0.32.0 io did all of that **twice for every +message you sent**, and again each time you opened `/plugin` or `/skills`. With a +few bundles installed that is a pause before every turn. + +**It is now read once for the session**, at startup, and again only when something +on disk has moved. + +**How io decides it moved.** It stats each declared bundle's `plugin.toml` and +compares the modified time and the length against what it recorded. That is a +handful of `metadata` calls, not a re-read — which is why `/plugin` and `/skills` +open instantly when nothing has changed. + +A bundle installed or removed is **always** seen, because the declared set itself is +compared rather than each entry in it. + +**The limit, stated rather than implied.** On a filesystem whose modified-time +granularity cannot separate two writes inside one second, a second edit that leaves +the file exactly the same length is not distinguishable from the first, and io will +not notice it until something else about the bundle changes. Restart the session, +or touch the file, if you have hit that. io says this rather than claiming a +freshness it cannot prove. diff --git a/docs/guide/skills.md b/docs/guide/skills.md index 683b8c2..06cf08a 100644 --- a/docs/guide/skills.md +++ b/docs/guide/skills.md @@ -130,3 +130,29 @@ as well. --- [README](../../README.md) · [All guides](../CAPABILITIES.md) · [What you may depend on](../CONTRACT.md) + +## The name you read, and the name the model was shown + +A bundle's skill is qualified with the bundle it came from, so two bundles can +each contribute a `review` and both are addressable. io-harness joins the two +halves with a double underscore — `ultraship__brainstorm` — and that string is +load-bearing: it is what goes into the model's own catalogue, and `read_skill` +resolves it by exact match, so the model can only ask for the string it was shown. + +**Since 0.32.0 you read `ultraship:brainstorm` instead.** The `/skills` list, the +`/` palette, the `Read skill` line in your scrollback, `/plugin`'s inventory and +`/status`'s policy rows all draw the colon form, and typing `/ultraship:brainstorm` +runs it. Nothing about the wire changed: io translates at the two edges — once on +the way to your screen, once on the way back from your keyboard — and io-harness +never sees a colon. + +`io exec`, `io plugin` and `io skill` report the underscore form, because a script +reads their output and a script addresses the wire. + +**Choosing a skill from the palette now writes a command rather than a sentence.** +It used to put `use the skill: ` into the composer, which submits as an +ordinary prompt — so whether the skill actually ran depended on how the model read +an English request. It writes `/ultraship:brainstorm` now, and that dispatches. + +A skill whose name collides with a built-in command resolves to the command. No +command contains a colon, so the two can always be told apart. diff --git a/docs/guide/the-session.md b/docs/guide/the-session.md index 5c11492..671d5b9 100644 --- a/docs/guide/the-session.md +++ b/docs/guide/the-session.md @@ -215,3 +215,59 @@ has started no background work has not started zero jobs. --- [README](../../README.md) · [All guides](../CAPABILITIES.md) · [What you may depend on](../CONTRACT.md) + +## The viewport takes the rows a surface needs + +`io` draws into an inline viewport — a few rows at the bottom of your terminal, +with everything finished committed above them into the terminal's own scrollback, +where your search and your selection already work. That viewport was a fixed eight +rows from 0.1.0 until 0.32.0, and a surface needing more quietly lost the +remainder. + +**It is now the size of what it has to show.** A question overlay asks for its +offers and its composer, a plan overlay for its steps and its footer, the queue for +one row per message it is holding, and a picker for its list. When the surface +closes the rows go back. + +**The ceiling is your terminal's height less four rows.** Those four are not a +ration — a surface may take the screen when it needs it — they are the exchange the +surface is *about*, kept visible. A question filling the terminal to the last row +is a question with no readable reason for being asked. On an 80×24 that leaves a +twenty-row viewport, which holds a twelve-step plan with its footer, or a +five-choice question with its context line and somewhere to type. + +**A surface that cannot have what it asked for degrades rather than overflowing.** +Growth is a request. Every one of them elides with a count — `⋯ 3 more` — so a +list cut short never looks like a list that ended. + +The scrollback above is untouched by any of this. Committed rows are the +terminal's, and growing or shrinking the viewport neither repeats them nor loses +them. + +## A message typed mid-turn reaches that turn + +Type while the agent is working and your line is queued rather than sent. The +queue is drawn above the composer, every message with its position. + +**Since 0.32.0 those messages are delivered into the running turn**, at its next +step boundary, and each one is recorded in your transcript as it goes. Before, they +waited for the turn to end and then ran as turns of their own — so a correction +typed thirty seconds into a ten-minute turn arrived nine and a half minutes late, +against work it was meant to change. + +`/steer` still sends immediately, and behaves exactly as it did. + +**A message that reaches no further step boundary is not lost.** If the turn +finishes, is interrupted, or errors before reading it, it runs as its own turn +afterwards and the session says which of the two happened. + +## The token figure while a step is streaming + +Providers do not bill per chunk, so nothing in the event stream carries a running +token count. Until 0.32.0 the figure simply sat still for the whole of a turn while +the clock beside it ran. + +It now moves, as an **estimate** taken from the streamed text, and it is written +with a leading tilde — `~1.2k tok` — so it can never be read as settled. When the +step commits, the provider's own number replaces it and the tilde goes. The two are +never added together. diff --git a/src/commands.rs b/src/commands.rs index b2ba4b4..2a179e8 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -102,7 +102,14 @@ pub const KEYS: &[(&str, &str)] = &[ "Esc", "stop the running turn, or close a picker without choosing", ), - ("/", "at an empty prompt, open the command palette"), + ( + "Tab", + "in any list, take the row under the marker; `Shift+Tab` steps back", + ), + ( + "/", + "open the command palette \u{2014} at the prompt or while a turn runs", + ), ("@", "after a space, complete a path from the workspace"), ( "!", @@ -2587,15 +2594,7 @@ fn table>(rows: &[(S, S)], width: usize, theme: &Theme) -> Vec Date: Sun, 30 Aug 2026 21:36:11 +0530 Subject: [PATCH 15/21] test: gate the two criteria the sabotage pass found ungated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both were written, documented and green, and neither had a test that could fail. The pass is what found them. O3 is the release's headline fix — a question drawn once rather than twice, and never as a warning. Replacing its condition with `if true`, which is exactly the defect it guards, failed nothing in a suite of 1,718. It is now asserted in both directions, and the second matters more: suppressing a question everywhere is worse than printing it twice, so a resumed run and a plain session must both still commit the line. O11 is `Tab`. It was bound in `Picker::key`, listed in the shipped key table, and written into the guide — and removing the arm entirely broke no test. Both spellings of shift-tab are asserted too, because a picker reads raw key events rather than going through the binding table, so a terminal sending `BackTab` and one sending `Tab`+SHIFT both arrive here as themselves. Eleven arms in total now, every one with a real site, every one killing the gate it was aimed at and nothing else. --- tests/events.rs | 80 +++++++++++++++++++++++++++++++++++++++++++++++++ tests/picker.rs | 80 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+) diff --git a/tests/events.rs b/tests/events.rs index 937572e..a001d9d 100644 --- a/tests/events.rs +++ b/tests/events.rs @@ -2445,3 +2445,83 @@ fn f9_a_stall_names_the_step_it_stopped_on_and_how_long_it_has_been_there() { assert!(stalled.contains("circles"), "{stalled:?}"); assert!(stalled.contains("stops here"), "{stalled:?}"); } + +// --------------------------------------------------------------------------- +// O3 — a question is committed exactly where nothing else will draw it +// --------------------------------------------------------------------------- + +/// A question the agent asked, as io-harness emits it. +fn a_question() -> EventKind { + EventKind::QuestionAsked { + question: "drop the column or keep it?".to_string(), + choices: vec!["drop".to_string(), "keep".to_string()], + } +} + +/// **O3 — with an overlay holding the question, the transcript stays quiet.** +/// +/// Through 0.31.0 this line was committed unconditionally and the overlay redrew +/// the same question through `Tone::Warning`, so the operator was asked twice and +/// told the second time was a warning. Two renderers, neither aware of the other. +/// +/// **The sabotage pass is why this test exists.** The condition was written, the +/// suite was green, and replacing it with `if true` — the exact defect it guards — +/// failed nothing at all. The fix had no gate until the arm was run. +#[test] +fn o3_a_question_is_not_committed_when_an_overlay_will_draw_it() { + let mut events = Events::new(DARK); + events.set_answering(true); + let line = rendered(&mut events, a_question()); + assert!( + line.is_empty(), + "the overlay is holding this question, so committing it asks the operator \ + twice: {line:?}", + ); +} + +/// **O3's other direction, and it is the one that matters more.** Suppressing a +/// question everywhere is a worse defect than printing it twice, so every path +/// that has no overlay must still commit the line. +#[test] +fn o3_a_question_is_committed_wherever_nothing_will_draw_it() { + // A resumed run: this process attached a responder but dropped the receiver, + // so no overlay exists here. `answering` is false and the line is the only + // thing that renders the question at all. + let mut resumed = Events::new(DARK); + resumed.set_answering(false); + let line = rendered(&mut resumed, a_question()); + assert!( + line.contains("drop the column or keep it?"), + "a resumed run's question reached no surface at all: {line:?}", + ); + assert!( + line.contains("drop") && line.contains("keep"), + "the offers go with it, or the operator is asked a question whose choices \ + they cannot see: {line:?}", + ); + + // Plain mode draws no overlay, whatever this process is holding. + let mut plain = Events::new(DARK); + plain.set_answering(true); + plain.set_plain(true); + let line = rendered(&mut plain, a_question()); + assert!( + line.contains("drop the column or keep it?"), + "plain mode draws no overlay, so the transcript is the only renderer: {line:?}", + ); +} + +/// **O3 — and it is not drawn as a warning.** `Tone::Warning`'s word is literally +/// `warning`, so under `MONO` every question the agent ever asked announced itself +/// as one. +#[test] +fn o3_the_committed_question_is_not_a_warning() { + let mut events = Events::new(io_cli::theme::MONO); + events.set_answering(false); + let line = rendered(&mut events, a_question()); + assert!(line.contains("the agent asks:"), "{line:?}"); + assert!( + !line.contains("warning"), + "a question is not a warning: {line:?}", + ); +} diff --git a/tests/picker.rs b/tests/picker.rs index ef28ca5..1e33768 100644 --- a/tests/picker.rs +++ b/tests/picker.rs @@ -621,3 +621,83 @@ fn f7_a_paste_does_not_leak_past_an_open_picker() { app.composer.text(), ); } + +// --------------------------------------------------------------------------- +// O11 — Tab accepts, Shift+Tab steps back, in every picker in the product +// --------------------------------------------------------------------------- + +/// **O11 — `Tab` takes the row under the marker.** +/// +/// It applies to every list in the product rather than to the palette alone, +/// because the product ships one `Picker`. Until 0.32.0 it fell into the +/// catch-all arm and did nothing at all — a key that looks like it should work and +/// silently does not is worse than one that is not bound. +/// +/// **The sabotage pass is why this test exists.** `Tab` was bound, documented in +/// the shipped key table and in the guide, and asserted nowhere: removing the arm +/// failed no test in the suite. +#[test] +fn o11_tab_takes_the_row_under_the_marker() { + let mut picker = Picker::new( + "Which command?", + vec![Row::new("first"), Row::new("second"), Row::new("third")], + ); + assert_eq!(picker.key(key(KeyCode::Down)), Outcome::Idle); + assert_eq!( + picker.key(key(KeyCode::Tab)), + Outcome::Chosen(1), + "Tab is the completion key an operator arrives already expecting, and it \ + must take the marked row exactly as Enter does", + ); +} + +/// **O11 — `Shift+Tab` steps the marker back, in both spellings a terminal +/// sends.** +/// +/// A terminal may send `BackTab`, or `Tab` with the shift modifier set. +/// `crate::keys` normalises the first into the second for a *binding*, but a +/// picker reads raw key events and never goes through that table — so both arrive +/// here as themselves, and matching only one is a key that works on some +/// terminals and not others. +#[test] +fn o11_shift_tab_steps_the_marker_back_in_both_spellings() { + for back in [ + KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE), + KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT), + ] { + let mut picker = Picker::new( + "Which command?", + vec![Row::new("first"), Row::new("second"), Row::new("third")], + ); + picker.key(key(KeyCode::Down)); + picker.key(key(KeyCode::Down)); + assert_eq!(picker.selected(), 2, "the marker moved down twice"); + + assert_eq!( + picker.key(back), + Outcome::Idle, + "stepping back chooses nothing" + ); + assert_eq!( + picker.selected(), + 1, + "{back:?} did not step the marker back", + ); + } +} + +/// **O11 — `Tab` on a heading declines, exactly as `Enter` does.** +/// +/// A heading cannot be reached by any path that moves the marker, but `Tab` is now +/// a second key that would turn a mistake there into the wrong action, so it +/// declines rather than trusting that. +#[test] +fn o11_tab_declines_on_a_heading_like_enter_does() { + let mut picker = Picker::new( + "Which command?", + vec![Row::heading("a group"), Row::new("first")], + ); + // The marker steps off the heading when the picker opens, so this asserts the + // pair agree rather than manufacturing an unreachable state. + assert_eq!(picker.key(key(KeyCode::Tab)), Outcome::Chosen(1)); +} From 48667e567e59daaf3237180c264a5ccab4c7bfc1 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 30 Aug 2026 21:36:39 +0530 Subject: [PATCH 16/21] style: rustfmt, and the lockfile's version `cargo fmt --all` over the release's own changes, and `Cargo.lock` picking up the 0.32.0 bump from the manifest. CI runs `cargo fmt --all -- --check`, so this is the difference between a green gate and a red one rather than a matter of taste. --- Cargo.lock | 2 +- src/app.rs | 4 +--- src/intent.rs | 8 +++----- src/picker.rs | 16 +++++++++++++--- src/plan.rs | 11 +++++++++-- src/rows.rs | 18 +++++++++++++++--- src/stats.rs | 7 ++++++- tests/intent.rs | 6 +++++- tests/interrupt.rs | 6 +++++- tests/plain.rs | 6 +++--- tests/queue_edit.rs | 32 ++++++++++++++++---------------- tests/queue_surface.rs | 12 ++++++------ tests/status.rs | 16 +++++++++++++--- tests/support/mod.rs | 7 +------ tests/viewport.rs | 6 +++++- 15 files changed, 102 insertions(+), 55 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e261fcd..37f2db9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1850,7 +1850,7 @@ dependencies = [ [[package]] name = "io-cli" -version = "0.31.0" +version = "0.32.0" dependencies = [ "clap", "crossterm", diff --git a/src/app.rs b/src/app.rs index 606b286..f49685c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1439,8 +1439,7 @@ impl App { let composer = self .composer .rows_wanted(width) - .min(COMPOSER_MAX) - .max(COMPOSER_ROWS); + .clamp(COMPOSER_ROWS, COMPOSER_MAX); let queued = if self.queue_open() { u16::try_from(self.prompts.len()).unwrap_or(u16::MAX) } else { @@ -1745,7 +1744,6 @@ impl App { lines } - /// What a keystroke means to the session. /// /// **The `match` is on an [`Action`], not on a `KeyCode`.** Which chord diff --git a/src/intent.rs b/src/intent.rs index 0bfa0b0..47e11e9 100644 --- a/src/intent.rs +++ b/src/intent.rs @@ -166,8 +166,8 @@ impl Intent { /// The free-text row is **last and is a peer**, not a footer: it is chosen the /// way every other row is chosen, and io-harness's own documentation says an /// answer is not obliged to be one of `choices`, so the surface should not - /// imply otherwise. Its index is `choices.len()`, which is what - /// [`Self::free_text`] resolves and what the unfold is keyed on. + /// imply otherwise. Its index is `choices.len()`, which is what the unfold is + /// keyed on and what [`Intent::key`] focuses when the operator starts typing. fn list(question: &Question) -> Picker { let mut rows: Vec = question .choices @@ -194,7 +194,6 @@ impl Intent { offers } - /// Open on a question a run already paused on, read back off the store. /// /// The row carries the answer and who gave it as well; neither is a question, @@ -404,7 +403,6 @@ impl Intent { /// [`crate::app::App::viewport_wanted`] clamps it to what the terminal can /// spare, and [`Self::render`] degrades against whatever it is given. pub fn rows_wanted(&self, width: u16, theme: &Theme) -> u16 { - crate::rows::wrapped(&self.head(theme), width) - .saturating_add(self.offers.rows_wanted()) + crate::rows::wrapped(&self.head(theme), width).saturating_add(self.offers.rows_wanted()) } } diff --git a/src/picker.rs b/src/picker.rs index f14e5e9..218ace3 100644 --- a/src/picker.rs +++ b/src/picker.rs @@ -700,8 +700,14 @@ impl Picker { // for. Reserved as blank rows here and drawn by the caller into the // rectangle recorded after the loop, because only this widget knows // where the row landed once the list has scrolled. - if block > 0 && self.unfold.is_some_and(|(row, _)| row == self.matches[position]) { - let top = area.y.saturating_add(u16::try_from(lines.len()).unwrap_or(u16::MAX)); + if block > 0 + && self + .unfold + .is_some_and(|(row, _)| row == self.matches[position]) + { + let top = area + .y + .saturating_add(u16::try_from(lines.len()).unwrap_or(u16::MAX)); if top.saturating_add(block) <= area.y.saturating_add(area.height) { opened_at = Some(Rect { x: area.x, @@ -720,7 +726,11 @@ impl Picker { // What the list could not show. Counted against everything the query // admits rather than against what is below the window, because a row // scrolled off the top is just as absent as one below the bottom. - let drawn = taken.min(self.matches.len().saturating_sub(self.offset.min(self.matches.len()))); + let drawn = taken.min( + self.matches + .len() + .saturating_sub(self.offset.min(self.matches.len())), + ); let hidden = self.matches.len().saturating_sub(drawn); if hidden > 0 { let note = format!("{} {hidden} more", theme.glyphs.elision); diff --git a/src/plan.rs b/src/plan.rs index 416e613..630835c 100644 --- a/src/plan.rs +++ b/src/plan.rs @@ -169,10 +169,17 @@ impl Review { // because these lines wrap. let head_rows = crate::rows::wrapped(std::slice::from_ref(&header), area.width); let foot_rows = crate::rows::wrapped(std::slice::from_ref(&footer), area.width); - let room = text_rows.saturating_sub(head_rows).saturating_sub(foot_rows); + let room = text_rows + .saturating_sub(head_rows) + .saturating_sub(foot_rows); let mut lines = vec![header]; - lines.extend(crate::rows::elide(self.steps(theme), room, area.width, theme)); + lines.extend(crate::rows::elide( + self.steps(theme), + room, + area.width, + theme, + )); lines.push(footer); frame.render_widget( diff --git a/src/rows.rs b/src/rows.rs index 6305947..c3ed34d 100644 --- a/src/rows.rs +++ b/src/rows.rs @@ -80,7 +80,12 @@ pub fn more(rows: u16, theme: &Theme) -> Line<'static> { /// at least one row, so a prefix longer than `room` can never fit in `room` rows. /// That bounds the loop by the height of a viewport rather than by the length of /// the content, which matters for a picker over four hundred rows. -pub fn elide(lines: Vec>, room: u16, width: u16, theme: &Theme) -> Vec> { +pub fn elide( + lines: Vec>, + room: u16, + width: u16, + theme: &Theme, +) -> Vec> { if room == 0 { return Vec::new(); } @@ -122,7 +127,11 @@ mod tests { // The defect this module exists for: one line, several rows. let long = vec![Line::from("hello world this wraps")]; - assert_eq!(wrapped(&long, 20), 2, "a wrapped line still counted as one row"); + assert_eq!( + wrapped(&long, 20), + 2, + "a wrapped line still counted as one row" + ); assert!( wrapped(&long, 20) > u16::try_from(long.len()).unwrap(), "the wrapped height must exceed the line count, or the measurement is \ @@ -140,7 +149,10 @@ mod tests { wrapped(&[Line::from("anything")], 0) >= 1, "a zero-width area reported no rows, which reads as unlimited room", ); - assert_eq!(wrapped(&[Line::from("anything")], 0), wrapped(&[Line::from("anything")], 1)); + assert_eq!( + wrapped(&[Line::from("anything")], 0), + wrapped(&[Line::from("anything")], 1) + ); } #[test] diff --git a/src/stats.rs b/src/stats.rs index 96be7a5..8d3863b 100644 --- a/src/stats.rs +++ b/src/stats.rs @@ -221,7 +221,12 @@ pub fn committed( } // What the cap held back. Silence here is indistinguishable from a // database with exactly five tables in it. - if let Some(rest) = size.tables.len().checked_sub(TABLES).filter(|rest| *rest > 0) { + if let Some(rest) = size + .tables + .len() + .checked_sub(TABLES) + .filter(|rest| *rest > 0) + { rows.push(Row::note(format!( "{rest} smaller table{} not shown", if rest == 1 { "" } else { "s" } diff --git a/tests/intent.rs b/tests/intent.rs index 933a6de..68f4b0a 100644 --- a/tests/intent.rs +++ b/tests/intent.rs @@ -518,7 +518,11 @@ fn o2_enter_on_an_offer_sends_that_offer_verbatim() { let (mut overlay, _reply) = live(five_choices()); // Up from the free-text row is the last offer. - assert_eq!(overlay.key(key(KeyCode::Up)), None, "moving does not answer"); + assert_eq!( + overlay.key(key(KeyCode::Up)), + None, + "moving does not answer" + ); assert_eq!( overlay.key(key(KeyCode::Enter)), Some(Some("expired_at".to_string())), diff --git a/tests/interrupt.rs b/tests/interrupt.rs index e5413c7..8bc65df 100644 --- a/tests/interrupt.rs +++ b/tests/interrupt.rs @@ -241,7 +241,11 @@ fn f9_a_streaming_answer_of_any_length_leaves_the_viewport_the_same_size() { committed, 200, "every finished line should have been committed as it arrived", ); - assert_eq!(app.viewport_wanted(80, 40), quiet, "the viewport did not move"); + assert_eq!( + app.viewport_wanted(80, 40), + quiet, + "the viewport did not move" + ); // A partial line stays live until something finishes it. app_lines(&mut app, "the tail with no newline yet"); diff --git a/tests/plain.rs b/tests/plain.rs index 9561089..d92c01b 100644 --- a/tests/plain.rs +++ b/tests/plain.rs @@ -691,9 +691,9 @@ fn f11_status_in_plain_mode_is_the_same_content_in_the_ascii_set() { &theme, 80, ) - .iter() - .map(text_of) - .collect() + .iter() + .map(text_of) + .collect() }; let rich = page(Glyphs::resolve(false, true, None), DARK, false); diff --git a/tests/queue_edit.rs b/tests/queue_edit.rs index 6de1623..fae9e16 100644 --- a/tests/queue_edit.rs +++ b/tests/queue_edit.rs @@ -112,7 +112,7 @@ fn with_queue(count: usize) -> App { } /// The viewport's rows as text, blanks kept: a row's *index* is the assertion. -fn rows_at(app: &App, viewport: u16) -> Vec { +fn rows_at(app: &mut App, viewport: u16) -> Vec { let (mut screen, _recorder) = support::screen_of(80, 40, viewport); screen .draw(|frame| app.render(frame, frame.area())) @@ -489,12 +489,12 @@ fn f3_up_and_down_move_the_selection_inside_the_surface() { let mut app = with_queue(3); assert!(app.queue_open()); assert!( - marked(&rows_at(&app, TALL)).is_none(), + marked(&rows_at(&mut app, TALL)).is_none(), "a surface nobody has touched draws no mark", ); assert_eq!(app.key(key(KeyCode::Up)), Command::None); - let rows = rows_at(&app, TALL); + let rows = rows_at(&mut app, TALL); assert_eq!( marked(&rows).map(|row| row.contains("queued prompt 2")), Some(true), @@ -509,12 +509,12 @@ fn f3_up_and_down_move_the_selection_inside_the_surface() { app.key(key(KeyCode::Up)); assert_eq!( - marked(&rows_at(&app, TALL)).map(|row| row.contains("queued prompt 1")), + marked(&rows_at(&mut app, TALL)).map(|row| row.contains("queued prompt 1")), Some(true), ); app.key(key(KeyCode::Down)); assert_eq!( - marked(&rows_at(&app, TALL)).map(|row| row.contains("queued prompt 2")), + marked(&rows_at(&mut app, TALL)).map(|row| row.contains("queued prompt 2")), Some(true), ); @@ -557,7 +557,7 @@ fn f3_with_the_surface_closed_up_still_recalls_the_previous_prompt() { ); assert_eq!(app.key(key(KeyCode::Up)), Command::None); - let rows = rows_at(&app, TALL); + let rows = rows_at(&mut app, TALL); assert!( prompt_row(&rows).contains("queued prompt 1"), "Up at the first line of a shut surface is prompt history, which is what \ @@ -568,7 +568,7 @@ fn f3_with_the_surface_closed_up_still_recalls_the_previous_prompt() { // And the walk keeps going, which a mark being moved instead could not fake. app.key(key(KeyCode::Up)); app.key(key(KeyCode::Up)); - let rows = rows_at(&app, TALL); + let rows = rows_at(&mut app, TALL); assert!( prompt_row(&rows).contains("the earlier prompt"), "three presses reach the prompt sent before the turn began: {rows:#?}", @@ -602,7 +602,7 @@ fn f3_a_line_is_edited_in_the_composer_and_put_back_at_its_own_position() { ["queued prompt 0", "queued prompt 2"], "the line is out of the queue while it is being edited", ); - let rows = rows_at(&app, TALL); + let rows = rows_at(&mut app, TALL); assert!( prompt_row(&rows).contains("queued prompt 1"), "and it is in the prompt, where it can be seen: {rows:#?}", @@ -630,7 +630,7 @@ fn f3_a_line_is_edited_in_the_composer_and_put_back_at_its_own_position() { Mode::Running, "and the turn underneath is untouched" ); - let rows = rows_at(&app, TALL); + let rows = rows_at(&mut app, TALL); assert_eq!( prompt_row(&rows), io_cli::composer::PROMPT.trim_end(), @@ -651,7 +651,7 @@ fn f3_an_edit_never_starts_on_top_of_a_half_typed_prompt() { app.key(key(KeyCode::Up)); typed(&mut app, "half typed"); - let before = rows_at(&app, TALL); + let before = rows_at(&mut app, TALL); let marked_before = marked(&before).map(str::to_string); assert_eq!( app.key(key(KeyCode::Up)), @@ -665,14 +665,14 @@ fn f3_an_edit_never_starts_on_top_of_a_half_typed_prompt() { // first draft of this test asserted that and failed against a feature older // than the queue — but that the QUEUE'S mark did not move. The key went to one // owner, and it was not this surface. - let rows = rows_at(&app, TALL); + let rows = rows_at(&mut app, TALL); assert_eq!( marked(&rows).map(str::to_string), marked_before, "the surface took an arrow that belonged to the composer: {rows:#?}", ); app.key(key(KeyCode::Down)); - let rows = rows_at(&app, TALL); + let rows = rows_at(&mut app, TALL); assert!( prompt_row(&rows).contains("half typed"), "and the composer's own recall gave the draft back: {rows:#?}", @@ -717,7 +717,7 @@ fn f3_a_queued_line_is_dropped() { schedule for it", ); assert_eq!(app.mode(), Mode::Running); - let rows = rows_at(&app, TALL); + let rows = rows_at(&mut app, TALL); // **The queue no longer lists it, and the notice names it.** Those are two // different jobs and the first draft of this test conflated them: it swept the // whole frame for the text and failed on io-cli's own `dropped "…"` sentence. @@ -770,7 +770,7 @@ fn f3_esc_during_an_edit_puts_the_line_back_unchanged() { Mode::Running, "the Esc that answered the edit did not reach the turn", ); - let rows = rows_at(&app, TALL); + let rows = rows_at(&mut app, TALL); assert_eq!( prompt_row(&rows), io_cli::composer::PROMPT.trim_end(), @@ -791,7 +791,7 @@ fn f3_two_queued_lines_swap_order() { ["queued prompt 1", "queued prompt 0", "queued prompt 2"], "the marked line moved one place up the run order", ); - let rows = rows_at(&app, TALL); + let rows = rows_at(&mut app, TALL); assert_eq!( marked(&rows).map(|row| row.contains("queued prompt 1")), Some(true), @@ -828,7 +828,7 @@ fn f3_a_turn_ending_under_an_edit_leaves_the_line_in_the_prompt() { app.finished(); assert_eq!(app.mode(), Mode::Idle); assert!(!app.queue_open(), "no turn to be queued behind"); - let rows = rows_at(&app, TALL); + let rows = rows_at(&mut app, TALL); assert!( prompt_row(&rows).contains("queued prompt 1"), "the line being edited is still in the prompt: {rows:#?}", diff --git a/tests/queue_surface.rs b/tests/queue_surface.rs index 9e3563d..bc5a16a 100644 --- a/tests/queue_surface.rs +++ b/tests/queue_surface.rs @@ -137,7 +137,7 @@ fn f2_the_queued_lines_are_drawn_above_the_composer_in_send_order() { let mut app = with_queue(3); assert!(app.queue_open(), "queueing a line opens the surface"); - let rows = rows_at(&mut app,TALL); + let rows = rows_at(&mut app, TALL); let first = row_of(&rows, "queued prompt 0"); let second = row_of(&rows, "queued prompt 1"); let third = row_of(&rows, "queued prompt 2"); @@ -207,7 +207,7 @@ fn f2_esc_closes_the_surface_and_leaves_the_turn_running() { "closing the view is not dropping the queue — the lines still run", ); - let rows = rows_at(&mut app,TALL); + let rows = rows_at(&mut app, TALL); assert!( !rows.iter().any(|row| row.contains("queued prompt 0")), "a closed surface draws nothing: {rows:#?}" @@ -282,7 +282,7 @@ fn f2_the_surface_draws_in_the_ascii_glyph_set_with_no_box_drawing_character() { send(&mut app, &format!("queued prompt {at}")); } - let rows = rows_at(&mut app,TALL); + let rows = rows_at(&mut app, TALL); let first = row_of(&rows, "queued prompt 0"); let composer = row_of(&rows, io_cli::composer::PROMPT.trim_end()); let drawn = rows[first..composer].join("\n"); @@ -419,7 +419,7 @@ fn n2_the_surface_is_visible_at_the_running_viewport_and_costs_it_no_height() { assert!(app.queue_open(), "queueing a line opens the surface"); let height = io_cli::term::VIEWPORT_HEIGHT; - let rows = rows_at(&mut app,height); + let rows = rows_at(&mut app, height); let queued = row_of(&rows, "queued prompt 0"); let prompt = row_of(&rows, io_cli::composer::PROMPT.trim_end()); assert!( @@ -613,10 +613,10 @@ fn the_count_under_the_window_is_what_is_under_the_window() { #[test] fn a_queue_behind_an_open_fleet_view_takes_no_row_from_the_layout() { let mut app = with_queue(2); - let queued = rows_at(&mut app,TALL); + let queued = rows_at(&mut app, TALL); app.toggle_fleet(); - let fleeted = rows_at(&mut app,TALL); + let fleeted = rows_at(&mut app, TALL); assert_eq!( queued.len(), fleeted.len(), diff --git a/tests/status.rs b/tests/status.rs index c3b70a8..87428e0 100644 --- a/tests/status.rs +++ b/tests/status.rs @@ -2857,12 +2857,19 @@ fn the_gate_standing_survives_a_width_the_counters_do_not() { #[test] fn o13_the_token_figure_moves_between_steps_and_says_it_is_an_estimate() { let mut status = io_cli::status::Status::new("a-model"); - assert_eq!(status.token_field(), None, "nothing spent is not zero spent"); + assert_eq!( + status.token_field(), + None, + "nothing spent is not zero spent" + ); // A settled figure from a step that has committed. status.tokens = Some(1_000); let settled = status.token_field().expect("a settled figure"); - assert!(!settled.starts_with('~'), "a settled figure is not an estimate: {settled}"); + assert!( + !settled.starts_with('~'), + "a settled figure is not an estimate: {settled}" + ); // Deltas arriving inside the next step. status.streaming = Some(120); @@ -2928,5 +2935,8 @@ fn o13_the_turns_own_figure_is_provisional_on_the_same_terms() { status.run_tokens = Some(300); assert!(!status.run_token_field().expect("settled").starts_with('~')); status.streaming = Some(40); - assert!(status.run_token_field().expect("provisional").starts_with('~')); + assert!(status + .run_token_field() + .expect("provisional") + .starts_with('~')); } diff --git a/tests/support/mod.rs b/tests/support/mod.rs index cb07302..4f9f54a 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -241,12 +241,7 @@ pub fn replace( height: u16, viewport: u16, ) { - let row = screen - .terminal_mut() - .get_frame() - .area() - .y - .saturating_add(1); + let row = screen.terminal_mut().get_frame().area().y.saturating_add(1); let recorder = recorder.clone(); screen .replace_from(row, viewport, move |rows| { diff --git a/tests/viewport.rs b/tests/viewport.rs index ce56c6a..7362973 100644 --- a/tests/viewport.rs +++ b/tests/viewport.rs @@ -72,7 +72,11 @@ fn n5_a_growth_neither_duplicates_nor_loses_committed_rows() { // The question overlay asking for room: eight rows becomes twenty. support::replace(&mut screen, &recorder, 100, 30, 20); - assert_eq!(screen.rows(), 20, "the viewport did not take the rows it asked for"); + assert_eq!( + screen.rows(), + 20, + "the viewport did not take the rows it asked for" + ); commit(&mut screen, "after-the-growth-gamma"); From f5b8a5aa1377293ac2aca2e02f0366eb9dde1c3f Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 30 Aug 2026 21:45:46 +0530 Subject: [PATCH 17/21] fix: the adversarial review's three HIGH and four MEDIUM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two reviewers, split by area. Both independently found the skill regression and the false freshness claim, which is the strongest signal either could have given. HIGH — mid-turn `/` and `@` ran in front of `App::key`, which is what hands a keystroke to an open overlay. The three modal overlays each own their own composer, so `app.composer` stays empty and `armed()` stays false for their whole lifetime: answering an agent's question with a path, or any text containing `@`, opened the palette instead and the question vanished. That is the release's headline feature, broken by the release's own new branch. `App::modal()` exists for exactly this and was not consulted. HIGH — choosing a non-bundle skill from the palette became unreachable. `invoke_skill` now writes `/`, and `parse` recognises a skill only by its colon — which bundle skills have and io-cli's own and the operator's own do not. Those printed "there is no /x" plus the whole command listing. It worked in 0.31.0, when the sentence submitted as an ordinary prompt. The driver now resolves any word against the inventory, with `commands::names_a_command` as the precedence rule so a skill can never take a command's name. HIGH — the display translation was applied to every tool-call target on the reasoning that no path contains `__`. `__init__.py`, `__pycache__`, `__tests__`, `__mocks__`, `__snapshots__`. `read src/__init__.py` was drawn `read src/:init__.py`, a path that does not exist, in the one place an operator checks what the agent touched — on the first turn in any Python or Jest repository. Gated on `read_skill`, whose target is a name. `set_answering(true)` was never restored, so after the first turn the durable question line was suppressed for the rest of the process — including `watch_child`, which draws no overlay at all, and the post-loop drain. A question reached no surface anywhere. The flag's own doc names `false` as the safe direction. `Resolved::stale` called `config.plugins()`, which is the full read. The release halved a cost it claimed to have removed, and said so in three places. It now re-stats only the paths it recorded — each manifest, and every file `Config::sources` names, so a bundle declared or undeclared is still seen. The token estimate outlived its turn: cleared at `Step` and `Finished`, but a provider error mid-stream emits neither, so a tilde-marked guess sat on an idle prompt and bled into the next turn. The question overlay's row demand oscillated by the composer's height on every arrow key, and the driver re-places the viewport whenever the demand moves — a terminal tear-down and cursor query per keystroke, on a surface open while a turn streams. The block is reserved whenever it is configured. `rows_wanted`'s chrome disagreed with what `render` reserves whenever the fleet was open, masked by `Fleet::rows_wanted` counting its summary row twice. Two compensating errors are not a correct answer. `Intent`'s `Cancelled` arm was dead and its comment contradicted `App::key` about what `Ctrl+C` does. `rows::elide` now measures its own suffix rather than assuming one row, and says so when there is room for nothing else. `cost_field` has its own doc back. --- src/app.rs | 16 ++++++++- src/commands.rs | 19 +++++++++++ src/events.rs | 29 ++++++++++++----- src/fleet.rs | 12 ++++--- src/intent.rs | 11 ++++--- src/main.rs | 51 +++++++++++++++++++++++++++-- src/picker.rs | 13 +++++--- src/resolved.rs | 86 +++++++++++++++++++++++++++++++++---------------- src/rows.rs | 13 +++++++- src/status.rs | 9 ++++++ tests/events.rs | 60 ++++++++++++++++++++++++++++++++++ 11 files changed, 264 insertions(+), 55 deletions(-) diff --git a/src/app.rs b/src/app.rs index f49685c..c7db217 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1090,6 +1090,14 @@ impl App { /// interrupted turn keeps its partial output in the scrollback instead of /// losing it with the turn. pub fn finished(&mut self) { + // **The estimate dies with the turn, however the turn died.** It is + // cleared at `Step` and at `Finished`, but not every ending emits one: a + // provider error propagating out mid-stream is exactly the case where + // tokens have streamed, and a deferred decision returns without + // finishing. Left standing, a tilde-marked guess sat on an idle prompt + // for a turn that was over, and then bled into the next one — a settled + // number and a guess added together, which the field's own doc forbids. + self.status.streaming = None; self.mode = Mode::Idle; self.status.working = false; // A per-turn fact, cleared with the turn. Left standing, an idle session @@ -1454,7 +1462,13 @@ impl App { } else { composer.saturating_add(queued) }; - let chrome = 6 + u16::from(queued == 0 && !self.fleet_open); + // **The same predicate `render` uses, not a restatement of it.** `render` + // keeps the blank above the activity line unless the queue is *drawn*, + // and the queue is not drawn while the fleet view has the composer's + // rect. Spelling it as `queued == 0 && !fleet_open` dropped the row + // whenever the fleet was open, so the demand under-asked by one — masked, + // until now, by `Fleet::rows_wanted` counting its summary row twice. + let chrome = 6 + u16::from(!self.queue_drawn()); chrome.saturating_add(surface) } diff --git a/src/commands.rs b/src/commands.rs index 2a179e8..fac0e9b 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -2598,6 +2598,25 @@ pub const MID_TURN: &[&str] = &[ "/image", ]; +/// Whether `word` is the first word of a command in [`COMMANDS`]. +/// +/// **The precedence rule for a skill invocation, and it points the safe way.** A +/// skill whose name collides with a command resolves to the command, because a +/// surface that already works must not be taken away by something an operator +/// installed. The driver asks this before it resolves a word against the skill +/// inventory. +/// +/// The first word, because `/copy diff` is one entry and `copy` is what a typed +/// line begins with. +pub fn names_a_command(word: &str) -> bool { + COMMANDS.iter().any(|(name, _)| { + name.trim_start_matches('/') + .split_whitespace() + .next() + .is_some_and(|first| first == word) + }) +} + /// Whether this typed line may run while a turn is in flight. /// /// Takes the whole line rather than a name, so a command whose admission ever diff --git a/src/events.rs b/src/events.rs index cacb542..18ceeb4 100644 --- a/src/events.rs +++ b/src/events.rs @@ -258,6 +258,12 @@ pub struct Events { root: std::path::PathBuf, } +/// io-harness's name for the tool that opens a skill. +/// +/// Named here because it is the one tool whose `target` is a *name* rather than a +/// path, which is what decides whether the display translation applies to it. +pub const READ_SKILL: &str = "read_skill"; + impl Events { pub fn new(theme: Theme) -> Self { Self { @@ -741,17 +747,24 @@ impl Events { name: shown.to_string(), raw: name.clone(), // **A skill's target is a name, not a path, and since 0.32.0 - // it is drawn the way the operator reads it.** `relative` - // passes a non-path through unchanged, so `read_skill` used to - // put io-harness's own `bundle__skill` straight into the - // scrollback — the one place the separator reached a person - // without going through a picker. `crate::naming::display` - // leaves every other target alone, because no path contains - // the separator. + // it is drawn the way the operator reads it** — `read_skill` + // was the one place io-harness's `bundle__skill` reached a + // person without going through a picker. + // + // **Gated on the tool, and the first draft was not.** It + // translated every target, on the reasoning that no path + // contains the separator — which is false and commonly so: + // `__init__.py`, `__pycache__`, `__tests__`, `__mocks__`, + // `__snapshots__`. `read src/__init__.py` was drawn as + // `read src/:init__.py`, a path that does not exist, in the one + // place an operator checks what the agent touched. Every Python + // and Jest repository would have met it on the first turn. target: if target == name { shown.to_string() + } else if name == crate::events::READ_SKILL { + crate::naming::display(target) } else { - crate::naming::display(&relative(target, &self.root)) + relative(target, &self.root) }, opened_at: at, measured: None, diff --git a/src/fleet.rs b/src/fleet.rs index 6b5748c..db0b4ce 100644 --- a/src/fleet.rs +++ b/src/fleet.rs @@ -529,16 +529,18 @@ impl Fleet { /// Counted rather than measured, because these rows are fitted to the width /// rather than wrapped: `rows` cuts each one, so one row is one line. pub fn rows_wanted(&self) -> u16 { + // `rows` yields one row per child and one per message, with the tier + // summary at their head — so the content is those three and nothing more. + // Counting the summary again as "the head row `render` reserves" asked for + // one row too many, which happened to cancel an equal-and-opposite + // under-ask in `App::rows_wanted`. Two compensating errors are not a + // correct answer; they agree only where the arithmetic happens to line up. let content = self .children .len() .saturating_add(self.messages.len()) - // The tier line. .saturating_add(1); - u16::try_from(content) - .unwrap_or(u16::MAX) - // The head row `render` reserves. - .saturating_add(1) + u16::try_from(content).unwrap_or(u16::MAX) } pub fn rows(&self, width: u16, glyphs: &Glyphs) -> Vec { diff --git a/src/intent.rs b/src/intent.rs index 47e11e9..e9155dd 100644 --- a/src/intent.rs +++ b/src/intent.rs @@ -278,10 +278,13 @@ impl Intent { .choices .get(index) .map(|choice| Some(choice.clone())), - // `Ctrl+C` inside the list. It means what `Esc` means here — nobody is - // answering — and `None` parks the run rather than denying it. - Outcome::Cancelled => Some(None), - Outcome::Idle => None, + // **`Cancelled` cannot arrive, and saying so is the point.** The only + // key a `Picker` cancels on is `Ctrl+C`, and `App::key` takes that + // before this overlay ever sees it — its own comment settles the + // question "does Ctrl+C decline, or interrupt?" with *it interrupts*. + // An arm answering `Some(None)` here would be a second, contradictory + // answer to that question, in a different file. + Outcome::Cancelled | Outcome::Idle => None, } } diff --git a/src/main.rs b/src/main.rs index fcd7cfe..a75ad45 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4219,7 +4219,41 @@ async fn loop_over Result>( // a static table and has no way to know what is installed, which is // exactly why it recognises the *shape* and leaves the *resolution* here. if let Command::Slash(text) = &command { - if let Action::Skill(wire, args) = commands::parse(text, app.keys(), &app.theme) { + // **Resolved against the inventory by name, qualified or not**, and + // that breadth is the fix for a regression this release introduced. + // `invoke_skill` writes `/`, and `commands::parse` answers + // `Action::Skill` only for a name carrying a colon — so a bundle's + // skill round-tripped and io-cli's own skills and the operator's own, + // which have no qualifier at all, fell to the unknown-command notice. + // Choosing one from the palette printed "there is no /x" and the whole + // command listing. In 0.31.0 they worked, because `invoke_skill` wrote + // prose that submitted as an ordinary prompt. + // + // A word the static table claims is the command, never a skill: a + // surface that already works must not be taken away by something an + // operator installed. `commands::names_a_command` is that rule, in the + // library where it can be asserted. + let word = text + .split_whitespace() + .next() + .unwrap_or_default() + .to_string(); + let wire = io_cli::naming::wire(&word); + if !word.is_empty() + && !commands::names_a_command(&word) + && skills.iter().any(|listed| listed.name == wire) + { + let args = text + .split_once(char::is_whitespace) + .map(|(_, rest)| rest.trim().to_string()) + .unwrap_or_default(); + command = Command::Submit(if args.is_empty() { + wire + } else { + format!("{wire}\n\n{args}") + }); + } else if let Action::Skill(wire, args) = commands::parse(text, app.keys(), &app.theme) + { command = if skills.iter().any(|listed| listed.name == wire) { // The name io-harness put in the model's catalogue, then // whatever the operator added. Not a sentence asking the model @@ -7358,7 +7392,8 @@ async fn turn( key, ); Command::None - } else if commands::opens_palette(key, app.composer.is_empty(), app.armed()) + } else if !app.modal() + && commands::opens_palette(key, app.composer.is_empty(), app.armed()) { picker = Some(( Picker::new( @@ -7368,7 +7403,8 @@ async fn turn( Pick::Palette, )); Command::None - } else if complete::opens(key, &app.composer.text(), app.armed()) { + } else if !app.modal() && complete::opens(key, &app.composer.text(), app.armed()) + { let effective = approval::session_policy( policy, app.posture(), @@ -7972,6 +8008,15 @@ async fn turn( } } } + // **The promise ends with the turn that made it.** `set_answering(true)` at the + // head of this function says an overlay in *this* process will draw a + // question; past this line no overlay is being opened for anything, and two + // paths still route question events into `Events` — the post-loop drain just + // below, and `watch_child`, which draws no overlay at all and whose watched + // child can ask. Leaving it set suppressed the durable line on both, so a + // question reached no surface anywhere. The flag's own doc names `false` as + // the safe direction; this is the line that keeps it there. + app.set_answering(false); app.status.elapsed = started.elapsed(); paint(screen, app)?; Ok(Turned { stopped, ran }) diff --git a/src/picker.rs b/src/picker.rs index 218ace3..b6a2599 100644 --- a/src/picker.rs +++ b/src/picker.rs @@ -410,11 +410,14 @@ impl Picker { /// is actually given. pub fn rows_wanted(&self) -> u16 { let list = u16::try_from(self.matches.len()).unwrap_or(u16::MAX); - let block = if self.unfolded_now() { - self.unfold.map_or(0, |(_, height)| height) - } else { - 0 - }; + // **Reserved whenever a row is configured to unfold, not only while it + // holds the marker.** Asking for the block only when it is open makes the + // demand oscillate by its height on every arrow key — and the driver + // re-places the viewport whenever the demand changes, which is a terminal + // tear-down and a cursor query per keystroke, on a surface that is open + // while a turn is in flight. `render` still draws the block only when it + // is open, so the reserved rows simply show more of the list meanwhile. + let block = self.unfold.map_or(0, |(_, height)| height); list.saturating_add(block).saturating_add(1) } diff --git a/src/resolved.rs b/src/resolved.rs index 504c78a..5dd4544 100644 --- a/src/resolved.rs +++ b/src/resolved.rs @@ -29,16 +29,25 @@ //! //! # Freshness //! -//! [`Resolved::stale`] stats each declared bundle's manifest and compares its -//! modified time **and** its length against what was recorded. Two writes inside -//! one second that leave the length unchanged are the case a filesystem with -//! one-second mtime granularity cannot distinguish, and this will not see the +//! [`Resolved::stale`] **re-stats the paths it recorded and nothing else.** It +//! opens no manifest and calls no loader, which is the whole point: a check that +//! resolved the set in order to decide whether to resolve the set would leave the +//! per-turn cost exactly where it was. The first draft did precisely that — it +//! compared `stamp_of(&config.plugins())`, and `Config::plugins()` is the full +//! read — so the release halved a cost it claimed to have removed. The adversarial +//! review caught it; the claim and the code now agree. +//! +//! Two kinds of path are stamped. Each bundle's `plugin.toml`, so an **edit** is +//! seen; and every file [`Config::sources`] names, so a bundle **declared or +//! undeclared** is seen — a new `[[plugin]]` entry changes the file that holds it. +//! A bundle whose directory is removed fails its stat, which is a change too. +//! +//! Each path is compared on its modified time **and** its length. Two writes +//! inside one second that leave the length unchanged are the case a filesystem +//! with one-second mtime granularity cannot distinguish, and this will not see the //! second one until something else about the bundle changes. That is stated here, //! in `docs/guide/plugins.md`, and asserted — a cache that cannot prove freshness //! should say so rather than imply a guarantee it has not got. -//! -//! A bundle that appears or disappears changes the declared set, which is compared -//! whole, so an install or a removal is always seen. use std::path::PathBuf; use std::time::SystemTime; @@ -68,7 +77,7 @@ impl Resolved { /// disk I/O. pub fn load(config: &Config) -> Self { let plugins = config.plugins(); - let stamp = stamp_of(&plugins); + let stamp = stamp_of(watched(&plugins, config)); Self { plugins, stamp } } @@ -85,37 +94,58 @@ impl Resolved { /// Whether anything on disk has moved since this was resolved. /// - /// Stats only — no manifest is parsed and no skill file is opened — so asking - /// costs a bounded number of `metadata` calls rather than the resolution it - /// is deciding whether to repeat. + /// **Stats only.** No manifest is opened and no loader is called, so asking + /// costs a bounded number of `metadata` calls rather than the resolution it is + /// deciding whether to repeat — which is the difference between removing the + /// per-turn cost and merely halving it. + /// + /// The paths re-stated here are the ones recorded at load: each bundle's + /// manifest, and every configuration file `Config::sources` named. Comparing + /// the whole set is what catches a bundle declared or undeclared, because a + /// new `[[plugin]]` entry changes the file that holds it, and what catches a + /// bundle directory that has been removed, because its stat then fails. pub fn stale(&self, config: &Config) -> bool { - // The declared set can itself change, and comparing the stamps whole is - // what catches a bundle installed or removed: a per-entry comparison over - // the *old* list would never look at a new one. - stamp_of(&config.plugins()) != self.stamp + let paths: Vec = self + .stamp + .iter() + .map(|(path, _)| path.clone()) + .chain(config.sources().iter().map(|(_, path)| path.clone())) + .collect(); + stamp_of(paths) != self.stamp } } -/// The manifest facts for every bundle a set holds, loaded and disabled alike. +/// Every path whose movement means the plugin set has to be read again: each +/// bundle's manifest, and each configuration file that could declare one. /// -/// **Disabled bundles are stamped too.** A bundle switched off is still declared, -/// still parsed by `Plugins::load`, and still something an operator can edit — and -/// leaving it out would mean an edit to a disabled bundle never showed up when it -/// was switched back on. -fn stamp_of(plugins: &Plugins) -> Stamp { - let mut out: Stamp = plugins +/// **Disabled bundles are watched too.** A bundle switched off is still declared, +/// still parsed by the loader, and still something an operator can edit — leaving +/// it out would mean an edit to a disabled bundle never showed up when it was +/// switched back on. +fn watched(plugins: &Plugins, config: &Config) -> Vec { + plugins .iter() .chain(plugins.disabled()) - .map(|plugin| { - let manifest = plugin.root().join(io_harness::PLUGIN_FILE); - let facts = std::fs::metadata(&manifest) + .map(|plugin| plugin.root().join(io_harness::PLUGIN_FILE)) + .chain(config.sources().iter().map(|(_, path)| path.clone())) + .collect() +} + +/// What a stat says about each of `paths`, right now. +fn stamp_of(paths: impl IntoIterator) -> Stamp { + let mut out: Stamp = paths + .into_iter() + .map(|path| { + let facts = std::fs::metadata(&path) .ok() .and_then(|meta| meta.modified().ok().map(|at| (at, meta.len()))); - (manifest, facts) + (path, facts) }) .collect(); - // Ordered, so two resolutions of the same disk compare equal however the - // declarations were listed. + // Ordered and deduplicated, so two readings of the same disk compare equal + // however the declarations were listed — and so a path named by both a bundle + // and a configuration file is stamped once. out.sort(); + out.dedup(); out } diff --git a/src/rows.rs b/src/rows.rs index c3ed34d..cf583a3 100644 --- a/src/rows.rs +++ b/src/rows.rs @@ -93,8 +93,19 @@ pub fn elide( if total <= room { return lines; } + // **The suffix is measured, not assumed to be one row.** `⋯ N more rows` is + // fourteen characters in Unicode and sixteen in ASCII, so below about fifteen + // columns it wraps — and reserving one row for it returned content a row + // taller than the caller had. + let suffix = wrapped(std::slice::from_ref(&more(total, theme)), width).max(1); + if room <= suffix { + // No room for content *and* a count. The count wins: a surface drawing + // nothing and saying nothing is indistinguishable from one with nothing to + // draw, which is the whole failure this function exists to prevent. + return vec![more(total, theme)]; + } let mut keep = lines.len().min(usize::from(room)); - while keep > 0 && wrapped(&lines[..keep], width).saturating_add(1) > room { + while keep > 0 && wrapped(&lines[..keep], width).saturating_add(suffix) > room { keep -= 1; } let mut out = lines[..keep].to_vec(); diff --git a/src/status.rs b/src/status.rs index 8e049a7..36b26f4 100644 --- a/src/status.rs +++ b/src/status.rs @@ -711,6 +711,10 @@ impl Status { /// is costing, and a number that only climbs across an hour says nothing /// about it. pub fn start_run(&mut self) { + // Defensively, beside `run_tokens`: a new turn must not inherit the + // previous one's estimate even if that turn ended by a path that emitted + // no `Finished`. + self.streaming = None; self.elapsed = Duration::ZERO; self.run_tokens = None; // Back to "no answer" rather than to zero, and it is the same distinction @@ -1130,6 +1134,11 @@ impl Status { } } + /// What the last turn cost, in money, or `None` where nothing prices it. + /// + /// (Its own doc, restored: 0.32.0 inserted `token_field` above it and left + /// this function describing itself with `token_field`'s paragraph — so + /// rustdoc had the `/cost` explanation attached to the token figure.) pub fn cost_field(&self) -> Option { self.cost.map(crate::cost::money) } diff --git a/tests/events.rs b/tests/events.rs index a001d9d..79648a5 100644 --- a/tests/events.rs +++ b/tests/events.rs @@ -2525,3 +2525,63 @@ fn o3_the_committed_question_is_not_a_warning() { "a question is not a warning: {line:?}", ); } + +/// **A path that contains io-harness's separator is drawn as itself.** +/// +/// The display translation belongs to `read_skill`, whose target is a *name*. The +/// first draft applied it to every tool call, on the reasoning that no path +/// contains `__` — which is false and commonly so. `read src/__init__.py` was +/// drawn `read src/:init__.py`, a path that does not exist, in the one place an +/// operator checks what the agent touched. +#[test] +fn a_tool_targets_path_is_never_translated_however_it_is_spelled() { + for path in [ + "src/__init__.py", + "src/__pycache__/thing.pyc", + "app/__tests__/login.test.ts", + "src/__mocks__/fs.js", + "snapshots/__snapshots__/App.snap", + "src/snake__case.rs", + ] { + let mut events = Events::new(DARK); + let opened = rendered( + &mut events, + EventKind::ToolCall { + name: "read_file".to_string(), + target: path.to_string(), + }, + ); + // The call is held open and committed by the `Step` that follows it, so + // the name is read off the live row rather than the scrollback. + assert!(opened.is_empty(), "a tool call commits nothing on its own"); + let live = events.live().to_string(); + assert!( + !live.contains(':'), + "the separator was translated inside a path, so the operator is shown \ + a file that does not exist: {live:?} for {path}", + ); + assert!( + live.contains("__"), + "the path lost its own characters: {live:?} for {path}", + ); + } +} + +/// And the one tool whose target really is a name still reads as a name. +#[test] +fn a_skills_target_is_still_drawn_the_way_the_operator_reads_it() { + let mut events = Events::new(DARK); + events.event( + &event(EventKind::ToolCall { + name: io_cli::events::READ_SKILL.to_string(), + target: "ultraship__brainstorm".to_string(), + }), + Duration::ZERO, + ); + let live = events.live().to_string(); + assert!( + live.contains("ultraship:brainstorm"), + "a skill is addressed by a name, and the name is the one drawn everywhere \ + else: {live:?}", + ); +} From b21d6e687d200fded71d8acf5a087e1b5d763e2e Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 30 Aug 2026 21:50:53 +0530 Subject: [PATCH 18/21] fix(main): a picker does not re-place the viewport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.13.0 removed viewport growth from the palette on purpose: re-placing an inline viewport asks the terminal where its cursor is, and that round trip lands on `/` — the fastest thing an operator does — with a two-second wait on a terminal that never answers. Its own note says the fallback is not to restore it. 0.32.0 restored it, and the live suite refused: `live_f6_the_palette_opens_ without_asking_the_terminal_anything`, 35 passed / 1 failed. That test drives the real binary through a pty and reads the byte stream. It is the only gate that could see this — `Screen::replace` re-attaches to a real terminal, so the `Fixed` backend every unit test uses observes nothing, and the decision lives in the driver, which nothing links. 1,724 offline tests were green over it. The adversarial review predicted it from the source alone, and added the part the live test does not reach: a picker's demand is `matches.len()`, which shrinks as a query is typed — so filtering four hundred models would have issued a cursor query on nearly every keystroke. The three overlays still grow; they are drawn inside the session's own frame and re-place at most once as they open and close. A picker gets the elision this release added instead, which says how many rows it did not draw and costs nothing. `Picker::rows_wanted` stays. It is what the elision measures against, and a later release that finds an affordable way to grow has a correct number waiting for it. O16 is narrowed to the three overlays by US-IO-CLI-0.32.0-I12, and the driver-text assertion in tests/viewport.rs is what keeps the round trip from coming back. --- src/main.rs | 36 +++++++++++++++++++++++------------- tests/viewport.rs | 38 +++++++++++++++++++++++++++++++++----- 2 files changed, 56 insertions(+), 18 deletions(-) diff --git a/src/main.rs b/src/main.rs index a75ad45..c37c3a2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10884,19 +10884,29 @@ fn paint_picker( // prompt, which `tests/viewport.rs` asserts over a growth, a shrink, a // grow-to-grow and a resize while grown. // - // A picker sizes for itself. It is drawn *instead of* the app, so `App` is - // not holding the surface that knows how many rows are wanted — which is the - // one case the demand cannot come from `viewport_wanted`. Both paths clamp - // through `term::viewport_for`, so there is still exactly one ceiling. - let wanted = match picker.as_ref() { - Some((open, _)) => io_cli::term::viewport_for(open.rows_wanted(), screen.terminal_rows()), - None => app.viewport_wanted(screen.width(), screen.terminal_rows()), - }; - if wanted != screen.rows() { - // A failure here leaves the session's own height in place — see - // `Screen::replace` — so a terminal that will not answer keeps a - // usable prompt rather than losing one over a row. - let _ = replace_viewport(screen, wanted); + // **A picker does not re-place the viewport, and that is 0.13.0's rule kept + // rather than an omission.** Re-placing asks the terminal where its cursor is + // and waits for the answer; up to 0.12.0 the palette grew the viewport on open + // and shrank it on close, which put that round trip on `/` — the fastest thing + // an operator does — with a two-second wait on a terminal that never answers. + // 0.13.0 removed it deliberately and `live_f6_the_palette_opens_without_asking_ + // the_terminal_anything` asserts it against a real pty. + // + // **0.32.0 tried to grow pickers too and the live suite caught it**, which is + // the whole argument for running that suite: the property is about bytes on a + // wire, `Screen::replace` re-attaches to a real terminal, and no `Fixed` + // backend can see either. What a picker gets instead is the elision this + // release added — it draws the rows the session's viewport has and says how + // many it did not, which is the improvement that does not cost a round trip. + // See `US-IO-CLI-0.32.0-I12`. + if picker.is_none() { + let wanted = app.viewport_wanted(screen.width(), screen.terminal_rows()); + if wanted != screen.rows() { + // A failure here leaves the session's own height in place — see + // `Screen::replace` — so a terminal that will not answer keeps a + // usable prompt rather than losing one over a row. + let _ = replace_viewport(screen, wanted); + } } let theme = app.theme; screen diff --git a/tests/viewport.rs b/tests/viewport.rs index 7362973..b4a171f 100644 --- a/tests/viewport.rs +++ b/tests/viewport.rs @@ -333,11 +333,20 @@ fn o16_the_queue_grows_the_viewport_by_what_it_is_holding() { ); } -/// **O16 — a picker.** Its demand does not come from `App` at all: a picker is -/// drawn *instead of* the session, so the driver sizes for it directly. Both -/// paths clamp through `term::viewport_for`, which is the single ceiling. +/// **A picker states its rows, and the driver deliberately does not act on it.** +/// +/// This asserted that the driver grew the viewport for a picker until the live +/// suite refused it: 0.13.0 removed exactly that, because re-placing asks the +/// terminal where its cursor is and the round trip lands on `/`. See +/// `US-IO-CLI-0.32.0-I12`, and `live_f6_the_palette_opens_without_asking_the_ +/// terminal_anything`, which is the gate — the property is about bytes on a wire +/// and no `Fixed` backend can see it. +/// +/// What is left is still worth asserting: the demand is honest and passes through +/// the one ceiling, so a later release that decides the round trip is affordable +/// has a correct number to use. What a picker gets today is the elision. #[test] -fn o16_a_picker_asks_for_its_own_rows_through_the_one_ceiling() { +fn a_picker_states_its_rows_and_they_pass_through_the_one_ceiling() { let short = io_cli::picker::Picker::new( "Which command?", (0..3) @@ -362,7 +371,26 @@ fn o16_a_picker_asks_for_its_own_rows_through_the_one_ceiling() { ); assert!( io_cli::term::viewport_for(long.rows_wanted(), TERMINAL) > VIEWPORT_HEIGHT, - "a picker larger than the floor is given rows", + "a picker larger than the floor would be given rows, if the driver asked", + ); + + // **And the driver does not ask.** Asserted over the driver's own text, + // because `src/main.rs` is linked by no test and this is the half that + // regressed: the re-placement must stay behind `picker.is_none()`. + let driver = std::fs::read_to_string( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/main.rs"), + ) + .expect("the driver"); + let paint = driver + .split_once("fn paint_picker(") + .expect("the one place a viewport is re-placed") + .1 + .split_once("\nfn ") + .expect("the end of the function") + .0; + assert!( + paint.contains("if picker.is_none() {"), + "the viewport is re-placed with a picker open, which puts a cursor query on `/` — the round trip 0.13.0 removed: {paint}", ); } From 8cc4901b116f187c81753d166ee475d042303a66 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 30 Aug 2026 21:56:54 +0530 Subject: [PATCH 19/21] fix: three lesser findings from the review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two count assertions in `rows.rs` used `contains` on a stringified number, which "116" satisfies for "16" and "16" for "6" — a count assertion that passes on a wrong count is exactly the vacuity this product keeps finding in its own gates. They compare the whole rendered row now. The streaming estimate's doc said "tokens the step now streaming looks like it has spent". It counts the completion only, because the assistant's text is the sole per-chunk signal, while the provider's total is prompt plus completion — so on a large prompt the provisional figure is a small fraction that then jumps. The tilde already made it honest; the doc now says what is actually measured. `io exec` resolves the plugin set twice per run, not once as its comment claimed: the contract and the hooks are built at different points and threading one resolution between them would reshape the headless construction for a process that runs one goal and exits. Bounded and stated rather than claimed away. --- src/exec.rs | 14 ++++++++++---- src/rows.rs | 20 +++++++++++++------- src/status.rs | 12 ++++++++++-- 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/src/exec.rs b/src/exec.rs index 2da9ff3..b2c82e5 100644 --- a/src/exec.rs +++ b/src/exec.rs @@ -518,10 +518,16 @@ pub fn contract( goal: String, sandbox: Option, ) -> TaskContract { - // `io exec` runs one goal and exits, so the resolution genuinely is once - // per run here — but it goes through the same module, because the gate that - // keeps the interactive path honest is a path allow-list and admits no - // exceptions for a caller that happens to be short-lived. + // Through the same module as the session's, because the gate that keeps the + // interactive path honest is a path allow-list and admits no exceptions for a + // caller that happens to be short-lived. + // + // **Twice per run, not once, and the comment used to claim otherwise.** The + // contract is built here and the hooks are built where the run is assembled, + // and threading one resolution between the two would reshape `Headless`'s + // construction for a process that resolves, runs one goal and exits. Bounded + // and stated rather than claimed away — the adversarial review caught the + // claim. let resolved = crate::resolved::Resolved::load(config); let contract = crate::contract::configured( goal, diff --git a/src/rows.rs b/src/rows.rs index cf583a3..5fa1e65 100644 --- a/src/rows.rs +++ b/src/rows.rs @@ -188,10 +188,15 @@ mod tests { last.contains("more rows"), "the surface dropped rows without saying so: {last:?}", ); - // 20 rows asked for, 5 available, one of which is the count itself. - assert!( - last.contains("16"), - "the count must be the rows actually dropped, but it read {last:?}", + // **The whole rendered row, not a substring of the number.** `contains("16")` + // is satisfied by "116" and `contains("6")` by "16" — a count assertion + // that passes on a wrong count is the vacuity this product keeps finding + // in its own gates. + let theme = theme(); + assert_eq!( + last, + more(16, &theme).to_string(), + "20 rows asked for and 5 available, one of which is the count itself, so 16 were dropped", ); } @@ -208,9 +213,10 @@ mod tests { let out = elide(lines, 4, width, &theme()); let last = out.last().expect("a count row").to_string(); let dropped = total - wrapped(&out[..out.len() - 1], width); - assert!( - last.contains(&dropped.to_string()), - "counted items rather than rows: {last:?} with {dropped} rows dropped", + assert_eq!( + last, + more(dropped, &theme()).to_string(), + "counted items rather than rows, or counted the wrong number of them", ); } diff --git a/src/status.rs b/src/status.rs index 36b26f4..c4a0cce 100644 --- a/src/status.rs +++ b/src/status.rs @@ -262,8 +262,16 @@ pub struct Status { /// them. `None` until one does — a session that has spent nothing yet is not /// a session that has spent zero, and the difference is the whole of F9. pub tokens: Option, - /// Tokens the step now streaming looks like it has spent, estimated from the - /// deltas as they arrive. + /// Tokens the step now streaming has *produced*, estimated from the deltas as + /// they arrive. + /// + /// **Produced, not spent, and the difference is large.** The only per-chunk + /// signal is the assistant's own text, so this counts the completion and + /// nothing else — while the provider's `Step` total is prompt plus completion. + /// On a step with a big prompt the provisional figure is a small fraction of + /// the settled one and then jumps to it. That is why it is drawn with a tilde + /// and never added to a settled number: it is an honest lower bound on a + /// number nobody can know mid-stream, not an approximation of the total. /// /// **An estimate, and drawn as one.** `EventKind::Token` carries text and no /// count — providers do not bill per chunk, so neither crate can know a From f384cb7405b824c4c74eee29a77e0e9d780be83d Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 30 Aug 2026 22:13:41 +0530 Subject: [PATCH 20/21] docs: the README no longer says the viewport is eight rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by sweeping the shipped prose for claims this release made false — the sweep that has caught stale text in three previous releases, and which no gate performs because a `contains` assertion cannot notice a sentence that is merely no longer true. Eight rows is the floor now, not the size. The paragraph says so, and says what the four reserved rows at the top are for. --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2d6bef9..ee3881d 100644 --- a/README.md +++ b/README.md @@ -64,9 +64,12 @@ posture on the next.](assets/screenshot.png) `io` does not enter the alternate screen and does not capture the mouse, in any mode, behind any flag. Every finished message, tool call and system line is -committed into the terminal's own scrollback; eight rows at the bottom hold the +committed into the terminal's own scrollback; a few rows at the bottom hold the unfinished tail of a streaming answer, a blank row, the activity line, a rule, -one row of composer and a three-row footer, and only those repaint. +one row of composer and a three-row footer, and only those repaint. Eight rows is +the floor: a surface that needs more — a question with its offers, a plan with its +steps, the messages you have queued — takes what it needs and gives it back, +stopping four rows short of the top so the exchange it is about stays visible. So when the session ends the whole conversation is still there. Your terminal's search finds it, tmux copy-mode scrolls it, and a mouse drag selects it — none of From e2bd3cbc4fc2eafdb770d2ee727b1a3866a82892 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 30 Aug 2026 22:15:27 +0530 Subject: [PATCH 21/21] docs: two more claims the display translation made false MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same sweep, carried further. `docs/guide/skills.md` said `/skills` and the palette spell a bundle's skill `__` — which this release changed, and which left that page contradicting a section added to it by this very release. `docs/guide/plugins.md` said `/plugin` draws the namespaced string "unchanged". Both now say what is true: qualified, drawn with a colon, addressed with io-harness's separator, and reported in the wire form by `io plugin list` and `io exec` because a script addresses the wire. A file that disagrees with itself is worse than one that is merely out of date, and only a by-hand reading finds either. --- docs/guide/plugins.md | 13 +++++++++---- docs/guide/skills.md | 7 ++++--- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/docs/guide/plugins.md b/docs/guide/plugins.md index e4964d5..029e2aa 100644 --- a/docs/guide/plugins.md +++ b/docs/guide/plugins.md @@ -72,10 +72,15 @@ misjudged can do to your permission boundary is take something out of it. **A bundle id must match `[a-z0-9][a-z0-9-]{0,31}`**, and every name it contributes is rewritten by io-harness to `__` at load — an agent's -name, an MCP server's id, a policy layer's name. `/plugin` draws that namespaced -string unchanged rather than a prettier short form, because it is what a refusal -will name, what a tool call will name, and what you will type to spawn the agent. -A shorter name here would be a third spelling of the same thing. +name, an MCP server's id, a policy layer's name. `/plugin` draws it **qualified** +rather than shortened, because the qualifier is what a refusal will name, what a +tool call will name, and what you will type to spawn the agent; a bare `reviewer` +would say nothing about where it came from. + +Since 0.32.0 the qualifier is drawn with a colon — `rust-review:reviewer` — while +io-harness keeps its own separator on the wire. That is a translation at the edge +and not a third spelling: `io plugin list` and `io exec` report the underscore +form, because a script addresses the wire. **Hooks are named, everywhere, from 0.30.0.** Each one gets a row: the event it fires on, or the tool call it sits in front of, and the command it runs. This is diff --git a/docs/guide/skills.md b/docs/guide/skills.md index 06cf08a..aba253e 100644 --- a/docs/guide/skills.md +++ b/docs/guide/skills.md @@ -107,9 +107,10 @@ that is why, and `/skills` is where you see which directory each name came from. **A bundle's skills are listed too, under the name the model actually uses.** Until 0.21.0 they reached the model and appeared on no surface that lists a skill, so `/skills` and the `/` palette were lists that disagreed with the catalogue the -turn was handed. They are in both now, spelled `__` — io-harness's -own namespacing, and the string a refusal or a tool call will name — with the -bundle named as where the row came from. +turn was handed. They are in both now, qualified by the bundle they came from — +drawn `:` since 0.32.0, and addressed as io-harness's own +`__`, which is what a refusal or a tool call will name. See *The +name you read, and the name the model was shown* below. **Turning a bundle's skill on or off is refused, and the refusal is the honest answer.** Turning a skill off is moving its file into a `disabled/` directory