@pi-archimedes/ask 2.0.0 → 2.1.0

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
@@ -2,27 +2,83 @@
2
2
 
3
3
  Structured question tool with tabbed multi-question flow and inline note editing.
4
4
 
5
+ When language models need clarification, asking unstructured questions in text leads to guessing and back-and-forth ambiguity. This tool provides structured choice prompts, tabbed navigation for multi-part questions, and inline note editing so users can deliver clear, complete guidance in a single step.
6
+
7
+ ## What you get
8
+
9
+ - **Tabbed multi-question flow** — submit review for multiple questions at once
10
+ - **Single-question picker** — instant submit for quick decisions
11
+ - **Inline note editing** — add custom notes and context per option
12
+ - **Markdown context descriptions** — rich context descriptions rendered above options
13
+ - **Automatic "Other" handling** — built-in custom response option with auto-focus
14
+ - **Subagent support** — subagents can call `ask` and questions appear in the parent agent's TUI via bidirectional IPC
15
+
16
+ ## Screenshots
17
+
18
+ ![ask from a subagent](../../docs/images/ask-subagent.png)
19
+
5
20
  ## Install
6
21
 
7
22
  ```bash
8
- pi install @pi-archimedes/ask
23
+ pi install npm:@pi-archimedes/ask
24
+ ```
25
+
26
+ Or install full meta package:
27
+
28
+ ```bash
29
+ pi install npm:pi-archimedes
9
30
  ```
10
31
 
11
- ## Features
32
+ ## Usage
12
33
 
13
- - Tabbed multi-question flow with submit review
14
- - Single-question picker with instant submit
15
- - Inline note editing per option
16
- - Markdown context descriptions
17
- - Automatic "Other (type your own)" handling
18
- - Subagent support — subagents can call `ask` and the question appears in the parent's TUI (bidirectional IPC, no temp files)
34
+ Single question example:
19
35
 
20
- ![ask from a subagent](../../docs/images/ask-subagent.png)
36
+ ```jsonc
37
+ {
38
+ "questions": [{
39
+ "id": "framework",
40
+ "question": "Which framework should we use?",
41
+ "options": [
42
+ { "label": "React" },
43
+ { "label": "Vue" },
44
+ { "label": "Svelte" }
45
+ ]
46
+ }]
47
+ }
48
+ ```
49
+
50
+ Multi-question with notes example:
21
51
 
22
- ## Dependencies
52
+ ```jsonc
53
+ {
54
+ "questions": [
55
+ {
56
+ "id": "priority",
57
+ "question": "What's the implementation priority?",
58
+ "description": "Choose the order for tackling these tasks.",
59
+ "options": [
60
+ { "label": "Core features first" },
61
+ { "label": "Tests first" },
62
+ { "label": "Design first" }
63
+ ],
64
+ "recommended": 0
65
+ },
66
+ {
67
+ "id": "approach",
68
+ "question": "Any additional constraints?",
69
+ "options": [
70
+ { "label": "No breaking changes" },
71
+ { "label": "Performance critical" },
72
+ { "label": "None" }
73
+ ],
74
+ "multi": true
75
+ }
76
+ ]
77
+ }
78
+ ```
23
79
 
24
- Depends on [`@pi-archimedes/core`](../core) for the shared event bus used to relay subagent questions.
80
+ ## Integration
25
81
 
26
- ## Settings
82
+ Depends on [`@pi-archimedes/core`](../core) for the shared event bus used to relay subagent questions. Subagents call `ask` and questions are safely dispatched to the parent agent's TUI over bidirectional IPC with no temporary files.
27
83
 
28
- No settings yet.
84
+ ← Back to [pi-archimedes](../../README.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/ask",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -11,7 +11,7 @@
11
11
  ],
12
12
  "main": "./src/index.ts",
13
13
  "dependencies": {
14
- "@pi-archimedes/core": "2.0.0"
14
+ "@pi-archimedes/core": "2.1.0"
15
15
  },
16
16
  "peerDependencies": {
17
17
  "@earendil-works/pi-coding-agent": ">=0.1.0",
@@ -0,0 +1,90 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import * as fc from "fast-check";
3
+ import { getLinearCursorIndexFromEditor } from "./cursor.js";
4
+
5
+ // ── Helpers ─────────────────────────────────────────────────────────────────
6
+
7
+ interface MockEditor {
8
+ lines: string[];
9
+ cursorLine: number;
10
+ cursorCol: number;
11
+ }
12
+
13
+ function makeEditor(lines: string[], cursorLine: number, cursorCol: number): MockEditor {
14
+ return { lines, cursorLine, cursorCol };
15
+ }
16
+
17
+ function editorFromMock(m: MockEditor) {
18
+ return {
19
+ getLines: () => m.lines,
20
+ getCursor: () => ({ line: m.cursorLine, col: m.cursorCol }),
21
+ };
22
+ }
23
+
24
+ // ── getLinearCursorIndexFromEditor ──────────────────────────────────────────
25
+
26
+ describe("getLinearCursorIndexFromEditor", () => {
27
+ it("single line, cursor at start → 0", () => {
28
+ const editor = editorFromMock(makeEditor(["hello"], 0, 0));
29
+ expect(getLinearCursorIndexFromEditor(editor)).toBe(0);
30
+ });
31
+
32
+ it("single line, cursor at end → line length", () => {
33
+ const editor = editorFromMock(makeEditor(["hello"], 0, 5));
34
+ expect(getLinearCursorIndexFromEditor(editor)).toBe(5);
35
+ });
36
+
37
+ it("multi-line, cursor on second line → first line length + 1 + col", () => {
38
+ const editor = editorFromMock(makeEditor(["hello", "world"], 1, 3));
39
+ // "hello" (5 chars) + "\n" (1 char) + 3 cols into "world" = 9
40
+ expect(getLinearCursorIndexFromEditor(editor)).toBe(9);
41
+ });
42
+
43
+ it("empty editor → 0", () => {
44
+ const editor = editorFromMock(makeEditor([], 0, 0));
45
+ expect(getLinearCursorIndexFromEditor(editor)).toBe(0);
46
+ });
47
+
48
+ it("cursor beyond bounds clamped correctly", () => {
49
+ const editor = editorFromMock(makeEditor(["hi"], 10, 100));
50
+ // Clamped to last line (line 0) and end of line (col 2)
51
+ expect(getLinearCursorIndexFromEditor(editor)).toBe(2);
52
+ });
53
+
54
+ it("property: linear index always >= 0", () => {
55
+ fc.assert(
56
+ fc.property(
57
+ fc.array(fc.string({ maxLength: 50 }), { maxLength: 20 }),
58
+ fc.nat({ max: 100 }),
59
+ fc.nat({ max: 100 }),
60
+ (lines, cursorLine, cursorCol) => {
61
+ const editor = editorFromMock(makeEditor(lines, cursorLine, cursorCol));
62
+ const result = getLinearCursorIndexFromEditor(editor);
63
+ if (result < 0) {
64
+ throw new Error(`Linear index is negative: ${result}`);
65
+ }
66
+ },
67
+ ),
68
+ { verbose: false },
69
+ );
70
+ });
71
+
72
+ it("property: linear index <= total character count", () => {
73
+ fc.assert(
74
+ fc.property(
75
+ fc.array(fc.string({ maxLength: 50 }), { maxLength: 20 }),
76
+ fc.nat({ max: 100 }),
77
+ fc.nat({ max: 100 }),
78
+ (lines, cursorLine, cursorCol) => {
79
+ const editor = editorFromMock(makeEditor(lines, cursorLine, cursorCol));
80
+ const result = getLinearCursorIndexFromEditor(editor);
81
+ const totalChars = lines.reduce((sum, line, i) => sum + line.length + (i < lines.length - 1 ? 1 : 0), 0);
82
+ if (result > totalChars) {
83
+ throw new Error(`Linear index ${result} exceeds total chars ${totalChars}`);
84
+ }
85
+ },
86
+ ),
87
+ { verbose: false },
88
+ );
89
+ });
90
+ });
@@ -0,0 +1,109 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import * as fc from "fast-check";
3
+
4
+ // ── Mock @earendil-works/pi-tui ────────────────────────────────────────────
5
+
6
+ vi.mock("@earendil-works/pi-tui", () => ({
7
+ wrapTextWithAnsi: vi.fn(),
8
+ truncateToWidth: vi.fn(),
9
+ }));
10
+
11
+ // Lazy import after mock is set up
12
+ const { wrapTextWithAnsi, truncateToWidth } = await import("@earendil-works/pi-tui");
13
+
14
+ const { appendWrappedTextLines } = await import("./wrap.js");
15
+
16
+ describe("appendWrappedTextLines", () => {
17
+ beforeEach(() => {
18
+ vi.clearAllMocks();
19
+
20
+ // Default: wrapTextWithAnsi returns input as single line (no wrapping needed)
21
+ vi.mocked(wrapTextWithAnsi).mockImplementation((text: string) => [text]);
22
+
23
+ // Default: truncateToWidth returns input unchanged (no truncation needed)
24
+ vi.mocked(truncateToWidth).mockImplementation((text: string) => text);
25
+ });
26
+
27
+ it("single line within width → no wrapping", () => {
28
+ const rendered: string[] = [];
29
+ appendWrappedTextLines(rendered, "hello", 80);
30
+ expect(rendered).toEqual(["hello"]);
31
+ expect(wrapTextWithAnsi).toHaveBeenCalledWith("hello", 80);
32
+ });
33
+
34
+ it("long line → wrapped to specified width", () => {
35
+ vi.mocked(wrapTextWithAnsi).mockReturnValueOnce(["part1", "part2", "part3"]);
36
+ const rendered: string[] = [];
37
+ appendWrappedTextLines(rendered, "a very long line of text that needs wrapping", 10);
38
+ expect(rendered).toEqual(["part1", "part2", "part3"]);
39
+ expect(wrapTextWithAnsi).toHaveBeenCalledWith("a very long line of text that needs wrapping", 10);
40
+ });
41
+
42
+ it("multiline input → each line wrapped independently", () => {
43
+ vi.mocked(wrapTextWithAnsi).mockImplementation((text: string) => {
44
+ if (text === "line1") return ["line1"];
45
+ if (text === "line2") return ["line2"];
46
+ return [text];
47
+ });
48
+ const rendered: string[] = [];
49
+ appendWrappedTextLines(rendered, "line1\nline2", 80);
50
+ expect(rendered).toEqual(["line1", "line2"]);
51
+ expect(wrapTextWithAnsi).toHaveBeenCalledTimes(2);
52
+ expect(wrapTextWithAnsi).toHaveBeenNthCalledWith(1, "line1", 80);
53
+ expect(wrapTextWithAnsi).toHaveBeenNthCalledWith(2, "line2", 80);
54
+ });
55
+
56
+ it("indent reduces effective wrap width", () => {
57
+ const rendered: string[] = [];
58
+ appendWrappedTextLines(rendered, "hello", 80, { indent: 4 });
59
+ expect(wrapTextWithAnsi).toHaveBeenCalledWith("hello", 76);
60
+ expect(truncateToWidth).toHaveBeenCalledWith(" hello", 80);
61
+ });
62
+
63
+ it("formatLine applied to each wrapped line", () => {
64
+ vi.mocked(wrapTextWithAnsi).mockReturnValueOnce(["part1", "part2"]);
65
+ const formatLine = vi.fn((line: string) => `>>${line}<<`);
66
+ const rendered: string[] = [];
67
+ appendWrappedTextLines(rendered, "some text", 80, { formatLine });
68
+ expect(formatLine).toHaveBeenCalledWith("part1");
69
+ expect(formatLine).toHaveBeenCalledWith("part2");
70
+ expect(truncateToWidth).toHaveBeenCalledWith(">>part1<<", 80);
71
+ expect(truncateToWidth).toHaveBeenCalledWith(">>part2<<", 80);
72
+ });
73
+
74
+ it("empty text → single empty line added", () => {
75
+ const rendered: string[] = [];
76
+ appendWrappedTextLines(rendered, "", 80);
77
+ expect(rendered).toEqual([""]);
78
+ });
79
+
80
+ it("property: output lines never exceed safeWidth chars", () => {
81
+ fc.assert(
82
+ fc.property(
83
+ fc.string({ maxLength: 200 }),
84
+ fc.integer({ min: 1, max: 80 }),
85
+ fc.integer({ min: 0, max: 20 }),
86
+ (text, width, indent) => {
87
+ const safeWidth = Math.max(1, Math.floor(width));
88
+ const actualIndent = Math.max(0, Math.floor(indent));
89
+ const wrapWidth = Math.max(1, safeWidth - actualIndent);
90
+
91
+ // Mock: wrap returns single line (worst case for truncation)
92
+ vi.mocked(wrapTextWithAnsi).mockReturnValueOnce([text]);
93
+ // Mock: truncateToWidth truncates to safeWidth
94
+ vi.mocked(truncateToWidth).mockImplementation((s: string) => s.slice(0, safeWidth));
95
+
96
+ const rendered: string[] = [];
97
+ appendWrappedTextLines(rendered, text, safeWidth, { indent: actualIndent });
98
+
99
+ for (const line of rendered) {
100
+ if (line.length > safeWidth) {
101
+ throw new Error(`Line length ${line.length} exceeds safeWidth ${safeWidth}`);
102
+ }
103
+ }
104
+ },
105
+ ),
106
+ { verbose: false },
107
+ );
108
+ });
109
+ });