@geoqiao/pi-ask 1.1.0 → 1.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.
- package/CHANGELOG.md +12 -0
- package/README.md +25 -4
- package/docs/architecture.md +98 -0
- package/docs/configuration.md +3 -1
- package/docs/contract.md +22 -7
- package/package.json +3 -2
- package/src/ask-tool-helpers.ts +2 -1
- package/src/ask-tool.ts +4 -0
- package/src/index.ts +1 -1
- package/src/rpc/controller.ts +602 -0
- package/src/ui/settings-list.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
# [1.2.0](https://github.com/geoqiao/pi-ask/compare/v1.1.0...v1.2.0) (2026-08-16)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* preserve RPC yes-no cancellation semantics ([9e5265d](https://github.com/geoqiao/pi-ask/commit/9e5265dec7ef6a990a3ac8a8aca190429a942fb7))
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* add portable RPC ask fallback ([6def4ed](https://github.com/geoqiao/pi-ask/commit/6def4ed1c803980b5da89ccfc2de1d56f54bef99))
|
|
12
|
+
|
|
1
13
|
# [1.1.0](https://github.com/eko24ive/pi-ask/compare/v1.0.2...v1.1.0) (2026-06-22)
|
|
2
14
|
|
|
3
15
|
|
package/README.md
CHANGED
|
@@ -6,14 +6,21 @@
|
|
|
6
6
|
[](https://github.com/geoqiao/pi-ask/commits/main)
|
|
7
7
|
[](https://github.com/geoqiao/pi-ask/stargazers)
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
> [!IMPORTANT]
|
|
10
|
+
> This is an independently maintained continuation of [`eko24ive/pi-ask`](https://github.com/eko24ive/pi-ask), with portable Pi RPC mode support while preserving the rich TUI experience.
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
`@geoqiao/pi-ask` is an ask tool that cares about your answers.
|
|
13
|
+
|
|
14
|
+
It lets an agent pause, ask structured questions in a terminal UI or portable Pi RPC dialogs, and continue with normalized answers instead of guessing.
|
|
12
15
|
|
|
13
16
|

|
|
14
17
|
|
|
15
18
|
High-quality video: [demo.mp4](https://github.com/user-attachments/assets/a8503ca9-afcb-4c31-9edc-353b985a0209)
|
|
16
19
|
|
|
20
|
+
## Upstream and contributions
|
|
21
|
+
|
|
22
|
+
This project preserves the upstream Git history, MIT license, and author attribution. New development and releases are maintained independently in [`geoqiao/pi-ask`](https://github.com/geoqiao/pi-ask). Issues and contributions are welcome in this repository.
|
|
23
|
+
|
|
17
24
|
## Install
|
|
18
25
|
|
|
19
26
|
```bash
|
|
@@ -34,7 +41,7 @@ pi -e npm:@geoqiao/pi-ask
|
|
|
34
41
|
|
|
35
42
|
## Features
|
|
36
43
|
|
|
37
|
-
Once installed, this package gives the agent a native way to ask for clarification instead of guessing.
|
|
44
|
+
Once installed, this package gives the agent a native way to ask for clarification instead of guessing. The rich interface is used in TUI mode.
|
|
38
45
|
|
|
39
46
|
- 🧭 Familiar ask-style interface: tabbed questions, single/multi select, and preview mode
|
|
40
47
|
- ✍️ Inline free-form `Type your own` answers
|
|
@@ -42,6 +49,7 @@ Once installed, this package gives the agent a native way to ask for clarificati
|
|
|
42
49
|
- 📝 Question-level and option-level notes
|
|
43
50
|
- 👀 Review tab with `Submit`, `Elaborate`, and `Cancel`
|
|
44
51
|
- 💬 Elaboration flow to capture note-based clarification before final submission
|
|
52
|
+
- 🔌 Pi RPC fallback using portable sequential dialogs with normalized results
|
|
45
53
|
- ⌨️ Context-aware customizable keymaps with aliases for main flow, editors, and settings
|
|
46
54
|
- ⚙️ Ask settings with persisted behaviour, notifications, keymaps, and `/answer` extraction config
|
|
47
55
|
- 🔔 Optional external notifications when an ask flow is waiting for input
|
|
@@ -93,7 +101,7 @@ Capture free-form input inline without leaving the flow.
|
|
|
93
101
|
|
|
94
102
|
Open ask settings with `?` during the ask flow, or with the `/ask-settings` command from pi.
|
|
95
103
|
|
|
96
|
-
Keymaps are context-aware and configurable in `~/.pi/agent/extensions/eko24ive-pi-ask.json`.
|
|
104
|
+
Keymaps are context-aware and configurable in `~/.pi/agent/extensions/eko24ive-pi-ask.json`. The established filename is retained so users can switch from the upstream package without losing settings.
|
|
97
105
|
Each action accepts a key string or an array of aliases.
|
|
98
106
|
|
|
99
107
|
Default contexts:
|
|
@@ -188,6 +196,19 @@ After installation, the extension registers the `ask_user` tool plus `/ask-setti
|
|
|
188
196
|
|
|
189
197
|
Agents can auto-discover and call `ask_user` when they need clarification instead of guessing. In interactive sessions, it opens a terminal UI flow for structured answers, supports native pi-style `@` file references while typing answers or notes, and returns normalized answers back to the agent. Ask settings are available both from `?` in the ask flow and from the `/ask-settings` command. Behaviour and notification settings are binary `on`/`off` toggles that save immediately when the config file is writable; save failures revert the toggle and show a manual-edit message. The settings overlay includes a guarded double-press reset-to-defaults action; keymaps, notification channels, and extraction settings are changed by editing the shown config file path.
|
|
190
198
|
|
|
199
|
+
### Pi RPC fallback
|
|
200
|
+
|
|
201
|
+
When Pi runs in RPC mode with portable extension UI support, `ask_user` keeps the same normalized result contract but uses sequential dialogs:
|
|
202
|
+
|
|
203
|
+
- single choices, including Yes/No pairs, use cancellable `select` dialogs
|
|
204
|
+
- short custom answers use `input`; multiline answers use `editor`
|
|
205
|
+
- each question offers explicit Skip and Cancel actions, followed by optional question/selected-option note actions
|
|
206
|
+
- multi-select repeats `select` with `[ ]` / `[x]` markers until `Finish selection` is chosen
|
|
207
|
+
- descriptions and preview content are flattened into readable option strings
|
|
208
|
+
- multiple questions include `[current/total]` progress in each dialog title
|
|
209
|
+
|
|
210
|
+
RPC intentionally does not reproduce the tabbed same-screen form, native checkbox cards, custom preview pane, question-type hotkeys, settings overlay, or final Submit/Elaborate review tab. The fallback completes in `submit` mode after the sequential questions. `/answer`, `/answer:again`, `/ask:replay`, and `/ask-settings` remain TUI-only.
|
|
211
|
+
|
|
191
212
|
### Answer and replay commands
|
|
192
213
|
|
|
193
214
|
`/answer` is useful when the agent asked questions in plain text instead of using `ask_user`. It extracts questions from the latest completed assistant message and opens the same ask UI.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
The codebase is split so the implementation reads through file boundaries and names, not through large explanatory docs.
|
|
4
|
+
|
|
5
|
+
## Design goals
|
|
6
|
+
|
|
7
|
+
- thin pi-specific wiring
|
|
8
|
+
- pure, testable state transitions
|
|
9
|
+
- rendering separated from decision logic
|
|
10
|
+
- a small stable tool contract
|
|
11
|
+
|
|
12
|
+
## Module map
|
|
13
|
+
|
|
14
|
+
### Tool surface
|
|
15
|
+
|
|
16
|
+
- `src/index.ts` — extension entrypoint
|
|
17
|
+
- `src/ask-tool.ts` — tool registration, mode routing, non-interactive fallback, transcript rendering, ask payload capture
|
|
18
|
+
- `src/rpc/controller.ts` — sequential portable-dialog RPC flow and state assembly
|
|
19
|
+
- `src/answer-commands.ts` — `/answer`, `/answer:again`, and `/ask:replay` command wiring
|
|
20
|
+
- `src/answer-extraction.ts` — configured extraction model selection and raw-JSON extraction retries
|
|
21
|
+
- `src/ask-payload-store.ts` — branch-aware persisted ask payload lookup
|
|
22
|
+
- `src/notifications.ts` — best-effort ask notification payload rendering and channel execution
|
|
23
|
+
- `src/remote-ask.ts` — package-prefixed local event contract, active-flow registry, and explicit remote submission validation
|
|
24
|
+
- `src/schema.ts` — TypeBox schema
|
|
25
|
+
- `src/types.ts` — shared types
|
|
26
|
+
|
|
27
|
+
### State
|
|
28
|
+
|
|
29
|
+
- `src/state/normalize.ts` — normalize incoming questions
|
|
30
|
+
- `src/state/answers.ts` — mutate and serialize answers
|
|
31
|
+
- `src/state/selectors.ts` — read-only selectors
|
|
32
|
+
- `src/state/transitions.ts` — navigation, selection, notes, input, submit, cancel
|
|
33
|
+
- `src/state/result.ts` — convert UI state to `AskResult`
|
|
34
|
+
- `src/state/view.ts` — view-mode helpers
|
|
35
|
+
- `src/state.ts` — state barrel used by UI and tests
|
|
36
|
+
|
|
37
|
+
### Config
|
|
38
|
+
|
|
39
|
+
- `src/config/defaults.ts` — canonical runtime defaults
|
|
40
|
+
- `src/config/schema.ts` — persisted config schema and runtime type
|
|
41
|
+
- `src/config/migrate.ts` — persisted-file validation boundary, including schema migration and context-aware keymap normalization/fallback
|
|
42
|
+
- `src/config/migrations/` — ordered schema-version migration framework for persisted config shape changes
|
|
43
|
+
- `src/config/store.ts` — non-destructive current/legacy config discovery, load, save, notice, and runtime subscription store
|
|
44
|
+
|
|
45
|
+
### UI
|
|
46
|
+
|
|
47
|
+
- `src/ui/controller.ts` — connects key input, editor lifecycle, live config subscription, dirty-dismiss confirmation, and pure state transitions
|
|
48
|
+
- `src/ui/input.ts` — raw input to commands using resolved context-aware config-backed keymaps
|
|
49
|
+
- `src/ui/dismiss-guard.ts` — pure helpers for dirty-flow exit confirmation behavior
|
|
50
|
+
- `src/ui/render.ts` and `src/ui/render-*.ts` — screen rendering, including config-backed footer/keymap hints
|
|
51
|
+
- `src/ui/settings-list.ts` / `src/ui/show-settings.ts` — lightweight ask settings list rendering and launcher
|
|
52
|
+
- `src/ui/constants.ts` and `src/ui/render-types.ts` — rendering constants/contracts
|
|
53
|
+
|
|
54
|
+
### Result formatting
|
|
55
|
+
|
|
56
|
+
- `src/result-format.ts` — shared summary/result line formatting
|
|
57
|
+
- `src/result.ts` — final result rendering
|
|
58
|
+
- `src/text.ts` / `src/constants.ts` — shared display strings
|
|
59
|
+
|
|
60
|
+
### Tests
|
|
61
|
+
|
|
62
|
+
- `tests/state.test.ts` — state transitions and serialization
|
|
63
|
+
- `tests/input.test.ts` — editing/navigation key behavior
|
|
64
|
+
- `tests/result.test.ts` — summaries and transcript output
|
|
65
|
+
- `tests/render-*.test.ts` / `tests/text.test.ts` — rendering helpers
|
|
66
|
+
|
|
67
|
+
## Invariants worth preserving
|
|
68
|
+
|
|
69
|
+
- submit is never blocked by unanswered questions
|
|
70
|
+
- TUI state and controller behavior stay isolated from the RPC portable-dialog controller
|
|
71
|
+
- RPC answers are written through shared answer helpers and serialized through the shared result builder
|
|
72
|
+
- single-select answers serialize as arrays
|
|
73
|
+
- when `behaviour.presentSingleAsMulti` is enabled, future single-select questions are handled with multi-select state semantics while result metadata preserves the requested `type` and adds `presentedType`
|
|
74
|
+
- active-flow question type changes are per-question runtime overrides handled in state/controller logic; they do not mutate the stored source payload or global config
|
|
75
|
+
- single-select free-form answers replace selected options for that question
|
|
76
|
+
- multi-select free-form answers augment selected options instead of clearing them
|
|
77
|
+
- preview questions keep their preview-pane behavior while also supporting the synthetic custom-answer option
|
|
78
|
+
- deselected option notes stay in UI state
|
|
79
|
+
- only selected option notes are emitted in the final result
|
|
80
|
+
- editor lifecycle stays in the controller, not in the reducers
|
|
81
|
+
- persisted ask settings are migrated to the current schema version in memory, validated, and normalized before use without rewriting the config file on load
|
|
82
|
+
- config schema migrations preserve user-provided values and add new defaults only when fields are absent
|
|
83
|
+
- replay payload lookup scans only the current session branch and revalidates payloads before use
|
|
84
|
+
- invalid persisted keymaps fall back to default keymaps for the current session without discarding valid behaviour, notification, or answer settings
|
|
85
|
+
- invalid notification channels are skipped and fall back to the default bell channel if none are valid
|
|
86
|
+
- ask settings behaviour and notification enabled changes attempt to persist immediately from the settings list; save failures revert the change and show an error; config reset is guarded by a short double-press confirmation
|
|
87
|
+
- `presentSingleAsMulti` is applied at ask-flow creation; toggling it does not rewrite already-normalized questions in an open flow
|
|
88
|
+
- `main.changeQuestionType` changes the active question type live (non-preview: `single <-> multi`; preview: `preview <-> multi`) and may require confirmation before destructive multi-to-single conversion
|
|
89
|
+
- when the ask config file is missing, the first ask use attempts to write a default persisted config snapshot under `~/.pi/agent/extensions/`; if writing fails, built-in defaults are used for the session
|
|
90
|
+
- legacy root config files are read as a fallback only when the current config file is absent; disk is left untouched
|
|
91
|
+
- invalid config files are left untouched; defaults are loaded for the session with a notice
|
|
92
|
+
- live config updates can affect an in-progress ask flow immediately
|
|
93
|
+
- remote ask submissions must be explicit `answer` or `cancel` responses; pi-ask validates ids/values but never infers approve/deny semantics from labels or option values
|
|
94
|
+
|
|
95
|
+
## Documentation rule
|
|
96
|
+
|
|
97
|
+
Docs should explain contracts, responsibilities, and invariants.
|
|
98
|
+
Code and tests should explain the rest.
|
package/docs/configuration.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-ask configuration
|
|
2
2
|
|
|
3
|
-
This file is the source of truth for configuring `@
|
|
3
|
+
This file is the source of truth for configuring `@geoqiao/pi-ask`.
|
|
4
4
|
|
|
5
5
|
When changing pi-ask settings:
|
|
6
6
|
|
|
@@ -16,6 +16,8 @@ Default path:
|
|
|
16
16
|
|
|
17
17
|
`~/.pi/agent/extensions/eko24ive-pi-ask.json`
|
|
18
18
|
|
|
19
|
+
The established filename is intentionally retained for compatibility with upstream installations.
|
|
20
|
+
|
|
19
21
|
If the file does not exist yet, pi-ask attempts to create it with the current default settings the first time the ask flow is used. If the config location is read-only or managed outside pi-ask, pi-ask uses built-in defaults for the session and leaves disk unchanged.
|
|
20
22
|
|
|
21
23
|
Older pi-ask versions wrote this file at `~/.pi/agent/eko24ive-pi-ask.json`. If that legacy file exists and the extensions config does not, pi-ask reads the legacy file as a fallback and leaves disk unchanged. If both files exist, pi-ask uses the extensions config and leaves the legacy root file untouched.
|
package/docs/contract.md
CHANGED
|
@@ -193,7 +193,7 @@ This document defines the stable external behavior. It does not explain internal
|
|
|
193
193
|
- elaborate `content` text and transcript rendering describe each note directly using the full question prompt and option label, and include the current committed answer text when available, instead of a generic elaboration banner
|
|
194
194
|
- when the user selects `Elaborate` without adding notes, elaborate `content` text and transcript rendering still include the committed answer text so the agent can elaborate on that answer directly
|
|
195
195
|
|
|
196
|
-
## Supported UX
|
|
196
|
+
## Supported TUI UX
|
|
197
197
|
|
|
198
198
|
- tabbed multi-question flow
|
|
199
199
|
- single-select, multi-select, and preview questions
|
|
@@ -255,9 +255,22 @@ Dirty dismiss:
|
|
|
255
255
|
- when `Confirm dismiss when dirty` is enabled, cancelling or dismissing a dirty ask flow requires the same action a second time
|
|
256
256
|
- the dirty-dismiss warning stays visible until the user changes tabs in the ask flow
|
|
257
257
|
|
|
258
|
-
##
|
|
258
|
+
## Runtime modes
|
|
259
259
|
|
|
260
|
-
The rich ask flow uses `ctx.ui.custom()`
|
|
260
|
+
The rich ask flow uses `ctx.ui.custom()` only in TUI mode. RPC mode never calls `custom()`; when portable extension dialogs are available it asks questions sequentially and serializes answers through the same state/result helpers:
|
|
261
|
+
|
|
262
|
+
- single choices, including Yes/No pairs, use `select` so dismissal remains distinct from choosing `No`
|
|
263
|
+
- short and multiline custom answers use `input` and `editor`
|
|
264
|
+
- every question exposes Skip explicitly; required remains advisory and its Skip label says so
|
|
265
|
+
- after each question, a portable action dialog can add/edit a short or multiline question note, or an editor note for a selected option
|
|
266
|
+
- multi-select repeatedly calls `select`, showing `[ ]` / `[x]` option markers and a `Finish selection` action; selections are collected locally in original option order
|
|
267
|
+
- descriptions and preview content are flattened into option strings instead of using a custom preview pane
|
|
268
|
+
- multiple questions are sequential and every title includes `[current/total]` progress
|
|
269
|
+
- dismissing a value dialog or choosing Cancel returns `cancelled: true`
|
|
270
|
+
|
|
271
|
+
RPC does not provide the tabbed same-screen form, native checkbox cards, custom preview pane, question-type hotkeys, settings overlay, or final Submit/Elaborate review tab. RPC completion uses `mode: "submit"`. `/answer`, `/answer:again`, `/ask:replay`, and `/ask-settings` remain TUI-only.
|
|
272
|
+
|
|
273
|
+
In print, JSON, other non-TUI modes, or RPC without portable UI availability, the tool returns a `Needs user input: ask_user requires interactive TUI mode.` message in `content` and a cancelled result in `details` instead of opening UI.
|
|
261
274
|
|
|
262
275
|
Validation is handled inside the tool so malformed calls produce the same structured error shape as other invalid payloads instead of relying on pre-execution schema failures.
|
|
263
276
|
|
|
@@ -265,20 +278,22 @@ The ask flow subscribes to runtime settings updates while open. In practice, thi
|
|
|
265
278
|
|
|
266
279
|
## Notifications
|
|
267
280
|
|
|
268
|
-
When enabled, pi-ask emits one best-effort external notification per ask session after the ask UI opens and waits for input. The default title is `pi ask`; the message is `Question waiting: <label or prompt>`. Channels run in configured order and failures never fail or cancel the ask flow.
|
|
281
|
+
When enabled, pi-ask emits one best-effort external notification per TUI ask session after the ask UI opens and waits for input. RPC clients surface the portable dialogs directly. The default title is `pi ask`; the message is `Question waiting: <label or prompt>`. Channels run in configured order and failures never fail or cancel the ask flow.
|
|
269
282
|
|
|
270
283
|
## Remote inter-extension events
|
|
271
284
|
|
|
272
|
-
pi-ask exposes a local `pi.events` contract for trusted Pi extensions. It does not expose a network API and does not automate terminal keystrokes.
|
|
285
|
+
pi-ask exposes a local `pi.events` contract for trusted Pi extensions. It does not expose a network API and does not automate terminal keystrokes. Integrations that need programmatic submissions instead of the normal TUI or RPC portable-dialog flow can use a trusted in-process bridge extension that consumes these events.
|
|
273
286
|
|
|
274
287
|
Channels:
|
|
275
288
|
|
|
289
|
+
The established event namespace is retained for compatibility with existing trusted integrations.
|
|
290
|
+
|
|
276
291
|
- `@eko24ive/pi-ask:started`
|
|
277
292
|
- `@eko24ive/pi-ask:completed`
|
|
278
293
|
- `@eko24ive/pi-ask:submit`
|
|
279
294
|
- `@eko24ive/pi-ask:submit-result`
|
|
280
295
|
|
|
281
|
-
Remote submissions must be explicit `{ kind: "answer" }` or `{ kind: "cancel" }` responses. Remote answers use question ids and normalized option values from the started event. pi-ask validates ids and values, recomputes labels/indices, and does not infer approval semantics from labels.
|
|
296
|
+
Remote submissions must be explicit `{ kind: "answer" }` or `{ kind: "cancel" }` responses. Remote answers use question ids and normalized option values from the started event. pi-ask validates ids and values, recomputes labels/indices, and does not infer approval semantics from labels. The normal `ask_user` tool uses the portable-dialog fallback in RPC; the event bridge remains available for other trusted in-process integrations.
|
|
282
297
|
|
|
283
298
|
See [`remote-events.md`](remote-events.md) for payload shapes, examples, and a local smoke test.
|
|
284
299
|
|
|
@@ -292,7 +307,7 @@ See [`remote-events.md`](remote-events.md) for payload shapes, examples, and a l
|
|
|
292
307
|
- submitted or elaborated command-flow results are sent back with user-message semantics
|
|
293
308
|
- replay commands scan only `ctx.sessionManager.getBranch()`, ignore sibling/future branch payloads, and revalidate stored payloads before opening the UI
|
|
294
309
|
|
|
295
|
-
The fallback message includes normalized pending questions and options so the caller can re-ask them manually. `details.questions` still contains normalized question metadata, while `details.answers` stays empty until a user responds.
|
|
310
|
+
The non-interactive fallback message includes normalized pending questions and options so the caller can re-ask them manually. `details.questions` still contains normalized question metadata, while `details.answers` stays empty until a user responds.
|
|
296
311
|
|
|
297
312
|
## Skill alignment (advisory)
|
|
298
313
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@geoqiao/pi-ask",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Pi package that adds an interactive ask_user clarification tool.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"skills/",
|
|
26
26
|
"docs/configuration.md",
|
|
27
27
|
"docs/contract.md",
|
|
28
|
+
"docs/architecture.md",
|
|
28
29
|
"docs/remote-events.md",
|
|
29
30
|
"README.md",
|
|
30
31
|
"LICENSE",
|
|
@@ -53,7 +54,7 @@
|
|
|
53
54
|
},
|
|
54
55
|
"publishConfig": {
|
|
55
56
|
"access": "public",
|
|
56
|
-
"provenance":
|
|
57
|
+
"provenance": true
|
|
57
58
|
},
|
|
58
59
|
"release": {
|
|
59
60
|
"repositoryUrl": "https://github.com/geoqiao/pi-ask.git",
|
package/src/ask-tool-helpers.ts
CHANGED
|
@@ -13,7 +13,7 @@ import type {
|
|
|
13
13
|
} from "./types.ts";
|
|
14
14
|
|
|
15
15
|
export const ASK_TOOL_DESCRIPTION =
|
|
16
|
-
"Interactive clarification tool for cases where the next step depends on user preferences, missing requirements, or choosing between multiple valid directions. Ask a short structured interview, collect normalized answers, and continue using those answers explicitly instead of guessing.
|
|
16
|
+
"Interactive clarification tool for cases where the next step depends on user preferences, missing requirements, or choosing between multiple valid directions. Ask a short structured interview, collect normalized answers, and continue using those answers explicitly instead of guessing. TUI mode supports single-select, multi-select, and preview-pane questions; RPC mode presents questions sequentially, implements multi-select with repeated portable dialogs, and flattens preview details into option text. Always include a machine-readable `value` for every option. Use `preview` only when every option includes `preview` text; descriptions alone are not enough.";
|
|
17
17
|
|
|
18
18
|
export const ASK_TOOL_PROMPT_GUIDELINES = [
|
|
19
19
|
"Use `ask_user` before making preference-sensitive decisions about scope, tone, UX, naming, architecture, docs, or implementation direction.",
|
|
@@ -25,6 +25,7 @@ export const ASK_TOOL_PROMPT_GUIDELINES = [
|
|
|
25
25
|
"After an `ask_user` elaboration or follow-up note, prefer another structured `ask_user` follow-up if a choice is still needed instead of switching to plain-text multiple choice in chat.",
|
|
26
26
|
"When prior `ask_user` answers narrow the branch, bundle the next 2-3 related unresolved decisions into one follow-up `ask_user` call when possible.",
|
|
27
27
|
"Use one-at-a-time `ask_user` follow-up calls only when the next question materially depends on the previous answer.",
|
|
28
|
+
"Do not promise same-screen forms, native checkbox cards, or a custom preview pane when `ask_user` is rendered through RPC; the portable fallback asks questions sequentially and uses a repeated-select loop for multi-select.",
|
|
28
29
|
] as const;
|
|
29
30
|
|
|
30
31
|
interface ValidateParamsOptions {
|
package/src/ask-tool.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from "./ask-tool-helpers.ts";
|
|
16
16
|
import { getAskConfigStore } from "./config/store.ts";
|
|
17
17
|
import type { RemoteAskRuntime } from "./remote-ask.ts";
|
|
18
|
+
import { runRpcAskFlow } from "./rpc/controller.ts";
|
|
18
19
|
import { AskParamsSchema } from "./schema.ts";
|
|
19
20
|
import type { AskParams } from "./types.ts";
|
|
20
21
|
import { runAskFlow } from "./ui/controller.ts";
|
|
@@ -68,6 +69,9 @@ async function executeAskTool(
|
|
|
68
69
|
sourceEntryId: toolCallId,
|
|
69
70
|
});
|
|
70
71
|
if (ctx.mode !== "tui") {
|
|
72
|
+
if (ctx.mode === "rpc" && ctx.hasUI) {
|
|
73
|
+
return successfulResponse(await runRpcAskFlow(ctx, validation.state));
|
|
74
|
+
}
|
|
71
75
|
return nonInteractiveResponse(validation.state);
|
|
72
76
|
}
|
|
73
77
|
ctx.ui.setWorkingVisible(false);
|
package/src/index.ts
CHANGED
|
@@ -13,7 +13,7 @@ const CONFIGURATION_DOC_PATH = resolve(
|
|
|
13
13
|
"docs",
|
|
14
14
|
"configuration.md"
|
|
15
15
|
);
|
|
16
|
-
const PI_ASK_CONFIG_PROMPT = `When the user asks to configure, customize, debug, or explain @
|
|
16
|
+
const PI_ASK_CONFIG_PROMPT = `When the user asks to configure, customize, debug, or explain @geoqiao/pi-ask settings or keymaps, first read ${CONFIGURATION_DOC_PATH} and follow it as the source of truth before editing config files.`;
|
|
17
17
|
|
|
18
18
|
export default function askExtension(pi: ExtensionAPI) {
|
|
19
19
|
resetAskConfigStore();
|
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
emptyAnswer,
|
|
4
|
+
isAnswerEmpty,
|
|
5
|
+
isOptionSelected,
|
|
6
|
+
saveCustomText,
|
|
7
|
+
saveOptionNote,
|
|
8
|
+
saveQuestionNote,
|
|
9
|
+
setSingleSelection,
|
|
10
|
+
toggleSelection,
|
|
11
|
+
} from "../state/answers.ts";
|
|
12
|
+
import { toAskResult } from "../state/result.ts";
|
|
13
|
+
import type {
|
|
14
|
+
AskDisplayOption,
|
|
15
|
+
AskQuestion,
|
|
16
|
+
AskResult,
|
|
17
|
+
AskState,
|
|
18
|
+
AskStateAnswer,
|
|
19
|
+
} from "../types.ts";
|
|
20
|
+
|
|
21
|
+
type RpcUi = Pick<ExtensionContext["ui"], "editor" | "input" | "select">;
|
|
22
|
+
|
|
23
|
+
interface RpcStepResult {
|
|
24
|
+
cancelled: boolean;
|
|
25
|
+
state: AskState;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface SelectAction {
|
|
29
|
+
kind:
|
|
30
|
+
| "cancel"
|
|
31
|
+
| "custom-editor"
|
|
32
|
+
| "custom-input"
|
|
33
|
+
| "finish"
|
|
34
|
+
| "option"
|
|
35
|
+
| "skip";
|
|
36
|
+
label: string;
|
|
37
|
+
optionIndex?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface NoteAction {
|
|
41
|
+
kind:
|
|
42
|
+
| "cancel"
|
|
43
|
+
| "continue"
|
|
44
|
+
| "option-note"
|
|
45
|
+
| "question-editor"
|
|
46
|
+
| "question-input";
|
|
47
|
+
label: string;
|
|
48
|
+
optionValue?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const CANCEL_LABEL = "Cancel ask";
|
|
52
|
+
const CONTINUE_LABEL = "Continue";
|
|
53
|
+
const CUSTOM_EDITOR_LABEL = "Write or edit a multiline answer…";
|
|
54
|
+
const CUSTOM_INPUT_LABEL = "Type or edit a short answer…";
|
|
55
|
+
const FINISH_SELECTION_LABEL = "Finish selection";
|
|
56
|
+
|
|
57
|
+
export async function runRpcAskFlow(
|
|
58
|
+
ctx: { ui: RpcUi },
|
|
59
|
+
initialState: AskState
|
|
60
|
+
): Promise<AskResult> {
|
|
61
|
+
let state = initialState;
|
|
62
|
+
|
|
63
|
+
for (const [questionIndex] of state.questions.entries()) {
|
|
64
|
+
const answerStep = await askQuestion(ctx.ui, state, questionIndex);
|
|
65
|
+
state = answerStep.state;
|
|
66
|
+
if (answerStep.cancelled) {
|
|
67
|
+
return cancelledResult(state);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const noteStep = await askForOptionalNotes(ctx.ui, state, questionIndex);
|
|
71
|
+
state = noteStep.state;
|
|
72
|
+
if (noteStep.cancelled) {
|
|
73
|
+
return cancelledResult(state);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return toAskResult({
|
|
78
|
+
...state,
|
|
79
|
+
activeTabIndex: state.questions.length,
|
|
80
|
+
completed: true,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function askQuestion(
|
|
85
|
+
ui: RpcUi,
|
|
86
|
+
state: AskState,
|
|
87
|
+
questionIndex: number
|
|
88
|
+
): Promise<RpcStepResult> {
|
|
89
|
+
const question = state.questions[questionIndex];
|
|
90
|
+
if (!question) {
|
|
91
|
+
return { cancelled: false, state };
|
|
92
|
+
}
|
|
93
|
+
if (question.type === "multi") {
|
|
94
|
+
return await askMultiQuestion(ui, state, question, questionIndex);
|
|
95
|
+
}
|
|
96
|
+
return await askSingleQuestion(ui, state, question, questionIndex);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function askSingleQuestion(
|
|
100
|
+
ui: RpcUi,
|
|
101
|
+
state: AskState,
|
|
102
|
+
question: AskQuestion,
|
|
103
|
+
questionIndex: number
|
|
104
|
+
): Promise<RpcStepResult> {
|
|
105
|
+
const actions: SelectAction[] = question.options
|
|
106
|
+
.filter((option) => !option.freeform)
|
|
107
|
+
.map((option, optionIndex) => ({
|
|
108
|
+
kind: "option" as const,
|
|
109
|
+
label: formatOption(option, optionIndex),
|
|
110
|
+
optionIndex,
|
|
111
|
+
}));
|
|
112
|
+
appendCommonActions(actions, question);
|
|
113
|
+
|
|
114
|
+
const action = await selectAction(
|
|
115
|
+
ui,
|
|
116
|
+
formatTitle(state, question, questionIndex),
|
|
117
|
+
actions
|
|
118
|
+
);
|
|
119
|
+
return applySingleAction({
|
|
120
|
+
action,
|
|
121
|
+
question,
|
|
122
|
+
questionIndex,
|
|
123
|
+
state,
|
|
124
|
+
ui,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function applySingleAction(args: {
|
|
129
|
+
action: SelectAction | undefined;
|
|
130
|
+
question: AskQuestion;
|
|
131
|
+
questionIndex: number;
|
|
132
|
+
state: AskState;
|
|
133
|
+
ui: RpcUi;
|
|
134
|
+
}): Promise<RpcStepResult> {
|
|
135
|
+
const { action, question, questionIndex, state, ui } = args;
|
|
136
|
+
if (!action || action.kind === "cancel") {
|
|
137
|
+
return { cancelled: true, state };
|
|
138
|
+
}
|
|
139
|
+
if (action.kind === "skip") {
|
|
140
|
+
return { cancelled: false, state: clearAnswer(state, question.id) };
|
|
141
|
+
}
|
|
142
|
+
if (action.kind === "custom-input" || action.kind === "custom-editor") {
|
|
143
|
+
return await askForCustomAnswer(
|
|
144
|
+
ui,
|
|
145
|
+
state,
|
|
146
|
+
question,
|
|
147
|
+
questionIndex,
|
|
148
|
+
action.kind === "custom-editor"
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
if (action.kind !== "option") {
|
|
152
|
+
return { cancelled: true, state };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return selectSingleOption(state, question, action.optionIndex);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function selectSingleOption(
|
|
159
|
+
state: AskState,
|
|
160
|
+
question: AskQuestion,
|
|
161
|
+
optionIndex: number | undefined
|
|
162
|
+
): RpcStepResult {
|
|
163
|
+
const option =
|
|
164
|
+
optionIndex === undefined ? undefined : question.options[optionIndex];
|
|
165
|
+
if (!option || optionIndex === undefined) {
|
|
166
|
+
return { cancelled: true, state };
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
cancelled: false,
|
|
170
|
+
state: updateAnswer(state, question.id, (answer) =>
|
|
171
|
+
setSingleSelection(answer, option, optionIndex)
|
|
172
|
+
),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function askMultiQuestion(
|
|
177
|
+
ui: RpcUi,
|
|
178
|
+
initialState: AskState,
|
|
179
|
+
question: AskQuestion,
|
|
180
|
+
questionIndex: number
|
|
181
|
+
): Promise<RpcStepResult> {
|
|
182
|
+
let state = initialState;
|
|
183
|
+
while (true) {
|
|
184
|
+
const actions = createMultiActions(question, state.answers[question.id]);
|
|
185
|
+
const action = await selectAction(
|
|
186
|
+
ui,
|
|
187
|
+
formatTitle(state, question, questionIndex, "Select all that apply"),
|
|
188
|
+
actions
|
|
189
|
+
);
|
|
190
|
+
const actionResult = await applyMultiAction({
|
|
191
|
+
action,
|
|
192
|
+
question,
|
|
193
|
+
questionIndex,
|
|
194
|
+
state,
|
|
195
|
+
ui,
|
|
196
|
+
});
|
|
197
|
+
if (actionResult.done) {
|
|
198
|
+
return actionResult.step;
|
|
199
|
+
}
|
|
200
|
+
state = actionResult.state;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function createMultiActions(
|
|
205
|
+
question: AskQuestion,
|
|
206
|
+
answer: AskStateAnswer | undefined
|
|
207
|
+
): SelectAction[] {
|
|
208
|
+
const actions: SelectAction[] = question.options
|
|
209
|
+
.filter((option) => !option.freeform)
|
|
210
|
+
.map((option, optionIndex) => ({
|
|
211
|
+
kind: "option" as const,
|
|
212
|
+
label: formatOption(option, optionIndex, {
|
|
213
|
+
selected: isOptionSelected(answer, option.value),
|
|
214
|
+
}),
|
|
215
|
+
optionIndex,
|
|
216
|
+
}));
|
|
217
|
+
actions.push({ kind: "finish", label: FINISH_SELECTION_LABEL });
|
|
218
|
+
actions.push({
|
|
219
|
+
kind: "custom-input",
|
|
220
|
+
label: formatCustomAction(CUSTOM_INPUT_LABEL, answer),
|
|
221
|
+
});
|
|
222
|
+
actions.push({
|
|
223
|
+
kind: "custom-editor",
|
|
224
|
+
label: formatCustomAction(CUSTOM_EDITOR_LABEL, answer),
|
|
225
|
+
});
|
|
226
|
+
actions.push({ kind: "skip", label: formatSkipLabel(question) });
|
|
227
|
+
actions.push({ kind: "cancel", label: CANCEL_LABEL });
|
|
228
|
+
return actions;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
type MultiActionResult =
|
|
232
|
+
| { done: false; state: AskState }
|
|
233
|
+
| { done: true; step: RpcStepResult };
|
|
234
|
+
|
|
235
|
+
async function applyMultiAction(args: {
|
|
236
|
+
action: SelectAction | undefined;
|
|
237
|
+
question: AskQuestion;
|
|
238
|
+
questionIndex: number;
|
|
239
|
+
state: AskState;
|
|
240
|
+
ui: RpcUi;
|
|
241
|
+
}): Promise<MultiActionResult> {
|
|
242
|
+
const { action, question, questionIndex, state, ui } = args;
|
|
243
|
+
if (!action || action.kind === "cancel") {
|
|
244
|
+
return { done: true, step: { cancelled: true, state } };
|
|
245
|
+
}
|
|
246
|
+
if (action.kind === "finish") {
|
|
247
|
+
return { done: true, step: { cancelled: false, state } };
|
|
248
|
+
}
|
|
249
|
+
if (action.kind === "skip") {
|
|
250
|
+
return {
|
|
251
|
+
done: true,
|
|
252
|
+
step: { cancelled: false, state: clearAnswer(state, question.id) },
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
if (action.kind === "custom-input" || action.kind === "custom-editor") {
|
|
256
|
+
const step = await askForCustomAnswer(
|
|
257
|
+
ui,
|
|
258
|
+
state,
|
|
259
|
+
question,
|
|
260
|
+
questionIndex,
|
|
261
|
+
action.kind === "custom-editor"
|
|
262
|
+
);
|
|
263
|
+
return step.cancelled
|
|
264
|
+
? { done: true, step }
|
|
265
|
+
: { done: false, state: step.state };
|
|
266
|
+
}
|
|
267
|
+
return applyMultiOptionAction(state, question, action);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function applyMultiOptionAction(
|
|
271
|
+
state: AskState,
|
|
272
|
+
question: AskQuestion,
|
|
273
|
+
action: SelectAction
|
|
274
|
+
): MultiActionResult {
|
|
275
|
+
const optionIndex = action.optionIndex;
|
|
276
|
+
const option =
|
|
277
|
+
optionIndex === undefined ? undefined : question.options[optionIndex];
|
|
278
|
+
if (!option || optionIndex === undefined) {
|
|
279
|
+
return { done: true, step: { cancelled: true, state } };
|
|
280
|
+
}
|
|
281
|
+
return {
|
|
282
|
+
done: false,
|
|
283
|
+
state: updateAnswer(state, question.id, (currentAnswer) => {
|
|
284
|
+
const nextAnswer = toggleSelection(currentAnswer, option, optionIndex);
|
|
285
|
+
return {
|
|
286
|
+
...nextAnswer,
|
|
287
|
+
selected: [...nextAnswer.selected].sort(
|
|
288
|
+
(left, right) => left.index - right.index
|
|
289
|
+
),
|
|
290
|
+
};
|
|
291
|
+
}),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function askForCustomAnswer(
|
|
296
|
+
ui: RpcUi,
|
|
297
|
+
state: AskState,
|
|
298
|
+
question: AskQuestion,
|
|
299
|
+
questionIndex: number,
|
|
300
|
+
multiline: boolean
|
|
301
|
+
): Promise<RpcStepResult> {
|
|
302
|
+
const currentText = state.answers[question.id]?.customText;
|
|
303
|
+
const title = formatTitle(
|
|
304
|
+
state,
|
|
305
|
+
question,
|
|
306
|
+
questionIndex,
|
|
307
|
+
multiline ? "Multiline answer" : "Short answer"
|
|
308
|
+
);
|
|
309
|
+
const value = multiline
|
|
310
|
+
? await ui.editor(title, currentText ?? "")
|
|
311
|
+
: await ui.input(title, formatInputPlaceholder(currentText));
|
|
312
|
+
if (value === undefined) {
|
|
313
|
+
return { cancelled: true, state };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
cancelled: false,
|
|
318
|
+
state: updateAnswer(state, question.id, (answer) =>
|
|
319
|
+
saveCustomText(
|
|
320
|
+
answer,
|
|
321
|
+
value,
|
|
322
|
+
question.type === "multi" ? "multi" : "single"
|
|
323
|
+
)
|
|
324
|
+
),
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function askForOptionalNotes(
|
|
329
|
+
ui: RpcUi,
|
|
330
|
+
initialState: AskState,
|
|
331
|
+
questionIndex: number
|
|
332
|
+
): Promise<RpcStepResult> {
|
|
333
|
+
let state = initialState;
|
|
334
|
+
const question = state.questions[questionIndex];
|
|
335
|
+
if (!question) {
|
|
336
|
+
return { cancelled: false, state };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
while (true) {
|
|
340
|
+
const answer = state.answers[question.id];
|
|
341
|
+
const actions = createNoteActions(answer);
|
|
342
|
+
const action = await selectNoteAction(
|
|
343
|
+
ui,
|
|
344
|
+
formatTitle(state, question, questionIndex, "Optional notes"),
|
|
345
|
+
actions
|
|
346
|
+
);
|
|
347
|
+
const actionResult = await applyNoteAction({
|
|
348
|
+
action,
|
|
349
|
+
question,
|
|
350
|
+
questionIndex,
|
|
351
|
+
state,
|
|
352
|
+
ui,
|
|
353
|
+
});
|
|
354
|
+
if (actionResult.done) {
|
|
355
|
+
return actionResult.step;
|
|
356
|
+
}
|
|
357
|
+
state = actionResult.state;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function appendCommonActions(
|
|
362
|
+
actions: SelectAction[],
|
|
363
|
+
question: AskQuestion
|
|
364
|
+
): void {
|
|
365
|
+
actions.push({ kind: "custom-input", label: CUSTOM_INPUT_LABEL });
|
|
366
|
+
actions.push({ kind: "custom-editor", label: CUSTOM_EDITOR_LABEL });
|
|
367
|
+
actions.push({ kind: "skip", label: formatSkipLabel(question) });
|
|
368
|
+
actions.push({ kind: "cancel", label: CANCEL_LABEL });
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function createNoteActions(answer: AskStateAnswer | undefined): NoteAction[] {
|
|
372
|
+
return [
|
|
373
|
+
{ kind: "continue", label: CONTINUE_LABEL },
|
|
374
|
+
{
|
|
375
|
+
kind: "question-input",
|
|
376
|
+
label: answer?.note
|
|
377
|
+
? "Replace or clear the short question note…"
|
|
378
|
+
: "Add a short question note…",
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
kind: "question-editor",
|
|
382
|
+
label: answer?.note
|
|
383
|
+
? "Edit the question note in a multiline editor…"
|
|
384
|
+
: "Add a multiline question note…",
|
|
385
|
+
},
|
|
386
|
+
...(answer?.selected ?? []).map((selection) => ({
|
|
387
|
+
kind: "option-note" as const,
|
|
388
|
+
optionValue: selection.value,
|
|
389
|
+
label: answer?.optionNotes?.[selection.value]
|
|
390
|
+
? `Edit note for selected option: ${compactText(selection.label)}…`
|
|
391
|
+
: `Add note for selected option: ${compactText(selection.label)}…`,
|
|
392
|
+
})),
|
|
393
|
+
{ kind: "cancel", label: CANCEL_LABEL },
|
|
394
|
+
];
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
type NoteActionResult =
|
|
398
|
+
| { done: false; state: AskState }
|
|
399
|
+
| { done: true; step: RpcStepResult };
|
|
400
|
+
|
|
401
|
+
async function applyNoteAction(args: {
|
|
402
|
+
action: NoteAction | undefined;
|
|
403
|
+
question: AskQuestion;
|
|
404
|
+
questionIndex: number;
|
|
405
|
+
state: AskState;
|
|
406
|
+
ui: RpcUi;
|
|
407
|
+
}): Promise<NoteActionResult> {
|
|
408
|
+
const { action, question, questionIndex, state, ui } = args;
|
|
409
|
+
if (!action || action.kind === "cancel") {
|
|
410
|
+
return { done: true, step: { cancelled: true, state } };
|
|
411
|
+
}
|
|
412
|
+
if (action.kind === "continue") {
|
|
413
|
+
return { done: true, step: { cancelled: false, state } };
|
|
414
|
+
}
|
|
415
|
+
if (action.kind === "question-input") {
|
|
416
|
+
return await editQuestionNote(ui, state, question, questionIndex, false);
|
|
417
|
+
}
|
|
418
|
+
if (action.kind === "question-editor") {
|
|
419
|
+
return await editQuestionNote(ui, state, question, questionIndex, true);
|
|
420
|
+
}
|
|
421
|
+
return await editOptionNote(ui, state, question, questionIndex, action);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function editQuestionNote(
|
|
425
|
+
ui: RpcUi,
|
|
426
|
+
state: AskState,
|
|
427
|
+
question: AskQuestion,
|
|
428
|
+
questionIndex: number,
|
|
429
|
+
multiline: boolean
|
|
430
|
+
): Promise<NoteActionResult> {
|
|
431
|
+
const currentNote = state.answers[question.id]?.note;
|
|
432
|
+
const title = formatTitle(state, question, questionIndex, "Question note");
|
|
433
|
+
const value = multiline
|
|
434
|
+
? await ui.editor(title, currentNote ?? "")
|
|
435
|
+
: await ui.input(title, formatNotePlaceholder(currentNote));
|
|
436
|
+
if (value === undefined) {
|
|
437
|
+
return { done: true, step: { cancelled: true, state } };
|
|
438
|
+
}
|
|
439
|
+
return {
|
|
440
|
+
done: false,
|
|
441
|
+
state: updateAnswer(state, question.id, (answer) =>
|
|
442
|
+
saveQuestionNote(answer, value)
|
|
443
|
+
),
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async function editOptionNote(
|
|
448
|
+
ui: RpcUi,
|
|
449
|
+
state: AskState,
|
|
450
|
+
question: AskQuestion,
|
|
451
|
+
questionIndex: number,
|
|
452
|
+
action: NoteAction
|
|
453
|
+
): Promise<NoteActionResult> {
|
|
454
|
+
const optionValue = action.optionValue;
|
|
455
|
+
const option = question.options.find(
|
|
456
|
+
(candidate) => candidate.value === optionValue
|
|
457
|
+
);
|
|
458
|
+
if (!(optionValue && option)) {
|
|
459
|
+
return { done: true, step: { cancelled: true, state } };
|
|
460
|
+
}
|
|
461
|
+
const value = await ui.editor(
|
|
462
|
+
formatTitle(
|
|
463
|
+
state,
|
|
464
|
+
question,
|
|
465
|
+
questionIndex,
|
|
466
|
+
`Note for ${compactText(option.label)}`
|
|
467
|
+
),
|
|
468
|
+
state.answers[question.id]?.optionNotes?.[optionValue] ?? ""
|
|
469
|
+
);
|
|
470
|
+
if (value === undefined) {
|
|
471
|
+
return { done: true, step: { cancelled: true, state } };
|
|
472
|
+
}
|
|
473
|
+
return {
|
|
474
|
+
done: false,
|
|
475
|
+
state: updateAnswer(state, question.id, (answer) =>
|
|
476
|
+
saveOptionNote(answer, optionValue, value)
|
|
477
|
+
),
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async function selectAction(
|
|
482
|
+
ui: RpcUi,
|
|
483
|
+
title: string,
|
|
484
|
+
actions: SelectAction[]
|
|
485
|
+
): Promise<SelectAction | undefined> {
|
|
486
|
+
const selected = await ui.select(
|
|
487
|
+
title,
|
|
488
|
+
actions.map((action) => action.label)
|
|
489
|
+
);
|
|
490
|
+
return actions.find((action) => action.label === selected);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async function selectNoteAction(
|
|
494
|
+
ui: RpcUi,
|
|
495
|
+
title: string,
|
|
496
|
+
actions: NoteAction[]
|
|
497
|
+
): Promise<NoteAction | undefined> {
|
|
498
|
+
const selected = await ui.select(
|
|
499
|
+
title,
|
|
500
|
+
actions.map((action) => action.label)
|
|
501
|
+
);
|
|
502
|
+
return actions.find((action) => action.label === selected);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function updateAnswer(
|
|
506
|
+
state: AskState,
|
|
507
|
+
questionId: string,
|
|
508
|
+
mutate: (answer: AskStateAnswer) => AskStateAnswer
|
|
509
|
+
): AskState {
|
|
510
|
+
const nextAnswer = mutate(state.answers[questionId] ?? emptyAnswer());
|
|
511
|
+
const answers = { ...state.answers };
|
|
512
|
+
if (isAnswerEmpty(nextAnswer)) {
|
|
513
|
+
delete answers[questionId];
|
|
514
|
+
} else {
|
|
515
|
+
answers[questionId] = nextAnswer;
|
|
516
|
+
}
|
|
517
|
+
return { ...state, answers };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function clearAnswer(state: AskState, questionId: string): AskState {
|
|
521
|
+
if (!state.answers[questionId]) {
|
|
522
|
+
return state;
|
|
523
|
+
}
|
|
524
|
+
const answers = { ...state.answers };
|
|
525
|
+
delete answers[questionId];
|
|
526
|
+
return { ...state, answers };
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function cancelledResult(state: AskState): AskResult {
|
|
530
|
+
return toAskResult({
|
|
531
|
+
...state,
|
|
532
|
+
cancelled: true,
|
|
533
|
+
completed: true,
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function formatTitle(
|
|
538
|
+
state: AskState,
|
|
539
|
+
question: AskQuestion,
|
|
540
|
+
questionIndex: number,
|
|
541
|
+
suffix?: string
|
|
542
|
+
): string {
|
|
543
|
+
const progress = `[${questionIndex + 1}/${state.questions.length}]`;
|
|
544
|
+
const flowTitle = state.title ? `${compactText(state.title)} — ` : "";
|
|
545
|
+
const detail = suffix ? ` — ${suffix}` : "";
|
|
546
|
+
return `${progress} ${flowTitle}${compactText(question.label)}: ${compactText(question.prompt)}${detail}`;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function formatOption(
|
|
550
|
+
option: AskDisplayOption,
|
|
551
|
+
optionIndex: number,
|
|
552
|
+
options: { selected?: boolean } = {}
|
|
553
|
+
): string {
|
|
554
|
+
let marker = "";
|
|
555
|
+
if (options.selected !== undefined) {
|
|
556
|
+
marker = options.selected ? "[x] " : "[ ] ";
|
|
557
|
+
}
|
|
558
|
+
const description = option.description
|
|
559
|
+
? ` — ${compactText(option.description)}`
|
|
560
|
+
: "";
|
|
561
|
+
const preview = option.preview
|
|
562
|
+
? ` — Preview: ${compactText(option.preview)}`
|
|
563
|
+
: "";
|
|
564
|
+
return `${marker}${optionIndex + 1}. ${compactText(option.label)}${description}${preview}`;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function formatCustomAction(
|
|
568
|
+
baseLabel: string,
|
|
569
|
+
answer: AskStateAnswer | undefined
|
|
570
|
+
): string {
|
|
571
|
+
if (!(answer?.customSelected && answer.customText?.trim())) {
|
|
572
|
+
return `[ ] ${baseLabel}`;
|
|
573
|
+
}
|
|
574
|
+
return `[x] ${baseLabel} — Current: ${truncateText(answer.customText)}`;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function formatSkipLabel(question: AskQuestion): string {
|
|
578
|
+
return question.required
|
|
579
|
+
? "Skip this question (required is advisory)"
|
|
580
|
+
: "Skip this question (optional)";
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function formatInputPlaceholder(currentText: string | undefined): string {
|
|
584
|
+
return currentText?.trim()
|
|
585
|
+
? `Current: ${truncateText(currentText)}. Enter a replacement or leave blank to clear.`
|
|
586
|
+
: "Enter a short free-form answer";
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function formatNotePlaceholder(currentNote: string | undefined): string {
|
|
590
|
+
return currentNote?.trim()
|
|
591
|
+
? `Current: ${truncateText(currentNote)}. Enter a replacement or leave blank to clear.`
|
|
592
|
+
: "Add an optional note or comment";
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function compactText(value: string): string {
|
|
596
|
+
return value.replace(/\s+/g, " ").trim();
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function truncateText(value: string): string {
|
|
600
|
+
const compact = compactText(value);
|
|
601
|
+
return compact.length > 80 ? `${compact.slice(0, 77)}…` : compact;
|
|
602
|
+
}
|
package/src/ui/settings-list.ts
CHANGED
|
@@ -162,7 +162,7 @@ export class AskSettingsList {
|
|
|
162
162
|
const lines = [
|
|
163
163
|
this.topBorder(innerWidth),
|
|
164
164
|
this.line(
|
|
165
|
-
center(this.theme.fg("accent", "@
|
|
165
|
+
center(this.theme.fg("accent", "@geoqiao/pi-ask"), innerWidth),
|
|
166
166
|
innerWidth
|
|
167
167
|
),
|
|
168
168
|
this.line("", innerWidth),
|