@geoqiao/pi-ask 1.1.0 → 1.2.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/CHANGELOG.md CHANGED
@@ -1,3 +1,22 @@
1
+ ## [1.2.1](https://github.com/geoqiao/pi-ask/compare/v1.2.0...v1.2.1) (2026-08-16)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * harden RPC fallback interactions ([4cceb3b](https://github.com/geoqiao/pi-ask/commit/4cceb3b2ab8d19b46f4688a53f8b7ae52e2b06ae))
7
+
8
+ # [1.2.0](https://github.com/geoqiao/pi-ask/compare/v1.1.0...v1.2.0) (2026-08-16)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * preserve RPC yes-no cancellation semantics ([9e5265d](https://github.com/geoqiao/pi-ask/commit/9e5265dec7ef6a990a3ac8a8aca190429a942fb7))
14
+
15
+
16
+ ### Features
17
+
18
+ * add portable RPC ask fallback ([6def4ed](https://github.com/geoqiao/pi-ask/commit/6def4ed1c803980b5da89ccfc2de1d56f54bef99))
19
+
1
20
  # [1.1.0](https://github.com/eko24ive/pi-ask/compare/v1.0.2...v1.1.0) (2026-06-22)
2
21
 
3
22
 
package/README.md CHANGED
@@ -3,17 +3,25 @@
3
3
  # @geoqiao/pi-ask
4
4
 
5
5
  [![npm downloads](https://badgen.net/npm/dm/@geoqiao/pi-ask)](https://www.npmjs.com/package/@geoqiao/pi-ask)
6
+ [![CI](https://github.com/geoqiao/pi-ask/actions/workflows/ci.yml/badge.svg)](https://github.com/geoqiao/pi-ask/actions/workflows/ci.yml)
6
7
  [![last commit](https://badgen.net/github/last-commit/geoqiao/pi-ask)](https://github.com/geoqiao/pi-ask/commits/main)
7
8
  [![stars](https://badgen.net/github/stars/geoqiao/pi-ask)](https://github.com/geoqiao/pi-ask/stargazers)
8
9
 
9
- `@geoqiao/pi-ask` is an independently maintained continuation of [`eko24ive/pi-ask`](https://github.com/eko24ive/pi-ask). It is an ask tool that cares about your answers.
10
+ > [!IMPORTANT]
11
+ > 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
12
 
11
- It lets an agent pause, ask structured questions in a terminal UI, and continue with normalized answers instead of guessing.
13
+ `@geoqiao/pi-ask` is an ask tool that cares about your answers.
14
+
15
+ 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
16
 
13
17
  ![pi-ask demo](docs/media/pi-ask-demo.gif)
14
18
 
15
19
  High-quality video: [demo.mp4](https://github.com/user-attachments/assets/a8503ca9-afcb-4c31-9edc-353b985a0209)
16
20
 
21
+ ## Upstream and contributions
22
+
23
+ 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.
24
+
17
25
  ## Install
18
26
 
19
27
  ```bash
@@ -34,7 +42,7 @@ pi -e npm:@geoqiao/pi-ask
34
42
 
35
43
  ## Features
36
44
 
37
- Once installed, this package gives the agent a native way to ask for clarification instead of guessing.
45
+ 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
46
 
39
47
  - 🧭 Familiar ask-style interface: tabbed questions, single/multi select, and preview mode
40
48
  - ✍️ Inline free-form `Type your own` answers
@@ -42,6 +50,7 @@ Once installed, this package gives the agent a native way to ask for clarificati
42
50
  - 📝 Question-level and option-level notes
43
51
  - 👀 Review tab with `Submit`, `Elaborate`, and `Cancel`
44
52
  - 💬 Elaboration flow to capture note-based clarification before final submission
53
+ - 🔌 Pi RPC fallback using portable sequential dialogs with normalized results
45
54
  - ⌨️ Context-aware customizable keymaps with aliases for main flow, editors, and settings
46
55
  - ⚙️ Ask settings with persisted behaviour, notifications, keymaps, and `/answer` extraction config
47
56
  - 🔔 Optional external notifications when an ask flow is waiting for input
@@ -93,7 +102,7 @@ Capture free-form input inline without leaving the flow.
93
102
 
94
103
  Open ask settings with `?` during the ask flow, or with the `/ask-settings` command from pi.
95
104
 
96
- Keymaps are context-aware and configurable in `~/.pi/agent/extensions/eko24ive-pi-ask.json`.
105
+ 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
106
  Each action accepts a key string or an array of aliases.
98
107
 
99
108
  Default contexts:
@@ -188,6 +197,21 @@ After installation, the extension registers the `ask_user` tool plus `/ask-setti
188
197
 
189
198
  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
199
 
200
+ ### Pi RPC fallback
201
+
202
+ When Pi runs in RPC mode with portable extension UI support, `ask_user` keeps the same normalized result contract but uses sequential dialogs:
203
+
204
+ - single choices, including Yes/No pairs, use cancellable `select` dialogs
205
+ - short custom answers use `input`; multiline answers use `editor`
206
+ - each question offers explicit Skip and Cancel actions, followed by optional question/selected-option note actions
207
+ - multi-select repeats `select` with `[ ]` / `[x]` markers until `Finish selection` is chosen
208
+ - descriptions and preview content are flattened into readable option strings
209
+ - multiple questions include `[current/total]` progress in each dialog title
210
+ - dismissing a question or notes-menu `select` cancels the flow, while dismissing a nested answer/note editor returns to its parent dialog
211
+ - tool abort signals close portable `select`/`input` dialogs; Pi's portable `editor` cannot be interrupted until it resolves
212
+
213
+ 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.
214
+
191
215
  ### Answer and replay commands
192
216
 
193
217
  `/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.
@@ -1,6 +1,6 @@
1
1
  # pi-ask configuration
2
2
 
3
- This file is the source of truth for configuring `@eko24ive/pi-ask`.
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
@@ -160,7 +160,7 @@ This document defines the stable external behavior. It does not explain internal
160
160
 
161
161
  ## Output rules
162
162
 
163
- - `cancelled: true` means the user dismissed the flow, UI was unavailable, or the payload was invalid before UI opened
163
+ - `cancelled: true` means the user dismissed a flow-level selection, chose Cancel, aborted the flow, UI was unavailable, or the payload was invalid before UI opened
164
164
  - invalid payloads return `error.kind === "invalid_input"` with structured `issues` and a transcript-friendly `Invalid ask_user payload:` message
165
165
  - `mode: "submit"` is normal completion; `mode: "elaborate"` means the user asked the agent to continue with follow-up clarification based on notes
166
166
  - unanswered questions are omitted from `answers`
@@ -175,7 +175,7 @@ This document defines the stable external behavior. It does not explain internal
175
175
  - `customText` stores the free-form answer
176
176
  - on single-select questions, saving free-form text clears selected options for that question
177
177
  - on multi-select questions, `values` and `labels` include both selected options and `customText` when both are present
178
- - on multi-select questions, selected options keep their original order and `customText` is appended last
178
+ - on multi-select questions, selected options keep the user's selection order and `customText` is appended last
179
179
  - submitting free-form text on a multi-select question stays on the same question tab and marks the custom row selected
180
180
  - on multi-select questions, toggling an empty custom row opens the free-form editor, while toggling a custom row with saved free-form text selects or deselects it without opening the editor or clearing the text
181
181
  - saving or clearing free-form text on a multi-select question does not clear other selected options
@@ -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,24 @@ 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
- ## Non-TUI and non-interactive modes
258
+ ## Runtime modes
259
259
 
260
- The rich ask flow uses `ctx.ui.custom()` and opens only in TUI mode. In print, JSON, RPC, or any other non-TUI mode, 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 custom UI.
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 the user's selection 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 question or optional-notes action `select`, choosing Cancel, or aborting the tool returns `cancelled: true`
270
+ - dismissing a nested custom-answer or note `input`/`editor` abandons that edit and returns to its parent action dialog without changing the answer
271
+ - `select` and `input` observe the tool abort signal; Pi's portable `editor` API has no signal or timeout option, so an abort is observed only after an open editor resolves
272
+
273
+ 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.
274
+
275
+ 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
276
 
262
277
  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
278
 
@@ -265,20 +280,22 @@ The ask flow subscribes to runtime settings updates while open. In practice, thi
265
280
 
266
281
  ## Notifications
267
282
 
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.
283
+ 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
284
 
270
285
  ## Remote inter-extension events
271
286
 
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. RPC or headless integrations should use a trusted in-process bridge extension that consumes these events rather than expecting the TUI-only custom surface to open.
287
+ 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
288
 
274
289
  Channels:
275
290
 
291
+ The established event namespace is retained for compatibility with existing trusted integrations.
292
+
276
293
  - `@eko24ive/pi-ask:started`
277
294
  - `@eko24ive/pi-ask:completed`
278
295
  - `@eko24ive/pi-ask:submit`
279
296
  - `@eko24ive/pi-ask:submit-result`
280
297
 
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.
298
+ 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
299
 
283
300
  See [`remote-events.md`](remote-events.md) for payload shapes, examples, and a local smoke test.
284
301
 
@@ -292,7 +309,7 @@ See [`remote-events.md`](remote-events.md) for payload shapes, examples, and a l
292
309
  - submitted or elaborated command-flow results are sent back with user-message semantics
293
310
  - replay commands scan only `ctx.sessionManager.getBranch()`, ignore sibling/future branch payloads, and revalidate stored payloads before opening the UI
294
311
 
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.
312
+ 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
313
 
297
314
  ## Skill alignment (advisory)
298
315
 
@@ -18,7 +18,7 @@ Remote submit:
18
18
 
19
19
  ## Started
20
20
 
21
- Emitted after a validated ask UI flow opens.
21
+ Emitted after a validated TUI ask flow opens. The RPC portable-dialog fallback does not start the remote event bridge or emit lifecycle events.
22
22
 
23
23
  ```ts
24
24
  type PiAskStartedEvent = {
@@ -104,7 +104,7 @@ Correlate by `requestId` and `flowId`. Do not depend on strict ordering between
104
104
 
105
105
  ## Completed
106
106
 
107
- Emitted when the flow resolves.
107
+ Emitted when the TUI flow resolves. RPC portable-dialog completion is returned through the normal tool result instead.
108
108
 
109
109
  ```ts
110
110
  type PiAskCompletedEvent = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geoqiao/pi-ask",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
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": false
57
+ "provenance": true
57
58
  },
58
59
  "release": {
59
60
  "repositoryUrl": "https://github.com/geoqiao/pi-ask.git",
@@ -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. Supports single-select, multi-select, and preview-pane questions. Always include a machine-readable `value` for every option. Use `preview` only when every option includes `preview` text; descriptions alone are not enough.";
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";
@@ -50,7 +51,7 @@ async function executeAskTool(
50
51
  pi: Pick<ExtensionAPI, "appendEntry">,
51
52
  toolCallId: string,
52
53
  params: AskParams,
53
- _signal: AbortSignal | undefined,
54
+ signal: AbortSignal | undefined,
54
55
  _onUpdate: unknown,
55
56
  ctx: ExtensionContext,
56
57
  remoteAsk?: RemoteAskRuntime
@@ -68,6 +69,11 @@ 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(
74
+ await runRpcAskFlow(ctx, validation.state, { signal })
75
+ );
76
+ }
71
77
  return nonInteractiveResponse(validation.state);
72
78
  }
73
79
  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 @eko24ive/pi-ask settings or keymaps, first read ${CONFIGURATION_DOC_PATH} and follow it as the source of truth before editing config files.`;
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,701 @@
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 RpcFlowOptions {
24
+ signal?: AbortSignal;
25
+ }
26
+
27
+ type RpcCompletionResult =
28
+ | { kind: "cancelled"; state: AskState }
29
+ | { kind: "completed"; state: AskState };
30
+
31
+ type RpcActionResult = RpcCompletionResult | { kind: "back"; state: AskState };
32
+
33
+ type RpcEditResult =
34
+ | { kind: "back"; state: AskState }
35
+ | { kind: "cancelled"; state: AskState }
36
+ | { kind: "saved"; state: AskState };
37
+
38
+ interface SelectAction {
39
+ kind:
40
+ | "cancel"
41
+ | "custom-editor"
42
+ | "custom-input"
43
+ | "finish"
44
+ | "option"
45
+ | "skip";
46
+ label: string;
47
+ optionIndex?: number;
48
+ }
49
+
50
+ interface NoteAction {
51
+ kind:
52
+ | "cancel"
53
+ | "continue"
54
+ | "option-note"
55
+ | "question-editor"
56
+ | "question-input";
57
+ label: string;
58
+ optionValue?: string;
59
+ }
60
+
61
+ const CANCEL_LABEL = "Cancel ask";
62
+ const CONTINUE_LABEL = "Continue";
63
+ const CUSTOM_EDITOR_LABEL = "Write or edit a multiline answer…";
64
+ const CUSTOM_INPUT_LABEL = "Type or edit a short answer…";
65
+ const FINISH_SELECTION_LABEL = "Finish selection";
66
+
67
+ export async function runRpcAskFlow(
68
+ ctx: { ui: RpcUi },
69
+ initialState: AskState,
70
+ options: RpcFlowOptions = {}
71
+ ): Promise<AskResult> {
72
+ let state = initialState;
73
+
74
+ for (const [questionIndex] of state.questions.entries()) {
75
+ if (options.signal?.aborted) {
76
+ return cancelledResult(state);
77
+ }
78
+ const answerStep = await askQuestion(
79
+ ctx.ui,
80
+ state,
81
+ questionIndex,
82
+ options.signal
83
+ );
84
+ state = answerStep.state;
85
+ if (answerStep.kind === "cancelled") {
86
+ return cancelledResult(state);
87
+ }
88
+
89
+ const noteStep = await askForOptionalNotes(
90
+ ctx.ui,
91
+ state,
92
+ questionIndex,
93
+ options.signal
94
+ );
95
+ state = noteStep.state;
96
+ if (noteStep.kind === "cancelled") {
97
+ return cancelledResult(state);
98
+ }
99
+ }
100
+ if (options.signal?.aborted) {
101
+ return cancelledResult(state);
102
+ }
103
+
104
+ return toAskResult({
105
+ ...state,
106
+ activeTabIndex: state.questions.length,
107
+ completed: true,
108
+ });
109
+ }
110
+
111
+ async function askQuestion(
112
+ ui: RpcUi,
113
+ state: AskState,
114
+ questionIndex: number,
115
+ signal?: AbortSignal
116
+ ): Promise<RpcCompletionResult> {
117
+ const question = state.questions[questionIndex];
118
+ if (!question) {
119
+ return { kind: "completed", state };
120
+ }
121
+ if (question.type === "multi") {
122
+ return await askMultiQuestion(ui, state, question, questionIndex, signal);
123
+ }
124
+ return await askSingleQuestion(ui, state, question, questionIndex, signal);
125
+ }
126
+
127
+ async function askSingleQuestion(
128
+ ui: RpcUi,
129
+ initialState: AskState,
130
+ question: AskQuestion,
131
+ questionIndex: number,
132
+ signal?: AbortSignal
133
+ ): Promise<RpcCompletionResult> {
134
+ let state = initialState;
135
+ while (true) {
136
+ if (signal?.aborted) {
137
+ return { kind: "cancelled", state };
138
+ }
139
+ const actions: SelectAction[] = [...question.options.entries()]
140
+ .filter(([, option]) => !option.freeform)
141
+ .map(([optionIndex, option]) => ({
142
+ kind: "option" as const,
143
+ label: formatOption(option, optionIndex),
144
+ optionIndex,
145
+ }));
146
+ appendCommonActions(actions, question);
147
+
148
+ const action = await selectAction(
149
+ ui,
150
+ formatTitle(state, question, questionIndex),
151
+ actions,
152
+ signal
153
+ );
154
+ const result = await applySingleAction({
155
+ action,
156
+ question,
157
+ questionIndex,
158
+ signal,
159
+ state,
160
+ ui,
161
+ });
162
+ if (result.kind !== "back") {
163
+ return result;
164
+ }
165
+ state = result.state;
166
+ }
167
+ }
168
+
169
+ async function applySingleAction(args: {
170
+ action: SelectAction | undefined;
171
+ question: AskQuestion;
172
+ questionIndex: number;
173
+ signal?: AbortSignal;
174
+ state: AskState;
175
+ ui: RpcUi;
176
+ }): Promise<RpcActionResult> {
177
+ const { action, question, questionIndex, signal, state, ui } = args;
178
+ if (!action || action.kind === "cancel") {
179
+ return { kind: "cancelled", state };
180
+ }
181
+ if (action.kind === "skip") {
182
+ return {
183
+ kind: "completed",
184
+ state: clearAnswer(state, question.id),
185
+ };
186
+ }
187
+ if (action.kind === "custom-input" || action.kind === "custom-editor") {
188
+ const editResult = await askForCustomAnswer(
189
+ ui,
190
+ state,
191
+ question,
192
+ questionIndex,
193
+ action.kind === "custom-editor",
194
+ signal
195
+ );
196
+ return editResult.kind === "saved"
197
+ ? { kind: "completed", state: editResult.state }
198
+ : editResult;
199
+ }
200
+ if (action.kind !== "option") {
201
+ return { kind: "cancelled", state };
202
+ }
203
+
204
+ return selectSingleOption(state, question, action.optionIndex);
205
+ }
206
+
207
+ function selectSingleOption(
208
+ state: AskState,
209
+ question: AskQuestion,
210
+ optionIndex: number | undefined
211
+ ): RpcCompletionResult {
212
+ const option =
213
+ optionIndex === undefined ? undefined : question.options[optionIndex];
214
+ if (!option || optionIndex === undefined) {
215
+ return { kind: "cancelled", state };
216
+ }
217
+ return {
218
+ kind: "completed",
219
+ state: updateAnswer(state, question.id, (answer) =>
220
+ setSingleSelection(answer, option, optionIndex)
221
+ ),
222
+ };
223
+ }
224
+
225
+ async function askMultiQuestion(
226
+ ui: RpcUi,
227
+ initialState: AskState,
228
+ question: AskQuestion,
229
+ questionIndex: number,
230
+ signal?: AbortSignal
231
+ ): Promise<RpcCompletionResult> {
232
+ let state = initialState;
233
+ while (true) {
234
+ if (signal?.aborted) {
235
+ return { kind: "cancelled", state };
236
+ }
237
+ const actions = createMultiActions(question, state.answers[question.id]);
238
+ const action = await selectAction(
239
+ ui,
240
+ formatTitle(state, question, questionIndex, "Select all that apply"),
241
+ actions,
242
+ signal
243
+ );
244
+ const actionResult = await applyMultiAction({
245
+ action,
246
+ question,
247
+ questionIndex,
248
+ signal,
249
+ state,
250
+ ui,
251
+ });
252
+ if (actionResult.kind !== "back") {
253
+ return actionResult;
254
+ }
255
+ state = actionResult.state;
256
+ }
257
+ }
258
+
259
+ function createMultiActions(
260
+ question: AskQuestion,
261
+ answer: AskStateAnswer | undefined
262
+ ): SelectAction[] {
263
+ const actions: SelectAction[] = [...question.options.entries()]
264
+ .filter(([, option]) => !option.freeform)
265
+ .map(([optionIndex, option]) => ({
266
+ kind: "option" as const,
267
+ label: formatOption(option, optionIndex, {
268
+ selected: isOptionSelected(answer, option.value),
269
+ }),
270
+ optionIndex,
271
+ }));
272
+ actions.push({ kind: "finish", label: FINISH_SELECTION_LABEL });
273
+ actions.push({
274
+ kind: "custom-input",
275
+ label: formatCustomAction(CUSTOM_INPUT_LABEL, answer),
276
+ });
277
+ actions.push({
278
+ kind: "custom-editor",
279
+ label: formatCustomAction(CUSTOM_EDITOR_LABEL, answer),
280
+ });
281
+ actions.push({ kind: "skip", label: formatSkipLabel(question) });
282
+ actions.push({ kind: "cancel", label: CANCEL_LABEL });
283
+ return actions;
284
+ }
285
+
286
+ async function applyMultiAction(args: {
287
+ action: SelectAction | undefined;
288
+ question: AskQuestion;
289
+ questionIndex: number;
290
+ signal?: AbortSignal;
291
+ state: AskState;
292
+ ui: RpcUi;
293
+ }): Promise<RpcActionResult> {
294
+ const { action, question, questionIndex, signal, state, ui } = args;
295
+ if (!action || action.kind === "cancel") {
296
+ return { kind: "cancelled", state };
297
+ }
298
+ if (action.kind === "finish") {
299
+ return { kind: "completed", state };
300
+ }
301
+ if (action.kind === "skip") {
302
+ return {
303
+ kind: "completed",
304
+ state: clearAnswer(state, question.id),
305
+ };
306
+ }
307
+ if (action.kind === "custom-input" || action.kind === "custom-editor") {
308
+ const editResult = await askForCustomAnswer(
309
+ ui,
310
+ state,
311
+ question,
312
+ questionIndex,
313
+ action.kind === "custom-editor",
314
+ signal
315
+ );
316
+ return editResult.kind === "cancelled"
317
+ ? editResult
318
+ : { kind: "back", state: editResult.state };
319
+ }
320
+ return applyMultiOptionAction(state, question, action);
321
+ }
322
+
323
+ function applyMultiOptionAction(
324
+ state: AskState,
325
+ question: AskQuestion,
326
+ action: SelectAction
327
+ ): RpcActionResult {
328
+ const optionIndex = action.optionIndex;
329
+ const option =
330
+ optionIndex === undefined ? undefined : question.options[optionIndex];
331
+ if (!option || optionIndex === undefined) {
332
+ return { kind: "cancelled", state };
333
+ }
334
+ return {
335
+ kind: "back",
336
+ state: updateAnswer(state, question.id, (currentAnswer) =>
337
+ toggleSelection(currentAnswer, option, optionIndex)
338
+ ),
339
+ };
340
+ }
341
+
342
+ async function askForCustomAnswer(
343
+ ui: RpcUi,
344
+ state: AskState,
345
+ question: AskQuestion,
346
+ questionIndex: number,
347
+ multiline: boolean,
348
+ signal?: AbortSignal
349
+ ): Promise<RpcEditResult> {
350
+ if (signal?.aborted) {
351
+ return { kind: "cancelled", state };
352
+ }
353
+ const currentText = state.answers[question.id]?.customText;
354
+ const title = formatTitle(
355
+ state,
356
+ question,
357
+ questionIndex,
358
+ multiline ? "Multiline answer" : "Short answer"
359
+ );
360
+ const value = multiline
361
+ ? await ui.editor(title, currentText ?? "")
362
+ : await ui.input(title, formatInputPlaceholder(currentText), { signal });
363
+ if (signal?.aborted) {
364
+ return { kind: "cancelled", state };
365
+ }
366
+ if (value === undefined) {
367
+ return { kind: "back", state };
368
+ }
369
+
370
+ return {
371
+ kind: "saved",
372
+ state: updateAnswer(state, question.id, (answer) =>
373
+ saveCustomText(
374
+ answer,
375
+ value,
376
+ question.type === "multi" ? "multi" : "single"
377
+ )
378
+ ),
379
+ };
380
+ }
381
+
382
+ async function askForOptionalNotes(
383
+ ui: RpcUi,
384
+ initialState: AskState,
385
+ questionIndex: number,
386
+ signal?: AbortSignal
387
+ ): Promise<RpcCompletionResult> {
388
+ let state = initialState;
389
+ const question = state.questions[questionIndex];
390
+ if (!question) {
391
+ return { kind: "completed", state };
392
+ }
393
+
394
+ while (true) {
395
+ if (signal?.aborted) {
396
+ return { kind: "cancelled", state };
397
+ }
398
+ const answer = state.answers[question.id];
399
+ const actions = createNoteActions(answer);
400
+ const action = await selectNoteAction(
401
+ ui,
402
+ formatTitle(state, question, questionIndex, "Optional notes"),
403
+ actions,
404
+ signal
405
+ );
406
+ const actionResult = await applyNoteAction({
407
+ action,
408
+ question,
409
+ questionIndex,
410
+ signal,
411
+ state,
412
+ ui,
413
+ });
414
+ if (actionResult.kind !== "back") {
415
+ return actionResult;
416
+ }
417
+ state = actionResult.state;
418
+ }
419
+ }
420
+
421
+ function appendCommonActions(
422
+ actions: SelectAction[],
423
+ question: AskQuestion
424
+ ): void {
425
+ actions.push({ kind: "custom-input", label: CUSTOM_INPUT_LABEL });
426
+ actions.push({ kind: "custom-editor", label: CUSTOM_EDITOR_LABEL });
427
+ actions.push({ kind: "skip", label: formatSkipLabel(question) });
428
+ actions.push({ kind: "cancel", label: CANCEL_LABEL });
429
+ }
430
+
431
+ function createNoteActions(answer: AskStateAnswer | undefined): NoteAction[] {
432
+ return [
433
+ { kind: "continue", label: CONTINUE_LABEL },
434
+ {
435
+ kind: "question-input",
436
+ label: answer?.note
437
+ ? "Replace or clear the short question note…"
438
+ : "Add a short question note…",
439
+ },
440
+ {
441
+ kind: "question-editor",
442
+ label: answer?.note
443
+ ? "Edit the question note in a multiline editor…"
444
+ : "Add a multiline question note…",
445
+ },
446
+ ...(answer?.selected ?? []).map((selection) => ({
447
+ kind: "option-note" as const,
448
+ optionValue: selection.value,
449
+ label: answer?.optionNotes?.[selection.value]
450
+ ? `Edit note for selected option: ${selection.index}. ${compactText(selection.label)}…`
451
+ : `Add note for selected option: ${selection.index}. ${compactText(selection.label)}…`,
452
+ })),
453
+ { kind: "cancel", label: CANCEL_LABEL },
454
+ ];
455
+ }
456
+
457
+ async function applyNoteAction(args: {
458
+ action: NoteAction | undefined;
459
+ question: AskQuestion;
460
+ questionIndex: number;
461
+ signal?: AbortSignal;
462
+ state: AskState;
463
+ ui: RpcUi;
464
+ }): Promise<RpcActionResult> {
465
+ const { action, question, questionIndex, signal, state, ui } = args;
466
+ if (!action || action.kind === "cancel") {
467
+ return { kind: "cancelled", state };
468
+ }
469
+ if (action.kind === "continue") {
470
+ return { kind: "completed", state };
471
+ }
472
+ if (action.kind === "question-input") {
473
+ return noteEditActionResult(
474
+ await editQuestionNote(ui, state, question, questionIndex, false, signal)
475
+ );
476
+ }
477
+ if (action.kind === "question-editor") {
478
+ return noteEditActionResult(
479
+ await editQuestionNote(ui, state, question, questionIndex, true, signal)
480
+ );
481
+ }
482
+ return noteEditActionResult(
483
+ await editOptionNote(ui, state, question, questionIndex, action, signal)
484
+ );
485
+ }
486
+
487
+ function noteEditActionResult(result: RpcEditResult): RpcActionResult {
488
+ return result.kind === "cancelled"
489
+ ? result
490
+ : { kind: "back", state: result.state };
491
+ }
492
+
493
+ async function editQuestionNote(
494
+ ui: RpcUi,
495
+ state: AskState,
496
+ question: AskQuestion,
497
+ questionIndex: number,
498
+ multiline: boolean,
499
+ signal?: AbortSignal
500
+ ): Promise<RpcEditResult> {
501
+ if (signal?.aborted) {
502
+ return { kind: "cancelled", state };
503
+ }
504
+ const currentNote = state.answers[question.id]?.note;
505
+ const title = formatTitle(state, question, questionIndex, "Question note");
506
+ const value = multiline
507
+ ? await ui.editor(title, currentNote ?? "")
508
+ : await ui.input(title, formatNotePlaceholder(currentNote), { signal });
509
+ if (signal?.aborted) {
510
+ return { kind: "cancelled", state };
511
+ }
512
+ if (value === undefined) {
513
+ return { kind: "back", state };
514
+ }
515
+ return {
516
+ kind: "saved",
517
+ state: updateAnswer(state, question.id, (answer) =>
518
+ saveQuestionNote(answer, value)
519
+ ),
520
+ };
521
+ }
522
+
523
+ async function editOptionNote(
524
+ ui: RpcUi,
525
+ state: AskState,
526
+ question: AskQuestion,
527
+ questionIndex: number,
528
+ action: NoteAction,
529
+ signal?: AbortSignal
530
+ ): Promise<RpcEditResult> {
531
+ if (signal?.aborted) {
532
+ return { kind: "cancelled", state };
533
+ }
534
+ const optionValue = action.optionValue;
535
+ const option = question.options.find(
536
+ (candidate) => candidate.value === optionValue
537
+ );
538
+ if (!(optionValue && option)) {
539
+ return { kind: "cancelled", state };
540
+ }
541
+ const value = await ui.editor(
542
+ formatTitle(
543
+ state,
544
+ question,
545
+ questionIndex,
546
+ `Note for ${compactText(option.label)}`
547
+ ),
548
+ state.answers[question.id]?.optionNotes?.[optionValue] ?? ""
549
+ );
550
+ if (signal?.aborted) {
551
+ return { kind: "cancelled", state };
552
+ }
553
+ if (value === undefined) {
554
+ return { kind: "back", state };
555
+ }
556
+ return {
557
+ kind: "saved",
558
+ state: updateAnswer(state, question.id, (answer) =>
559
+ saveOptionNote(answer, optionValue, value)
560
+ ),
561
+ };
562
+ }
563
+
564
+ async function selectAction(
565
+ ui: RpcUi,
566
+ title: string,
567
+ actions: SelectAction[],
568
+ signal?: AbortSignal
569
+ ): Promise<SelectAction | undefined> {
570
+ if (signal?.aborted) {
571
+ return;
572
+ }
573
+ const selected = await ui.select(
574
+ title,
575
+ actions.map((action) => action.label),
576
+ { signal }
577
+ );
578
+ if (signal?.aborted) {
579
+ return;
580
+ }
581
+ return actions.find((action) => action.label === selected);
582
+ }
583
+
584
+ async function selectNoteAction(
585
+ ui: RpcUi,
586
+ title: string,
587
+ actions: NoteAction[],
588
+ signal?: AbortSignal
589
+ ): Promise<NoteAction | undefined> {
590
+ if (signal?.aborted) {
591
+ return;
592
+ }
593
+ const selected = await ui.select(
594
+ title,
595
+ actions.map((action) => action.label),
596
+ { signal }
597
+ );
598
+ if (signal?.aborted) {
599
+ return;
600
+ }
601
+ return actions.find((action) => action.label === selected);
602
+ }
603
+
604
+ function updateAnswer(
605
+ state: AskState,
606
+ questionId: string,
607
+ mutate: (answer: AskStateAnswer) => AskStateAnswer
608
+ ): AskState {
609
+ const nextAnswer = mutate(state.answers[questionId] ?? emptyAnswer());
610
+ const answers = { ...state.answers };
611
+ if (isAnswerEmpty(nextAnswer)) {
612
+ delete answers[questionId];
613
+ } else {
614
+ answers[questionId] = nextAnswer;
615
+ }
616
+ return { ...state, answers };
617
+ }
618
+
619
+ function clearAnswer(state: AskState, questionId: string): AskState {
620
+ if (!state.answers[questionId]) {
621
+ return state;
622
+ }
623
+ const answers = { ...state.answers };
624
+ delete answers[questionId];
625
+ return { ...state, answers };
626
+ }
627
+
628
+ function cancelledResult(state: AskState): AskResult {
629
+ return toAskResult({
630
+ ...state,
631
+ cancelled: true,
632
+ completed: true,
633
+ });
634
+ }
635
+
636
+ function formatTitle(
637
+ state: AskState,
638
+ question: AskQuestion,
639
+ questionIndex: number,
640
+ suffix?: string
641
+ ): string {
642
+ const progress = `[${questionIndex + 1}/${state.questions.length}]`;
643
+ const flowTitle = state.title ? `${compactText(state.title)} — ` : "";
644
+ const detail = suffix ? ` — ${suffix}` : "";
645
+ return `${progress} ${flowTitle}${compactText(question.label)}: ${compactText(question.prompt)}${detail}`;
646
+ }
647
+
648
+ function formatOption(
649
+ option: AskDisplayOption,
650
+ optionIndex: number,
651
+ options: { selected?: boolean } = {}
652
+ ): string {
653
+ let marker = "";
654
+ if (options.selected !== undefined) {
655
+ marker = options.selected ? "[x] " : "[ ] ";
656
+ }
657
+ const description = option.description
658
+ ? ` — ${compactText(option.description)}`
659
+ : "";
660
+ const preview = option.preview
661
+ ? ` — Preview: ${compactText(option.preview)}`
662
+ : "";
663
+ return `${marker}${optionIndex + 1}. ${compactText(option.label)}${description}${preview}`;
664
+ }
665
+
666
+ function formatCustomAction(
667
+ baseLabel: string,
668
+ answer: AskStateAnswer | undefined
669
+ ): string {
670
+ if (!(answer?.customSelected && answer.customText?.trim())) {
671
+ return `[ ] ${baseLabel}`;
672
+ }
673
+ return `[x] ${baseLabel} — Current: ${truncateText(answer.customText)}`;
674
+ }
675
+
676
+ function formatSkipLabel(question: AskQuestion): string {
677
+ return question.required
678
+ ? "Skip this question (required is advisory)"
679
+ : "Skip this question (optional)";
680
+ }
681
+
682
+ function formatInputPlaceholder(currentText: string | undefined): string {
683
+ return currentText?.trim()
684
+ ? `Current: ${truncateText(currentText)}. Enter a replacement or leave blank to clear.`
685
+ : "Enter a short free-form answer";
686
+ }
687
+
688
+ function formatNotePlaceholder(currentNote: string | undefined): string {
689
+ return currentNote?.trim()
690
+ ? `Current: ${truncateText(currentNote)}. Enter a replacement or leave blank to clear.`
691
+ : "Add an optional note or comment";
692
+ }
693
+
694
+ function compactText(value: string): string {
695
+ return value.replace(/\s+/g, " ").trim();
696
+ }
697
+
698
+ function truncateText(value: string): string {
699
+ const compact = compactText(value);
700
+ return compact.length > 80 ? `${compact.slice(0, 77)}…` : compact;
701
+ }
@@ -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", "@eko24ive/pi-ask"), innerWidth),
165
+ center(this.theme.fg("accent", "@geoqiao/pi-ask"), innerWidth),
166
166
  innerWidth
167
167
  ),
168
168
  this.line("", innerWidth),