pi-ask-popup 0.2.1 → 0.3.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/README.md CHANGED
@@ -25,7 +25,7 @@ Give the model a task with a real decision in it:
25
25
 
26
26
  > Add caching to the API client.
27
27
 
28
- Rather than picking for you, the model calls `ask_user_question` and a dialog takes over the bottom of your terminal. Move with `Up` and `Down`, pick with `Enter`, or land on `Type something.` to answer in your own words. While typing, `Shift+Enter` adds a line, `Ctrl+G` opens Pi's external editor, `Ctrl+U` clears the draft, and `Esc` cancels the whole questionnaire. Pressing `n` adds a note to the current question. The questionnaire stays in one place until you submit.
28
+ Rather than picking for you, the model calls `ask_user_question` and a dialog takes over the bottom of your terminal. Move with `Up` and `Down`, pick with `Enter`, or land on `Type something.` to answer in your own words. If the question needs a conversation instead of an answer, `Chat about this` closes the dialog and the two of you talk it out in chat. While typing, `Shift+Enter` adds a line, `Ctrl+G` opens Pi's external editor, `Ctrl+U` clears the draft, and `Esc` cancels the whole questionnaire. Pressing `n` adds a note to the current question. The questionnaire stays in one place until you submit.
29
29
 
30
30
  When the model asks several things at once, `Tab` moves between questions and a Submit tab reviews everything before it goes back:
31
31
 
@@ -39,6 +39,7 @@ A note written on a question you never answer still reaches the model, and a glo
39
39
 
40
40
  - **Typed options, not a wall of prose.** Each question carries 2 to 4 authored choices, and every choice explains what it means or what it costs you.
41
41
  - **You can always answer in your own words.** A `Type something.` row is added to every question and widens to the full pane while you type. On a multi-select question it ticks itself the moment you type into it, and what you wrote is submitted alongside whatever boxes you ticked. Clear the text and the tick goes with it.
42
+ - **Talk instead of guessing.** A `Chat about this` row sits on every question. Pick it when the question itself needs discussing: the dialog closes, your answers so far stay with the model, and it waits for what you type next in chat before answering that question.
42
43
  - **Compare artifacts, not labels.** An option can carry a markdown `preview` that renders in a bordered box beside the option list.
43
44
  - **One interruption, not five.** Up to four questions arrive in a single tabbed dialog, and a Submit tab names anything still blank before you commit.
44
45
  - **Notes on any answer, or on all of them.** `n` opens a note editor on any question tab, and on the Submit tab it writes one note covering everything. A written note stays on its tab, dimmed, and the tab bar marks which tabs carry one. A note on a question you never answer still reaches the model as `unansweredNotes`.
package/docs/hosts.md CHANGED
@@ -31,7 +31,7 @@ The walk asks one question per dialog and returns the same result shape the TUI
31
31
  - No side by side preview pane. Previews are folded into the dialog title instead, truncated at 600 characters each.
32
32
  - No tab bar and no Submit review tab. One dialog per question, in order.
33
33
  - No notes. Both note types, per-question `n` on a question tab and global `n` on the Submit tab, are terminal-only. The host's native `select` and `input` have no note field.
34
- - Multi-select is a free-text input: type the option numbers, comma separated (`1,3`). Any token that is not a valid option index is treated as a typed custom answer, which is how the `Type something.` escape survives. An empty input commits an empty selection, matching `Next` with nothing checked.
34
+ - Multi-select is a free-text input: type the option numbers, comma separated (`1,3`). Any token that is not a valid option index is treated as a typed custom answer, which is how the `Type something.` escape survives. An empty input commits an empty selection, matching `Next` with nothing checked. The exact word `chat` (any capitalisation) is the `Chat about this` escape: it closes the walk with a `chatRequested` marker for that question, like the row does in the TUI.
35
35
  - Closing any dialog cancels the whole questionnaire, the same as `Esc` in the TUI.
36
36
 
37
37
  If the host can render neither custom UI nor dialogs, the call returns `error: "no_custom_ui"` with text telling the model the user never saw the questions and to ask them as plain chat text instead. This is not a decline.
@@ -49,6 +49,7 @@ Some parts of the dialog only appear when the conditions are right:
49
49
  | Tab bar and Submit tab | The call carries more than one question |
50
50
  | `Next` row | The question is multi-select |
51
51
  | `Type something.` row | Always |
52
+ | `Chat about this` row | Always; on the RPC multi-select path, the word `chat` in the input |
52
53
  | Side by side preview | An option carries a `preview`, and terminal and pane are both at least 100 columns |
53
54
  | Preview pane at all | Single-select questions only |
54
55
  | Collapse shortcut | `collapseKey` is not `"off"` |
package/docs/keyboard.md CHANGED
@@ -21,7 +21,7 @@ The table names the default keys. The dialog follows your Pi keybindings. Confir
21
21
 
22
22
  In a multi-select question, `Enter` on a regular row toggles its checkbox just like `Space`. It does not submit. Committing the question means focusing the `Next` row and pressing `Enter`. That is intentional: it makes `Enter` a cheap way to flip boxes without leaving the home row.
23
23
 
24
- `Space` is blocked on two rows: `Next` is a command, not a choice, and `Type something.` is a text input where the space character belongs to your answer.
24
+ `Space` is blocked on three rows: `Next` and `Chat about this` are commands, not choices, and `Type something.` is a text input where the space character belongs to your answer.
25
25
 
26
26
  Timeout: if the call included a `timeout`, a live countdown shows in the footer and in the collapsed hint row, like `12s left`. The first keystroke you make cancels the timer. It does not reset, it stops.
27
27
 
@@ -30,11 +30,14 @@ Timeout: if the call included a `timeout`, a live countdown shows in the footer
30
30
  | Row | Label | Added to |
31
31
  | --- | --- | --- |
32
32
  | Custom answer | `Type something.` | Every question, single-select and multi-select, with or without previews |
33
+ | Chat escape | `Chat about this` | Every question, single-select and multi-select |
33
34
  | Commit | `Next` | Multi-select questions only |
34
35
 
35
36
  Focusing `Type something.` turns the row into an inline multiline editor. In preview mode it expands to the full pane width while you type, so a long custom answer is not squeezed into the narrow options column. `Shift+Enter` inserts a line break. Vertical arrows move between lines and return to row navigation at the top and bottom of the draft. The draft replaces the static row label while you browse other options and is kept per question. `Ctrl+G` sends it through Pi's configured external editor and brings the result back. `Ctrl+U` clears it. `Esc` is the way to cancel the questionnaire. Confirming the row produces an answer of `kind: "custom"`.
36
37
 
37
- Both labels are reserved. The model cannot use them as option labels. The check always compares against the English strings.
38
+ Confirming `Chat about this` produces no answer at all: the dialog closes and the result carries a `chatRequested` marker for that question instead. The row is numbered like the rows above it but drawn under a full-width rule, because it ends the questionnaire rather than answering it. See [Tool schema](./tool-schema.md).
39
+
40
+ All three labels are reserved. The model cannot use them as option labels. The check always compares against the English strings.
38
41
 
39
42
  ## Notes
40
43
 
@@ -45,7 +45,7 @@ The two `maxLength` limits are checked by the param schema before `execute` runs
45
45
 
46
46
  ### Reserved option labels
47
47
 
48
- Using any of `"Other"`, `"Type something."`, or `"Next"` as an option label is rejected with `reserved_label`. The last two are the rows the dialog adds itself. `"Other"` is reserved because models are often primed to reach for it. Reservation is unconditional. A single-select question rejects `"Next"` even though that row is never added there.
48
+ Using any of `"Other"`, `"Type something."`, `"Chat about this"`, or `"Next"` as an option label is rejected with `reserved_label`. The last three are the rows the dialog adds itself. `"Other"` is reserved because models are often primed to reach for it. Reservation is unconditional. A single-select question rejects `"Next"` even though that row is never added there.
49
49
 
50
50
  ## Validation errors
51
51
 
@@ -89,12 +89,16 @@ Every rejection returns `cancelled: true`, an empty `answers` array, and an `err
89
89
  question: string,
90
90
  note: string,
91
91
  }>,
92
+ chatRequested?: { // set when the user picked "Chat about this"
93
+ questionIndex: number, // the question they want to discuss first
94
+ question: string,
95
+ },
92
96
  error?: QuestionnaireError, // one of the codes above
93
97
  }
94
98
  }
95
99
  ```
96
100
 
97
- `globalNote` and `unansweredNotes` use a conditional spread. The key only appears when the value is non-empty. A result with no notes has no such key at all, so `!("globalNote" in result)` holds.
101
+ `globalNote`, `unansweredNotes` and `chatRequested` use a conditional spread. The key only appears when the value is non-empty. A result with no notes has no such key at all, so `!("globalNote" in result)` holds.
98
102
 
99
103
  ### Envelope text
100
104
 
@@ -102,6 +106,8 @@ On success the text reads `User has answered your questions: "<question>"="<answ
102
106
 
103
107
  Cancelling, and any result with no answer segments, no unanswered note segments, and no global note, both collapse to the single string `User declined to answer questions` so the model sees one clear signal. Partial submission is allowed: unanswered questions simply add no segment. A cancelled result always reads as the decline in text. Its notes, if any, survive only in `details.globalNote` and `details.unansweredNotes`.
104
108
 
109
+ A chat request never reads as the decline. When the user picks the `Chat about this` row, the dialog closes at once and the text reads `User picked "Chat about this" on question N: "<question>"` followed by the answers already given, if any, in the same segment shape as a normal envelope. It carries no instruction — what the model should do (ask the user what they want to clarify, then wait) is stated once, in the tool description, rather than repeated in text the model reads back to itself. A custom `guidance.description` (see the config docs) replaces that instruction along with every other behavioural note the default description carries. The details carry `cancelled: true` and `chatRequested`; the picked question itself gains no answer.
110
+
105
111
  When `error` is `timed_out`, the text is `Questionnaire timed out, the user did not respond within the configured timeout. The user never saw a decline; do NOT treat this as a rejection. Ask the questions as plain chat text instead or retry.` The details keep `cancelled: true` and `error: "timed_out"` alongside any notes or answers the user left.
106
112
 
107
113
  ## Events
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ask-popup",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Pi extension. A tabbed terminal questionnaire the model can put to you when it would otherwise guess, with typed options, markdown previews and notes instead of free-form replies.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -326,7 +326,7 @@ export const DEFAULT_PROMPT_SNIPPET = `Ask the user up to ${MAX_QUESTIONS} struc
326
326
  export const DEFAULT_PROMPT_GUIDELINES: string[] = [
327
327
  `Use ask_user_question when the user's request is underspecified and you would otherwise decide for them. "I could guess and carry on" is the situation this tool exists for, not a reason to skip it — you can ask up to ${MAX_QUESTIONS} questions per invocation.`,
328
328
  `Prefer ask_user_question over asking in prose whenever you can name the candidate answers: which approach, which library, what to call it, how far to take it, which trade-off to accept. Ask in plain text only when the answer is open-ended enough that ${MIN_OPTIONS} concrete options cannot be written.`,
329
- `Each ask_user_question question MUST have ${MIN_OPTIONS}-${MAX_OPTIONS} options. Every option requires a concise label (1-5 words) and a description explaining what the choice means or its trade-offs. The user can additionally type a custom answer via the automatically appended "Type something." row on every question, or press Esc to abandon the questionnaire. Do NOT author "Other" or "Type something." labels yourself — reserved labels are rejected at runtime.`,
329
+ `Each ask_user_question question MUST have ${MIN_OPTIONS}-${MAX_OPTIONS} options. Every option requires a concise label (1-5 words) and a description explaining what the choice means or its trade-offs. The user can additionally type a custom answer via the automatically appended "Type something." row on every question, or press Esc to abandon the questionnaire. Every question also carries a "Chat about this" row: if the user picks it, the dialog closes with that question unanswered, and you should open with "What would you like to clarify about that question?" and wait — never re-ask or answer over it. Do NOT author "Other", "Type something." or "Chat about this" labels yourself — reserved labels are rejected at runtime.`,
330
330
  `In ask_user_question, set multiSelect: true when multiple answers are valid. Provide an options[].preview markdown string when an option benefits from richer side-by-side context (mockups, code snippets, diagrams, configs) — single-select only. The "Type something." row is appended to every question; in preview mode it expands to the full pane width while typing so the custom answer is not cramped into the narrow options column. If you recommend a specific option, make that the first option and append "(Recommended)" to its label.`,
331
331
  "Do not stack multiple ask_user_question calls back-to-back — group all clarifying questions into one invocation.",
332
332
  ];
@@ -339,6 +339,7 @@ export const DEFAULT_TOOL_DESCRIPTION = `Ask the user one or more structured que
339
339
 
340
340
  Usage notes:
341
341
  - Users can type a custom answer via the automatically appended "Type something." row on every question or press Esc to abandon the questionnaire. Do NOT author "Other" or "Type something." labels yourself — reserved labels are rejected at runtime.
342
+ - Every question also carries an automatically appended "Chat about this" row. When the user picks it, the dialog closes with that question unanswered and the tool result names it. Open by asking "What would you like to clarify about that question?" and wait. Do not answer it for them and do not re-ask it as a questionnaire. Questions answered before the pick stay answered — once the discussion resolves, put only the still-missing questions in a new ask_user_question call.
342
343
  - Use multiSelect: true when multiple answers are valid. The "Type something." row is available on every question, including when options carry a \`preview\`; in preview mode it expands to the full pane width while typing so the custom answer is not cramped into the narrow options column.
343
344
  - If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label.
344
345
 
@@ -18,7 +18,9 @@
18
18
  * for them, and inventing a second dialog to collect one would double the
19
19
  * number of prompts for something most answers never use.
20
20
  *
21
- * The "Type something." escape does survive, on both variants.
21
+ * The "Type something." escape and the "Chat about this" escape both survive,
22
+ * on both variants: the select lists carry both rows, and the multi-select
23
+ * input accepts the word `chat` as the row's counterpart.
22
24
  */
23
25
 
24
26
  import { ROW_INTENT_META } from "./state/row-intent.js";
@@ -30,9 +32,11 @@ import type {
30
32
  } from "./tool/types.js";
31
33
 
32
34
  const MULTI_SELECT_INSTRUCTIONS =
33
- 'Enter the numbers of all that apply, comma-separated (e.g. "1,3"), or type a custom answer as plain text.';
35
+ 'Enter the numbers of all that apply, comma-separated (e.g. "1,3"), or type a custom answer as plain text. Reply "chat" to set this question aside and discuss it in chat instead.';
34
36
  const CUSTOM_ANSWER_TITLE = "Type your answer:";
35
37
  const MULTI_SELECT_PLACEHOLDER = "1,3";
38
+ /** The word a multi-select input reply must equal to trigger the chat escape. */
39
+ const CHAT_KEYWORD = "chat";
36
40
 
37
41
  /** How much of an option's preview is folded into a select title before truncation. */
38
42
  const MAX_PREVIEW_CHARS = 600;
@@ -80,6 +84,7 @@ export function hasDialogUI(ui: unknown): ui is DialogUI {
80
84
  */
81
85
  type AskOutcome =
82
86
  | { kind: "answer"; answer: QuestionAnswer }
87
+ | { kind: "chat"; question: string }
83
88
  | { kind: "dismissed" }
84
89
  | { kind: "host_error"; detail: string };
85
90
 
@@ -138,6 +143,16 @@ export async function runRpcQuestionnaire(
138
143
  if (outcome.kind === "dismissed") {
139
144
  return { answers, cancelled: true };
140
145
  }
146
+ if (outcome.kind === "chat") {
147
+ // Same shape the overlay's chat row produces: everything answered before
148
+ // this question rides along, the marker names this one, and the rest of
149
+ // the walk is abandoned.
150
+ return {
151
+ answers,
152
+ cancelled: true,
153
+ chatRequested: { questionIndex: qi, question: outcome.question },
154
+ };
155
+ }
141
156
  if (outcome.kind === "host_error") {
142
157
  return { answers, cancelled: true, error: "host_error", hostErrorDetail: outcome.detail };
143
158
  }
@@ -156,6 +171,7 @@ async function askSingleSelect(
156
171
  ): Promise<AskOutcome> {
157
172
  const options = q.options.map(formatOptionLine);
158
173
  options.push(`${q.options.length + 1}. ${ROW_INTENT_META.other.label}`);
174
+ options.push(`${q.options.length + 2}. ${ROW_INTENT_META.chat.label}`);
159
175
  const chosen = await ui.select(`${header}${q.question}${buildPreviewBlock(q)}`, options, opts);
160
176
  if (chosen === undefined || chosen === null) {
161
177
  return DISMISSED;
@@ -182,6 +198,11 @@ async function askSingleSelect(
182
198
  }
183
199
  return { kind: "answer", answer };
184
200
  }
201
+ // The "Chat about this" row, one index past the authored options plus the
202
+ // free-text row.
203
+ if (idx === q.options.length + 1) {
204
+ return { kind: "chat", question: q.question };
205
+ }
185
206
  // The "Type something." row, which is the one index past the authored options.
186
207
  const typed = await ui.input(`${header}${q.question}\n\n${CUSTOM_ANSWER_TITLE}`, "", opts);
187
208
  if (typed === undefined || typed === null) {
@@ -227,6 +248,15 @@ async function askMultiSelect(
227
248
  answer: { questionIndex, question: q.question, kind: "multi", answer: null, selected: [] },
228
249
  };
229
250
  }
251
+ // The chat escape. There is no row to focus in an input dialog, so the word
252
+ // itself is the affordance, and it is matched exactly rather than split into
253
+ // tokens: a reply of "1, chat, 3" is a custom answer naming the word, not a
254
+ // mixed selection, and pretending otherwise would answer a question the user
255
+ // asked to set aside. Case-insensitive because the instructions show it
256
+ // lowercase and no dialog here punishes capitalisation anywhere else.
257
+ if (trimmed.toLowerCase() === CHAT_KEYWORD) {
258
+ return { kind: "chat", question: q.question };
259
+ }
230
260
  const tokens = trimmed.split(/[,\s]+/).filter((tok) => tok.length > 0);
231
261
  const indices = tokens.map((tok) =>
232
262
  /^\d+\.?$/.test(tok) ? parseIndex(tok, q.options.length) : null,
@@ -40,6 +40,13 @@ export type QuestionnaireAction =
40
40
  | { kind: "confirm"; answer: QuestionAnswer; autoAdvanceTab?: number | undefined }
41
41
  | { kind: "toggle"; index: number }
42
42
  | { kind: "multi_confirm"; selected: string[]; autoAdvanceTab?: number | undefined }
43
+ /**
44
+ * The user picked the "Chat about this" row on the current tab: close the
45
+ * dialog, keep the answers already given, and mark this question as the one
46
+ * they want to discuss before answering. Ends the questionnaire like
47
+ * `cancel`, but with the marker attached.
48
+ */
49
+ | { kind: "chat_request" }
43
50
  | { kind: "cancel" }
44
51
  | { kind: "notes_enter" }
45
52
  | { kind: "notes_exit" }
@@ -127,6 +134,12 @@ function buildSingleSelectAnswer(
127
134
  if (item.kind === "other") {
128
135
  return null;
129
136
  }
137
+ if (item.kind === "chat") {
138
+ // Not an answer. Enter on this row is intercepted in routeSingleSelectTab
139
+ // before it gets here; this branch is the defensive backstop that keeps a
140
+ // chat row from ever minting an answer.
141
+ return null;
142
+ }
130
143
  if (item.kind === "next") {
131
144
  return null;
132
145
  }
@@ -363,6 +376,14 @@ function routeMultiSelectTab(
363
376
  if (!focusedMeta?.autoSubmitsInMulti) {
364
377
  return { kind: "toggle", index: state.optionIndex };
365
378
  }
379
+ // Enter on the chat row ends the questionnaire with the current tab marked
380
+ // for discussion, keeping whatever boxes are ticked as its answer. It
381
+ // shares `autoSubmitsInMulti` with Next but performs a different commit,
382
+ // so it is dispatched by kind rather than falling through to
383
+ // `multi_confirm`, which would advance instead of closing.
384
+ if (focusedKind === "chat") {
385
+ return { kind: "chat_request" };
386
+ }
366
387
  // Enter on Next: carry autoAdvanceTab so the host can advance to the next tab in
367
388
  // multi-question mode, OR submit the dialog in single-question mode
368
389
  // (autoAdvanceTab === undefined when !isMulti). Without this, a single multi-select
@@ -386,6 +407,10 @@ function routeSingleSelectTab(
386
407
  runtime: QuestionnaireRuntime,
387
408
  ): QuestionnaireAction {
388
409
  if (isConfirm(kb, data)) {
410
+ // Enter on the chat row abandons the questionnaire instead of answering.
411
+ if (runtime.currentItem?.kind === "chat") {
412
+ return { kind: "chat_request" };
413
+ }
389
414
  const answer = buildSingleSelectAnswer(state, runtime);
390
415
  if (!answer) {
391
416
  return { kind: "ignore" };
@@ -11,15 +11,19 @@ import type { QuestionData } from "../tool/types.js";
11
11
  * `Record<RowKind, ...>`) AND every exhaustive switch in the renderer, so a new
12
12
  * row cannot ship half-wired.
13
13
  */
14
- export type RowKind = "option" | "other" | "next";
14
+ export type RowKind = "option" | "other" | "chat" | "next";
15
15
 
16
16
  /**
17
17
  * Sentinel kinds: the protocol-driven rows, as opposed to author-defined
18
18
  * `option` rows. The auto-append walker, the reserved-label derivation and
19
19
  * `LABELS_BY_KIND` all iterate this list.
20
+ *
21
+ * Order is the append order on every question: options, the free-text row, the
22
+ * chat row, the commit row. The chat row sits before "Next" because it reads
23
+ * as another way out, not as the primary action.
20
24
  */
21
25
  export type SentinelKind = Exclude<RowKind, "option">;
22
- export const SENTINEL_KINDS: readonly SentinelKind[] = ["other", "next"];
26
+ export const SENTINEL_KINDS: readonly SentinelKind[] = ["other", "chat", "next"];
23
27
 
24
28
  /**
25
29
  * One renderable row. Lives here rather than in the view because it is the
@@ -56,13 +60,16 @@ export interface WrappingSelectItem {
56
60
  * validation time. `RESERVED_LABEL_SET` derives from this flag.
57
61
  * - `livesInMainList` — the row appears in the tab's item array.
58
62
  * - `numbered` — the row contributes to main-list numbering. The multi-select
59
- * `Next` row is the only listed row that does not.
63
+ * `Next` row is drawn bare by `MultiSelectView`, which does its own
64
+ * numbering; the `chat` row keeps its number there too.
60
65
  * - `activatesInputMode` — focusing the row flips `state.inputMode`, turning it
61
66
  * into an inline editor. Read by the reducer's `nav` case.
62
67
  * - `blocksMultiToggle` — in multi-select, Space and Enter-as-toggle are
63
- * suppressed on this row. `Next` only.
68
+ * suppressed on this row. `Next` and `chat` only.
64
69
  * - `autoSubmitsInMulti` — in multi-select, Enter on this row commits the
65
- * question. `Next` only.
70
+ * question. `Next` and `chat` only.
71
+ * - `separatorAbove` — the renderer draws a full-width rule above the row,
72
+ * setting it off from the answer list. `chat` only.
66
73
  * - `autoAppendOnSingleSelect` / `autoAppendOnMultiSelect` — whether the item
67
74
  * builder appends this row in that mode.
68
75
  */
@@ -76,6 +83,7 @@ export interface RowIntentMeta {
76
83
  autoSubmitsInMulti: boolean;
77
84
  autoAppendOnSingleSelect: boolean;
78
85
  autoAppendOnMultiSelect: boolean;
86
+ separatorAbove: boolean;
79
87
  }
80
88
 
81
89
  export const ROW_INTENT_META: Record<RowKind, RowIntentMeta> = {
@@ -89,6 +97,7 @@ export const ROW_INTENT_META: Record<RowKind, RowIntentMeta> = {
89
97
  autoSubmitsInMulti: false,
90
98
  autoAppendOnSingleSelect: false,
91
99
  autoAppendOnMultiSelect: false,
100
+ separatorAbove: false,
92
101
  },
93
102
  other: {
94
103
  label: "Type something.",
@@ -100,6 +109,32 @@ export const ROW_INTENT_META: Record<RowKind, RowIntentMeta> = {
100
109
  autoSubmitsInMulti: false,
101
110
  autoAppendOnSingleSelect: true,
102
111
  autoAppendOnMultiSelect: true,
112
+ separatorAbove: false,
113
+ },
114
+ /**
115
+ * The "Chat about this" row. Selecting it abandons the questionnaire: the
116
+ * dialog closes and the tool result carries a `chatRequested` marker for the
117
+ * question it was picked on, so the model stops and treats the user's next
118
+ * chat message as a clarification of that question. It is a decision, not an
119
+ * answer — no `QuestionAnswer` is minted for it.
120
+ *
121
+ * It is numbered like the rows above it but sits under a full-width rule,
122
+ * because it is not one of the answers: it ends the questionnaire. In
123
+ * multi-select it shares `autoSubmitsInMulti` and `blocksMultiToggle` with
124
+ * `next`, even though the commit it performs closes the dialog rather than
125
+ * advancing a tab.
126
+ */
127
+ chat: {
128
+ label: "Chat about this",
129
+ reserved: true,
130
+ livesInMainList: true,
131
+ numbered: true,
132
+ activatesInputMode: false,
133
+ blocksMultiToggle: true,
134
+ autoSubmitsInMulti: true,
135
+ autoAppendOnSingleSelect: true,
136
+ autoAppendOnMultiSelect: true,
137
+ separatorAbove: true,
103
138
  },
104
139
  next: {
105
140
  label: "Next",
@@ -111,6 +146,7 @@ export const ROW_INTENT_META: Record<RowKind, RowIntentMeta> = {
111
146
  autoSubmitsInMulti: true,
112
147
  autoAppendOnSingleSelect: false,
113
148
  autoAppendOnMultiSelect: true,
149
+ separatorAbove: false,
114
150
  },
115
151
  };
116
152
 
@@ -120,6 +156,7 @@ export const ROW_INTENT_META: Record<RowKind, RowIntentMeta> = {
120
156
  */
121
157
  export const LABELS_BY_KIND: { readonly [K in SentinelKind]: string } = {
122
158
  other: ROW_INTENT_META.other.label,
159
+ chat: ROW_INTENT_META.chat.label,
123
160
  next: ROW_INTENT_META.next.label,
124
161
  };
125
162
 
@@ -131,7 +168,9 @@ export const LABELS_BY_KIND: { readonly [K in SentinelKind]: string } = {
131
168
  */
132
169
  export const RESERVED_LABEL_SET: ReadonlySet<string> = new Set<string>([
133
170
  "Other",
134
- ...SENTINEL_KINDS.filter((k) => ROW_INTENT_META[k].reserved).map((k) => ROW_INTENT_META[k].label),
171
+ // A flatMap because the filter and the projection read the same meta entry;
172
+ // two passes would walk SENTINEL_KINDS twice for one set.
173
+ ...SENTINEL_KINDS.flatMap((k) => (ROW_INTENT_META[k].reserved ? [ROW_INTENT_META[k].label] : [])),
135
174
  ]);
136
175
 
137
176
  /**
@@ -27,6 +27,7 @@ function emptyMultiSelectProps(ctx: PerTabBindingContext): MultiSelectViewProps
27
27
  inputBuffer: ctx.inputBuffer,
28
28
  inputCursorOffset: ctx.inputCursorOffset,
29
29
  },
30
+ chatActive: false,
30
31
  nextActive: false,
31
32
  nextLabel: LABELS_BY_KIND.next,
32
33
  };
@@ -66,7 +67,10 @@ export const selectMultiSelectProps: PerTabSelector<MultiSelectViewProps> = (sta
66
67
  inputBuffer: ctx.inputBuffer,
67
68
  inputCursorOffset: ctx.inputCursorOffset,
68
69
  },
69
- nextActive: focused && state.optionIndex === question.options.length + 1,
70
+ // Row order below the options is the free-text row, then the chat row,
71
+ // then the commit row — `SENTINEL_KINDS` order.
72
+ chatActive: focused && state.optionIndex === question.options.length + 1,
73
+ nextActive: focused && state.optionIndex === question.options.length + 2,
70
74
  nextLabel: nextLabelFor(ctx),
71
75
  };
72
76
  };
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ ChatRequested,
2
3
  QuestionAnswer,
3
4
  QuestionData,
4
5
  QuestionnaireResult,
@@ -216,7 +217,19 @@ function switchTabResult(
216
217
  };
217
218
  }
218
219
 
219
- function doneFor(state: QuestionnaireState, ctx: ApplyContext, cancelled: boolean): ApplyResult {
220
+ /**
221
+ * Lift the dialog's state into its terminal `QuestionnaireResult`.
222
+ *
223
+ * `chatRequested` rides only on a chat-request close, which is why it is a
224
+ * parameter and not a state read: nothing else that lands here — submit,
225
+ * cancel, confirm's final tab, timeout — carries one.
226
+ */
227
+ function doneFor(
228
+ state: QuestionnaireState,
229
+ ctx: ApplyContext,
230
+ cancelled: boolean,
231
+ chatRequested?: ChatRequested,
232
+ ): ApplyResult {
220
233
  // Global note lift: the Submit-tab note lives at the `questions.length` pseudo-index
221
234
  // in `notesByTab` — question tabs only occupy 0..questions.length-1, so this can never
222
235
  // cross-contaminate a per-question note. Attached regardless of `cancelled` (the
@@ -231,6 +244,12 @@ function doneFor(state: QuestionnaireState, ctx: ApplyContext, cancelled: boolea
231
244
  answers: orderedAnswers(state, ctx.questions),
232
245
  cancelled,
233
246
  };
247
+ if (chatRequested) {
248
+ // SAFETY: conditional spread by assignment — chatRequested is present only when the
249
+ // caller passed one, keeping note-free results byte-identical like globalNote.
250
+ (result as QuestionnaireResult & { chatRequested: ChatRequested }).chatRequested =
251
+ chatRequested;
252
+ }
234
253
  if (globalNote && globalNote.length > 0) {
235
254
  // SAFETY: globalNote is optional per QuestionnaireResult; present only when non-empty.
236
255
  (result as QuestionnaireResult & { globalNote: string }).globalNote = globalNote;
@@ -407,6 +426,28 @@ const notesExitHandler: Handler<"notes_exit"> = (state, _action, _ctx) => {
407
426
  };
408
427
 
409
428
  const cancelHandler: Handler<"cancel"> = (s, _a, c) => doneFor(s, c, true);
429
+ /**
430
+ * Enter on the "Chat about this" row.
431
+ *
432
+ * The current tab's multi-select state is persisted first, so the ticked boxes
433
+ * (and any text typed on the "Type something." row) stay the question's answer
434
+ * — picking the row means "discuss before I answer this one", not "forget what
435
+ * I ticked". On a single-select tab there is nothing in flight to persist, and
436
+ * the call is a no-op by construction. The marker names the tab the row was
437
+ * picked on; tabs after it stay unanswered.
438
+ */
439
+ const chatRequestHandler: Handler<"chat_request"> = (state, _action, ctx) => {
440
+ const q = ctx.questions[state.currentTab];
441
+ if (!q) {
442
+ return { state, effects: [] };
443
+ }
444
+ const answers = persistMultiSelectAnswer(state, ctx);
445
+ const next: QuestionnaireState = { ...state, answers };
446
+ return doneFor(next, ctx, true, {
447
+ questionIndex: state.currentTab,
448
+ question: q.question,
449
+ });
450
+ };
410
451
  const submitHandler: Handler<"submit"> = (s, _a, c) => doneFor(s, c, false);
411
452
  const submitNavHandler: Handler<"submit_nav"> = (s, a, _c) => ({
412
453
  state: { ...s, submitChoiceIndex: a.nextIndex },
@@ -458,6 +499,7 @@ const HANDLERS = {
458
499
  toggle: toggleHandler,
459
500
  multi_confirm: multiConfirmHandler,
460
501
  cancel: cancelHandler,
502
+ chat_request: chatRequestHandler,
461
503
  notes_enter: notesEnterHandler,
462
504
  notes_exit: notesExitHandler,
463
505
  notes_forward: notesForwardHandler,
@@ -1,5 +1,7 @@
1
1
  import { formatAnswerScalar } from "./format-answer.js";
2
+ import { ROW_INTENT_META } from "../state/row-intent.js";
2
3
  import type {
4
+ ChatRequested,
3
5
  QuestionAnswer,
4
6
  QuestionnaireResult,
5
7
  QuestionParams,
@@ -70,6 +72,27 @@ export function buildQuestionnaireResponse(
70
72
  }
71
73
  return buildToolResult(HOST_ERROR_MESSAGE, details);
72
74
  }
75
+ if (result?.chatRequested) {
76
+ // A chat request is a decision, so it must not reach the decline collapse
77
+ // below: the model would read "User declined to answer questions" for a
78
+ // user who asked to talk. Segments are built for the answered questions
79
+ // even though the marker alone already makes the result non-empty — a
80
+ // chat close with nothing answered is still a chat close.
81
+ const cr = result.chatRequested;
82
+ const details: QuestionnaireResult = {
83
+ answers: result.answers,
84
+ cancelled: true,
85
+ chatRequested: cr,
86
+ };
87
+ if (result.globalNote && result.globalNote.length > 0) {
88
+ (details as { globalNote: string }).globalNote = result.globalNote;
89
+ }
90
+ if (result.unansweredNotes && result.unansweredNotes.length > 0) {
91
+ (details as { unansweredNotes: typeof result.unansweredNotes }).unansweredNotes =
92
+ result.unansweredNotes;
93
+ }
94
+ return buildToolResult(buildChatRequestMessage(cr, collectSegments(result, params)), details);
95
+ }
73
96
  if (!result || result.cancelled) {
74
97
  // The decline text stays canonical even when a global note rides a
75
98
  // cancelled result. The note survives in `details`, like partial answers.
@@ -90,51 +113,79 @@ export function buildQuestionnaireResponse(
90
113
  return buildToolResult(DECLINE_MESSAGE, details);
91
114
  }
92
115
 
93
- // Indexed once rather than scanned per question. Both sides are keyed by
94
- // `questionIndex`, which is what the loop below asks for, and it is the same
95
- // shape `orderedAnswers` uses in the reducer. A first entry wins, so a
96
- // duplicated index reads as the earlier `find` did.
116
+ // Indexed once per segment walk inside `collectSegments`; nothing here needs
117
+ // the maps directly.
118
+ const segments = collectSegments(result, params);
119
+ if (segments.length === 0) {
120
+ return buildToolResult(DECLINE_MESSAGE, { answers: result.answers, cancelled: true });
121
+ }
122
+ return buildToolResult(`${ENVELOPE_PREFIX} ${segments.join(" ")} ${ENVELOPE_SUFFIX}`, result);
123
+ }
124
+
125
+ /** First entry per `questionIndex` wins, which is what `Array.find` did. */
126
+ function byQuestionIndex<T extends { questionIndex: number }>(items: readonly T[]): Map<number, T> {
127
+ const out = new Map<number, T>();
128
+ for (const item of items) {
129
+ if (!out.has(item.questionIndex)) {
130
+ out.set(item.questionIndex, item);
131
+ }
132
+ }
133
+ return out;
134
+ }
135
+
136
+ /**
137
+ * The envelope segments for one result, in ask order.
138
+ *
139
+ * Iterates the questions rather than the answers so segments always follow the
140
+ * order the model asked in, whatever order the user filled tabs. A note with no
141
+ * answer behind it still belongs in ask order, so it is emitted inline rather
142
+ * than grouped at the end — which is also why a questionnaire submitted with
143
+ * nothing but such a note counts as answered rather than declined. The global
144
+ * note rides last, echoed raw with a trailing period matching an answer
145
+ * segment's shape.
146
+ */
147
+ function collectSegments(result: QuestionnaireResult, params: QuestionParams): string[] {
97
148
  const answerByIndex = byQuestionIndex(result.answers);
98
149
  const noteByIndex = byQuestionIndex(result.unansweredNotes ?? []);
99
-
100
150
  const segments: string[] = [];
101
- // Iterate the questions rather than the answers so segments always follow the
102
- // order the model asked in, whatever order the user filled tabs.
103
151
  for (let i = 0; i < params.questions.length; i++) {
104
152
  const a = answerByIndex.get(i);
105
153
  if (a) {
106
154
  segments.push(buildAnswerSegment(a));
107
155
  continue;
108
156
  }
109
- // A note with no answer behind it still belongs in ask order, so it is
110
- // emitted here rather than grouped at the end. Because this loop runs
111
- // before the "nothing to report" check below, a questionnaire submitted
112
- // with nothing but such a note counts as answered rather than declined.
113
157
  const n = noteByIndex.get(i);
114
158
  if (n) {
115
159
  segments.push(buildUnansweredNoteSegment(n));
116
160
  }
117
161
  }
118
162
  if (result.globalNote && result.globalNote.length > 0) {
119
- // Raw multiline echo, no reformatting, trailing period matching the shape
120
- // of an answer segment.
121
163
  segments.push(`global note: ${result.globalNote}.`);
122
164
  }
123
- if (segments.length === 0) {
124
- return buildToolResult(DECLINE_MESSAGE, { answers: result.answers, cancelled: true });
125
- }
126
- return buildToolResult(`${ENVELOPE_PREFIX} ${segments.join(" ")} ${ENVELOPE_SUFFIX}`, result);
165
+ return segments;
127
166
  }
128
167
 
129
- /** First entry per `questionIndex` wins, which is what `Array.find` did. */
130
- function byQuestionIndex<T extends { questionIndex: number }>(items: readonly T[]): Map<number, T> {
131
- const out = new Map<number, T>();
132
- for (const item of items) {
133
- if (!out.has(item.questionIndex)) {
134
- out.set(item.questionIndex, item);
135
- }
168
+ /**
169
+ * The envelope for a "Chat about this" close.
170
+ *
171
+ * States facts only — which question, and the answers already given in ask
172
+ * order — with no instruction to act on them. What the model does next (ask
173
+ * the user what they want to clarify, then wait) lives in the tool description
174
+ * and prompt guidelines, the standing-instruction channels, not here. An
175
+ * imperative placed in this text got echoed verbatim as the model's reply, so
176
+ * this string carries none.
177
+ */
178
+ function buildChatRequestMessage(
179
+ chatRequested: ChatRequested,
180
+ segments: readonly string[],
181
+ ): string {
182
+ const parts: string[] = [
183
+ `User picked "${ROW_INTENT_META.chat.label}" on question ${chatRequested.questionIndex + 1}: "${chatRequested.question}"`,
184
+ ];
185
+ if (segments.length > 0) {
186
+ parts.push(segments.join(" "));
136
187
  }
137
- return out;
188
+ return parts.join(" ");
138
189
  }
139
190
 
140
191
  /**
package/src/tool/types.ts CHANGED
@@ -40,6 +40,7 @@ export type SentinelLabel = (typeof SENTINEL_LABELS)[keyof typeof SENTINEL_LABEL
40
40
  export const RESERVED_LABELS = [
41
41
  "Other",
42
42
  ROW_INTENT_META.other.label,
43
+ ROW_INTENT_META.chat.label,
43
44
  ROW_INTENT_META.next.label,
44
45
  ] as const;
45
46
  export type ReservedLabel = (typeof RESERVED_LABELS)[number];
@@ -74,7 +75,7 @@ export const QuestionSchema = Type.Object({
74
75
  minItems: MIN_OPTIONS,
75
76
  maxItems: MAX_OPTIONS,
76
77
  description:
77
- "The available choices for this question. Must have 2-4 options. Each option should be a distinct, mutually exclusive choice (unless multiSelect is enabled). The 'Type something.' row is appended automatically — do NOT author it.",
78
+ "The available choices for this question. Must have 2-4 options. Each option should be a distinct, mutually exclusive choice (unless multiSelect is enabled). The 'Type something.' row and the 'Chat about this' row are appended automatically — do NOT author them.",
78
79
  }),
79
80
  multiSelect: Type.Optional(
80
81
  Type.Boolean({
@@ -113,6 +114,10 @@ export type QuestionParams = Static<typeof QuestionParamsSchema>;
113
114
  * - `option` — the user picked an authored option. `answer` is its label.
114
115
  * - `custom` — the user typed free text in the "Type something." row.
115
116
  * `answer` is the text, or null when they committed nothing.
117
+ *
118
+ * A "Chat about this" selection never becomes one of these: it is a decision
119
+ * to stop and discuss, and it travels on `QuestionnaireResult.chatRequested`
120
+ * instead of minting a pseudo-answer here.
116
121
  * - `multi` — the user committed multi-select choices. `selected` carries the
117
122
  * chosen labels and `answer` is null. Text typed on the "Type something." row
118
123
  * appears in `selected` too, as its own trimmed entry: on a multi-select
@@ -168,9 +173,26 @@ export type QuestionnaireError =
168
173
  | "timed_out"
169
174
  | "host_error";
170
175
 
176
+ export interface ChatRequested {
177
+ questionIndex: number;
178
+ question: string;
179
+ }
180
+
171
181
  export interface QuestionnaireResult {
172
182
  answers: QuestionAnswer[];
173
183
  cancelled: boolean;
184
+ /**
185
+ * The user selected the "Chat about this" sentinel, closing the dialog
186
+ * without answering this question, and wants to discuss it in chat first.
187
+ * Attached only on a chat-request result — never on a submit, decline or
188
+ * timeout.
189
+ *
190
+ * Same conditional-spread contract as `globalNote`: the key appears only via
191
+ * conditional spread of a non-empty value, is never assigned `undefined`, and
192
+ * so a result with no chat request stays byte-identical and
193
+ * `!("chatRequested" in result)` holds.
194
+ */
195
+ chatRequested?: ChatRequested;
174
196
  /**
175
197
  * A note covering the whole questionnaire rather than one question, authored
176
198
  * on the Submit tab. Attached on cancel as well as submit, mirroring
@@ -37,6 +37,7 @@ export interface MultiSelectOtherRowProps {
37
37
  export interface MultiSelectViewProps {
38
38
  rows: ReadonlyArray<{ checked: boolean; active: boolean }>;
39
39
  other: MultiSelectOtherRowProps;
40
+ chatActive: boolean;
40
41
  nextActive: boolean;
41
42
  nextLabel: string;
42
43
  }
@@ -80,6 +81,7 @@ export class MultiSelectView implements StatefulView<MultiSelectViewProps> {
80
81
  inputBuffer: "",
81
82
  inputCursorOffset: undefined,
82
83
  },
84
+ chatActive: false,
83
85
  nextActive: false,
84
86
  nextLabel: ROW_INTENT_META.next.label,
85
87
  };
@@ -115,7 +117,8 @@ export class MultiSelectView implements StatefulView<MultiSelectViewProps> {
115
117
 
116
118
  const build: MultiSelectBuild = { lines: [], focusedRange: [0, 0] };
117
119
  const contentWidth = Math.max(1, width - this.prefixVisibleWidth());
118
- const numberWidth = String(Math.max(1, this.question.options.length + 1)).length;
120
+ // Fits the chat row's N+2, the highest number the list can draw.
121
+ const numberWidth = String(Math.max(1, this.question.options.length + 2)).length;
119
122
 
120
123
  this.appendOptionRows(build, width, contentWidth, numberWidth);
121
124
 
@@ -125,6 +128,8 @@ export class MultiSelectView implements StatefulView<MultiSelectViewProps> {
125
128
  build.focusedRange = [otherStart, build.lines.length];
126
129
  }
127
130
 
131
+ this.appendChatRow(build, width, numberWidth);
132
+
128
133
  this.appendNextRow(build, width);
129
134
 
130
135
  const value = { lines: build.lines, focusedRange: build.focusedRange };
@@ -171,6 +176,32 @@ export class MultiSelectView implements StatefulView<MultiSelectViewProps> {
171
176
  }
172
177
  }
173
178
 
179
+ /**
180
+ * The "Chat about this" row, drawn bare like the commit row: no number, no
181
+ * checkbox. It closes the dialog rather than toggling anything, so painting
182
+ * it as a box would promise a behavior it does not have. It keeps its place
183
+ * in the numbering (N+2, after the free-text row's N+1) but sits under a
184
+ * full-width rule, matching the single-select list's separator. Sits between
185
+ * the free-text row and the commit row, matching `SENTINEL_KINDS` order.
186
+ */
187
+ private appendChatRow(build: MultiSelectBuild, width: number, numberWidth: number): void {
188
+ const chatStart = build.lines.length;
189
+ build.lines.push("\u2500".repeat(Math.max(0, width)));
190
+ const chatPointer = this.props.chatActive
191
+ ? this.theme.fg("accent", ACTIVE_POINTER)
192
+ : INACTIVE_POINTER;
193
+ const number = String(this.question.options.length + 2).padStart(numberWidth, " ");
194
+ const chatLabel = this.props.chatActive
195
+ ? this.theme.fg("accent", this.theme.bold(ROW_INTENT_META.chat.label))
196
+ : ROW_INTENT_META.chat.label;
197
+ build.lines.push(
198
+ truncateToWidth(`${chatPointer}${number}${NUMBER_SEPARATOR}${chatLabel}`, width, ""),
199
+ );
200
+ if (this.props.chatActive) {
201
+ build.focusedRange = [chatStart, build.lines.length];
202
+ }
203
+ }
204
+
174
205
  private appendNextRow(build: MultiSelectBuild, width: number): void {
175
206
  const nextStart = build.lines.length;
176
207
  const nextPointer = this.props.nextActive
@@ -219,11 +250,11 @@ export class MultiSelectView implements StatefulView<MultiSelectViewProps> {
219
250
  // Canonical prefix for OPTION rows: INACTIVE_POINTER + numberWidth digits + NUMBER_SEPARATOR
220
251
  // + UNCHECKED + BOX_LABEL_GAP. State-independent because ACTIVE/INACTIVE pointer share
221
252
  // visibleWidth, CHECKED/UNCHECKED share visibleWidth, and numberWidth is constant per question.
222
- // The number column fits `options.length + 1` so the "Type something." row's N+1 number
253
+ // The number column fits `options.length + 2` so the "Chat about this" row's N+2 number
223
254
  // is never clipped. The Next sentinel uses a bare `pointer + "Next"` shape — its width
224
255
  // never exceeds this prefix at any reasonable terminal width, so it's safe to leave it
225
256
  // out of the canonical computation.
226
- const numberWidth = String(Math.max(1, this.question.options.length + 1)).length;
257
+ const numberWidth = String(Math.max(1, this.question.options.length + 2)).length;
227
258
  return (
228
259
  visibleWidth(INACTIVE_POINTER) +
229
260
  numberWidth +
@@ -1,4 +1,5 @@
1
1
  import type { WrappingSelectItem } from "../../state/row-intent.js";
2
+ import { ROW_INTENT_META } from "../../state/row-intent.js";
2
3
  import type { Component } from "@earendil-works/pi-tui";
3
4
  import { visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
4
5
  import { renderInlineInputRow } from "./inline-input.js";
@@ -286,14 +287,27 @@ export class WrappingSelect implements Component {
286
287
  width - visibleWidth(rowPrefix),
287
288
  );
288
289
 
290
+ // A row that ends the questionnaire rather than answering it is drawn
291
+ // under a full-width rule, so it reads as outside the answer list. The
292
+ // rule is part of the item's own render, which keeps the visible-window
293
+ // arithmetic honest: the rule scrolls with the row and the focused range
294
+ // covers both.
295
+ const separator = ROW_INTENT_META[item.kind].separatorAbove
296
+ ? [this.theme.scrollInfo("\u2500".repeat(Math.max(0, width)))]
297
+ : [];
298
+
289
299
  if (this.shouldRenderAsInlineInput(item, isActive)) {
290
- return this.renderInlineInputRow(rowPrefix, continuationPrefix, contentWidth);
300
+ return [
301
+ ...separator,
302
+ ...this.renderInlineInputRow(rowPrefix, continuationPrefix, contentWidth),
303
+ ];
291
304
  }
292
305
 
293
306
  const { label, isConfirmed } = this.deriveConfirmedState(item, index);
294
307
  const applySelectedStyle = isActive || isConfirmed;
295
308
 
296
309
  return [
310
+ ...separator,
297
311
  ...this.renderLabelBlock(
298
312
  label,
299
313
  rowPrefix,