pi-ask-popup 0.2.0 → 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 +2 -1
- package/docs/hosts.md +2 -1
- package/docs/keyboard.md +5 -2
- package/docs/tool-schema.md +15 -6
- package/package.json +1 -1
- package/src/ask-user-question.ts +6 -2
- package/src/events.ts +8 -2
- package/src/index.ts +1 -0
- package/src/rpc-fallback.ts +32 -2
- package/src/state/key-router.ts +25 -0
- package/src/state/row-intent.ts +45 -6
- package/src/state/selectors/projections.ts +5 -1
- package/src/state/state-reducer.ts +43 -1
- package/src/tool/response-envelope.ts +76 -25
- package/src/tool/types.ts +23 -1
- package/src/view/components/multi-select-view.ts +34 -3
- package/src/view/components/wrapping-select.ts +15 -1
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
|
|
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
|
-
|
|
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
|
|
package/docs/tool-schema.md
CHANGED
|
@@ -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
|
|
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 `
|
|
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,23 +106,28 @@ 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
|
|
108
114
|
|
|
109
|
-
The package emits
|
|
115
|
+
The package emits lifecycle events on Pi's event bus after validation passes. Import their names and payload types from the `./events` subpath:
|
|
110
116
|
|
|
111
117
|
```ts
|
|
112
118
|
import {
|
|
113
119
|
ASK_POPUP_PROMPT_EVENT,
|
|
114
120
|
ASK_POPUP_BLOCKED_EVENT,
|
|
121
|
+
HERDR_BLOCKED_EVENT,
|
|
115
122
|
type AskPopupPromptEventPayload,
|
|
116
123
|
type AskPopupBlockedEventPayload,
|
|
117
124
|
} from "pi-ask-popup/events";
|
|
118
125
|
```
|
|
119
126
|
|
|
120
|
-
`ASK_POPUP_PROMPT_EVENT`
|
|
127
|
+
`ASK_POPUP_PROMPT_EVENT` is `pi-ask-popup:prompt`. It fires once before the dialog shows. Its payload is `questions[].{ question, header, multiSelect, options[] }`, where each option is `{ label, description, hasPreview }`. Preview content is not shipped, only `hasPreview: boolean`, so listeners that forward the event stay small.
|
|
128
|
+
|
|
129
|
+
`ASK_POPUP_BLOCKED_EVENT` is `pi-ask-popup:blocked`. Its payload is `{ active: boolean }`. It brackets the wait with `true` when the questionnaire starts and `false` when it resolves, so status or footer extensions can show that the agent is waiting.
|
|
121
130
|
|
|
122
|
-
`
|
|
131
|
+
`HERDR_BLOCKED_EVENT` is `herdr:blocked`. It receives the same blocked payload at the same points. Herdr's Pi integration consumes this channel to mark the agent as waiting for input and issue its configured request notification. Nothing happens when Herdr is absent.
|
|
123
132
|
|
|
124
|
-
|
|
133
|
+
The payloads are JSON-safe. Channel names are stable. Changes are append-only and optional, and any breaking change ships as a new channel rather than a version field.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-ask-popup",
|
|
3
|
-
"version": "0.
|
|
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",
|
package/src/ask-user-question.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
ASK_POPUP_BLOCKED_EVENT,
|
|
23
23
|
ASK_POPUP_PROMPT_EVENT,
|
|
24
24
|
buildBlockedPayload,
|
|
25
|
+
HERDR_BLOCKED_EVENT,
|
|
25
26
|
buildPromptPayload,
|
|
26
27
|
} from "./events.js";
|
|
27
28
|
// Static import: the walker pulls only types, none of the render graph the
|
|
@@ -88,7 +89,9 @@ function emitPrompt(pi: ExtensionAPI, params: QuestionParams): void {
|
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
function emitBlocked(pi: ExtensionAPI, active: boolean): void {
|
|
91
|
-
|
|
92
|
+
const payload = buildBlockedPayload(active);
|
|
93
|
+
pi.events.emit(ASK_POPUP_BLOCKED_EVENT, payload);
|
|
94
|
+
pi.events.emit(HERDR_BLOCKED_EVENT, payload);
|
|
92
95
|
}
|
|
93
96
|
|
|
94
97
|
/** The backstop for a host with no UI at all; the reconciler normally strips the tool first. */
|
|
@@ -323,7 +326,7 @@ export const DEFAULT_PROMPT_SNIPPET = `Ask the user up to ${MAX_QUESTIONS} struc
|
|
|
323
326
|
export const DEFAULT_PROMPT_GUIDELINES: string[] = [
|
|
324
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.`,
|
|
325
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.`,
|
|
326
|
-
`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"
|
|
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.`,
|
|
327
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.`,
|
|
328
331
|
"Do not stack multiple ask_user_question calls back-to-back — group all clarifying questions into one invocation.",
|
|
329
332
|
];
|
|
@@ -336,6 +339,7 @@ export const DEFAULT_TOOL_DESCRIPTION = `Ask the user one or more structured que
|
|
|
336
339
|
|
|
337
340
|
Usage notes:
|
|
338
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.
|
|
339
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.
|
|
340
344
|
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label.
|
|
341
345
|
|
package/src/events.ts
CHANGED
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
* This module deliberately imports nothing. A footer, statusline or notifier
|
|
5
5
|
* subscribes to these channels to know what is being asked and whether the
|
|
6
6
|
* agent is waiting on a person; it should not have to load a render graph, or
|
|
7
|
-
* a schema compiler, to read
|
|
7
|
+
* a schema compiler, to read three strings and four interfaces. Adding an import
|
|
8
8
|
* here defeats the reason the `./events` subpath exists, so there is a test
|
|
9
9
|
* that fails if one appears.
|
|
10
10
|
*
|
|
11
|
-
* Stability rules for
|
|
11
|
+
* Stability rules for these channels:
|
|
12
12
|
*
|
|
13
13
|
* 1. Channel names never change once published. Subscribers hardcode them.
|
|
14
14
|
* 2. Payload changes are append-only, and new fields ship optional.
|
|
@@ -29,6 +29,12 @@ export const ASK_POPUP_PROMPT_EVENT = "pi-ask-popup:prompt" as const;
|
|
|
29
29
|
/** Fired true before the wait and false when it ends, however it ends. */
|
|
30
30
|
export const ASK_POPUP_BLOCKED_EVENT = "pi-ask-popup:blocked" as const;
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Herdr's Pi integration listens on this shared channel for nested UI that
|
|
34
|
+
* needs a decision. Emitting it is inert when Herdr is not installed.
|
|
35
|
+
*/
|
|
36
|
+
export const HERDR_BLOCKED_EVENT = "herdr:blocked" as const;
|
|
37
|
+
|
|
32
38
|
export interface AskPopupPromptOption {
|
|
33
39
|
label: string;
|
|
34
40
|
description: string;
|
package/src/index.ts
CHANGED
package/src/rpc-fallback.ts
CHANGED
|
@@ -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
|
|
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,
|
package/src/state/key-router.ts
CHANGED
|
@@ -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" };
|
package/src/state/row-intent.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 +
|
|
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 +
|
|
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
|
|
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,
|