@howaboua/pi-ask 0.0.3 → 0.0.4

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.
Files changed (3) hide show
  1. package/README.md +7 -1
  2. package/ask/tui.ts +116 -39
  3. package/package.json +3 -3
package/README.md CHANGED
@@ -42,10 +42,16 @@ Set either value to `false`, then run `/reload`, to hide that template. The same
42
42
 
43
43
  ## TUI controls
44
44
 
45
+ The panel uses Pi's active keybindings. The defaults are:
46
+
45
47
  - `Up` / `Down` — move through choices, free text, and comments
46
- - `Enter` — choose or edit the focused item
48
+ - `Enter` — choose or edit the focused item, or save an edit
47
49
  - `Left` / `Right` — move between prompts and the review tab
48
50
  - `Tab` — advance, using `Other/rephrase` when the current prompt is unanswered
49
51
  - `Esc` — dismiss the panel
50
52
 
53
+ Configure the corresponding `tui.select.*`, `tui.editor.cursorLeft`,
54
+ `tui.editor.cursorRight`, and `tui.input.*` actions in Pi's
55
+ `keybindings.json`.
56
+
51
57
  The tool requires Pi's interactive TUI or RPC UI. It returns dismissal as a distinct result so the agent can stop asking rather than guessing.
package/ask/tui.ts CHANGED
@@ -1,8 +1,9 @@
1
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import {
2
+ type ExtensionContext,
3
+ type KeybindingsManager,
4
+ } from "@earendil-works/pi-coding-agent";
2
5
  import {
3
6
  Editor,
4
- Key,
5
- matchesKey,
6
7
  truncateToWidth,
7
8
  wrapTextWithAnsi,
8
9
  } from "@earendil-works/pi-tui";
@@ -24,6 +25,7 @@ import {
24
25
  } from "./state.js";
25
26
 
26
27
  type AskTheme = ExtensionContext["ui"]["theme"];
28
+ type AskKeybinding = Parameters<KeybindingsManager["getKeys"]>[0];
27
29
  type AddLine = (line?: string) => void;
28
30
  type EditingKind = "other" | "comment";
29
31
 
@@ -44,6 +46,7 @@ interface RenderTabsOptions {
44
46
  interface RenderReviewOptions {
45
47
  add: AddLine;
46
48
  handoff: boolean;
49
+ keybindings: KeybindingsManager;
47
50
  promptStates: PromptState[];
48
51
  prompts: AskPrompt[];
49
52
  theme: AskTheme;
@@ -64,6 +67,7 @@ interface RenderPromptOptions {
64
67
  editor: Editor;
65
68
  handoff: boolean;
66
69
  isEditing: EditingKind | null;
70
+ keybindings: KeybindingsManager;
67
71
  choices: PromptChoice[];
68
72
  prompt: AskPrompt;
69
73
  promptState: PromptState | undefined;
@@ -86,6 +90,18 @@ function addWrapped(
86
90
  }
87
91
  }
88
92
 
93
+ function renderKeyHint(
94
+ theme: AskTheme,
95
+ keybindings: KeybindingsManager,
96
+ action: AskKeybinding,
97
+ description: string,
98
+ ): string {
99
+ return (
100
+ theme.fg("dim", keybindings.getKeys(action).join("/")) +
101
+ theme.fg("muted", ` ${description}`)
102
+ );
103
+ }
104
+
89
105
  function renderAskTabs({
90
106
  handoff,
91
107
  prompts,
@@ -110,17 +126,13 @@ function renderAskTabs({
110
126
  function renderAskReview({
111
127
  add,
112
128
  handoff,
129
+ keybindings,
113
130
  promptStates,
114
131
  prompts,
115
132
  theme,
116
133
  width,
117
134
  }: RenderReviewOptions): void {
118
- add(
119
- theme.fg(
120
- handoff ? "warning" : "accent",
121
- handoff ? " Resume agent" : " Review",
122
- ),
123
- );
135
+ add(theme.fg("accent", handoff ? " Resume agent" : " Review"));
124
136
  add();
125
137
  prompts.forEach((prompt, index) => {
126
138
  const promptState = promptStates[index];
@@ -147,13 +159,32 @@ function renderAskReview({
147
159
  }
148
160
  });
149
161
  add();
150
- add(
151
- theme.fg(
152
- "dim",
153
- handoff
154
- ? " Enter returns control • ←/→ prompts • Esc dismisses"
155
- : " Enter submits • ←/→ prompts • Esc dismisses",
156
- ),
162
+ addWrapped(
163
+ add,
164
+ " " +
165
+ renderKeyHint(
166
+ theme,
167
+ keybindings,
168
+ "tui.select.confirm",
169
+ handoff ? "return control" : "submit",
170
+ ) +
171
+ " • " +
172
+ renderKeyHint(
173
+ theme,
174
+ keybindings,
175
+ "tui.editor.cursorLeft",
176
+ "previous prompt",
177
+ ) +
178
+ " • " +
179
+ renderKeyHint(
180
+ theme,
181
+ keybindings,
182
+ "tui.editor.cursorRight",
183
+ "next prompt",
184
+ ) +
185
+ " • " +
186
+ renderKeyHint(theme, keybindings, "tui.select.cancel", "dismiss"),
187
+ width,
157
188
  );
158
189
  }
159
190
 
@@ -178,6 +209,7 @@ function renderAskPrompt({
178
209
  editor,
179
210
  handoff,
180
211
  isEditing,
212
+ keybindings,
181
213
  choices,
182
214
  prompt,
183
215
  promptState,
@@ -186,7 +218,7 @@ function renderAskPrompt({
186
218
  width,
187
219
  }: RenderPromptOptions): void {
188
220
  if (handoff) {
189
- add(theme.fg("warning", " Human action needed"));
221
+ add(theme.fg("accent", " Human action needed"));
190
222
  add();
191
223
  }
192
224
  addWrapped(add, theme.fg("text", prompt.title), width, " ");
@@ -237,17 +269,39 @@ function renderAskPrompt({
237
269
  for (const line of editor.render(width - 2)) add(` ${line}`);
238
270
  }
239
271
  add();
240
- add(
241
- theme.fg(
242
- "dim",
243
- " ↑↓ select • Enter choose/type • Tab next/default • blank Other/rephrase = follow-up • Esc close",
244
- ),
272
+ if (isEditing) {
273
+ addWrapped(
274
+ add,
275
+ " " +
276
+ renderKeyHint(theme, keybindings, "tui.input.submit", "save") +
277
+ " • " +
278
+ renderKeyHint(theme, keybindings, "tui.input.tab", "next/default") +
279
+ " • " +
280
+ renderKeyHint(theme, keybindings, "tui.select.cancel", "cancel edit"),
281
+ width,
282
+ );
283
+ return;
284
+ }
285
+ addWrapped(
286
+ add,
287
+ " " +
288
+ renderKeyHint(theme, keybindings, "tui.select.up", "up") +
289
+ " • " +
290
+ renderKeyHint(theme, keybindings, "tui.select.down", "down") +
291
+ " • " +
292
+ renderKeyHint(theme, keybindings, "tui.select.confirm", "choose/type") +
293
+ " • " +
294
+ renderKeyHint(theme, keybindings, "tui.input.tab", "next/default") +
295
+ " • blank Other/rephrase = follow-up • " +
296
+ renderKeyHint(theme, keybindings, "tui.select.cancel", "close"),
297
+ width,
245
298
  );
246
299
  }
247
300
 
248
301
  interface EditingInputOptions {
249
302
  advance: () => void;
250
303
  editor: Editor;
304
+ keybindings: KeybindingsManager;
251
305
  refresh: () => void;
252
306
  saveEditing: (submittedText?: string) => void;
253
307
  state: AskUiState;
@@ -255,15 +309,22 @@ interface EditingInputOptions {
255
309
 
256
310
  function handleAskEditingInput(
257
311
  data: string,
258
- { advance, editor, refresh, saveEditing, state }: EditingInputOptions,
312
+ {
313
+ advance,
314
+ editor,
315
+ keybindings,
316
+ refresh,
317
+ saveEditing,
318
+ state,
319
+ }: EditingInputOptions,
259
320
  ): void {
260
- if (matchesKey(data, Key.tab)) {
321
+ if (keybindings.matches(data, "tui.input.tab")) {
261
322
  saveEditing(editor.getText());
262
323
  state.editing = null;
263
324
  advance();
264
325
  return;
265
326
  }
266
- if (matchesKey(data, Key.escape)) {
327
+ if (keybindings.matches(data, "tui.select.cancel")) {
267
328
  state.editing = null;
268
329
  refresh();
269
330
  return;
@@ -276,6 +337,7 @@ interface PromptInputOptions {
276
337
  advanceWithDefault: () => void;
277
338
  choices: () => PromptChoice[];
278
339
  count: () => number;
340
+ keybindings: KeybindingsManager;
279
341
  pick: (index: number) => void;
280
342
  refresh: () => void;
281
343
  selectOther: () => void;
@@ -289,6 +351,7 @@ function handleAskPromptInput(
289
351
  advanceWithDefault,
290
352
  choices,
291
353
  count,
354
+ keybindings,
292
355
  pick,
293
356
  refresh,
294
357
  selectOther,
@@ -296,21 +359,21 @@ function handleAskPromptInput(
296
359
  state,
297
360
  }: PromptInputOptions,
298
361
  ): void {
299
- if (matchesKey(data, Key.up)) {
362
+ if (keybindings.matches(data, "tui.select.up")) {
300
363
  state.focus = Math.max(0, state.focus - 1);
301
364
  refresh();
302
365
  return;
303
366
  }
304
- if (matchesKey(data, Key.down)) {
367
+ if (keybindings.matches(data, "tui.select.down")) {
305
368
  state.focus = Math.min(count() - 1, state.focus + 1);
306
369
  refresh();
307
370
  return;
308
371
  }
309
- if (matchesKey(data, Key.tab)) {
372
+ if (keybindings.matches(data, "tui.input.tab")) {
310
373
  advanceWithDefault();
311
374
  return;
312
375
  }
313
- if (!matchesKey(data, Key.enter)) return;
376
+ if (!keybindings.matches(data, "tui.select.confirm")) return;
314
377
  if (state.focus === choices().length) {
315
378
  selectOther();
316
379
  return;
@@ -325,6 +388,7 @@ function handleAskPromptInput(
325
388
  interface NavigationInputOptions {
326
389
  done: (responses: AskResponse[] | null) => void;
327
390
  isReview: () => boolean;
391
+ keybindings: KeybindingsManager;
328
392
  promptStates: PromptState[];
329
393
  prompts: AskPrompt[];
330
394
  setTab: (next: number) => void;
@@ -336,26 +400,27 @@ function handleAskNavigationInput(
336
400
  {
337
401
  done,
338
402
  isReview,
403
+ keybindings,
339
404
  promptStates,
340
405
  prompts,
341
406
  setTab,
342
407
  state,
343
408
  }: NavigationInputOptions,
344
409
  ): boolean {
345
- if (matchesKey(data, Key.escape)) {
410
+ if (keybindings.matches(data, "tui.select.cancel")) {
346
411
  done(null);
347
412
  return true;
348
413
  }
349
- if (matchesKey(data, Key.left)) {
414
+ if (keybindings.matches(data, "tui.editor.cursorLeft")) {
350
415
  setTab(state.tab - 1);
351
416
  return true;
352
417
  }
353
- if (matchesKey(data, Key.right)) {
418
+ if (keybindings.matches(data, "tui.editor.cursorRight")) {
354
419
  setTab(state.tab + 1);
355
420
  return true;
356
421
  }
357
422
  if (!isReview()) return false;
358
- if (matchesKey(data, Key.enter)) {
423
+ if (keybindings.matches(data, "tui.select.confirm")) {
359
424
  done(promptStatesToResponses(prompts, promptStates));
360
425
  }
361
426
  return true;
@@ -367,7 +432,7 @@ export async function askInTui(
367
432
  { handoff = false, signal }: { handoff?: boolean; signal?: AbortSignal } = {},
368
433
  ): Promise<AskResponse[] | null> {
369
434
  if (!ctx.hasUI || signal?.aborted) return null;
370
- return await ctx.ui.custom<AskResponse[] | null>((tui, theme, _kb, done) => {
435
+ return await ctx.ui.custom<AskResponse[] | null>((tui, theme, kb, done) => {
371
436
  let settled = false;
372
437
  const finish = (result: AskResponse[] | null) => {
373
438
  if (settled) return;
@@ -384,13 +449,13 @@ export async function askInTui(
384
449
  // transitions easy to desynchronise as this tool grows.
385
450
  const promptStates = prompts.map(createPromptState);
386
451
  const editor = new Editor(tui, {
387
- borderColor: (text) => theme.fg(handoff ? "warning" : "accent", text),
452
+ borderColor: (text) => theme.fg("accent", text),
388
453
  selectList: {
389
454
  selectedPrefix: (text) => theme.fg("accent", text),
390
455
  selectedText: (text) => theme.fg("accent", text),
391
456
  description: (text) => theme.fg("muted", text),
392
457
  scrollInfo: (text) => theme.fg("dim", text),
393
- noMatch: (text) => theme.fg("warning", text),
458
+ noMatch: (text) => theme.fg("muted", text),
394
459
  },
395
460
  });
396
461
 
@@ -468,6 +533,7 @@ export async function askInTui(
468
533
  handleAskEditingInput(data, {
469
534
  advance,
470
535
  editor,
536
+ keybindings: kb,
471
537
  refresh,
472
538
  saveEditing,
473
539
  state,
@@ -478,6 +544,7 @@ export async function askInTui(
478
544
  handleAskNavigationInput(data, {
479
545
  done: finish,
480
546
  isReview,
547
+ keybindings: kb,
481
548
  promptStates,
482
549
  prompts,
483
550
  setTab,
@@ -490,6 +557,7 @@ export async function askInTui(
490
557
  advanceWithDefault,
491
558
  count,
492
559
  choices,
560
+ keybindings: kb,
493
561
  pick,
494
562
  refresh,
495
563
  selectOther,
@@ -502,13 +570,21 @@ export async function askInTui(
502
570
  const lines: string[] = [];
503
571
  const add: AddLine = (line = "") =>
504
572
  lines.push(truncateToWidth(line, width));
505
- add(theme.fg(handoff ? "warning" : "accent", "─".repeat(width)));
573
+ add(theme.fg("accent", "─".repeat(width)));
506
574
  add(
507
575
  renderAskTabs({ handoff, prompts, responded, tab: state.tab, theme }),
508
576
  );
509
577
  add();
510
578
  if (isReview()) {
511
- renderAskReview({ add, handoff, promptStates, prompts, theme, width });
579
+ renderAskReview({
580
+ add,
581
+ handoff,
582
+ keybindings: kb,
583
+ promptStates,
584
+ prompts,
585
+ theme,
586
+ width,
587
+ });
512
588
  } else {
513
589
  const prompt = current();
514
590
  if (prompt) {
@@ -517,6 +593,7 @@ export async function askInTui(
517
593
  editor,
518
594
  handoff,
519
595
  isEditing: state.editing,
596
+ keybindings: kb,
520
597
  choices: choices(),
521
598
  prompt,
522
599
  promptState: currentPromptState(),
@@ -526,7 +603,7 @@ export async function askInTui(
526
603
  });
527
604
  }
528
605
  }
529
- add(theme.fg(handoff ? "warning" : "accent", "─".repeat(width)));
606
+ add(theme.fg("accent", "─".repeat(width)));
530
607
  cached = lines;
531
608
  return lines;
532
609
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@howaboua/pi-ask",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "description": "Interactive decisions, review triage, and human handoffs for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -49,8 +49,8 @@
49
49
  },
50
50
  "devDependencies": {
51
51
  "@biomejs/biome": "^2.4.16",
52
- "@earendil-works/pi-coding-agent": "0.80.7",
53
- "@earendil-works/pi-tui": "0.80.7",
52
+ "@earendil-works/pi-coding-agent": "0.80.8",
53
+ "@earendil-works/pi-tui": "0.80.8",
54
54
  "typebox": "1.1.38",
55
55
  "typescript": "^6.0.3"
56
56
  },