@ssweens/pi-huddle 1.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/LICENSE +21 -0
- package/README.md +229 -0
- package/ask-user-screenshot.png +0 -0
- package/extensions/index.ts +325 -0
- package/extensions/lib/ask-user-dialog.ts +443 -0
- package/extensions/lib/utils.ts +166 -0
- package/package.json +37 -0
- package/screenshot.png +0 -0
- package/skills/huddle/SKILL.md +164 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ssweens
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# pi-huddle
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pi install @ssweens/pi-huddle
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Huddle mode for [pi](https://github.com/badlogic/pi-mono). Safe exploration with permission gates, plus a powerful `ask_user` tool for structured multi-question elicitation. Toggle with `/huddle`, `/holup`, `/plan`, or `Alt+H`.
|
|
12
|
+
|
|
13
|
+
## Features
|
|
14
|
+
|
|
15
|
+
- **Huddle mode** — read-only by default; writes require your approval
|
|
16
|
+
- **`ask_user` tool** — rich TUI dialog for structured elicitation (available in all modes)
|
|
17
|
+
- **Permission gates** — approve or deny individual edit/write operations inline
|
|
18
|
+
- **Bash allowlist** — safe commands execute freely, destructive ones prompt first
|
|
19
|
+
- **Three commands** — `/huddle` (primary), `/holup`, `/plan` all toggle the mode
|
|
20
|
+
- **`Alt+H` shortcut** — Option+H on Mac
|
|
21
|
+
- **CLI flag** — `pi --plan` to start in huddle mode
|
|
22
|
+
- **Session persistence** — huddle state survives session resume
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pi install /path/to/pi-huddle
|
|
28
|
+
|
|
29
|
+
# Or project-local
|
|
30
|
+
pi install -l /path/to/pi-huddle
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
### Toggle Huddle Mode
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
/huddle # primary command
|
|
39
|
+
/holup # alias
|
|
40
|
+
/plan # alias (backward compat)
|
|
41
|
+
Alt+H (Option+H) # keyboard shortcut
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Start in Huddle Mode
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pi --huddle # start pi directly in huddle mode
|
|
48
|
+
pi --plan # alias (backward compat)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Workflow
|
|
52
|
+
|
|
53
|
+
1. **Enter huddle mode** — `/huddle` or `Alt+H`
|
|
54
|
+
2. **Use `ask_user`** — gather requirements and clarify before acting
|
|
55
|
+
3. **Explore safely** — read, search, and analyze freely
|
|
56
|
+
4. **Approve edits on demand** — each write operation requires approval
|
|
57
|
+
5. **Exit when ready** — toggle off to restore full access
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## ask_user Tool
|
|
62
|
+
|
|
63
|
+
The `ask_user` tool is available **in all modes** — not just huddle. It presents a rich TUI dialog with one tab per question, numbered options, freeform text input, and a submit/review view.
|
|
64
|
+
|
|
65
|
+
### Dialog UX
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
← □ Auth method □ Library ✓ Submit →
|
|
69
|
+
|
|
70
|
+
Which auth approach should I use?
|
|
71
|
+
|
|
72
|
+
1. JWT tokens
|
|
73
|
+
Stateless, scales well, standard choice.
|
|
74
|
+
2. Session cookies
|
|
75
|
+
Simpler for server-rendered apps.
|
|
76
|
+
3. OAuth2 / OIDC
|
|
77
|
+
Best for third-party login integration.
|
|
78
|
+
4. API keys
|
|
79
|
+
Simplest for machine-to-machine auth.
|
|
80
|
+
5. |ype something. ← freeform field, type immediately
|
|
81
|
+
────────────────────────────────────────
|
|
82
|
+
6. Chat about this
|
|
83
|
+
|
|
84
|
+
Enter to select · Tab/↑↓ to navigate · Esc to cancel
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
- **Tab bar** — `←`/`→` or `Tab`/`Shift+Tab` to navigate between questions and Submit
|
|
88
|
+
- **Options** — `↑`/`↓` to move, `Enter` to select
|
|
89
|
+
- **Freeform** — navigate to row 5, start typing immediately; `Enter` to confirm
|
|
90
|
+
- **Chat about this** — tells the agent the user wants to discuss before deciding
|
|
91
|
+
- **Submit view** — recap of all answers before final submission
|
|
92
|
+
- **`multiSelect: true`** — `Space` or `Enter` to toggle, multiple selections allowed
|
|
93
|
+
|
|
94
|
+
### Tool Call Example
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
{
|
|
98
|
+
"questions": [
|
|
99
|
+
{
|
|
100
|
+
"question": "Which auth approach should I use?",
|
|
101
|
+
"header": "Auth method",
|
|
102
|
+
"options": [
|
|
103
|
+
{
|
|
104
|
+
"label": "JWT tokens (Recommended)",
|
|
105
|
+
"description": "Stateless, scales well, standard choice",
|
|
106
|
+
"markdown": "Authorization: Bearer <token>"
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
"label": "Session cookies",
|
|
110
|
+
"description": "Simpler for server-rendered apps"
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
"label": "OAuth2 / OIDC",
|
|
114
|
+
"description": "Best for third-party login integration"
|
|
115
|
+
}
|
|
116
|
+
],
|
|
117
|
+
"multiSelect": false
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
"question": "Which features do you want to enable?",
|
|
121
|
+
"header": "Features",
|
|
122
|
+
"options": [
|
|
123
|
+
{ "label": "Logging", "description": "Structured JSON logs" },
|
|
124
|
+
{ "label": "Metrics", "description": "Prometheus /metrics endpoint" },
|
|
125
|
+
{ "label": "Tracing", "description": "OpenTelemetry spans" },
|
|
126
|
+
{ "label": "Alerts", "description": "PagerDuty integration" }
|
|
127
|
+
],
|
|
128
|
+
"multiSelect": true
|
|
129
|
+
}
|
|
130
|
+
]
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Return Value
|
|
135
|
+
|
|
136
|
+
```json
|
|
137
|
+
{
|
|
138
|
+
"answers": {
|
|
139
|
+
"Which auth approach should I use?": "JWT tokens (Recommended)",
|
|
140
|
+
"Which features do you want to enable?": "Logging, Tracing"
|
|
141
|
+
},
|
|
142
|
+
"annotations": {},
|
|
143
|
+
"metadata": {}
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Usage Notes
|
|
148
|
+
|
|
149
|
+
- 1–4 questions per call
|
|
150
|
+
- 2–4 options per question
|
|
151
|
+
- `markdown` field shows a code preview when an option is focused
|
|
152
|
+
- `multiSelect: true` for feature flags, configuration choices, etc.
|
|
153
|
+
- Put "(Recommended)" at end of preferred option label
|
|
154
|
+
- If user selects "Chat about this", agent should respond conversationally
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## Permission Gates
|
|
159
|
+
|
|
160
|
+
### ✅ Always Allowed
|
|
161
|
+
|
|
162
|
+
| Tool | Description |
|
|
163
|
+
|------|-------------|
|
|
164
|
+
| `read` | Read file contents |
|
|
165
|
+
| `bash` | Allowlisted safe commands |
|
|
166
|
+
| `grep` | Search within files |
|
|
167
|
+
| `find` | Find files |
|
|
168
|
+
| `ls` | List directories |
|
|
169
|
+
| `ask_user` | Structured elicitation |
|
|
170
|
+
|
|
171
|
+
### ⚠️ Requires Permission
|
|
172
|
+
|
|
173
|
+
- **`edit`** — file modifications
|
|
174
|
+
- **`write`** — file creation/overwriting
|
|
175
|
+
- **Non-allowlisted bash commands**
|
|
176
|
+
|
|
177
|
+
### Permission Dialog
|
|
178
|
+
|
|
179
|
+
```
|
|
180
|
+
⚠ Huddle Mode — edit: /path/to/file.ts
|
|
181
|
+
[Allow] [Deny] [Deny with feedback]
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
**Deny with feedback** sends the reason to the agent so it can adjust.
|
|
185
|
+
|
|
186
|
+
### Safe Bash Commands (No Prompt)
|
|
187
|
+
|
|
188
|
+
`cat`, `head`, `tail`, `grep`, `find`, `rg`, `fd`, `ls`, `pwd`, `tree`,
|
|
189
|
+
`git status`, `git log`, `git diff`, `git branch`, `npm list`, `curl`, `jq`
|
|
190
|
+
|
|
191
|
+
### Blocked Bash Commands (Prompt Required)
|
|
192
|
+
|
|
193
|
+
`rm`, `mv`, `cp`, `mkdir`, `touch`, `git add`, `git commit`, `git push`,
|
|
194
|
+
`npm install`, `yarn add`, `pip install`, `sudo`, `>`, `>>`
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## Architecture
|
|
199
|
+
|
|
200
|
+
```
|
|
201
|
+
pi-huddle/
|
|
202
|
+
├── package.json # Package manifest
|
|
203
|
+
├── extensions/
|
|
204
|
+
│ ├── index.ts # Commands, shortcuts, ask_user tool, permission gates
|
|
205
|
+
│ └── lib/
|
|
206
|
+
│ ├── ask-user-dialog.ts # TUI dialog component
|
|
207
|
+
│ └── utils.ts # Bash command classification
|
|
208
|
+
├── skills/
|
|
209
|
+
│ └── huddle/
|
|
210
|
+
│ └── SKILL.md # Teaches the agent huddle mode behaviour
|
|
211
|
+
├── LICENSE
|
|
212
|
+
└── README.md
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Two pi primitives:
|
|
216
|
+
|
|
217
|
+
- **Extension** — registers `/huddle`, `/holup`, `/plan` commands, `Alt+H` shortcut, `ask_user` tool, permission gates, and context injection
|
|
218
|
+
- **Skill** — documents huddle mode and `ask_user` behaviour so the agent knows how to use them
|
|
219
|
+
|
|
220
|
+
## Development
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
/reload # Hot-reload after editing
|
|
224
|
+
/huddle # Test huddle mode
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
## License
|
|
228
|
+
|
|
229
|
+
[MIT](LICENSE)
|
|
Binary file
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Huddle Extension
|
|
3
|
+
*
|
|
4
|
+
* Safe exploration mode with permission gates for file modifications.
|
|
5
|
+
* Read-only by default; writes require user approval.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - /huddle, /holup, or /plan commands to toggle
|
|
9
|
+
* - Alt+P shortcut to toggle
|
|
10
|
+
* - Bash restricted to allowlisted commands (others prompt for permission)
|
|
11
|
+
* - edit/write tools prompt for permission during huddle mode
|
|
12
|
+
* - ask_user tool for structured elicitation during planning
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
|
16
|
+
import type { TextContent } from "@mariozechner/pi-ai";
|
|
17
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
18
|
+
import { Type } from "@sinclair/typebox";
|
|
19
|
+
import { AskUserDialog, type AskUserDialogResult } from "./lib/ask-user-dialog.js";
|
|
20
|
+
import { isSafeCommand } from "./lib/utils.js";
|
|
21
|
+
|
|
22
|
+
// Tools
|
|
23
|
+
const HUDDLE_MODE_TOOLS = ["read", "bash", "grep", "find", "ls", "ask_user"];
|
|
24
|
+
const NORMAL_MODE_TOOLS = ["read", "bash", "edit", "write", "ask_user"];
|
|
25
|
+
|
|
26
|
+
export default function huddleExtension(pi: ExtensionAPI): void {
|
|
27
|
+
let huddleEnabled = false;
|
|
28
|
+
|
|
29
|
+
pi.registerFlag("huddle", {
|
|
30
|
+
description: "Start in huddle mode (read-only exploration)",
|
|
31
|
+
type: "boolean",
|
|
32
|
+
default: false,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
pi.registerFlag("plan", {
|
|
36
|
+
description: "Start in huddle mode (alias for --huddle)",
|
|
37
|
+
type: "boolean",
|
|
38
|
+
default: false,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
function updateStatus(ctx: ExtensionContext): void {
|
|
42
|
+
if (huddleEnabled) {
|
|
43
|
+
ctx.ui.setStatus("huddle", ctx.ui.theme.fg("warning", "⏸ huddle"));
|
|
44
|
+
} else {
|
|
45
|
+
ctx.ui.setStatus("huddle", undefined);
|
|
46
|
+
}
|
|
47
|
+
ctx.ui.setWidget("plan-todos", undefined);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function toggleHuddle(ctx: ExtensionContext): void {
|
|
51
|
+
huddleEnabled = !huddleEnabled;
|
|
52
|
+
|
|
53
|
+
if (huddleEnabled) {
|
|
54
|
+
pi.setActiveTools(HUDDLE_MODE_TOOLS);
|
|
55
|
+
ctx.ui.notify(`Huddle mode enabled. Tools: ${HUDDLE_MODE_TOOLS.join(", ")}. Safe: cd, rg, fd, cat, git status/log/diff`);
|
|
56
|
+
} else {
|
|
57
|
+
pi.setActiveTools(NORMAL_MODE_TOOLS);
|
|
58
|
+
ctx.ui.notify("Huddle mode disabled. Full access restored.");
|
|
59
|
+
}
|
|
60
|
+
updateStatus(ctx);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Primary command
|
|
64
|
+
pi.registerCommand("huddle", {
|
|
65
|
+
description: "Toggle huddle mode (read-only exploration + structured elicitation)",
|
|
66
|
+
handler: async (_args, ctx) => toggleHuddle(ctx),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Aliases
|
|
70
|
+
pi.registerCommand("holup", {
|
|
71
|
+
description: "Toggle huddle mode (alias for /huddle)",
|
|
72
|
+
handler: async (_args, ctx) => toggleHuddle(ctx),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
pi.registerCommand("plan", {
|
|
76
|
+
description: "Toggle huddle mode (alias for /huddle)",
|
|
77
|
+
handler: async (_args, ctx) => toggleHuddle(ctx),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
pi.registerShortcut("alt+h", {
|
|
81
|
+
description: "Toggle huddle mode",
|
|
82
|
+
handler: async (ctx) => toggleHuddle(ctx),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Ask User Question tool - structured elicitation
|
|
86
|
+
pi.registerTool({
|
|
87
|
+
name: "ask_user",
|
|
88
|
+
label: "Ask User Question",
|
|
89
|
+
description: `Use this tool when you need to ask the user questions during execution. This allows you to:
|
|
90
|
+
- Gather user preferences or requirements
|
|
91
|
+
- Clarify ambiguous instructions
|
|
92
|
+
- Get decisions on implementation choices as you work
|
|
93
|
+
- Offer choices to the user about what direction to take
|
|
94
|
+
|
|
95
|
+
Usage notes:
|
|
96
|
+
- Users will always be able to type a custom answer in the freeform field
|
|
97
|
+
- Use multiSelect: true to allow multiple answers to be selected for a question
|
|
98
|
+
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label
|
|
99
|
+
|
|
100
|
+
Huddle mode note: In huddle mode, use this tool to clarify requirements or choose between approaches BEFORE finalizing your plan. Do NOT use this tool to ask "Is my plan ready?" or "Should I proceed?" - use ExitHuddleMode for plan approval. IMPORTANT: Do not reference "the plan" in your questions (e.g. "Do you have feedback about the plan?", "Does the plan look good?") because the user cannot see the plan in the UI until you call ExitHuddleMode. If you need plan approval, use ExitHuddleMode instead.`,
|
|
101
|
+
parameters: Type.Object({
|
|
102
|
+
questions: Type.Array(
|
|
103
|
+
Type.Object({
|
|
104
|
+
question: Type.String({
|
|
105
|
+
description: "The complete question to ask the user. Should be clear, specific, and end with a question mark. Example: 'Which library should we use for date formatting?' If multiSelect is true, phrase it accordingly, e.g. 'Which features do you want to enable?'",
|
|
106
|
+
}),
|
|
107
|
+
header: Type.String({
|
|
108
|
+
description: "Very short label displayed as a chip/tag (max 12 chars). Examples: 'Auth method', 'Library', 'Approach'.",
|
|
109
|
+
}),
|
|
110
|
+
options: Type.Array(
|
|
111
|
+
Type.Object({
|
|
112
|
+
label: Type.String({
|
|
113
|
+
description: "The display text for this option that the user will see and select. Should be concise (1-5 words) and clearly describe the choice.",
|
|
114
|
+
}),
|
|
115
|
+
description: Type.String({
|
|
116
|
+
description: "Explanation of what this option means or what will happen if chosen. Useful for providing context about trade-offs or implications.",
|
|
117
|
+
}),
|
|
118
|
+
markdown: Type.Optional(Type.String({
|
|
119
|
+
description: "Optional preview content shown in a monospace box when this option is focused. Use for ASCII mockups, code snippets, or diagrams that help users visually compare options. Supports multi-line text with newlines.",
|
|
120
|
+
})),
|
|
121
|
+
}),
|
|
122
|
+
{
|
|
123
|
+
minItems: 2,
|
|
124
|
+
maxItems: 4,
|
|
125
|
+
}
|
|
126
|
+
),
|
|
127
|
+
multiSelect: Type.Boolean({
|
|
128
|
+
default: false,
|
|
129
|
+
description: "Set to true to allow the user to select multiple options instead of just one. Use when choices are not mutually exclusive.",
|
|
130
|
+
}),
|
|
131
|
+
}),
|
|
132
|
+
{
|
|
133
|
+
minItems: 1,
|
|
134
|
+
maxItems: 4,
|
|
135
|
+
description: "Questions to ask the user (1-4 questions)",
|
|
136
|
+
}
|
|
137
|
+
),
|
|
138
|
+
metadata: Type.Optional(Type.Object({
|
|
139
|
+
source: Type.Optional(Type.String({
|
|
140
|
+
description: "Optional identifier for the source of this question (e.g., 'remember' for /remember command). Used for analytics tracking.",
|
|
141
|
+
})),
|
|
142
|
+
}, {
|
|
143
|
+
description: "Optional metadata for tracking and analytics purposes. Not displayed to user.",
|
|
144
|
+
})),
|
|
145
|
+
}),
|
|
146
|
+
execute: async (_toolCallId, params, _signal, _onUpdate, ctx) => {
|
|
147
|
+
const { questions, metadata } = params;
|
|
148
|
+
|
|
149
|
+
const result = await ctx.ui.custom<AskUserDialogResult>(
|
|
150
|
+
(tui, theme, _kb, done) => {
|
|
151
|
+
const dialog = new AskUserDialog(questions, theme);
|
|
152
|
+
dialog.onDone = (r) => done(r);
|
|
153
|
+
return {
|
|
154
|
+
get focused() { return dialog.focused; },
|
|
155
|
+
set focused(v: boolean) { dialog.focused = v; },
|
|
156
|
+
render: (w: number) => dialog.render(w),
|
|
157
|
+
invalidate: () => dialog.invalidate(),
|
|
158
|
+
handleInput: (data: string) => {
|
|
159
|
+
dialog.handleInput(data);
|
|
160
|
+
tui.requestRender();
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
},
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
// Cancelled (Esc)
|
|
167
|
+
if (!result) {
|
|
168
|
+
return {
|
|
169
|
+
content: [{ type: "text", text: "User cancelled the question." }],
|
|
170
|
+
details: { answers: {}, annotations: {}, metadata },
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// "Chat about this"
|
|
175
|
+
if ("chatMode" in result) {
|
|
176
|
+
return {
|
|
177
|
+
content: [{ type: "text", text: "The user selected 'Chat about this'. They want to discuss the options before deciding. Respond conversationally." }],
|
|
178
|
+
details: { chatMode: true, metadata },
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Normal submission
|
|
183
|
+
const summary = Object.entries(result.answers)
|
|
184
|
+
.map(([q, a]) => `- ${q}\n → ${a}`)
|
|
185
|
+
.join("\n");
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
content: [{ type: "text", text: `User answers:\n${summary}` }],
|
|
189
|
+
details: { ...result, metadata },
|
|
190
|
+
};
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// Permission gate for blocked operations in huddle mode
|
|
195
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
196
|
+
if (!huddleEnabled) return;
|
|
197
|
+
|
|
198
|
+
const toolName = event.toolName;
|
|
199
|
+
|
|
200
|
+
if (toolName === "write" || toolName === "edit") {
|
|
201
|
+
const path = event.input.path || event.input.file || "unknown";
|
|
202
|
+
const theme = ctx.ui.theme;
|
|
203
|
+
const title = `${theme.fg("warning", theme.bold("⚠ Huddle Mode"))} — ${theme.fg("accent", toolName)}: ${theme.fg("accent", path)}`;
|
|
204
|
+
const choice = await ctx.ui.select(title, [
|
|
205
|
+
"Allow",
|
|
206
|
+
"Deny",
|
|
207
|
+
"Deny with feedback",
|
|
208
|
+
]);
|
|
209
|
+
|
|
210
|
+
if (choice === "Allow") return;
|
|
211
|
+
|
|
212
|
+
let reason = `User denied ${toolName} permission in huddle mode`;
|
|
213
|
+
if (choice === "Deny with feedback") {
|
|
214
|
+
const feedback = await ctx.ui.input("Why? (feedback sent to agent):");
|
|
215
|
+
if (feedback) reason = feedback;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return { block: true, reason };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (toolName === "bash") {
|
|
222
|
+
const command = event.input.command as string;
|
|
223
|
+
if (!isSafeCommand(command)) {
|
|
224
|
+
const theme = ctx.ui.theme;
|
|
225
|
+
const title = `${theme.fg("warning", theme.bold("⚠ Huddle Mode"))} — ${theme.fg("accent", command)}`;
|
|
226
|
+
const choice = await ctx.ui.select(title, [
|
|
227
|
+
"Allow",
|
|
228
|
+
"Deny",
|
|
229
|
+
"Deny with feedback",
|
|
230
|
+
]);
|
|
231
|
+
|
|
232
|
+
if (choice === "Allow") return;
|
|
233
|
+
|
|
234
|
+
let reason = `User denied bash command in huddle mode: ${command}`;
|
|
235
|
+
if (choice === "Deny with feedback") {
|
|
236
|
+
const feedback = await ctx.ui.input("Why? (feedback sent to agent):");
|
|
237
|
+
if (feedback) reason = feedback;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return { block: true, reason };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// Filter out stale huddle context when not in huddle mode
|
|
246
|
+
pi.on("context", async (event) => {
|
|
247
|
+
if (huddleEnabled) return;
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
messages: event.messages.filter((m) => {
|
|
251
|
+
const msg = m as AgentMessage & { customType?: string };
|
|
252
|
+
if (msg.customType === "huddle-context") return false;
|
|
253
|
+
if (msg.role !== "user") return true;
|
|
254
|
+
|
|
255
|
+
const content = msg.content;
|
|
256
|
+
if (typeof content === "string") {
|
|
257
|
+
return !content.includes("[HUDDLE MODE ACTIVE]");
|
|
258
|
+
}
|
|
259
|
+
if (Array.isArray(content)) {
|
|
260
|
+
return !content.some(
|
|
261
|
+
(c) => c.type === "text" && (c as TextContent).text?.includes("[HUDDLE MODE ACTIVE]"),
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
return true;
|
|
265
|
+
}),
|
|
266
|
+
};
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// Inject huddle context before agent starts
|
|
270
|
+
pi.on("before_agent_start", async () => {
|
|
271
|
+
if (huddleEnabled) {
|
|
272
|
+
return {
|
|
273
|
+
message: {
|
|
274
|
+
customType: "huddle-context",
|
|
275
|
+
content: `[HUDDLE MODE ACTIVE]
|
|
276
|
+
You are in huddle mode - a read-only exploration mode for safe code analysis and structured elicitation.
|
|
277
|
+
|
|
278
|
+
IMPORTANT: Do NOT attempt to use edit or write tools while huddle mode is active. They are disabled. If you believe a file change is needed, tell the user and ask them to exit huddle mode first (via /huddle, /holup, /plan, or Alt+P).
|
|
279
|
+
|
|
280
|
+
Available Tools:
|
|
281
|
+
- read, bash, grep, find, ls, ask_user (always allowed)
|
|
282
|
+
|
|
283
|
+
Safe Bash Commands (always allowed):
|
|
284
|
+
cat, cd, rg, fd, grep, head, tail, ls, find, git status/log/diff/branch, npm list
|
|
285
|
+
|
|
286
|
+
Other bash commands will prompt for permission.
|
|
287
|
+
|
|
288
|
+
Use the ask_user tool for structured elicitation — gathering requirements, clarifying ambiguity, and getting decisions from the user before acting.
|
|
289
|
+
|
|
290
|
+
Create a detailed numbered plan under a "Plan:" header:
|
|
291
|
+
|
|
292
|
+
Plan:
|
|
293
|
+
1. First step description
|
|
294
|
+
2. Second step description
|
|
295
|
+
...
|
|
296
|
+
|
|
297
|
+
Do NOT execute the plan. Only plan and analyze. When you are ready to execute, ask the user to exit huddle mode.`,
|
|
298
|
+
display: false,
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// Restore state on session start/resume
|
|
305
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
306
|
+
if (pi.getFlag("huddle") === true || pi.getFlag("plan") === true) {
|
|
307
|
+
huddleEnabled = true;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const entries = ctx.sessionManager.getEntries();
|
|
311
|
+
|
|
312
|
+
const huddleEntry = entries
|
|
313
|
+
.filter((e: { type: string; customType?: string }) => e.type === "custom" && e.customType === "huddle")
|
|
314
|
+
.pop() as { data?: { enabled: boolean } } | undefined;
|
|
315
|
+
|
|
316
|
+
if (huddleEntry?.data) {
|
|
317
|
+
huddleEnabled = huddleEntry.data.enabled ?? huddleEnabled;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (huddleEnabled) {
|
|
321
|
+
pi.setActiveTools(HUDDLE_MODE_TOOLS);
|
|
322
|
+
}
|
|
323
|
+
updateStatus(ctx);
|
|
324
|
+
});
|
|
325
|
+
}
|
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AskUserDialog - TUI component matching the Claude Code AskUserQuestion UI.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Component, Focusable } from "@mariozechner/pi-tui";
|
|
6
|
+
import { Input, Key, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
|
|
7
|
+
|
|
8
|
+
// Inline block cursor: inverse-video character (used after typed text)
|
|
9
|
+
const BLOCK_CURSOR = "\x1b[7m \x1b[27m";
|
|
10
|
+
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
11
|
+
|
|
12
|
+
export interface QuestionOption {
|
|
13
|
+
label: string;
|
|
14
|
+
description: string;
|
|
15
|
+
markdown?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface QuestionDef {
|
|
19
|
+
question: string;
|
|
20
|
+
header: string;
|
|
21
|
+
options: QuestionOption[];
|
|
22
|
+
multiSelect: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface AskUserResult {
|
|
26
|
+
answers: Record<string, string>;
|
|
27
|
+
annotations: Record<string, { markdown?: string; notes?: string }>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type AskUserDialogResult = AskUserResult | { chatMode: true } | null;
|
|
31
|
+
|
|
32
|
+
const SUBMIT_VIEW = -1;
|
|
33
|
+
|
|
34
|
+
export class AskUserDialog implements Component, Focusable {
|
|
35
|
+
private questions: QuestionDef[];
|
|
36
|
+
private theme: Theme;
|
|
37
|
+
|
|
38
|
+
private currentQ = 0;
|
|
39
|
+
private selectedIdx = 0;
|
|
40
|
+
private submitIdx = 0;
|
|
41
|
+
|
|
42
|
+
// Regular option selections: question → selected labels
|
|
43
|
+
private selections = new Map<string, string[]>();
|
|
44
|
+
// Freeform text per question
|
|
45
|
+
private freeformValues = new Map<string, string>();
|
|
46
|
+
// Questions where freeform was explicitly confirmed (Enter pressed)
|
|
47
|
+
private confirmedFreeform = new Set<string>();
|
|
48
|
+
private annotations = new Map<string, { markdown?: string }>();
|
|
49
|
+
|
|
50
|
+
// Single Input instance — value swapped when switching questions
|
|
51
|
+
private freeformInput: Input;
|
|
52
|
+
|
|
53
|
+
// Focusable — kept so TUI recognises us, but we use inline BLOCK_CURSOR not CURSOR_MARKER
|
|
54
|
+
private _focused = false;
|
|
55
|
+
get focused(): boolean { return this._focused; }
|
|
56
|
+
set focused(value: boolean) { this._focused = value; }
|
|
57
|
+
|
|
58
|
+
onDone?: (result: AskUserDialogResult) => void;
|
|
59
|
+
|
|
60
|
+
constructor(questions: QuestionDef[], theme: Theme) {
|
|
61
|
+
this.questions = questions;
|
|
62
|
+
this.theme = theme;
|
|
63
|
+
this.freeformInput = new Input();
|
|
64
|
+
// No onSubmit/onEscape — we intercept keys in handleInput ourselves
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private get currentQuestion(): QuestionDef | null {
|
|
68
|
+
if (this.currentQ === SUBMIT_VIEW) return null;
|
|
69
|
+
return this.questions[this.currentQ] ?? null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private get isOnFreeform(): boolean {
|
|
73
|
+
const q = this.currentQuestion;
|
|
74
|
+
if (!q) return false;
|
|
75
|
+
return this.selectedIdx === q.options.length;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private get chatIdx(): number {
|
|
79
|
+
const q = this.currentQuestion;
|
|
80
|
+
if (!q) return 0;
|
|
81
|
+
return q.options.length + 1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private get totalOptions(): number {
|
|
85
|
+
const q = this.currentQuestion;
|
|
86
|
+
if (!q) return 0;
|
|
87
|
+
return q.options.length + 2; // options + freeform + chat
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Freeform helpers ──────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
private saveFreeform(): void {
|
|
93
|
+
const q = this.currentQuestion;
|
|
94
|
+
if (!q) return;
|
|
95
|
+
const val = this.freeformInput.getValue().trim();
|
|
96
|
+
if (val) {
|
|
97
|
+
this.freeformValues.set(q.question, val);
|
|
98
|
+
} else {
|
|
99
|
+
this.freeformValues.delete(q.question);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private restoreFreeform(): void {
|
|
104
|
+
const q = this.currentQuestion;
|
|
105
|
+
if (!q) return;
|
|
106
|
+
this.freeformInput.setValue(this.freeformValues.get(q.question) ?? "");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private clearFreeform(): void {
|
|
110
|
+
const q = this.currentQuestion;
|
|
111
|
+
if (!q) return;
|
|
112
|
+
this.freeformValues.delete(q.question);
|
|
113
|
+
this.confirmedFreeform.delete(q.question);
|
|
114
|
+
this.freeformInput.setValue("");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Answer state helpers ──────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
private isAnswered(q: QuestionDef): boolean {
|
|
120
|
+
const sel = this.selections.get(q.question);
|
|
121
|
+
return (!!sel && sel.length > 0) || this.confirmedFreeform.has(q.question);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── Navigation helpers ────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
private setQuestion(idx: number): void {
|
|
127
|
+
// Save freeform if leaving a question on the freeform row
|
|
128
|
+
if (this.currentQ !== SUBMIT_VIEW && this.isOnFreeform) {
|
|
129
|
+
this.saveFreeform();
|
|
130
|
+
}
|
|
131
|
+
this.currentQ = idx;
|
|
132
|
+
this.selectedIdx = 0;
|
|
133
|
+
// If landing on a question and its freeform was previously filled, restore
|
|
134
|
+
if (this.currentQ !== SUBMIT_VIEW) {
|
|
135
|
+
this.freeformInput.setValue(this.freeformValues.get(this.currentQuestion!.question) ?? "");
|
|
136
|
+
this.freeformInput.focused = false;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private setSelectedIdx(idx: number): void {
|
|
141
|
+
const wasOnFreeform = this.isOnFreeform;
|
|
142
|
+
if (wasOnFreeform) this.saveFreeform();
|
|
143
|
+
|
|
144
|
+
this.selectedIdx = idx;
|
|
145
|
+
|
|
146
|
+
if (this.isOnFreeform) {
|
|
147
|
+
this.restoreFreeform();
|
|
148
|
+
} else {
|
|
149
|
+
this.freeformInput.focused = false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private goNext(): void {
|
|
154
|
+
if (this.currentQ === SUBMIT_VIEW) {
|
|
155
|
+
this.setQuestion(0);
|
|
156
|
+
} else if (this.currentQ < this.questions.length - 1) {
|
|
157
|
+
this.setQuestion(this.currentQ + 1);
|
|
158
|
+
} else {
|
|
159
|
+
if (this.isOnFreeform) this.saveFreeform();
|
|
160
|
+
this.currentQ = SUBMIT_VIEW;
|
|
161
|
+
this.selectedIdx = 0;
|
|
162
|
+
this.submitIdx = 0;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private goPrev(): void {
|
|
167
|
+
if (this.currentQ === SUBMIT_VIEW) {
|
|
168
|
+
this.setQuestion(this.questions.length - 1);
|
|
169
|
+
} else if (this.currentQ === 0) {
|
|
170
|
+
if (this.isOnFreeform) this.saveFreeform();
|
|
171
|
+
this.currentQ = SUBMIT_VIEW;
|
|
172
|
+
this.selectedIdx = 0;
|
|
173
|
+
this.submitIdx = 0;
|
|
174
|
+
} else {
|
|
175
|
+
this.setQuestion(this.currentQ - 1);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── Selection logic ───────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
private selectCurrentOption(): void {
|
|
182
|
+
if (this.currentQ === SUBMIT_VIEW) {
|
|
183
|
+
if (this.submitIdx === 0) this.doSubmit();
|
|
184
|
+
else this.onDone?.(null);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const q = this.currentQuestion!;
|
|
189
|
+
const qKey = q.question;
|
|
190
|
+
const freeformIdx = q.options.length;
|
|
191
|
+
|
|
192
|
+
if (this.selectedIdx === freeformIdx) {
|
|
193
|
+
// Enter on freeform = confirm the typed value
|
|
194
|
+
const val = this.freeformInput.getValue().trim();
|
|
195
|
+
if (val) {
|
|
196
|
+
this.saveFreeform();
|
|
197
|
+
this.confirmedFreeform.add(qKey);
|
|
198
|
+
if (!q.multiSelect) this.goNext();
|
|
199
|
+
}
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (this.selectedIdx === this.chatIdx) {
|
|
204
|
+
this.onDone?.({ chatMode: true });
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const opt = q.options[this.selectedIdx];
|
|
209
|
+
if (q.multiSelect) {
|
|
210
|
+
const current = this.selections.get(qKey) ?? [];
|
|
211
|
+
const idx = current.indexOf(opt.label);
|
|
212
|
+
if (idx >= 0) {
|
|
213
|
+
current.splice(idx, 1);
|
|
214
|
+
this.selections.set(qKey, [...current]);
|
|
215
|
+
} else {
|
|
216
|
+
this.selections.set(qKey, [...current, opt.label]);
|
|
217
|
+
if (opt.markdown) this.annotations.set(qKey, { markdown: opt.markdown });
|
|
218
|
+
}
|
|
219
|
+
} else {
|
|
220
|
+
this.selections.set(qKey, [opt.label]);
|
|
221
|
+
if (opt.markdown) this.annotations.set(qKey, { markdown: opt.markdown });
|
|
222
|
+
// Clear freeform if a real option was chosen
|
|
223
|
+
this.clearFreeform();
|
|
224
|
+
this.goNext();
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private doSubmit(): void {
|
|
229
|
+
const answers: Record<string, string> = {};
|
|
230
|
+
const annotations: Record<string, { markdown?: string }> = {};
|
|
231
|
+
for (const q of this.questions) {
|
|
232
|
+
const sel = this.selections.get(q.question);
|
|
233
|
+
// Only include freeform if explicitly confirmed with Enter
|
|
234
|
+
const freeform = this.confirmedFreeform.has(q.question)
|
|
235
|
+
? this.freeformValues.get(q.question)
|
|
236
|
+
: undefined;
|
|
237
|
+
const parts: string[] = [];
|
|
238
|
+
if (sel && sel.length > 0) parts.push(...sel);
|
|
239
|
+
if (freeform) parts.push(freeform);
|
|
240
|
+
answers[q.question] = parts.length > 0 ? parts.join(", ") : "(skipped)";
|
|
241
|
+
const ann = this.annotations.get(q.question);
|
|
242
|
+
if (ann) annotations[q.question] = ann;
|
|
243
|
+
}
|
|
244
|
+
this.onDone?.({ answers, annotations });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── Input handling ────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
handleInput(data: string): void {
|
|
250
|
+
if (this.currentQ === SUBMIT_VIEW) {
|
|
251
|
+
if (matchesKey(data, Key.up)) this.submitIdx = Math.max(0, this.submitIdx - 1);
|
|
252
|
+
else if (matchesKey(data, Key.down)) this.submitIdx = Math.min(1, this.submitIdx + 1);
|
|
253
|
+
else if (matchesKey(data, Key.enter)) this.selectCurrentOption();
|
|
254
|
+
else if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) this.goNext();
|
|
255
|
+
else if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) this.goPrev();
|
|
256
|
+
else if (matchesKey(data, Key.escape)) this.onDone?.(null);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Navigation keys always take priority, even on the freeform row
|
|
261
|
+
if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
|
|
262
|
+
this.goNext(); return;
|
|
263
|
+
}
|
|
264
|
+
if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
|
|
265
|
+
this.goPrev(); return;
|
|
266
|
+
}
|
|
267
|
+
if (matchesKey(data, Key.escape)) {
|
|
268
|
+
this.onDone?.(null); return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// On the freeform row — up/down navigate away; everything else goes to Input
|
|
272
|
+
if (this.isOnFreeform) {
|
|
273
|
+
if (matchesKey(data, Key.up)) {
|
|
274
|
+
this.setSelectedIdx(this.selectedIdx - 1);
|
|
275
|
+
} else if (matchesKey(data, Key.down)) {
|
|
276
|
+
this.setSelectedIdx(this.selectedIdx + 1);
|
|
277
|
+
} else if (matchesKey(data, Key.enter)) {
|
|
278
|
+
this.selectCurrentOption();
|
|
279
|
+
} else {
|
|
280
|
+
// Character input — goes straight to freeformInput
|
|
281
|
+
this.freeformInput.handleInput(data);
|
|
282
|
+
// Keep freeformValues in sync live
|
|
283
|
+
this.saveFreeform();
|
|
284
|
+
}
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Normal option navigation
|
|
289
|
+
if (matchesKey(data, Key.up)) {
|
|
290
|
+
if (this.selectedIdx > 0) this.setSelectedIdx(this.selectedIdx - 1);
|
|
291
|
+
} else if (matchesKey(data, Key.down)) {
|
|
292
|
+
if (this.selectedIdx < this.totalOptions - 1) this.setSelectedIdx(this.selectedIdx + 1);
|
|
293
|
+
} else if (matchesKey(data, Key.enter)) {
|
|
294
|
+
this.selectCurrentOption();
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
invalidate(): void {
|
|
299
|
+
this.freeformInput.invalidate?.();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ── Rendering ─────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
render(width: number): string[] {
|
|
305
|
+
const t = this.theme;
|
|
306
|
+
const lines: string[] = [];
|
|
307
|
+
|
|
308
|
+
// ── Tab bar ──────────────────────────────────────────────
|
|
309
|
+
const tabParts: string[] = [];
|
|
310
|
+
for (let i = 0; i < this.questions.length; i++) {
|
|
311
|
+
const q = this.questions[i];
|
|
312
|
+
const isActive = i === this.currentQ;
|
|
313
|
+
const isDone = this.isAnswered(q);
|
|
314
|
+
const icon = isDone ? "☒" : "□";
|
|
315
|
+
const label = `${icon} ${q.header}`;
|
|
316
|
+
tabParts.push(isActive
|
|
317
|
+
? t.bg("selectedBg", ` ${t.bold(label)} `)
|
|
318
|
+
: t.fg("muted", ` ${label} `));
|
|
319
|
+
}
|
|
320
|
+
const submitActive = this.currentQ === SUBMIT_VIEW;
|
|
321
|
+
tabParts.push(submitActive
|
|
322
|
+
? t.bg("selectedBg", t.bold(" ✓ Submit "))
|
|
323
|
+
: t.fg("dim", " ✓ Submit "));
|
|
324
|
+
|
|
325
|
+
lines.push(truncateToWidth(`← ${tabParts.join("")} →`, width));
|
|
326
|
+
lines.push("");
|
|
327
|
+
|
|
328
|
+
// ── Submit view ──────────────────────────────────────────
|
|
329
|
+
if (this.currentQ === SUBMIT_VIEW) {
|
|
330
|
+
lines.push(t.bold("Review your answers"));
|
|
331
|
+
lines.push("");
|
|
332
|
+
|
|
333
|
+
const unanswered = this.questions.filter((q) => !this.isAnswered(q));
|
|
334
|
+
if (unanswered.length > 0) {
|
|
335
|
+
lines.push(t.fg("warning", "⚠ You have not answered all questions"));
|
|
336
|
+
lines.push("");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
for (const q of this.questions) {
|
|
340
|
+
const sel = this.selections.get(q.question) ?? [];
|
|
341
|
+
const freeform = this.confirmedFreeform.has(q.question)
|
|
342
|
+
? this.freeformValues.get(q.question)
|
|
343
|
+
: undefined;
|
|
344
|
+
const parts = [...sel, ...(freeform ? [freeform] : [])];
|
|
345
|
+
if (parts.length > 0) {
|
|
346
|
+
lines.push(truncateToWidth(` ● ${q.question}`, width));
|
|
347
|
+
lines.push(truncateToWidth(` ${t.fg("accent", `→ ${parts.join(", ")}`)}`, width));
|
|
348
|
+
lines.push("");
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
lines.push(t.fg("muted", "Ready to submit your answers?"));
|
|
353
|
+
lines.push("");
|
|
354
|
+
lines.push(" " + (this.submitIdx === 0
|
|
355
|
+
? t.fg("accent", `> 1. ${t.bold("Submit answers")}`)
|
|
356
|
+
: ` 1. ${t.bold("Submit answers")}`));
|
|
357
|
+
lines.push(" " + (this.submitIdx === 1
|
|
358
|
+
? t.fg("accent", "> 2. Cancel")
|
|
359
|
+
: " 2. Cancel"));
|
|
360
|
+
lines.push("");
|
|
361
|
+
lines.push(t.fg("dim", "Enter to select · ←/→ or Tab to go back · Esc to cancel"));
|
|
362
|
+
return lines.map((l) => truncateToWidth(l, width));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ── Question view ────────────────────────────────────────
|
|
366
|
+
const q = this.currentQuestion!;
|
|
367
|
+
const freeformIdx = q.options.length;
|
|
368
|
+
const checkedLabels = this.selections.get(q.question) ?? [];
|
|
369
|
+
const freeformValue = this.freeformValues.get(q.question) ?? "";
|
|
370
|
+
|
|
371
|
+
lines.push(t.bold(q.question));
|
|
372
|
+
lines.push("");
|
|
373
|
+
|
|
374
|
+
for (let i = 0; i < q.options.length; i++) {
|
|
375
|
+
const opt = q.options[i];
|
|
376
|
+
const isCursor = i === this.selectedIdx;
|
|
377
|
+
const isChecked = checkedLabels.includes(opt.label);
|
|
378
|
+
const num = `${i + 1}.`;
|
|
379
|
+
const checkmark = isChecked ? ` ${t.fg("success", "✓")}` : "";
|
|
380
|
+
|
|
381
|
+
let labelLine: string;
|
|
382
|
+
if (isCursor && isChecked) {
|
|
383
|
+
labelLine = `${t.fg("accent", `> ${num} ${opt.label}`)}${checkmark}`;
|
|
384
|
+
} else if (isCursor) {
|
|
385
|
+
labelLine = t.fg("accent", `> ${num} ${opt.label}`);
|
|
386
|
+
} else if (isChecked) {
|
|
387
|
+
labelLine = ` ${t.fg("accent", `${num} ${opt.label}`)}${checkmark}`;
|
|
388
|
+
} else {
|
|
389
|
+
labelLine = ` ${num} ${opt.label}`;
|
|
390
|
+
}
|
|
391
|
+
lines.push(truncateToWidth(" " + labelLine, width));
|
|
392
|
+
if (opt.description) {
|
|
393
|
+
lines.push(truncateToWidth(` ${t.fg("muted", opt.description)}`, width));
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ── Freeform row ─────────────────────────────────────────
|
|
398
|
+
const isOnFreeform = this.selectedIdx === freeformIdx;
|
|
399
|
+
const otherNum = `${freeformIdx + 1}.`;
|
|
400
|
+
|
|
401
|
+
if (isOnFreeform) {
|
|
402
|
+
const inputVal = this.freeformInput.getValue();
|
|
403
|
+
if (inputVal === "") {
|
|
404
|
+
// Block cursor over the "T" — invert the first char of the placeholder
|
|
405
|
+
const placeholder = "Type something.";
|
|
406
|
+
const cursorChar = `\x1b[7m${placeholder[0]}\x1b[27m`;
|
|
407
|
+
const row = ` ${t.fg("accent", `> ${otherNum}`)} ${cursorChar}${t.fg("dim", placeholder.slice(1))}`;
|
|
408
|
+
lines.push(truncateToWidth(row, width));
|
|
409
|
+
} else {
|
|
410
|
+
// Block cursor after typed text — no checkmark until Enter confirms
|
|
411
|
+
const row = ` ${t.fg("accent", `> ${otherNum} ${inputVal}`)}${BLOCK_CURSOR}`;
|
|
412
|
+
lines.push(truncateToWidth(row, width));
|
|
413
|
+
}
|
|
414
|
+
} else if (freeformValue && this.confirmedFreeform.has(q.question)) {
|
|
415
|
+
// Confirmed — show with checkmark
|
|
416
|
+
lines.push(truncateToWidth(` ${t.fg("accent", `${otherNum} ${freeformValue}`)} ${t.fg("success", "✓")}`, width));
|
|
417
|
+
} else if (freeformValue) {
|
|
418
|
+
// Typed but not yet confirmed — show without checkmark
|
|
419
|
+
lines.push(truncateToWidth(` ${t.fg("dim", `${otherNum} ${freeformValue}`)}`, width));
|
|
420
|
+
} else {
|
|
421
|
+
// Not active, empty
|
|
422
|
+
lines.push(truncateToWidth(` ${otherNum} ${t.fg("dim", "Type something.")}`, width));
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ── Separator + Chat ─────────────────────────────────────
|
|
426
|
+
lines.push("");
|
|
427
|
+
lines.push(t.fg("dim", "─".repeat(Math.max(width - 2, 10))));
|
|
428
|
+
lines.push("");
|
|
429
|
+
|
|
430
|
+
const isChatCursor = this.selectedIdx === this.chatIdx;
|
|
431
|
+
lines.push(isChatCursor
|
|
432
|
+
? truncateToWidth(` ${t.fg("accent", `> ${this.chatIdx + 1}. Chat about this`)}`, width)
|
|
433
|
+
: truncateToWidth(` ${this.chatIdx + 1}. ${t.fg("muted", "Chat about this")}`, width));
|
|
434
|
+
|
|
435
|
+
lines.push("");
|
|
436
|
+
lines.push(truncateToWidth(
|
|
437
|
+
t.fg("dim", "Enter to select · Tab/↑↓ to navigate · Esc to cancel"),
|
|
438
|
+
width,
|
|
439
|
+
));
|
|
440
|
+
|
|
441
|
+
return lines.map((l) => truncateToWidth(l, width));
|
|
442
|
+
}
|
|
443
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure utility functions for plan mode.
|
|
3
|
+
* Extracted for testability.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Destructive commands blocked in plan mode
|
|
7
|
+
const DESTRUCTIVE_PATTERNS = [
|
|
8
|
+
/\brm\b/i,
|
|
9
|
+
/\brmdir\b/i,
|
|
10
|
+
/\bmv\b/i,
|
|
11
|
+
/\bcp\b/i,
|
|
12
|
+
/\bmkdir\b/i,
|
|
13
|
+
/\btouch\b/i,
|
|
14
|
+
/\bchmod\b/i,
|
|
15
|
+
/\bchown\b/i,
|
|
16
|
+
/\bchgrp\b/i,
|
|
17
|
+
/\bln\b/i,
|
|
18
|
+
/\btee\b/i,
|
|
19
|
+
/\btruncate\b/i,
|
|
20
|
+
/\bdd\b/i,
|
|
21
|
+
/\bshred\b/i,
|
|
22
|
+
/(^|[^<])>(?!>)/,
|
|
23
|
+
/>>/,
|
|
24
|
+
/\bnpm\s+(install|uninstall|update|ci|link|publish)/i,
|
|
25
|
+
/\byarn\s+(add|remove|install|publish)/i,
|
|
26
|
+
/\bpnpm\s+(add|remove|install|publish)/i,
|
|
27
|
+
/\bpip\s+(install|uninstall)/i,
|
|
28
|
+
/\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i,
|
|
29
|
+
/\bbrew\s+(install|uninstall|upgrade)/i,
|
|
30
|
+
/\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)/i,
|
|
31
|
+
/\bsudo\b/i,
|
|
32
|
+
/\bsu\b/i,
|
|
33
|
+
/\bkill\b/i,
|
|
34
|
+
/\bpkill\b/i,
|
|
35
|
+
/\bkillall\b/i,
|
|
36
|
+
/\breboot\b/i,
|
|
37
|
+
/\bshutdown\b/i,
|
|
38
|
+
/\bsystemctl\s+(start|stop|restart|enable|disable)/i,
|
|
39
|
+
/\bservice\s+\S+\s+(start|stop|restart)/i,
|
|
40
|
+
/\b(vim?|nano|emacs|code|subl)\b/i,
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
// Safe read-only commands allowed in plan mode
|
|
44
|
+
const SAFE_PATTERNS = [
|
|
45
|
+
/^\s*cd\b/,
|
|
46
|
+
/^\s*cat\b/,
|
|
47
|
+
/^\s*head\b/,
|
|
48
|
+
/^\s*tail\b/,
|
|
49
|
+
/^\s*less\b/,
|
|
50
|
+
/^\s*more\b/,
|
|
51
|
+
/^\s*grep\b/,
|
|
52
|
+
/^\s*find\b/,
|
|
53
|
+
/^\s*ls\b/,
|
|
54
|
+
/^\s*pwd\b/,
|
|
55
|
+
/^\s*echo\b/,
|
|
56
|
+
/^\s*printf\b/,
|
|
57
|
+
/^\s*wc\b/,
|
|
58
|
+
/^\s*sort\b/,
|
|
59
|
+
/^\s*uniq\b/,
|
|
60
|
+
/^\s*diff\b/,
|
|
61
|
+
/^\s*file\b/,
|
|
62
|
+
/^\s*stat\b/,
|
|
63
|
+
/^\s*du\b/,
|
|
64
|
+
/^\s*df\b/,
|
|
65
|
+
/^\s*tree\b/,
|
|
66
|
+
/^\s*which\b/,
|
|
67
|
+
/^\s*whereis\b/,
|
|
68
|
+
/^\s*type\b/,
|
|
69
|
+
/^\s*env\b/,
|
|
70
|
+
/^\s*printenv\b/,
|
|
71
|
+
/^\s*uname\b/,
|
|
72
|
+
/^\s*whoami\b/,
|
|
73
|
+
/^\s*id\b/,
|
|
74
|
+
/^\s*date\b/,
|
|
75
|
+
/^\s*cal\b/,
|
|
76
|
+
/^\s*uptime\b/,
|
|
77
|
+
/^\s*ps\b/,
|
|
78
|
+
/^\s*top\b/,
|
|
79
|
+
/^\s*htop\b/,
|
|
80
|
+
/^\s*free\b/,
|
|
81
|
+
/^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)/i,
|
|
82
|
+
/^\s*git\s+ls-/i,
|
|
83
|
+
/^\s*npm\s+(list|ls|view|info|search|outdated|audit)/i,
|
|
84
|
+
/^\s*yarn\s+(list|info|why|audit)/i,
|
|
85
|
+
/^\s*node\s+--version/i,
|
|
86
|
+
/^\s*python\s+--version/i,
|
|
87
|
+
/^\s*curl\s/i,
|
|
88
|
+
/^\s*wget\s+-O\s*-/i,
|
|
89
|
+
/^\s*jq\b/,
|
|
90
|
+
/^\s*sed\s+-n/i,
|
|
91
|
+
/^\s*awk\b/,
|
|
92
|
+
/^\s*rg\b/,
|
|
93
|
+
/^\s*fd\b/,
|
|
94
|
+
/^\s*bat\b/,
|
|
95
|
+
/^\s*exa\b/,
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Split command into parts respecting quoted strings.
|
|
100
|
+
* Handles: &&, ;, | (but not inside quotes)
|
|
101
|
+
*/
|
|
102
|
+
function splitCommandRespectingQuotes(command: string): string[] {
|
|
103
|
+
const parts: string[] = [];
|
|
104
|
+
let current = "";
|
|
105
|
+
let inSingleQuote = false;
|
|
106
|
+
let inDoubleQuote = false;
|
|
107
|
+
let i = 0;
|
|
108
|
+
|
|
109
|
+
while (i < command.length) {
|
|
110
|
+
const char = command[i];
|
|
111
|
+
const nextChar = command[i + 1];
|
|
112
|
+
|
|
113
|
+
if (char === "'" && !inDoubleQuote) {
|
|
114
|
+
inSingleQuote = !inSingleQuote;
|
|
115
|
+
current += char;
|
|
116
|
+
} else if (char === '"' && !inSingleQuote) {
|
|
117
|
+
inDoubleQuote = !inDoubleQuote;
|
|
118
|
+
current += char;
|
|
119
|
+
} else if (!inSingleQuote && !inDoubleQuote) {
|
|
120
|
+
// Check for separators outside quotes
|
|
121
|
+
if (char === "&" && nextChar === "&") {
|
|
122
|
+
parts.push(current.trim());
|
|
123
|
+
current = "";
|
|
124
|
+
i += 2; // Skip both &
|
|
125
|
+
continue;
|
|
126
|
+
} else if (char === ";") {
|
|
127
|
+
parts.push(current.trim());
|
|
128
|
+
current = "";
|
|
129
|
+
} else if (char === "|") {
|
|
130
|
+
parts.push(current.trim());
|
|
131
|
+
current = "";
|
|
132
|
+
} else {
|
|
133
|
+
current += char;
|
|
134
|
+
}
|
|
135
|
+
} else {
|
|
136
|
+
current += char;
|
|
137
|
+
}
|
|
138
|
+
i++;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (current.trim()) {
|
|
142
|
+
parts.push(current.trim());
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return parts;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function isSafeCommand(command: string): boolean {
|
|
149
|
+
// Check for destructive patterns anywhere in the command
|
|
150
|
+
const isDestructive = DESTRUCTIVE_PATTERNS.some((p) => p.test(command));
|
|
151
|
+
if (isDestructive) return false;
|
|
152
|
+
|
|
153
|
+
// Split compound commands and check each part
|
|
154
|
+
const parts = splitCommandRespectingQuotes(command);
|
|
155
|
+
|
|
156
|
+
for (const part of parts) {
|
|
157
|
+
const trimmed = part.trim();
|
|
158
|
+
if (!trimmed) continue;
|
|
159
|
+
|
|
160
|
+
// Check if this part starts with a safe command
|
|
161
|
+
const isPartSafe = SAFE_PATTERNS.some((p) => p.test(trimmed));
|
|
162
|
+
if (!isPartSafe) return false;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return true;
|
|
166
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ssweens/pi-huddle",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Huddle mode for pi - safe exploration and structured elicitation before execution",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package",
|
|
8
|
+
"huddle-mode",
|
|
9
|
+
"plan-mode",
|
|
10
|
+
"safe-mode",
|
|
11
|
+
"read-only",
|
|
12
|
+
"ask-user"
|
|
13
|
+
],
|
|
14
|
+
"author": "ssweens",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"files": [
|
|
17
|
+
"extensions/",
|
|
18
|
+
"skills/",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE",
|
|
21
|
+
"screenshot.png",
|
|
22
|
+
"ask-user-screenshot.png"
|
|
23
|
+
],
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"@mariozechner/pi-ai": "*",
|
|
26
|
+
"@mariozechner/pi-agent-core": "*",
|
|
27
|
+
"@mariozechner/pi-coding-agent": "*"
|
|
28
|
+
},
|
|
29
|
+
"pi": {
|
|
30
|
+
"extensions": [
|
|
31
|
+
"./extensions"
|
|
32
|
+
],
|
|
33
|
+
"skills": [
|
|
34
|
+
"./skills"
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
}
|
package/screenshot.png
ADDED
|
Binary file
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: huddle
|
|
3
|
+
description: Use this skill when working in pi's Huddle Mode. Huddle Mode is a safe exploration mode where read operations are always allowed, write operations require user permission, and the ask_user tool enables structured multi-question elicitation with a rich TUI dialog.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Huddle Mode Skill
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
Huddle Mode is a safety feature that allows free read-only exploration while requiring user approval for any file modifications. It also provides the `ask_user` tool for structured elicitation — gathering requirements, clarifying ambiguity, and getting decisions from the user via a rich multi-question TUI dialog.
|
|
11
|
+
|
|
12
|
+
## When to Use
|
|
13
|
+
|
|
14
|
+
- **Initial code exploration** - Understanding a new codebase safely
|
|
15
|
+
- **Complex refactoring** - Planning multi-step changes before executing
|
|
16
|
+
- **Requirements gathering** - Using `ask_user` to clarify intent before acting
|
|
17
|
+
- **Safety-critical changes** - When you want explicit approval for each modification
|
|
18
|
+
|
|
19
|
+
## Commands
|
|
20
|
+
|
|
21
|
+
| Command | Description |
|
|
22
|
+
|---------|-------------|
|
|
23
|
+
| `/huddle` | Toggle huddle mode on/off (primary) |
|
|
24
|
+
| `/holup` | Toggle huddle mode on/off (alias) |
|
|
25
|
+
| `/plan` | Toggle huddle mode on/off (alias) |
|
|
26
|
+
| `--huddle` | CLI flag to start pi in huddle mode |
|
|
27
|
+
| `--plan` | CLI flag alias (backward compat) |
|
|
28
|
+
| `Alt+H` (Option+H on Mac) | Keyboard shortcut to toggle |
|
|
29
|
+
|
|
30
|
+
## Workflow
|
|
31
|
+
|
|
32
|
+
### 1. Enter Huddle Mode
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
/huddle # or /holup, /plan, Alt+H
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### 2. Permission Gates
|
|
39
|
+
|
|
40
|
+
**Always Allowed:**
|
|
41
|
+
- `read`, `grep`, `find`, `ls` - Read and search operations
|
|
42
|
+
- `bash` - Allowlisted safe commands (cat, grep, ls, git status, etc.)
|
|
43
|
+
- `ask_user` - Structured user elicitation
|
|
44
|
+
|
|
45
|
+
**Requires Permission:**
|
|
46
|
+
- `edit` - File modifications (user must approve each edit)
|
|
47
|
+
- `write` - File creation (user must approve each write)
|
|
48
|
+
- Non-allowlisted bash commands (npm install, git commit, etc.)
|
|
49
|
+
|
|
50
|
+
### 3. Exit Huddle Mode
|
|
51
|
+
|
|
52
|
+
When ready to execute changes:
|
|
53
|
+
- Toggle off with `/huddle`, `/holup`, `/plan`, or `Alt+H`
|
|
54
|
+
- Full tool access restored
|
|
55
|
+
|
|
56
|
+
## ask_user Tool
|
|
57
|
+
|
|
58
|
+
The `ask_user` tool is available in **both huddle mode and normal mode**. It presents a rich TUI dialog with tabs for each question, multiple-choice options, freeform text input, and a submit/review view.
|
|
59
|
+
|
|
60
|
+
### When to Use
|
|
61
|
+
|
|
62
|
+
- Gather user preferences or requirements before acting
|
|
63
|
+
- Clarify ambiguous instructions
|
|
64
|
+
- Get decisions on implementation choices
|
|
65
|
+
- Offer architectural choices with descriptions and code previews
|
|
66
|
+
|
|
67
|
+
**Huddle mode:** Use `ask_user` to clarify requirements BEFORE finalizing your plan. Do NOT ask "Is my plan ready?" — the user cannot see the plan until they exit huddle mode.
|
|
68
|
+
|
|
69
|
+
### Tool Parameters
|
|
70
|
+
|
|
71
|
+
| Parameter | Type | Description |
|
|
72
|
+
|-----------|------|-------------|
|
|
73
|
+
| `questions` | array | 1–4 questions to ask |
|
|
74
|
+
| `questions[].question` | string | Full question text (should end with ?) |
|
|
75
|
+
| `questions[].header` | string | Short tab label (max 12 chars). E.g. "Auth method", "Library" |
|
|
76
|
+
| `questions[].options` | array | 2–4 options per question |
|
|
77
|
+
| `questions[].options[].label` | string | Display text (1–5 words) |
|
|
78
|
+
| `questions[].options[].description` | string | Trade-off explanation shown below label |
|
|
79
|
+
| `questions[].options[].markdown` | string | Optional code/ASCII preview shown when focused |
|
|
80
|
+
| `questions[].multiSelect` | boolean | Allow multiple selections (default: false) |
|
|
81
|
+
| `metadata` | object | Optional `{ source }` for tracking |
|
|
82
|
+
|
|
83
|
+
### UX Behaviour
|
|
84
|
+
|
|
85
|
+
- **Tab bar** at top — one tab per question + Submit tab; `←`/`→` or `Tab`/`Shift+Tab` navigate
|
|
86
|
+
- **Numbered options** — `↑`/`↓` to move, `Enter` to select
|
|
87
|
+
- **Freeform field** — navigate to it and start typing immediately; `Enter` confirms the typed answer
|
|
88
|
+
- **Chat about this** — last row on each question; returns a "discuss" signal to the agent
|
|
89
|
+
- **Submit view** — recap of all answers with `● Question → Answer` format
|
|
90
|
+
- **Esc** to cancel at any time
|
|
91
|
+
|
|
92
|
+
### Example
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{
|
|
96
|
+
"questions": [
|
|
97
|
+
{
|
|
98
|
+
"question": "Which approach should I use for error handling?",
|
|
99
|
+
"header": "Errors",
|
|
100
|
+
"options": [
|
|
101
|
+
{
|
|
102
|
+
"label": "Return early (Recommended)",
|
|
103
|
+
"description": "Exit on first error, simplest code path",
|
|
104
|
+
"markdown": "if (err) return { error: err };"
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
"label": "Collect errors",
|
|
108
|
+
"description": "Gather all errors, report at end"
|
|
109
|
+
}
|
|
110
|
+
],
|
|
111
|
+
"multiSelect": false
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
"question": "Which features do you want to enable?",
|
|
115
|
+
"header": "Features",
|
|
116
|
+
"options": [
|
|
117
|
+
{ "label": "Logging", "description": "Structured JSON logs" },
|
|
118
|
+
{ "label": "Metrics", "description": "Prometheus endpoint" },
|
|
119
|
+
{ "label": "Tracing", "description": "OpenTelemetry spans" },
|
|
120
|
+
{ "label": "Alerts", "description": "PagerDuty integration" }
|
|
121
|
+
],
|
|
122
|
+
"multiSelect": true
|
|
123
|
+
}
|
|
124
|
+
]
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Return Value
|
|
129
|
+
|
|
130
|
+
```json
|
|
131
|
+
{
|
|
132
|
+
"answers": {
|
|
133
|
+
"Which approach should I use for error handling?": "Return early (Recommended)",
|
|
134
|
+
"Which features do you want to enable?": "Logging, Tracing"
|
|
135
|
+
},
|
|
136
|
+
"annotations": {},
|
|
137
|
+
"metadata": {}
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Allowed Bash Commands (No Prompt)
|
|
142
|
+
|
|
143
|
+
- File inspection: `cat`, `head`, `tail`, `less`, `more`
|
|
144
|
+
- Search: `grep`, `find`, `rg`, `fd`
|
|
145
|
+
- Directory: `ls`, `pwd`, `tree`
|
|
146
|
+
- Git read: `git status`, `git log`, `git diff`, `git branch`
|
|
147
|
+
- Package info: `npm list`, `npm outdated`, `yarn info`
|
|
148
|
+
- Utilities: `curl`, `jq`, `uname`, `whoami`, `date`
|
|
149
|
+
|
|
150
|
+
## Blocked Bash Commands (Prompt Required)
|
|
151
|
+
|
|
152
|
+
- File mutation: `rm`, `mv`, `cp`, `mkdir`, `touch`
|
|
153
|
+
- Git writes: `git add`, `git commit`, `git push`
|
|
154
|
+
- Package installs: `npm install`, `yarn add`, `pip install`
|
|
155
|
+
- System: `sudo`, `kill`, `reboot`
|
|
156
|
+
- Redirections: `>`, `>>`
|
|
157
|
+
|
|
158
|
+
## Tips
|
|
159
|
+
|
|
160
|
+
1. **Use `ask_user` early** — clarify intent before exploring, not after
|
|
161
|
+
2. **Up to 4 questions per call** — batch related questions together
|
|
162
|
+
3. **Use `markdown` field** for code previews in option descriptions
|
|
163
|
+
4. **multiSelect** for feature flags, configuration choices, etc.
|
|
164
|
+
5. **Exit huddle when ready** — the user controls when to execute
|