import { promises as fs } from "node:fs"; import path from "node:path"; import { DynamicBorder, getAgentDir, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; import { Container, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui"; import { inspectModelLimitAvailability, LIMIT_LABELS, suggestedLimitValue } from "./limits.ts"; import { DEFAULT_PROMPT, loadSettings } from "./settings.ts"; import type { LimitKey, TreeLabelerSettings } from "./types.ts"; type Scope = "global" | "project"; type JsonObject = Record; function object(value: unknown): JsonObject { return value && typeof value === "object" && !Array.isArray(value) ? value as JsonObject : {}; } function settingsPath(ctx: ExtensionCommandContext, scope: Scope): string { return scope === "global" ? path.join(getAgentDir(), "settings.json") : path.join(ctx.cwd, ".pi", "settings.json"); } export function serializeSettings(settings: TreeLabelerSettings): JsonObject { return { enabled: settings.enabled, model: settings.model, thinkingLevel: settings.thinkingLevel, prompt: settings.prompt, exposure: settings.exposure, applyMode: settings.applyMode, automation: { ...settings.automation }, sessionNaming: { ...settings.sessionNaming }, autoLimits: settings.autoLimits, offerLimitIncreases: settings.offerLimitIncreases, maxNodes: settings.maxNodes, excerptChars: settings.excerptChars, maxInputChars: settings.maxInputChars, maxLabels: settings.maxLabels, maxLabelChars: settings.maxLabelChars, outputTokens: settings.outputTokens ?? null, timeoutSecs: settings.timeoutMs / 1000, includeToolNames: settings.includeToolNames, persistPayloads: settings.persistPayloads, }; } async function readRoot(file: string): Promise { try { return object(JSON.parse(await fs.readFile(file, "utf8"))); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return {}; throw new Error(`Cannot read ${file}: ${error instanceof Error ? error.message : String(error)}`); } } async function writeRoot(file: string, root: JsonObject): Promise { await fs.mkdir(path.dirname(file), { recursive: true }); const temporary = `${file}.${process.pid}.${Date.now()}.tmp`; await fs.writeFile(temporary, `${JSON.stringify(root, null, 2)}\n`, "utf8"); await fs.rename(temporary, file); } async function writeScope(ctx: ExtensionCommandContext, scope: Scope, settings: TreeLabelerSettings | undefined): Promise { const file = settingsPath(ctx, scope); const root = await readRoot(file); if (settings) root.treeLabeler = serializeSettings(settings); else delete root.treeLabeler; await writeRoot(file, root); } async function patchScope(ctx: ExtensionCommandContext, scope: Scope, patch: JsonObject): Promise { const file = settingsPath(ctx, scope); const root = await readRoot(file); root.treeLabeler = { ...object(root.treeLabeler), ...patch }; await writeRoot(file, root); } function bool(value: boolean): string { return value ? "on" : "off"; } function short(value: string, max = 42): string { return value.length <= max ? value : `${value.slice(0, max - 1)}…`; } function equal(value: unknown, baseline: unknown): boolean { return JSON.stringify(value) === JSON.stringify(baseline); } function changed(ctx: ExtensionCommandContext, value: unknown, baseline: unknown, text: string): string { return equal(value, baseline) ? text : ctx.ui.theme.fg("warning", text); } function dirtyMarker(ctx: ExtensionCommandContext, value: unknown, baseline: unknown): string { return equal(value, baseline) ? "" : ctx.ui.theme.fg("warning", " · unsaved"); } function compactNumber(value: number): string { return value >= 1_000_000 ? `${Number((value / 1_000_000).toFixed(1))}M` : value >= 1000 ? `${Number((value / 1000).toFixed(1))}K` : String(value); } function autoLimitDescription(ctx: ExtensionCommandContext, model: string): { available: boolean; text: string } { const availability = inspectModelLimitAvailability(ctx, model); return availability.available ? { available: true, text: `available (${compactNumber(availability.contextWindow)} context · ${compactNumber(availability.maxTokens)} output)` } : { available: false, text: `unavailable for selected model (${availability.reason})` }; } const MAX_VISIBLE_OPTIONS = 10; /** Use a bounded SelectList in TUI mode so every long config menu scrolls. */ export async function selectOption( ctx: ExtensionCommandContext, title: string, options: readonly string[], warning?: string, ): Promise { if (ctx.mode !== "tui") return ctx.ui.select(title, [...options]); return ctx.ui.custom((tui, theme, _keybindings, done) => { const items: SelectItem[] = options.map((label, index) => ({ value: String(index), label })); const availableRows = Math.max(1, tui.terminal.rows - (warning ? 7 : 6)); const maxVisible = Math.max(1, Math.min(items.length, MAX_VISIBLE_OPTIONS, availableRows)); const list = new SelectList(items, maxVisible, { selectedPrefix: (text) => theme.fg("accent", text), selectedText: (text) => theme.fg("accent", text), description: (text) => theme.fg("muted", text), scrollInfo: (text) => theme.fg("dim", text), noMatch: (text) => theme.fg("warning", text), }); list.onSelect = (item) => done(options[Number(item.value)]); list.onCancel = () => done(undefined); const container = new Container(); container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text))); container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0)); if (warning) container.addChild(new Text(theme.fg("warning", warning), 1, 0)); container.addChild(list); container.addChild(new Text(theme.fg("dim", " ↑↓ navigate · Enter select · Esc cancel"), 1, 0)); container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text))); return { render: (width) => container.render(width), invalidate: () => container.invalidate(), handleInput: (data) => { list.handleInput(data); tui.requestRender(); }, }; }); } export async function offerLimitIncrease( ctx: ExtensionCommandContext, settings: TreeLabelerSettings, hits: LimitKey[], ): Promise { const unique = [...new Set(hits)]; const names = unique.map((key) => LIMIT_LABELS[key]).join(", "); if (!settings.offerLimitIncreases || ctx.mode !== "tui") { ctx.ui.notify(`Tree-label limits reached: ${names}. Limit-increase prompts are ${settings.offerLimitIncreases ? "available only in TUI mode" : "disabled in /tree-labels config"}.`, "warning"); return; } const increaseable = settings.autoLimits ? unique.filter((key) => key === "timeoutMs") : unique; if (!increaseable.length) { ctx.ui.notify(`Model-aware auto limits reached: ${names}. Auto limits already use this model's advertised capacity; turn Auto limits off in /tree-labels config to customize them.`, "warning"); return; } const increases = increaseable.flatMap((key) => { const current = settings[key]; const suggested = suggestedLimitValue(key, settings); return typeof current === "number" && suggested !== undefined && suggested > current ? [{ key, current, suggested }] : []; }); const scopes: Scope[] = ctx.isProjectTrusted() ? ["project", "global"] : ["global"]; const displayValue = (key: LimitKey, value: number) => key === "timeoutMs" ? value / 1000 : value; const choices = increases.flatMap(({ key, current, suggested }) => { const base = `${LIMIT_LABELS[key]}: ${displayValue(key, current)} → ${displayValue(key, suggested)}`; return scopes.map((scope) => `${base} (${scope})`); }); choices.push("Keep current limits", "Stop offering limit increases (global)"); const selected = await selectOption(ctx, `Limits reached: ${names}`, choices); if (!selected || selected === "Keep current limits") return; if (selected === "Stop offering limit increases (global)") { await patchScope(ctx, "global", { offerLimitIncreases: false }); ctx.ui.notify("Disabled tree-label limit-increase prompts globally. Re-enable them in /tree-labels config.", "info"); return; } const increase = increases.find(({ key, current, suggested }) => selected.startsWith(`${LIMIT_LABELS[key]}: ${displayValue(key, current)} → ${displayValue(key, suggested)}`)); if (!increase) return; const scope: Scope = selected.endsWith("(project)") ? "project" : "global"; if (scope === "project" && !ctx.isProjectTrusted()) { ctx.ui.notify("Project settings cannot be changed until this project is trusted.", "error"); return; } const patch = increase.key === "timeoutMs" ? { timeoutSecs: increase.suggested / 1000 } : { [increase.key]: increase.suggested }; await patchScope(ctx, scope, patch); ctx.ui.notify(`Raised ${LIMIT_LABELS[increase.key]} to ${displayValue(increase.key, increase.suggested)} in ${settingsPath(ctx, scope)}. Retry the tree-label run to use it.`, "info"); } async function choose(ctx: ExtensionCommandContext, title: string, values: readonly T[], current: T): Promise { const labels = values.map((value) => value === current ? `${value} ✓` : value); const selected = await selectOption(ctx, title, labels); if (!selected) return undefined; return values[labels.indexOf(selected)]; } async function numberInput(ctx: ExtensionCommandContext, title: string, current: number, min: number, max: number): Promise { const raw = await ctx.ui.input(`${title} (${min}–${max})`, String(current)); if (raw === undefined) return undefined; const value = Number(raw.trim()); if (!Number.isInteger(value) || value < min || value > max) { ctx.ui.notify(`Enter an integer from ${min} to ${max}.`, "warning"); return undefined; } return value; } async function chooseExposure(ctx: ExtensionCommandContext, current: TreeLabelerSettings["exposure"]): Promise { const options: Array<{ value: TreeLabelerSettings["exposure"]; label: string }> = [ { value: "balanced", label: "balanced — whole-tree map; detail around all landmarks (recommended)" }, { value: "tree", label: "tree — maximum detail for every exposed entry; highest cost" }, { value: "branch", label: "branch — checked-out path only; cheapest and most private" }, { value: "neighborhood", label: "neighborhood — whole-tree map; detail near the checked-out path" }, ]; const labels = options.map((option) => `${option.label}${option.value === current ? " ✓" : ""}`); const selected = await selectOption(ctx, "How much of the session tree should the labeler see?", labels); if (!selected) return undefined; return options[labels.indexOf(selected)]?.value; } async function chooseModel(ctx: ExtensionCommandContext, current: string): Promise { const models = ctx.modelRegistry.getAvailable() .map((model) => `${model.provider}/${model.id}`) .sort((a, b) => a.localeCompare(b)); const values = ["current", ...models]; const labels = values.map((value) => { const suffix = value === current ? " ✓" : ""; return value === "current" ? `current — follow Pi's selected model${suffix}` : `${value}${suffix}`; }); const selected = await selectOption(ctx, "Authenticated model for tree labeling", labels); if (!selected) return undefined; return values[labels.indexOf(selected)]; } async function editAutomation(ctx: ExtensionCommandContext, draft: TreeLabelerSettings): Promise { while (true) { const choices = [ `Enabled: ${bool(draft.automation.enabled)}`, `Run after turns: ${draft.automation.afterTurns}`, `On new branch: ${bool(draft.automation.onBranchCreated)}`, `On /tree navigation: ${bool(draft.automation.onTreeNavigation)}`, `On compaction: ${bool(draft.automation.onCompaction)}`, "Back", ]; const selected = await selectOption(ctx, "Tree-label automation", choices); if (!selected || selected === "Back") return; const index = choices.indexOf(selected); if (index === 0) draft.automation.enabled = !draft.automation.enabled; if (index === 1) { const value = await numberInput(ctx, "Run after user turns", draft.automation.afterTurns, 1, 100); if (value !== undefined) draft.automation.afterTurns = value; } if (index === 2) draft.automation.onBranchCreated = !draft.automation.onBranchCreated; if (index === 3) draft.automation.onTreeNavigation = !draft.automation.onTreeNavigation; if (index === 4) draft.automation.onCompaction = !draft.automation.onCompaction; } } async function editSessionNaming(ctx: ExtensionCommandContext, draft: TreeLabelerSettings): Promise { while (true) { const choices = [ `Enabled: ${bool(draft.sessionNaming.enabled)}`, `Fallback to latest user message: ${bool(draft.sessionNaming.fallbackToMessage)}`, `Maximum name characters: ${draft.sessionNaming.maxChars}`, "Back", ]; const selected = await selectOption(ctx, "Checked-out section session naming", choices); if (!selected || selected === "Back") return; const index = choices.indexOf(selected); if (index === 0) draft.sessionNaming.enabled = !draft.sessionNaming.enabled; if (index === 1) draft.sessionNaming.fallbackToMessage = !draft.sessionNaming.fallbackToMessage; if (index === 2) { const value = await numberInput(ctx, "Maximum session-name characters", draft.sessionNaming.maxChars, 20, 120); if (value !== undefined) draft.sessionNaming.maxChars = value; } } } async function editPrompt(ctx: ExtensionCommandContext, draft: TreeLabelerSettings): Promise { const action = await selectOption(ctx, "Custom labeling prompt", ["Replace prompt", "Restore default prompt", "Cancel"]); if (action === "Replace prompt") { const value = await ctx.ui.input("New labeling prompt", "Describe what should deserve a label…"); if (value?.trim()) draft.prompt = value.trim(); } else if (action === "Restore default prompt") draft.prompt = DEFAULT_PROMPT; } async function editOutputBudget(ctx: ExtensionCommandContext, draft: TreeLabelerSettings): Promise { const action = await selectOption(ctx, "Output token budget", [ `Provider default — no extension cap${draft.outputTokens === undefined ? " ✓" : ""}`, `Set custom budget${draft.outputTokens !== undefined ? ` — ${draft.outputTokens} ✓` : ""}`, "Cancel", ]); if (action?.startsWith("Provider default")) draft.outputTokens = undefined; else if (action?.startsWith("Set custom")) { const value = await numberInput(ctx, "Output token budget", draft.outputTokens ?? 1400, 256, 64000); if (value !== undefined) draft.outputTokens = value; } } async function editGeneral(ctx: ExtensionCommandContext, draft: TreeLabelerSettings): Promise { while (true) { const choices = [ `Extension enabled: ${bool(draft.enabled)}`, `Apply mode: ${draft.applyMode}`, "Back", ]; const selected = await selectOption(ctx, "General", choices); if (!selected || selected === "Back") return; const index = choices.indexOf(selected); if (index === 0) draft.enabled = !draft.enabled; else if (index === 1) { const value = await choose(ctx, "Apply mode", ["review", "auto", "manual"] as const, draft.applyMode); if (value) draft.applyMode = value; } } } async function editModelAndLimits(ctx: ExtensionCommandContext, draft: TreeLabelerSettings): Promise { while (true) { const inactive = draft.autoLimits ? " (custom override inactive)" : ""; const autoLimits = autoLimitDescription(ctx, draft.model); const choices = [ `Model: ${draft.model}`, `Thinking: ${draft.thinkingLevel}`, `Auto limits: ${bool(draft.autoLimits)} — ${autoLimits.text}`, `Offer limit increases: ${bool(draft.offerLimitIncreases)}`, `Output tokens: ${draft.outputTokens ?? "provider default"}${inactive}`, `Timeout seconds: ${draft.timeoutMs / 1000}${draft.autoLimits ? " (auto minimum: 300)" : ""}`, "Back", ]; const selected = await selectOption(ctx, "Model and capacity", choices); if (!selected || selected === "Back") return; const index = choices.indexOf(selected); if (index === 0) { const value = await chooseModel(ctx, draft.model); if (value) draft.model = value; } else if (index === 1) { const value = await choose(ctx, "Thinking level", ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const, draft.thinkingLevel); if (value) draft.thinkingLevel = value; } else if (index === 2) draft.autoLimits = !draft.autoLimits; else if (index === 3) draft.offerLimitIncreases = !draft.offerLimitIncreases; else if (index === 4) await editOutputBudget(ctx, draft); else if (index === 5) { const value = await numberInput(ctx, "Timeout seconds", draft.timeoutMs / 1000, 5, 1800); if (value !== undefined) draft.timeoutMs = value * 1000; } } } async function editTreeInput(ctx: ExtensionCommandContext, draft: TreeLabelerSettings): Promise { while (true) { const inactive = draft.autoLimits ? " (custom override inactive)" : ""; const choices = [ `Exposure: ${draft.exposure}`, `Max nodes: ${draft.maxNodes}${inactive}`, `Excerpt characters: ${draft.excerptChars}${inactive}`, `Max input characters: ${draft.maxInputChars}${inactive}`, "Back", ]; const selected = await selectOption(ctx, "Tree input", choices); if (!selected || selected === "Back") return; const index = choices.indexOf(selected); if (index === 0) { const value = await chooseExposure(ctx, draft.exposure); if (value) draft.exposure = value; } else if (index === 1) { const value = await numberInput(ctx, "Maximum exposed nodes", draft.maxNodes, 20, 10000); if (value !== undefined) draft.maxNodes = value; } else if (index === 2) { const value = await numberInput(ctx, "Excerpt characters", draft.excerptChars, 80, 10000); if (value !== undefined) draft.excerptChars = value; } else if (index === 3) { const value = await numberInput(ctx, "Maximum input characters", draft.maxInputChars, 8000, 3000000); if (value !== undefined) draft.maxInputChars = value; } } } async function editLabelPolicy(ctx: ExtensionCommandContext, draft: TreeLabelerSettings): Promise { while (true) { const inactive = draft.autoLimits ? " (auto value used)" : ""; const choices = [ `Prompt: ${short(draft.prompt.replace(/\s+/g, " "))}`, `Max labels: ${draft.maxLabels}${inactive}`, `Max label characters: ${draft.maxLabelChars}${inactive}`, "Back", ]; const selected = await selectOption(ctx, "Label policy", choices); if (!selected || selected === "Back") return; const index = choices.indexOf(selected); if (index === 0) await editPrompt(ctx, draft); else if (index === 1) { const value = await numberInput(ctx, "Maximum labels per run", draft.maxLabels, 1, 200); if (value !== undefined) draft.maxLabels = value; } else if (index === 2) { const value = await numberInput(ctx, "Maximum label characters", draft.maxLabelChars, 12, 200); if (value !== undefined) draft.maxLabelChars = value; } } } async function editPrivacy(ctx: ExtensionCommandContext, draft: TreeLabelerSettings): Promise { while (true) { const choices = [ `Include tool names: ${bool(draft.includeToolNames)}`, `Persist prompts/responses: ${bool(draft.persistPayloads)}`, "Back", ]; const selected = await selectOption(ctx, "Privacy and diagnostics", choices); if (!selected || selected === "Back") return; const index = choices.indexOf(selected); if (index === 0) draft.includeToolNames = !draft.includeToolNames; else if (index === 1) draft.persistPayloads = !draft.persistPayloads; } } export async function showConfig(ctx: ExtensionCommandContext, requestedScope?: Scope): Promise { if (!ctx.hasUI) { ctx.ui.notify("/tree-labels config requires TUI or RPC dialog support.", "warning"); return; } let scope = requestedScope; if (!scope) { const globalSettings = loadSettings(ctx.cwd, false); const scopes = [`Global — apply mode: ${globalSettings.applyMode}`]; if (ctx.isProjectTrusted()) { const projectSettings = loadSettings(ctx.cwd, true); scopes.push(`Project — apply mode: ${projectSettings.applyMode} (overrides global)`); } const selected = await selectOption(ctx, "Configure tree labeler", scopes); if (!selected) return; scope = selected.startsWith("Project") ? "project" : "global"; } if (scope === "project" && !ctx.isProjectTrusted()) { ctx.ui.notify("Project settings cannot be changed until this project is trusted.", "error"); return; } const draft = structuredClone(loadSettings(ctx.cwd, scope === "project" && ctx.isProjectTrusted())); const baseline = structuredClone(draft); let projectOverridesGlobal = false; if (scope === "global" && ctx.isProjectTrusted()) { const projectOverrides = object((await readRoot(settingsPath(ctx, "project"))).treeLabeler); projectOverridesGlobal = Object.keys(projectOverrides).length > 0; } while (true) { const autoLimits = autoLimitDescription(ctx, draft.model); const limitSummary = draft.autoLimits ? `auto limits ${autoLimits.available ? "✓" : "unavailable"}` : `custom limits (auto ${autoLimits.available ? "available" : "unavailable"})`; const modelSummary = [ changed(ctx, draft.model, baseline.model, draft.model), changed(ctx, draft.thinkingLevel, baseline.thinkingLevel, draft.thinkingLevel), changed(ctx, draft.autoLimits, baseline.autoLimits, limitSummary), ].join(" · "); const choices = [ `General… ${changed(ctx, draft.enabled, baseline.enabled, draft.enabled ? "enabled" : "disabled")} · ${changed(ctx, draft.applyMode, baseline.applyMode, draft.applyMode)}${dirtyMarker(ctx, { enabled: draft.enabled, applyMode: draft.applyMode }, { enabled: baseline.enabled, applyMode: baseline.applyMode })}`, `Model and capacity… ${modelSummary}${dirtyMarker(ctx, { model: draft.model, thinkingLevel: draft.thinkingLevel, autoLimits: draft.autoLimits, offerLimitIncreases: draft.offerLimitIncreases, outputTokens: draft.outputTokens, timeoutMs: draft.timeoutMs }, { model: baseline.model, thinkingLevel: baseline.thinkingLevel, autoLimits: baseline.autoLimits, offerLimitIncreases: baseline.offerLimitIncreases, outputTokens: baseline.outputTokens, timeoutMs: baseline.timeoutMs })}`, `Tree input… ${changed(ctx, draft.exposure, baseline.exposure, draft.exposure)}${dirtyMarker(ctx, { exposure: draft.exposure, maxNodes: draft.maxNodes, excerptChars: draft.excerptChars, maxInputChars: draft.maxInputChars }, { exposure: baseline.exposure, maxNodes: baseline.maxNodes, excerptChars: baseline.excerptChars, maxInputChars: baseline.maxInputChars })}`, `Label policy… max ${changed(ctx, draft.maxLabels, baseline.maxLabels, String(draft.maxLabels))} labels · ${changed(ctx, draft.maxLabelChars, baseline.maxLabelChars, String(draft.maxLabelChars))} chars${dirtyMarker(ctx, { prompt: draft.prompt, maxLabels: draft.maxLabels, maxLabelChars: draft.maxLabelChars }, { prompt: baseline.prompt, maxLabels: baseline.maxLabels, maxLabelChars: baseline.maxLabelChars })}`, `Automation… ${changed(ctx, draft.automation.enabled, baseline.automation.enabled, draft.automation.enabled ? "on" : "off")}${dirtyMarker(ctx, draft.automation, baseline.automation)}`, `Session naming… ${changed(ctx, draft.sessionNaming.enabled, baseline.sessionNaming.enabled, draft.sessionNaming.enabled ? "on" : "off")}${dirtyMarker(ctx, draft.sessionNaming, baseline.sessionNaming)}`, `Privacy and diagnostics… payload persistence ${changed(ctx, draft.persistPayloads, baseline.persistPayloads, draft.persistPayloads ? "on" : "off")}${dirtyMarker(ctx, { includeToolNames: draft.includeToolNames, persistPayloads: draft.persistPayloads }, { includeToolNames: baseline.includeToolNames, persistPayloads: baseline.persistPayloads })}`, equal(draft, baseline) ? `Save to ${scope}` : ctx.ui.theme.fg("warning", `Save to ${scope} · unsaved changes`), `Reset ${scope} overrides`, "Cancel", ]; const heading = scope === "project" ? "Tree labeler settings (project overrides global)" : "Tree labeler settings (global)"; const warning = scope === "global" && projectOverridesGlobal ? "⚠ Project Setting Found, They Will Override This!" : undefined; const selected = await selectOption(ctx, heading, choices, warning); if (!selected || selected === "Cancel") return; const index = choices.indexOf(selected); if (index === 0) await editGeneral(ctx, draft); else if (index === 1) await editModelAndLimits(ctx, draft); else if (index === 2) await editTreeInput(ctx, draft); else if (index === 3) await editLabelPolicy(ctx, draft); else if (index === 4) await editAutomation(ctx, draft); else if (index === 5) await editSessionNaming(ctx, draft); else if (index === 6) await editPrivacy(ctx, draft); else if (index === 7) { await writeScope(ctx, scope, draft); ctx.ui.notify(`Saved tree-labeler settings to ${settingsPath(ctx, scope)}.`, "info"); return; } else if (index === 8) { const confirmed = await ctx.ui.confirm(`Reset ${scope} tree-labeler settings?`, "This removes the treeLabeler section from that settings file."); if (confirmed) { await writeScope(ctx, scope, undefined); ctx.ui.notify(`Removed ${scope} tree-labeler overrides.`, "info"); return; } } } }