From 9f95a34c182087c2de902e56c56dcc51f1aa2d8a Mon Sep 17 00:00:00 2001 From: kmou424 Date: Wed, 17 Jun 2026 00:23:35 +0800 Subject: [PATCH] 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. --- README.md | 3 +- packages/wow-core/src/config.ts | 1 + packages/wow-pi/package.json | 3 +- packages/wow-pi/src/index.ts | 1 + packages/wow-statusline/package.json | 17 ++ packages/wow-statusline/src/config.ts | 74 ++++++ packages/wow-statusline/src/format.ts | 124 +++++++++ packages/wow-statusline/src/global.ts | 4 + packages/wow-statusline/src/index.ts | 82 ++++++ packages/wow-statusline/src/state.ts | 104 ++++++++ .../wow-statusline/src/statusline-editor.ts | 105 ++++++++ packages/wow-statusline/src/types.ts | 60 +++++ packages/wow-statusline/src/ui.ts | 237 ++++++++++++++++++ packages/wow-statusline/tsconfig.json | 4 + wow.example.yaml | 2 + 15 files changed, 819 insertions(+), 2 deletions(-) create mode 100644 packages/wow-statusline/package.json create mode 100644 packages/wow-statusline/src/config.ts create mode 100644 packages/wow-statusline/src/format.ts create mode 100644 packages/wow-statusline/src/global.ts create mode 100644 packages/wow-statusline/src/index.ts create mode 100644 packages/wow-statusline/src/state.ts create mode 100644 packages/wow-statusline/src/statusline-editor.ts create mode 100644 packages/wow-statusline/src/types.ts create mode 100644 packages/wow-statusline/src/ui.ts create mode 100644 packages/wow-statusline/tsconfig.json diff --git a/README.md b/README.md index 820e4ee..5d2dc69 100644 --- a/README.md +++ b/README.md @@ -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. `/.pi/wow.json`, `/.pi/wow.yaml`, `/.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/`. diff --git a/packages/wow-core/src/config.ts b/packages/wow-core/src/config.ts index 119ce4a..4ee21dc 100644 --- a/packages/wow-core/src/config.ts +++ b/packages/wow-core/src/config.ts @@ -12,6 +12,7 @@ export interface WowConfig { env?: Record | Array>; overrideExisting?: boolean; }; + statusline?: boolean | { enabled?: boolean }; [key: string]: unknown; } diff --git a/packages/wow-pi/package.json b/packages/wow-pi/package.json index fc06395..047d73b 100644 --- a/packages/wow-pi/package.json +++ b/packages/wow-pi/package.json @@ -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": "*" diff --git a/packages/wow-pi/src/index.ts b/packages/wow-pi/src/index.ts index fdb67e9..b2fd0b7 100644 --- a/packages/wow-pi/src/index.ts +++ b/packages/wow-pi/src/index.ts @@ -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, diff --git a/packages/wow-statusline/package.json b/packages/wow-statusline/package.json new file mode 100644 index 0000000..ef5b972 --- /dev/null +++ b/packages/wow-statusline/package.json @@ -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": "*" + } +} diff --git a/packages/wow-statusline/src/config.ts b/packages/wow-statusline/src/config.ts new file mode 100644 index 0000000..cccdc07 --- /dev/null +++ b/packages/wow-statusline/src/config.ts @@ -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("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); +} diff --git a/packages/wow-statusline/src/format.ts b/packages/wow-statusline/src/format.ts new file mode 100644 index 0000000..84d1314 --- /dev/null +++ b/packages/wow-statusline/src/format.ts @@ -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`; +} diff --git a/packages/wow-statusline/src/global.ts b/packages/wow-statusline/src/global.ts new file mode 100644 index 0000000..ffdde8d --- /dev/null +++ b/packages/wow-statusline/src/global.ts @@ -0,0 +1,4 @@ +import { createLogger } from "wow-core"; + +export const TAG = "statusline"; +export const log = createLogger(TAG); diff --git a/packages/wow-statusline/src/index.ts b/packages/wow-statusline/src/index.ts new file mode 100644 index 0000000..73f297c --- /dev/null +++ b/packages/wow-statusline/src/index.ts @@ -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 }); diff --git a/packages/wow-statusline/src/state.ts b/packages/wow-statusline/src/state.ts new file mode 100644 index 0000000..8442c71 --- /dev/null +++ b/packages/wow-statusline/src/state.ts @@ -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; +} diff --git a/packages/wow-statusline/src/statusline-editor.ts b/packages/wow-statusline/src/statusline-editor.ts new file mode 100644 index 0000000..46fc998 --- /dev/null +++ b/packages/wow-statusline/src/statusline-editor.ts @@ -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))]; + }; +} diff --git a/packages/wow-statusline/src/types.ts b/packages/wow-statusline/src/types.ts new file mode 100644 index 0000000..9d2e8bd --- /dev/null +++ b/packages/wow-statusline/src/types.ts @@ -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; +} + +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]>; +} diff --git a/packages/wow-statusline/src/ui.ts b/packages/wow-statusline/src/ui.ts new file mode 100644 index 0000000..5ede06c --- /dev/null +++ b/packages/wow-statusline/src/ui.ts @@ -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[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[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(); + } +} diff --git a/packages/wow-statusline/tsconfig.json b/packages/wow-statusline/tsconfig.json new file mode 100644 index 0000000..564a599 --- /dev/null +++ b/packages/wow-statusline/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src"] +} diff --git a/wow.example.yaml b/wow.example.yaml index 9ba4920..d4154ba 100644 --- a/wow.example.yaml +++ b/wow.example.yaml @@ -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