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(),