@pi-unipi/ask-user 0.1.10 → 2.0.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 +64 -43
- package/ask-ui.ts +54 -14
- package/commands.ts +1 -1
- package/handoff.ts +163 -0
- package/launcher-ui.ts +143 -0
- package/package.json +6 -1
- package/skills/ask-user/SKILL.md +14 -2
- package/tools.ts +92 -5
- package/types.ts +28 -0
package/README.md
CHANGED
|
@@ -1,30 +1,26 @@
|
|
|
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
|
-
|
|
15
|
+
For workflow handoffs, options can use `action: "new_session"` with a `prefill`. Selecting one opens a launcher where **Compact & run** queues the prefill after compaction (or a short fallback) and **Run directly** queues it immediately. If automatic queuing fails, the prefill is placed in the editor for you to submit manually.
|
|
18
16
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
})
|
|
27
|
-
```
|
|
17
|
+
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
|
+
|
|
19
|
+
## Agent Tool
|
|
20
|
+
|
|
21
|
+
| Tool | Description |
|
|
22
|
+
|------|-------------|
|
|
23
|
+
| `ask_user` | Structured user input with options |
|
|
28
24
|
|
|
29
25
|
### Parameters
|
|
30
26
|
|
|
@@ -37,12 +33,52 @@ ask_user({
|
|
|
37
33
|
| `allowFreeform` | boolean? | true | Allow freeform text input |
|
|
38
34
|
| `timeout` | number? | — | Auto-dismiss after N ms |
|
|
39
35
|
|
|
36
|
+
### Example
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
ask_user({
|
|
40
|
+
question: "Which database should we use?",
|
|
41
|
+
options: [
|
|
42
|
+
{ label: "PostgreSQL", description: "Reliable, feature-rich" },
|
|
43
|
+
{ label: "SQLite", description: "Simple, serverless" },
|
|
44
|
+
],
|
|
45
|
+
})
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### `new_session` Handoffs
|
|
49
|
+
|
|
50
|
+
```typescript
|
|
51
|
+
ask_user({
|
|
52
|
+
question: "Continue with implementation?",
|
|
53
|
+
options: [
|
|
54
|
+
{
|
|
55
|
+
label: "Proceed to work",
|
|
56
|
+
value: "work",
|
|
57
|
+
action: "new_session",
|
|
58
|
+
prefill: "/unipi:work specs:2026-05-06-feature-plan.md",
|
|
59
|
+
},
|
|
60
|
+
{ label: "Done for now", value: "done", action: "end_turn" },
|
|
61
|
+
],
|
|
62
|
+
allowFreeform: false,
|
|
63
|
+
})
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
When the user chooses a `new_session` option:
|
|
67
|
+
|
|
68
|
+
| Launcher choice | Behavior |
|
|
69
|
+
|-----------------|----------|
|
|
70
|
+
| 🧹 Compact & run | Starts context compaction, returns immediately, then queues/submits the prefill as a follow-up message from the compaction callback or a short fallback timer |
|
|
71
|
+
| ▶ Run directly | Queues/submits the prefill immediately as a follow-up message |
|
|
72
|
+
| ✕ Cancel | Cancels the handoff; no message is queued |
|
|
73
|
+
|
|
74
|
+
The tool result is rendered as `queued compact → ...` or `queued direct → ...`. If automatic delivery fails, ask-user falls back to editor prefill and warns you to press Enter.
|
|
75
|
+
|
|
40
76
|
### Keyboard Controls
|
|
41
77
|
|
|
42
78
|
| Mode | Keys |
|
|
43
79
|
|------|------|
|
|
44
|
-
| Single-select |
|
|
45
|
-
| Multi-select |
|
|
80
|
+
| Single-select | Up/Down navigate, Enter select, Esc cancel |
|
|
81
|
+
| Multi-select | Up/Down navigate, Space toggle, Enter submit, Esc cancel |
|
|
46
82
|
| Freeform | Type text, Enter submit, Esc back |
|
|
47
83
|
|
|
48
84
|
### TUI Display
|
|
@@ -57,7 +93,7 @@ ask_user({
|
|
|
57
93
|
Option C
|
|
58
94
|
Type something...
|
|
59
95
|
|
|
60
|
-
|
|
96
|
+
Up/Down navigate, Enter select, Esc cancel
|
|
61
97
|
─────────────────────────────
|
|
62
98
|
```
|
|
63
99
|
|
|
@@ -66,34 +102,19 @@ ask_user({
|
|
|
66
102
|
─────────────────────────────
|
|
67
103
|
Which features to enable?
|
|
68
104
|
─────────────────────────────
|
|
69
|
-
> [
|
|
105
|
+
> [x] Logging
|
|
70
106
|
[ ] Metrics
|
|
71
|
-
[
|
|
107
|
+
[x] Tracing
|
|
72
108
|
[ ] Type something...
|
|
73
109
|
|
|
74
|
-
|
|
110
|
+
Up/Down navigate, Space toggle, Enter submit, Esc cancel
|
|
75
111
|
─────────────────────────────
|
|
76
112
|
```
|
|
77
113
|
|
|
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
|
|
114
|
+
## Configurables
|
|
91
115
|
|
|
92
|
-
|
|
116
|
+
Ask-user has no configuration. Input mode is determined by the `allowMultiple` and `allowFreeform` parameters the agent passes.
|
|
93
117
|
|
|
94
|
-
##
|
|
118
|
+
## License
|
|
95
119
|
|
|
96
|
-
|
|
97
|
-
- `@mariozechner/pi-coding-agent` — Pi extension API
|
|
98
|
-
- `@mariozechner/pi-tui` — TUI components
|
|
99
|
-
- `@sinclair/typebox` — Schema validation
|
|
120
|
+
MIT
|
package/ask-ui.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* Uses ctx.ui.custom() callback pattern following question.ts/questionnaire.ts.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
|
|
8
|
+
import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth, type TUI, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
|
|
9
|
+
import type { Theme, AgentToolResult } from "@mariozechner/pi-coding-agent";
|
|
9
10
|
import type { NormalizedOption, AskUserResponse } from "./types.js";
|
|
10
11
|
|
|
11
12
|
/** Result returned by the ask UI */
|
|
@@ -31,9 +32,9 @@ export function renderAskUI(params: {
|
|
|
31
32
|
allowFreeform: boolean;
|
|
32
33
|
timeout?: number;
|
|
33
34
|
}): (
|
|
34
|
-
tui:
|
|
35
|
-
theme:
|
|
36
|
-
kb:
|
|
35
|
+
tui: TUI,
|
|
36
|
+
theme: Theme,
|
|
37
|
+
kb: import("@mariozechner/pi-coding-agent").KeybindingsManager,
|
|
37
38
|
done: (result: AskUIResult | null) => void,
|
|
38
39
|
) => {
|
|
39
40
|
render: (width: number) => string[];
|
|
@@ -446,7 +447,7 @@ export function renderAskUI(params: {
|
|
|
446
447
|
function renderOptions(
|
|
447
448
|
lines: string[],
|
|
448
449
|
add: (s: string) => void,
|
|
449
|
-
theme:
|
|
450
|
+
theme: Theme,
|
|
450
451
|
width: number,
|
|
451
452
|
) {
|
|
452
453
|
for (let i = 0; i < displayOptions.length; i++) {
|
|
@@ -561,8 +562,8 @@ export function renderAskUI(params: {
|
|
|
561
562
|
* Create a renderCall function for the ask_user tool.
|
|
562
563
|
*/
|
|
563
564
|
export function createRenderCall() {
|
|
564
|
-
return (args:
|
|
565
|
-
const question = args.question || "";
|
|
565
|
+
return (args: Record<string, unknown>, theme: Theme, _context: unknown) => {
|
|
566
|
+
const question = (args.question as string) || "";
|
|
566
567
|
const options = Array.isArray(args.options) ? args.options : [];
|
|
567
568
|
const mode = args.allowMultiple ? "multi-select" : "single-select";
|
|
568
569
|
const count = options.length;
|
|
@@ -581,14 +582,15 @@ export function createRenderCall() {
|
|
|
581
582
|
* Create a renderResult function for the ask_user tool.
|
|
582
583
|
*/
|
|
583
584
|
export function createRenderResult() {
|
|
584
|
-
return (result:
|
|
585
|
-
const details = result.details;
|
|
585
|
+
return (result: AgentToolResult<unknown>, _options: unknown, theme: Theme, _context: unknown) => {
|
|
586
|
+
const details = result.details as Record<string, unknown> | undefined;
|
|
586
587
|
if (!details) {
|
|
587
|
-
const
|
|
588
|
-
|
|
588
|
+
const content = result.content as unknown as Array<Record<string, unknown>> | undefined;
|
|
589
|
+
const text = content?.[0];
|
|
590
|
+
return new Text(text?.type === "text" ? (text.text as string) : "", 0, 0);
|
|
589
591
|
}
|
|
590
592
|
|
|
591
|
-
const response = details
|
|
593
|
+
const response = (details as { response: AskUserResponse }).response;
|
|
592
594
|
if (!response) {
|
|
593
595
|
return new Text(theme.fg("warning", "No response"), 0, 0);
|
|
594
596
|
}
|
|
@@ -638,14 +640,52 @@ export function createRenderResult() {
|
|
|
638
640
|
0,
|
|
639
641
|
0,
|
|
640
642
|
);
|
|
641
|
-
case "new_session":
|
|
643
|
+
case "new_session": {
|
|
644
|
+
const prefill = response.prefill || "";
|
|
645
|
+
if (response.launchStatus === "editor_prefill") {
|
|
646
|
+
const label = response.launchedWith === "compact"
|
|
647
|
+
? "⚠ compact editor prefill → "
|
|
648
|
+
: "⚠ direct editor prefill → ";
|
|
649
|
+
return new Text(
|
|
650
|
+
theme.fg("warning", label) + theme.fg("accent", prefill),
|
|
651
|
+
0,
|
|
652
|
+
0,
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
if (response.launchStatus === "failed") {
|
|
656
|
+
const label = response.launchedWith === "compact"
|
|
657
|
+
? "handoff failed (compact) → "
|
|
658
|
+
: "handoff failed (direct) → ";
|
|
659
|
+
return new Text(
|
|
660
|
+
theme.fg("error", label) + theme.fg("accent", prefill),
|
|
661
|
+
0,
|
|
662
|
+
0,
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
if (response.launchedWith === "compact") {
|
|
666
|
+
return new Text(
|
|
667
|
+
theme.fg("success", "✓ queued compact → ") +
|
|
668
|
+
theme.fg("accent", prefill),
|
|
669
|
+
0,
|
|
670
|
+
0,
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
if (response.launchedWith === "direct") {
|
|
674
|
+
return new Text(
|
|
675
|
+
theme.fg("success", "✓ queued direct → ") +
|
|
676
|
+
theme.fg("accent", prefill),
|
|
677
|
+
0,
|
|
678
|
+
0,
|
|
679
|
+
);
|
|
680
|
+
}
|
|
642
681
|
return new Text(
|
|
643
682
|
theme.fg("success", "✓ ") +
|
|
644
683
|
theme.fg("muted", "new session") +
|
|
645
|
-
(
|
|
684
|
+
(prefill ? theme.fg("accent", `: ${prefill}`) : ""),
|
|
646
685
|
0,
|
|
647
686
|
0,
|
|
648
687
|
);
|
|
688
|
+
}
|
|
649
689
|
default:
|
|
650
690
|
return new Text(
|
|
651
691
|
theme.fg("text", JSON.stringify(response)),
|
package/commands.ts
CHANGED
|
@@ -23,7 +23,7 @@ export function registerAskUserCommands(pi: ExtensionAPI): void {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
ctx.ui.custom(
|
|
26
|
-
(tui
|
|
26
|
+
(tui, _theme, _keybindings, done) => {
|
|
27
27
|
const overlay = new AskUserSettingsOverlay();
|
|
28
28
|
overlay.onClose = () => done(undefined);
|
|
29
29
|
return {
|
package/handoff.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/ask-user — new_session handoff helpers
|
|
3
|
+
*
|
|
4
|
+
* Queues launcher prefill messages without waiting for LLM follow-up.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
8
|
+
import type { SessionLaunchReason, SessionLaunchStatus } from "./types.js";
|
|
9
|
+
|
|
10
|
+
/** Compact handoff fallback timer. Keeps the launcher from stalling if callbacks wait for the tool turn to finish. */
|
|
11
|
+
export const COMPACT_HANDOFF_FALLBACK_MS = 1500;
|
|
12
|
+
|
|
13
|
+
export interface HandoffResult {
|
|
14
|
+
status: SessionLaunchStatus;
|
|
15
|
+
reason: SessionLaunchReason;
|
|
16
|
+
prefill?: string;
|
|
17
|
+
error?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface NormalizedPrefill {
|
|
21
|
+
ok: true;
|
|
22
|
+
prefill: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface InvalidPrefill {
|
|
26
|
+
ok: false;
|
|
27
|
+
reason: "empty-prefill";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function normalizePrefill(prefill: string | undefined): NormalizedPrefill | InvalidPrefill {
|
|
31
|
+
const trimmed = (prefill ?? "").trim();
|
|
32
|
+
if (!trimmed) {
|
|
33
|
+
return { ok: false, reason: "empty-prefill" };
|
|
34
|
+
}
|
|
35
|
+
return { ok: true, prefill: trimmed };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function errorMessage(error: unknown): string {
|
|
39
|
+
return error instanceof Error ? error.message : String(error);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function notify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error"): void {
|
|
43
|
+
try {
|
|
44
|
+
ctx.ui.notify(message, level);
|
|
45
|
+
} catch {
|
|
46
|
+
// Notifications are best-effort only; never let status UI break delivery.
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function fallbackToEditor(
|
|
51
|
+
ctx: ExtensionContext,
|
|
52
|
+
prefill: string,
|
|
53
|
+
reason: SessionLaunchReason,
|
|
54
|
+
error: unknown,
|
|
55
|
+
): HandoffResult {
|
|
56
|
+
try {
|
|
57
|
+
ctx.ui.setEditorText(prefill);
|
|
58
|
+
notify(ctx, "Automatic handoff failed. The command was placed in the editor; press Enter to run it.", "warning");
|
|
59
|
+
return {
|
|
60
|
+
status: "editor_prefill",
|
|
61
|
+
reason,
|
|
62
|
+
prefill,
|
|
63
|
+
error: errorMessage(error),
|
|
64
|
+
};
|
|
65
|
+
} catch (editorError) {
|
|
66
|
+
notify(ctx, `Automatic handoff failed and editor fallback failed: ${errorMessage(editorError)}`, "error");
|
|
67
|
+
return {
|
|
68
|
+
status: "failed",
|
|
69
|
+
reason,
|
|
70
|
+
prefill,
|
|
71
|
+
error: `${errorMessage(error)}; editor fallback: ${errorMessage(editorError)}`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function deliverFollowUpMessage(
|
|
77
|
+
pi: ExtensionAPI,
|
|
78
|
+
ctx: ExtensionContext,
|
|
79
|
+
prefill: string,
|
|
80
|
+
reason: SessionLaunchReason,
|
|
81
|
+
): HandoffResult {
|
|
82
|
+
try {
|
|
83
|
+
pi.sendUserMessage(prefill, { deliverAs: "followUp" });
|
|
84
|
+
return { status: "queued", reason, prefill };
|
|
85
|
+
} catch (error) {
|
|
86
|
+
return fallbackToEditor(ctx, prefill, reason, error);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function queueDirectHandoff(
|
|
91
|
+
pi: ExtensionAPI,
|
|
92
|
+
ctx: ExtensionContext,
|
|
93
|
+
prefill: string | undefined,
|
|
94
|
+
): HandoffResult {
|
|
95
|
+
const normalized = normalizePrefill(prefill);
|
|
96
|
+
if (!normalized.ok) {
|
|
97
|
+
return { status: "cancelled", reason: normalized.reason };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return deliverFollowUpMessage(pi, ctx, normalized.prefill, "direct");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function queueCompactHandoff(params: {
|
|
104
|
+
pi: ExtensionAPI;
|
|
105
|
+
ctx: ExtensionContext;
|
|
106
|
+
prefill: string | undefined;
|
|
107
|
+
customInstructions: string;
|
|
108
|
+
}): HandoffResult {
|
|
109
|
+
const { pi, ctx, customInstructions } = params;
|
|
110
|
+
const normalized = normalizePrefill(params.prefill);
|
|
111
|
+
if (!normalized.ok) {
|
|
112
|
+
return { status: "cancelled", reason: normalized.reason };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const { prefill } = normalized;
|
|
116
|
+
let delivered = false;
|
|
117
|
+
let fallbackTimer: ReturnType<typeof setTimeout> | undefined;
|
|
118
|
+
|
|
119
|
+
const clearFallbackTimer = () => {
|
|
120
|
+
if (fallbackTimer) {
|
|
121
|
+
clearTimeout(fallbackTimer);
|
|
122
|
+
fallbackTimer = undefined;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const deliverOnce = (reason: SessionLaunchReason): HandoffResult | undefined => {
|
|
127
|
+
if (delivered) {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
delivered = true;
|
|
131
|
+
clearFallbackTimer();
|
|
132
|
+
if (reason === "fallback-timeout") {
|
|
133
|
+
notify(ctx, "Compaction is still running; queued the selected handoff command via fallback.", "info");
|
|
134
|
+
}
|
|
135
|
+
return deliverFollowUpMessage(pi, ctx, prefill, reason);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
fallbackTimer = setTimeout(() => {
|
|
139
|
+
deliverOnce("fallback-timeout");
|
|
140
|
+
}, COMPACT_HANDOFF_FALLBACK_MS);
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
ctx.compact({
|
|
144
|
+
customInstructions,
|
|
145
|
+
onComplete: () => {
|
|
146
|
+
deliverOnce("compacted");
|
|
147
|
+
},
|
|
148
|
+
onError: (error) => {
|
|
149
|
+
notify(ctx, `Compaction reported an error; continuing handoff anyway: ${errorMessage(error)}`, "warning");
|
|
150
|
+
deliverOnce("compaction-error");
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
} catch (error) {
|
|
154
|
+
notify(ctx, `Could not start compaction; running selected handoff directly: ${errorMessage(error)}`, "warning");
|
|
155
|
+
return deliverOnce("compact-start-failed") ?? {
|
|
156
|
+
status: "queued",
|
|
157
|
+
reason: "compact-start-failed",
|
|
158
|
+
prefill,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return { status: "scheduled", reason: "compact-started", prefill };
|
|
163
|
+
}
|
package/launcher-ui.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
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, type TUI, visibleWidth } from "@mariozechner/pi-tui";
|
|
9
|
+
import type { Theme, KeybindingsManager } from "@mariozechner/pi-coding-agent";
|
|
10
|
+
import type { SessionLauncherResult } from "./types.js";
|
|
11
|
+
|
|
12
|
+
/** Launcher option definition */
|
|
13
|
+
interface LauncherOption {
|
|
14
|
+
label: string;
|
|
15
|
+
icon: string;
|
|
16
|
+
action: SessionLauncherResult["action"];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const OPTIONS: LauncherOption[] = [
|
|
20
|
+
{ label: "Compact & run", icon: "🧹", action: "compact" },
|
|
21
|
+
{ label: "Run directly", icon: "▶", action: "direct" },
|
|
22
|
+
{ label: "Cancel", icon: "✕", action: "cancel" },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Render the session launcher UI.
|
|
27
|
+
*
|
|
28
|
+
* Simple single-select picker with 3 fixed options.
|
|
29
|
+
* No editor, no timeout, no multi-select.
|
|
30
|
+
*/
|
|
31
|
+
export function renderLauncherUI(params: {
|
|
32
|
+
prefill: string;
|
|
33
|
+
}): (
|
|
34
|
+
tui: TUI,
|
|
35
|
+
theme: Theme,
|
|
36
|
+
kb: KeybindingsManager,
|
|
37
|
+
done: (result: SessionLauncherResult | null) => void,
|
|
38
|
+
) => {
|
|
39
|
+
render: (width: number) => string[];
|
|
40
|
+
invalidate: () => void;
|
|
41
|
+
handleInput: (data: string) => void;
|
|
42
|
+
} {
|
|
43
|
+
return (_tui, theme, _kb, done) => {
|
|
44
|
+
const { prefill } = params;
|
|
45
|
+
|
|
46
|
+
// State
|
|
47
|
+
let optionIndex = 0;
|
|
48
|
+
let cachedLines: string[] | undefined;
|
|
49
|
+
|
|
50
|
+
function refresh() {
|
|
51
|
+
cachedLines = undefined;
|
|
52
|
+
_tui.requestRender();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function handleInput(data: string) {
|
|
56
|
+
// Navigation
|
|
57
|
+
if (matchesKey(data, Key.up)) {
|
|
58
|
+
optionIndex = Math.max(0, optionIndex - 1);
|
|
59
|
+
refresh();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (matchesKey(data, Key.down)) {
|
|
63
|
+
optionIndex = Math.min(OPTIONS.length - 1, optionIndex + 1);
|
|
64
|
+
refresh();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Enter: select
|
|
69
|
+
if (matchesKey(data, Key.enter)) {
|
|
70
|
+
const opt = OPTIONS[optionIndex];
|
|
71
|
+
done({ action: opt.action, prefill });
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Escape: cancel
|
|
76
|
+
if (matchesKey(data, Key.escape)) {
|
|
77
|
+
done(null);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function render(width: number): string[] {
|
|
83
|
+
if (cachedLines) return cachedLines;
|
|
84
|
+
|
|
85
|
+
const lines: string[] = [];
|
|
86
|
+
const innerWidth = Math.max(40, width - 2);
|
|
87
|
+
const border = (s: string) => theme.fg("accent", s);
|
|
88
|
+
|
|
89
|
+
function padVisible(content: string, targetWidth: number): string {
|
|
90
|
+
const vw = visibleWidth(content);
|
|
91
|
+
const pad = Math.max(0, targetWidth - vw);
|
|
92
|
+
return content + " ".repeat(pad);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const add = (s: string) =>
|
|
96
|
+
lines.push(
|
|
97
|
+
border("│") +
|
|
98
|
+
padVisible(truncateToWidth(s, innerWidth), innerWidth) +
|
|
99
|
+
border("│"),
|
|
100
|
+
);
|
|
101
|
+
const addEmpty = () =>
|
|
102
|
+
lines.push(border("│") + " ".repeat(innerWidth) + border("│"));
|
|
103
|
+
|
|
104
|
+
// Top border
|
|
105
|
+
lines.push(border(`╭${"─".repeat(innerWidth)}╮`));
|
|
106
|
+
|
|
107
|
+
// Header: show prefill command (truncated)
|
|
108
|
+
const headerPrefix = " 🚀 ";
|
|
109
|
+
const maxPrefillWidth = innerWidth - headerPrefix.length - 1;
|
|
110
|
+
const truncatedPrefill = truncateToWidth(prefill || "(no command)", maxPrefillWidth);
|
|
111
|
+
add(theme.fg("accent", headerPrefix) + theme.fg("text", truncatedPrefill));
|
|
112
|
+
addEmpty();
|
|
113
|
+
|
|
114
|
+
// Options
|
|
115
|
+
for (let i = 0; i < OPTIONS.length; i++) {
|
|
116
|
+
const opt = OPTIONS[i];
|
|
117
|
+
const isSelected = i === optionIndex;
|
|
118
|
+
const prefix = isSelected ? theme.fg("accent", "> ") : " ";
|
|
119
|
+
const label = `${opt.icon} ${opt.label}`;
|
|
120
|
+
const color = isSelected ? "accent" : "text";
|
|
121
|
+
add(prefix + theme.fg(color, label));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Footer hint
|
|
125
|
+
addEmpty();
|
|
126
|
+
add(theme.fg("dim", " ↑↓ navigate • Enter select • Esc cancel"));
|
|
127
|
+
|
|
128
|
+
// Bottom border
|
|
129
|
+
lines.push(border(`╰${"─".repeat(innerWidth)}╯`));
|
|
130
|
+
|
|
131
|
+
cachedLines = lines;
|
|
132
|
+
return lines;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
render,
|
|
137
|
+
invalidate: () => {
|
|
138
|
+
cachedLines = undefined;
|
|
139
|
+
},
|
|
140
|
+
handleInput,
|
|
141
|
+
};
|
|
142
|
+
};
|
|
143
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/ask-user",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Structured user input tool for Pi coding agent — single-select, multi-select, freeform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,13 +23,18 @@
|
|
|
23
23
|
"index.ts",
|
|
24
24
|
"types.ts",
|
|
25
25
|
"tools.ts",
|
|
26
|
+
"handoff.ts",
|
|
26
27
|
"ask-ui.ts",
|
|
28
|
+
"launcher-ui.ts",
|
|
27
29
|
"commands.ts",
|
|
28
30
|
"config.ts",
|
|
29
31
|
"settings-tui.ts",
|
|
30
32
|
"skills/**/*",
|
|
31
33
|
"README.md"
|
|
32
34
|
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"test": "node --experimental-strip-types --test tests/**/*.test.ts"
|
|
37
|
+
},
|
|
33
38
|
"pi": {
|
|
34
39
|
"extensions": [
|
|
35
40
|
"index.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
|
|
59
|
+
| `"new_session"` | Starts a handoff. Returns `new_session` response kind with optional `prefill`. Shows a launcher overlay offering **Compact & run** (compact first, then queue/submit the prefill) or **Run directly** (queue/submit immediately). The current LLM follow-up is aborted after a successful queue or editor fallback. |
|
|
60
60
|
|
|
61
61
|
## Examples
|
|
62
62
|
|
|
@@ -149,4 +149,16 @@ ask_user({
|
|
|
149
149
|
- "Looks good" returns immediately with selection
|
|
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
|
-
- "Start fresh"
|
|
152
|
+
- "Start fresh" opens the launcher; **Compact & run** or **Run directly** queues the prefill message automatically
|
|
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 | Starts `ctx.compact()` without waiting in the tool spinner, then queues/submits the prefill as a follow-up message after compaction or a short fallback timer |
|
|
161
|
+
| ▶ Run directly | Queues/submits the prefill immediately as a follow-up message, without compaction |
|
|
162
|
+
| ✕ Cancel | Cancels the session launch; no prefill is queued |
|
|
163
|
+
|
|
164
|
+
The prefill can be a slash command (for example `/unipi:work specs:...`) or any non-empty message. If automatic delivery fails, ask_user places the prefill in the editor and warns the user to press Enter. This two-step flow lets the user manage context window usage before starting a new task while avoiding unnecessary LLM follow-up in the old session.
|
package/tools.ts
CHANGED
|
@@ -8,12 +8,15 @@ import { Type } from "@sinclair/typebox";
|
|
|
8
8
|
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
9
9
|
import {
|
|
10
10
|
ASK_USER_TOOLS,
|
|
11
|
+
COMPACTOR_INSTRUCTION,
|
|
11
12
|
UNIPI_EVENTS,
|
|
12
13
|
emitEvent,
|
|
13
14
|
} from "@pi-unipi/core";
|
|
14
|
-
import type { NormalizedOption, AskUserResponse } from "./types.js";
|
|
15
|
+
import type { NormalizedOption, AskUserResponse, SessionLauncherResult } from "./types.js";
|
|
15
16
|
import { renderAskUI, createRenderCall, createRenderResult } from "./ask-ui.js";
|
|
17
|
+
import { renderLauncherUI } from "./launcher-ui.js";
|
|
16
18
|
import { getAskUserSettings } from "./config.js";
|
|
19
|
+
import { queueCompactHandoff, queueDirectHandoff } from "./handoff.js";
|
|
17
20
|
|
|
18
21
|
/**
|
|
19
22
|
* Register ask-user tools.
|
|
@@ -35,7 +38,7 @@ export function registerAskUserTools(pi: ExtensionAPI): void {
|
|
|
35
38
|
"Use allowFreeform: false to restrict to predefined options only.",
|
|
36
39
|
"Use action: 'input' on an option to let the user add custom text before submitting.",
|
|
37
40
|
"Use action: 'end_turn' on an option to let the user signal end of turn.",
|
|
38
|
-
"Use action: 'new_session' with prefill to let the user
|
|
41
|
+
"Use action: 'new_session' with prefill to let the user hand off to a queued follow-up message or slash command."
|
|
39
42
|
],
|
|
40
43
|
parameters: Type.Object({
|
|
41
44
|
question: Type.String({
|
|
@@ -76,7 +79,7 @@ export function registerAskUserTools(pi: ExtensionAPI): void {
|
|
|
76
79
|
{
|
|
77
80
|
description:
|
|
78
81
|
"Special action: 'select' (default), 'input' (text input), " +
|
|
79
|
-
"'end_turn' (signal end of turn), 'new_session' (
|
|
82
|
+
"'end_turn' (signal end of turn), 'new_session' (queue handoff with prefill).",
|
|
80
83
|
},
|
|
81
84
|
),
|
|
82
85
|
),
|
|
@@ -317,12 +320,15 @@ export function registerAskUserTools(pi: ExtensionAPI): void {
|
|
|
317
320
|
break;
|
|
318
321
|
}
|
|
319
322
|
case "end_turn":
|
|
323
|
+
// Abort the agent immediately — no LLM follow-up, no wasted tokens.
|
|
324
|
+
// The tool result is still recorded in session history.
|
|
325
|
+
ctx.abort();
|
|
320
326
|
contentText = "User chose to end the turn.";
|
|
321
327
|
break;
|
|
322
328
|
case "new_session":
|
|
323
329
|
contentText = response.prefill
|
|
324
|
-
? `User chose to
|
|
325
|
-
: "User chose
|
|
330
|
+
? `User chose to hand off to: ${response.prefill}`
|
|
331
|
+
: "User chose a handoff without a prefill.";
|
|
326
332
|
break;
|
|
327
333
|
case "timed_out":
|
|
328
334
|
contentText = "User did not respond (timed out)";
|
|
@@ -331,6 +337,87 @@ export function registerAskUserTools(pi: ExtensionAPI): void {
|
|
|
331
337
|
contentText = "No response";
|
|
332
338
|
}
|
|
333
339
|
|
|
340
|
+
// Session launcher intercept: when user selects new_session, offer compact/direct/cancel
|
|
341
|
+
if (response.kind === "new_session") {
|
|
342
|
+
const prefill = response.prefill || "";
|
|
343
|
+
const launcherResult = await ctx.ui.custom<SessionLauncherResult | null>(
|
|
344
|
+
renderLauncherUI({ prefill }),
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
if (!launcherResult || launcherResult.action === "cancel") {
|
|
348
|
+
return {
|
|
349
|
+
content: [{ type: "text", text: "User cancelled the session launch" }],
|
|
350
|
+
details: {
|
|
351
|
+
question,
|
|
352
|
+
options: normalizedOptions.map((o) => o.label),
|
|
353
|
+
response: {
|
|
354
|
+
kind: "cancelled",
|
|
355
|
+
comment: "Session launcher cancelled",
|
|
356
|
+
} as AskUserResponse,
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const handoff = launcherResult.action === "compact"
|
|
362
|
+
? queueCompactHandoff({
|
|
363
|
+
pi,
|
|
364
|
+
ctx,
|
|
365
|
+
prefill,
|
|
366
|
+
// Use the compactor sentinel so @pi-unipi/compactor's zero-LLM
|
|
367
|
+
// pipeline intercepts. If compactor is not installed, Pi's built-in
|
|
368
|
+
// LLM-based compaction runs instead.
|
|
369
|
+
customInstructions: `${COMPACTOR_INSTRUCTION}\nPreparing for new task. Summarize previous work concisely, preserving only what's essential for: ${prefill}`,
|
|
370
|
+
})
|
|
371
|
+
: queueDirectHandoff(pi, ctx, prefill);
|
|
372
|
+
|
|
373
|
+
if (handoff.status === "cancelled") {
|
|
374
|
+
return {
|
|
375
|
+
content: [{ type: "text", text: "Session launch cancelled: no prefill message was provided." }],
|
|
376
|
+
details: {
|
|
377
|
+
question,
|
|
378
|
+
options: normalizedOptions.map((o) => o.label),
|
|
379
|
+
response: {
|
|
380
|
+
kind: "cancelled",
|
|
381
|
+
comment: "Session launcher had no prefill to queue",
|
|
382
|
+
launchStatus: handoff.status,
|
|
383
|
+
launchReason: handoff.reason,
|
|
384
|
+
} as AskUserResponse,
|
|
385
|
+
},
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (handoff.status !== "failed") {
|
|
390
|
+
// Handoff is scheduled/queued or recoverably editor-prefilled; abort the
|
|
391
|
+
// current turn so the queued command can run without LLM follow-up.
|
|
392
|
+
ctx.abort();
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const launchLabel = launcherResult.action === "compact" ? "compact" : "direct";
|
|
396
|
+
if (handoff.status === "editor_prefill") {
|
|
397
|
+
contentText = `Queued ${launchLabel} handoff fell back to editor prefill: ${handoff.prefill}`;
|
|
398
|
+
} else if (handoff.status === "failed") {
|
|
399
|
+
contentText = `Failed to queue ${launchLabel} handoff: ${handoff.prefill ?? prefill}`;
|
|
400
|
+
} else {
|
|
401
|
+
contentText = `Queued ${launchLabel} handoff: ${handoff.prefill ?? prefill}`;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return {
|
|
405
|
+
content: [{ type: "text", text: contentText }],
|
|
406
|
+
details: {
|
|
407
|
+
question,
|
|
408
|
+
options: normalizedOptions.map((o) => o.label),
|
|
409
|
+
response: {
|
|
410
|
+
...response,
|
|
411
|
+
prefill: handoff.prefill ?? prefill,
|
|
412
|
+
launchedWith: launcherResult.action,
|
|
413
|
+
launchStatus: handoff.status,
|
|
414
|
+
launchReason: handoff.reason,
|
|
415
|
+
launchError: handoff.error,
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
334
421
|
return {
|
|
335
422
|
content: [{ type: "text", text: contentText }],
|
|
336
423
|
details: {
|
package/types.ts
CHANGED
|
@@ -40,6 +40,20 @@ export interface AskUserParams {
|
|
|
40
40
|
timeout?: number;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
export type SessionLaunchAction = "compact" | "direct";
|
|
44
|
+
|
|
45
|
+
export type SessionLaunchStatus = "scheduled" | "queued" | "editor_prefill" | "cancelled" | "failed";
|
|
46
|
+
|
|
47
|
+
export type SessionLaunchReason =
|
|
48
|
+
| "direct"
|
|
49
|
+
| "compact-started"
|
|
50
|
+
| "compacted"
|
|
51
|
+
| "compaction-error"
|
|
52
|
+
| "fallback-timeout"
|
|
53
|
+
| "compact-start-failed"
|
|
54
|
+
| "empty-prefill"
|
|
55
|
+
| "send-failed";
|
|
56
|
+
|
|
43
57
|
/** Response from ask_user tool */
|
|
44
58
|
export interface AskUserResponse {
|
|
45
59
|
/** Response kind */
|
|
@@ -52,6 +66,20 @@ export interface AskUserResponse {
|
|
|
52
66
|
prefill?: string;
|
|
53
67
|
/** Optional user comment */
|
|
54
68
|
comment?: string;
|
|
69
|
+
/** Session launcher action, when a new_session handoff was launched */
|
|
70
|
+
launchedWith?: SessionLaunchAction;
|
|
71
|
+
/** Session launcher delivery/scheduling status */
|
|
72
|
+
launchStatus?: SessionLaunchStatus;
|
|
73
|
+
/** Session launcher status reason */
|
|
74
|
+
launchReason?: SessionLaunchReason;
|
|
75
|
+
/** Best-effort error message for handoff fallback/failure paths */
|
|
76
|
+
launchError?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Result from the session launcher UI */
|
|
80
|
+
export interface SessionLauncherResult {
|
|
81
|
+
action: "compact" | "direct" | "cancel";
|
|
82
|
+
prefill: string;
|
|
55
83
|
}
|
|
56
84
|
|
|
57
85
|
/** Normalized option with resolved value */
|