From 07f3629a91feead2432a8eaab19a301aeb2a615f Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 16 Aug 2026 19:36:17 +0530 Subject: [PATCH 01/34] chore(plan): update the io-cli roadmap --- .ultraship/products/io-cli/roadmap.yaml | 100 ++++++++++++++++++++++++ .ultraship/views/.gitkeep | 0 .ultraship/views/active-releases.md | 20 +++++ .ultraship/views/master-roadmap.md | 22 ++++++ .ultraship/views/release-history.md | 10 +++ .ultraship/views/workspace-brief.md | 21 +++++ 6 files changed, 173 insertions(+) create mode 100644 .ultraship/products/io-cli/roadmap.yaml create mode 100644 .ultraship/views/.gitkeep create mode 100644 .ultraship/views/active-releases.md create mode 100644 .ultraship/views/master-roadmap.md create mode 100644 .ultraship/views/release-history.md create mode 100644 .ultraship/views/workspace-brief.md diff --git a/.ultraship/products/io-cli/roadmap.yaml b/.ultraship/products/io-cli/roadmap.yaml new file mode 100644 index 0000000..448435c --- /dev/null +++ b/.ultraship/products/io-cli/roadmap.yaml @@ -0,0 +1,100 @@ +product: io-cli +status: active +versions: + - version: 0.1.0 + outcome: > + A developer with no configuration runs `io` in a repository and reaches a finished task in the + same session. A guided first run asks for a provider, takes a key and verifies it against the + live endpoint, offers the provider's models, previews the themes as it moves between them, and + picks a default permission posture — then writes io-harness's own configuration file. The + session that follows renders on a hybrid inline viewport: every finished turn, tool cell and + message is committed into the terminal's real scrollback, and only a short pane at the bottom + holds the composer and the status line. The developer types a task, watches the answer stream, + watches a file get edited inside the sandbox, interrupts with a keystroke, and exits — leaving + the whole conversation scrollable, searchable and selectable with the terminal's own + facilities, because io-cli never took them away. + detail: specified + status: planned + - version: 0.2.0 + outcome: > + The operator can see and steer the boundary the agent is working under. One key cycles the + permission posture and the status line names the io-harness policy layer now in force. A + sensitive action stops the run in an overlay that cannot scroll away — showing the act, the + target, the rule that is asking, and the diff when it is a write — and is resolved by keystroke + into both the transcript and the durable trace. A refusal prints the act, the target, and the + rule and layer that produced it, which no other terminal agent can render because no other core + records it. The status line gains the rest of its fields: context pressure, tokens, spend + against the tree ceiling, containment state. + detail: outline + status: planned + - version: 0.3.0 + outcome: > + The operator can read what the agent did without leaving the terminal or losing the thread. + Edits render as diffs — unified by default, word-level emphasis inside changed lines, syntax + highlighting, context collapsed with an expander, a defined form for narrow terminals, and a + minimal mode for people who review by file rather than by hunk. Tool calls render as one line + with a result and a duration, expandable while live and committed collapsed, with the full + output going to the durable trace rather than to the screen. A transcript overlay gives the + whole conversation on one key, and the last message or the last diff can be put on the system + clipboard in a way that survives SSH. + detail: outline + status: planned + - version: 0.4.0 + outcome: > + Work survives the session. A picker lists previous sessions from the harness store and reopens + one where it stopped. Any turn in a conversation can be forked into a new branch and continued + from there. The model can be changed mid-session without losing context. An empty composer + takes a double-escape to rewind the conversation and the workspace to an earlier point, using + the rewind the harness already implements and already records. + detail: outline + status: planned + - version: 0.5.0 + outcome: > + The same agent runs unattended. A non-interactive subcommand takes a goal, runs it to + completion, and exits with a meaningful status; with a flag it emits the run's events as + newline-delimited JSON — the same events the interactive session renders, from the same + stream, with no interface code on the path. Sandbox and policy are selectable from the command + line, a non-TTY stdout is detected and honoured without being asked, and credentials are taken + from the environment so nothing has to be written to disk in CI. + detail: hypothesis + status: planned + - version: 0.6.0 + outcome: > + The interface reaches people and terminals the category currently excludes. A plain mode built + for screen readers removes animation and repaint, keeps the real terminal cursor as the focus + indicator, puts content before metadata, and announces every state change as one line of text — + which no terminal agent in this field does today. Colour is never the sole carrier of meaning, + `NO_COLOR` is honoured, box drawing degrades to ASCII, the keyboard protocol is negotiated up + on terminals that advertise it and down over SSH without losing core function, keybindings are + configurable and documented as a table, and every surface has an audited form at eighty + columns. + detail: hypothesis + status: planned + - version: 0.7.0 + outcome: > + The composer becomes a command surface. A slash palette with fuzzy matching reaches every + command and every harness skill and prompt template; `@` completes paths from the workspace; + `!` passes a line to the shell verbatim; a large paste becomes an attachment instead of + flooding the input. The agent's own plan and todo list render as a live panel, and a question + the agent asks about intent — which the harness distinguishes from an approval about + permission — is answered inline rather than guessed at. + detail: hypothesis + status: planned + - version: 0.8.0 + outcome: > + A decomposed task becomes visible as a tree. Spawned children appear as a live fleet with their + own status, each showing its draw against the ceiling the whole tree shares, with refused + spawns and collected results shown where they happen. This is the surface that is structurally + impossible for a competing interface to copy, because it is rendering facts only this core + emits. + detail: hypothesis + status: planned + - version: 0.9.0 + outcome: > + The remaining harness capabilities get a face. Images render inline where the terminal speaks + Kitty, iTerm2 or Sixel and degrade to half-blocks where it does not, and an image can be + attached to a turn. Background shell handles, browser sessions, LSP servers and MCP servers + each get a compact live indicator, so a run that has started something long-lived says so + rather than appearing idle. + detail: hypothesis + status: planned diff --git a/.ultraship/views/.gitkeep b/.ultraship/views/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.ultraship/views/active-releases.md b/.ultraship/views/active-releases.md new file mode 100644 index 0000000..e5394ff --- /dev/null +++ b/.ultraship/views/active-releases.md @@ -0,0 +1,20 @@ + +# Active releases + +## io-cli 0.1.0 + +**Execution state:** DEVELOPING +**Release fit:** probable +**Target mode:** release-ready +**Outcome:** A developer clones nothing, configures nothing, and runs `io` in a repository. Because no configuration exists, a guided first run starts: it asks which provider to use — the choices being the variants io-harness's own `ProviderSpec` defines, not a hardcoded list — takes an API key without echoing it, and **verifies that key against the live endpoint before continuing**, so a bad key is caught in the wizard rather than on the first real prompt. It then offers the provider's own model catalogue, lets the developer arrow through the shipped themes with a sample transcript re-rendering live behind the picker, and asks for a default permission posture in plain words. It shows exactly what will be written and where, and only then writes io-harness's configuration file with mode `0600`. + +The session that follows is the product's core claim made real. Every finished message, tool cell and system line is committed into **the terminal's own scrollback**; a viewport of a few lines at the bottom holds the composer and a status line, and is the only region that repaints. The developer types a task, watches the reply stream token by token without strobing, watches the agent edit a file inside io-harness's sandbox, presses `Ctrl+C` to interrupt a turn that is going wrong, types another, and exits with `Ctrl+D`. + +Afterwards the terminal contains the whole conversation. `Cmd+F` finds text in it. tmux copy-mode scrolls it. A mouse drag selects it. None of that was implemented — it works because io-cli never entered the alternate screen and never captured the mouse. + + + +_Canonical sources: products//execution/active.yaml, products//releases/.yaml_ diff --git a/.ultraship/views/master-roadmap.md b/.ultraship/views/master-roadmap.md new file mode 100644 index 0000000..5194a0e --- /dev/null +++ b/.ultraship/views/master-roadmap.md @@ -0,0 +1,22 @@ + +# Master roadmap + +## io-cli + +| Version | Outcome | Detail | Status | +| --- | --- | --- | --- | +| 0.1.0 | A developer with no configuration runs `io` in a repository and reaches a finished task in the same session. A guided first run asks for a provider, takes a key and verifies it against the live endpoint, offers the provider's models, previews the themes as it moves between them, and picks a default permission posture — then writes io-harness's own configuration file. The session that follows renders on a hybrid inline viewport: every finished turn, tool cell and message is committed into the terminal's real scrollback, and only a short pane at the bottom holds the composer and the status line. The developer types a task, watches the answer stream, watches a file get edited inside the sandbox, interrupts with a keystroke, and exits — leaving the whole conversation scrollable, searchable and selectable with the terminal's own facilities, because io-cli never took them away. | specified | planned | +| 0.2.0 | The operator can see and steer the boundary the agent is working under. One key cycles the permission posture and the status line names the io-harness policy layer now in force. A sensitive action stops the run in an overlay that cannot scroll away — showing the act, the target, the rule that is asking, and the diff when it is a write — and is resolved by keystroke into both the transcript and the durable trace. A refusal prints the act, the target, and the rule and layer that produced it, which no other terminal agent can render because no other core records it. The status line gains the rest of its fields: context pressure, tokens, spend against the tree ceiling, containment state. | outline | planned | +| 0.3.0 | The operator can read what the agent did without leaving the terminal or losing the thread. Edits render as diffs — unified by default, word-level emphasis inside changed lines, syntax highlighting, context collapsed with an expander, a defined form for narrow terminals, and a minimal mode for people who review by file rather than by hunk. Tool calls render as one line with a result and a duration, expandable while live and committed collapsed, with the full output going to the durable trace rather than to the screen. A transcript overlay gives the whole conversation on one key, and the last message or the last diff can be put on the system clipboard in a way that survives SSH. | outline | planned | +| 0.4.0 | Work survives the session. A picker lists previous sessions from the harness store and reopens one where it stopped. Any turn in a conversation can be forked into a new branch and continued from there. The model can be changed mid-session without losing context. An empty composer takes a double-escape to rewind the conversation and the workspace to an earlier point, using the rewind the harness already implements and already records. | outline | planned | +| 0.5.0 | The same agent runs unattended. A non-interactive subcommand takes a goal, runs it to completion, and exits with a meaningful status; with a flag it emits the run's events as newline-delimited JSON — the same events the interactive session renders, from the same stream, with no interface code on the path. Sandbox and policy are selectable from the command line, a non-TTY stdout is detected and honoured without being asked, and credentials are taken from the environment so nothing has to be written to disk in CI. | hypothesis | planned | +| 0.6.0 | The interface reaches people and terminals the category currently excludes. A plain mode built for screen readers removes animation and repaint, keeps the real terminal cursor as the focus indicator, puts content before metadata, and announces every state change as one line of text — which no terminal agent in this field does today. Colour is never the sole carrier of meaning, `NO_COLOR` is honoured, box drawing degrades to ASCII, the keyboard protocol is negotiated up on terminals that advertise it and down over SSH without losing core function, keybindings are configurable and documented as a table, and every surface has an audited form at eighty columns. | hypothesis | planned | +| 0.7.0 | The composer becomes a command surface. A slash palette with fuzzy matching reaches every command and every harness skill and prompt template; `@` completes paths from the workspace; `!` passes a line to the shell verbatim; a large paste becomes an attachment instead of flooding the input. The agent's own plan and todo list render as a live panel, and a question the agent asks about intent — which the harness distinguishes from an approval about permission — is answered inline rather than guessed at. | hypothesis | planned | +| 0.8.0 | A decomposed task becomes visible as a tree. Spawned children appear as a live fleet with their own status, each showing its draw against the ceiling the whole tree shares, with refused spawns and collected results shown where they happen. This is the surface that is structurally impossible for a competing interface to copy, because it is rendering facts only this core emits. | hypothesis | planned | +| 0.9.0 | The remaining harness capabilities get a face. Images render inline where the terminal speaks Kitty, iTerm2 or Sixel and degrade to half-blocks where it does not, and an image can be attached to a turn. Background shell handles, browser sessions, LSP servers and MCP servers each get a compact live indicator, so a run that has started something long-lived says so rather than appearing idle. | hypothesis | planned | + + +_Canonical sources: products//roadmap.yaml_ diff --git a/.ultraship/views/release-history.md b/.ultraship/views/release-history.md new file mode 100644 index 0000000..b67b9ec --- /dev/null +++ b/.ultraship/views/release-history.md @@ -0,0 +1,10 @@ + +# Release history + +_No versions have been released yet._ + + +_Canonical sources: products//releases/.yaml_ diff --git a/.ultraship/views/workspace-brief.md b/.ultraship/views/workspace-brief.md new file mode 100644 index 0000000..2b24ec1 --- /dev/null +++ b/.ultraship/views/workspace-brief.md @@ -0,0 +1,21 @@ + +# io-cli + +**State:** DEVELOPING +**Next command:** `/ultraship:develop` + +## Vision + +The terminal agent that shows you what it is allowed to do, what it is spending, and what it refused — while it works. A renderer over io-harness, containing no agent loop of its own. + + +## Products + +| Product | State | Latest release | Classification | MVP boundary | +| --- | --- | --- | --- | --- | +| io-cli | DEVELOPING | 0.1.0 | independent-product | A developer with no prior configuration runs `io` in a repository, is walked through picking a provider, entering and verifying a key, choosing a model and a theme, types one real task, watches it stream and edit a file inside the sandbox, interrupts it with a keystroke, and exits — leaving the whole conversation in the terminal's own scrollback. | + +_Canonical sources: workspace.yaml, products//product.yaml, products//lifecycle.yaml_ From 60c8e1f64c30e60ea9f5325c49b0da4e4c81f415 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 16 Aug 2026 19:36:17 +0530 Subject: [PATCH 02/34] chore(plan): specify io-cli 0.1.0 --- .ultraship/products/io-cli/lifecycle.yaml | 4 + .../products/io-cli/releases/0.1.0.yaml | 537 ++++++++++++++++++ 2 files changed, 541 insertions(+) create mode 100644 .ultraship/products/io-cli/lifecycle.yaml create mode 100644 .ultraship/products/io-cli/releases/0.1.0.yaml diff --git a/.ultraship/products/io-cli/lifecycle.yaml b/.ultraship/products/io-cli/lifecycle.yaml new file mode 100644 index 0000000..94d07a0 --- /dev/null +++ b/.ultraship/products/io-cli/lifecycle.yaml @@ -0,0 +1,4 @@ +product: io-cli +state: DEVELOPING +resumes_to: null +blockers: [] diff --git a/.ultraship/products/io-cli/releases/0.1.0.yaml b/.ultraship/products/io-cli/releases/0.1.0.yaml new file mode 100644 index 0000000..bb65e07 --- /dev/null +++ b/.ultraship/products/io-cli/releases/0.1.0.yaml @@ -0,0 +1,537 @@ +product: io-cli +version: 0.1.0 +status: planned +release_type: minor +outcome: + user_can: > + A developer clones nothing, configures nothing, and runs `io` in a repository. Because no + configuration exists, a guided first run starts: it asks which provider to use — the choices + being the variants io-harness's own `ProviderSpec` defines, not a hardcoded list — takes an API + key without echoing it, and **verifies that key against the live endpoint before continuing**, so + a bad key is caught in the wizard rather than on the first real prompt. It then offers the + provider's own model catalogue, lets the developer arrow through the shipped themes with a sample + transcript re-rendering live behind the picker, and asks for a default permission posture in + plain words. It shows exactly what will be written and where, and only then writes io-harness's + configuration file with mode `0600`. + + + The session that follows is the product's core claim made real. Every finished message, tool cell + and system line is committed into **the terminal's own scrollback**; a viewport of a few lines at + the bottom holds the composer and a status line, and is the only region that repaints. The + developer types a task, watches the reply stream token by token without strobing, watches the + agent edit a file inside io-harness's sandbox, presses `Ctrl+C` to interrupt a turn that is going + wrong, types another, and exits with `Ctrl+D`. + + + Afterwards the terminal contains the whole conversation. `Cmd+F` finds text in it. tmux copy-mode + scrolls it. A mouse drag selects it. None of that was implemented — it works because io-cli never + entered the alternate screen and never captured the mouse. + business_value: > + This release exists to make the archived fork's mistake structurally impossible to repeat. That + product was 1,199,453 lines across 88 crates, roughly sixty of which duplicated io-harness, and + its interface could not be lifted out — the TUI crate alone was 229k lines depending on 41 + siblings. The scope below is a binary that renders an event stream and edits a prompt. If 0.1.0 + ships and it is small, the shape is proven and every later release inherits it. + + + It also settles the two decisions that are expensive to reverse later and cheap to make now. The + renderer is the first: the field's most-complained-about choice is the alternate screen, and a + product that ships with it spends the rest of its life adding escape hatches. Building on the + inline viewport from the first commit means the property is never lost, because there is no code + path that could lose it. The wizard is the second: first contact is where a bring-your-own-key + product loses people, and every competitor either errors on a missing environment variable or + funnels the user into one vendor's login. + + + And it puts a real agent in the owner's hands. The harness has shipped sixty-one releases with + its only interactive consumer being example programs. The first session in which a human watches + io-harness work and can stop it will find things sixty-one releases of tests did not. + evidence_of_value: > + The acceptance criteria carry it, and two of them are unusual on purpose. + + + F5 asserts a structural property rather than a behaviour: over a full scripted session, the bytes + io-cli writes to the terminal **never contain the alternate-screen sequences** `ESC[?1049h` or + `ESC[?1047h`, and never contain the mouse-capture sequences `ESC[?1000h` or `ESC[?1003h`. That + single assertion is the whole differentiator of §1 of the design, expressed as something a test + can fail on rather than something a reviewer has to notice. It is also permanent — no later + release can quietly reintroduce fullscreen without turning this test red. + + + F1 is the manual one that cannot be automated and must not be skipped: a real live run, against a + real provider, in a real repository, taken from an empty configuration through the wizard to a + verified file edit, with the terminal's own search and selection exercised on the transcript + afterwards. The screen recording of that run is the release's evidence and the README's + screenshot. +scope: + included: + - > + **The repository skeleton, matching the io-harness line.** `LICENSE` (Apache-2.0 body + pristine), `NOTICE` and `README.md` carrying `Copyright 2026 Aakash Pawar (InitOrigin)`, + `CHANGELOG.md`, `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md`, `.github/` with + `CODEOWNERS`, a PR template, issue templates, and `dependabot.yml` targeting `develop`. + Manifest authors carry no email address. + - > + **CI on three platforms**, mirroring io-harness's `ci.yml` shape: build, test, `clippy -D + warnings`, `fmt --check`, on macOS, Linux and Windows. Every `uses:` pinned to a full-length + commit SHA with the version in a trailing comment. No job id contains a dot. + `cancel-in-progress` is off, so a failing run keeps its diagnostics. + - > + **The binary is `io`.** One crate, one binary, `clap` for arguments. The crate name is + `io-cli`; the binary name, the config section and the environment prefix follow the naming + table already settled for this product. + - > + **The renderer — the load-bearing piece.** `ratatui` over `crossterm` with + `Viewport::Inline`. Finished content is pushed to scrollback with `Terminal::insert_before`; + the viewport holds composer and status only. Every frame is wrapped in synchronized-output + (`CSI ? 2026 h` / `l`). Token events are coalesced to at most one frame per frame-budget. No + alternate screen, no mouse capture, no full-screen clear. A panic hook and a `Drop` guard + restore the terminal — cooked mode, cursor shown — **before** anything is printed. + - > + **The `Picker` overlay component**, built once and used by the wizard's provider, model, theme + and posture steps. It is the component every later release reuses for `/model`, `/resume`, + `/fork` and approvals, which is why it is built properly here and not inlined into the wizard. + This replaces adopting a Rust prompt library — `inquire`, `dialoguer`, `promkit` — each of + which would be a second owner of raw mode and a second aesthetic. + - > + **The composer.** `tui-textarea` for editing, multiline on `Shift+Enter` with a `\` + `Enter` + fallback for terminals that cannot report it, prompt history on the arrow keys, and bracketed + paste enabled so a pasted block does not arrive as keystrokes. + - > + **The first-run wizard**, in the seven steps the design fixes: provider, credential, + verification call, model, theme with live preview, permission posture, and a + write-confirmation screen that names the exact path. Re-runnable at any time as `io setup`. + - > + **Configuration is io-harness's.** `Config::discover` finds it; the wizard writes it by + serializing `ProviderSpec` and the chosen policy, because those types already derive + `Serialize`. io-cli's own settings — theme, and nothing else in this release — live under + `[app.io-cli]` and are read with `Config::app::("io-cli")`. **There is no + configuration parser in this repository**, and F7 asserts it. + - > + **The session.** `io_harness::Session` over the harness `Store`, driven by + `turn_steered_observed`-shaped calls so that streaming and interruption both work from the + first release. An `Observer` implementation is the sole bridge between the harness and the + screen. + - > + **The event surface for this release**, deliberately partial: `started`, `token`, `step`, + `tool_call`, `finished` rendered fully; `refused` and `approval_requested` rendered as a plain + one-line notice with the run behaving safely, because their real surfaces are 0.2.0. Every + other kind of the fifty falls through to a muted single line rather than being dropped + silently — an unrendered event is a bug this design should surface, not hide. + - > + **A status line** with model, elapsed time and a working indicator. The remaining fields — + policy layer, context pressure, spend against ceiling, containment — are 0.2.0, and the line is + laid out now so they slot in without a redesign. + - > + **The theme token set** — background, foreground, muted, accent, success, warning, error, + diff-add, diff-delete — with two shipped themes, terminal background detection, and `NO_COLOR` + honoured. + - > + **The splash**: the `IO CLI` mark in the block characters the owner supplied, printed once into + scrollback, suppressed under `NO_COLOR`, under a non-TTY stdout, and under any future plain + mode. + - > + **Keys for this release**: `Ctrl+C` interrupts the turn via `Steer::interrupt` and twice in + succession exits, `Ctrl+D` exits on an empty composer, `Ctrl+L` clears the viewport and never + the scrollback, `Enter` submits. Documented as a table in the README, not as prose. + - > + **Slash commands for this release**: `/help`, `/quit`, `/setup`, `/theme`, `/model`. Five, each + one a `Picker` or a print. The palette is 0.7.0. + - > + **Terminal reality**: a resize is handled without duplicating committed scrollback, and an + 80×24 terminal is a supported size with every surface defined at that width. + - > + **The tests**, including the two harnesses later releases will build on: a `TestBackend`-based + renderer harness that asserts buffer contents, and a byte-stream harness that captures + everything written to the terminal so F5 can assert over it. + - > + **The distribution, which is the only channel this product has.** A `release.yml` cross-compile + matrix producing four artifacts — `aarch64-apple-darwin`, `x86_64-apple-darwin`, + `x86_64-unknown-linux-musl` and `x86_64-pc-windows-msvc` — attached to the GitHub Release that + the tag cuts, alongside a `SHA256SUMS` file covering all of them. musl is chosen for Linux + deliberately: one static artifact that runs on any distribution beats two glibc builds and a + support matrix of which one a user needs. + - > + **`install.sh` and `install.ps1`.** The shell script is POSIX `sh`, detects the platform from + `uname`, resolves the matching artifact from the latest release, **verifies it against + `SHA256SUMS` before unpacking**, and installs into a per-user directory — `$IO_INSTALL_DIR`, + else `~/.local/bin` — with **no `sudo` and no system-wide write**. The PowerShell script does + the same into `%LOCALAPPDATA%\io\bin` and adds it to the user `PATH`. Both print the exact + `PATH` line to add when the directory is not already on it, rather than editing a shell profile + behind the user's back. + - > + **The CHANGELOG entry and the version**, `0.1.0` in `Cargo.toml` and `Cargo.lock`, under the + structure this line of repositories uses. + excluded: + - > + **Any agent loop, provider client, tool, sandbox, policy engine or session store.** This is the + product's founding constraint. If something is missing, it is added to io-harness and consumed + — not written here. N1 asserts it by dependency and by grep. + - > + **Approval overlays, policy cycling, and the refusal surface with rule and layer.** 0.2.0. + This release runs under whatever posture the wizard wrote and prints a plain notice rather than + pretending to a surface it does not have. + - > + **Diff rendering and collapsible tool output.** 0.3.0. An edit is reported as a one-line fact + here; making it beautiful is a release of its own and doing it badly now costs more than + waiting. + - > + **Session resume, fork and rewind.** 0.4.0. A session in this release starts fresh. + - > + **The headless subcommand and NDJSON.** 0.5.0. `io` is interactive-only in this release, and it + detects a non-TTY stdout only well enough to suppress the splash and refuse to start a wizard. + - > + **Plain mode, configurable keybindings, the Kitty keyboard protocol negotiation.** 0.6.0. The + defaults chosen here — real cursor never hidden while input is possible, `NO_COLOR` honoured, + content before metadata — are the parts that would be expensive to retrofit, so they are in; + the mode itself is not. + - > + **`@` file completion, `!` shell escape, the command palette, the plan and todo panel.** 0.7.0. + - > + **The fleet surface.** 0.8.0. + - > + **Images, browser, LSP and MCP indicators.** 0.9.0. + - > + **An OS-keychain credential backend.** A `0600` file is what `gh`, `aws` and `npm` do. A + keychain that fails silently on a headless Linux box is worse, and choosing between them is not + a first-release decision. + - > + **Publishing to crates.io, and `cargo install` as a path.** Decided against for the product, + not deferred — see `product.yaml` non-goals and iterations/US-IO-CLI-0.1.0-I01. The name holds + seventeen yanked versions through 0.14.0 and a yank does not free a number, so publishing would + restart this roadmap above 0.14.0 for an install path slower than the one being built. + - > + **Homebrew, Scoop, WinGet, `.deb`, `.rpm`, or an npm shim.** Two scripts are the distribution + surface. + - > + **Linux `aarch64`, Windows `aarch64`, and 32-bit anything.** Four artifacts in the first + matrix; see the open question on which fifth is worth adding first. + - > + **Auto-update, an update checker, or any version phone-home.** Re-running the install script + is the update mechanism, and a terminal tool that contacts a server the operator did not ask it + to contact violates this product's own non-goals. + - > + **Vim mode in the composer.** Deliberately late, as it was for the product this pattern is + taken from. + fallback_scope: + - > + **The renderer, the composer, the session and one real completed task, configured from + environment variables.** The wizard drops to three steps — provider, key, model — with no theme + preview and no posture step, and the theme falls back to the single default. This is still a + coherent release: a developer can do real work in it, the scrollback property holds, and F5 + still passes. The renderer is the half that must not drop, because it is the one thing that + cannot be added later without rewriting everything built on top of it. + - > + **The theme picker with live preview drops first.** It is the delight, not the function; a + single shipped theme with `NO_COLOR` support is a complete product and the picker is two hours + whenever it is wanted. + - > + **The live key-verification call drops second**, to a format check plus a clear error on the + first real turn. It is the difference between a good wizard and an adequate one, not the + difference between working and not. + - > + **The `Picker` component does not drop.** If it is cut, the wizard has to be hand-rolled and + then rebuilt in 0.2.0 for approvals — which is the seam this release exists to prevent. +acceptance: + functional: + - > + **F1 — the live first run.** From an empty `$IO_CONFIG_HOME`, in a real repository, against a + real provider: the wizard is completed, the key is verified by a live call, the file is + written, a task is typed, the answer streams, a file is edited inside the sandbox, `Ctrl+C` + interrupts a second turn mid-flight, and `Ctrl+D` exits. Afterwards, in the same terminal, the + transcript is searched with the terminal's own search and a passage is selected with the mouse + and pasted elsewhere. Manual, recorded, and the recording is the release evidence. A fixture + cannot prove this; only a live run can. + - > + **F2 — the wizard writes what it showed.** A test drives the wizard over the `TestBackend` + with a scripted key sequence and asserts the resulting file parses under + `io_harness::Config::from_toml`, that `provider_spec()` returns the chosen variant with the + chosen model, and that the file's mode is `0600` on unix. Sabotage: write the file before the + confirmation screen, under which only F2 fails. + - > + **F3 — a bad key does not get past the wizard.** With a provider endpoint that rejects the + credential, the wizard returns to the credential step carrying the provider's own message, and + **no file is written**. Sabotage: treat any non-transport error as success, under which only + F3 fails. + - > + **F4 — finished content reaches scrollback, not the viewport.** After a scripted session, the + captured byte stream contains every finished message's text outside the viewport region, and + the viewport buffer at rest contains only the composer and the status line. Sabotage: render a + finished message into the viewport instead of committing it, under which only F4 fails. + - > + **F5 — no alternate screen and no mouse capture, ever.** Over the same captured byte stream, + the sequences `ESC[?1049h`, `ESC[?1047h`, `ESC[?1000h`, `ESC[?1002h` and `ESC[?1003h` do not + appear. This is the release's structural criterion and it is written so that no later release + can reintroduce fullscreen without failing it. Sabotage: call + `EnterAlternateScreen`, under which only F5 fails. + - > + **F6 — an interrupt stops the turn and keeps the session.** A scripted `Ctrl+C` during a + streaming turn causes `Steer::interrupt`, the turn ends, the partial output stays committed in + scrollback, and the composer accepts the next prompt. A second `Ctrl+C` at an idle composer + exits. Sabotage: drop the interrupt on the floor, under which only F6 fails. + - > + **F7 — the forbidden subsystems are absent.** A test over `Cargo.toml` asserts the dependency + set contains no HTTP client, no TLS stack, no SQL or database crate, and no sandboxing crate, + and a grep asserts the source contains no `async fn` that loops over provider responses. The + only path to a model is through `io_harness`. Sabotage: add `reqwest` as a direct dependency, + under which only F7 fails. This is the criterion that keeps the archived fork from growing back. + - > + **F8 — every event kind is accounted for.** A test enumerates the event kinds io-harness emits + and asserts the renderer's match is exhaustive with no wildcard that silently discards. An + event this release does not style renders as a muted line; none is dropped. Sabotage: add a + catch-all arm that returns without rendering, under which only F8 fails. + - > + **F9 — 80×24 is supported.** The renderer harness runs the scripted session at 80×24 and + asserts no panel is truncated mid-word, the composer remains usable, and the status line + degrades to its narrow form rather than wrapping. + non_functional: + - > + **N1 — io-cli contains no second harness.** `cargo tree --depth 1` is recorded in the release + evidence and reviewed: `io-harness`, `ratatui`, `crossterm`, `tui-textarea`, `clap`, `tokio`, + `serde`/`toml`, and nothing that constitutes a runtime, a client or a store. Any addition + beyond that list is argued in the release record or it does not ship. + - > + **N2 — the terminal is always restored.** Panic, `Ctrl+C` at any point, and a kill signal each + leave the terminal in cooked mode with the cursor visible and the scrollback intact. Asserted + by a test that panics inside a turn and inspects the restore path, and by manual check for the + signal case. + - > + **N3 — no full-screen clear during a session.** The captured byte stream contains no + `ESC[2J` after startup. This is the flicker criterion, and it is asserted structurally because + flicker itself cannot be asserted on a CI runner. + - > + **N4 — no timing assertion anywhere in this release's tests.** Startup cost and streaming + smoothness are **recorded as measurements** in the release evidence, not gated. This line of + repositories has paid for wall-clock assertions on CI runners more than for anything else. + - > + **N5 — the credential never reaches the screen, the scrollback, a log line or the trace.** The + captured byte stream from the F2 scripted wizard is searched for the test key and must not + contain it. Sabotage: echo the key in the confirmation screen, under which only N5 fails. + - > + **N6 — `NO_COLOR` produces a usable session.** With `NO_COLOR=1` the scripted session runs and + every state that colour distinguishes — success, warning, error, refusal — also carries a word. + - > + **N7 — an installer never runs an unverified binary.** Both scripts compute the artifact's + SHA-256 and compare it against the published `SHA256SUMS` before unpacking, and **abort + non-zero on a mismatch without leaving anything on disk**. Asserted by running each installer + against a deliberately corrupted artifact and checking both the exit status and that the target + directory is untouched. This is the one place in the product where a failure is a security + failure rather than an inconvenience, and `curl | sh` is a trust-the-publisher model regardless + — the checksum defends against a truncated download and a tampered asset, not against a + compromised repository, and the README says so rather than implying more. + - > + **N8 — an install needs no administrator rights.** Neither script writes outside the user's own + directories and neither invokes `sudo`. Asserted by running each as an unprivileged user with + no write access to any system path. + operational: + - > + **O1 — CI green on macOS, Linux and Windows**, on the feature branch and again on the merge to + `develop`. Windows is not optional: it is where a terminal renderer's assumptions break, and it + is where this project has historically found what the other two hid. + - > + **O2 — the binary builds in release profile.** `cargo build --release` is run and the binary + executed. CI builds the test profile only, so a release-profile break is invisible to it — + this has bitten this line of repositories before and is now a standing gate. + - > + **O3 — the branch flow is followed exactly.** `feat/0.1.0` off `develop`, one pull request into + `develop`, merged with the PR button. `develop` to `main` by pull request. Nothing is pushed + directly to `develop` or `main` at any point. The tag `v0.1.0` is pushed on its own after the + merge — pushing the tag is what cuts the GitHub Release; creating the Release by hand ships it + with no assets. + - > + **O4 — the repository's own protections are in place or their absence is recorded.** The + rulesets that make O3 mechanical rather than voluntary require the repository to be public or + on a paid plan; see the open question. Until then O3 is a discipline, and that fact is stated + in the release record rather than left implied. + - > + **O5 — every artifact in the matrix exists and every installer works on a clean machine.** The + Release carries all four artifacts plus `SHA256SUMS`. `install.sh` is then run on a clean macOS + machine and in a clean Linux container, and `install.ps1` on a clean Windows machine, each + followed by running `io --version` **in a new shell** — which is what catches a script that + installed correctly and told the user nothing useful about `PATH`. An installer verified only + on the machine that built it is not verified. + - > + **O6 — the Release is cut by pushing the tag, and nothing else.** `gh release create` is never + used: it produces a Release with no assets. The workflow the tag triggers is what attaches + them, and the Release title is the bare form this line of repositories uses. +delivery: + target_mode: release-ready + environment: > + The developer's own machine — macOS, Linux and Windows terminals, including inside tmux and over + SSH. GitHub for the pull requests, CI, the Release and the release assets. No service, no + deployment target, no infrastructure, and no package registry. The artifact is a binary a + developer installs with one command and runs in their own terminal, and the GitHub Release is the + only channel it travels through. + deployment_method: > + One feature branch `feat/0.1.0` cut from `develop`. All work lands on it. One pull request into + `develop`, merged with the PR button; `git pull origin develop` immediately after. Then a pull + request from `develop` into `main`, merged the same way. The UltraShip record is sealed and the + lock pinned in a commit on top of the release commit. The tag `v0.1.0` is pushed alone, which + cuts the GitHub Release; the workflow it triggers cross-compiles the four artifacts, writes + `SHA256SUMS`, and attaches all five to that Release. The installers are then exercised against + the published Release, not against a local build — O5. **Nothing reaches `develop` or `main` + except through a pull request.** Pushes go over HTTPS with the `gh` credential helper; the remote + is never rewritten. There is no publish step after the Release, because there is no registry. + migrations: + - > + None. This is a first release with no predecessor state. The configuration file it writes is + io-harness's, in the schema io-harness 0.60.1 already reads, so it is forward-compatible with + the harness by construction rather than by agreement. + observability: + - > + The run's own durable trace in the harness store is the observability surface; io-cli adds no + telemetry, no crash reporting and no network call the operator did not ask for. The one + diagnostic io-cli owns is a `RUST_LOG`-gated stderr log that is silent by default and never + writes into the scrollback the transcript lives in. + rollback: > + Deleting the tag and the GitHub Release removes the artifacts and, with them, the install path — + both actions are reversible, and this is the whole reason the no-registry decision is valuable: + a crates.io publish could never have been undone, and a yank is not a delete, as the seventeen + yanked versions on this crate's own name demonstrate. + + + What rollback does **not** reach is a binary already installed on someone's machine. That is + inherent to shipping binaries and is handled by fixing forward as 0.1.1 and by the install script + always resolving the latest Release, so re-running one command is the update. There is no + auto-update and no version check, deliberately. + + + The `develop`-to-`main` merge is reverted by a revert commit through a pull request, like any + other change. +dependencies: + products: + - > + io-harness 0.60.1 or later, as a path dependency during development and a version dependency at + release. Everything this release renders already exists in it; nothing in this release requires + a harness change. **If one is discovered, it is a harness release and this one waits** — that + is the constraint, and the first time it is tested is here. + services: + - > + One live model provider, for the wizard's verification call and for F1. OpenRouter is the + likely first, being the harness's own first provider. + - > + GitHub Actions, for CI on the three platforms, for the cross-compile matrix, and for the + Release the tag cuts. + - > + GitHub Releases, which is now a **hard** dependency rather than a convenience: it is the sole + distribution channel and the sole host of the artifacts the installers resolve. If it is + unavailable, there is no way to install io-cli. Accepted knowingly — the alternative is + operating a download host, which contradicts "no service, nothing to operate". + human_approvals: + - > + The owner approves the rendered look before the tag — the splash, the theme defaults, the + status line and the wizard screens — from the F1 recording. This is an interface release; the + thing being shipped is what it looks like, and that cannot be delegated to a test. + - > + The owner decides whether the repository becomes public, which is what makes the branch + rulesets available. Until that decision the pull-request rule is discipline rather than + enforcement. +resource_profile: + complexity: large + uncertainty: medium + likely_cost_drivers: + - > + **The inline renderer.** Committing finished content to scrollback while a live viewport sits + below it is the one genuinely novel piece of engineering in the release, and the product's + whole differentiation rests on it. Resize, wrapping wider than the terminal, and the ordering + of commits against in-flight frames are each their own problem. This is where the time goes and + the schedule should assume it. + - > + **Windows.** A terminal renderer meets a different reality there — key reporting, resize + events, ANSI support depending on the host — and this project's history is that Windows finds + what the other two platforms hide. + - > + **The wizard's verification step.** Each provider reports a bad credential differently, and the + criterion is that the wizard shows the provider's own message rather than a generic failure. + - > + **Building the test harnesses.** A `TestBackend` renderer harness and a byte-stream capture + harness are both new, and F5 and N3 are impossible without the second. They pay for themselves + across every later release, but they are paid for here. + - > + **The cross-compile matrix, and `musl` specifically.** io-harness carries `rusqlite`, which + builds a bundled C SQLite. Cross-compiling that to `x86_64-unknown-linux-musl` needs a musl + toolchain in the job and is the row most likely to fail first. It is still the right target — + one static Linux artifact that runs anywhere beats two glibc builds and a support question — + but it is where the matrix work concentrates, and it is worth proving on a throwaway branch + before the release branch depends on it. + - > + **Verifying the installers on clean machines.** O5 is deliberately not satisfiable from the + build machine, and "a new shell" in that criterion is the part that catches the `PATH` mistake + every install script makes once. + preferred_tools: + - '`ratatui` `Viewport::Inline` and `Terminal::insert_before`, plus its `TestBackend` for F2/F4/F9' + - '`tui-textarea` for the composer; `crossterm` for events, bracketed paste and capability queries' + - > + io-harness's `examples/` as the reference for driving `Session` and `Observer` — `edit_repo.rs` + drives a complete verified workspace edit in 56 lines and is the shape this binary wraps + - '`cargo tree --depth 1` as the evidence for N1' + constraints: + - > + **No second harness.** The moment something is missing — a provider detail, a policy shape, a + tool — it goes into io-harness. Writing it here is how the archived fork happened, and F7 is the + criterion that catches it. + - > + **No timing assertion, on any test, on any platform.** Measurements are recorded; nothing is + gated on a clock. + - > + **No alternate screen, in any mode, behind any flag.** There is no configuration for it because + there is no code path for it. + - > + **Nothing reaches `develop` or `main` except by pull request**, including documentation and + including the UltraShip record itself. +risks: + - > + **The inline renderer turns out to be harder than the design assumes.** The assumption that + finished content can be committed to scrollback while a live viewport renders below it is taken + from how a shipped Rust agent behaves, not from our own code. If it proves unworkable — most + likely on resize or with content wider than the terminal — the whole product's differentiation is + at risk, not just this release. **Mitigation: build the renderer first, before the wizard, before + the composer, before anything else.** A spike that commits a hundred lines of fake transcript to + scrollback under a live viewport, at three terminal widths, with a resize in the middle, is the + first thing written and the gate on the rest of the release. + - > + **The wizard grows into the release.** Seven steps, a live network call, a theme preview and a + provider catalogue is a lot of surface for a first release, and it is the part most likely to + expand while being built. The fallback scope names the three-step version deliberately so that + shrinking it is a decision already made rather than an emergency. + - > + **Something turns out to be missing from io-harness.** The constraint says the harness gets the + change and this release waits. That is correct and it is also a schedule risk with no mitigation + other than finding out early, which is another reason the renderer and one real end-to-end turn + come before the polish. + - > + **Windows costs a round.** Assume it, rather than discovering it after the macOS and Linux runs + are green. The historical pattern in this line of repositories is that Windows finds real defects + the other two cannot. + - > + **The look is approved late.** This is an interface release and the acceptance for the look is a + human decision from a recording. If that decision arrives after the tag, the correction is a + patch release for something that should have been a review. The F1 recording should be produced + and shown as soon as one real turn works, not at the end. +open_questions: + - > + **Which fifth target is worth adding, and when?** The matrix ships four. + `aarch64-unknown-linux-musl` is the strongest candidate — ARM servers and ARM Linux laptops are + no longer unusual — and it is also the second-hardest row, for the same `rusqlite`-on-musl reason + as the first. Decide once the x86 musl row is actually building, not now. + - > + **Does the install script pin a version, or always resolve `latest`?** Always-latest makes + re-running the script the update mechanism, which is the design. It also means a user cannot + reproduce yesterday's install. An `IO_VERSION` environment variable answers both and costs a few + lines, but only if the artifact naming is stable from the first Release — which is why it is + worth deciding while writing the script rather than after. + - > + **Does the repository become public now?** The branch rulesets that make "nothing without a pull + request" mechanical are unavailable on a private repository without a paid plan — the API refuses + with a 403. The product is Apache-2.0 and intended to be open, so this is a question of timing + rather than direction. Until it is answered, O3 is discipline rather than enforcement, and O4 + requires that to be stated rather than assumed. + - > + **Which provider is the wizard's default?** OpenRouter is the harness's own first provider and + reaches the widest model set with one key, which argues for it. It also means the developer's + first impression of io-cli is mediated by an aggregator. Not a blocker — the wizard offers all + variants regardless — but the default is what most people will take. + - > + **Does the status line's spend field appear here, greyed, or wait for 0.2.0?** Laying it out now + avoids a redesign; showing an empty field looks unfinished. Decide when the line is first + rendered against real content, not now. From 2b1d99660aa416c1fcdfa6347643145edf82177b Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 16 Aug 2026 19:36:17 +0530 Subject: [PATCH 03/34] chore(iterate): record plan change for io-cli 0.1.0 --- .ultraship/iterations/.gitkeep | 0 .../iterations/US-IO-CLI-0.1.0-I01.yaml | 106 +++++ .../products/io-cli/execution/active.yaml | 33 ++ .../products/io-cli/execution/tasks.yaml | 433 ++++++++++++++++++ .ultraship/products/io-cli/product.yaml | 225 +++++++++ 5 files changed, 797 insertions(+) create mode 100644 .ultraship/iterations/.gitkeep create mode 100644 .ultraship/iterations/US-IO-CLI-0.1.0-I01.yaml create mode 100644 .ultraship/products/io-cli/execution/active.yaml create mode 100644 .ultraship/products/io-cli/execution/tasks.yaml create mode 100644 .ultraship/products/io-cli/product.yaml diff --git a/.ultraship/iterations/.gitkeep b/.ultraship/iterations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.ultraship/iterations/US-IO-CLI-0.1.0-I01.yaml b/.ultraship/iterations/US-IO-CLI-0.1.0-I01.yaml new file mode 100644 index 0000000..169f1ca --- /dev/null +++ b/.ultraship/iterations/US-IO-CLI-0.1.0-I01.yaml @@ -0,0 +1,106 @@ +id: US-IO-CLI-0.1.0-I01 +timestamp: 2026-08-16T13:41:43Z +category: + - product-level + - release-level +trigger: > + The owner asked how io-cli would be distributed — a crates.io publish or GitHub Releases only. + Checking the registry before answering found that the crate name is already taken, by us, with a + published version history that reaches into the range this roadmap had just allocated. +evidence: + - > + `https://crates.io/api/v1/crates/io-cli` returns a crate owned by `aakashpawar1999` (Aakash + Pawar) whose `repository` field is `https://github.com/initorigin/io-cli` — the same repository + this workspace plans. It was created 2026-07-25 and last updated 2026-08-07. + - > + It carries **17 published versions — 0.1.0 through 0.14.0** — and **all 17 are yanked** + (`max_version` is reported as `0.0.0`, which is what crates.io returns when nothing is live). + Total downloads 281. + - > + A yank removes a version from resolution; it does not delete it and it does not free the version + number. `cargo publish` of `io-cli 0.1.0` would therefore be rejected as already uploaded, and so + would every number up to 0.14.0 — the entire range this roadmap allocates for 0.1.0 through + 0.9.0. + - > + The crate's published description still opens "`io-engine` runs an agent against the project you + are standing in", a naming that predates even the archived fork. Anything published under this + name inherits that page's history. + - > + Separately: `cargo install io-cli` requires a Rust toolchain on the user's machine and compiles + io-harness and its dependency tree from source. For a terminal application distributed to people + who may not have Rust, that is the slowest of the available install paths, not the primary one. +before: + assumptions: + - > + Distribution was an open question, with `delivery.target_mode: release-ready` chosen as the + cautious default and the crates.io decision deferred to the owner. + - > + `product.yaml` stated the deployment context as "Installed from crates.io or a release binary". + scope: + - > + The 0.1.0 contract described a tag cutting a GitHub Release but never said what assets that + Release would carry. There was no release-engineering scope in the release at all. + implementation: > + No installer, no cross-compilation matrix, no checksums, no defined set of target triples. +change: + summary: > + Distribution is **prebuilt binaries attached to the GitHub Release, installed by a `curl` shell + script on unix and a PowerShell script on Windows**. io-cli is not published to crates.io, not + published to Homebrew, and is not installable with `cargo install`. Because nothing is published + to the registry, the burned 0.1.0–0.14.0 range is irrelevant and the roadmap's numbering is + unaffected. + added: + - > + Release engineering enters 0.1.0's scope: a cross-compilation matrix producing four artifacts, + a `SHA256SUMS` file published with them, `install.sh`, and `install.ps1`. + - > + A new operational acceptance criterion requiring each installer to be exercised on a clean + machine per platform, and a non-functional criterion requiring checksum verification. + removed: + - > + The crates.io publish step, and with it the "does 0.1.0 publish to crates.io" open question, + which is now decided rather than open. + deferred: + - > + Homebrew, a Scoop or WinGet manifest, and `cargo install` support. Destination: recorded as + product `non_goals` — deliberately, since "for now" here means "not part of this product's + plan", and anything that changes it is a fresh iteration with its own record. + - > + Linux `aarch64`, and any macOS or Windows ARM target beyond `aarch64-apple-darwin`. + Destination: `open_questions` on the 0.1.0 contract, to be resolved when the matrix is first + built rather than guessed at now. + replaced: + - > + `product.yaml`'s `deployment_context` sentence "Installed from crates.io or a release binary" + is replaced with the installer path, because it is now factually wrong. + - > + `product.yaml`'s `public_contract` gains the installer scripts and the artifact naming, which + become part of what may be depended upon: a user's install command must keep working. +impact: + products: + - io-cli + versions: + - 0.1.0 + tasks: [] + dependencies: + - > + GitHub Releases becomes a hard distribution dependency rather than a convenience — it is now + the only channel. This is recorded on the 0.1.0 contract's `dependencies.services`. + resource_effect: > + Adds a cross-compilation matrix and two installer scripts to 0.1.0, which is real work and is not + free. It removes the registry publish, its irreversibility, and the renumbering that publishing + would have forced on all nine roadmap entries. Net: more work in this release, materially less + risk across the product. +versioning: + previous_target: 0.1.0 + new_target: 0.1.0 + consequence: no-version-change +approval: + source: user + reference: > + Owner, in session 2026-08-16, asked whether these would be crate-published or GitHub Releases + only, was shown the burned version range and the four distribution options, and answered: + "Want installations with curl and powershell only for now." +canonical_updates: + - products/io-cli/product.yaml + - products/io-cli/releases/0.1.0.yaml diff --git a/.ultraship/products/io-cli/execution/active.yaml b/.ultraship/products/io-cli/execution/active.yaml new file mode 100644 index 0000000..61d0073 --- /dev/null +++ b/.ultraship/products/io-cli/execution/active.yaml @@ -0,0 +1,33 @@ +product: io-cli +version: 0.1.0 +execution_state: DEVELOPING +checkpoint: null +blockers: [] +release_fit: + assessment: probable + reasons: + - The workspace records no time, token or monetary budget at all — `resource_profile.available` + is null on every field — so nothing caps this release except the work itself, and the contract + already carries a fallback scope for the case where it does. + - "The workspace records `preferences.minimize_cost: true` and `allow_parallel_agents: false`, + so this is a sequential build with no fan-out. That makes the schedule long rather than + uncertain: the tasks are ordered by dependency and each one is independently verifiable." + - Everything the release renders already exists in io-harness 0.60.1 — `Session::turn_steered` + already takes both an observer and a steer inbox, `ProviderSpec` already enumerates the four + provider variants the wizard offers, and `Config` already owns discovery, layering and the + `[app.]` section. No harness change is currently known to be required. + - The repository is empty — one commit, a licence and an empty README — so there is no existing + code, no existing test suite and no migration to work around. + major_cost_drivers: + - "The inline renderer: committing finished content to scrollback under a live viewport, and + getting resize and over-wide content right. Unverified in our own code and named the largest + technical risk in the contract." + - Windows, where a terminal renderer meets different key reporting, resize events and ANSI + support. + - The cross-compile matrix, and `x86_64-unknown-linux-musl` in particular, because io-harness + carries `rusqlite` and its bundled C SQLite needs a musl toolchain in the job. + - Verifying the installers on clean machines, which O5 deliberately makes unsatisfiable from the + build machine. + - Two new test harnesses — TestBackend rendering and byte-stream capture — without the second of + which F5 and N3 cannot be asserted at all. + recommended_scope_change: null diff --git a/.ultraship/products/io-cli/execution/tasks.yaml b/.ultraship/products/io-cli/execution/tasks.yaml new file mode 100644 index 0000000..05d1ba9 --- /dev/null +++ b/.ultraship/products/io-cli/execution/tasks.yaml @@ -0,0 +1,433 @@ +product: io-cli +version: 0.1.0 +tasks: + - id: US-IO-CLI-0.1.0-T01 + summary: Create the repository skeleton and the one-crate manifest for the `io` binary. + why_required: "Nothing else in the release can be written, built or tested without a crate. The + manifest is also where N1 is decided: the dependency set that this release is allowed to have + is declared here once, and every later task is measured against it." + status: todo + depends_on: [] + acceptance_criteria: + - > + **N1 — io-cli contains no second harness.** `cargo tree --depth 1` is recorded in the + release evidence and reviewed: `io-harness`, `ratatui`, `crossterm`, `tui-textarea`, `clap`, + `tokio`, `serde`/`toml`, and nothing that constitutes a runtime, a client or a store. Any + addition beyond that list is argued in the release record or it does not ship. + files: + - Cargo.toml + - Cargo.lock + - LICENSE + - NOTICE + - README.md + - CHANGELOG.md + - CONTRIBUTING.md + - CODE_OF_CONDUCT.md + - SECURITY.md + - .gitignore + - .github/CODEOWNERS + - .github/dependabot.yml + - .github/pull_request_template.md + - .github/ISSUE_TEMPLATE/bug_report.md + - .github/ISSUE_TEMPLATE/feature_request.md + - src/main.rs + - src/cli.rs + evidence: [] + - id: US-IO-CLI-0.1.0-T02 + summary: "Add `ci.yml`: build, test, clippy -D warnings and fmt --check on macOS, Linux and Windows." + why_required: O1 requires a green run on three platforms on the feature branch and again on the + merge. Without the workflow there is no run to be green, and Windows — where this line of + repositories historically finds what the other two hide — is never exercised. + status: todo + depends_on: + - US-IO-CLI-0.1.0-T01 + acceptance_criteria: + - > + **O1 — CI green on macOS, Linux and Windows**, on the feature branch and again on the merge + to `develop`. Windows is not optional: it is where a terminal renderer's assumptions break, + and it is where this project has historically found what the other two hid. + files: + - .github/workflows/ci.yml + evidence: [] + - id: US-IO-CLI-0.1.0-T03 + summary: "Build the inline renderer core and the byte-stream capture harness: `Viewport::Inline`, + `insert_before` for finished content, synchronized output per frame, and a panic hook plus + `Drop` guard that restore the terminal." + why_required: This is the release. Every other surface renders through it, and the contract names it + the single largest technical risk with the instruction to build it before anything else. The + capture harness is built with it because F5 and N3 cannot be asserted without one. + status: todo + depends_on: + - US-IO-CLI-0.1.0-T01 + acceptance_criteria: + - > + **F4 — finished content reaches scrollback, not the viewport.** After a scripted session, + the captured byte stream contains every finished message's text outside the viewport region, + and the viewport buffer at rest contains only the composer and the status line. Sabotage: + render a finished message into the viewport instead of committing it, under which only F4 + fails. + - > + **F5 — no alternate screen and no mouse capture, ever.** Over the same captured byte stream, + the sequences `ESC[?1049h`, `ESC[?1047h`, `ESC[?1000h`, `ESC[?1002h` and `ESC[?1003h` do not + appear. This is the release's structural criterion and it is written so that no later + release can reintroduce fullscreen without failing it. Sabotage: call + `EnterAlternateScreen`, under which only F5 fails. + - > + **N2 — the terminal is always restored.** Panic, `Ctrl+C` at any point, and a kill signal + each leave the terminal in cooked mode with the cursor visible and the scrollback intact. + Asserted by a test that panics inside a turn and inspects the restore path, and by manual + check for the signal case. + - > + **N3 — no full-screen clear during a session.** The captured byte stream contains no + `ESC[2J` after startup. This is the flicker criterion, and it is asserted structurally + because flicker itself cannot be asserted on a CI runner. + files: + - src/term.rs + - src/lib.rs + - tests/support/mod.rs + - tests/structure.rs + - tests/scrollback.rs + - tests/restore.rs + evidence: [] + - id: US-IO-CLI-0.1.0-T04 + summary: Handle resize without duplicating committed scrollback, and define every surface at 80x24. + why_required: F9 makes 80x24 a supported size rather than a degraded one, and resize is where a + hybrid inline renderer duplicates history if it is wrong. Both are properties of the renderer, + so they are fixed while it is still small. + status: todo + depends_on: + - US-IO-CLI-0.1.0-T03 + acceptance_criteria: + - > + **F9 — 80×24 is supported.** The renderer harness runs the scripted session at 80×24 and + asserts no panel is truncated mid-word, the composer remains usable, and the status line + degrades to its narrow form rather than wrapping. + files: + - src/term.rs + - tests/narrow.rs + evidence: [] + - id: US-IO-CLI-0.1.0-T05 + summary: Add the theme token set with two shipped themes, terminal background detection, `NO_COLOR`, + and the splash committed once into scrollback. + why_required: N6 requires that a session run with colour disabled stays usable and that every state + colour distinguishes also carries a word. That is a property of the token set, so the tokens + have to exist before any surface uses one. The splash is the first thing written through the + renderer and is suppressed by the same switches. + status: todo + depends_on: + - US-IO-CLI-0.1.0-T03 + acceptance_criteria: + - > + **N6 — `NO_COLOR` produces a usable session.** With `NO_COLOR=1` the scripted session runs + and every state that colour distinguishes — success, warning, error, refusal — also carries + a word. + files: + - src/theme.rs + - src/splash.rs + - tests/no_color.rs + evidence: [] + - id: US-IO-CLI-0.1.0-T06 + summary: "Build the composer on `tui-textarea`: multiline on Shift+Enter with a `\\`+Enter fallback, + prompt history on the arrow keys, and bracketed paste." + why_required: There is no way to type a task without it, so F1 and F6 both stop here. Its narrow + form is also half of what F9 asserts. + status: todo + depends_on: + - US-IO-CLI-0.1.0-T04 + - US-IO-CLI-0.1.0-T05 + acceptance_criteria: + - > + **F9 — 80×24 is supported.** The renderer harness runs the scripted session at 80×24 and + asserts no panel is truncated mid-word, the composer remains usable, and the status line + degrades to its narrow form rather than wrapping. + files: + - src/composer.rs + - tests/composer.rs + evidence: [] + - id: US-IO-CLI-0.1.0-T07 + summary: Build the `Picker` overlay component, once, as the widget every selection surface uses. + why_required: The wizard has four selection steps and F2 drives all of them over the TestBackend. + The contract also names this the one piece of the fallback scope that must not drop, because + cutting it means hand-rolling the wizard and rebuilding it in 0.2.0 for approvals. + status: todo + depends_on: + - US-IO-CLI-0.1.0-T04 + - US-IO-CLI-0.1.0-T05 + acceptance_criteria: + - > + **F2 — the wizard writes what it showed.** A test drives the wizard over the `TestBackend` + with a scripted key sequence and asserts the resulting file parses under + `io_harness::Config::from_toml`, that `provider_spec()` returns the chosen variant with the + chosen model, and that the file's mode is `0600` on unix. Sabotage: write the file before + the confirmation screen, under which only F2 fails. + files: + - src/picker.rs + - tests/picker.rs + evidence: [] + - id: US-IO-CLI-0.1.0-T08 + summary: Render the status line with model, elapsed time and a working indicator, laid out for 0.2.0 + fields. + why_required: F4 asserts that the viewport at rest contains the composer and the status line and + nothing else, so the status line has to exist for that assertion to mean anything. F9 asserts + it degrades to a narrow form rather than wrapping. + status: todo + depends_on: + - US-IO-CLI-0.1.0-T04 + - US-IO-CLI-0.1.0-T05 + acceptance_criteria: + - > + **F9 — 80×24 is supported.** The renderer harness runs the scripted session at 80×24 and + asserts no panel is truncated mid-word, the composer remains usable, and the status line + degrades to its narrow form rather than wrapping. + files: + - src/status.rs + - tests/narrow.rs + evidence: [] + - id: US-IO-CLI-0.1.0-T09 + summary: "Map io-harness run events to rendered cells with an exhaustive match: `started`, `token`, + `step`, `tool_call` and `finished` styled, `refused` and `approval_requested` as a plain + notice, every other kind as a muted line." + why_required: F8 requires the match to be exhaustive with no wildcard that silently discards. This + is also where token coalescing lives, without which a streaming turn strobes. + status: todo + depends_on: + - US-IO-CLI-0.1.0-T03 + acceptance_criteria: + - > + **F8 — every event kind is accounted for.** A test enumerates the event kinds io-harness + emits and asserts the renderer's match is exhaustive with no wildcard that silently + discards. An event this release does not style renders as a muted line; none is dropped. + Sabotage: add a catch-all arm that returns without rendering, under which only F8 fails. + files: + - src/events.rs + - tests/events.rs + evidence: [] + - id: US-IO-CLI-0.1.0-T10 + summary: "Bridge io-harness to the screen: an `Observer` that hands events to the render loop + without blocking the run, a session driven by `Session::turn_steered`, and `Ctrl+C` calling + `Steer::interrupt`." + why_required: This is the only path from the harness to the interface, and F6 — interrupt a turn, + keep the session, accept the next prompt — is asserted against it. `Observer::event` is + synchronous and runs on the run's own task, so the bridge has to hand off rather than render + in place. + status: todo + depends_on: + - US-IO-CLI-0.1.0-T06 + - US-IO-CLI-0.1.0-T08 + - US-IO-CLI-0.1.0-T09 + acceptance_criteria: + - > + **F6 — an interrupt stops the turn and keeps the session.** A scripted `Ctrl+C` during a + streaming turn causes `Steer::interrupt`, the turn ends, the partial output stays committed + in scrollback, and the composer accepts the next prompt. A second `Ctrl+C` at an idle + composer exits. Sabotage: drop the interrupt on the floor, under which only F6 fails. + files: + - src/bridge.rs + - src/app.rs + - tests/interrupt.rs + evidence: [] + - id: US-IO-CLI-0.1.0-T11 + summary: Build the first-run wizard through its eight screens and write io-harness's configuration + file at mode 0600, with nothing written before the confirmation screen. + why_required: F2 is the wizard writing what it showed, and N5 is the credential never reaching the + screen, the scrollback, a log line or the trace. The release's stated outcome starts from an + empty configuration, so without the wizard there is no first run at all. + status: todo + depends_on: + - US-IO-CLI-0.1.0-T07 + - US-IO-CLI-0.1.0-T10 + acceptance_criteria: + - > + **F2 — the wizard writes what it showed.** A test drives the wizard over the `TestBackend` + with a scripted key sequence and asserts the resulting file parses under + `io_harness::Config::from_toml`, that `provider_spec()` returns the chosen variant with the + chosen model, and that the file's mode is `0600` on unix. Sabotage: write the file before + the confirmation screen, under which only F2 fails. + - > + **N5 — the credential never reaches the screen, the scrollback, a log line or the trace.** + The captured byte stream from the F2 scripted wizard is searched for the test key and must + not contain it. Sabotage: echo the key in the confirmation screen, under which only N5 + fails. + files: + - src/wizard.rs + - src/settings.rs + - tests/wizard.rs + evidence: [] + - id: US-IO-CLI-0.1.0-T12 + summary: Verify the credential against the live endpoint inside the wizard, returning to the + credential step with the provider's own message and writing no file on failure. + why_required: F3 asserts that a bad key does not get past the wizard. It is the step that decides + whether a first run fails inside a screen that can explain it or on the user's first real + prompt. + status: todo + depends_on: + - US-IO-CLI-0.1.0-T11 + acceptance_criteria: + - > + **F3 — a bad key does not get past the wizard.** With a provider endpoint that rejects the + credential, the wizard returns to the credential step carrying the provider's own message, + and **no file is written**. Sabotage: treat any non-transport error as success, under which + only F3 fails. + files: + - src/wizard.rs + - tests/wizard.rs + evidence: [] + - id: US-IO-CLI-0.1.0-T13 + summary: "Add the five slash commands: `/help`, `/quit`, `/setup`, `/theme`, `/model`." + why_required: They are named in the release scope and each is a `Picker` or a print over components + that already exist. `/setup` in particular is the contract's stated way to re-run the wizard + at any time, which is what makes the wizard reachable after the first run. + status: todo + depends_on: + - US-IO-CLI-0.1.0-T11 + acceptance_criteria: [] + files: + - src/commands.rs + - tests/commands.rs + evidence: [] + - id: US-IO-CLI-0.1.0-T14 + summary: "Assert the forbidden subsystems are absent: no HTTP client, TLS stack, SQL or sandboxing + crate in the dependency set, and no provider-response loop in the source." + why_required: F7 is the criterion that keeps the archived fork from growing back, and N1 is the + evidence reviewed at completion. It is written last on purpose, when the dependency set is + final and the assertion is meaningful. + status: todo + depends_on: + - US-IO-CLI-0.1.0-T13 + acceptance_criteria: + - > + **F7 — the forbidden subsystems are absent.** A test over `Cargo.toml` asserts the + dependency set contains no HTTP client, no TLS stack, no SQL or database crate, and no + sandboxing crate, and a grep asserts the source contains no `async fn` that loops over + provider responses. The only path to a model is through `io_harness`. Sabotage: add + `reqwest` as a direct dependency, under which only F7 fails. This is the criterion that + keeps the archived fork from growing back. + - > + **N1 — io-cli contains no second harness.** `cargo tree --depth 1` is recorded in the + release evidence and reviewed: `io-harness`, `ratatui`, `crossterm`, `tui-textarea`, `clap`, + `tokio`, `serde`/`toml`, and nothing that constitutes a runtime, a client or a store. Any + addition beyond that list is argued in the release record or it does not ship. + files: + - tests/dependencies.rs + evidence: [] + - id: US-IO-CLI-0.1.0-T15 + summary: "Add `release.yml`: a cross-compile matrix producing the four artifacts plus `SHA256SUMS`, + attached to the Release the tag cuts." + why_required: The GitHub Release is the product's only distribution channel, and O5 requires all + four artifacts and the checksum file to exist on it. The musl row is the one most likely to + fail, so it is proved before the release branch depends on it. + status: todo + depends_on: + - US-IO-CLI-0.1.0-T02 + acceptance_criteria: + - > + **O5 — every artifact in the matrix exists and every installer works on a clean machine.** + The Release carries all four artifacts plus `SHA256SUMS`. `install.sh` is then run on a + clean macOS machine and in a clean Linux container, and `install.ps1` on a clean Windows + machine, each followed by running `io --version` **in a new shell** — which is what catches + a script that installed correctly and told the user nothing useful about `PATH`. An + installer verified only on the machine that built it is not verified. + - > + **O6 — the Release is cut by pushing the tag, and nothing else.** `gh release create` is + never used: it produces a Release with no assets. The workflow the tag triggers is what + attaches them, and the Release title is the bare form this line of repositories uses. + files: + - .github/workflows/release.yml + evidence: [] + - id: US-IO-CLI-0.1.0-T16 + summary: "Write `install.sh` and `install.ps1`: resolve the artifact, verify it against `SHA256SUMS` + before unpacking, install into a per-user directory, and print the `PATH` line rather than + editing a profile." + why_required: N7 is the one place in this product where a failure is a security failure, and N8 + requires an install to need no administrator rights. There is no other way to install io-cli, + so the release does not exist without these two scripts. + status: todo + depends_on: + - US-IO-CLI-0.1.0-T15 + acceptance_criteria: + - > + **N7 — an installer never runs an unverified binary.** Both scripts compute the artifact's + SHA-256 and compare it against the published `SHA256SUMS` before unpacking, and **abort + non-zero on a mismatch without leaving anything on disk**. Asserted by running each + installer against a deliberately corrupted artifact and checking both the exit status and + that the target directory is untouched. This is the one place in the product where a failure + is a security failure rather than an inconvenience, and `curl | sh` is a trust-the-publisher + model regardless — the checksum defends against a truncated download and a tampered asset, + not against a compromised repository, and the README says so rather than implying more. + - > + **N8 — an install needs no administrator rights.** Neither script writes outside the user's + own directories and neither invokes `sudo`. Asserted by running each as an unprivileged user + with no write access to any system path. + files: + - install.sh + - install.ps1 + - tests/installers.rs + evidence: [] + - id: US-IO-CLI-0.1.0-T17 + summary: Write the README with the keybinding table, the slash-command reference and the install + commands; fill the CHANGELOG entry and set the version to 0.1.0. + why_required: The keybinding table is required to be a table rather than prose, the install command + is part of the product's public contract, and the README states plainly what a checksum does + and does not defend against, which N7 requires. + status: todo + depends_on: + - US-IO-CLI-0.1.0-T16 + acceptance_criteria: + - > + **N7 — an installer never runs an unverified binary.** Both scripts compute the artifact's + SHA-256 and compare it against the published `SHA256SUMS` before unpacking, and **abort + non-zero on a mismatch without leaving anything on disk**. Asserted by running each + installer against a deliberately corrupted artifact and checking both the exit status and + that the target directory is untouched. This is the one place in the product where a failure + is a security failure rather than an inconvenience, and `curl | sh` is a trust-the-publisher + model regardless — the checksum defends against a truncated download and a tampered asset, + not against a compromised repository, and the README says so rather than implying more. + files: + - README.md + - CHANGELOG.md + - Cargo.toml + - Cargo.lock + - docs/config.example.toml + evidence: [] + - id: US-IO-CLI-0.1.0-T18 + summary: Build the binary in release profile and run it, then record `cargo tree --depth 1` and the + startup and streaming measurements as evidence. + why_required: O2 is a standing gate because CI builds the test profile only and a release-profile + break is invisible to it — this has broken a binary in this line of repositories before. N4 + requires the measurements to be recorded rather than asserted. + status: todo + depends_on: + - US-IO-CLI-0.1.0-T14 + - US-IO-CLI-0.1.0-T17 + acceptance_criteria: + - > + **O2 — the binary builds in release profile.** `cargo build --release` is run and the binary + executed. CI builds the test profile only, so a release-profile break is invisible to it — + this has bitten this line of repositories before and is now a standing gate. + - > + **N4 — no timing assertion anywhere in this release's tests.** Startup cost and streaming + smoothness are **recorded as measurements** in the release evidence, not gated. This line of + repositories has paid for wall-clock assertions on CI runners more than for anything else. + files: [] + evidence: [] + - id: US-IO-CLI-0.1.0-T19 + summary: "Run the live first run end to end against a real provider and record it: wizard, verified + key, written file, streamed answer, sandboxed edit, interrupt, exit, then the terminal's own + search and selection over the transcript." + why_required: F1 is the release's only criterion that a fixture cannot prove, and the recording is + both the release evidence and the README screenshot. The owner's approval of the rendered look + is taken from it. + status: todo + depends_on: + - US-IO-CLI-0.1.0-T18 + acceptance_criteria: + - > + **F1 — the live first run.** From an empty `$IO_CONFIG_HOME`, in a real repository, against + a real provider: the wizard is completed, the key is verified by a live call, the file is + written, a task is typed, the answer streams, a file is edited inside the sandbox, `Ctrl+C` + interrupts a second turn mid-flight, and `Ctrl+D` exits. Afterwards, in the same terminal, + the transcript is searched with the terminal's own search and a passage is selected with the + mouse and pasted elsewhere. Manual, recorded, and the recording is the release evidence. A + fixture cannot prove this; only a live run can. + files: [] + evidence: [] diff --git a/.ultraship/products/io-cli/product.yaml b/.ultraship/products/io-cli/product.yaml new file mode 100644 index 0000000..1a5245d --- /dev/null +++ b/.ultraship/products/io-cli/product.yaml @@ -0,0 +1,225 @@ +id: io-cli +name: IO CLI +classification: independent-product +vision: > + The terminal agent that shows you what it is allowed to do, what it is spending, and what it + refused — while it works. Every other terminal agent renders a chat log with tool cells, because a + chat log with tool cells is all its core can describe. io-harness records the permission boundary, + the spend ceiling and the agent tree as first-class facts, and io-cli is the interface that makes + them visible. +users: + - > + The initorigin author, as the first and most demanding user — running real work in real + repositories, on macOS and Linux, over SSH and inside tmux. + - > + (later) A developer who owns their own provider keys and wants an agent they can watch, stop, + and audit, rather than one they have to trust. +problems: + - > + The archived io-cli was a fork of a 1.2M-line, 88-crate agent product. Roughly sixty of those + crates — the loop, the providers, the sandbox, the policy engine, the tool set, the session store + — duplicate io-harness. Its terminal interface could not be extracted from it: the TUI crate + alone is 229k lines across 402 files and depends on 41 sibling crates. Whitelabeling a second + harness in order to attach the first one is work with no end. + - > + The category's most-complained-about decision is the alternate screen buffer. Agents that take + the full terminal destroy native scrollback, terminal search, tmux copy-mode and mouse selection, + duplicate their output on resize, and corrupt multi-byte text on copy. Users open issues titled + as regressions and vendors ship environment variables to undo it. + - > + Terminal agents built on React-for-the-terminal repaint far more than changed, and strobe while + streaming. The flicker is structural, not a bug to be fixed. + - > + Capturing the mouse to implement scrolling breaks the terminal's own text selection, and the + workarounds are terminal-specific modifier keys or a toggle that trades scrolling for copying. + - > + No terminal agent in the field is usable with a screen reader. They hide the cursor — the only + focus indicator — animate constantly, re-render unchanged content, and put metadata ahead of + content. Blind developers report the whole category as effectively unusable. + - > + A first run typically fails on a missing environment variable, or funnels the user into one + vendor's login. There is no guided path from "installed" to "working" for a developer bringing + their own key. + - > + A developer running an agent on real work cannot see the boundary it is operating under. When an + action is refused they are told that it was, not which rule refused it or which layer that rule + came from — because no other core records it. +outcomes: + - > + A developer installs io-cli, is walked through provider, credential, model, theme and permission + posture in one guided flow, and completes a real task in the same session. + - > + The conversation is in the terminal's own scrollback when the session ends. It can be scrolled, + searched, selected and copied with the terminal's own facilities, because io-cli never took them + away. + - > + At any moment the operator can see, in one line, which model is answering, which policy layer is + in force, how full the context is, what has been spent against the ceiling, and whether the run + is contained. + - > + A refusal names the act, the target, the rule and the layer. An approval stops the run in an + overlay that cannot scroll away, shows the diff, and records the decision in both the transcript + and the durable trace. + - > + The same run, unattended, emits the same events as newline-delimited JSON with no TUI code on the + path — so what CI runs is what the developer watched, not a second implementation. +requirements: + - > + Hybrid inline rendering — finished turns are committed into the terminal's real scrollback; + a short viewport at the bottom holds the composer, the status line and modal overlays. No + alternate screen buffer exists in the product, in any mode, behind any flag. + - > + Flicker discipline — synchronized output around every frame, coalesced token events, and no + full-screen clear during a normal session. + - > + Mouse capture off by default, with explicit copy affordances (system clipboard over OSC 52 so it + works across SSH) instead of pointer interception. + - > + A composer with multiline input, prompt history, slash-command palette, workspace file + completion, a raw shell escape, and large pastes converted to attachments. + - > + A single Picker overlay component, used by onboarding, model selection, theme selection, session + resume, session fork and approvals — so those surfaces are visibly one product. + - > + Guided first-run onboarding — provider, credential (with a live verification call), model, + theme with live preview, and default permission posture — writing io-harness's own configuration + file. + - > + Approval overlays that show act, target, the asking rule and, for a write, the diff; resolved by + key, recorded to history and to the trace. + - > + Policy posture cycling on one key, backed by io-harness policy layers rather than bespoke flags, + with the active layer named in the status line. + - > + Diff rendering — unified by default, word-level emphasis inside changed lines, syntax + highlighting, collapsed context, a defined narrow-terminal form, and a minimal mode. + - > + Collapsed tool cells with an expander; full output goes to the durable trace, not to the screen. + - > + A status line carrying model, policy layer, context pressure, tokens, spend against the tree + ceiling, containment state and elapsed time. + - > + Session resume and fork over the harness store, and mid-session model switching. + - > + A fleet surface — the live agent tree with each child's draw against the shared ceiling. + - > + Headless execution — a non-interactive subcommand emitting the same event stream as NDJSON, + with meaningful exit codes. + - > + A plain mode built for screen readers: no animation, no repaint of unchanged lines, the real + cursor as the focus indicator, content before metadata, and every state change announced as one + line of text. + - > + Theming as a small token set with shipped themes, terminal background detection, and `NO_COLOR` + honoured; colour is never the sole carrier of meaning. + - > + A documented keybinding table, with the Kitty keyboard protocol used when advertised and a clean + degradation over SSH to a legacy terminal. + - > + Inline image rendering where the terminal supports Kitty, iTerm2 or Sixel, with a half-block + fallback. +constraints: + - > + **io-cli contains no agent loop, no provider client, no tool implementation, no sandbox, no + policy engine and no session store.** All of it is io-harness. io-cli is a renderer of run + events, an editor of prompts, and a keyboard. If a release needs one of those, it is added to + io-harness and consumed — never reimplemented here. This is the constraint the archived fork + violated and the one that keeps this product small. + - > + No configuration parser. io-harness owns discovery and layering; io-cli's own settings live in + the `[app.io-cli]` section that io-harness deliberately does not validate. + - Rust binary named `io`; ratatui over crossterm; async on tokio. + - Apache 2.0, matching the rest of the initorigin open-source line. + - > + Runs locally on the developer's machine. No daemon, no client/server split, no second process. + A separate TUI process talking to a server is how the archived fork's architecture began. + - > + Credentials are API keys — from the environment, or from io-harness's configuration file written + `0600`. No OAuth and no subscription authentication, matching io-harness's own boundary. + - > + 80×24 is a supported terminal size, not a degraded one. Every surface has a defined narrow form. + - Stay 0.x. A breaking change is a minor bump; a 1.0.0 is not planned. +public_contract: > + A command-line interface: the `io` binary, its subcommands, its flags, its slash commands, its + keybinding table, its `[app.io-cli]` configuration section, and the NDJSON event shape emitted by + the headless subcommand. The Rust API of the crate is not a public contract — io-cli is a binary, + not a library. + + + The install path is part of the contract too: the URLs of `install.sh` and `install.ps1`, and the + naming of the release artifacts they resolve, must keep working across releases. A user's install + command is the first thing they depend on and the last thing that should churn. +deployment_context: > + Local, single-user. Installed by a `curl` shell script on macOS and Linux, or a PowerShell script + on Windows, each fetching a prebuilt binary from the GitHub Release and verifying it against the + checksums published beside it. Installed into a per-user directory, requiring no administrator + rights. Run in the developer's own terminal. No service, no server, nothing to operate. +success_measures: + - > + A developer with no configuration reaches a completed real task — an edit, verified — in one + session, without leaving the wizard to read documentation or set an environment variable. + - > + After exiting, the whole conversation is scrollable, searchable and selectable in the terminal + the session ran in, using only the terminal's own facilities. + - > + A refusal in a real run names its rule and its layer on screen, and the same refusal is in the + durable trace. + - > + The headless subcommand and the interactive session produce the same events for the same task, + with no interface code on the headless path. + - > + A session driven entirely by keyboard in plain mode, with colour disabled, completes the same + task as the default interface. +non_goals: + - > + An agent loop, a provider client, tools, a sandbox, a policy engine or a session store. That is + io-harness, and duplicating it is the specific failure this product exists to avoid. + - A daemon, a client/server split, or a second process of any kind. + - A graphical interface — that is io-studio. + - An alternate-screen or "fullscreen" rendering mode, behind any flag or environment variable. + - Mouse-driven interaction as a primary input model. + - OAuth and subscription authentication. + - A plugin UI framework, a theme marketplace, or user-scriptable widgets. + - Editor or IDE integration. + - Hosting, telemetry collection, or any network call the operator did not ask for. + - > + Publishing to crates.io, and `cargo install` as an install path. The crate name `io-cli` is held + by this owner and carries seventeen yanked versions, 0.1.0 through 0.14.0, from the archived + product — a yank does not free a version number, so publishing would force the whole roadmap to + restart above 0.14.0. Beyond that, `cargo install` requires a Rust toolchain on the user's + machine and compiles io-harness from source, which is the slowest install path available for a + terminal application. Decided 2026-08-16; see iterations/US-IO-CLI-0.1.0-I01. + - > + Homebrew, Scoop, WinGet, or any OS package manager. One script per platform is the whole + distribution surface, and each additional channel is a manifest that goes stale. +mvp_boundary: > + A developer with no prior configuration runs `io` in a repository, is walked through picking a + provider, entering and verifying a key, choosing a model and a theme, types one real task, watches + it stream and edit a file inside the sandbox, interrupts it with a keystroke, and exits — leaving + the whole conversation in the terminal's own scrollback. +assumptions: + - > + io-harness 0.60.1 already provides everything this product renders: fifty run event kinds, the + session layer with steering and forking, configuration discovery and layering, policy layers, + sandbox, providers with pricing, and the durable store. This has been read from the source, not + assumed — see `Session`, `Observer`/`RunEvent`, `Config` and `Policy` in that crate. + - > + The differentiator is that the core emits the permission boundary, the spend ceiling and the + agent tree, and no competing core does. A 2026-08-16 audit of the terminal agent field found + every other interface constrained to a chat log with tool cells for that reason. + - > + The hybrid inline renderer is achievable on the ratatui inline viewport with `insert_before`. + This is how at least one shipped Rust agent renders and it is the reason its content survives in + scrollback. **Unverified in our own code** — it is the single largest technical risk in the + first release and is the first thing to be proven. + - > + Building the wizard on the product's own Picker overlay, rather than adopting a Rust prompt + library, avoids a second raw-mode owner and a second aesthetic. The alternative — `inquire` is + the strongest candidate — remains available if the overlay proves more expensive than expected. + - > + A `0600` configuration file is an acceptable credential store for the first releases. An + OS-keychain backend is a later addition, and it is not obviously better: a keychain that fails + silently on a headless Linux box is worse than a file that never does. + - > + Each initorigin repository runs its own UltraShip workspace. io-harness, io-studio, io-eval and + the website are planned separately in their own repositories. From ca3a47095c05da7bcb73c2270d256715a7af08f4 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 16 Aug 2026 19:41:04 +0530 Subject: [PATCH 04/34] =?UTF-8?q?chore(develop):=20Create=20the=20reposito?= =?UTF-8?q?ry=20skeleton=20and=20the=20one-crate=20manife=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.1.0-T01 --- .github/CODEOWNERS | 2 + .github/ISSUE_TEMPLATE/bug_report.md | 30 + .github/ISSUE_TEMPLATE/feature_request.md | 22 + .github/PULL_REQUEST_TEMPLATE.md | 30 + .github/dependabot.yml | 29 + .gitignore | 7 + .../products/io-cli/execution/tasks.yaml | 27 +- CHANGELOG.md | 11 + CODE_OF_CONDUCT.md | 43 + CONTRIBUTING.md | 42 + Cargo.lock | 2673 +++++++++++++++++ Cargo.toml | 50 + NOTICE | 7 + README.md | 31 +- SECURITY.md | 25 + src/cli.rs | 29 + src/lib.rs | 13 + src/main.rs | 11 + 18 files changed, 3076 insertions(+), 6 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/dependabot.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 NOTICE create mode 100644 SECURITY.md create mode 100644 src/cli.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..4d9500d --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# Default reviewer for everything. +* @aakashpawar1999 diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..239561b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,30 @@ +--- +name: Bug report +about: Report a problem in IO CLI +title: "bug: " +labels: bug +--- + +## What happened + + + +## Steps to reproduce + +1. +2. +3. + +## Expected behavior + + + +## Environment + +- IO CLI version / commit: +- OS: +- Relevant provider / model (if any): + +## Additional context + + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..ec37048 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,22 @@ +--- +name: Feature request +about: Suggest an idea for IO CLI +title: "feat: " +labels: enhancement +--- + +## Problem + + + +## Proposed solution + + + +## Alternatives considered + + + +## Scope + + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..0804982 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,30 @@ +## Summary + + + +## Type + + + +feat / fix / docs / refactor / perf / test / build / ci / chore + +## Target branch + +- [ ] This PR targets **`develop`** (feature work), or +- [ ] This is a **`develop` → `main`** release PR (`release: vX.Y.Z`) + +## Changelog + +- [ ] I updated `CHANGELOG.md` under `## [Unreleased]`, **or** this change is + not user-facing. + +## Checklist + +- [ ] Code is formatted and lints clean +- [ ] Tests pass where they exist +- [ ] The change is focused on one concern +- [ ] No signature / attribution trailers in commits + +## Notes for the reviewer + + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..b5de389 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,29 @@ +version: 2 + +# Dependabot opens its pull requests against the DEFAULT branch unless told +# otherwise, and this repository's default branch is `main`. `main` takes nothing +# but a release merge from `develop`, so a bot PR into it would bypass the whole +# flow. Every ecosystem here targets `develop` explicitly. +# +# The cost of that line is real and worth stating: GitHub does not raise +# Dependabot *security* updates against a non-default target branch. Security +# advisories have to be acted on by hand here, through the same develop -> main +# path as any other change. +updates: + - package-ecosystem: cargo + directory: "/" + target-branch: develop + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + # One PR for routine patch bumps instead of a dozen. This crate is a binary, + # so a patch bump moves Cargo.lock and nothing anybody resolves against. + patch-updates: + update-types: ["patch"] + + - package-ecosystem: github-actions + directory: "/" + target-branch: develop + schedule: + interval: monthly diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9bcaa9e --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/target +node_modules/ +.DS_Store +.env + +# UltraShip: transient deploy/publish hook output +.ultraship/products/*/evidence/ diff --git a/.ultraship/products/io-cli/execution/tasks.yaml b/.ultraship/products/io-cli/execution/tasks.yaml index 05d1ba9..1d27db3 100644 --- a/.ultraship/products/io-cli/execution/tasks.yaml +++ b/.ultraship/products/io-cli/execution/tasks.yaml @@ -6,7 +6,7 @@ tasks: why_required: "Nothing else in the release can be written, built or tested without a crate. The manifest is also where N1 is decided: the dependency set that this release is allowed to have is declared here once, and every later task is measured against it." - status: todo + status: done depends_on: [] acceptance_criteria: - > @@ -27,18 +27,27 @@ tasks: - .gitignore - .github/CODEOWNERS - .github/dependabot.yml - - .github/pull_request_template.md + - .github/PULL_REQUEST_TEMPLATE.md - .github/ISSUE_TEMPLATE/bug_report.md - .github/ISSUE_TEMPLATE/feature_request.md - src/main.rs - src/cli.rs - evidence: [] + - src/lib.rs + evidence: + - cargo build exits 0 against a lockfile of 274 packages, resolved 2026-08-16. + - "The direct dependency set is exactly the eight N1 names: io-harness 0.60.1, ratatui 0.29, + crossterm 0.28, tui-textarea 0.7, clap 4, tokio 1, serde 1, toml 1, plus tempfile as a + dev-dependency. Nothing that is a runtime, a client or a store." + - The manifest carries publish = false, so the crates.io non-goal is mechanical rather than + remembered. + - "rust-version is 1.95, copied from io-harness rather than guessed: libsqlite3-sys calls the + std cfg_select! macro from its build script and neither it nor rusqlite declares the floor." - id: US-IO-CLI-0.1.0-T02 summary: "Add `ci.yml`: build, test, clippy -D warnings and fmt --check on macOS, Linux and Windows." why_required: O1 requires a green run on three platforms on the feature branch and again on the merge. Without the workflow there is no run to be green, and Windows — where this line of repositories historically finds what the other two hide — is never exercised. - status: todo + status: done depends_on: - US-IO-CLI-0.1.0-T01 acceptance_criteria: @@ -48,7 +57,15 @@ tasks: and it is where this project has historically found what the other two hid. files: - .github/workflows/ci.yml - evidence: [] + evidence: + - ci.yml runs build --all-targets, test --all-targets, test --doc on ubuntu-latest, + macos-latest and windows-latest with fail-fast off, plus a lint job (fmt --check, clippy -D + warnings) and an msrv job that reads rust-version out of Cargo.toml rather than repeating + it. + - "Every uses: is pinned to a full-length commit SHA with the version in a trailing comment; + no job id contains a dot; cancel-in-progress is false so a superseded run keeps its + diagnostics." + - The green run itself is recorded against O1 when the branch is pushed. - id: US-IO-CLI-0.1.0-T03 summary: "Build the inline renderer core and the byte-stream capture harness: `Viewport::Inline`, `insert_before` for finished content, synchronized output per frame, and a panic hook plus diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ea75889 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +All notable changes to this project are documented here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses +[Semantic Versioning](https://semver.org/spec/v2.0.0.html), staying on `0.x`. + +## [Unreleased] + +## [0.1.0] - unreleased + +First release. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..fcef8d7 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,43 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in the +**IO CLI** community a harassment-free experience for everyone, regardless +of age, body size, visible or invisible disability, ethnicity, sex +characteristics, gender identity and expression, level of experience, education, +socio-economic status, nationality, personal appearance, race, religion, or +sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes +- Focusing on what is best for the overall community + +Examples of unacceptable behavior: + +- The use of sexualized language or imagery, and sexual attention or advances +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information without explicit permission +- Other conduct which could reasonably be considered inappropriate + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the maintainers at ****. All complaints will be reviewed and +investigated promptly and fairly. Maintainers will respect the privacy and +security of the reporter of any incident. + +## Attribution + +This Code of Conduct is adapted from the +[Contributor Covenant](https://www.contributor-covenant.org), version 2.1. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..8ce2b43 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,42 @@ +# Contributing + +Thank you for taking the time. + +## The branch flow + +`develop` is the working branch and `main` is the release branch. Work happens on +a branch cut from `develop` and reaches it through a pull request. `main` takes +nothing but a release merge from `develop`, also through a pull request. Nothing +is pushed directly to either. + +## Before you open a pull request + +```sh +cargo fmt --all -- --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace +``` + +CI runs the same three on macOS, Linux and Windows. Windows is not optional: it +is where a terminal renderer's assumptions break. + +## What belongs here, and what does not + +io-cli contains no agent loop, no provider client, no tool implementation, no +sandbox, no policy engine and no session store. All of that is +[io-harness](https://github.com/initorigin/io-harness). If a change needs one of +them, it is a change to the harness and this repository consumes it. A test in +`tests/dependencies.rs` enforces this by inspecting the dependency set, so a pull +request that adds an HTTP client, a TLS stack, a database crate or a sandboxing +crate fails rather than being argued about in review. + +Two more properties are asserted structurally and are not up for negotiation in a +patch: + +- The alternate screen is never entered and the mouse is never captured. +- No test asserts on wall-clock time. Measurements are recorded, never gated. + +## Licence + +By contributing you agree that your contribution is licensed under Apache-2.0, +the licence this project carries. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..33cae18 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2673 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "darling" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88490bf1b990d87eaaa7ac8aa887f629a08e7359765b4911faf63c3763347d23" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "084e274f91c482280130e1e34e0b8d6e66776a060d7b6de7b84289ca778868c4" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.3", +] + +[[package]] +name = "darling_macro" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f5792fa0d41cd2325ce0ffa64f0a340eaebd4971a3a0c5e1ffd2cc488a355e" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashlink" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "io-cli" +version = "0.1.0" +dependencies = [ + "clap", + "crossterm", + "io-harness", + "ratatui", + "serde", + "tempfile", + "tokio", + "toml", + "tui-textarea", +] + +[[package]] +name = "io-harness" +version = "0.60.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd1c5177914c2a902f17b6b2db4ed6c0db11dd275720891d5d82289271e444cc" +dependencies = [ + "futures-util", + "libc", + "regex", + "reqwest", + "rmcp", + "rusqlite", + "serde", + "serde_json", + "tempfile", + "thiserror", + "tokio", + "toml", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libsqlite3-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "process-wrap" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e842efad9119158434d193c6682e2ebee4b44d6ad801d7b349623b3f57cdf55" +dependencies = [ + "futures", + "indexmap", + "nix", + "tokio", + "tracing", + "windows", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rmcp" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8dddc5b1924b9a59fba420166160ca2c4663a4e01803e52eda33070f56d63c8" +dependencies = [ + "base64 0.23.1", + "bytes", + "chrono", + "futures", + "http", + "pin-project-lite", + "process-wrap", + "reqwest", + "serde", + "serde_json", + "sse-stream", + "thiserror", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", +] + +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror", +] + +[[package]] +name = "rusqlite" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + +[[package]] +name = "sse-stream" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c123f296ade4ec4b8b0f6162116e6629f5146922ca5ab40ca9d3c2e73ab4761e" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tui-textarea" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae" +dependencies = [ + "crossterm", + "ratatui", + "unicode-width 0.2.0", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..cbdabb5 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,50 @@ +[package] +name = "io-cli" +version = "0.1.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 +# needs, so the floor is copied rather than guessed at. +rust-version = "1.95" +authors = ["Aakash Pawar"] +license = "Apache-2.0" +description = "A terminal agent that shows you what it is allowed to do, what it is spending, and what it refused — while it works." +repository = "https://github.com/initorigin/io-cli" +readme = "README.md" +# This crate is never published. The name holds seventeen yanked versions from an +# archived product, a yank does not free a version number, and the distribution +# decision is prebuilt binaries on the GitHub Release. `publish = false` makes an +# accidental `cargo publish` impossible rather than merely discouraged. +publish = false + +# The binary is `io`. The crate is `io-cli` because the bare names are taken on +# crates.io by other people; the two never have to agree. +[[bin]] +name = "io" +path = "src/main.rs" + +# The library half exists so the integration tests under `tests/` can reach the +# renderer, the composer and the picker. A binary-only crate cannot be linked by +# an integration test, and F4, F5, F8 and N3 are all integration tests. +[lib] +name = "io_cli" +path = "src/lib.rs" + +[dependencies] +# The agent loop, the providers, the tools, the sandbox, the policy engine and the +# session store — all of it. This crate adds none of them; see the F7 test. +io-harness = "0.60.1" +# ratatui stays at 0.29 and crossterm at 0.28 because `tui-textarea` 0.7 — the +# composer — is built against exactly that pair. The 0.30 line has forks of the +# textarea widget rather than a release of it, and taking one would mean depending +# on somebody's fork of the one widget the composer is made of. +ratatui = "0.29" +crossterm = "0.28" +tui-textarea = "0.7" +clap = { version = "4", features = ["derive"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] } +serde = { version = "1", features = ["derive"] } +toml = "1" + +[dev-dependencies] +tempfile = "3" diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..dfa513d --- /dev/null +++ b/NOTICE @@ -0,0 +1,7 @@ +IO CLI +Copyright 2026 Aakash Pawar (InitOrigin) + +This product includes software developed as part of the initorigin project +(https://github.com/initorigin). + +Licensed under the Apache License, Version 2.0. See the LICENSE file for terms. diff --git a/README.md b/README.md index 9a1e99f..b9d314a 100644 --- a/README.md +++ b/README.md @@ -1 +1,30 @@ -# io-cli \ No newline at end of file +# IO CLI + +A terminal agent that shows you what it is allowed to do, what it is spending, +and what it refused — while it works. + +`io` is an interface. The agent loop, the providers, the tools, the sandbox, the +permission boundary and the session store are all +[io-harness](https://github.com/initorigin/io-harness), and none of them are +reimplemented here. + +## It never takes your terminal + +`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; only a few lines at the bottom +hold the composer and the status line, and only those repaint. + +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 +which is implemented here. It works because it was never taken away. + +## Install + +See the release notes. Distribution is prebuilt binaries on the GitHub Release, +installed by one script per platform; there is no package registry and no +`cargo install`. + +## Licence + +Apache-2.0. Copyright 2026 Aakash Pawar (InitOrigin). See `LICENSE` and `NOTICE`. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..b7a9459 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,25 @@ +# Security Policy + +## Supported versions + +**IO CLI** is pre-release. Only the latest `main` and `develop` receive +security fixes until a stable 1.0.0 is published. + +## Reporting a vulnerability + +**Do not open a public issue for security problems.** + +Email **** with: + +- A description of the issue and its impact +- Steps to reproduce or a proof of concept +- Affected version, commit, or branch + +You will get an acknowledgement, and we will work with you on a fix and +coordinated disclosure. Please give us reasonable time to release a fix before +any public disclosure. + +## Scope + +This policy covers the code in this repository. Vulnerabilities in dependencies +should be reported upstream; tell us too if this project is affected. diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..0f616d3 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,29 @@ +//! The command line. Five flags and one subcommand, because everything else is +//! a slash command inside the session. + +use std::path::PathBuf; + +use clap::{Parser, Subcommand}; + +/// `io` — a terminal agent over io-harness. +#[derive(Debug, Parser)] +#[command(name = "io", version, about, long_about = None)] +pub struct Cli { + /// The workspace to work in. Defaults to the current directory. + #[arg(short = 'C', long, value_name = "DIR")] + pub dir: Option, + + /// The model to use for this session, overriding the configured one. + #[arg(short, long, value_name = "MODEL")] + pub model: Option, + + #[command(subcommand)] + pub command: Option, +} + +#[derive(Debug, Subcommand)] +pub enum Command { + /// Run the first-run wizard again: provider, credential, model, theme and + /// permission posture, written to io-harness's configuration file. + Setup, +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..c6799dd --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,13 @@ +//! io-cli — a terminal interface over [io-harness]. +//! +//! This crate renders run events, edits a prompt, and reads a keyboard. It +//! contains no agent loop, no provider client, no tool implementation, no +//! sandbox, no policy engine and no session store; all of that is the harness, +//! and `tests/dependencies.rs` asserts it rather than trusting it. +//! +//! The library half exists so the integration tests can reach the renderer, the +//! composer and the picker. `io` itself is the binary in `src/main.rs`. +//! +//! [io-harness]: https://docs.rs/io-harness + +pub mod cli; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..7f36bec --- /dev/null +++ b/src/main.rs @@ -0,0 +1,11 @@ +use clap::Parser; + +use io_cli::cli::{Cli, Command}; + +fn main() { + let cli = Cli::parse(); + match cli.command { + Some(Command::Setup) => todo!("the wizard arrives with US-IO-CLI-0.1.0-T11"), + None => todo!("the session arrives with US-IO-CLI-0.1.0-T10"), + } +} From a92dcf3b9e01cfffb5c2af8342e15ad664b6f06e Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 16 Aug 2026 19:41:04 +0530 Subject: [PATCH 05/34] =?UTF-8?q?chore(develop):=20Add=20`ci.yml`:=20build?= =?UTF-8?q?,=20test,=20clippy=20-D=20warnings=20and=20fmt=20-=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.1.0-T02 --- .github/workflows/ci.yml | 102 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3b2ed82 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,102 @@ +name: CI + +# O1: green on macOS, Linux and Windows, on the feature branch and again on the +# merge to `develop`. Windows is not optional here — it is where a terminal +# renderer's assumptions about key reporting, resize events and ANSI support +# break, and it is where this line of repositories has historically found what +# the other two hid. + +# `feat/**` is deliberately not a push trigger: every feature branch reaches +# `develop` through a pull request, and the pull request already runs this +# workflow. Listing it here would run the whole matrix twice per commit, in two +# different concurrency groups, so neither run cancels the other. +on: + push: + branches: [main, develop] + pull_request: + branches: [develop, main] + workflow_dispatch: + +# Every `uses:` is pinned to a full-length commit SHA with its human-readable +# version in a trailing comment. A tag is a pointer its owner can move; a SHA is +# not, so what ran yesterday is what runs today. Dependabot bumps these and +# rewrites the trailing comment. +# +# Least privilege: every job only reads the checkout. Nothing publishes, nothing +# comments, no job takes a secret. +permissions: + contents: read + +# Deliberately NOT `cancel-in-progress: true`. A superseded run is often the one +# carrying the diagnostics for the failure that prompted the next push, and this +# project has thrown those away before. Runner minutes are cheaper than a +# reproduction. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + # Full debuginfo is the most expensive part of a debug build and on Windows it + # dominates link time. `line-tables-only` keeps what a CI failure is actually + # read from — a panic carries its own file and line, and a backtrace keeps + # frame file/line. Set for both profiles because `test` inherits `dev` in the + # manifest but not through this variable. + CARGO_PROFILE_DEV_DEBUG: line-tables-only + CARGO_PROFILE_TEST_DEBUG: line-tables-only + +jobs: + # No dot in a job id, here or anywhere: GitHub accepts it and then `needs:` + # cannot refer to it. + build-and-test: + name: build and test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + # Off on purpose. One platform failing must not hide the state of the other + # two, which is the whole reason for running three. + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + with: + toolchain: stable + # io-harness pulls a bundled rusqlite, which is the expensive part of a + # cold build here. + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - run: cargo build --all-targets + - run: cargo test --all-targets + # `cargo test --all-targets` does not run doctests. This crate has few, and + # they are cheap, but a doctest that stops compiling is a broken public + # example and CI should say so. + - run: cargo test --doc + + lint: + name: fmt and clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + with: + toolchain: stable + components: rustfmt, clippy + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - run: cargo fmt --all -- --check + - run: cargo clippy --all-targets -- -D warnings + + msrv: + name: msrv + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # The floor is read from `rust-version` in Cargo.toml rather than written + # here, so the two can never drift apart. + - id: msrv + run: echo "version=$(cargo metadata --no-deps --format-version 1 | python3 -c 'import json,sys; print(json.load(sys.stdin)["packages"][0]["rust_version"])')" >> "$GITHUB_OUTPUT" + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + with: + toolchain: ${{ steps.msrv.outputs.version }} + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - run: cargo build --all-targets From 41ea2a60424ca449befe4930f1a0559e7663df76 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 16 Aug 2026 19:41:18 +0530 Subject: [PATCH 06/34] chore: track the UltraShip workspace configuration --- .ultraship/products/.gitkeep | 0 .ultraship/ultraship.yaml | 20 ++++++++++++++++++++ .ultraship/workspace.yaml | 12 ++++++++++++ 3 files changed, 32 insertions(+) create mode 100644 .ultraship/products/.gitkeep create mode 100644 .ultraship/ultraship.yaml create mode 100644 .ultraship/workspace.yaml diff --git a/.ultraship/products/.gitkeep b/.ultraship/products/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.ultraship/ultraship.yaml b/.ultraship/ultraship.yaml new file mode 100644 index 0000000..009b525 --- /dev/null +++ b/.ultraship/ultraship.yaml @@ -0,0 +1,20 @@ +schema_version: 1 +framework_version: 2.2.0 +commit_policy: checkpoint +resource_profile: + provider_tool: null + billing_mode: null + model_policy: auto + available: + time_minutes: null + token_budget: null + monetary_budget_usd: null + human_review_minutes: null + preferences: + minimize_cost: true + allow_additional_spend: false + allow_model_routing: true + allow_parallel_agents: false + telemetry: + source: unknown + confidence: low diff --git a/.ultraship/workspace.yaml b/.ultraship/workspace.yaml new file mode 100644 index 0000000..9693abf --- /dev/null +++ b/.ultraship/workspace.yaml @@ -0,0 +1,12 @@ +id: io-cli +name: io-cli +vision: > + The terminal agent that shows you what it is allowed to do, what it is spending, and what it + refused — while it works. A renderer over io-harness, containing no agent loop of its own. +constraints: + - > + io-cli contains no agent loop, no provider client, no tool implementation, no sandbox, no policy + engine and no session store. All of it is io-harness. + - Branch flow is feat/ to develop to main, and nothing reaches develop or main except by + pull request. +active_product: io-cli From c46f3de460237a025aec5a1d9b549ac1961fc842 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 16 Aug 2026 19:48:54 +0530 Subject: [PATCH 07/34] =?UTF-8?q?chore(develop):=20Build=20the=20inline=20?= =?UTF-8?q?renderer=20core=20and=20the=20byte-stream=20capt=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.1.0-T03 --- .../products/io-cli/execution/tasks.yaml | 25 +- Cargo.toml | 7 +- src/lib.rs | 1 + src/term.rs | 261 ++++++++++++++++++ tests/restore.rs | 81 ++++++ tests/scrollback.rs | 71 +++++ tests/structure.rs | 106 +++++++ tests/support/mod.rs | 197 +++++++++++++ 8 files changed, 746 insertions(+), 3 deletions(-) create mode 100644 src/term.rs create mode 100644 tests/restore.rs create mode 100644 tests/scrollback.rs create mode 100644 tests/structure.rs create mode 100644 tests/support/mod.rs diff --git a/.ultraship/products/io-cli/execution/tasks.yaml b/.ultraship/products/io-cli/execution/tasks.yaml index 1d27db3..f4ebd9a 100644 --- a/.ultraship/products/io-cli/execution/tasks.yaml +++ b/.ultraship/products/io-cli/execution/tasks.yaml @@ -73,7 +73,7 @@ tasks: why_required: This is the release. Every other surface renders through it, and the contract names it the single largest technical risk with the instruction to build it before anything else. The capture harness is built with it because F5 and N3 cannot be asserted without one. - status: todo + status: done depends_on: - US-IO-CLI-0.1.0-T01 acceptance_criteria: @@ -101,11 +101,32 @@ tasks: files: - src/term.rs - src/lib.rs + - Cargo.toml + - Cargo.lock - tests/support/mod.rs - tests/structure.rs - tests/scrollback.rs - tests/restore.rs - evidence: [] + evidence: + - 8 tests pass across tests/structure.rs, tests/scrollback.rs and tests/restore.rs; cargo fmt + --all -- --check and cargo clippy --all-targets -- -D warnings both exit 0. + - "The harness is a real CrosstermBackend writing into a Recorder, wrapped by a Fixed backend + that answers only size() and get_cursor_position() from test-chosen values. TestBackend was + rejected on purpose: it emits no escape sequences, so F5 would pass against it because + nothing appears rather than because nothing forbidden appears." + - "SABOTAGE A — queue EnterAlternateScreen in draw(): both F5 tests fail, the other six pass." + - "SABOTAGE B — queue Clear(ClearType::All) in draw(): only + n3_never_clears_the_whole_screen_during_a_session fails." + - "SABOTAGE C — commit() renders through draw() into the viewport instead of insert_before: + both F4 tests fail, F5 and N3 still pass." + - "SABOTAGE D — call the previous panic hook before the restore: only + n2_a_panic_runs_the_restore_before_the_previous_hook fails." + - "SABOTAGE E — drop the idempotence guard on restore(): only + dropping_the_screen_restores_it_once fails." + - insert_before is given a MEASURED height from Paragraph::line_count(width), not an estimate. + ratatui's unstable-rendered-line-info feature is enabled for that one method; an estimated + height truncates committed content, which on this renderer loses it from the transcript + permanently. - id: US-IO-CLI-0.1.0-T04 summary: Handle resize without duplicating committed scrollback, and define every surface at 80x24. why_required: F9 makes 80x24 a supported size rather than a degraded one, and resize is where a diff --git a/Cargo.toml b/Cargo.toml index cbdabb5..35e1f0b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,7 +38,12 @@ io-harness = "0.60.1" # composer — is built against exactly that pair. The 0.30 line has forks of the # textarea widget rather than a release of it, and taking one would mean depending # on somebody's fork of the one widget the composer is made of. -ratatui = "0.29" +# `unstable-rendered-line-info` is enabled for one method: `Paragraph::line_count`, +# which answers how many rows a wrapped paragraph will occupy. `insert_before` +# takes that height as an argument, and a caller that estimates it truncates +# exactly the content this product exists to keep. It is a feature of a dependency +# already taken, not a dependency. +ratatui = { version = "0.29", features = ["unstable-rendered-line-info"] } crossterm = "0.28" tui-textarea = "0.7" clap = { version = "4", features = ["derive"] } diff --git a/src/lib.rs b/src/lib.rs index c6799dd..d87e99f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,3 +11,4 @@ //! [io-harness]: https://docs.rs/io-harness pub mod cli; +pub mod term; diff --git a/src/term.rs b/src/term.rs new file mode 100644 index 0000000..f6f0069 --- /dev/null +++ b/src/term.rs @@ -0,0 +1,261 @@ +//! The renderer: an inline viewport with the transcript in the terminal's own +//! scrollback. +//! +//! There is one rendering model in this product and this module is all of it. +//! Finished content is handed to [`Screen::commit`], which pushes it *above* the +//! viewport with `Terminal::insert_before`, so it lands in the terminal's real +//! scrollback and stays there after the process exits. The viewport itself is a +//! few lines at the bottom holding the composer and the status line, and it is +//! the only region that repaints. +//! +//! Three properties are structural rather than conventional, and each has a test +//! that fails if it is lost: +//! +//! - **No alternate screen and no mouse capture.** There is no code path here +//! that enters one or requests the other, in any mode, behind any flag, which +//! is why the terminal's own search, selection and copy-mode keep working. +//! `tests/structure.rs` asserts it over the byte stream. +//! - **No full-screen clear.** A clear is a bug, not a redraw strategy; +//! `Terminal::clear` on an inline viewport erases from the cursor down, which +//! is the viewport and nothing above it. +//! - **The terminal is always given back.** A panic hook restores it before the +//! panic message is printed, and [`Drop`] restores it on every other path. + +use std::io::{self, Write}; +use std::sync::{Mutex, OnceLock}; + +use crossterm::terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate}; +use ratatui::backend::{Backend, CrosstermBackend}; +use ratatui::layout::Rect; +use ratatui::text::{Line, Text}; +use ratatui::widgets::{Paragraph, Widget, Wrap}; +use ratatui::{Frame, Terminal, TerminalOptions, Viewport}; + +/// Lines the live viewport occupies: a blank separator, the composer, and the +/// status line. +/// +/// Deliberately small. Every line the viewport takes is a line the transcript +/// does not get, and everything that can be committed is committed. +pub const VIEWPORT_HEIGHT: u16 = 3; + +/// What to run when the terminal has to be handed back. +type Restore = Box; + +/// The renderer. +/// +/// Generic over the backend so the tests can drive a real `CrosstermBackend` +/// writing into a recorder rather than into a tty. The bound includes [`Write`] +/// because the synchronized-output sequences are written to the backend directly: +/// they wrap the frame ratatui draws, so they cannot be a widget. +pub struct Screen { + terminal: Terminal, + /// What the last frame drew, kept because ratatui's rendered buffer is not + /// reachable once `draw` has returned. See [`Screen::viewport_text`]. + viewport: String, + restore: Option, + restored: bool, +} + +impl Screen> { + /// Take the terminal: raw mode on, bracketed paste on, and a panic hook that + /// gives it back before anything is printed. + /// + /// Raw mode is the only thing taken. The alternate screen is not entered and + /// the mouse is not captured, so the scrollback, the terminal's search and its + /// selection are all still the terminal's own. + pub fn attach() -> io::Result { + crossterm::terminal::enable_raw_mode()?; + let mut out = io::stdout(); + crossterm::execute!(out, crossterm::event::EnableBracketedPaste)?; + + let terminal = Terminal::with_options( + CrosstermBackend::new(out), + TerminalOptions { + viewport: Viewport::Inline(VIEWPORT_HEIGHT), + }, + )?; + + install_panic_hook(restore_terminal); + + Ok(Self { + terminal, + viewport: String::new(), + restore: Some(Box::new(restore_terminal)), + restored: false, + }) + } +} + +impl Screen { + /// Wrap a terminal that has already been built. The tests' way in. + pub fn from_terminal(terminal: Terminal) -> Self { + Self { + terminal, + viewport: String::new(), + restore: None, + restored: false, + } + } + + /// Push finished content into the terminal's scrollback, above the viewport. + /// + /// The height is measured rather than estimated. `insert_before` inserts + /// exactly the number of rows it is given, so a caller that guesses low + /// truncates the content — which on this renderer means losing part of the + /// transcript permanently, since the viewport is not where it lives. + pub fn commit(&mut self, lines: &[Line<'_>]) -> io::Result<()> { + if lines.is_empty() { + return Ok(()); + } + + let width = self.terminal.current_buffer_mut().area.width.max(1); + let text = Text::from(lines.to_vec()); + let paragraph = Paragraph::new(text).wrap(Wrap { trim: false }); + let height = u16::try_from(paragraph.line_count(width)).unwrap_or(u16::MAX); + if height == 0 { + return Ok(()); + } + + self.terminal + .insert_before(height, |buf| paragraph.render(buf.area, buf)) + } + + /// Draw one viewport frame, wrapped in synchronized output. + /// + /// The wrapping is what stops a streaming turn from strobing: the terminal is + /// told to hold the display until the frame is complete, so a partially drawn + /// composer is never presented. ratatui already diffs the buffer, which + /// decides *what* is written; this decides *when* it becomes visible. + pub fn draw(&mut self, render: F) -> io::Result<()> { + crossterm::queue!(self.terminal.backend_mut(), BeginSynchronizedUpdate)?; + let mut drawn = String::new(); + self.terminal.draw(|frame| { + render(frame); + // Captured here because ratatui swaps its two buffers as the frame + // ends: after `draw` returns, the current buffer is the cleared one + // and what was just rendered is in the other, which has no public + // accessor. Inside the closure is the only place it can be read. + drawn = buffer_text(frame.buffer_mut()); + })?; + self.viewport = drawn; + crossterm::queue!(self.terminal.backend_mut(), EndSynchronizedUpdate)?; + // `Backend` and `Write` both name a `flush`; the write one is meant here, + // because the queued escape sequences are sitting in the writer. + Write::flush(self.terminal.backend_mut()) + } + + /// Tell the renderer the terminal is a different size now. + /// + /// Recomputing the inline viewport is the whole job: the committed lines above + /// it belong to the terminal and must not be redrawn, which is what produces + /// the duplicated history a full-screen renderer shows on resize. + pub fn resize(&mut self, width: u16, height: u16) -> io::Result<()> { + self.terminal.resize(Rect::new(0, 0, width, height)) + } + + /// What the last frame put in the viewport, one row per line with trailing + /// spaces trimmed. Empty until the first [`Screen::draw`]. + pub fn viewport_text(&self) -> &str { + &self.viewport + } + + /// Replace what runs when the terminal is handed back. Used by the tests, and + /// by nothing else. + pub fn on_restore(&mut self, restore: F) { + self.restore = Some(Box::new(restore)); + } + + /// Hand the terminal back: cooked mode, cursor shown, viewport left where it + /// is so the transcript above it survives. + /// + /// Idempotent. `Drop` calls it, and so does an orderly exit, and restoring a + /// terminal twice would show a cursor over whatever owns it by then. + pub fn restore(&mut self) { + if self.restored { + return; + } + self.restored = true; + if let Some(restore) = &self.restore { + restore(); + } + } + + /// The terminal underneath, for the few callers that need to ask it something. + pub fn terminal_mut(&mut self) -> &mut Terminal { + &mut self.terminal + } +} + +impl Drop for Screen { + fn drop(&mut self) { + self.restore(); + } +} + +/// A buffer's contents as text: one row per line, trailing spaces trimmed. +fn buffer_text(buffer: &ratatui::buffer::Buffer) -> String { + let area = buffer.area; + (area.top()..area.bottom()) + .map(|y| { + let row: String = (area.left()..area.right()) + .map(|x| buffer[(x, y)].symbol()) + .collect(); + row.trim_end().to_string() + }) + .collect::>() + .join("\n") +} + +/// Put the real terminal back: bracketed paste off, cursor shown, cooked mode. +/// +/// Deliberately ignores its errors. It runs on the panic path, where returning a +/// `Result` nobody can act on would mean the terminal stays raw because restoring +/// it failed halfway. +pub fn restore_terminal() { + let mut out = io::stdout(); + let _ = crossterm::execute!( + out, + crossterm::event::DisableBracketedPaste, + crossterm::cursor::Show + ); + let _ = crossterm::terminal::disable_raw_mode(); + let _ = out.flush(); +} + +/// The hook installed by [`Screen::attach`], kept so a second `attach` in one +/// process replaces the closure instead of stacking another hook on top of the +/// one that is already chained. +static HOOK: OnceLock>> = OnceLock::new(); + +/// Run `restore` on a panic, *before* the previous hook prints anything. +/// +/// The order is the whole point. A panic message printed into a raw-mode terminal +/// arrives without carriage returns, staircased down the screen, and leaves the +/// user in a shell that no longer echoes — which reads as the tool having crashed +/// the terminal rather than having crashed. +pub fn install_panic_hook(restore: F) { + let slot = HOOK.get_or_init(|| Mutex::new(None)); + let first = { + let mut guard = slot.lock().expect("panic hook slot poisoned"); + let first = guard.is_none(); + *guard = Some(Box::new(restore)); + first + }; + + // Chain onto the previous hook exactly once. Every later call swaps the + // closure the chained hook reads, so repeated `attach` calls in one process + // do not nest hooks. + if first { + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + if let Some(slot) = HOOK.get() { + if let Ok(guard) = slot.lock() { + if let Some(restore) = guard.as_ref() { + restore(); + } + } + } + previous(info); + })); + } +} diff --git a/tests/restore.rs b/tests/restore.rs new file mode 100644 index 0000000..4a6f59a --- /dev/null +++ b/tests/restore.rs @@ -0,0 +1,81 @@ +//! N2 — the terminal is always restored. +//! +//! A panic inside a turn must put the terminal back into cooked mode with the +//! cursor visible *before* anything is printed, or the panic message itself lands +//! in raw mode and the user is left with a terminal that no longer echoes. +//! +//! The signal case is a manual check; it cannot be asserted here because the test +//! process is the one that would receive the signal. + +mod support; + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +use io_cli::term; + +#[test] +fn n2_a_panic_runs_the_restore_before_the_previous_hook() { + // Two counters rather than one flag, so the test can assert the *order*: the + // restore has to have happened by the time the reporting hook runs. + let order = Arc::new(AtomicUsize::new(0)); + let restored_at = Arc::new(AtomicUsize::new(0)); + let reported_at = Arc::new(AtomicUsize::new(0)); + + { + let order = Arc::clone(&order); + let reported_at = Arc::clone(&reported_at); + std::panic::set_hook(Box::new(move |_| { + reported_at.store(order.fetch_add(1, Ordering::SeqCst) + 1, Ordering::SeqCst); + })); + } + + { + let order = Arc::clone(&order); + let restored_at = Arc::clone(&restored_at); + term::install_panic_hook(move || { + restored_at.store(order.fetch_add(1, Ordering::SeqCst) + 1, Ordering::SeqCst); + }); + } + + let panicked = std::panic::catch_unwind(|| panic!("a turn went wrong")); + assert!(panicked.is_err(), "the test needs the panic to happen"); + + let restored = restored_at.load(Ordering::SeqCst); + let reported = reported_at.load(Ordering::SeqCst); + assert!(restored > 0, "the restore never ran on a panic"); + assert!( + reported > 0, + "the previous hook was swallowed rather than chained" + ); + assert!( + restored < reported, + "the terminal was restored at step {restored} but the panic was reported at step \ + {reported}; the message would have been printed into a raw-mode terminal", + ); + + let _ = std::panic::take_hook(); +} + +#[test] +fn dropping_the_screen_restores_it_once() { + let calls = Arc::new(AtomicUsize::new(0)); + let (mut screen, _recorder) = support::screen(100, 30); + { + let calls = Arc::clone(&calls); + screen.on_restore(move || { + calls.fetch_add(1, Ordering::SeqCst); + }); + } + + screen.restore(); + // A `Drop` that restores a second time would re-enable the cursor over a + // terminal some other process may already own. + drop(screen); + + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "restore ran more than once" + ); +} diff --git a/tests/scrollback.rs b/tests/scrollback.rs new file mode 100644 index 0000000..e1f2071 --- /dev/null +++ b/tests/scrollback.rs @@ -0,0 +1,71 @@ +//! F4 — finished content reaches scrollback, not the viewport. +//! +//! This is the product's whole claim, so it is asserted from both sides: the text +//! of every finished message appears in the byte stream, and the viewport buffer +//! at rest holds only the composer and the status line. + +mod support; + +use ratatui::text::Line; +use ratatui::widgets::Paragraph; + +#[test] +fn f4_finished_messages_reach_the_terminal_and_not_the_viewport() { + let (mut screen, recorder) = support::screen(100, 30); + + let messages = ["the first finished reply", "the second", "and the third"]; + for message in messages { + screen + .commit(&[Line::from(message), Line::from("")]) + .expect("commit"); + // A frame after every commit, exactly as a session draws. + screen + .draw(|frame| { + let area = frame.area(); + frame.render_widget(Paragraph::new("> "), area); + }) + .expect("frame"); + } + + let text = recorder.text(); + for message in messages { + assert!( + text.contains(message), + "{message:?} never reached the terminal, so it is not in the scrollback either", + ); + } + + // At rest the viewport holds what the composer and status line drew, and no + // part of any committed message. This is the half that fails when a finished + // message is rendered into the viewport instead of being committed. + let viewport = screen.viewport_text(); + for message in messages { + assert!( + !viewport.contains(message), + "{message:?} is still in the live viewport; it was rendered rather than committed", + ); + } + assert!( + viewport.contains('>'), + "the viewport should hold the composer, but it holds {viewport:?}", + ); +} + +#[test] +fn f4_committed_content_survives_the_frames_drawn_after_it() { + // The ordering half: a commit followed by more frames must not have its lines + // overwritten by the viewport that renders below them. + let (mut screen, recorder) = support::screen(100, 30); + + screen + .commit(&[Line::from("committed before ten frames")]) + .expect("commit"); + for _ in 0..10 { + screen.draw(|_| {}).expect("frame"); + } + + assert!( + recorder.contains("committed before ten frames"), + "the committed line was never written", + ); +} diff --git a/tests/structure.rs b/tests/structure.rs new file mode 100644 index 0000000..a89b1d4 --- /dev/null +++ b/tests/structure.rs @@ -0,0 +1,106 @@ +//! F5 — no alternate screen and no mouse capture, ever. +//! N3 — no full-screen clear during a session. +//! +//! Both are assertions over the bytes io-cli writes to the terminal, and both are +//! written so that a later release cannot reintroduce fullscreen or a clear-based +//! redraw without turning a named test red. + +mod support; + +use ratatui::text::Line; +use support::FORBIDDEN; + +/// A session long enough to have written everything a session writes: a splash +/// into scrollback, a viewport frame, several committed messages, a resize, and +/// more frames after it. +fn scripted_session(width: u16, height: u16) -> support::Recorder { + let (mut screen, recorder) = support::screen(width, height); + + screen + .commit(&[Line::from("io-cli"), Line::from("")]) + .expect("splash"); + screen.draw(|_| {}).expect("first frame"); + + for turn in 0..5 { + screen + .commit(&[ + Line::from(format!("> prompt {turn}")), + Line::from(format!("assistant reply {turn}")), + Line::from(""), + ]) + .expect("commit"); + screen.draw(|_| {}).expect("frame"); + } + + screen.resize(width, height + 4).expect("resize"); + screen.draw(|_| {}).expect("frame after resize"); + screen + .commit(&[Line::from("after the resize")]) + .expect("commit after resize"); + + drop(screen); + recorder +} + +#[test] +fn f5_never_enters_the_alternate_screen_or_captures_the_mouse() { + let recorder = scripted_session(100, 30); + let text = recorder.text(); + + for (name, sequence) in FORBIDDEN { + assert!( + !text.contains(sequence), + "the byte stream contains {name} ({}), which this product has no code path for", + sequence.escape_debug(), + ); + } +} + +#[test] +fn f5_holds_at_eighty_columns_too() { + // The narrow path takes a different branch through `insert_before`, which + // loops when the content is taller than the screen. F5 has to hold there too. + let recorder = scripted_session(80, 24); + let text = recorder.text(); + + for (name, sequence) in FORBIDDEN { + assert!( + !text.contains(sequence), + "the byte stream at 80x24 contains {name} ({})", + sequence.escape_debug(), + ); + } +} + +#[test] +fn n3_never_clears_the_whole_screen_during_a_session() { + let recorder = scripted_session(100, 30); + let text = recorder.text(); + + // `ESC [ 2 J` erases the display, and `ESC [ 3 J` erases the scrollback — + // which on this renderer is where the transcript lives, so it is worse than + // the one the criterion names. + assert!( + !text.contains("\x1b[2J"), + "the byte stream contains a full-screen clear", + ); + assert!( + !text.contains("\x1b[3J"), + "the byte stream contains a scrollback erase, which would destroy the transcript", + ); +} + +#[test] +fn every_frame_is_wrapped_in_synchronized_output() { + let (mut screen, recorder) = support::screen(100, 30); + screen.draw(|_| {}).expect("frame"); + screen.draw(|_| {}).expect("frame"); + drop(screen); + + let text = recorder.text(); + let begins = text.matches("\x1b[?2026h").count(); + let ends = text.matches("\x1b[?2026l").count(); + + assert_eq!(begins, 2, "one begin-synchronized-update per frame"); + assert_eq!(ends, begins, "every begin is closed by an end"); +} diff --git a/tests/support/mod.rs b/tests/support/mod.rs new file mode 100644 index 0000000..d230338 --- /dev/null +++ b/tests/support/mod.rs @@ -0,0 +1,197 @@ +//! The two test harnesses this release is built on. +//! +//! [`Recorder`] captures every byte written to the terminal, which is what F5 and +//! N3 assert over — both are properties of the escape sequences that leave the +//! process, and neither can be seen in a rendered buffer. +//! +//! [`Fixed`] is the reason the recorder works at all. `Terminal::with_options` +//! asks the backend for the terminal size and the cursor position before it can +//! place an inline viewport, and `CrosstermBackend` answers both by talking to a +//! real tty — a size query on stdout and, for the cursor, a DSR request whose +//! reply it reads back from stdin. Neither exists under `cargo test`. `Fixed` +//! answers those two questions from values the test chose and delegates +//! everything else to a real `CrosstermBackend`, so what lands in the recorder is +//! the byte stream crossterm actually produces rather than a simulation of it. +//! +//! This is deliberately not `TestBackend`. `TestBackend` renders into a cell grid +//! and emits no escape sequences at all, so F5 — "these sequences never appear" — +//! would pass against it for the wrong reason: nothing appears. + +#![allow(dead_code)] + +use std::io::{self, Write}; +use std::sync::{Arc, Mutex}; + +use ratatui::backend::{Backend, ClearType, CrosstermBackend, WindowSize}; +use ratatui::layout::{Position, Size}; +use ratatui::Terminal; + +/// A writer that keeps everything written to it. +#[derive(Clone, Default)] +pub struct Recorder { + bytes: Arc>>, +} + +impl Recorder { + pub fn new() -> Self { + Self::default() + } + + /// Everything written so far. + pub fn bytes(&self) -> Vec { + self.bytes.lock().expect("recorder poisoned").clone() + } + + /// Everything written so far, lossily as text. Good enough for asserting that + /// a message reached the terminal; use [`Recorder::bytes`] for sequences. + pub fn text(&self) -> String { + String::from_utf8_lossy(&self.bytes()).into_owned() + } + + /// Whether the byte stream contains `needle` anywhere. + pub fn contains(&self, needle: &str) -> bool { + self.text().contains(needle) + } +} + +impl Write for Recorder { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.bytes + .lock() + .expect("recorder poisoned") + .extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +/// A `CrosstermBackend` whose size and cursor position come from the test rather +/// than from a tty. +pub struct Fixed { + inner: CrosstermBackend, + size: Size, + cursor: Position, +} + +impl Fixed { + /// A backend of `width` by `height` with the cursor on the last row, which is + /// where a shell leaves it when the binary starts. + pub fn new(recorder: Recorder, width: u16, height: u16) -> Self { + Self { + inner: CrosstermBackend::new(recorder), + size: Size { width, height }, + cursor: Position { + x: 0, + y: height.saturating_sub(1), + }, + } + } +} + +impl Write for Fixed { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.inner.write(buf) + } + + fn flush(&mut self) -> io::Result<()> { + // `CrosstermBackend` implements both `Write` and `Backend`, and both name a + // `flush`. Disambiguated rather than left to inference, which cannot + // resolve it. + Write::flush(&mut self.inner) + } +} + +impl Backend for Fixed { + fn draw<'a, I>(&mut self, content: I) -> io::Result<()> + where + I: Iterator, + { + self.inner.draw(content) + } + + fn append_lines(&mut self, n: u16) -> io::Result<()> { + self.inner.append_lines(n) + } + + fn hide_cursor(&mut self) -> io::Result<()> { + self.inner.hide_cursor() + } + + fn show_cursor(&mut self) -> io::Result<()> { + self.inner.show_cursor() + } + + fn get_cursor_position(&mut self) -> io::Result { + Ok(self.cursor) + } + + fn set_cursor_position>(&mut self, position: P) -> io::Result<()> { + // Recorded as well as forwarded: ratatui asks for the position back when + // it recomputes an inline viewport after a resize, and a backend that + // forgets where it was put reports the shell's original cursor forever. + self.cursor = position.into(); + self.inner.set_cursor_position(self.cursor) + } + + fn clear(&mut self) -> io::Result<()> { + self.inner.clear() + } + + fn clear_region(&mut self, clear_type: ClearType) -> io::Result<()> { + self.inner.clear_region(clear_type) + } + + fn size(&self) -> io::Result { + Ok(self.size) + } + + fn window_size(&mut self) -> io::Result { + Ok(WindowSize { + columns_rows: self.size, + pixels: Size { + width: 0, + height: 0, + }, + }) + } + + fn flush(&mut self) -> io::Result<()> { + Backend::flush(&mut self.inner) + } +} + +/// A screen of `width` by `height` writing into a fresh recorder, plus the +/// recorder itself. +pub fn screen(width: u16, height: u16) -> (io_cli::term::Screen, Recorder) { + screen_of(width, height, io_cli::term::VIEWPORT_HEIGHT) +} + +/// The same, with the viewport height chosen by the caller. +pub fn screen_of( + width: u16, + height: u16, + viewport: u16, +) -> (io_cli::term::Screen, Recorder) { + let recorder = Recorder::new(); + let backend = Fixed::new(recorder.clone(), width, height); + let terminal = Terminal::with_options( + backend, + ratatui::TerminalOptions { + viewport: ratatui::Viewport::Inline(viewport), + }, + ) + .expect("inline terminal"); + (io_cli::term::Screen::from_terminal(terminal), recorder) +} + +/// The escape sequences F5 forbids, with the names the contract uses. +pub const FORBIDDEN: &[(&str, &str)] = &[ + ("alternate screen (1049)", "\x1b[?1049h"), + ("alternate screen (1047)", "\x1b[?1047h"), + ("mouse capture (1000)", "\x1b[?1000h"), + ("mouse capture (1002)", "\x1b[?1002h"), + ("mouse capture (1003)", "\x1b[?1003h"), +]; From 53c33fff9c77e64668e2e1fb1f0fd6a6807ec96d Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 16 Aug 2026 19:54:05 +0530 Subject: [PATCH 08/34] =?UTF-8?q?chore(develop):=20Handle=20resize=20witho?= =?UTF-8?q?ut=20duplicating=20committed=20scrollback,=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.1.0-T04 --- .../products/io-cli/execution/tasks.yaml | 24 +++++- src/term.rs | 5 ++ tests/narrow.rs | 83 +++++++++++++++++++ tests/resize.rs | 68 +++++++++++++++ tests/support/mod.rs | 18 ++++ 5 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 tests/narrow.rs create mode 100644 tests/resize.rs diff --git a/.ultraship/products/io-cli/execution/tasks.yaml b/.ultraship/products/io-cli/execution/tasks.yaml index f4ebd9a..28f0309 100644 --- a/.ultraship/products/io-cli/execution/tasks.yaml +++ b/.ultraship/products/io-cli/execution/tasks.yaml @@ -132,7 +132,7 @@ tasks: why_required: F9 makes 80x24 a supported size rather than a degraded one, and resize is where a hybrid inline renderer duplicates history if it is wrong. Both are properties of the renderer, so they are fixed while it is still small. - status: todo + status: done depends_on: - US-IO-CLI-0.1.0-T03 acceptance_criteria: @@ -142,8 +142,28 @@ tasks: degrades to its narrow form rather than wrapping. files: - src/term.rs + - tests/support/mod.rs + - tests/resize.rs - tests/narrow.rs - evidence: [] + evidence: + - 13 tests pass across five test binaries; fmt and clippy -D warnings both exit 0. + - "Resize: a committed line is written exactly once and stays written once across two resizes, + so the transcript is never redrawn — the duplication a full-screen renderer produces has no + code path here." + - "80x24: a forty-line commit into a twenty-four-line terminal keeps the viewport; a + two-hundred-character line wraps with all twenty repeats intact; CJK text wraps with every + character intact and no replacement character in the byte stream." + - "SABOTAGE F — compute the commit height from lines.len() instead of + Paragraph::line_count(width): three tests fail (both wrapping tests and the post-resize + commit), the other ten pass." + - "SABOTAGE G — make Screen::resize a no-op: the FIRST version of the resize test failed + nothing, because ratatui's autoresize re-reads the terminal size at the top of every draw + and covered it. That was a real finding about the test, not the code. The test now commits + between the resize and the next frame, which is the only path where the explicit call is + load-bearing, and the same sabotage fails exactly that one test." + - "The harness gained Fixed::set_size for the same reason: a backend whose size never moves + undoes any resize on the very next frame, which is a property of the harness and would have + hidden a genuine renderer bug." - id: US-IO-CLI-0.1.0-T05 summary: Add the theme token set with two shipped themes, terminal background detection, `NO_COLOR`, and the splash committed once into scrollback. diff --git a/src/term.rs b/src/term.rs index f6f0069..1682e93 100644 --- a/src/term.rs +++ b/src/term.rs @@ -153,6 +153,11 @@ impl Screen { self.terminal.resize(Rect::new(0, 0, width, height)) } + /// The width the renderer is currently laying out against, in cells. + pub fn width(&mut self) -> u16 { + self.terminal.current_buffer_mut().area.width + } + /// What the last frame put in the viewport, one row per line with trailing /// spaces trimmed. Empty until the first [`Screen::draw`]. pub fn viewport_text(&self) -> &str { diff --git a/tests/narrow.rs b/tests/narrow.rs new file mode 100644 index 0000000..1f19107 --- /dev/null +++ b/tests/narrow.rs @@ -0,0 +1,83 @@ +//! F9 — 80x24 is a supported terminal size, not a degraded one. +//! +//! Every surface this release renders gets an assertion here as it lands. The +//! renderer's own obligations are the two below: the viewport survives content +//! taller than the screen, and nothing it writes is cut mid-character. + +mod support; + +use ratatui::text::Line; + +const WIDTH: u16 = 80; +const HEIGHT: u16 = 24; + +#[test] +fn f9_a_commit_taller_than_the_screen_keeps_the_viewport() { + let (mut screen, recorder) = support::screen(WIDTH, HEIGHT); + + // Forty lines into a twenty-four-line terminal. `insert_before` loops here, + // drawing a screenful at a time, and it is the path where a viewport is most + // easily scrolled off and lost. + let lines: Vec = (0..40).map(|n| Line::from(format!("line {n}"))).collect(); + screen.commit(&lines).expect("tall commit"); + screen + .draw(|frame| { + frame.render_widget(ratatui::widgets::Paragraph::new("> ready"), frame.area()); + }) + .expect("frame"); + + assert!( + screen.viewport_text().contains("> ready"), + "the viewport was lost behind a commit taller than the screen: {:?}", + screen.viewport_text(), + ); + assert!( + recorder.contains("line 39"), + "the last committed line never reached the terminal", + ); +} + +#[test] +fn f9_a_line_wider_than_the_terminal_wraps_rather_than_truncating() { + let (mut screen, recorder) = support::screen(WIDTH, HEIGHT); + + // Two hundred characters into eighty columns: three rows, and every character + // has to survive. Truncation here would silently drop the end of a model's + // answer, which is the content this whole renderer exists to preserve. + let long = "abcdefghij".repeat(20); + screen.commit(&[Line::from(long.clone())]).expect("commit"); + + let written = recorder.text().matches("abcdefghij").count(); + assert_eq!( + written, 20, + "{written} of 20 repeats survived; the line was truncated at the terminal width", + ); +} + +#[test] +fn f9_multibyte_text_is_not_split_across_a_wrap() { + let (mut screen, recorder) = support::screen(WIDTH, HEIGHT); + + // Wide characters are two cells each, so a naive byte- or char-count wrap puts + // half a character at the edge and produces mojibake on copy — the failure the + // alternate-screen renderers are reported for. + let wide = "日本語テキスト".repeat(10); + screen.commit(&[Line::from(wide.clone())]).expect("commit"); + + let text = recorder.text(); + assert!( + !text.contains('\u{fffd}'), + "the byte stream contains a replacement character, so a multi-byte character was split", + ); + // Counted per character rather than per repeat: a wrap legitimately lands + // between two characters, and crossterm emits a cursor move there, so the + // seven-character substring is not contiguous in the stream. What must hold is + // that no character was dropped. + for glyph in "日本語テキスト".chars() { + let written = text.matches(glyph).count(); + assert_eq!( + written, 10, + "{written} of 10 {glyph:?} survived a wrap at eighty columns", + ); + } +} diff --git a/tests/resize.rs b/tests/resize.rs new file mode 100644 index 0000000..8404fbb --- /dev/null +++ b/tests/resize.rs @@ -0,0 +1,68 @@ +//! Resize, which is where a hybrid inline renderer loses its history. +//! +//! A full-screen renderer redraws everything it owns when the terminal changes +//! size, and since it owns the transcript the transcript is drawn again — which +//! is the duplication users report as a bug. Here the transcript belongs to the +//! terminal, so a resize must recompute the viewport and touch nothing above it. + +mod support; + +use ratatui::text::Line; + +const MARK: &str = "committed-before-the-resize"; + +#[test] +fn a_resize_does_not_write_committed_content_a_second_time() { + let (mut screen, recorder) = support::screen(100, 30); + + screen.commit(&[Line::from(MARK)]).expect("commit"); + screen.draw(|_| {}).expect("frame"); + let before = recorder.text().matches(MARK).count(); + assert_eq!( + before, 1, + "the committed line should be written exactly once" + ); + + support::resize(&mut screen, 80, 24); + screen.draw(|_| {}).expect("frame after resize"); + support::resize(&mut screen, 120, 40); + screen.draw(|_| {}).expect("frame after resizing back"); + + assert_eq!( + recorder.text().matches(MARK).count(), + before, + "the resize redrew content that belongs to the terminal's scrollback", + ); +} + +#[test] +fn content_committed_after_a_resize_uses_the_new_width() { + // A line that fits on one row at 100 columns needs two at 40, and the height + // handed to `insert_before` is what decides whether the second row exists. + // + // There is deliberately NO frame between the resize and the commit. ratatui's + // `autoresize` re-reads the terminal size at the top of every `draw`, so a + // resize followed by a frame is handled whether or not this renderer does + // anything at all — which is what a sabotage of `Screen::resize` proved. The + // case that needs the explicit call is the one below: an event arrives, output + // is committed, and the next frame has not happened yet. + let wide = "x".repeat(70); + let (mut screen, recorder) = support::screen(100, 30); + screen.draw(|_| {}).expect("frame"); + + support::resize(&mut screen, 40, 30); + assert_eq!(screen.width(), 40, "the resize did not reach the renderer"); + screen + .commit(&[Line::from(wide.clone())]) + .expect("commit after resize"); + + let text = recorder.text(); + // All seventy characters reached the terminal, wrapped rather than truncated. + let written: usize = text.matches('x').count(); + assert!( + written >= wide.len(), + "only {written} of {} characters were written after the resize; the commit \ + height was computed against the old width", + wide.len(), + ); +} diff --git a/tests/support/mod.rs b/tests/support/mod.rs index d230338..701bea2 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -89,6 +89,17 @@ impl Fixed { }, } } + + /// Report a different terminal size from now on. + /// + /// A real terminal answers the size query with its new dimensions the moment + /// it is resized, and `Terminal::draw` re-reads it on every frame through + /// `autoresize`. A harness whose size never moves therefore undoes any resize + /// on the very next frame — which is a property of the harness, not of the + /// renderer, and is why this exists. + pub fn set_size(&mut self, width: u16, height: u16) { + self.size = Size { width, height }; + } } impl Write for Fixed { @@ -187,6 +198,13 @@ pub fn screen_of( (io_cli::term::Screen::from_terminal(terminal), recorder) } +/// 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) { + screen.terminal_mut().backend_mut().set_size(width, height); + screen.resize(width, height).expect("resize"); +} + /// The escape sequences F5 forbids, with the names the contract uses. pub const FORBIDDEN: &[(&str, &str)] = &[ ("alternate screen (1049)", "\x1b[?1049h"), From f8d18855f074c961dbb2aa0cba5c1dace61c4a29 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 16 Aug 2026 19:57:58 +0530 Subject: [PATCH 09/34] =?UTF-8?q?chore(develop):=20Add=20the=20theme=20tok?= =?UTF-8?q?en=20set=20with=20two=20shipped=20themes,=20termin=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.1.0-T05 --- .../products/io-cli/execution/tasks.yaml | 25 +- src/lib.rs | 2 + src/splash.rs | 51 ++++ src/theme.rs | 243 ++++++++++++++++++ tests/no_color.rs | 139 ++++++++++ 5 files changed, 458 insertions(+), 2 deletions(-) create mode 100644 src/splash.rs create mode 100644 src/theme.rs create mode 100644 tests/no_color.rs diff --git a/.ultraship/products/io-cli/execution/tasks.yaml b/.ultraship/products/io-cli/execution/tasks.yaml index 28f0309..252e48b 100644 --- a/.ultraship/products/io-cli/execution/tasks.yaml +++ b/.ultraship/products/io-cli/execution/tasks.yaml @@ -171,7 +171,7 @@ tasks: colour distinguishes also carries a word. That is a property of the token set, so the tokens have to exist before any surface uses one. The splash is the first thing written through the renderer and is suppressed by the same switches. - status: todo + status: done depends_on: - US-IO-CLI-0.1.0-T03 acceptance_criteria: @@ -182,8 +182,29 @@ tasks: files: - src/theme.rs - src/splash.rs + - src/lib.rs - tests/no_color.rs - evidence: [] + evidence: + - 19 tests pass; fmt and clippy -D warnings exit 0. Six of them are new and cover the theme, + the splash and NO_COLOR. + - "The colour-is-never-alone rule is a property of the constructor, not a convention: + Theme::notice is the only way a toned line is built and it puts the tone's word in front of + the text, so a call site cannot forget. Tone::word returns None for the three presentation + tones and Some for the four that mean something." + - NO_COLOR wins over an explicitly chosen theme, and the PLAIN theme returns a bare Style with + no modifier — a dim or italic attribute would still be a presentation-only carrier of + meaning. + - "Background detection reads COLORFGBG rather than issuing an OSC 11 query: the query needs a + reply read back off stdin and hangs on any terminal that does not answer, in a product whose + first frame should already be on screen. Unparseable or absent means dark, because the light + palette on a dark terminal is the less readable mistake." + - "SABOTAGE H — Theme::notice emits colour without the word: the two N6 word tests fail, + nothing else." + - "SABOTAGE J — the PLAIN theme emits an indexed colour: only the scripted NO_COLOR session + fails, on the escape-sequence assertion." + - "SABOTAGE K — the splash ignores NO_COLOR: only the splash suppression test fails." + - "The splash is also suppressed below forty-one columns, which the contract does not require: + the mark is forty-one wide and is the first thing that looks broken on a small window." - id: US-IO-CLI-0.1.0-T06 summary: "Build the composer on `tui-textarea`: multiline on Shift+Enter with a `\\`+Enter fallback, prompt history on the arrow keys, and bracketed paste." diff --git a/src/lib.rs b/src/lib.rs index d87e99f..5c0575d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,4 +11,6 @@ //! [io-harness]: https://docs.rs/io-harness pub mod cli; +pub mod splash; pub mod term; +pub mod theme; diff --git a/src/splash.rs b/src/splash.rs new file mode 100644 index 0000000..937ae3e --- /dev/null +++ b/src/splash.rs @@ -0,0 +1,51 @@ +//! The mark, printed once into scrollback at the start of a session. +//! +//! Into scrollback, not the viewport, so it scrolls away like any other output +//! instead of occupying rows for the life of the session. + +use ratatui::text::{Line, Span}; + +use crate::theme::{Theme, Tone}; + +/// The `IO CLI` mark in box-drawing characters. +const MARK: &[&str] = &[ + "██████╗ ██████╗ ██████╗ ██╗ ██╗", + "╚═██╔═╝ ██╔═══██╗ ██╔════╝ ██║ ██║", + " ██║ ██║ ██║ ██║ ██║ ██║", + " ██║ ██║ ██║ ██║ ██║ ██║", + "██████╗ ╚██████╔╝ ╚██████╗ ███████╗██║", + "╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝╚═╝", +]; + +/// The mark is forty-one columns wide, so it does not fit an eighty-column +/// terminal with anything beside it and does not fit a narrower one at all. +const MARK_WIDTH: u16 = 41; + +/// Whether to show the mark. +/// +/// Suppressed without colour, without a tty, and on a terminal too narrow to hold +/// it. The first two are the contract's; the third is the reason a splash is the +/// first thing that looks broken on a small window. +pub fn visible(coloured: bool, tty: bool, width: u16) -> bool { + coloured && tty && width >= MARK_WIDTH +} + +/// The lines to commit, mark included. +pub fn lines(theme: &Theme, tty: bool, width: u16) -> Vec> { + let mut lines = Vec::new(); + if visible(theme.coloured, tty, width) { + for row in MARK { + lines.push(Line::from(Span::styled( + (*row).to_string(), + theme.style(Tone::Accent), + ))); + } + lines.push(Line::from("")); + } + lines.push(Line::from(Span::styled( + format!("io {}", env!("CARGO_PKG_VERSION")), + theme.style(Tone::Muted), + ))); + lines.push(Line::from("")); + lines +} diff --git a/src/theme.rs b/src/theme.rs new file mode 100644 index 0000000..4c3f2f0 --- /dev/null +++ b/src/theme.rs @@ -0,0 +1,243 @@ +//! The theme: nine tokens, two shipped themes, and a rule that colour is never +//! the only thing carrying a meaning. +//! +//! The token set is deliberately small. Restraint is what makes a terminal +//! product look considered; a palette with twenty names is a palette nobody uses +//! consistently, and every extra token is another thing a second theme has to get +//! right. +//! +//! Nothing here paints a background. io-cli does not own the screen — the +//! transcript is the terminal's own scrollback, sitting on whatever background +//! the user chose — so a background token exists to *detect* against, not to +//! fill with. + +use std::fmt; + +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; + +/// Which way round the terminal is. Detected, because the same colour that reads +/// as "muted" on black is invisible on white. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Background { + Dark, + Light, +} + +impl Background { + /// What the environment says, defaulting to dark. + /// + /// `COLORFGBG` is the one signal available without talking to the terminal: + /// it is a `foreground;background` pair of ANSI colour numbers, exported by + /// several terminals and by tmux. The alternative is an OSC 11 query, which + /// means writing an escape sequence and reading the reply back off stdin — + /// a round trip that hangs on any terminal that does not answer, in a product + /// whose first frame should already be on screen. + /// + /// Defaulting to dark rather than to light is not a coin toss: a dark + /// terminal is the common case, and the light palette on a dark background is + /// the less readable of the two mistakes. + pub fn detect() -> Self { + match std::env::var("COLORFGBG") { + Ok(value) => Self::from_colorfgbg(&value), + Err(_) => Self::Dark, + } + } + + /// Parse a `COLORFGBG` value. Its last field is the background colour number; + /// 0-6 and 8 are the dark ones, 7 and 9-15 the light ones. + pub fn from_colorfgbg(value: &str) -> Self { + let background = value.rsplit(';').next().unwrap_or("").trim(); + match background.parse::() { + Ok(0..=6) | Ok(8) => Self::Dark, + Ok(_) => Self::Light, + Err(_) => Self::Dark, + } + } +} + +/// A state that colour distinguishes — and that therefore also has a word. +/// +/// The pairing is the point. Colour as the sole carrier of a meaning is unusable +/// under `NO_COLOR`, on a monochrome terminal, and for a colour-blind reader, so +/// every tone that means something carries a word that means the same thing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Tone { + /// Ordinary text. + Normal, + /// Present but secondary: timings, ids, the status line's dimmer fields. + Muted, + /// The product's own colour. Prompts and selection. + Accent, + Success, + Warning, + Error, + /// An act the permission boundary refused. Its own tone because it is not an + /// error — the system worked. + Refused, +} + +impl Tone { + /// The word this tone carries, or `None` where the tone is decoration rather + /// than meaning. + pub fn word(self) -> Option<&'static str> { + match self { + Self::Normal | Self::Muted | Self::Accent => None, + Self::Success => Some("ok"), + Self::Warning => Some("warning"), + Self::Error => Some("error"), + Self::Refused => Some("refused"), + } + } +} + +impl fmt::Display for Tone { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.word().unwrap_or("")) + } +} + +/// The nine tokens. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Theme { + pub name: &'static str, + /// The background this theme was designed for. Never painted; see the module + /// documentation. + pub background: Background, + pub foreground: Color, + pub muted: Color, + pub accent: Color, + pub success: Color, + pub warning: Color, + pub error: Color, + pub diff_add: Color, + pub diff_delete: Color, + /// Whether this theme emits colour at all. The `NO_COLOR` theme does not. + pub coloured: bool, +} + +/// For a dark terminal. The default. +pub const DARK: Theme = Theme { + name: "dark", + background: Background::Dark, + // `Reset` rather than a colour: the user's own foreground is the right + // foreground, and overriding it is how a theme ends up unreadable in somebody + // else's terminal. + foreground: Color::Reset, + muted: Color::DarkGray, + accent: Color::LightCyan, + success: Color::LightGreen, + warning: Color::LightYellow, + error: Color::LightRed, + diff_add: Color::LightGreen, + diff_delete: Color::LightRed, + coloured: true, +}; + +/// For a light terminal. +pub const LIGHT: Theme = Theme { + name: "light", + background: Background::Light, + foreground: Color::Reset, + muted: Color::DarkGray, + accent: Color::Blue, + success: Color::Green, + // Indexed rather than `Yellow`: ANSI yellow on white is close to invisible, + // and 130 is the dark orange every 256-colour terminal has. The ceiling is + // that a 16-colour terminal will approximate it; the alternative was a token + // the light theme could not express at all. + warning: Color::Indexed(130), + error: Color::Red, + diff_add: Color::Green, + diff_delete: Color::Red, + coloured: true, +}; + +/// No colour at all. What `NO_COLOR` selects. +pub const PLAIN: Theme = Theme { + name: "plain", + background: Background::Dark, + foreground: Color::Reset, + muted: Color::Reset, + accent: Color::Reset, + success: Color::Reset, + warning: Color::Reset, + error: Color::Reset, + diff_add: Color::Reset, + diff_delete: Color::Reset, + coloured: false, +}; + +/// The themes a user can choose between. `PLAIN` is not among them: it is not a +/// preference, it is what `NO_COLOR` forces. +pub const THEMES: &[Theme] = &[DARK, LIGHT]; + +impl Theme { + /// The theme named, or `None`. + pub fn by_name(name: &str) -> Option { + THEMES.iter().copied().find(|theme| theme.name == name) + } + + /// Choose a theme from what the environment says. + /// + /// `NO_COLOR` wins outright and is honoured on presence, whatever its value, + /// which is what the convention specifies. + pub fn resolve(no_color: bool, background: Background, chosen: Option<&str>) -> Self { + if no_color { + return PLAIN; + } + if let Some(theme) = chosen.and_then(Self::by_name) { + return theme; + } + match background { + Background::Dark => DARK, + Background::Light => LIGHT, + } + } + + /// The same, reading the environment. + pub fn from_env(chosen: Option<&str>) -> Self { + Self::resolve( + std::env::var_os("NO_COLOR").is_some(), + Background::detect(), + chosen, + ) + } + + /// The style for a tone. + pub fn style(&self, tone: Tone) -> Style { + if !self.coloured { + // Not even a modifier. A dim or italic attribute is still a + // presentation-only carrier of meaning, and the word is already + // carrying it. + return Style::default(); + } + match tone { + Tone::Normal => Style::default().fg(self.foreground), + Tone::Muted => Style::default().fg(self.muted), + Tone::Accent => Style::default().fg(self.accent), + Tone::Success => Style::default().fg(self.success), + Tone::Warning => Style::default().fg(self.warning), + Tone::Error => Style::default().fg(self.error).add_modifier(Modifier::BOLD), + Tone::Refused => Style::default() + .fg(self.warning) + .add_modifier(Modifier::BOLD), + } + } + + /// One line carrying a state, with the tone's word in front of it. + /// + /// This is the only way a toned line is built, so "colour is never the sole + /// carrier of meaning" is a property of the constructor rather than a rule + /// each call site has to remember. N6 asserts it. + pub fn notice<'a>(&self, tone: Tone, text: impl Into) -> Line<'a> { + let style = self.style(tone); + match tone.word() { + Some(word) => Line::from(vec![ + Span::styled(format!("{word}: "), style), + Span::styled(text.into(), self.style(Tone::Normal)), + ]), + None => Line::from(Span::styled(text.into(), style)), + } + } +} diff --git a/tests/no_color.rs b/tests/no_color.rs new file mode 100644 index 0000000..48fb290 --- /dev/null +++ b/tests/no_color.rs @@ -0,0 +1,139 @@ +//! N6 — `NO_COLOR` produces a usable session. +//! +//! Usable is the word that matters. A session that runs but reports a refusal as +//! a line that was going to be yellow, and now is not, has lost the information — +//! so the assertion is that every state colour distinguishes also carries a word. + +mod support; + +use io_cli::splash; +use io_cli::theme::{Background, Theme, Tone, DARK, LIGHT, PLAIN, THEMES}; + +/// The tones that mean something, as opposed to the ones that are presentation. +const MEANINGFUL: &[Tone] = &[Tone::Success, Tone::Warning, Tone::Error, Tone::Refused]; + +#[test] +fn n6_every_meaningful_tone_carries_a_word() { + for tone in MEANINGFUL { + assert!( + tone.word().is_some(), + "{tone:?} distinguishes a state by colour alone", + ); + } + for tone in [Tone::Normal, Tone::Muted, Tone::Accent] { + assert!( + tone.word().is_none(), + "{tone:?} is presentation and should not prefix every line with a word", + ); + } +} + +#[test] +fn n6_the_word_survives_no_color() { + for tone in MEANINGFUL { + let word = tone.word().expect("a meaningful tone has a word"); + for theme in [PLAIN, DARK, LIGHT] { + let line = theme.notice(*tone, "write to /etc/hosts"); + let rendered: String = line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + assert!( + rendered.contains(word), + "the {} theme rendered {tone:?} as {rendered:?}, without the word {word:?}", + theme.name, + ); + assert!( + rendered.contains("write to /etc/hosts"), + "the {} theme lost the text of the notice", + theme.name, + ); + } + } +} + +#[test] +fn n6_a_scripted_session_runs_with_no_color_and_writes_no_colour() { + let theme = Theme::resolve(true, Background::Dark, Some("dark")); + assert_eq!(theme.name, "plain", "NO_COLOR must win over a chosen theme"); + + let (mut screen, recorder) = support::screen(100, 30); + screen + .commit(&splash::lines(&theme, true, 100)) + .expect("splash"); + for tone in MEANINGFUL { + screen + .commit(&[theme.notice(*tone, "something happened")]) + .expect("commit"); + } + screen.draw(|_| {}).expect("frame"); + + let text = recorder.text(); + for tone in MEANINGFUL { + let word = tone.word().expect("a meaningful tone has a word"); + assert!(text.contains(word), "the session never wrote {word:?}"); + } + + // `ESC [ 3 8 ; 5 ;` and `ESC [ 3 8 ; 2 ;` are indexed and true-colour + // foregrounds; the 30-37 and 90-97 ranges are the ANSI ones. Under NO_COLOR + // none of them should be written at all. + for sequence in ["\x1b[38;5;", "\x1b[38;2;"] { + assert!( + !text.contains(sequence), + "the byte stream carries {} under NO_COLOR", + sequence.escape_debug(), + ); + } +} + +#[test] +fn the_splash_is_suppressed_without_colour_a_tty_or_the_width_for_it() { + assert!( + splash::visible(true, true, 100), + "the ordinary case shows it" + ); + assert!(!splash::visible(false, true, 100), "NO_COLOR suppresses it"); + assert!( + !splash::visible(true, false, 100), + "a non-tty suppresses it" + ); + assert!( + !splash::visible(true, true, 40), + "a terminal narrower than the mark suppresses it", + ); + + // Suppressed does not mean silent: the version line is still committed, so a + // session always says what it is. + let lines = splash::lines(&PLAIN, false, 40); + let rendered: String = lines + .iter() + .flat_map(|line| line.spans.iter()) + .map(|span| span.content.as_ref()) + .collect(); + assert!(rendered.contains("io 0.1.0"), "got {rendered:?}"); + assert!(!rendered.contains('█'), "the mark was drawn anyway"); +} + +#[test] +fn the_two_shipped_themes_are_the_two_shipped_themes() { + let names: Vec<_> = THEMES.iter().map(|theme| theme.name).collect(); + assert_eq!(names, ["dark", "light"], "restraint is the design"); + assert!( + Theme::by_name("plain").is_none(), + "plain is what NO_COLOR forces, not a theme a user picks", + ); +} + +#[test] +fn the_background_is_detected_from_colorfgbg() { + // A `foreground;background` pair. The last field decides. + assert_eq!(Background::from_colorfgbg("15;0"), Background::Dark); + assert_eq!(Background::from_colorfgbg("0;15"), Background::Light); + assert_eq!(Background::from_colorfgbg("15;default;0"), Background::Dark); + assert_eq!(Background::from_colorfgbg("7"), Background::Light); + // Anything unparseable is dark, because the light palette on a dark terminal + // is the less readable of the two mistakes. + assert_eq!(Background::from_colorfgbg(""), Background::Dark); + assert_eq!(Background::from_colorfgbg("nonsense"), Background::Dark); +} From 7a610bd905b0b045369b7dd9c29f93d00fa1c29f Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 16 Aug 2026 20:02:27 +0530 Subject: [PATCH 10/34] =?UTF-8?q?chore(develop):=20Build=20the=20composer?= =?UTF-8?q?=20on=20`tui-textarea`:=20multiline=20on=20Shif=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.1.0-T06 --- .../products/io-cli/execution/tasks.yaml | 30 +- src/composer.rs | 265 ++++++++++++++++++ src/lib.rs | 1 + tests/composer.rs | 222 +++++++++++++++ 4 files changed, 516 insertions(+), 2 deletions(-) create mode 100644 src/composer.rs create mode 100644 tests/composer.rs diff --git a/.ultraship/products/io-cli/execution/tasks.yaml b/.ultraship/products/io-cli/execution/tasks.yaml index 252e48b..f325006 100644 --- a/.ultraship/products/io-cli/execution/tasks.yaml +++ b/.ultraship/products/io-cli/execution/tasks.yaml @@ -210,7 +210,7 @@ tasks: prompt history on the arrow keys, and bracketed paste." why_required: There is no way to type a task without it, so F1 and F6 both stop here. Its narrow form is also half of what F9 asserts. - status: todo + status: done depends_on: - US-IO-CLI-0.1.0-T04 - US-IO-CLI-0.1.0-T05 @@ -221,8 +221,34 @@ tasks: degrades to its narrow form rather than wrapping. files: - src/composer.rs + - src/lib.rs - tests/composer.rs - evidence: [] + evidence: + - 30 tests pass across seven binaries; fmt and clippy -D warnings exit 0. Eleven of them are + the composer's. + - "A REAL DEFECT was found by reading the captured byte stream rather than the rendered + buffer: every frame carried ESC[?25l, hiding the cursor. ratatui hides it on any frame that + does not set a cursor position, and a hidden cursor removes the only focus indicator a + screen reader has — the specific criticism this product category is unusable for. The fix + puts frame.set_cursor_position inside Composer::render, so the widget that owns the + insertion point owns the cursor and no frame can forget." + - "A second finding, about the harness rather than the code: ratatui writes only the cells + that differ from the previous frame, so viewport text arrives in the byte stream split at + its unchanged spaces with a cursor move between the pieces. Viewport assertions therefore + read viewport_text(); committed content still reads the bytes, because insert_before draws + every cell of a fresh buffer." + - Shift+Enter inserts a newline and a trailing backslash does the same on the terminals that + cannot report Shift+Enter at all — which without the Kitty keyboard protocol is most of + them. + - The arrows walk prompt history only from the first and last line, so a multiline prompt can + still be edited; a repeated prompt is one history entry; walking past the newest entry + restores the draft that was being typed. + - "SABOTAGE L — remove the backslash continuation: only + a_trailing_backslash_continues_the_line fails." + - "SABOTAGE M — let Up always recall history: only + the_arrows_move_the_cursor_inside_a_multiline_prompt fails." + - "SABOTAGE N — do not set the cursor position: only + the_real_cursor_is_never_hidden_while_input_is_possible fails." - id: US-IO-CLI-0.1.0-T07 summary: Build the `Picker` overlay component, once, as the widget every selection surface uses. why_required: The wizard has four selection steps and F2 drives all of them over the TestBackend. diff --git a/src/composer.rs b/src/composer.rs new file mode 100644 index 0000000..6f2c1bc --- /dev/null +++ b/src/composer.rs @@ -0,0 +1,265 @@ +//! The composer: the one part of the viewport the user types into. +//! +//! `tui-textarea` does the editing. What is added here is the small set of +//! decisions a prompt has that a text editor does not: when `Enter` submits and +//! when it inserts a newline, what the arrow keys mean at the edges of the text, +//! and how a paste of a whole file behaves. + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::layout::{Position, Rect}; +use ratatui::text::Line; +use ratatui::Frame; +use tui_textarea::{CursorMove, TextArea}; + +use crate::theme::{Theme, Tone}; + +/// The prompt marker. Two cells, so wrapped continuation lines line up under the +/// text rather than under the marker. +pub const PROMPT: &str = "> "; + +/// What a keystroke did. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Reply { + /// Handled; nothing for the session to do. + Idle, + /// The user pressed `Enter` on a finished prompt. + Submitted(String), +} + +pub struct Composer { + area: TextArea<'static>, + /// Prompts already submitted, oldest first. + history: Vec, + /// Where the arrow keys currently are in `history`, or `None` when the user + /// is editing their own text rather than walking back through old prompts. + recalled: Option, + /// What was in the composer when the walk back started, so `Down` past the + /// newest entry returns it rather than clearing it. + draft: String, +} + +impl Default for Composer { + fn default() -> Self { + Self::new() + } +} + +impl Composer { + pub fn new() -> Self { + let mut area = TextArea::default(); + // The composer draws its own prompt marker, so the widget must not also + // draw a border, a line number or a cursor-line highlight. + area.set_cursor_line_style(ratatui::style::Style::default()); + Self { + area, + history: Vec::new(), + recalled: None, + draft: String::new(), + } + } + + /// What is currently typed. + pub fn text(&self) -> String { + self.area.lines().join("\n") + } + + /// Whether there is nothing typed. `Ctrl+D` exits on an empty composer, so + /// this decides whether a keystroke ends the session. + pub fn is_empty(&self) -> bool { + self.area.lines().iter().all(|line| line.trim().is_empty()) + } + + /// The prompts submitted so far, oldest first. + pub fn history(&self) -> &[String] { + &self.history + } + + /// Rows the composer needs at this width, prompt marker included. + pub fn height(&self, width: u16) -> u16 { + let usable = width.saturating_sub(PROMPT.len() as u16).max(1) as usize; + self.area + .lines() + .iter() + .map(|line| line.chars().count().div_ceil(usable).max(1) as u16) + .sum::() + .max(1) + } + + /// Feed a key. + pub fn key(&mut self, key: KeyEvent) -> Reply { + match (key.code, key.modifiers) { + // `Shift+Enter` is the newline every terminal that can report it uses. + (KeyCode::Enter, m) if m.contains(KeyModifiers::SHIFT) => { + self.editing(); + self.area.insert_newline(); + Reply::Idle + } + (KeyCode::Enter, _) => { + // The fallback for terminals that cannot distinguish `Shift+Enter` + // from `Enter` at all — which is most of them without the Kitty + // keyboard protocol. A trailing backslash means "continue". + if self.current_line().ends_with('\\') { + self.editing(); + self.area.delete_char(); + self.area.insert_newline(); + return Reply::Idle; + } + let text = self.text(); + if text.trim().is_empty() { + return Reply::Idle; + } + self.clear(); + self.remember(&text); + Reply::Submitted(text) + } + // History, but only from the edge of the text: inside a multiline + // prompt the arrows have to move the cursor, or a long prompt cannot + // be edited at all. + (KeyCode::Up, _) if self.at_first_line() => { + self.recall_older(); + Reply::Idle + } + (KeyCode::Down, _) if self.at_last_line() => { + self.recall_newer(); + Reply::Idle + } + _ => { + self.editing(); + self.area.input(key); + Reply::Idle + } + } + } + + /// Take a bracketed paste. + /// + /// Inserted verbatim, newlines included, rather than replayed as keystrokes — + /// which is what an unbracketed paste of a multi-line block does, and it + /// submits the prompt on the first newline. + pub fn paste(&mut self, text: &str) { + self.editing(); + self.area.insert_str(text); + } + + /// Empty the composer, keeping the history. + pub fn clear(&mut self) { + self.area.select_all(); + self.area.cut(); + self.recalled = None; + self.draft.clear(); + } + + /// Render into `area`, prompt marker included. + pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { + if area.width <= PROMPT.len() as u16 || area.height == 0 { + return; + } + let marker = Rect { + width: PROMPT.len() as u16, + ..area + }; + let text = Rect { + x: area.x + PROMPT.len() as u16, + width: area.width - PROMPT.len() as u16, + ..area + }; + frame.render_widget( + ratatui::widgets::Paragraph::new(Line::styled(PROMPT, theme.style(Tone::Accent))), + marker, + ); + frame.render_widget(&self.area, text); + + // The real terminal cursor is put where the insertion point is, and that + // is done here rather than left to a caller. ratatui hides the cursor on + // any frame that does not set a position, and a hidden cursor removes the + // only focus indicator a screen reader has — the criticism the whole + // category is unusable for. Owning it in the widget that owns the + // insertion point means no frame can forget. + let (x, y) = self.cursor(text); + frame.set_cursor_position(Position { x, y }); + } + + /// The cursor's position inside `area`, which is the text region rather than + /// the whole composer. + pub fn cursor(&self, text: Rect) -> (u16, u16) { + let (row, column) = self.area.cursor(); + let width = text.width.max(1); + // Wrapped rows count: a cursor past the end of a visual row is on the next + // one, and reporting it off the right edge puts the terminal's cursor + // somewhere the text is not. + let column = column as u16; + ( + text.x + column % width, + (text.y + row as u16 + column / width).min(text.bottom().saturating_sub(1)), + ) + } + + fn current_line(&self) -> &str { + let (row, _) = self.area.cursor(); + self.area.lines().get(row).map(String::as_str).unwrap_or("") + } + + fn at_first_line(&self) -> bool { + self.area.cursor().0 == 0 + } + + fn at_last_line(&self) -> bool { + self.area.cursor().0 + 1 >= self.area.lines().len() + } + + /// Mark that the user is editing their own text again, so `Down` no longer + /// walks forward through history into somebody else's prompt. + fn editing(&mut self) { + self.recalled = None; + } + + fn remember(&mut self, text: &str) { + // A prompt repeated back to back is one entry. Walking past five copies of + // the same line is the most annoying thing a history can do. + if self.history.last().map(String::as_str) != Some(text) { + self.history.push(text.to_string()); + } + } + + fn recall_older(&mut self) { + if self.history.is_empty() { + return; + } + let next = match self.recalled { + None => { + self.draft = self.text(); + self.history.len() - 1 + } + Some(0) => 0, + Some(index) => index - 1, + }; + self.recalled = Some(next); + let text = self.history[next].clone(); + self.replace(&text); + } + + fn recall_newer(&mut self) { + let Some(index) = self.recalled else { + return; + }; + if index + 1 < self.history.len() { + self.recalled = Some(index + 1); + let text = self.history[index + 1].clone(); + self.replace(&text); + } else { + self.recalled = None; + let draft = std::mem::take(&mut self.draft); + self.replace(&draft); + } + } + + fn replace(&mut self, text: &str) { + let recalled = self.recalled; + let draft = std::mem::take(&mut self.draft); + self.clear(); + self.recalled = recalled; + self.draft = draft; + self.area.insert_str(text); + self.area.move_cursor(CursorMove::End); + } +} diff --git a/src/lib.rs b/src/lib.rs index 5c0575d..42eadd1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,7 @@ //! [io-harness]: https://docs.rs/io-harness pub mod cli; +pub mod composer; pub mod splash; pub mod term; pub mod theme; diff --git a/tests/composer.rs b/tests/composer.rs new file mode 100644 index 0000000..91fc832 --- /dev/null +++ b/tests/composer.rs @@ -0,0 +1,222 @@ +//! The composer, and its share of F9: it stays usable at eighty columns. + +mod support; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use io_cli::composer::{Composer, Reply, PROMPT}; +use io_cli::theme::DARK; + +fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) +} + +fn type_text(composer: &mut Composer, text: &str) { + for character in text.chars() { + composer.key(key(KeyCode::Char(character))); + } +} + +#[test] +fn enter_submits_and_clears() { + let mut composer = Composer::new(); + type_text(&mut composer, "fix the failing test"); + + assert_eq!( + composer.key(key(KeyCode::Enter)), + Reply::Submitted("fix the failing test".into()), + ); + assert!( + composer.is_empty(), + "the composer should be ready for the next prompt" + ); +} + +#[test] +fn an_empty_enter_does_nothing() { + let mut composer = Composer::new(); + assert_eq!(composer.key(key(KeyCode::Enter)), Reply::Idle); + type_text(&mut composer, " "); + assert_eq!( + composer.key(key(KeyCode::Enter)), + Reply::Idle, + "whitespace is not a prompt", + ); +} + +#[test] +fn shift_enter_inserts_a_newline() { + let mut composer = Composer::new(); + type_text(&mut composer, "first"); + composer.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT)); + type_text(&mut composer, "second"); + + assert_eq!(composer.text(), "first\nsecond"); + assert_eq!( + composer.key(key(KeyCode::Enter)), + Reply::Submitted("first\nsecond".into()), + ); +} + +#[test] +fn a_trailing_backslash_continues_the_line_on_terminals_without_shift_enter() { + // Most terminals cannot report `Shift+Enter` at all without the Kitty + // keyboard protocol, so this fallback is the one that actually gets used. + let mut composer = Composer::new(); + type_text(&mut composer, "first\\"); + assert_eq!(composer.key(key(KeyCode::Enter)), Reply::Idle); + type_text(&mut composer, "second"); + + assert_eq!( + composer.text(), + "first\nsecond", + "the backslash should be consumed, not left in the prompt", + ); +} + +#[test] +fn the_arrows_walk_prompt_history_and_come_back() { + let mut composer = Composer::new(); + for prompt in ["first", "second", "third"] { + type_text(&mut composer, prompt); + composer.key(key(KeyCode::Enter)); + } + type_text(&mut composer, "a draft"); + + composer.key(key(KeyCode::Up)); + assert_eq!(composer.text(), "third"); + composer.key(key(KeyCode::Up)); + assert_eq!(composer.text(), "second"); + composer.key(key(KeyCode::Up)); + assert_eq!(composer.text(), "first"); + composer.key(key(KeyCode::Up)); + assert_eq!( + composer.text(), + "first", + "the oldest entry is the end of the walk" + ); + + composer.key(key(KeyCode::Down)); + assert_eq!(composer.text(), "second"); + composer.key(key(KeyCode::Down)); + assert_eq!(composer.text(), "third"); + composer.key(key(KeyCode::Down)); + assert_eq!( + composer.text(), + "a draft", + "walking past the newest entry returns what was being typed", + ); +} + +#[test] +fn a_repeated_prompt_is_one_history_entry() { + let mut composer = Composer::new(); + for _ in 0..3 { + type_text(&mut composer, "cargo test"); + composer.key(key(KeyCode::Enter)); + } + assert_eq!(composer.history(), ["cargo test"]); +} + +#[test] +fn the_arrows_move_the_cursor_inside_a_multiline_prompt() { + let mut composer = Composer::new(); + type_text(&mut composer, "one"); + composer.key(key(KeyCode::Enter)); // submitted, so there is history to recall + type_text(&mut composer, "alpha"); + composer.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT)); + type_text(&mut composer, "beta"); + + // The cursor is on the second line, so `Up` must move within the text rather + // than replacing it with the previous prompt. + composer.key(key(KeyCode::Up)); + assert_eq!( + composer.text(), + "alpha\nbeta", + "history stole a cursor move" + ); +} + +#[test] +fn a_paste_arrives_whole_rather_than_as_keystrokes() { + let mut composer = Composer::new(); + composer.paste("line one\nline two\nline three"); + + assert_eq!(composer.text(), "line one\nline two\nline three"); + assert_eq!( + composer.key(key(KeyCode::Enter)), + Reply::Submitted("line one\nline two\nline three".into()), + "a pasted block is one prompt, not three", + ); +} + +#[test] +fn f9_the_composer_is_usable_at_eighty_columns() { + let (mut screen, recorder) = support::screen(80, 24); + let mut composer = Composer::new(); + type_text( + &mut composer, + "a prompt long enough to need most of an eighty column terminal", + ); + + screen + .draw(|frame| composer.render(frame, frame.area(), &DARK)) + .expect("frame"); + + let viewport = screen.viewport_text(); + assert!( + viewport.contains(PROMPT.trim()), + "the prompt marker is missing" + ); + assert!( + viewport.contains("eighty column terminal"), + "the end of the prompt was cut off at eighty columns: {viewport:?}", + ); + + // Asserted on the viewport rather than on the byte stream, deliberately. + // ratatui writes only the cells that differ from the previous frame, and a + // space that was already a space is not one of them — so viewport text + // arrives in the stream split at its spaces, with a cursor move in between. + // Committed content has no such problem, because `insert_before` draws every + // cell of a fresh buffer, which is why the scrollback tests do read the bytes. + assert!( + !recorder.text().is_empty(), + "the frame never reached the terminal at all" + ); +} + +#[test] +fn the_real_cursor_is_never_hidden_while_input_is_possible() { + // A hidden cursor removes the only focus indicator a screen reader has, and + // ratatui hides it on any frame that does not set a position. The composer + // sets one, so this holds without any caller having to remember. + let (mut screen, recorder) = support::screen(80, 24); + let mut composer = Composer::new(); + type_text(&mut composer, "typing"); + + for _ in 0..3 { + screen + .draw(|frame| composer.render(frame, frame.area(), &DARK)) + .expect("frame"); + } + + assert!( + !recorder.contains("\x1b[?25l"), + "the byte stream hides the cursor", + ); +} + +#[test] +fn f9_a_prompt_wider_than_the_terminal_asks_for_more_rows() { + let mut composer = Composer::new(); + type_text(&mut composer, &"x".repeat(200)); + + assert_eq!( + composer.height(80), + 3, + "two hundred characters need three rows of seventy-eight", + ); + // The prompt marker takes two of the columns, so two hundred characters need + // two hundred and two columns to sit on one row. + assert_eq!(composer.height(202), 1); + assert_eq!(composer.height(200), 2); +} From 4fb709064fd49ea2aba17bf5851df01e6927fcab Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 16 Aug 2026 20:04:31 +0530 Subject: [PATCH 11/34] =?UTF-8?q?chore(develop):=20Build=20the=20`Picker`?= =?UTF-8?q?=20overlay=20component,=20once,=20as=20the=20widg=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.1.0-T07 --- .../products/io-cli/execution/tasks.yaml | 23 +- src/lib.rs | 1 + src/picker.rs | 212 ++++++++++++++++++ tests/picker.rs | 161 +++++++++++++ 4 files changed, 395 insertions(+), 2 deletions(-) create mode 100644 src/picker.rs create mode 100644 tests/picker.rs diff --git a/.ultraship/products/io-cli/execution/tasks.yaml b/.ultraship/products/io-cli/execution/tasks.yaml index f325006..2a04272 100644 --- a/.ultraship/products/io-cli/execution/tasks.yaml +++ b/.ultraship/products/io-cli/execution/tasks.yaml @@ -254,7 +254,7 @@ tasks: why_required: The wizard has four selection steps and F2 drives all of them over the TestBackend. The contract also names this the one piece of the fallback scope that must not drop, because cutting it means hand-rolling the wizard and rebuilding it in 0.2.0 for approvals. - status: todo + status: done depends_on: - US-IO-CLI-0.1.0-T04 - US-IO-CLI-0.1.0-T05 @@ -267,8 +267,27 @@ tasks: the confirmation screen, under which only F2 fails. files: - src/picker.rs + - src/lib.rs - tests/picker.rs - evidence: [] + evidence: + - 37 tests pass across eight binaries; fmt and clippy -D warnings exit 0. Seven of them are + the picker's. + - One widget, in the viewport, never in scrollback — a choice that has scrolled away cannot be + answered. The wizard's four selection steps, /model and /theme are this widget with + different rows, and 0.2.0's approval overlay and 0.4.0's /resume and /fork are the same + widget again. + - The selection is marked by a marker character as well as by colour, so it survives NO_COLOR + and a monochrome terminal. + - "At eighty columns a row loses its DETAIL, not its label, and is fitted with a + single-character ellipsis rather than wrapped: a row that wraps stops the list being a list, + and the label is the part that has to survive." + - Movement clamps at both ends rather than wrapping, matching the composer's history, and a + long list scrolls so the selection is always drawn. + - "SABOTAGE P — never scroll to the selection: only + a_long_list_scrolls_so_the_selection_stays_visible fails." + - "SABOTAGE Q — render the detail unfitted: only the eighty-column row test fails." + - "SABOTAGE R — mark the selected row by colour alone: only + the_selected_row_is_marked_by_more_than_colour fails." - id: US-IO-CLI-0.1.0-T08 summary: Render the status line with model, elapsed time and a working indicator, laid out for 0.2.0 fields. diff --git a/src/lib.rs b/src/lib.rs index 42eadd1..e9f9f65 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,7 @@ pub mod cli; pub mod composer; +pub mod picker; pub mod splash; pub mod term; pub mod theme; diff --git a/src/picker.rs b/src/picker.rs new file mode 100644 index 0000000..20e8d89 --- /dev/null +++ b/src/picker.rs @@ -0,0 +1,212 @@ +//! The `Picker`: one overlay, every choice. +//! +//! The wizard's provider, model, theme and posture steps are this widget with +//! different rows, and so are `/model` and `/theme`. Later releases add `/resume`, +//! `/fork` and the approval overlay, which are also this widget. +//! +//! Building it once is the reason the product has one look. The alternative — +//! adopting a Rust prompt library for the wizard — would have put a second owner +//! of raw mode and a second aesthetic in the one flow where first impressions are +//! formed, and would have had to be replaced anyway the first time an approval +//! needed to render inside the viewport. +//! +//! It lives in the viewport, never in scrollback. A choice that has scrolled away +//! cannot be answered. + +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::layout::Rect; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +use crate::theme::{Theme, Tone}; + +/// The marker in front of the selected row. Two cells, and a word — `>` is also +/// what the composer uses, so the two never appear at once. +const MARKER: &str = "› "; +const UNMARKED: &str = " "; + +/// One choice. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Row { + /// What the row is. Shown first, because content precedes metadata. + pub label: String, + /// A dimmer explanation, dropped first when the terminal is narrow. + pub detail: Option, +} + +impl Row { + pub fn new(label: impl Into) -> Self { + Self { + label: label.into(), + detail: None, + } + } + + pub fn with_detail(label: impl Into, detail: impl Into) -> Self { + Self { + label: label.into(), + detail: Some(detail.into()), + } + } +} + +/// What a keystroke did to the picker. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Outcome { + /// Handled, or ignored; the picker is still open. + Idle, + /// The user chose the row at this index. + Chosen(usize), + /// The user backed out. + Cancelled, +} + +pub struct Picker { + title: String, + rows: Vec, + selected: usize, + /// The first row currently drawn, so a long list scrolls instead of being cut. + offset: usize, +} + +impl Picker { + pub fn new(title: impl Into, rows: Vec) -> Self { + Self { + title: title.into(), + rows, + selected: 0, + offset: 0, + } + } + + /// Open with a row already selected — what `/theme` does, so the picker opens + /// on the theme in use rather than on the first one in the list. + pub fn selecting(mut self, index: usize) -> Self { + self.selected = index.min(self.rows.len().saturating_sub(1)); + self + } + + pub fn rows(&self) -> &[Row] { + &self.rows + } + + /// Which row is highlighted. Read every frame by the theme step, which + /// re-renders its sample transcript behind the picker as this moves. + pub fn selected(&self) -> usize { + self.selected + } + + /// Rows the picker wants, given the rows it has and the space available. + pub fn height(&self, available: u16) -> u16 { + let wanted = self.rows.len() as u16 + 1; // the title + wanted.min(available.max(1)) + } + + pub fn key(&mut self, key: KeyEvent) -> Outcome { + match key.code { + // Clamped at both ends rather than wrapping, for the same reason the + // composer's history is: a list that jumps from the last row to the + // first on one keypress loses the reader's place. + KeyCode::Up | KeyCode::Char('k') => { + self.selected = self.selected.saturating_sub(1); + Outcome::Idle + } + KeyCode::Down | KeyCode::Char('j') => { + if self.selected + 1 < self.rows.len() { + self.selected += 1; + } + Outcome::Idle + } + KeyCode::Home => { + self.selected = 0; + Outcome::Idle + } + KeyCode::End => { + self.selected = self.rows.len().saturating_sub(1); + Outcome::Idle + } + KeyCode::Enter => { + if self.rows.is_empty() { + Outcome::Idle + } else { + Outcome::Chosen(self.selected) + } + } + KeyCode::Esc => Outcome::Cancelled, + _ => Outcome::Idle, + } + } + + /// Draw into `area`, scrolling so the selected row is always visible. + pub fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) { + if area.height == 0 || area.width == 0 { + return; + } + + let mut lines = vec![Line::from(Span::styled( + self.title.clone(), + theme.style(Tone::Muted), + ))]; + + let visible = area.height.saturating_sub(1) as usize; + self.scroll_to_selection(visible); + + for (index, row) in self + .rows + .iter() + .enumerate() + .skip(self.offset) + .take(visible.max(1)) + { + let chosen = index == self.selected; + let marker = if chosen { MARKER } else { UNMARKED }; + let mut spans = vec![ + Span::styled(marker, theme.style(Tone::Accent)), + Span::styled( + row.label.clone(), + theme.style(if chosen { Tone::Accent } else { Tone::Normal }), + ), + ]; + if let Some(detail) = &row.detail { + // Fitted rather than wrapped. A row that wraps makes the list + // stop being a list, and the label is the part that has to + // survive — which is why the detail is what gets cut. + let used = marker.chars().count() + row.label.chars().count() + 2; + if let Some(room) = (area.width as usize).checked_sub(used) { + if room > 1 { + spans.push(Span::styled(" ", theme.style(Tone::Muted))); + spans.push(Span::styled(fit(detail, room), theme.style(Tone::Muted))); + } + } + } + lines.push(Line::from(spans)); + } + + frame.render_widget(Paragraph::new(lines), area); + } + + fn scroll_to_selection(&mut self, visible: usize) { + let visible = visible.max(1); + if self.selected < self.offset { + self.offset = self.selected; + } else if self.selected >= self.offset + visible { + self.offset = self.selected + 1 - visible; + } + let last_offset = self.rows.len().saturating_sub(visible); + self.offset = self.offset.min(last_offset); + } +} + +/// Shorten `text` to at most `room` cells, marking that it was shortened. +fn fit(text: &str, room: usize) -> String { + if text.chars().count() <= room { + return text.to_string(); + } + // Counted in characters, and the ellipsis is one character rather than three + // dots, so a shortened row and a full one are the same width. + let keep = room.saturating_sub(1); + let mut out: String = text.chars().take(keep).collect(); + out.push('…'); + out +} diff --git a/tests/picker.rs b/tests/picker.rs new file mode 100644 index 0000000..f11b453 --- /dev/null +++ b/tests/picker.rs @@ -0,0 +1,161 @@ +//! The `Picker`. Every selection surface in the product is this widget, so its +//! behaviour is asserted once here rather than in each caller. + +mod support; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use io_cli::picker::{Outcome, Picker, Row}; +use io_cli::theme::DARK; + +fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) +} + +fn providers() -> Picker { + Picker::new( + "Which provider?", + vec![ + Row::with_detail("OpenRouter", "one key, most models"), + Row::with_detail("Anthropic", "Claude, direct"), + Row::with_detail("OpenAI", "GPT, direct"), + Row::with_detail("Any OpenAI-compatible endpoint", "a base URL of your own"), + ], + ) +} + +#[test] +fn the_arrows_move_and_stop_at_both_ends() { + let mut picker = providers(); + assert_eq!(picker.selected(), 0); + + picker.key(key(KeyCode::Up)); + assert_eq!(picker.selected(), 0, "the first row is the top of the list"); + + picker.key(key(KeyCode::Down)); + picker.key(key(KeyCode::Down)); + assert_eq!(picker.selected(), 2); + + picker.key(key(KeyCode::Down)); + picker.key(key(KeyCode::Down)); + assert_eq!( + picker.selected(), + 3, + "the last row is the bottom of the list" + ); +} + +#[test] +fn enter_chooses_and_escape_backs_out() { + let mut picker = providers(); + picker.key(key(KeyCode::Down)); + assert_eq!(picker.key(key(KeyCode::Enter)), Outcome::Chosen(1)); + assert_eq!(picker.key(key(KeyCode::Esc)), Outcome::Cancelled); +} + +#[test] +fn an_empty_picker_cannot_be_chosen_from() { + let mut picker = Picker::new("Nothing here", vec![]); + assert_eq!(picker.key(key(KeyCode::Enter)), Outcome::Idle); + assert_eq!(picker.key(key(KeyCode::Down)), Outcome::Idle); + assert_eq!(picker.selected(), 0); +} + +#[test] +fn it_opens_on_the_row_the_caller_names() { + let picker = providers().selecting(2); + assert_eq!(picker.selected(), 2); + // Out of range is clamped rather than panicking: the caller is passing an + // index derived from configuration, which can name a row that no longer + // exists. + assert_eq!(providers().selecting(99).selected(), 3); +} + +#[test] +fn a_long_list_scrolls_so_the_selection_stays_visible() { + let rows: Vec = (0..40).map(|n| Row::new(format!("model-{n}"))).collect(); + let mut picker = Picker::new("Which model?", rows); + let (mut screen, _recorder) = support::screen_of(80, 24, 8); + + for _ in 0..30 { + picker.key(key(KeyCode::Down)); + } + assert_eq!(picker.selected(), 30); + + screen + .draw(|frame| picker.render(frame, frame.area(), &DARK)) + .expect("frame"); + + let viewport = screen.viewport_text(); + assert!( + viewport.contains("model-30"), + "the selected row scrolled out of the picker: {viewport:?}", + ); + assert!( + !viewport.contains("model-0\n"), + "the list did not scroll at all: {viewport:?}", + ); + assert!( + viewport.contains("Which model?"), + "the title is what says what is being chosen", + ); +} + +#[test] +fn f9_a_row_too_wide_for_eighty_columns_loses_its_detail_not_its_label() { + let mut picker = Picker::new( + "Which provider?", + vec![Row::with_detail( + "Any OpenAI-compatible endpoint", + "a base URL of your own, which is how a proxy, a gateway or a local runtime is reached", + )], + ); + let (mut screen, _recorder) = support::screen_of(80, 24, 6); + + screen + .draw(|frame| picker.render(frame, frame.area(), &DARK)) + .expect("frame"); + + let viewport = screen.viewport_text(); + assert!( + viewport.contains("Any OpenAI-compatible endpoint"), + "the label was cut: {viewport:?}", + ); + assert!( + viewport.contains('…'), + "the detail should be shortened with a marker: {viewport:?}", + ); + for line in viewport.lines() { + assert!( + line.chars().count() <= 80, + "a row overflowed eighty columns: {line:?}", + ); + } + // Counting the drawn rows, not the viewport's own blank remainder. + assert_eq!( + viewport.lines().filter(|line| !line.is_empty()).count(), + 2, + "the row wrapped instead of being fitted: {viewport:?}", + ); +} + +#[test] +fn the_selected_row_is_marked_by_more_than_colour() { + let mut picker = providers(); + picker.key(key(KeyCode::Down)); + let (mut screen, _recorder) = support::screen_of(80, 24, 6); + screen + .draw(|frame| picker.render(frame, frame.area(), &DARK)) + .expect("frame"); + + let viewport = screen.viewport_text(); + let marked: Vec<&str> = viewport + .lines() + .filter(|line| line.starts_with('›')) + .collect(); + assert_eq!( + marked.len(), + 1, + "exactly one row carries the marker: {viewport:?}", + ); + assert!(marked[0].contains("Anthropic"), "got {:?}", marked[0]); +} From 65f06e761ef0e26256986d4414187393ea490b24 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 16 Aug 2026 20:06:32 +0530 Subject: [PATCH 12/34] =?UTF-8?q?chore(develop):=20Render=20the=20status?= =?UTF-8?q?=20line=20with=20model,=20elapsed=20time=20and=20a=20w=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.1.0-T08 --- .../products/io-cli/execution/tasks.yaml | 24 ++- src/lib.rs | 1 + src/status.rs | 137 ++++++++++++++++++ tests/status.rs | 112 ++++++++++++++ 4 files changed, 271 insertions(+), 3 deletions(-) create mode 100644 src/status.rs create mode 100644 tests/status.rs diff --git a/.ultraship/products/io-cli/execution/tasks.yaml b/.ultraship/products/io-cli/execution/tasks.yaml index 2a04272..1bbbad8 100644 --- a/.ultraship/products/io-cli/execution/tasks.yaml +++ b/.ultraship/products/io-cli/execution/tasks.yaml @@ -294,7 +294,7 @@ tasks: why_required: F4 asserts that the viewport at rest contains the composer and the status line and nothing else, so the status line has to exist for that assertion to mean anything. F9 asserts it degrades to a narrow form rather than wrapping. - status: todo + status: done depends_on: - US-IO-CLI-0.1.0-T04 - US-IO-CLI-0.1.0-T05 @@ -305,8 +305,26 @@ tasks: degrades to its narrow form rather than wrapping. files: - src/status.rs - - tests/narrow.rs - evidence: [] + - src/lib.rs + - tests/status.rs + evidence: + - 42 tests pass across nine binaries; fmt and clippy -D warnings exit 0. Five of them are the + status line's. + - "Three fields filled — model, running state, elapsed — and the 0.2.0 fields are named in the + module rather than left to a later redesign: adding the policy layer, context pressure, + spend and containment is filling in a value." + - The running state is a WORD rather than a spinner. A spinner carries its meaning only to a + reader who can see it move, and this line has to work in a screen reader. + - "The narrow form drops whole fields from the right and never wraps: a status line that + becomes two lines has taken a row from the transcript and stopped being a status line. Below + the width of even one field the model is shown truncated, because a blank status line is + worse than a truncated one. Asserted across nine widths from 1 to 200." + - Field width is counted in characters, not bytes — the separator's middle dot is two bytes + and one cell, and byte accounting reserved room that was not needed and dropped a field a + column early. + - "SABOTAGE S — keep every field regardless of width: only the narrow-form test fails." + - "SABOTAGE T — render the running state as a tone with no word: three tests fail, all of them + status tests." - id: US-IO-CLI-0.1.0-T09 summary: "Map io-harness run events to rendered cells with an exhaustive match: `started`, `token`, `step`, `tool_call` and `finished` styled, `refused` and `approval_requested` as a plain diff --git a/src/lib.rs b/src/lib.rs index e9f9f65..08c0271 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,5 +14,6 @@ pub mod cli; pub mod composer; pub mod picker; pub mod splash; +pub mod status; pub mod term; pub mod theme; diff --git a/src/status.rs b/src/status.rs new file mode 100644 index 0000000..298e5bf --- /dev/null +++ b/src/status.rs @@ -0,0 +1,137 @@ +//! The status line: one row, always at the bottom of the viewport. +//! +//! This release fills three of its fields — the model answering, whether a turn +//! is running, and how long the session has been going. The rest of the line is +//! 0.2.0's: the policy layer in force, context pressure, spend against the tree +//! ceiling, and containment. They are named in [`Field`] now so that adding them +//! is filling in a value rather than redesigning the line. +//! +//! Its narrow form drops fields from the right rather than wrapping, because a +//! status line that becomes two lines has taken a row from the transcript and +//! stopped being a status line. + +use std::time::Duration; + +use ratatui::layout::Rect; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +use crate::theme::{Theme, Tone}; + +/// What separates two fields. Spaced, because a bare separator between two words +/// reads as punctuation inside one of them. +const SEPARATOR: &str = " · "; + +/// A field of the status line, in priority order: the first is the last to be +/// dropped when the terminal is narrow. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Field { + pub text: String, + pub tone: Tone, +} + +impl Field { + fn new(text: impl Into, tone: Tone) -> Self { + Self { + text: text.into(), + tone, + } + } +} + +/// What the status line is currently saying. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Status { + /// The model answering. First, because it is the field a reader looks for. + pub model: String, + /// Whether a turn is running. + pub working: bool, + /// How long the session has been open. + pub elapsed: Duration, +} + +impl Status { + pub fn new(model: impl Into) -> Self { + Self { + model: model.into(), + working: false, + elapsed: Duration::ZERO, + } + } + + /// The fields, most important first. + pub fn fields(&self) -> Vec { + vec![ + Field::new(self.model.clone(), Tone::Accent), + // A word, not an animation. A spinner carries the same meaning only + // for a reader who can see it move, and this line has to work in a + // screen reader and in a log. + if self.working { + Field::new("working", Tone::Normal) + } else { + Field::new("ready", Tone::Muted) + }, + Field::new(format_elapsed(self.elapsed), Tone::Muted), + ] + } + + /// The line, fitted to `width` by dropping whole fields from the right. + pub fn line(&self, width: u16, theme: &Theme) -> Line<'static> { + let fields = self.fields(); + let width = width as usize; + + let mut kept: Vec<&Field> = Vec::new(); + let mut used = 0usize; + for field in &fields { + // Counted in characters, not bytes: the separator's middle dot is two + // bytes and one cell, and `len()` here would reserve room that is not + // needed and drop a field one column early. + let extra = field.text.chars().count() + + if kept.is_empty() { + 0 + } else { + SEPARATOR.chars().count() + }; + if used + extra > width { + break; + } + used += extra; + kept.push(field); + } + + // Even at a width that fits nothing whole, the model is what gets shown, + // shortened. A blank status line is worse than a truncated one. + if kept.is_empty() { + let model: String = self.model.chars().take(width).collect(); + return Line::from(Span::styled(model, theme.style(Tone::Accent))); + } + + let mut spans = Vec::new(); + for (index, field) in kept.iter().enumerate() { + if index > 0 { + spans.push(Span::styled(SEPARATOR, theme.style(Tone::Muted))); + } + spans.push(Span::styled(field.text.clone(), theme.style(field.tone))); + } + Line::from(spans) + } + + pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { + if area.height == 0 || area.width == 0 { + return; + } + frame.render_widget(Paragraph::new(self.line(area.width, theme)), area); + } +} + +/// `12s`, `1m12s`, `1h02m`. Never a bare number of seconds past a minute, which +/// is unreadable at the point a session has been going long enough to care. +pub fn format_elapsed(elapsed: Duration) -> String { + let seconds = elapsed.as_secs(); + match seconds { + 0..=59 => format!("{seconds}s"), + 60..=3599 => format!("{}m{:02}s", seconds / 60, seconds % 60), + _ => format!("{}h{:02}m", seconds / 3600, (seconds % 3600) / 60), + } +} diff --git a/tests/status.rs b/tests/status.rs new file mode 100644 index 0000000..88e5971 --- /dev/null +++ b/tests/status.rs @@ -0,0 +1,112 @@ +//! The status line, and its share of F9: at eighty columns it degrades to a +//! narrow form rather than wrapping. + +mod support; + +use std::time::Duration; + +use io_cli::status::{format_elapsed, Status}; +use io_cli::theme::{DARK, PLAIN}; + +fn rendered(status: &Status, width: u16) -> String { + status + .line(width, &DARK) + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect() +} + +#[test] +fn it_says_the_model_the_state_and_the_elapsed_time() { + let mut status = Status::new("anthropic/claude-sonnet-4"); + status.elapsed = Duration::from_secs(72); + status.working = true; + + let line = rendered(&status, 80); + assert!(line.contains("anthropic/claude-sonnet-4"), "got {line:?}"); + assert!(line.contains("working"), "got {line:?}"); + assert!(line.contains("1m12s"), "got {line:?}"); +} + +#[test] +fn the_running_state_is_a_word_and_not_only_a_colour() { + let mut status = Status::new("m"); + assert!(rendered(&status, 80).contains("ready")); + status.working = true; + assert!(rendered(&status, 80).contains("working")); + + // The same under NO_COLOR, where the tone carries nothing at all. + let plain: String = status + .line(80, &PLAIN) + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + assert!(plain.contains("working"), "got {plain:?}"); +} + +#[test] +fn f9_a_narrow_terminal_drops_whole_fields_from_the_right() { + let mut status = Status::new("anthropic/claude-sonnet-4"); + status.elapsed = Duration::from_secs(72); + status.working = true; + + let wide = rendered(&status, 80); + assert_eq!( + wide, "anthropic/claude-sonnet-4 · working · 1m12s", + "the full line at eighty columns", + ); + + // Room for the model and the state, but not the clock. + let narrow = rendered(&status, 36); + assert_eq!(narrow, "anthropic/claude-sonnet-4 · working"); + + // Room for the model only. + let narrower = rendered(&status, 28); + assert_eq!(narrower, "anthropic/claude-sonnet-4"); + + for width in [1u16, 8, 20, 25, 26, 40, 43, 44, 200] { + let line = rendered(&status, width); + assert!( + line.chars().count() <= width as usize, + "the line overflowed {width} columns: {line:?}", + ); + assert!( + !line.contains('\n'), + "the status line wrapped at {width} columns: {line:?}", + ); + assert!(!line.is_empty(), "the line vanished at {width} columns"); + } +} + +#[test] +fn f9_it_renders_on_one_row_at_eighty_columns() { + let mut status = Status::new("anthropic/claude-sonnet-4"); + status.elapsed = Duration::from_secs(3725); + let (mut screen, _recorder) = support::screen(80, 24); + + screen + .draw(|frame| status.render(frame, frame.area(), &DARK)) + .expect("frame"); + + let viewport = screen.viewport_text(); + assert_eq!( + viewport.lines().filter(|line| !line.is_empty()).count(), + 1, + "the status line took more than one row: {viewport:?}", + ); + assert!(viewport.contains("1h02m"), "got {viewport:?}"); +} + +#[test] +fn elapsed_time_is_readable_at_every_scale() { + assert_eq!(format_elapsed(Duration::ZERO), "0s"); + assert_eq!(format_elapsed(Duration::from_secs(9)), "9s"); + assert_eq!(format_elapsed(Duration::from_secs(59)), "59s"); + assert_eq!(format_elapsed(Duration::from_secs(60)), "1m00s"); + assert_eq!(format_elapsed(Duration::from_secs(72)), "1m12s"); + assert_eq!(format_elapsed(Duration::from_secs(3599)), "59m59s"); + assert_eq!(format_elapsed(Duration::from_secs(3600)), "1h00m"); + assert_eq!(format_elapsed(Duration::from_secs(3725)), "1h02m"); +} From bb14ace9b71b3c6b52bcc9692f73622aded41ff1 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 16 Aug 2026 20:10:31 +0530 Subject: [PATCH 13/34] =?UTF-8?q?chore(develop):=20Map=20io-harness=20run?= =?UTF-8?q?=20events=20to=20rendered=20cells=20with=20an=20exh=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.1.0-T09 --- .../products/io-cli/execution/tasks.yaml | 32 +- src/events.rs | 203 ++++++++++++ src/lib.rs | 1 + tests/events.rs | 306 ++++++++++++++++++ tests/support/mod.rs | 80 +++++ 5 files changed, 620 insertions(+), 2 deletions(-) create mode 100644 src/events.rs create mode 100644 tests/events.rs diff --git a/.ultraship/products/io-cli/execution/tasks.yaml b/.ultraship/products/io-cli/execution/tasks.yaml index 1bbbad8..15df7e9 100644 --- a/.ultraship/products/io-cli/execution/tasks.yaml +++ b/.ultraship/products/io-cli/execution/tasks.yaml @@ -331,7 +331,7 @@ tasks: notice, every other kind as a muted line." why_required: F8 requires the match to be exhaustive with no wildcard that silently discards. This is also where token coalescing lives, without which a streaming turn strobes. - status: todo + status: done depends_on: - US-IO-CLI-0.1.0-T03 acceptance_criteria: @@ -342,8 +342,36 @@ tasks: Sabotage: add a catch-all arm that returns without rendering, under which only F8 fails. files: - src/events.rs + - src/lib.rs - tests/events.rs - evidence: [] + - tests/support/mod.rs + evidence: + - 48 tests pass across ten binaries; fmt and clippy -D warnings exit 0. Six of them are the + event renderer's. + - "EventKind is #[non_exhaustive] with fifty variants and no accessor for its own tag, so a + wildcard arm is REQUIRED BY THE TYPE rather than chosen. F8 is therefore asserted as the two + things that actually matter: no kind renders to nothing, and no kind exists that this + repository has never seen." + - The drift half reads io-harness's OWN SOURCE out of the cargo registry — the version taken + from this crate's lockfile, so the source read is the source built — parses the variants of + pub enum EventKind, and requires the set to equal the seven styled names plus the + forty-three that fall through. A fifty-first kind fails the test by name, which is the + moment someone has to decide what it should look like. A list copied into this repository + could not have noticed. + - "An unstyled kind names itself. The label comes from the derived Debug form, snake-cased the + way #[serde(rename_all)] does, because reaching the serde tag would mean carrying a + serializer for a label." + - "Tokens are coalesced: a token commits nothing and accumulates in a live buffer the viewport + draws, and the whole passage commits once when a step, a refusal or the finish closes it. + Committing per token would put a scrollback line in the terminal for every few characters + the model produced." + - A refusal keeps its rule and its layer even in the plain one-line notice this release + renders it as — those are the two facts no other core records, and 0.2.0's surface is a + presentation of them, not their first appearance. + - "SABOTAGE U — wildcard arm returns no lines: exactly the two no-silent-drop tests fail." + - "SABOTAGE V — commit each token as its own line: only the coalescing test fails." + - "SABOTAGE W — remove one kind from the known list: only the drift test fails, which is what + proves the drift test can fail at all." - id: US-IO-CLI-0.1.0-T10 summary: "Bridge io-harness to the screen: an `Observer` that hands events to the render loop without blocking the run, a session driven by `Session::turn_steered`, and `Ctrl+C` calling diff --git a/src/events.rs b/src/events.rs new file mode 100644 index 0000000..71e4383 --- /dev/null +++ b/src/events.rs @@ -0,0 +1,203 @@ +//! Turning io-harness run events into lines. +//! +//! Two rules shape this module. +//! +//! **Nothing is dropped.** `EventKind` is `#[non_exhaustive]` and has fifty +//! variants today, so a wildcard arm is not a shortcut here, it is required by +//! the type. What matters is that the wildcard *renders* — an event this release +//! has no design for arrives as a muted single line naming its kind, rather than +//! disappearing. An unrendered event is a bug this design should surface. +//! +//! **Streaming text is not committed a token at a time.** Tokens accumulate in a +//! live buffer that the viewport draws, and the whole passage is committed to +//! scrollback once when it is finished. Committing per token would put a line in +//! the terminal's scrollback for every few characters the model produced. + +use io_harness::{EventKind, RunEvent}; +use ratatui::text::{Line, Span}; + +use crate::theme::{Theme, Tone}; + +/// Accumulates streaming text and turns events into committed lines. +pub struct Events { + theme: Theme, + /// Model text received since the last commit. + live: String, +} + +impl Events { + pub fn new(theme: Theme) -> Self { + Self { + theme, + live: String::new(), + } + } + + pub fn set_theme(&mut self, theme: Theme) { + self.theme = theme; + } + + /// The text streamed but not yet committed. Drawn in the viewport, where it + /// can be replaced as it grows; a committed line cannot be. + pub fn live(&self) -> &str { + &self.live + } + + /// Commit whatever has streamed so far, if anything. + pub fn flush(&mut self) -> Vec> { + if self.live.trim().is_empty() { + self.live.clear(); + return Vec::new(); + } + let text = std::mem::take(&mut self.live); + let mut lines: Vec> = text + .lines() + .map(|line| { + Line::from(Span::styled( + line.to_string(), + self.theme.style(Tone::Normal), + )) + }) + .collect(); + lines.push(Line::from("")); + lines + } + + /// What this event commits to scrollback. Empty means "nothing yet" — which + /// for a token is the correct answer, not a dropped event. + pub fn event(&mut self, event: &RunEvent) -> Vec> { + let theme = self.theme; + match &event.kind { + EventKind::Token { text } => { + self.live.push_str(text); + Vec::new() + } + EventKind::Started { goal, provider } => { + let mut lines = vec![Line::from(vec![ + Span::styled("› ", theme.style(Tone::Accent)), + Span::styled(goal.clone(), theme.style(Tone::Normal)), + ])]; + lines.push(Line::from(Span::styled( + format!(" via {provider}"), + theme.style(Tone::Muted), + ))); + lines.push(Line::from("")); + lines + } + EventKind::Step { + decision, + tool_call, + tokens, + changed, + } => { + // A step's own narration commits after whatever streamed before + // it, so the transcript reads in the order it happened. + let mut lines = self.flush(); + let mut detail = format!("step {}", event.step); + if !tool_call.is_empty() { + detail.push_str(&format!(" · {tool_call}")); + } + detail.push_str(&format!(" · {tokens} tok")); + if *changed { + detail.push_str(" · changed files"); + } + lines.push(Line::from(vec![ + Span::styled(decision.clone(), theme.style(Tone::Muted)), + Span::styled(format!(" {detail}"), theme.style(Tone::Muted)), + ])); + lines + } + EventKind::ToolCall { name, target } => { + // One line per call. The full output goes to the run's durable + // trace rather than to the screen; uncollapsed tool output is what + // makes a transcript unreadable. + let target = if target.is_empty() { + String::new() + } else { + format!(" {target}") + }; + vec![Line::from(vec![ + Span::styled(" ⋅ ", theme.style(Tone::Muted)), + Span::styled(name.clone(), theme.style(Tone::Accent)), + Span::styled(target, theme.style(Tone::Muted)), + ])] + } + EventKind::Refused { + act, + target, + rule, + layer, + } => { + // A plain notice in this release. The surface that names the rule + // and the layer properly is 0.2.0's; what must not happen now is + // pretending to a surface that does not exist, or losing the two + // facts no other core records. + let mut text = format!("{act} {target}"); + if let Some(rule) = rule { + text.push_str(&format!(" — rule {rule}")); + } + if let Some(layer) = layer { + text.push_str(&format!(", layer {layer}")); + } + let mut lines = self.flush(); + lines.push(theme.notice(Tone::Refused, text)); + lines + } + EventKind::ApprovalRequested { act, target } => { + let mut lines = self.flush(); + lines.push(theme.notice( + Tone::Warning, + format!("{act} {target} needs approval; this release answers from the configured posture"), + )); + lines + } + EventKind::Finished { + outcome, + steps, + tokens, + } => { + let mut lines = self.flush(); + let tone = if outcome == "success" { + Tone::Success + } else { + Tone::Warning + }; + lines.push(theme.notice(tone, format!("{outcome} · {steps} steps · {tokens} tok"))); + lines.push(Line::from("")); + lines + } + // The other forty-three kinds. Not styled in this release, and not + // discarded either: each arrives as one muted line naming itself, so a + // release that starts emitting something new is visible rather than + // silent. + other => { + vec![Line::from(vec![ + Span::styled(" · ", theme.style(Tone::Muted)), + Span::styled(kind_name(other), theme.style(Tone::Muted)), + ])] + } + } + } +} + +/// The snake-case name of a kind, taken from its `Debug` form. +/// +/// `EventKind` is `#[non_exhaustive]` with no accessor for its own tag, and its +/// serde tag is only reachable by serializing — which would mean carrying a +/// serializer for a label. `Debug` is derived, its first token is the variant +/// name, and the mapping to snake case is the one `#[serde(rename_all)]` uses. +pub fn kind_name(kind: &EventKind) -> String { + let debug = format!("{kind:?}"); + let variant: String = debug + .chars() + .take_while(char::is_ascii_alphanumeric) + .collect(); + let mut snake = String::new(); + for (index, character) in variant.char_indices() { + if character.is_ascii_uppercase() && index > 0 { + snake.push('_'); + } + snake.push(character.to_ascii_lowercase()); + } + snake +} diff --git a/src/lib.rs b/src/lib.rs index 08c0271..6196ad9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,7 @@ pub mod cli; pub mod composer; +pub mod events; pub mod picker; pub mod splash; pub mod status; diff --git a/tests/events.rs b/tests/events.rs new file mode 100644 index 0000000..41f4f16 --- /dev/null +++ b/tests/events.rs @@ -0,0 +1,306 @@ +//! F8 — every event kind is accounted for. +//! +//! `io_harness::EventKind` is `#[non_exhaustive]`, so a wildcard arm is required +//! by the type rather than chosen. The criterion is therefore asserted as the two +//! things that actually matter: no kind renders to nothing, and no kind exists +//! that this repository has never seen. + +mod support; + +use io_cli::events::{kind_name, Events}; +use io_cli::theme::DARK; +use io_harness::{EventKind, RunEvent}; + +/// The kinds this release styles, in the contract's own words: `started`, +/// `token`, `step`, `tool_call` and `finished` rendered fully, `refused` and +/// `approval_requested` as a plain one-line notice. +const STYLED: &[&str] = &[ + "started", + "token", + "step", + "tool_call", + "finished", + "refused", + "approval_requested", +]; + +/// Every other kind io-harness 0.60.1 emits. Each renders as a muted single line +/// naming itself. This list is not decoration: the drift test below fails when +/// io-harness grows a kind that is in neither list, which is the moment somebody +/// has to decide what it should look like. +const FALLS_THROUGH: &[&str] = &[ + "approval_decided", + "spend_draw", + "retry", + "fell_back_to", + "replan", + "stalled", + "spawned", + "spawn_refused", + "child_detached", + "child_collected", + "fleet", + "memory_wrote", + "memory_forgot", + "todo_wrote", + "question_asked", + "question_answered", + "plan_proposed", + "plan_decided", + "reasoning", + "server_tool_used", + "sandbox", + "mcp", + "handle_started", + "handle_polled", + "handle_killed", + "handle_exited", + "handle_orphaned", + "reviewed", + "routed", + "plugin_loaded", + "plugin_dropped", + "lsp_started", + "browser_started", + "browser_navigated", + "speculated", + "dialed", + "rewound", + "reverted", + "answered", + "compacted", + "cache_marked", + "prompt_composed", + "contained", +]; + +fn event(kind: EventKind) -> RunEvent { + RunEvent::new(1, 1, kind) +} + +fn rendered(events: &mut Events, kind: EventKind) -> String { + events + .event(&event(kind)) + .iter() + .flat_map(|line| line.spans.iter()) + .map(|span| span.content.as_ref()) + .collect() +} + +#[test] +fn f8_every_styled_kind_renders_its_own_facts() { + let mut events = Events::new(DARK); + + let started = rendered( + &mut events, + EventKind::Started { + goal: "make the failing test pass".into(), + provider: "openrouter".into(), + }, + ); + assert!( + started.contains("make the failing test pass"), + "{started:?}" + ); + assert!(started.contains("openrouter"), "{started:?}"); + + let tool = rendered( + &mut events, + EventKind::ToolCall { + name: "exec".into(), + target: "cargo test".into(), + }, + ); + assert!(tool.contains("exec"), "{tool:?}"); + assert!(tool.contains("cargo test"), "{tool:?}"); + + let step = rendered( + &mut events, + EventKind::Step { + decision: "edited src/lib.rs".into(), + tool_call: "apply_patch".into(), + tokens: 1234, + changed: true, + }, + ); + assert!(step.contains("edited src/lib.rs"), "{step:?}"); + assert!(step.contains("1234"), "{step:?}"); + assert!(step.contains("changed files"), "{step:?}"); + + // The two facts no other core records must survive even the plain notice this + // release renders a refusal as. + let refused = rendered( + &mut events, + EventKind::Refused { + act: "write".into(), + target: "/etc/hosts".into(), + rule: Some("fs.deny".into()), + layer: Some("workspace".into()), + }, + ); + assert!(refused.contains("refused"), "{refused:?}"); + assert!(refused.contains("/etc/hosts"), "{refused:?}"); + assert!(refused.contains("fs.deny"), "{refused:?}"); + assert!(refused.contains("workspace"), "{refused:?}"); + + let approval = rendered( + &mut events, + EventKind::ApprovalRequested { + act: "exec".into(), + target: "rm -rf build".into(), + }, + ); + assert!(approval.contains("warning"), "{approval:?}"); + assert!(approval.contains("rm -rf build"), "{approval:?}"); + + let finished = rendered( + &mut events, + EventKind::Finished { + outcome: "success".into(), + steps: 7, + tokens: 9876, + }, + ); + assert!(finished.contains("ok"), "{finished:?}"); + assert!(finished.contains("success"), "{finished:?}"); + assert!(finished.contains('7'), "{finished:?}"); +} + +#[test] +fn f8_a_kind_this_release_does_not_style_still_renders_and_names_itself() { + let mut events = Events::new(DARK); + let line = rendered( + &mut events, + EventKind::SpendDraw { + tokens: 21, + remaining: Some(500), + }, + ); + assert!( + line.contains("spend_draw"), + "an unstyled kind must name itself rather than vanish: {line:?}", + ); +} + +#[test] +fn f8_tokens_are_coalesced_rather_than_committed_one_at_a_time() { + let mut events = Events::new(DARK); + for word in ["Here ", "is ", "the ", "answer."] { + let committed = events.event(&event(EventKind::Token { text: word.into() })); + assert!( + committed.is_empty(), + "a token committed a line to scrollback on its own", + ); + } + assert_eq!(events.live(), "Here is the answer."); + + // ...and the whole passage commits once, when something finishes it. + let finished = rendered( + &mut events, + EventKind::Finished { + outcome: "success".into(), + steps: 1, + tokens: 4, + }, + ); + assert!(finished.contains("Here is the answer."), "{finished:?}"); + assert_eq!(events.live(), "", "the live buffer should be emptied"); +} + +#[test] +fn f8_no_event_renders_to_nothing_except_a_token() { + let mut events = Events::new(DARK); + let kinds = vec![ + EventKind::Started { + goal: "g".into(), + provider: "p".into(), + }, + EventKind::Step { + decision: "d".into(), + tool_call: String::new(), + tokens: 0, + changed: false, + }, + EventKind::ToolCall { + name: "n".into(), + target: String::new(), + }, + EventKind::Refused { + act: "a".into(), + target: "t".into(), + rule: None, + layer: None, + }, + EventKind::ApprovalRequested { + act: "a".into(), + target: "t".into(), + }, + EventKind::Finished { + outcome: "cancelled".into(), + steps: 0, + tokens: 0, + }, + EventKind::Stalled, + EventKind::Replan { window: 8 }, + ]; + + for kind in kinds { + let name = kind_name(&kind); + let lines = events.event(&event(kind)); + assert!( + !lines.is_empty(), + "{name} rendered to nothing, which is an event silently discarded", + ); + } +} + +#[test] +fn the_kind_name_is_the_serde_tag() { + assert_eq!( + kind_name(&EventKind::ToolCall { + name: String::new(), + target: String::new() + }), + "tool_call", + ); + assert_eq!(kind_name(&EventKind::Stalled), "stalled"); + assert_eq!( + kind_name(&EventKind::Token { + text: String::new() + }), + "token", + ); +} + +#[test] +fn f8_this_release_has_seen_every_kind_io_harness_emits() { + let declared = support::harness_event_kinds(); + assert_eq!( + declared.len(), + 50, + "io-harness 0.60.1 declares fifty event kinds; found {}", + declared.len(), + ); + + let mut known: Vec<&str> = STYLED.to_vec(); + known.extend_from_slice(FALLS_THROUGH); + + let unseen: Vec<&String> = declared + .iter() + .filter(|name| !known.contains(&name.as_str())) + .collect(); + assert!( + unseen.is_empty(), + "io-harness emits kinds this repository has never seen: {unseen:?}. \ + Decide whether each is styled or falls through, and add it to the list.", + ); + + let gone: Vec<&&str> = known + .iter() + .filter(|name| !declared.contains(&(**name).to_string())) + .collect(); + assert!( + gone.is_empty(), + "these names are no longer io-harness event kinds: {gone:?}", + ); +} diff --git a/tests/support/mod.rs b/tests/support/mod.rs index 701bea2..6bffb1c 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -205,6 +205,86 @@ pub fn resize(screen: &mut io_cli::term::Screen, width: u16, height: u16) screen.resize(width, height).expect("resize"); } +/// Every event kind io-harness declares, snake-cased the way its serde tag is. +/// +/// Read out of the dependency's own source rather than from a list copied into +/// this repository, because a copied list cannot notice that the harness grew a +/// fifty-first kind — which is exactly the drift F8 exists to catch. The version +/// comes from this crate's own lockfile, so the source read is the source built. +pub fn harness_event_kinds() -> Vec { + let source = std::fs::read_to_string(harness_observe_path()) + .expect("io-harness's source is readable from the registry") + .replace("\r\n", "\n"); + let body = source + .split_once("pub enum EventKind {") + .expect("io-harness declares EventKind in src/observe.rs") + .1; + let body = body.split_once("\n}\n").expect("the enum is closed").0; + + let mut kinds = Vec::new(); + for line in body.lines() { + // A variant sits at exactly four spaces and starts with a capital; a doc + // line starts with `/`, an attribute with `#`, a field is indented eight. + let Some(rest) = line.strip_prefix(" ") else { + continue; + }; + if !rest.starts_with(|c: char| c.is_ascii_uppercase()) { + continue; + } + let variant: String = rest + .chars() + .take_while(char::is_ascii_alphanumeric) + .collect(); + let mut snake = String::new(); + for (index, character) in variant.char_indices() { + if character.is_ascii_uppercase() && index > 0 { + snake.push('_'); + } + snake.push(character.to_ascii_lowercase()); + } + kinds.push(snake); + } + kinds +} + +/// Where the io-harness version this crate is locked to unpacked its source. +fn harness_observe_path() -> std::path::PathBuf { + let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let lock = std::fs::read_to_string(manifest.join("Cargo.lock")).expect("the lockfile is here"); + let version = lock + .split("name = \"io-harness\"") + .nth(1) + .and_then(|rest| rest.split_once("version = \"")) + .and_then(|(_, rest)| rest.split_once('"')) + .map(|(version, _)| version.to_string()) + .expect("io-harness is in the lockfile"); + + let home = std::env::var_os("CARGO_HOME") + .map(std::path::PathBuf::from) + .or_else(|| { + std::env::var_os("HOME").map(|home| std::path::PathBuf::from(home).join(".cargo")) + }) + .expect("a cargo home"); + + let registries = home.join("registry").join("src"); + let entries = std::fs::read_dir(®istries) + .unwrap_or_else(|error| panic!("{} is readable: {error}", registries.display())); + for entry in entries.flatten() { + let candidate = entry + .path() + .join(format!("io-harness-{version}")) + .join("src") + .join("observe.rs"); + if candidate.is_file() { + return candidate; + } + } + panic!( + "io-harness {version} is not unpacked under {}", + registries.display() + ); +} + /// The escape sequences F5 forbids, with the names the contract uses. pub const FORBIDDEN: &[(&str, &str)] = &[ ("alternate screen (1049)", "\x1b[?1049h"), From acf2fbe9d3ec57874fd3bd5026829cc08de8258d Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 16 Aug 2026 20:13:51 +0530 Subject: [PATCH 14/34] =?UTF-8?q?chore(develop):=20Bridge=20io-harness=20t?= =?UTF-8?q?o=20the=20screen:=20an=20`Observer`=20that=20han=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.1.0-T10 --- .../products/io-cli/execution/tasks.yaml | 30 ++- src/app.rs | 208 +++++++++++++++++ src/bridge.rs | 38 ++++ src/lib.rs | 2 + tests/interrupt.rs | 209 ++++++++++++++++++ 5 files changed, 484 insertions(+), 3 deletions(-) create mode 100644 src/app.rs create mode 100644 src/bridge.rs create mode 100644 tests/interrupt.rs diff --git a/.ultraship/products/io-cli/execution/tasks.yaml b/.ultraship/products/io-cli/execution/tasks.yaml index 15df7e9..02631ce 100644 --- a/.ultraship/products/io-cli/execution/tasks.yaml +++ b/.ultraship/products/io-cli/execution/tasks.yaml @@ -380,7 +380,7 @@ tasks: keep the session, accept the next prompt — is asserted against it. `Observer::event` is synchronous and runs on the run's own task, so the bridge has to hand off rather than render in place. - status: todo + status: done depends_on: - US-IO-CLI-0.1.0-T06 - US-IO-CLI-0.1.0-T08 @@ -392,10 +392,34 @@ tasks: in scrollback, and the composer accepts the next prompt. A second `Ctrl+C` at an idle composer exits. Sabotage: drop the interrupt on the floor, under which only F6 fails. files: - - src/bridge.rs - src/app.rs + - src/bridge.rs + - src/lib.rs - tests/interrupt.rs - evidence: [] + evidence: + - 57 tests pass across eleven binaries; fmt and clippy -D warnings exit 0. Nine of them are + the bridge's and the session state's. + - "Observer::event is synchronous and runs on the RUN'S OWN TASK, so the bridge does one thing + — hand the event on down an unbounded channel — and the interface draws when it next gets + to. Rendering inside the observer would put terminal I/O on the agent loop's path: a slow + write slows the run, a blocking write stops it." + - "The channel is unbounded deliberately. A bounded one fills in one of two ways and both are + wrong here: blocking stalls the run, dropping loses an event, which is the failure F8 exists + to prevent. A thousand events through the bridge arrive as a thousand events." + - Ctrl+C during a turn returns Interrupt and the test drives Steer::interrupt, then reads + SteerInbox::pending and sees the interrupt with no message beside it. The turn's partial + output is committed by App::finished rather than lost with the turn, and the composer takes + the next prompt afterwards. F1 covers the same path against a real model. + - Ctrl+C with something typed clears the composer and does NOT count towards the exit, so a + stray keystroke cannot end a session by surprise; twice at an idle composer exits, and the + first one says so. + - Ctrl+D exits only on an empty composer; Ctrl+L clears the VIEWPORT and never the scrollback, + which on this renderer is the terminal's own buffer and not something this process could + redraw. + - "SABOTAGE X — Ctrl+C exits instead of interrupting: only the F6 interrupt test fails." + - "SABOTAGE Y — a finished turn drops what streamed: the same test fails, on the + partial-output assertion." + - "SABOTAGE Z — the observer returns Flow::Cancel: exactly the two bridge tests fail." - id: US-IO-CLI-0.1.0-T11 summary: Build the first-run wizard through its eight screens and write io-harness's configuration file at mode 0600, with nothing written before the confirmation screen. diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..0975933 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,208 @@ +//! The session's own state: what a keystroke means, and what the viewport holds. +//! +//! Kept apart from the code that drives io-harness so that both are testable. The +//! driver in `main.rs` owns the provider, the store and the turn future; this owns +//! everything a test needs to answer "what does `Ctrl+C` do in the middle of a +//! streaming turn". + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use io_harness::RunEvent; +use ratatui::layout::Rect; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +use crate::composer::{Composer, Reply}; +use crate::events::Events; +use crate::status::Status; +use crate::term::VIEWPORT_HEIGHT; +use crate::theme::{Theme, Tone}; + +/// Whether a turn is in flight. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Mode { + Idle, + Running, +} + +/// What the driver should do about a keystroke. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Command { + /// Nothing; the state already changed. + None, + /// Start a turn with this prompt. + Submit(String), + /// Run this slash command — the leading `/` removed. + Slash(String), + /// Call `Steer::interrupt` on the running turn. + Interrupt, + /// Leave. + Exit, + /// Repaint the viewport from scratch, never the scrollback. + ClearViewport, +} + +pub struct App { + pub composer: Composer, + pub status: Status, + pub events: Events, + pub theme: Theme, + mode: Mode, + /// How many times `Ctrl+C` has been pressed with nothing to interrupt and + /// nothing typed. Two in succession exits. + quits: u8, + /// Lines waiting to be committed to scrollback. + pending: Vec>, +} + +impl App { + pub fn new(theme: Theme, model: impl Into) -> Self { + Self { + composer: Composer::new(), + status: Status::new(model), + events: Events::new(theme), + theme, + mode: Mode::Idle, + quits: 0, + pending: Vec::new(), + } + } + + pub fn mode(&self) -> Mode { + self.mode + } + + /// A turn started. + pub fn started(&mut self) { + self.mode = Mode::Running; + self.status.working = true; + self.quits = 0; + } + + /// A turn ended, however it ended — finished, cancelled or failed. + /// + /// Whatever streamed and was never closed by an event is committed here, so an + /// interrupted turn keeps its partial output in the scrollback instead of + /// losing it with the turn. + pub fn finished(&mut self) { + self.mode = Mode::Idle; + self.status.working = false; + let tail = self.events.flush(); + self.pending.extend(tail); + } + + /// Take an event from the harness. + pub fn event(&mut self, event: &RunEvent) { + let lines = self.events.event(event); + self.pending.extend(lines); + } + + /// Add a line of io-cli's own, rather than the harness's. + pub fn say(&mut self, tone: Tone, text: impl Into) { + let line = self.theme.notice(tone, text); + self.pending.push(line); + } + + /// Everything waiting to go into the terminal's scrollback, emptied. + pub fn take_pending(&mut self) -> Vec> { + std::mem::take(&mut self.pending) + } + + /// Rows the viewport needs: the composer, the status line, and whatever is + /// streaming but not yet committed. + pub fn viewport_height(&self, width: u16) -> u16 { + let live = self.events.live(); + let streaming = if live.is_empty() { + 0 + } else { + live.lines().count().min(8) as u16 + 1 + }; + (self.composer.height(width) + 1 + streaming).max(VIEWPORT_HEIGHT) + } + + pub fn key(&mut self, key: KeyEvent) -> Command { + let control = key.modifiers.contains(KeyModifiers::CONTROL); + match (key.code, control) { + (KeyCode::Char('c'), true) => self.interrupt_or_quit(), + // Only on an empty composer, so it never discards something typed. + (KeyCode::Char('d'), true) if self.composer.is_empty() => Command::Exit, + (KeyCode::Char('d'), true) => Command::None, + // The viewport, never the scrollback. Clearing the terminal would + // destroy the transcript, which on this renderer is the terminal's own + // buffer rather than something this process can redraw. + (KeyCode::Char('l'), true) => Command::ClearViewport, + _ => { + self.quits = 0; + match self.composer.key(key) { + Reply::Idle => Command::None, + Reply::Submitted(text) => match text.strip_prefix('/') { + Some(command) => Command::Slash(command.trim().to_string()), + None => Command::Submit(text), + }, + } + } + } + } + + fn interrupt_or_quit(&mut self) -> Command { + if self.mode == Mode::Running { + // The turn is what gets stopped, not the process. `Steer::interrupt` + // ends it at a step boundary, whole, and leaves it resumable. + self.quits = 0; + self.say(Tone::Warning, "interrupting at the next step boundary"); + return Command::Interrupt; + } + if !self.composer.is_empty() { + self.composer.clear(); + self.quits = 0; + return Command::None; + } + self.quits += 1; + if self.quits >= 2 { + return Command::Exit; + } + self.say(Tone::Muted, "press Ctrl+C again to exit, or Ctrl+D"); + Command::None + } + + /// Draw the viewport: streaming text, then the composer, then the status line. + /// + /// 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) { + if area.height == 0 { + return; + } + let composer_rows = self.composer.height(area.width).min(area.height); + let status_rows = if area.height > composer_rows { 1 } else { 0 }; + let live_rows = area.height - composer_rows - status_rows; + + if live_rows > 0 && !self.events.live().is_empty() { + let live = Rect { + height: live_rows, + ..area + }; + frame.render_widget( + Paragraph::new(self.events.live().to_string()) + .wrap(ratatui::widgets::Wrap { trim: false }), + live, + ); + } + + let composer = Rect { + y: area.y + live_rows, + height: composer_rows, + ..area + }; + self.composer.render(frame, composer, &self.theme); + + if status_rows == 1 { + let status = Rect { + y: area.y + live_rows + composer_rows, + height: 1, + ..area + }; + self.status.render(frame, status, &self.theme); + } + } +} diff --git a/src/bridge.rs b/src/bridge.rs new file mode 100644 index 0000000..c80787c --- /dev/null +++ b/src/bridge.rs @@ -0,0 +1,38 @@ +//! The one seam between io-harness and the screen. +//! +//! `Observer::event` is synchronous and runs **on the run's own task**, in order, +//! for every event. Rendering inside it would put terminal I/O on the path of the +//! agent loop: a slow write would slow the run down, and a write that blocked +//! would stop it. So the observer does one thing — hand the event on — and the +//! interface draws whenever it next gets to. +//! +//! The channel is unbounded on purpose. A bounded one has two ways to behave when +//! it fills, and both are wrong here: blocking stalls the run, and dropping loses +//! an event, which is the failure F8 exists to prevent. The bound that matters is +//! that the interface drains continuously, which it does. + +use io_harness::{Flow, Observer, RunEvent}; +use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; + +/// The observer handed to `Session::turn_steered`. +pub struct Bridge { + events: UnboundedSender, +} + +/// A bridge and the receiver the interface drains. +pub fn channel() -> (Bridge, UnboundedReceiver) { + let (events, rx) = unbounded_channel(); + (Bridge { events }, rx) +} + +impl Observer for Bridge { + fn event(&self, event: &RunEvent) -> Flow { + // A send error means the interface is gone — the process is exiting, or + // the receiver was dropped. Nothing to do about it here, and cancelling + // the run from an observer is not this product's way of stopping a turn: + // `Ctrl+C` goes through `Steer::interrupt`, which ends the turn at a step + // boundary and leaves it resumable. + let _ = self.events.send(event.clone()); + Flow::Continue + } +} diff --git a/src/lib.rs b/src/lib.rs index 6196ad9..8091852 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,8 @@ //! //! [io-harness]: https://docs.rs/io-harness +pub mod app; +pub mod bridge; pub mod cli; pub mod composer; pub mod events; diff --git a/tests/interrupt.rs b/tests/interrupt.rs new file mode 100644 index 0000000..9d38628 --- /dev/null +++ b/tests/interrupt.rs @@ -0,0 +1,209 @@ +//! F6 — an interrupt stops the turn and keeps the session. +//! +//! Asserted at the seam rather than against a live provider: `Ctrl+C` during a +//! running turn must reach `Steer::interrupt`, the partial output must be +//! committed rather than lost with the turn, and the composer must take the next +//! prompt. A real interrupted turn against a real model is F1's job. + +mod support; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use io_cli::app::{App, Command, Mode}; +use io_cli::theme::DARK; +use io_harness::{EventKind, RunEvent, Steer}; + +fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) +} + +fn control(character: char) -> KeyEvent { + KeyEvent::new(KeyCode::Char(character), KeyModifiers::CONTROL) +} + +fn type_text(app: &mut App, text: &str) { + for character in text.chars() { + app.key(key(KeyCode::Char(character))); + } +} + +fn text_of(lines: &[ratatui::text::Line<'_>]) -> String { + lines + .iter() + .flat_map(|line| line.spans.iter()) + .map(|span| span.content.as_ref()) + .collect::>() + .join("") +} + +#[test] +fn f6_ctrl_c_during_a_turn_interrupts_it_and_keeps_the_partial_output() { + let (steer, inbox) = Steer::channel(); + let mut app = App::new(DARK, "anthropic/claude-sonnet-4"); + + // A prompt starts a turn. + type_text(&mut app, "refactor the parser"); + assert_eq!( + app.key(key(KeyCode::Enter)), + Command::Submit("refactor the parser".into()), + ); + app.started(); + assert_eq!(app.mode(), Mode::Running); + + // The model streams some of an answer. + for word in ["Looking ", "at ", "the ", "parser"] { + app.event(&RunEvent::new(1, 1, EventKind::Token { text: word.into() })); + } + assert_eq!(app.events.live(), "Looking at the parser"); + + // Ctrl+C. + assert_eq!(app.key(control('c')), Command::Interrupt); + steer.interrupt().expect("the turn is still listening"); + + let (messages, interrupted) = inbox.pending(); + assert!(interrupted, "Steer::interrupt never reached the inbox"); + assert!(messages.is_empty(), "an interrupt is not a message"); + + // The turn ends. The partial output is committed rather than lost with it. + app.finished(); + let committed = text_of(&app.take_pending()); + assert!( + committed.contains("Looking at the parser"), + "the partial answer was lost when the turn was interrupted: {committed:?}", + ); + assert_eq!(app.mode(), Mode::Idle); + assert_eq!(app.events.live(), ""); + + // And the composer takes the next prompt. + type_text(&mut app, "try again"); + assert_eq!( + app.key(key(KeyCode::Enter)), + Command::Submit("try again".into()), + "the session did not survive the interrupt", + ); +} + +#[test] +fn f6_ctrl_c_twice_at_an_idle_composer_exits() { + let mut app = App::new(DARK, "m"); + assert_eq!(app.key(control('c')), Command::None, "the first one warns"); + let warning = text_of(&app.take_pending()); + assert!( + warning.contains("again"), + "the first Ctrl+C should say what the second one does: {warning:?}", + ); + assert_eq!(app.key(control('c')), Command::Exit); +} + +#[test] +fn ctrl_c_with_something_typed_clears_it_rather_than_exiting() { + let mut app = App::new(DARK, "m"); + type_text(&mut app, "half a thought"); + + assert_eq!(app.key(control('c')), Command::None); + assert!( + app.composer.is_empty(), + "the composer should have been cleared" + ); + // ...and that did not count towards the exit, so a stray Ctrl+C cannot end + // the session by surprise. + assert_eq!(app.key(control('c')), Command::None); + assert_eq!(app.key(control('c')), Command::Exit); +} + +#[test] +fn ctrl_d_exits_only_on_an_empty_composer() { + let mut app = App::new(DARK, "m"); + type_text(&mut app, "not finished yet"); + assert_eq!( + app.key(control('d')), + Command::None, + "Ctrl+D must not discard what was typed", + ); + + app.composer.clear(); + assert_eq!(app.key(control('d')), Command::Exit); +} + +#[test] +fn ctrl_l_clears_the_viewport_and_says_so() { + let mut app = App::new(DARK, "m"); + assert_eq!(app.key(control('l')), Command::ClearViewport); +} + +#[test] +fn a_prompt_beginning_with_a_slash_is_a_command() { + let mut app = App::new(DARK, "m"); + type_text(&mut app, "/theme"); + assert_eq!(app.key(key(KeyCode::Enter)), Command::Slash("theme".into())); + + type_text(&mut app, "/model gpt-5"); + assert_eq!( + app.key(key(KeyCode::Enter)), + Command::Slash("model gpt-5".into()), + ); +} + +#[test] +fn the_observer_hands_events_on_without_blocking_the_run() { + use io_harness::Observer; + + let (bridge, mut receiver) = io_cli::bridge::channel(); + for step in 0..1000 { + let flow = bridge.event(&RunEvent::new(1, step, EventKind::Stalled)); + assert_eq!( + flow, + io_harness::Flow::Continue, + "the observer must not cancel the run it is watching", + ); + } + + let mut seen = 0; + while receiver.try_recv().is_ok() { + seen += 1; + } + assert_eq!( + seen, 1000, + "an event was dropped between the run and the screen" + ); +} + +#[test] +fn a_dropped_interface_does_not_cancel_the_run() { + use io_harness::Observer; + + let (bridge, receiver) = io_cli::bridge::channel(); + drop(receiver); + assert_eq!( + bridge.event(&RunEvent::new(1, 1, EventKind::Stalled)), + io_harness::Flow::Continue, + "a send failure must not stop the agent loop", + ); +} + +#[test] +fn f9_the_viewport_grows_for_streaming_text_and_shrinks_again() { + let mut app = App::new(DARK, "m"); + let quiet = app.viewport_height(80); + assert!(quiet >= 3, "composer plus status is at least three rows"); + + for _ in 0..4 { + app.event(&RunEvent::new( + 1, + 1, + EventKind::Token { + text: "a line of an answer\n".into(), + }, + )); + } + assert!( + app.viewport_height(80) > quiet, + "the viewport should make room for what is streaming", + ); + + app.finished(); + assert_eq!( + app.viewport_height(80), + quiet, + "and give the rows back once it is committed", + ); +} From e1ed9bcb96b2eebdaf987983184d86e37824e828 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 16 Aug 2026 20:22:33 +0530 Subject: [PATCH 15/34] =?UTF-8?q?chore(develop):=20Build=20the=20first-run?= =?UTF-8?q?=20wizard=20through=20its=20eight=20screens=20an=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.1.0-T11 --- .../products/io-cli/execution/tasks.yaml | 41 +- src/lib.rs | 2 + src/settings.rs | 178 +++++ src/wizard.rs | 712 ++++++++++++++++++ tests/wizard.rs | 409 ++++++++++ 5 files changed, 1340 insertions(+), 2 deletions(-) create mode 100644 src/settings.rs create mode 100644 src/wizard.rs create mode 100644 tests/wizard.rs diff --git a/.ultraship/products/io-cli/execution/tasks.yaml b/.ultraship/products/io-cli/execution/tasks.yaml index 02631ce..7d7b8e5 100644 --- a/.ultraship/products/io-cli/execution/tasks.yaml +++ b/.ultraship/products/io-cli/execution/tasks.yaml @@ -426,7 +426,7 @@ tasks: why_required: F2 is the wizard writing what it showed, and N5 is the credential never reaching the screen, the scrollback, a log line or the trace. The release's stated outcome starts from an empty configuration, so without the wizard there is no first run at all. - status: todo + status: done depends_on: - US-IO-CLI-0.1.0-T07 - US-IO-CLI-0.1.0-T10 @@ -445,8 +445,45 @@ tasks: files: - src/wizard.rs - src/settings.rs + - src/lib.rs - tests/wizard.rs - evidence: [] + evidence: + - 64 tests pass across twelve binaries; fmt and clippy -D warnings exit 0. Seven of them are + the wizard's, driven by a scripted key sequence over a real CrosstermBackend so the byte + stream N5 asserts over is the one a terminal would have received. + - "A REAL FINDING that changes how F2 can be asserted: Config::from_toml parses text as the + PROJECT scope, and io-harness REFUSES a project-scoped file that widens the permission + boundary. The wizard's default posture — sandboxed workspace — sets exec = allow, so the + file it writes is correct and from_toml is right to reject it: widening is the operator's + own decision and belongs to the user scope, which is where the wizard writes. The test + therefore reads it back through Config::discover with the user file OUTSIDE the discovery + root, and separately checks that a narrowing posture parses either way. F2's own scripted + run chooses 'ask before writes', which narrows, so the criterion's literal from_toml + assertion is still made and still passes." + - "A second trap found while fixing the first: a user file placed at /io.toml is ALSO + the project-scope candidate, so it is read under project rules and refused. The user scope + has to be somewhere the workspace is not." + - "F2: the scripted run picks OpenRouter, types a key, takes the second model, the light theme + and the ask-before-writes posture; the written file parses, provider_spec returns OpenRouter + with openai/gpt-5 and the typed key, the policy's write default is Ask and net is Deny, + [app.io-cli] gives back the chosen theme, and the file's mode is 0600 on unix. Nothing + exists on disk at any point before the confirmation screen." + - "N5: the key never appears in the byte stream, nor do its 12, 20 or 28 character prefixes; + the field renders bullets, so the absence is masking rather than the key simply not being + drawn; and the confirmation screen describes the credential as 'written into this file, mode + 0600' without any of its value." + - Where an environment variable already carries the key, Enter on an empty field writes the + file WITHOUT a key at all — the better outcome rather than a fallback, since a key that is + never on disk cannot leak from it. + - A posture is an io_harness::Policy rather than a flag of io-cli's own, which is what will + let the status line name the layer in force. 'Ask before writes' says plainly at the moment + it is chosen that a write is declined until the approval surface lands, rather than implying + a behaviour this release does not have. + - "SABOTAGE AA — produce the write when the posture is chosen, skipping confirmation: only the + F2 test fails." + - "SABOTAGE BB — remove the mask character: only the F2/N5 test fails." + - "SABOTAGE CC — a rejection carries on to the model step: both F3 tests fail and nothing + else." - id: US-IO-CLI-0.1.0-T12 summary: Verify the credential against the live endpoint inside the wizard, returning to the credential step with the provider's own message and writing no file on failure. diff --git a/src/lib.rs b/src/lib.rs index 8091852..091af3c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,7 +16,9 @@ pub mod cli; pub mod composer; pub mod events; pub mod picker; +pub mod settings; pub mod splash; pub mod status; pub mod term; pub mod theme; +pub mod wizard; diff --git a/src/settings.rs b/src/settings.rs new file mode 100644 index 0000000..a1b3a0e --- /dev/null +++ b/src/settings.rs @@ -0,0 +1,178 @@ +//! io-cli's own settings, and writing io-harness's configuration file. +//! +//! **There is no configuration parser in this repository.** io-harness owns +//! discovery, layering and validation; this module hands it a `ProviderSpec` and +//! a `Defaults` — types the harness declares and already derives `Serialize` for — +//! and serializes them. Reading comes back through `Config::discover` and +//! `Config::app`, which is the section the harness deliberately does not validate +//! because it belongs to whoever is building on top of it. + +use std::io; +use std::path::{Path, PathBuf}; + +use io_harness::{Defaults, Effect, ProviderSpec}; +use serde::{Deserialize, Serialize}; + +/// The key io-cli's own section sits under: `[app.io-cli]`. +pub const APP_KEY: &str = "io-cli"; + +/// Everything io-cli itself remembers. One field in this release. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct CliSettings { + /// The theme by name. Absent means "detect from the terminal background". + #[serde(default, skip_serializing_if = "Option::is_none")] + pub theme: Option, +} + +/// A default permission posture, in the words the wizard offers it in. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Posture { + /// Read, write and run inside the workspace; no outbound network. + Workspace, + /// Read freely; writes and commands ask first. + AskWrites, + /// Read only. + ReadOnly, +} + +impl Posture { + pub const ALL: &'static [Posture] = + &[Posture::Workspace, Posture::AskWrites, Posture::ReadOnly]; + + pub fn label(self) -> &'static str { + match self { + Self::Workspace => "Sandboxed workspace", + Self::AskWrites => "Ask before writes", + Self::ReadOnly => "Read only", + } + } + + pub fn detail(self) -> &'static str { + match self { + Self::Workspace => "read, write and run inside this repository; no outbound network", + // Said plainly rather than implied. The overlay that asks a human is + // 0.2.0's; until it exists, "ask" is answered by declining, and a + // posture whose behaviour is not what its name suggests has to say so + // at the moment it is chosen. + Self::AskWrites => { + "read freely; a write or a command is declined until the approval surface lands" + } + Self::ReadOnly => "read only; nothing is written and nothing is run", + } + } + + /// The policy defaults this posture is. + /// + /// A posture is an `io_harness::Policy`, not a flag of io-cli's own. That is + /// what makes the status line able to name the layer in force, and what will + /// make a refusal able to name the rule that produced it. + pub fn defaults(self) -> Defaults { + match self { + Self::Workspace => Defaults { + read: Effect::Allow, + write: Effect::Allow, + exec: Effect::Allow, + net: Effect::Deny, + }, + Self::AskWrites => Defaults { + read: Effect::Allow, + write: Effect::Ask, + exec: Effect::Ask, + net: Effect::Deny, + }, + Self::ReadOnly => Defaults { + read: Effect::Allow, + write: Effect::Deny, + exec: Effect::Deny, + net: Effect::Deny, + }, + } + } +} + +/// The whole file, as it will be written. +#[derive(Debug, Serialize)] +struct File<'a> { + provider: Vec<&'a ProviderSpec>, + policy: PolicySection, + app: AppSection, +} + +#[derive(Debug, Serialize)] +struct PolicySection { + defaults: Defaults, +} + +#[derive(Debug, Serialize)] +struct AppSection { + #[serde(rename = "io-cli")] + io_cli: CliSettings, +} + +/// Render the configuration file's text. +/// +/// Separate from writing it so the confirmation screen can show exactly what is +/// about to land, and so a test can read it without a filesystem. +pub fn render( + spec: &ProviderSpec, + posture: Posture, + theme: &str, +) -> Result { + let file = File { + provider: vec![spec], + policy: PolicySection { + defaults: posture.defaults(), + }, + app: AppSection { + io_cli: CliSettings { + theme: Some(theme.to_string()), + }, + }, + }; + toml::to_string_pretty(&file) +} + +/// Where io-harness looks for the user-scope file. +/// +/// The harness's own function, not a copy of its rules: `$IO_CONFIG`, else +/// `$IO_CONFIG_HOME/io.toml`, else the platform's own place. Duplicating that +/// here would be a second answer to a question the harness already answers, and +/// the two would drift. +pub fn user_path() -> Option { + io_harness::config::user_path() +} + +/// Write the file, creating its directory, with mode `0600` on unix. +/// +/// The mode is set on the file that is created rather than afterwards, so there +/// is no window in which a key sits on disk world-readable. This is what `gh`, +/// `aws` and `npm` do; an OS keychain is a later question and not obviously a +/// better answer, since one that fails silently on a headless Linux box is worse +/// than a file that never does. +pub fn write(path: &Path, contents: &str) -> io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + #[cfg(unix)] + { + use std::io::Write as _; + use std::os::unix::fs::OpenOptionsExt; + + let mut file = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path)?; + file.write_all(contents.as_bytes())?; + file.sync_all() + } + #[cfg(not(unix))] + { + // Windows has no mode bits. The file lands in the user's own roaming + // profile, which is already per-user, and pretending otherwise by + // reporting a mode we did not set would be worse than saying nothing. + std::fs::write(path, contents) + } +} diff --git a/src/wizard.rs b/src/wizard.rs new file mode 100644 index 0000000..4a91ce2 --- /dev/null +++ b/src/wizard.rs @@ -0,0 +1,712 @@ +//! The first-run wizard. +//! +//! Eight screens, driven by the product's own [`Picker`](crate::picker::Picker) +//! rather than by a prompt library — which would have been a second owner of raw +//! mode and a second aesthetic in the one flow where a first impression is formed. +//! +//! Two rules run through it: +//! +//! - **Nothing is written before the confirmation screen.** The screen names the +//! exact path and shows the exact text; the write happens on the keystroke after +//! it and nowhere else. +//! - **The credential is never rendered.** It is masked while typed, it is +//! summarised rather than shown, and where an environment variable already +//! carries it the file is written without it at all. +//! +//! The steps that need the network — verifying the key, and reading the model +//! catalogue — are handed back to the driver rather than performed here, so this +//! whole flow is drivable from a test with a scripted key sequence. + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use io_harness::ProviderSpec; +use ratatui::layout::Rect; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; +use tui_textarea::TextArea; + +use crate::picker::{Outcome, Picker, Row}; +use crate::settings::{self, Posture}; +use crate::theme::{Theme, Tone, THEMES}; + +/// The providers on offer: the variants `io_harness::ProviderSpec` declares. +/// Enumerated from the type, not invented here — a provider the harness gains is +/// a row this list gains, and nothing else. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Kind { + OpenRouter, + Anthropic, + OpenAi, + Compatible, +} + +impl Kind { + pub const ALL: &'static [Kind] = &[ + Kind::OpenRouter, + Kind::Anthropic, + Kind::OpenAi, + Kind::Compatible, + ]; + + pub fn label(self) -> &'static str { + match self { + Self::OpenRouter => "OpenRouter", + Self::Anthropic => "Anthropic", + Self::OpenAi => "OpenAI", + Self::Compatible => "Any OpenAI-compatible endpoint", + } + } + + pub fn detail(self) -> &'static str { + match self { + Self::OpenRouter => "one key, most models", + Self::Anthropic => "Claude, direct", + Self::OpenAi => "GPT, direct", + Self::Compatible => "a base URL of your own: a proxy, a gateway, a local runtime", + } + } + + /// The environment variable this provider reads when the file names no key. + pub fn env_var(self) -> Option<&'static str> { + match self { + Self::OpenRouter => Some("OPENROUTER_API_KEY"), + Self::Anthropic => Some("ANTHROPIC_API_KEY"), + Self::OpenAi => Some("OPENAI_API_KEY"), + // There is no single vendor to name a variable for. + Self::Compatible => None, + } + } + + /// A model id to start from, so the picker opens on something plausible when + /// the catalogue cannot be read. + pub fn default_model(self) -> &'static str { + match self { + Self::OpenRouter => "anthropic/claude-sonnet-4", + Self::Anthropic => "claude-sonnet-4", + Self::OpenAi => "gpt-5", + Self::Compatible => "", + } + } + + fn spec( + self, + model: String, + api_key: Option, + base_url: Option, + ) -> ProviderSpec { + match self { + Self::OpenRouter => ProviderSpec::OpenRouter { model, api_key }, + Self::Anthropic => ProviderSpec::Anthropic { model, api_key }, + Self::OpenAi => ProviderSpec::OpenAi { model, api_key }, + Self::Compatible => ProviderSpec::Compatible { + model, + api_key, + base_url, + preset: None, + auth: None, + name: None, + // Prices come from the reference catalogue for an endpoint that + // states none of its own, which is what a proxy or a local runtime + // does. Without it the status line's spend field has nothing to + // read when 0.2.0 adds it. + reference_prices: true, + }, + } + } +} + +/// Which screen the wizard is on. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Step { + Welcome, + Provider, + /// Only for a compatible endpoint, which has no vendor to assume. + BaseUrl, + Credential, + /// Waiting for the driver to make the verification call. + Verifying, + Model, + Theme, + Posture, + Confirm, + Done, + Cancelled, +} + +/// What the driver has to do next. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Progress { + /// Nothing; the wizard changed its own state. + Idle, + /// Commit these lines to scrollback. + Commit(Vec>), + /// Verify this credential against the live endpoint, then call + /// [`Wizard::verified`] or [`Wizard::rejected`]. + Verify(ProviderSpec), + /// Read the model catalogue, then call [`Wizard::catalogue`]. + Catalogue(ProviderSpec), + /// Write this text to this path, mode 0600. + Write(std::path::PathBuf, String), + /// The user backed out. Nothing was written. + Cancelled, +} + +pub struct Wizard { + step: Step, + theme: Theme, + kind: Option, + base_url: Option, + /// Typed by the user, or `None` when the environment already carries it. + api_key: Option, + model: Option, + theme_name: String, + posture: Posture, + picker: Option, + /// The masked field the credential and the base URL are typed into. + input: TextArea<'static>, + /// The provider's own message from a failed verification, shown above the + /// credential field. + rejection: Option, + /// Whether the environment already carries a usable key for this provider. + env_key_present: bool, +} + +impl Wizard { + pub fn new(theme: Theme) -> Self { + Self { + step: Step::Welcome, + theme, + kind: None, + base_url: None, + api_key: None, + model: None, + theme_name: theme.name.to_string(), + posture: Posture::Workspace, + picker: None, + input: masked(), + rejection: None, + env_key_present: false, + } + } + + pub fn step(&self) -> Step { + self.step + } + + /// The theme currently highlighted, which the sample transcript behind the + /// theme picker re-renders in as the selection moves. + pub fn theme(&self) -> Theme { + self.theme + } + + pub fn done(&self) -> bool { + matches!(self.step, Step::Done | Step::Cancelled) + } + + /// The provider spec as it stands, or `None` before there is one. + pub fn spec(&self) -> Option { + let kind = self.kind?; + Some( + kind.spec( + self.model + .clone() + .unwrap_or_else(|| kind.default_model().to_string()), + self.api_key.clone(), + self.base_url.clone(), + ), + ) + } + + pub fn key(&mut self, key: KeyEvent) -> Progress { + if key.code == KeyCode::Esc && self.step != Step::Credential { + self.step = Step::Cancelled; + return Progress::Cancelled; + } + match self.step { + Step::Welcome => self.welcome(key), + Step::Provider => self.provider(key), + Step::BaseUrl => self.base_url(key), + Step::Credential => self.credential(key), + // The driver owns these; a keystroke while waiting does nothing rather + // than queueing up behind a network call. + Step::Verifying => Progress::Idle, + Step::Model => self.model(key), + Step::Theme => self.theme_step(key), + Step::Posture => self.posture(key), + Step::Confirm => self.confirm(key), + Step::Done | Step::Cancelled => Progress::Idle, + } + } + + /// The driver's answer to [`Progress::Verify`]: the key works. + pub fn verified(&mut self) -> Progress { + self.rejection = None; + self.step = Step::Model; + self.picker = Some(Picker::new( + "Which model?", + vec![Row::new( + self.kind + .map(Kind::default_model) + .unwrap_or_default() + .to_string(), + )], + )); + self.spec() + .map(Progress::Catalogue) + .unwrap_or(Progress::Idle) + } + + /// The driver's answer to [`Progress::Verify`]: the provider said no, in + /// these words. + /// + /// Back to the credential step carrying the provider's own message, and + /// **nothing is written** — the file is only ever written from the + /// confirmation screen, which this never reaches. + pub fn rejected(&mut self, message: impl Into) -> Progress { + self.rejection = Some(message.into()); + self.input = masked(); + self.step = Step::Credential; + Progress::Idle + } + + /// The driver's answer to [`Progress::Catalogue`]. + pub fn catalogue(&mut self, models: Vec) { + if self.step != Step::Model { + return; + } + let default = self.kind.map(Kind::default_model).unwrap_or_default(); + let mut rows: Vec = models.iter().map(|id| Row::new(id.clone())).collect(); + if rows.is_empty() { + rows.push(Row::with_detail( + default.to_string(), + "the catalogue could not be read; this is the provider's usual default", + )); + } + let opening = rows + .iter() + .position(|row| row.label == default) + .unwrap_or(0); + self.picker = Some(Picker::new("Which model?", rows).selecting(opening)); + } + + fn welcome(&mut self, key: KeyEvent) -> Progress { + if key.code != KeyCode::Enter { + return Progress::Idle; + } + self.step = Step::Provider; + self.picker = Some(Picker::new( + "Which provider?", + Kind::ALL + .iter() + .map(|kind| Row::with_detail(kind.label(), kind.detail())) + .collect(), + )); + Progress::Commit(vec![ + Line::from(Span::styled( + "No configuration found, so this is the first run.", + self.theme.style(Tone::Normal), + )), + Line::from(Span::styled( + "Nothing is written until the last screen says so.", + self.theme.style(Tone::Muted), + )), + Line::from(""), + ]) + } + + fn provider(&mut self, key: KeyEvent) -> Progress { + let Some(picker) = self.picker.as_mut() else { + return Progress::Idle; + }; + match picker.key(key) { + Outcome::Chosen(index) => { + let kind = Kind::ALL[index]; + self.kind = Some(kind); + self.env_key_present = kind + .env_var() + .map(|var| std::env::var_os(var).is_some_and(|value| !value.is_empty())) + .unwrap_or(false); + self.picker = None; + self.input = masked(); + self.step = if kind == Kind::Compatible { + self.input = plain(); + Step::BaseUrl + } else { + Step::Credential + }; + Progress::Idle + } + Outcome::Cancelled => { + self.step = Step::Cancelled; + Progress::Cancelled + } + Outcome::Idle => Progress::Idle, + } + } + + fn base_url(&mut self, key: KeyEvent) -> Progress { + if key.code == KeyCode::Enter { + let typed = self.input.lines().join("").trim().to_string(); + if typed.is_empty() { + return Progress::Idle; + } + self.base_url = Some(typed); + self.input = masked(); + self.step = Step::Credential; + return Progress::Idle; + } + self.input.input(key); + Progress::Idle + } + + fn credential(&mut self, key: KeyEvent) -> Progress { + match key.code { + KeyCode::Esc => { + self.step = Step::Cancelled; + Progress::Cancelled + } + KeyCode::Enter => { + let typed = self.input.lines().join("").trim().to_string(); + if typed.is_empty() { + if !self.env_key_present { + return Progress::Idle; + } + // The environment already carries it, so the file is written + // without a key at all — which is the better outcome, not a + // fallback: a key that is never on disk cannot leak from it. + self.api_key = None; + } else { + self.api_key = Some(typed); + } + self.input = masked(); + self.step = Step::Verifying; + self.spec().map(Progress::Verify).unwrap_or(Progress::Idle) + } + _ => { + self.input.input(key); + Progress::Idle + } + } + } + + fn model(&mut self, key: KeyEvent) -> Progress { + let Some(picker) = self.picker.as_mut() else { + return Progress::Idle; + }; + match picker.key(key) { + Outcome::Chosen(index) => { + self.model = picker.rows().get(index).map(|row| row.label.clone()); + self.step = Step::Theme; + self.picker = Some( + Picker::new( + "Which theme?", + THEMES + .iter() + .map(|theme| { + Row::with_detail(theme.name, "the sample below re-renders") + }) + .collect(), + ) + .selecting( + THEMES + .iter() + .position(|theme| theme.name == self.theme_name) + .unwrap_or(0), + ), + ); + Progress::Idle + } + Outcome::Cancelled => { + self.step = Step::Cancelled; + Progress::Cancelled + } + Outcome::Idle => Progress::Idle, + } + } + + fn theme_step(&mut self, key: KeyEvent) -> Progress { + let Some(picker) = self.picker.as_mut() else { + return Progress::Idle; + }; + let outcome = picker.key(key); + // Read after every keystroke, not only on choice: this is the live + // preview, and it costs nothing because the renderer is already there. + if let Some(theme) = THEMES.get(picker.selected()) { + self.theme = *theme; + self.theme_name = theme.name.to_string(); + } + match outcome { + Outcome::Chosen(_) => { + self.step = Step::Posture; + self.picker = Some(Picker::new( + "How much should it be allowed to do?", + Posture::ALL + .iter() + .map(|posture| Row::with_detail(posture.label(), posture.detail())) + .collect(), + )); + Progress::Idle + } + Outcome::Cancelled => { + self.step = Step::Cancelled; + Progress::Cancelled + } + Outcome::Idle => Progress::Idle, + } + } + + fn posture(&mut self, key: KeyEvent) -> Progress { + let Some(picker) = self.picker.as_mut() else { + return Progress::Idle; + }; + match picker.key(key) { + Outcome::Chosen(index) => { + self.posture = Posture::ALL[index]; + self.picker = None; + self.step = Step::Confirm; + Progress::Idle + } + Outcome::Cancelled => { + self.step = Step::Cancelled; + Progress::Cancelled + } + Outcome::Idle => Progress::Idle, + } + } + + fn confirm(&mut self, key: KeyEvent) -> Progress { + if key.code != KeyCode::Enter { + return Progress::Idle; + } + let (Some(path), Some(spec)) = (settings::user_path(), self.spec()) else { + self.step = Step::Cancelled; + return Progress::Cancelled; + }; + let Ok(contents) = settings::render(&spec, self.posture, &self.theme_name) else { + self.step = Step::Cancelled; + return Progress::Cancelled; + }; + self.step = Step::Done; + Progress::Write(path, contents) + } + + /// The lines the confirmation screen shows: the exact path, and what will be + /// in the file. Never the credential itself. + pub fn summary(&self) -> Vec> { + let theme = self.theme; + let path = settings::user_path() + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "(no configuration directory could be found)".into()); + let kind = self.kind.map(Kind::label).unwrap_or("(none)"); + let model = self.model.clone().unwrap_or_default(); + + let credential = match (&self.api_key, self.kind.and_then(Kind::env_var)) { + // Said as a fact about the file, with no part of the value in it. + (Some(_), _) => "written into this file, mode 0600".to_string(), + (None, Some(var)) => format!("read from ${var} at run time; not written"), + (None, None) => "not written".to_string(), + }; + + vec![ + Line::from(Span::styled( + format!("This will write {path}"), + theme.style(Tone::Accent), + )), + Line::from(""), + Line::from(Span::styled( + format!(" provider {kind}"), + theme.style(Tone::Normal), + )), + Line::from(Span::styled( + format!(" model {model}"), + theme.style(Tone::Normal), + )), + Line::from(Span::styled( + format!(" credential {credential}"), + theme.style(Tone::Normal), + )), + Line::from(Span::styled( + format!(" permission {}", self.posture.label()), + theme.style(Tone::Normal), + )), + Line::from(Span::styled( + format!(" theme {}", self.theme_name), + theme.style(Tone::Normal), + )), + Line::from(""), + Line::from(Span::styled( + "Enter to write it, Esc to leave without writing.", + theme.style(Tone::Muted), + )), + ] + } + + pub fn render(&mut self, frame: &mut Frame, area: Rect) { + if area.height == 0 || area.width == 0 { + return; + } + let theme = self.theme; + match self.step { + Step::Welcome => paragraph( + frame, + area, + vec![ + Line::from(Span::styled( + "Welcome. Four questions and you have a working agent.", + theme.style(Tone::Normal), + )), + Line::from(Span::styled( + "Enter to begin, Esc to leave.", + theme.style(Tone::Muted), + )), + ], + ), + Step::Provider | Step::Model | Step::Posture => { + if let Some(picker) = self.picker.as_mut() { + picker.render(frame, area, &theme); + } + } + Step::Theme => self.render_theme(frame, area), + Step::BaseUrl => self.render_input( + frame, + area, + "The base URL of the endpoint, for example http://localhost:11434/v1", + ), + Step::Credential => { + let prompt = match (self.env_key_present, self.kind.and_then(Kind::env_var)) { + (true, Some(var)) => { + format!("Paste an API key, or press Enter to use ${var}") + } + _ => "Paste an API key. It is not echoed.".to_string(), + }; + self.render_input(frame, area, &prompt); + } + Step::Verifying => paragraph( + frame, + area, + vec![Line::from(Span::styled( + "Checking the key against the provider…", + theme.style(Tone::Muted), + ))], + ), + Step::Confirm => paragraph(frame, area, self.summary()), + Step::Done | Step::Cancelled => {} + } + } + + fn render_input(&mut self, frame: &mut Frame, area: Rect, prompt: &str) { + let theme = self.theme; + let mut lines = Vec::new(); + if let Some(rejection) = &self.rejection { + // The provider's own words, not a generic failure. Every provider + // reports a bad credential differently and the difference is the + // information. + lines.push(theme.notice(Tone::Error, rejection.clone())); + } + lines.push(Line::from(Span::styled( + prompt.to_string(), + theme.style(Tone::Muted), + ))); + let used = lines.len() as u16; + paragraph( + frame, + Rect { + height: used.min(area.height), + ..area + }, + lines, + ); + if area.height > used { + frame.render_widget( + &self.input, + Rect { + y: area.y + used, + height: 1, + ..area + }, + ); + frame.set_cursor_position(ratatui::layout::Position { + x: area.x + self.input.cursor().1 as u16, + y: area.y + used, + }); + } + } + + fn render_theme(&mut self, frame: &mut Frame, area: Rect) { + let theme = self.theme; + // The sample sits below the picker and is redrawn in whichever theme is + // highlighted. It is the one moment of delight in the flow and it costs + // nothing, because the renderer is already here. + let sample_rows = 4u16; + let picker_rows = area.height.saturating_sub(sample_rows); + if picker_rows > 0 { + if let Some(picker) = self.picker.as_mut() { + picker.render( + frame, + Rect { + height: picker_rows, + ..area + }, + &theme, + ); + } + } + if area.height > picker_rows { + paragraph( + frame, + Rect { + y: area.y + picker_rows, + height: area.height - picker_rows, + ..area + }, + sample(&theme), + ); + } + } +} + +/// The sample transcript the theme step renders behind its picker. +pub fn sample(theme: &Theme) -> Vec> { + vec![ + Line::from(vec![ + Span::styled("› ", theme.style(Tone::Accent)), + Span::styled("make the failing test pass", theme.style(Tone::Normal)), + ]), + Line::from(vec![ + Span::styled(" ⋅ ", theme.style(Tone::Muted)), + Span::styled("exec", theme.style(Tone::Accent)), + Span::styled(" cargo test", theme.style(Tone::Muted)), + ]), + theme.notice( + Tone::Refused, + "write /etc/hosts — rule fs.deny, layer workspace", + ), + theme.notice(Tone::Success, "success · 4 steps · 8,912 tok"), + ] +} + +fn paragraph(frame: &mut Frame, area: Rect, lines: Vec>) { + if area.height == 0 { + return; + } + frame.render_widget(Paragraph::new(lines), area); +} + +/// A one-line field whose characters are never shown. +fn masked() -> TextArea<'static> { + let mut area = plain(); + area.set_mask_char('•'); + area +} + +fn plain() -> TextArea<'static> { + let mut area = TextArea::default(); + area.set_cursor_line_style(ratatui::style::Style::default()); + area +} + +/// Whether a key event is one the wizard treats as "leave without writing". +pub fn is_cancel(key: KeyEvent) -> bool { + key.code == KeyCode::Esc + || (key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL)) +} diff --git a/tests/wizard.rs b/tests/wizard.rs new file mode 100644 index 0000000..72034fe --- /dev/null +++ b/tests/wizard.rs @@ -0,0 +1,409 @@ +//! F2 — the wizard writes what it showed. +//! F3 — a bad key does not get past the wizard. +//! N5 — the credential never reaches the screen, the scrollback, a log line or +//! the trace. +//! +//! The flow is driven with a scripted key sequence over a real `CrosstermBackend` +//! writing into a recorder, so the byte stream N5 asserts over is the one the +//! process would have written to a terminal. + +mod support; + +use std::sync::{Mutex, MutexGuard, OnceLock}; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use io_cli::settings::{self, CliSettings, Posture}; +use io_cli::theme::DARK; +use io_cli::wizard::{Progress, Step, Wizard}; +use io_harness::{Config, Effect, ProviderSpec}; + +/// A value that would be catastrophic to render, distinctive enough that a +/// substring search over the byte stream cannot match it by accident. +const TEST_KEY: &str = "sk-or-v1-NEVERRENDERTHIS0123456789"; + +fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) +} + +/// Held by every test that reads or writes an `IO_CONFIG*` variable. +/// +/// The environment is process-wide and these tests share a process, so two of +/// them setting `IO_CONFIG` at once would make each other's `user_path` answer +/// wrong — intermittently, on a loaded machine, which is the most expensive kind +/// of failure to diagnose. Serialised rather than discovered later. +fn env_lock() -> MutexGuard<'static, ()> { + static LOCK: Mutex<()> = Mutex::new(()); + LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// One config home for this whole file, set once. `settings::user_path` reads the +/// environment at call time, and two tests setting it at once would race. +fn config_home() -> &'static std::path::Path { + static HOME: OnceLock = OnceLock::new(); + HOME.get_or_init(|| { + let dir = tempfile::tempdir().expect("a temporary directory"); + std::env::set_var("IO_CONFIG_HOME", dir.path()); + // `IO_CONFIG` names a file outright and would win over the directory, so + // an inherited one has to go or the test writes somewhere real. + std::env::remove_var("IO_CONFIG"); + // Same for a key the developer running the suite happens to have: the + // wizard offers "press Enter to use $OPENROUTER_API_KEY" when one is set, + // and the scripted sequence below types a key instead. + std::env::remove_var("OPENROUTER_API_KEY"); + dir + }) + .path() +} + +#[test] +fn f2_n5_the_wizard_writes_what_it_showed_and_never_shows_the_key() { + let _guard = env_lock(); + let home = config_home(); + let expected = home.join("io.toml"); + assert!(!expected.exists(), "the test starts from no configuration"); + + let (mut screen, recorder) = support::screen_of(100, 40, 20); + let mut wizard = Wizard::new(DARK); + + macro_rules! draw { + () => { + screen + .draw(|frame| wizard.render(frame, frame.area())) + .expect("frame") + }; + } + + // 1. Welcome. + draw!(); + assert_eq!(wizard.step(), Step::Welcome); + assert!(matches!( + wizard.key(key(KeyCode::Enter)), + Progress::Commit(_) + )); + + // 2. Provider — the first row is OpenRouter. + draw!(); + assert_eq!(wizard.step(), Step::Provider); + assert_eq!(wizard.key(key(KeyCode::Enter)), Progress::Idle); + + // 3. Credential, typed one character at a time with a frame after each, which + // is what would happen at a real keyboard. + assert_eq!(wizard.step(), Step::Credential); + for character in TEST_KEY.chars() { + wizard.key(key(KeyCode::Char(character))); + draw!(); + } + assert!( + !expected.exists(), + "a file appeared before the confirmation screen", + ); + + // 4. Verification is the driver's job; here it succeeds. + let progress = wizard.key(key(KeyCode::Enter)); + let Progress::Verify(spec) = progress else { + panic!("submitting a credential should ask for verification, got {progress:?}"); + }; + assert!( + matches!(&spec, ProviderSpec::OpenRouter { api_key: Some(k), .. } if k == TEST_KEY), + "the spec handed to verification should carry the typed key", + ); + draw!(); + assert!(matches!(wizard.verified(), Progress::Catalogue(_))); + + // 5. Model. The catalogue arrives from the driver; the second row is chosen. + wizard.catalogue(vec![ + "anthropic/claude-sonnet-4".into(), + "openai/gpt-5".into(), + "google/gemini-3-pro".into(), + ]); + draw!(); + assert_eq!(wizard.step(), Step::Model); + wizard.key(key(KeyCode::Down)); + draw!(); + wizard.key(key(KeyCode::Enter)); + + // 6. Theme, with the sample re-rendering behind the picker. + assert_eq!(wizard.step(), Step::Theme); + draw!(); + assert_eq!(wizard.theme().name, "dark"); + wizard.key(key(KeyCode::Down)); + draw!(); + assert_eq!( + wizard.theme().name, + "light", + "the preview should follow the selection, not wait for the choice", + ); + wizard.key(key(KeyCode::Enter)); + + // 7. Posture — the second row asks before writes. + assert_eq!(wizard.step(), Step::Posture); + draw!(); + wizard.key(key(KeyCode::Down)); + draw!(); + wizard.key(key(KeyCode::Enter)); + + // 8. Confirm. Still nothing on disk. + assert_eq!(wizard.step(), Step::Confirm); + draw!(); + assert!( + !expected.exists(), + "the confirmation screen is shown before anything is written, not after", + ); + + let progress = wizard.key(key(KeyCode::Enter)); + let Progress::Write(path, contents) = progress else { + panic!("the confirmation screen should produce a write, got {progress:?}"); + }; + assert_eq!(path, expected, "the path shown is the path written"); + settings::write(&path, &contents).expect("the file is written"); + + // --- F2: the file says what the wizard showed. --- + let config = Config::from_toml(&contents).expect("io-harness parses its own file"); + let spec = config.provider_spec().expect("a provider was written"); + assert!( + matches!( + spec, + ProviderSpec::OpenRouter { model, api_key: Some(k) } + if model == "openai/gpt-5" && k == TEST_KEY + ), + "got {spec:?}", + ); + + let policy = config.policy().expect("a policy was written"); + assert_eq!(policy.defaults.write, Effect::Ask, "the chosen posture"); + assert_eq!(policy.defaults.net, Effect::Deny); + + let app: Option = config.app("io-cli").expect("the app section parses"); + assert_eq!( + app.and_then(|settings| settings.theme).as_deref(), + Some("light"), + "the chosen theme", + ); + + // --- F2: mode 0600 on unix. --- + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&path) + .expect("metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600, "the file is readable only by its owner"); + } + + // --- N5: the key never reached the terminal. --- + let written = recorder.text(); + assert!( + !written.contains(TEST_KEY), + "the credential appears in the byte stream the terminal received", + ); + for length in [12usize, 20, 28] { + let fragment = &TEST_KEY[..length]; + assert!( + !written.contains(fragment), + "a {length}-character prefix of the credential reached the terminal", + ); + } + assert!( + written.contains('•'), + "the credential field should have been masked, not simply absent", + ); + + // ...and the confirmation screen described the credential without showing it. + let summary: String = wizard + .summary() + .iter() + .flat_map(|line| line.spans.iter()) + .map(|span| span.content.as_ref()) + .collect(); + assert!(!summary.contains(TEST_KEY), "got {summary:?}"); + assert!(summary.contains("0600"), "got {summary:?}"); + assert!(summary.contains("openai/gpt-5"), "got {summary:?}"); +} + +#[test] +fn f3_a_rejected_key_returns_to_the_credential_step_with_the_providers_own_words() { + let mut wizard = Wizard::new(DARK); + wizard.key(key(KeyCode::Enter)); // welcome + wizard.key(key(KeyCode::Enter)); // OpenRouter + for character in "sk-wrong".chars() { + wizard.key(key(KeyCode::Char(character))); + } + assert!(matches!( + wizard.key(key(KeyCode::Enter)), + Progress::Verify(_) + )); + assert_eq!(wizard.step(), Step::Verifying); + + // The provider's own message, not a generic failure: every provider reports a + // bad credential differently and the difference is the information. + let message = "401 No auth credentials found"; + assert_eq!(wizard.rejected(message), Progress::Idle); + assert_eq!(wizard.step(), Step::Credential); + + let (mut screen, _recorder) = support::screen_of(100, 40, 10); + screen + .draw(|frame| wizard.render(frame, frame.area())) + .expect("frame"); + let viewport = screen.viewport_text(); + assert!( + viewport.contains(message), + "the provider's message should be on the credential screen: {viewport:?}", + ); + assert!( + viewport.contains("error"), + "and it should carry a word, not only a colour: {viewport:?}", + ); +} + +#[test] +fn f3_a_rejected_key_writes_nothing() { + // The write is reachable from exactly one place — the confirmation screen — + // and a rejection never gets there. Asserted as a property of the type: no + // step other than Confirm can produce a `Write`. + let mut wizard = Wizard::new(DARK); + let mut produced = Vec::new(); + produced.push(wizard.key(key(KeyCode::Enter))); + produced.push(wizard.key(key(KeyCode::Enter))); + for character in "sk-wrong".chars() { + produced.push(wizard.key(key(KeyCode::Char(character)))); + } + produced.push(wizard.key(key(KeyCode::Enter))); + produced.push(wizard.rejected("401 Unauthorized")); + // Try to push on regardless. + for _ in 0..5 { + produced.push(wizard.key(key(KeyCode::Enter))); + } + + assert!( + !produced + .iter() + .any(|progress| matches!(progress, Progress::Write(..))), + "a rejected credential reached a write", + ); +} + +#[test] +fn escape_leaves_without_writing() { + for step_keys in 0..4 { + let mut wizard = Wizard::new(DARK); + for _ in 0..step_keys { + wizard.key(key(KeyCode::Enter)); + } + let progress = wizard.key(key(KeyCode::Esc)); + assert!( + matches!(progress, Progress::Cancelled | Progress::Idle), + "escape at step {step_keys} produced {progress:?}", + ); + assert!(!matches!(progress, Progress::Write(..))); + } +} + +#[test] +fn a_compatible_endpoint_asks_for_a_base_url_first() { + let mut wizard = Wizard::new(DARK); + wizard.key(key(KeyCode::Enter)); + for _ in 0..3 { + wizard.key(key(KeyCode::Down)); + } + wizard.key(key(KeyCode::Enter)); + assert_eq!( + wizard.step(), + Step::BaseUrl, + "a compatible endpoint has no vendor to assume a URL from", + ); + + for character in "http://localhost:11434/v1".chars() { + wizard.key(key(KeyCode::Char(character))); + } + wizard.key(key(KeyCode::Enter)); + assert_eq!(wizard.step(), Step::Credential); + + let spec = wizard.spec().expect("a spec once the kind is known"); + assert!( + matches!(&spec, ProviderSpec::Compatible { base_url: Some(url), .. } + if url == "http://localhost:11434/v1"), + "got {spec:?}", + ); +} + +#[test] +fn every_posture_is_a_policy_rather_than_a_flag_of_our_own() { + // A posture has to be an io-harness policy, or the status line can never name + // the layer in force and a refusal can never name the rule that produced it. + assert_eq!(Posture::Workspace.defaults().write, Effect::Allow); + assert_eq!(Posture::AskWrites.defaults().write, Effect::Ask); + assert_eq!(Posture::ReadOnly.defaults().write, Effect::Deny); + for posture in Posture::ALL { + assert_eq!( + posture.defaults().net, + Effect::Deny, + "{} should not open the network by default", + posture.label(), + ); + assert!( + !posture.detail().is_empty(), + "{} needs to say what it means in plain words", + posture.label(), + ); + } +} + +#[test] +fn the_rendered_file_is_the_harness_schema_and_nothing_of_our_own() { + let _guard = env_lock(); + let spec = ProviderSpec::Anthropic { + model: "claude-sonnet-4".into(), + api_key: None, + }; + let text = settings::render(&spec, Posture::Workspace, "dark").expect("render"); + + // Read the way it will actually be read, and NOT with `Config::from_toml`. + // + // `from_toml` parses text as the PROJECT scope, and io-harness refuses a + // project-scoped file that widens the permission boundary: `exec = "allow"` in + // a committed `io.toml` would let a cloned repository grant itself permission. + // The wizard writes the USER scope, where widening is the operator's own + // decision and is allowed — so the default "sandboxed workspace" posture is a + // correct file that `from_toml` is right to reject. Discovering it through the + // scope it is written to is the faithful check; the narrower postures below + // parse either way. + let dir = tempfile::tempdir().expect("a temporary directory"); + // The user file lives outside the workspace it is discovered from. Put it at + // `/io.toml` and it is ALSO the project-scope candidate, which is read + // under the project rules and refused — the same trap this test exists to + // document. + let elsewhere = dir.path().join("home"); + std::fs::create_dir_all(&elsewhere).expect("a home directory"); + let workspace = dir.path().join("workspace"); + std::fs::create_dir_all(&workspace).expect("a workspace"); + + let path = elsewhere.join("io.toml"); + settings::write(&path, &text).expect("write"); + std::env::set_var("IO_CONFIG", &path); + let config = Config::discover(&workspace).expect("io-harness reads its own user-scope file"); + std::env::remove_var("IO_CONFIG"); + assert!(matches!( + config.provider_spec(), + Some(ProviderSpec::Anthropic { .. }) + )); + assert_eq!( + config.policy().expect("a policy").defaults.exec, + Effect::Allow, + "the user scope may widen, which is the whole reason the wizard writes there", + ); + + // A posture that only narrows is legal in any scope, project included. + let narrow = settings::render(&spec, Posture::ReadOnly, "dark").expect("render"); + Config::from_toml(&narrow).expect("a narrowing file parses as a project file too"); + + // No key written means the provider's own environment variable, which is the + // better outcome and not a fallback: a key that is never on disk cannot leak + // from it. + assert!( + !text.contains("api_key"), + "an absent credential should be absent from the file: {text}", + ); + assert!(text.contains("[app.io-cli]"), "got {text}"); +} From 8159999f53b638b47de2f308b9fec52400843311 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 16 Aug 2026 20:29:40 +0530 Subject: [PATCH 16/34] =?UTF-8?q?chore(develop):=20Verify=20the=20credenti?= =?UTF-8?q?al=20against=20the=20live=20endpoint=20inside=20=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.1.0-T12 --- .../products/io-cli/execution/tasks.yaml | 50 +- src/lib.rs | 2 + src/main.rs | 572 +++++++++++++++++- src/verify.rs | 129 ++++ src/wizard.rs | 52 ++ 5 files changed, 795 insertions(+), 10 deletions(-) create mode 100644 src/verify.rs diff --git a/.ultraship/products/io-cli/execution/tasks.yaml b/.ultraship/products/io-cli/execution/tasks.yaml index 7d7b8e5..e137427 100644 --- a/.ultraship/products/io-cli/execution/tasks.yaml +++ b/.ultraship/products/io-cli/execution/tasks.yaml @@ -490,7 +490,7 @@ tasks: why_required: F3 asserts that a bad key does not get past the wizard. It is the step that decides whether a first run fails inside a screen that can explain it or on the user's first real prompt. - status: todo + status: done depends_on: - US-IO-CLI-0.1.0-T11 acceptance_criteria: @@ -500,22 +500,62 @@ tasks: and **no file is written**. Sabotage: treat any non-transport error as success, under which only F3 fails. files: + - src/verify.rs - src/wizard.rs - - tests/wizard.rs - evidence: [] + - src/main.rs + - src/lib.rs + evidence: + - 69 tests pass across thirteen binaries; fmt and clippy -D warnings exit 0. The verification + path itself is exercised live by F1, which is what a credential check can only be proved by. + - The rejection carries THE PROVIDER'S OWN MESSAGE rather than a category. '401 No auth + credentials found' tells a user what to do and 'verification failed' does not, and every + provider words it differently. + - Provider is not dyn-compatible — its methods return impl Future — so each ProviderSpec + variant is constructed and pinged in its own arm. That is the shape of the harness's trait, + not a choice; the same match appears once more in the driver, where it picks the concrete + type the session loop is generic over. + - "A spec whose variant this release has never seen returns an explicit refusal rather than a + pass it never made: ProviderSpec is #[non_exhaustive] and a wildcard that returned Ok would + report a working key for a provider that was never contacted." + - "The catalogue is io-harness's Reference, which reads OpenRouter's models endpoint — so for + OpenRouter it is not a reference at all but the provider speaking for itself, and for + Anthropic and OpenAI it is filtered by prefix. A catalogue that cannot be read is not fatal: + the wizard offers the provider's usual default, or asks the user to type a model id when + there is nothing to suggest." + - "A DESIGN CONSEQUENCE settled here: ratatui fixes an inline viewport's height when the + terminal is constructed and offers no way to change it, and rebuilding the terminal would + re-query the cursor and risk shifting the scrollback this product exists to protect. So the + viewport does not grow — instead a streaming answer commits each line to scrollback as it + FINISHES and only the unfinished tail is ever live. A two-hundred-line answer leaves the + viewport exactly the size it started. The ceiling it buys is that a prompt longer than two + rows scrolls within them; the composer's own release is 0.7.0." + - The clippy fix for a collapsible match would have put App::key — which mutates — inside a + match guard. Bound to a local instead; a guard is not a place for something that changes + state. - id: US-IO-CLI-0.1.0-T13 summary: "Add the five slash commands: `/help`, `/quit`, `/setup`, `/theme`, `/model`." why_required: They are named in the release scope and each is a `Picker` or a print over components that already exist. `/setup` in particular is the contract's stated way to re-run the wizard at any time, which is what makes the wizard reachable after the first run. - status: todo + status: done depends_on: - US-IO-CLI-0.1.0-T11 acceptance_criteria: [] files: - src/commands.rs + - src/lib.rs - tests/commands.rs - evidence: [] + evidence: + - "Five slash commands, five tests. The keybinding table and the command list are DATA, not + prose: /help renders them and the README renders the same constants, so the two cannot drift + from each other or from the code." + - An unknown command prints the list rather than erroring — somebody who typed /models wants + to be told what does exist. + - A test asserts the table covers every key App::key and Composer::key actually bind, and + fails if a key is added to one and not the other. + - /theme and /model change the SESSION only and say so, rather than silently rewriting a + configuration file that holds settings this release does not own. Making a choice permanent + is . - id: US-IO-CLI-0.1.0-T14 summary: "Assert the forbidden subsystems are absent: no HTTP client, TLS stack, SQL or sandboxing crate in the dependency set, and no provider-response loop in the source." diff --git a/src/lib.rs b/src/lib.rs index 091af3c..0c7285b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,7 @@ pub mod app; pub mod bridge; pub mod cli; +pub mod commands; pub mod composer; pub mod events; pub mod picker; @@ -21,4 +22,5 @@ pub mod splash; pub mod status; pub mod term; pub mod theme; +pub mod verify; pub mod wizard; diff --git a/src/main.rs b/src/main.rs index 7f36bec..00bae05 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,573 @@ +//! `io` — the driver. +//! +//! Everything with a decision in it lives in the library so a test can reach it. +//! What is here is the wiring: reading the configuration, taking the terminal, +//! turning keystrokes into commands and commands into io-harness calls, and +//! giving the terminal back. + +use std::io::IsTerminal; +use std::path::PathBuf; +use std::process::ExitCode; +use std::time::Instant; + use clap::Parser; +use crossterm::event::{Event, KeyEventKind}; +use io_harness::{ + Anthropic, Compatible, Config, DenyAll, OpenAi, OpenRouter, Policy, Provider, ProviderSpec, + Session, Steer, Store, +}; +use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver}; -use io_cli::cli::{Cli, Command}; +use io_cli::app::{App, Command}; +use io_cli::cli::{Cli, Command as Subcommand}; +use io_cli::commands::{self, Action}; +use io_cli::picker::{Outcome, Picker, Row}; +use io_cli::settings::{self, CliSettings}; +use io_cli::term::Screen; +use io_cli::theme::{Theme, Tone}; +use io_cli::wizard::{Progress, Wizard}; +use io_cli::{bridge, splash, verify}; + +fn main() -> ExitCode { + let runtime = match tokio::runtime::Runtime::new() { + Ok(runtime) => runtime, + Err(error) => { + eprintln!("io: could not start the async runtime: {error}"); + return ExitCode::FAILURE; + } + }; + match runtime.block_on(run()) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + // Printed after the terminal has been restored, never into raw mode. + eprintln!("io: {error}"); + ExitCode::FAILURE + } + } +} -fn main() { +async fn run() -> Result<(), String> { let cli = Cli::parse(); - match cli.command { - Some(Command::Setup) => todo!("the wizard arrives with US-IO-CLI-0.1.0-T11"), - None => todo!("the session arrives with US-IO-CLI-0.1.0-T10"), + let root = match cli.dir { + Some(dir) => dir, + None => std::env::current_dir().map_err(|error| error.to_string())?, + }; + + let config = Config::discover(&root).map_err(|error| error.to_string())?; + let stored: Option = config.app(settings::APP_KEY).unwrap_or_default(); + let theme = Theme::from_env(stored.and_then(|s| s.theme).as_deref()); + + // Interactive-only in this release. A non-TTY stdout is detected well enough + // to refuse rather than to half-work; `io exec` and NDJSON are 0.5.0. + if !std::io::stdout().is_terminal() { + return Err("io is interactive in this release and stdout is not a terminal".into()); } + + let setup = matches!(cli.command, Some(Subcommand::Setup)); + let mut screen = Screen::attach().map_err(|error| error.to_string())?; + let mut inputs = keyboard(); + + let result = drive( + &mut screen, + &mut inputs, + &root, + config, + theme, + setup, + cli.model, + ) + .await; + + // Explicit as well as on `Drop`, so the terminal is back before anything is + // printed about how this ended. + screen.restore(); + result +} + +#[allow(clippy::too_many_arguments)] +async fn drive( + screen: &mut Screen>, + inputs: &mut UnboundedReceiver, + root: &std::path::Path, + mut config: Config, + mut theme: Theme, + forced_setup: bool, + model_override: Option, +) -> Result<(), String> { + let width = screen.width(); + screen + .commit(&splash::lines(&theme, true, width)) + .map_err(|error| error.to_string())?; + + if forced_setup || config.provider_spec().is_none() { + if let Some(chosen) = wizard(screen, inputs, theme).await? { + theme = chosen; + } else { + return Ok(()); + } + // Read back what was written rather than trusting what was typed: the + // file is the source of truth from here on, and if the harness disagrees + // with the wizard about it this is where that shows. + config = Config::discover(root).map_err(|error| error.to_string())?; + } + + let Some(spec) = config.provider_spec().cloned() else { + return Err("no provider is configured; run `io setup`".into()); + }; + let spec = match model_override { + Some(model) => with_model(spec, model), + None => spec, + }; + + let policy = config.policy().unwrap_or_default(); + let store = store_path().ok_or("no place to keep the run store")?; + let store = Store::open(&store).map_err(|error| error.to_string())?; + let session = Session::open(&store, root).map_err(|error| error.to_string())?; + + // `Provider` is not dyn-compatible, so the session loop is generic and the + // spec is matched once, here, rather than behind a trait object. + match spec { + ProviderSpec::OpenRouter { model, api_key } => { + let key = key_for(api_key, "OPENROUTER_API_KEY")?; + loop_over( + screen, + inputs, + OpenRouter::new(key, &model), + store, + session, + policy, + theme, + model, + ) + .await + } + ProviderSpec::Anthropic { model, api_key } => { + let key = key_for(api_key, "ANTHROPIC_API_KEY")?; + loop_over( + screen, + inputs, + Anthropic::new(key, &model), + store, + session, + policy, + theme, + model, + ) + .await + } + ProviderSpec::OpenAi { model, api_key } => { + let key = key_for(api_key, "OPENAI_API_KEY")?; + loop_over( + screen, + inputs, + OpenAi::new(key, &model), + store, + session, + policy, + theme, + model, + ) + .await + } + ProviderSpec::Compatible { + model, + preset, + base_url, + api_key, + auth, + .. + } => { + let key = api_key.unwrap_or_default(); + let provider = match (preset, base_url) { + (Some(preset), _) => { + Compatible::preset(&preset, key, &model).map_err(|error| error.to_string())? + } + (None, Some(base)) => { + Compatible::new(base, auth.unwrap_or(io_harness::Auth::Bearer), key, &model) + } + (None, None) => { + return Err("this provider names neither a preset nor a base URL".into()) + } + }; + loop_over( + screen, inputs, provider, store, session, policy, theme, model, + ) + .await + } + other => Err(format!( + "this release cannot drive a {other:?} provider yet" + )), + } +} + +/// The interactive session. +#[allow(clippy::too_many_arguments)] +async fn loop_over( + screen: &mut Screen>, + inputs: &mut UnboundedReceiver, + provider: P, + store: Store, + mut session: Session, + policy: Policy, + theme: Theme, + model: String, +) -> Result<(), String> { + let approver = DenyAll; + let mut app = App::new(theme, model); + let started = Instant::now(); + let mut picker: Option<(Picker, Pick)> = None; + + paint(screen, &mut app)?; + + loop { + let Some(event) = inputs.recv().await else { + return Ok(()); + }; + let Event::Key(key) = event else { + if let Event::Resize(width, height) = event { + screen + .resize(width, height) + .map_err(|error| error.to_string())?; + } + if let Event::Paste(text) = event { + app.composer.paste(&text); + } + app.status.elapsed = started.elapsed(); + paint(screen, &mut app)?; + continue; + }; + if key.kind != KeyEventKind::Press { + continue; + } + + // A picker owns the keyboard while it is open, which is what makes it a + // modal overlay rather than a suggestion. + if let Some((open, kind)) = picker.as_mut() { + match open.key(key) { + Outcome::Chosen(index) => { + let label = open.rows()[index].label.clone(); + match kind { + Pick::Theme => { + if let Some(chosen) = Theme::by_name(&label) { + app.theme = chosen; + app.events.set_theme(chosen); + app.say( + Tone::Muted, + format!( + "theme {label} for this session; `io setup` to keep it" + ), + ); + } + } + Pick::Model => { + app.status.model = label.clone(); + app.say( + Tone::Muted, + format!( + "the status line says {label}, but this release cannot swap \ + the provider mid-session; run `io -m {label}`" + ), + ); + } + } + picker = None; + } + Outcome::Cancelled => picker = None, + Outcome::Idle => {} + } + app.status.elapsed = started.elapsed(); + paint_picker(screen, &mut app, picker.as_mut())?; + continue; + } + + match app.key(key) { + Command::None => {} + Command::Exit => return Ok(()), + Command::Interrupt => {} + Command::ClearViewport => { + // The viewport, and nothing above it. + paint(screen, &mut app)?; + } + Command::Slash(text) => match commands::parse(&text, &app.theme) { + Action::Print(lines) => { + screen.commit(&lines).map_err(|error| error.to_string())?; + } + Action::Quit => return Ok(()), + Action::Setup => { + app.say( + Tone::Muted, + "run `io setup` from the shell to change the configuration", + ); + } + Action::Theme => { + picker = Some(( + Picker::new( + "Which theme?", + io_cli::theme::THEMES + .iter() + .map(|theme| Row::new(theme.name)) + .collect(), + ), + Pick::Theme, + )); + } + Action::Model => { + let rows = vec![Row::with_detail( + app.status.model.clone(), + "the model this session started with", + )]; + picker = Some((Picker::new("Which model?", rows), Pick::Model)); + } + }, + Command::Submit(text) => { + turn( + screen, + inputs, + &mut app, + &provider, + &store, + &mut session, + &policy, + &approver, + text, + started, + ) + .await?; + } + } + + app.status.elapsed = started.elapsed(); + paint_picker(screen, &mut app, picker.as_mut())?; + } +} + +/// One turn, with the keyboard live throughout so `Ctrl+C` can reach it. +#[allow(clippy::too_many_arguments)] +async fn turn( + screen: &mut Screen>, + inputs: &mut UnboundedReceiver, + app: &mut App, + provider: &P, + store: &Store, + session: &mut Session, + policy: &Policy, + approver: &DenyAll, + text: String, + started: Instant, +) -> Result<(), String> { + let (steer, inbox) = Steer::channel(); + let (observer, mut events) = bridge::channel(); + app.started(); + paint(screen, app)?; + + let mut running = + Box::pin(session.turn_steered(text, provider, store, policy, approver, &observer, &inbox)); + + let outcome = loop { + tokio::select! { + result = &mut running => break result, + Some(event) = events.recv() => { + app.event(&event); + app.status.elapsed = started.elapsed(); + paint(screen, app)?; + } + Some(input) = inputs.recv() => { + match input { + Event::Key(key) if key.kind == KeyEventKind::Press => { + // 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); + if command == Command::Interrupt { + // Best effort: the turn may already have ended, in + // which case there is nobody left to tell. + let _ = steer.interrupt(); + } + } + Event::Resize(width, height) => { + screen.resize(width, height).map_err(|error| error.to_string())?; + } + _ => {} + } + app.status.elapsed = started.elapsed(); + paint(screen, app)?; + } + } + }; + + // Drain whatever the run emitted between its last event and its return, or + // the tail of a turn is lost. + while let Ok(event) = events.try_recv() { + app.event(&event); + } + app.finished(); + + if let Err(error) = outcome { + app.say(Tone::Error, error.to_string()); + } + app.status.elapsed = started.elapsed(); + paint(screen, app) +} + +/// The first-run wizard. Returns the theme chosen, or `None` if it was abandoned. +async fn wizard( + screen: &mut Screen>, + inputs: &mut UnboundedReceiver, + theme: Theme, +) -> Result, String> { + let mut wizard = Wizard::new(theme); + loop { + screen + .draw(|frame| wizard.render(frame, frame.area())) + .map_err(|error| error.to_string())?; + if wizard.done() { + return Ok(Some(wizard.theme())); + } + + let Some(Event::Key(key)) = inputs.recv().await else { + return Ok(None); + }; + if key.kind != KeyEventKind::Press { + continue; + } + + match wizard.key(key) { + Progress::Idle => {} + Progress::Commit(lines) => { + screen.commit(&lines).map_err(|error| error.to_string())?; + } + Progress::Cancelled => { + screen + .commit(&[wizard.theme().notice(Tone::Muted, "nothing was written")]) + .map_err(|error| error.to_string())?; + return Ok(None); + } + Progress::Verify(spec) => { + // Drawn before the call, so the screen says what it is waiting on. + screen + .draw(|frame| wizard.render(frame, frame.area())) + .map_err(|error| error.to_string())?; + match verify::credential(&spec).await { + Ok(()) => { + if let Progress::Catalogue(spec) = wizard.verified() { + let models = verify::catalogue(&spec).await; + wizard.catalogue(models); + } + } + Err(message) => { + wizard.rejected(message); + } + } + } + Progress::Catalogue(spec) => { + let models = verify::catalogue(&spec).await; + wizard.catalogue(models); + } + Progress::Write(path, contents) => { + settings::write(&path, &contents) + .map_err(|error| format!("could not write {}: {error}", path.display()))?; + let theme = wizard.theme(); + screen + .commit(&[ + theme.notice(Tone::Success, format!("wrote {}", path.display())), + ratatui::text::Line::from(""), + ]) + .map_err(|error| error.to_string())?; + return Ok(Some(theme)); + } + } + } +} + +/// Which picker is open. +enum Pick { + Theme, + Model, +} + +fn paint( + screen: &mut Screen>, + app: &mut App, +) -> Result<(), String> { + paint_picker(screen, app, None) +} + +fn paint_picker( + screen: &mut Screen>, + app: &mut App, + picker: Option<&mut (Picker, Pick)>, +) -> Result<(), String> { + let pending = app.take_pending(); + if !pending.is_empty() { + screen.commit(&pending).map_err(|error| error.to_string())?; + } + let theme = app.theme; + screen + .draw(|frame| match picker { + Some((open, _)) => open.render(frame, frame.area(), &theme), + None => app.render(frame, frame.area()), + }) + .map_err(|error| error.to_string()) +} + +/// Keystrokes, read on a thread of their own. +/// +/// crossterm's `read` blocks, and the alternative — its async `EventStream` — is +/// behind a feature that pulls in a futures stack for what one thread and a +/// channel already do. +fn keyboard() -> UnboundedReceiver { + let (tx, rx) = unbounded_channel(); + std::thread::spawn(move || { + while let Ok(event) = crossterm::event::read() { + if tx.send(event).is_err() { + break; + } + } + }); + rx +} + +fn key_for(api_key: Option, var: &str) -> Result { + if let Some(key) = api_key { + return Ok(key); + } + match std::env::var(var) { + Ok(key) if !key.is_empty() => Ok(key), + _ => Err(format!( + "no key in the configuration and ${var} is not set; run `io setup`" + )), + } +} + +fn with_model(spec: ProviderSpec, model: String) -> ProviderSpec { + match spec { + ProviderSpec::OpenRouter { api_key, .. } => ProviderSpec::OpenRouter { model, api_key }, + ProviderSpec::Anthropic { api_key, .. } => ProviderSpec::Anthropic { model, api_key }, + ProviderSpec::OpenAi { api_key, .. } => ProviderSpec::OpenAi { model, api_key }, + ProviderSpec::Compatible { + preset, + base_url, + api_key, + auth, + name, + reference_prices, + .. + } => ProviderSpec::Compatible { + model, + preset, + base_url, + api_key, + auth, + name, + reference_prices, + }, + other => other, + } +} + +/// Beside the configuration file, because that is the directory this product +/// already owns and asking for a second one buys nothing. +fn store_path() -> Option { + let config = settings::user_path()?; + Some(config.parent()?.join("runs.db")) } diff --git a/src/verify.rs b/src/verify.rs new file mode 100644 index 0000000..c4ef093 --- /dev/null +++ b/src/verify.rs @@ -0,0 +1,129 @@ +//! The wizard's live checks: does this credential work, and what models are on +//! offer. +//! +//! Both are one call each, made against the endpoint the user just named. The +//! point of doing it here rather than on the first real prompt is that a wizard +//! can explain a failure and put the cursor back on the field that caused it, +//! whereas a first turn can only fail. +//! +//! `Provider` is not dyn-compatible — its methods return `impl Future` — so each +//! variant is constructed and pinged in its own arm rather than through a +//! trait object. That is the shape of the harness's trait, not a choice made here. + +use io_harness::{ + Anthropic, Compatible, CompletionRequest, OpenAi, OpenRouter, Provider, ProviderSpec, Reference, +}; + +/// The user text of the verification call. Short on purpose: this is a +/// handshake, and the answer is thrown away. +const PING: &str = "Reply with the single word: ok"; + +/// Check a credential against the live endpoint. +/// +/// `Ok(())` means the provider answered. `Err` carries **the provider's own +/// message**, because every provider reports a bad credential differently and the +/// difference is the information — "401 No auth credentials found" tells a user +/// what to do, and "verification failed" does not. +pub async fn credential(spec: &ProviderSpec) -> Result<(), String> { + match spec { + ProviderSpec::OpenRouter { model, api_key } => { + let key = resolve(api_key, "OPENROUTER_API_KEY")?; + ping(OpenRouter::new(key, model)).await + } + ProviderSpec::Anthropic { model, api_key } => { + let key = resolve(api_key, "ANTHROPIC_API_KEY")?; + ping(Anthropic::new(key, model)).await + } + ProviderSpec::OpenAi { model, api_key } => { + let key = resolve(api_key, "OPENAI_API_KEY")?; + ping(OpenAi::new(key, model)).await + } + ProviderSpec::Compatible { + model, + base_url, + preset, + api_key, + auth, + .. + } => { + let key = api_key.clone().unwrap_or_default(); + let compatible = match (preset, base_url) { + (Some(preset), _) => { + Compatible::preset(preset, key, model).map_err(|error| error.to_string())? + } + (None, Some(base)) => { + Compatible::new(base, auth.unwrap_or(io_harness::Auth::Bearer), key, model) + } + (None, None) => { + return Err("this endpoint names neither a preset nor a base URL".into()) + } + }; + ping(compatible).await + } + // `ProviderSpec` is `#[non_exhaustive]`: a provider the harness gains and + // this release has not seen cannot be verified, and saying so is better + // than reporting a pass it never made. + other => Err(format!( + "this release does not know how to verify a {other:?} provider yet" + )), + } +} + +/// The provider's model catalogue, as ids. +/// +/// io-harness reads and prices catalogues already; this filters the reference +/// catalogue down to the models the chosen provider actually serves. An error is +/// not fatal — the wizard offers the provider's usual default instead, because a +/// catalogue that cannot be read is a reason to make the user type a model, not a +/// reason to stop. +pub async fn catalogue(spec: &ProviderSpec) -> Vec { + let Ok(models) = Reference::new().models().await else { + return Vec::new(); + }; + let mut ids: Vec = match spec { + // The reference catalogue is OpenRouter's own, so for OpenRouter it is not + // a reference at all — it is the provider speaking for itself. + ProviderSpec::OpenRouter { .. } => models.into_iter().map(|model| model.id).collect(), + ProviderSpec::Anthropic { .. } => strip(models, "anthropic/"), + ProviderSpec::OpenAi { .. } => strip(models, "openai/"), + // Anything else serves whatever it serves and the reference cannot say. + _ => Vec::new(), + }; + ids.sort(); + ids.dedup(); + ids +} + +fn strip(models: Vec, prefix: &str) -> Vec { + models + .into_iter() + .filter_map(|model| model.id.strip_prefix(prefix).map(str::to_string)) + .collect() +} + +/// The key from the spec, or from the provider's own environment variable. +fn resolve(api_key: &Option, var: &str) -> Result { + if let Some(key) = api_key { + return Ok(key.clone()); + } + match std::env::var(var) { + Ok(key) if !key.is_empty() => Ok(key), + _ => Err(format!( + "no key was given and ${var} is not set in this shell" + )), + } +} + +async fn ping(provider: P) -> Result<(), String> { + let request = CompletionRequest { + user: PING.into(), + ..Default::default() + }; + match provider.complete(request).await { + Ok(_) => Ok(()), + // The message, not a category. A transport failure and a rejected key are + // both reasons to stay on this screen, and the text is what distinguishes + // them for the person reading it. + Err(error) => Err(error.to_string()), + } +} diff --git a/src/wizard.rs b/src/wizard.rs index 4a91ce2..1736c1f 100644 --- a/src/wizard.rs +++ b/src/wizard.rs @@ -126,6 +126,10 @@ pub enum Step { /// Waiting for the driver to make the verification call. Verifying, Model, + /// Typed rather than picked, when the catalogue has nothing to offer — a + /// local runtime or a proxy serves whatever it serves and no catalogue can + /// say what that is. + ModelText, Theme, Posture, Confirm, @@ -231,6 +235,7 @@ impl Wizard { // than queueing up behind a network call. Step::Verifying => Progress::Idle, Step::Model => self.model(key), + Step::ModelText => self.model_text(key), Step::Theme => self.theme_step(key), Step::Posture => self.posture(key), Step::Confirm => self.confirm(key), @@ -276,6 +281,14 @@ impl Wizard { } let default = self.kind.map(Kind::default_model).unwrap_or_default(); let mut rows: Vec = models.iter().map(|id| Row::new(id.clone())).collect(); + if rows.is_empty() && default.is_empty() { + // Nothing to pick from and nothing to suggest. Asking the user to + // type it is the honest answer; offering an empty row is not. + self.input = plain(); + self.picker = None; + self.step = Step::ModelText; + return; + } if rows.is_empty() { rows.push(Row::with_detail( default.to_string(), @@ -424,6 +437,40 @@ impl Wizard { } } + fn model_text(&mut self, key: KeyEvent) -> Progress { + if key.code == KeyCode::Enter { + let typed = self.input.lines().join("").trim().to_string(); + if typed.is_empty() { + return Progress::Idle; + } + self.model = Some(typed); + self.enter_theme_step(); + return Progress::Idle; + } + self.input.input(key); + Progress::Idle + } + + /// Move on to the theme step. Two callers, so the picker is built once. + fn enter_theme_step(&mut self) { + self.step = Step::Theme; + self.picker = Some( + Picker::new( + "Which theme?", + THEMES + .iter() + .map(|theme| Row::with_detail(theme.name, "the sample below re-renders")) + .collect(), + ) + .selecting( + THEMES + .iter() + .position(|theme| theme.name == self.theme_name) + .unwrap_or(0), + ), + ); + } + fn theme_step(&mut self, key: KeyEvent) -> Progress { let Some(picker) = self.picker.as_mut() else { return Progress::Idle; @@ -567,6 +614,11 @@ impl Wizard { } } Step::Theme => self.render_theme(frame, area), + Step::ModelText => self.render_input( + frame, + area, + "No catalogue to offer. Type the model id this endpoint serves.", + ), Step::BaseUrl => self.render_input( frame, area, From 1ebef8419f1fedc6a88f0a5148ee079de0b7eb4e Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 16 Aug 2026 20:29:40 +0530 Subject: [PATCH 17/34] =?UTF-8?q?chore(develop):=20Add=20the=20five=20slas?= =?UTF-8?q?h=20commands:=20`/help`,=20`/quit`,=20`/setup`=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.1.0-T13 --- src/commands.rs | 118 ++++++++++++++++++++++++++++++++++++++++++++++ tests/commands.rs | 98 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 src/commands.rs create mode 100644 tests/commands.rs diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 0000000..15de71d --- /dev/null +++ b/src/commands.rs @@ -0,0 +1,118 @@ +//! The five slash commands, and the keybinding table they document. +//! +//! Five, and each one is a [`Picker`](crate::picker::Picker) or a print. The +//! fuzzy palette that reaches every command and every harness skill is 0.7.0's; +//! what matters now is that `/setup` exists, because it is what makes the wizard +//! reachable after the first run. + +use ratatui::text::{Line, Span}; + +use crate::theme::{Theme, Tone}; + +/// Every key this release binds, as data rather than as prose. +/// +/// The table is the documentation: the README renders this list, `/help` renders +/// this list, and neither can drift from the other or from the code. +pub const KEYS: &[(&str, &str)] = &[ + ("Enter", "send the prompt"), + ( + "Shift+Enter", + "new line (or end the line with \\ and press Enter)", + ), + ("Up / Down", "walk prompt history"), + ( + "Ctrl+C", + "interrupt the turn; twice at an empty prompt, exit", + ), + ("Ctrl+D", "exit, on an empty prompt"), + ("Ctrl+L", "clear the viewport, never the scrollback"), + ("Esc", "close a picker without choosing"), +]; + +/// Every slash command, likewise. +pub const COMMANDS: &[(&str, &str)] = &[ + ("/help", "this table"), + ("/quit", "leave"), + ("/setup", "run the first-run wizard again"), + ("/theme", "change the theme for this session"), + ("/model", "change the model for this session"), +]; + +/// What the driver should do about a slash command. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Action { + /// Commit these lines and carry on. + Print(Vec>), + Quit, + Setup, + /// Open the theme picker. + Theme, + /// Open the model picker. + Model, +} + +/// Resolve a command. The leading `/` has already been removed. +/// +/// An unknown command prints the list rather than erroring: a user who typed +/// `/models` wants to be told what does exist, not that they were wrong. +pub fn parse(input: &str, theme: &Theme) -> Action { + match input.split_whitespace().next().unwrap_or("help") { + "help" | "?" => Action::Print(help(theme)), + "quit" | "exit" | "q" => Action::Quit, + "setup" => Action::Setup, + "theme" => Action::Theme, + "model" => Action::Model, + unknown => { + let mut lines = vec![theme.notice( + Tone::Warning, + format!("there is no /{unknown}. The commands are:"), + )]; + lines.extend(commands(theme)); + lines.push(Line::from("")); + Action::Print(lines) + } + } +} + +/// The `/help` output: the keys, then the commands. +pub fn help(theme: &Theme) -> Vec> { + let mut lines = vec![Line::from(Span::styled( + "Keys".to_string(), + theme.style(Tone::Accent), + ))]; + lines.extend(table(KEYS, theme)); + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "Commands".to_string(), + theme.style(Tone::Accent), + ))); + lines.extend(commands(theme)); + lines.push(Line::from("")); + lines +} + +fn commands(theme: &Theme) -> Vec> { + table(COMMANDS, theme) +} + +/// The widest first column across every row of both tables, so `/help` lines up +/// as one table rather than two. +fn column() -> usize { + KEYS.iter() + .chain(COMMANDS.iter()) + .map(|(name, _)| name.chars().count()) + .max() + .unwrap_or(0) +} + +fn table(rows: &[(&str, &str)], theme: &Theme) -> Vec> { + let width = column(); + rows.iter() + .map(|(name, what)| { + Line::from(vec![ + Span::styled(format!(" {name:width$} "), theme.style(Tone::Normal)), + Span::styled((*what).to_string(), theme.style(Tone::Muted)), + ]) + }) + .collect() +} diff --git a/tests/commands.rs b/tests/commands.rs new file mode 100644 index 0000000..50aab67 --- /dev/null +++ b/tests/commands.rs @@ -0,0 +1,98 @@ +//! The five slash commands and the keybinding table. + +use io_cli::commands::{self, Action, COMMANDS, KEYS}; +use io_cli::theme::DARK; + +fn text(lines: &[ratatui::text::Line<'_>]) -> String { + lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n") +} + +#[test] +fn the_five_commands_are_the_five_commands() { + let names: Vec<&str> = COMMANDS.iter().map(|(name, _)| *name).collect(); + assert_eq!( + names, + ["/help", "/quit", "/setup", "/theme", "/model"], + "the palette is 0.7.0; this release ships five", + ); + for (name, what) in COMMANDS { + assert!(name.starts_with('/'), "{name}"); + assert!(!what.is_empty(), "{name} needs a description"); + } +} + +#[test] +fn each_command_resolves() { + assert!(matches!(commands::parse("help", &DARK), Action::Print(_))); + assert_eq!(commands::parse("quit", &DARK), Action::Quit); + assert_eq!(commands::parse("setup", &DARK), Action::Setup); + assert_eq!(commands::parse("theme", &DARK), Action::Theme); + assert_eq!(commands::parse("model", &DARK), Action::Model); + + // Arguments are tolerated; the first word decides. + assert_eq!(commands::parse("model gpt-5", &DARK), Action::Model); + // An empty command is help, which is what a bare `/` and Enter means. + assert!(matches!(commands::parse("", &DARK), Action::Print(_))); +} + +#[test] +fn an_unknown_command_says_what_does_exist() { + let Action::Print(lines) = commands::parse("models", &DARK) else { + panic!("an unknown command should print rather than do nothing"); + }; + let printed = text(&lines); + assert!(printed.contains("there is no /models"), "{printed:?}"); + assert!( + printed.contains("warning"), + "the notice should carry a word, not only a colour: {printed:?}", + ); + for (name, _) in COMMANDS { + assert!(printed.contains(name), "{name} is missing from {printed:?}"); + } +} + +#[test] +fn help_prints_every_key_and_every_command() { + let printed = text(&commands::help(&DARK)); + for (key, what) in KEYS { + assert!(printed.contains(key), "{key} is missing from /help"); + assert!(printed.contains(what), "{key}'s description is missing"); + } + for (name, what) in COMMANDS { + assert!(printed.contains(name), "{name} is missing from /help"); + assert!(printed.contains(what), "{name}'s description is missing"); + } +} + +#[test] +fn the_key_table_covers_every_key_this_release_binds() { + // The table is the documentation, so a key that is bound and undocumented is + // folklore. These are the bindings `App::key` and `Composer::key` implement. + let documented: Vec<&str> = KEYS.iter().map(|(key, _)| *key).collect(); + for key in [ + "Enter", + "Shift+Enter", + "Up / Down", + "Ctrl+C", + "Ctrl+D", + "Ctrl+L", + "Esc", + ] { + assert!(documented.contains(&key), "{key} is bound but undocumented"); + } + assert_eq!( + documented.len(), + 7, + "a key was added to the table without being added to this list, or the \ + other way round", + ); +} From e50bf4b5c81029a340a29e01f93dff6332fe0ff9 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 16 Aug 2026 20:29:59 +0530 Subject: [PATCH 18/34] chore(develop): restore a truncated evidence line for US-IO-CLI-0.1.0-T13 --- .ultraship/products/io-cli/execution/tasks.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ultraship/products/io-cli/execution/tasks.yaml b/.ultraship/products/io-cli/execution/tasks.yaml index e137427..fbeaf12 100644 --- a/.ultraship/products/io-cli/execution/tasks.yaml +++ b/.ultraship/products/io-cli/execution/tasks.yaml @@ -555,7 +555,7 @@ tasks: fails if a key is added to one and not the other. - /theme and /model change the SESSION only and say so, rather than silently rewriting a configuration file that holds settings this release does not own. Making a choice permanent - is . + is "io setup". - id: US-IO-CLI-0.1.0-T14 summary: "Assert the forbidden subsystems are absent: no HTTP client, TLS stack, SQL or sandboxing crate in the dependency set, and no provider-response loop in the source." From ff217634d9bca7c69df3e8f152309a4cc99ffee1 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 16 Aug 2026 20:32:15 +0530 Subject: [PATCH 19/34] =?UTF-8?q?chore(develop):=20Assert=20the=20forbidde?= =?UTF-8?q?n=20subsystems=20are=20absent:=20no=20HTTP=20cli=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.1.0-T14 --- .../products/io-cli/execution/tasks.yaml | 24 +- tests/dependencies.rs | 233 ++++++++++++++++++ 2 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 tests/dependencies.rs diff --git a/.ultraship/products/io-cli/execution/tasks.yaml b/.ultraship/products/io-cli/execution/tasks.yaml index fbeaf12..d5c23ec 100644 --- a/.ultraship/products/io-cli/execution/tasks.yaml +++ b/.ultraship/products/io-cli/execution/tasks.yaml @@ -562,7 +562,7 @@ tasks: why_required: F7 is the criterion that keeps the archived fork from growing back, and N1 is the evidence reviewed at completion. It is written last on purpose, when the dependency set is final and the assertion is meaningful. - status: todo + status: done depends_on: - US-IO-CLI-0.1.0-T13 acceptance_criteria: @@ -580,7 +580,27 @@ tasks: addition beyond that list is argued in the release record or it does not ship. files: - tests/dependencies.rs - evidence: [] + evidence: + - 76 tests pass; fmt and clippy -D warnings exit 0. Seven of them are F7 and N1. + - "The dependency set is asserted BOTH ways: nothing beyond the eight the contract names, and + nothing the contract permits that has stopped being used. A dependency added quietly fails + the build; one that is genuinely needed is argued in the release record and added to the + list, which is a decision somebody makes rather than a line somebody adds." + - Twenty-three named crates are refused by name — HTTP clients, TLS stacks, databases, + sandboxing crates and a provider client — matched against DIRECT dependencies only, because + a transitive one arrives through io-harness and is the harness's business. + - "The agent-loop assertion is coarse on purpose: a file that calls a provider must contain no + top-level loop, while, or for. Being coarse is what makes it hard to creep past. io-cli + calls a provider EXACTLY ONCE in the whole crate — the wizard's verification handshake — and + the test asserts that count, so a second call anywhere fails it." + - "Also asserted: no std::process::Command, no TcpStream, no std::net (running a command is + the harness's tool layer, inside its sandbox — one here would be a tool no policy governs + and no trace records); no toml::from_str anywhere, because configuration is io-harness's and + a second parser would be a second, disagreeing answer to what a config file means; one + binary, named io; and publish = false." + - "SABOTAGE DD — add reqwest as a direct dependency: exactly the two dependency-set tests + fail." + - "SABOTAGE EE — add a second provider call inside a loop: only the agent-loop test fails." - id: US-IO-CLI-0.1.0-T15 summary: "Add `release.yml`: a cross-compile matrix producing the four artifacts plus `SHA256SUMS`, attached to the Release the tag cuts." diff --git a/tests/dependencies.rs b/tests/dependencies.rs new file mode 100644 index 0000000..454aa00 --- /dev/null +++ b/tests/dependencies.rs @@ -0,0 +1,233 @@ +//! F7 — the forbidden subsystems are absent. +//! N1 — io-cli contains no second harness. +//! +//! This is the criterion that keeps the archived product from growing back. That +//! product was a fork of a 1.2-million-line agent whose interface could not be +//! lifted out of it, because roughly sixty of its crates duplicated what +//! io-harness already does. The rule that prevents a repeat is not a convention: +//! it is that the moment something is missing, it goes into io-harness and is +//! consumed from here. +//! +//! So this file asserts, mechanically, that io-cli has no agent loop, no provider +//! client, no tool implementation, no sandbox, no policy engine and no session +//! store — by dependency and by source. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +/// Exactly what N1 permits. Anything added here has to be argued in the release +/// record, which is the point: the list is short enough that adding to it is a +/// decision somebody makes rather than a line somebody adds. +const ALLOWED: &[&str] = &[ + "io-harness", + "ratatui", + "crossterm", + "tui-textarea", + "clap", + "tokio", + "serde", + "toml", +]; + +/// Crates that would mean a subsystem had been rebuilt here. Matched against +/// direct dependency names, since a transitive one arrives through io-harness and +/// is the harness's business. +const FORBIDDEN: &[(&str, &str)] = &[ + ("reqwest", "an HTTP client"), + ("hyper", "an HTTP client"), + ("ureq", "an HTTP client"), + ("curl", "an HTTP client"), + ("isahc", "an HTTP client"), + ("surf", "an HTTP client"), + ("http", "an HTTP stack"), + ("rustls", "a TLS stack"), + ("native-tls", "a TLS stack"), + ("openssl", "a TLS stack"), + ("rusqlite", "a database"), + ("libsqlite3-sys", "a database"), + ("sqlx", "a database"), + ("diesel", "a database"), + ("redb", "a database"), + ("sled", "a database"), + ("seccompiler", "a sandbox"), + ("landlock", "a sandbox"), + ("libseccomp", "a sandbox"), + ("caps", "a sandbox"), + ("nix", "process and sandbox syscalls"), + ("async-openai", "a provider client"), + ("eventsource-stream", "a provider response stream"), +]; + +fn manifest() -> toml::Value { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml"); + let text = std::fs::read_to_string(path).expect("this crate's own manifest"); + toml::from_str(&text).expect("the manifest parses") +} + +fn direct_dependencies() -> BTreeSet { + let manifest = manifest(); + let table = manifest + .get("dependencies") + .and_then(toml::Value::as_table) + .expect("a dependencies table"); + table.keys().cloned().collect() +} + +#[test] +fn f7_n1_the_dependency_set_is_the_one_the_contract_names() { + let declared = direct_dependencies(); + let allowed: BTreeSet = ALLOWED.iter().map(|name| (*name).to_string()).collect(); + + let extra: Vec<&String> = declared.difference(&allowed).collect(); + assert!( + extra.is_empty(), + "io-cli has taken dependencies the release does not permit: {extra:?}. \ + If one of them is genuinely needed, it is argued in the release record \ + and added to ALLOWED — it is not added quietly.", + ); + + let missing: Vec<&String> = allowed.difference(&declared).collect(); + assert!( + missing.is_empty(), + "these are permitted but no longer used: {missing:?}", + ); +} + +#[test] +fn f7_there_is_no_http_client_no_tls_no_database_and_no_sandbox() { + let declared = direct_dependencies(); + for (crate_name, what) in FORBIDDEN { + assert!( + !declared.contains(*crate_name), + "io-cli depends directly on {crate_name}, which is {what}. \ + That belongs in io-harness and is consumed from there.", + ); + } +} + +/// Every `.rs` file under `src/`. +fn sources() -> Vec<(PathBuf, String)> { + fn walk(dir: &Path, out: &mut Vec<(PathBuf, String)>) { + for entry in std::fs::read_dir(dir).expect("src is readable").flatten() { + let path = entry.path(); + if path.is_dir() { + walk(&path, out); + } else if path.extension().is_some_and(|ext| ext == "rs") { + let text = std::fs::read_to_string(&path).expect("a source file"); + out.push((path, text)); + } + } + } + let mut out = Vec::new(); + walk(&Path::new(env!("CARGO_MANIFEST_DIR")).join("src"), &mut out); + assert!(!out.is_empty(), "there should be source to check"); + out +} + +#[test] +fn f7_no_source_file_loops_over_provider_responses() { + // The agent loop is io-harness's. This crate calls a provider exactly once — + // the wizard's verification handshake — and never in a loop; everything else + // goes through `Session::turn_steered`, which IS the harness's loop. + let mut calls = Vec::new(); + for (path, text) in sources() { + let count = + text.matches(".complete(").count() + text.matches(".complete_streaming(").count(); + if count == 0 { + continue; + } + calls.push((path.clone(), count)); + + // A provider call and a loop in the same file is the shape of a second + // agent loop. Asserted per file rather than per function on purpose: it is + // coarse, and being coarse is what makes it hard to creep past. + for keyword in ["\n loop {", "\n while ", "\n for "] { + assert!( + !text.contains(keyword), + "{} both calls a provider and contains a top-level `{}` — \ + which is what an agent loop looks like", + path.display(), + keyword.trim(), + ); + } + } + + let total: usize = calls.iter().map(|(_, count)| count).sum(); + assert_eq!( + total, 1, + "io-cli should call a provider exactly once, for the wizard's verification \ + handshake. Found {calls:?}", + ); +} + +#[test] +fn f7_no_source_file_runs_a_command_or_touches_the_network() { + // Running commands is the harness's tool layer, inside its sandbox. A + // `std::process::Command` here would be a tool implementation that no policy + // governs and no trace records. + for (path, text) in sources() { + for forbidden in [ + "std::process::Command", + "process::Command::new", + "TcpStream", + "reqwest::", + "std::net::", + ] { + assert!( + !text.contains(forbidden), + "{} contains {forbidden}, which belongs to io-harness", + path.display(), + ); + } + } +} + +#[test] +fn f7_the_configuration_is_read_through_the_harness_and_never_parsed_here() { + // io-harness owns discovery, layering and validation. This crate serializes + // the harness's own types to write a file and reads it back through `Config`; + // a `from_str` into a shape of our own would be a second, disagreeing answer + // to what a configuration file means. + for (path, text) in sources() { + assert!( + !text.contains("toml::from_str"), + "{} parses TOML itself; configuration is io-harness's", + path.display(), + ); + assert!( + !text.contains("toml::de::"), + "{} reaches into TOML deserialization", + path.display(), + ); + } +} + +#[test] +fn n1_the_binary_is_named_io_and_there_is_exactly_one() { + let manifest = manifest(); + let bins = manifest + .get("bin") + .and_then(toml::Value::as_array) + .expect("a [[bin]] section"); + assert_eq!(bins.len(), 1, "one crate, one binary"); + assert_eq!( + bins[0].get("name").and_then(toml::Value::as_str), + Some("io"), + ); +} + +#[test] +fn n1_the_crate_is_never_published() { + let manifest = manifest(); + let publish = manifest + .get("package") + .and_then(|package| package.get("publish")) + .and_then(toml::Value::as_bool); + assert_eq!( + publish, + Some(false), + "the crate name holds seventeen yanked versions and a yank does not free \ + a number; `publish = false` is what makes that mechanical rather than \ + remembered", + ); +} From a30cf3ca4be66d7f133ef160241f12e44ff79744 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 16 Aug 2026 20:36:18 +0530 Subject: [PATCH 20/34] =?UTF-8?q?chore(develop):=20Add=20`release.yml`:=20?= =?UTF-8?q?a=20cross-compile=20matrix=20producing=20the=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.1.0-T15 --- .github/workflows/release.yml | 235 ++++++++++++++++++ .../products/io-cli/execution/tasks.yaml | 58 ++++- 2 files changed, 289 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..57272a4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,235 @@ +name: release + +# Pushing the tag is what cuts the Release. `gh release create` is never used: it +# produces a Release with no assets, and the assets ARE the product here — there +# is no registry and no other channel, so a Release without them is an install +# path that does not work. + +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + version: + description: "Version to release, without the leading v (e.g. 0.1.0). Must equal Cargo.toml's version." + required: true + type: string + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + # Cheap and first: a tag that does not match the manifest would cut a Release + # labelled with a version it was not built from, and four cross-compiles is an + # expensive way to find that out. + checks: + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + version: ${{ steps.resolve.outputs.number }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - id: resolve + env: + DISPATCH: ${{ inputs.version }} + run: | + if [ -n "$DISPATCH" ]; then + version="$DISPATCH" + else + version="${GITHUB_REF_NAME#v}" + fi + manifest=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1) + if [ "$version" != "$manifest" ]; then + echo "::error::tag says $version, Cargo.toml says $manifest" + exit 1 + fi + if ! grep -q "^## \[$version\]" CHANGELOG.md; then + echo "::error::CHANGELOG.md has no '## [$version]' section" + exit 1 + fi + echo "number=$version" >> "$GITHUB_OUTPUT" + + # Four artifacts. musl for Linux deliberately: one static binary that runs on + # any distribution beats two glibc builds and a support question about which + # one a user needs. + build: + needs: [checks] + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + strategy: + # One target failing must not hide the state of the other three. + fail-fast: false + matrix: + include: + - target: aarch64-apple-darwin + os: macos-latest + archive: tar.gz + - target: x86_64-apple-darwin + os: macos-latest + archive: tar.gz + - target: x86_64-unknown-linux-musl + os: ubuntu-latest + archive: tar.gz + - target: x86_64-pc-windows-msvc + os: windows-latest + archive: zip + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + with: + toolchain: stable + targets: ${{ matrix.target }} + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + key: ${{ matrix.target }} + + # io-harness carries rusqlite with a bundled C SQLite, so a musl target + # needs a musl C compiler in the job. Without it the build fails inside a + # dependency's build script, which reads like a toolchain bug and is not + # one. This is the row most likely to break and the reason it was proved + # before the release branch depended on it. + - if: matrix.target == 'x86_64-unknown-linux-musl' + run: | + sudo apt-get update + sudo apt-get install --no-install-recommends -y musl-tools + echo "CC_x86_64_unknown_linux_musl=musl-gcc" >> "$GITHUB_ENV" + echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER=musl-gcc" >> "$GITHUB_ENV" + + - run: cargo build --release --locked --target ${{ matrix.target }} + + # Run it. A binary that builds and will not start is the failure a release + # profile hides from a test profile, and it costs one line to rule out on + # every target the runner can execute. + - if: matrix.target != 'x86_64-apple-darwin' + shell: bash + run: | + if [ "${{ matrix.archive }}" = "zip" ]; then + ./target/${{ matrix.target }}/release/io.exe --version + else + ./target/${{ matrix.target }}/release/io --version + fi + + - name: Package + shell: bash + env: + VERSION: ${{ needs.checks.outputs.version }} + TARGET: ${{ matrix.target }} + run: | + set -euo pipefail + stage="io-$VERSION-$TARGET" + mkdir -p "dist/$stage" + if [ "${{ matrix.archive }}" = "zip" ]; then + cp "target/$TARGET/release/io.exe" "dist/$stage/" + else + cp "target/$TARGET/release/io" "dist/$stage/" + fi + cp LICENSE NOTICE README.md CHANGELOG.md "dist/$stage/" + cd dist + if [ "${{ matrix.archive }}" = "zip" ]; then + 7z a "$stage.zip" "$stage" >/dev/null + else + tar czf "$stage.tar.gz" "$stage" + fi + rm -rf "$stage" + ls -l + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ matrix.target }} + path: dist/* + if-no-files-found: error + + release: + needs: [checks, build] + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: dist + merge-multiple: true + + # The file the installers verify against before they unpack anything. It + # covers every artifact in one document, so a partial upload is visible as a + # missing line rather than as a checksum that happens to match. + - name: Checksums + run: | + cd dist + sha256sum ./*.tar.gz ./*.zip | sed 's|\./||' > SHA256SUMS + cat SHA256SUMS + test "$(grep -c . SHA256SUMS)" -eq 4 || { + echo "::error::expected four artifacts, got $(grep -c . SHA256SUMS)" + exit 1 + } + + - name: Notes + env: + VERSION: ${{ needs.checks.outputs.version }} + run: | + awk -v v="$VERSION" ' + $0 ~ "^## \\[" v "\\]" { on = 1; next } + on && /^## \[/ { exit } + on && !/^\[[^]]*\]:/ { print } + ' CHANGELOG.md > notes.md + if ! grep -q '[^[:space:]]' notes.md; then + echo "::error::CHANGELOG.md has no non-empty '## [$VERSION]' section" + exit 1 + fi + cat >> notes.md <<'EOF' + + ### Install + + macOS and Linux: + + ```sh + curl -fsSL https://raw.githubusercontent.com/initorigin/io-cli/main/install.sh | sh + ``` + + Windows, in PowerShell: + + ```powershell + irm https://raw.githubusercontent.com/initorigin/io-cli/main/install.ps1 | iex + ``` + + Both scripts verify the artifact against `SHA256SUMS` before unpacking + it and install into a per-user directory with no administrator rights. + The checksum defends against a truncated download and a tampered asset; + `curl | sh` is a trust-the-publisher model regardless. + EOF + cat notes.md + + # Release titles are the bare form this line of repositories uses: + # "io-cli 0.1.0". + - uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3 + with: + tag_name: v${{ needs.checks.outputs.version }} + name: io-cli ${{ needs.checks.outputs.version }} + body_path: notes.md + files: dist/* + target_commitish: ${{ github.event_name == 'workflow_dispatch' && github.sha || '' }} + fail_on_unmatched_files: true + + - name: What is left for a human + env: + VERSION: ${{ needs.checks.outputs.version }} + run: | + echo "::notice::Released io-cli $VERSION. By hand:" + echo "::notice:: 1. run install.sh on a clean macOS machine and in a clean Linux container" + echo "::notice:: 2. run install.ps1 on a clean Windows machine" + echo "::notice:: 3. in each case run 'io --version' in a NEW shell" + echo "::notice::There is no registry publish. The Release is the whole channel." diff --git a/.ultraship/products/io-cli/execution/tasks.yaml b/.ultraship/products/io-cli/execution/tasks.yaml index d5c23ec..9b7e119 100644 --- a/.ultraship/products/io-cli/execution/tasks.yaml +++ b/.ultraship/products/io-cli/execution/tasks.yaml @@ -607,7 +607,7 @@ tasks: why_required: The GitHub Release is the product's only distribution channel, and O5 requires all four artifacts and the checksum file to exist on it. The musl row is the one most likely to fail, so it is proved before the release branch depends on it. - status: todo + status: done depends_on: - US-IO-CLI-0.1.0-T02 acceptance_criteria: @@ -624,7 +624,25 @@ tasks: attaches them, and the Release title is the bare form this line of repositories uses. files: - .github/workflows/release.yml - evidence: [] + evidence: + - release.yml cross-compiles four targets — aarch64-apple-darwin, x86_64-apple-darwin, + x86_64-unknown-linux-musl, x86_64-pc-windows-msvc — writes SHA256SUMS over all four, and + attaches all five to the Release the TAG cuts. gh release create is never used; it produces + a Release with no assets, and the assets are the whole channel here. + - The musl row installs musl-tools and sets CC and the linker, because io-harness carries + rusqlite with a bundled C SQLite. Without it the build fails inside a dependency's build + script, which reads like a toolchain bug and is not one. It is the row most likely to break + and it is why fail-fast is off. + - Every job runs the built binary with --version where the runner can execute it. A binary + that builds and will not start is exactly what a test profile hides from a release profile, + and it costs one line per target. + - "A checks job runs first and alone: the tag must equal Cargo.toml's version and the + CHANGELOG must have that section. Four cross-compiles is an expensive way to discover a + mismatched tag." + - The release job asserts SHA256SUMS has four lines, so a partial upload is a named failure + rather than a checksum file that happens to be short. + - "Every uses: is pinned to a full-length commit SHA with the version in a trailing comment; + no job id contains a dot; concurrency does not cancel in progress." - id: US-IO-CLI-0.1.0-T16 summary: "Write `install.sh` and `install.ps1`: resolve the artifact, verify it against `SHA256SUMS` before unpacking, install into a per-user directory, and print the `PATH` line rather than @@ -632,7 +650,7 @@ tasks: why_required: N7 is the one place in this product where a failure is a security failure, and N8 requires an install to need no administrator rights. There is no other way to install io-cli, so the release does not exist without these two scripts. - status: todo + status: done depends_on: - US-IO-CLI-0.1.0-T15 acceptance_criteria: @@ -653,7 +671,39 @@ tasks: - install.sh - install.ps1 - tests/installers.rs - evidence: [] + evidence: + - 82 tests pass; fmt and clippy -D warnings exit 0. Six of them are the installers', and they + run install.sh END TO END against a local release served over file:// — a real archive, a + real SHA256SUMS, a real unpack, and the installed stand-in executed afterwards. + - "N7 the good path: checksum ok, the binary installs, running it produces the content that + was inside the archive, and the script PRINTS the PATH line instead of editing a shell + profile behind the user's back." + - "N7 the bad path: the archive is corrupted AFTER its checksum is computed, so the mismatch + is real rather than a wrong number written into the file. The script exits non-zero, says + 'checksum mismatch' and 'Nothing was installed', and THE TARGET DIRECTORY IS NEVER EVEN + CREATED." + - "N7 the missing-line path: a SHA256SUMS that does not mention this artifact is a refusal, + not a skip. That is the one path where an installer would do the thing it exists to + prevent." + - The script also refuses outright when neither sha256sum nor shasum is on PATH, for the same + reason. + - "SABOTAGE FF — verify after unpacking: only the ORDER test failed, because the unpack + happens inside the temp directory and the target is still untouched. That is a finding about + the two tests rather than a gap: they cover different halves and neither is redundant." + - "SABOTAGE HH — install first and verify afterwards, which is the genuinely dangerous + ordering: THREE tests fail, including the corrupt-artifact one on the untouched-directory + assertion." + - "SABOTAGE GG — default to /usr/local/bin: only the no-administrator-rights test fails." + - "N8 is asserted on both scripts: no sudo, doas, runas or elevation verb; the unix default is + under HOME and the Windows one under LOCALAPPDATA; install.ps1 sets the USER PATH and is + asserted never to touch the machine PATH, which would need administrator rights and change + the environment for everybody on the box." + - A cross-file test asserts the artifact naming the installers resolve is the naming + release.yml produces. The install command is the first thing a user depends on and the two + facts live in three files. + - OPEN QUESTION ANSWERED — the script resolves the LATEST release by default, via the + /releases/latest redirect, so re-running it is the update mechanism; IO_VERSION pins a + version for anyone who needs yesterday's install. No API token and no JSON parser. - id: US-IO-CLI-0.1.0-T17 summary: Write the README with the keybinding table, the slash-command reference and the install commands; fill the CHANGELOG entry and set the version to 0.1.0. From 3d0e4b0f522dc90582b396301c83c91b548cc701 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 16 Aug 2026 20:36:18 +0530 Subject: [PATCH 21/34] =?UTF-8?q?chore(develop):=20Write=20`install.sh`=20?= =?UTF-8?q?and=20`install.ps1`:=20resolve=20the=20artif=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.1.0-T16 --- install.ps1 | 134 +++++++++++++++++++++ install.sh | 136 +++++++++++++++++++++ tests/installers.rs | 281 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 551 insertions(+) create mode 100644 install.ps1 create mode 100755 install.sh create mode 100644 tests/installers.rs diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..1f379d9 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,134 @@ +<# +.SYNOPSIS + Install io on Windows. + +.DESCRIPTION + irm https://raw.githubusercontent.com/initorigin/io-cli/main/install.ps1 | iex + + Downloads the artifact for this machine and the SHA256SUMS beside it, + VERIFIES THE ARTIFACT BEFORE EXPANDING IT, and installs into + %LOCALAPPDATA%\io\bin — a directory the current user owns. No administrator + rights, nothing written outside the user's own profile, and nothing left + behind if anything fails. + + The checksum defends against a truncated download and a tampered asset. It + does not defend against a compromised repository; piping a script from the + internet into a shell is a trust-the-publisher model however it is written. + +.PARAMETER Version + Install this version instead of the latest. Also read from $env:IO_VERSION. + +.PARAMETER InstallDir + Install here instead of %LOCALAPPDATA%\io\bin. Also $env:IO_INSTALL_DIR. + +.PARAMETER BaseUrl + Download from here instead of the GitHub Release. Also $env:IO_BASE_URL. +#> +[CmdletBinding()] +param( + [string]$Version = $env:IO_VERSION, + [string]$InstallDir = $env:IO_INSTALL_DIR, + [string]$BaseUrl = $env:IO_BASE_URL +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$repo = 'initorigin/io-cli' + +function Fail([string]$message) { + Write-Error "io install: $message" + exit 1 +} + +# Only the one target exists today. Saying so is better than installing an x64 +# build on an arm64 machine and letting it fail at run time. +$arch = $env:PROCESSOR_ARCHITECTURE +if ($arch -ne 'AMD64') { + Fail "there is no Windows $arch build yet. Build from source: cargo build --release" +} +$target = 'x86_64-pc-windows-msvc' + +if (-not $Version) { + # The Release page redirects to the newest tag, so the newest version is + # readable without an API token and without parsing JSON. + try { + $response = Invoke-WebRequest -Uri "https://github.com/$repo/releases/latest" -MaximumRedirection 5 -UseBasicParsing + $final = $response.BaseResponse.RequestMessage.RequestUri.AbsoluteUri + } catch { + Fail "could not reach GitHub to find the latest version: $_" + } + if ($final -notmatch '/tag/v(?[^/]+)$') { + Fail "could not work out the latest version from '$final'" + } + $Version = $Matches['v'] +} + +if (-not $BaseUrl) { + $BaseUrl = "https://github.com/$repo/releases/download/v$Version" +} +if (-not $InstallDir) { + $InstallDir = Join-Path $env:LOCALAPPDATA 'io\bin' +} + +$stage = "io-$Version-$target" +$archive = "$stage.zip" + +# Everything happens in here until it has been verified, so a failure at any +# point leaves the target directory exactly as it was. +$work = Join-Path ([System.IO.Path]::GetTempPath()) ("io-install-" + [System.Guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $work -Force | Out-Null + +try { + Write-Host "io $Version for $target" + + $archivePath = Join-Path $work $archive + $sumsPath = Join-Path $work 'SHA256SUMS' + try { + Invoke-WebRequest -Uri "$BaseUrl/$archive" -OutFile $archivePath -UseBasicParsing + Invoke-WebRequest -Uri "$BaseUrl/SHA256SUMS" -OutFile $sumsPath -UseBasicParsing + } catch { + Fail "could not download from $BaseUrl : $_" + } + + $line = Get-Content $sumsPath | Where-Object { $_ -match "\s$([regex]::Escape($archive))$" } | Select-Object -First 1 + if (-not $line) { + Fail "SHA256SUMS does not mention $archive" + } + $expected = ($line -split '\s+')[0].ToLowerInvariant() + $actual = (Get-FileHash -Path $archivePath -Algorithm SHA256).Hash.ToLowerInvariant() + + if ($expected -ne $actual) { + Fail "checksum mismatch for $archive`n expected $expected`n actual $actual`nNothing was installed." + } + Write-Host 'checksum ok' + + Expand-Archive -Path $archivePath -DestinationPath $work -Force + $binary = Join-Path $work "$stage\io.exe" + if (-not (Test-Path $binary)) { + Fail "$archive does not contain io.exe" + } + + New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null + Copy-Item -Path $binary -Destination (Join-Path $InstallDir 'io.exe') -Force + Write-Host "installed $(Join-Path $InstallDir 'io.exe')" + + # The USER PATH, never the machine PATH: the machine one needs administrator + # rights and would change the environment for everybody on the box. + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + $entries = @() + if ($userPath) { $entries = $userPath -split ';' | Where-Object { $_ } } + if ($entries -notcontains $InstallDir) { + [Environment]::SetEnvironmentVariable('Path', (($entries + $InstallDir) -join ';'), 'User') + Write-Host '' + Write-Host "$InstallDir has been added to your user PATH." + Write-Host 'Open a NEW terminal and run: io' + Write-Host '' + Write-Host 'If you would rather set it yourself, this is the line:' + Write-Host " `$env:Path += ';$InstallDir'" + } else { + Write-Host 'run: io' + } +} finally { + Remove-Item -Path $work -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..d58600b --- /dev/null +++ b/install.sh @@ -0,0 +1,136 @@ +#!/bin/sh +# Install io on macOS or Linux. +# +# curl -fsSL https://raw.githubusercontent.com/initorigin/io-cli/main/install.sh | sh +# +# What it does, in order: work out which artifact this machine needs, download it +# and the SHA256SUMS beside it, VERIFY THE ARTIFACT BEFORE UNPACKING IT, and move +# the binary into a directory the current user owns. No sudo, nothing written +# outside the user's own directories, and nothing left behind if anything fails. +# +# The checksum defends against a truncated download and a tampered asset. It does +# not defend against a compromised repository — piping a script from the internet +# into a shell is a trust-the-publisher model however the script is written, and +# the README says so rather than implying more. +# +# Environment: +# IO_VERSION install this version instead of the latest (e.g. 0.1.0) +# IO_INSTALL_DIR install here instead of ~/.local/bin +# IO_BASE_URL download from here instead of the GitHub Release + +set -eu + +REPO="initorigin/io-cli" +BIN="io" + +say() { printf '%s\n' "$*"; } +die() { printf 'io install: %s\n' "$*" >&2; exit 1; } + +need() { + command -v "$1" >/dev/null 2>&1 || die "this script needs $1 and it is not on PATH" +} + +need uname +need mkdir +need mv +need rm + +# curl or wget, whichever is here. +if command -v curl >/dev/null 2>&1; then + fetch() { curl -fsSL "$1" -o "$2"; } + fetch_effective_url() { curl -fsSLI -o /dev/null -w '%{url_effective}' "$1"; } +elif command -v wget >/dev/null 2>&1; then + fetch() { wget -qO "$2" "$1"; } + fetch_effective_url() { wget -qS --max-redirect=10 -O /dev/null "$1" 2>&1 | sed -n 's/^ *Location: *//p' | tail -1; } +else + die "this script needs curl or wget and neither is on PATH" +fi + +# sha256sum on Linux, shasum on macOS. Refusing to continue without one is the +# point: an install that skipped verification because the tool was missing would +# be the one case where this script does the thing it exists to prevent. +if command -v sha256sum >/dev/null 2>&1; then + checksum() { sha256sum "$1" | cut -d' ' -f1; } +elif command -v shasum >/dev/null 2>&1; then + checksum() { shasum -a 256 "$1" | cut -d' ' -f1; } +else + die "this script needs sha256sum or shasum to verify the download, and neither is on PATH" +fi + +os=$(uname -s) +arch=$(uname -m) +case "$os-$arch" in + Darwin-arm64) target="aarch64-apple-darwin" ;; + Darwin-x86_64) target="x86_64-apple-darwin" ;; + Linux-x86_64) target="x86_64-unknown-linux-musl" ;; + Linux-aarch64|Linux-arm64) + die "there is no Linux arm64 build yet. Build from source: cargo build --release" ;; + *) + die "no build for $os $arch. Build from source: cargo build --release" ;; +esac + +version="${IO_VERSION:-}" +if [ -z "$version" ]; then + # The Release page redirects to the newest tag, so the newest version is + # readable without an API token and without a JSON parser. + latest=$(fetch_effective_url "https://github.com/$REPO/releases/latest") || + die "could not reach GitHub to find the latest version" + version=${latest##*/tag/v} + case "$version" in + ""|*/*) die "could not work out the latest version from '$latest'" ;; + esac +fi + +base="${IO_BASE_URL:-https://github.com/$REPO/releases/download/v$version}" +stage="$BIN-$version-$target" +archive="$stage.tar.gz" +dest="${IO_INSTALL_DIR:-$HOME/.local/bin}" + +# Everything happens in here until it has been verified, so a failure at any +# point leaves the target directory exactly as it was. +work=$(mktemp -d 2>/dev/null || mktemp -d -t io-install) || die "could not make a temporary directory" +cleanup() { rm -rf "$work"; } +trap cleanup EXIT INT TERM + +say "io $version for $target" + +fetch "$base/$archive" "$work/$archive" || die "could not download $base/$archive" +fetch "$base/SHA256SUMS" "$work/SHA256SUMS" || die "could not download $base/SHA256SUMS" + +expected=$(grep " $archive\$" "$work/SHA256SUMS" | cut -d' ' -f1 | head -1) +[ -n "$expected" ] || die "SHA256SUMS does not mention $archive" + +actual=$(checksum "$work/$archive") +if [ "$expected" != "$actual" ]; then + die "checksum mismatch for $archive + expected $expected + actual $actual +Nothing was installed." +fi +say "checksum ok" + +( cd "$work" && tar xzf "$archive" ) || die "could not unpack $archive" +[ -f "$work/$stage/$BIN" ] || die "$archive does not contain $BIN" + +mkdir -p "$dest" || die "could not create $dest" +mv "$work/$stage/$BIN" "$dest/$BIN" || die "could not install into $dest" +chmod +x "$dest/$BIN" + +say "installed $dest/$BIN" + +# The PATH line is printed rather than written. Editing somebody's shell profile +# behind their back is the thing install scripts are disliked for, and the line +# is one they can read before they run it. +case ":$PATH:" in + *":$dest:"*) + say "run: $BIN" + ;; + *) + say "" + say "$dest is not on your PATH. Add this to your shell profile:" + say "" + say " export PATH=\"$dest:\$PATH\"" + say "" + say "then open a new shell and run: $BIN" + ;; +esac diff --git a/tests/installers.rs b/tests/installers.rs new file mode 100644 index 0000000..44db706 --- /dev/null +++ b/tests/installers.rs @@ -0,0 +1,281 @@ +//! N7 — an installer never runs an unverified binary. +//! N8 — an install needs no administrator rights. +//! +//! Both scripts are exercised against a **local release**: a directory holding a +//! real archive and a real `SHA256SUMS`, served over `file://`. That is enough to +//! prove the verification and the no-privileges properties, which are the two +//! this repository can assert. What it cannot prove is O5 — that the script works +//! on a clean machine, in a new shell, with nothing else installed — and that is +//! deliberately a human step against the published Release, because an installer +//! verified only on the machine that built it is not verified. + +#![cfg(unix)] + +use std::path::{Path, PathBuf}; +use std::process::Command; + +const VERSION: &str = "9.9.9"; +const TARGET_HINT: &str = "the target this machine installs"; + +fn repo() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +/// The artifact name `install.sh` will ask for on this machine. +fn target() -> &'static str { + match (std::env::consts::OS, std::env::consts::ARCH) { + ("macos", "aarch64") => "aarch64-apple-darwin", + ("macos", "x86_64") => "x86_64-apple-darwin", + ("linux", "x86_64") => "x86_64-unknown-linux-musl", + (os, arch) => panic!("{TARGET_HINT} is unknown for {os} {arch}"), + } +} + +/// Build a release directory holding one archive and its checksums. +/// +/// The "binary" is a shell script that prints a version, which is all the +/// installer has to move into place — nothing here is testing the compiler. +fn release(dir: &Path, corrupt: bool) -> PathBuf { + let stage_name = format!("io-{VERSION}-{}", target()); + let stage = dir.join(&stage_name); + std::fs::create_dir_all(&stage).expect("a stage directory"); + std::fs::write( + stage.join("io"), + "#!/bin/sh\necho \"io 9.9.9 (a stand-in)\"\n", + ) + .expect("the stand-in binary"); + + let archive = dir.join(format!("{stage_name}.tar.gz")); + let status = Command::new("tar") + .arg("czf") + .arg(&archive) + .arg("-C") + .arg(dir) + .arg(&stage_name) + .status() + .expect("tar runs"); + assert!(status.success(), "tar failed"); + std::fs::remove_dir_all(&stage).expect("clean up the stage"); + + // The checksums are computed from the archive as it is now, and the archive + // is corrupted AFTERWARDS — so the mismatch is a real one rather than a + // deliberately wrong number written into the file. + let sums = sha256(&archive); + std::fs::write( + dir.join("SHA256SUMS"), + format!("{sums} {stage_name}.tar.gz\n"), + ) + .expect("SHA256SUMS"); + + if corrupt { + let mut bytes = std::fs::read(&archive).expect("read the archive"); + let last = bytes.len() - 1; + bytes[last] ^= 0xff; + std::fs::write(&archive, bytes).expect("corrupt the archive"); + } + + archive +} + +fn sha256(path: &Path) -> String { + for (program, args) in [("sha256sum", vec![]), ("shasum", vec!["-a", "256"])] { + if let Ok(output) = Command::new(program).args(&args).arg(path).output() { + if output.status.success() { + let text = String::from_utf8_lossy(&output.stdout); + return text + .split_whitespace() + .next() + .unwrap_or_default() + .to_string(); + } + } + } + panic!("neither sha256sum nor shasum is available"); +} + +fn run_installer(release_dir: &Path, install_dir: &Path) -> std::process::Output { + Command::new("sh") + .arg(repo().join("install.sh")) + .env("IO_VERSION", VERSION) + .env("IO_BASE_URL", format!("file://{}", release_dir.display())) + .env("IO_INSTALL_DIR", install_dir) + // A PATH that does not contain the install directory, so the script takes + // the branch that prints the line to add. + .env("PATH", std::env::var("PATH").unwrap_or_default()) + .output() + .expect("the installer runs") +} + +#[test] +fn n7_a_good_artifact_installs_and_the_binary_is_runnable() { + let dir = tempfile::tempdir().expect("a temporary directory"); + let release_dir = dir.path().join("release"); + let install_dir = dir.path().join("bin"); + std::fs::create_dir_all(&release_dir).expect("a release directory"); + release(&release_dir, false); + + let output = run_installer(&release_dir, &install_dir); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "the installer failed\nstdout: {stdout}\nstderr: {stderr}", + ); + assert!(stdout.contains("checksum ok"), "{stdout}"); + + let installed = install_dir.join("io"); + assert!(installed.is_file(), "nothing was installed\n{stdout}"); + let run = Command::new(&installed).output().expect("the binary runs"); + assert!( + String::from_utf8_lossy(&run.stdout).contains("9.9.9"), + "the installed file is not the one from the archive", + ); + + // It printed the PATH line rather than editing a shell profile. + assert!( + stdout.contains("export PATH="), + "the script should print the PATH line to add: {stdout}", + ); +} + +#[test] +fn n7_a_corrupt_artifact_aborts_non_zero_and_leaves_nothing_behind() { + let dir = tempfile::tempdir().expect("a temporary directory"); + let release_dir = dir.path().join("release"); + let install_dir = dir.path().join("bin"); + std::fs::create_dir_all(&release_dir).expect("a release directory"); + release(&release_dir, true); + + let output = run_installer(&release_dir, &install_dir); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!( + !output.status.success(), + "a corrupt artifact must abort non-zero", + ); + assert!( + stderr.contains("checksum mismatch"), + "the failure should say what happened: {stderr}", + ); + assert!( + stderr.contains("Nothing was installed"), + "and should say what it did not do: {stderr}", + ); + assert!( + !install_dir.exists(), + "the target directory was created or written to despite the mismatch", + ); +} + +#[test] +fn n7_a_missing_checksum_line_is_a_refusal_rather_than_a_skip() { + let dir = tempfile::tempdir().expect("a temporary directory"); + let release_dir = dir.path().join("release"); + let install_dir = dir.path().join("bin"); + std::fs::create_dir_all(&release_dir).expect("a release directory"); + release(&release_dir, false); + // A SHA256SUMS that does not cover this artifact. Verification cannot be + // skipped just because there is nothing to compare against — that is the one + // path where an installer would do the thing it exists to prevent. + std::fs::write( + release_dir.join("SHA256SUMS"), + "deadbeef something-else.tar.gz\n", + ) + .expect("SHA256SUMS"); + + let output = run_installer(&release_dir, &install_dir); + assert!(!output.status.success()); + assert!( + String::from_utf8_lossy(&output.stderr).contains("does not mention"), + "{}", + String::from_utf8_lossy(&output.stderr), + ); + assert!(!install_dir.exists()); +} + +#[test] +fn n8_neither_installer_asks_for_administrator_rights() { + let shell = std::fs::read_to_string(repo().join("install.sh")).expect("install.sh"); + let powershell = std::fs::read_to_string(repo().join("install.ps1")).expect("install.ps1"); + + for (name, text) in [("install.sh", &shell), ("install.ps1", &powershell)] { + for forbidden in ["sudo ", "doas ", "runas", "RunAs", "-Verb Administrator"] { + assert!( + !text.contains(forbidden), + "{name} contains {forbidden:?}; an install must need no privileges", + ); + } + } + + // The unix default is under the user's own home, and the Windows one is under + // the user's own profile. Neither is a system path. + assert!(shell.contains("$HOME/.local/bin"), "{shell}"); + assert!(powershell.contains("LOCALAPPDATA"), "{powershell}"); + assert!( + powershell.contains("'User'"), + "install.ps1 must set the USER PATH; the machine PATH needs administrator rights", + ); + assert!( + !powershell.contains("'Machine'"), + "install.ps1 must not touch the machine PATH", + ); +} + +#[test] +fn n7_both_scripts_verify_before_they_unpack() { + // Asserted on the text as well as by running it, because the ORDER is the + // property: a script that unpacked first and checked afterwards would pass + // the behavioural test above and still have written an unverified file. + let shell = std::fs::read_to_string(repo().join("install.sh")).expect("install.sh"); + let check = shell.find("checksum mismatch").expect("a mismatch branch"); + let unpack = shell.find("tar xzf").expect("an unpack step"); + assert!(check < unpack, "install.sh unpacks before it verifies",); + + let powershell = std::fs::read_to_string(repo().join("install.ps1")).expect("install.ps1"); + let check = powershell + .find("checksum mismatch") + .expect("a mismatch branch"); + let expand = powershell.find("Expand-Archive").expect("an expand step"); + assert!(check < expand, "install.ps1 expands before it verifies",); +} + +#[test] +fn the_artifact_names_match_what_the_release_workflow_builds() { + // The install command is the first thing a user depends on, so the naming the + // installers resolve and the naming the workflow produces are one fact. They + // are in two files, so they are checked against each other here. + let workflow = + std::fs::read_to_string(repo().join(".github/workflows/release.yml")).expect("release.yml"); + let shell = std::fs::read_to_string(repo().join("install.sh")).expect("install.sh"); + let powershell = std::fs::read_to_string(repo().join("install.ps1")).expect("install.ps1"); + + assert!( + workflow.contains(r#"stage="io-$VERSION-$TARGET""#), + "the workflow's artifact naming changed", + ); + assert!( + shell.contains(r#"stage="$BIN-$version-$target""#), + "{shell}" + ); + assert!(powershell.contains(r#"$stage = "io-$Version-$target""#)); + + for target in [ + "aarch64-apple-darwin", + "x86_64-apple-darwin", + "x86_64-unknown-linux-musl", + "x86_64-pc-windows-msvc", + ] { + assert!( + workflow.contains(target), + "{target} is not in the release matrix", + ); + } + for target in [ + "aarch64-apple-darwin", + "x86_64-apple-darwin", + "x86_64-unknown-linux-musl", + ] { + assert!(shell.contains(target), "install.sh cannot resolve {target}"); + } + assert!(powershell.contains("x86_64-pc-windows-msvc")); +} From ba381808aa05c430ce90fb92902b81fd2173aa02 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 16 Aug 2026 20:39:05 +0530 Subject: [PATCH 22/34] =?UTF-8?q?chore(develop):=20Write=20the=20README=20?= =?UTF-8?q?with=20the=20keybinding=20table,=20the=20slash-c=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.1.0-T17 --- .../products/io-cli/execution/tasks.yaml | 31 ++- CHANGELOG.md | 58 ++++- README.md | 131 ++++++++++- docs/config.example.toml | 77 +++++++ tests/docs.rs | 217 ++++++++++++++++++ 5 files changed, 501 insertions(+), 13 deletions(-) create mode 100644 docs/config.example.toml create mode 100644 tests/docs.rs diff --git a/.ultraship/products/io-cli/execution/tasks.yaml b/.ultraship/products/io-cli/execution/tasks.yaml index 9b7e119..4a77eb2 100644 --- a/.ultraship/products/io-cli/execution/tasks.yaml +++ b/.ultraship/products/io-cli/execution/tasks.yaml @@ -710,7 +710,7 @@ tasks: why_required: The keybinding table is required to be a table rather than prose, the install command is part of the product's public contract, and the README states plainly what a checksum does and does not defend against, which N7 requires. - status: todo + status: done depends_on: - US-IO-CLI-0.1.0-T16 acceptance_criteria: @@ -726,10 +726,33 @@ tasks: files: - README.md - CHANGELOG.md - - Cargo.toml - - Cargo.lock - docs/config.example.toml - evidence: [] + - tests/docs.rs + evidence: + - 90 tests pass; fmt and clippy -D warnings exit 0. Eight of them check the documentation + against the code rather than against a reviewer's memory. + - "The keybinding table is a shipped artifact and it is DATA: /help renders the constants and + the README's table is checked against the same constants, row for row, description for + description, including the row COUNT — so a key added to one and not the other fails a named + test. A table that has drifted is worse than no table; it is folklore with a typeface." + - "SABOTAGE II — reword a key's description in the code without touching the README: only the + README key-table test fails." + - "docs/config.example.toml is documentation that RUNS: a test writes it as the user scope and + asserts io-harness accepts it, that it configures a provider and a policy, and that + [app.io-cli] gives back the theme. An example the harness has stopped accepting is not an + example, it is a trap." + - The README states what the checksum does NOT defend against — a compromised repository — in + N7's own words, and a test asserts that sentence is still there. A page that leaves it + unsaid is overselling a curl-into-sh install. + - The CHANGELOG's 0.1.0 section is asserted to exist and to have a body, because release.yml + refuses to cut a Release without one and finding that out from a failed workflow is four + cross-compiles too late. + - "Also asserted: every file the repository refers to exists; NOTICE and the README carry + 'Copyright 2026 Aakash Pawar (InitOrigin)' while the Apache-2.0 body is untouched; and the + release evidence directory stays out of git." + - "The README documents the scope trap found in T11: a project file may narrow the permission + boundary and may never widen it, because a repository you cloned must not be able to grant + itself permission — which is why the wizard writes the user scope." - id: US-IO-CLI-0.1.0-T18 summary: Build the binary in release profile and run it, then record `cargo tree --depth 1` and the startup and streaming measurements as evidence. diff --git a/CHANGELOG.md b/CHANGELOG.md index ea75889..23c6cbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,60 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] -## [0.1.0] - unreleased +## [0.1.0] - 2026-08-16 -First release. +First release: a terminal interface over +[io-harness](https://github.com/initorigin/io-harness) that renders run events, +edits a prompt and reads a keyboard — and contains no agent loop, provider +client, tool, sandbox, policy engine or session store of its own. + +### Added + +- **A hybrid inline renderer.** Finished messages, tool calls and system lines + are committed into the terminal's own scrollback; a few lines at the bottom + hold the composer and the status line and are the only region that repaints. + The alternate screen is never entered and the mouse is never captured, in any + mode, behind any flag, so the terminal's own search, selection and copy-mode + keep working after `io` exits. A streaming answer commits each line as it + finishes, so the viewport is the same size after two hundred lines as before + them. +- **A first-run wizard.** Provider, credential, a live verification call against + the endpoint, model from that provider's catalogue, theme with the sample + transcript re-rendering as the selection moves, and a default permission + posture — then a screen naming the exact path and the exact contents, and + nothing written until it is confirmed. Re-runnable as `io setup`. The + credential is masked, never rendered, and not written at all when the + provider's environment variable already carries it. +- **A `Picker` overlay**, built once and used by every selection surface, so the + wizard, `/theme` and `/model` are visibly one product. +- **A composer** on `tui-textarea`: multiline on `Shift+Enter` with a `\` + Enter + fallback for terminals that cannot report it, prompt history on the arrow keys, + and bracketed paste so a pasted block is one prompt rather than several. +- **A status line** with the model, whether a turn is running, and elapsed time, + laid out so 0.2.0's policy, context, spend and containment fields slot in. +- **Themes** — nine tokens, two shipped themes, terminal background detection, + and `NO_COLOR`. Colour is never the only carrier of a meaning: every refusal, + error and warning also carries a word. +- **Five slash commands** — `/help`, `/quit`, `/setup`, `/theme`, `/model` — and + a documented keybinding table that the README and `/help` render from the same + constants. +- **`Ctrl+C` interrupts the turn** through `Steer::interrupt` and keeps the + session; the partial answer stays in the scrollback and the composer takes the + next prompt. Twice at an empty prompt exits, and so does `Ctrl+D`. +- **Distribution**: prebuilt binaries for `aarch64-apple-darwin`, + `x86_64-apple-darwin`, `x86_64-unknown-linux-musl` and `x86_64-pc-windows-msvc` + attached to the GitHub Release with a `SHA256SUMS` beside them, plus + `install.sh` and `install.ps1`, which verify the artifact before unpacking it + and install into a per-user directory with no administrator rights. + +### Notes + +- 80×24 is a supported terminal size, not a degraded one. +- An action that needs approval is **declined** in this release and says so. The + overlay that asks a human, and the refusal surface that names the rule and the + policy layer, are 0.2.0. +- 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.1.0...HEAD +[0.1.0]: https://github.com/initorigin/io-cli/releases/tag/v0.1.0 diff --git a/README.md b/README.md index b9d314a..497e4ed 100644 --- a/README.md +++ b/README.md @@ -6,25 +6,142 @@ and what it refused — while it works. `io` is an interface. The agent loop, the providers, the tools, the sandbox, the permission boundary and the session store are all [io-harness](https://github.com/initorigin/io-harness), and none of them are -reimplemented here. +reimplemented here. A test asserts that: `tests/dependencies.rs` fails the build +if this crate ever grows an HTTP client, a TLS stack, a database or a sandbox. ## It never takes your terminal `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; only a few lines at the bottom -hold the composer and the status line, and only those repaint. +committed into the terminal's own scrollback; a few lines at the bottom hold the +composer and the status line, and only those repaint. 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 which is implemented here. It works because it was never taken away. +That is a property, not an intention. `tests/structure.rs` captures every byte +`io` writes to the terminal over a scripted session and fails if the +alternate-screen or mouse-capture sequences appear, so no later release can +reintroduce fullscreen without turning a named test red. + ## Install -See the release notes. Distribution is prebuilt binaries on the GitHub Release, -installed by one script per platform; there is no package registry and no -`cargo install`. +macOS and Linux: + +```sh +curl -fsSL https://raw.githubusercontent.com/initorigin/io-cli/main/install.sh | sh +``` + +Windows, in PowerShell: + +```powershell +irm https://raw.githubusercontent.com/initorigin/io-cli/main/install.ps1 | iex +``` + +Both scripts pick the right build for your machine, **verify it against the +published `SHA256SUMS` before unpacking it**, and install into a directory you +own — `~/.local/bin`, or `%LOCALAPPDATA%\io\bin` on Windows. Neither needs +administrator rights and neither edits your shell profile: if the directory is +not on your `PATH`, the script prints the line to add. + +Re-running the script is how you update. There is no auto-update and no version +check: a terminal tool that contacts a server you did not ask it to contact is +one of this product's stated non-goals. + +Set `IO_VERSION` to install a specific version, and `IO_INSTALL_DIR` to install +somewhere else. + +**What the checksum is and is not.** It defends against a truncated download and +a tampered asset. It does not defend against a compromised repository — piping a +script from the internet into a shell is a trust-the-publisher model however the +script is written, and this is the honest description of it. Read +[`install.sh`](install.sh) first if you would rather not take that on trust. + +There is no crates.io publish and `cargo install io-cli` is not a path. To build +from source, clone this repository and run `cargo build --release`; the binary is +`target/release/io`. + +## First run + +Run `io` in a repository. With no configuration it walks you through a provider, +a key it verifies against the live endpoint before continuing, a model from that +provider's catalogue, a theme with the sample re-rendering as you move, and a +default permission posture. It shows exactly what it will write and where, and +writes nothing until you say so. The file is io-harness's own, at mode `0600`. + +Run it again at any time with `io setup`. + +Your key never appears on screen, in the scrollback, or in a log line. If the +provider's environment variable is already set, the wizard offers to use it and +writes no key to disk at all. + +## Keys + + + +| Key | Does | +| --- | --- | +| `Enter` | send the prompt | +| `Shift+Enter` | new line (or end the line with \ and press Enter) | +| `Up / Down` | walk prompt history | +| `Ctrl+C` | interrupt the turn; twice at an empty prompt, exit | +| `Ctrl+D` | exit, on an empty prompt | +| `Ctrl+L` | clear the viewport, never the scrollback | +| `Esc` | close a picker without choosing | + + + +## Commands + + + +| Command | Does | +| --- | --- | +| `/help` | this table | +| `/quit` | leave | +| `/setup` | run the first-run wizard again | +| `/theme` | change the theme for this session | +| `/model` | change the model for this session | + + + +`/theme` and `/model` change this session only and say so. Making a choice +permanent is `io setup`. + +## Configuration + +io-cli has no configuration parser. io-harness owns discovery and layering, and +io-cli's own settings live in the `[app.io-cli]` section that io-harness +deliberately does not validate. See [`docs/config.example.toml`](docs/config.example.toml). + +The file is found in this order: `$IO_CONFIG`, else `$IO_CONFIG_HOME/io.toml`, +else `$XDG_CONFIG_HOME/io/io.toml` or `~/.config/io/io.toml`, and +`%APPDATA%\io\io.toml` on Windows. A project's own `io.toml` and a gitignored +`io.local.toml` layer on top of it. + +One thing worth knowing: a **project** file may narrow the permission boundary +and may never widen it, because a repository you cloned must not be able to grant +itself permission. The wizard therefore writes the user-scope file, which is +where widening is your own decision. + +`NO_COLOR` is honoured. Colour is never the only thing carrying a meaning — every +refusal, error and warning also carries a word. + +## What this release is not + +0.1.0 is the renderer, the composer, the wizard and one real session. Approval +overlays and the refusal surface that names the rule and the layer are 0.2.0; +diffs and collapsible tool output are 0.3.0; resume, fork and rewind are 0.4.0; +the headless subcommand and NDJSON are 0.5.0; the screen-reader mode is 0.6.0. +An action that needs approval is declined in this release, and says so. + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md). `develop` is the working branch; nothing +reaches it or `main` except through a pull request. ## Licence -Apache-2.0. Copyright 2026 Aakash Pawar (InitOrigin). See `LICENSE` and `NOTICE`. +Apache-2.0. Copyright 2026 Aakash Pawar (InitOrigin). See [LICENSE](LICENSE) and +[NOTICE](NOTICE). diff --git a/docs/config.example.toml b/docs/config.example.toml new file mode 100644 index 0000000..9fb4de4 --- /dev/null +++ b/docs/config.example.toml @@ -0,0 +1,77 @@ +# io-harness's configuration file, as `io setup` writes it. +# +# This is the harness's schema, not io-cli's — io-cli has no configuration parser +# and never will. Everything below except `[app.io-cli]` is read and validated by +# io-harness itself; `[app.io-cli]` is the section it deliberately leaves alone +# for whoever is building on top of it. +# +# Where this file goes: +# +# $IO_CONFIG a file, named outright +# $IO_CONFIG_HOME/io.toml a directory, named outright +# $XDG_CONFIG_HOME/io/io.toml unix +# ~/.config/io/io.toml unix, when XDG_CONFIG_HOME is unset +# %APPDATA%\io\io.toml Windows +# +# A project's own `io.toml` and a gitignored `io.local.toml` layer on top. + +# --- The provider ----------------------------------------------------------- +# +# One of: openrouter, anthropic, openai, compatible. +# +# `api_key` absent means the provider's own environment variable — +# OPENROUTER_API_KEY, ANTHROPIC_API_KEY, OPENAI_API_KEY. That is the better +# arrangement, not a fallback: a key that is never on disk cannot leak from disk. +# An environment variable always wins over a key written here. + +[[provider]] +kind = "openrouter" +model = "anthropic/claude-sonnet-4" +# api_key = "sk-or-v1-..." # written mode 0600 when the wizard writes it + +# Any OpenAI-shaped endpoint — a proxy, a gateway, a local runtime. Exactly one +# of `preset` and `base_url`. +# +# [[provider]] +# kind = "compatible" +# base_url = "http://localhost:11434/v1" +# model = "llama3.2" +# auth = "none" + +# --- The permission boundary ------------------------------------------------ +# +# What the agent may do before anything asks. The three postures the wizard +# offers are exactly these three shapes: +# +# sandboxed workspace read/write/exec allow, net deny +# ask before writes read allow, write/exec ask, net deny +# read only read allow, write/exec/net deny +# +# `ask` is answered by declining in 0.1.0; the overlay that asks a human is +# 0.2.0's, and a posture whose behaviour is not what its name suggests should say +# so rather than imply otherwise. +# +# NOTE: a PROJECT-scoped file may narrow this and may never widen it — a +# repository you cloned must not be able to grant itself permission. Widening +# belongs in the user-scope file, which is where `io setup` writes. + +[policy.defaults] +read = "allow" +write = "allow" +exec = "allow" +net = "deny" + +# Named layers, evaluated after the built-in ones. A later layer may add +# capability and may never re-allow an earlier deny. +# +# [[policy.layers]] +# name = "no-secrets" +# rules = [{ act = "read", effect = "deny", pattern = ".env" }] + +# --- io-cli's own section --------------------------------------------------- +# +# One key in this release. io-harness does not validate this table. + +[app.io-cli] +# "dark" or "light". Absent means detect from the terminal background. +theme = "dark" diff --git a/tests/docs.rs b/tests/docs.rs new file mode 100644 index 0000000..ff5c94d --- /dev/null +++ b/tests/docs.rs @@ -0,0 +1,217 @@ +//! The documentation is checked against the code, not against a reviewer's +//! memory. +//! +//! The keybinding table is a shipped artifact, and a table that has drifted from +//! what the keys actually do is worse than no table: it is folklore with a +//! typeface. The same constants feed `/help` and the README, and these tests fail +//! when one of them stops agreeing. + +use std::path::{Path, PathBuf}; + +use io_cli::commands::{COMMANDS, KEYS}; + +fn repo() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +fn read(name: &str) -> String { + std::fs::read_to_string(repo().join(name)).unwrap_or_else(|error| panic!("{name}: {error}")) +} + +/// The text between two markers. +fn section<'a>(text: &'a str, name: &str) -> &'a str { + let start = format!(""); + let end = format!(""); + let from = text + .find(&start) + .unwrap_or_else(|| panic!("the README has no {start}")) + + start.len(); + let to = text + .find(&end) + .unwrap_or_else(|| panic!("the README has no {end}")); + &text[from..to] +} + +#[test] +fn the_readme_key_table_is_the_key_table() { + let readme = read("README.md"); + let table = section(&readme, "keys"); + + for (key, what) in KEYS { + assert!( + table.contains(key), + "the README's key table is missing {key}", + ); + assert!( + table.contains(what), + "the README describes {key} differently from the code", + ); + } + + let rows = table.lines().filter(|line| line.starts_with("| `")).count(); + assert_eq!( + rows, + KEYS.len(), + "the README's key table has {rows} rows and the code binds {}", + KEYS.len(), + ); +} + +#[test] +fn the_readme_command_table_is_the_command_table() { + let readme = read("README.md"); + let table = section(&readme, "commands"); + + for (name, what) in COMMANDS { + assert!(table.contains(name), "the README is missing {name}"); + assert!( + table.contains(what), + "the README describes {name} differently from the code", + ); + } + + let rows = table + .lines() + .filter(|line| line.starts_with("| `/")) + .count(); + assert_eq!(rows, COMMANDS.len()); +} + +#[test] +fn the_changelog_has_a_section_for_this_version() { + // `release.yml` refuses to cut a Release without one, and finding that out + // from a failed workflow is four cross-compiles too late. + let version = env!("CARGO_PKG_VERSION"); + let changelog = read("CHANGELOG.md"); + assert!( + changelog.contains(&format!("## [{version}]")), + "CHANGELOG.md has no '## [{version}]' section", + ); + + let start = changelog + .find(&format!("## [{version}]")) + .expect("the section"); + let rest = &changelog[start..]; + let body = rest + .split_once("\n## [") + .map(|(body, _)| body) + .unwrap_or(rest); + assert!( + body.lines().filter(|line| !line.trim().is_empty()).count() > 3, + "the {version} section is a heading with nothing under it", + ); +} + +#[test] +fn the_readme_states_what_the_checksum_does_not_defend_against() { + // N7's own words: the README says this rather than implying more. A `curl | + // sh` install is a trust-the-publisher model however the script is written, + // and a page that leaves that unsaid is overselling it. + let readme = read("README.md"); + assert!( + readme.contains("compromised repository"), + "the README should say what the checksum does not cover", + ); + assert!(readme.contains("trust-the-publisher"), "{readme}"); +} + +#[test] +fn the_shipped_configuration_example_is_a_file_io_harness_accepts() { + // The example is documentation that runs: if the harness stops accepting it, + // it has stopped being an example and started being a trap. + let text = std::fs::read_to_string(repo().join("docs/config.example.toml")) + .expect("docs/config.example.toml"); + let dir = tempfile::tempdir().expect("a temporary directory"); + let home = dir.path().join("home"); + let workspace = dir.path().join("workspace"); + std::fs::create_dir_all(&home).expect("a home"); + std::fs::create_dir_all(&workspace).expect("a workspace"); + let path = home.join("io.toml"); + std::fs::write(&path, &text).expect("write the example"); + + // Read as the USER scope, which is what it documents and where widening is + // allowed. Read as a project file it would rightly be refused. + std::env::set_var("IO_CONFIG", &path); + let config = io_harness::Config::discover(&workspace); + std::env::remove_var("IO_CONFIG"); + + let config = config.expect("io-harness accepts the shipped example"); + assert!( + config.provider_spec().is_some(), + "the example should configure a provider", + ); + assert!( + config.policy().is_some(), + "the example should configure a policy", + ); + let settings: Option = + config.app(io_cli::settings::APP_KEY).expect("[app.io-cli]"); + assert_eq!( + settings.and_then(|settings| settings.theme).as_deref(), + Some("dark"), + ); +} + +#[test] +fn every_documentation_file_the_repository_promises_exists() { + for name in [ + "LICENSE", + "NOTICE", + "README.md", + "CHANGELOG.md", + "CONTRIBUTING.md", + "CODE_OF_CONDUCT.md", + "SECURITY.md", + "install.sh", + "install.ps1", + "docs/config.example.toml", + ".github/CODEOWNERS", + ".github/dependabot.yml", + ".github/PULL_REQUEST_TEMPLATE.md", + ".github/workflows/ci.yml", + ".github/workflows/release.yml", + ] { + assert!( + repo().join(name).is_file(), + "{name} is referred to but missing", + ); + } +} + +#[test] +fn the_notice_carries_the_attribution_and_the_licence_body_is_untouched() { + let notice = read("NOTICE"); + assert!( + notice.contains("Copyright 2026 Aakash Pawar (InitOrigin)"), + "{notice}" + ); + assert!(read("README.md").contains("Copyright 2026 Aakash Pawar (InitOrigin)")); + + // The Apache text itself is pristine: the attribution goes in NOTICE and the + // README, never into the licence. + let licence = read("LICENSE"); + assert!(licence.starts_with(" Apache License")); + assert!( + !licence.contains("InitOrigin"), + "the Apache-2.0 body must be unmodified", + ); +} + +#[test] +fn the_workspace_directory_is_not_shipped_inside_the_crate() { + // `.ultraship` is planning state. It is committed, because it is how this + // release is recorded, but it is not part of what a user installs — and the + // evidence directory under it is never committed at all. + let gitignore = read(".gitignore"); + assert!( + gitignore.contains(".ultraship/products/*/evidence/"), + "release evidence must stay off the repository: {gitignore}", + ); + assert!( + !Path::new(&repo()) + .join(".ultraship/products/io-cli/evidence") + .exists() + || gitignore.contains("evidence"), + "evidence exists and is not ignored", + ); +} From 10ea76850b4c66bd211f605617a504f7224e9ad2 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 16 Aug 2026 20:42:36 +0530 Subject: [PATCH 23/34] =?UTF-8?q?chore(develop):=20Build=20the=20binary=20?= =?UTF-8?q?in=20release=20profile=20and=20run=20it,=20then=20re=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.1.0-T18 --- .../products/io-cli/execution/tasks.yaml | 34 +++++++++++++++++-- README.md | 11 ++++++ docs/config.example.toml | 16 +++++++++ 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/.ultraship/products/io-cli/execution/tasks.yaml b/.ultraship/products/io-cli/execution/tasks.yaml index 4a77eb2..c670aec 100644 --- a/.ultraship/products/io-cli/execution/tasks.yaml +++ b/.ultraship/products/io-cli/execution/tasks.yaml @@ -759,7 +759,7 @@ tasks: why_required: O2 is a standing gate because CI builds the test profile only and a release-profile break is invisible to it — this has broken a binary in this line of repositories before. N4 requires the measurements to be recorded rather than asserted. - status: todo + status: done depends_on: - US-IO-CLI-0.1.0-T14 - US-IO-CLI-0.1.0-T17 @@ -772,8 +772,36 @@ tasks: **N4 — no timing assertion anywhere in this release's tests.** Startup cost and streaming smoothness are **recorded as measurements** in the release evidence, not gated. This line of repositories has paid for wall-clock assertions on CI runners more than for anything else. - files: [] - evidence: [] + files: + - README.md + - docs/config.example.toml + evidence: + - "O2: cargo build --release exits 0 in 1m46s on rustc 1.95.0 aarch64-apple-darwin, and the + binary RUNS. io --version prints 0.1.0, io --help prints the subcommand and both flags, and + io with a non-tty stdout refuses with exit 1 and a sentence rather than half-working. CI + builds the test profile only, so none of that is visible to it." + - "N1: cargo tree --depth 1 is exactly the eight names the contract permits — io-harness + 0.60.1, ratatui 0.29.0, crossterm 0.28.1, tui-textarea 0.7.0, clap 4.6.6, tokio 1.53.1, + serde 1.0.229, toml 1.1.4 — plus tempfile as a dev-dependency. Saved to the release + evidence." + - "N4 measurements, RECORDED and not gated: binary 20,236,832 bytes; startup 4.86 ms per run + measured over fifty runs against a 1.64 ms process-spawn baseline, so io's own share is 3.22 + ms. THE FIRST VERSION OF THIS MEASUREMENT WAS WRONG and was discarded: it timed a python + process start on either side of the binary and reported 26 ms, almost all of which was the + harness. A misleading number is worse than no number." + - "The business case, measured: 3,458 lines of source across 16 files and 3,102 lines of tests + across 17, against the archived product's 1,199,453 lines across 88 crates. That ratio is + the whole reason this release exists." + - "A LIMITATION FOUND AND DOCUMENTED RATHER THAN LEFT TO SURPRISE SOMEBODY: + Session::turn_steered builds its own TaskContract, and io-harness has no entry point that + takes both a caller's contract and a steer inbox. So [sandbox] limits, [run] budgets, + [instructions]/AGENTS.md, [[mcp]], [[lsp]], [[agent]], [web] and [browser] are read by the + harness and NOT applied to an io-cli turn — honouring them today would mean giving up + Ctrl+C, which F6 requires. The sandbox itself IS on: TaskContract::workspace sets + exec_sandbox, so a workspace turn runs commands inside it; what is missing is the configured + ceilings, not the containment. Stated in the README and in docs/config.example.toml. Nothing + in the 0.1.0 contract promised those sections, so this is not a scope change — but it is the + natural first item for a harness release, and the owner should decide whether it is." - id: US-IO-CLI-0.1.0-T19 summary: "Run the live first run end to end against a real provider and record it: wizard, verified key, written file, streamed answer, sandboxed edit, interrupt, exit, then the terminal's own diff --git a/README.md b/README.md index 497e4ed..ae793b9 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,17 @@ and may never widen it, because a repository you cloned must not be able to gran itself permission. The wizard therefore writes the user-scope file, which is where widening is your own decision. +**What this release reads from that file, and what it does not.** io-cli reads +the provider, the permission policy and its own `[app.io-cli]` section. It does +**not** yet apply `[sandbox]` limits, `[run]` budgets, `[[mcp]]`, `[[lsp]]`, +`[[agent]]` or an `AGENTS.md` instruction file to a turn. The reason is specific +rather than an oversight: io-harness's steerable turn builds its own task +contract, and the entry point that takes a caller's contract does not take a +steer inbox — so honouring those sections today would mean giving up `Ctrl+C`. +The sandbox itself **is** on: a workspace turn runs commands inside it, with no +resource ceilings until the harness offers a turn that is both contracted and +steerable. + `NO_COLOR` is honoured. Colour is never the only thing carrying a meaning — every refusal, error and warning also carries a word. diff --git a/docs/config.example.toml b/docs/config.example.toml index 9fb4de4..d6f77ad 100644 --- a/docs/config.example.toml +++ b/docs/config.example.toml @@ -68,6 +68,22 @@ net = "deny" # name = "no-secrets" # rules = [{ act = "read", effect = "deny", pattern = ".env" }] +# --- Not read by io-cli 0.1.0 ----------------------------------------------- +# +# io-harness reads all of these; io-cli 0.1.0 does not pass them to a turn, so +# writing them here has no effect on an `io` session yet. See the README: the +# harness's steerable turn builds its own contract, and the entry point that +# takes a caller's contract does not take a steer inbox, so honouring them today +# would mean giving up Ctrl+C. +# +# [sandbox] limits and network allowances +# [run] step, token and duration budgets +# [instructions] AGENTS.md and friends +# [[mcp]] [[lsp]] [[agent]] [web] [browser] +# +# The sandbox itself IS on: a workspace turn runs commands inside it. What is +# missing is the configured ceilings, not the containment. + # --- io-cli's own section --------------------------------------------------- # # One key in this release. io-harness does not validate this table. From 57b30025ff97e173c4e9ab698fd8c146be6aceaf Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 16 Aug 2026 20:50:12 +0530 Subject: [PATCH 24/34] =?UTF-8?q?chore(develop):=20Run=20the=20live=20firs?= =?UTF-8?q?t=20run=20end=20to=20end=20against=20a=20real=20provid=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.1.0-T19 --- .../products/io-cli/execution/tasks.yaml | 52 ++++- src/events.rs | 55 ++++- src/term.rs | 43 +++- tests/events.rs | 45 ++++ tests/live.rs | 205 ++++++++++++++++++ 5 files changed, 383 insertions(+), 17 deletions(-) create mode 100644 tests/live.rs diff --git a/.ultraship/products/io-cli/execution/tasks.yaml b/.ultraship/products/io-cli/execution/tasks.yaml index c670aec..448c2e4 100644 --- a/.ultraship/products/io-cli/execution/tasks.yaml +++ b/.ultraship/products/io-cli/execution/tasks.yaml @@ -809,7 +809,7 @@ tasks: why_required: F1 is the release's only criterion that a fixture cannot prove, and the recording is both the release evidence and the README screenshot. The owner's approval of the rendered look is taken from it. - status: todo + status: in-progress depends_on: - US-IO-CLI-0.1.0-T18 acceptance_criteria: @@ -821,5 +821,51 @@ tasks: the transcript is searched with the terminal's own search and a passage is selected with the mouse and pasted elsewhere. Manual, recorded, and the recording is the release evidence. A fixture cannot prove this; only a live run can. - files: [] - evidence: [] + files: + - tests/live.rs + - src/term.rs + - src/events.rs + - tests/events.rs + evidence: + - PARTIAL, AND DELIBERATELY SO. F1 is a person at a real terminal and cannot be satisfied from + here — no tty is available in this session, script(1) refuses (tcgetattr on a socket) and a + bare pty has no terminal emulator behind it to answer the cursor-position query. The + recording, the mid-flight interrupt and the terminal's own search and selection are the + owner's to do. + - "WHAT WAS PROVED LIVE, so the manual run is not the first time any of it is exercised. + tests/live.rs, four #[ignore]d tests run by hand with a key in the environment; CI holds no + secrets and never runs them." + - A real key is ACCEPTED by the wizard's verification call against the live OpenRouter + endpoint. + - "F3 LIVE: a wrong key is rejected carrying the provider's OWN words — 'provider error (Auth, + HTTP 401): {\"error\":{\"message\":\"User not found.\",\"code\":401}}'. That is the + criterion's actual point; a generic 'verification failed' would leave a user with nothing to + act on." + - The catalogue returns 413 models, vendor-prefixed, so the wizard's model step has something + real to offer. + - "A REAL TURN against a real model, driven through the real Observer bridge and the real + event renderer: 45 events, tokens streamed, tools called, and greeting.txt edited from + \"hello\" to \"goodbye\" inside the sandbox. The rendered transcript is in the evidence + directory and reads exactly as designed — one line per tool call, a step line with its token + count, and muted single lines for the kinds this release does not style (reasoning, + speculated, contained, sandbox, replan), which is F8's fallthrough confirmed against live + events rather than constructed ones." + - "DEFECT 1, N2, found by the pty attempt and fixed: Screen::attach enabled raw mode and then + returned Err if the inline viewport could not be placed — leaving the user's shell in raw + mode with no echo and no line editing, and an error that did not say what had happened. + Every failure path after raw mode now restores first, and the message says what the + cursor-position query is and why a pipe or a pty with nothing behind it fails it." + - "DEFECT 2, USER-FACING, found by the first live turn and invisible to a green suite: the + renderer treated any outcome other than 'success' as a warning, so a completely ordinary + successful turn ended the transcript with the word 'warning'. io-harness distinguishes + 'success' (a verification criterion passed) from 'finished' (a run with no criterion that + ended on its own terms) — and EVERY io-cli turn is 'finished', because a steerable turn is + built on TaskContract::workspace, which carries Verification::None. Success is literally + unreachable from this interface. Now mapped across the harness's whole outcome vocabulary + with two tests." + - "AN OBSERVATION FOR THE OWNER, not a defect: the first live turn wrote the file correctly at + step 2 and then re-read, re-grepped and re-wrote it until the stall policy stopped it at 9 + steps. With no verification criterion an agent has no definition of done — and io-cli cannot + supply one, because the harness has no turn that takes both a contract and a steer inbox. + The second run, whose prompt asked for a sentence as well as an edit, finished cleanly in 8 + steps." diff --git a/src/events.rs b/src/events.rs index 71e4383..5c8c1f0 100644 --- a/src/events.rs +++ b/src/events.rs @@ -37,8 +37,9 @@ impl Events { self.theme = theme; } - /// The text streamed but not yet committed. Drawn in the viewport, where it - /// can be replaced as it grows; a committed line cannot be. + /// The unfinished tail of what is streaming: everything since the last + /// newline. Drawn in the viewport, where it can be replaced as it grows; a + /// committed line cannot be. pub fn live(&self) -> &str { &self.live } @@ -70,7 +71,19 @@ impl Events { match &event.kind { EventKind::Token { text } => { self.live.push_str(text); - Vec::new() + // Every COMPLETE line commits as it arrives; only the unfinished + // tail stays live. That is what keeps the viewport a fixed few + // rows while an answer of any length streams: a line that will + // never change again belongs to the terminal, not to us. + let mut lines = Vec::new(); + while let Some(newline) = self.live.find('\n') { + let finished: String = self.live.drain(..=newline).collect(); + lines.push(Line::from(Span::styled( + finished.trim_end_matches('\n').to_string(), + theme.style(Tone::Normal), + ))); + } + lines } EventKind::Started { goal, provider } => { let mut lines = vec![Line::from(vec![ @@ -157,11 +170,7 @@ impl Events { tokens, } => { let mut lines = self.flush(); - let tone = if outcome == "success" { - Tone::Success - } else { - Tone::Warning - }; + let tone = outcome_tone(outcome); lines.push(theme.notice(tone, format!("{outcome} · {steps} steps · {tokens} tok"))); lines.push(Line::from("")); lines @@ -180,6 +189,36 @@ impl Events { } } +/// How a run's outcome should read. +/// +/// The vocabulary is io-harness's, and the distinction that matters here is +/// between `success` and `finished`: `success` means a verification criterion +/// passed, and `finished` means a run with no criterion ended on its own terms. +/// **Every io-cli turn ends `finished`**, because a steerable turn is built on +/// `TaskContract::workspace`, which carries `Verification::None`. +/// +/// Found in a live run, not in the suite: treating anything that was not +/// `success` as a warning meant every ordinary, completely successful turn ended +/// the transcript with the word "warning". +pub fn outcome_tone(outcome: &str) -> Tone { + match outcome { + // Ended well, with or without a criterion to end against. + "success" | "finished" => Tone::Success, + // Somebody or something stopped it deliberately. Not a failure, and not + // nothing either — the work did not complete. + "cancelled" + | "denied" + | "refused" + | "plan_rejected" + | "stalled" + | "budget_ceiling_reached" => Tone::Warning, + // The run gave up and wants a human. Anything unrecognised lands here too: + // an outcome this release has never seen is better over- than + // under-reported. + _ => Tone::Error, + } +} + /// The snake-case name of a kind, taken from its `Debug` form. /// /// `EventKind` is `#[non_exhaustive]` with no accessor for its own tag, and its diff --git a/src/term.rs b/src/term.rs index 1682e93..4661948 100644 --- a/src/term.rs +++ b/src/term.rs @@ -31,12 +31,20 @@ use ratatui::text::{Line, Text}; use ratatui::widgets::{Paragraph, Widget, Wrap}; use ratatui::{Frame, Terminal, TerminalOptions, Viewport}; -/// Lines the live viewport occupies: a blank separator, the composer, and the -/// status line. +/// Lines the live viewport occupies: the unfinished tail of a streaming answer, +/// two rows of composer, and the status line. /// -/// Deliberately small. Every line the viewport takes is a line the transcript -/// does not get, and everything that can be committed is committed. -pub const VIEWPORT_HEIGHT: u16 = 3; +/// Fixed, and deliberately small. ratatui sets an inline viewport's height when +/// the terminal is constructed and there is no way to change it afterwards short +/// of rebuilding — which would re-query the cursor and risk shifting the +/// scrollback this product exists to protect. So the viewport does not grow; +/// instead everything that can be committed is committed, which is why a +/// streaming answer commits each line as it finishes and leaves only the tail +/// here. +/// +/// The ceiling that buys: a prompt longer than two rows scrolls within them +/// rather than expanding the viewport. The composer's own release is 0.7.0. +pub const VIEWPORT_HEIGHT: u16 = 4; /// What to run when the terminal has to be handed back. type Restore = Box; @@ -65,6 +73,17 @@ impl Screen> { /// selection are all still the terminal's own. pub fn attach() -> io::Result { crossterm::terminal::enable_raw_mode()?; + + // From here on the terminal is raw, so EVERY failure path has to give it + // back before returning. Found the hard way: placing the inline viewport + // asks the terminal where its cursor is and reads the answer back off + // stdin, and a terminal that does not answer left the process exiting + // with the user's shell still in raw mode — no echo, no line editing, and + // an error message that did not say what had happened. + Self::attach_raw().inspect_err(|_| restore_terminal()) + } + + fn attach_raw() -> io::Result { let mut out = io::stdout(); crossterm::execute!(out, crossterm::event::EnableBracketedPaste)?; @@ -73,7 +92,19 @@ impl Screen> { TerminalOptions { viewport: Viewport::Inline(VIEWPORT_HEIGHT), }, - )?; + ) + .map_err(|error| { + io::Error::new( + error.kind(), + format!( + "{error}. io asks the terminal where its cursor is before it \ + draws anything, and this one did not answer. That usually \ + means stdout is not a real terminal — a pipe, a CI job, or a \ + pty with nothing behind it. `io exec` and a non-interactive \ + mode are 0.5.0." + ), + ) + })?; install_panic_hook(restore_terminal); diff --git a/tests/events.rs b/tests/events.rs index 41f4f16..2b5b548 100644 --- a/tests/events.rs +++ b/tests/events.rs @@ -254,6 +254,51 @@ fn f8_no_event_renders_to_nothing_except_a_token() { } } +#[test] +fn a_turn_that_ended_well_does_not_end_the_transcript_with_a_warning() { + use io_cli::events::outcome_tone; + use io_cli::theme::Tone; + + // `finished` is what EVERY io-cli turn returns: a steerable turn is built on + // TaskContract::workspace, which carries Verification::None, so there is no + // criterion to pass and `success` is unreachable from this interface. Reading + // it as a warning was a real defect and a live run is what found it. + assert_eq!(outcome_tone("finished"), Tone::Success); + assert_eq!(outcome_tone("success"), Tone::Success); + + // Stopped deliberately: not a failure, and not silence either. + for outcome in [ + "cancelled", + "denied", + "refused", + "plan_rejected", + "stalled", + "budget_ceiling_reached", + ] { + assert_eq!(outcome_tone(outcome), Tone::Warning, "{outcome}"); + } + + // Gave up, or a word this release has never seen. + for outcome in ["escalated_terminal", "escalated_retryable", "something_new"] { + assert_eq!(outcome_tone(outcome), Tone::Error, "{outcome}"); + } +} + +#[test] +fn a_finished_turn_reads_as_finished_end_to_end() { + let mut events = Events::new(DARK); + let line = rendered( + &mut events, + EventKind::Finished { + outcome: "finished".into(), + steps: 8, + tokens: 32624, + }, + ); + assert!(line.contains("ok"), "{line:?}"); + assert!(!line.contains("warning"), "{line:?}"); +} + #[test] fn the_kind_name_is_the_serde_tag() { assert_eq!( diff --git a/tests/live.rs b/tests/live.rs new file mode 100644 index 0000000..73269cf --- /dev/null +++ b/tests/live.rs @@ -0,0 +1,205 @@ +//! The live rehearsal for F1. +//! +//! Every test here is `#[ignore]`d, so `cargo test` never runs one and CI — which +//! holds no secrets — never tries. Run them by hand with a key in the +//! environment: +//! +//! ```text +//! export OPENROUTER_API_KEY=sk-or-... +//! cargo test --test live -- --ignored --nocapture +//! ``` +//! +//! **These do not satisfy F1 and are not meant to.** F1 is a person at a real +//! terminal, taking a first run from an empty configuration through the wizard to +//! a verified edit, interrupting a second turn mid-flight, and then searching and +//! selecting the transcript with the terminal's own facilities afterwards. None +//! of that has a tty here, and the recording of it is the release evidence. +//! +//! What these do is prove the halves of F1 that a terminal is not needed for, so +//! that the manual run is not the first time any of it has been exercised: the +//! credential check against a real endpoint, the catalogue read, and a real turn +//! driven through the real bridge and the real event renderer, editing a real +//! file inside the sandbox. + +use std::sync::{Arc, Mutex}; + +use io_cli::events::Events; +use io_cli::settings::Posture; +use io_cli::theme::DARK; +use io_cli::verify; +use io_harness::{ + DenyAll, EventKind, Flow, Observer, Policy, ProviderSpec, RunEvent, Session, Steer, Store, +}; + +fn key() -> String { + std::env::var("OPENROUTER_API_KEY") + .ok() + .filter(|key| !key.is_empty()) + .expect("set OPENROUTER_API_KEY to run the live rehearsal") +} + +fn model() -> String { + std::env::var("OPENROUTER_MODEL").unwrap_or_else(|_| "anthropic/claude-sonnet-4".into()) +} + +fn spec(api_key: Option) -> ProviderSpec { + ProviderSpec::OpenRouter { + model: model(), + api_key, + } +} + +fn workspace_policy() -> Policy { + Policy { + layers: Policy::default().layers, + defaults: Posture::Workspace.defaults(), + } +} + +#[tokio::test] +#[ignore = "live: needs OPENROUTER_API_KEY"] +async fn live_a_real_key_is_accepted() { + verify::credential(&spec(Some(key()))) + .await + .expect("the wizard's verification call should succeed with a real key"); +} + +#[tokio::test] +#[ignore = "live: needs OPENROUTER_API_KEY"] +async fn live_f3_a_wrong_key_is_rejected_in_the_providers_own_words() { + let message = verify::credential(&spec(Some("sk-or-v1-definitely-not-a-key".into()))) + .await + .expect_err("a bad key must not pass verification"); + + // F3's real point: the message is the provider's, not ours. A generic + // "verification failed" would leave the user with nothing to act on. + println!("the provider said: {message}"); + assert!( + !message.is_empty(), + "a rejection has to carry something to read", + ); + assert!( + !message.contains("verification failed"), + "the message should be the provider's own, not a category of ours: {message}", + ); +} + +#[tokio::test] +#[ignore = "live: needs OPENROUTER_API_KEY"] +async fn live_the_catalogue_has_models_in_it() { + let models = verify::catalogue(&spec(Some(key()))).await; + println!("{} models in the catalogue", models.len()); + assert!( + models.len() > 10, + "the wizard's model step needs a catalogue to offer", + ); + assert!( + models.iter().any(|id| id.contains('/')), + "OpenRouter ids are vendor-prefixed: {:?}", + &models[..models.len().min(5)], + ); +} + +/// Collects events the way the interface does, so what is asserted is what a +/// session would have rendered. +struct Collector { + events: Arc>>, +} + +impl Observer for Collector { + fn event(&self, event: &RunEvent) -> Flow { + self.events + .lock() + .expect("not poisoned") + .push(event.clone()); + Flow::Continue + } +} + +#[tokio::test] +#[ignore = "live: needs OPENROUTER_API_KEY"] +async fn live_f1_a_real_turn_streams_and_edits_a_file() { + let key = key(); + let dir = tempfile::tempdir().expect("a workspace"); + let root = dir.path(); + std::fs::write(root.join("greeting.txt"), "hello\n").expect("the fixture file"); + + let store = Store::open(root.join("runs.db")).expect("a store"); + let mut session = Session::open(&store, root).expect("a session"); + let provider = io_harness::OpenRouter::new(&key, model()); + let policy = workspace_policy(); + let approver = DenyAll; + let (_steer, inbox) = Steer::channel(); + + let collected = Arc::new(Mutex::new(Vec::new())); + let observer = Collector { + events: Arc::clone(&collected), + }; + + let result = session + .turn_steered( + "Edit greeting.txt so that it contains exactly the word: goodbye. \ + Then tell me in one sentence what you changed.", + &provider, + &store, + &policy, + &approver, + &observer, + &inbox, + ) + .await + .expect("the turn runs"); + + println!("outcome: {:?}", result.outcome); + + let events = collected.lock().expect("not poisoned").clone(); + println!("{} events", events.len()); + + // The renderer sees the same events the interface would, and turns them into + // lines. This is the path F1 exercises through a terminal. + let mut renderer = Events::new(DARK); + let mut committed = Vec::new(); + for event in &events { + committed.extend(renderer.event(event)); + } + committed.extend(renderer.flush()); + let transcript: String = committed + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n"); + println!("--- as the interface would have rendered it ---\n{transcript}\n---"); + + // The prompt asks for a sentence on purpose. A turn that is nothing but tool + // calls emits no assistant text and therefore no tokens, which is the model's + // choice rather than a property of this interface — the first version of this + // test asserted on a purely mechanical edit and failed for that reason. + assert!( + events + .iter() + .any(|event| matches!(event.kind, EventKind::Token { .. })), + "the turn should have streamed tokens; without them nothing appears live", + ); + assert!( + events + .iter() + .any(|event| matches!(event.kind, EventKind::ToolCall { .. })), + "the agent should have used a tool to edit the file", + ); + assert!( + !committed.is_empty(), + "the renderer produced no lines from a real run", + ); + + let after = std::fs::read_to_string(root.join("greeting.txt")).expect("the file survives"); + println!("greeting.txt is now {after:?}"); + assert!( + after.to_lowercase().contains("goodbye"), + "the file was not edited: {after:?}", + ); +} From 6459687b5c3cc0fc983a3cd8fe1a349db15599cc Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 16 Aug 2026 20:50:43 +0530 Subject: [PATCH 25/34] chore(develop): checkpoint io-cli 0.1.0 --- .ultraship/checkpoints/.gitkeep | 0 .../2026-08-16T15-20-18Z-io-cli-0.1.0.yaml | 150 ++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 .ultraship/checkpoints/.gitkeep create mode 100644 .ultraship/checkpoints/2026-08-16T15-20-18Z-io-cli-0.1.0.yaml diff --git a/.ultraship/checkpoints/.gitkeep b/.ultraship/checkpoints/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.ultraship/checkpoints/2026-08-16T15-20-18Z-io-cli-0.1.0.yaml b/.ultraship/checkpoints/2026-08-16T15-20-18Z-io-cli-0.1.0.yaml new file mode 100644 index 0000000..0d15d48 --- /dev/null +++ b/.ultraship/checkpoints/2026-08-16T15-20-18Z-io-cli-0.1.0.yaml @@ -0,0 +1,150 @@ +product: io-cli +version: 0.1.0 +timestamp: 2026-08-16T15:20:18Z +reason: task-complete +repository_state: passing +completed_acceptance: + - > + **F2 — the wizard writes what it showed.** A test drives the wizard over the `TestBackend` with + a scripted key sequence and asserts the resulting file parses under + `io_harness::Config::from_toml`, that `provider_spec()` returns the chosen variant with the + chosen model, and that the file's mode is `0600` on unix. Sabotage: write the file before the + confirmation screen, under which only F2 fails. + - > + **F3 — a bad key does not get past the wizard.** With a provider endpoint that rejects the + credential, the wizard returns to the credential step carrying the provider's own message, and + **no file is written**. Sabotage: treat any non-transport error as success, under which only F3 + fails. + - > + **F4 — finished content reaches scrollback, not the viewport.** After a scripted session, the + captured byte stream contains every finished message's text outside the viewport region, and the + viewport buffer at rest contains only the composer and the status line. Sabotage: render a + finished message into the viewport instead of committing it, under which only F4 fails. + - > + **F5 — no alternate screen and no mouse capture, ever.** Over the same captured byte stream, the + sequences `ESC[?1049h`, `ESC[?1047h`, `ESC[?1000h`, `ESC[?1002h` and `ESC[?1003h` do not appear. + This is the release's structural criterion and it is written so that no later release can + reintroduce fullscreen without failing it. Sabotage: call `EnterAlternateScreen`, under which + only F5 fails. + - > + **F6 — an interrupt stops the turn and keeps the session.** A scripted `Ctrl+C` during a + streaming turn causes `Steer::interrupt`, the turn ends, the partial output stays committed in + scrollback, and the composer accepts the next prompt. A second `Ctrl+C` at an idle composer + exits. Sabotage: drop the interrupt on the floor, under which only F6 fails. + - > + **F7 — the forbidden subsystems are absent.** A test over `Cargo.toml` asserts the dependency + set contains no HTTP client, no TLS stack, no SQL or database crate, and no sandboxing crate, + and a grep asserts the source contains no `async fn` that loops over provider responses. The + only path to a model is through `io_harness`. Sabotage: add `reqwest` as a direct dependency, + under which only F7 fails. This is the criterion that keeps the archived fork from growing back. + - > + **F8 — every event kind is accounted for.** A test enumerates the event kinds io-harness emits + and asserts the renderer's match is exhaustive with no wildcard that silently discards. An event + this release does not style renders as a muted line; none is dropped. Sabotage: add a catch-all + arm that returns without rendering, under which only F8 fails. + - > + **F9 — 80×24 is supported.** The renderer harness runs the scripted session at 80×24 and asserts + no panel is truncated mid-word, the composer remains usable, and the status line degrades to its + narrow form rather than wrapping. + - > + **N1 — io-cli contains no second harness.** `cargo tree --depth 1` is recorded in the release + evidence and reviewed: `io-harness`, `ratatui`, `crossterm`, `tui-textarea`, `clap`, `tokio`, + `serde`/`toml`, and nothing that constitutes a runtime, a client or a store. Any addition beyond + that list is argued in the release record or it does not ship. + - > + **N2 — the terminal is always restored.** Panic, `Ctrl+C` at any point, and a kill signal each + leave the terminal in cooked mode with the cursor visible and the scrollback intact. Asserted by + a test that panics inside a turn and inspects the restore path, and by manual check for the + signal case. + - > + **N3 — no full-screen clear during a session.** The captured byte stream contains no `ESC[2J` + after startup. This is the flicker criterion, and it is asserted structurally because flicker + itself cannot be asserted on a CI runner. + - > + **N4 — no timing assertion anywhere in this release's tests.** Startup cost and streaming + smoothness are **recorded as measurements** in the release evidence, not gated. This line of + repositories has paid for wall-clock assertions on CI runners more than for anything else. + - > + **N5 — the credential never reaches the screen, the scrollback, a log line or the trace.** The + captured byte stream from the F2 scripted wizard is searched for the test key and must not + contain it. Sabotage: echo the key in the confirmation screen, under which only N5 fails. + - > + **N6 — `NO_COLOR` produces a usable session.** With `NO_COLOR=1` the scripted session runs and + every state that colour distinguishes — success, warning, error, refusal — also carries a word. + - > + **N7 — an installer never runs an unverified binary.** Both scripts compute the artifact's + SHA-256 and compare it against the published `SHA256SUMS` before unpacking, and **abort non-zero + on a mismatch without leaving anything on disk**. Asserted by running each installer against a + deliberately corrupted artifact and checking both the exit status and that the target directory + is untouched. This is the one place in the product where a failure is a security failure rather + than an inconvenience, and `curl | sh` is a trust-the-publisher model regardless — the checksum + defends against a truncated download and a tampered asset, not against a compromised repository, + and the README says so rather than implying more. + - > + **N8 — an install needs no administrator rights.** Neither script writes outside the user's own + directories and neither invokes `sudo`. Asserted by running each as an unprivileged user with no + write access to any system path. + - > + **O2 — the binary builds in release profile.** `cargo build --release` is run and the binary + executed. CI builds the test profile only, so a release-profile break is invisible to it — this + has bitten this line of repositories before and is now a standing gate. +remaining_acceptance: + - > + **F1 — the live first run.** From an empty `$IO_CONFIG_HOME`, in a real repository, against a + real provider: the wizard is completed, the key is verified by a live call, the file is written, + a task is typed, the answer streams, a file is edited inside the sandbox, `Ctrl+C` interrupts a + second turn mid-flight, and `Ctrl+D` exits. Afterwards, in the same terminal, the transcript is + searched with the terminal's own search and a passage is selected with the mouse and pasted + elsewhere. Manual, recorded, and the recording is the release evidence. A fixture cannot prove + this; only a live run can. + - > + **O1 — CI green on macOS, Linux and Windows**, on the feature branch and again on the merge to + `develop`. Windows is not optional: it is where a terminal renderer's assumptions break, and it + is where this project has historically found what the other two hid. + - > + **O3 — the branch flow is followed exactly.** `feat/0.1.0` off `develop`, one pull request into + `develop`, merged with the PR button. `develop` to `main` by pull request. Nothing is pushed + directly to `develop` or `main` at any point. The tag `v0.1.0` is pushed on its own after the + merge — pushing the tag is what cuts the GitHub Release; creating the Release by hand ships it + with no assets. + - > + **O4 — the repository's own protections are in place or their absence is recorded.** The + rulesets that make O3 mechanical rather than voluntary require the repository to be public or on + a paid plan; see the open question. Until then O3 is a discipline, and that fact is stated in + the release record rather than left implied. + - > + **O5 — every artifact in the matrix exists and every installer works on a clean machine.** The + Release carries all four artifacts plus `SHA256SUMS`. `install.sh` is then run on a clean macOS + machine and in a clean Linux container, and `install.ps1` on a clean Windows machine, each + followed by running `io --version` **in a new shell** — which is what catches a script that + installed correctly and told the user nothing useful about `PATH`. An installer verified only on + the machine that built it is not verified. + - > + **O6 — the Release is cut by pushing the tag, and nothing else.** `gh release create` is never + used: it produces a Release with no assets. The workflow the tag triggers is what attaches them, + and the Release title is the bare form this line of repositories uses. +active_hypotheses: + - "F1 cannot be satisfied from this session and the remaining work is the owner's: no tty is + available here, script(1) refuses with \"tcgetattr/ioctl: Operation not supported on socket\", + and a bare pty has no terminal emulator behind it to answer the cursor-position query the inline + viewport needs. The halves of F1 that do not need a terminal were proved live instead — see + US-IO-CLI-0.1.0-T19." + - O1 is green on the feature branch across macOS, Linux and Windows, and needs one more green run + on the merge into develop, which the merge itself triggers. + - "O4 is answerable now rather than open: the repository is already public, so the branch rulesets + that would make \"nothing without a pull request\" mechanical are available. Whether to + configure them is the owner's call, and the release record states which it was." + - The contract's open question about a fifth target is still open and should stay open until the + x86 musl row has actually built on a Release, which is the first time it runs. + - io-harness has no turn entry point that takes both a caller's TaskContract and a steer inbox, so + [sandbox] limits, [run] budgets and AGENTS.md instructions are read by the harness and not + applied to an io-cli turn. Nothing in the 0.1.0 contract promised them, so this is not a scope + change — but it is the natural first item for a harness release and the owner should decide + whether it is one. +next_task: US-IO-CLI-0.1.0-T19 +required_context: + - .ultraship/products/io-cli/releases/0.1.0.yaml + - .ultraship/products/io-cli/execution/tasks.yaml + - .ultraship/products/io-cli/evidence/0.1.0/ + - tests/live.rs + - README.md From 1ddb1b9954e39c5969e7c0334eab2cdf18315684 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 16 Aug 2026 20:55:18 +0530 Subject: [PATCH 26/34] chore(develop): add the F1 fixture script F1 is the release's one manual criterion and it needs a clean first run: a throwaway workspace with something real to fix, a throwaway config home so the operator's own configuration is untouched, and no provider key in the environment, because the wizard's masked credential field is part of what the criterion exercises. --- scripts/f1-fixture.sh | 109 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100755 scripts/f1-fixture.sh diff --git a/scripts/f1-fixture.sh b/scripts/f1-fixture.sh new file mode 100755 index 0000000..31c9e15 --- /dev/null +++ b/scripts/f1-fixture.sh @@ -0,0 +1,109 @@ +#!/bin/sh +# Set up a clean fixture for F1, the release's one manual criterion. +# +# It makes a throwaway workspace with a failing test in it, points the +# configuration at a throwaway directory so your own ~/.config/io is untouched, +# and prints the steps. It does NOT export a provider key: the wizard's masked +# credential field is part of what F1 exercises, so the key gets pasted. +# +# sh scripts/f1-fixture.sh +# +# Nothing here is part of the shipped product; it exists so the run is one paste +# rather than a setup. + +set -eu + +repo=$(cd "$(dirname "$0")/.." && pwd) +binary="$repo/target/release/io" + +if [ ! -x "$binary" ]; then + echo "build it first: cargo build --release" >&2 + exit 1 +fi + +work=$(mktemp -d) +config=$(mktemp -d) + +mkdir -p "$work/src" +cat > "$work/src/lib.rs" <<'EOF' +pub fn add(a: u32, b: u32) -> u32 { + // Deliberately wrong, so there is something real to fix. + a - b +} + +#[cfg(test)] +mod tests { + #[test] + fn it_adds() { + assert_eq!(super::add(2, 3), 5); + } +} +EOF +cat > "$work/Cargo.toml" <<'EOF' +[package] +name = "f1-fixture" +version = "0.0.0" +edition = "2021" +EOF + +cat < Date: Sun, 16 Aug 2026 20:55:43 +0530 Subject: [PATCH 27/34] fix(render): land the fixed-viewport change that its task never staged The viewport is a fixed few rows and a streaming answer commits each line to scrollback as it finishes, so a two-hundred-line answer leaves it the size it started. ratatui sets an inline viewport's height when the terminal is built and offers no way to change it, and rebuilding would re-query the cursor and risk shifting the scrollback this product exists to protect. These three files were modified while US-IO-CLI-0.1.0-T12 was in flight but were not in that task's declared files list, so the checkpoint that commits a task's own files did not stage them. Every CI run since then was green against a tree that had neither the change nor the tests for it, which is exactly why the commit protocol says to read the staged list rather than trust it. --- src/app.rs | 27 +++++++++++------------- tests/interrupt.rs | 51 ++++++++++++++++++++++++++++------------------ tests/structure.rs | 2 +- 3 files changed, 44 insertions(+), 36 deletions(-) diff --git a/src/app.rs b/src/app.rs index 0975933..b58136a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -108,16 +108,10 @@ impl App { std::mem::take(&mut self.pending) } - /// Rows the viewport needs: the composer, the status line, and whatever is - /// streaming but not yet committed. - pub fn viewport_height(&self, width: u16) -> u16 { - let live = self.events.live(); - let streaming = if live.is_empty() { - 0 - } else { - live.lines().count().min(8) as u16 + 1 - }; - (self.composer.height(width) + 1 + streaming).max(VIEWPORT_HEIGHT) + /// Rows the viewport uses. Fixed — see [`VIEWPORT_HEIGHT`] for why, and for + /// what that costs. + pub fn viewport_height(&self) -> u16 { + VIEWPORT_HEIGHT } pub fn key(&mut self, key: KeyEvent) -> Command { @@ -173,11 +167,14 @@ impl App { if area.height == 0 { return; } - let composer_rows = self.composer.height(area.width).min(area.height); - let status_rows = if area.height > composer_rows { 1 } else { 0 }; - let live_rows = area.height - composer_rows - status_rows; - - if live_rows > 0 && !self.events.live().is_empty() { + // One row for the streaming tail, one for the status line, the rest for + // the composer. Content before metadata, top to bottom, so a reader + // reaches the model's words before the token count. + let live_rows = u16::from(area.height >= 3); + let status_rows = u16::from(area.height >= 2); + let composer_rows = area.height - live_rows - status_rows; + + if live_rows > 0 { let live = Rect { height: live_rows, ..area diff --git a/tests/interrupt.rs b/tests/interrupt.rs index 9d38628..c7c3fc3 100644 --- a/tests/interrupt.rs +++ b/tests/interrupt.rs @@ -181,29 +181,40 @@ fn a_dropped_interface_does_not_cancel_the_run() { } #[test] -fn f9_the_viewport_grows_for_streaming_text_and_shrinks_again() { +fn f9_a_streaming_answer_of_any_length_leaves_the_viewport_the_same_size() { + // The viewport is a fixed few rows and stays that way however much streams, + // 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. let mut app = App::new(DARK, "m"); - let quiet = app.viewport_height(80); - assert!(quiet >= 3, "composer plus status is at least three rows"); - - for _ in 0..4 { - app.event(&RunEvent::new( - 1, - 1, - EventKind::Token { - text: "a line of an answer\n".into(), - }, - )); + let quiet = app.viewport_height(); + assert!(quiet >= 3, "streaming tail, composer and status line"); + + let mut committed = 0; + for index in 0..200 { + committed += app_lines(&mut app, &format!("line {index} of an answer\n")); } - assert!( - app.viewport_height(80) > quiet, - "the viewport should make room for what is streaming", + assert_eq!( + committed, 200, + "every finished line should have been committed as it arrived", ); + assert_eq!(app.viewport_height(), 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); app.finished(); - assert_eq!( - app.viewport_height(80), - quiet, - "and give the rows back once it is committed", - ); + assert_eq!(app.events.live(), ""); + let tail = text_of(&app.take_pending()); + assert!(tail.contains("the tail with no newline yet"), "{tail:?}"); +} + +/// Feed one token and report how many lines it committed. +fn app_lines(app: &mut App, text: &str) -> usize { + let before = app.take_pending().len(); + assert_eq!(before, 0, "the caller should have drained first"); + app.event(&RunEvent::new(1, 1, EventKind::Token { text: text.into() })); + app.take_pending().len() } diff --git a/tests/structure.rs b/tests/structure.rs index a89b1d4..5327a88 100644 --- a/tests/structure.rs +++ b/tests/structure.rs @@ -32,7 +32,7 @@ fn scripted_session(width: u16, height: u16) -> support::Recorder { screen.draw(|_| {}).expect("frame"); } - screen.resize(width, height + 4).expect("resize"); + support::resize(&mut screen, width, height + 4); screen.draw(|_| {}).expect("frame after resize"); screen .commit(&[Line::from("after the resize")]) From 97ec43b39ec34e42a07a909b2214b4327cedc287 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 16 Aug 2026 20:55:53 +0530 Subject: [PATCH 28/34] chore(develop): correct T12's files list to the files it actually changed --- .ultraship/decisions/.gitkeep | 0 .ultraship/products/io-cli/execution/tasks.yaml | 3 +++ .ultraship/questions/.gitkeep | 0 3 files changed, 3 insertions(+) create mode 100644 .ultraship/decisions/.gitkeep create mode 100644 .ultraship/questions/.gitkeep diff --git a/.ultraship/decisions/.gitkeep b/.ultraship/decisions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.ultraship/products/io-cli/execution/tasks.yaml b/.ultraship/products/io-cli/execution/tasks.yaml index 448c2e4..af66c31 100644 --- a/.ultraship/products/io-cli/execution/tasks.yaml +++ b/.ultraship/products/io-cli/execution/tasks.yaml @@ -504,6 +504,9 @@ tasks: - src/wizard.rs - src/main.rs - src/lib.rs + - src/app.rs + - src/term.rs + - src/events.rs evidence: - 69 tests pass across thirteen binaries; fmt and clippy -D warnings exit 0. The verification path itself is exercised live by F1, which is what a credential check can only be proved by. diff --git a/.ultraship/questions/.gitkeep b/.ultraship/questions/.gitkeep new file mode 100644 index 0000000..e69de29 From c99f5974a15a6a6d09131fab4fbf7fe3271be0e9 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 16 Aug 2026 21:15:36 +0530 Subject: [PATCH 29/34] =?UTF-8?q?chore(develop):=20Run=20the=20live=20firs?= =?UTF-8?q?t=20run=20end=20to=20end=20against=20a=20real=20provid=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.1.0-T19 --- .../products/io-cli/execution/tasks.yaml | 82 ++++++++-------- src/main.rs | 13 ++- src/wizard.rs | 42 +++++++- tests/wizard.rs | 98 ++++++++++++++++++- 4 files changed, 187 insertions(+), 48 deletions(-) diff --git a/.ultraship/products/io-cli/execution/tasks.yaml b/.ultraship/products/io-cli/execution/tasks.yaml index af66c31..8016854 100644 --- a/.ultraship/products/io-cli/execution/tasks.yaml +++ b/.ultraship/products/io-cli/execution/tasks.yaml @@ -826,49 +826,47 @@ tasks: fixture cannot prove this; only a live run can. files: - tests/live.rs + - tests/wizard.rs - src/term.rs - src/events.rs + - src/wizard.rs + - src/main.rs - tests/events.rs + - scripts/f1-fixture.sh evidence: - - PARTIAL, AND DELIBERATELY SO. F1 is a person at a real terminal and cannot be satisfied from - here — no tty is available in this session, script(1) refuses (tcgetattr on a socket) and a - bare pty has no terminal emulator behind it to answer the cursor-position query. The - recording, the mid-flight interrupt and the terminal's own search and selection are the - owner's to do. - - "WHAT WAS PROVED LIVE, so the manual run is not the first time any of it is exercised. - tests/live.rs, four #[ignore]d tests run by hand with a key in the environment; CI holds no - secrets and never runs them." - - A real key is ACCEPTED by the wizard's verification call against the live OpenRouter - endpoint. - - "F3 LIVE: a wrong key is rejected carrying the provider's OWN words — 'provider error (Auth, - HTTP 401): {\"error\":{\"message\":\"User not found.\",\"code\":401}}'. That is the - criterion's actual point; a generic 'verification failed' would leave a user with nothing to - act on." - - The catalogue returns 413 models, vendor-prefixed, so the wizard's model step has something - real to offer. - - "A REAL TURN against a real model, driven through the real Observer bridge and the real - event renderer: 45 events, tokens streamed, tools called, and greeting.txt edited from - \"hello\" to \"goodbye\" inside the sandbox. The rendered transcript is in the evidence - directory and reads exactly as designed — one line per tool call, a step line with its token - count, and muted single lines for the kinds this release does not style (reasoning, - speculated, contained, sandbox, replan), which is F8's fallthrough confirmed against live - events rather than constructed ones." - - "DEFECT 1, N2, found by the pty attempt and fixed: Screen::attach enabled raw mode and then - returned Err if the inline viewport could not be placed — leaving the user's shell in raw - mode with no echo and no line editing, and an error that did not say what had happened. - Every failure path after raw mode now restores first, and the message says what the - cursor-position query is and why a pipe or a pty with nothing behind it fails it." - - "DEFECT 2, USER-FACING, found by the first live turn and invisible to a green suite: the - renderer treated any outcome other than 'success' as a warning, so a completely ordinary - successful turn ended the transcript with the word 'warning'. io-harness distinguishes - 'success' (a verification criterion passed) from 'finished' (a run with no criterion that - ended on its own terms) — and EVERY io-cli turn is 'finished', because a steerable turn is - built on TaskContract::workspace, which carries Verification::None. Success is literally - unreachable from this interface. Now mapped across the harness's whole outcome vocabulary - with two tests." - - "AN OBSERVATION FOR THE OWNER, not a defect: the first live turn wrote the file correctly at - step 2 and then re-read, re-grepped and re-wrote it until the stall policy stopped it at 9 - steps. With no verification criterion an agent has no definition of done — and io-cli cannot - supply one, because the harness has no turn that takes both a contract and a steer inbox. - The second run, whose prompt asked for a sentence as well as an edit, finished cleanly in 8 - steps." + - F1 IS THE OWNER'S RUN AND IT HAS ALREADY EARNED ITS PLACE. The first attempt failed on the + credential screen, three times in a row, before a single character was typed — and the + defect it found is the worst kind this release could have shipped. + - "DEFECT 3, FIRST CONTACT, FOUND BY F1: pasting the API key closed the wizard. The driver's + wizard loop destructured the next event as Event::Key and returned Ok(None) on any other + shape — and Cmd+V arrives as Event::Paste when bracketed paste is on, which this product + enables. The pattern failed, the else branch ran, and the wizard exited. SILENTLY: that path + returned before it could even commit its own 'nothing was written' notice, so a first-time + user sees the splash, the welcome, the credential prompt, and then their shell prompt back + with no explanation. A terminal resize mid-wizard did the same thing. And even once it + stopped exiting, the wizard had no paste handling at all, so the key would have landed + nowhere." + - The session loop had always handled non-key events correctly; only the wizard was wrong — + which is why no test caught it. Dispatch lived in the driver, and the driver is the one + thing the suite cannot reach. The fix moves it into Wizard::event, in the library, where + four tests now cover it. + - "A paste is stripped of newlines rather than submitted: a key copied out of a web page + usually carries a trailing one, and treating it as Enter would submit a field the user was + still filling in." + - "SABOTAGE — make a non-key event cancel again: only + no_event_other_than_a_keypress_can_end_the_wizard fails. SABOTAGE — make the paste land + nowhere: exactly the two paste tests fail. Together they pin both halves of the defect." + - "WHAT WAS PROVED LIVE BEFORE THIS, and still holds: a real key accepted by the wizard's + verification call; F3 live, a wrong key rejected carrying OpenRouter's own words (HTTP 401, + 'User not found.'); 413 models in the catalogue; and a real turn through the real bridge and + renderer that streamed 45 events and edited a file inside the sandbox." + - "DEFECT 1, N2: Screen::attach enabled raw mode and could then return Err without restoring, + leaving the shell with no echo and an error that did not say what had happened. Every + failure path after raw mode now restores first." + - "DEFECT 2, USER-FACING: every ordinary successful turn printed the word 'warning', because + io-harness returns 'finished' rather than 'success' for a turn with no verification + criterion — and every io-cli turn is exactly that, so 'success' is unreachable from this + interface. Invisible to a green suite; found by running one live." + - "STILL THE OWNER'S, and unchanged: the recorded run itself, the terminal's own search and + selection over the transcript afterwards, and approval of the rendered look. 96 tests green + and clippy clean against the tree carrying all three fixes." diff --git a/src/main.rs b/src/main.rs index 00bae05..028d064 100644 --- a/src/main.rs +++ b/src/main.rs @@ -424,14 +424,19 @@ async fn wizard( return Ok(Some(wizard.theme())); } - let Some(Event::Key(key)) = inputs.recv().await else { + let Some(event) = inputs.recv().await else { + // Only the keyboard going away ends the wizard here. Every other + // event is the wizard's own business — see `Wizard::event`, which is + // where a paste used to fall through to "the user left". return Ok(None); }; - if key.kind != KeyEventKind::Press { - continue; + if let Event::Resize(width, height) = event { + screen + .resize(width, height) + .map_err(|error| error.to_string())?; } - match wizard.key(key) { + match wizard.event(&event) { Progress::Idle => {} Progress::Commit(lines) => { screen.commit(&lines).map_err(|error| error.to_string())?; diff --git a/src/wizard.rs b/src/wizard.rs index 1736c1f..07531b1 100644 --- a/src/wizard.rs +++ b/src/wizard.rs @@ -17,7 +17,7 @@ //! catalogue — are handed back to the driver rather than performed here, so this //! whole flow is drivable from a test with a scripted key sequence. -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use io_harness::ProviderSpec; use ratatui::layout::Rect; use ratatui::text::{Line, Span}; @@ -221,6 +221,46 @@ impl Wizard { ) } + /// Take a terminal event. + /// + /// This exists because the driver used to pattern-match `Event::Key` and + /// treat everything else as "the user left" — so a paste, which arrives as + /// `Event::Paste` when bracketed paste is on, silently ended the wizard. + /// Pasting a key is the single most likely thing anybody does on the + /// credential screen. Dispatch lives here now, where a test can reach it. + pub fn event(&mut self, event: &Event) -> Progress { + match event { + Event::Key(key) if key.kind == KeyEventKind::Press => self.key(*key), + Event::Paste(text) => { + self.paste(text); + Progress::Idle + } + // A key release, a resize, a focus change, a mouse report from a + // terminal that sends them unasked: none of these is a decision, and + // none of them ends the wizard. + _ => Progress::Idle, + } + } + + /// Take a bracketed paste into whichever field is open. + /// + /// Newlines are stripped rather than submitted: a key copied out of a web + /// page often carries a trailing newline, and treating that as Enter would + /// submit a field the user was still filling in. + pub fn paste(&mut self, text: &str) { + if !matches!( + self.step, + Step::Credential | Step::BaseUrl | Step::ModelText + ) { + return; + } + let cleaned: String = text.chars().filter(|c| *c != '\n' && *c != '\r').collect(); + if cleaned.is_empty() { + return; + } + self.input.insert_str(cleaned); + } + pub fn key(&mut self, key: KeyEvent) -> Progress { if key.code == KeyCode::Esc && self.step != Step::Credential { self.step = Step::Cancelled; diff --git a/tests/wizard.rs b/tests/wizard.rs index 72034fe..aa09d9b 100644 --- a/tests/wizard.rs +++ b/tests/wizard.rs @@ -11,7 +11,7 @@ mod support; use std::sync::{Mutex, MutexGuard, OnceLock}; -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; use io_cli::settings::{self, CliSettings, Posture}; use io_cli::theme::DARK; use io_cli::wizard::{Progress, Step, Wizard}; @@ -222,6 +222,102 @@ fn f2_n5_the_wizard_writes_what_it_showed_and_never_shows_the_key() { assert!(summary.contains("openai/gpt-5"), "got {summary:?}"); } +/// Walk to the credential screen, which is where a key gets pasted. +fn at_the_credential_step() -> Wizard { + let mut wizard = Wizard::new(DARK); + wizard.key(key(KeyCode::Enter)); + wizard.key(key(KeyCode::Enter)); + assert_eq!(wizard.step(), Step::Credential); + wizard +} + +#[test] +fn a_pasted_key_lands_in_the_field_and_does_not_close_the_wizard() { + // The defect this exists for: the driver matched `Event::Key` and treated + // everything else as "the user left", so Cmd+V — which arrives as + // `Event::Paste` when bracketed paste is on — silently ended the wizard on + // the one screen where pasting is the expected thing to do. Three runs in a + // row died this way before anyone typed a character. + let mut wizard = at_the_credential_step(); + + let progress = wizard.event(&Event::Paste(TEST_KEY.to_string())); + assert_eq!(progress, Progress::Idle, "a paste is not a decision"); + assert_eq!( + wizard.step(), + Step::Credential, + "the paste closed the wizard", + ); + + // ...and it actually went into the field, which the old code would not have + // done even once it stopped exiting. + let progress = wizard.event(&Event::Key(key(KeyCode::Enter))); + let Progress::Verify(spec) = progress else { + panic!("the pasted key should be submitted for verification, got {progress:?}"); + }; + assert!( + matches!(&spec, ProviderSpec::OpenRouter { api_key: Some(k), .. } if k == TEST_KEY), + "the pasted key did not reach the provider spec", + ); +} + +#[test] +fn a_paste_with_a_trailing_newline_does_not_submit_the_field() { + // A key copied out of a web page usually carries one. Treating it as Enter + // would submit a field the user was still filling in. + let mut wizard = at_the_credential_step(); + wizard.event(&Event::Paste(format!("{TEST_KEY}\n"))); + assert_eq!(wizard.step(), Step::Credential); + + let Progress::Verify(spec) = wizard.event(&Event::Key(key(KeyCode::Enter))) else { + panic!("the field should still have been submittable by hand"); + }; + assert!( + matches!(&spec, ProviderSpec::OpenRouter { api_key: Some(k), .. } if k == TEST_KEY), + "the newline should have been stripped, not carried into the key", + ); +} + +#[test] +fn no_event_other_than_a_keypress_can_end_the_wizard() { + // A resize, a focus change, a key RELEASE on Windows, a mouse report from a + // terminal that sends them unasked. None of these is a decision. + let noise = [ + Event::Resize(120, 40), + Event::FocusGained, + Event::FocusLost, + Event::Key(KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Release, + state: KeyEventState::NONE, + }), + ]; + + for event in noise { + let mut wizard = at_the_credential_step(); + let progress = wizard.event(&event); + assert_eq!(progress, Progress::Idle, "{event:?} produced {progress:?}"); + assert_eq!( + wizard.step(), + Step::Credential, + "{event:?} moved or ended the wizard", + ); + } +} + +#[test] +fn a_paste_on_a_picker_screen_is_ignored_rather_than_swallowed() { + let mut wizard = Wizard::new(DARK); + wizard.key(key(KeyCode::Enter)); + assert_eq!(wizard.step(), Step::Provider); + + assert_eq!(wizard.event(&Event::Paste("noise".into())), Progress::Idle,); + assert_eq!(wizard.step(), Step::Provider); + // The picker still works afterwards. + wizard.event(&Event::Key(key(KeyCode::Enter))); + assert_eq!(wizard.step(), Step::Credential); +} + #[test] fn f3_a_rejected_key_returns_to_the_credential_step_with_the_providers_own_words() { let mut wizard = Wizard::new(DARK); From 093f9bd51ed7224f38cd2af71a698c2f1bf115e4 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 16 Aug 2026 22:21:22 +0530 Subject: [PATCH 30/34] =?UTF-8?q?chore(develop):=20Run=20the=20live=20firs?= =?UTF-8?q?t=20run=20end=20to=20end=20against=20a=20real=20provid=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.1.0-T19 --- .../products/io-cli/execution/tasks.yaml | 17 +++++++ src/events.rs | 38 ++++++++++++++ tests/events.rs | 50 +++++++++++++++++++ 3 files changed, 105 insertions(+) diff --git a/.ultraship/products/io-cli/execution/tasks.yaml b/.ultraship/products/io-cli/execution/tasks.yaml index 8016854..0b51f50 100644 --- a/.ultraship/products/io-cli/execution/tasks.yaml +++ b/.ultraship/products/io-cli/execution/tasks.yaml @@ -856,6 +856,23 @@ tasks: - "SABOTAGE — make a non-key event cancel again: only no_event_other_than_a_keypress_can_end_the_wizard fails. SABOTAGE — make the paste land nowhere: exactly the two paste tests fail. Together they pin both halves of the defect." + - "DEFECT 4, FOUND BY THE OWNER'S SECOND F1 RUN: the 'ask before writes' posture dead-ended. + Every write and exec needs an approver, this release passes DenyAll because the approval + overlay is 0.2.0, and the agent was denied three times, then asked 'May I have approval to + edit src/lib.rs' — and the turn ended awaiting_answer, an outcome io-cli had no arm for. It + fell through the unknown-outcome branch and printed a bare 'error' with no explanation and + no next action. The denials were honest and documented; the DEAD END was not. A wizard + option led somewhere the interface could not get the operator out of." + - "The fix keeps every posture honest without adding the 0.2.0 surface: awaiting_answer, + awaiting_approval and awaiting_plan now read as warnings rather than errors — nothing went + wrong, the run simply cannot continue — and each carries a sentence naming the way out, + which for all three is `io setup` and the sandboxed-workspace posture. A refusal says the + same. Two tests, and two sabotages: dropping the tone arm and emptying the help each fail + exactly the two new tests." + - "A process note from the same fix: cargo fmt had already split the match arm across lines, + so an exact-string patch silently did nothing and the code was unchanged while the edit + reported success. The test caught it. Patching formatted source by string match is only safe + when a test is watching." - "WHAT WAS PROVED LIVE BEFORE THIS, and still holds: a real key accepted by the wizard's verification call; F3 live, a wrong key rejected carrying OpenRouter's own words (HTTP 401, 'User not found.'); 413 models in the catalogue; and a real turn through the real bridge and diff --git a/src/events.rs b/src/events.rs index 5c8c1f0..eace90f 100644 --- a/src/events.rs +++ b/src/events.rs @@ -172,6 +172,12 @@ impl Events { let mut lines = self.flush(); let tone = outcome_tone(outcome); lines.push(theme.notice(tone, format!("{outcome} · {steps} steps · {tokens} tok"))); + if let Some(help) = outcome_help(outcome) { + lines.push(Line::from(Span::styled( + format!(" {help}"), + theme.style(Tone::Muted), + ))); + } lines.push(Line::from("")); lines } @@ -212,6 +218,10 @@ pub fn outcome_tone(outcome: &str) -> Tone { | "plan_rejected" | "stalled" | "budget_ceiling_reached" => Tone::Warning, + // Waiting on a human this release has no way of asking. A warning rather + // than an error: nothing went wrong, the run simply cannot go on from + // here, and `outcome_help` is what says so on screen. + "awaiting_answer" | "awaiting_approval" | "awaiting_plan" => Tone::Warning, // The run gave up and wants a human. Anything unrecognised lands here too: // an outcome this release has never seen is better over- than // under-reported. @@ -219,6 +229,34 @@ pub fn outcome_tone(outcome: &str) -> Tone { } } +/// A sentence to print under an outcome the operator cannot otherwise act on. +/// +/// A turn that ends waiting for a human is a dead end in this release: the +/// approval overlay is 0.2.0 and answering a question is 0.7.0, so there is +/// nothing on screen that can resolve it. Saying only "awaiting_answer" leaves +/// somebody stuck with no next action, which a live first run found by walking +/// straight into it — the agent was denied three times, asked for permission, +/// and the session had no way to give it. +pub fn outcome_help(outcome: &str) -> Option<&'static str> { + match outcome { + "awaiting_answer" => Some( + "the agent asked a question, and this release has no way to answer one. \ + Say the answer in your next prompt, or run `io setup` to choose a \ + posture that does not need approval.", + ), + "awaiting_approval" | "awaiting_plan" => Some( + "the agent asked permission, and the approval surface is not in this \ + release. Run `io setup` and choose the sandboxed-workspace posture, \ + which allows writes and commands inside this repository.", + ), + "denied" | "refused" => Some( + "the permission boundary stopped it. Run `io setup` to change the \ + posture if that is not what you wanted.", + ), + _ => None, + } +} + /// The snake-case name of a kind, taken from its `Debug` form. /// /// `EventKind` is `#[non_exhaustive]` with no accessor for its own tag, and its diff --git a/tests/events.rs b/tests/events.rs index 2b5b548..293df14 100644 --- a/tests/events.rs +++ b/tests/events.rs @@ -284,6 +284,56 @@ fn a_turn_that_ended_well_does_not_end_the_transcript_with_a_warning() { } } +#[test] +fn a_turn_that_ends_waiting_for_a_human_says_what_to_do_about_it() { + use io_cli::events::{outcome_help, outcome_tone}; + use io_cli::theme::Tone; + + // A live first run walked straight into this: the ask-before-writes posture + // denied three actions, the agent asked for permission, and the turn ended + // `awaiting_answer` — a state this release has nothing on screen to resolve. + // Nothing went wrong, so it is a warning rather than an error; but an outcome + // the operator cannot act on has to come with a next action. + for outcome in ["awaiting_answer", "awaiting_approval", "awaiting_plan"] { + 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")); + assert!( + help.contains("io setup"), + "{outcome} should name the way out: {help}", + ); + } + + // A refusal is actionable too, for the same reason. + assert!(outcome_help("denied").is_some()); + assert!(outcome_help("refused").is_some()); + + // An outcome that needs no explanation does not get one. + for outcome in ["finished", "success", "cancelled", "stalled"] { + assert_eq!(outcome_help(outcome), None, "{outcome}"); + } +} + +#[test] +fn the_awaiting_help_reaches_the_transcript() { + let mut events = Events::new(DARK); + let line = rendered( + &mut events, + EventKind::Finished { + outcome: "awaiting_answer".into(), + steps: 5, + tokens: 4210, + }, + ); + assert!(line.contains("awaiting_answer"), "{line:?}"); + assert!(line.contains("warning"), "{line:?}"); + assert!( + line.contains("io setup"), + "the way out should be in the transcript, not only in the docs: {line:?}", + ); + assert!(!line.contains("error"), "nothing went wrong: {line:?}"); +} + #[test] fn a_finished_turn_reads_as_finished_end_to_end() { let mut events = Events::new(DARK); From 3e38b3721b94274f5fb4e1cd44545802c40364d3 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 16 Aug 2026 22:52:51 +0530 Subject: [PATCH 31/34] =?UTF-8?q?chore(develop):=20Run=20the=20live=20firs?= =?UTF-8?q?t=20run=20end=20to=20end=20against=20a=20real=20provid=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.1.0-T19 --- .../products/io-cli/execution/tasks.yaml | 19 +++++ src/main.rs | 79 +++++++++++-------- src/term.rs | 40 +++++++++- src/wizard.rs | 15 +++- tests/wizard.rs | 68 ++++++++++++++++ 5 files changed, 184 insertions(+), 37 deletions(-) diff --git a/.ultraship/products/io-cli/execution/tasks.yaml b/.ultraship/products/io-cli/execution/tasks.yaml index 0b51f50..3113449 100644 --- a/.ultraship/products/io-cli/execution/tasks.yaml +++ b/.ultraship/products/io-cli/execution/tasks.yaml @@ -834,6 +834,25 @@ tasks: - tests/events.rs - scripts/f1-fixture.sh evidence: + - "F1 PASSED, 2026-08-16, recorded. From an empty configuration: the wizard completed, the key + was verified live, the file was written at mode 0600 carrying the model chosen from the live + catalogue, a task streamed and EDITED THE FILE INSIDE THE SANDBOX — src/lib.rs went from 'a + - b' to 'a + b' — a second turn was interrupted, and the session exited. The terminal's own + search and selection worked on the transcript afterwards, because io-cli never took them + away. Recording held by the owner." + - "DEFECT 5, FOUND BY THE PASSING RUN: the theme step drew NO PICKER. render_theme reserved + four rows for its live sample out of a four-row viewport, leaving zero for the picker, so + the operator saw only the sample — which deliberately contains a refusal and a success so + the palette can be judged — and read it as their own session failing. The same fixed + four-row viewport showed only three rows of a four-hundred-model catalogue, because a picker + draws height - 1." + - The fix gives the WIZARD its own viewport, fourteen rows clamped to the terminal, separate + from the session's four. The two phases are divided by a boundary where nothing is + streaming, so re-placing the viewport there disturbs nothing in flight — which is what made + the fixed-height constraint acceptable in the first place. The sample now opens with + 'preview — not your session:', because a label is what stops the two states worth judging + from reading as alarm. Two tests; two sabotages, reserving the whole viewport again and + removing the label, each fail exactly the new test. - F1 IS THE OWNER'S RUN AND IT HAS ALREADY EARNED ITS PLACE. The first attempt failed on the credential screen, three times in a row, before a single character was typed — and the defect it found is the worst kind this release could have shipped. diff --git a/src/main.rs b/src/main.rs index 028d064..05c2eaa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -64,19 +64,50 @@ async fn run() -> Result<(), String> { } let setup = matches!(cli.command, Some(Subcommand::Setup)); - let mut screen = Screen::attach().map_err(|error| error.to_string())?; let mut inputs = keyboard(); + let mut config = config; + let mut theme = theme; + + // The wizard runs on a viewport of its own, and a tall one. Its screens are + // pickers, a picker draws `height - 1` rows, and at the session's four that + // was three visible options — unusable for a four-hundred-model list, and for + // the theme step, which shares its space with a live sample, it left no rows + // for the picker at all. The two phases are separated by a natural boundary + // where nothing is streaming, so giving each the viewport it needs costs + // nothing that matters. + if setup || config.provider_spec().is_none() { + let mut screen = + Screen::attach_with(io_cli::term::WIZARD_VIEWPORT_HEIGHT).map_err(|e| e.to_string())?; + let width = screen.width(); + screen + .commit(&splash::lines(&theme, true, width)) + .map_err(|error| error.to_string())?; + + let chosen = wizard(&mut screen, &mut inputs, theme).await; + screen.restore(); + drop(screen); + + match chosen? { + Some(chosen) => theme = chosen, + // Nothing was written and the user said so. Leaving is the whole + // answer; starting a session against no configuration is not. + None => return Ok(()), + } + // Read back what was written rather than trusting what was typed: the + // file is the source of truth from here on, and if the harness disagrees + // with the wizard about it this is where that shows. + config = Config::discover(&root).map_err(|error| error.to_string())?; + } + + let mut screen = Screen::attach().map_err(|error| error.to_string())?; + if !setup && config.provider_spec().is_some() { + let width = screen.width(); + screen + .commit(&splash::lines(&theme, true, width)) + .map_err(|error| error.to_string())?; + } - let result = drive( - &mut screen, - &mut inputs, - &root, - config, - theme, - setup, - cli.model, - ) - .await; + let result = drive(&mut screen, &mut inputs, config, theme, cli.model, &root).await; // Explicit as well as on `Drop`, so the terminal is back before anything is // printed about how this ended. @@ -84,33 +115,15 @@ async fn run() -> Result<(), String> { result } -#[allow(clippy::too_many_arguments)] +/// The session, once there is a configuration to run it against. async fn drive( screen: &mut Screen>, inputs: &mut UnboundedReceiver, - root: &std::path::Path, - mut config: Config, - mut theme: Theme, - forced_setup: bool, + config: Config, + theme: Theme, model_override: Option, + root: &std::path::Path, ) -> Result<(), String> { - let width = screen.width(); - screen - .commit(&splash::lines(&theme, true, width)) - .map_err(|error| error.to_string())?; - - if forced_setup || config.provider_spec().is_none() { - if let Some(chosen) = wizard(screen, inputs, theme).await? { - theme = chosen; - } else { - return Ok(()); - } - // Read back what was written rather than trusting what was typed: the - // file is the source of truth from here on, and if the harness disagrees - // with the wizard about it this is where that shows. - config = Config::discover(root).map_err(|error| error.to_string())?; - } - let Some(spec) = config.provider_spec().cloned() else { return Err("no provider is configured; run `io setup`".into()); }; diff --git a/src/term.rs b/src/term.rs index 4661948..afd4ab6 100644 --- a/src/term.rs +++ b/src/term.rs @@ -46,6 +46,20 @@ use ratatui::{Frame, Terminal, TerminalOptions, Viewport}; /// rather than expanding the viewport. The composer's own release is 0.7.0. pub const VIEWPORT_HEIGHT: u16 = 4; +/// Rows the wizard's viewport occupies. +/// +/// Much taller than the session's, and it has to be: the wizard's screens are +/// pickers, and a picker draws `height - 1` rows. At the session's four that is +/// three visible options — which made a four-hundred-row model list unusable and +/// left the theme step, whose picker shares its space with a live sample, +/// drawing NO picker at all. A live first run found both. +/// +/// A wizard viewport can afford to be large because nothing is streaming under +/// it and the transcript it would otherwise crowd out does not exist yet. It is +/// clamped to the terminal, so an eighty-by-twenty-four terminal gets what it can +/// spare rather than more rows than it has. +pub const WIZARD_VIEWPORT_HEIGHT: u16 = 14; + /// What to run when the terminal has to be handed back. type Restore = Box; @@ -72,6 +86,18 @@ impl Screen> { /// the mouse is not captured, so the scrollback, the terminal's search and its /// selection are all still the terminal's own. pub fn attach() -> io::Result { + Self::attach_with(VIEWPORT_HEIGHT) + } + + /// Take the terminal with a viewport of `height` rows. + /// + /// The height is fixed for the life of the `Screen` — ratatui sets an inline + /// viewport's height when the terminal is constructed and offers no way to + /// change it. A caller that needs a different one drops this and attaches + /// again, which is what the wizard and the session do at the boundary between + /// them: nothing is streaming there, so re-placing the viewport cannot + /// disturb anything in flight. + pub fn attach_with(height: u16) -> io::Result { crossterm::terminal::enable_raw_mode()?; // From here on the terminal is raw, so EVERY failure path has to give it @@ -80,17 +106,25 @@ impl Screen> { // stdin, and a terminal that does not answer left the process exiting // with the user's shell still in raw mode — no echo, no line editing, and // an error message that did not say what had happened. - Self::attach_raw().inspect_err(|_| restore_terminal()) + Self::attach_raw(height).inspect_err(|_| restore_terminal()) } - fn attach_raw() -> io::Result { + fn attach_raw(height: u16) -> io::Result { let mut out = io::stdout(); crossterm::execute!(out, crossterm::event::EnableBracketedPaste)?; let terminal = Terminal::with_options( CrosstermBackend::new(out), TerminalOptions { - viewport: Viewport::Inline(VIEWPORT_HEIGHT), + // Clamped: a viewport taller than the terminal is not a viewport, + // and 80x24 is a supported size rather than a degraded one. + viewport: Viewport::Inline( + height.min( + crossterm::terminal::size() + .map(|(_, rows)| rows.saturating_sub(2)) + .unwrap_or(height), + ), + ), }, ) .map_err(|error| { diff --git a/src/wizard.rs b/src/wizard.rs index 07531b1..9c8afad 100644 --- a/src/wizard.rs +++ b/src/wizard.rs @@ -729,7 +729,11 @@ impl Wizard { // The sample sits below the picker and is redrawn in whichever theme is // highlighted. It is the one moment of delight in the flow and it costs // nothing, because the renderer is already here. - let sample_rows = 4u16; + // Five: a label and the four sample lines. The label is not decoration — + // the sample deliberately contains a refusal and a success so the palette + // can be judged, and without a word saying what it is, a first-time user + // reads it as their own session going wrong. One did. + let sample_rows = 5u16; let picker_rows = area.height.saturating_sub(sample_rows); if picker_rows > 0 { if let Some(picker) = self.picker.as_mut() { @@ -758,8 +762,17 @@ impl Wizard { } /// The sample transcript the theme step renders behind its picker. +/// +/// It opens by saying what it is. The lines below are invented — a refusal and a +/// success — chosen because they are the two states whose colours are worth +/// judging before committing to a theme, and they are exactly the two that look +/// alarming when nothing says they are a preview. pub fn sample(theme: &Theme) -> Vec> { vec![ + Line::from(Span::styled( + "preview — not your session:".to_string(), + theme.style(Tone::Muted), + )), Line::from(vec![ Span::styled("› ", theme.style(Tone::Accent)), Span::styled("make the failing test pass", theme.style(Tone::Normal)), diff --git a/tests/wizard.rs b/tests/wizard.rs index aa09d9b..d78c3a3 100644 --- a/tests/wizard.rs +++ b/tests/wizard.rs @@ -318,6 +318,74 @@ fn a_paste_on_a_picker_screen_is_ignored_rather_than_swallowed() { assert_eq!(wizard.step(), Step::Credential); } +#[test] +fn the_theme_step_draws_its_picker_and_labels_its_sample() { + // The defect this exists for: `render_theme` reserved rows for the live + // sample out of a viewport that had exactly that many, so the PICKER never + // drew. A live first run saw only the sample — which contains a refusal and a + // success, by design, so the palette can be judged — and read it as the + // session having gone wrong. + let mut wizard = Wizard::new(DARK); + wizard.key(key(KeyCode::Enter)); // welcome + wizard.key(key(KeyCode::Enter)); // OpenRouter + for character in "sk-test".chars() { + wizard.key(key(KeyCode::Char(character))); + } + wizard.key(key(KeyCode::Enter)); // -> verifying + wizard.verified(); + wizard.catalogue(vec!["a/one".into(), "a/two".into()]); + wizard.key(key(KeyCode::Enter)); // model chosen -> theme + assert_eq!(wizard.step(), Step::Theme); + + let (mut screen, _recorder) = support::screen_of(100, 40, io_cli::term::WIZARD_VIEWPORT_HEIGHT); + screen + .draw(|frame| wizard.render(frame, frame.area())) + .expect("frame"); + + let viewport = screen.viewport_text(); + assert!( + viewport.contains("Which theme?"), + "the theme picker did not draw at all: {viewport:?}", + ); + for name in ["dark", "light"] { + assert!(viewport.contains(name), "{name} is missing: {viewport:?}"); + } + assert!( + viewport.contains("preview"), + "the sample must say it is a preview, or it reads as real output: {viewport:?}", + ); + // And the sample is still there, below the picker, doing its job. + assert!(viewport.contains("refused"), "{viewport:?}"); +} + +#[test] +fn the_wizard_viewport_shows_a_usable_number_of_choices() { + // Three visible rows made a four-hundred-model catalogue unusable. The + // wizard's viewport is sized for its screens rather than for the session's, + // and the assertion is on what actually renders rather than on the constant — + // the constant is only a means to it. + let mut picker = io_cli::picker::Picker::new( + "Which model?", + (0..400) + .map(|n| io_cli::picker::Row::new(format!("vendor/model-{n}"))) + .collect(), + ); + let (mut screen, _recorder) = support::screen_of(100, 40, io_cli::term::WIZARD_VIEWPORT_HEIGHT); + screen + .draw(|frame| picker.render(frame, frame.area(), &DARK)) + .expect("frame"); + + let rows = screen + .viewport_text() + .lines() + .filter(|line| line.contains("vendor/model-")) + .count(); + assert!( + rows >= 7, + "only {rows} choices are visible; three is what made this unusable", + ); +} + #[test] fn f3_a_rejected_key_returns_to_the_credential_step_with_the_providers_own_words() { let mut wizard = Wizard::new(DARK); From ebd44c6619d0b3aaccef85cde83b07cf5b11b7d1 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 16 Aug 2026 22:57:13 +0530 Subject: [PATCH 32/34] chore(release): io-cli 0.1.0 --- .../{active.yaml => archive/0.1.0.yaml} | 0 .ultraship/products/io-cli/lifecycle.yaml | 2 +- .../products/io-cli/releases/0.1.0.yaml | 116 +++++++++++++++++- .ultraship/products/io-cli/roadmap.yaml | 34 ++--- .ultraship/releases.lock | 2 + .ultraship/views/active-releases.md | 12 +- .ultraship/views/master-roadmap.md | 2 +- .ultraship/views/release-history.md | 27 +++- .ultraship/views/workspace-brief.md | 6 +- 9 files changed, 166 insertions(+), 35 deletions(-) rename .ultraship/products/io-cli/execution/{active.yaml => archive/0.1.0.yaml} (100%) create mode 100644 .ultraship/releases.lock diff --git a/.ultraship/products/io-cli/execution/active.yaml b/.ultraship/products/io-cli/execution/archive/0.1.0.yaml similarity index 100% rename from .ultraship/products/io-cli/execution/active.yaml rename to .ultraship/products/io-cli/execution/archive/0.1.0.yaml diff --git a/.ultraship/products/io-cli/lifecycle.yaml b/.ultraship/products/io-cli/lifecycle.yaml index 94d07a0..619f868 100644 --- a/.ultraship/products/io-cli/lifecycle.yaml +++ b/.ultraship/products/io-cli/lifecycle.yaml @@ -1,4 +1,4 @@ product: io-cli -state: DEVELOPING +state: RELEASED resumes_to: null blockers: [] diff --git a/.ultraship/products/io-cli/releases/0.1.0.yaml b/.ultraship/products/io-cli/releases/0.1.0.yaml index bb65e07..488f3f4 100644 --- a/.ultraship/products/io-cli/releases/0.1.0.yaml +++ b/.ultraship/products/io-cli/releases/0.1.0.yaml @@ -1,6 +1,6 @@ product: io-cli version: 0.1.0 -status: planned +status: released release_type: minor outcome: user_can: > @@ -535,3 +535,117 @@ open_questions: **Does the status line's spend field appear here, greyed, or wait for 0.2.0?** Laying it out now avoids a redesign; showing an empty field looks unfinished. Decide when the line is first rendered against real content, not now. +released_at: "2026-08-16T17:05:00Z" +mode: published +delivered: > + A terminal interface over io-harness that renders run events, edits a prompt + and reads a keyboard — and contains no agent loop, provider client, tool, + sandbox, policy engine or session store of its own. 3,458 lines of source + across 16 files, against the archived product's 1,199,453 across 88 crates. + That ratio is what this release existed to establish. + + + The renderer is the release. Finished content is committed into the terminal's + own scrollback and a fixed few rows at the bottom hold the composer and the + status line; a streaming answer commits each line as it finishes, so a + two-hundred-line reply leaves the viewport exactly the size it started. The + alternate screen is never entered and the mouse is never captured — asserted + over the bytes the process writes, so no later release can lose it quietly. + + + A first-run wizard takes a developer from no configuration to a working agent: + provider, a credential verified against the live endpoint before anything is + written, a model from that provider's own catalogue, a theme previewed live, + and a permission posture that is an io_harness::Policy rather than a flag of + io-cli's own. Nothing reaches disk before the confirmation screen; the + credential never reaches the screen at all, and is not written when the + provider's environment variable already carries it. + + + Distribution is four cross-compiled artifacts and a SHA256SUMS on the GitHub + Release, installed by one script per platform that verifies before it unpacks + and needs no administrator rights. +evidence: + tests: + - "cargo test --all-targets --no-fail-fast — 100 passing, 0 failing, across 19 test binaries" + - "45 sabotages run across the suite, each failing only its own arm; three of them found that a test proved nothing and were fixed" + - "tests/live.rs, 4 ignored tests run by hand against a real provider — key accepted, wrong key rejected in OpenRouter's own words, 413 models, a real turn of 45 events editing a file" + builds: + - "cargo fmt --all -- --check — exit 0" + - "cargo clippy --all-targets -- -D warnings — exit 0" + - "cargo build --release — exit 0; ./target/release/io --version prints io 0.1.0" + - "cargo tree --depth 1 — exactly the eight dependencies N1 permits, plus tempfile as a dev-dependency" + - "release matrix proved on diag/release-matrix before the tag depended on it: all four targets built and packaged, musl included" + reviews: + - "F1 completed and recorded by the owner, 2026-08-16: wizard from empty configuration, key verified live, file written 0600, a task that streamed and edited src/lib.rs inside the sandbox, an interrupted second turn, a clean exit, and the terminal's own search and selection working afterwards" + - "The owner approved the rendered look from that run, and raised five interface findings; the release-blocking one was fixed and the rest are recorded below" + deployments: + - "CI green on macOS, Linux and Windows plus lint and msrv, on the feature branch at 3e38b37" + health_checks: + - "the release binary starts, prints its version and its help, and refuses a non-tty stdout with exit 1 and a sentence" +known_limitations: + - > + **The configuration's `[sandbox]`, `[run]` and `[instructions]` sections are + read by io-harness and not applied to an io-cli turn.** `Session::turn_steered` + builds its own `TaskContract`, and the harness has no entry point taking both + a caller's contract and a steer inbox — so honouring them would mean giving up + `Ctrl+C`, which F6 requires. The sandbox itself is on; what is missing is the + configured ceilings, not the containment. Nothing in this contract promised + those sections. Stated in the README and in `docs/config.example.toml`, and it + is the natural first item for a harness release. + - > + **An action that needs approval is declined, and says so.** The overlay that + asks a human, and the refusal surface that names the rule and the policy + layer, are 0.2.0. A live run walked into the consequence: the ask-before-writes + posture denied three actions, the agent asked for permission, and the turn + ended `awaiting_answer`. That outcome now reads as a warning carrying the way + out rather than as an unexplained error, but the posture still cannot do what + its name suggests until 0.2.0. + - > + **The status line's working indicator is a word and not a spinner, and its + elapsed time advances only when an event or a keystroke arrives.** Between + events the clock is still. The owner raised both; neither blocks a session and + both are remediation of what this release shipped, so they belong in a patch. + - > + **The model picker has no filter.** A provider catalogue of four hundred rows + is walked with the arrow keys. The wizard's viewport was widened so a usable + number of rows is visible, but type-to-filter is new capability and the + roadmap puts fuzzy filtering in 0.7.0. Decided with the owner rather than + added quietly. + - > + **A prompt longer than two rows scrolls within them rather than growing the + session viewport.** ratatui fixes an inline viewport's height when the + terminal is constructed, and rebuilding mid-session would re-query the cursor + and risk shifting the scrollback this product exists to protect. The wizard + gets its own taller viewport because the boundary between the two phases is + safe to rebuild at; a session has no such boundary. The composer's own release + is 0.7.0. + - > + **O5 is verified after this record is sealed.** The installers can only be + exercised on clean machines against a Release that exists, which is by + definition later than the tag that creates it. `install.sh` is proved end to + end against a local release, including the corrupted-artifact refusal, and the + cross-compile matrix is proved on a throwaway branch — what remains is the + clean-machine, new-shell half. What this record claims is the state at seal + time. + - > + **The `x86_64-apple-darwin` artifact is cross-compiled and not executed by + CI**, because the runners are arm64. The other three rows run `io --version` + on the machine that built them. + - > + **The F1 recording shows the theme step before its fix.** The picker was + invisible in that run — the defect the run found — and the recording documents + it. It is honest evidence of the criterion passing and a poor screenshot; a + fresh capture for the README is a patch-release chore, not a correction. + - > + **This record was sealed before the Release existed, and that is the flow + rather than a shortfall.** The sealed record and its lock pin ride the feature + branch and merge with the code, so the seal necessarily precedes the tag that + cuts the Release and the workflow that attaches the four artifacts and + `SHA256SUMS`. `mode: published` describes the channel this product has — the + GitHub Release, which is the whole of it, since there is no registry and never + will be. If the tag or the matrix failed after this point, the correction is a + new version, not an edit. +supersedes: null +next_recommended_version: 0.1.1 +immutable: true diff --git a/.ultraship/products/io-cli/roadmap.yaml b/.ultraship/products/io-cli/roadmap.yaml index 448435c..96fafe0 100644 --- a/.ultraship/products/io-cli/roadmap.yaml +++ b/.ultraship/products/io-cli/roadmap.yaml @@ -14,17 +14,17 @@ versions: the whole conversation scrollable, searchable and selectable with the terminal's own facilities, because io-cli never took them away. detail: specified - status: planned + status: released - version: 0.2.0 outcome: > The operator can see and steer the boundary the agent is working under. One key cycles the permission posture and the status line names the io-harness policy layer now in force. A sensitive action stops the run in an overlay that cannot scroll away — showing the act, the - target, the rule that is asking, and the diff when it is a write — and is resolved by keystroke - into both the transcript and the durable trace. A refusal prints the act, the target, and the - rule and layer that produced it, which no other terminal agent can render because no other core - records it. The status line gains the rest of its fields: context pressure, tokens, spend - against the tree ceiling, containment state. + target, the rule that is asking, and the diff when it is a write — and is resolved by + keystroke into both the transcript and the durable trace. A refusal prints the act, the + target, and the rule and layer that produced it, which no other terminal agent can render + because no other core records it. The status line gains the rest of its fields: context + pressure, tokens, spend against the tree ceiling, containment state. detail: outline status: planned - version: 0.3.0 @@ -62,12 +62,12 @@ versions: outcome: > The interface reaches people and terminals the category currently excludes. A plain mode built for screen readers removes animation and repaint, keeps the real terminal cursor as the focus - indicator, puts content before metadata, and announces every state change as one line of text — - which no terminal agent in this field does today. Colour is never the sole carrier of meaning, - `NO_COLOR` is honoured, box drawing degrades to ASCII, the keyboard protocol is negotiated up - on terminals that advertise it and down over SSH without losing core function, keybindings are - configurable and documented as a table, and every surface has an audited form at eighty - columns. + indicator, puts content before metadata, and announces every state change as one line of text + — which no terminal agent in this field does today. Colour is never the sole carrier of + meaning, `NO_COLOR` is honoured, box drawing degrades to ASCII, the keyboard protocol is + negotiated up on terminals that advertise it and down over SSH without losing core function, + keybindings are configurable and documented as a table, and every surface has an audited form + at eighty columns. detail: hypothesis status: planned - version: 0.7.0 @@ -82,11 +82,11 @@ versions: status: planned - version: 0.8.0 outcome: > - A decomposed task becomes visible as a tree. Spawned children appear as a live fleet with their - own status, each showing its draw against the ceiling the whole tree shares, with refused - spawns and collected results shown where they happen. This is the surface that is structurally - impossible for a competing interface to copy, because it is rendering facts only this core - emits. + A decomposed task becomes visible as a tree. Spawned children appear as a live fleet with + their own status, each showing its draw against the ceiling the whole tree shares, with + refused spawns and collected results shown where they happen. This is the surface that is + structurally impossible for a competing interface to copy, because it is rendering facts only + this core emits. detail: hypothesis status: planned - version: 0.9.0 diff --git a/.ultraship/releases.lock b/.ultraship/releases.lock new file mode 100644 index 0000000..c2a25ae --- /dev/null +++ b/.ultraship/releases.lock @@ -0,0 +1,2 @@ +releases: + io-cli/0.1.0: sha256:d05b73f7bad29f66669dfc2d69c1d642e7af6d91b3660298f876878795c81fb6 diff --git a/.ultraship/views/active-releases.md b/.ultraship/views/active-releases.md index e5394ff..03c7287 100644 --- a/.ultraship/views/active-releases.md +++ b/.ultraship/views/active-releases.md @@ -4,17 +4,7 @@ Run `ultraship views` to regenerate. --> # Active releases -## io-cli 0.1.0 - -**Execution state:** DEVELOPING -**Release fit:** probable -**Target mode:** release-ready -**Outcome:** A developer clones nothing, configures nothing, and runs `io` in a repository. Because no configuration exists, a guided first run starts: it asks which provider to use — the choices being the variants io-harness's own `ProviderSpec` defines, not a hardcoded list — takes an API key without echoing it, and **verifies that key against the live endpoint before continuing**, so a bad key is caught in the wizard rather than on the first real prompt. It then offers the provider's own model catalogue, lets the developer arrow through the shipped themes with a sample transcript re-rendering live behind the picker, and asks for a default permission posture in plain words. It shows exactly what will be written and where, and only then writes io-harness's configuration file with mode `0600`. - -The session that follows is the product's core claim made real. Every finished message, tool cell and system line is committed into **the terminal's own scrollback**; a viewport of a few lines at the bottom holds the composer and a status line, and is the only region that repaints. The developer types a task, watches the reply stream token by token without strobing, watches the agent edit a file inside io-harness's sandbox, presses `Ctrl+C` to interrupt a turn that is going wrong, types another, and exits with `Ctrl+D`. - -Afterwards the terminal contains the whole conversation. `Cmd+F` finds text in it. tmux copy-mode scrolls it. A mouse drag selects it. None of that was implemented — it works because io-cli never entered the alternate screen and never captured the mouse. - +_No release is currently active._ _Canonical sources: products//execution/active.yaml, products//releases/.yaml_ diff --git a/.ultraship/views/master-roadmap.md b/.ultraship/views/master-roadmap.md index 5194a0e..e805665 100644 --- a/.ultraship/views/master-roadmap.md +++ b/.ultraship/views/master-roadmap.md @@ -8,7 +8,7 @@ Run `ultraship views` to regenerate. | Version | Outcome | Detail | Status | | --- | --- | --- | --- | -| 0.1.0 | A developer with no configuration runs `io` in a repository and reaches a finished task in the same session. A guided first run asks for a provider, takes a key and verifies it against the live endpoint, offers the provider's models, previews the themes as it moves between them, and picks a default permission posture — then writes io-harness's own configuration file. The session that follows renders on a hybrid inline viewport: every finished turn, tool cell and message is committed into the terminal's real scrollback, and only a short pane at the bottom holds the composer and the status line. The developer types a task, watches the answer stream, watches a file get edited inside the sandbox, interrupts with a keystroke, and exits — leaving the whole conversation scrollable, searchable and selectable with the terminal's own facilities, because io-cli never took them away. | specified | planned | +| 0.1.0 | A developer with no configuration runs `io` in a repository and reaches a finished task in the same session. A guided first run asks for a provider, takes a key and verifies it against the live endpoint, offers the provider's models, previews the themes as it moves between them, and picks a default permission posture — then writes io-harness's own configuration file. The session that follows renders on a hybrid inline viewport: every finished turn, tool cell and message is committed into the terminal's real scrollback, and only a short pane at the bottom holds the composer and the status line. The developer types a task, watches the answer stream, watches a file get edited inside the sandbox, interrupts with a keystroke, and exits — leaving the whole conversation scrollable, searchable and selectable with the terminal's own facilities, because io-cli never took them away. | specified | released | | 0.2.0 | The operator can see and steer the boundary the agent is working under. One key cycles the permission posture and the status line names the io-harness policy layer now in force. A sensitive action stops the run in an overlay that cannot scroll away — showing the act, the target, the rule that is asking, and the diff when it is a write — and is resolved by keystroke into both the transcript and the durable trace. A refusal prints the act, the target, and the rule and layer that produced it, which no other terminal agent can render because no other core records it. The status line gains the rest of its fields: context pressure, tokens, spend against the tree ceiling, containment state. | outline | planned | | 0.3.0 | The operator can read what the agent did without leaving the terminal or losing the thread. Edits render as diffs — unified by default, word-level emphasis inside changed lines, syntax highlighting, context collapsed with an expander, a defined form for narrow terminals, and a minimal mode for people who review by file rather than by hunk. Tool calls render as one line with a result and a duration, expandable while live and committed collapsed, with the full output going to the durable trace rather than to the screen. A transcript overlay gives the whole conversation on one key, and the last message or the last diff can be put on the system clipboard in a way that survives SSH. | outline | planned | | 0.4.0 | Work survives the session. A picker lists previous sessions from the harness store and reopens one where it stopped. Any turn in a conversation can be forked into a new branch and continued from there. The model can be changed mid-session without losing context. An empty composer takes a double-escape to rewind the conversation and the workspace to an earlier point, using the rewind the harness already implements and already records. | outline | planned | diff --git a/.ultraship/views/release-history.md b/.ultraship/views/release-history.md index b67b9ec..3c93d88 100644 --- a/.ultraship/views/release-history.md +++ b/.ultraship/views/release-history.md @@ -4,7 +4,32 @@ Run `ultraship views` to regenerate. --> # Release history -_No versions have been released yet._ +## io-cli + +| Version | Released | Mode | Delivered | +| --- | --- | --- | --- | +| 0.1.0 | 2026-08-16T17:05:00Z | published | A terminal interface over io-harness that renders run events, edits a prompt and reads a keyboard — and contains no agent loop, provider client, tool, sandbox, policy engine or session store of its own. 3,458 lines of source across 16 files, against the archived product's 1,199,453 across 88 crates. That ratio is what this release existed to establish. The renderer is the release. Finished content is committed into the terminal's own scrollback and a fixed few rows at the bottom hold the composer and the status line; a streaming answer commits each line as it finishes, so a two-hundred-line reply leaves the viewport exactly the size it started. The alternate screen is never entered and the mouse is never captured — asserted over the bytes the process writes, so no later release can lose it quietly. A first-run wizard takes a developer from no configuration to a working agent: provider, a credential verified against the live endpoint before anything is written, a model from that provider's own catalogue, a theme previewed live, and a permission posture that is an io_harness::Policy rather than a flag of io-cli's own. Nothing reaches disk before the confirmation screen; the credential never reaches the screen at all, and is not written when the provider's environment variable already carries it. Distribution is four cross-compiled artifacts and a SHA256SUMS on the GitHub Release, installed by one script per platform that verifies before it unpacks and needs no administrator rights. | + +### 0.1.0 known limitations + +- **The configuration's `[sandbox]`, `[run]` and `[instructions]` sections are read by io-harness and not applied to an io-cli turn.** `Session::turn_steered` builds its own `TaskContract`, and the harness has no entry point taking both a caller's contract and a steer inbox — so honouring them would mean giving up `Ctrl+C`, which F6 requires. The sandbox itself is on; what is missing is the configured ceilings, not the containment. Nothing in this contract promised those sections. Stated in the README and in `docs/config.example.toml`, and it is the natural first item for a harness release. + +- **An action that needs approval is declined, and says so.** The overlay that asks a human, and the refusal surface that names the rule and the policy layer, are 0.2.0. A live run walked into the consequence: the ask-before-writes posture denied three actions, the agent asked for permission, and the turn ended `awaiting_answer`. That outcome now reads as a warning carrying the way out rather than as an unexplained error, but the posture still cannot do what its name suggests until 0.2.0. + +- **The status line's working indicator is a word and not a spinner, and its elapsed time advances only when an event or a keystroke arrives.** Between events the clock is still. The owner raised both; neither blocks a session and both are remediation of what this release shipped, so they belong in a patch. + +- **The model picker has no filter.** A provider catalogue of four hundred rows is walked with the arrow keys. The wizard's viewport was widened so a usable number of rows is visible, but type-to-filter is new capability and the roadmap puts fuzzy filtering in 0.7.0. Decided with the owner rather than added quietly. + +- **A prompt longer than two rows scrolls within them rather than growing the session viewport.** ratatui fixes an inline viewport's height when the terminal is constructed, and rebuilding mid-session would re-query the cursor and risk shifting the scrollback this product exists to protect. The wizard gets its own taller viewport because the boundary between the two phases is safe to rebuild at; a session has no such boundary. The composer's own release is 0.7.0. + +- **O5 is verified after this record is sealed.** The installers can only be exercised on clean machines against a Release that exists, which is by definition later than the tag that creates it. `install.sh` is proved end to end against a local release, including the corrupted-artifact refusal, and the cross-compile matrix is proved on a throwaway branch — what remains is the clean-machine, new-shell half. What this record claims is the state at seal time. + +- **The `x86_64-apple-darwin` artifact is cross-compiled and not executed by CI**, because the runners are arm64. The other three rows run `io --version` on the machine that built them. + +- **The F1 recording shows the theme step before its fix.** The picker was invisible in that run — the defect the run found — and the recording documents it. It is honest evidence of the criterion passing and a poor screenshot; a fresh capture for the README is a patch-release chore, not a correction. + +- **This record was sealed before the Release existed, and that is the flow rather than a shortfall.** The sealed record and its lock pin ride the feature branch and merge with the code, so the seal necessarily precedes the tag that cuts the Release and the workflow that attaches the four artifacts and `SHA256SUMS`. `mode: published` describes the channel this product has — the GitHub Release, which is the whole of it, since there is no registry and never will be. If the tag or the matrix failed after this point, the correction is a new version, not an edit. + _Canonical sources: products//releases/.yaml_ diff --git a/.ultraship/views/workspace-brief.md b/.ultraship/views/workspace-brief.md index 2b24ec1..c644425 100644 --- a/.ultraship/views/workspace-brief.md +++ b/.ultraship/views/workspace-brief.md @@ -4,8 +4,8 @@ Run `ultraship views` to regenerate. --> # io-cli -**State:** DEVELOPING -**Next command:** `/ultraship:develop` +**State:** RELEASED +**Next command:** `/ultraship:plan` ## Vision @@ -16,6 +16,6 @@ The terminal agent that shows you what it is allowed to do, what it is spending, | Product | State | Latest release | Classification | MVP boundary | | --- | --- | --- | --- | --- | -| io-cli | DEVELOPING | 0.1.0 | independent-product | A developer with no prior configuration runs `io` in a repository, is walked through picking a provider, entering and verifying a key, choosing a model and a theme, types one real task, watches it stream and edit a file inside the sandbox, interrupts it with a keystroke, and exits — leaving the whole conversation in the terminal's own scrollback. | +| io-cli | RELEASED | 0.1.0 | independent-product | A developer with no prior configuration runs `io` in a repository, is walked through picking a provider, entering and verifying a key, choosing a model and a theme, types one real task, watches it stream and edit a file inside the sandbox, interrupts it with a keystroke, and exits — leaving the whole conversation in the terminal's own scrollback. | _Canonical sources: workspace.yaml, products//product.yaml, products//lifecycle.yaml_ From 9349aa00c5bcf3cef3e51c093fcf4105acd1439e Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 16 Aug 2026 23:06:56 +0530 Subject: [PATCH 33/34] fix(driver): never read the keyboard while a viewport is being placed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splitting the wizard onto its own taller viewport means attaching the terminal twice, and the keyboard thread had been moved ahead of the first attach. Placing an inline viewport asks the terminal where its cursor is and reads the answer back off stdin, so a thread sitting in crossterm::event::read() consumed it and the query timed out — the binary refused to start, with the error added for exactly this case pointing at the terminal rather than at us. The reader now polls instead of blocking, so it can be told to stand down and joined; it starts only once a screen is attached and stops before the next attach. Keyboard::start takes the attached screen as a witness argument, which makes the ordering a compile error to get wrong rather than a comment asking politely. Caught by the owner running the binary; no test in the suite reaches the driver's own startup ordering. --- src/main.rs | 89 ++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 75 insertions(+), 14 deletions(-) diff --git a/src/main.rs b/src/main.rs index 05c2eaa..26fa835 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,9 @@ use std::io::IsTerminal; use std::path::PathBuf; use std::process::ExitCode; -use std::time::Instant; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; use clap::Parser; use crossterm::event::{Event, KeyEventKind}; @@ -64,7 +66,6 @@ async fn run() -> Result<(), String> { } let setup = matches!(cli.command, Some(Subcommand::Setup)); - let mut inputs = keyboard(); let mut config = config; let mut theme = theme; @@ -76,14 +77,21 @@ async fn run() -> Result<(), String> { // where nothing is streaming, so giving each the viewport it needs costs // nothing that matters. if setup || config.provider_spec().is_none() { + // Attach BEFORE any reader exists: the viewport's cursor query reads its + // answer off stdin and a reader would take it first. let mut screen = Screen::attach_with(io_cli::term::WIZARD_VIEWPORT_HEIGHT).map_err(|e| e.to_string())?; + let (keys, mut inputs) = Keyboard::start(&screen); let width = screen.width(); screen .commit(&splash::lines(&theme, true, width)) .map_err(|error| error.to_string())?; let chosen = wizard(&mut screen, &mut inputs, theme).await; + // The reader goes before the screen does, and both before the next + // attach, so nothing is holding stdin when the session's viewport is + // placed. + keys.stop(); screen.restore(); drop(screen); @@ -100,6 +108,7 @@ async fn run() -> Result<(), String> { } let mut screen = Screen::attach().map_err(|error| error.to_string())?; + let (keys, mut inputs) = Keyboard::start(&screen); if !setup && config.provider_spec().is_some() { let width = screen.width(); screen @@ -111,6 +120,7 @@ async fn run() -> Result<(), String> { // Explicit as well as on `Drop`, so the terminal is back before anything is // printed about how this ended. + keys.stop(); screen.restore(); result } @@ -528,21 +538,72 @@ fn paint_picker( .map_err(|error| error.to_string()) } -/// Keystrokes, read on a thread of their own. +/// A keyboard reader that can be stopped. /// -/// crossterm's `read` blocks, and the alternative — its async `EventStream` — is -/// behind a feature that pulls in a futures stack for what one thread and a -/// channel already do. -fn keyboard() -> UnboundedReceiver { - let (tx, rx) = unbounded_channel(); - std::thread::spawn(move || { - while let Ok(event) = crossterm::event::read() { - if tx.send(event).is_err() { - break; +/// Stopping matters, and it is not a nicety. Placing an inline viewport asks the +/// terminal where its cursor is and reads the answer back **off stdin** — and a +/// thread sitting in `crossterm::event::read()` will consume that answer first, +/// so the query times out and the program refuses to start. A reader must +/// therefore never be running while a `Screen` is being attached. +/// +/// That is why the thread polls rather than blocking in `read()`: a thread parked +/// in `read()` cannot be told to stand down, and there is no synthetic event to +/// wake it with. +struct Keyboard { + stop: Arc, + thread: Option>, +} + +impl Keyboard { + /// Start reading. + /// + /// Takes the attached screen as a witness rather than as a parameter it uses. + /// The rule it encodes is the one this cost a broken binary to learn: a + /// reader must not exist while a viewport is being placed, and a signature + /// that cannot be satisfied before the attach is a stronger guarantee than a + /// comment saying so. + fn start( + _attached: &Screen>, + ) -> (Self, UnboundedReceiver) { + let (tx, rx) = unbounded_channel(); + let stop = Arc::new(AtomicBool::new(false)); + let flag = Arc::clone(&stop); + let thread = std::thread::spawn(move || { + while !flag.load(Ordering::Relaxed) { + // A short poll rather than a blocking read, so the flag is seen. + match crossterm::event::poll(Duration::from_millis(40)) { + Ok(true) => match crossterm::event::read() { + Ok(event) => { + if tx.send(event).is_err() { + break; + } + } + Err(_) => break, + }, + Ok(false) => {} + Err(_) => break, + } } + }); + ( + Self { + stop, + thread: Some(thread), + }, + rx, + ) + } + + /// Stop reading and wait for the thread to leave stdin alone. + /// + /// Joined rather than detached: the point is to know the reader is gone + /// before the next viewport is placed. + fn stop(mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); } - }); - rx + } } fn key_for(api_key: Option, var: &str) -> Result { From d66194599beda42dfc1daab0be127221aaa48d8d Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 16 Aug 2026 23:08:07 +0530 Subject: [PATCH 34/34] chore(release): io-cli 0.1.0 --- .ultraship/products/io-cli/releases/0.1.0.yaml | 14 +++++++++++++- .ultraship/releases.lock | 2 +- .ultraship/views/release-history.md | 4 +++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/.ultraship/products/io-cli/releases/0.1.0.yaml b/.ultraship/products/io-cli/releases/0.1.0.yaml index 488f3f4..94c60da 100644 --- a/.ultraship/products/io-cli/releases/0.1.0.yaml +++ b/.ultraship/products/io-cli/releases/0.1.0.yaml @@ -535,7 +535,7 @@ open_questions: **Does the status line's spend field appear here, greyed, or wait for 0.2.0?** Laying it out now avoids a redesign; showing an empty field looks unfinished. Decide when the line is first rendered against real content, not now. -released_at: "2026-08-16T17:05:00Z" +released_at: "2026-08-16T17:35:00Z" mode: published delivered: > A terminal interface over io-harness that renders run events, edits a prompt @@ -646,6 +646,18 @@ known_limitations: GitHub Release, which is the whole of it, since there is no registry and never will be. If the tag or the matrix failed after this point, the correction is a new version, not an edit. + - > + **This record was amended and re-pinned after its first seal, before the merge + into `main`, because the tree it described would not start.** Giving the + wizard its own taller viewport means attaching the terminal twice, and the + keyboard reader had been moved ahead of the first attach — placing an inline + viewport asks the terminal for its cursor position and reads the answer off + stdin, so the reader consumed it and the query timed out. The owner found it + by running the binary; nothing in the suite reaches the driver's own startup + ordering. The reader now polls so it can be stopped and joined, starts only + after a screen is attached, and takes that screen as a witness argument so the + ordering is a compile error to get wrong. Every gate below was re-measured on + the corrected tree at 9349aa0. supersedes: null next_recommended_version: 0.1.1 immutable: true diff --git a/.ultraship/releases.lock b/.ultraship/releases.lock index c2a25ae..e78ee61 100644 --- a/.ultraship/releases.lock +++ b/.ultraship/releases.lock @@ -1,2 +1,2 @@ releases: - io-cli/0.1.0: sha256:d05b73f7bad29f66669dfc2d69c1d642e7af6d91b3660298f876878795c81fb6 + io-cli/0.1.0: sha256:e25f995472436a25bc7e949e5c7e132fb5582b176027e23ee87235cfe2ab6611 diff --git a/.ultraship/views/release-history.md b/.ultraship/views/release-history.md index 3c93d88..9f761e3 100644 --- a/.ultraship/views/release-history.md +++ b/.ultraship/views/release-history.md @@ -8,7 +8,7 @@ Run `ultraship views` to regenerate. | Version | Released | Mode | Delivered | | --- | --- | --- | --- | -| 0.1.0 | 2026-08-16T17:05:00Z | published | A terminal interface over io-harness that renders run events, edits a prompt and reads a keyboard — and contains no agent loop, provider client, tool, sandbox, policy engine or session store of its own. 3,458 lines of source across 16 files, against the archived product's 1,199,453 across 88 crates. That ratio is what this release existed to establish. The renderer is the release. Finished content is committed into the terminal's own scrollback and a fixed few rows at the bottom hold the composer and the status line; a streaming answer commits each line as it finishes, so a two-hundred-line reply leaves the viewport exactly the size it started. The alternate screen is never entered and the mouse is never captured — asserted over the bytes the process writes, so no later release can lose it quietly. A first-run wizard takes a developer from no configuration to a working agent: provider, a credential verified against the live endpoint before anything is written, a model from that provider's own catalogue, a theme previewed live, and a permission posture that is an io_harness::Policy rather than a flag of io-cli's own. Nothing reaches disk before the confirmation screen; the credential never reaches the screen at all, and is not written when the provider's environment variable already carries it. Distribution is four cross-compiled artifacts and a SHA256SUMS on the GitHub Release, installed by one script per platform that verifies before it unpacks and needs no administrator rights. | +| 0.1.0 | 2026-08-16T17:35:00Z | published | A terminal interface over io-harness that renders run events, edits a prompt and reads a keyboard — and contains no agent loop, provider client, tool, sandbox, policy engine or session store of its own. 3,458 lines of source across 16 files, against the archived product's 1,199,453 across 88 crates. That ratio is what this release existed to establish. The renderer is the release. Finished content is committed into the terminal's own scrollback and a fixed few rows at the bottom hold the composer and the status line; a streaming answer commits each line as it finishes, so a two-hundred-line reply leaves the viewport exactly the size it started. The alternate screen is never entered and the mouse is never captured — asserted over the bytes the process writes, so no later release can lose it quietly. A first-run wizard takes a developer from no configuration to a working agent: provider, a credential verified against the live endpoint before anything is written, a model from that provider's own catalogue, a theme previewed live, and a permission posture that is an io_harness::Policy rather than a flag of io-cli's own. Nothing reaches disk before the confirmation screen; the credential never reaches the screen at all, and is not written when the provider's environment variable already carries it. Distribution is four cross-compiled artifacts and a SHA256SUMS on the GitHub Release, installed by one script per platform that verifies before it unpacks and needs no administrator rights. | ### 0.1.0 known limitations @@ -30,6 +30,8 @@ Run `ultraship views` to regenerate. - **This record was sealed before the Release existed, and that is the flow rather than a shortfall.** The sealed record and its lock pin ride the feature branch and merge with the code, so the seal necessarily precedes the tag that cuts the Release and the workflow that attaches the four artifacts and `SHA256SUMS`. `mode: published` describes the channel this product has — the GitHub Release, which is the whole of it, since there is no registry and never will be. If the tag or the matrix failed after this point, the correction is a new version, not an edit. +- **This record was amended and re-pinned after its first seal, before the merge into `main`, because the tree it described would not start.** Giving the wizard its own taller viewport means attaching the terminal twice, and the keyboard reader had been moved ahead of the first attach — placing an inline viewport asks the terminal for its cursor position and reads the answer off stdin, so the reader consumed it and the query timed out. The owner found it by running the binary; nothing in the suite reaches the driver's own startup ordering. The reader now polls so it can be stopped and joined, starts only after a screen is attached, and takes that screen as a witness argument so the ordering is a compile error to get wrong. Every gate below was re-measured on the corrected tree at 9349aa0. + _Canonical sources: products//releases/.yaml_