@pi-archimedes/ask 2.0.1 → 2.2.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 +69 -13
- package/package.json +2 -2
- package/src/cursor.test.ts +90 -0
- package/src/wrap.test.ts +109 -0
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
|
+

|
|
19
|
+
|
|
5
20
|
## Install
|
|
6
21
|
|
|
7
22
|
```bash
|
|
8
|
-
pi install
|
|
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
|
-
##
|
|
32
|
+
## Usage
|
|
12
33
|
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
80
|
+
## Integration
|
|
25
81
|
|
|
26
|
-
|
|
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
|
-
|
|
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
|
|
3
|
+
"version": "2.2.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
|
|
14
|
+
"@pi-archimedes/core": "2.2.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
|
+
});
|
package/src/wrap.test.ts
ADDED
|
@@ -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
|
+
});
|