chore: snapshot backup before rainycy push (20260624-032434)
Auto-committed by MiMo for migration to git.rainycy.top
This commit is contained in:
27
AGENTS.md
27
AGENTS.md
@@ -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.
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
13
bun.lock
13
bun.lock
@@ -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=="],
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
@@ -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_]*):/;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>();
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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) });
|
||||||
}
|
}
|
||||||
|
|||||||
63
packages/wow-statusline/src/commands.ts
Normal file
63
packages/wow-statusline/src/commands.ts
Normal 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}`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user