Skip to content
Open
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
7 changes: 7 additions & 0 deletions packages/coding-agent/changes.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Local fork changes

## 2026-07-29 — Two-line footer extension example

- Changed: added a public-API-only `examples/extensions/two-line-footer/` example with compact path, token, cache, context, model, and generic extension-status rendering. Long paths elide from the head before session metadata yields, built-in anchor indicators remain visible, one built-in context counter stays with the left-side metrics, and other extension statuses are sorted by key and reserved at the right edge.
- Why: users can opt into a denser two-row footer without adding core behavior or coupling status-producing extensions to a specific footer implementation.
- Extension boundary: the example uses `ctx.ui.setFooter()` and public `footerData` accessors exclusively; no core footer source changes are required.
- Merge-conflict risk: low. The change adds an isolated example directory, one test, one catalog row, and this record.

## 2026-07-28 — Billing-class provider errors always pin the session model swap

- Changed: billing-class failures (credit balance, insufficient quota) engage the fallback chain with the pinned `"billing"` reason unconditionally — the candidate becomes the session model for the rest of the session and never auto-reverts. Files: `src/core/retry-fallback/billing.ts` (classifier), `src/core/retry-fallback/controller.ts` (billing reason pins and notes the cooldown), `src/core/agent-session.ts` (classifies hard-error-eligible failures). Non-billing hard errors keep the temporary, revertable switch. Supersedes the opt-in `retry.billingErrorPolicy` variant of the same change; the setting no longer exists.
Expand Down
1 change: 1 addition & 0 deletions packages/coding-agent/examples/extensions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ cp permission-gate.ts ~/.senpi/agent/extensions/
| `titlebar-spinner.ts` | Braille spinner animation in terminal title while the agent is working |
| `summarize.ts` | Summarize conversation with GPT-5.2 and show in transient UI |
| `custom-footer.ts` | Custom footer with git branch and token stats via `ctx.ui.setFooter()` |
| `two-line-footer/` | Responsive two-line footer with head-elided paths, compact stats, left counters, and right-aligned statuses |
| `custom-header.ts` | Custom header via `ctx.ui.setHeader()` |
| `overlay-test.ts` | Test overlay compositing with inline text inputs and edge cases |
| `overlay-qa-tests.ts` | Comprehensive overlay QA tests: anchors, margins, stacking, overflow, animation |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
import { stripVTControlCharacters } from "node:util";
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";

export interface FooterText {
readonly colored: string;
readonly plain: string;
}

export interface FooterLine {
readonly left: FooterText;
readonly right: FooterText;
readonly width: number;
}

export interface FooterTextTools {
readonly measure: (text: string) => number;
readonly truncate: (text: string, width: number, ellipsis: string) => string;
readonly colorTruncatedLeft: (text: string) => string;
}

export interface FooterUsageTotals {
readonly input: number;
readonly output: number;
readonly cacheRead: number;
readonly cacheWrite: number;
readonly latestCacheHitRate: number | undefined;
readonly cost: number;
}

export interface FooterUsageSegment {
readonly color: "dim" | "success";
readonly text: string;
}

export interface FooterTopLeftSegment {
readonly color: "accent" | "muted" | "success" | "warning";
readonly kind: "badge" | "branch" | "fallback" | "path" | "session";
readonly text: string;
}

export interface FooterTopLeftInput {
readonly path: string;
readonly branch: string;
readonly sessionName: string;
readonly omoNative: boolean;
}

export interface FooterStatusGroups {
readonly left: readonly string[];
readonly right: readonly string[];
}

export interface FooterBottomLine {
readonly left: string;
readonly right: string;
}

export const FOOTER_SEPARATOR = " • ";

function trimOneDecimal(value: number): string {
const formatted = value.toFixed(1);
return formatted.endsWith(".0") ? formatted.slice(0, -2) : formatted;
}

export function sanitizeFooterLabel(value: string): string {
return stripVTControlCharacters(value)
.replace(/[\u0000-\u001f\u007f-\u009f]+/gu, " ")
.replace(/\s+/gu, " ")
.trim();
}

export function compactWorkingDirectory(cwd: string): string {
const safeCwd = sanitizeFooterLabel(cwd);
const parts = safeCwd.replaceAll("\\", "/").replace(/\/+$/, "").split("/").filter(Boolean);
const pathSeparator = /^[A-Za-z]:[\\/]/.test(safeCwd) || safeCwd.includes("\\") ? "\\" : "/";
const compactPath = parts.slice(-2).join(pathSeparator);

return compactPath ? `[${compactPath}]` : "[]";
}

export function buildFooterTopLeftSegments(input: FooterTopLeftInput): readonly FooterTopLeftSegment[] {
const segments: FooterTopLeftSegment[] = [];
if (input.omoNative) {
segments.push({ color: "success", kind: "badge", text: "(🏴‍☠️ OmO Native)" });
}
segments.push({ color: "accent", kind: "path", text: input.path });
if (input.branch) segments.push({ color: "warning", kind: "branch", text: input.branch });
if (input.sessionName) segments.push({ color: "muted", kind: "session", text: input.sessionName });
return segments;
}

export function elideHead(text: string, maxWidth: number): string {
if (maxWidth <= 0) return "";
if (visibleWidth(text) <= maxWidth) return text;
if (maxWidth === 1) return "…";
const budget = maxWidth - visibleWidth("…");
const kept: string[] = [];
let used = 0;
for (const char of [...text].reverse()) {
const charWidth = visibleWidth(char);
if (used + charWidth > budget) break;
kept.unshift(char);
used += charWidth;
}
return `…${kept.join("")}`;
}

export function formatTokens(count: number): string {
const rounded = Math.round(count);
if (rounded < 1_000) return rounded.toString();
if (rounded < 10_000) return `${trimOneDecimal(rounded / 1_000)}K`;
if (rounded < 1_000_000) return `${Math.round(rounded / 1_000)}K`;
if (rounded < 10_000_000) {
return `${trimOneDecimal(rounded / 1_000_000)}M`;
}
if (rounded < 1_000_000_000) {
return `${Math.round(rounded / 1_000_000)}M`;
}
if (rounded < 10_000_000_000) {
return `${trimOneDecimal(rounded / 1_000_000_000)}B`;
}
return `${Math.round(rounded / 1_000_000_000)}B`;
}

export function buildFooterUsageSegments(
usage: FooterUsageTotals,
usingSubscription: boolean,
): readonly FooterUsageSegment[] {
const segments: FooterUsageSegment[] = [];
if (usage.input) {
segments.push({ color: "dim", text: `↑${formatTokens(usage.input)}` });
}
if (usage.output) {
segments.push({ color: "dim", text: `↓${formatTokens(usage.output)}` });
}
if (usage.cacheRead || usage.cacheWrite) {
segments.push({
color: "dim",
text: `cache ${formatTokens(usage.cacheRead)}/${formatTokens(usage.cacheWrite)}`,
});
}
if ((usage.cacheRead > 0 || usage.cacheWrite > 0) && usage.latestCacheHitRate !== undefined) {
segments.push({
color: "dim",
text: `CH${usage.latestCacheHitRate.toFixed(1)}%`,
});
}
if (usage.cost || usingSubscription) {
segments.push({
color: "success",
text: `$${usage.cost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`,
});
}
return segments;
}

export function fitFooterSegments(
leadingSegments: readonly string[],
statusSegments: readonly string[],
maxWidth: number,
measure: (text: string) => number = visibleWidth,
): string {
const separator = FOOTER_SEPARATOR;
const statusText = statusSegments.join(separator);
if (!statusText) return leadingSegments.join(separator);

const reservedWidth = measure(statusText);
if (reservedWidth >= maxWidth) return statusText;

const availableLeading = maxWidth - reservedWidth - measure(separator);
const leadingText = leadingSegments.join(separator);
if (!leadingText) return statusText;
if (measure(leadingText) <= availableLeading) {
return `${leadingText}${separator}${statusText}`;
}

const elided = `...${separator}${statusText}`;
return measure(elided) <= maxWidth ? elided : statusText;
}

export function sortedFooterStatuses(statuses: ReadonlyMap<string, string>): FooterStatusGroups {
const left: string[] = [];
const right: string[] = [];
for (const [key, text] of Array.from(statuses.entries()).sort(([leftKey], [rightKey]) =>
leftKey.localeCompare(rightKey),
)) {
const target = key === "ext:nested-agents:status" ? left : right;
target.push(sanitizeFooterLabel(text));
}
return { left, right };
}

export function planFooterBottomLine(
usageSegments: readonly string[],
contextText: string,
statusSegments: readonly string[],
maxRightWidth: number,
measure: (text: string) => number = visibleWidth,
): FooterBottomLine {
return {
left: usageSegments.join(FOOTER_SEPARATOR),
right: fitFooterSegments([contextText], statusSegments, maxRightWidth, measure),
};
}

export function alignStyledFooterLine(line: FooterLine, tools: FooterTextTools): string {
const rightWidth = tools.measure(line.right.plain);
if (rightWidth >= line.width) {
const renderedRight = tools.truncate(line.right.colored, line.width, "");
const renderedWidth = tools.measure(renderedRight);
return `${" ".repeat(Math.max(0, line.width - renderedWidth))}${renderedRight}`;
}

const minimumPadding = line.left.plain ? 2 : 0;
const availableLeft = Math.max(0, line.width - rightWidth - minimumPadding);
let renderedLeft = line.left.colored;
let leftWidth = tools.measure(line.left.plain);

if (leftWidth > availableLeft) {
const truncatedLeft = elideHead(line.left.plain, availableLeft);
renderedLeft = tools.colorTruncatedLeft(truncatedLeft);
leftWidth = tools.measure(truncatedLeft);
}

const padding = " ".repeat(Math.max(0, line.width - leftWidth - rightWidth));
return `${renderedLeft}${padding}${line.right.colored}`;
}

export function alignFooterLine(left: string, right: string, width: number): string {
return alignStyledFooterLine(
{
left: { colored: left, plain: left },
right: { colored: right, plain: right },
width,
},
{
colorTruncatedLeft: (text) => text,
measure: visibleWidth,
truncate: (text, maxWidth, ellipsis) => truncateToWidth(text, maxWidth, ellipsis).replaceAll("\u001b[0m", ""),
},
);
}
37 changes: 37 additions & 0 deletions packages/coding-agent/examples/extensions/two-line-footer/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { ExtensionAPI, ExtensionContext } from "@code-yeongyu/senpi";

import { createTwoLineFooterFactory } from "./two-line-footer.ts";

export default function (pi: ExtensionAPI) {
let enabled = false;

const toggleFooter = (ctx: ExtensionContext, showNotification: boolean): void => {
enabled = !enabled;

if (!enabled) {
ctx.ui.setFooter(undefined);
if (showNotification) {
ctx.ui.notify("Default footer restored", "info");
}
return;
}

ctx.ui.setFooter(createTwoLineFooterFactory(ctx));
if (showNotification) {
ctx.ui.notify("Two-line footer enabled", "info");
}
};

pi.registerCommand("footer", {
description: "Toggle two-line footer",
handler: async (_args, ctx) => {
toggleFooter(ctx, true);
},
});

pi.on("session_start", async (_event, ctx) => {
if (ctx.mode !== "tui") return;
enabled = false;
toggleFooter(ctx, false);
});
}
Loading