Optional notes on ask answers, and submittable empty multi-selects #225
Labels
No labels
Agent
Ignore
Agent
In Progress
Agent
Planned
Agent
Planning
Agent
Ready
Agent
Triage
Compat
Breaking
Kind
Bug
Kind
Documentation
Kind
Enhancement
Kind
Feature
Kind
Security
Kind
Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
lucas/omp-deck#225
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
The deck's
askdialog 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: truequestions. The deck's dialog currently refuses to submit a multi-select with nothing checked, and the server cannot even represent that answer:ExtensionUIBridge.deckSelectonly accepts a non-emptyvaluesarray, so an empty one falls through toundefined, whichcreateDeckAskToolinterprets 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 selectedanswer rather than an abort.Plan
1. Carry a note on the dialog wire response
In
packages/protocol/src/index.ts, add toExtUiDialogResponse(the interface documented as the client's reply shape, immediately aftervalues):No other protocol change:
ext_ui_dialog_responsealready spreadsExtUiDialogResponse, andapps/web/src/lib/store.tsrespondToExtUiDialogalready spreads the response object onto the frame, so the field reaches the server with no store change.2. Return note and empty selections from
deckSelectIn
apps/server/src/bridge/ext-ui-bridge.ts:deckSelect's return type toPromise<DeckSelectAnswer | null | undefined>and rewrite itsparseResponsebody (currentlyext-ui-bridge.ts:235-244) to:Update the method's existing doc comment: the
string[]return line becomesDeckSelectAnswer — 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). Itsselectcase reads onlyresponse.valueand so drops both multi-select arrays and notes. Replace that case with:Leave the
confirm/input/editor/ default cases untouched: notes are collected only on select dialogs.The generic
select(),input(),editor()andconfirm()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:formatAskAnswercurrently 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:executecurrently types the resolved dialogs asconst answered = responses as string[][]. Change toDeckSelectAnswer[](imported from./ext-ui-bridge.ts), and havebuildResult(q, answer: DeckSelectAnswer)splitanswer.valuesintoselectedOptions/customInputexactly as today while passingnote: answer.notethrough. Thenull/undefinedbranches 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
noteon the details in both shapes.AskToolDetailsand itsQuestionResult(imported from@oh-my-pi/pi-tui/tools/ask) already declarenote?: string, so no type change is needed. LeavenoteOptionunset: a deck note is attached to the question's answer as a whole, not to an individual option row.Build
responseTextwith the note on its own line beneath each answer line:and use it for both the
details.resultsmap-and-join path and the single-question path (replacingdeck-tools.ts:296-302).ASK_DESCRIPTIONis 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.Answergainsnote?: string(documented as "free text added alongside the answer; never replaces it").Validity.
isSelectAnswerValidkeeps its single-select rules; itsmultibranch becomesreturn true— a multi-select is answerable with nothing checked. A note never affects validity for either kind. InallAnswered, aselectdialog withmulticounts as answered even when noanswers[dialogId]entry exists yet; every other kind keeps its current requirement. Keep the per-tab checkmark driven byanswers[d.dialogId] !== undefinedso the check still means "you touched this question".Submitting. In
submitAll, iterate dialogs as today but handle the missing-entry case for multi selects: whendialog.kind === "select" && dialog.multiand there is no answer entry, respond{ values: [] }. Otherwise, formultirespond{ values, ...(note) }(values built as today fromanswer.valuesplus trimmedotherText), and for single-select{ value, ...(note) }. The note is included only whenanswer.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
SelectInputinsideQuestionTab, rendered only fordialog.kind === "select":It holds local
expandedstate initialised tovalue.trim().length > 0(so a note survives tab switches and reappears expanded). Collapsed, it renders a small ghost button labelled+ Add notestyled like the existing Summary-tab "Edit" link (text-2xs text-accent underline-offset-2 hover:underline). Expanded, it renders a label-lesstextareawithrows={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), placeholderNote (optional) — sent along with your answer, autofocused on expand. NoonKeyDownhandler: 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, whendialog.kind === "select" && dialog.multiand the resolved selection is empty (novaluesentries and no non-blankotherText), the primary button readsSubmit (none selected); otherwiseSubmit. The Summary tab's button staysSubmit all.Showing the note after answering. In both the collapsed card (
AskDialog.tsx:125-172) andSummaryTab, render the note under the answer line when present: a muted rowclassName="mt-0.5 whitespace-pre-wrap text-2xs text-ink-3"with contentnote: {ans.note}. In both places the derivedanswerTextfor a multi dialog with no parts must readnone selectedinstead of the current—, so the review surfaces match what the agent receives.5. Transcript rendering
In
apps/web/src/components/tools/Ask.tsx:note?: stringto the localQuestionResultandAskDetailsinterfaces (these mirror the server's details payload).buildSelectionMapreturns{ selected, custom, note }; the map's value type gainsnote?: string, populated fromr.note/details.notein both branches.<ul>: when the result exists and nothing is selected and there is no custom answer, render a mutednone selectedrow in the samefont-mono text-2xslist style, so an empty multi answer is not a blank card. Whensel?.noteis present, render it beneath the list asnote: {sel.note}withclassName="whitespace-pre-wrap font-mono text-2xs text-ink-3".6. Tests
Extend
apps/server/src/bridge/deck-tools.test.ts(existingformatAskAnswersuite):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.tscoveringdeckSelect's answer parsing through the real response path — constructnew ExtensionUIBridge("s1"), register a frame listener to capture the emitteddialogId, calldeckSelect(...), thenhandleResponse(dialogId, …)and await the promise:{ values: [] }→ resolves to{ values: [] }(notundefined): 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 indeck-tools.ts).End-to-end, with
bun run devand the cockpit open on a session:+ Add note, type a two-line note, select a non-recommended option, Submit. Expected: the collapsed card shows the answer plus anote: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:multi: truequestion. Touch nothing; the button readsSubmit (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 anone selectedrow with no option marked.none selectedfollowed by the indentednote:line.