@howaboua/pi-ask 0.0.4 → 0.0.6

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/CHANGELOG.md ADDED
@@ -0,0 +1,45 @@
1
+ # @howaboua/pi-ask
2
+
3
+ ## 0.0.6
4
+
5
+ - Added a Code and Notebook Mode bridge API. This allows extensions that use Pi TUI to run inside `exec`.
6
+
7
+ ## 0.0.5
8
+
9
+ ### Changes
10
+
11
+ - [#269](https://github.com/IgorWarzocha/howaboua-pi-stuff/pull/269) [`6138ffd`](https://github.com/IgorWarzocha/howaboua-pi-stuff/commit/6138ffd735bb4f7f80e451320dbfd0933a4acaa7) Thanks [@howaclawa](https://github.com/howaclawa)!:
12
+ - Add shared realtime voice prompts for ask prompts, Auto Trees, Shepherdr settlements, and review progress.
13
+ - Announce compaction and stream conversational Pi updates after two sentences.
14
+ - Keep silent tool-step summaries compatible without exposing Chat Completions thinking content.
15
+ - Configure delegation acknowledgements and deliver V3 delegations immediately.
16
+ - Preserve late delegations, calls after data-channel closure, prepared Code Mode prompts, and Codex cache continuity.
17
+ - Reduce LAN playback dropouts with one more jitter-buffer frame.
18
+
19
+ ## 0.0.4
20
+
21
+ ### Changes
22
+
23
+ - [#131](https://github.com/IgorWarzocha/howaboua-pi-stuff/pull/131) [`828f52e`](https://github.com/IgorWarzocha/howaboua-pi-stuff/commit/828f52eff1291629d73c3058173cff2fa60e3b28) Thanks [@IgorWarzocha](https://github.com/IgorWarzocha)!:
24
+ - Uses configured Pi keybindings for ask navigation and theme-native TUI colors.
25
+
26
+ ## 0.0.3
27
+
28
+ ### Changes
29
+
30
+ - [#128](https://github.com/IgorWarzocha/howaboua-pi-stuff/pull/128) [`9604ec3`](https://github.com/IgorWarzocha/howaboua-pi-stuff/commit/9604ec3505eff2d9ee789f42ef45038bc00da02e) Thanks [@IgorWarzocha](https://github.com/IgorWarzocha)!:
31
+ - Remove the duplicated ask tool name from the model-facing prompt inventory.
32
+
33
+ ## 0.0.2
34
+
35
+ ### Changes
36
+
37
+ - [#120](https://github.com/IgorWarzocha/howaboua-pi-stuff/pull/120) [`8f82078`](https://github.com/IgorWarzocha/howaboua-pi-stuff/commit/8f82078a8e733dbe770c7b5fd9ad1b20cd5a21af) Thanks [@IgorWarzocha](https://github.com/IgorWarzocha)!:
38
+ - Report open ask panels as blocked to Herdr's Pi integration.
39
+
40
+ ## 0.0.1
41
+
42
+ ### Changes
43
+
44
+ - [#113](https://github.com/IgorWarzocha/howaboua-pi-stuff/pull/113) [`f516f97`](https://github.com/IgorWarzocha/howaboua-pi-stuff/commit/f516f97c76ae27a354f80f85a5a34ecd56c4e9c4) Thanks [@IgorWarzocha](https://github.com/IgorWarzocha)!:
45
+ - Add interactive human input, review triage, and handoff prompts through the `ask` tool, plus configurable `/fold` and `/grill` workflows.
package/README.md CHANGED
@@ -14,6 +14,8 @@ Try it for one session:
14
14
  pi -e npm:@howaboua/pi-ask
15
15
  ```
16
16
 
17
+ When [Pi Codex](https://www.npmjs.com/package/@howaboua/pi-codex-conversion) 3.0.24 or newer is installed too, `ask` is available inside Code and Notebook Mode as `await tools.ask({ prompts, handoff? })`.
18
+
17
19
  ## How it behaves
18
20
 
19
21
  The agent can present several independently decidable prompts in one tabbed panel. Each prompt supports a short title, supporting evidence, one or more choices, free text, and an optional comment. Review findings become one prompt each, so you can fix, defer, or reject them without translating a wall of prose back into instructions.
@@ -29,7 +31,7 @@ When Pi runs inside Herdr with its Pi integration installed, an open ask panel m
29
31
  - `/fold [report]` turns a long report or structured list into one interactive disposition prompt per item.
30
32
  - `/grill [idea]` investigates an idea, asks successive decisions, and keeps the agreed plan in `docs/`.
31
33
 
32
- Both templates are enabled by default. The extension creates `~/.pi/agent/ask.json` on first load:
34
+ Both templates are enabled by default. The extension creates `ask.json` under Pi's agent directory on first load. That is `~/.pi/agent/ask.json` by default. `PI_CODING_AGENT_DIR` changes the agent directory.
33
35
 
34
36
  ```json
35
37
  {
package/ask/contracts.ts CHANGED
@@ -1,13 +1,11 @@
1
1
  import { type Static, Type } from "typebox";
2
2
 
3
- export { OTHER_OPTION_LABEL, REPHRASE_REQUEST_RESPONSE } from "./constants.js";
4
-
5
- export const ChoiceSchema = Type.Object({
3
+ const ChoiceSchema = Type.Object({
6
4
  label: Type.String({ description: "Short choice." }),
7
5
  description: Type.Optional(Type.String({ description: "Optional detail." })),
8
6
  });
9
7
 
10
- export const PromptSchema = Type.Object({
8
+ const PromptSchema = Type.Object({
11
9
  title: Type.String({ description: "Short prompt." }),
12
10
  body: Type.Optional(Type.String({ description: "Context or evidence." })),
13
11
  multiple: Type.Optional(Type.Boolean({ description: "Allow multiple." })),
package/ask/normalize.ts CHANGED
@@ -14,7 +14,7 @@ export function textContent(text: string): { type: "text"; text: string } {
14
14
  return { type: "text", text };
15
15
  }
16
16
 
17
- export function normalizePromptChoice(choice: unknown): PromptChoice | null {
17
+ function normalizePromptChoice(choice: unknown): PromptChoice | null {
18
18
  if (!isRecord(choice)) return null;
19
19
  const label =
20
20
  typeof choice["label"] === "string" ? choice["label"].trim() : "";
@@ -26,7 +26,7 @@ export function normalizePromptChoice(choice: unknown): PromptChoice | null {
26
26
  return { label, ...(description ? { description } : {}) };
27
27
  }
28
28
 
29
- export function normalizePrompt(
29
+ function normalizePrompt(
30
30
  input: unknown,
31
31
  index: number,
32
32
  handoff = false,
package/ask/tool.ts CHANGED
@@ -16,8 +16,10 @@ type AskInComposer = (
16
16
  ) => Promise<unknown>;
17
17
 
18
18
  interface BlockedState {
19
+ id: string;
19
20
  active: boolean;
20
21
  label: string;
22
+ prompt: string;
21
23
  }
22
24
 
23
25
  type OnBlockedChange = (state: BlockedState) => void;
@@ -42,7 +44,7 @@ export function createAskTool({
42
44
  "ask: Do not add Other/rephrase; it is automatic.",
43
45
  ],
44
46
  executionMode: "sequential",
45
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
47
+ async execute(toolCallId, params, signal, _onUpdate, ctx) {
46
48
  const { handoff, prompts } = normalizeAskInput(params);
47
49
  if (prompts.length === 0) {
48
50
  throw new Error(
@@ -53,8 +55,12 @@ export function createAskTool({
53
55
  throw new Error("ask requires an interactive UI.");
54
56
 
55
57
  const blockedState = {
58
+ id: toolCallId,
56
59
  active: true,
57
60
  label: handoff ? "Human action needed" : "Waiting for input",
61
+ prompt: handoff
62
+ ? "The user needs to complete an action before the active work can continue. Please announce this briefly in your natural voice."
63
+ : "User input is required before the active work can continue. Please announce this briefly in your natural voice.",
58
64
  };
59
65
  onBlockedChange?.(blockedState);
60
66
  let rawResponses: unknown;
@@ -89,11 +95,13 @@ export function createAskTool({
89
95
  details: { kind: handoff ? "handoff" : "prompt", responses },
90
96
  };
91
97
  },
92
- renderCall(args, theme) {
98
+ renderCall(args, theme, context) {
93
99
  const count = Array.isArray(args.prompts) ? args.prompts.length : 0;
94
100
  return new Text(
95
101
  theme.fg(
96
- "toolTitle",
102
+ context && "isBlocked" in context && context.isBlocked === true
103
+ ? "warning"
104
+ : "toolTitle",
97
105
  theme.bold(args.handoff === true ? "ask handoff " : "ask "),
98
106
  ) + theme.fg("muted", `${count} prompt${count === 1 ? "" : "s"}`),
99
107
  0,
@@ -0,0 +1,145 @@
1
+ import type { KeybindingsManager } from "@earendil-works/pi-coding-agent";
2
+ import type { Editor } from "@earendil-works/pi-tui";
3
+ import type {
4
+ AskPrompt,
5
+ AskResponse,
6
+ PromptChoice,
7
+ PromptState,
8
+ } from "./contracts.js";
9
+ import { promptStatesToResponses } from "./state.js";
10
+
11
+ export type EditingKind = "other" | "comment";
12
+
13
+ export interface AskUiState {
14
+ tab: number;
15
+ focus: number;
16
+ editing: EditingKind | null;
17
+ }
18
+
19
+ interface EditingInputOptions {
20
+ advance: () => void;
21
+ editor: Editor;
22
+ keybindings: KeybindingsManager;
23
+ refresh: () => void;
24
+ saveEditing: (submittedText?: string) => void;
25
+ state: AskUiState;
26
+ }
27
+
28
+ export function handleAskEditingInput(
29
+ data: string,
30
+ {
31
+ advance,
32
+ editor,
33
+ keybindings,
34
+ refresh,
35
+ saveEditing,
36
+ state,
37
+ }: EditingInputOptions,
38
+ ): void {
39
+ if (keybindings.matches(data, "tui.input.tab")) {
40
+ saveEditing(editor.getText());
41
+ state.editing = null;
42
+ advance();
43
+ return;
44
+ }
45
+ if (keybindings.matches(data, "tui.select.cancel")) {
46
+ state.editing = null;
47
+ refresh();
48
+ return;
49
+ }
50
+ editor.handleInput(data);
51
+ refresh();
52
+ }
53
+
54
+ interface PromptInputOptions {
55
+ advanceWithDefault: () => void;
56
+ choices: () => PromptChoice[];
57
+ count: () => number;
58
+ keybindings: KeybindingsManager;
59
+ pick: (index: number) => void;
60
+ refresh: () => void;
61
+ selectOther: () => void;
62
+ startEditingComment: () => void;
63
+ state: AskUiState;
64
+ }
65
+
66
+ export function handleAskPromptInput(
67
+ data: string,
68
+ {
69
+ advanceWithDefault,
70
+ choices,
71
+ count,
72
+ keybindings,
73
+ pick,
74
+ refresh,
75
+ selectOther,
76
+ startEditingComment,
77
+ state,
78
+ }: PromptInputOptions,
79
+ ): void {
80
+ if (keybindings.matches(data, "tui.select.up")) {
81
+ state.focus = Math.max(0, state.focus - 1);
82
+ refresh();
83
+ return;
84
+ }
85
+ if (keybindings.matches(data, "tui.select.down")) {
86
+ state.focus = Math.min(count() - 1, state.focus + 1);
87
+ refresh();
88
+ return;
89
+ }
90
+ if (keybindings.matches(data, "tui.input.tab")) {
91
+ advanceWithDefault();
92
+ return;
93
+ }
94
+ if (!keybindings.matches(data, "tui.select.confirm")) return;
95
+ if (state.focus === choices().length) {
96
+ selectOther();
97
+ return;
98
+ }
99
+ if (state.focus === choices().length + 1) {
100
+ startEditingComment();
101
+ return;
102
+ }
103
+ pick(state.focus);
104
+ }
105
+
106
+ interface NavigationInputOptions {
107
+ done: (responses: AskResponse[] | null) => void;
108
+ isReview: () => boolean;
109
+ keybindings: KeybindingsManager;
110
+ promptStates: PromptState[];
111
+ prompts: AskPrompt[];
112
+ setTab: (next: number) => void;
113
+ state: AskUiState;
114
+ }
115
+
116
+ export function handleAskNavigationInput(
117
+ data: string,
118
+ {
119
+ done,
120
+ isReview,
121
+ keybindings,
122
+ promptStates,
123
+ prompts,
124
+ setTab,
125
+ state,
126
+ }: NavigationInputOptions,
127
+ ): boolean {
128
+ if (keybindings.matches(data, "tui.select.cancel")) {
129
+ done(null);
130
+ return true;
131
+ }
132
+ if (keybindings.matches(data, "tui.editor.cursorLeft")) {
133
+ setTab(state.tab - 1);
134
+ return true;
135
+ }
136
+ if (keybindings.matches(data, "tui.editor.cursorRight")) {
137
+ setTab(state.tab + 1);
138
+ return true;
139
+ }
140
+ if (!isReview()) return false;
141
+ if (keybindings.matches(data, "tui.select.confirm")) {
142
+ done(promptStatesToResponses(prompts, promptStates));
143
+ }
144
+ return true;
145
+ }
@@ -0,0 +1,273 @@
1
+ import {
2
+ type ExtensionContext,
3
+ type KeybindingsManager,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import { Editor, wrapTextWithAnsi } from "@earendil-works/pi-tui";
6
+ import { OTHER_OPTION_LABEL } from "./constants.js";
7
+ import type { AskPrompt, PromptChoice, PromptState } from "./contracts.js";
8
+ import type { AskUiState, EditingKind } from "./tui-input.js";
9
+
10
+ type AskTheme = ExtensionContext["ui"]["theme"];
11
+ type AskKeybinding = Parameters<KeybindingsManager["getKeys"]>[0];
12
+ type AddLine = (line?: string) => void;
13
+ interface RenderTabsOptions {
14
+ handoff: boolean;
15
+ prompts: AskPrompt[];
16
+ responded: (index: number) => boolean;
17
+ tab: number;
18
+ theme: AskTheme;
19
+ }
20
+
21
+ interface RenderReviewOptions {
22
+ add: AddLine;
23
+ handoff: boolean;
24
+ keybindings: KeybindingsManager;
25
+ promptStates: PromptState[];
26
+ prompts: AskPrompt[];
27
+ theme: AskTheme;
28
+ width: number;
29
+ }
30
+
31
+ interface RenderChoiceOptions {
32
+ add: AddLine;
33
+ checked: boolean | undefined;
34
+ choice: PromptChoice;
35
+ selected: boolean;
36
+ theme: AskTheme;
37
+ width: number;
38
+ }
39
+
40
+ interface RenderPromptOptions {
41
+ add: AddLine;
42
+ editor: Editor;
43
+ handoff: boolean;
44
+ isEditing: EditingKind | null;
45
+ keybindings: KeybindingsManager;
46
+ choices: PromptChoice[];
47
+ prompt: AskPrompt;
48
+ promptState: PromptState | undefined;
49
+ state: AskUiState;
50
+ theme: AskTheme;
51
+ width: number;
52
+ }
53
+
54
+ function addWrapped(
55
+ add: AddLine,
56
+ text: string,
57
+ width: number,
58
+ indent = "",
59
+ ): void {
60
+ for (const line of wrapTextWithAnsi(
61
+ text,
62
+ Math.max(1, width - indent.length),
63
+ )) {
64
+ add(`${indent}${line}`);
65
+ }
66
+ }
67
+
68
+ function renderKeyHint(
69
+ theme: AskTheme,
70
+ keybindings: KeybindingsManager,
71
+ action: AskKeybinding,
72
+ description: string,
73
+ ): string {
74
+ return (
75
+ theme.fg("dim", keybindings.getKeys(action).join("/")) +
76
+ theme.fg("muted", ` ${description}`)
77
+ );
78
+ }
79
+ export function renderAskTabs({
80
+ handoff,
81
+ prompts,
82
+ responded,
83
+ tab,
84
+ theme,
85
+ }: RenderTabsOptions): string {
86
+ return ` ${Array.from({ length: prompts.length + 1 }, (_, index) => {
87
+ const active = index === tab;
88
+ const label =
89
+ index === prompts.length
90
+ ? handoff
91
+ ? "Resume"
92
+ : "Review"
93
+ : `${responded(index) ? "■" : "□"} ${index + 1}`;
94
+ return active
95
+ ? theme.bg("selectedBg", theme.fg("text", ` ${label} `))
96
+ : theme.fg("muted", ` ${label} `);
97
+ }).join(" ")}`;
98
+ }
99
+
100
+ export function renderAskReview({
101
+ add,
102
+ handoff,
103
+ keybindings,
104
+ promptStates,
105
+ prompts,
106
+ theme,
107
+ width,
108
+ }: RenderReviewOptions): void {
109
+ add(theme.fg("accent", handoff ? " Resume agent" : " Review"));
110
+ add();
111
+ prompts.forEach((prompt, index) => {
112
+ const promptState = promptStates[index];
113
+ const selections = promptState?.selections ?? [];
114
+ addWrapped(
115
+ add,
116
+ theme.fg("muted", `${index + 1}. ${prompt.title}`),
117
+ width,
118
+ " ",
119
+ );
120
+ addWrapped(
121
+ add,
122
+ theme.fg("text", selections.join(", ") || "No selection"),
123
+ width,
124
+ " ",
125
+ );
126
+ if (promptState?.comment.trim()) {
127
+ addWrapped(
128
+ add,
129
+ theme.fg("muted", `Comment: ${promptState.comment.trim()}`),
130
+ width,
131
+ " ",
132
+ );
133
+ }
134
+ });
135
+ add();
136
+ addWrapped(
137
+ add,
138
+ " " +
139
+ renderKeyHint(
140
+ theme,
141
+ keybindings,
142
+ "tui.select.confirm",
143
+ handoff ? "return control" : "submit",
144
+ ) +
145
+ " • " +
146
+ renderKeyHint(
147
+ theme,
148
+ keybindings,
149
+ "tui.editor.cursorLeft",
150
+ "previous prompt",
151
+ ) +
152
+ " • " +
153
+ renderKeyHint(
154
+ theme,
155
+ keybindings,
156
+ "tui.editor.cursorRight",
157
+ "next prompt",
158
+ ) +
159
+ " • " +
160
+ renderKeyHint(theme, keybindings, "tui.select.cancel", "dismiss"),
161
+ width,
162
+ );
163
+ }
164
+
165
+ function renderAskChoice({
166
+ add,
167
+ checked,
168
+ choice,
169
+ selected,
170
+ theme,
171
+ width,
172
+ }: RenderChoiceOptions): void {
173
+ add(
174
+ `${selected ? theme.fg("accent", "> ") : " "}${checked ? theme.fg("success", "✓ ") : " "}${theme.fg(selected ? "accent" : "text", choice.label)}`,
175
+ );
176
+ if (choice.description) {
177
+ addWrapped(add, theme.fg("muted", choice.description), width, " ");
178
+ }
179
+ }
180
+
181
+ export function renderAskPrompt({
182
+ add,
183
+ editor,
184
+ handoff,
185
+ isEditing,
186
+ keybindings,
187
+ choices,
188
+ prompt,
189
+ promptState,
190
+ state,
191
+ theme,
192
+ width,
193
+ }: RenderPromptOptions): void {
194
+ if (handoff) {
195
+ add(theme.fg("accent", " Human action needed"));
196
+ add();
197
+ }
198
+ addWrapped(add, theme.fg("text", prompt.title), width, " ");
199
+ add(
200
+ theme.fg(
201
+ "muted",
202
+ ` ${prompt.multiple ? "Choose any that apply" : "Choose one"}`,
203
+ ),
204
+ );
205
+ if (prompt.body) {
206
+ add();
207
+ addWrapped(add, theme.fg("muted", prompt.body), width, " ");
208
+ }
209
+ add();
210
+ choices.forEach((choice, index) => {
211
+ renderAskChoice({
212
+ add,
213
+ checked: promptState?.selections.includes(choice.label),
214
+ choice,
215
+ selected: state.focus === index,
216
+ theme,
217
+ width,
218
+ });
219
+ });
220
+ const selectedOther = state.focus === choices.length;
221
+ add(
222
+ `${selectedOther ? theme.fg("accent", "> ") : " "}${promptState?.customEnabled ? theme.fg("success", "✓ ") : " "}${theme.fg(selectedOther ? "accent" : "muted", OTHER_OPTION_LABEL)}${promptState?.customText.trim() ? theme.fg("text", `: ${promptState.customText.trim()}`) : ""}`,
223
+ );
224
+ if (isEditing === "other") {
225
+ add();
226
+ for (const line of editor.render(width - 2)) add(` ${line}`);
227
+ }
228
+ const selectedComment = state.focus === choices.length + 1;
229
+ add();
230
+ add(
231
+ `${selectedComment ? theme.fg("accent", "> ") : " "}${theme.fg(selectedComment ? "accent" : "text", "Comment (optional)")}`,
232
+ );
233
+ if (promptState?.comment.trim()) {
234
+ addWrapped(
235
+ add,
236
+ theme.fg("muted", promptState.comment.trim()),
237
+ width,
238
+ " ",
239
+ );
240
+ }
241
+ if (isEditing === "comment") {
242
+ add();
243
+ for (const line of editor.render(width - 2)) add(` ${line}`);
244
+ }
245
+ add();
246
+ if (isEditing) {
247
+ addWrapped(
248
+ add,
249
+ " " +
250
+ renderKeyHint(theme, keybindings, "tui.input.submit", "save") +
251
+ " • " +
252
+ renderKeyHint(theme, keybindings, "tui.input.tab", "next/default") +
253
+ " • " +
254
+ renderKeyHint(theme, keybindings, "tui.select.cancel", "cancel edit"),
255
+ width,
256
+ );
257
+ return;
258
+ }
259
+ addWrapped(
260
+ add,
261
+ " " +
262
+ renderKeyHint(theme, keybindings, "tui.select.up", "up") +
263
+ " • " +
264
+ renderKeyHint(theme, keybindings, "tui.select.down", "down") +
265
+ " • " +
266
+ renderKeyHint(theme, keybindings, "tui.select.confirm", "choose/type") +
267
+ " • " +
268
+ renderKeyHint(theme, keybindings, "tui.input.tab", "next/default") +
269
+ " • blank Other/rephrase = follow-up • " +
270
+ renderKeyHint(theme, keybindings, "tui.select.cancel", "close"),
271
+ width,
272
+ );
273
+ }