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..0d3ae456 --- /dev/null +++ b/src/tui/components/status-bar-update.test.tsx @@ -0,0 +1,83 @@ +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("narrates the install instead of offering it while the installer runs", () => { + const running = apply(offered(), [{ type: "update_started" }]); + 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"); + }); + + 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( + 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..c98525d6 100644 --- a/src/tui/components/status-bar.tsx +++ b/src/tui/components/status-bar.tsx @@ -12,9 +12,20 @@ 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, + type UpdateBannerPhase, +} 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 +64,39 @@ 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 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. + // 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, bannerPhase) + : null; return ( - + {railRestore ? : null} {brand ? ( <> @@ -76,7 +112,13 @@ export function StatusBar({ {state.localModelsPanel.pull ? ( ) : null} {title ? ( @@ -88,6 +130,19 @@ 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 +160,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..567d517e --- /dev/null +++ b/src/tui/components/update-banner.test.tsx @@ -0,0 +1,85 @@ +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("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 new file mode 100644 index 00000000..2d5d0c6d --- /dev/null +++ b/src/tui/components/update-banner.tsx @@ -0,0 +1,151 @@ +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 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 — 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 + * 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; + /** + * 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 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 "; + +export interface UpdateBannerPlan { + /** 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 + * guessing — the two share the same row. + */ +export function planUpdateBanner( + latest: string, + budget: number, + phase: UpdateBannerPhase = "offer", +): UpdateBannerPlan | null { + 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 && 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, 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 + // 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} + {plan.button ? ( + mouse ? ( + { + if (!isPrimaryPress(hit.event)) return false; + mouse.callbacks.onUpdateConfirmed?.(); + return true; + }} + > + {button} + + ) : ( + button + ) + ) : null} + + ); +} 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..6fcdf588 100644 --- a/src/tui/tui-command.ts +++ b/src/tui/tui-command.ts @@ -12,6 +12,8 @@ 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 { 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"; @@ -660,7 +662,18 @@ 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. 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; }, @@ -751,7 +764,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(); @@ -812,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, 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(),