diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 126c17b42..638dc5203 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -12,6 +12,7 @@ ### Changed - Changed the `ask` tool transcript renderer to mark single-choice questions with circular radio glyphs (`○`/`◉`) instead of the rectangular checkbox glyphs (`☐`/`☑`) it shares with multi-select questions, so a "pick one" combo box visually reads as a radio group rather than a checklist. Multi-select questions keep checkboxes. Added a `radio.selected`/`radio.unselected` symbol pair across the unicode, nerd-font, and ASCII presets. +- Changed the `ask` tool transcript renderer to mark the chosen answer inside the question form rather than re-listing the questions in a detached summary block below it. Once a question is answered, the standalone prompt preview is dropped and the result redraws the same form — every offered option still shown, with the selected one(s) filled in (`◉`/`☑`, highlighted) and the rest dimmed (`○`/`☐`); custom free-text answers and cancellations render in place as the final entry. This removes the duplicate question/option listing that previously appeared once as the call preview and again as the result. - Changed task-completion and `ask` desktop notifications to structured terminal notifications (title, body, type, and a focus-on-click action). On Kitty these render through OSC 99 as a proper title/body with click-to-focus; terminals without confirmed OSC 99 support collapse them to the previous single-line message (BEL/OSC 9). ### Fixed diff --git a/packages/coding-agent/src/tools/ask.ts b/packages/coding-agent/src/tools/ask.ts index 4b99f84f7..601fa48c2 100644 --- a/packages/coding-agent/src/tools/ask.ts +++ b/packages/coding-agent/src/tools/ask.ts @@ -16,7 +16,7 @@ */ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; -import { type Component, Container, Markdown, renderInlineMarkdown, TERMINAL, Text } from "@oh-my-pi/pi-tui"; +import { type Component, Container, Markdown, type MarkdownTheme, renderInlineMarkdown, TERMINAL, Text } from "@oh-my-pi/pi-tui"; import { prompt, untilAborted } from "@oh-my-pi/pi-utils"; import * as z from "zod/v4"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; @@ -644,7 +644,56 @@ function optionMarker(uiTheme: Theme, multi: boolean | undefined, selected: bool return selected ? uiTheme.radio.selected : uiTheme.radio.unselected; } +/** + * Render the answered option list for a question: every offered option with its + * selection marker filled in, plus any custom free-text answer. This keeps the + * result visually identical to the question form (`renderCall`) so the answer + * reads in place rather than as a detached summary block. + * + * `linePrefix` is the indent that precedes each entry's tree branch — a single + * leading space for top-level (single-question) entries, or the question's + * vertical continuation for nested (multi-question) entries. + */ +function renderAnswerOptions( + uiTheme: Theme, + mdTheme: MarkdownTheme, + linePrefix: string, + options: string[] | undefined, + selectedOptions: string[] | undefined, + multi: boolean | undefined, + customInput: string | undefined, +): string { + const selected = new Set(selectedOptions ?? []); + // Prefer the full recorded option set; fall back to the selected labels when + // details omit the options array. + const list = options && options.length > 0 ? options : (selectedOptions ?? []); + + // Nothing was chosen (and no custom answer) → a lone cancelled marker. + if (selected.size === 0 && customInput === undefined) { + return `${linePrefix}${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`; + } + + let text = ""; + for (let i = 0; i < list.length; i++) { + const label = list[i]; + const isSelected = selected.has(label); + const isLastEntry = i === list.length - 1 && customInput === undefined; + const branch = isLastEntry ? uiTheme.tree.last : uiTheme.tree.branch; + const marker = optionMarker(uiTheme, multi, isSelected); + const markerStyled = isSelected ? uiTheme.fg("success", marker) : uiTheme.fg("dim", marker); + const labelStyled = renderInlineMarkdown(label, mdTheme, t => + isSelected ? uiTheme.fg("toolOutput", t) : uiTheme.fg("muted", t), + ); + text += `${text ? "\n" : ""}${linePrefix}${uiTheme.fg("dim", branch)} ${markerStyled} ${labelStyled}`; + } + if (customInput !== undefined) { + text += renderCustomInput(uiTheme, linePrefix, customInput, true, text.length > 0); + } + return text; +} + export const askToolRenderer = { + mergeCallAndResult: true, renderCall(args: AskRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component { const label = formatTitle("Ask", uiTheme); const mdTheme = getMarkdownTheme(); @@ -760,43 +809,21 @@ export const askToolRenderer = { for (let i = 0; i < details.results.length; i++) { const r = details.results[i]; const isLastQuestion = i === details.results.length - 1; - const branch = isLastQuestion ? uiTheme.tree.last : uiTheme.tree.branch; - const continuation = isLastQuestion ? " " : `${uiTheme.fg("dim", uiTheme.tree.vertical)} `; - const hasSelection = r.customInput !== undefined || r.selectedOptions.length > 0; - const statusIcon = hasSelection - ? uiTheme.styledSymbol("status.success", "success") - : uiTheme.styledSymbol("status.warning", "warning"); + const qBranch = isLastQuestion ? uiTheme.tree.last : uiTheme.tree.branch; + const continuation = isLastQuestion ? " " : uiTheme.tree.vertical; + const linePrefix = ` ${uiTheme.fg("dim", continuation)} `; container.addChild( - new Text(` ${uiTheme.fg("dim", branch)} ${statusIcon} ${uiTheme.fg("dim", `[${r.id}]`)}`, 0, 0), + new Text(` ${uiTheme.fg("dim", qBranch)} ${uiTheme.fg("dim", `[${r.id}]`)}`, 0, 0), ); container.addChild(new Markdown(r.question, 3, 0, mdTheme, accentStyle)); - - const answerLines: string[] = []; - for (let j = 0; j < r.selectedOptions.length; j++) { - const isLast = j === r.selectedOptions.length - 1 && r.customInput === undefined; - const optBranch = isLast ? uiTheme.tree.last : uiTheme.tree.branch; - const selectedLabel = renderInlineMarkdown(r.selectedOptions[j], mdTheme, t => - uiTheme.fg("toolOutput", t), - ); - answerLines.push( - `${continuation}${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("success", optionMarker(uiTheme, r.multi, true))} ${selectedLabel}`, - ); - } - if (answerLines.length > 0) { - container.addChild(new Text(answerLines.join("\n"), 0, 0)); - } - if (r.customInput !== undefined) { - container.addChild(new Text(renderCustomInput(uiTheme, continuation, r.customInput, true, false), 0, 0)); - } else if (r.selectedOptions.length === 0) { - container.addChild( - new Text( - `${continuation}${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`, - 0, - 0, - ), - ); - } + container.addChild( + new Text( + renderAnswerOptions(uiTheme, mdTheme, linePrefix, r.options, r.selectedOptions, r.multi, r.customInput), + 0, + 0, + ), + ); } return container; } @@ -815,33 +842,21 @@ export const askToolRenderer = { container.addChild(new Text(header, 0, 0)); container.addChild(new Markdown(details.question, 1, 0, mdTheme, accentStyle)); - const answerLines: string[] = []; - if (details.selectedOptions && details.selectedOptions.length > 0) { - for (let i = 0; i < details.selectedOptions.length; i++) { - const isLast = i === details.selectedOptions.length - 1 && details.customInput === undefined; - const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch; - const selectedLabel = renderInlineMarkdown(details.selectedOptions[i], mdTheme, t => - uiTheme.fg("toolOutput", t), - ); - answerLines.push( - ` ${uiTheme.fg("dim", branch)} ${uiTheme.fg("success", optionMarker(uiTheme, details.multi, true))} ${selectedLabel}`, - ); - } - } - if (answerLines.length > 0) { - container.addChild(new Text(answerLines.join("\n"), 0, 0)); - } - if (details.customInput !== undefined) { - container.addChild(new Text(renderCustomInput(uiTheme, " ", details.customInput, true, false), 0, 0)); - } else if (!details.selectedOptions || details.selectedOptions.length === 0) { - container.addChild( - new Text( - ` ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`, - 0, - 0, + container.addChild( + new Text( + renderAnswerOptions( + uiTheme, + mdTheme, + " ", + details.options, + details.selectedOptions, + details.multi, + details.customInput, ), - ); - } + 0, + 0, + ), + ); return container; },