fix(coding-agent/tools): merged ask tool answer rendering into the original question form

- Added a shared `renderAnswerOptions` helper that redrew answered options with markers, custom input, and cancellation state in-place.
- Set `mergeCallAndResult` to true so ask prompts now keep their question form visible while showing the final selection.
- Kept selection rendering consistent by preserving markers and option ordering while reusing the same layout for completed answers.
This commit is contained in:
can1357
2026-06-04 01:34:13 +02:00
parent 53aaced81f
commit 51ffdc20d8
2 changed files with 76 additions and 60 deletions
+1
View File
@@ -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
+75 -60
View File
@@ -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;
},