@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 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
  [![last commit](https://badgen.net/github/last-commit/geoqiao/pi-ask)](https://github.com/geoqiao/pi-ask/commits/main)
7
7
  [![stars](https://badgen.net/github/stars/geoqiao/pi-ask)](https://github.com/geoqiao/pi-ask/stargazers)
8
8
 
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.
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
- It lets an agent pause, ask structured questions in a terminal UI, and continue with normalized answers instead of guessing.
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
  ![pi-ask demo](docs/media/pi-ask-demo.gif)
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.
@@ -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
@@ -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
- ## 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 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. 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.
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.1.0",
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": 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";
@@ -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 @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,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
+ }
@@ -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),