pi-goal-list-loop-audit 0.27.9 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/extensions/loops/goal.ts +230 -157
- package/extensions/settings-menu.ts +571 -0
- package/package.json +1 -1
package/extensions/loops/goal.ts
CHANGED
|
@@ -141,6 +141,11 @@ import {
|
|
|
141
141
|
syncSubagentModelOverrides,
|
|
142
142
|
type SubagentModelStrategy,
|
|
143
143
|
} from "../goal-loop-subagents.js";
|
|
144
|
+
import {
|
|
145
|
+
buildSettingsRows,
|
|
146
|
+
SettingsMenuComponent,
|
|
147
|
+
type SettingsRow,
|
|
148
|
+
} from "../settings-menu.js";
|
|
144
149
|
import {
|
|
145
150
|
applyMeasurement,
|
|
146
151
|
applyMetriclessTick,
|
|
@@ -2893,173 +2898,241 @@ function resolveAuditorModel(ctx: ExtensionContext, ref?: string): { model: any;
|
|
|
2893
2898
|
* Done/Esc exits. Rarely opened by design; scriptable /glla key=value remains
|
|
2894
2899
|
* for tmux/headless.
|
|
2895
2900
|
*/
|
|
2901
|
+
/**
|
|
2902
|
+
* v0.28.0: open the /glla settings menu as a TUI table (top tabs row +
|
|
2903
|
+
* 4-column body: KEY | VALUE | SOURCE | DESCRIPTION). Loops until the user
|
|
2904
|
+
* exits (Esc / undefined from confirm or cancel) or until a handler returns.
|
|
2905
|
+
*
|
|
2906
|
+
* The dispatcher (handleSettingChoice, below) takes a stable id and calls the
|
|
2907
|
+
* per-key editor (input/select/confirm dialog) used by the pick. The prior
|
|
2908
|
+
* v0.27.0 dispatcher used `choice.startsWith(label)` strings; the new id-based
|
|
2909
|
+
* switch is contract-equal in behavior and unit-testable via
|
|
2910
|
+
* extensions/settings-menu.ts.
|
|
2911
|
+
*/
|
|
2896
2912
|
async function openSettingsUI(ctx: ExtensionContext): Promise<void> {
|
|
2897
2913
|
for (;;) {
|
|
2898
|
-
const prov = settingsProvenance(ctx.cwd);
|
|
2899
|
-
const show = (k: keyof Settings, fallback: string) => {
|
|
2900
|
-
const p = prov[k];
|
|
2901
|
-
const v = p.value === undefined ? fallback : String(p.value);
|
|
2902
|
-
return `${v} [${p.source}]`;
|
|
2903
|
-
};
|
|
2904
2914
|
const settings = loadSettings(ctx.cwd);
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
"── Keep-going ──",
|
|
2910
|
-
`Auto-resume on load — ${show("autoResume", "default")} — on: resume on session load too · off: never · default: hold on load, resume on reload/fork`,
|
|
2911
|
-
`Auto-accept drafts — ${show("autoAcceptDrafts", "(off)")} — on: goal/loop drafts activate without the Confirm dialog (unattended rigs)`,
|
|
2912
|
-
`Aggressive mode — ${show("aggressiveMode", "(off)")} — flips DEFAULTS toward keep-going (autoResume, cap 10, stuck 10, wedge off, quota auto-retry, cap→TODOs); explicit per-key settings still win`,
|
|
2913
|
-
"── Auditor ──",
|
|
2914
|
-
`Auditor model — ${show("auditorModel", "(pi session model)")} — provider/model override for the isolated auditor`,
|
|
2915
|
-
`Auditor thinking — ${show("auditorThinkingLevel", "(session, floor high)")} — thinking level for the auditor session`,
|
|
2916
|
-
`Audit cap — ${show("auditCap", "(5)")} — pause the goal after N consecutive disapprovals (0 = unlimited)`,
|
|
2917
|
-
`Audit feedback chars — ${show("auditFeedbackChars", "(full report)")} — cap the executor-visible disapproval report (0 = full report)`,
|
|
2918
|
-
`Quota retry minutes — ${show("quotaRetryMinutes", `(${DEFAULT_QUOTA_RETRY_MINUTES})`)} — auto-retry a quota-exhausted auditor after N minutes`,
|
|
2919
|
-
"── Stall brakes ──",
|
|
2920
|
-
`Wedge alert minutes — ${show("wedgeAlertMinutes", `(${WEDGE_ALERT_DEFAULT_MINUTES})`)} — hung-command alert while the session is busy (0 = off)`,
|
|
2921
|
-
`Stuck max interventions — ${show("stuckMaxInterventions", "(5)")} — consecutive stuck interventions before a loop stops`,
|
|
2922
|
-
`Stall escalation refires — ${show("stallEscalationRefires", "(5)")} — heartbeat refires with no turn before the goal pauses / loop stops (0 = never)`,
|
|
2923
|
-
`Stall short words — ${show("stallShortWords", `(${DEFAULT_STALL_SHORT_WORDS})`)} — turns with no tools AND fewer words than this count as a nudge`,
|
|
2924
|
-
`Stall similarity threshold — ${show("stallSimilarityThreshold", `(${DEFAULT_STALL_SIM_THRESHOLD})`)} — no-tool turns whose text is > this similar to the prior turn count as a nudge (0–1)`,
|
|
2925
|
-
"── Subagents ──",
|
|
2926
|
-
`Subagent model strategy — ${show("subagentModelStrategy", "(inherit-parent)")} — inherit-parent shares your session model+quota; agent-default pins haiku for Explore`,
|
|
2927
|
-
`Subagent Explore pin — ${settings.subagentModelOverrides?.Explore ?? "(follows strategy)"} — provider/model pin; always wins over strategy`,
|
|
2928
|
-
`Subagent Plan pin — ${settings.subagentModelOverrides?.Plan ?? "(follows strategy)"} — provider/model pin; always wins over strategy`,
|
|
2929
|
-
`Subagent general-purpose pin — ${settings.subagentModelOverrides?.["general-purpose"] ?? "(follows strategy)"} — provider/model pin; always wins over strategy`,
|
|
2930
|
-
"── Other ──",
|
|
2931
|
-
`Notify command — ${show("notifyCmd", "(off)")} — desktop push command; the event message is passed as $1`,
|
|
2932
|
-
`Token limit per goal — ${show("tokenLimit", "(off)")} — per-goal token budget; pause when exceeded (0 = off)`,
|
|
2933
|
-
`Reviewer config… — open the reviewer menu (post-completion follow-up enqueuer: mode, triggers, cascade, caps)`,
|
|
2934
|
-
"Done",
|
|
2935
|
-
];
|
|
2936
|
-
let choice: string | undefined;
|
|
2915
|
+
const prov = settingsProvenance(ctx.cwd);
|
|
2916
|
+
const rows = buildSettingsRows(settings, prov);
|
|
2917
|
+
const id = await promptSettingsMenu(ctx, rows);
|
|
2918
|
+
if (!id) return;
|
|
2937
2919
|
try {
|
|
2938
|
-
|
|
2939
|
-
`pi-goal-list-loop-audit settings — global: ${globalSettingsPath()}`,
|
|
2940
|
-
rows,
|
|
2941
|
-
);
|
|
2920
|
+
await handleSettingChoice(id, ctx);
|
|
2942
2921
|
} catch {
|
|
2943
2922
|
return;
|
|
2944
2923
|
}
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
}
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
}
|
|
3057
|
-
|
|
3058
|
-
|
|
2924
|
+
}
|
|
2925
|
+
}
|
|
2926
|
+
|
|
2927
|
+
/**
|
|
2928
|
+
* Show the table-rendered settings menu and return the user's pick id (or
|
|
2929
|
+
* undefined for cancel). Wraps `ctx.ui.custom` so openSettingsUI stays a thin
|
|
2930
|
+
* loop. Falls back to a select-based legacy menu when the runtime has no
|
|
2931
|
+
* `ctx.ui.custom` (the `ctx.hasUI` guard already protects this path elsewhere;
|
|
2932
|
+
* this is a second-line defense for headless custom-only shards).
|
|
2933
|
+
*/
|
|
2934
|
+
async function promptSettingsMenu(
|
|
2935
|
+
ctx: ExtensionContext,
|
|
2936
|
+
rows: SettingsRow[],
|
|
2937
|
+
): Promise<string | undefined> {
|
|
2938
|
+
const title = `pi-goal-list-loop-audit settings — global: ${globalSettingsPath()}`;
|
|
2939
|
+
if (typeof (ctx.ui as { custom?: unknown }).custom !== "function") {
|
|
2940
|
+
// Headless / no custom shard — fall back to the legacy flat-row select
|
|
2941
|
+
// for any environment that lacks the new primitive. This is rare and
|
|
2942
|
+
// effectively an emergency hatch; the new UI is the supported path.
|
|
2943
|
+
const flat = rows.map((r) => `${r.label} — ${r.valueText} [${r.sourceText.replace(/^\[|\]$/g, "")}] — ${r.description}`);
|
|
2944
|
+
flat.push("Done");
|
|
2945
|
+
const v = await ctx.ui.select(title, flat);
|
|
2946
|
+
if (!v || v === "Done") return undefined;
|
|
2947
|
+
return rows.find((r) => v.startsWith(r.label))?.id;
|
|
2948
|
+
}
|
|
2949
|
+
return await ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
|
|
2950
|
+
return new SettingsMenuComponent({ rows, title }, () => tui.requestRender(), theme, keybindings, done);
|
|
2951
|
+
});
|
|
2952
|
+
}
|
|
2953
|
+
|
|
2954
|
+
/**
|
|
2955
|
+
* v0.28.0: per-key dispatch for the settings menu. The id comes from
|
|
2956
|
+
* `buildSettingsRows` (e.g. "autoResume", "auditorModel", "subagentModelOverrides.Explore").
|
|
2957
|
+
* Same handlers as v0.27.0's if/else chain — only the trigger changed from
|
|
2958
|
+
* `startsWith(label)` strings to stable ids.
|
|
2959
|
+
*/
|
|
2960
|
+
async function handleSettingChoice(id: string, ctx: ExtensionContext): Promise<void> {
|
|
2961
|
+
switch (id) {
|
|
2962
|
+
case "autoResume": {
|
|
2963
|
+
const v = await ctx.ui.select("Auto-resume goals/loops on session start", [
|
|
2964
|
+
"default — HOLD when a session is loaded (popup shows what waits); auto-resume on reload/fork so machinery never strands work",
|
|
2965
|
+
"on — auto-resume on EVERY session start (unattended rigs)",
|
|
2966
|
+
"off — never auto-resume; always wait for an explicit resume",
|
|
2967
|
+
]);
|
|
2968
|
+
if (v) saveSettings("global", ctx.cwd, { autoResume: v.startsWith("on") ? true : v.startsWith("off") ? false : undefined });
|
|
2969
|
+
return;
|
|
2970
|
+
}
|
|
2971
|
+
case "autoAcceptDrafts": {
|
|
2972
|
+
const v = await ctx.ui.select("Auto-accept goal/loop drafts", [
|
|
2973
|
+
"off — the Confirm dialog gates every draft",
|
|
2974
|
+
"on — drafts activate immediately, no Confirm (unattended rigs)",
|
|
2975
|
+
]);
|
|
2976
|
+
if (v) saveSettings("global", ctx.cwd, { autoAcceptDrafts: v.startsWith("on") ? true : undefined });
|
|
2977
|
+
return;
|
|
2978
|
+
}
|
|
2979
|
+
case "aggressiveMode": {
|
|
2980
|
+
const v = await ctx.ui.select("Aggressive mode (flips DEFAULTS toward keep-going — explicit per-key settings still win)", [
|
|
2981
|
+
"off — current behavior: pause at the audit cap, wedge alerts on, manual resume",
|
|
2982
|
+
"on — autoResume, audit cap 10, stuck max 10, wedge alerts off, quota auto-retry, cap disapprovals become a TODO list and the goal KEEPS GOING",
|
|
2983
|
+
]);
|
|
2984
|
+
if (v) {
|
|
2985
|
+
saveSettings("global", ctx.cwd, { aggressiveMode: v.startsWith("on") });
|
|
2986
|
+
ctx.ui.notify(`Aggressive mode ${v.startsWith("on") ? "ON — goals keep going past the audit cap; objections become TODOs" : "off"}.`, "info");
|
|
2987
|
+
}
|
|
2988
|
+
return;
|
|
2989
|
+
}
|
|
2990
|
+
case "auditorModel": {
|
|
2991
|
+
const v = await ctx.ui.input("Auditor model override", "provider/model-id — empty keeps the pi session model");
|
|
2992
|
+
if (v !== undefined) saveSettings("global", ctx.cwd, { auditorModel: v.trim() || undefined });
|
|
2993
|
+
return;
|
|
2994
|
+
}
|
|
2995
|
+
case "auditorThinkingLevel": {
|
|
2996
|
+
const v = await ctx.ui.select("Auditor thinking level", ["off", "minimal", "low", "medium", "high", "xhigh"]);
|
|
2997
|
+
if (v) saveSettings("global", ctx.cwd, { auditorThinkingLevel: v as Settings["auditorThinkingLevel"] });
|
|
2998
|
+
return;
|
|
2999
|
+
}
|
|
3000
|
+
case "auditCap": {
|
|
3001
|
+
const v = await ctx.ui.input("Consecutive auditor disapprovals before the goal pauses", "non-negative integer; 0 = unlimited, empty = default 5");
|
|
3002
|
+
if (v !== undefined) {
|
|
3003
|
+
const n = Number.parseInt(v.trim(), 10);
|
|
3004
|
+
if (Number.isFinite(n) && n >= 0) saveSettings("global", ctx.cwd, { auditCap: n });
|
|
3005
|
+
else if (!v.trim()) saveSettings("global", ctx.cwd, { auditCap: undefined });
|
|
3006
|
+
else ctx.ui.notify(`Not a non-negative integer: ${v}`, "warning");
|
|
3007
|
+
}
|
|
3008
|
+
return;
|
|
3009
|
+
}
|
|
3010
|
+
case "auditFeedbackChars": {
|
|
3011
|
+
const v = await ctx.ui.input("Auditor feedback returned to the executor (characters)", "non-negative integer cap; 0 or empty = full report (default)");
|
|
3012
|
+
if (v !== undefined) {
|
|
3013
|
+
const raw = v.trim();
|
|
3014
|
+
const n = Number(raw);
|
|
3015
|
+
if (/^\d+$/.test(raw) && Number.isSafeInteger(n)) saveSettings("global", ctx.cwd, { auditFeedbackChars: n });
|
|
3016
|
+
else if (!v.trim()) saveSettings("global", ctx.cwd, { auditFeedbackChars: undefined });
|
|
3017
|
+
else ctx.ui.notify(`Not a non-negative integer: ${v}`, "warning");
|
|
3018
|
+
}
|
|
3019
|
+
return;
|
|
3020
|
+
}
|
|
3021
|
+
case "quotaRetryMinutes": {
|
|
3022
|
+
const v = await ctx.ui.input("Minutes before auto-retrying a quota-exhausted auditor", `positive integer; empty = default ${DEFAULT_QUOTA_RETRY_MINUTES}`);
|
|
3023
|
+
if (v !== undefined) {
|
|
3024
|
+
const n = Number.parseInt(v.trim(), 10);
|
|
3025
|
+
if (Number.isFinite(n) && n > 0) saveSettings("global", ctx.cwd, { quotaRetryMinutes: n });
|
|
3026
|
+
else if (!v.trim()) saveSettings("global", ctx.cwd, { quotaRetryMinutes: undefined });
|
|
3027
|
+
else ctx.ui.notify(`Not a positive integer: ${v}`, "warning");
|
|
3028
|
+
}
|
|
3029
|
+
return;
|
|
3030
|
+
}
|
|
3031
|
+
case "wedgeAlertMinutes": {
|
|
3032
|
+
const v = await ctx.ui.input("Wedge alert threshold (minutes)", "non-negative integer; 0 = off, empty = default 30");
|
|
3033
|
+
if (v !== undefined) {
|
|
3034
|
+
const n = Number.parseInt(v.trim(), 10);
|
|
3035
|
+
if (Number.isFinite(n) && n >= 0) saveSettings("global", ctx.cwd, { wedgeAlertMinutes: n });
|
|
3036
|
+
else if (!v.trim()) saveSettings("global", ctx.cwd, { wedgeAlertMinutes: undefined });
|
|
3037
|
+
else ctx.ui.notify(`Not a non-negative integer: ${v}`, "warning");
|
|
3038
|
+
}
|
|
3039
|
+
return;
|
|
3040
|
+
}
|
|
3041
|
+
case "stuckMaxInterventions": {
|
|
3042
|
+
const v = await ctx.ui.input("Consecutive stuck interventions before a loop stops", "positive integer; empty = default 5 (10 under aggressiveMode)");
|
|
3043
|
+
if (v !== undefined) {
|
|
3044
|
+
const n = Number.parseInt(v.trim(), 10);
|
|
3045
|
+
if (Number.isFinite(n) && n > 0) saveSettings("global", ctx.cwd, { stuckMaxInterventions: n });
|
|
3046
|
+
else if (!v.trim()) saveSettings("global", ctx.cwd, { stuckMaxInterventions: undefined });
|
|
3047
|
+
else ctx.ui.notify(`Not a positive integer: ${v}`, "warning");
|
|
3048
|
+
}
|
|
3049
|
+
return;
|
|
3050
|
+
}
|
|
3051
|
+
case "stallEscalationRefires": {
|
|
3052
|
+
const v = await ctx.ui.input("Heartbeat refires without a turn before the goal pauses / loop stops", "non-negative integer; 0 = never escalate, empty = default 5");
|
|
3053
|
+
if (v !== undefined) {
|
|
3054
|
+
const n = Number.parseInt(v.trim(), 10);
|
|
3055
|
+
if (Number.isFinite(n) && n >= 0) saveSettings("global", ctx.cwd, { stallEscalationRefires: n });
|
|
3056
|
+
else if (!v.trim()) saveSettings("global", ctx.cwd, { stallEscalationRefires: undefined });
|
|
3057
|
+
else ctx.ui.notify(`Not a non-negative integer: ${v}`, "warning");
|
|
3059
3058
|
}
|
|
3060
|
-
} catch {
|
|
3061
3059
|
return;
|
|
3062
3060
|
}
|
|
3061
|
+
case "stallShortWords": {
|
|
3062
|
+
const v = await ctx.ui.input("Stall short words threshold", "non-negative integer; 0 = off, empty = default 15");
|
|
3063
|
+
if (v !== undefined) {
|
|
3064
|
+
const n = Number.parseInt(v.trim(), 10);
|
|
3065
|
+
if (Number.isFinite(n) && n >= 0) saveSettings("global", ctx.cwd, { stallShortWords: n });
|
|
3066
|
+
else if (!v.trim()) saveSettings("global", ctx.cwd, { stallShortWords: undefined });
|
|
3067
|
+
else ctx.ui.notify(`Not a non-negative integer: ${v}`, "warning");
|
|
3068
|
+
}
|
|
3069
|
+
return;
|
|
3070
|
+
}
|
|
3071
|
+
case "stallSimilarityThreshold": {
|
|
3072
|
+
const v = await ctx.ui.input("Stall similarity threshold (0..1)", "decimal between 0 and 1; empty = default 0.6");
|
|
3073
|
+
if (v !== undefined) {
|
|
3074
|
+
const n = Number(v.trim());
|
|
3075
|
+
if (Number.isFinite(n) && n >= 0 && n <= 1) saveSettings("global", ctx.cwd, { stallSimilarityThreshold: n });
|
|
3076
|
+
else if (!v.trim()) saveSettings("global", ctx.cwd, { stallSimilarityThreshold: undefined });
|
|
3077
|
+
else ctx.ui.notify(`Not a decimal between 0 and 1: ${v}`, "warning");
|
|
3078
|
+
}
|
|
3079
|
+
return;
|
|
3080
|
+
}
|
|
3081
|
+
case "subagentModelStrategy": {
|
|
3082
|
+
const v = await ctx.ui.select("Subagent model (pi-subagents default agents)", [
|
|
3083
|
+
"inherit-parent — share your session model + quota pool (recommended)",
|
|
3084
|
+
"agent-default — use the upstream pi-subagents default agents",
|
|
3085
|
+
]);
|
|
3086
|
+
if (v) {
|
|
3087
|
+
const strategy: SubagentModelStrategy = v.startsWith("agent-default") ? "agent-default" : "inherit-parent";
|
|
3088
|
+
saveSettings("global", ctx.cwd, { subagentModelStrategy: strategy });
|
|
3089
|
+
ctx.ui.notify("Subagent model strategy saved — applies to NEW pi sessions (pi-subagents registers agents at session start).", "info");
|
|
3090
|
+
}
|
|
3091
|
+
return;
|
|
3092
|
+
}
|
|
3093
|
+
case "subagentModelOverrides.Explore":
|
|
3094
|
+
case "subagentModelOverrides.Plan":
|
|
3095
|
+
case "subagentModelOverrides.general-purpose": {
|
|
3096
|
+
const agentType = id.slice("subagentModelOverrides.".length);
|
|
3097
|
+
const v = await ctx.ui.input(`Model pin for ${agentType} subagents`, "provider/model-id e.g. minimax/MiniMax-M3 — always wins over strategy; empty = follow strategy");
|
|
3098
|
+
if (v !== undefined) {
|
|
3099
|
+
const current = loadSettings(ctx.cwd).subagentModelOverrides ?? {};
|
|
3100
|
+
const next = { ...current };
|
|
3101
|
+
if (v.trim()) next[agentType] = v.trim();
|
|
3102
|
+
else delete next[agentType];
|
|
3103
|
+
saveSettings("global", ctx.cwd, { subagentModelOverrides: Object.keys(next).length > 0 ? next : undefined });
|
|
3104
|
+
ctx.ui.notify(`${agentType} model pin saved — applies to NEW pi sessions.`, "info");
|
|
3105
|
+
}
|
|
3106
|
+
return;
|
|
3107
|
+
}
|
|
3108
|
+
case "subagentResolved":
|
|
3109
|
+
// Read-only (effective resolution row) — no editor; row just shows the
|
|
3110
|
+
// current effective subagent models. Treat as no-op.
|
|
3111
|
+
return;
|
|
3112
|
+
case "notifyCmd": {
|
|
3113
|
+
const v = await ctx.ui.input("Notify command — the event message is passed as $1", "e.g. a desktop-notification or push command; empty = off");
|
|
3114
|
+
if (v !== undefined) saveSettings("global", ctx.cwd, { notifyCmd: v.trim() || undefined });
|
|
3115
|
+
return;
|
|
3116
|
+
}
|
|
3117
|
+
case "tokenLimit": {
|
|
3118
|
+
const v = await ctx.ui.input("Per-goal token budget", "non-negative integer; 0 or empty = off (no cap)");
|
|
3119
|
+
if (v !== undefined) {
|
|
3120
|
+
const n = Number.parseInt(v.trim(), 10);
|
|
3121
|
+
if (Number.isFinite(n) && n >= 0) saveSettings("global", ctx.cwd, { tokenLimit: n });
|
|
3122
|
+
else if (!v.trim()) saveSettings("global", ctx.cwd, { tokenLimit: undefined });
|
|
3123
|
+
else ctx.ui.notify(`Not a non-negative integer: ${v}`, "warning");
|
|
3124
|
+
}
|
|
3125
|
+
return;
|
|
3126
|
+
}
|
|
3127
|
+
case "postaudit":
|
|
3128
|
+
await cmdReviewerSettings(ctx);
|
|
3129
|
+
return;
|
|
3130
|
+
default:
|
|
3131
|
+
// Unknown id — keep the menu looping. Surface a soft warning so the
|
|
3132
|
+
// user knows a row existed but had no handler (better than silently
|
|
3133
|
+
// swallowing it).
|
|
3134
|
+
ctx.ui.notify(`/glla: unknown setting id "${id}" — please report this.`, "warning");
|
|
3135
|
+
return;
|
|
3063
3136
|
}
|
|
3064
3137
|
}
|
|
3065
3138
|
|
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
// pi-goal-list-loop-audit — v0.28.0
|
|
2
|
+
// extensions/settings-menu.ts
|
|
3
|
+
//
|
|
4
|
+
// The /glla settings menu as a real TUI table (v0.28.0).
|
|
5
|
+
//
|
|
6
|
+
// Pre-0.28.0 used `ctx.ui.select` with flat single-line rows; v0.28.0
|
|
7
|
+
// replaces it with a `ctx.ui.custom` Container/Text layout featuring:
|
|
8
|
+
// • a top TABS row listing all 5 sections (left/right to switch sections)
|
|
9
|
+
// • a 4-column table for the active section (KEY | VALUE | SOURCE | DESCRIPTION)
|
|
10
|
+
// • up/down navigation scoped to the active section's rows
|
|
11
|
+
// • Enter → emit the selected row's id (caller dispatches handler)
|
|
12
|
+
// • Esc / Ctrl+C → emit undefined (caller exits)
|
|
13
|
+
//
|
|
14
|
+
// Sections (5 total) map to the pre-0.28.0 menu groupings:
|
|
15
|
+
// keep-going | auditor | stall-brakes | subagents | other
|
|
16
|
+
//
|
|
17
|
+
// Extracted into its own module so tests can import `buildSettingsRows` directly
|
|
18
|
+
// (mirrors how `readState` lives in goal-loop-core.ts) and so the renderer is
|
|
19
|
+
// unit-testable via synthetic handleInput calls (no live TUI needed).
|
|
20
|
+
//
|
|
21
|
+
// The pre-v0.28.0 headless fallback (`/glla` with no args and no UI) keeps its
|
|
22
|
+
// existing text rendering — that's still the right shape for tmux/cron. Only
|
|
23
|
+
// the TUI menu becomes a table.
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
type Component,
|
|
27
|
+
truncateToWidth,
|
|
28
|
+
visibleWidth,
|
|
29
|
+
} from "@earendil-works/pi-tui";
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
DEFAULT_AUDIT_FEEDBACK_CHARS,
|
|
33
|
+
DEFAULT_QUOTA_RETRY_MINUTES,
|
|
34
|
+
DEFAULT_STALL_ESCALATION_REFIRES,
|
|
35
|
+
} from "./goal-loop-core.ts";
|
|
36
|
+
import {
|
|
37
|
+
DEFAULT_STALL_SIM_THRESHOLD,
|
|
38
|
+
DEFAULT_STALL_SHORT_WORDS,
|
|
39
|
+
WEDGE_ALERT_DEFAULT_MINUTES,
|
|
40
|
+
} from "./goal-loop-backoff.ts";
|
|
41
|
+
import type { Settings } from "./goal-settings.ts";
|
|
42
|
+
import { resolveEffectiveSubagentModel } from "./goal-loop-subagents.ts";
|
|
43
|
+
|
|
44
|
+
// =================================================================
|
|
45
|
+
// Pure row builder (testable + reusable from the headless fallback)
|
|
46
|
+
// =================================================================
|
|
47
|
+
|
|
48
|
+
export type SettingsSectionId =
|
|
49
|
+
| "keep-going"
|
|
50
|
+
| "auditor"
|
|
51
|
+
| "stall-brakes"
|
|
52
|
+
| "subagents"
|
|
53
|
+
| "other";
|
|
54
|
+
|
|
55
|
+
export const SETTINGS_SECTIONS: readonly { id: SettingsSectionId; label: string }[] = [
|
|
56
|
+
{ id: "keep-going", label: "Keep-going" },
|
|
57
|
+
{ id: "auditor", label: "Auditor" },
|
|
58
|
+
{ id: "stall-brakes", label: "Stall brakes" },
|
|
59
|
+
{ id: "subagents", label: "Subagents" },
|
|
60
|
+
{ id: "other", label: "Other" },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
/** One menu row. `id` is the stable dispatch key (caller switch(id) → handler). */
|
|
64
|
+
export interface SettingsRow {
|
|
65
|
+
/** Stable dispatch key — used both as the table id and as the switch(id) case. */
|
|
66
|
+
id: string;
|
|
67
|
+
/** Which section this row belongs to. */
|
|
68
|
+
section: SettingsSectionId;
|
|
69
|
+
/** KEY column — the setting name (left-aligned, padded to keyW). */
|
|
70
|
+
label: string;
|
|
71
|
+
/** VALUE column — current effective value, e.g. `true` / `(off)` / `60`. */
|
|
72
|
+
valueText: string;
|
|
73
|
+
/** SOURCE column — provenance tag, one of `[project]` / `[global]` / `[default]`. */
|
|
74
|
+
sourceText: string;
|
|
75
|
+
/** DESCRIPTION column — one-line explanation; truncated with ellipsis when narrow. */
|
|
76
|
+
description: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type ProvenanceSource = "project" | "global" | "default";
|
|
80
|
+
|
|
81
|
+
export interface MenuProvenance {
|
|
82
|
+
value: unknown;
|
|
83
|
+
source: ProvenanceSource;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Defaults surfaced in the menu when the user has not set a value. */
|
|
87
|
+
export interface MenuDefaults {
|
|
88
|
+
auditCap: number;
|
|
89
|
+
stuckMaxInterventions: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const DEFAULT_MENU_DEFAULTS: MenuDefaults = {
|
|
93
|
+
auditCap: 5,
|
|
94
|
+
stuckMaxInterventions: 5,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** Subagent model provenance context needed to render the subagent pins column. */
|
|
98
|
+
export interface MenuSubagentContext {
|
|
99
|
+
/** Active session model id (provider/model) — used by inherit-parent resolution. */
|
|
100
|
+
sessionModel?: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Build the full ordered list of menu rows for every section.
|
|
105
|
+
* Pure: no I/O, no extension context; the renderer composes sections onto rows.
|
|
106
|
+
*/
|
|
107
|
+
export function buildSettingsRows(
|
|
108
|
+
settings: Settings,
|
|
109
|
+
prov: Partial<Record<keyof Settings, MenuProvenance>>,
|
|
110
|
+
subagent: MenuSubagentContext = {},
|
|
111
|
+
defaults: MenuDefaults = DEFAULT_MENU_DEFAULTS,
|
|
112
|
+
): SettingsRow[] {
|
|
113
|
+
const provFor = (k: keyof Settings): MenuProvenance =>
|
|
114
|
+
prov[k] ?? { value: undefined, source: "default" };
|
|
115
|
+
const show = (k: keyof Settings, fallback: string): string => {
|
|
116
|
+
const p = provFor(k);
|
|
117
|
+
return p.value === undefined ? fallback : String(p.value);
|
|
118
|
+
};
|
|
119
|
+
const src = (k: keyof Settings): string => `[${provFor(k).source}]`;
|
|
120
|
+
|
|
121
|
+
const rows: SettingsRow[] = [];
|
|
122
|
+
|
|
123
|
+
// ── Keep-going ──
|
|
124
|
+
rows.push(
|
|
125
|
+
{
|
|
126
|
+
id: "autoResume",
|
|
127
|
+
section: "keep-going",
|
|
128
|
+
label: "Auto-resume on load",
|
|
129
|
+
valueText: show("autoResume", "default"),
|
|
130
|
+
sourceText: src("autoResume"),
|
|
131
|
+
description:
|
|
132
|
+
"on: resume on session load too · off: never · default: hold on load, resume on reload/fork",
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
id: "autoAcceptDrafts",
|
|
136
|
+
section: "keep-going",
|
|
137
|
+
label: "Auto-accept drafts",
|
|
138
|
+
valueText: show("autoAcceptDrafts", "(off)"),
|
|
139
|
+
sourceText: src("autoAcceptDrafts"),
|
|
140
|
+
description: "on: goal/loop drafts activate without the Confirm dialog (unattended rigs)",
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
id: "aggressiveMode",
|
|
144
|
+
section: "keep-going",
|
|
145
|
+
label: "Aggressive mode",
|
|
146
|
+
valueText: show("aggressiveMode", "(off)"),
|
|
147
|
+
sourceText: src("aggressiveMode"),
|
|
148
|
+
description:
|
|
149
|
+
"flips DEFAULTS toward keep-going (autoResume, cap 10, stuck 10, wedge off, quota auto-retry, cap→TODOs); explicit per-key settings still win",
|
|
150
|
+
},
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
// ── Auditor ──
|
|
154
|
+
rows.push(
|
|
155
|
+
{
|
|
156
|
+
id: "auditorModel",
|
|
157
|
+
section: "auditor",
|
|
158
|
+
label: "Auditor model",
|
|
159
|
+
valueText: show("auditorModel", "(pi session model)"),
|
|
160
|
+
sourceText: src("auditorModel"),
|
|
161
|
+
description: "provider/model override for the isolated auditor",
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
id: "auditorThinkingLevel",
|
|
165
|
+
section: "auditor",
|
|
166
|
+
label: "Auditor thinking",
|
|
167
|
+
valueText: show("auditorThinkingLevel", "(session, floor high)"),
|
|
168
|
+
sourceText: src("auditorThinkingLevel"),
|
|
169
|
+
description: "thinking level for the auditor session",
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
id: "auditCap",
|
|
173
|
+
section: "auditor",
|
|
174
|
+
label: "Audit cap",
|
|
175
|
+
valueText: show("auditCap", `(${defaults.auditCap})`),
|
|
176
|
+
sourceText: src("auditCap"),
|
|
177
|
+
description: "pause the goal after N consecutive disapprovals (0 = unlimited)",
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
id: "auditFeedbackChars",
|
|
181
|
+
section: "auditor",
|
|
182
|
+
label: "Audit feedback chars",
|
|
183
|
+
valueText: show(
|
|
184
|
+
"auditFeedbackChars",
|
|
185
|
+
DEFAULT_AUDIT_FEEDBACK_CHARS === 0 ? "(full report)" : `(${DEFAULT_AUDIT_FEEDBACK_CHARS})`,
|
|
186
|
+
),
|
|
187
|
+
sourceText: src("auditFeedbackChars"),
|
|
188
|
+
description: "cap the executor-visible disapproval report (0 = full report)",
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
id: "quotaRetryMinutes",
|
|
192
|
+
section: "auditor",
|
|
193
|
+
label: "Quota retry minutes",
|
|
194
|
+
valueText: show("quotaRetryMinutes", `(${DEFAULT_QUOTA_RETRY_MINUTES})`),
|
|
195
|
+
sourceText: src("quotaRetryMinutes"),
|
|
196
|
+
description: "auto-retry a quota-exhausted auditor after N minutes",
|
|
197
|
+
},
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
// ── Stall brakes ──
|
|
201
|
+
rows.push(
|
|
202
|
+
{
|
|
203
|
+
id: "wedgeAlertMinutes",
|
|
204
|
+
section: "stall-brakes",
|
|
205
|
+
label: "Wedge alert minutes",
|
|
206
|
+
valueText: show("wedgeAlertMinutes", `(${WEDGE_ALERT_DEFAULT_MINUTES})`),
|
|
207
|
+
sourceText: src("wedgeAlertMinutes"),
|
|
208
|
+
description: "hung-command alert while the session is busy (0 = off)",
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
id: "stuckMaxInterventions",
|
|
212
|
+
section: "stall-brakes",
|
|
213
|
+
label: "Stuck max interventions",
|
|
214
|
+
valueText: show("stuckMaxInterventions", `(${defaults.stuckMaxInterventions})`),
|
|
215
|
+
sourceText: src("stuckMaxInterventions"),
|
|
216
|
+
description: "consecutive stuck interventions before a loop stops",
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
id: "stallEscalationRefires",
|
|
220
|
+
section: "stall-brakes",
|
|
221
|
+
label: "Stall escalation refires",
|
|
222
|
+
valueText: show("stallEscalationRefires", `(${DEFAULT_STALL_ESCALATION_REFIRES})`),
|
|
223
|
+
sourceText: src("stallEscalationRefires"),
|
|
224
|
+
description:
|
|
225
|
+
"heartbeat refires with no turn before the goal pauses / loop stops (0 = never)",
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
id: "stallShortWords",
|
|
229
|
+
section: "stall-brakes",
|
|
230
|
+
label: "Stall short words",
|
|
231
|
+
valueText: show("stallShortWords", `(${DEFAULT_STALL_SHORT_WORDS})`),
|
|
232
|
+
sourceText: src("stallShortWords"),
|
|
233
|
+
description: "turns with no tools AND fewer words than this count as a nudge",
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
id: "stallSimilarityThreshold",
|
|
237
|
+
section: "stall-brakes",
|
|
238
|
+
label: "Stall similarity threshold",
|
|
239
|
+
valueText: show("stallSimilarityThreshold", `(${DEFAULT_STALL_SIM_THRESHOLD})`),
|
|
240
|
+
sourceText: src("stallSimilarityThreshold"),
|
|
241
|
+
description:
|
|
242
|
+
"no-tool turns whose text is > this similar to the prior turn count as a nudge (0–1)",
|
|
243
|
+
},
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
// ── Subagents ──
|
|
247
|
+
rows.push(
|
|
248
|
+
{
|
|
249
|
+
id: "subagentModelStrategy",
|
|
250
|
+
section: "subagents",
|
|
251
|
+
label: "Subagent model strategy",
|
|
252
|
+
valueText: show("subagentModelStrategy", "(inherit-parent)"),
|
|
253
|
+
sourceText: src("subagentModelStrategy"),
|
|
254
|
+
description:
|
|
255
|
+
"inherit-parent shares your session model + quota pool; agent-default uses the upstream pi-subagents default agents",
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
id: "subagentModelOverrides.Explore",
|
|
259
|
+
section: "subagents",
|
|
260
|
+
label: "Subagent Explore pin",
|
|
261
|
+
valueText: settings.subagentModelOverrides?.Explore ?? "(follows strategy)",
|
|
262
|
+
sourceText:
|
|
263
|
+
settings.subagentModelOverrides?.Explore !== undefined
|
|
264
|
+
? src("subagentModelOverrides")
|
|
265
|
+
: "[default]",
|
|
266
|
+
description: "provider/model pin; always wins over strategy",
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
id: "subagentModelOverrides.Plan",
|
|
270
|
+
section: "subagents",
|
|
271
|
+
label: "Subagent Plan pin",
|
|
272
|
+
valueText: settings.subagentModelOverrides?.Plan ?? "(follows strategy)",
|
|
273
|
+
sourceText:
|
|
274
|
+
settings.subagentModelOverrides?.Plan !== undefined
|
|
275
|
+
? src("subagentModelOverrides")
|
|
276
|
+
: "[default]",
|
|
277
|
+
description: "provider/model pin; always wins over strategy",
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
id: "subagentModelOverrides.general-purpose",
|
|
281
|
+
section: "subagents",
|
|
282
|
+
label: "Subagent general-purpose pin",
|
|
283
|
+
valueText: settings.subagentModelOverrides?.["general-purpose"] ?? "(follows strategy)",
|
|
284
|
+
sourceText:
|
|
285
|
+
settings.subagentModelOverrides?.["general-purpose"] !== undefined
|
|
286
|
+
? src("subagentModelOverrides")
|
|
287
|
+
: "[default]",
|
|
288
|
+
description: "provider/model pin; always wins over strategy",
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
id: "subagentResolved",
|
|
292
|
+
section: "subagents",
|
|
293
|
+
label: "Effective resolution",
|
|
294
|
+
valueText: [
|
|
295
|
+
resolveEffectiveSubagentModel("Explore", settings, subagent.sessionModel),
|
|
296
|
+
resolveEffectiveSubagentModel("Plan", settings, subagent.sessionModel),
|
|
297
|
+
resolveEffectiveSubagentModel("general-purpose", settings, subagent.sessionModel),
|
|
298
|
+
].join(" · "),
|
|
299
|
+
sourceText: "[runtime]",
|
|
300
|
+
description: "effective Explore / Plan / general-purpose model given current settings",
|
|
301
|
+
},
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
// ── Other ──
|
|
305
|
+
rows.push(
|
|
306
|
+
{
|
|
307
|
+
id: "notifyCmd",
|
|
308
|
+
section: "other",
|
|
309
|
+
label: "Notify command",
|
|
310
|
+
valueText: show("notifyCmd", "(off)"),
|
|
311
|
+
sourceText: src("notifyCmd"),
|
|
312
|
+
description: "desktop push command; the event message is passed as $1",
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
id: "tokenLimit",
|
|
316
|
+
section: "other",
|
|
317
|
+
label: "Token limit per goal",
|
|
318
|
+
valueText: show("tokenLimit", "(off)"),
|
|
319
|
+
sourceText: src("tokenLimit"),
|
|
320
|
+
description: "per-goal token budget; pause when exceeded (0 = off)",
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
id: "postaudit",
|
|
324
|
+
section: "other",
|
|
325
|
+
label: "Postaudit config…",
|
|
326
|
+
valueText: "open sub-menu",
|
|
327
|
+
sourceText: "[—]",
|
|
328
|
+
description:
|
|
329
|
+
"post-completion follow-up enqueuer: mode, triggers, cascade, caps (postaudit / reviewer)",
|
|
330
|
+
},
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
return rows;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// =================================================================
|
|
337
|
+
// TUI table component
|
|
338
|
+
// =================================================================
|
|
339
|
+
|
|
340
|
+
/** Padding + gutter between columns. */
|
|
341
|
+
const COL_GUTTER = 2;
|
|
342
|
+
|
|
343
|
+
/** Maximum width for each fixed column before truncation kicks in. */
|
|
344
|
+
const MAX_KEY_W = 32;
|
|
345
|
+
const MAX_VALUE_W = 24;
|
|
346
|
+
const MAX_SOURCE_W = 10;
|
|
347
|
+
const MIN_DESC_W = 12;
|
|
348
|
+
|
|
349
|
+
/** A minimal subset of pi-tui's Theme interface used by the renderer. */
|
|
350
|
+
export interface SettingsMenuTheme {
|
|
351
|
+
fg(color: "accent" | "muted" | "dim" | "warning" | "success", text: string): string;
|
|
352
|
+
bold(text: string): string;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Structural type for the KeybindingsManager — avoids pulling in the
|
|
356
|
+
* full class so callers can supply any compatible implementation.
|
|
357
|
+
* (Top-level and nested pi-tui ship separate KeybindingsManager classes
|
|
358
|
+
* with private fields; structural typing sidesteps the cross-package type
|
|
359
|
+
* incompatibility entirely.) */
|
|
360
|
+
export interface KeybindingsManagerLike {
|
|
361
|
+
matches(data: string, key: string): boolean;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export interface SettingsMenuFactoryDeps {
|
|
365
|
+
rows: SettingsRow[];
|
|
366
|
+
title: string;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* TUI Component for the /glla settings menu. Renders a top tabs row + a
|
|
371
|
+
* 4-column table for the active section. The host (extensions/loops/goal.ts)
|
|
372
|
+
* constructs it via `ctx.ui.custom(...)` and dispatches the returned id
|
|
373
|
+
* with `switch (id)` instead of the pre-v0.28.0 `startsWith` strings.
|
|
374
|
+
*/
|
|
375
|
+
export class SettingsMenuComponent implements Component {
|
|
376
|
+
private readonly rows: SettingsRow[];
|
|
377
|
+
private readonly title: string;
|
|
378
|
+
private readonly requestRender: () => void;
|
|
379
|
+
private readonly theme: SettingsMenuTheme;
|
|
380
|
+
private readonly keybindings: KeybindingsManagerLike;
|
|
381
|
+
private readonly done: (id: string | undefined) => void;
|
|
382
|
+
|
|
383
|
+
private activeSectionIdx: number;
|
|
384
|
+
private selectedIdx: number;
|
|
385
|
+
private cachedWidth?: number;
|
|
386
|
+
private cachedLines?: string[];
|
|
387
|
+
|
|
388
|
+
constructor(
|
|
389
|
+
deps: SettingsMenuFactoryDeps,
|
|
390
|
+
requestRender: () => void,
|
|
391
|
+
theme: SettingsMenuTheme,
|
|
392
|
+
keybindings: KeybindingsManagerLike,
|
|
393
|
+
done: (id: string | undefined) => void,
|
|
394
|
+
) {
|
|
395
|
+
this.rows = deps.rows;
|
|
396
|
+
this.title = deps.title;
|
|
397
|
+
this.requestRender = requestRender;
|
|
398
|
+
this.theme = theme;
|
|
399
|
+
this.keybindings = keybindings;
|
|
400
|
+
this.done = done;
|
|
401
|
+
this.activeSectionIdx = 0;
|
|
402
|
+
this.selectedIdx = 0;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Index into `SETTINGS_SECTIONS`. Exposed for tests. */
|
|
406
|
+
getActiveSectionIdx(): number {
|
|
407
|
+
return this.activeSectionIdx;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Index into the active-section's visible rows. Exposed for tests. */
|
|
411
|
+
getSelectedIdx(): number {
|
|
412
|
+
return this.selectedIdx;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Rows in the active section. Exposed for tests. */
|
|
416
|
+
visibleRows(): SettingsRow[] {
|
|
417
|
+
return this.rows.filter(
|
|
418
|
+
(r) => r.section === SETTINGS_SECTIONS[this.activeSectionIdx]!.id,
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
private refresh(): void {
|
|
423
|
+
this.cachedWidth = undefined;
|
|
424
|
+
this.cachedLines = undefined;
|
|
425
|
+
this.requestRender();
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Move within the active section, wrapping at ends. Exposed for tests. */
|
|
429
|
+
move(delta: number): void {
|
|
430
|
+
const vs = this.visibleRows();
|
|
431
|
+
if (vs.length === 0) return;
|
|
432
|
+
const n = vs.length;
|
|
433
|
+
this.selectedIdx = ((this.selectedIdx + delta) % n + n) % n;
|
|
434
|
+
this.refresh();
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Switch section. -1 = left, +1 = right; wraps at ends. Exposed for tests. */
|
|
438
|
+
switchSection(delta: number): void {
|
|
439
|
+
const n = SETTINGS_SECTIONS.length;
|
|
440
|
+
this.activeSectionIdx = ((this.activeSectionIdx + delta) % n + n) % n;
|
|
441
|
+
this.selectedIdx = 0;
|
|
442
|
+
this.refresh();
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private resolveSelectedId(): string | undefined {
|
|
446
|
+
return this.visibleRows()[this.selectedIdx]?.id;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
private widths(width: number) {
|
|
450
|
+
let keyW = visibleWidth(this.theme.bold("KEY"));
|
|
451
|
+
let valueW = visibleWidth(this.theme.bold("VALUE"));
|
|
452
|
+
let sourceW = visibleWidth(this.theme.bold("SOURCE"));
|
|
453
|
+
for (const r of this.visibleRows()) {
|
|
454
|
+
if (visibleWidth(r.label) > keyW) keyW = visibleWidth(r.label);
|
|
455
|
+
if (visibleWidth(r.valueText) > valueW) valueW = visibleWidth(r.valueText);
|
|
456
|
+
if (visibleWidth(r.sourceText) > sourceW) sourceW = visibleWidth(r.sourceText);
|
|
457
|
+
}
|
|
458
|
+
keyW = Math.min(keyW, MAX_KEY_W);
|
|
459
|
+
valueW = Math.min(valueW, MAX_VALUE_W);
|
|
460
|
+
sourceW = Math.min(sourceW, MAX_SOURCE_W);
|
|
461
|
+
const descW = Math.max(MIN_DESC_W, width - keyW - valueW - sourceW - 3 * COL_GUTTER);
|
|
462
|
+
return { keyW, valueW, sourceW, descW };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
private padEnd(text: string, width: number): string {
|
|
466
|
+
const w = visibleWidth(text);
|
|
467
|
+
return w >= width ? text : text + " ".repeat(width - w);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
private renderBody(width: number): string[] {
|
|
471
|
+
const { keyW, valueW, sourceW, descW } = this.widths(width);
|
|
472
|
+
const gutter = " ".repeat(COL_GUTTER);
|
|
473
|
+
|
|
474
|
+
const lines: string[] = [];
|
|
475
|
+
|
|
476
|
+
lines.push(this.theme.fg("accent", this.theme.bold(this.title)));
|
|
477
|
+
|
|
478
|
+
lines.push(
|
|
479
|
+
SETTINGS_SECTIONS.map((s, i) =>
|
|
480
|
+
i === this.activeSectionIdx
|
|
481
|
+
? this.theme.fg("accent", `[${s.label}]`)
|
|
482
|
+
: this.theme.fg("dim", s.label),
|
|
483
|
+
).join(" "),
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
lines.push(
|
|
487
|
+
[
|
|
488
|
+
this.padEnd(this.theme.bold("KEY"), keyW),
|
|
489
|
+
this.padEnd(this.theme.bold("VALUE"), valueW),
|
|
490
|
+
this.padEnd(this.theme.bold("SOURCE"), sourceW),
|
|
491
|
+
this.theme.bold("DESCRIPTION"),
|
|
492
|
+
].join(gutter),
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
const vs = this.visibleRows();
|
|
496
|
+
if (vs.length === 0) {
|
|
497
|
+
lines.push(this.theme.fg("muted", "(no settings in this section)"));
|
|
498
|
+
} else {
|
|
499
|
+
vs.forEach((r, i) => {
|
|
500
|
+
const selected = i === this.selectedIdx;
|
|
501
|
+
const prefix = selected ? "▶ " : " ";
|
|
502
|
+
const row = [
|
|
503
|
+
this.padEnd(prefix + r.label, keyW),
|
|
504
|
+
this.padEnd(r.valueText, valueW),
|
|
505
|
+
this.padEnd(r.sourceText, sourceW),
|
|
506
|
+
truncateToWidth(r.description, descW, "…"),
|
|
507
|
+
].join(gutter);
|
|
508
|
+
lines.push(selected ? this.theme.fg("accent", row) : row);
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
lines.push(
|
|
513
|
+
this.theme.fg(
|
|
514
|
+
"dim",
|
|
515
|
+
"←/→ tab · ↑/↓ move · enter drill-in · esc exit",
|
|
516
|
+
),
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
return lines;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
render(width: number): string[] {
|
|
523
|
+
if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
|
|
524
|
+
this.cachedWidth = width;
|
|
525
|
+
this.cachedLines = this.renderBody(width);
|
|
526
|
+
return this.cachedLines;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
handleInput(data: string): void {
|
|
530
|
+
if (this.keybindings.matches(data, "tui.select.confirm")) {
|
|
531
|
+
this.done(this.resolveSelectedId());
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
if (this.keybindings.matches(data, "tui.select.cancel") || data === "\x1b") {
|
|
535
|
+
this.done(undefined);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
if (this.keybindings.matches(data, "tui.select.up")) {
|
|
539
|
+
this.move(-1);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
if (this.keybindings.matches(data, "tui.select.down")) {
|
|
543
|
+
this.move(+1);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
// Left/right cycle sections. The Keybindings type only has up/down, so we
|
|
547
|
+
// match the raw CSI arrow-key sequences directly. Some terminals emit
|
|
548
|
+
// SS3 ("\x1bOD"/"\x1bOC") instead — fall back to those too.
|
|
549
|
+
if (data === "\x1b[D" || data === "\x1bOD") {
|
|
550
|
+
this.switchSection(-1);
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
if (data === "\x1b[C" || data === "\x1bOC") {
|
|
554
|
+
this.switchSection(+1);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
if (data === "\t") {
|
|
558
|
+
this.switchSection(+1);
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
if (data === "\x1b[Z") {
|
|
562
|
+
this.switchSection(-1);
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
invalidate(): void {
|
|
568
|
+
this.cachedWidth = undefined;
|
|
569
|
+
this.cachedLines = undefined;
|
|
570
|
+
}
|
|
571
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-goal-list-loop-audit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"description": "Goal. Loop. Audit. Done. \u2014 a pi-coding-agent extension that supervises long-running work, with isolated auditor on each completion. Beat bamboozling by design: the auditor runs in a fresh session with no extensions, no skills, no editor \u2014 only the read tools needed to verify your goal.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "dracon",
|