@quantmind-br/pi-goal-x 0.0.0-stage → 0.35.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/LICENSE +21 -0
- package/README.md +131 -2
- package/assets/badge-dark.svg +18 -0
- package/assets/badge-light.svg +18 -0
- package/docs/advanced-usage.md +106 -0
- package/docs/agent-flow-design.md +306 -0
- package/docs/agentic-runtime-prd.md +773 -0
- package/docs/architecture.md +368 -0
- package/docs/controls.md +140 -0
- package/docs/pi-autoresearch-survey.md +50 -0
- package/docs/pi-goal-x-unified-dashboard-plan.md +1667 -0
- package/docs/prompt-caching.md +31 -0
- package/docs/unified-dashboard.md +286 -0
- package/extensions/auditor-selector.ts +74 -0
- package/extensions/goal-accounting.ts +107 -0
- package/extensions/goal-activity.ts +202 -0
- package/extensions/goal-auditor.ts +480 -0
- package/extensions/goal-commands.ts +866 -0
- package/extensions/goal-compaction.ts +197 -0
- package/extensions/goal-completion.ts +173 -0
- package/extensions/goal-context.ts +12 -0
- package/extensions/goal-contract.ts +58 -0
- package/extensions/goal-controls.ts +202 -0
- package/extensions/goal-core-tools.ts +549 -0
- package/extensions/goal-core.ts +78 -0
- package/extensions/goal-detail.ts +65 -0
- package/extensions/goal-dialog-controls.ts +31 -0
- package/extensions/goal-draft.ts +244 -0
- package/extensions/goal-drafting.ts +535 -0
- package/extensions/goal-events.ts +692 -0
- package/extensions/goal-format.ts +376 -0
- package/extensions/goal-interruption.ts +53 -0
- package/extensions/goal-ledger-index.ts +68 -0
- package/extensions/goal-ledger.ts +1007 -0
- package/extensions/goal-live-retention.ts +219 -0
- package/extensions/goal-oracle.ts +346 -0
- package/extensions/goal-policy.ts +370 -0
- package/extensions/goal-pool.ts +103 -0
- package/extensions/goal-prompt-cache.ts +80 -0
- package/extensions/goal-questionnaire.ts +1073 -0
- package/extensions/goal-record.ts +388 -0
- package/extensions/goal-recovery.ts +256 -0
- package/extensions/goal-runtime.ts +277 -0
- package/extensions/goal-scheduler-state.ts +100 -0
- package/extensions/goal-scheduler.ts +436 -0
- package/extensions/goal-service.ts +843 -0
- package/extensions/goal-session-health.ts +130 -0
- package/extensions/goal-session-safety.ts +65 -0
- package/extensions/goal-settings.ts +1370 -0
- package/extensions/goal-state.ts +1069 -0
- package/extensions/goal-status.ts +305 -0
- package/extensions/goal-task-confirmation.ts +174 -0
- package/extensions/goal-task-count.ts +59 -0
- package/extensions/goal-task-derive.ts +46 -0
- package/extensions/goal-task-index.ts +57 -0
- package/extensions/goal-task-tools.ts +595 -0
- package/extensions/goal-tool-inputs.ts +102 -0
- package/extensions/goal-tool-names.ts +73 -0
- package/extensions/goal-tools.ts +16 -0
- package/extensions/goal-widget.ts +455 -0
- package/extensions/goal.ts +42 -0
- package/extensions/network-error-backoff.ts +55 -0
- package/extensions/prompts/goal-prompts.ts +321 -0
- package/extensions/storage/atomic-rename.ts +52 -0
- package/extensions/storage/goal-files.ts +695 -0
- package/extensions/storage/goal-lock.ts +131 -0
- package/extensions/storage/goal-root.ts +95 -0
- package/extensions/widgets/auditor-dashboard-model.ts +174 -0
- package/extensions/widgets/dialog-scaffold.ts +26 -0
- package/extensions/widgets/goal-controls-dialog.ts +329 -0
- package/extensions/widgets/goal-dashboard-model.ts +530 -0
- package/extensions/widgets/goal-dashboard-renderer.ts +674 -0
- package/extensions/widgets/goal-escape-dialog.ts +148 -0
- package/extensions/widgets/goal-notifications.ts +9 -0
- package/extensions/widgets/goal-widget.ts +723 -0
- package/extensions/widgets/task-list-overlay.ts +409 -0
- package/extensions/widgets/text-cache.ts +30 -0
- package/package.json +117 -4
- package/scripts/recover-session-checkpoints.mjs +289 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { Input, isKittyProtocolActive, matchesKey, parseKey, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { Component, TUI, KeyId } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { ExtensionContext, Theme, KeybindingsManager } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import {
|
|
5
|
+
GOAL_COMMANDS, GOAL_KEY_ACTIONS, PAUSE_ORDERS, bindingKeys, commandPaths, commandProblems,
|
|
6
|
+
commandRoutes, keybindingProblems, normalizeGoalKey, parseCommandPaths, parseGoalKeybinding, resolveGoalControls, sameGoalKey, valueAtPath,
|
|
7
|
+
type GoalKeyAction, type GoalKeybinding,
|
|
8
|
+
} from "../goal-controls.ts";
|
|
9
|
+
import {
|
|
10
|
+
formatGoalKeybinding, invalidateGoalSettingsCache, loadSettingsSnapshot, mutateSettingsLayer,
|
|
11
|
+
type GoalSettings, type SettingsSnapshot, type SettingsScope, type SettingsMutation,
|
|
12
|
+
} from "../goal-settings.ts";
|
|
13
|
+
import type { GoalCore } from "../goal-state.ts";
|
|
14
|
+
import { truncateToWidth } from "./text-cache.ts";
|
|
15
|
+
|
|
16
|
+
type Tab = "Commands" | "Keys" | "Pausing";
|
|
17
|
+
type Row = { label: string; path: string[]; help: string; kind: "commands" | "keys" | "order" | "reset"; defaults: unknown; action?: GoalKeyAction };
|
|
18
|
+
const TABS: Tab[] = ["Commands", "Keys", "Pausing"];
|
|
19
|
+
const rowsFor = (tab: Tab): Row[] => tab === "Commands"
|
|
20
|
+
? GOAL_COMMANDS.map(([id, label, help]) => ({ label, path: ["commands", id], help, kind: "commands", defaults: [id] }))
|
|
21
|
+
: tab === "Keys"
|
|
22
|
+
? GOAL_KEY_ACTIONS.map(action => ({ label: action.path[1] === "commands" ? `Command: ${action.label}` : action.label, path: action.path, help: action.help, kind: "keys", defaults: action.defaults, action }))
|
|
23
|
+
: [{ label: "Pause order", path: ["pauseOrder"], help: "Choose what happens on the first and second press of the pause key.", kind: "order", defaults: "together" }];
|
|
24
|
+
|
|
25
|
+
export function commandControlsFingerprint(commands: GoalSettings["commands"]): string {
|
|
26
|
+
return JSON.stringify(GOAL_COMMANDS.map(([id]) => [id, commandPaths(commands, id)]));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Bounded, model-free settings UI. The parent owns modal isolation and reload. */
|
|
30
|
+
export class GoalControlsDialog implements Component {
|
|
31
|
+
private snapshot: SettingsSnapshot;
|
|
32
|
+
private scope: SettingsScope = "project";
|
|
33
|
+
private tabIndex = 0;
|
|
34
|
+
private selection = [0, 0, 0];
|
|
35
|
+
private query = new Input();
|
|
36
|
+
private editor = new Input();
|
|
37
|
+
private view: "list" | "actions" | "input" | "capture" | "key-preview" = "list";
|
|
38
|
+
private row: Row | undefined;
|
|
39
|
+
private actionIndex = 0;
|
|
40
|
+
private adding = false;
|
|
41
|
+
private recorded: KeyId | undefined;
|
|
42
|
+
private notice = "";
|
|
43
|
+
private error = "";
|
|
44
|
+
private closed = false;
|
|
45
|
+
private pageSize = 8;
|
|
46
|
+
private showCursor: boolean;
|
|
47
|
+
private core: GoalCore;
|
|
48
|
+
private ctx: ExtensionContext;
|
|
49
|
+
private tui: TUI;
|
|
50
|
+
private theme: Theme;
|
|
51
|
+
private keymap: KeybindingsManager;
|
|
52
|
+
private done: (result: "reload" | undefined) => void;
|
|
53
|
+
constructor(core: GoalCore, ctx: ExtensionContext, tui: TUI, theme: Theme, keymap: KeybindingsManager, done: (result: "reload" | undefined) => void, scope: SettingsScope = "project") {
|
|
54
|
+
this.core = core; this.ctx = ctx; this.tui = tui; this.theme = theme; this.keymap = keymap; this.done = done;
|
|
55
|
+
this.scope = scope;
|
|
56
|
+
this.snapshot = loadSettingsSnapshot(ctx.cwd);
|
|
57
|
+
this.showCursor = tui.getShowHardwareCursor();
|
|
58
|
+
tui.setShowHardwareCursor(false);
|
|
59
|
+
ctx.ui.setWorkingVisible(false);
|
|
60
|
+
this.editor.onSubmit = () => this.saveInput();
|
|
61
|
+
this.editor.onEscape = () => { this.view = "actions"; this.error = ""; };
|
|
62
|
+
}
|
|
63
|
+
dispose(): void { this.tui.setShowHardwareCursor(this.showCursor); this.ctx.ui.setWorkingVisible(true); }
|
|
64
|
+
invalidate(): void {}
|
|
65
|
+
private get tab(): Tab { return TABS[this.tabIndex]!; }
|
|
66
|
+
private pending(): boolean { return commandControlsFingerprint(this.snapshot.value.commands) !== commandControlsFingerprint(this.core.loadedCommands); }
|
|
67
|
+
private rows(): Row[] {
|
|
68
|
+
const query = this.query.getValue().toLowerCase().trim();
|
|
69
|
+
const rows = rowsFor(this.tab).filter(row => `${row.label} ${row.help} ${this.display(row, this.setting(row))}`.toLowerCase().includes(query));
|
|
70
|
+
if (!query) rows.push({ label: `Reset ${this.tab.toLowerCase()} overrides…`, path: [this.tab === "Commands" ? "commands" : this.tab === "Keys" ? "keybindings" : "pauseOrder"], kind: "reset", defaults: undefined, help: `Remove only ${this.scope} overrides in this section.`, });
|
|
71
|
+
return rows;
|
|
72
|
+
}
|
|
73
|
+
private inherited(row: Row): unknown { return (this.scope === "project" ? valueAtPath(this.snapshot.global.layer, row.path) : undefined) ?? row.defaults; }
|
|
74
|
+
private setting(row: Row): unknown { return valueAtPath(this.snapshot[this.scope].layer, row.path) ?? this.inherited(row); }
|
|
75
|
+
private source(row: Row): string {
|
|
76
|
+
if (row.kind === "reset") return "";
|
|
77
|
+
if (valueAtPath(this.snapshot[this.scope].layer, row.path) !== undefined) return this.scope;
|
|
78
|
+
if (this.scope === "project" && valueAtPath(this.snapshot.global.layer, row.path) !== undefined) return "global";
|
|
79
|
+
return "default";
|
|
80
|
+
}
|
|
81
|
+
private display(row: Row, value: unknown): string {
|
|
82
|
+
if (row.kind === "commands") return (value as string[]).map(route => `/${route}`).join(", ") || (row.path[1] === "goal-settings" ? "/goal-settings (recovery only)" : "Disabled");
|
|
83
|
+
if (row.kind === "keys") return formatGoalKeybinding(value as GoalKeybinding);
|
|
84
|
+
if (row.kind === "order") return PAUSE_ORDERS.find(order => order.id === value)?.label ?? "Goal and turn together";
|
|
85
|
+
return "";
|
|
86
|
+
}
|
|
87
|
+
private options(): string[] {
|
|
88
|
+
if (this.row?.kind === "reset") return [`Remove ${this.scope} overrides`, "Back"];
|
|
89
|
+
if (this.row?.kind === "order") return [...PAUSE_ORDERS.map(order => order.label), this.inheritLabel(), "Back"];
|
|
90
|
+
return [
|
|
91
|
+
...(this.row?.kind === "commands" ? ["Edit command paths", "Add an alias"] : ["Record a replacement key", "Record another key", "Edit key names"]),
|
|
92
|
+
this.row?.path[1] === "goal-settings" ? "Recovery entry only" : "Disable", "Use default", this.inheritLabel(), "Back",
|
|
93
|
+
];
|
|
94
|
+
}
|
|
95
|
+
private inheritLabel(): string { return this.scope === "project" ? "Use inherited value" : "Remove global override"; }
|
|
96
|
+
private optionHelp(): string {
|
|
97
|
+
const row = this.row!;
|
|
98
|
+
const option = this.options()[this.actionIndex];
|
|
99
|
+
if (option === this.inheritLabel()) return `Inherited: ${this.display(row, this.inherited(row))}. Remove this scope's override.`;
|
|
100
|
+
if (option === "Use default") return `Default: ${this.display(row, row.defaults)}. Keep this value in ${this.scope === "project" ? "this project" : "all projects"}, even if the inherited value changes.`;
|
|
101
|
+
if (option === "Back") return "Return without changing this control.";
|
|
102
|
+
if (row.kind === "order") return PAUSE_ORDERS[this.actionIndex]!.help;
|
|
103
|
+
if (row.kind === "reset") return row.help;
|
|
104
|
+
if (option === "Recovery entry only") return "Remove additional settings commands. /goal-settings always remains available.";
|
|
105
|
+
if (option === "Disable") return row.kind === "commands" ? "Remove slash commands for this action. Any command shortcut still works." : "Remove this action's keys in the selected scope.";
|
|
106
|
+
if (option === "Add an alias") return "Keep the current names and add another command. The first name stays preferred.";
|
|
107
|
+
return row.help;
|
|
108
|
+
}
|
|
109
|
+
private mutate(mutation: SettingsMutation): boolean {
|
|
110
|
+
try {
|
|
111
|
+
this.snapshot = mutateSettingsLayer({ scope: this.scope, cwd: this.ctx.cwd, mutation, validate: (next, current) => {
|
|
112
|
+
// Another session may have edited a different leaf since this screen opened.
|
|
113
|
+
invalidateGoalSettingsCache();
|
|
114
|
+
const snapshot = loadSettingsSnapshot(this.ctx.cwd);
|
|
115
|
+
const global = this.scope === "global" ? current : snapshot.global.layer;
|
|
116
|
+
const project = this.scope === "project" ? current : snapshot.project.layer;
|
|
117
|
+
const nextGlobal = this.scope === "global" ? next : global;
|
|
118
|
+
const nextProject = this.scope === "project" ? next : project;
|
|
119
|
+
const before = [resolveGoalControls(global), resolveGoalControls(global, project)];
|
|
120
|
+
const after = [resolveGoalControls(nextGlobal), resolveGoalControls(nextGlobal, nextProject)];
|
|
121
|
+
const edited = GOAL_KEY_ACTIONS.find(action => action.path.join(".") === mutation.path.join("."));
|
|
122
|
+
const problems = (values: typeof before) => values.flatMap(value => mutation.path[0] === "commands" ? commandProblems(value.commands) : keybindingProblems(value, edited, isKittyProtocolActive()));
|
|
123
|
+
const existing = new Set(problems(before));
|
|
124
|
+
const introduced = problems(after).find(problem => !existing.has(problem));
|
|
125
|
+
if (introduced) throw new Error(introduced);
|
|
126
|
+
} });
|
|
127
|
+
this.core.updateUI(this.ctx);
|
|
128
|
+
this.error = "";
|
|
129
|
+
const masked = this.scope === "global" && valueAtPath(this.snapshot.project.layer, mutation.path) !== undefined;
|
|
130
|
+
this.notice = masked ? "Saved for all projects. Project overrides still apply."
|
|
131
|
+
: mutation.path[0] === "commands" && this.pending() ? ("reload" in this.ctx ? "Saved. Ctrl+R applies commands when Pi is idle." : "Saved. Close controls and run /reload to apply commands.")
|
|
132
|
+
: "Saved. Changes are active now.";
|
|
133
|
+
this.view = "list";
|
|
134
|
+
return true;
|
|
135
|
+
} catch (error) { this.error = error instanceof Error ? error.message : String(error); return false; }
|
|
136
|
+
}
|
|
137
|
+
private save(value: unknown): void {
|
|
138
|
+
const row = this.row!;
|
|
139
|
+
// Validate against a fresh merged snapshot, including other scopes.
|
|
140
|
+
this.snapshot = loadSettingsSnapshot(this.ctx.cwd);
|
|
141
|
+
let problems: string[] = [];
|
|
142
|
+
if (row.kind === "commands") {
|
|
143
|
+
const ownRoots = new Set(commandRoutes(this.core.loadedCommands).map(route => route.path.split(" ")[0]!));
|
|
144
|
+
let occupied: string[] = [];
|
|
145
|
+
try { occupied = this.core.pi.getCommands().filter(command => !ownRoots.has(command.name)).map(command => command.name); } catch { /* Not every embedding has command introspection. */ }
|
|
146
|
+
problems = commandProblems({ [row.path[1]!]: value } as GoalSettings["commands"], occupied).filter(problem => problem.includes("belongs to") || problem.includes("reserved"));
|
|
147
|
+
} else if (row.kind === "keys") {
|
|
148
|
+
// Goal-to-goal conflicts are checked against fresh layers under the write lock.
|
|
149
|
+
const keys = bindingKeys(value as GoalKeybinding);
|
|
150
|
+
try {
|
|
151
|
+
const bindings = this.keymap.getResolvedBindings();
|
|
152
|
+
// Modal/tree-only bindings never compete with the main goal controls.
|
|
153
|
+
const main = Object.entries(bindings).filter(([id]) => /^(app\.(interrupt$|clear$|exit$|suspend$|thinking\.(cycle|toggle)$|model\.|tools\.|message\.|clipboard\.|editor\.|session\.(new|tree|fork|resume)$)|tui\.(editor|input)\.)/.test(id));
|
|
154
|
+
for (const key of keys) {
|
|
155
|
+
if (key === "escape" && (row.path[1] === "pause" || row.path[1] === "interruptAudit" || row.path[2] === "collapse")) continue;
|
|
156
|
+
const conflict = main.find(([, binding]) => bindingKeys(binding).some(bound => sameGoalKey(bound, key, isKittyProtocolActive())));
|
|
157
|
+
if (conflict && row.action?.context !== "expanded" && row.action?.context !== "audit" && row.action?.context !== "dialog") problems.push(`${formatGoalKeybinding(key)} is used by Pi (${conflict[0]}). Choose a free key or change it in Pi's keybindings.json.`);
|
|
158
|
+
}
|
|
159
|
+
} catch { /* Embeddings may omit keymap inspection. */ }
|
|
160
|
+
}
|
|
161
|
+
if (problems.length) { this.error = problems[0]!; return; }
|
|
162
|
+
this.mutate({ op: "set", path: row.path, value });
|
|
163
|
+
}
|
|
164
|
+
private saveInput(): void {
|
|
165
|
+
const row = this.row!;
|
|
166
|
+
const text = this.editor.getValue().trim();
|
|
167
|
+
if (!text) { this.error = "Enter a value, or use Disable to leave this action unbound."; return; }
|
|
168
|
+
const parts = text.split(",").map(part => part.trim());
|
|
169
|
+
if (row.kind === "commands") {
|
|
170
|
+
const routes = parseCommandPaths(parts);
|
|
171
|
+
if (!routes) { this.error = "Use command words such as /goal edit. Separate aliases with commas."; return; }
|
|
172
|
+
this.save(this.adding ? [...new Set([...(this.setting(row) as string[]), ...routes])] : routes);
|
|
173
|
+
} else {
|
|
174
|
+
let keys = parseGoalKeybinding(text) ?? parseGoalKeybinding(parts);
|
|
175
|
+
if (text.startsWith("[")) { try { keys = parseGoalKeybinding(JSON.parse(text)); } catch { keys = undefined; } }
|
|
176
|
+
if (!keys) { this.error = "Use Pi key names such as ctrl+alt+p or f6, separated by commas."; return; }
|
|
177
|
+
this.save(bindingKeys(keys));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
private selectOption(): void {
|
|
181
|
+
const option = this.options()[this.actionIndex];
|
|
182
|
+
const row = this.row!;
|
|
183
|
+
this.error = "";
|
|
184
|
+
if (option === "Back") { this.view = "list"; return; }
|
|
185
|
+
if (row.kind === "reset") { this.mutate({ op: "unset", path: row.path }); return; }
|
|
186
|
+
if (option === this.inheritLabel()) { this.mutate({ op: "unset", path: row.path }); return; }
|
|
187
|
+
if (row.kind === "order") { this.save(PAUSE_ORDERS[this.actionIndex]!.id); return; }
|
|
188
|
+
if (option === "Disable" || option === "Recovery entry only") { this.save([]); return; }
|
|
189
|
+
if (option === "Use default") { this.save(row.defaults); return; }
|
|
190
|
+
this.adding = option === "Add an alias" || option === "Record another key";
|
|
191
|
+
if (option?.startsWith("Record")) { this.view = "capture"; return; }
|
|
192
|
+
this.editor = new Input();
|
|
193
|
+
this.editor.onSubmit = () => this.saveInput();
|
|
194
|
+
this.editor.onEscape = () => { this.view = "actions"; this.error = ""; };
|
|
195
|
+
// Input.setValue preserves the old cursor position. Inserting fresh text
|
|
196
|
+
// starts editing at the end and gives each field its own undo history.
|
|
197
|
+
const keys = row.kind === "keys" ? bindingKeys(this.setting(row) as GoalKeybinding) : [];
|
|
198
|
+
const initial = this.adding ? "" : row.kind === "commands" ? (this.setting(row) as string[]).map(route => `/${route}`).join(", ") : keys.some(key => key.includes(",")) ? JSON.stringify(keys) : keys.join(", ");
|
|
199
|
+
if (initial) this.editor.handleInput(initial);
|
|
200
|
+
this.view = "input";
|
|
201
|
+
}
|
|
202
|
+
private match(data: string, action: "up" | "down" | "confirm" | "cancel"): boolean {
|
|
203
|
+
try { if (this.keymap.matches(data, `tui.select.${action}`)) return true; } catch { /* Legacy Pi selection IDs. */ }
|
|
204
|
+
return matchesKey(data, ({ up: "up", down: "down", confirm: "enter", cancel: "escape" } as const)[action]);
|
|
205
|
+
}
|
|
206
|
+
handleInput(data: string): void {
|
|
207
|
+
if (this.closed || (data.startsWith("\x1b[") && /^[0-9:;]*:[23][u~ABCDHF]$/.test(data.slice(2)))) return;
|
|
208
|
+
if (this.view === "list") this.selection[this.tabIndex] = Math.max(0, Math.min(this.selection[this.tabIndex]!, this.rows().length - 1));
|
|
209
|
+
if (this.view === "input") { this.editor.handleInput(data); this.tui.requestRender(); return; }
|
|
210
|
+
if (this.view === "capture") {
|
|
211
|
+
if (this.match(data, "cancel")) { this.view = "actions"; this.error = ""; }
|
|
212
|
+
else {
|
|
213
|
+
const key = normalizeGoalKey(parseKey(data));
|
|
214
|
+
if (key) { this.recorded = key; this.view = "key-preview"; this.error = ""; }
|
|
215
|
+
else this.error = "Pi could not identify that key. Try Edit key names instead.";
|
|
216
|
+
}
|
|
217
|
+
} else if (this.view === "key-preview") {
|
|
218
|
+
if (this.match(data, "cancel")) { this.view = "actions"; this.error = ""; }
|
|
219
|
+
else if (this.match(data, "confirm")) this.save(this.adding ? [...new Set([...bindingKeys(this.setting(this.row!) as GoalKeybinding), this.recorded!])] : [this.recorded!]);
|
|
220
|
+
} else if (this.view === "actions") {
|
|
221
|
+
if (this.match(data, "cancel")) { this.view = "list"; this.error = ""; }
|
|
222
|
+
else if (this.match(data, "up")) { this.actionIndex = Math.max(0, this.actionIndex - 1); this.error = ""; }
|
|
223
|
+
else if (this.match(data, "down")) { this.actionIndex = Math.min(this.options().length - 1, this.actionIndex + 1); this.error = ""; }
|
|
224
|
+
else if (this.match(data, "confirm")) this.selectOption();
|
|
225
|
+
} else if (this.match(data, "cancel")) {
|
|
226
|
+
if (this.query.getValue()) { this.query.setValue(""); this.selection[this.tabIndex] = 0; }
|
|
227
|
+
else { this.closed = true; this.done(undefined); }
|
|
228
|
+
} else if (matchesKey(data, "tab") || matchesKey(data, "right") || matchesKey(data, "shift+tab") || matchesKey(data, "left")) {
|
|
229
|
+
this.tabIndex = (this.tabIndex + (matchesKey(data, "left") || matchesKey(data, "shift+tab") ? 2 : 1)) % 3;
|
|
230
|
+
this.query.setValue(""); this.notice = ""; this.error = "";
|
|
231
|
+
} else if (matchesKey(data, "ctrl+s")) { this.scope = this.scope === "project" ? "global" : "project"; this.notice = ""; this.error = ""; this.snapshot = loadSettingsSnapshot(this.ctx.cwd); }
|
|
232
|
+
else if (matchesKey(data, "ctrl+r") && this.pending()) {
|
|
233
|
+
if (!this.ctx.isIdle()) this.error = "Commands are saved. Stop the current turn before applying them with /reload.";
|
|
234
|
+
else if (!("reload" in this.ctx)) this.error = "Commands are saved. Close controls and run /reload to apply them.";
|
|
235
|
+
else { this.closed = true; this.done("reload"); }
|
|
236
|
+
} else if (this.match(data, "up") || this.match(data, "down") || matchesKey(data, "pageUp") || matchesKey(data, "pageDown") || matchesKey(data, "home") || matchesKey(data, "end")) {
|
|
237
|
+
const change = matchesKey(data, "home") ? -Infinity : matchesKey(data, "end") ? Infinity : matchesKey(data, "pageUp") ? -this.pageSize : matchesKey(data, "pageDown") ? this.pageSize : this.match(data, "up") ? -1 : 1;
|
|
238
|
+
this.selection[this.tabIndex] = Math.max(0, Math.min(this.rows().length - 1, this.selection[this.tabIndex]! + change));
|
|
239
|
+
this.error = ""; this.notice = "";
|
|
240
|
+
}
|
|
241
|
+
else if (this.match(data, "confirm")) {
|
|
242
|
+
this.row = this.rows()[this.selection[this.tabIndex]!];
|
|
243
|
+
if (this.row) { this.view = "actions"; this.actionIndex = this.row.kind === "order" ? Math.max(0, PAUSE_ORDERS.findIndex(order => order.id === this.setting(this.row!))) : 0; this.notice = ""; this.error = ""; }
|
|
244
|
+
} else { this.query.handleInput(data); this.selection[this.tabIndex] = 0; this.error = ""; this.notice = ""; }
|
|
245
|
+
this.tui.requestRender();
|
|
246
|
+
}
|
|
247
|
+
render(width: number): string[] {
|
|
248
|
+
const inner = Math.max(10, Math.min(width - 2, 100));
|
|
249
|
+
const height = Math.max(12, Math.min(25, (this.tui.terminal.rows || 32) - 7));
|
|
250
|
+
const dim = (text: string) => this.theme.fg("dim", text);
|
|
251
|
+
const accent = (text: string) => this.theme.fg("accent", text);
|
|
252
|
+
const fit = (text: string) => truncateToWidth(text, inner - 2, "…");
|
|
253
|
+
const lines: string[] = [this.theme.bold("Goal controls") + dim(` · ${this.scope === "project" ? "This project" : "All projects"}`),
|
|
254
|
+
TABS.map((tab, i) => i === this.tabIndex ? accent(this.theme.bold(`[${tab}]`)) : dim(` ${tab} `)).join(" ")];
|
|
255
|
+
const help: string[] = [];
|
|
256
|
+
let source = "";
|
|
257
|
+
const footer = this.view === "list" ? [inner < 50 ? "↑↓/PgUp/PgDn move · Enter edit" : "↑↓/PgUp/PgDn move · Home/End ends · Enter edit", "Tab tabs · Ctrl+S scope · Esc back",
|
|
258
|
+
...(this.pending() ? ["reload" in this.ctx ? "Ctrl+R apply saved commands when idle" : "/reload applies command changes"] : [])]
|
|
259
|
+
: [(this.view === "input" || this.view === "key-preview") ? "Enter save · Esc cancel" : this.view === "capture" ? "Press a key · Esc cancel" : "↑↓ move · Enter select · Esc back"];
|
|
260
|
+
const messageLines = (message: string, list: boolean) => wrapTextWithAnsi(message, inner - 2).slice(0, Math.max(1, Math.min(3, height - footer.length - (list ? 8 : 9))));
|
|
261
|
+
if (this.view === "list") {
|
|
262
|
+
const rows = this.rows();
|
|
263
|
+
const selected = Math.max(0, Math.min(this.selection[this.tabIndex]!, rows.length - 1));
|
|
264
|
+
this.selection[this.tabIndex] = selected;
|
|
265
|
+
lines.push(dim(`Search: ${this.query.getValue() || "type to filter"}`));
|
|
266
|
+
const row = rows[selected];
|
|
267
|
+
if (row) help.push(row.kind === "order" ? PAUSE_ORDERS.find(order => order.id === this.setting(row))!.help : row.help);
|
|
268
|
+
if (this.tab === "Pausing") help.push(`Pause key: ${formatGoalKeybinding(this.snapshot.value.keybindings?.pause ?? "escape")}. ${this.core.interruption.isHeld() ? "Turn is stopped; waiting for you." : ""}`);
|
|
269
|
+
const capacity = Math.max(1, height - 8 - footer.length - messageLines(this.error || [this.notice, ...help].filter(Boolean).join(" "), true).length);
|
|
270
|
+
this.pageSize = capacity;
|
|
271
|
+
const start = Math.max(0, Math.min(selected - Math.floor(capacity / 2), rows.length - capacity));
|
|
272
|
+
const labelWidth = Math.min(39, Math.max(15, Math.floor(inner * .48)));
|
|
273
|
+
for (const [index, row] of rows.slice(start, start + capacity).entries()) {
|
|
274
|
+
const active = start + index === selected;
|
|
275
|
+
const label = truncateToWidth(row.label, labelWidth, "…");
|
|
276
|
+
const value = this.display(row, this.setting(row));
|
|
277
|
+
const content = row.kind === "reset" ? `${active ? "›" : " "} ${row.label}` : `${active ? "›" : " "} ${label}${" ".repeat(Math.max(1, labelWidth - visibleWidth(label) + 1))}${value}`;
|
|
278
|
+
lines.push(active ? accent(fit(content)) : fit(content));
|
|
279
|
+
}
|
|
280
|
+
if (!rows.length) lines.push(dim("No matching controls."));
|
|
281
|
+
while (lines.length < 3 + capacity) lines.push("");
|
|
282
|
+
const origin = row ? ({ project: "This project", global: "All projects", default: "Default" }[this.source(row)] ?? "") : "";
|
|
283
|
+
source = rows.length ? `${selected + 1}/${rows.length}${origin ? ` · Source: ${origin}` : ""}${this.scope === "global" && row && valueAtPath(this.snapshot.project.layer, row.path) !== undefined ? " · overridden here" : ""}` : "";
|
|
284
|
+
} else {
|
|
285
|
+
lines.push(accent(this.theme.bold(this.row!.label)));
|
|
286
|
+
help.push(this.row!.help);
|
|
287
|
+
if (this.view === "actions") {
|
|
288
|
+
lines.push(dim(`${this.actionIndex + 1}/${this.options().length} · Current: ${this.display(this.row!, this.setting(this.row!))}`));
|
|
289
|
+
this.options().forEach((option, i) => lines.push(i === this.actionIndex ? accent(`› ${option}`) : ` ${option}`));
|
|
290
|
+
help.splice(0, help.length, this.optionHelp());
|
|
291
|
+
} else if (this.view === "input") {
|
|
292
|
+
lines.push(dim(this.row!.kind === "commands" ? "Command paths, separated by commas:" : "Pi key names, separated by commas:"));
|
|
293
|
+
lines.push(...this.editor.render(inner - 2));
|
|
294
|
+
help.unshift(this.row!.kind === "commands" ? "First path is preferred; additional paths are aliases. Example: /goal-tweak, /goal edit." : this.editor.getValue().startsWith("[") ? 'JSON key list keeps comma keys intact. Example: ["ctrl+,", "f6"].' : "Example: ctrl+alt+p, f6. Use Disable to leave this action unbound.");
|
|
295
|
+
} else if (this.view === "capture") {
|
|
296
|
+
lines.push("", accent("Press the key you want to use…"));
|
|
297
|
+
help.unshift("Esc cancels. To bind Escape, use Edit key names.");
|
|
298
|
+
} else {
|
|
299
|
+
lines.push("", accent(this.theme.bold(formatGoalKeybinding(this.recorded))), "", "Enter to save · Esc to cancel");
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const message = this.error || [this.notice, ...help].filter(Boolean).join(" ");
|
|
303
|
+
const wrapped = messageLines(message, this.view === "list");
|
|
304
|
+
// Keep the selected action, validation message and navigation visible.
|
|
305
|
+
const bodyLimit = height - wrapped.length - footer.length - 3 - (this.view === "list" ? 1 : 0);
|
|
306
|
+
if (lines.length > bodyLimit) {
|
|
307
|
+
if (this.view === "actions") {
|
|
308
|
+
const head = lines.slice(0, Math.min(4, bodyLimit - 1));
|
|
309
|
+
const capacity = Math.max(1, bodyLimit - head.length);
|
|
310
|
+
const start = Math.max(4, 4 + this.actionIndex - capacity + 1);
|
|
311
|
+
lines.splice(0, lines.length, ...head, ...lines.slice(start, start + capacity));
|
|
312
|
+
} else lines.splice(bodyLimit);
|
|
313
|
+
}
|
|
314
|
+
if (this.view === "list") lines.push(dim(source));
|
|
315
|
+
lines.push(dim("─".repeat(inner - 2)), ...wrapped.map(line => this.error ? this.theme.fg("error", line) : dim(line)), ...footer.map(dim));
|
|
316
|
+
const border = (text: string) => accent("│") + " " + fit(text) + " ".repeat(Math.max(0, inner - 1 - visibleWidth(fit(text)))) + accent("│");
|
|
317
|
+
return [accent(`┌${"─".repeat(inner)}┐`), ...lines.map(border), accent(`└${"─".repeat(inner)}┘`)];
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export async function showGoalControls(core: GoalCore, ctx: ExtensionContext, scope: SettingsScope = "project"): Promise<boolean> {
|
|
322
|
+
if (!ctx.hasUI) { ctx.ui.notify("Open /goal-settings controls in an interactive Pi session to customize controls.", "info"); return false; }
|
|
323
|
+
core.enterGoalModal();
|
|
324
|
+
let reload: "reload" | undefined;
|
|
325
|
+
try { reload = await ctx.ui.custom((tui, theme, keymap, done) => new GoalControlsDialog(core, ctx, tui, theme, keymap, done, scope)); }
|
|
326
|
+
finally { core.exitGoalModal(); }
|
|
327
|
+
if (reload && "reload" in ctx) { await (ctx as ExtensionContext & { reload(): Promise<void> }).reload(); return true; }
|
|
328
|
+
return false;
|
|
329
|
+
}
|