pi-ask-tool-extension 0.1.4 → 0.2.2

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
@@ -68,7 +68,17 @@ ask({
68
68
  Result example:
69
69
 
70
70
  ```txt
71
- User selected: Session
71
+ User answers:
72
+ auth: Session
73
+
74
+ Answer context:
75
+ Question 1 (auth)
76
+ Prompt: Which auth approach?
77
+ Options:
78
+ 1. JWT
79
+ 2. Session
80
+ Response:
81
+ Selected: Session
72
82
  ```
73
83
 
74
84
  ### Single question (multi-select)
@@ -89,7 +99,18 @@ ask({
89
99
  Result example:
90
100
 
91
101
  ```txt
92
- User selected: Logging, Metrics
102
+ User answers:
103
+ features: [Logging, Metrics]
104
+
105
+ Answer context:
106
+ Question 1 (features)
107
+ Prompt: Which features should be enabled?
108
+ Options:
109
+ 1. Logging
110
+ 2. Metrics
111
+ 3. Tracing
112
+ Response:
113
+ Selected: [Logging, Metrics]
93
114
  ```
94
115
 
95
116
  ### Multi-question (tab flow)
@@ -117,6 +138,23 @@ Result example:
117
138
  User answers:
118
139
  auth: Session
119
140
  cache: Redis
141
+
142
+ Answer context:
143
+ Question 1 (auth)
144
+ Prompt: Which auth approach?
145
+ Options:
146
+ 1. JWT
147
+ 2. Session
148
+ Response:
149
+ Selected: Session
150
+
151
+ Question 2 (cache)
152
+ Prompt: Which cache strategy?
153
+ Options:
154
+ 1. Redis
155
+ 2. None
156
+ Response:
157
+ Selected: Redis
120
158
  ```
121
159
 
122
160
  ## Interaction Model
@@ -168,10 +206,22 @@ For `Other`, a note is required to become valid.
168
206
 
169
207
  ```bash
170
208
  npm install
171
- npm test
172
- npm run typecheck
209
+ npm run check
173
210
  ```
174
211
 
212
+ `npm run check` runs:
213
+
214
+ - TypeScript checks (`npm run typecheck`)
215
+ - Test suite with coverage (`npm run test:coverage`)
216
+ - Coverage gate (`npm run coverage:check`)
217
+
218
+ Coverage gate defaults (override via env vars in CI if needed):
219
+
220
+ - Overall: lines >= 38%, functions >= 80%
221
+ - `src/index.ts`: lines >= 95%, functions >= 100%
222
+ - `src/ask-logic.ts`: lines >= 95%, functions >= 100%
223
+ - `src/ask-inline-note.ts`: lines >= 80%, functions >= 70%
224
+
175
225
  ## Project Structure
176
226
 
177
227
  - `src/index.ts` - extension entrypoint, tool registration, and orchestration
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ask-tool-extension",
3
- "version": "0.1.4",
3
+ "version": "0.2.2",
4
4
  "description": "Ask tool extension for pi with tabbed questioning and inline note editing",
5
5
  "repository": {
6
6
  "type": "git",
@@ -25,7 +25,10 @@
25
25
  ],
26
26
  "scripts": {
27
27
  "test": "bun test",
28
- "typecheck": "tsc --noEmit"
28
+ "test:coverage": "bun test --coverage --coverage-reporter=lcov --coverage-reporter=text",
29
+ "coverage:check": "node ./scripts/check-coverage.mjs",
30
+ "typecheck": "tsc --noEmit",
31
+ "check": "npm run typecheck && npm run test:coverage && npm run coverage:check"
29
32
  },
30
33
  "peerDependencies": {
31
34
  "@mariozechner/pi-coding-agent": "*",
@@ -1,6 +1,10 @@
1
+ import { wrapTextWithAnsi } from "@mariozechner/pi-tui";
2
+
1
3
  const INLINE_NOTE_SEPARATOR = " — note: ";
2
4
  const INLINE_EDIT_CURSOR = "▍";
3
5
 
6
+ export const INLINE_NOTE_WRAP_PADDING = 2;
7
+
4
8
  function sanitizeNoteForInlineDisplay(rawNote: string): string {
5
9
  return rawNote.replace(/[\r\n\t]/g, " ").replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "");
6
10
  }
@@ -31,14 +35,31 @@ export function buildOptionLabelWithInlineNote(
31
35
  }
32
36
 
33
37
  const labelPrefix = `${baseOptionLabel}${INLINE_NOTE_SEPARATOR}`;
34
- const rawInlineNote = isEditingNote ? `${sanitizedNote}${INLINE_EDIT_CURSOR}` : sanitizedNote.trim();
35
- const fullInlineLabel = `${labelPrefix}${rawInlineNote}`;
38
+ const inlineNote = isEditingNote ? `${sanitizedNote}${INLINE_EDIT_CURSOR}` : sanitizedNote.trim();
39
+ const inlineLabel = `${labelPrefix}${inlineNote}`;
36
40
 
37
41
  if (maxInlineLabelLength == null) {
38
- return fullInlineLabel;
42
+ return inlineLabel;
39
43
  }
40
44
 
41
45
  return isEditingNote
42
- ? truncateTextKeepingTail(fullInlineLabel, maxInlineLabelLength)
43
- : truncateTextKeepingHead(fullInlineLabel, maxInlineLabelLength);
46
+ ? truncateTextKeepingTail(inlineLabel, maxInlineLabelLength)
47
+ : truncateTextKeepingHead(inlineLabel, maxInlineLabelLength);
48
+ }
49
+
50
+ export function buildWrappedOptionLabelWithInlineNote(
51
+ baseOptionLabel: string,
52
+ rawNote: string,
53
+ isEditingNote: boolean,
54
+ maxInlineLabelLength: number,
55
+ wrapPadding = INLINE_NOTE_WRAP_PADDING,
56
+ ): string[] {
57
+ const inlineLabel = buildOptionLabelWithInlineNote(baseOptionLabel, rawNote, isEditingNote);
58
+ const sanitizedWrapPadding = Number.isFinite(wrapPadding) ? Math.max(0, Math.floor(wrapPadding)) : 0;
59
+ const sanitizedMaxInlineLabelLength = Number.isFinite(maxInlineLabelLength)
60
+ ? Math.max(1, Math.floor(maxInlineLabelLength))
61
+ : 1;
62
+ const wrapWidth = Math.max(1, sanitizedMaxInlineLabelLength - sanitizedWrapPadding);
63
+ const wrappedLines = wrapTextWithAnsi(inlineLabel, wrapWidth);
64
+ return wrappedLines.length > 0 ? wrappedLines : [""];
44
65
  }
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionUIContext } from "@mariozechner/pi-coding-agent";
2
- import { Editor, type EditorTheme, Key, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
2
+ import { Editor, type EditorTheme, Key, matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
3
3
  import {
4
4
  OTHER_OPTION,
5
5
  appendRecommendedTagToOptionLabels,
@@ -7,7 +7,7 @@ import {
7
7
  type AskOption,
8
8
  type AskSelection,
9
9
  } from "./ask-logic";
10
- import { buildOptionLabelWithInlineNote } from "./ask-inline-note";
10
+ import { INLINE_NOTE_WRAP_PADDING, buildWrappedOptionLabelWithInlineNote } from "./ask-inline-note";
11
11
 
12
12
  interface SingleQuestionInput {
13
13
  question: string;
@@ -115,21 +115,28 @@ export async function askSingleQuestionWithInlineNote(
115
115
  addLine(theme.fg("text", ` ${questionInput.question}`));
116
116
  renderedLines.push("");
117
117
 
118
- const maxInlineLabelLength = Math.max(12, width - 6);
119
118
  for (let optionIndex = 0; optionIndex < selectableOptionLabels.length; optionIndex++) {
120
119
  const optionLabel = selectableOptionLabels[optionIndex];
121
120
  const isCursorOption = optionIndex === cursorOptionIndex;
122
121
  const isEditingThisOption = isNoteEditorOpen && isCursorOption;
123
- const optionLabelWithInlineNote = buildOptionLabelWithInlineNote(
122
+ const cursorPrefixText = isCursorOption ? "→ " : " ";
123
+ const cursorPrefix = isCursorOption ? theme.fg("accent", cursorPrefixText) : cursorPrefixText;
124
+ const bullet = isCursorOption ? "●" : "○";
125
+ const markerText = `${bullet} `;
126
+ const optionColor = isCursorOption ? "accent" : "text";
127
+ const prefixWidth = visibleWidth(cursorPrefixText) + visibleWidth(markerText);
128
+ const wrappedInlineLabelLines = buildWrappedOptionLabelWithInlineNote(
124
129
  optionLabel,
125
130
  getRawNoteForOption(optionIndex),
126
131
  isEditingThisOption,
127
- maxInlineLabelLength,
132
+ Math.max(1, width - prefixWidth),
133
+ INLINE_NOTE_WRAP_PADDING,
128
134
  );
129
- const cursorPrefix = isCursorOption ? theme.fg("accent", "→ ") : " ";
130
- const bullet = isCursorOption ? "" : "○";
131
- const optionColor = isCursorOption ? "accent" : "text";
132
- addLine(`${cursorPrefix}${theme.fg(optionColor, `${bullet} ${optionLabelWithInlineNote}`)}`);
135
+ const continuationPrefix = " ".repeat(prefixWidth);
136
+ addLine(`${cursorPrefix}${theme.fg(optionColor, `${markerText}${wrappedInlineLabelLines[0] ?? ""}`)}`);
137
+ for (const wrappedLine of wrappedInlineLabelLines.slice(1)) {
138
+ addLine(`${continuationPrefix}${theme.fg(optionColor, wrappedLine)}`);
139
+ }
133
140
  }
134
141
 
135
142
  renderedLines.push("");
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionUIContext } from "@mariozechner/pi-coding-agent";
2
- import { Editor, type EditorTheme, Key, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
2
+ import { Editor, type EditorTheme, Key, matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
3
3
  import {
4
4
  OTHER_OPTION,
5
5
  appendRecommendedTagToOptionLabels,
@@ -8,7 +8,7 @@ import {
8
8
  type AskQuestion,
9
9
  type AskSelection,
10
10
  } from "./ask-logic";
11
- import { buildOptionLabelWithInlineNote } from "./ask-inline-note";
11
+ import { INLINE_NOTE_WRAP_PADDING, buildWrappedOptionLabelWithInlineNote } from "./ask-inline-note";
12
12
 
13
13
  interface PreparedQuestion {
14
14
  id: string;
@@ -317,27 +317,29 @@ export async function askQuestionsWithTabs(
317
317
  addLine(theme.fg("text", ` ${preparedQuestion.question}`));
318
318
  renderedLines.push("");
319
319
 
320
- const maxInlineLabelLength = Math.max(12, width - 8);
321
320
  for (let optionIndex = 0; optionIndex < preparedQuestion.options.length; optionIndex++) {
322
321
  const optionLabel = preparedQuestion.options[optionIndex];
323
322
  const isCursorOption = optionIndex === cursorOptionIndex;
324
323
  const isOptionSelected = selectedOptionIndexes.includes(optionIndex);
325
324
  const isEditingThisOption = isNoteEditorOpen && isCursorOption;
326
- const optionLabelWithInlineNote = buildOptionLabelWithInlineNote(
325
+ const cursorPrefixText = isCursorOption ? "→ " : " ";
326
+ const cursorPrefix = isCursorOption ? theme.fg("accent", cursorPrefixText) : cursorPrefixText;
327
+ const markerText = preparedQuestion.multi
328
+ ? `${isOptionSelected ? "[x]" : "[ ]"} `
329
+ : `${isOptionSelected ? "●" : "○"} `;
330
+ const optionColor = isCursorOption ? "accent" : isOptionSelected ? "success" : "text";
331
+ const prefixWidth = visibleWidth(cursorPrefixText) + visibleWidth(markerText);
332
+ const wrappedInlineLabelLines = buildWrappedOptionLabelWithInlineNote(
327
333
  optionLabel,
328
334
  getQuestionNote(questionIndex, optionIndex),
329
335
  isEditingThisOption,
330
- maxInlineLabelLength,
336
+ Math.max(1, width - prefixWidth),
337
+ INLINE_NOTE_WRAP_PADDING,
331
338
  );
332
- const cursorPrefix = isCursorOption ? theme.fg("accent", "→ ") : " ";
333
- if (preparedQuestion.multi) {
334
- const checkbox = isOptionSelected ? "[x]" : "[ ]";
335
- const optionColor = isCursorOption ? "accent" : isOptionSelected ? "success" : "text";
336
- addLine(`${cursorPrefix}${theme.fg(optionColor, `${checkbox} ${optionLabelWithInlineNote}`)}`);
337
- } else {
338
- const bullet = isOptionSelected ? "●" : "○";
339
- const optionColor = isCursorOption ? "accent" : isOptionSelected ? "success" : "text";
340
- addLine(`${cursorPrefix}${theme.fg(optionColor, `${bullet} ${optionLabelWithInlineNote}`)}`);
339
+ const continuationPrefix = " ".repeat(prefixWidth);
340
+ addLine(`${cursorPrefix}${theme.fg(optionColor, `${markerText}${wrappedInlineLabelLines[0] ?? ""}`)}`);
341
+ for (const wrappedLine of wrappedInlineLabelLines.slice(1)) {
342
+ addLine(`${continuationPrefix}${theme.fg(optionColor, wrappedLine)}`);
341
343
  }
342
344
  }
343
345
 
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
2
  import { Type, type Static } from "@sinclair/typebox";
3
- import type { AskQuestion } from "./ask-logic";
3
+ import { OTHER_OPTION, type AskQuestion } from "./ask-logic";
4
4
  import { askSingleQuestionWithInlineNote } from "./ask-inline-ui";
5
5
  import { askQuestionsWithTabs } from "./ask-tabs-ui";
6
6
 
@@ -37,6 +37,7 @@ interface QuestionResult {
37
37
  }
38
38
 
39
39
  interface AskToolDetails {
40
+ id?: string;
40
41
  question?: string;
41
42
  options?: string[];
42
43
  multi?: boolean;
@@ -45,16 +46,106 @@ interface AskToolDetails {
45
46
  results?: QuestionResult[];
46
47
  }
47
48
 
49
+ function sanitizeForSessionText(value: string): string {
50
+ return value
51
+ .replace(/[\r\n\t]/g, " ")
52
+ .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "")
53
+ .replace(/\s{2,}/g, " ")
54
+ .trim();
55
+ }
56
+
57
+ function sanitizeOptionForSessionText(option: string): string {
58
+ const sanitizedOption = sanitizeForSessionText(option);
59
+ return sanitizedOption.length > 0 ? sanitizedOption : "(empty option)";
60
+ }
61
+
62
+ function toSessionSafeQuestionResult(result: QuestionResult): QuestionResult {
63
+ const selectedOptions = result.selectedOptions
64
+ .map((selectedOption) => sanitizeForSessionText(selectedOption))
65
+ .filter((selectedOption) => selectedOption.length > 0);
66
+
67
+ const rawCustomInput = result.customInput;
68
+ const customInput = rawCustomInput == null ? undefined : sanitizeForSessionText(rawCustomInput);
69
+
70
+ return {
71
+ id: sanitizeForSessionText(result.id) || "(unknown)",
72
+ question: sanitizeForSessionText(result.question) || "(empty question)",
73
+ options: result.options.map(sanitizeOptionForSessionText),
74
+ multi: result.multi,
75
+ selectedOptions,
76
+ customInput: customInput && customInput.length > 0 ? customInput : undefined,
77
+ };
78
+ }
79
+
80
+ function formatSelectionForSummary(result: QuestionResult): string {
81
+ const hasSelectedOptions = result.selectedOptions.length > 0;
82
+ const hasCustomInput = Boolean(result.customInput);
83
+
84
+ if (!hasSelectedOptions && !hasCustomInput) {
85
+ return "(cancelled)";
86
+ }
87
+
88
+ if (hasSelectedOptions && hasCustomInput) {
89
+ const selectedPart = result.multi
90
+ ? `[${result.selectedOptions.join(", ")}]`
91
+ : result.selectedOptions[0];
92
+ return `${selectedPart} + Other: "${result.customInput}"`;
93
+ }
94
+
95
+ if (hasCustomInput) {
96
+ return `"${result.customInput}"`;
97
+ }
98
+
99
+ if (result.multi) {
100
+ return `[${result.selectedOptions.join(", ")}]`;
101
+ }
102
+
103
+ return result.selectedOptions[0];
104
+ }
105
+
48
106
  function formatQuestionResult(result: QuestionResult): string {
49
- if (result.customInput) {
50
- return `${result.id}: \"${result.customInput}\"`;
107
+ return `${result.id}: ${formatSelectionForSummary(result)}`;
108
+ }
109
+
110
+ function formatQuestionContext(result: QuestionResult, questionIndex: number): string {
111
+ const lines: string[] = [
112
+ `Question ${questionIndex + 1} (${result.id})`,
113
+ `Prompt: ${result.question}`,
114
+ "Options:",
115
+ ...result.options.map((option, optionIndex) => ` ${optionIndex + 1}. ${option}`),
116
+ "Response:",
117
+ ];
118
+
119
+ const hasSelectedOptions = result.selectedOptions.length > 0;
120
+ const hasCustomInput = Boolean(result.customInput);
121
+
122
+ if (!hasSelectedOptions && !hasCustomInput) {
123
+ lines.push(" Selected: (cancelled)");
124
+ return lines.join("\n");
51
125
  }
52
- if (result.selectedOptions.length > 0) {
53
- return result.multi
54
- ? `${result.id}: [${result.selectedOptions.join(", ")}]`
55
- : `${result.id}: ${result.selectedOptions[0]}`;
126
+
127
+ if (hasSelectedOptions) {
128
+ const selectedText = result.multi
129
+ ? `[${result.selectedOptions.join(", ")}]`
130
+ : result.selectedOptions[0];
131
+ lines.push(` Selected: ${selectedText}`);
132
+ }
133
+
134
+ if (hasCustomInput) {
135
+ if (!hasSelectedOptions) {
136
+ lines.push(` Selected: ${OTHER_OPTION}`);
137
+ }
138
+ lines.push(` Custom input: ${result.customInput}`);
56
139
  }
57
- return `${result.id}: (cancelled)`;
140
+
141
+ return lines.join("\n");
142
+ }
143
+
144
+ function buildAskSessionContent(results: QuestionResult[]): string {
145
+ const safeResults = results.map(toSessionSafeQuestionResult);
146
+ const summaryLines = safeResults.map(formatQuestionResult).join("\n");
147
+ const contextBlocks = safeResults.map((result, index) => formatQuestionContext(result, index)).join("\n\n");
148
+ return `User answers:\n${summaryLines}\n\nAnswer context:\n${contextBlocks}`;
58
149
  }
59
150
 
60
151
  const ASK_TOOL_DESCRIPTION = `
@@ -96,7 +187,9 @@ export default function askExtension(pi: ExtensionAPI) {
96
187
  ? (await askQuestionsWithTabs(ctx.ui, [q as AskQuestion])).selections[0] ?? { selectedOptions: [] }
97
188
  : await askSingleQuestionWithInlineNote(ctx.ui, q as AskQuestion);
98
189
  const optionLabels = q.options.map((option) => option.label);
99
- const details: AskToolDetails = {
190
+
191
+ const result: QuestionResult = {
192
+ id: q.id,
100
193
  question: q.question,
101
194
  options: optionLabels,
102
195
  multi: q.multi ?? false,
@@ -104,22 +197,18 @@ export default function askExtension(pi: ExtensionAPI) {
104
197
  customInput: selection.customInput,
105
198
  };
106
199
 
107
- if (selection.customInput) {
108
- return {
109
- content: [{ type: "text", text: `User provided custom input: ${selection.customInput}` }],
110
- details,
111
- };
112
- }
113
-
114
- if (selection.selectedOptions.length > 0) {
115
- return {
116
- content: [{ type: "text", text: `User selected: ${selection.selectedOptions.join(", ")}` }],
117
- details,
118
- };
119
- }
200
+ const details: AskToolDetails = {
201
+ id: q.id,
202
+ question: q.question,
203
+ options: optionLabels,
204
+ multi: q.multi ?? false,
205
+ selectedOptions: selection.selectedOptions,
206
+ customInput: selection.customInput,
207
+ results: [result],
208
+ };
120
209
 
121
210
  return {
122
- content: [{ type: "text", text: "User cancelled the selection" }],
211
+ content: [{ type: "text", text: buildAskSessionContent([result]) }],
123
212
  details,
124
213
  };
125
214
  }
@@ -140,7 +229,7 @@ export default function askExtension(pi: ExtensionAPI) {
140
229
  }
141
230
 
142
231
  return {
143
- content: [{ type: "text", text: `User answers:\n${results.map(formatQuestionResult).join("\n")}` }],
232
+ content: [{ type: "text", text: buildAskSessionContent(results) }],
144
233
  details: { results } satisfies AskToolDetails,
145
234
  };
146
235
  },