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 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[key] ?? "(default)";
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
- inputMode = false;
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
- inputMode = false;
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
- inputMode = true;
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
- // §options-scroll: select-mode question tabs and the submit summary
815
- // are a viewport over the full content (never truncated); input
816
- // mode keeps the legacy tail-keep so the editor stays the priority.
817
- let scrollState: DialogScrollState | null = null;
818
- if (!inputMode) {
819
- scrollState = { scrollTop, needsFollow, optionRanges, followIndex: optionIndex };
820
- }
821
- lines = fitDialogLines(lines, maxDialogLines, protectedCount, proposalSegments, scrollState, (s) => theme.fg("dim", s));
822
- if (scrollState) {
823
- scrollTop = scrollState.scrollTop;
824
- needsFollow = scrollState.needsFollow;
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 { render, invalidate: () => { cachedLines = undefined; }, handleInput };
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, "ctrl+shift+t")) {
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, "ctrl+shift+up") || matchesKey(data, "ctrl+shift+down") || matchesKey(data, "ctrl+shift+pageUp") || matchesKey(data, "ctrl+shift+pageDown") || matchesKey(data, "ctrl+shift+home") || matchesKey(data, "ctrl+shift+end")) {
186
- const key = matchesKey(data, "ctrl+shift+up") ? "up" : matchesKey(data, "ctrl+shift+down") ? "down" : matchesKey(data, "ctrl+shift+pageUp") ? "pageUp" : matchesKey(data, "ctrl+shift+pageDown") ? "pageDown" : matchesKey(data, "ctrl+shift+home") ? "home" : "end";
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 === "minimal"
403
- ? "↑↓: scroll"
404
- : mode === "narrow"
405
- ? "Ctrl+Shift+↑↓: scroll"
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
- lines.push(boxFooter(theme, safeWidth, "Esc/Ctrl+Shift+T: collapse"));
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.0",
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",