@mjasnikovs/pi-task 0.13.8 → 0.13.9

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.
@@ -28,6 +28,19 @@ export interface AskSpec {
28
28
  recommended2?: string;
29
29
  /** Whether the browser card shows a Skip button (answers with empty string). */
30
30
  allowSkip: boolean;
31
+ /**
32
+ * When set, the local TUI shows a select() picker of these entries instead of
33
+ * a bare text input — one option per line, arrow-key navigable. Each entry's
34
+ * `label` is what the picker displays; its `value` is what ask() resolves to
35
+ * when chosen. A built-in "type a different answer" entry is appended that
36
+ * falls back to a text input, preserving the free-text override. Used for the
37
+ * binary A/B grill/clarify fork. Remote browsers ignore this and keep
38
+ * rendering recommended/recommended2 as buttons.
39
+ */
40
+ options?: {
41
+ label: string;
42
+ value: string;
43
+ }[];
31
44
  }
32
45
  /** Wraps a live command ctx and fans interactions out to local TUI + browsers. */
33
46
  export declare class SessionUI {
@@ -38,6 +51,15 @@ export declare class SessionUI {
38
51
  get hasUI(): boolean;
39
52
  /** Race the local input against a remote answer; first to settle wins. */
40
53
  ask(spec: AskSpec): Promise<string | undefined>;
54
+ /**
55
+ * The local-TUI half of ask(). With `spec.options` it renders a select()
56
+ * picker (each option on its own line) plus a trailing "type a different
57
+ * answer" entry that drops to a text input; the chosen entry's `value` is
58
+ * returned. Without options it falls back to a single text input. Cancelling
59
+ * either dialog (or an abort when the remote wins the race) resolves to
60
+ * undefined.
61
+ */
62
+ private askLocal;
41
63
  }
42
64
  export declare function publishNotify(message: string, level: 'info' | 'warning' | 'error'): void;
43
65
  export declare function publishViewer(title: string, text: string): void;
@@ -25,6 +25,9 @@ export function answerPrompt(id, value) {
25
25
  b.pending.delete(id);
26
26
  settle(value);
27
27
  }
28
+ /** Trailing picker entry that drops to a free-text input — the local mirror of
29
+ * the remote card's "✎ Manual answer" button. */
30
+ const TYPE_OWN_LABEL = 'Type a different answer…';
28
31
  /** Wraps a live command ctx and fans interactions out to local TUI + browsers. */
29
32
  export class SessionUI {
30
33
  ctx;
@@ -63,9 +66,7 @@ export class SessionUI {
63
66
  // Local: resolves to a value/undefined, or undefined on abort. Swallow
64
67
  // the rejection some implementations throw on abort so it never leaks.
65
68
  const local = this.ctx.hasUI ?
66
- this.ctx.ui
67
- .input(spec.localTitle, spec.recommended, { signal: ac.signal })
68
- .catch(() => undefined)
69
+ this.askLocal(spec, ac.signal).catch(() => undefined)
69
70
  : new Promise(() => { });
70
71
  try {
71
72
  const winner = await Promise.race([
@@ -81,6 +82,31 @@ export class SessionUI {
81
82
  clearPrompt(id);
82
83
  }
83
84
  }
85
+ /**
86
+ * The local-TUI half of ask(). With `spec.options` it renders a select()
87
+ * picker (each option on its own line) plus a trailing "type a different
88
+ * answer" entry that drops to a text input; the chosen entry's `value` is
89
+ * returned. Without options it falls back to a single text input. Cancelling
90
+ * either dialog (or an abort when the remote wins the race) resolves to
91
+ * undefined.
92
+ */
93
+ async askLocal(spec, signal) {
94
+ const opts = spec.options;
95
+ if (opts && opts.length > 0) {
96
+ const labels = opts.map(o => o.label);
97
+ const choice = await this.ctx.ui.select(spec.localTitle, [...labels, TYPE_OWN_LABEL], {
98
+ signal
99
+ });
100
+ if (choice === undefined)
101
+ return undefined; // cancelled / aborted
102
+ if (choice === TYPE_OWN_LABEL) {
103
+ return this.ctx.ui.input(spec.localTitle, undefined, { signal });
104
+ }
105
+ const hit = opts.find(o => o.label === choice);
106
+ return hit ? hit.value : choice;
107
+ }
108
+ return this.ctx.ui.input(spec.localTitle, spec.recommended, { signal });
109
+ }
84
110
  }
85
111
  export function publishNotify(message, level) {
86
112
  getBridge().broadcast({ type: 'notify', message, level });
@@ -80,31 +80,39 @@ export async function planAuto(ctx, cwd, feature, deps) {
80
80
  const plainQ = stripInlineMarkdown(question);
81
81
  const plainSuggested = suggested === undefined ? undefined : stripInlineMarkdown(suggested);
82
82
  const plainAlt = alt === undefined ? undefined : stripInlineMarkdown(alt);
83
- // Compact A/B presentation, identical to /task's grill dialog: a binary
84
- // fork shows both options labelled A/B; a single recommendation shows just
85
- // the default; an open question shows the bare prompt. No verbose
86
- // "Recommended:" / "press Enter to accept" scaffolding.
87
- const title = plainSuggested ?
88
- plainAlt ?
89
- `${shownQ}\nA: ${renderInlineMarkdown(suggested, theme)}\nB: ${renderInlineMarkdown(alt, theme)}`
90
- : `${shownQ}\n${renderInlineMarkdown(suggested, theme)}`
83
+ // Identical to /task's grill dialog: a binary fork becomes a select()
84
+ // picker locally each option on its own line, labelled A/B; a single
85
+ // recommendation rides under the question as the input default; an open
86
+ // question shows the bare prompt. No verbose "Recommended:" /
87
+ // "press Enter to accept" scaffolding.
88
+ const twoOption = plainSuggested !== undefined && plainAlt !== undefined;
89
+ const title = !twoOption && plainSuggested ?
90
+ `${shownQ}\n${renderInlineMarkdown(suggested, theme)}`
91
91
  : shownQ;
92
92
  const a = await ui.ask({
93
93
  localTitle: title,
94
94
  question: plainQ,
95
95
  recommended: plainSuggested,
96
96
  ...(plainAlt !== undefined && { recommended2: plainAlt }),
97
- allowSkip: plainSuggested === undefined && plainAlt === undefined
97
+ allowSkip: plainSuggested === undefined && plainAlt === undefined,
98
+ ...(twoOption && {
99
+ options: [
100
+ {
101
+ label: `A: ${renderInlineMarkdown(suggested, theme)}`,
102
+ value: plainSuggested
103
+ },
104
+ { label: `B: ${renderInlineMarkdown(alt, theme)}`, value: plainAlt }
105
+ ]
106
+ })
98
107
  });
99
108
  if (a === undefined) {
100
109
  ctx.ui.notify('/task-auto cancelled.', 'warning');
101
110
  return null;
102
111
  }
103
112
  const typed = a.trim();
104
- // Two-option mode labels the choices "A:"/"B:", so a user (local TUI or
105
- // remote "Manual answer") naturally types the bare letter to pick. Map it
106
- // back to the option's full text. Mirrors phaseGrill's answer mapping.
107
- const twoOption = plainSuggested !== undefined && plainAlt !== undefined;
113
+ // The local picker resolves to the chosen option's full value, but a
114
+ // remote user (or the picker's free-text fallback) may still type a bare
115
+ // "A"/"B" — map those back to the option's full text. Mirrors phaseGrill.
108
116
  let answer;
109
117
  if (typed.length === 0 && plainSuggested) {
110
118
  answer = `${plainSuggested} (accepted recommendation)`;
@@ -326,10 +326,13 @@ export async function phaseGrill(deps, ctx, widgetState, refined, research) {
326
326
  else {
327
327
  const plainSuggested = auto.suggested === undefined ? undefined : stripInlineMarkdown(auto.suggested);
328
328
  const plainAlt = auto.alt === undefined ? undefined : stripInlineMarkdown(auto.alt);
329
- const localTitle = plainSuggested ?
330
- plainAlt ?
331
- `${shownQ}\nA: ${renderInlineMarkdown(auto.suggested, theme)}\nB: ${renderInlineMarkdown(auto.alt, theme)}`
332
- : `${shownQ}\n${renderInlineMarkdown(auto.suggested, theme)}`
329
+ // A binary fork (suggested + alt) becomes a select() picker locally —
330
+ // each option on its own line, labelled A/B. A single recommendation
331
+ // rides along under the question as the input default; an open
332
+ // question shows the bare prompt.
333
+ const twoOption = plainSuggested !== undefined && plainAlt !== undefined;
334
+ const localTitle = !twoOption && plainSuggested ?
335
+ `${shownQ}\n${renderInlineMarkdown(auto.suggested, theme)}`
333
336
  : shownQ;
334
337
  widgetState.lastLine = `awaiting Q${n + 1}`;
335
338
  const a = await ui.ask({
@@ -337,16 +340,25 @@ export async function phaseGrill(deps, ctx, widgetState, refined, research) {
337
340
  question: plainQ,
338
341
  recommended: plainSuggested,
339
342
  recommended2: plainAlt,
340
- allowSkip: plainSuggested === undefined && plainAlt === undefined
343
+ allowSkip: plainSuggested === undefined && plainAlt === undefined,
344
+ ...(twoOption && {
345
+ options: [
346
+ {
347
+ label: `A: ${renderInlineMarkdown(auto.suggested, theme)}`,
348
+ value: plainSuggested
349
+ },
350
+ { label: `B: ${renderInlineMarkdown(auto.alt, theme)}`, value: plainAlt }
351
+ ]
352
+ })
341
353
  });
342
354
  if (a === undefined)
343
355
  throw new Error(USER_CANCELLED);
344
356
  const typed = a.trim();
345
- // Two-option mode labels the choices "A:"/"B:", so a user (local TUI
346
- // or remote "Manual answer") naturally types the bare letter to pick.
347
- // Map it back to the option's full text — storing the literal "A"
348
- // leaves the next grill-gen call a dangling reference it can't decode.
349
- const twoOption = plainSuggested !== undefined && plainAlt !== undefined;
357
+ // The local picker resolves to the chosen option's full value, but a
358
+ // remote user (or the picker's free-text fallback) may still type a
359
+ // bare "A"/"B" — map those back to the option's full text, since
360
+ // storing the literal letter leaves the next grill-gen call a
361
+ // dangling reference it can't decode.
350
362
  if (typed.length === 0 && plainSuggested) {
351
363
  answer = plainSuggested;
352
364
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.13.8",
3
+ "version": "0.13.9",
4
4
  "description": "Deterministic spec-orchestration for local models, with a bundled real-time remote web view and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",