Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/tui/agent-event-reducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
1 change: 1 addition & 0 deletions src/tui/agent-event-reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
83 changes: 83 additions & 0 deletions src/tui/components/status-bar-update.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<StatusBar state={offered()} />).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(<StatusBar state={state} />).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(<StatusBar state={running} />).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(<StatusBar state={done} />).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(<StatusBar state={failed} />).lastFrame() ?? ""),
).toContain("Update");
});

it("says nothing when no newer version exists", () => {
const state = createInitialTuiState(fakeSession());
const frame = strip(render(<StatusBar state={state} />).lastFrame() ?? "");
expect(frame).not.toContain("Update");
});

it("pins the banner to the right edge when given the row width", () => {
const view = render(<StatusBar state={offered()} width={78} />);
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(<StatusBar state={offered()} width={78} />);
const rows = strip(view.lastFrame() ?? "")
.split("\n")
.filter((line) => line.trim().length > 0);
expect(rows).toHaveLength(1);
});
});
73 changes: 69 additions & 4 deletions src/tui/components/status-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 (
<Box>
<Box {...(width ? { width } : {})}>
{railRestore ? <RailRestoreButton /> : null}
{brand ? (
<>
Expand All @@ -76,7 +112,13 @@ export function StatusBar({
{state.localModelsPanel.pull ? (
<DownloadChip
pull={state.localModelsPanel.pull}
budget={chipBudget(columns, brand, title)}
budget={
// The banner has already taken its cells from the same
// leftover; hand the chip what genuinely remains or the two
// meet in the middle and wrap the row.
chipBudget(columns, brand, title) -
(bannerPlan ? bannerPlan.width + 2 : 0)
}
/>
) : null}
{title ? (
Expand All @@ -88,6 +130,19 @@ export function StatusBar({
</Text>
</Text>
) : 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. */}
<Box flexGrow={1} minWidth={2} />
<UpdateBanner
latest={banner.latest}
phase={bannerPhase}
budget={bannerBudget}
/>
</>
) : null}
</Box>
);
}
Expand All @@ -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;
Expand Down
85 changes: 85 additions & 0 deletions src/tui/components/update-banner.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<UpdateBanner latest="9.9.9" phase="offer" budget={60} />);
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(<UpdateBanner latest="9.9.9" phase="offer" budget={20} />).lastFrame() ?? "",
);
expect(medium).toContain("v9.9.9");
expect(medium).not.toContain("new version");
expect(medium).toContain("Update");

const tight = strip(
render(<UpdateBanner latest="9.9.9" phase="offer" budget={9} />).lastFrame() ?? "",
);
expect(tight).toContain("Update");
expect(tight).not.toContain("9.9.9");
});

it("disappears rather than wrapping the one-row bar", () => {
const view = render(<UpdateBanner latest="9.9.9" phase="offer" budget={5} />);
expect(strip(view.lastFrame() ?? "").trim()).toBe("");
});

it("tells the operator not to close the terminal while installing", () => {
const frame = strip(
render(
<UpdateBanner latest="9.9.9" phase="running" budget={60} />,
).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(
<UpdateBanner latest="9.9.9" phase="done" budget={60} />,
).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(
<UpdateBanner latest="9.9.9" phase="running" budget={26} />,
).lastFrame() ?? "",
);
expect(medium).toContain("do not close");
expect(medium).not.toContain("9.9.9");

const tight = strip(
render(
<UpdateBanner latest="9.9.9" phase="running" budget={12} />,
).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);
}
}
}
});
});
Loading
Loading