@xynogen/pix-ask 0.2.19 → 0.2.21

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
@@ -6,6 +6,8 @@ Pi tool — structured questionnaire UI (`ask_user`).
6
6
 
7
7
  Registers the `ask_user` tool in Pi. When the agent needs to resolve ambiguous requirements, it presents up to 4 structured multiple-choice questions in a TUI dialog. Each question requires 2–4 options with labels and descriptions; supports multi-select and markdown side-by-side previews for richer context. Single-select questions (without a preview) auto-append a "Type something." row for free-form input; multi-select questions append a "Next" row to advance. Single-select questions with a `preview` skip the free-form row to make room for the side-by-side layout. In non-interactive (RPC/JSON) mode, falls back to text-based prompts. The agent uses this tool before proceeding rather than guessing at intent.
8
8
 
9
+ While the dialog is open, `ask_user` holds the shared **agent-state** coordinator in the `blocked` state (via `withAgentBlock` from [`@xynogen/pix-runtime`](https://www.npmjs.com/package/@xynogen/pix-runtime)). Inside a herdr pane that transition fires a "needs attention" notification, so a user away from the terminal is pinged when a question is waiting.
10
+
9
11
  ## Install
10
12
 
11
13
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-ask",
3
- "version": "0.2.19",
3
+ "version": "0.2.21",
4
4
  "description": "Pi tool — structured questionnaire UI (ask_user)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -36,7 +36,7 @@
36
36
  },
37
37
  "dependencies": {
38
38
  "@xynogen/pix-pretty": "^1.11.2",
39
- "@xynogen/pix-runtime": "^0.5.3",
39
+ "@xynogen/pix-runtime": "^0.7.0",
40
40
  "typebox": "^1.1.38"
41
41
  },
42
42
  "peerDependencies": {
package/src/index.ts CHANGED
@@ -1,5 +1,14 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { Text } from "@earendil-works/pi-tui";
3
+ import { modalOverlayOptions } from "@xynogen/pix-pretty/modal-frame";
4
+ import {
5
+ dotJoin,
6
+ formatCollapsedToolRow,
7
+ hideCollapsedToolCall,
8
+ pluralize,
9
+ } from "@xynogen/pix-pretty/utils";
10
+ import { withAgentBlock } from "@xynogen/pix-runtime";
11
+ import { type CollapseState, tickCollapse } from "@xynogen/pix-runtime/collapse";
3
12
  import { once } from "@xynogen/pix-runtime/once";
4
13
  import { buildResponseText } from "./helpers.js";
5
14
  import { AskQuestionnaire } from "./questionnaire.js";
@@ -30,6 +39,7 @@ export default function registerAsk(pi: ExtensionAPI): void {
30
39
  pi.registerTool({
31
40
  name: "ask_user",
32
41
  label: "Ask",
42
+ renderShell: "self",
33
43
  description: `Ask the user up to ${MAX_QUESTIONS} structured questions (${MIN_OPTIONS}-${MAX_OPTIONS} options each) when requirements are ambiguous.`,
34
44
  promptSnippet: `Ask the user up to ${MAX_QUESTIONS} structured questions (${MIN_OPTIONS}-${MAX_OPTIONS} options each) when requirements are ambiguous`,
35
45
  promptGuidelines: [
@@ -65,16 +75,18 @@ export default function registerAsk(pi: ExtensionAPI): void {
65
75
  return { content: [{ type: "text", text }], details: result };
66
76
  }
67
77
 
68
- const result = await ctx.ui.custom<QuestionnaireResult | null>(
69
- (tui, theme, keybindings, done) => {
70
- if (signal) {
71
- signal.addEventListener("abort", () => done({ answers: [], cancelled: true }), {
72
- once: true,
73
- });
74
- }
75
- return new AskQuestionnaire(typed, tui, theme, keybindings, done);
76
- },
77
- { overlay: true },
78
+ const result = await withAgentBlock(pi.events, "ask_user", "Waiting for user answer", () =>
79
+ ctx.ui.custom<QuestionnaireResult | null>(
80
+ (tui, theme, keybindings, done) => {
81
+ if (signal) {
82
+ signal.addEventListener("abort", () => done({ answers: [], cancelled: true }), {
83
+ once: true,
84
+ });
85
+ }
86
+ return new AskQuestionnaire(typed, tui, theme, keybindings, done);
87
+ },
88
+ { overlay: true, overlayOptions: modalOverlayOptions() },
89
+ ),
78
90
  );
79
91
 
80
92
  if (!result || result.cancelled) {
@@ -88,31 +100,71 @@ export default function registerAsk(pi: ExtensionAPI): void {
88
100
  return { content: [{ type: "text", text }], details: result };
89
101
  },
90
102
 
91
- renderCall(args, theme) {
103
+ renderCall(args, theme, renderCtx) {
104
+ const text =
105
+ renderCtx?.lastComponent instanceof Text ? renderCtx.lastComponent : new Text("", 0, 0);
106
+ if (
107
+ renderCtx &&
108
+ hideCollapsedToolCall(renderCtx.state as CollapseState, renderCtx.expanded, (value) =>
109
+ text.setText(value),
110
+ )
111
+ )
112
+ return text;
92
113
  const questions = Array.isArray(args.questions) ? args.questions : [];
93
114
  const count = questions.length;
94
115
  const firstQ = questions[0]?.question ?? "";
95
- let text = theme.fg("toolTitle", theme.bold(`ask (${count}) `));
96
- text += theme.fg("muted", firstQ);
97
- if (count > 1) text += theme.fg("dim", ` +${count - 1} more`);
98
- return new Text(text, 0, 0);
116
+ const head = `${theme.fg("toolTitle", theme.bold("ask_user"))} ${theme.fg("muted", firstQ)}`;
117
+ text.setText(
118
+ dotJoin([head, theme.fg("dim", pluralize(count, "question"))], (s) => theme.fg("dim", s)),
119
+ );
120
+ return text;
99
121
  },
100
122
 
101
- renderResult(result, options, theme) {
123
+ renderResult(result, options, theme, renderCtx) {
124
+ const text =
125
+ renderCtx?.lastComponent instanceof Text ? renderCtx.lastComponent : new Text("", 0, 0);
102
126
  const details = result.details as
103
127
  | { answers?: QuestionAnswer[]; cancelled?: boolean }
104
128
  | undefined;
105
129
  if (options.isPartial) {
106
- return new Text(theme.fg("muted", "Waiting for user input..."), 0, 0);
130
+ text.setText(theme.fg("muted", "Waiting for user input"));
131
+ return text;
107
132
  }
108
133
  if (!details || details.cancelled || !details.answers?.length) {
109
- return new Text(theme.fg("warning", "Cancelled"), 0, 0);
134
+ // Cancellation is a warning outcome, not success — shared row keeps the
135
+ // tool identity and a text label (never color alone) for accessibility.
136
+ text.setText(formatCollapsedToolRow(theme, "ask_user", "", "cancelled", "warning"));
137
+ return text;
138
+ }
139
+ const values = details.answers.map((a) =>
140
+ a.kind === "multi" ? (a.selected ?? []).join(", ") : (a.answer ?? ""),
141
+ );
142
+ const collapsed =
143
+ !!renderCtx &&
144
+ tickCollapse(
145
+ "ask_user",
146
+ renderCtx.state as CollapseState,
147
+ renderCtx.invalidate,
148
+ renderCtx.expanded,
149
+ );
150
+ if (collapsed) {
151
+ text.setText(
152
+ formatCollapsedToolRow(
153
+ theme,
154
+ "ask_user",
155
+ values.join(", "),
156
+ pluralize(details.answers.length, "answer"),
157
+ ),
158
+ );
159
+ return text;
110
160
  }
111
- const texts = details.answers.map((a) => {
112
- const v = a.kind === "multi" ? (a.selected ?? []).join(", ") : (a.answer ?? "");
113
- return `${a.questionIndex + 1}: ${v}`;
114
- });
115
- return new Text(theme.fg("success", `✓ ${texts.join(" ")}`), 0, 0);
161
+ const lines = details.answers.map(
162
+ (a, i) =>
163
+ `${theme.fg("muted", `${a.questionIndex + 1}:`)} ${theme.fg("success", values[i] ?? "")}`,
164
+ );
165
+ const header = `${theme.fg("success", "✓")} ${theme.fg("toolTitle", theme.bold("ask_user"))}`;
166
+ text.setText([header, ...lines].join("\n"));
167
+ return text;
116
168
  },
117
169
  });
118
170
  });
@@ -15,7 +15,6 @@ import {
15
15
  frameModal,
16
16
  MIN_MODAL_HEIGHT,
17
17
  ModalPager,
18
- modalWidth,
19
18
  terminalModalHeight,
20
19
  } from "@xynogen/pix-pretty/modal-frame";
21
20
  import { ChipEditor } from "./chip-editor.js";
@@ -511,9 +510,8 @@ export class AskQuestionnaire extends Container {
511
510
  }
512
511
 
513
512
  override render(termWidth: number): string[] {
514
- // Cap to a fixed-width floating modal; render content at the inner width
515
- // and frame it with a rounded border (see frameLines).
516
- const mw = modalWidth(termWidth);
513
+ // Overlay owns configured bounds; fill them so background cannot bleed through.
514
+ const mw = Math.max(1, Math.floor(termWidth));
517
515
  const width = mw - 4; // border (2) + padding (2)
518
516
  const inner = Math.max(20, width);
519
517
  const t = this.theme;