chore: snapshot backup before rainycy push (20260624-032434)

Auto-committed by MiMo for migration to git.rainycy.top
This commit is contained in:
sakuradairong
2026-06-24 03:28:14 +08:00
parent 206c0886a6
commit a81e02dee8
24 changed files with 529 additions and 243 deletions

View File

@@ -2,15 +2,20 @@
## Project Overview
`wow-pi` is a Bun + TypeScript pi extension package that ports selected `wow-omp` features to original pi. It provides two runtime modules: Markdown context injection and environment variable injection.
`wow-pi` is a Bun + TypeScript pi extension package that ports selected `wow-omp` features to original pi. It provides three runtime modules:
- **contexts** — Markdown context injection into the agent system prompt
- **inject** — `.env` file and `inject.env` value loading into `process.env`
- **statusline** — editor status bar with cwd, branch, model, thinking level, usage, and cost
## Architecture & Data Flow
- Root `package.json` declares a private workspace and pi extension entry at `packages/wow-pi/src/index.ts`.
- `packages/wow-pi/src/index.ts` imports `wow-contexts` and `wow-inject` for side-effect module registration, then calls each registered module with pi's `ExtensionAPI`.
- `packages/wow-pi/src/index.ts` imports `wow-contexts`, `wow-inject`, and `wow-statusline` for side-effect module registration, then calls each registered module with pi's `ExtensionAPI`.
- `packages/wow-core` owns shared infrastructure: config loading, module registry, logger, UI rendering, path utilities, and `${protocol:value}` reference resolution.
- `packages/wow-contexts` registers `/wow:contexts:list`, `/wow:contexts:reload`, `/wow:init`, then injects configured Markdown files into `before_agent_start` system prompts.
- `packages/wow-inject` loads `.env` files and `inject.env` values during setup and `session_start`, writing resolved values into `process.env`.
- `packages/wow-inject` loads `.env` files and `inject.env` values during `session_start`, writing resolved values into `process.env`.
- `packages/wow-statusline` registers `/wow:statusline:show`, installs a custom editor (when no other extension has one), a below-editor summary widget, and a polite `setFooter` hijack that captures the host `FooterDataProvider` for git branch and extension statuses.
## Key Directories
@@ -18,8 +23,8 @@
- `packages/wow-core/`: shared config, registry, logger, UI, utilities, and ref resolver.
- `packages/wow-contexts/`: context scanning/building, command handlers, and init prompt.
- `packages/wow-inject/`: `.env` parser and environment injection module.
- `packages/wow-statusline/`: editor footer, statusline widget, render helpers, and command.
- `scripts/`: build and install/uninstall orchestration scripts.
- `docs/contexts/`: subsystem reference cards loaded by wow contexts.
## Development Commands
@@ -52,6 +57,9 @@
- `packages/wow-contexts/src/commands.ts`: `/wow:*` command handlers.
- `packages/wow-contexts/src/init-prompt.ts`: `/wow:init` prompt template.
- `packages/wow-inject/src/env.ts`: environment injection flow.
- `packages/wow-statusline/src/index.ts`: session lifecycle wiring for the editor and widget.
- `packages/wow-statusline/src/ui.ts`: polite `setFooter` hijack and editor/widget installation.
- `packages/wow-statusline/src/config.ts`: statusline config resolver (boolean or partial object).
- `scripts/build.ts`: debug/release build orchestrator.
- `scripts/install.ts`: hard-copy install/uninstall logic.
- `wow.example.yaml`: example global/project wow configuration.
@@ -71,12 +79,5 @@
- `bun run check` is the primary validation command and only runs TypeScript.
- Build validation should include `bun run build` and `bun run build:release` when touching bundling or dependencies.
- Install validation should include `bun run script:install` and a pi smoke command such as `PI_CODING_AGENT_DIR="$HOME/.pi/agent" pi --help`.
- For extension behavior, manually verify `/wow:contexts:list`, `/wow:contexts:reload`, and `/wow:init` in pi.
## Module Contexts (`docs/contexts/`)
- `docs/contexts/wow-pi.md`: extension entry and module orchestration.
- `docs/contexts/wow-core.md`: shared config, registry, logger, UI, and utilities.
- `docs/contexts/wow-contexts.md`: Markdown context scanning, injection, and commands.
- `docs/contexts/wow-inject.md`: `.env` loading and ref-based environment injection.
- `docs/contexts/build-and-install.md`: bundle, install, release, and validation flow.
- For extension behavior, manually verify `/wow:contexts:list`, `/wow:contexts:reload`, `/wow:init`, and `/wow:statusline:show` in pi.
- When another extension also customizes the editor or footer (e.g. `~/.pi/agent/extensions/statusline.ts`), verify that `wow-statusline` defers to it instead of clobbering — run `/wow:statusline:show` and check `editorInstalled` reporting.

View File

@@ -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. For `statusline`, the supported public shape is intentionally minimal: `true`, `false`, or `{ enabled: boolean }`. See `wow.example.yaml` for an example.
Project config extends global config. Array fields are merged and de-duplicated; object fields are shallow-merged. `statusline` accepts a boolean or a partial config object — see `wow.example.yaml` for the full surface.
## Packages

View File

@@ -50,11 +50,22 @@
"wow-contexts": "workspace:*",
"wow-core": "workspace:*",
"wow-inject": "workspace:*",
"wow-statusline": "workspace:*",
},
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*",
},
},
"packages/wow-statusline": {
"name": "wow-statusline",
"dependencies": {
"wow-core": "workspace:*",
},
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*",
"@earendil-works/pi-tui": "*",
},
},
},
"packages": {
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.91.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw=="],
@@ -361,6 +372,8 @@
"wow-pi": ["wow-pi@workspace:packages/wow-pi"],
"wow-statusline": ["wow-statusline@workspace:packages/wow-statusline"],
"wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],

View File

@@ -1,10 +1,4 @@
import {
type ContextFileEntry,
resolveContextPath,
scanDir,
tryRead,
} from "./files";
import { resolvePaths } from "./resolver";
import { type ContextFileEntry, enumerateContextPaths, tryRead } from "./files";
export interface BuildResult {
files: ContextFileEntry[];
@@ -12,35 +6,19 @@ export interface BuildResult {
}
export async function build(cwd: string): Promise<BuildResult> {
const paths = resolvePaths(cwd);
const files: ContextFileEntry[] = [];
const loadedPaths = new Map<string, string>();
for (const entry of paths) {
if (entry.endsWith("/*.md")) {
const dir = resolveContextPath(entry.slice(0, -4), cwd);
const before = files.length;
await scanDir(dir, files);
for (let index = before; index < files.length; index++) {
const file = files[index];
if (file) loadedPaths.set(file.abs, fileHash(file.content));
}
continue;
}
const abs = resolveContextPath(entry, cwd);
const before = files.length;
await tryRead(abs, files);
if (files.length > before) {
const file = files[files.length - 1];
if (file) loadedPaths.set(file.abs, fileHash(file.content));
}
for (const candidate of await enumerateContextPaths(cwd)) {
await tryRead(candidate.abs, files);
}
return {
files: dedupeFiles(files).filter((file) => file.content.length > 0),
loadedPaths,
};
const deduped = dedupeFiles(files).filter((file) => file.content.length > 0);
for (const file of deduped) {
loadedPaths.set(file.abs, fileHash(file.content));
}
return { files: deduped, loadedPaths };
}
export function fileHash(content: string): string {

View File

@@ -1,14 +1,12 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import type {
ExtensionAPI,
ExtensionCommandContext,
} from "@earendil-works/pi-coding-agent";
import { registerWowCommand, show, showInfo, showWarn } from "wow-core";
import { fileHash } from "./builder";
import { resolveContextPath } from "./files";
import { enumerateContextPaths } from "./files";
import { buildInitPrompt } from "./init-prompt";
import { resolvePaths } from "./resolver";
import { contextState } from "./state";
interface FileEntry {
@@ -133,48 +131,7 @@ async function handleInit(
}
async function findCandidateFiles(cwd: string): Promise<FileEntry[]> {
const found: FileEntry[] = [];
for (const entry of resolvePaths(cwd)) {
if (entry.endsWith("/*.md")) {
const dir = resolveContextPath(entry.slice(0, -4), cwd);
const names = await readdirSafe(dir);
for (const name of names.sort()) {
if (!name.endsWith(".md")) continue;
const abs = path.resolve(dir, name);
found.push({ abs, display: path.relative(cwd, abs) });
}
continue;
}
const abs = resolveContextPath(entry, cwd);
if (await existsSafe(abs)) found.push({ abs, display: entry });
}
const deduped: FileEntry[] = [];
const seen = new Set<string>();
for (const file of found) {
if (seen.has(file.abs)) continue;
seen.add(file.abs);
deduped.push(file);
}
return deduped;
}
async function readdirSafe(dir: string): Promise<string[]> {
try {
return await fs.readdir(dir);
} catch {
return [];
}
}
async function existsSafe(filePath: string): Promise<boolean> {
try {
return (await fs.stat(filePath)).isFile();
} catch {
return false;
}
return enumerateContextPaths(cwd);
}
async function computeHash(absPath: string): Promise<string | null> {

View File

@@ -1,12 +1,22 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { resolvePath } from "wow-core";
import { resolvePaths } from "./resolver";
export interface ContextFileEntry {
abs: string;
content: string;
}
export interface ContextCandidate {
abs: string;
/**
* Path suitable for display. For glob hits this is the project-relative
* path; for single-file entries it is the original spec the user wrote.
*/
display: string;
}
export function resolveContextPath(input: string, cwd: string): string {
return resolvePath(input, cwd);
}
@@ -41,3 +51,57 @@ export async function tryRead(
// Missing/unreadable context files are ignored.
}
}
/**
* Enumerate every file referenced by the configured context paths. The result
* is the union of every `*.md` inside a glob directory and every single-file
* spec; duplicate absolute paths are removed. This is the shared scan used by
* both the runtime builder (which then reads content) and the
* `/wow:contexts:list` command (which only needs existence).
*/
export async function enumerateContextPaths(
cwd: string,
): Promise<ContextCandidate[]> {
const found: ContextCandidate[] = [];
for (const entry of resolvePaths(cwd)) {
if (entry.endsWith("/*.md")) {
const dir = resolveContextPath(entry.slice(0, -4), cwd);
const names = await readdirSafe(dir);
for (const name of names.sort()) {
if (!name.endsWith(".md")) continue;
const abs = path.resolve(dir, name);
found.push({ abs, display: path.relative(cwd, abs) });
}
continue;
}
const abs = resolveContextPath(entry, cwd);
if (await existsSafe(abs)) found.push({ abs, display: entry });
}
const deduped: ContextCandidate[] = [];
const seen = new Set<string>();
for (const file of found) {
if (seen.has(file.abs)) continue;
seen.add(file.abs);
deduped.push(file);
}
return deduped;
}
async function readdirSafe(dir: string): Promise<string[]> {
try {
return await fs.readdir(dir);
} catch {
return [];
}
}
async function existsSafe(filePath: string): Promise<boolean> {
try {
return (await fs.stat(filePath)).isFile();
} catch {
return false;
}
}

View File

@@ -33,7 +33,7 @@ const setup: ModuleSetup = (pi) => {
}
const loadedContextFiles = event.systemPromptOptions.contextFiles ?? [];
if (overlapsLoadedContext(buildResult, loadedContextFiles)) {
if (overlapsLoadedContext(buildResult, loadedContextFiles, ctx.cwd)) {
buildResult = await rebuild(ctx.cwd);
}
@@ -47,6 +47,7 @@ const setup: ModuleSetup = (pi) => {
event.systemPrompt,
buildResult,
loadedContextFiles,
ctx.cwd,
);
log.debug("system prompt after context injection", { systemPrompt });

View File

@@ -1,3 +1,4 @@
import * as path from "node:path";
import type { BuildResult } from "./builder";
const PROJECT_CONTEXT_CLOSE = "</project_context>";
@@ -8,10 +9,11 @@ export function mergeProjectContext(
systemPrompt: string,
result: BuildResult,
loadedContextFiles: LoadedContextFile[],
cwd: string,
): string {
let prompt = systemPrompt;
const pendingInstructions: string[] = [];
const loadedPaths = new Set(loadedContextFiles.map((file) => file.path));
const loadedPaths = absolutisePaths(loadedContextFiles, cwd);
for (const file of result.files) {
const instructions = formatProjectInstructions(file.abs, file.content);
@@ -31,13 +33,34 @@ export function mergeProjectContext(
export function overlapsLoadedContext(
result: BuildResult,
loadedContextFiles: LoadedContextFile[],
cwd: string,
): boolean {
if (loadedContextFiles.length === 0) return false;
const loadedPaths = new Set(loadedContextFiles.map((file) => file.path));
const loadedPaths = absolutisePaths(loadedContextFiles, cwd);
return result.files.some((file) => loadedPaths.has(file.abs));
}
/**
* Normalise host-supplied context file paths to absolute so the equality
* check against `BuildResult.files[].abs` works regardless of whether the
* host emitted absolute paths, repo-relative paths, or `./foo` style.
*/
function absolutisePaths(
loadedContextFiles: LoadedContextFile[],
cwd: string,
): Set<string> {
return new Set(
loadedContextFiles.map((file) => {
try {
return path.resolve(cwd, file.path);
} catch {
return file.path;
}
}),
);
}
function appendProjectInstructions(
systemPrompt: string,
instructions: string,

View File

@@ -142,7 +142,14 @@ function mergeEnvConfig(
return Object.keys(merged).length > 0 ? merged : undefined;
}
function normaliseEnvMap(input: unknown): Record<string, string> {
/**
* Coerce a YAML/JSON `env` config value into a flat `Record<string, string>`.
* Accepts either a single object (`{ KEY: "value" }`) or an array of objects
* (`[{ KEY: "value" }]`) for layering; non-string values are dropped.
* Exported so feature packages (e.g. `wow-inject`) can share the same
* coercion rules instead of duplicating them.
*/
export function normaliseEnvMap(input: unknown): Record<string, string> {
if (!input) return {};
const entries = Array.isArray(input)
? Object.assign(

View File

@@ -1,7 +1,7 @@
export * from "./commands";
export * from "./config";
export * from "./logger";
export * from "./path";
export * from "./registry";
export * from "./ui";
export * from "./utils";
export * from "./utils/ref-resolver";

View File

@@ -1,12 +1,14 @@
import { homedir } from "node:os";
import * as path from "node:path";
/** Expand a leading `~` to the current user's home directory. */
export function expandHome(input: string): string {
if (input === "~") return homedir();
if (input.startsWith("~/")) return path.join(homedir(), input.slice(2));
return input;
}
/** Resolve a path against a cwd, expanding `~` first. */
export function resolvePath(input: string, cwd = process.cwd()): string {
return path.resolve(cwd, expandHome(input));
}

View File

@@ -1,6 +1,6 @@
import * as fs from "node:fs";
import type { TaggedLogger } from "../logger";
import { expandHome } from ".";
import { expandHome } from "../path";
export interface KeyResolver {
protocol: string;
@@ -13,20 +13,33 @@ export function registerKeyResolver(resolver: KeyResolver): void {
resolvers.push(resolver);
}
registerKeyResolver({
protocol: "file",
resolve(value: string): string | undefined {
const content = fs.readFileSync(expandHome(value), "utf-8").trim();
return content.length > 0 ? content : undefined;
},
});
let initialised = false;
registerKeyResolver({
protocol: "env",
resolve(value: string): string | undefined {
return process.env[value];
},
});
/**
* Register the built-in resolvers. Idempotent — safe to call multiple times,
* but the side-effect is only run once. The `wow-pi` extension entry point
* should call this during setup so the order of resolver registration does
* not depend on module import order or tree-shaking decisions.
*/
export function initResolvers(): void {
if (initialised) return;
initialised = true;
registerKeyResolver({
protocol: "file",
resolve(value: string): string | undefined {
const content = fs.readFileSync(expandHome(value), "utf-8").trim();
return content.length > 0 ? content : undefined;
},
});
registerKeyResolver({
protocol: "env",
resolve(value: string): string | undefined {
return process.env[value];
},
});
}
const REF_RE = /^\$\{([a-zA-Z_][a-zA-Z0-9_]*):/;

View File

@@ -1,7 +1,7 @@
import type { WowConfig } from "wow-core";
import { getWowSettingSync, resolveRef } from "wow-core";
import { getWowSettingSync, normaliseEnvMap, resolveRef } from "wow-core";
import { loadEnvFile } from "./env-file";
import { log, resolvedEnv } from "./global";
import { log } from "./global";
interface InjectStats {
envFilesLoaded: number;
@@ -53,7 +53,6 @@ export function injectProcessEnv(cwd = process.cwd()): InjectStats {
}
process.env[name] = resolved;
resolvedEnv.set(name, resolved);
stats.envVarsLoaded++;
}
@@ -63,26 +62,3 @@ export function injectProcessEnv(cwd = process.cwd()): InjectStats {
return stats;
}
function normaliseEnvMap(input: unknown): Record<string, string> {
if (!input) return {};
const entries = Array.isArray(input)
? Object.assign(
{},
...input.filter(
(item) => item && typeof item === "object" && !Array.isArray(item),
),
)
: input;
if (!entries || typeof entries !== "object" || Array.isArray(entries))
return {};
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(entries)) {
if (typeof value === "string" || value instanceof String) {
result[key] = String(value);
}
}
return result;
}

View File

@@ -2,4 +2,3 @@ import { createLogger } from "wow-core";
export const TAG = "inject";
export const log = createLogger(TAG);
export const resolvedEnv = new Map<string, string>();

View File

@@ -8,20 +8,19 @@ import { injectProcessEnv } from "./env";
import { log, TAG } from "./global";
const setup: ModuleSetup = (pi) => {
const load = (cwd = process.cwd()) => {
// Defer env injection until session_start so we always read the project
// cwd (`ctx.cwd`), not the cwd of the pi process at extension-load time.
// Loading at setup would resolve relative env paths from the wrong
// directory and any keys it set would be sticky in `process.env` against
// the later project-cwd load (`overrideExisting: false` skips them).
pi.on("session_start", (_event, ctx) => {
resetWowConfigCache();
const cfg = getWowSettingSync<WowConfig["inject"]>(TAG, cwd);
const cfg = getWowSettingSync<WowConfig["inject"]>(TAG, ctx.cwd);
if (cfg?.enabled === false) {
log.debug("inject disabled — skipping module");
return;
}
injectProcessEnv(cwd);
};
load();
pi.on("session_start", (_event, ctx) => {
load(ctx.cwd);
injectProcessEnv(ctx.cwd);
});
};

View File

@@ -7,18 +7,30 @@ import {
createLogger,
getModules,
initLogger,
initResolvers,
resetWowConfigCache,
} from "wow-core";
export default async function wowPi(pi: ExtensionAPI): Promise<void> {
resetWowConfigCache();
initResolvers();
initLogger();
const log = createLogger("main");
for (const mod of getModules()) {
await mod.register(pi);
log.debug("module registered", { module: mod.name });
const modules = getModules();
for (const mod of modules) {
try {
await mod.register(pi);
log.debug("module registered", { module: mod.name });
} catch (error) {
// A misbehaving module must not stop the others from loading —
// surface the failure but continue the registration loop.
log.error("module registration failed", {
module: mod.name,
error: String(error),
});
}
}
log.info("wow-pi loaded", { modules: getModules().map((mod) => mod.name) });
log.info("wow-pi loaded", { modules: modules.map((mod) => mod.name) });
}

View File

@@ -0,0 +1,63 @@
import type {
ExtensionAPI,
ExtensionCommandContext,
} from "@earendil-works/pi-coding-agent";
import { registerWowCommand, show } from "wow-core";
import { getStatuslineConfig } from "./config";
import { TAG } from "./global";
import { createState } from "./state";
export function registerCommands(pi: ExtensionAPI): void {
registerWowCommand(pi, "statusline:show", {
description: "Show wow-statusline current enabled state and config",
handler: handleShow,
});
}
async function handleShow(
_args: string,
ctx: ExtensionCommandContext,
): Promise<void> {
const state = createState();
const config = getStatuslineConfig(ctx.cwd);
const sections = config.footer.sections.join(", ") || "(none)";
show(ctx, "info", "wow-statusline", {
type: "list",
sections: [
{
summary: `● module: ${TAG}`,
items: [
`enabled: ${config.enabled ? "yes" : "no"}`,
`current ctx: ${state.currentCtx ? "ready" : "(not initialized)"}`,
],
},
{
summary: "● footer",
items: [
`enabled: ${config.footer.enabled ? "yes" : "no"}`,
`sections: ${sections}`,
`pathStyle: ${config.footer.pathStyle}`,
`providerStyle: ${config.footer.providerStyle}`,
`separator: ${config.footer.separator}`,
],
},
{
summary: "● status summary",
items: [
`enabled: ${config.statusSummary.enabled ? "yes" : "no"}`,
`placement: ${config.statusSummary.placement}`,
`maxItems: ${config.statusSummary.maxItems}`,
],
},
{
summary: "● editor style",
items: [
`enabled: ${config.editorStyle.enabled ? "yes" : "no"}`,
`prompt: ${config.editorStyle.prompt}`,
`folderIcon: ${config.editorStyle.folderIcon}`,
],
},
],
});
}

View File

@@ -15,10 +15,8 @@ const DEFAULT_SECTIONS: StatuslineSection[] = [
"cost",
];
const BASE_STATUSLINE_CONFIG: Omit<
ResolvedStatuslineConfig,
"enabled" | "editorStyle"
> = {
const DEFAULT_CONFIG: ResolvedStatuslineConfig = {
enabled: true,
footer: {
enabled: true,
sections: DEFAULT_SECTIONS,
@@ -37,33 +35,44 @@ const BASE_STATUSLINE_CONFIG: Omit<
maxItems: 4,
separator: " | ",
},
editorStyle: {
enabled: true,
prompt: "",
folderIcon: hasNerdFonts() ? "" : "📁",
indent: " ",
},
};
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: " ",
},
};
return resolveStatuslineConfig(raw);
}
function resolveEnabled(value: StatuslineConfig | undefined): boolean {
if (typeof value === "boolean") return value;
if (value && typeof value === "object") return value.enabled !== false;
return true;
/**
* Normalize a user-supplied `StatuslineConfig` into a complete
* `ResolvedStatuslineConfig`. User values are shallow-merged over the
* defaults; `false` at the top level disables the module; `true` or any
* object form enables it (object lets the user opt in/out specific groups).
*/
export function resolveStatuslineConfig(
raw: StatuslineConfig | undefined,
): ResolvedStatuslineConfig {
if (raw === false) {
return { ...DEFAULT_CONFIG, enabled: false };
}
const user = typeof raw === "object" && raw !== null ? raw : {};
return {
enabled: user.enabled ?? true,
footer: { ...DEFAULT_CONFIG.footer, ...(user.footer ?? {}) },
indicator: { ...DEFAULT_CONFIG.indicator, ...(user.indicator ?? {}) },
statusSummary: {
...DEFAULT_CONFIG.statusSummary,
...(user.statusSummary ?? {}),
},
editorStyle: { ...DEFAULT_CONFIG.editorStyle, ...(user.editorStyle ?? {}) },
};
}
function hasNerdFonts(): boolean {

View File

@@ -1,8 +1,6 @@
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,

View File

@@ -3,6 +3,7 @@ import type {
ExtensionContext,
} from "@earendil-works/pi-coding-agent";
import { registerModule, resetWowConfigCache } from "wow-core";
import { registerCommands } from "./commands";
import { getStatuslineConfig } from "./config";
import { log, TAG } from "./global";
import { createState } from "./state";
@@ -12,12 +13,16 @@ import {
updateStatuslineState,
} from "./ui";
// Throttle streaming refresh to ~12.5 FPS so high-churn `message_update`
// events do not flood the TUI re-render loop.
const STREAMING_REFRESH_INTERVAL_MS = 80;
const setup = (pi: ExtensionAPI): void => {
const state = createState();
let lastStreamingRefresh = 0;
registerCommands(pi);
const apply = (ctx: ExtensionContext): void => {
resetWowConfigCache();
state.currentCtx = ctx;
@@ -47,7 +52,12 @@ const setup = (pi: ExtensionAPI): void => {
refresh();
});
pi.on("model_select", refreshOnEvent);
pi.on("model_select", async () => {
// New model may not support the previous thinking level (pi clamps
// internally); re-read so the footer reflects the effective level.
state.thinkingLevel = pi.getThinkingLevel();
refresh();
});
pi.on("thinking_level_select", async (event) => {
state.thinkingLevel = event.level;
@@ -67,6 +77,8 @@ const setup = (pi: ExtensionAPI): void => {
state.originalSetFooter = null;
state.footerHijacked = false;
state.lastRenderSignature = "";
state.nextTickResetScheduled = false;
state.editorInstalled = false;
});
};

View File

@@ -15,6 +15,26 @@ export interface StatuslineState {
originalSetFooter: ((...args: unknown[]) => unknown) | null;
footerHijacked: boolean;
lastRenderSignature: string;
/**
* Cached token usage summary. Refreshed by `updateStatuslineState` before
* each signature check; the cached value is what `snapshotFromContext`
* returns to the renderer, so display does not re-walk the session branch.
*/
cachedUsage: UsageSummary;
/** Leaf id at the time `cachedUsage` was computed. */
cachedUsageLeafId: string;
/**
* Coalescing flag for the nextTick that re-asserts our empty footer after
* another extension calls `setFooter`. Prevents microtask ping-pong when
* multiple extensions compete for the slot.
*/
nextTickResetScheduled: boolean;
/**
* Whether we actually installed our custom editor component. When another
* extension's editor is already set, we defer and leave this false so
* `clearStatuslineUI` does not clobber the other extension.
*/
editorInstalled: boolean;
}
export function createState(): StatuslineState {
@@ -28,6 +48,10 @@ export function createState(): StatuslineState {
originalSetFooter: null,
footerHijacked: false,
lastRenderSignature: "",
cachedUsage: { input: 0, output: 0, cost: 0 },
cachedUsageLeafId: "",
nextTickResetScheduled: false,
editorInstalled: false,
};
}
@@ -41,11 +65,27 @@ export function snapshotFromContext(
modelName: ctx.model?.name || ctx.model?.id || "no-model",
provider: ctx.model?.provider,
thinkingLevel: state.thinkingLevel,
usage: collectUsage(ctx),
usage: state.cachedUsage,
statuses: [...state.statusEntries],
};
}
/**
* Refresh `state.cachedUsage` when the session leaf changes. Returns true if
* usage was recomputed, so callers can decide whether to also recompute the
* render signature.
*/
export function refreshUsageCache(
state: StatuslineState,
ctx: ExtensionContext,
): boolean {
const leafId = readLeafId(ctx);
if (leafId === state.cachedUsageLeafId) return false;
state.cachedUsageLeafId = leafId;
state.cachedUsage = collectUsage(ctx);
return true;
}
export function syncFooterSnapshot(state: StatuslineState): void {
if (!state.footerData) return;
state.branch = state.footerData.getGitBranch() || "";
@@ -56,6 +96,14 @@ export function syncFooterSnapshot(state: StatuslineState): void {
);
}
function readLeafId(ctx: ExtensionContext): string {
try {
return ctx.sessionManager.getLeafId() ?? "";
} catch {
return "";
}
}
function collectUsage(ctx: ExtensionContext): UsageSummary {
let input = 0;
let output = 0;

View File

@@ -8,7 +8,15 @@ export type StatuslineSection =
| "cost"
| "statuses";
export type StatuslineConfig = boolean | { enabled?: boolean };
export type StatuslineConfig =
| boolean
| {
enabled?: boolean;
footer?: Partial<ResolvedStatuslineConfig["footer"]>;
indicator?: Partial<ResolvedStatuslineConfig["indicator"]>;
statusSummary?: Partial<ResolvedStatuslineConfig["statusSummary"]>;
editorStyle?: Partial<ResolvedStatuslineConfig["editorStyle"]>;
};
export interface FooterDataSnapshot {
getGitBranch(): string | null;

View File

@@ -1,6 +1,12 @@
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
import { buildEditorTopLine, buildStatusSummary, EMPTY_USAGE } from "./format";
import { type StatuslineState, syncFooterSnapshot } from "./state";
import { buildEditorTopLine, buildStatusSummary } from "./format";
import { log } from "./global";
import {
type StatuslineState,
refreshUsageCache,
snapshotFromContext,
syncFooterSnapshot,
} from "./state";
import {
buildStatuslineWidget,
EmptyFooter,
@@ -12,10 +18,26 @@ const STATUS_WIDGET_ID = "wow-statusline:summary";
const FOOTER_GUARD = "__wowStatuslineAllowFooter";
const FOOTER_HIJACKED = "__wowStatuslineFooterHijacked";
interface HijackedUi {
[FOOTER_HIJACKED]?: boolean;
[FOOTER_GUARD]?: boolean;
setFooter: SetFooter;
}
interface FooterDataLike extends FooterDataSnapshot {
onBranchChange(listener: () => void): () => void;
}
type SetFooter = (
factory:
| ((
_tui: unknown,
theme: Theme,
footerData: FooterDataLike,
) => EmptyFooterWithDispose)
| undefined,
) => void;
const SIGNATURE_CONFIG: ResolvedStatuslineConfig = {
enabled: true,
footer: {
@@ -71,6 +93,8 @@ export function updateStatuslineState(state: StatuslineState): void {
syncFooterSnapshot(state);
if (!state.currentCtx) return;
refreshUsageCache(state, state.currentCtx);
const nextSignature = computeRenderSignature(state.currentCtx, state);
if (nextSignature === state.lastRenderSignature) return;
@@ -85,36 +109,114 @@ export function clearStatuslineUI(
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);
if (state.editorInstalled) {
ctx.ui.setEditorComponent(undefined);
state.editorInstalled = false;
}
}
/**
* Polite footer hijack.
*
* The host's `setFooter` is a single-slot API. Multiple extensions that want
* to suppress the built-in footer (e.g. to install their own custom editor)
* must coordinate. We install a wrapper that:
*
* - Recognises our own calls via the per-UI `__wowStatuslineAllowFooter`
* guard and passes them through to the original.
* - Recognises the same guard for the known third-party
* `~/.pi/agent/extensions/statusline.ts` (which uses `__sfAllow`) and
* passes those through too, so a third-party nextTick reset is not
* double-wrapped.
* - For any other caller (typically another extension's `setFooter` call),
* forwards the factory wrapped so the host still calls it (so the other
* extension's data is captured), and schedules a single coalesced
* `process.nextTick` to re-assert our empty footer.
*
* The result: when several extensions compete for the footer slot, the last
* one to install a hijack takes priority; all participants still get a
* `FooterDataProvider` via their own empty factory.
*/
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;
};
const ui = ctx.ui as unknown as HijackedUi;
if (ui[FOOTER_HIJACKED]) {
state.footerHijacked = true;
return;
}
const original = ui.setFooter.bind(ui);
state.originalSetFooter = original;
const original = ui.setFooter.bind(ui) as SetFooter;
state.originalSetFooter = original as unknown as (
...args: unknown[]
) => unknown;
ui.setFooter = ((factory: unknown) => {
const wrapped: SetFooter = (factory) => {
// Our own setFooter call: pass through to whatever was installed
// before us (could be host original, could be another extension's
// wrapper).
if (ui[FOOTER_GUARD]) {
return original(factory as Parameters<typeof original>[0]);
original(factory);
return;
}
// Third-party `statusline.ts` uses `globalThis.__sfAllow` to mark its
// own calls. Recognise it so its nextTick re-set is not double-wrapped.
if ((globalThis as { __sfAllow?: boolean }).__sfAllow) {
original(factory);
return;
}
// Other extension's setFooter: let their factory actually run (so they
// capture the FooterDataProvider) but return our empty Component so
// the host shows nothing instead of their content. Then schedule a
// single coalesced nextTick to re-assert our empty factory as the
// canonical slot owner.
if (factory) {
original(((_tui: unknown, theme: Theme, footerData: FooterDataLike) => {
captureFooterData(state, theme, footerData);
scheduleEmptyFooterReset(ui, state);
return new EmptyFooterWithDispose(
footerData.onBranchChange(() => {
syncFooterSnapshot(state);
state.requestRender?.();
}),
);
}) as Parameters<SetFooter>[0]);
}
};
return undefined;
}) as typeof ui.setFooter;
ui.setFooter = wrapped as typeof ui.setFooter;
ui[FOOTER_HIJACKED] = true;
state.footerHijacked = true;
}
/**
* Coalesce repeated nextTick resets. Without this, several other extensions
* calling `setFooter` in quick succession would each enqueue a microtask.
*/
function scheduleEmptyFooterReset(
ui: HijackedUi,
state: StatuslineState,
): void {
if (state.nextTickResetScheduled) return;
state.nextTickResetScheduled = true;
process.nextTick(() => {
state.nextTickResetScheduled = false;
if (!ui[FOOTER_HIJACKED]) return;
installEmptyFooterWithGuard(ui, state);
});
}
function installEmptyFooterWithGuard(
ui: HijackedUi,
state: StatuslineState,
): void {
ui[FOOTER_GUARD] = true;
try {
ui.setFooter(makeEmptyFactory(state));
} finally {
ui[FOOTER_GUARD] = false;
}
}
function installStatusWidget(
ctx: ExtensionContext,
state: StatuslineState,
@@ -138,40 +240,47 @@ function installEditor(
state: StatuslineState,
config: ResolvedStatuslineConfig,
): void {
// Defer if another extension has already set a custom editor. The host
// has a single-slot editor — replacing theirs would clobber their
// keybindings and visual design. We still keep the widget + footer
// hijack; the user gets the other extension's editor plus our
// below-editor summary line.
if (ctx.ui.getEditorComponent()) {
log.debug("deferring editor install: another extension already set one", {
cwd: ctx.cwd,
});
return;
}
ctx.ui.setEditorComponent(
(tui, theme, keybindings) =>
new StatuslineEditor(tui, theme, keybindings, ctx, state, config),
);
state.editorInstalled = true;
}
function installEmptyFooter(
ctx: ExtensionContext,
state: StatuslineState,
): void {
const ui = ctx.ui as typeof ctx.ui & {
[FOOTER_GUARD]?: boolean;
setFooter: (...args: unknown[]) => unknown;
};
installEmptyFooterWithGuard(ctx.ui as unknown as HijackedUi, state);
}
ui[FOOTER_GUARD] = true;
try {
const emptyFactory = (
_tui: unknown,
theme: Theme,
footerData: FooterDataLike,
) => {
captureFooterData(state, theme, footerData);
const dispose = footerData.onBranchChange(() => {
function makeEmptyFactory(
state: StatuslineState,
): (
_tui: unknown,
theme: Theme,
footerData: FooterDataLike,
) => EmptyFooterWithDispose {
return (_tui, theme, footerData) => {
captureFooterData(state, theme, footerData);
return new EmptyFooterWithDispose(
footerData.onBranchChange(() => {
syncFooterSnapshot(state);
state.requestRender?.();
});
return new EmptyFooterWithDispose(dispose);
};
ui.setFooter(emptyFactory as Parameters<typeof ui.setFooter>[0]);
} finally {
ui[FOOTER_GUARD] = false;
}
}),
);
};
}
function captureFooterData(
@@ -187,35 +296,11 @@ 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}`;
const snapshot = snapshotFromContext(state, ctx);
const top = buildEditorTopLine(ctx.ui.theme, snapshot, SIGNATURE_CONFIG);
const bottom = buildStatusSummary(snapshot, SIGNATURE_CONFIG);
const usageKey = `${state.cachedUsage.input}-${state.cachedUsage.output}-${state.cachedUsage.cost}`;
return `${top}\u0002${bottom}\u0002${usageKey}`;
}
function restoreFooter(ctx: ExtensionContext, state: StatuslineState): void {
@@ -233,6 +318,7 @@ function restoreFooter(ctx: ExtensionContext, state: StatuslineState): void {
state.originalSetFooter = null;
state.footerHijacked = false;
state.lastRenderSignature = "";
state.nextTickResetScheduled = false;
ui.setFooter(undefined);
}

View File

@@ -18,7 +18,24 @@ inject:
ANTHROPIC_API_KEY: ${file:~/.secrets/anthropic-key}
OPENAI_API_KEY: ${env:OPENAI_API_KEY_BACKUP}
statusline: true
statusline:
enabled: true
footer:
sections: [cwd, branch, model, provider, thinking, usage, cost]
pathStyle: short
branch: true
providerStyle: bracket
separator: " · "
statusSummary:
enabled: true
placement: belowEditor
maxItems: 4
separator: " | "
editorStyle:
enabled: true
prompt: ""
folderIcon: "📁"
indent: " "
logger:
level: info