Optional notes on ask answers, and submittable empty multi-selects #225

Closed
opened 2026-11-05 16:37:54 +01:00 by omp-deck · 0 comments
Collaborator

Problem

The deck's ask dialog offers exactly two ways to say something to the agent: pick an option, or use "Other (type your own)" — which replaces the answer. The omp CLI additionally lets the user attach a note: free text added alongside a normal answer. It does not replace the answer and it does not cancel the question. The deck has no equivalent, so any caveat on an otherwise-fine option forces the user either into "Other" (losing the structured answer) or into cancelling the dialog and typing a message (losing the ask result).

Second, agents frequently forget to offer a "none" option on multi: true questions. The deck's dialog currently refuses to submit a multi-select with nothing checked, and the server cannot even represent that answer: ExtensionUIBridge.deckSelect only accepts a non-empty values array, so an empty one falls through to undefined, which createDeckAskTool interprets as "X button / Stop" and aborts the turn. "I pick none of these" and "cancel this turn" are indistinguishable today.

End state: every select question in the ask dialog has a collapsed "+ Add note" expander whose text travels with the answer to the agent; multi-select questions can be submitted with zero selections, which reaches the agent as a deliberate none selected answer rather than an abort.

Plan

1. Carry a note on the dialog wire response

In packages/protocol/src/index.ts, add to ExtUiDialogResponse (the interface documented as the client's reply shape, immediately after values):

	/** Free-text note the user attached to the answer. Additive: it never
	 *  replaces `value`/`values` and never means cancellation. Only the deck
	 *  `ask` tool's select dialogs collect it. */
	note?: string;

No other protocol change: ext_ui_dialog_response already spreads ExtUiDialogResponse, and apps/web/src/lib/store.ts respondToExtUiDialog already spreads the response object onto the frame, so the field reaches the server with no store change.

2. Return note and empty selections from deckSelect

In apps/server/src/bridge/ext-ui-bridge.ts:

  • Export the answer shape next to the class:
/** Answer to a deck `ask` select dialog: the chosen values plus an optional
 *  note the user attached. `values` may be empty — a multi-select answered
 *  with nothing checked is a deliberate "none of these", not a cancel. */
export interface DeckSelectAnswer {
	values: string[];
	note?: string;
}
  • Change deckSelect's return type to Promise<DeckSelectAnswer | null | undefined> and rewrite its parseResponse body (currently ext-ui-bridge.ts:235-244) to:
			(resp) => {
				if (resp.cancelled) {
					// null = dismissed for new message; undefined = X / Stop
					return resp.dismissedForNewMessage ? null : undefined;
				}
				const note = resp.note?.trim() ? resp.note : undefined;
				// An empty `values` array is an answer ("none of these"); only a
				// response carrying neither field is a non-answer.
				const values = Array.isArray(resp.values)
					? resp.values
					: typeof resp.value === "string"
						? [resp.value]
						: undefined;
				if (values === undefined) return undefined;
				return { values, ...(note !== undefined ? { note } : {}) };
			},

Update the method's existing doc comment: the string[] return line becomes DeckSelectAnswer — answered (possibly empty selection, plus optional note).

  • formatDetachedAnswer (same file, bottom) formats answers for dialogs restored after a server restart, which are delivered as a synthetic user message (in-process.ts:515-519). Its select case reads only response.value and so drops both multi-select arrays and notes. Replace that case with:
		case "select": {
			const picked = Array.isArray(response.values)
				? (response.values.length > 0 ? response.values.join(", ") : "none selected")
				: typeof response.value === "string"
					? response.value
					: "(No selection)";
			return response.note?.trim() ? `${picked}\n  note: ${response.note}` : picked;
		}

Leave the confirm / input / editor / default cases untouched: notes are collected only on select dialogs.

The generic select(), input(), editor() and confirm() methods are not changed — they serve extension callers whose return types have no room for a note, and the dialog UI will not offer a note field for those kinds (step 4).

3. Format note and empty answers for the agent

In apps/server/src/bridge/deck-tools.ts:

  • formatAskAnswer currently returns "" when nothing is selected and there is no custom text — that would emit a bare "<question>: " line. Change its tail so an empty answer is explicit, and update its doc comment accordingly:
export function formatAskAnswer(selectedOptions: string[], customInput: string | undefined): string {
	const parts: string[] = [];
	if (selectedOptions.length > 0) parts.push(`selected: ${selectedOptions.join(", ")}`);
	if (customInput?.trim()) parts.push(`custom: ${customInput}`);
	return parts.length > 0 ? parts.join("; ") : "none selected";
}
  • Add, exported next to it:
/**
 * Render a user note as its own line beneath the answer line, indented two
 * spaces so multi-line notes stay visually attached to their question in a
 * multi-question result.
 */
export function formatAskNote(note: string): string {
	return note
		.split("\n")
		.map((line, i) => (i === 0 ? `  note: ${line}` : `  ${line}`))
		.join("\n");
}
  • execute currently types the resolved dialogs as const answered = responses as string[][]. Change to DeckSelectAnswer[] (imported from ./ext-ui-bridge.ts), and have buildResult(q, answer: DeckSelectAnswer) split answer.values into selectedOptions / customInput exactly as today while passing note: answer.note through. The null / undefined branches above it are unchanged: dismiss-for-new-message and X/Stop still discard any typed note, since a cancelled question has no answer to attach it to.

  • Populate note on the details in both shapes. AskToolDetails and its QuestionResult (imported from @oh-my-pi/pi-tui/tools/ask) already declare note?: string, so no type change is needed. Leave noteOption unset: a deck note is attached to the question's answer as a whole, not to an individual option row.

  • Build responseText with the note on its own line beneath each answer line:

			const lineFor = (question: string, selected: string[], custom: string | undefined, note: string | undefined): string =>
				note === undefined
					? `${question}: ${formatAskAnswer(selected, custom)}`
					: `${question}: ${formatAskAnswer(selected, custom)}\n${formatAskNote(note)}`;

and use it for both the details.results map-and-join path and the single-question path (replacing deck-tools.ts:296-302).

ASK_DESCRIPTION is not changed: notes are a user-side affordance and arrive in the result text without the agent needing instructions.

4. Dialog UI: note expander, empty multi-select submit

All in apps/web/src/components/chat/AskDialog.tsx.

  • Answer gains note?: string (documented as "free text added alongside the answer; never replaces it").

  • Validity. isSelectAnswerValid keeps its single-select rules; its multi branch becomes return true — a multi-select is answerable with nothing checked. A note never affects validity for either kind. In allAnswered, a select dialog with multi counts as answered even when no answers[dialogId] entry exists yet; every other kind keeps its current requirement. Keep the per-tab checkmark driven by answers[d.dialogId] !== undefined so the check still means "you touched this question".

  • Submitting. In submitAll, iterate dialogs as today but handle the missing-entry case for multi selects: when dialog.kind === "select" && dialog.multi and there is no answer entry, respond { values: [] }. Otherwise, for multi respond { values, ...(note) } (values built as today from answer.values plus trimmed otherText), and for single-select { value, ...(note) }. The note is included only when answer.note?.trim() is non-empty, sent untrimmed-but-non-blank (the server trims for its emptiness check and keeps the user's text).

  • Note field. Add below SelectInput inside QuestionTab, rendered only for dialog.kind === "select":

function NoteField({ value, onChange }: { value: string; onChange: (v: string) => void }): JSX.Element

It holds local expanded state initialised to value.trim().length > 0 (so a note survives tab switches and reappears expanded). Collapsed, it renders a small ghost button labelled + Add note styled like the existing Summary-tab "Edit" link (text-2xs text-accent underline-offset-2 hover:underline). Expanded, it renders a label-less textarea with rows={3}, the same classes as the existing Other textarea (resize-y rounded border border-line bg-paper px-2 py-1.5 font-mono text-2xs), placeholder Note (optional) — sent along with your answer, autofocused on expand. No onKeyDown handler: Enter inserts a newline; submission is via the Submit button only. This deliberately differs from the Other textarea's Enter-submits behaviour because notes are commentary and often multi-line.

  • Submit label. In QuestionTab, when dialog.kind === "select" && dialog.multi and the resolved selection is empty (no values entries and no non-blank otherText), the primary button reads Submit (none selected); otherwise Submit. The Summary tab's button stays Submit all.

  • Showing the note after answering. In both the collapsed card (AskDialog.tsx:125-172) and SummaryTab, render the note under the answer line when present: a muted row className="mt-0.5 whitespace-pre-wrap text-2xs text-ink-3" with content note: {ans.note}. In both places the derived answerText for a multi dialog with no parts must read none selected instead of the current —, so the review surfaces match what the agent receives.

5. Transcript rendering

In apps/web/src/components/tools/Ask.tsx:

  • Add note?: string to the local QuestionResult and AskDetails interfaces (these mirror the server's details payload).
  • buildSelectionMap returns { selected, custom, note }; the map's value type gains note?: string, populated from r.note / details.note in both branches.
  • In the render, after the options <ul>: when the result exists and nothing is selected and there is no custom answer, render a muted none selected row in the same font-mono text-2xs list style, so an empty multi answer is not a blank card. When sel?.note is present, render it beneath the list as note: {sel.note} with className="whitespace-pre-wrap font-mono text-2xs text-ink-3".

6. Tests

Extend apps/server/src/bridge/deck-tools.test.ts (existing formatAskAnswer suite):

  • formatAskAnswer([], undefined) → "none selected".
  • formatAskNote("be careful") → " note: be careful".
  • formatAskNote("one\ntwo") → " note: one\n two".

Add apps/server/src/bridge/ext-ui-bridge.test.ts covering deckSelect's answer parsing through the real response path — construct new ExtensionUIBridge("s1"), register a frame listener to capture the emitted dialogId, call deckSelect(...), then handleResponse(dialogId, …) and await the promise:

  • { values: [] } → resolves to { values: [] } (not undefined): the empty multi-select answer no longer reads as a cancel.
  • { value: "A", note: "ship it" } → { values: ["A"], note: "ship it" }.
  • { value: "A", note: " " } → { values: ["A"] } (blank note dropped).
  • { cancelled: true } → undefined; { cancelled: true, dismissedForNewMessage: true } → null.

Verification

Run from the repo root:

  • bun test apps/server/src/bridge/ — the two suites above pass.
  • bun run typecheck — covers the protocol change and both app packages (deckSelect's new return type must be reflected in deck-tools.ts).

End-to-end, with bun run dev and the cockpit open on a session:

  1. Have the agent ask a single-select question. Click + Add note, type a two-line note, select a non-recommended option, Submit. Expected: the collapsed card shows the answer plus a note: line; the transcript tool card shows the chosen option marked and the note beneath; the agent's next message reflects both the option and the note. The tool result text reads:
    <question>: selected: <option>
      note: <line one>
      <line two>
    
  2. Have the agent ask a multi: true question. Touch nothing; the button reads Submit (none selected). Click it. Expected: the turn is not aborted (no "Aborted" card in the transcript), the agent receives <question>: none selected, and the transcript card shows a none selected row with no option marked.
  3. Repeat 2 with a note typed and still nothing checked. Expected: agent receives none selected followed by the indented note: line.
  4. With a dialog open and a note half-typed, press the X. Expected: the turn aborts exactly as before, the note is discarded, and no stray result text appears.
## Problem The deck's `ask` dialog offers exactly two ways to say something to the agent: pick an option, or use "Other (type your own)" — which *replaces* the answer. The omp CLI additionally lets the user attach a **note**: free text added *alongside* a normal answer. It does not replace the answer and it does not cancel the question. The deck has no equivalent, so any caveat on an otherwise-fine option forces the user either into "Other" (losing the structured answer) or into cancelling the dialog and typing a message (losing the ask result). Second, agents frequently forget to offer a "none" option on `multi: true` questions. The deck's dialog currently refuses to submit a multi-select with nothing checked, and the server cannot even represent that answer: `ExtensionUIBridge.deckSelect` only accepts a non-empty `values` array, so an empty one falls through to `undefined`, which `createDeckAskTool` interprets as "X button / Stop" and aborts the turn. "I pick none of these" and "cancel this turn" are indistinguishable today. End state: every select question in the ask dialog has a collapsed "+ Add note" expander whose text travels with the answer to the agent; multi-select questions can be submitted with zero selections, which reaches the agent as a deliberate `none selected` answer rather than an abort. ## Plan ### 1. Carry a note on the dialog wire response In `packages/protocol/src/index.ts`, add to `ExtUiDialogResponse` (the interface documented as the client's reply shape, immediately after `values`): ```ts /** Free-text note the user attached to the answer. Additive: it never * replaces `value`/`values` and never means cancellation. Only the deck * `ask` tool's select dialogs collect it. */ note?: string; ``` No other protocol change: `ext_ui_dialog_response` already spreads `ExtUiDialogResponse`, and `apps/web/src/lib/store.ts` `respondToExtUiDialog` already spreads the response object onto the frame, so the field reaches the server with no store change. ### 2. Return note and empty selections from `deckSelect` In `apps/server/src/bridge/ext-ui-bridge.ts`: - Export the answer shape next to the class: ```ts /** Answer to a deck `ask` select dialog: the chosen values plus an optional * note the user attached. `values` may be empty — a multi-select answered * with nothing checked is a deliberate "none of these", not a cancel. */ export interface DeckSelectAnswer { values: string[]; note?: string; } ``` - Change `deckSelect`'s return type to `Promise<DeckSelectAnswer | null | undefined>` and rewrite its `parseResponse` body (currently `ext-ui-bridge.ts:235-244`) to: ```ts (resp) => { if (resp.cancelled) { // null = dismissed for new message; undefined = X / Stop return resp.dismissedForNewMessage ? null : undefined; } const note = resp.note?.trim() ? resp.note : undefined; // An empty `values` array is an answer ("none of these"); only a // response carrying neither field is a non-answer. const values = Array.isArray(resp.values) ? resp.values : typeof resp.value === "string" ? [resp.value] : undefined; if (values === undefined) return undefined; return { values, ...(note !== undefined ? { note } : {}) }; }, ``` Update the method's existing doc comment: the `string[]` return line becomes `DeckSelectAnswer — answered (possibly empty selection, plus optional note)`. - `formatDetachedAnswer` (same file, bottom) formats answers for dialogs restored after a server restart, which are delivered as a synthetic user message (`in-process.ts:515-519`). Its `select` case reads only `response.value` and so drops both multi-select arrays and notes. Replace that case with: ```ts case "select": { const picked = Array.isArray(response.values) ? (response.values.length > 0 ? response.values.join(", ") : "none selected") : typeof response.value === "string" ? response.value : "(No selection)"; return response.note?.trim() ? `${picked}\n note: ${response.note}` : picked; } ``` Leave the `confirm` / `input` / `editor` / default cases untouched: notes are collected only on select dialogs. The generic `select()`, `input()`, `editor()` and `confirm()` methods are **not** changed — they serve extension callers whose return types have no room for a note, and the dialog UI will not offer a note field for those kinds (step 4). ### 3. Format note and empty answers for the agent In `apps/server/src/bridge/deck-tools.ts`: - `formatAskAnswer` currently returns `""` when nothing is selected and there is no custom text — that would emit a bare `"<question>: "` line. Change its tail so an empty answer is explicit, and update its doc comment accordingly: ```ts export function formatAskAnswer(selectedOptions: string[], customInput: string | undefined): string { const parts: string[] = []; if (selectedOptions.length > 0) parts.push(`selected: ${selectedOptions.join(", ")}`); if (customInput?.trim()) parts.push(`custom: ${customInput}`); return parts.length > 0 ? parts.join("; ") : "none selected"; } ``` - Add, exported next to it: ```ts /** * Render a user note as its own line beneath the answer line, indented two * spaces so multi-line notes stay visually attached to their question in a * multi-question result. */ export function formatAskNote(note: string): string { return note .split("\n") .map((line, i) => (i === 0 ? ` note: ${line}` : ` ${line}`)) .join("\n"); } ``` - `execute` currently types the resolved dialogs as `const answered = responses as string[][]`. Change to `DeckSelectAnswer[]` (imported from `./ext-ui-bridge.ts`), and have `buildResult(q, answer: DeckSelectAnswer)` split `answer.values` into `selectedOptions` / `customInput` exactly as today while passing `note: answer.note` through. The `null` / `undefined` branches above it are unchanged: dismiss-for-new-message and X/Stop still discard any typed note, since a cancelled question has no answer to attach it to. - Populate `note` on the details in both shapes. `AskToolDetails` and its `QuestionResult` (imported from `@oh-my-pi/pi-tui/tools/ask`) already declare `note?: string`, so no type change is needed. Leave `noteOption` unset: a deck note is attached to the question's answer as a whole, not to an individual option row. - Build `responseText` with the note on its own line beneath each answer line: ```ts const lineFor = (question: string, selected: string[], custom: string | undefined, note: string | undefined): string => note === undefined ? `${question}: ${formatAskAnswer(selected, custom)}` : `${question}: ${formatAskAnswer(selected, custom)}\n${formatAskNote(note)}`; ``` and use it for both the `details.results` map-and-join path and the single-question path (replacing `deck-tools.ts:296-302`). `ASK_DESCRIPTION` is not changed: notes are a user-side affordance and arrive in the result text without the agent needing instructions. ### 4. Dialog UI: note expander, empty multi-select submit All in `apps/web/src/components/chat/AskDialog.tsx`. - `Answer` gains `note?: string` (documented as "free text added alongside the answer; never replaces it"). - **Validity.** `isSelectAnswerValid` keeps its single-select rules; its `multi` branch becomes `return true` — a multi-select is answerable with nothing checked. A note never affects validity for either kind. In `allAnswered`, a `select` dialog with `multi` counts as answered even when no `answers[dialogId]` entry exists yet; every other kind keeps its current requirement. Keep the per-tab checkmark driven by `answers[d.dialogId] !== undefined` so the check still means "you touched this question". - **Submitting.** In `submitAll`, iterate dialogs as today but handle the missing-entry case for multi selects: when `dialog.kind === "select" && dialog.multi` and there is no answer entry, respond `{ values: [] }`. Otherwise, for `multi` respond `{ values, ...(note) }` (values built as today from `answer.values` plus trimmed `otherText`), and for single-select `{ value, ...(note) }`. The note is included only when `answer.note?.trim()` is non-empty, sent untrimmed-but-non-blank (the server trims for its emptiness check and keeps the user's text). - **Note field.** Add below `SelectInput` inside `QuestionTab`, rendered only for `dialog.kind === "select"`: ```tsx function NoteField({ value, onChange }: { value: string; onChange: (v: string) => void }): JSX.Element ``` It holds local `expanded` state initialised to `value.trim().length > 0` (so a note survives tab switches and reappears expanded). Collapsed, it renders a small ghost button labelled `+ Add note` styled like the existing Summary-tab "Edit" link (`text-2xs text-accent underline-offset-2 hover:underline`). Expanded, it renders a label-less `textarea` with `rows={3}`, the same classes as the existing Other textarea (`resize-y rounded border border-line bg-paper px-2 py-1.5 font-mono text-2xs`), placeholder `Note (optional) — sent along with your answer`, autofocused on expand. **No `onKeyDown` handler**: Enter inserts a newline; submission is via the Submit button only. This deliberately differs from the Other textarea's Enter-submits behaviour because notes are commentary and often multi-line. - **Submit label.** In `QuestionTab`, when `dialog.kind === "select" && dialog.multi` and the resolved selection is empty (no `values` entries and no non-blank `otherText`), the primary button reads `Submit (none selected)`; otherwise `Submit`. The Summary tab's button stays `Submit all`. - **Showing the note after answering.** In both the collapsed card (`AskDialog.tsx:125-172`) and `SummaryTab`, render the note under the answer line when present: a muted row `className="mt-0.5 whitespace-pre-wrap text-2xs text-ink-3"` with content `note: {ans.note}`. In both places the derived `answerText` for a multi dialog with no parts must read `none selected` instead of the current `—`, so the review surfaces match what the agent receives. ### 5. Transcript rendering In `apps/web/src/components/tools/Ask.tsx`: - Add `note?: string` to the local `QuestionResult` and `AskDetails` interfaces (these mirror the server's details payload). - `buildSelectionMap` returns `{ selected, custom, note }`; the map's value type gains `note?: string`, populated from `r.note` / `details.note` in both branches. - In the render, after the options `<ul>`: when the result exists and nothing is selected and there is no custom answer, render a muted `none selected` row in the same `font-mono text-2xs` list style, so an empty multi answer is not a blank card. When `sel?.note` is present, render it beneath the list as `note: {sel.note}` with `className="whitespace-pre-wrap font-mono text-2xs text-ink-3"`. ### 6. Tests Extend `apps/server/src/bridge/deck-tools.test.ts` (existing `formatAskAnswer` suite): - `formatAskAnswer([], undefined)` → `"none selected"`. - `formatAskNote("be careful")` → `" note: be careful"`. - `formatAskNote("one\ntwo")` → `" note: one\n two"`. Add `apps/server/src/bridge/ext-ui-bridge.test.ts` covering `deckSelect`'s answer parsing through the real response path — construct `new ExtensionUIBridge("s1")`, register a frame listener to capture the emitted `dialogId`, call `deckSelect(...)`, then `handleResponse(dialogId, …)` and await the promise: - `{ values: [] }` → resolves to `{ values: [] }` (not `undefined`): the empty multi-select answer no longer reads as a cancel. - `{ value: "A", note: "ship it" }` → `{ values: ["A"], note: "ship it" }`. - `{ value: "A", note: " " }` → `{ values: ["A"] }` (blank note dropped). - `{ cancelled: true }` → `undefined`; `{ cancelled: true, dismissedForNewMessage: true }` → `null`. ## Verification Run from the repo root: - `bun test apps/server/src/bridge/` — the two suites above pass. - `bun run typecheck` — covers the protocol change and both app packages (`deckSelect`'s new return type must be reflected in `deck-tools.ts`). End-to-end, with `bun run dev` and the cockpit open on a session: 1. Have the agent ask a single-select question. Click `+ Add note`, type a two-line note, select a non-recommended option, Submit. **Expected:** the collapsed card shows the answer plus a `note:` line; the transcript tool card shows the chosen option marked and the note beneath; the agent's next message reflects both the option and the note. The tool result text reads: ``` <question>: selected: <option> note: <line one> <line two> ``` 2. Have the agent ask a `multi: true` question. Touch nothing; the button reads `Submit (none selected)`. Click it. **Expected:** the turn is **not** aborted (no "Aborted" card in the transcript), the agent receives `<question>: none selected`, and the transcript card shows a `none selected` row with no option marked. 3. Repeat 2 with a note typed and still nothing checked. **Expected:** agent receives `none selected` followed by the indented `note:` line. 4. With a dialog open and a note half-typed, press the X. **Expected:** the turn aborts exactly as before, the note is discarded, and no stray result text appears.
lucas closed this issue 2026-11-06 11:48:24 +01:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
lucas/omp-deck#225
No description provided.