@pi-unipi/ask-user 0.1.1

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/config.ts ADDED
@@ -0,0 +1,133 @@
1
+ /**
2
+ * @pi-unipi/ask-user — Config system
3
+ *
4
+ * Reads/writes ask-user settings in ~/.pi/agent/settings.json
5
+ * under the "unipi.askUser" key.
6
+ */
7
+
8
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { homedir } from "node:os";
11
+
12
+ /** Ask-user settings */
13
+ export interface AskUserSettings {
14
+ /** Whether the ask_user tool is enabled */
15
+ enabled: boolean;
16
+ /** Allowed question formats */
17
+ allowedFormats: {
18
+ /** Allow single-select questions */
19
+ singleSelect: boolean;
20
+ /** Allow multi-select questions */
21
+ multiSelect: boolean;
22
+ /** Allow freeform text input */
23
+ freeform: boolean;
24
+ };
25
+ }
26
+
27
+ /** Default settings */
28
+ export const DEFAULT_SETTINGS: AskUserSettings = {
29
+ enabled: true,
30
+ allowedFormats: {
31
+ singleSelect: true,
32
+ multiSelect: true,
33
+ freeform: true,
34
+ },
35
+ };
36
+
37
+ /** Settings path */
38
+ const SETTINGS_PATH = join(homedir(), ".pi", "agent", "settings.json");
39
+
40
+ /** Settings key within settings.json */
41
+ const SETTINGS_KEY = "unipi";
42
+
43
+ /** Cached settings */
44
+ let cachedSettings: AskUserSettings | null = null;
45
+
46
+ /**
47
+ * Check if value is a plain object.
48
+ */
49
+ function isRecord(value: unknown): value is Record<string, unknown> {
50
+ return typeof value === "object" && value !== null && !Array.isArray(value);
51
+ }
52
+
53
+ /**
54
+ * Read the full settings file.
55
+ */
56
+ function readSettingsFile(): Record<string, unknown> {
57
+ if (!existsSync(SETTINGS_PATH)) return {};
58
+ try {
59
+ const parsed = JSON.parse(readFileSync(SETTINGS_PATH, "utf-8"));
60
+ return isRecord(parsed) ? parsed : {};
61
+ } catch {
62
+ return {};
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Write the full settings file.
68
+ */
69
+ function writeSettingsFile(data: Record<string, unknown>): void {
70
+ const dir = require("node:path").dirname(SETTINGS_PATH);
71
+ if (!existsSync(dir)) {
72
+ require("node:fs").mkdirSync(dir, { recursive: true });
73
+ }
74
+ writeFileSync(SETTINGS_PATH, JSON.stringify(data, null, 2) + "\n", "utf-8");
75
+ }
76
+
77
+ /**
78
+ * Get ask-user settings from settings.json.
79
+ */
80
+ export function getAskUserSettings(): AskUserSettings {
81
+ if (cachedSettings) return cachedSettings;
82
+
83
+ const settings = readSettingsFile();
84
+ const unipi = settings[SETTINGS_KEY];
85
+
86
+ if (!isRecord(unipi) || !isRecord(unipi.askUser)) {
87
+ cachedSettings = { ...DEFAULT_SETTINGS };
88
+ return cachedSettings;
89
+ }
90
+
91
+ const askUser = unipi.askUser as Record<string, unknown>;
92
+
93
+ const enabled = typeof askUser.enabled === "boolean" ? askUser.enabled : DEFAULT_SETTINGS.enabled;
94
+
95
+ let allowedFormats = DEFAULT_SETTINGS.allowedFormats;
96
+ if (isRecord(askUser.allowedFormats)) {
97
+ const fmt = askUser.allowedFormats;
98
+ allowedFormats = {
99
+ singleSelect: typeof fmt.singleSelect === "boolean" ? fmt.singleSelect : DEFAULT_SETTINGS.allowedFormats.singleSelect,
100
+ multiSelect: typeof fmt.multiSelect === "boolean" ? fmt.multiSelect : DEFAULT_SETTINGS.allowedFormats.multiSelect,
101
+ freeform: typeof fmt.freeform === "boolean" ? fmt.freeform : DEFAULT_SETTINGS.allowedFormats.freeform,
102
+ };
103
+ }
104
+
105
+ cachedSettings = { enabled, allowedFormats };
106
+ return cachedSettings;
107
+ }
108
+
109
+ /**
110
+ * Save ask-user settings to settings.json.
111
+ */
112
+ export function saveAskUserSettings(settings: AskUserSettings): void {
113
+ const file = readSettingsFile();
114
+
115
+ if (!isRecord(file[SETTINGS_KEY])) {
116
+ file[SETTINGS_KEY] = {};
117
+ }
118
+
119
+ (file[SETTINGS_KEY] as Record<string, unknown>).askUser = {
120
+ enabled: settings.enabled,
121
+ allowedFormats: settings.allowedFormats,
122
+ };
123
+
124
+ writeSettingsFile(file);
125
+ cachedSettings = settings;
126
+ }
127
+
128
+ /**
129
+ * Clear cached settings (for testing or reload).
130
+ */
131
+ export function clearSettingsCache(): void {
132
+ cachedSettings = null;
133
+ }
package/index.ts ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * @pi-unipi/ask-user — Extension entry
3
+ *
4
+ * Provides ask_user tool for structured user input with single-select,
5
+ * multi-select, and freeform modes. Includes bundled skill for agent guidance.
6
+ */
7
+
8
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
9
+ import {
10
+ UNIPI_EVENTS,
11
+ MODULES,
12
+ ASK_USER_TOOLS,
13
+ emitEvent,
14
+ getPackageVersion,
15
+ } from "@pi-unipi/core";
16
+ import { registerAskUserTools } from "./tools.js";
17
+ import { registerAskUserCommands } from "./commands.js";
18
+
19
+ /** Package version */
20
+ const VERSION = getPackageVersion(new URL(".", import.meta.url).pathname);
21
+
22
+ export default function (pi: ExtensionAPI) {
23
+ // Register skills directory
24
+ const skillsDir = new URL("./skills", import.meta.url).pathname;
25
+ pi.on("resources_discover", async () => {
26
+ return {
27
+ skillPaths: [skillsDir],
28
+ };
29
+ });
30
+
31
+ // Register tools and commands
32
+ registerAskUserTools(pi);
33
+ registerAskUserCommands(pi);
34
+
35
+ // Session lifecycle — announce module
36
+ pi.on("session_start", async () => {
37
+ emitEvent(pi, UNIPI_EVENTS.MODULE_READY, {
38
+ name: MODULES.ASK_USER,
39
+ version: VERSION,
40
+ commands: ["unipi:ask-user-settings"],
41
+ tools: [ASK_USER_TOOLS.ASK],
42
+ });
43
+ });
44
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@pi-unipi/ask-user",
3
+ "version": "0.1.1",
4
+ "description": "Structured user input tool for Pi coding agent — single-select, multi-select, freeform",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Neuron Mr White",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/Neuron-Mr-White/unipi.git",
11
+ "directory": "packages/ask-user"
12
+ },
13
+ "keywords": [
14
+ "pi-package",
15
+ "pi-extension",
16
+ "pi-coding-agent",
17
+ "unipi",
18
+ "ask-user",
19
+ "interactive",
20
+ "user-input"
21
+ ],
22
+ "files": [
23
+ "index.ts",
24
+ "types.ts",
25
+ "tools.ts",
26
+ "ask-ui.ts",
27
+ "commands.ts",
28
+ "config.ts",
29
+ "settings-tui.ts",
30
+ "skills/**/*",
31
+ "README.md"
32
+ ],
33
+ "pi": {
34
+ "extensions": [
35
+ "index.ts"
36
+ ],
37
+ "skills": [
38
+ "skills"
39
+ ]
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "dependencies": {
45
+ "@pi-unipi/core": "*"
46
+ },
47
+ "peerDependencies": {
48
+ "@mariozechner/pi-coding-agent": "*",
49
+ "@mariozechner/pi-tui": "*",
50
+ "@sinclair/typebox": "*"
51
+ }
52
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * @pi-unipi/ask-user — Settings TUI Component
3
+ *
4
+ * Interactive settings editor for ask_user tool.
5
+ * Allows enabling/disabling the tool and configuring allowed question formats.
6
+ */
7
+
8
+ import type { Component } from "@mariozechner/pi-tui";
9
+ import { truncateToWidth } from "@mariozechner/pi-tui";
10
+ import { getAskUserSettings, saveAskUserSettings, type AskUserSettings } from "./config.js";
11
+
12
+ /** ANSI escape codes */
13
+ const ansi = {
14
+ reset: "\x1b[0m",
15
+ bold: "\x1b[1m",
16
+ dim: "\x1b[2m",
17
+ // Colors
18
+ cyan: "\x1b[36m",
19
+ green: "\x1b[32m",
20
+ yellow: "\x1b[33m",
21
+ red: "\x1b[31m",
22
+ gray: "\x1b[90m",
23
+ };
24
+
25
+ /** Toggle symbols */
26
+ const TOGGLE_ON = `${ansi.green}●${ansi.reset}`;
27
+ const TOGGLE_OFF = `${ansi.dim}○${ansi.reset}`;
28
+
29
+ /** Setting items */
30
+ interface SettingItem {
31
+ key: string;
32
+ label: string;
33
+ description: string;
34
+ getValue: (settings: AskUserSettings) => boolean;
35
+ setValue: (settings: AskUserSettings, value: boolean) => void;
36
+ }
37
+
38
+ /** List of configurable settings */
39
+ const SETTINGS: SettingItem[] = [
40
+ {
41
+ key: "enabled",
42
+ label: "Enable ask_user tool",
43
+ description: "Allow the agent to ask structured questions",
44
+ getValue: (s) => s.enabled,
45
+ setValue: (s, v) => (s.enabled = v),
46
+ },
47
+ {
48
+ key: "singleSelect",
49
+ label: "Allow single-select",
50
+ description: "Questions with one correct answer",
51
+ getValue: (s) => s.allowedFormats.singleSelect,
52
+ setValue: (s, v) => (s.allowedFormats.singleSelect = v),
53
+ },
54
+ {
55
+ key: "multiSelect",
56
+ label: "Allow multi-select",
57
+ description: "Questions with multiple correct answers",
58
+ getValue: (s) => s.allowedFormats.multiSelect,
59
+ setValue: (s, v) => (s.allowedFormats.multiSelect = v),
60
+ },
61
+ {
62
+ key: "freeform",
63
+ label: "Allow freeform text",
64
+ description: "Open-ended questions with text input",
65
+ getValue: (s) => s.allowedFormats.freeform,
66
+ setValue: (s, v) => (s.allowedFormats.freeform = v),
67
+ },
68
+ ];
69
+
70
+ /**
71
+ * Settings overlay component.
72
+ */
73
+ export class AskUserSettingsOverlay implements Component {
74
+ private settings: AskUserSettings;
75
+ private selectedIndex = 0;
76
+ /** Callback when overlay should close */
77
+ onClose?: () => void;
78
+
79
+ constructor() {
80
+ this.settings = getAskUserSettings();
81
+ }
82
+
83
+ /**
84
+ * Invalidate cached render state.
85
+ */
86
+ invalidate(): void {
87
+ // No cached state
88
+ }
89
+
90
+ /**
91
+ * Handle keyboard input.
92
+ */
93
+ handleInput(data: string): void {
94
+ switch (data) {
95
+ case "\x1b[A": // Up
96
+ case "k":
97
+ this.selectedIndex = (this.selectedIndex - 1 + SETTINGS.length) % SETTINGS.length;
98
+ break;
99
+ case "\x1b[B": // Down
100
+ case "j":
101
+ this.selectedIndex = (this.selectedIndex + 1) % SETTINGS.length;
102
+ break;
103
+ case " ": // Space - toggle
104
+ this.toggleSetting(SETTINGS[this.selectedIndex].key);
105
+ break;
106
+ case "\r": // Enter - save and close
107
+ this.save();
108
+ this.onClose?.();
109
+ break;
110
+ case "\x1b": // Escape - close without saving
111
+ this.onClose?.();
112
+ break;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Toggle a setting by key.
118
+ */
119
+ private toggleSetting(key: string): void {
120
+ const item = SETTINGS.find((s) => s.key === key);
121
+ if (!item) return;
122
+ const current = item.getValue(this.settings);
123
+ item.setValue(this.settings, !current);
124
+ }
125
+
126
+ /**
127
+ * Save settings to disk.
128
+ */
129
+ private save(): void {
130
+ saveAskUserSettings(this.settings);
131
+ }
132
+
133
+ /**
134
+ * Render the overlay.
135
+ */
136
+ render(width: number): string[] {
137
+ const lines: string[] = [];
138
+ const add = (s: string) => lines.push(truncateToWidth(s, width));
139
+
140
+ // Header
141
+ add(`${ansi.bold}${ansi.cyan}Ask User Settings${ansi.reset}`);
142
+ add(`${ansi.dim}Configure how the agent can ask you questions${ansi.reset}`);
143
+ add("");
144
+
145
+ // Settings list
146
+ for (let i = 0; i < SETTINGS.length; i++) {
147
+ const item = SETTINGS[i];
148
+ const isSelected = i === this.selectedIndex;
149
+ const value = item.getValue(this.settings);
150
+ const toggle = value ? TOGGLE_ON : TOGGLE_OFF;
151
+ const labelColor = isSelected ? ansi.bold : ansi.dim;
152
+ const descColor = ansi.gray;
153
+
154
+ add(`${isSelected ? ansi.cyan + "▸" + ansi.reset : " "} ${toggle} ${labelColor}${item.label}${ansi.reset}`);
155
+ add(` ${descColor}${item.description}${ansi.reset}`);
156
+ }
157
+
158
+ // Footer
159
+ add("");
160
+ add(`${ansi.dim}↑↓ navigate • Space toggle • Enter save • Esc cancel${ansi.reset}`);
161
+
162
+ return lines;
163
+ }
164
+ }
@@ -0,0 +1,100 @@
1
+ ---
2
+ name: ask-user
3
+ description: >
4
+ Interactive decision-gating tool for structured user input.
5
+ Use ask_user when you need user confirmation, preferences, or decisions
6
+ before proceeding with high-impact or ambiguous choices.
7
+ allowed-tools:
8
+ - ask_user
9
+ ---
10
+
11
+ # Ask User
12
+
13
+ Use the `ask_user` tool to collect structured input from the user.
14
+
15
+ ## When to use ask_user
16
+
17
+ - Architectural trade-offs with high impact
18
+ - Requirements are ambiguous or conflicting
19
+ - Assumptions would materially change implementation
20
+ - User preferences needed (style, approach, priority)
21
+ - Confirming before destructive operations
22
+
23
+ ## Decision Handshake Flow
24
+
25
+ 1. Gather evidence and summarize context
26
+ 2. Ask ONE focused question via `ask_user`
27
+ 3. Wait for explicit user choice
28
+ 4. Confirm the decision, then proceed
29
+
30
+ ## Parameters
31
+
32
+ | Parameter | Type | Default | Description |
33
+ |-----------|------|---------|-------------|
34
+ | `question` | string | required | The question to ask |
35
+ | `context` | string? | — | Additional context shown before question |
36
+ | `options` | array? | [] | Multiple-choice options with labels, descriptions, values |
37
+ | `allowMultiple` | boolean? | false | Enable multi-select |
38
+ | `allowFreeform` | boolean? | true | Add "Custom response" checkable option |
39
+ | `timeout` | number? | — | Auto-dismiss after N ms |
40
+
41
+ ## Examples
42
+
43
+ Single choice:
44
+ ```
45
+ ask_user({
46
+ question: "Which database should we use?",
47
+ options: [
48
+ { label: "PostgreSQL", description: "Reliable, feature-rich" },
49
+ { label: "SQLite", description: "Simple, serverless" }
50
+ ]
51
+ })
52
+ ```
53
+
54
+ Multi-select:
55
+ ```
56
+ ask_user({
57
+ question: "Which features to implement?",
58
+ options: [
59
+ { label: "Auth", value: "auth" },
60
+ { label: "Cache", value: "cache" },
61
+ { label: "Logging", value: "logging" }
62
+ ],
63
+ allowMultiple: true
64
+ })
65
+ ```
66
+
67
+ With context:
68
+ ```
69
+ ask_user({
70
+ question: "Which approach?",
71
+ context: "Current bottleneck: network I/O. Goal: reduce latency.",
72
+ options: [
73
+ { label: "Cache-first" },
74
+ { label: "DB-first" }
75
+ ]
76
+ })
77
+ ```
78
+
79
+ Freeform only:
80
+ ```
81
+ ask_user({
82
+ question: "What should we name this module?",
83
+ options: [],
84
+ allowFreeform: true
85
+ })
86
+ ```
87
+
88
+ Combined (multi-select + freeform):
89
+ ```
90
+ ask_user({
91
+ question: "Which features and what custom feature?",
92
+ options: [
93
+ { label: "Auth", value: "auth" },
94
+ { label: "Cache", value: "cache" }
95
+ ],
96
+ allowMultiple: true,
97
+ allowFreeform: true
98
+ })
99
+ ```
100
+ User can check "Auth", "Cache", and "Custom response" to type additional features.