pi-goal-x 0.27.0 → 0.27.2
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 +52 -10
- 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 {
|
|
@@ -134,6 +134,39 @@ function fit(value: string, width: number): string {
|
|
|
134
134
|
return visibleWidth(value) > width ? truncateToWidth(value, width, "…") : value;
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
// ── terminal-height bound (spec 2026-08-10-widget-height-bound-scrollback-fix) ──
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Minimum rows reserved below/around the widget so pi's frame stays usable
|
|
141
|
+
* when the widget is at its cap: status line (1) + editor minimum (3) +
|
|
142
|
+
* footer (1) + a chat row (1). With the widget capped at
|
|
143
|
+
* `terminalRows − WIDGET_HEIGHT_RESERVE`, the widget never fills the terminal:
|
|
144
|
+
* the chat row stays in the frame, the widget's top lines stay within the
|
|
145
|
+
* viewport (no pi-tui full-render scrollback wipe), and the editor/footer
|
|
146
|
+
* stay visible — the equal-height scroll-up failure is impossible. Kept at
|
|
147
|
+
* the minimum that still guarantees the chat row: a larger reserve would
|
|
148
|
+
* slice dashboards that genuinely fit (e.g. the expanded 24-line dashboard
|
|
149
|
+
* on a 30-row terminal renders unchanged at 24 + 5 chrome + 1 chat = 30).
|
|
150
|
+
*/
|
|
151
|
+
export const WIDGET_HEIGHT_RESERVE = 6;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Deterministic height guard: cap rendered lines to the terminal height minus
|
|
155
|
+
* the reserved chrome. Head slice, because the dashboard's content priority
|
|
156
|
+
* is top-down (identity → status → progress → tasks → details → hints); the
|
|
157
|
+
* goal identity/status and the interactive task list stay in frame. Pure
|
|
158
|
+
* index function — stable across renders, so the widget never shrinks on its
|
|
159
|
+
* own and can never trigger pi-tui's clearOnShrink full-render
|
|
160
|
+
* (`\x1b[2J\x1b[H\x1b[3J` scrollback wipe). No-op (unbounded) when
|
|
161
|
+
* terminalRows is missing (mock TUI, headless contexts, /goal-status text).
|
|
162
|
+
*/
|
|
163
|
+
export function boundWidgetRenderLines(lines: string[], terminalRows: number | undefined): string[] {
|
|
164
|
+
if (!terminalRows || terminalRows <= 0) return lines;
|
|
165
|
+
const cap = Math.max(1, terminalRows - WIDGET_HEIGHT_RESERVE);
|
|
166
|
+
if (lines.length <= cap) return lines;
|
|
167
|
+
return lines.slice(0, cap);
|
|
168
|
+
}
|
|
169
|
+
|
|
137
170
|
function heading(theme: Theme, width: number, left: string, right = ""): string {
|
|
138
171
|
if (!right) return fit(left, width);
|
|
139
172
|
const rightPart = ` ${right}`;
|
|
@@ -190,18 +223,18 @@ export function renderAuditResultCardView(
|
|
|
190
223
|
return renderAuditResultCard(deriveAuditResultCard(view.verdict, view.report), theme, width);
|
|
191
224
|
}
|
|
192
225
|
|
|
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[] {
|
|
226
|
+
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; /** Terminal-height bound (see boundWidgetRenderLines); undefined = unbounded. */ terminalRows?: number } = {}): string[] {
|
|
194
227
|
// When auditor progress is active, show the structured audit dashboard
|
|
195
228
|
// instead of the normal goal widget (§15.3).
|
|
196
229
|
if (options.auditorProgress) {
|
|
197
|
-
return renderAuditorWidgetLines(options.auditorProgress, theme, width, {
|
|
230
|
+
return boundWidgetRenderLines(renderAuditorWidgetLines(options.auditorProgress, theme, width, {
|
|
198
231
|
showToolDetails: options.expanded === true || options.debug === true,
|
|
199
|
-
});
|
|
232
|
+
}), options.terminalRows);
|
|
200
233
|
}
|
|
201
234
|
const openGoalCount = options.openGoalCount ?? 0;
|
|
202
235
|
if (!goal) {
|
|
203
236
|
// Unfocused panel when open goals exist; nothing when the pool is empty.
|
|
204
|
-
return openGoalCount > 0 ? renderUnfocusedDashboard(openGoalCount, theme, width) : [];
|
|
237
|
+
return boundWidgetRenderLines(openGoalCount > 0 ? renderUnfocusedDashboard(openGoalCount, theme, width) : [], options.terminalRows);
|
|
205
238
|
}
|
|
206
239
|
const safeWidth = Math.max(1, width);
|
|
207
240
|
const otherCount = Math.max(0, openGoalCount - 1);
|
|
@@ -213,9 +246,9 @@ export function renderGoalWidgetLines(goal: GoalWidgetRecord | null, theme: Them
|
|
|
213
246
|
});
|
|
214
247
|
if (!model) return [];
|
|
215
248
|
const lines = options.expanded
|
|
216
|
-
? renderExpandedDashboard(model, theme, safeWidth, { scrollOffset: options.expandedScrollOffset, rows: options.expandedTaskRows })
|
|
217
|
-
: renderCompactDashboard(model, theme, safeWidth, { scrollOffset: options.compactScrollOffset });
|
|
218
|
-
return clampLinesToWidth(lines, width);
|
|
249
|
+
? renderExpandedDashboard(model, theme, safeWidth, { scrollOffset: options.expandedScrollOffset, rows: options.expandedTaskRows, keybindings: options.keybindings })
|
|
250
|
+
: renderCompactDashboard(model, theme, safeWidth, { scrollOffset: options.compactScrollOffset, keybindings: options.keybindings });
|
|
251
|
+
return boundWidgetRenderLines(clampLinesToWidth(lines, width), options.terminalRows);
|
|
219
252
|
}
|
|
220
253
|
|
|
221
254
|
export class GoalWidgetComponent implements Component {
|
|
@@ -318,10 +351,17 @@ export class GoalWidgetComponent implements Component {
|
|
|
318
351
|
render(width: number): string[] {
|
|
319
352
|
const settings = this.getSettings();
|
|
320
353
|
this.lastRenderWidth = Math.max(1, width);
|
|
354
|
+
// Terminal-height bound (spec 2026-08-10): read pi's terminal rows at
|
|
355
|
+
// render time so the widget never fills the terminal — the chat/editor
|
|
356
|
+
// stay visible and scrollable and the widget can never trigger pi-tui's
|
|
357
|
+
// shrink full-render (\x1b[2J\x1b[H\x1b[3J) scrollback wipe. Same cast
|
|
358
|
+
// pattern as the questionnaire churn guard; mock TUIs without a
|
|
359
|
+
// `terminal` render unbounded.
|
|
360
|
+
const terminalRows = (this.tui as unknown as { terminal?: { rows?: number } }).terminal?.rows;
|
|
321
361
|
// §15.4: a finished audit shows its result card until cleared.
|
|
322
362
|
const auditResult = this.getAuditResult();
|
|
323
363
|
if (auditResult) {
|
|
324
|
-
return clampLinesToWidth(renderAuditResultCardView(auditResult, this.theme, width), width);
|
|
364
|
+
return boundWidgetRenderLines(clampLinesToWidth(renderAuditResultCardView(auditResult, this.theme, width), width), terminalRows);
|
|
325
365
|
}
|
|
326
366
|
const goal = this.getGoal();
|
|
327
367
|
const otherCount = Math.max(0, this.getOpenGoalCount() - 1);
|
|
@@ -344,11 +384,13 @@ export class GoalWidgetComponent implements Component {
|
|
|
344
384
|
compactScrollOffset: this.compactScrollOffset,
|
|
345
385
|
expandedScrollOffset: this.expandedScrollOffset,
|
|
346
386
|
expandedTaskRows: expandedTaskViewportRows(this.lastRenderWidth),
|
|
387
|
+
keybindings: settings.keybindings?.dashboard,
|
|
388
|
+
terminalRows,
|
|
347
389
|
});
|
|
348
390
|
if (this.getDebugMode()) {
|
|
349
391
|
lines.push(...this.renderDebugPanel(width));
|
|
350
392
|
}
|
|
351
|
-
return clampLinesToWidth(lines, width);
|
|
393
|
+
return boundWidgetRenderLines(clampLinesToWidth(lines, width), terminalRows);
|
|
352
394
|
}
|
|
353
395
|
|
|
354
396
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-goal-x",
|
|
3
|
-
"version": "0.27.
|
|
3
|
+
"version": "0.27.2",
|
|
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",
|