feat(statusline): added wow-statusline package for editor status bar
- Created wow-statusline package with configurable editor footer showing CWD, git branch, model/provider info, thinking level, token usage, and cost.
- Registered `/wow:statusline:show` command to inspect the module's enabled state.
- Added `statusline?` config option to WowConfig, supporting boolean or `{ enabled?: boolean }` shape.
- Wired statusline into wow-pi extension entry point as a workspace dependency and side-effect import.
This commit is contained in:
@@ -22,7 +22,7 @@ wow-pi reads JSON or YAML config from both global and project locations, in orde
|
||||
1. `~/.pi/agent/wow.json`, `~/.pi/agent/wow.yaml`, `~/.pi/agent/wow.yml`
|
||||
2. `<project>/.pi/wow.json`, `<project>/.pi/wow.yaml`, `<project>/.pi/wow.yml`
|
||||
|
||||
Project config extends global config. Array fields are merged and de-duplicated; object fields are shallow-merged. See `wow.example.yaml` for a complete example.
|
||||
Project config extends global config. Array fields are merged and de-duplicated; object fields are shallow-merged. For `statusline`, the supported public shape is intentionally minimal: `true`, `false`, or `{ enabled: boolean }`. See `wow.example.yaml` for an example.
|
||||
|
||||
## Packages
|
||||
|
||||
@@ -32,5 +32,6 @@ This repo is organized as a workspace monorepo:
|
||||
- `packages/wow-contexts`: self-registering Markdown context injector with `/wow:contexts:list`, `/wow:contexts:reload`, and `/wow:init`.
|
||||
- `packages/wow-inject`: self-registering env loader that reads `.env` files and resolves `${file:...}` / `${env:...}` into `process.env`.
|
||||
- `packages/wow-pi`: pi extension entry point; imports feature packages for side-effect registration, then runs registered modules.
|
||||
- `packages/wow-statusline`: customized statusline/footer module built on pi's supported UI APIs.
|
||||
|
||||
Logs are written to `~/.pi/agent/wow/logs/`.
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface WowConfig {
|
||||
env?: Record<string, string> | Array<Record<string, string>>;
|
||||
overrideExisting?: boolean;
|
||||
};
|
||||
statusline?: boolean | { enabled?: boolean };
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"dependencies": {
|
||||
"wow-contexts": "workspace:*",
|
||||
"wow-core": "workspace:*",
|
||||
"wow-inject": "workspace:*"
|
||||
"wow-inject": "workspace:*",
|
||||
"wow-statusline": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@earendil-works/pi-coding-agent": "*"
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
import "wow-contexts";
|
||||
import "wow-inject";
|
||||
import "wow-statusline";
|
||||
import {
|
||||
createLogger,
|
||||
getModules,
|
||||
|
||||
17
packages/wow-statusline/package.json
Normal file
17
packages/wow-statusline/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "wow-statusline",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"wow-core": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@earendil-works/pi-coding-agent": "*",
|
||||
"@earendil-works/pi-tui": "*"
|
||||
}
|
||||
}
|
||||
74
packages/wow-statusline/src/config.ts
Normal file
74
packages/wow-statusline/src/config.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { getWowSettingSync } from "wow-core";
|
||||
import type {
|
||||
ResolvedStatuslineConfig,
|
||||
StatuslineConfig,
|
||||
StatuslineSection,
|
||||
} from "./types";
|
||||
|
||||
const DEFAULT_SECTIONS: StatuslineSection[] = [
|
||||
"cwd",
|
||||
"branch",
|
||||
"model",
|
||||
"provider",
|
||||
"thinking",
|
||||
"usage",
|
||||
"cost",
|
||||
];
|
||||
|
||||
const BASE_STATUSLINE_CONFIG: Omit<
|
||||
ResolvedStatuslineConfig,
|
||||
"enabled" | "editorStyle"
|
||||
> = {
|
||||
footer: {
|
||||
enabled: true,
|
||||
sections: DEFAULT_SECTIONS,
|
||||
pathStyle: "short",
|
||||
branch: true,
|
||||
providerStyle: "bracket",
|
||||
separator: " · ",
|
||||
},
|
||||
indicator: {
|
||||
enabled: false,
|
||||
mode: "default",
|
||||
},
|
||||
statusSummary: {
|
||||
enabled: true,
|
||||
placement: "belowEditor",
|
||||
maxItems: 4,
|
||||
separator: " | ",
|
||||
},
|
||||
};
|
||||
|
||||
export function getStatuslineConfig(
|
||||
cwd = process.cwd(),
|
||||
): ResolvedStatuslineConfig {
|
||||
const raw = getWowSettingSync<StatuslineConfig>("statusline", cwd);
|
||||
|
||||
return {
|
||||
enabled: resolveEnabled(raw),
|
||||
...BASE_STATUSLINE_CONFIG,
|
||||
footer: {
|
||||
...BASE_STATUSLINE_CONFIG.footer,
|
||||
sections: [...BASE_STATUSLINE_CONFIG.footer.sections],
|
||||
},
|
||||
editorStyle: {
|
||||
enabled: true,
|
||||
prompt: "❯",
|
||||
folderIcon: hasNerdFonts() ? "" : "📁",
|
||||
indent: " ",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resolveEnabled(value: StatuslineConfig | undefined): boolean {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (value && typeof value === "object") return value.enabled !== false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasNerdFonts(): boolean {
|
||||
if (process.env.PI_NERD_FONTS === "1") return true;
|
||||
if (process.env.PI_NERD_FONTS === "0") return false;
|
||||
const term = process.env.TERM_PROGRAM || process.env.TERM || "";
|
||||
return /iterm|wezterm|kitty|ghostty|alacritty/i.test(term);
|
||||
}
|
||||
124
packages/wow-statusline/src/format.ts
Normal file
124
packages/wow-statusline/src/format.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import type { Theme } from "@earendil-works/pi-coding-agent";
|
||||
import type { ResolvedStatuslineConfig, SessionSnapshot } from "./types";
|
||||
|
||||
export const EMPTY_USAGE = { input: 0, output: 0, cost: 0 };
|
||||
|
||||
export function buildEditorTopLine(
|
||||
theme: Theme,
|
||||
snapshot: SessionSnapshot,
|
||||
config: ResolvedStatuslineConfig,
|
||||
): string {
|
||||
const left: string[] = [];
|
||||
const right: string[] = [];
|
||||
const enabled = new Set(config.footer.sections);
|
||||
|
||||
if (enabled.has("cwd")) {
|
||||
left.push(
|
||||
theme.fg("accent", abbreviatePath(snapshot.cwd, config.footer.pathStyle)),
|
||||
);
|
||||
}
|
||||
if (enabled.has("branch") && config.footer.branch && snapshot.branch) {
|
||||
left.push(theme.fg("success", `(${snapshot.branch})`));
|
||||
}
|
||||
if (enabled.has("model")) {
|
||||
right.push(theme.fg("accent", snapshot.modelName || "no-model"));
|
||||
}
|
||||
if (
|
||||
enabled.has("provider") &&
|
||||
snapshot.provider &&
|
||||
config.footer.providerStyle !== "hidden"
|
||||
) {
|
||||
right[right.length - 1] =
|
||||
config.footer.providerStyle === "text"
|
||||
? `${right[right.length - 1]}${theme.fg("dim", ` provider:${snapshot.provider}`)}`
|
||||
: `${right[right.length - 1]}${theme.fg("dim", ` [${snapshot.provider}]`)}`;
|
||||
}
|
||||
if (enabled.has("thinking") && snapshot.thinkingLevel !== "off") {
|
||||
right.push(
|
||||
theme.fg(thinkingColor(snapshot.thinkingLevel), snapshot.thinkingLevel),
|
||||
);
|
||||
}
|
||||
if (enabled.has("usage") && (snapshot.usage.input || snapshot.usage.output)) {
|
||||
right.push(
|
||||
theme.fg(
|
||||
"dim",
|
||||
`↑${fmt(snapshot.usage.input)} ↓${fmt(snapshot.usage.output)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (enabled.has("cost") && snapshot.usage.cost > 0) {
|
||||
right.push(
|
||||
theme.fg(
|
||||
"warning",
|
||||
`$${snapshot.usage.cost.toFixed(snapshot.usage.cost >= 1 ? 2 : 3)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const leftText = left.join(theme.fg("dim", " "));
|
||||
const rightText = right.join(theme.fg("dim", " | "));
|
||||
if (!rightText) return leftText;
|
||||
return `${leftText}${theme.fg("dim", config.footer.separator)}${rightText}`;
|
||||
}
|
||||
|
||||
export function buildStatusSummary(
|
||||
snapshot: SessionSnapshot,
|
||||
config: ResolvedStatuslineConfig,
|
||||
): string {
|
||||
const values = snapshot.statuses
|
||||
.map(([, text]) => text.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, config.statusSummary.maxItems);
|
||||
|
||||
return values.join(config.statusSummary.separator);
|
||||
}
|
||||
|
||||
function abbreviatePath(pathValue: string, style: "full" | "short"): string {
|
||||
const home = process.env.HOME || "";
|
||||
let normalized = pathValue.replace(/\\/g, "/");
|
||||
if (home && normalized.startsWith(home.replace(/\\/g, "/"))) {
|
||||
normalized = `~${normalized.slice(home.length)}`;
|
||||
}
|
||||
if (style === "full") return normalized;
|
||||
|
||||
const parts = normalized.split("/").filter(Boolean);
|
||||
if (normalized.startsWith("~/") && parts[0] !== "~") parts.unshift("~");
|
||||
if (parts.length <= 3) return normalized;
|
||||
|
||||
const head = parts[0] || "";
|
||||
const tail = parts[parts.length - 1] || "";
|
||||
const middle = parts
|
||||
.slice(1, -1)
|
||||
.map((part) => part[0] || "")
|
||||
.join("/");
|
||||
return `${head}/${middle}/${tail}`;
|
||||
}
|
||||
|
||||
function thinkingColor(
|
||||
level: string,
|
||||
):
|
||||
| "thinkingMinimal"
|
||||
| "thinkingLow"
|
||||
| "thinkingMedium"
|
||||
| "thinkingHigh"
|
||||
| "thinkingXhigh" {
|
||||
switch (level) {
|
||||
case "minimal":
|
||||
return "thinkingMinimal";
|
||||
case "low":
|
||||
return "thinkingLow";
|
||||
case "high":
|
||||
return "thinkingHigh";
|
||||
case "xhigh":
|
||||
return "thinkingXhigh";
|
||||
default:
|
||||
return "thinkingMedium";
|
||||
}
|
||||
}
|
||||
|
||||
function fmt(value: number): string {
|
||||
if (value < 1000) return `${value}`;
|
||||
if (value < 1_000_000)
|
||||
return `${(value / 1000).toFixed(value < 10_000 ? 1 : 0)}k`;
|
||||
return `${(value / 1_000_000).toFixed(1)}M`;
|
||||
}
|
||||
4
packages/wow-statusline/src/global.ts
Normal file
4
packages/wow-statusline/src/global.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { createLogger } from "wow-core";
|
||||
|
||||
export const TAG = "statusline";
|
||||
export const log = createLogger(TAG);
|
||||
82
packages/wow-statusline/src/index.ts
Normal file
82
packages/wow-statusline/src/index.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type {
|
||||
ExtensionAPI,
|
||||
ExtensionContext,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import { registerModule, resetWowConfigCache } from "wow-core";
|
||||
import { getStatuslineConfig } from "./config";
|
||||
import { TAG, log } from "./global";
|
||||
import { createState } from "./state";
|
||||
import {
|
||||
applyStatuslineUI,
|
||||
clearStatuslineUI,
|
||||
updateStatuslineState,
|
||||
} from "./ui";
|
||||
|
||||
const setup = (pi: ExtensionAPI): void => {
|
||||
const state = createState();
|
||||
let lastStreamingRefresh = 0;
|
||||
|
||||
const apply = (ctx: ExtensionContext): void => {
|
||||
resetWowConfigCache();
|
||||
state.currentCtx = ctx;
|
||||
applyStatuslineUI(ctx, state, getStatuslineConfig(ctx.cwd));
|
||||
};
|
||||
|
||||
const refresh = (): void => {
|
||||
updateStatuslineState(state);
|
||||
};
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
state.thinkingLevel = pi.getThinkingLevel();
|
||||
apply(ctx);
|
||||
log.debug("statusline applied", { cwd: ctx.cwd });
|
||||
});
|
||||
|
||||
pi.on("agent_start", async () => {
|
||||
refresh();
|
||||
});
|
||||
|
||||
pi.on("message_update", async () => {
|
||||
const now = Date.now();
|
||||
if (now - lastStreamingRefresh < 80) return;
|
||||
lastStreamingRefresh = now;
|
||||
refresh();
|
||||
});
|
||||
|
||||
pi.on("model_select", async () => {
|
||||
refresh();
|
||||
});
|
||||
|
||||
pi.on("thinking_level_select", async (event) => {
|
||||
state.thinkingLevel = event.level;
|
||||
refresh();
|
||||
});
|
||||
|
||||
pi.on("turn_end", async () => {
|
||||
refresh();
|
||||
});
|
||||
|
||||
pi.on("agent_end", async () => {
|
||||
refresh();
|
||||
});
|
||||
|
||||
pi.on("session_tree", async () => {
|
||||
refresh();
|
||||
});
|
||||
|
||||
pi.on("session_compact", async () => {
|
||||
refresh();
|
||||
});
|
||||
|
||||
pi.on("session_shutdown", async (_event, ctx) => {
|
||||
clearStatuslineUI(ctx, state);
|
||||
state.currentCtx = null;
|
||||
state.requestRender = null;
|
||||
state.footerData = null;
|
||||
state.originalSetFooter = null;
|
||||
state.footerHijacked = false;
|
||||
state.lastRenderSignature = "";
|
||||
});
|
||||
};
|
||||
|
||||
registerModule({ name: TAG, register: setup });
|
||||
104
packages/wow-statusline/src/state.ts
Normal file
104
packages/wow-statusline/src/state.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import type {
|
||||
FooterDataSnapshot,
|
||||
SessionSnapshot,
|
||||
UsageSummary,
|
||||
} from "./types";
|
||||
|
||||
export interface StatuslineState {
|
||||
currentCtx: ExtensionContext | null;
|
||||
thinkingLevel: string;
|
||||
statusEntries: Array<[string, string]>;
|
||||
branch: string;
|
||||
requestRender: (() => void) | null;
|
||||
footerData: FooterDataSnapshot | null;
|
||||
originalSetFooter: ((...args: unknown[]) => unknown) | null;
|
||||
footerHijacked: boolean;
|
||||
lastRenderSignature: string;
|
||||
}
|
||||
|
||||
export function createState(): StatuslineState {
|
||||
return {
|
||||
currentCtx: null,
|
||||
thinkingLevel: "off",
|
||||
statusEntries: [],
|
||||
branch: "",
|
||||
requestRender: null,
|
||||
footerData: null,
|
||||
originalSetFooter: null,
|
||||
footerHijacked: false,
|
||||
lastRenderSignature: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function snapshotFromContext(
|
||||
state: StatuslineState,
|
||||
ctx: ExtensionContext,
|
||||
): SessionSnapshot {
|
||||
return {
|
||||
cwd: ctx.cwd,
|
||||
branch: state.branch,
|
||||
modelName: ctx.model?.name || ctx.model?.id || "no-model",
|
||||
provider: ctx.model?.provider,
|
||||
thinkingLevel: state.thinkingLevel,
|
||||
usage: collectUsage(ctx),
|
||||
statuses: [...state.statusEntries],
|
||||
};
|
||||
}
|
||||
|
||||
export function syncFooterSnapshot(state: StatuslineState): void {
|
||||
if (!state.footerData) return;
|
||||
state.branch = state.footerData.getGitBranch() || "";
|
||||
const statuses = state.footerData.getExtensionStatuses();
|
||||
if (statuses.size === 0) return;
|
||||
state.statusEntries = Array.from(statuses.entries()).sort(([left], [right]) =>
|
||||
left.localeCompare(right),
|
||||
);
|
||||
}
|
||||
|
||||
function collectUsage(ctx: ExtensionContext): UsageSummary {
|
||||
let input = 0;
|
||||
let output = 0;
|
||||
let cost = 0;
|
||||
|
||||
try {
|
||||
for (const entry of ctx.sessionManager.getBranch()) {
|
||||
if (entry.type !== "message" || entry.message.role !== "assistant")
|
||||
continue;
|
||||
const usage = readUsage(entry.message);
|
||||
if (!usage) continue;
|
||||
input += usage.input;
|
||||
output += usage.output;
|
||||
cost += usage.cost;
|
||||
}
|
||||
} catch {
|
||||
// Session access can fail during teardown or special modes.
|
||||
}
|
||||
|
||||
return { input, output, cost };
|
||||
}
|
||||
|
||||
function readUsage(message: unknown): UsageSummary | null {
|
||||
if (!message || typeof message !== "object") return null;
|
||||
const usage = (message as { usage?: unknown }).usage;
|
||||
if (!usage || typeof usage !== "object") return null;
|
||||
|
||||
const input = readNumber((usage as { input?: unknown }).input);
|
||||
const output = readNumber((usage as { output?: unknown }).output);
|
||||
const costValue =
|
||||
usage && typeof usage === "object"
|
||||
? readNumber(
|
||||
(
|
||||
(usage as { cost?: unknown }).cost as
|
||||
| { total?: unknown }
|
||||
| undefined
|
||||
)?.total,
|
||||
)
|
||||
: 0;
|
||||
|
||||
return { input, output, cost: costValue };
|
||||
}
|
||||
|
||||
function readNumber(value: unknown): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
105
packages/wow-statusline/src/statusline-editor.ts
Normal file
105
packages/wow-statusline/src/statusline-editor.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
CustomEditor,
|
||||
type ExtensionContext,
|
||||
type KeybindingsManager,
|
||||
type Theme,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import type { Component, EditorTheme, TUI } from "@earendil-works/pi-tui";
|
||||
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
||||
import { buildEditorTopLine, buildStatusSummary } from "./format";
|
||||
import { snapshotFromContext, type StatuslineState } from "./state";
|
||||
import type { ResolvedStatuslineConfig } from "./types";
|
||||
|
||||
const ANSI_SGR_RE = /\u001b\[[0-9;]*m/g;
|
||||
|
||||
export class EmptyFooter implements Component {
|
||||
render(): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
}
|
||||
|
||||
export class StatuslineEditor extends CustomEditor {
|
||||
private readonly extensionCtx: ExtensionContext;
|
||||
private readonly statuslineState: StatuslineState;
|
||||
private readonly statuslineConfig: ResolvedStatuslineConfig;
|
||||
|
||||
constructor(
|
||||
tui: TUI,
|
||||
theme: EditorTheme,
|
||||
keybindings: KeybindingsManager,
|
||||
ctx: ExtensionContext,
|
||||
state: StatuslineState,
|
||||
config: ResolvedStatuslineConfig,
|
||||
) {
|
||||
super(tui, theme, keybindings, { paddingX: 0 });
|
||||
this.extensionCtx = ctx;
|
||||
this.statuslineState = state;
|
||||
this.statuslineConfig = config;
|
||||
this.statuslineState.requestRender = tui.requestRender.bind(tui);
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const innerWidth = Math.max(1, width - 2);
|
||||
const lines = super.render(innerWidth);
|
||||
if (lines.length < 3) return lines;
|
||||
|
||||
const theme = this.extensionCtx.ui.theme;
|
||||
const snapshot = snapshotFromContext(
|
||||
this.statuslineState,
|
||||
this.extensionCtx,
|
||||
);
|
||||
let bottom = lines.length - 1;
|
||||
for (let index = lines.length - 1; index >= 1; index--) {
|
||||
const plain = lines[index]!.replace(ANSI_SGR_RE, "");
|
||||
if (plain.length > 0 && /^─{3,}/.test(plain)) {
|
||||
bottom = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const topText = buildEditorTopLine(theme, snapshot, this.statuslineConfig);
|
||||
const folder = this.statuslineConfig.editorStyle.folderIcon;
|
||||
const content = `${folder} ${topText}`;
|
||||
const border = theme.fg("borderMuted", "─");
|
||||
const contentWidth = visibleWidth(content);
|
||||
const remaining = width - contentWidth - 2;
|
||||
|
||||
lines[0] =
|
||||
remaining >= 1
|
||||
? content + theme.fg("dim", " ") + border.repeat(remaining) + border
|
||||
: border.repeat(Math.max(0, width));
|
||||
|
||||
if (lines.length > 1) {
|
||||
lines[1] =
|
||||
theme.fg("accent", this.statuslineConfig.editorStyle.prompt) +
|
||||
theme.fg("dim", " ") +
|
||||
(lines[1] ?? "");
|
||||
}
|
||||
|
||||
lines[bottom] = border.repeat(Math.max(0, width));
|
||||
|
||||
for (let index = 0; index < lines.length; index++) {
|
||||
if (visibleWidth(lines[index]!) > width) {
|
||||
lines[index] = truncateToWidth(lines[index]!, width);
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildStatuslineWidget(
|
||||
ctx: ExtensionContext,
|
||||
state: StatuslineState,
|
||||
config: ResolvedStatuslineConfig,
|
||||
): (width: number, theme: Theme) => string[] {
|
||||
return (width, _theme) => {
|
||||
const snapshot = snapshotFromContext(state, ctx);
|
||||
const line = buildStatusSummary(snapshot, config);
|
||||
if (!line) return [];
|
||||
const padded = ` ${line}`;
|
||||
return [truncateToWidth(padded, Math.max(0, width))];
|
||||
};
|
||||
}
|
||||
60
packages/wow-statusline/src/types.ts
Normal file
60
packages/wow-statusline/src/types.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
export type StatuslineSection =
|
||||
| "cwd"
|
||||
| "branch"
|
||||
| "model"
|
||||
| "provider"
|
||||
| "thinking"
|
||||
| "usage"
|
||||
| "cost"
|
||||
| "statuses";
|
||||
|
||||
export type StatuslineConfig = boolean | { enabled?: boolean };
|
||||
|
||||
export interface FooterDataSnapshot {
|
||||
getGitBranch(): string | null;
|
||||
getExtensionStatuses(): ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
export interface ResolvedStatuslineConfig {
|
||||
enabled: boolean;
|
||||
footer: {
|
||||
enabled: boolean;
|
||||
sections: StatuslineSection[];
|
||||
pathStyle: "full" | "short";
|
||||
branch: boolean;
|
||||
providerStyle: "bracket" | "text" | "hidden";
|
||||
separator: string;
|
||||
};
|
||||
indicator: {
|
||||
enabled: boolean;
|
||||
mode: "default" | "dot" | "pulse" | "none";
|
||||
};
|
||||
statusSummary: {
|
||||
enabled: boolean;
|
||||
placement: "aboveEditor" | "belowEditor";
|
||||
maxItems: number;
|
||||
separator: string;
|
||||
};
|
||||
editorStyle: {
|
||||
enabled: boolean;
|
||||
prompt: string;
|
||||
folderIcon: string;
|
||||
indent: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UsageSummary {
|
||||
input: number;
|
||||
output: number;
|
||||
cost: number;
|
||||
}
|
||||
|
||||
export interface SessionSnapshot {
|
||||
cwd: string;
|
||||
branch: string;
|
||||
modelName: string;
|
||||
provider?: string;
|
||||
thinkingLevel: string;
|
||||
usage: UsageSummary;
|
||||
statuses: Array<[string, string]>;
|
||||
}
|
||||
237
packages/wow-statusline/src/ui.ts
Normal file
237
packages/wow-statusline/src/ui.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
||||
import { buildEditorTopLine, buildStatusSummary, EMPTY_USAGE } from "./format";
|
||||
import {
|
||||
EmptyFooter,
|
||||
StatuslineEditor,
|
||||
buildStatuslineWidget,
|
||||
} from "./statusline-editor";
|
||||
import { syncFooterSnapshot, type StatuslineState } from "./state";
|
||||
import type { FooterDataSnapshot, ResolvedStatuslineConfig } from "./types";
|
||||
|
||||
const STATUS_WIDGET_ID = "wow-statusline:summary";
|
||||
const FOOTER_GUARD = "__wowStatuslineAllowFooter";
|
||||
const FOOTER_HIJACKED = "__wowStatuslineFooterHijacked";
|
||||
|
||||
interface FooterDataLike extends FooterDataSnapshot {
|
||||
onBranchChange(listener: () => void): () => void;
|
||||
}
|
||||
|
||||
const SIGNATURE_CONFIG: ResolvedStatuslineConfig = {
|
||||
enabled: true,
|
||||
footer: {
|
||||
enabled: true,
|
||||
sections: [
|
||||
"cwd",
|
||||
"branch",
|
||||
"model",
|
||||
"provider",
|
||||
"thinking",
|
||||
"usage",
|
||||
"cost",
|
||||
],
|
||||
pathStyle: "short",
|
||||
branch: true,
|
||||
providerStyle: "bracket",
|
||||
separator: " · ",
|
||||
},
|
||||
indicator: { enabled: false, mode: "default" },
|
||||
statusSummary: {
|
||||
enabled: true,
|
||||
placement: "belowEditor",
|
||||
maxItems: 4,
|
||||
separator: " | ",
|
||||
},
|
||||
editorStyle: {
|
||||
enabled: true,
|
||||
prompt: "❯",
|
||||
folderIcon: "",
|
||||
indent: " ",
|
||||
},
|
||||
};
|
||||
|
||||
export function applyStatuslineUI(
|
||||
ctx: ExtensionContext,
|
||||
state: StatuslineState,
|
||||
config: ResolvedStatuslineConfig,
|
||||
): void {
|
||||
state.currentCtx = ctx;
|
||||
|
||||
if (!config.enabled) {
|
||||
clearStatuslineUI(ctx, state);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.ui.setWorkingVisible(false);
|
||||
hijackFooter(ctx, state);
|
||||
installEmptyFooter(ctx, state);
|
||||
|
||||
ctx.ui.setWidget(
|
||||
STATUS_WIDGET_ID,
|
||||
(_tui, theme) => ({
|
||||
render(width: number): string[] {
|
||||
const render = buildStatuslineWidget(ctx, state, config);
|
||||
return render(width, theme);
|
||||
},
|
||||
invalidate() {},
|
||||
}),
|
||||
{ placement: "belowEditor" },
|
||||
);
|
||||
|
||||
ctx.ui.setEditorComponent(
|
||||
(tui, theme, keybindings) =>
|
||||
new StatuslineEditor(tui, theme, keybindings, ctx, state, config),
|
||||
);
|
||||
}
|
||||
|
||||
export function updateStatuslineState(state: StatuslineState): void {
|
||||
syncFooterSnapshot(state);
|
||||
if (!state.currentCtx) return;
|
||||
|
||||
const nextSignature = computeRenderSignature(state.currentCtx, state);
|
||||
if (nextSignature === state.lastRenderSignature) return;
|
||||
|
||||
state.lastRenderSignature = nextSignature;
|
||||
state.requestRender?.();
|
||||
}
|
||||
|
||||
export function clearStatuslineUI(
|
||||
ctx: ExtensionContext,
|
||||
state: StatuslineState,
|
||||
): void {
|
||||
restoreFooter(ctx, state);
|
||||
ctx.ui.setWidget(STATUS_WIDGET_ID, undefined, { placement: "belowEditor" });
|
||||
ctx.ui.setWidget(STATUS_WIDGET_ID, undefined, { placement: "aboveEditor" });
|
||||
ctx.ui.setEditorComponent(undefined);
|
||||
ctx.ui.setWorkingVisible(true);
|
||||
ctx.ui.setWorkingIndicator();
|
||||
}
|
||||
|
||||
function hijackFooter(ctx: ExtensionContext, state: StatuslineState): void {
|
||||
const ui = ctx.ui as typeof ctx.ui & {
|
||||
[FOOTER_HIJACKED]?: boolean;
|
||||
[FOOTER_GUARD]?: boolean;
|
||||
setFooter: (...args: unknown[]) => unknown;
|
||||
};
|
||||
|
||||
if (ui[FOOTER_HIJACKED]) {
|
||||
state.footerHijacked = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const original = ui.setFooter.bind(ui);
|
||||
state.originalSetFooter = original;
|
||||
|
||||
ui.setFooter = ((factory: unknown) => {
|
||||
if (ui[FOOTER_GUARD]) {
|
||||
return original(factory as Parameters<typeof original>[0]);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}) as typeof ui.setFooter;
|
||||
|
||||
ui[FOOTER_HIJACKED] = true;
|
||||
state.footerHijacked = true;
|
||||
}
|
||||
|
||||
function installEmptyFooter(
|
||||
ctx: ExtensionContext,
|
||||
state: StatuslineState,
|
||||
): void {
|
||||
const ui = ctx.ui as typeof ctx.ui & {
|
||||
[FOOTER_GUARD]?: boolean;
|
||||
setFooter: (...args: unknown[]) => unknown;
|
||||
};
|
||||
|
||||
ui[FOOTER_GUARD] = true;
|
||||
try {
|
||||
const emptyFactory = (
|
||||
_tui: unknown,
|
||||
theme: Theme,
|
||||
footerData: FooterDataLike,
|
||||
) => {
|
||||
captureFooterData(state, theme, footerData);
|
||||
const dispose = footerData.onBranchChange(() => {
|
||||
state.branch = footerData.getGitBranch() || "";
|
||||
syncFooterSnapshot(state);
|
||||
state.requestRender?.();
|
||||
});
|
||||
return new EmptyFooterWithDispose(dispose);
|
||||
};
|
||||
|
||||
ui.setFooter(emptyFactory as Parameters<typeof ui.setFooter>[0]);
|
||||
} finally {
|
||||
ui[FOOTER_GUARD] = false;
|
||||
}
|
||||
}
|
||||
|
||||
function captureFooterData(
|
||||
state: StatuslineState,
|
||||
_theme: Theme,
|
||||
footerData: FooterDataLike,
|
||||
): void {
|
||||
state.footerData = footerData;
|
||||
syncFooterSnapshot(state);
|
||||
}
|
||||
|
||||
function computeRenderSignature(
|
||||
ctx: ExtensionContext,
|
||||
state: StatuslineState,
|
||||
): string {
|
||||
const snapshot = {
|
||||
cwd: ctx.cwd,
|
||||
branch: state.branch,
|
||||
model: ctx.model?.name || ctx.model?.id || "no-model",
|
||||
provider: ctx.model?.provider || "",
|
||||
thinking: state.thinkingLevel,
|
||||
statuses: state.statusEntries
|
||||
.map(([key, value]) => `${key}:${value}`)
|
||||
.join("\u0001"),
|
||||
};
|
||||
|
||||
const renderSnapshot = {
|
||||
cwd: snapshot.cwd,
|
||||
branch: snapshot.branch,
|
||||
modelName: snapshot.model,
|
||||
provider: snapshot.provider,
|
||||
thinkingLevel: snapshot.thinking,
|
||||
usage: EMPTY_USAGE,
|
||||
statuses: state.statusEntries,
|
||||
};
|
||||
|
||||
const top = buildEditorTopLine(
|
||||
ctx.ui.theme,
|
||||
renderSnapshot,
|
||||
SIGNATURE_CONFIG,
|
||||
);
|
||||
const bottom = buildStatusSummary(renderSnapshot, SIGNATURE_CONFIG);
|
||||
|
||||
return `${top}\u0002${bottom}\u0002${snapshot.statuses}`;
|
||||
}
|
||||
|
||||
function restoreFooter(ctx: ExtensionContext, state: StatuslineState): void {
|
||||
const ui = ctx.ui as typeof ctx.ui & {
|
||||
[FOOTER_HIJACKED]?: boolean;
|
||||
[FOOTER_GUARD]?: boolean;
|
||||
setFooter: (...args: unknown[]) => unknown;
|
||||
};
|
||||
|
||||
if (state.originalSetFooter) {
|
||||
ui.setFooter = state.originalSetFooter as typeof ui.setFooter;
|
||||
}
|
||||
ui[FOOTER_HIJACKED] = false;
|
||||
ui[FOOTER_GUARD] = false;
|
||||
state.originalSetFooter = null;
|
||||
state.footerHijacked = false;
|
||||
state.lastRenderSignature = "";
|
||||
ui.setFooter(undefined);
|
||||
}
|
||||
|
||||
class EmptyFooterWithDispose extends EmptyFooter {
|
||||
constructor(private readonly disposeFn: () => void) {
|
||||
super();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposeFn();
|
||||
}
|
||||
}
|
||||
4
packages/wow-statusline/tsconfig.json
Normal file
4
packages/wow-statusline/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -18,5 +18,7 @@ inject:
|
||||
ANTHROPIC_API_KEY: ${file:~/.secrets/anthropic-key}
|
||||
OPENAI_API_KEY: ${env:OPENAI_API_KEY_BACKUP}
|
||||
|
||||
statusline: true
|
||||
|
||||
logger:
|
||||
level: info
|
||||
|
||||
Reference in New Issue
Block a user