@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 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
+ ![Huddle mode — permission-gated exploration](screenshot.png)
4
+
5
+ ![ask_user — structured multi-question elicitation](ask-user-screenshot.png)
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