pi-ask-popup 0.1.0 → 0.2.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.
Files changed (37) hide show
  1. package/README.md +95 -32
  2. package/docs/configuration.md +93 -0
  3. package/docs/hosts.md +71 -0
  4. package/docs/keyboard.md +65 -0
  5. package/docs/tool-schema.md +124 -0
  6. package/package.json +12 -2
  7. package/preview/popup-submit.webp +0 -0
  8. package/preview/popup-with-notes.webp +0 -0
  9. package/preview/popup-with-tab.webp +0 -0
  10. package/src/ask-user-question.ts +54 -21
  11. package/src/config.ts +118 -24
  12. package/src/rpc-fallback.ts +85 -28
  13. package/src/state/build-questionnaire.ts +45 -8
  14. package/src/state/external-editor.ts +24 -12
  15. package/src/state/key-router.ts +152 -41
  16. package/src/state/questionnaire-session.ts +79 -14
  17. package/src/state/row-intent.ts +6 -2
  18. package/src/state/selectors/derivations.ts +18 -6
  19. package/src/state/selectors/focus.ts +6 -2
  20. package/src/state/selectors/projections.ts +9 -1
  21. package/src/state/state-reducer.ts +110 -38
  22. package/src/tool/response-envelope.ts +71 -22
  23. package/src/tool/types.ts +28 -4
  24. package/src/view/component-binding.ts +3 -1
  25. package/src/view/components/inline-input.ts +3 -1
  26. package/src/view/components/multi-select-view.ts +32 -7
  27. package/src/view/components/option-list-view.ts +5 -0
  28. package/src/view/components/preview/markdown-content-cache.ts +75 -23
  29. package/src/view/components/preview/preview-block-renderer.ts +8 -1
  30. package/src/view/components/preview/preview-box-renderer.ts +4 -5
  31. package/src/view/components/preview/preview-layout-decider.ts +52 -15
  32. package/src/view/components/preview/preview-pane.ts +72 -28
  33. package/src/view/components/tab-bar.ts +26 -8
  34. package/src/view/components/wrapping-select.ts +110 -37
  35. package/src/view/dialog-builder.ts +44 -10
  36. package/src/view/props-adapter.ts +29 -7
  37. package/src/view/tab-content-strategy.ts +69 -21
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # pi-ask-popup
2
2
 
3
- Let the model ask you instead of guessing. This Pi extension registers one tool,
4
- `ask_user_question`, that opens a terminal dialog of up to four questions with
5
- written-out options, and hands your choices back as structured data.
3
+ Let the model ask you instead of guessing. This Pi extension registers one tool, `ask_user_question`, that opens a terminal dialog of up to four questions with written-out options, and hands your choices back as structured data.
4
+
5
+ ![Single question with a preview, options on the left and a bordered markdown box on the right](./preview/popup-with-tab.webp)
6
6
 
7
7
  ## Install
8
8
 
@@ -10,45 +10,108 @@ written-out options, and hands your choices back as structured data.
10
10
  pi install npm:pi-ask-popup
11
11
  ```
12
12
 
13
+ Restart your Pi session.
14
+
15
+ ```sh
16
+ pi --version # needs 0.80 or newer
17
+ node --version # needs 22 or newer
18
+ ```
19
+
20
+ No runtime dependencies, no build step, no API keys. The extension makes no model calls of its own.
21
+
22
+ ## Quick start
23
+
24
+ Give the model a task with a real decision in it:
25
+
26
+ > Add caching to the API client.
27
+
28
+ Rather than picking for you, the model calls `ask_user_question` and a dialog takes over the bottom of your terminal. Move with `Up` and `Down`, pick with `Enter`, or land on `Type something.` to answer in your own words. While typing, `Shift+Enter` adds a line, `Ctrl+G` opens Pi's external editor, `Ctrl+U` clears the draft, and `Esc` cancels the whole questionnaire. Pressing `n` adds a note to the current question. The questionnaire stays in one place until you submit.
29
+
30
+ When the model asks several things at once, `Tab` moves between questions and a Submit tab reviews everything before it goes back:
31
+
32
+ ![Four questions in tabs with a Submit review tab](./preview/popup-submit.webp)
33
+
34
+ A note written on a question you never answer still reaches the model, and a global note from the Submit tab covers the whole questionnaire:
35
+
36
+ ![Notes shown dimmed on their tabs and marked in the tab bar](./preview/popup-with-notes.webp)
37
+
13
38
  ## What it does
14
39
 
15
- - **Typed options, not a wall of prose.** Each question carries 2-4 authored
16
- choices, and every choice explains what it means or what it costs you.
17
- - **You can always answer in your own words.** A `Type something.` row is
18
- appended to every question and widens to the full pane while you type.
19
- - **Compare artifacts, not labels.** An option can carry a markdown `preview`
20
- that renders in a bordered box beside the option list.
21
- - **One interruption, not five.** Up to four questions arrive in a single tabbed
22
- dialog, and a Submit tab names anything still blank before you commit.
23
- - **Notes on any answer, or on all of them.** `n` opens a note editor on any
24
- question tab, and on the Submit tab it writes one note covering everything.
25
- A written note stays on its tab, dimmed, and the tab bar marks which tabs
26
- carry one. A note on a question you never answer still reaches the model.
27
- - **Read the transcript behind it.** `Ctrl+]` collapses the dialog and brings it
28
- back with your answers intact.
29
- - **A timeout that is not a decline.** Auto-dismiss returns a distinct
30
- `timed_out` result, so the model never mistakes a clock for a refusal.
31
- - **Works outside the terminal.** RPC and ACP hosts such as Zed or the VS Code
32
- pendant walk the host's native dialogs instead.
40
+ - **Typed options, not a wall of prose.** Each question carries 2 to 4 authored choices, and every choice explains what it means or what it costs you.
41
+ - **You can always answer in your own words.** A `Type something.` row is added to every question and widens to the full pane while you type. On a multi-select question it ticks itself the moment you type into it, and what you wrote is submitted alongside whatever boxes you ticked. Clear the text and the tick goes with it.
42
+ - **Compare artifacts, not labels.** An option can carry a markdown `preview` that renders in a bordered box beside the option list.
43
+ - **One interruption, not five.** Up to four questions arrive in a single tabbed dialog, and a Submit tab names anything still blank before you commit.
44
+ - **Notes on any answer, or on all of them.** `n` opens a note editor on any question tab, and on the Submit tab it writes one note covering everything. A written note stays on its tab, dimmed, and the tab bar marks which tabs carry one. A note on a question you never answer still reaches the model as `unansweredNotes`.
45
+ - **Read the transcript behind it.** `Ctrl+]` collapses the dialog and brings it back with your answers intact.
46
+ - **A timeout that is not a decline.** Pass `timeout` in milliseconds and the dialog shows a live countdown. If it expires, the tool returns `cancelled: true` with `error: "timed_out"`, not a refusal. The model can retry or fall back to asking as plain text.
47
+ - **Works outside the terminal.** RPC and ACP hosts such as Zed or the VS Code pendant walk the host's native dialogs instead.
48
+
49
+ ## Configuration
50
+
51
+ Optional. Settings live in `pi-ask-popup.json`. Two layers, project overrides global:
52
+
53
+ | Layer | Path | When it is read |
54
+ | --- | --- | --- |
55
+ | Global | `~/.pi/agent/pi-ask-popup.json` | Always |
56
+ | Project | `<project>/.pi/pi-ask-popup.json` | Only when the workspace is trusted |
57
+
58
+ | Setting | What it does | Default |
59
+ | --- | --- | --- |
60
+ | `collapseKey` | Key that collapses and expands the dialog. Accepts Pi keybinding ids such as `alt+o`. Use `"off"` to disable. | `"ctrl+]"` |
61
+ | `guidance.description` | Full replacement for the tool description the model sees. | built-in description |
62
+ | `guidance.promptSnippet` | One-line summary of the tool in the system prompt. | built-in snippet |
63
+ | `guidance.promptGuidelines` | Usage guidelines given to the model, as a list of strings. | 5 built-in guidelines |
64
+
65
+ Guidance is read from the global layer only. A checked-in file should not be able to change what the agent is told. `collapseKey` can be set per project.
66
+
67
+ ```json
68
+ {
69
+ "collapseKey": "alt+o"
70
+ }
71
+ ```
72
+
73
+ A bad field is dropped back to its default without a warning. A whole file with bad JSON is ignored with a warning shown on the next tool call. See [Configuration](./docs/configuration.md) for the full grammar for `collapseKey`, file lookup, and how warnings are shown.
74
+
75
+ ## Reference
76
+
77
+ - [Tool schema](./docs/tool-schema.md): params, limits, reserved labels, `timeout`, validation errors, the result shape, and the `pi-ask-popup:*` events.
78
+ - [Keyboard and layout](./docs/keyboard.md): every key, the rows the dialog adds, notes, collapse mode, countdown, and how previews and overflow adapt to terminal size.
79
+ - [Configuration](./docs/configuration.md): file lookup, the `collapseKey` grammar, the `guidance.*` prompt overrides, and how bad values are handled.
80
+ - [Hosts and runtime behavior](./docs/hosts.md): terminal vs RPC vs non-interactive, what changes in each, and the `session_load_failed` and `stale_module_cache` cases.
33
81
 
34
82
  ## Requirements
35
83
 
36
84
  - Node.js 22 or newer
37
- - Pi Agent 0.80 or newer, with an interactive terminal or an RPC/ACP host
38
- - A terminal at least 100 columns wide for side-by-side previews
85
+ - Pi Agent 0.80 or newer, with an interactive terminal or an RPC or ACP host. Non-interactive runs never see the tool.
86
+ - A terminal at least 100 columns wide for side by side previews. Narrower terminals stack the preview under the options.
87
+
88
+ ## Troubleshooting
89
+
90
+ **The model says the questionnaire UI failed to load and asks its questions as chat text.**
91
+
92
+ The dialog modules were replaced on disk while Pi was running, usually by a package manager install that touched the store. Repair the install if it is broken, then restart Pi. See [Hosts](./docs/hosts.md) for `session_load_failed` and `stale_module_cache`.
93
+
94
+ **`Ctrl+]` does nothing.**
95
+
96
+ On layouts where `]` is on the shifted layer, like Latin American Spanish `es-AR` and `es-MX`, the default is unreachable. Set `collapseKey` to something you can type:
97
+
98
+ ```json
99
+ {
100
+ "collapseKey": "alt+o"
101
+ }
102
+ ```
103
+
104
+ Or use `"off"` to disable the shortcut. The footer hint inside the dialog and the collapsed one-line hint both name whatever key you set.
105
+
106
+ **Side by side preview never appears.**
39
107
 
40
- No runtime dependencies, no build step, no API keys. The extension makes no
41
- model calls of its own.
108
+ Both the terminal and the dialog pane must be at least 100 columns wide. Below that the preview stacks under the options. The preview pane only appears for single-select questions.
42
109
 
43
- ## Credits
110
+ ## How this relates to other packages
44
111
 
45
- Derived from [`@juicesharp/rpiv-ask-user-question`](https://github.com/juicesharp/rpiv-mono/tree/main/packages/rpiv-ask-user-question)
46
- by juicesharp, MIT licensed. This fork strips the `rpiv-config` and `rpiv-i18n`
47
- dependencies, targets Pi 0.80+, and adds a timeout.
112
+ This is a fork of [`@juicesharp/rpiv-ask-user-question`](https://github.com/juicesharp/rpiv-mono/tree/main/packages/rpiv-ask-user-question) by juicesharp, MIT licensed. The fork removes the `rpiv-config` and `rpiv-i18n` dependencies, so there is no localization and no XDG config path. Config lives in `~/.pi/agent/pi-ask-popup.json` with a per-project override in `<project>/.pi/pi-ask-popup.json`, and only when the workspace is trusted. It targets Pi 0.80 and later, and it adds a `timeout` with a distinct `timed_out` result.
48
113
 
49
- If you want something smaller, [`pi-ask-user`](https://www.npmjs.com/package/pi-ask-user)
50
- by Enzo Lucchesi does a comparable job in a fraction of the code, with a
51
- searchable split-pane selector instead of tabs and previews. Pick whichever fits.
114
+ If you want something smaller, [`pi-ask-user`](https://www.npmjs.com/package/pi-ask-user) by Enzo Lucchesi does a comparable job in far less code, with a searchable split-pane selector instead of tabs and previews. Pick whichever fits your setup. The README notes both so you can choose with full information.
52
115
 
53
116
  ## License
54
117
 
@@ -0,0 +1,93 @@
1
+ # Configuration
2
+
3
+ Every setting this package reads, where the file lives, and what happens when a value is wrong.
4
+
5
+ ## The config file
6
+
7
+ ```text
8
+ pi-ask-popup.json
9
+ ```
10
+
11
+ The file is optional. With no config at all, every setting takes its default. This package only reads the file. It never creates, writes, or changes it.
12
+
13
+ A complete example:
14
+
15
+ ```json
16
+ {
17
+ "collapseKey": "alt+o",
18
+ "guidance": {
19
+ "description": "Ask the user structured questions whenever requirements are ambiguous.",
20
+ "promptSnippet": "Ask me before guessing on anything ambiguous",
21
+ "promptGuidelines": [
22
+ "Batch every clarifying question into one ask_user_question call.",
23
+ "Put your recommended option first and suffix it with (Recommended)."
24
+ ]
25
+ }
26
+ }
27
+ ```
28
+
29
+ ## Where the file is looked up
30
+
31
+ Two layers. The project layer overrides the global layer. Guidance merges per field, so a workspace can pin one line without restating the rest.
32
+
33
+ | Layer | Path | When it is read |
34
+ | --- | --- | --- |
35
+ | Global | `~/.pi/agent/pi-ask-popup.json` | Always |
36
+ | Project | `<project>/.pi/pi-ask-popup.json` | Only when the workspace is trusted |
37
+
38
+ The global path follows `getAgentDir()` from Pi. If you set `PI_CODING_AGENT_DIR`, that directory is used instead of `~/.pi/agent`. `CONFIG_DIR_NAME` is `.pi`, so the project path is always `<project>/.pi/pi-ask-popup.json`.
39
+
40
+ Project trust matters. Pi asks you to trust a checkout before it runs code from it. The project config is only read when `ctx.isProjectTrusted()` is true. An untrusted checkout cannot change your keyboard shortcut or rewrite the instructions the model sees. Guidance is stricter: it is read from the global layer only, even in a trusted project. Guidance is text that goes into the model prompt, and a checked-in file should not be able to change what the agent is told.
41
+
42
+ If neither file exists, you get the defaults. That is the normal case, not an error.
43
+
44
+ ## When the file is invalid
45
+
46
+ The loader never throws. It returns defaults and reports problems as warnings. The caller decides where warnings go. On RPC and JSON hosts the process is speaking a protocol on stdout, so a stray `console.warn` would break the stream. Warnings are shown with `ctx.ui.notify` on the first tool call instead.
47
+
48
+ | Problem | What happens |
49
+ | --- | --- |
50
+ | File does not exist | No warning. No overrides. |
51
+ | Cannot read the file (permission, is a directory) | Warning with the path and the system message. No overrides from that layer. |
52
+ | Invalid JSON | Warning with the path and the parser message. No overrides from that layer. |
53
+ | Valid JSON that is not an object (string, number, null, array) | Warning that the file is not an object. No overrides from that layer. |
54
+ | A single field has the wrong type | That field is dropped and the built-in default stays. No warning. One bad field should not block the rest. |
55
+
56
+ ## Settings
57
+
58
+ | Setting | What it does | Default |
59
+ | --- | --- | --- |
60
+ | `collapseKey` | Key that collapses and expands the dialog overlay. | `"ctrl+]"` |
61
+ | `guidance.description` | Full text of the tool description the model sees. Replaces the built-in default entirely. | built-in description |
62
+ | `guidance.promptSnippet` | One-line summary of the tool in the system prompt. | built-in snippet |
63
+ | `guidance.promptGuidelines` | List of usage guidelines given to the model. | 5 built-in guidelines |
64
+
65
+ ### `collapseKey`
66
+
67
+ The value uses Pi's keybinding id format: zero or more distinct modifiers from `ctrl`, `shift`, `alt`, `super`, joined by `+`, followed by a base key. Values are trimmed and lowercased before matching.
68
+
69
+ The base key is either a single printable character from
70
+
71
+ ```text
72
+ a-z 0-9 ` - = [ ] \ ; ' , . / ! @ # $ % ^ & * ( ) _ | ~ { } : < > ?
73
+ ```
74
+
75
+ or one of the named keys `escape`, `esc`, `enter`, `return`, `tab`, `space`, `backspace`, `delete`, `insert`, `clear`, `home`, `end`, `pageup`, `pagedown`, `up`, `down`, `left`, `right`, `f1` through `f12`.
76
+
77
+ Examples that work: `"ctrl+]"`, `"alt+o"`, `"ctrl+shift+h"`, `"f9"`, `"ctrl+}"`.
78
+
79
+ Set `"off"` to disable the collapse shortcut. No raw terminal listener is registered in that case.
80
+
81
+ A spec that does not match the grammar is rejected and the default is used. This is strict on purpose. Pi's parser takes the last `+` part as the key and discards unknown parts, so a typo like `"ctr+]"` would otherwise silently capture every bare `]` you type.
82
+
83
+ The footer hint inside the dialog names whatever key you configure (`Alt+O to collapse` for `"alt+o"`), as does the collapsed one-line footer and the one-shot notification shown when the dialog is first hidden. With `"off"` the hint is removed.
84
+
85
+ ### `guidance.description`, `guidance.promptSnippet`, and `guidance.promptGuidelines`
86
+
87
+ `guidance.description` replaces the entire built-in description Pi registers for the `ask_user_question` tool. There is no merging. It is used only when it is a non-empty string. Anything else falls back to the built-in default.
88
+
89
+ `guidance.promptSnippet` and `guidance.promptGuidelines` replace the text Pi puts in the system prompt about when to use `ask_user_question`. Use them to make the model ask more or less often, or to enforce a house style for options.
90
+
91
+ `promptSnippet` is used only when it is a non-empty string. `promptGuidelines` is used only when it is a non-empty array whose entries are all non-empty strings. Anything else falls back to the built-in defaults.
92
+
93
+ Guidance is read once, when the extension registers the tool, so changes take effect on the next Pi restart. This is the same for `collapseKey`, but that key is resolved on each tool call so it can pick up the project override.
package/docs/hosts.md ADDED
@@ -0,0 +1,71 @@
1
+ # Hosts and runtime behavior
2
+
3
+ Where the questionnaire renders, what it falls back to, and what happens when it cannot render at all.
4
+
5
+ ## Three environments
6
+
7
+ | Environment | What the model sees | What you see |
8
+ | --- | --- | --- |
9
+ | Interactive terminal | `ask_user_question` in its tool list | The full tabbed TUI overlay |
10
+ | RPC or ACP host (VS Code pendant, Zed, Paseo) | `ask_user_question` in its tool list | A sequence of the host's own native select and input dialogs |
11
+ | Non-interactive run (no UI) | Nothing, the tool is removed | Nothing |
12
+
13
+ ### Terminal attention
14
+
15
+ After UI availability and questionnaire validation succeed, the package sends one standard terminal BEL (`\x07`) just before the interactive wait begins. The signal goes to `stdout` only when `process.stdout.isTTY` is true, so redirected output and non-TTY RPC streams stay untouched. A TTY-backed RPC dialog walk receives the same signal as the TUI path.
16
+
17
+ The BEL is best effort. If the synchronous write fails, the questionnaire continues and the existing prompt and blocked lifecycle and result shape are unchanged. Your terminal decides whether the BEL is audible, visual, or ignored. No BEL is sent for missing UI, invalid questionnaires, or a failed TUI session load.
18
+
19
+ ### Non-interactive runs
20
+
21
+ A `before_agent_start` hook checks `ctx.hasUI` before every turn. When there is no UI, `ask_user_question` is removed from the tool list so the model never sees a tool it cannot use. When UI comes back, the tool is restored. The check is idempotent and leaves sibling tools alone.
22
+
23
+ A second guard lives inside the tool handler. If a call somehow arrives without UI, it returns `error: "no_ui"` and the text `Error: UI not available (running in non-interactive mode)`.
24
+
25
+ ### RPC and ACP hosts
26
+
27
+ RPC hosts report `hasUI: true` because Pi's dialog protocol works there, but custom terminal UI does not render. The package catches this two ways: hosts that advertise `ctx.mode === "rpc"` go straight to the dialog walk and skip the TUI import, and older RPC builds are caught by a fallback when custom UI resolves without rendering. Either path needs the host to expose both `select` and `input`.
28
+
29
+ The walk asks one question per dialog and returns the same result shape the TUI produces. Trade-offs that come with native dialogs:
30
+
31
+ - No side by side preview pane. Previews are folded into the dialog title instead, truncated at 600 characters each.
32
+ - No tab bar and no Submit review tab. One dialog per question, in order.
33
+ - No notes. Both note types, per-question `n` on a question tab and global `n` on the Submit tab, are terminal-only. The host's native `select` and `input` have no note field.
34
+ - Multi-select is a free-text input: type the option numbers, comma separated (`1,3`). Any token that is not a valid option index is treated as a typed custom answer, which is how the `Type something.` escape survives. An empty input commits an empty selection, matching `Next` with nothing checked.
35
+ - Closing any dialog cancels the whole questionnaire, the same as `Esc` in the TUI.
36
+
37
+ If the host can render neither custom UI nor dialogs, the call returns `error: "no_custom_ui"` with text telling the model the user never saw the questions and to ask them as plain chat text instead. This is not a decline.
38
+
39
+ ### Timeout on any host
40
+
41
+ `timeout` is part of the tool params and is passed through to the RPC walk as `dialogOpts.timeout`. The host is expected to dismiss the dialogs after that time. The TUI path runs its own countdown and shows the remaining seconds in the footer. In both cases expiry returns `cancelled: true` with `error: "timed_out"`, not a decline. See [Tool schema](./tool-schema.md).
42
+
43
+ ## Surfaces that depend on conditions
44
+
45
+ Some parts of the dialog only appear when the conditions are right:
46
+
47
+ | Surface | Appears when |
48
+ | --- | --- |
49
+ | Tab bar and Submit tab | The call carries more than one question |
50
+ | `Next` row | The question is multi-select |
51
+ | `Type something.` row | Always |
52
+ | Side by side preview | An option carries a `preview`, and terminal and pane are both at least 100 columns |
53
+ | Preview pane at all | Single-select questions only |
54
+ | Collapse shortcut | `collapseKey` is not `"off"` |
55
+ | Full overlay hide on collapse | The host also exposes raw terminal input, the only path that can reopen a hidden overlay. Without it, collapsing shrinks the dialog to a visible one-line row instead |
56
+ | Countdown | `timeout` was passed and no keystroke has cancelled it |
57
+
58
+ ## Loading and startup cost
59
+
60
+ The dialog's render graph costs about 560 ms to import, so it is loaded lazily on the first tool call, not when the extension registers. To keep that first call fast and safe, the graph is also pre-warmed in the background two seconds after startup. The pre-warm timer is unref'd, so it never keeps a process alive, and a failed pre-warm is ignored. The first real call tries again and reports correctly.
61
+
62
+ The pre-warm exists for a specific failure. Pi's module loader registers a module in its graph cache before it runs, and does not remove it if running throws. If your package manager replaces the store while Pi is running, one failed import can poison the cache for the rest of the process. Running the graph early, while the paths Pi saw at boot still exist, keeps it in memory and avoids that.
63
+
64
+ When it does happen, you get a structured result rather than a raw `TypeError`:
65
+
66
+ | `error` | Meaning | Fix |
67
+ | --- | --- | --- |
68
+ | `session_load_failed` | The dialog module could not be imported. | Repair the install if needed, then restart Pi. |
69
+ | `stale_module_cache` | The module cache went stale after an earlier failed import. | Restart Pi. This cannot be fixed inside the running process. |
70
+
71
+ Both messages tell the model the questions were never shown and to ask them as plain chat text instead of treating the failure as a decline.
@@ -0,0 +1,65 @@
1
+ # Keyboard and dialog layout
2
+
3
+ Every key the questionnaire dialog reacts to, the rows it adds for you, and how it adapts to the size of your terminal.
4
+
5
+ ## Keys
6
+
7
+ | Key | What it does | Where it applies |
8
+ | --- | --- | --- |
9
+ | `Up` / `Down` | Move between rows. Wraps at both ends. | Option list, Submit picker |
10
+ | `Enter` | Confirm the focused option, commit typed text, close notes, or activate the focused Submit picker row. | Everywhere |
11
+ | `Shift+Enter` | Insert a newline. | `Type something.` input, notes editor |
12
+ | `Esc` | Cancel the whole questionnaire. | Everywhere except the notes editor, where it closes notes |
13
+ | `Tab` / `Shift+Tab` | Next and previous tab, wrapping. `Right` / `Left` do the same. | Multi-question dialogs only |
14
+ | `Space` | Toggle the focused checkbox. | Multi-select questions |
15
+ | `n` | Open the notes editor for the focused question, or on the Submit tab the global note for the whole questionnaire. | Every question tab, and the Submit tab in multi-question dialogs |
16
+ | `Ctrl+G` | Open Pi's configured external editor with the current custom answer draft. | `Type something.` input |
17
+ | `Ctrl+U` | Clear the current custom answer draft. | `Type something.` input |
18
+ | `Ctrl+]` | Collapse or expand the dialog. Configurable with `collapseKey`. | Everywhere, including while collapsed |
19
+
20
+ The table names the default keys. The dialog follows your Pi keybindings. Confirm listens to both `tui.select.confirm` and `tui.input.submit`, and a key bound to `tui.input.newLine` always inserts a newline even if it also matches confirm. So a Slack-style setup where `Enter` is mapped to `tui.input.newLine` and submit moved to `Ctrl+Enter` still works: `Enter` breaks lines, and your submit key confirms everywhere `Enter` does.
21
+
22
+ In a multi-select question, `Enter` on a regular row toggles its checkbox just like `Space`. It does not submit. Committing the question means focusing the `Next` row and pressing `Enter`. That is intentional: it makes `Enter` a cheap way to flip boxes without leaving the home row.
23
+
24
+ `Space` is blocked on two rows: `Next` is a command, not a choice, and `Type something.` is a text input where the space character belongs to your answer.
25
+
26
+ Timeout: if the call included a `timeout`, a live countdown shows in the footer and in the collapsed hint row, like `12s left`. The first keystroke you make cancels the timer. It does not reset, it stops.
27
+
28
+ ## The rows the dialog adds
29
+
30
+ | Row | Label | Added to |
31
+ | --- | --- | --- |
32
+ | Custom answer | `Type something.` | Every question, single-select and multi-select, with or without previews |
33
+ | Commit | `Next` | Multi-select questions only |
34
+
35
+ Focusing `Type something.` turns the row into an inline multiline editor. In preview mode it expands to the full pane width while you type, so a long custom answer is not squeezed into the narrow options column. `Shift+Enter` inserts a line break. Vertical arrows move between lines and return to row navigation at the top and bottom of the draft. The draft replaces the static row label while you browse other options and is kept per question. `Ctrl+G` sends it through Pi's configured external editor and brings the result back. `Ctrl+U` clears it. `Esc` is the way to cancel the questionnaire. Confirming the row produces an answer of `kind: "custom"`.
36
+
37
+ Both labels are reserved. The model cannot use them as option labels. The check always compares against the English strings.
38
+
39
+ ## Notes
40
+
41
+ `n` opens a notes editor on any question tab, whether the question is single or multi-select and whether its options carry previews. Notes live in a side band keyed by tab index, not inside the answer, so writing a note does not mark a question as answered. The Submit tab still lists it as missing. The note joins the answer when you confirm it, and reaches the model as `user notes: <text>`.
42
+
43
+ On the Submit tab, `n` opens the global note editor instead. One note covers the whole questionnaire. It lives outside every answer, so it survives tab switches and never marks a question as answered. It reaches the model as `global note: <text>`, and submitting with nothing but a global note still returns an answered result rather than a decline.
44
+
45
+ Inside the editor, `Shift+Enter` inserts a newline, while `Esc` and `Enter` close it. Other keystrokes edit the buffer, so `n` types an `n`. Pasted line breaks are kept.
46
+
47
+ A note on a question you never answer is not lost. It is returned as `unansweredNotes` alongside `answers` and appears in the envelope as `note on "<question>": <text>.`
48
+
49
+ ## Collapse mode
50
+
51
+ `Ctrl+]` gets the dialog out of the way. The overlay is marked hidden in Pi's overlay stack and shrinks to a single dim hint row, so the transcript it was covering becomes readable and chat scrolling resumes. Press the same key to bring the questionnaire back with your answers intact. The first time you collapse, Pi shows the key to press. That message names your configured key.
52
+
53
+ Because Pi sends no input to a hidden overlay, the collapse key is also captured at the raw terminal level. It only acts when the questionnaire is hidden or focused, so a different overlay on top of it, for example `/btw`, keeps its keystrokes.
54
+
55
+ While collapsed, every keystroke except cancel is ignored. You cannot change answers you cannot see.
56
+
57
+ The default `Ctrl+]` is free in Terminal.app, iTerm2, Warp, tmux, zellij, and screen. On layouts where `]` sits on the shifted layer, like Latin American `es-AR` or `es-MX`, set a different `collapseKey` or `"off"` to disable it. See [Configuration](./configuration.md) and [Troubleshooting in the README](../README.md).
58
+
59
+ ## Layout
60
+
61
+ Options render in a vertical list. When any option in a single-select question carries a `preview`, the dialog splits into a side by side layout with the option list on the left and a bordered monospace preview box on the right. This only happens when both the terminal and the dialog pane are at least 100 columns wide. Below that, the preview stacks under the options instead.
62
+
63
+ When the dialog is taller than the terminal, the body scrolls between a sticky heading and a sticky footer, and an overflow indicator shows which direction is clipped: `Up` for content above, `Down` for content below, `Both` for both. The exact markers are `↑`, `↓`, and `↕`.
64
+
65
+ The footer hint line adapts to context. It drops the notes hint and adds the `Shift+Enter` newline hint whenever a text editor has focus, with `Ctrl+U` still at the far right for custom answers. It adds the tab hint only in multi-question dialogs. The Submit tab follows the same idea: its bottom hint row sits below the picker and carries an `n to add a note` part that gives way to the `Shift+Enter` hint while the global note editor is open. A committed note shows as a `Note` entry in the review list. `Ctrl+G` is Pi's global external editor shortcut and is not repeated there. On narrow terminals the right edge clips with `…` so the main hints stay visible.
@@ -0,0 +1,124 @@
1
+ # Tool schema
2
+
3
+ The full program surface of `ask_user_question`: what the model sends, what validation rejects, what comes back, and the event other extensions can listen to.
4
+
5
+ ## Parameters
6
+
7
+ ```ts
8
+ ask_user_question({
9
+ questions: [
10
+ {
11
+ question: string, // full question text, ends with "?"
12
+ header: string, // chip label, max 16 chars
13
+ options: [
14
+ {
15
+ label: string, // 1 to 5 words, max 60 chars
16
+ description: string, // what the choice means or its trade-off
17
+ preview?: string, // markdown rendered next to the options
18
+ },
19
+ // 2 to 4 options total
20
+ ],
21
+ multiSelect?: boolean, // default false
22
+ },
23
+ // 1 to 4 questions total
24
+ ],
25
+ timeout?: number, // ms, min 1000, auto-dismiss with countdown
26
+ })
27
+ ```
28
+
29
+ ### Limits
30
+
31
+ | Field | Constraint | Enforced by |
32
+ | --- | --- | --- |
33
+ | `questions` | 1 to 4 entries | TypeBox schema and `validateQuestionnaire` |
34
+ | `questions[].header` | max 16 characters | TypeBox schema only |
35
+ | `questions[].options` | 2 to 4 entries | TypeBox schema (both bounds) and `validateQuestionnaire` (minimum only) |
36
+ | `options[].label` | max 60 characters | TypeBox schema only |
37
+ | `options[].preview` | single-select questions only | tool description (multi-select tabs render checkbox rows) |
38
+ | `timeout` | integer at least 1000 | TypeBox schema only |
39
+
40
+ The two `maxLength` limits are checked by the param schema before `execute` runs. The runtime validator does not re-check them.
41
+
42
+ ### Timeout
43
+
44
+ `timeout` is optional. When set, the dialog starts a live countdown shown in the footer and in the collapsed hint row. The first keystroke cancels the timer. If the countdown reaches zero, the questionnaire dismisses itself and returns `cancelled: true` with `error: "timed_out"`. This is not a decline. The model should retry or ask the same questions as plain chat text. A timed out result keeps the same `answers`, `globalNote`, and `unansweredNotes` the user had managed to leave behind, if any. No timeout means the dialog waits until the user acts.
45
+
46
+ ### Reserved option labels
47
+
48
+ Using any of `"Other"`, `"Type something."`, or `"Next"` as an option label is rejected with `reserved_label`. The last two are the rows the dialog adds itself. `"Other"` is reserved because models are often primed to reach for it. Reservation is unconditional. A single-select question rejects `"Next"` even though that row is never added there.
49
+
50
+ ## Validation errors
51
+
52
+ Every rejection returns `cancelled: true`, an empty `answers` array, and an `error` code. The `content[0].text` string is written for the model, not for a log.
53
+
54
+ | `error` | Cause |
55
+ | --- | --- |
56
+ | `no_questions` | `questions` was empty |
57
+ | `too_many_questions` | more than 4 questions in one call |
58
+ | `duplicate_question` | two questions with identical text |
59
+ | `empty_options` | a question carried fewer than 2 options |
60
+ | `reserved_label` | an option used a reserved label |
61
+ | `duplicate_option_label` | two options in one question share a label |
62
+ | `no_ui` | the run has no UI (`ctx.hasUI === false`) |
63
+ | `no_custom_ui` | the host cannot render custom UI and exposes no `select` or `input` dialogs |
64
+ | `session_load_failed` | the dialog module failed to import (store changed on disk mid session) |
65
+ | `stale_module_cache` | the loader cached a broken module after an earlier failed import. Needs a Pi restart |
66
+ | `timed_out` | the `timeout` countdown expired before the user acted |
67
+
68
+ `reserved_label` is checked before `duplicate_option_label`.
69
+
70
+ ## Result
71
+
72
+ ```ts
73
+ {
74
+ content: [{ type: "text", text: string }], // envelope prose, or the decline message
75
+ details: {
76
+ answers: Array<{
77
+ questionIndex: number,
78
+ question: string,
79
+ kind: "option" | "custom" | "multi",
80
+ answer: string | null, // option label, typed text, or null for multi
81
+ selected?: string[], // chosen labels, multi-select only
82
+ notes?: string, // free-text note, when you wrote one
83
+ preview?: string, // echoed when the chosen option carried a preview
84
+ }>,
85
+ cancelled: boolean,
86
+ globalNote?: string, // Submit-tab note. Present even when cancelled is true
87
+ unansweredNotes?: Array<{ // notes on questions that were never answered
88
+ questionIndex: number,
89
+ question: string,
90
+ note: string,
91
+ }>,
92
+ error?: QuestionnaireError, // one of the codes above
93
+ }
94
+ }
95
+ ```
96
+
97
+ `globalNote` and `unansweredNotes` use a conditional spread. The key only appears when the value is non-empty. A result with no notes has no such key at all, so `!("globalNote" in result)` holds.
98
+
99
+ ### Envelope text
100
+
101
+ On success the text reads `User has answered your questions: "<question>"="<answer>". … You can now continue with the user's answers in mind.` A chosen option's `preview` is added as `selected preview: <markdown>`, a per-question note as `user notes: <text>`, a note on an unanswered question as `note on "<question>": <text>.`, and the Submit tab's global note as a trailing `global note: <text>.` segment. A global note alone, or a single `unansweredNotes` entry alone, still yields the answered envelope. It counts as an answer even when every question is blank.
102
+
103
+ Cancelling, and any result with no answer segments, no unanswered note segments, and no global note, both collapse to the single string `User declined to answer questions` so the model sees one clear signal. Partial submission is allowed: unanswered questions simply add no segment. A cancelled result always reads as the decline in text. Its notes, if any, survive only in `details.globalNote` and `details.unansweredNotes`.
104
+
105
+ When `error` is `timed_out`, the text is `Questionnaire timed out, the user did not respond within the configured timeout. The user never saw a decline; do NOT treat this as a rejection. Ask the questions as plain chat text instead or retry.` The details keep `cancelled: true` and `error: "timed_out"` alongside any notes or answers the user left.
106
+
107
+ ## Events
108
+
109
+ The package emits one event on Pi's event bus, after validation passes and before the dialog shows. Import it from the `./events` subpath:
110
+
111
+ ```ts
112
+ import {
113
+ ASK_POPUP_PROMPT_EVENT,
114
+ ASK_POPUP_BLOCKED_EVENT,
115
+ type AskPopupPromptEventPayload,
116
+ type AskPopupBlockedEventPayload,
117
+ } from "pi-ask-popup/events";
118
+ ```
119
+
120
+ `ASK_POPUP_PROMPT_EVENT` channel is `pi-ask-popup:prompt`. Payload is `questions[].{ question, header, multiSelect, options[] }` where each option is `{ label, description, hasPreview }`. Preview content is not shipped, only `hasPreview: boolean`, so listeners that forward the event stay small.
121
+
122
+ `ASK_POPUP_BLOCKED_EVENT` channel is `pi-ask-popup:blocked`. Payload is `{ blocked: boolean }`. It brackets the wait, true when the questionnaire starts and false when it resolves, so status or footer extensions can show that the agent is waiting.
123
+
124
+ Both payloads are JSON-safe. Channel names are stable. Changes are append-only and optional, and any breaking change ships as a new channel rather than a version field.
package/package.json CHANGED
@@ -1,10 +1,19 @@
1
1
  {
2
2
  "name": "pi-ask-popup",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Pi extension. A tabbed terminal questionnaire the model can put to you when it would otherwise guess, with typed options, markdown previews and notes instead of free-form replies.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
- "author": "derangga <derangga1011@gmail.com>",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/derangga/pi-extensions.git",
10
+ "directory": "packages/pi-ask-popup"
11
+ },
12
+ "homepage": "https://github.com/derangga/pi-extensions/tree/master/packages/pi-ask-popup#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/derangga/pi-extensions/issues"
15
+ },
16
+ "author": "derangga <contact@rangga.site>",
8
17
  "keywords": [
9
18
  "pi-package",
10
19
  "pi-extension",
@@ -31,6 +40,7 @@
31
40
  "files": [
32
41
  "src/",
33
42
  "docs/",
43
+ "preview/",
34
44
  "README.md",
35
45
  "LICENSE"
36
46
  ],
Binary file
Binary file
Binary file