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 ## 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 ## Architecture & Data Flow
- Root `package.json` declares a private workspace and pi extension entry at `packages/wow-pi/src/index.ts`. - 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-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-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 ## Key Directories
@@ -18,8 +23,8 @@
- `packages/wow-core/`: shared config, registry, logger, UI, utilities, and ref resolver. - `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-contexts/`: context scanning/building, command handlers, and init prompt.
- `packages/wow-inject/`: `.env` parser and environment injection module. - `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. - `scripts/`: build and install/uninstall orchestration scripts.
- `docs/contexts/`: subsystem reference cards loaded by wow contexts.
## Development Commands ## Development Commands
@@ -52,6 +57,9 @@
- `packages/wow-contexts/src/commands.ts`: `/wow:*` command handlers. - `packages/wow-contexts/src/commands.ts`: `/wow:*` command handlers.
- `packages/wow-contexts/src/init-prompt.ts`: `/wow:init` prompt template. - `packages/wow-contexts/src/init-prompt.ts`: `/wow:init` prompt template.
- `packages/wow-inject/src/env.ts`: environment injection flow. - `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/build.ts`: debug/release build orchestrator.
- `scripts/install.ts`: hard-copy install/uninstall logic. - `scripts/install.ts`: hard-copy install/uninstall logic.
- `wow.example.yaml`: example global/project wow configuration. - `wow.example.yaml`: example global/project wow configuration.
@@ -71,12 +79,5 @@
- `bun run check` is the primary validation command and only runs TypeScript. - `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. - 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`. - 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. - 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.
## 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.

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` 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` 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 ## Packages

View File

@@ -50,11 +50,22 @@
"wow-contexts": "workspace:*", "wow-contexts": "workspace:*",
"wow-core": "workspace:*", "wow-core": "workspace:*",
"wow-inject": "workspace:*", "wow-inject": "workspace:*",
"wow-statusline": "workspace:*",
}, },
"peerDependencies": { "peerDependencies": {
"@earendil-works/pi-coding-agent": "*", "@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": { "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=="], "@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-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=="], "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=="], "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 { import { type ContextFileEntry, enumerateContextPaths, tryRead } from "./files";
type ContextFileEntry,
resolveContextPath,
scanDir,
tryRead,
} from "./files";
import { resolvePaths } from "./resolver";
export interface BuildResult { export interface BuildResult {
files: ContextFileEntry[]; files: ContextFileEntry[];
@@ -12,35 +6,19 @@ export interface BuildResult {
} }
export async function build(cwd: string): Promise<BuildResult> { export async function build(cwd: string): Promise<BuildResult> {
const paths = resolvePaths(cwd);
const files: ContextFileEntry[] = []; const files: ContextFileEntry[] = [];
const loadedPaths = new Map<string, string>(); const loadedPaths = new Map<string, string>();
for (const entry of paths) { for (const candidate of await enumerateContextPaths(cwd)) {
if (entry.endsWith("/*.md")) { await tryRead(candidate.abs, files);
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));
}
} }
return { const deduped = dedupeFiles(files).filter((file) => file.content.length > 0);
files: dedupeFiles(files).filter((file) => file.content.length > 0), for (const file of deduped) {
loadedPaths, loadedPaths.set(file.abs, fileHash(file.content));
}; }
return { files: deduped, loadedPaths };
} }
export function fileHash(content: string): string { export function fileHash(content: string): string {

View File

@@ -1,14 +1,12 @@
import * as fs from "node:fs/promises"; import * as fs from "node:fs/promises";
import * as path from "node:path";
import type { import type {
ExtensionAPI, ExtensionAPI,
ExtensionCommandContext, ExtensionCommandContext,
} from "@earendil-works/pi-coding-agent"; } from "@earendil-works/pi-coding-agent";
import { registerWowCommand, show, showInfo, showWarn } from "wow-core"; import { registerWowCommand, show, showInfo, showWarn } from "wow-core";
import { fileHash } from "./builder"; import { fileHash } from "./builder";
import { resolveContextPath } from "./files"; import { enumerateContextPaths } from "./files";
import { buildInitPrompt } from "./init-prompt"; import { buildInitPrompt } from "./init-prompt";
import { resolvePaths } from "./resolver";
import { contextState } from "./state"; import { contextState } from "./state";
interface FileEntry { interface FileEntry {
@@ -133,48 +131,7 @@ async function handleInit(
} }
async function findCandidateFiles(cwd: string): Promise<FileEntry[]> { async function findCandidateFiles(cwd: string): Promise<FileEntry[]> {
const found: FileEntry[] = []; return enumerateContextPaths(cwd);
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;
}
} }
async function computeHash(absPath: string): Promise<string | null> { async function computeHash(absPath: string): Promise<string | null> {

View File

@@ -1,12 +1,22 @@
import * as fs from "node:fs/promises"; import * as fs from "node:fs/promises";
import * as path from "node:path"; import * as path from "node:path";
import { resolvePath } from "wow-core"; import { resolvePath } from "wow-core";
import { resolvePaths } from "./resolver";
export interface ContextFileEntry { export interface ContextFileEntry {
abs: string; abs: string;
content: 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 { export function resolveContextPath(input: string, cwd: string): string {
return resolvePath(input, cwd); return resolvePath(input, cwd);
} }
@@ -41,3 +51,57 @@ export async function tryRead(
// Missing/unreadable context files are ignored. // 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 ?? []; const loadedContextFiles = event.systemPromptOptions.contextFiles ?? [];
if (overlapsLoadedContext(buildResult, loadedContextFiles)) { if (overlapsLoadedContext(buildResult, loadedContextFiles, ctx.cwd)) {
buildResult = await rebuild(ctx.cwd); buildResult = await rebuild(ctx.cwd);
} }
@@ -47,6 +47,7 @@ const setup: ModuleSetup = (pi) => {
event.systemPrompt, event.systemPrompt,
buildResult, buildResult,
loadedContextFiles, loadedContextFiles,
ctx.cwd,
); );
log.debug("system prompt after context injection", { systemPrompt }); 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"; import type { BuildResult } from "./builder";
const PROJECT_CONTEXT_CLOSE = "</project_context>"; const PROJECT_CONTEXT_CLOSE = "</project_context>";
@@ -8,10 +9,11 @@ export function mergeProjectContext(
systemPrompt: string, systemPrompt: string,
result: BuildResult, result: BuildResult,
loadedContextFiles: LoadedContextFile[], loadedContextFiles: LoadedContextFile[],
cwd: string,
): string { ): string {
let prompt = systemPrompt; let prompt = systemPrompt;
const pendingInstructions: string[] = []; const pendingInstructions: string[] = [];
const loadedPaths = new Set(loadedContextFiles.map((file) => file.path)); const loadedPaths = absolutisePaths(loadedContextFiles, cwd);
for (const file of result.files) { for (const file of result.files) {
const instructions = formatProjectInstructions(file.abs, file.content); const instructions = formatProjectInstructions(file.abs, file.content);
@@ -31,13 +33,34 @@ export function mergeProjectContext(
export function overlapsLoadedContext( export function overlapsLoadedContext(
result: BuildResult, result: BuildResult,
loadedContextFiles: LoadedContextFile[], loadedContextFiles: LoadedContextFile[],
cwd: string,
): boolean { ): boolean {
if (loadedContextFiles.length === 0) return false; 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)); 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( function appendProjectInstructions(
systemPrompt: string, systemPrompt: string,
instructions: string, instructions: string,

View File

@@ -142,7 +142,14 @@ function mergeEnvConfig(
return Object.keys(merged).length > 0 ? merged : undefined; 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 {}; if (!input) return {};
const entries = Array.isArray(input) const entries = Array.isArray(input)
? Object.assign( ? Object.assign(

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import * as fs from "node:fs"; import * as fs from "node:fs";
import type { TaggedLogger } from "../logger"; import type { TaggedLogger } from "../logger";
import { expandHome } from "."; import { expandHome } from "../path";
export interface KeyResolver { export interface KeyResolver {
protocol: string; protocol: string;
@@ -13,20 +13,33 @@ export function registerKeyResolver(resolver: KeyResolver): void {
resolvers.push(resolver); resolvers.push(resolver);
} }
registerKeyResolver({ let initialised = false;
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", * Register the built-in resolvers. Idempotent — safe to call multiple times,
resolve(value: string): string | undefined { * but the side-effect is only run once. The `wow-pi` extension entry point
return process.env[value]; * 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_]*):/; const REF_RE = /^\$\{([a-zA-Z_][a-zA-Z0-9_]*):/;

View File

@@ -1,7 +1,7 @@
import type { WowConfig } from "wow-core"; 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 { loadEnvFile } from "./env-file";
import { log, resolvedEnv } from "./global"; import { log } from "./global";
interface InjectStats { interface InjectStats {
envFilesLoaded: number; envFilesLoaded: number;
@@ -53,7 +53,6 @@ export function injectProcessEnv(cwd = process.cwd()): InjectStats {
} }
process.env[name] = resolved; process.env[name] = resolved;
resolvedEnv.set(name, resolved);
stats.envVarsLoaded++; stats.envVarsLoaded++;
} }
@@ -63,26 +62,3 @@ export function injectProcessEnv(cwd = process.cwd()): InjectStats {
return stats; 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 TAG = "inject";
export const log = createLogger(TAG); 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"; import { log, TAG } from "./global";
const setup: ModuleSetup = (pi) => { 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(); resetWowConfigCache();
const cfg = getWowSettingSync<WowConfig["inject"]>(TAG, cwd); const cfg = getWowSettingSync<WowConfig["inject"]>(TAG, ctx.cwd);
if (cfg?.enabled === false) { if (cfg?.enabled === false) {
log.debug("inject disabled — skipping module"); log.debug("inject disabled — skipping module");
return; return;
} }
injectProcessEnv(cwd); injectProcessEnv(ctx.cwd);
};
load();
pi.on("session_start", (_event, ctx) => {
load(ctx.cwd);
}); });
}; };

View File

@@ -7,18 +7,30 @@ import {
createLogger, createLogger,
getModules, getModules,
initLogger, initLogger,
initResolvers,
resetWowConfigCache, resetWowConfigCache,
} from "wow-core"; } from "wow-core";
export default async function wowPi(pi: ExtensionAPI): Promise<void> { export default async function wowPi(pi: ExtensionAPI): Promise<void> {
resetWowConfigCache(); resetWowConfigCache();
initResolvers();
initLogger(); initLogger();
const log = createLogger("main"); const log = createLogger("main");
for (const mod of getModules()) { const modules = getModules();
await mod.register(pi); for (const mod of modules) {
log.debug("module registered", { module: mod.name }); 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", "cost",
]; ];
const BASE_STATUSLINE_CONFIG: Omit< const DEFAULT_CONFIG: ResolvedStatuslineConfig = {
ResolvedStatuslineConfig, enabled: true,
"enabled" | "editorStyle"
> = {
footer: { footer: {
enabled: true, enabled: true,
sections: DEFAULT_SECTIONS, sections: DEFAULT_SECTIONS,
@@ -37,33 +35,44 @@ const BASE_STATUSLINE_CONFIG: Omit<
maxItems: 4, maxItems: 4,
separator: " | ", separator: " | ",
}, },
editorStyle: {
enabled: true,
prompt: "",
folderIcon: hasNerdFonts() ? "" : "📁",
indent: " ",
},
}; };
export function getStatuslineConfig( export function getStatuslineConfig(
cwd = process.cwd(), cwd = process.cwd(),
): ResolvedStatuslineConfig { ): ResolvedStatuslineConfig {
const raw = getWowSettingSync<StatuslineConfig>("statusline", cwd); const raw = getWowSettingSync<StatuslineConfig>("statusline", cwd);
return resolveStatuslineConfig(raw);
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; * Normalize a user-supplied `StatuslineConfig` into a complete
if (value && typeof value === "object") return value.enabled !== false; * `ResolvedStatuslineConfig`. User values are shallow-merged over the
return true; * 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 { function hasNerdFonts(): boolean {

View File

@@ -1,8 +1,6 @@
import type { Theme } from "@earendil-works/pi-coding-agent"; import type { Theme } from "@earendil-works/pi-coding-agent";
import type { ResolvedStatuslineConfig, SessionSnapshot } from "./types"; import type { ResolvedStatuslineConfig, SessionSnapshot } from "./types";
export const EMPTY_USAGE = { input: 0, output: 0, cost: 0 };
export function buildEditorTopLine( export function buildEditorTopLine(
theme: Theme, theme: Theme,
snapshot: SessionSnapshot, snapshot: SessionSnapshot,

View File

@@ -3,6 +3,7 @@ import type {
ExtensionContext, ExtensionContext,
} from "@earendil-works/pi-coding-agent"; } from "@earendil-works/pi-coding-agent";
import { registerModule, resetWowConfigCache } from "wow-core"; import { registerModule, resetWowConfigCache } from "wow-core";
import { registerCommands } from "./commands";
import { getStatuslineConfig } from "./config"; import { getStatuslineConfig } from "./config";
import { log, TAG } from "./global"; import { log, TAG } from "./global";
import { createState } from "./state"; import { createState } from "./state";
@@ -12,12 +13,16 @@ import {
updateStatuslineState, updateStatuslineState,
} from "./ui"; } 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 STREAMING_REFRESH_INTERVAL_MS = 80;
const setup = (pi: ExtensionAPI): void => { const setup = (pi: ExtensionAPI): void => {
const state = createState(); const state = createState();
let lastStreamingRefresh = 0; let lastStreamingRefresh = 0;
registerCommands(pi);
const apply = (ctx: ExtensionContext): void => { const apply = (ctx: ExtensionContext): void => {
resetWowConfigCache(); resetWowConfigCache();
state.currentCtx = ctx; state.currentCtx = ctx;
@@ -47,7 +52,12 @@ const setup = (pi: ExtensionAPI): void => {
refresh(); 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) => { pi.on("thinking_level_select", async (event) => {
state.thinkingLevel = event.level; state.thinkingLevel = event.level;
@@ -67,6 +77,8 @@ const setup = (pi: ExtensionAPI): void => {
state.originalSetFooter = null; state.originalSetFooter = null;
state.footerHijacked = false; state.footerHijacked = false;
state.lastRenderSignature = ""; state.lastRenderSignature = "";
state.nextTickResetScheduled = false;
state.editorInstalled = false;
}); });
}; };

View File

@@ -15,6 +15,26 @@ export interface StatuslineState {
originalSetFooter: ((...args: unknown[]) => unknown) | null; originalSetFooter: ((...args: unknown[]) => unknown) | null;
footerHijacked: boolean; footerHijacked: boolean;
lastRenderSignature: string; 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 { export function createState(): StatuslineState {
@@ -28,6 +48,10 @@ export function createState(): StatuslineState {
originalSetFooter: null, originalSetFooter: null,
footerHijacked: false, footerHijacked: false,
lastRenderSignature: "", 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", modelName: ctx.model?.name || ctx.model?.id || "no-model",
provider: ctx.model?.provider, provider: ctx.model?.provider,
thinkingLevel: state.thinkingLevel, thinkingLevel: state.thinkingLevel,
usage: collectUsage(ctx), usage: state.cachedUsage,
statuses: [...state.statusEntries], 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 { export function syncFooterSnapshot(state: StatuslineState): void {
if (!state.footerData) return; if (!state.footerData) return;
state.branch = state.footerData.getGitBranch() || ""; 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 { function collectUsage(ctx: ExtensionContext): UsageSummary {
let input = 0; let input = 0;
let output = 0; let output = 0;

View File

@@ -8,7 +8,15 @@ export type StatuslineSection =
| "cost" | "cost"
| "statuses"; | "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 { export interface FooterDataSnapshot {
getGitBranch(): string | null; getGitBranch(): string | null;

View File

@@ -1,6 +1,12 @@
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent"; import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
import { buildEditorTopLine, buildStatusSummary, EMPTY_USAGE } from "./format"; import { buildEditorTopLine, buildStatusSummary } from "./format";
import { type StatuslineState, syncFooterSnapshot } from "./state"; import { log } from "./global";
import {
type StatuslineState,
refreshUsageCache,
snapshotFromContext,
syncFooterSnapshot,
} from "./state";
import { import {
buildStatuslineWidget, buildStatuslineWidget,
EmptyFooter, EmptyFooter,
@@ -12,10 +18,26 @@ const STATUS_WIDGET_ID = "wow-statusline:summary";
const FOOTER_GUARD = "__wowStatuslineAllowFooter"; const FOOTER_GUARD = "__wowStatuslineAllowFooter";
const FOOTER_HIJACKED = "__wowStatuslineFooterHijacked"; const FOOTER_HIJACKED = "__wowStatuslineFooterHijacked";
interface HijackedUi {
[FOOTER_HIJACKED]?: boolean;
[FOOTER_GUARD]?: boolean;
setFooter: SetFooter;
}
interface FooterDataLike extends FooterDataSnapshot { interface FooterDataLike extends FooterDataSnapshot {
onBranchChange(listener: () => void): () => void; onBranchChange(listener: () => void): () => void;
} }
type SetFooter = (
factory:
| ((
_tui: unknown,
theme: Theme,
footerData: FooterDataLike,
) => EmptyFooterWithDispose)
| undefined,
) => void;
const SIGNATURE_CONFIG: ResolvedStatuslineConfig = { const SIGNATURE_CONFIG: ResolvedStatuslineConfig = {
enabled: true, enabled: true,
footer: { footer: {
@@ -71,6 +93,8 @@ export function updateStatuslineState(state: StatuslineState): void {
syncFooterSnapshot(state); syncFooterSnapshot(state);
if (!state.currentCtx) return; if (!state.currentCtx) return;
refreshUsageCache(state, state.currentCtx);
const nextSignature = computeRenderSignature(state.currentCtx, state); const nextSignature = computeRenderSignature(state.currentCtx, state);
if (nextSignature === state.lastRenderSignature) return; if (nextSignature === state.lastRenderSignature) return;
@@ -85,36 +109,114 @@ export function clearStatuslineUI(
restoreFooter(ctx, state); restoreFooter(ctx, state);
ctx.ui.setWidget(STATUS_WIDGET_ID, undefined, { placement: "belowEditor" }); ctx.ui.setWidget(STATUS_WIDGET_ID, undefined, { placement: "belowEditor" });
ctx.ui.setWidget(STATUS_WIDGET_ID, undefined, { placement: "aboveEditor" }); 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 { function hijackFooter(ctx: ExtensionContext, state: StatuslineState): void {
const ui = ctx.ui as typeof ctx.ui & { const ui = ctx.ui as unknown as HijackedUi;
[FOOTER_HIJACKED]?: boolean;
[FOOTER_GUARD]?: boolean;
setFooter: (...args: unknown[]) => unknown;
};
if (ui[FOOTER_HIJACKED]) { if (ui[FOOTER_HIJACKED]) {
state.footerHijacked = true; state.footerHijacked = true;
return; return;
} }
const original = ui.setFooter.bind(ui); const original = ui.setFooter.bind(ui) as SetFooter;
state.originalSetFooter = original; 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]) { 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; ui.setFooter = wrapped as typeof ui.setFooter;
}) as typeof ui.setFooter;
ui[FOOTER_HIJACKED] = true; ui[FOOTER_HIJACKED] = true;
state.footerHijacked = 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( function installStatusWidget(
ctx: ExtensionContext, ctx: ExtensionContext,
state: StatuslineState, state: StatuslineState,
@@ -138,40 +240,47 @@ function installEditor(
state: StatuslineState, state: StatuslineState,
config: ResolvedStatuslineConfig, config: ResolvedStatuslineConfig,
): void { ): 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( ctx.ui.setEditorComponent(
(tui, theme, keybindings) => (tui, theme, keybindings) =>
new StatuslineEditor(tui, theme, keybindings, ctx, state, config), new StatuslineEditor(tui, theme, keybindings, ctx, state, config),
); );
state.editorInstalled = true;
} }
function installEmptyFooter( function installEmptyFooter(
ctx: ExtensionContext, ctx: ExtensionContext,
state: StatuslineState, state: StatuslineState,
): void { ): void {
const ui = ctx.ui as typeof ctx.ui & { installEmptyFooterWithGuard(ctx.ui as unknown as HijackedUi, state);
[FOOTER_GUARD]?: boolean; }
setFooter: (...args: unknown[]) => unknown;
};
ui[FOOTER_GUARD] = true; function makeEmptyFactory(
try { state: StatuslineState,
const emptyFactory = ( ): (
_tui: unknown, _tui: unknown,
theme: Theme, theme: Theme,
footerData: FooterDataLike, footerData: FooterDataLike,
) => { ) => EmptyFooterWithDispose {
captureFooterData(state, theme, footerData); return (_tui, theme, footerData) => {
const dispose = footerData.onBranchChange(() => { captureFooterData(state, theme, footerData);
return new EmptyFooterWithDispose(
footerData.onBranchChange(() => {
syncFooterSnapshot(state); syncFooterSnapshot(state);
state.requestRender?.(); state.requestRender?.();
}); }),
return new EmptyFooterWithDispose(dispose); );
}; };
ui.setFooter(emptyFactory as Parameters<typeof ui.setFooter>[0]);
} finally {
ui[FOOTER_GUARD] = false;
}
} }
function captureFooterData( function captureFooterData(
@@ -187,35 +296,11 @@ function computeRenderSignature(
ctx: ExtensionContext, ctx: ExtensionContext,
state: StatuslineState, state: StatuslineState,
): string { ): string {
const snapshot = { const snapshot = snapshotFromContext(state, ctx);
cwd: ctx.cwd, const top = buildEditorTopLine(ctx.ui.theme, snapshot, SIGNATURE_CONFIG);
branch: state.branch, const bottom = buildStatusSummary(snapshot, SIGNATURE_CONFIG);
model: ctx.model?.name || ctx.model?.id || "no-model", const usageKey = `${state.cachedUsage.input}-${state.cachedUsage.output}-${state.cachedUsage.cost}`;
provider: ctx.model?.provider || "", return `${top}\u0002${bottom}\u0002${usageKey}`;
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 { function restoreFooter(ctx: ExtensionContext, state: StatuslineState): void {
@@ -233,6 +318,7 @@ function restoreFooter(ctx: ExtensionContext, state: StatuslineState): void {
state.originalSetFooter = null; state.originalSetFooter = null;
state.footerHijacked = false; state.footerHijacked = false;
state.lastRenderSignature = ""; state.lastRenderSignature = "";
state.nextTickResetScheduled = false;
ui.setFooter(undefined); ui.setFooter(undefined);
} }

View File

@@ -18,7 +18,24 @@ inject:
ANTHROPIC_API_KEY: ${file:~/.secrets/anthropic-key} ANTHROPIC_API_KEY: ${file:~/.secrets/anthropic-key}
OPENAI_API_KEY: ${env:OPENAI_API_KEY_BACKUP} 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: logger:
level: info level: info