feat(tui): mode badge in normal footer + paused state indicator

- renderFooter: add mode badge (compact at <80 cols, full at ≥80 cols)
  to right side so active mode is always visible, not only during auto
- renderAutoFooter: refactor to use shared renderModeBadge instead of
  duplicating badge logic inline
- renderModeBadge: handle paused state — all badge parts dim, 'P!' prefix
  shown in compact form, 'paused ·' prefix shown in full form
- getMode(): surface session.paused as a field on the returned mode object
  so badge renderers can reflect paused state without inspecting session directly
- Export renderModeBadge from header.js; footer imports it via FOOTER_THEME adapter

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Mikael Hugo 2026-05-09 04:41:00 +02:00
parent 848ac0dd99
commit 9441022909
3 changed files with 48 additions and 27 deletions

View file

@ -1,6 +1,7 @@
import { truncateToWidth, visibleWidth } from "@singularity-forge/pi-tui";
import { getAutoSession } from "../sf/auto/session.js";
import { refreshGitStatus } from "./git.js";
import { renderModeBadge } from "./header.js";
const RESET = "\x1b[0m";
const BOLD = "\x1b[1m";
@ -74,6 +75,12 @@ function join(parts) {
function shorten(text, max) {
return text.length > max ? `${text.slice(0, Math.max(0, max - 3))}...` : text;
}
/** Minimal theme adapter so renderModeBadge (header.js) can run with footer's ANSI helpers. */
const FOOTER_THEME = {
fg: (tone, text) => ansiFg(toneHex(tone), text),
bold: (text) => `${BOLD}${text}${RESET}`,
};
function getSessionStats(ctx) {
let cost = 0;
let tokens = 0;
@ -98,6 +105,8 @@ function getSessionStats(ctx) {
export function renderFooter(_theme, footerData, ctx, width) {
const git = refreshGitStatus(process.cwd());
const { cost, cxPct } = getSessionStats(ctx);
const session = getAutoSession();
const mode = session?.getMode?.();
const leftParts = [];
if (git.repo) {
leftParts.push(ansiFg(SE.ember40, git.repo, true));
@ -136,6 +145,9 @@ export function renderFooter(_theme, footerData, ctx, width) {
leftParts.push(chip("status", statuses.join(" "), "accent"));
}
const rightParts = [];
if (mode) {
rightParts.push(renderModeBadge(FOOTER_THEME, mode, width < 80));
}
if (ctx.model) {
rightParts.push(
chip("model", `${ctx.model.provider}/${ctx.model.id}`, "text"),
@ -180,19 +192,16 @@ export function renderAutoFooter(_theme, footerData, ctx, width) {
modelMode: "smart",
};
const leftParts = [`${BOLD}${ansiFg(SE.ember40, "SF")}`];
leftParts.push(ansiFg(SE.ember40, mode.workMode));
leftParts.push(ansiFg(SE.gray60, "·"));
leftParts.push(ansiFg(SE.success, "∞"));
leftParts.push(ansiFg(SE.gray60, "·"));
leftParts.push(ansiFg(SE.warning, mode.permissionProfile));
const badge = renderModeBadge(FOOTER_THEME, mode, width < 80);
const leftParts = [`${BOLD}${ansiFg(SE.ember40, "SF")}`, badge].filter(
Boolean,
);
const statuses = Array.from(footerData.getExtensionStatuses().entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([, text]) => text.trim())
.filter(Boolean);
if (statuses.length) {
leftParts.push(ansiFg(SE.gray60, "·"));
leftParts.push(ansiFg(SE.gray60, statuses.join(" ")));
}

View file

@ -51,27 +51,36 @@ function compactModelModeBadge(mm) {
function renderModeBadge(theme, mode, compact) {
if (!mode) return "";
const th = theme;
const paused = mode.paused === true;
if (compact) {
const badges = [
th.fg("accent", compactModeBadge(mode.workMode)),
paused ? th.fg("dim", "P!") : "",
th.fg(paused ? "dim" : "accent", compactModeBadge(mode.workMode)),
th.fg("dim", compactRunControlBadge(mode.runControl)),
th.fg("warning", compactPermissionBadge(mode.permissionProfile)),
th.fg("success", compactModelModeBadge(mode.modelMode)),
];
th.fg(
paused ? "dim" : "warning",
compactPermissionBadge(mode.permissionProfile),
),
th.fg(paused ? "dim" : "success", compactModelModeBadge(mode.modelMode)),
].filter(Boolean);
return `[${badges.join("")}]`;
}
const parts = [
th.fg("accent", mode.workMode),
paused ? th.fg("dim", "paused") : "",
paused ? th.fg("dim", "·") : "",
th.fg(paused ? "dim" : "accent", mode.workMode),
th.fg("dim", "·"),
th.fg("text", mode.runControl),
th.fg("dim", mode.runControl),
th.fg("dim", "·"),
th.fg("warning", mode.permissionProfile),
th.fg(paused ? "dim" : "warning", mode.permissionProfile),
th.fg("dim", "·"),
th.fg("success", mode.modelMode),
];
th.fg(paused ? "dim" : "success", mode.modelMode),
].filter(Boolean);
return parts.join(" ");
}
export { renderModeBadge };
/**
* Minimal auto-mode header shows only mode badge + project name.
* Keeps the user aware SF is running autonomously without full header noise.

View file

@ -505,17 +505,20 @@ export class AutoSession {
* Get current mode state as a canonical object.
*/
getMode() {
return buildModeState({
workMode: this.workMode,
runControl: this.active
? this.stepMode
? "assisted"
: "autonomous"
: this.runControl,
permissionProfile: this.permissionProfile,
modelMode: this.modelMode,
surface: this.surface,
});
return {
...buildModeState({
workMode: this.workMode,
runControl: this.active
? this.stepMode
? "assisted"
: "autonomous"
: this.runControl,
permissionProfile: this.permissionProfile,
modelMode: this.modelMode,
surface: this.surface,
}),
paused: this.paused,
};
}
toJSON() {
return {