pi-goal-x 0.27.0 → 0.27.1
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/README.md +16 -0
- package/docs/unified-dashboard.md +5 -0
- package/extensions/goal-commands.ts +3 -1
- package/extensions/goal-questionnaire.ts +139 -23
- package/extensions/goal-settings.ts +78 -1
- package/extensions/goal-widget.ts +6 -4
- package/extensions/widgets/goal-dashboard-renderer.ts +19 -9
- package/extensions/widgets/goal-widget.ts +5 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -376,6 +376,22 @@ Settings are stored in:
|
|
|
376
376
|
|
|
377
377
|
Use `/goal-settings` to configure task lists, verification contracts, subtask depth, automatic goal selection, and completion auditing. Goal objectives have no hard length limit by default; set `objectiveMaxChars` (or `PI_GOAL_OBJECTIVE_MAX_CHARS`, `0` = no limit) to cap objective length across `create_goal`, `propose_goal_draft`, and `/goal-tweak`.
|
|
378
378
|
|
|
379
|
+
Configure the task shortcuts in the same file when the terminal captures the defaults:
|
|
380
|
+
|
|
381
|
+
```json
|
|
382
|
+
{
|
|
383
|
+
"keybindings": {
|
|
384
|
+
"dashboard": {
|
|
385
|
+
"toggleExpand": "ctrl+shift+t",
|
|
386
|
+
"scrollUp": "ctrl+shift+up",
|
|
387
|
+
"scrollDown": "ctrl+shift+down"
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
The default task bindings are `ctrl+shift+t`, `ctrl+shift+up`, and `ctrl+shift+down`. Use pi key names such as `ctrl+shift+up`.
|
|
394
|
+
|
|
379
395
|
## License
|
|
380
396
|
|
|
381
397
|
MIT
|
|
@@ -227,6 +227,11 @@ auditor's findings, and returns to the normal dashboard so work can continue.
|
|
|
227
227
|
| `Esc` | Collapse the expanded dashboard; otherwise pause the goal |
|
|
228
228
|
| `Esc` (during audit) | Stop the audit and choose to continue working or complete without audit |
|
|
229
229
|
|
|
230
|
+
Configure the three task shortcuts with `keybindings.dashboard.toggleExpand`,
|
|
231
|
+
`keybindings.dashboard.scrollUp`, and `keybindings.dashboard.scrollDown` in
|
|
232
|
+
`.pi/pi-goal-x-settings.json`. The defaults are `ctrl+shift+t`,
|
|
233
|
+
`ctrl+shift+up`, and `ctrl+shift+down`.
|
|
234
|
+
|
|
230
235
|
## Width behavior
|
|
231
236
|
|
|
232
237
|
Layout modes (§5.5):
|
|
@@ -382,7 +382,9 @@ export function registerGoalCommands(core: GoalCore): void {
|
|
|
382
382
|
if (key === "subtaskDepth") return config.subtaskDepth !== undefined ? String(config.subtaskDepth) : "1";
|
|
383
383
|
if (key === "stallTimeoutMinutes") return config.stallTimeoutMinutes !== undefined ? String(config.stallTimeoutMinutes) : "0";
|
|
384
384
|
if (key === "objectiveMaxChars") return config.objectiveMaxChars !== undefined ? String(config.objectiveMaxChars) : "0";
|
|
385
|
-
return config
|
|
385
|
+
if (key === "keybindings") return config.keybindings ? `${config.keybindings.dashboard.toggleExpand}, ${config.keybindings.dashboard.scrollUp}, ${config.keybindings.dashboard.scrollDown}` : "(default)";
|
|
386
|
+
const value = config[key];
|
|
387
|
+
return typeof value === "string" ? value : "(default)";
|
|
386
388
|
}
|
|
387
389
|
|
|
388
390
|
function settingsLines(config: GoalSettings): string[] {
|
|
@@ -344,12 +344,18 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
|
|
|
344
344
|
let optionIndex = 0;
|
|
345
345
|
let inputMode = false;
|
|
346
346
|
let inputQuestionId: string | null = null;
|
|
347
|
+
// Focusable container state: the TUI sets `focused` when the dialog has
|
|
348
|
+
// focus; we propagate it to the embedded Editor while in input mode so
|
|
349
|
+
// the editor emits CURSOR_MARKER and the hardware cursor is positioned
|
|
350
|
+
// for IME input (pi docs/tui.md — Focusable Interface).
|
|
351
|
+
let dialogFocused = false;
|
|
347
352
|
let cachedLines: string[] | undefined;
|
|
348
353
|
let optionsStartIndex = -1;
|
|
349
354
|
// §options-scroll viewport state (select-mode + submit tabs only).
|
|
350
355
|
let scrollTop = 0;
|
|
351
356
|
let needsFollow = false;
|
|
352
357
|
let optionRanges: Array<[number, number]> = [];
|
|
358
|
+
let questionBlockEnd = -1;
|
|
353
359
|
let auditorEnabled = auditorToggleInit?.defaultEnabled ?? true;
|
|
354
360
|
const answers = new Map<string, GoalQuestionnaireAnswer>();
|
|
355
361
|
const drafts = new Map<string, string>();
|
|
@@ -406,6 +412,9 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
|
|
|
406
412
|
inputMode = true;
|
|
407
413
|
inputQuestionId = q.id;
|
|
408
414
|
editor.setText(draft ?? (existing?.wasCustom ? existing.answer : ""));
|
|
415
|
+
// Anchor the editor while the dialog has focus (see enterInputMode).
|
|
416
|
+
editor.focused = dialogFocused;
|
|
417
|
+
tui.setShowHardwareCursor(dialogFocused);
|
|
409
418
|
} else if (existing?.wasCustom) {
|
|
410
419
|
optionIndex = q.options.length;
|
|
411
420
|
} else if (existing && !existing.wasCustom) {
|
|
@@ -441,6 +450,89 @@ function advanceAfterAnswer() {
|
|
|
441
450
|
answers.set(qId, { id: qId, question: q?.question ?? qId, answer: value, wasCustom });
|
|
442
451
|
}
|
|
443
452
|
|
|
453
|
+
/**
|
|
454
|
+
* Activate the answer editor for a question: position the hardware
|
|
455
|
+
* cursor and mark the Editor focused (emits CURSOR_MARKER for IME).
|
|
456
|
+
*/
|
|
457
|
+
/**
|
|
458
|
+
* §options-first fit: every option line always stays in frame. When the
|
|
459
|
+
* question + context + options exceed the bound, the question/context
|
|
460
|
+
* section yields first (it remains in the agent transcript): middle lines
|
|
461
|
+
* (blanks, auditor, "Current:", context) drop before the question block
|
|
462
|
+
* and the footer hint, and the tail slack (blank/footer) gives way before
|
|
463
|
+
* the question. Returns null when even the option block + borders cannot
|
|
464
|
+
* fit (pathological) so the caller falls back to the scroll viewport.
|
|
465
|
+
*/
|
|
466
|
+
function fitOptionsInFrame(
|
|
467
|
+
lines: string[],
|
|
468
|
+
maxDialogLines: number,
|
|
469
|
+
questionBlockEnd: number,
|
|
470
|
+
optionsStart: number,
|
|
471
|
+
lastOptionLine: number,
|
|
472
|
+
): string[] | null {
|
|
473
|
+
if (maxDialogLines <= 0 || lines.length <= maxDialogLines) return lines;
|
|
474
|
+
if (optionsStart < 0 || lastOptionLine < optionsStart) return null;
|
|
475
|
+
const head = lines.slice(0, Math.max(questionBlockEnd, 1));
|
|
476
|
+
const middle = lines.slice(Math.max(questionBlockEnd, 1), optionsStart);
|
|
477
|
+
const tail = lines.slice(optionsStart);
|
|
478
|
+
const optionsBlockLen = lastOptionLine - optionsStart + 1;
|
|
479
|
+
// The option block + bottom border must fit on their own; otherwise the
|
|
480
|
+
// scroll viewport is the last resort (nothing is ever hidden).
|
|
481
|
+
if (optionsBlockLen + 1 > maxDialogLines) return null;
|
|
482
|
+
// Shrink the middle from its end: blanks first, then auditor/Current/
|
|
483
|
+
// context lines — the question block is untouched.
|
|
484
|
+
const mid = middle.slice();
|
|
485
|
+
while (head.length + mid.length + tail.length > maxDialogLines && mid.length > 0) {
|
|
486
|
+
let blankIdx = -1;
|
|
487
|
+
for (let i = mid.length - 1; i >= 0; i--) {
|
|
488
|
+
if (mid[i]!.trim() === "") { blankIdx = i; break; }
|
|
489
|
+
}
|
|
490
|
+
if (blankIdx >= 0) mid.splice(blankIdx, 1);
|
|
491
|
+
else mid.pop();
|
|
492
|
+
}
|
|
493
|
+
// Still over: the tail gives its slack (blank between options and
|
|
494
|
+
// footer, then the footer hint) before the question block does. Never
|
|
495
|
+
// drop the option block or the bottom border.
|
|
496
|
+
const tailKept = tail.slice();
|
|
497
|
+
while (head.length + mid.length + tailKept.length > maxDialogLines && tailKept.length > optionsBlockLen + 1) {
|
|
498
|
+
// Prefer removing a blank line (never the options or the border),
|
|
499
|
+
// then the line just above the bottom border (the footer hint).
|
|
500
|
+
let tailBlank = -1;
|
|
501
|
+
for (let i = tailKept.length - 2; i >= 1; i--) {
|
|
502
|
+
if (tailKept[i]!.trim() === "") { tailBlank = i; break; }
|
|
503
|
+
}
|
|
504
|
+
if (tailBlank >= 1) tailKept.splice(tailBlank, 1);
|
|
505
|
+
else tailKept.splice(tailKept.length - 2, 1);
|
|
506
|
+
}
|
|
507
|
+
// Still over: give the question block's trailing wrap lines (then the
|
|
508
|
+
// question itself) before giving up — options + borders always stay.
|
|
509
|
+
const headKept = head.slice();
|
|
510
|
+
while (head.length + mid.length + tailKept.length > maxDialogLines && headKept.length > 1) {
|
|
511
|
+
headKept.pop();
|
|
512
|
+
}
|
|
513
|
+
if (headKept.length + mid.length + tailKept.length > maxDialogLines) return null;
|
|
514
|
+
return [...headKept, ...mid, ...tailKept];
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function enterInputMode(qId: string) {
|
|
518
|
+
inputMode = true;
|
|
519
|
+
inputQuestionId = qId;
|
|
520
|
+
const draft = drafts.get(qId);
|
|
521
|
+
const existing = answers.get(qId);
|
|
522
|
+
editor.setText(draft ?? (existing?.wasCustom ? existing.answer : ""));
|
|
523
|
+
editor.focused = dialogFocused;
|
|
524
|
+
tui.setShowHardwareCursor(dialogFocused);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** Deactivate the answer editor: release the cursor and clear the editor. */
|
|
528
|
+
function leaveInputMode() {
|
|
529
|
+
inputMode = false;
|
|
530
|
+
inputQuestionId = null;
|
|
531
|
+
editor.focused = false;
|
|
532
|
+
tui.setShowHardwareCursor(false);
|
|
533
|
+
editor.setText("");
|
|
534
|
+
}
|
|
535
|
+
|
|
444
536
|
editor.onSubmit = (value) => {
|
|
445
537
|
if (!inputQuestionId) return;
|
|
446
538
|
const trimmed = value.trim();
|
|
@@ -450,9 +542,7 @@ function advanceAfterAnswer() {
|
|
|
450
542
|
}
|
|
451
543
|
drafts.delete(inputQuestionId);
|
|
452
544
|
saveAnswer(inputQuestionId, trimmed, true);
|
|
453
|
-
|
|
454
|
-
inputQuestionId = null;
|
|
455
|
-
editor.setText("");
|
|
545
|
+
leaveInputMode();
|
|
456
546
|
advanceAfterAnswer();
|
|
457
547
|
};
|
|
458
548
|
|
|
@@ -462,9 +552,7 @@ function advanceAfterAnswer() {
|
|
|
462
552
|
if (text.trim()) drafts.set(inputQuestionId, text);
|
|
463
553
|
else drafts.delete(inputQuestionId);
|
|
464
554
|
}
|
|
465
|
-
|
|
466
|
-
inputQuestionId = null;
|
|
467
|
-
editor.setText("");
|
|
555
|
+
leaveInputMode();
|
|
468
556
|
}
|
|
469
557
|
|
|
470
558
|
enterQuestion(questions[0]!);
|
|
@@ -573,11 +661,7 @@ function advanceAfterAnswer() {
|
|
|
573
661
|
|
|
574
662
|
if (matchesKey(data, Key.enter) && q) {
|
|
575
663
|
if (q.options.length === 0 || opts[optionIndex]?.isCustom) {
|
|
576
|
-
|
|
577
|
-
inputQuestionId = q.id;
|
|
578
|
-
const draft = drafts.get(q.id);
|
|
579
|
-
const existing = answers.get(q.id);
|
|
580
|
-
editor.setText(draft ?? (existing?.wasCustom ? existing.answer : ""));
|
|
664
|
+
enterInputMode(q.id);
|
|
581
665
|
refresh();
|
|
582
666
|
return;
|
|
583
667
|
}
|
|
@@ -768,6 +852,7 @@ function advanceAfterAnswer() {
|
|
|
768
852
|
add(allAnswered() ? theme.fg("success", " Press Enter to submit") : theme.fg("warning", ` Unanswered: ${questions.filter((qq) => !answers.has(qq.id)).map((qq) => qq.id).join(", ")}`));
|
|
769
853
|
} else if (q) {
|
|
770
854
|
addWrapped(theme.fg("text", ` ${q.question}`));
|
|
855
|
+
questionBlockEnd = lines.length;
|
|
771
856
|
protectedCount = lines.length;
|
|
772
857
|
if (q.context) renderContextLines(q.context);
|
|
773
858
|
// Auditor toggle line between context and options
|
|
@@ -811,24 +896,55 @@ function advanceAfterAnswer() {
|
|
|
811
896
|
const proposalSegments = !inputMode && currentTab !== questions.length && !!q && q.context
|
|
812
897
|
? findProposalPresentationSegments(lines, optionsStartIndex)
|
|
813
898
|
: null;
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
899
|
+
if (proposalSegments) {
|
|
900
|
+
// Proposal confirmations keep their segment protection (tasks +
|
|
901
|
+
// auditor + options within the bound; the objective-box middle
|
|
902
|
+
// stays in the scrollback presentation).
|
|
903
|
+
lines = fitDialogLines(lines, maxDialogLines, protectedCount, proposalSegments);
|
|
904
|
+
} else if (!inputMode && currentTab !== questions.length && questionBlockEnd >= 0) {
|
|
905
|
+
// Plain select-mode question tab: all options in frame;
|
|
906
|
+
// question/context yields first (it stays in the transcript).
|
|
907
|
+
const fitted = fitOptionsInFrame(lines, maxDialogLines, questionBlockEnd, optionsStartIndex, optionRanges[optionRanges.length - 1]?.[1] ?? -1);
|
|
908
|
+
if (fitted) {
|
|
909
|
+
lines = fitted;
|
|
910
|
+
scrollTop = 0;
|
|
911
|
+
needsFollow = false;
|
|
912
|
+
} else {
|
|
913
|
+
// Pathological: options alone overflow the bound — scroll
|
|
914
|
+
// viewport is the last resort (nothing ever hidden).
|
|
915
|
+
const scrollState = { scrollTop, needsFollow, optionRanges, followIndex: optionIndex };
|
|
916
|
+
lines = fitDialogLines(lines, maxDialogLines, protectedCount, null, scrollState, (s) => theme.fg("dim", s));
|
|
917
|
+
scrollTop = scrollState.scrollTop;
|
|
918
|
+
needsFollow = scrollState.needsFollow;
|
|
919
|
+
}
|
|
920
|
+
} else {
|
|
921
|
+
// Input mode, submit tab: legacy tail-keep / viewport behavior.
|
|
922
|
+
const scrollState = !inputMode ? { scrollTop, needsFollow, optionRanges, followIndex: optionIndex } : null;
|
|
923
|
+
lines = fitDialogLines(lines, maxDialogLines, protectedCount, null, scrollState, (s) => theme.fg("dim", s));
|
|
924
|
+
if (scrollState) {
|
|
925
|
+
scrollTop = scrollState.scrollTop;
|
|
926
|
+
needsFollow = scrollState.needsFollow;
|
|
927
|
+
}
|
|
825
928
|
}
|
|
826
929
|
}
|
|
827
930
|
cachedLines = lines;
|
|
828
931
|
return lines;
|
|
829
932
|
}
|
|
830
933
|
|
|
831
|
-
return {
|
|
934
|
+
return {
|
|
935
|
+
render,
|
|
936
|
+
invalidate: () => { cachedLines = undefined; },
|
|
937
|
+
handleInput,
|
|
938
|
+
// Focusable (pi docs/tui.md): propagate focus to the embedded Editor
|
|
939
|
+
// so it emits CURSOR_MARKER and the hardware cursor is positioned
|
|
940
|
+
// correctly for IME input while the answer editor is active.
|
|
941
|
+
get focused(): boolean { return dialogFocused; },
|
|
942
|
+
set focused(v: boolean) {
|
|
943
|
+
dialogFocused = v;
|
|
944
|
+
editor.focused = v && inputMode;
|
|
945
|
+
tui.setShowHardwareCursor(v && inputMode);
|
|
946
|
+
},
|
|
947
|
+
};
|
|
832
948
|
});
|
|
833
949
|
}
|
|
834
950
|
|
|
@@ -9,16 +9,56 @@
|
|
|
9
9
|
*
|
|
10
10
|
* The file may contain:
|
|
11
11
|
* disableTasks, disableContracts, subtaskDepth,
|
|
12
|
-
* provider, model, thinkingLevel, disabled, objectiveMaxChars
|
|
12
|
+
* provider, model, thinkingLevel, disabled, objectiveMaxChars, keybindings
|
|
13
|
+
*
|
|
14
|
+
* `keybindings.dashboard` accepts `toggleExpand`, `scrollUp`, and `scrollDown`.
|
|
13
15
|
*
|
|
14
16
|
* additionalProperties: false — unknown keys are rejected.
|
|
15
17
|
*/
|
|
16
18
|
|
|
17
19
|
import * as fs from "node:fs";
|
|
18
20
|
import * as path from "node:path";
|
|
21
|
+
import type { KeyId } from "@earendil-works/pi-tui";
|
|
19
22
|
|
|
20
23
|
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
|
21
24
|
|
|
25
|
+
export interface GoalDashboardKeybindings {
|
|
26
|
+
toggleExpand: KeyId;
|
|
27
|
+
scrollUp: KeyId;
|
|
28
|
+
scrollDown: KeyId;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface GoalKeybindings {
|
|
32
|
+
dashboard: GoalDashboardKeybindings;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const DEFAULT_GOAL_KEYBINDINGS: GoalKeybindings = {
|
|
36
|
+
dashboard: {
|
|
37
|
+
toggleExpand: "ctrl+shift+t",
|
|
38
|
+
scrollUp: "ctrl+shift+up",
|
|
39
|
+
scrollDown: "ctrl+shift+down",
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function formatGoalKeybinding(key: string): string {
|
|
44
|
+
return key.split("+").map((part) => ({
|
|
45
|
+
ctrl: "Ctrl",
|
|
46
|
+
control: "Ctrl",
|
|
47
|
+
shift: "Shift",
|
|
48
|
+
alt: "Alt",
|
|
49
|
+
up: "↑",
|
|
50
|
+
down: "↓",
|
|
51
|
+
left: "←",
|
|
52
|
+
right: "→",
|
|
53
|
+
pageup: "PageUp",
|
|
54
|
+
pagedown: "PageDown",
|
|
55
|
+
home: "Home",
|
|
56
|
+
end: "End",
|
|
57
|
+
enter: "Enter",
|
|
58
|
+
escape: "Esc",
|
|
59
|
+
}[part.toLowerCase()] ?? part.toUpperCase())).join("+");
|
|
60
|
+
}
|
|
61
|
+
|
|
22
62
|
export interface GoalSettings {
|
|
23
63
|
disableTasks?: boolean;
|
|
24
64
|
disableContracts?: boolean;
|
|
@@ -38,6 +78,8 @@ export interface GoalSettings {
|
|
|
38
78
|
* /goal-tweak).
|
|
39
79
|
*/
|
|
40
80
|
objectiveMaxChars?: number;
|
|
81
|
+
/** Keyboard shortcuts for the compact task list and dashboard expansion. */
|
|
82
|
+
keybindings?: GoalKeybindings;
|
|
41
83
|
}
|
|
42
84
|
|
|
43
85
|
export const PI_GOAL_SETTINGS_FILE_ENV = "PI_GOAL_SETTINGS_FILE";
|
|
@@ -84,8 +126,12 @@ const ALLOWED_SETTINGS_KEYS = new Set([
|
|
|
84
126
|
"auditorProjectResources",
|
|
85
127
|
"stallTimeoutMinutes",
|
|
86
128
|
"objectiveMaxChars",
|
|
129
|
+
"keybindings",
|
|
87
130
|
]);
|
|
88
131
|
|
|
132
|
+
const ALLOWED_KEYBINDING_KEYS = new Set(["dashboard"]);
|
|
133
|
+
const ALLOWED_DASHBOARD_KEYBINDING_KEYS = new Set(["toggleExpand", "scrollUp", "scrollDown"]);
|
|
134
|
+
|
|
89
135
|
/**
|
|
90
136
|
* Resolve the path to the unified settings file.
|
|
91
137
|
* Uses `PI_GOAL_SETTINGS_FILE` env var if set (relative to cwd or absolute).
|
|
@@ -128,6 +174,31 @@ function asNonNegativeInt(value: unknown): number | undefined {
|
|
|
128
174
|
return undefined;
|
|
129
175
|
}
|
|
130
176
|
|
|
177
|
+
function asKeybinding(value: unknown): KeyId | undefined {
|
|
178
|
+
const key = asNonEmptyString(value);
|
|
179
|
+
if (!key) return undefined;
|
|
180
|
+
return key as KeyId;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function parseKeybindings(value: unknown): GoalKeybindings | undefined {
|
|
184
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
185
|
+
const record = value as Record<string, unknown>;
|
|
186
|
+
const unknownKeys = Object.keys(record).filter((key) => !ALLOWED_KEYBINDING_KEYS.has(key));
|
|
187
|
+
if (unknownKeys.length > 0) throw new Error(`Unknown keybindings key(s): ${unknownKeys.join(", ")}`);
|
|
188
|
+
const dashboard = record.dashboard;
|
|
189
|
+
if (!dashboard || typeof dashboard !== "object" || Array.isArray(dashboard)) return undefined;
|
|
190
|
+
const dashboardRecord = dashboard as Record<string, unknown>;
|
|
191
|
+
const unknownDashboardKeys = Object.keys(dashboardRecord).filter((key) => !ALLOWED_DASHBOARD_KEYBINDING_KEYS.has(key));
|
|
192
|
+
if (unknownDashboardKeys.length > 0) throw new Error(`Unknown dashboard keybinding(s): ${unknownDashboardKeys.join(", ")}`);
|
|
193
|
+
return {
|
|
194
|
+
dashboard: {
|
|
195
|
+
toggleExpand: asKeybinding(dashboardRecord.toggleExpand) ?? DEFAULT_GOAL_KEYBINDINGS.dashboard.toggleExpand,
|
|
196
|
+
scrollUp: asKeybinding(dashboardRecord.scrollUp) ?? DEFAULT_GOAL_KEYBINDINGS.dashboard.scrollUp,
|
|
197
|
+
scrollDown: asKeybinding(dashboardRecord.scrollDown) ?? DEFAULT_GOAL_KEYBINDINGS.dashboard.scrollDown,
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
131
202
|
function asThinkingLevel(value: unknown): ThinkingLevel | undefined {
|
|
132
203
|
const text = asNonEmptyString(value);
|
|
133
204
|
return text && THINKING_LEVELS.has(text) ? text as ThinkingLevel : undefined;
|
|
@@ -164,6 +235,8 @@ export function parseGoalSettings(raw: unknown): GoalSettings {
|
|
|
164
235
|
if (stallTimeoutMinutes !== undefined) settings.stallTimeoutMinutes = stallTimeoutMinutes;
|
|
165
236
|
const objectiveMaxChars = asNonNegativeInt(record.objectiveMaxChars);
|
|
166
237
|
if (objectiveMaxChars !== undefined) settings.objectiveMaxChars = objectiveMaxChars;
|
|
238
|
+
const keybindings = parseKeybindings(record.keybindings);
|
|
239
|
+
if (keybindings) settings.keybindings = keybindings;
|
|
167
240
|
return settings;
|
|
168
241
|
}
|
|
169
242
|
|
|
@@ -207,6 +280,7 @@ export function loadGoalSettings(cwd: string, env: NodeJS.ProcessEnv = process.e
|
|
|
207
280
|
auditorProjectResources: fileConfig.auditorProjectResources ?? false,
|
|
208
281
|
stallTimeoutMinutes: fileConfig.stallTimeoutMinutes,
|
|
209
282
|
objectiveMaxChars: asNonNegativeInt(env.PI_GOAL_OBJECTIVE_MAX_CHARS) ?? fileConfig.objectiveMaxChars,
|
|
283
|
+
keybindings: fileConfig.keybindings ?? DEFAULT_GOAL_KEYBINDINGS,
|
|
210
284
|
};
|
|
211
285
|
}
|
|
212
286
|
|
|
@@ -250,6 +324,7 @@ export function effectiveSettingsReport(cwd: string, env: NodeJS.ProcessEnv = pr
|
|
|
250
324
|
{ key: "auditorProjectResources", label: "auditor project resources", format: (v) => (v.auditorProjectResources === true ? "true" : "false") },
|
|
251
325
|
{ key: "stallTimeoutMinutes", label: "stall timeout (minutes)", format: (v) => String(v.stallTimeoutMinutes ?? 0) },
|
|
252
326
|
{ key: "objectiveMaxChars", label: "max objective length (0 = none)", format: (v) => String(v.objectiveMaxChars ?? 0) },
|
|
327
|
+
{ key: "keybindings", label: "dashboard keybindings", format: (v) => `${v.keybindings?.dashboard.toggleExpand ?? DEFAULT_GOAL_KEYBINDINGS.dashboard.toggleExpand}, ${v.keybindings?.dashboard.scrollUp ?? DEFAULT_GOAL_KEYBINDINGS.dashboard.scrollUp}, ${v.keybindings?.dashboard.scrollDown ?? DEFAULT_GOAL_KEYBINDINGS.dashboard.scrollDown}` },
|
|
253
328
|
];
|
|
254
329
|
for (const row of rows) {
|
|
255
330
|
const envVar = envOverrideFor(row.key, env);
|
|
@@ -286,6 +361,7 @@ export function saveGoalSettingsFileConfig(cwd: string, settings: GoalSettings):
|
|
|
286
361
|
if (settings.auditorProjectResources === true) clean.auditorProjectResources = true;
|
|
287
362
|
if (settings.stallTimeoutMinutes !== undefined) clean.stallTimeoutMinutes = settings.stallTimeoutMinutes;
|
|
288
363
|
if (settings.objectiveMaxChars !== undefined) clean.objectiveMaxChars = settings.objectiveMaxChars;
|
|
364
|
+
if (settings.keybindings) clean.keybindings = parseKeybindings(settings.keybindings);
|
|
289
365
|
const configPath = goalSettingsPath(cwd);
|
|
290
366
|
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
291
367
|
settingsFileCache.delete(configPath);
|
|
@@ -301,6 +377,7 @@ export function saveGoalSettingsFileConfig(cwd: string, settings: GoalSettings):
|
|
|
301
377
|
if (settings.auditorProjectResources === true) persisted.auditorProjectResources = true;
|
|
302
378
|
if (clean.stallTimeoutMinutes !== undefined) persisted.stallTimeoutMinutes = clean.stallTimeoutMinutes;
|
|
303
379
|
if (clean.objectiveMaxChars !== undefined) persisted.objectiveMaxChars = clean.objectiveMaxChars;
|
|
380
|
+
if (clean.keybindings) persisted.keybindings = clean.keybindings;
|
|
304
381
|
fs.writeFileSync(configPath, `${JSON.stringify(persisted, null, 2)}\n`, "utf8");
|
|
305
382
|
return clean;
|
|
306
383
|
}
|
|
@@ -2,7 +2,7 @@ import { matchesKey } from "@earendil-works/pi-tui";
|
|
|
2
2
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { cloneGoal, createGoal, nowIso, type GoalTask } from "./goal-record.ts";
|
|
4
4
|
import { checkSubtasksComplete, findTaskInTree } from "./goal-policy.ts";
|
|
5
|
-
import { loadGoalSettings } from "./goal-settings.ts";
|
|
5
|
+
import { DEFAULT_GOAL_KEYBINDINGS, loadGoalSettings } from "./goal-settings.ts";
|
|
6
6
|
import { serializeGoalFile } from "./storage/goal-files.ts";
|
|
7
7
|
import type { AuditorWidgetProgress } from "./widgets/goal-widget.ts";
|
|
8
8
|
import type { GoalCore } from "./goal-state.ts";
|
|
@@ -111,6 +111,8 @@ export async function toggleTaskViaService(core: GoalCore, ctx: ExtensionContext
|
|
|
111
111
|
export function syncTerminalInputPause(core: GoalCore, ctx: ExtensionContext): void {
|
|
112
112
|
if (!ctx.hasUI) return;
|
|
113
113
|
core.terminalInputUnsubscribe?.();
|
|
114
|
+
const settings = loadGoalSettings(typeof ctx.cwd === "string" ? ctx.cwd : process.cwd());
|
|
115
|
+
const keybindings = settings.keybindings?.dashboard ?? DEFAULT_GOAL_KEYBINDINGS.dashboard;
|
|
114
116
|
core.terminalInputUnsubscribe = ctx.ui.onTerminalInput((data) => {
|
|
115
117
|
// If an audit is running, Escape aborts the audit instead of pausing.
|
|
116
118
|
// Must return { consume: true } so the TUI doesn't also process the key
|
|
@@ -157,7 +159,7 @@ export function syncTerminalInputPause(core: GoalCore, ctx: ExtensionContext): v
|
|
|
157
159
|
// Ctrl+Shift+T — toggle the unified dashboard between compact and
|
|
158
160
|
// expanded task views (the task-list overlay is merged into the
|
|
159
161
|
// dashboard; §10).
|
|
160
|
-
if (matchesKey(data,
|
|
162
|
+
if (matchesKey(data, keybindings.toggleExpand)) {
|
|
161
163
|
core.toggleDashboardExpanded();
|
|
162
164
|
return { consume: true };
|
|
163
165
|
}
|
|
@@ -182,8 +184,8 @@ export function syncTerminalInputPause(core: GoalCore, ctx: ExtensionContext): v
|
|
|
182
184
|
return { consume: true };
|
|
183
185
|
}
|
|
184
186
|
}
|
|
185
|
-
if (matchesKey(data,
|
|
186
|
-
const key = matchesKey(data,
|
|
187
|
+
if (matchesKey(data, keybindings.scrollUp) || matchesKey(data, keybindings.scrollDown) || matchesKey(data, "ctrl+shift+pageUp") || matchesKey(data, "ctrl+shift+pageDown") || matchesKey(data, "ctrl+shift+home") || matchesKey(data, "ctrl+shift+end")) {
|
|
188
|
+
const key = matchesKey(data, keybindings.scrollUp) ? "up" : matchesKey(data, keybindings.scrollDown) ? "down" : matchesKey(data, "ctrl+shift+pageUp") ? "pageUp" : matchesKey(data, "ctrl+shift+pageDown") ? "pageDown" : matchesKey(data, "ctrl+shift+home") ? "home" : "end";
|
|
187
189
|
if (core.goalWidgetComponentRef?.current?.handleCompactScrollKey(key)) {
|
|
188
190
|
return { consume: true };
|
|
189
191
|
}
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
} from "./goal-dashboard-model.ts";
|
|
27
27
|
import type { GoalActivityItem } from "../goal-activity.ts";
|
|
28
28
|
import { truncateText } from "../goal-core.ts";
|
|
29
|
+
import { DEFAULT_GOAL_KEYBINDINGS, formatGoalKeybinding, type GoalDashboardKeybindings } from "../goal-settings.ts";
|
|
29
30
|
|
|
30
31
|
// mdLink is deliberately excluded: the chrome must stay neutral gray, not
|
|
31
32
|
// blue-tinged ("more grey than blue").
|
|
@@ -291,7 +292,7 @@ export function renderCompactDashboard(
|
|
|
291
292
|
model: GoalDashboardModel,
|
|
292
293
|
theme: Theme,
|
|
293
294
|
width: number,
|
|
294
|
-
opts: { footerHint?: string; scrollOffset?: number } = {},
|
|
295
|
+
opts: { footerHint?: string; scrollOffset?: number; keybindings?: GoalDashboardKeybindings } = {},
|
|
295
296
|
): string[] {
|
|
296
297
|
const safeWidth = Math.max(10, width);
|
|
297
298
|
const mode = layoutMode(safeWidth);
|
|
@@ -397,14 +398,22 @@ export function renderCompactDashboard(
|
|
|
397
398
|
lines.push(boxLine(theme, safeWidth, `${dim(theme, `File ${model.filePath}`)}`));
|
|
398
399
|
}
|
|
399
400
|
|
|
401
|
+
const keybindings = opts.keybindings ?? DEFAULT_GOAL_KEYBINDINGS.dashboard;
|
|
402
|
+
const toggleShortcut = formatGoalKeybinding(keybindings.toggleExpand);
|
|
403
|
+
const scrollShortcut = `${formatGoalKeybinding(keybindings.scrollUp)}/${formatGoalKeybinding(keybindings.scrollDown)}`;
|
|
404
|
+
const usesDefaultScrollKeys = keybindings.scrollUp === DEFAULT_GOAL_KEYBINDINGS.dashboard.scrollUp && keybindings.scrollDown === DEFAULT_GOAL_KEYBINDINGS.dashboard.scrollDown;
|
|
405
|
+
const scrollHint = usesDefaultScrollKeys
|
|
406
|
+
? mode === "minimal" ? "↑↓" : "Ctrl+Shift+↑↓"
|
|
407
|
+
: scrollShortcut;
|
|
408
|
+
const expandHint = mode === "wide" || mode === "medium"
|
|
409
|
+
? `${toggleShortcut}: expand tasks`
|
|
410
|
+
: `${toggleShortcut}: expand`;
|
|
400
411
|
const footerHint = opts.footerHint
|
|
401
412
|
?? (listOverflows
|
|
402
|
-
? mode === "
|
|
403
|
-
?
|
|
404
|
-
:
|
|
405
|
-
|
|
406
|
-
: "Ctrl+Shift+T: expand · Ctrl+Shift+↑↓: scroll"
|
|
407
|
-
: spec.footerHint);
|
|
413
|
+
? mode === "wide" || mode === "medium"
|
|
414
|
+
? `${toggleShortcut}: expand · ${scrollHint}: scroll`
|
|
415
|
+
: `${scrollHint}: scroll`
|
|
416
|
+
: expandHint);
|
|
408
417
|
// The auditor dot sits at the bottom-right of the border: green when the
|
|
409
418
|
// focused goal's independent auditor is on, muted gray when off. In
|
|
410
419
|
// wide/medium a right-aligned note (same frame tone as the hint) explains
|
|
@@ -434,7 +443,7 @@ export function renderExpandedDashboard(
|
|
|
434
443
|
model: GoalDashboardModel,
|
|
435
444
|
theme: Theme,
|
|
436
445
|
width: number,
|
|
437
|
-
opts: { scrollOffset?: number; rows?: number } = {},
|
|
446
|
+
opts: { scrollOffset?: number; rows?: number; keybindings?: GoalDashboardKeybindings } = {},
|
|
438
447
|
): string[] {
|
|
439
448
|
const safeWidth = Math.max(10, width);
|
|
440
449
|
const mode = layoutMode(safeWidth);
|
|
@@ -497,7 +506,8 @@ export function renderExpandedDashboard(
|
|
|
497
506
|
lines.push(...renderActivityBlock(model.recentActivity, theme, safeWidth));
|
|
498
507
|
}
|
|
499
508
|
|
|
500
|
-
|
|
509
|
+
const toggleShortcut = formatGoalKeybinding(opts.keybindings?.toggleExpand ?? DEFAULT_GOAL_KEYBINDINGS.dashboard.toggleExpand);
|
|
510
|
+
lines.push(boxFooter(theme, safeWidth, `Esc/${toggleShortcut}: collapse`));
|
|
501
511
|
return lines;
|
|
502
512
|
}
|
|
503
513
|
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
type GoalDisplayRecordLike,
|
|
10
10
|
} from "../goal-core.ts";
|
|
11
11
|
import type { GoalRecord, GoalTask, GoalTaskList, TaskStatus } from "../goal-record.ts";
|
|
12
|
-
import type { GoalSettings } from "../goal-settings.ts";
|
|
12
|
+
import type { GoalDashboardKeybindings, GoalSettings } from "../goal-settings.ts";
|
|
13
13
|
import { sisyphusStepProgress } from "../goal-policy.ts";
|
|
14
14
|
import type { GoalLedgerEvent } from "../goal-ledger.ts";
|
|
15
15
|
import {
|
|
@@ -190,7 +190,7 @@ export function renderAuditResultCardView(
|
|
|
190
190
|
return renderAuditResultCard(deriveAuditResultCard(view.verdict, view.report), theme, width);
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
-
export function renderGoalWidgetLines(goal: GoalWidgetRecord | null, theme: Theme, width: number, options: { openGoalCount?: number; auditorProgress?: AuditorWidgetProgress | null; disableTasks?: boolean; stalled?: boolean; ledgerEvents?: GoalLedgerEvent[]; expanded?: boolean; debug?: boolean; model?: GoalDashboardModel | null; compactScrollOffset?: number; expandedScrollOffset?: number; expandedTaskRows?: number } = {}): string[] {
|
|
193
|
+
export function renderGoalWidgetLines(goal: GoalWidgetRecord | null, theme: Theme, width: number, options: { openGoalCount?: number; auditorProgress?: AuditorWidgetProgress | null; disableTasks?: boolean; stalled?: boolean; ledgerEvents?: GoalLedgerEvent[]; expanded?: boolean; debug?: boolean; model?: GoalDashboardModel | null; compactScrollOffset?: number; expandedScrollOffset?: number; expandedTaskRows?: number; keybindings?: GoalDashboardKeybindings } = {}): string[] {
|
|
194
194
|
// When auditor progress is active, show the structured audit dashboard
|
|
195
195
|
// instead of the normal goal widget (§15.3).
|
|
196
196
|
if (options.auditorProgress) {
|
|
@@ -213,8 +213,8 @@ export function renderGoalWidgetLines(goal: GoalWidgetRecord | null, theme: Them
|
|
|
213
213
|
});
|
|
214
214
|
if (!model) return [];
|
|
215
215
|
const lines = options.expanded
|
|
216
|
-
? renderExpandedDashboard(model, theme, safeWidth, { scrollOffset: options.expandedScrollOffset, rows: options.expandedTaskRows })
|
|
217
|
-
: renderCompactDashboard(model, theme, safeWidth, { scrollOffset: options.compactScrollOffset });
|
|
216
|
+
? renderExpandedDashboard(model, theme, safeWidth, { scrollOffset: options.expandedScrollOffset, rows: options.expandedTaskRows, keybindings: options.keybindings })
|
|
217
|
+
: renderCompactDashboard(model, theme, safeWidth, { scrollOffset: options.compactScrollOffset, keybindings: options.keybindings });
|
|
218
218
|
return clampLinesToWidth(lines, width);
|
|
219
219
|
}
|
|
220
220
|
|
|
@@ -344,6 +344,7 @@ export class GoalWidgetComponent implements Component {
|
|
|
344
344
|
compactScrollOffset: this.compactScrollOffset,
|
|
345
345
|
expandedScrollOffset: this.expandedScrollOffset,
|
|
346
346
|
expandedTaskRows: expandedTaskViewportRows(this.lastRenderWidth),
|
|
347
|
+
keybindings: settings.keybindings?.dashboard,
|
|
347
348
|
});
|
|
348
349
|
if (this.getDebugMode()) {
|
|
349
350
|
lines.push(...this.renderDebugPanel(width));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-goal-x",
|
|
3
|
-
"version": "0.27.
|
|
3
|
+
"version": "0.27.1",
|
|
4
4
|
"description": "Goal mode extension for pi: durable long-running objectives, five model tools, structured tasks, independent completion audit, Sisyphus mode, auto-continue, and a status overlay.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "pi-goal-x contributors",
|