@pi-unipi/ask-user 0.1.9 → 0.1.11
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 +34 -43
- package/ask-ui.ts +19 -1
- package/launcher-ui.ts +142 -0
- package/package.json +2 -1
- package/skills/ask-user/SKILL.md +13 -1
- package/tools.ts +53 -1
- package/types.ts +6 -0
package/README.md
CHANGED
|
@@ -1,30 +1,24 @@
|
|
|
1
1
|
# @pi-unipi/ask-user
|
|
2
2
|
|
|
3
|
-
Structured user input
|
|
3
|
+
Structured user input for decision gates. When the agent needs you to pick between options — which database, which approach, which files to change — it calls `ask_user` instead of guessing.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Three input modes: single-select (pick one), multi-select (toggle several), freeform (type your own). The agent presents the question, you answer, it continues.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
## Commands
|
|
8
8
|
|
|
9
|
-
Ask
|
|
9
|
+
Ask-user has no user commands. It's an agent tool package — the agent calls it when it needs input.
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
- **Multi-select** — Toggle multiple options, then submit
|
|
13
|
-
- **Freeform** — Type a custom answer
|
|
11
|
+
## Special Triggers
|
|
14
12
|
|
|
15
|
-
|
|
13
|
+
All workflow skills detect ask-user and use it for decision gates. Instead of the agent deciding on its own, it presents options and waits for your input. This happens naturally during brainstorm, plan, work, and other skills when the agent faces ambiguity.
|
|
16
14
|
|
|
17
|
-
The
|
|
15
|
+
The bundled skill guides the agent to use `ask_user` for high-stakes decisions — architecture choices, database selection, naming decisions, anything with lasting impact.
|
|
18
16
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
{ label: "SQLite", description: "Simple, serverless" },
|
|
25
|
-
],
|
|
26
|
-
})
|
|
27
|
-
```
|
|
17
|
+
## Agent Tool
|
|
18
|
+
|
|
19
|
+
| Tool | Description |
|
|
20
|
+
|------|-------------|
|
|
21
|
+
| `ask_user` | Structured user input with options |
|
|
28
22
|
|
|
29
23
|
### Parameters
|
|
30
24
|
|
|
@@ -37,12 +31,24 @@ ask_user({
|
|
|
37
31
|
| `allowFreeform` | boolean? | true | Allow freeform text input |
|
|
38
32
|
| `timeout` | number? | — | Auto-dismiss after N ms |
|
|
39
33
|
|
|
34
|
+
### Example
|
|
35
|
+
|
|
36
|
+
```typescript
|
|
37
|
+
ask_user({
|
|
38
|
+
question: "Which database should we use?",
|
|
39
|
+
options: [
|
|
40
|
+
{ label: "PostgreSQL", description: "Reliable, feature-rich" },
|
|
41
|
+
{ label: "SQLite", description: "Simple, serverless" },
|
|
42
|
+
],
|
|
43
|
+
})
|
|
44
|
+
```
|
|
45
|
+
|
|
40
46
|
### Keyboard Controls
|
|
41
47
|
|
|
42
48
|
| Mode | Keys |
|
|
43
49
|
|------|------|
|
|
44
|
-
| Single-select |
|
|
45
|
-
| Multi-select |
|
|
50
|
+
| Single-select | Up/Down navigate, Enter select, Esc cancel |
|
|
51
|
+
| Multi-select | Up/Down navigate, Space toggle, Enter submit, Esc cancel |
|
|
46
52
|
| Freeform | Type text, Enter submit, Esc back |
|
|
47
53
|
|
|
48
54
|
### TUI Display
|
|
@@ -57,7 +63,7 @@ ask_user({
|
|
|
57
63
|
Option C
|
|
58
64
|
Type something...
|
|
59
65
|
|
|
60
|
-
|
|
66
|
+
Up/Down navigate, Enter select, Esc cancel
|
|
61
67
|
─────────────────────────────
|
|
62
68
|
```
|
|
63
69
|
|
|
@@ -66,34 +72,19 @@ ask_user({
|
|
|
66
72
|
─────────────────────────────
|
|
67
73
|
Which features to enable?
|
|
68
74
|
─────────────────────────────
|
|
69
|
-
> [
|
|
75
|
+
> [x] Logging
|
|
70
76
|
[ ] Metrics
|
|
71
|
-
[
|
|
77
|
+
[x] Tracing
|
|
72
78
|
[ ] Type something...
|
|
73
79
|
|
|
74
|
-
|
|
80
|
+
Up/Down navigate, Space toggle, Enter submit, Esc cancel
|
|
75
81
|
─────────────────────────────
|
|
76
82
|
```
|
|
77
83
|
|
|
78
|
-
##
|
|
79
|
-
|
|
80
|
-
```bash
|
|
81
|
-
pi install npm:@pi-unipi/ask-user
|
|
82
|
-
```
|
|
83
|
-
|
|
84
|
-
Or install the full Unipi suite:
|
|
85
|
-
|
|
86
|
-
```bash
|
|
87
|
-
pi install npm:@pi-unipi/unipi
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
## Bundled Skill
|
|
84
|
+
## Configurables
|
|
91
85
|
|
|
92
|
-
|
|
86
|
+
Ask-user has no configuration. Input mode is determined by the `allowMultiple` and `allowFreeform` parameters the agent passes.
|
|
93
87
|
|
|
94
|
-
##
|
|
88
|
+
## License
|
|
95
89
|
|
|
96
|
-
|
|
97
|
-
- `@mariozechner/pi-coding-agent` — Pi extension API
|
|
98
|
-
- `@mariozechner/pi-tui` — TUI components
|
|
99
|
-
- `@sinclair/typebox` — Schema validation
|
|
90
|
+
MIT
|
package/ask-ui.ts
CHANGED
|
@@ -638,7 +638,24 @@ export function createRenderResult() {
|
|
|
638
638
|
0,
|
|
639
639
|
0,
|
|
640
640
|
);
|
|
641
|
-
case "new_session":
|
|
641
|
+
case "new_session": {
|
|
642
|
+
const launchedWith = (response as any).launchedWith;
|
|
643
|
+
if (launchedWith === "compact") {
|
|
644
|
+
return new Text(
|
|
645
|
+
theme.fg("success", "✓ compacted → ") +
|
|
646
|
+
theme.fg("accent", response.prefill || ""),
|
|
647
|
+
0,
|
|
648
|
+
0,
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
if (launchedWith === "direct") {
|
|
652
|
+
return new Text(
|
|
653
|
+
theme.fg("success", "✓ running → ") +
|
|
654
|
+
theme.fg("accent", response.prefill || ""),
|
|
655
|
+
0,
|
|
656
|
+
0,
|
|
657
|
+
);
|
|
658
|
+
}
|
|
642
659
|
return new Text(
|
|
643
660
|
theme.fg("success", "✓ ") +
|
|
644
661
|
theme.fg("muted", "new session") +
|
|
@@ -646,6 +663,7 @@ export function createRenderResult() {
|
|
|
646
663
|
0,
|
|
647
664
|
0,
|
|
648
665
|
);
|
|
666
|
+
}
|
|
649
667
|
default:
|
|
650
668
|
return new Text(
|
|
651
669
|
theme.fg("text", JSON.stringify(response)),
|
package/launcher-ui.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/ask-user — Session Launcher TUI
|
|
3
|
+
*
|
|
4
|
+
* Secondary overlay shown when user selects a new_session option.
|
|
5
|
+
* Offers Compact & run, Run directly, or Cancel.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Key, matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
9
|
+
import type { SessionLauncherResult } from "./types.js";
|
|
10
|
+
|
|
11
|
+
/** Launcher option definition */
|
|
12
|
+
interface LauncherOption {
|
|
13
|
+
label: string;
|
|
14
|
+
icon: string;
|
|
15
|
+
action: SessionLauncherResult["action"];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const OPTIONS: LauncherOption[] = [
|
|
19
|
+
{ label: "Compact & run", icon: "🧹", action: "compact" },
|
|
20
|
+
{ label: "Run directly", icon: "▶", action: "direct" },
|
|
21
|
+
{ label: "Cancel", icon: "✕", action: "cancel" },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Render the session launcher UI.
|
|
26
|
+
*
|
|
27
|
+
* Simple single-select picker with 3 fixed options.
|
|
28
|
+
* No editor, no timeout, no multi-select.
|
|
29
|
+
*/
|
|
30
|
+
export function renderLauncherUI(params: {
|
|
31
|
+
prefill: string;
|
|
32
|
+
}): (
|
|
33
|
+
tui: any,
|
|
34
|
+
theme: any,
|
|
35
|
+
kb: any,
|
|
36
|
+
done: (result: SessionLauncherResult | null) => void,
|
|
37
|
+
) => {
|
|
38
|
+
render: (width: number) => string[];
|
|
39
|
+
invalidate: () => void;
|
|
40
|
+
handleInput: (data: string) => void;
|
|
41
|
+
} {
|
|
42
|
+
return (_tui, theme, _kb, done) => {
|
|
43
|
+
const { prefill } = params;
|
|
44
|
+
|
|
45
|
+
// State
|
|
46
|
+
let optionIndex = 0;
|
|
47
|
+
let cachedLines: string[] | undefined;
|
|
48
|
+
|
|
49
|
+
function refresh() {
|
|
50
|
+
cachedLines = undefined;
|
|
51
|
+
_tui.requestRender();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function handleInput(data: string) {
|
|
55
|
+
// Navigation
|
|
56
|
+
if (matchesKey(data, Key.up)) {
|
|
57
|
+
optionIndex = Math.max(0, optionIndex - 1);
|
|
58
|
+
refresh();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (matchesKey(data, Key.down)) {
|
|
62
|
+
optionIndex = Math.min(OPTIONS.length - 1, optionIndex + 1);
|
|
63
|
+
refresh();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Enter: select
|
|
68
|
+
if (matchesKey(data, Key.enter)) {
|
|
69
|
+
const opt = OPTIONS[optionIndex];
|
|
70
|
+
done({ action: opt.action, prefill });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Escape: cancel
|
|
75
|
+
if (matchesKey(data, Key.escape)) {
|
|
76
|
+
done(null);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function render(width: number): string[] {
|
|
82
|
+
if (cachedLines) return cachedLines;
|
|
83
|
+
|
|
84
|
+
const lines: string[] = [];
|
|
85
|
+
const innerWidth = Math.max(40, width - 2);
|
|
86
|
+
const border = (s: string) => theme.fg("accent", s);
|
|
87
|
+
|
|
88
|
+
function padVisible(content: string, targetWidth: number): string {
|
|
89
|
+
const vw = visibleWidth(content);
|
|
90
|
+
const pad = Math.max(0, targetWidth - vw);
|
|
91
|
+
return content + " ".repeat(pad);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const add = (s: string) =>
|
|
95
|
+
lines.push(
|
|
96
|
+
border("│") +
|
|
97
|
+
padVisible(truncateToWidth(s, innerWidth), innerWidth) +
|
|
98
|
+
border("│"),
|
|
99
|
+
);
|
|
100
|
+
const addEmpty = () =>
|
|
101
|
+
lines.push(border("│") + " ".repeat(innerWidth) + border("│"));
|
|
102
|
+
|
|
103
|
+
// Top border
|
|
104
|
+
lines.push(border(`╭${"─".repeat(innerWidth)}╮`));
|
|
105
|
+
|
|
106
|
+
// Header: show prefill command (truncated)
|
|
107
|
+
const headerPrefix = " 🚀 ";
|
|
108
|
+
const maxPrefillWidth = innerWidth - headerPrefix.length - 1;
|
|
109
|
+
const truncatedPrefill = truncateToWidth(prefill || "(no command)", maxPrefillWidth);
|
|
110
|
+
add(theme.fg("accent", headerPrefix) + theme.fg("text", truncatedPrefill));
|
|
111
|
+
addEmpty();
|
|
112
|
+
|
|
113
|
+
// Options
|
|
114
|
+
for (let i = 0; i < OPTIONS.length; i++) {
|
|
115
|
+
const opt = OPTIONS[i];
|
|
116
|
+
const isSelected = i === optionIndex;
|
|
117
|
+
const prefix = isSelected ? theme.fg("accent", "> ") : " ";
|
|
118
|
+
const label = `${opt.icon} ${opt.label}`;
|
|
119
|
+
const color = isSelected ? "accent" : "text";
|
|
120
|
+
add(prefix + theme.fg(color, label));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Footer hint
|
|
124
|
+
addEmpty();
|
|
125
|
+
add(theme.fg("dim", " ↑↓ navigate • Enter select • Esc cancel"));
|
|
126
|
+
|
|
127
|
+
// Bottom border
|
|
128
|
+
lines.push(border(`╰${"─".repeat(innerWidth)}╯`));
|
|
129
|
+
|
|
130
|
+
cachedLines = lines;
|
|
131
|
+
return lines;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
render,
|
|
136
|
+
invalidate: () => {
|
|
137
|
+
cachedLines = undefined;
|
|
138
|
+
},
|
|
139
|
+
handleInput,
|
|
140
|
+
};
|
|
141
|
+
};
|
|
142
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/ask-user",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
4
4
|
"description": "Structured user input tool for Pi coding agent — single-select, multi-select, freeform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"types.ts",
|
|
25
25
|
"tools.ts",
|
|
26
26
|
"ask-ui.ts",
|
|
27
|
+
"launcher-ui.ts",
|
|
27
28
|
"commands.ts",
|
|
28
29
|
"config.ts",
|
|
29
30
|
"settings-tui.ts",
|
package/skills/ask-user/SKILL.md
CHANGED
|
@@ -56,7 +56,7 @@ Use the `ask_user` tool to collect structured input from the user.
|
|
|
56
56
|
| `"select"` | Normal selection (default). Returns immediately. |
|
|
57
57
|
| `"input"` | Enters text input mode. Returns `combined` response with selection + text. |
|
|
58
58
|
| `"end_turn"` | Signals end of agent turn. Returns `end_turn` response kind. |
|
|
59
|
-
| `"new_session"` | Starts a new session. Returns `new_session` response kind with optional `prefill`. |
|
|
59
|
+
| `"new_session"` | Starts a new session. Returns `new_session` response kind with optional `prefill`. Shows a launcher overlay offering **Compact & run** (compacts context first) or **Run directly**. |
|
|
60
60
|
|
|
61
61
|
## Examples
|
|
62
62
|
|
|
@@ -150,3 +150,15 @@ ask_user({
|
|
|
150
150
|
- "I want changes" enters text input mode for the user to explain
|
|
151
151
|
- "Done for now" signals the agent to end its turn
|
|
152
152
|
- "Start fresh" starts a new session with the prefill message
|
|
153
|
+
|
|
154
|
+
## Session Launcher
|
|
155
|
+
|
|
156
|
+
When a user selects a `new_session` option, a secondary launcher overlay appears with three choices:
|
|
157
|
+
|
|
158
|
+
| Choice | Behavior |
|
|
159
|
+
|--------|----------|
|
|
160
|
+
| 🧹 Compact & run | Compacts current context (via `ctx.compact()`), then returns the prefill command to the LLM |
|
|
161
|
+
| ▶ Run directly | Returns the prefill command to the LLM without compaction |
|
|
162
|
+
| ✕ Cancel | Cancels the session launch |
|
|
163
|
+
|
|
164
|
+
This two-step flow lets the user manage context window usage before starting a new task.
|
package/tools.ts
CHANGED
|
@@ -11,8 +11,9 @@ import {
|
|
|
11
11
|
UNIPI_EVENTS,
|
|
12
12
|
emitEvent,
|
|
13
13
|
} from "@pi-unipi/core";
|
|
14
|
-
import type { NormalizedOption, AskUserResponse } from "./types.js";
|
|
14
|
+
import type { NormalizedOption, AskUserResponse, SessionLauncherResult } from "./types.js";
|
|
15
15
|
import { renderAskUI, createRenderCall, createRenderResult } from "./ask-ui.js";
|
|
16
|
+
import { renderLauncherUI } from "./launcher-ui.js";
|
|
16
17
|
import { getAskUserSettings } from "./config.js";
|
|
17
18
|
|
|
18
19
|
/**
|
|
@@ -331,6 +332,57 @@ export function registerAskUserTools(pi: ExtensionAPI): void {
|
|
|
331
332
|
contentText = "No response";
|
|
332
333
|
}
|
|
333
334
|
|
|
335
|
+
// Session launcher intercept: when user selects new_session, offer compact/direct/cancel
|
|
336
|
+
if (response.kind === "new_session") {
|
|
337
|
+
const prefill = response.prefill || "";
|
|
338
|
+
const launcherResult = await ctx.ui.custom<SessionLauncherResult | null>(
|
|
339
|
+
renderLauncherUI({ prefill }),
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
if (!launcherResult || launcherResult.action === "cancel") {
|
|
343
|
+
return {
|
|
344
|
+
content: [{ type: "text", text: "User cancelled the session launch" }],
|
|
345
|
+
details: {
|
|
346
|
+
question,
|
|
347
|
+
options: normalizedOptions.map((o) => o.label),
|
|
348
|
+
response: {
|
|
349
|
+
kind: "cancelled",
|
|
350
|
+
comment: "Session launcher cancelled",
|
|
351
|
+
} as AskUserResponse,
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (launcherResult.action === "compact") {
|
|
357
|
+
try {
|
|
358
|
+
await new Promise<void>((resolve, reject) => {
|
|
359
|
+
ctx.compact({
|
|
360
|
+
customInstructions: `Preparing for new task. Summarize previous work concisely, preserving only what's essential for: ${prefill}`,
|
|
361
|
+
onComplete: () => resolve(),
|
|
362
|
+
onError: (err) => reject(err),
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
} catch (err) {
|
|
366
|
+
// Compaction failure shouldn't block the session launch — continue anyway
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const actionLabel = launcherResult.action === "compact" ? "compacted" : "running";
|
|
371
|
+
contentText = `User chose to proceed (${actionLabel}): ${prefill}`;
|
|
372
|
+
|
|
373
|
+
return {
|
|
374
|
+
content: [{ type: "text", text: contentText }],
|
|
375
|
+
details: {
|
|
376
|
+
question,
|
|
377
|
+
options: normalizedOptions.map((o) => o.label),
|
|
378
|
+
response: {
|
|
379
|
+
...response,
|
|
380
|
+
launchedWith: launcherResult.action,
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
334
386
|
return {
|
|
335
387
|
content: [{ type: "text", text: contentText }],
|
|
336
388
|
details: {
|
package/types.ts
CHANGED
|
@@ -54,6 +54,12 @@ export interface AskUserResponse {
|
|
|
54
54
|
comment?: string;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/** Result from the session launcher UI */
|
|
58
|
+
export interface SessionLauncherResult {
|
|
59
|
+
action: "compact" | "direct" | "cancel";
|
|
60
|
+
prefill: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
57
63
|
/** Normalized option with resolved value */
|
|
58
64
|
export interface NormalizedOption {
|
|
59
65
|
label: string;
|