@xynogen/pix-ask 0.2.19 → 0.2.20

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 +2 -0
  2. package/package.json +2 -2
  3. package/src/index.ts +74 -23
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.20",
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.6.0",
40
40
  "typebox": "^1.1.38"
41
41
  },
42
42
  "peerDependencies": {
package/src/index.ts CHANGED
@@ -1,5 +1,13 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { Text } from "@earendil-works/pi-tui";
3
+ import {
4
+ dotJoin,
5
+ formatCollapsedToolRow,
6
+ hideCollapsedToolCall,
7
+ pluralize,
8
+ } from "@xynogen/pix-pretty/utils";
9
+ import { withAgentBlock } from "@xynogen/pix-runtime";
10
+ import { type CollapseState, tickCollapse } from "@xynogen/pix-runtime/collapse";
3
11
  import { once } from "@xynogen/pix-runtime/once";
4
12
  import { buildResponseText } from "./helpers.js";
5
13
  import { AskQuestionnaire } from "./questionnaire.js";
@@ -30,6 +38,7 @@ export default function registerAsk(pi: ExtensionAPI): void {
30
38
  pi.registerTool({
31
39
  name: "ask_user",
32
40
  label: "Ask",
41
+ renderShell: "self",
33
42
  description: `Ask the user up to ${MAX_QUESTIONS} structured questions (${MIN_OPTIONS}-${MAX_OPTIONS} options each) when requirements are ambiguous.`,
34
43
  promptSnippet: `Ask the user up to ${MAX_QUESTIONS} structured questions (${MIN_OPTIONS}-${MAX_OPTIONS} options each) when requirements are ambiguous`,
35
44
  promptGuidelines: [
@@ -65,16 +74,18 @@ export default function registerAsk(pi: ExtensionAPI): void {
65
74
  return { content: [{ type: "text", text }], details: result };
66
75
  }
67
76
 
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 },
77
+ const result = await withAgentBlock(pi.events, "ask_user", "Waiting for user answer", () =>
78
+ ctx.ui.custom<QuestionnaireResult | null>(
79
+ (tui, theme, keybindings, done) => {
80
+ if (signal) {
81
+ signal.addEventListener("abort", () => done({ answers: [], cancelled: true }), {
82
+ once: true,
83
+ });
84
+ }
85
+ return new AskQuestionnaire(typed, tui, theme, keybindings, done);
86
+ },
87
+ { overlay: true },
88
+ ),
78
89
  );
79
90
 
80
91
  if (!result || result.cancelled) {
@@ -88,31 +99,71 @@ export default function registerAsk(pi: ExtensionAPI): void {
88
99
  return { content: [{ type: "text", text }], details: result };
89
100
  },
90
101
 
91
- renderCall(args, theme) {
102
+ renderCall(args, theme, renderCtx) {
103
+ const text =
104
+ renderCtx?.lastComponent instanceof Text ? renderCtx.lastComponent : new Text("", 0, 0);
105
+ if (
106
+ renderCtx &&
107
+ hideCollapsedToolCall(renderCtx.state as CollapseState, renderCtx.expanded, (value) =>
108
+ text.setText(value),
109
+ )
110
+ )
111
+ return text;
92
112
  const questions = Array.isArray(args.questions) ? args.questions : [];
93
113
  const count = questions.length;
94
114
  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);
115
+ const head = `${theme.fg("toolTitle", theme.bold("ask_user"))} ${theme.fg("muted", firstQ)}`;
116
+ text.setText(
117
+ dotJoin([head, theme.fg("dim", pluralize(count, "question"))], (s) => theme.fg("dim", s)),
118
+ );
119
+ return text;
99
120
  },
100
121
 
101
- renderResult(result, options, theme) {
122
+ renderResult(result, options, theme, renderCtx) {
123
+ const text =
124
+ renderCtx?.lastComponent instanceof Text ? renderCtx.lastComponent : new Text("", 0, 0);
102
125
  const details = result.details as
103
126
  | { answers?: QuestionAnswer[]; cancelled?: boolean }
104
127
  | undefined;
105
128
  if (options.isPartial) {
106
- return new Text(theme.fg("muted", "Waiting for user input..."), 0, 0);
129
+ text.setText(theme.fg("muted", "Waiting for user input"));
130
+ return text;
107
131
  }
108
132
  if (!details || details.cancelled || !details.answers?.length) {
109
- return new Text(theme.fg("warning", "Cancelled"), 0, 0);
133
+ // Cancellation is a warning outcome, not success — shared row keeps the
134
+ // tool identity and a text label (never color alone) for accessibility.
135
+ text.setText(formatCollapsedToolRow(theme, "ask_user", "", "cancelled", "warning"));
136
+ return text;
137
+ }
138
+ const values = details.answers.map((a) =>
139
+ a.kind === "multi" ? (a.selected ?? []).join(", ") : (a.answer ?? ""),
140
+ );
141
+ const collapsed =
142
+ !!renderCtx &&
143
+ tickCollapse(
144
+ "ask_user",
145
+ renderCtx.state as CollapseState,
146
+ renderCtx.invalidate,
147
+ renderCtx.expanded,
148
+ );
149
+ if (collapsed) {
150
+ text.setText(
151
+ formatCollapsedToolRow(
152
+ theme,
153
+ "ask_user",
154
+ values.join(", "),
155
+ pluralize(details.answers.length, "answer"),
156
+ ),
157
+ );
158
+ return text;
110
159
  }
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);
160
+ const lines = details.answers.map(
161
+ (a, i) =>
162
+ `${theme.fg("muted", `${a.questionIndex + 1}:`)} ${theme.fg("success", values[i] ?? "")}`,
163
+ );
164
+ const header = `${theme.fg("success", "✓")} ${theme.fg("toolTitle", theme.bold("ask_user"))}`;
165
+ text.setText([header, ...lines].join("\n"));
166
+ return text;
116
167
  },
117
168
  });
118
169
  });