From b657a65ed30dd01d244c62eec3b4c2dae5db6d79 Mon Sep 17 00:00:00 2001 From: Valerii Date: Wed, 2 Sep 2026 17:32:01 +0300 Subject: [PATCH 1/2] tui: persistent update banner in the status bar's top-right corner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The startup update modal is skippable, and skipping it left no trace on screen — an operator who pressed n once stayed on the old version with nothing reminding them a newer one exists. The offer now also raises a banner pinned to the right edge of the status bar: 'new version vX.Y.Z available [ Update ]'. It renders inverse-video, so it is the opposite of whatever ground the palette and terminal use — distinguishable by construction, and static, so it never pulls the eye away from the work. Clicking Update runs the same path as the modal's y (including the refusal while a turn is in flight); the modal itself is untouched and remains the keyboard route. The banner survives update_dismissed (the modal is the question, the banner is the memory), yields while an update runs or has finished, and returns on a failed install so there is still a way to retry. On narrow rows it degrades — full sentence, bare version, button alone, nothing — instead of wrapping the one-row bar. Testing ground: 'atomic-agent tui --fake-update 9.9.9' pretends that version is released — the real check and the real installer are both bypassed, so the modal, the banner and its degradations can be eyeballed on a dev build. --- src/tui/agent-event-reducer.test.ts | 31 ++++++ src/tui/agent-event-reducer.ts | 1 + src/tui/components/status-bar-update.test.tsx | 69 ++++++++++++ src/tui/components/status-bar.tsx | 61 +++++++++- src/tui/components/update-banner.test.tsx | 44 ++++++++ src/tui/components/update-banner.tsx | 104 ++++++++++++++++++ src/tui/tui-app.tsx | 1 + src/tui/tui-args.test.ts | 27 +++++ src/tui/tui-args.ts | 21 ++++ src/tui/tui-command.ts | 26 ++++- src/tui/tui-state.ts | 10 ++ 11 files changed, 389 insertions(+), 6 deletions(-) create mode 100644 src/tui/components/status-bar-update.test.tsx create mode 100644 src/tui/components/update-banner.test.tsx create mode 100644 src/tui/components/update-banner.tsx diff --git a/src/tui/agent-event-reducer.test.ts b/src/tui/agent-event-reducer.test.ts index a78e62f0..340c6324 100644 --- a/src/tui/agent-event-reducer.test.ts +++ b/src/tui/agent-event-reducer.test.ts @@ -834,3 +834,34 @@ describe("turn_gate_blocked", () => { expect(blocked.feed.at(-1)?.line).not.toContain("\n"); }); }); + +describe("update banner state", () => { + const offer: TuiAction = { + type: "update_available", + current: "0.5.4", + latest: "9.9.9", + }; + + it("update_available raises both the modal and the banner", () => { + const next = reduceTuiState(createInitialTuiState(fakeSession()), offer); + expect(next.updatePrompt).toEqual({ current: "0.5.4", latest: "9.9.9" }); + expect(next.updateBanner).toEqual({ current: "0.5.4", latest: "9.9.9" }); + }); + + it("update_dismissed clears only the modal — the banner is the memory", () => { + const next = apply(createInitialTuiState(fakeSession()), [ + offer, + { type: "update_dismissed" }, + ]); + expect(next.updatePrompt).toBeNull(); + expect(next.updateBanner).toEqual({ current: "0.5.4", latest: "9.9.9" }); + }); + + it("a repeat offer while an update runs still changes nothing", () => { + const running = apply(createInitialTuiState(fakeSession()), [ + offer, + { type: "update_started" }, + ]); + expect(reduceTuiState(running, offer)).toBe(running); + }); +}); diff --git a/src/tui/agent-event-reducer.ts b/src/tui/agent-event-reducer.ts index 2f8baaf6..bbaf06ac 100644 --- a/src/tui/agent-event-reducer.ts +++ b/src/tui/agent-event-reducer.ts @@ -310,6 +310,7 @@ export function reduceTuiState(state: TuiState, action: TuiAction): TuiState { return { ...state, updatePrompt: { current: action.current, latest: action.latest }, + updateBanner: { current: action.current, latest: action.latest }, }; case "update_dismissed": return { ...state, updatePrompt: null }; diff --git a/src/tui/components/status-bar-update.test.tsx b/src/tui/components/status-bar-update.test.tsx new file mode 100644 index 00000000..fb4fb9d5 --- /dev/null +++ b/src/tui/components/status-bar-update.test.tsx @@ -0,0 +1,69 @@ +import { render } from "ink-testing-library"; +import React from "react"; +import { describe, expect, it } from "vitest"; +import { reduceTuiState } from "../agent-event-reducer.js"; +import { apply, fakeSession } from "../test-fixtures.js"; +import { createInitialTuiState, type TuiState } from "../tui-state.js"; +import { StatusBar } from "./status-bar.js"; + +const strip = (s: string): string => s.replace(/\u001b\[[0-9;]*m/g, ""); + +function offered(): TuiState { + return reduceTuiState(createInitialTuiState(fakeSession()), { + type: "update_available", + current: "0.5.4", + latest: "9.9.9", + }); +} + +describe("StatusBar update banner", () => { + it("shows the offer at the end of the bar", () => { + const frame = strip(render().lastFrame() ?? ""); + expect(frame).toContain("v9.9.9"); + expect(frame).toContain("Update"); + }); + + it("stays on screen after the startup modal is dismissed", () => { + const state = apply(offered(), [{ type: "update_dismissed" }]); + expect(state.updatePrompt).toBeNull(); + const frame = strip(render().lastFrame() ?? ""); + expect(frame).toContain("Update"); + }); + + it("yields while the installer runs, and returns on failure", () => { + const running = apply(offered(), [{ type: "update_started" }]); + expect( + strip(render().lastFrame() ?? ""), + ).not.toContain("Update"); + + const failed = apply(running, [ + { type: "update_finished", ok: false, error: "boom" }, + ]); + expect( + strip(render().lastFrame() ?? ""), + ).toContain("Update"); + }); + + it("says nothing when no newer version exists", () => { + const state = createInitialTuiState(fakeSession()); + const frame = strip(render().lastFrame() ?? ""); + expect(frame).not.toContain("Update"); + }); + + it("pins the banner to the right edge when given the row width", () => { + const view = render(); + const line = strip(view.lastFrame() ?? "").split("\n")[0] ?? ""; + // The button's trailing pad cell sits on the last column; everything + // before the banner is left-flowing content and a stretched spacer. + expect(line.trimEnd().endsWith("Update")).toBe(true); + expect(line.trimEnd().length).toBeGreaterThan(60); + }); + + it("keeps the bar one row tall with the banner up", () => { + const view = render(); + const rows = strip(view.lastFrame() ?? "") + .split("\n") + .filter((line) => line.trim().length > 0); + expect(rows).toHaveLength(1); + }); +}); diff --git a/src/tui/components/status-bar.tsx b/src/tui/components/status-bar.tsx index d7bcf427..4782332a 100644 --- a/src/tui/components/status-bar.tsx +++ b/src/tui/components/status-bar.tsx @@ -12,9 +12,16 @@ import type { TuiState } from "../tui-state.js"; import { getAppVersion } from "../../version.js"; import { Chip, tracked } from "./chip.js"; import { sessionTitleLine } from "./session-title.js"; +import { planUpdateBanner, UpdateBanner } from "./update-banner.js"; interface StatusBarProps { state: TuiState; + /** + * Row width in cells. When set, the bar claims the full row and pins + * the update banner to its right edge; without it (unit tests, odd + * hosts) the bar stays content-sized and the banner trails the text. + */ + width?: number; /** * Draw the `atomic-agent vX.Y.Z` lockup. False when the rail is on * screen: the rail already carries the brand and the version, and two @@ -53,14 +60,35 @@ interface StatusBarProps { */ export function StatusBar({ state, + width, brand = true, railRestore = false, }: StatusBarProps): ReactElement { const section = getCurrentSection(state); const title = currentSessionTitle(state); const { columns } = useTerminalSize(); + // The banner outlives the modal (`updateBanner` survives + // `update_dismissed`) and yields only to an update actually running + // or finished — `failed` keeps it up, because the banner is then the + // one remaining way to retry. + const banner = + state.updateStatus === "idle" || state.updateStatus === "failed" + ? state.updateBanner + : null; + // `chipBudget` reserves cells for a session tag whether or not one is + // drawn — safe slack for the download chip, but it starves the banner + // out of a fresh 70-column session where the corner is visibly empty. + // Reclaim the reservation when no tag renders. + const bannerBudget = Math.max( + 0, + rawBudget(columns, brand, title) + + (state.session.sessionId ? 0 : SESSION_TAG), + ); + const bannerPlan = banner + ? planUpdateBanner(banner.latest, bannerBudget) + : null; return ( - + {railRestore ? : null} {brand ? ( <> @@ -76,7 +104,13 @@ export function StatusBar({ {state.localModelsPanel.pull ? ( ) : null} {title ? ( @@ -88,6 +122,15 @@ export function StatusBar({ ) : null} + {banner && bannerPlan ? ( + <> + {/* flexGrow pushes the banner into the top-right corner when + the bar knows its row width; content-sized bars (no + `width`) collapse the spacer to two plain cells. */} + + + + ) : null} ); } @@ -105,14 +148,24 @@ export function StatusBar({ * the header into a paragraph and push the whole app down the screen. */ function chipBudget(columns: number, brand: boolean, title: string | null): number { + return Math.max(0, rawBudget(columns, brand, title)); +} + +/** + * The same leftover before clamping. The banner's session-tag reclaim + * must be added to THIS number — adding it after the clamp turned a + * 42-column deficit into 18 phantom cells and wrapped the bar. + */ +function rawBudget(columns: number, brand: boolean, title: string | null): number { const BRAND = 22; const BREADCRUMB = 14; - const SESSION_TAG = 18; const used = (brand ? BRAND : 0) + BREADCRUMB + SESSION_TAG + (title ? title.length + 4 : 0); - return Math.max(0, columns - used - 2); + return columns - used - 2; } +const SESSION_TAG = 18; + function currentSessionTitle(state: TuiState): string | null { const id = state.session.sessionId; if (!id) return null; diff --git a/src/tui/components/update-banner.test.tsx b/src/tui/components/update-banner.test.tsx new file mode 100644 index 00000000..607a3aa3 --- /dev/null +++ b/src/tui/components/update-banner.test.tsx @@ -0,0 +1,44 @@ +import { render } from "ink-testing-library"; +import React from "react"; +import { describe, expect, it } from "vitest"; +import { planUpdateBanner, UpdateBanner } from "./update-banner.js"; + +const strip = (s: string): string => s.replace(/\u001b\[[0-9;]*m/g, ""); + +describe("UpdateBanner", () => { + it("says the whole sentence when the row has room", () => { + const view = render(); + const frame = strip(view.lastFrame() ?? ""); + expect(frame).toContain("new version v9.9.9 available"); + expect(frame).toContain("Update"); + }); + + it("sheds the sentence, then the version, as the row fills up", () => { + const medium = strip( + render().lastFrame() ?? "", + ); + expect(medium).toContain("v9.9.9"); + expect(medium).not.toContain("new version"); + expect(medium).toContain("Update"); + + const tight = strip( + render().lastFrame() ?? "", + ); + expect(tight).toContain("Update"); + expect(tight).not.toContain("9.9.9"); + }); + + it("disappears rather than wrapping the one-row bar", () => { + const view = render(); + expect(strip(view.lastFrame() ?? "").trim()).toBe(""); + }); + + it("never plans a form wider than its budget", () => { + for (const latest of ["1.0.0", "10.20.30", "0.5.5-rc.1"]) { + for (let budget = 0; budget <= 60; budget += 1) { + const plan = planUpdateBanner(latest, budget); + if (plan) expect(plan.width).toBeLessThanOrEqual(budget); + } + } + }); +}); diff --git a/src/tui/components/update-banner.tsx b/src/tui/components/update-banner.tsx new file mode 100644 index 00000000..41284221 --- /dev/null +++ b/src/tui/components/update-banner.tsx @@ -0,0 +1,104 @@ +import { Text } from "ink"; +import type { ReactElement } from "react"; +import { MouseTarget, useMouseCommands } from "../mouse/mouse-context.js"; +import { isPrimaryPress } from "../mouse/mouse-event.js"; +import { theme } from "../theme/theme.js"; + +/** + * The persistent "a newer release exists" strip at the right end of the + * status bar. + * + * The startup {@link UpdateModal} already offers the update once; this + * banner is what remains after the operator skips it. It has to survive + * the whole session without stealing attention from the work — so it + * sits in the one corner the eye only visits deliberately, and it never + * blinks, animates, or claims a key. What it *does* claim is contrast: + * the strip renders inverse-video, swapping ink and ground, which is + * distinguishable on every palette by construction — whatever the + * terminal's background is, the banner is its opposite. No hand-picked + * colour can promise that across twelve palettes and user terminals. + * + * `Update` is the click target and runs the same path as the modal's + * `y` (`onUpdateConfirmed` → `runUpdate`), including its refusal while + * a turn is in flight. Without mouse support the banner is inert + * signage, like every other chip — the modal remains the keyboard route. + */ +export interface UpdateBannerProps { + latest: string; + /** + * Columns the banner may use. Ink wraps rather than clips, so an + * over-wide banner would fold the one-row status bar into a + * paragraph; the banner degrades instead — full sentence, then bare + * version, then the button alone, then nothing. + */ + budget: number; +} + +/** The click target. Fixed label, so its width is a constant. */ +const BUTTON = " Update "; + +/** Cell between the label and the button. */ +const GAP = 1; + +export interface UpdateBannerPlan { + /** Inverse-video label before the button; `null` for button-only. */ + label: string | null; + /** Total cells the banner occupies, button included. */ + width: number; +} + +/** + * Which form fits the budget. Exported so the status bar can subtract + * the banner's real width from the download chip's budget instead of + * guessing — the two share the same row. + */ +export function planUpdateBanner( + latest: string, + budget: number, +): UpdateBannerPlan | null { + const full = ` new version v${latest} available `; + const short = ` v${latest} `; + for (const label of [full, short]) { + const width = label.length + GAP + BUTTON.length; + if (width <= budget) return { label, width }; + } + if (BUTTON.length <= budget) return { label: null, width: BUTTON.length }; + return null; +} + +export function UpdateBanner({ + latest, + budget, +}: UpdateBannerProps): ReactElement | null { + const mouse = useMouseCommands(); + const plan = planUpdateBanner(latest, budget); + if (!plan) return null; + // Inverse accent: the palette's accent as ground, the terminal's own + // background as ink. Louder than the inverse label beside it, so the + // actionable cell reads as the button and the sentence as its caption. + const button = ( + + {BUTTON} + + ); + // Siblings, not one parent: `MouseTarget` wraps its child in a + // Box to own a measurable region, and Ink refuses a Box inside Text. + return ( + <> + {plan.label ? {`${plan.label} `} : null} + {mouse ? ( + { + if (!isPrimaryPress(hit.event)) return false; + mouse.callbacks.onUpdateConfirmed?.(); + return true; + }} + > + {button} + + ) : ( + button + )} + + ); +} diff --git a/src/tui/tui-app.tsx b/src/tui/tui-app.tsx index b38cbbb4..2d275bc8 100644 --- a/src/tui/tui-app.tsx +++ b/src/tui/tui-app.tsx @@ -1735,6 +1735,7 @@ export function TuiApp({ diff --git a/src/tui/tui-args.test.ts b/src/tui/tui-args.test.ts index 3abbed21..54953653 100644 --- a/src/tui/tui-args.test.ts +++ b/src/tui/tui-args.test.ts @@ -45,3 +45,30 @@ describe("parseTuiArgs mouse flags", () => { expect(TUI_HELP).toContain("--mouse"); }); }); + +describe("parseTuiArgs --fake-update", () => { + it("stays off by default", () => { + expect(parseTuiArgs([])).toMatchObject({ fakeUpdateVersion: null }); + }); + + it("captures the pretended version", () => { + expect(parseTuiArgs(["--fake-update", "9.9.9"])).toMatchObject({ + fakeUpdateVersion: "9.9.9", + }); + }); + + it("tolerates a v-prefixed version, since releases are tagged that way", () => { + expect(parseTuiArgs(["--fake-update", "v9.9.9"])).toMatchObject({ + fakeUpdateVersion: "9.9.9", + }); + }); + + it("refuses a missing or flag-shaped value", () => { + expect(parseTuiArgs(["--fake-update"])).toHaveProperty("error"); + expect(parseTuiArgs(["--fake-update", "--no-mouse"])).toHaveProperty("error"); + }); + + it("advertises the flag in --help", () => { + expect(TUI_HELP).toContain("--fake-update"); + }); +}); diff --git a/src/tui/tui-args.ts b/src/tui/tui-args.ts index fbd862ca..20f01f27 100644 --- a/src/tui/tui-args.ts +++ b/src/tui/tui-args.ts @@ -17,6 +17,14 @@ export interface TuiArgs { * text selection for this run. */ mouse: boolean | null; + /** + * Dev testing ground for the update surfaces: pretend this version is + * available on GitHub Releases. Skips the real check (and the real + * installer on accept), so the modal, the status-bar banner and their + * degradations can be eyeballed without publishing a release or + * running a stale binary. `null` in normal operation. + */ + fakeUpdateVersion: string | null; } export type TuiArgsResult = TuiArgs | { error: string } | { help: true }; @@ -36,6 +44,7 @@ export const TUI_HELP = " --skip-llama-setup Skip the first-run local-model setup gate", " --mouse Force terminal mouse support on for this run", " --no-mouse Disable mouse support; restores drag-to-select", + " --fake-update Dev: pretend version is released (no real install)", "", "Needs an interactive terminal; in scripts use `atomic-agent run`.", ].join("\n") + "\n"; @@ -58,6 +67,7 @@ export function parseTuiArgs(args: string[]): TuiArgsResult { let noApproval = false; let skipLlamaSetup = false; let mouse: boolean | null = null; + let fakeUpdateVersion: string | null = null; for (let i = 0; i < args.length; i += 1) { const flag = args[i]; switch (flag) { @@ -90,6 +100,16 @@ export function parseTuiArgs(args: string[]): TuiArgsResult { case "--no-mouse": mouse = false; break; + case "--fake-update": { + const value = args[++i]; + // A bare version, not a flag that happened to follow. Catching + // `--fake-update --no-mouse` here beats a banner advertising + // "v--no-mouse" ten minutes into a test session. + if (!value || value.startsWith("-")) + return { error: "--fake-update requires a version (e.g. --fake-update 9.9.9)" }; + fakeUpdateVersion = value.replace(/^v/, ""); + break; + } default: return { error: `unknown flag: ${flag}` }; } @@ -100,6 +120,7 @@ export function parseTuiArgs(args: string[]): TuiArgsResult { noApproval, skipLlamaSetup, mouse, + fakeUpdateVersion, }; } diff --git a/src/tui/tui-command.ts b/src/tui/tui-command.ts index 7d6b5e08..963a0ac8 100644 --- a/src/tui/tui-command.ts +++ b/src/tui/tui-command.ts @@ -12,6 +12,7 @@ import { import { checkLlamaServer } from "../llm/llama-server-health.js"; import { describeLlamaHealthFailure } from "../llm/describe-llama-health-failure.js"; import { createAgentRuntime, type AgentRuntime } from "../runtime/bootstrap.js"; +import { getAppVersion } from "../version.js"; import type { LogRecord, LogSink } from "../tracing/structured-logger.js"; import type { MetricSample, MetricSink } from "../tracing/metrics-collector.js"; import { isKnownLocalModelId } from "../local-llm/index.js"; @@ -660,7 +661,17 @@ export async function tuiCommand(args: string[]): Promise { onAnalyticsSetEnabledRequested: (enabled) => orchestrator.privacy.setAnalyticsEnabled(enabled), onPrivacyRefreshRequested: () => orchestrator.privacy.refresh(), - onUpdateConfirmed: () => orchestrator.runUpdate(), + onUpdateConfirmed: () => + parsed.fakeUpdateVersion + ? // The testing ground must never reach install.sh: the + // point of `--fake-update` is to look at the surfaces, and + // "accept" on a dev build would install the real latest + // release over whatever is being worked on. + bus.emit({ + type: "system_message", + text: `--fake-update: accepted (v${parsed.fakeUpdateVersion}); install skipped in fake mode`, + }) + : orchestrator.runUpdate(), onUpdateRestart: () => { restartRequested = true; }, @@ -751,7 +762,18 @@ export async function tuiCommand(args: string[]): Promise { // Fire-and-forget startup version check. Surfaces an in-app update // offer when a newer release is published; silently no-ops when // disabled, offline, rate-limited, or running a dev build. - void orchestrator.checkForUpdate(); + // `--fake-update` bypasses the check (a dev build fails + // `canSelfUpdate` anyway) and emits the offer directly, so the modal + // and the status-bar banner can be exercised on demand. + if (parsed.fakeUpdateVersion) { + bus.emit({ + type: "update_available", + current: getAppVersion(), + latest: parsed.fakeUpdateVersion, + }); + } else { + void orchestrator.checkForUpdate(); + } try { await ink.waitUntilExit(); diff --git a/src/tui/tui-state.ts b/src/tui/tui-state.ts index 4c6d3a25..e5bb3ba7 100644 --- a/src/tui/tui-state.ts +++ b/src/tui/tui-state.ts @@ -527,6 +527,15 @@ export interface TuiState { * offer was dismissed / accepted). Drives the {@link UpdateModal}. */ updatePrompt: { current: string; latest: string } | null; + /** + * Persistent "a newer release exists" fact behind the status-bar + * banner. Set alongside {@link updatePrompt} and — unlike the prompt — + * NOT cleared by `update_dismissed`: skipping the modal means "not + * now", and the banner is what keeps the offer reachable afterwards. + * The bar hides it while an update is running or finished + * (`updateStatus`), so no reducer case ever needs to null it. + */ + updateBanner: { current: string; latest: string } | null; /** * Lifecycle of an accepted self-update. `running` while `install.sh` * executes; `done` / `failed` after it settles. Purely informational — @@ -790,6 +799,7 @@ export function createInitialTuiState( themePickerOriginal: "", aborting: false, updatePrompt: null, + updateBanner: null, updateStatus: "idle", ringBufferSize, tasksPanel: createInitialTasksPanelState(), From cc175ae51ca195b3f6e695bbcda244d9dc51c70a Mon Sep 17 00:00:00 2001 From: Valerii Date: Wed, 2 Sep 2026 19:07:42 +0300 Subject: [PATCH 2/2] tui: update banner clickable through the modal floor; lifecycle strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two field reports from the first demo round: The obvious first click a fresh launch invites is the banner's Update button — while the startup offer modal is still on screen. The modal raises the mouse floor to the modal rung, so that click was silently swallowed. The button now registers on the modal rung itself: a click there is exactly the modal's y, so answering the offer from the corner is the same decision, not a bypass. And once an update is accepted, the corner went quiet at the very moment it had something worth saying. The strip now narrates the lifecycle instead of hiding: 'updating to vX — do not close' while the installer runs (degrading to 'updating — do not close', then 'updating…'), 'updated to vX — restart to apply' once it lands, and back to the offer — the retry path — after a failure. --fake-update's accept now walks the same event sequence the real installer emits (update_started, feed lines, update_finished ok) on a watchable timeline instead of a one-line notice, so the whole arc — modal, click-through, do-not-close strip, restart prompt — is on show; the restart re-execs the same dev command, so nothing is ever installed. --- src/tui/components/status-bar-update.test.tsx | 24 +++- src/tui/components/status-bar.tsx | 32 +++-- src/tui/components/update-banner.test.tsx | 59 +++++++-- src/tui/components/update-banner.tsx | 121 ++++++++++++------ src/tui/tui-command.ts | 37 +++++- 5 files changed, 207 insertions(+), 66 deletions(-) diff --git a/src/tui/components/status-bar-update.test.tsx b/src/tui/components/status-bar-update.test.tsx index fb4fb9d5..0d3ae456 100644 --- a/src/tui/components/status-bar-update.test.tsx +++ b/src/tui/components/status-bar-update.test.tsx @@ -30,13 +30,27 @@ describe("StatusBar update banner", () => { expect(frame).toContain("Update"); }); - it("yields while the installer runs, and returns on failure", () => { + it("narrates the install instead of offering it while the installer runs", () => { const running = apply(offered(), [{ type: "update_started" }]); - expect( - strip(render().lastFrame() ?? ""), - ).not.toContain("Update"); + const frame = strip(render().lastFrame() ?? ""); + expect(frame).toContain("do not close"); + // The button is gone: a second accept mid-install has no meaning. + expect(frame).not.toContain("Update"); + }); + + it("says a restart applies it once the installer lands", () => { + const done = apply(offered(), [ + { type: "update_started" }, + { type: "update_finished", ok: true, version: "9.9.9" }, + ]); + const frame = strip(render().lastFrame() ?? ""); + expect(frame).toContain("restart to apply"); + expect(frame).not.toContain("Update"); + }); - const failed = apply(running, [ + it("returns to the offer — the retry path — after a failed install", () => { + const failed = apply(offered(), [ + { type: "update_started" }, { type: "update_finished", ok: false, error: "boom" }, ]); expect( diff --git a/src/tui/components/status-bar.tsx b/src/tui/components/status-bar.tsx index 4782332a..c98525d6 100644 --- a/src/tui/components/status-bar.tsx +++ b/src/tui/components/status-bar.tsx @@ -12,7 +12,11 @@ import type { TuiState } from "../tui-state.js"; import { getAppVersion } from "../../version.js"; import { Chip, tracked } from "./chip.js"; import { sessionTitleLine } from "./session-title.js"; -import { planUpdateBanner, UpdateBanner } from "./update-banner.js"; +import { + planUpdateBanner, + UpdateBanner, + type UpdateBannerPhase, +} from "./update-banner.js"; interface StatusBarProps { state: TuiState; @@ -68,13 +72,17 @@ export function StatusBar({ const title = currentSessionTitle(state); const { columns } = useTerminalSize(); // The banner outlives the modal (`updateBanner` survives - // `update_dismissed`) and yields only to an update actually running - // or finished — `failed` keeps it up, because the banner is then the - // one remaining way to retry. - const banner = - state.updateStatus === "idle" || state.updateStatus === "failed" - ? state.updateBanner - : null; + // `update_dismissed`) and then narrates the whole lifecycle: the + // offer while nothing runs, "do not close" while the installer works, + // the restart hint once it lands. `failed` renders as a fresh offer, + // because the button is then the one remaining way to retry. + const banner = state.updateBanner; + const bannerPhase: UpdateBannerPhase = + state.updateStatus === "running" + ? "running" + : state.updateStatus === "done" + ? "done" + : "offer"; // `chipBudget` reserves cells for a session tag whether or not one is // drawn — safe slack for the download chip, but it starves the banner // out of a fresh 70-column session where the corner is visibly empty. @@ -85,7 +93,7 @@ export function StatusBar({ (state.session.sessionId ? 0 : SESSION_TAG), ); const bannerPlan = banner - ? planUpdateBanner(banner.latest, bannerBudget) + ? planUpdateBanner(banner.latest, bannerBudget, bannerPhase) : null; return ( @@ -128,7 +136,11 @@ export function StatusBar({ the bar knows its row width; content-sized bars (no `width`) collapse the spacer to two plain cells. */} - + ) : null} diff --git a/src/tui/components/update-banner.test.tsx b/src/tui/components/update-banner.test.tsx index 607a3aa3..567d517e 100644 --- a/src/tui/components/update-banner.test.tsx +++ b/src/tui/components/update-banner.test.tsx @@ -7,7 +7,7 @@ const strip = (s: string): string => s.replace(/\u001b\[[0-9;]*m/g, ""); describe("UpdateBanner", () => { it("says the whole sentence when the row has room", () => { - const view = render(); + const view = render(); const frame = strip(view.lastFrame() ?? ""); expect(frame).toContain("new version v9.9.9 available"); expect(frame).toContain("Update"); @@ -15,29 +15,70 @@ describe("UpdateBanner", () => { it("sheds the sentence, then the version, as the row fills up", () => { const medium = strip( - render().lastFrame() ?? "", + render().lastFrame() ?? "", ); expect(medium).toContain("v9.9.9"); expect(medium).not.toContain("new version"); expect(medium).toContain("Update"); const tight = strip( - render().lastFrame() ?? "", + render().lastFrame() ?? "", ); expect(tight).toContain("Update"); expect(tight).not.toContain("9.9.9"); }); it("disappears rather than wrapping the one-row bar", () => { - const view = render(); + const view = render(); expect(strip(view.lastFrame() ?? "").trim()).toBe(""); }); - it("never plans a form wider than its budget", () => { - for (const latest of ["1.0.0", "10.20.30", "0.5.5-rc.1"]) { - for (let budget = 0; budget <= 60; budget += 1) { - const plan = planUpdateBanner(latest, budget); - if (plan) expect(plan.width).toBeLessThanOrEqual(budget); + it("tells the operator not to close the terminal while installing", () => { + const frame = strip( + render( + , + ).lastFrame() ?? "", + ); + expect(frame).toContain("updating to v9.9.9"); + expect(frame).toContain("do not close"); + expect(frame).not.toContain("Update"); + }); + + it("asks for a restart once the install has landed", () => { + const frame = strip( + render( + , + ).lastFrame() ?? "", + ); + expect(frame).toContain("restart to apply"); + expect(frame).not.toContain("Update"); + }); + + it("degrades the running strip rather than wrapping it", () => { + const medium = strip( + render( + , + ).lastFrame() ?? "", + ); + expect(medium).toContain("do not close"); + expect(medium).not.toContain("9.9.9"); + + const tight = strip( + render( + , + ).lastFrame() ?? "", + ); + expect(tight).toContain("updating"); + expect(tight).not.toContain("do not close"); + }); + + it("never plans a form wider than its budget, in any phase", () => { + for (const phase of ["offer", "running", "done"] as const) { + for (const latest of ["1.0.0", "10.20.30", "0.5.5-rc.1"]) { + for (let budget = 0; budget <= 60; budget += 1) { + const plan = planUpdateBanner(latest, budget, phase); + if (plan) expect(plan.width).toBeLessThanOrEqual(budget); + } } } }); diff --git a/src/tui/components/update-banner.tsx b/src/tui/components/update-banner.tsx index 41284221..2d5d0c6d 100644 --- a/src/tui/components/update-banner.tsx +++ b/src/tui/components/update-banner.tsx @@ -2,21 +2,22 @@ import { Text } from "ink"; import type { ReactElement } from "react"; import { MouseTarget, useMouseCommands } from "../mouse/mouse-context.js"; import { isPrimaryPress } from "../mouse/mouse-event.js"; +import { MOUSE_LAYER_MODAL } from "../mouse/mouse-registry.js"; import { theme } from "../theme/theme.js"; /** - * The persistent "a newer release exists" strip at the right end of the - * status bar. + * The persistent update strip at the right end of the status bar. * * The startup {@link UpdateModal} already offers the update once; this - * banner is what remains after the operator skips it. It has to survive - * the whole session without stealing attention from the work — so it - * sits in the one corner the eye only visits deliberately, and it never - * blinks, animates, or claims a key. What it *does* claim is contrast: - * the strip renders inverse-video, swapping ink and ground, which is - * distinguishable on every palette by construction — whatever the - * terminal's background is, the banner is its opposite. No hand-picked - * colour can promise that across twelve palettes and user terminals. + * banner is what remains after the operator skips it — and what narrates + * the install once they accept. It has to survive the whole session + * without stealing attention from the work, so it sits in the one corner + * the eye only visits deliberately, and it never blinks, animates, or + * claims a key. What it *does* claim is contrast: the strip renders + * inverse-video, swapping ink and ground, which is distinguishable on + * every palette by construction — whatever the terminal's background is, + * the banner is its opposite. No hand-picked colour can promise that + * across twelve palettes and user terminals. * * `Update` is the click target and runs the same path as the modal's * `y` (`onUpdateConfirmed` → `runUpdate`), including its refusal while @@ -25,28 +26,61 @@ import { theme } from "../theme/theme.js"; */ export interface UpdateBannerProps { latest: string; + /** + * Where the update is in its life. `offer` shows the sentence and the + * button; `running` swaps them for "updating — do not close" (the + * installer is replacing the binary and the one useful instruction is + * to leave it alone); `done` says a restart applies it. The bar maps + * `updateStatus` onto this — the failed state renders as a fresh + * `offer`, because the button is then the way to retry. + */ + phase: UpdateBannerPhase; /** * Columns the banner may use. Ink wraps rather than clips, so an * over-wide banner would fold the one-row status bar into a - * paragraph; the banner degrades instead — full sentence, then bare - * version, then the button alone, then nothing. + * paragraph; the banner degrades instead — full sentence, then a + * terse one, then (for `offer`) the button alone, then nothing. */ budget: number; } +export type UpdateBannerPhase = "offer" | "running" | "done"; + /** The click target. Fixed label, so its width is a constant. */ const BUTTON = " Update "; -/** Cell between the label and the button. */ -const GAP = 1; - export interface UpdateBannerPlan { - /** Inverse-video label before the button; `null` for button-only. */ + /** Inverse-video label; `null` for the button-only offer form. */ label: string | null; + /** Whether the `Update` button renders (offer phase only). */ + button: boolean; /** Total cells the banner occupies, button included. */ width: number; } +/** Longest-first label ladder for each phase. */ +function labelLadder(phase: UpdateBannerPhase, latest: string): string[] { + switch (phase) { + case "offer": + return [` new version v${latest} available `, ` v${latest} `]; + case "running": + // "do not close" is the payload: the installer is mid-way through + // replacing the binary, and killing the terminal now is the one + // thing the operator can do to make it worse. + return [ + ` updating to v${latest} — do not close `, + ` updating — do not close `, + ` updating… `, + ]; + case "done": + return [ + ` updated to v${latest} — restart to apply `, + ` restart to apply `, + ` updated `, + ]; + } +} + /** * Which form fits the budget. Exported so the status bar can subtract * the banner's real width from the download chip's budget instead of @@ -55,23 +89,26 @@ export interface UpdateBannerPlan { export function planUpdateBanner( latest: string, budget: number, + phase: UpdateBannerPhase = "offer", ): UpdateBannerPlan | null { - const full = ` new version v${latest} available `; - const short = ` v${latest} `; - for (const label of [full, short]) { - const width = label.length + GAP + BUTTON.length; - if (width <= budget) return { label, width }; + const button = phase === "offer"; + const buttonWidth = button ? BUTTON.length : 0; + for (const label of labelLadder(phase, latest)) { + const width = label.length + buttonWidth; + if (width <= budget) return { label, button, width }; } - if (BUTTON.length <= budget) return { label: null, width: BUTTON.length }; + if (button && BUTTON.length <= budget) + return { label: null, button, width: BUTTON.length }; return null; } export function UpdateBanner({ latest, + phase, budget, }: UpdateBannerProps): ReactElement | null { const mouse = useMouseCommands(); - const plan = planUpdateBanner(latest, budget); + const plan = planUpdateBanner(latest, budget, phase); if (!plan) return null; // Inverse accent: the palette's accent as ground, the terminal's own // background as ink. Louder than the inverse label beside it, so the @@ -85,20 +122,30 @@ export function UpdateBanner({ // Box to own a measurable region, and Ink refuses a Box inside Text. return ( <> - {plan.label ? {`${plan.label} `} : null} - {mouse ? ( - { - if (!isPrimaryPress(hit.event)) return false; - mouse.callbacks.onUpdateConfirmed?.(); - return true; - }} - > - {button} - - ) : ( - button - )} + {plan.label ? {plan.label} : null} + {plan.button ? ( + mouse ? ( + { + if (!isPrimaryPress(hit.event)) return false; + mouse.callbacks.onUpdateConfirmed?.(); + return true; + }} + > + {button} + + ) : ( + button + ) + ) : null} ); } diff --git a/src/tui/tui-command.ts b/src/tui/tui-command.ts index 963a0ac8..6fcdf588 100644 --- a/src/tui/tui-command.ts +++ b/src/tui/tui-command.ts @@ -13,6 +13,7 @@ import { checkLlamaServer } from "../llm/llama-server-health.js"; import { describeLlamaHealthFailure } from "../llm/describe-llama-health-failure.js"; import { createAgentRuntime, type AgentRuntime } from "../runtime/bootstrap.js"; import { getAppVersion } from "../version.js"; +import type { TuiAction } from "./tui-action.js"; import type { LogRecord, LogSink } from "../tracing/structured-logger.js"; import type { MetricSample, MetricSink } from "../tracing/metrics-collector.js"; import { isKnownLocalModelId } from "../local-llm/index.js"; @@ -666,11 +667,12 @@ export async function tuiCommand(args: string[]): Promise { ? // The testing ground must never reach install.sh: the // point of `--fake-update` is to look at the surfaces, and // "accept" on a dev build would install the real latest - // release over whatever is being worked on. - bus.emit({ - type: "system_message", - text: `--fake-update: accepted (v${parsed.fakeUpdateVersion}); install skipped in fake mode`, - }) + // release over whatever is being worked on. Instead, walk + // the same events the real installer emits so the whole + // lifecycle — "do not close" strip, feed lines, restart + // prompt — is on show. The restart re-execs this same dev + // command, which is a no-op by construction. + simulateFakeUpdate(bus, parsed.fakeUpdateVersion) : orchestrator.runUpdate(), onUpdateRestart: () => { restartRequested = true; @@ -834,6 +836,31 @@ export async function tuiCommand(args: string[]): Promise { return orchestrator.exitCode; } +/** + * `--fake-update` accept path: emit the exact event sequence + * `runUpdate` emits, on a human-watchable timeline, without ever + * touching the installer. Ends in `update_finished ok`, so the "press + * any key to restart" prompt is exercised too — the restart re-execs + * the same `tui --fake-update` command, landing back at the offer. + */ +function simulateFakeUpdate( + bus: ReturnType, + version: string, +): void { + bus.emit({ type: "update_started" }); + const script: readonly [number, TuiAction][] = [ + [400, { type: "runtime_info", line: `[update] (fake) downloading atomic-agent v${version}…` }], + [1500, { type: "runtime_info", line: "[update] (fake) verifying checksum…" }], + [2200, { type: "runtime_info", line: "[update] (fake) installing — nothing on this machine is being replaced" }], + [3000, { type: "update_finished", ok: true, version }], + ]; + for (const [delay, action] of script) { + // Unref'd so a Ctrl+C mid-"install" never has the process lingering + // on demo timers. + setTimeout(() => bus.emit(action), delay).unref(); + } +} + /** * Ctrl+N / `/window`: launch a second agent in a new OS terminal window. * Fire-and-forget — the result is reported into the chat log either way,