feat(web): render AskUserQuestion result as an option list (#1391)
Some checks are pending
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Desktop release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions

* feat(web): render AskUserQuestion result as an option list

Parse the AskUserQuestion tool result and echo the full option list, highlighting the chosen option(s) and dimming the rest, instead of dumping raw JSON. Handles single/multi select, free-text Other answers, and the dismissed state. Answers are zipped back to the input questions by index since the input carries no ids.

* fix(web): drop the stray indent in the tool-call card body

The expanded body of every tool-call card carried a hard-coded 36px left indent (a magic number meant to align with the header name). Drop it so expanded content aligns with the header's 11px padding instead. The design-system preview page mirrored the same rule and is updated to match.

* fix(web): align markdown diff block with the design system

Restyle the local ```diff renderer in chat Markdown to match the ~/diff panel: code text keeps the normal ink colour, the +/- sign carries the add/del colour, and rows use a soft background with an inset accent bar instead of dyeing the text green/red. The header and copy button now match the standard code-block chrome (height, hover, focus ring).

* fix(web): match the markdown diff block chrome to code blocks

Make the local markdown diff renderer's shell identical to a regular code block: swap the text copy button for the same icon button, inherit the header's text-xs sizing, and keep the container / header / code-area padding, background, radius and shadow in lockstep with .code-block-container and .code-block-header. The diff-specific row tinting (soft background, inset accent bar, coloured sign) is kept since that is what makes it a diff.

* fix(web): fall back to raw output for non-answer AskUserQuestion results

Background launches and error cases return plain-text tool output (task_id/status lines, or a failure reason), not the { answers } JSON the card expects. The card used to render an empty, fully-unselected option list in those cases, hiding the task id or error. Now the card only renders the option list when the output parses as the answer payload; otherwise it shows the raw output. Addresses review feedback (P2).

* fix(web): localize AskUserQuestion result labels

The card hard-coded user-visible strings (Dismissed, answer/answers, Answered, the (+N more) summary), so Chinese-locale transcripts mixed in English. Move them into the en/zh tools locale files and read them via t(). Addresses review feedback (P2).
This commit is contained in:
qer 2026-07-06 02:36:31 +08:00 committed by GitHub
parent 4963c9016f
commit c5c6282f44
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 650 additions and 42 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
web: Render AskUserQuestion answers as a readable option list with the chosen option(s) highlighted, instead of raw JSON.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
web: Align the markdown diff code block with the design system: code text keeps the normal ink colour while the sign and a soft row background carry the change, matching the ~/diff panel.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
web: Drop the stray left indent in the tool-call card body so expanded content aligns with the header.

View file

@ -20,6 +20,7 @@ import { copyTextToClipboard } from '../../lib/clipboard';
import * as katexWorkerModule from 'markstream-vue/workers/katexRenderer.worker?worker&type=module';
import * as mermaidWorkerModule from 'markstream-vue/workers/mermaidParser.worker?worker&type=module';
import Tooltip from '../ui/Tooltip.vue';
import Icon from '../ui/Icon.vue';
// px-based CSS build (our app is px, not rem). Imported here so the styles
// load wherever Markdown is used; scoped overrides below re-skin it to
// Terminal Pro. Importing the same file from multiple components is a no-op
@ -383,14 +384,24 @@ const segments = computed<Segment[]>(() => {
return out;
});
// Lines of a diff block, classed by +/- for colouring (escaped by Vue's text
// interpolation in the template).
function diffLines(code: string): { cls: string; text: string }[] {
// Lines of a diff block, split into a sign + the code text so the row can be
// skinned like the ~/diff panel (DiffLines.vue): the code text keeps the normal
// ink colour and only the +/- sign carries the add/del colour. The leading
// marker (a single '+', '-', or the context-line space) is stripped from the
// text so the code columns line up. Escaped by Vue's text interpolation.
type DiffRowType = 'add' | 'del' | 'hunk' | 'ctx';
interface DiffRow {
type: DiffRowType;
sign: string;
text: string;
}
function diffLines(code: string): DiffRow[] {
return code.split('\n').map((line) => {
if (/^\+(?!\+\+)/.test(line)) return { cls: 'diff-add', text: line };
if (/^-(?!--)/.test(line)) return { cls: 'diff-del', text: line };
if (line.startsWith('@@')) return { cls: 'diff-hunk', text: line };
return { cls: 'diff-ctx', text: line };
if (line.startsWith('@@')) return { type: 'hunk', sign: '', text: line };
if (/^\+(?!\+\+)/.test(line)) return { type: 'add', sign: '+', text: line.slice(1) };
if (/^-(?!--)/.test(line)) return { type: 'del', sign: '-', text: line.slice(1) };
if (line.startsWith(' ')) return { type: 'ctx', sign: '', text: line.slice(1) };
return { type: 'ctx', sign: '', text: line };
});
}
@ -433,16 +444,17 @@ function copyDiff(code: string, idx: number) {
<div class="diff-bar">
<span class="diff-lang">diff</span>
<Tooltip :text="t('filePreview.copyCode')">
<button class="diff-copy" @click="copyDiff(seg.code, i)">
{{ copiedDiff === i ? '✓' : '⧉' }}
<button class="diff-copy" :aria-label="t('filePreview.copyCode')" @click="copyDiff(seg.code, i)">
<Icon :name="copiedDiff === i ? 'check' : 'copy'" size="sm" />
</button>
</Tooltip>
</div>
<pre class="diff-pre"><code><span
v-for="(ln, j) in diffLines(seg.code)"
:key="j"
:class="ln.cls"
>{{ ln.text }}</span></code></pre>
class="diff-line"
:class="`diff-${ln.type}`"
><span v-if="ln.type !== 'hunk'" class="diff-sign">{{ ln.sign }}</span><span class="diff-text">{{ ln.text }}</span></span></code></pre>
</div>
</template>
</div>
@ -745,9 +757,12 @@ function copyDiff(code: string, idx: number) {
}
/* ---------------------------------------------------------------------------
Local ```diff renderer — same look as the code blocks above, with the
original +/- line colouring (green additions, red deletions). markstream
would strip the markers + drop deletions, so we render diffs ourselves.
Local ```diff renderer — same chrome as the code blocks above, with the
diff rows skinned like the ~/diff panel (DiffLines.vue): a soft row
background and an inset accent bar mark the change, the +/- sign carries
the colour, and the code text itself keeps the normal ink colour so it
stays legible. markstream would strip the markers + drop deletions, so we
render diffs ourselves.
--------------------------------------------------------------------------- */
.diff-wrap {
margin: 0.6em 0;
@ -760,61 +775,85 @@ function copyDiff(code: string, idx: number) {
.diff-bar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
padding: 3px 8px;
padding: 4px 12px;
background: var(--color-surface);
border-bottom: 1px solid var(--color-line);
color: var(--color-text-muted);
font: var(--text-xs) var(--font-mono);
}
.diff-lang {
font: var(--text-xs) var(--font-mono);
color: var(--color-text-muted);
margin-right: auto;
letter-spacing: 0.04em;
}
/* Copy button — mirrors the §03 IconButton / code-block action: muted glyph,
sunken hover, soft radius, shared focus ring. */
.diff-copy {
background: none;
border: none;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--color-text-muted);
font: var(--text-sm) var(--font-mono);
padding: 0 2px;
line-height: 1;
background: transparent;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
padding: 2px 6px;
transition: background var(--duration-base) var(--ease-out),
color var(--duration-base) var(--ease-out);
}
.diff-copy:hover {
color: var(--color-accent);
background: var(--color-surface-sunken);
color: var(--color-text);
}
.diff-copy:focus-visible {
outline: none;
box-shadow: var(--p-focus-ring);
}
.diff-pre {
margin: 0;
padding: 10px 12px;
padding: 12px 0;
overflow-x: auto;
background: var(--color-surface-sunken);
}
.diff-pre code {
font: var(--text-sm) var(--font-mono);
display: block;
width: max-content;
min-width: 100%;
font: var(--text-sm)/1.65 var(--font-mono);
color: var(--color-text);
}
.diff-pre code span {
.diff-line {
display: block;
padding-left: 8px;
border-left: 2px solid transparent;
margin-left: -12px;
padding-right: 12px;
width: 100%;
padding: 0 14px;
}
.diff-sign {
display: inline-block;
width: 14px;
text-align: center;
color: var(--color-text-muted);
user-select: none;
}
.diff-text {
color: var(--color-text);
}
.diff-add {
background: var(--color-success-soft);
box-shadow: inset 2px 0 0 color-mix(in srgb, var(--color-success) 55%, transparent);
}
.diff-add .diff-sign {
color: var(--color-success);
background: color-mix(in srgb, var(--color-success) 10%, transparent);
border-left-color: var(--color-success) !important;
}
.diff-del {
background: var(--color-danger-soft);
box-shadow: inset 2px 0 0 color-mix(in srgb, var(--color-danger) 55%, transparent);
}
.diff-del .diff-sign {
color: var(--color-danger);
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
border-left-color: var(--color-danger) !important;
}
.diff-hunk {
color: var(--color-accent);
background: var(--color-surface);
}
.diff-ctx {
.diff-hunk .diff-text {
color: var(--color-text-muted);
}

View file

@ -168,7 +168,7 @@ defineEmits<{ toggle: [] }>();
color: var(--color-danger);
}
/* Expanded detail: sunken panel under the row, indented to align with the name.
/* Expanded detail: sunken panel under the row.
Collapses/expands via a height transition; `interpolate-size: allow-keywords`
(set on :root) lets `height: auto` interpolate instead of snap. The visual
styles live on `.bb-pad` so they clip cleanly inside the 0-height clip box. */
@ -181,7 +181,7 @@ defineEmits<{ toggle: [] }>();
height: auto;
}
.bb-pad {
padding: 0 11px 11px 36px;
padding: 0 11px 11px;
background: var(--color-surface-sunken);
border-top: 1px solid var(--color-line);
color: var(--color-text);

View file

@ -0,0 +1,266 @@
<!-- apps/kimi-web/src/components/chat/tool-calls/AskUserTool.vue
Result card for the AskUserQuestion tool. On a successful answer the
output is a single JSON line ({ answers, note? }); answers are keyed by
synthesized question id (`q_<index>`) and the values are synthesized option
ids (`opt_<q>_<o>`, comma-joined for multi-select) or free-text (Other). We
zip answers back to the input questions by index and echo the full option
list, marking the picked option(s) selected and the rest faint so the
transcript shows both what was chosen and what was passed over.
Background launches and error cases return plain-text output instead of
the answer JSON; those fall back to a raw output view so the task id /
failure reason is not hidden behind an empty option list. -->
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import type { FilePreviewRequest, ToolCall, ToolMedia } from '../../../types';
import { toolGlyph, toolLabel } from '../../../lib/toolMeta';
import {
parseAskInput,
parseAskOutput,
resolveAnswer,
} from './askUserToolParse';
import ToolRow from '../ToolRow.vue';
const props = withDefaults(
defineProps<{
tool: ToolCall;
mobile?: boolean;
stackPosition?: 'single' | 'first' | 'middle' | 'last';
toolDiffPanel?: boolean;
}>(),
{ mobile: false, stackPosition: 'single', toolDiffPanel: false },
);
defineEmits<{
openMedia: [media: ToolMedia];
openFile: [target: FilePreviewRequest];
openToolDiff: [id: string];
}>();
const { t } = useI18n();
const SUMMARY_MAX = 80;
function clip(s: string, max = SUMMARY_MAX): string {
const trimmed = s.trim();
return trimmed.length > max ? trimmed.slice(0, max - 1) + '…' : trimmed;
}
const questions = computed(() => parseAskInput(props.tool.arg));
const output = computed(() => parseAskOutput(props.tool.output));
const recognized = computed(() => output.value.recognized);
const isDismissed = computed(
() => recognized.value && Object.keys(output.value.answers).length === 0 && output.value.note.length > 0,
);
const resolved = computed(() =>
questions.value.map((_, i) => resolveAnswer(output.value.answers[`q_${i}`])),
);
const answeredCount = computed(() => Object.keys(output.value.answers).length);
function isSelected(qi: number, oi: number): boolean {
return resolved.value[qi]?.selected.has(oi) ?? false;
}
function otherText(qi: number): string {
return resolved.value[qi]?.otherText ?? '';
}
function isIndeterminate(qi: number): boolean {
return resolved.value[qi]?.indeterminate ?? false;
}
function glyphFor(multiSelect: boolean, on: boolean): string {
return multiSelect ? (on ? '■' : '□') : (on ? '●' : '○');
}
const summary = computed(() => {
if (!recognized.value) return clip(props.tool.output?.[0] ?? '');
if (isDismissed.value) return t('tools.ask.dismissed');
const first = questions.value[0]?.question ?? '';
const base = clip(first);
if (questions.value.length <= 1) return base;
return `${base} ${t('tools.ask.more', { count: questions.value.length - 1 })}`;
});
const chip = computed(() => {
if (!recognized.value) return '';
if (isDismissed.value) return t('tools.ask.dismissed');
if (answeredCount.value === 0) return '';
return answeredCount.value === 1
? t('tools.ask.answer', { count: 1 })
: t('tools.ask.answers', { count: answeredCount.value });
});
const hasOutput = computed(() => !!props.tool.output && props.tool.output.length > 0);
const canExpand = computed(
() => (recognized.value && (questions.value.length > 0 || isDismissed.value)) || hasOutput.value,
);
const open = ref(props.tool.defaultExpanded === true && canExpand.value);
const status = computed<'running' | 'ok' | 'error'>(() => props.tool.status as 'running' | 'ok' | 'error');
const label = computed(() => toolLabel(props.tool.name));
const glyph = computed(() => toolGlyph(props.tool.name));
function toggle(): void {
if (canExpand.value) open.value = !open.value;
}
watch(
() => [props.tool.defaultExpanded, props.tool.output?.length, props.tool.status] as const,
() => {
if (props.tool.defaultExpanded === true && canExpand.value) open.value = true;
},
);
</script>
<template>
<ToolRow
:status="status"
:icon="glyph"
:name="label"
:arg="!open ? summary : ''"
:time="tool.timing"
:open="open"
:expandable="canExpand"
:stacked="stackPosition !== 'single'"
:stack-position="stackPosition"
@toggle="toggle"
>
<template #trailing>
<span v-if="chip" class="chip">{{ chip }}</span>
</template>
<div v-if="isDismissed" class="au-dismissed">{{ output.note }}</div>
<div v-else-if="recognized" class="au-list">
<div v-for="(q, qi) in questions" :key="qi" class="au-block">
<div class="au-q">
<span v-if="q.header" class="au-hdr">{{ q.header }}</span>
<span class="au-qtext">{{ q.question }}</span>
</div>
<div class="au-opts">
<div
v-for="(opt, oi) in q.options"
:key="oi"
class="au-opt"
:class="{ sel: isSelected(qi, oi) }"
>
<span class="au-glyph">{{ glyphFor(q.multiSelect, isSelected(qi, oi)) }}</span>
<span class="au-label">{{ opt.label }}</span>
<span v-if="opt.description" class="au-desc">{{ opt.description }}</span>
</div>
<div v-if="otherText(qi)" class="au-opt sel">
<span class="au-glyph">{{ glyphFor(q.multiSelect, true) }}</span>
<span class="au-label">{{ otherText(qi) }}</span>
</div>
<div v-if="isIndeterminate(qi)" class="au-opt sel">
<span class="au-glyph"></span>
<span class="au-label">{{ t('tools.ask.answered') }}</span>
</div>
</div>
</div>
</div>
<!-- Not the answer payload (background launch / error): show the raw tool
output instead of an empty option list. -->
<div v-else class="au-raw">
<div v-for="(line, i) in tool.output ?? []" :key="i">{{ line }}</div>
</div>
</ToolRow>
</template>
<style scoped>
.chip {
color: var(--color-text-muted);
font-size: var(--text-xs);
flex: none;
}
.au-dismissed {
color: var(--color-text-muted);
font: italic var(--text-sm)/var(--leading-normal) var(--font-ui);
}
.au-list {
display: flex;
flex-direction: column;
font: var(--text-sm)/var(--leading-normal) var(--font-ui);
}
.au-block {
padding: 4px 0;
}
.au-block + .au-block {
margin-top: 4px;
padding-top: 10px;
border-top: 1px dashed var(--color-line);
}
.au-q {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 6px;
}
.au-hdr {
font: var(--text-xs) var(--font-mono);
color: var(--color-text-muted);
background: var(--color-surface-raised);
border: 1px solid var(--color-line);
border-radius: var(--radius-sm);
padding: 0 6px;
flex: none;
}
.au-qtext {
color: var(--color-text);
font-weight: var(--weight-medium);
}
.au-opts {
display: flex;
flex-direction: column;
gap: 4px;
}
.au-opt {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 10px;
border: 1px solid var(--color-line);
border-radius: var(--radius-md);
color: var(--color-text-faint);
}
.au-opt.sel {
border-color: var(--color-accent-bd);
background: var(--color-accent-soft);
color: var(--color-text);
}
.au-glyph {
font: var(--text-base) var(--font-mono);
color: var(--color-text-faint);
width: 14px;
text-align: center;
flex: none;
}
.au-opt.sel .au-glyph {
color: var(--color-accent-hover);
}
.au-label {
color: inherit;
}
.au-desc {
color: var(--color-text-faint);
font-size: var(--text-xs);
margin-left: 2px;
}
.au-opt.sel .au-desc {
color: var(--color-text-muted);
}
.au-raw {
padding: 11px 13px;
border: 1px solid var(--color-line);
border-radius: var(--radius-md);
background: var(--color-surface-raised);
font: var(--text-sm)/1.65 var(--font-mono);
white-space: pre-wrap;
word-break: break-word;
}
</style>

View file

@ -0,0 +1,126 @@
// Pure parsers for the AskUserQuestion tool card. Kept separate from the SFC so
// the index-zip / id-decode logic is unit-testable without a DOM.
//
// Wire shape (from agent-core SCHEMAS §6.4):
// tool.arg : JSON { questions: [{ question, header, options[{label,description}], multi_select }] }
// Input questions carry NO id — order === broker order.
// tool.output[0]: on a successful answer, JSON { answers: Record<qid, string|true>, note? }
// qid = `q_<index>`; value = `opt_<q>_<o>` (single),
// `opt_<q>_<o>,opt_<q>_<o>` (multi, comma-joined), free-text
// (Other), or `opt_…,<text>` (multi+Other). skipped → omitted.
// Dismissed → { answers: {}, note }.
// : on a background launch, plain text (`task_id: …\nstatus: …`);
// on an error (e.g. unsupported interactive questions), plain
// text. Those are NOT the answer payload and must be shown raw.
export interface AskOption {
label: string;
description: string;
}
export interface AskQuestion {
question: string;
header: string;
options: AskOption[];
multiSelect: boolean;
}
export interface AskOutput {
/** True only when the output parsed as the answer payload (`{ answers: {...} }`).
* False for background / error plain-text output, which the card must show raw. */
recognized: boolean;
answers: Record<string, string | true>;
note: string;
}
export interface Resolved {
/** Option indices picked for this question. */
selected: Set<number>;
/** Free-text "Other" segment, when the answer carried one. */
otherText: string;
/** The flattened value was the literal `true` answered, but no concrete
option to echo back onto the list. */
indeterminate: boolean;
}
export function parseAskInput(arg: string): AskQuestion[] {
if (!arg) return [];
try {
const obj = JSON.parse(arg) as Record<string, unknown>;
const raw = obj['questions'];
if (!Array.isArray(raw)) return [];
const out: AskQuestion[] = [];
for (const q of raw) {
if (!q || typeof q !== 'object') continue;
const qr = q as Record<string, unknown>;
const opts: AskOption[] = Array.isArray(qr['options'])
? (qr['options'] as unknown[]).map(o => {
const or = (o && typeof o === 'object' ? o : {}) as Record<string, unknown>;
return {
label: typeof or['label'] === 'string' ? or['label'] : '',
description: typeof or['description'] === 'string' ? or['description'] : '',
};
})
: [];
out.push({
question: typeof qr['question'] === 'string' ? qr['question'] : '',
header: typeof qr['header'] === 'string' ? qr['header'] : '',
options: opts,
multiSelect: qr['multi_select'] === true,
});
}
return out;
} catch {
return [];
}
}
const EMPTY: AskOutput = { recognized: false, answers: {}, note: '' };
export function parseAskOutput(output: string[] | undefined): AskOutput {
const line = output?.[0];
if (!line) return EMPTY;
let obj: unknown;
try {
obj = JSON.parse(line);
} catch {
// Plain-text output (background `task_id/status`, error message) — show raw.
return EMPTY;
}
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return EMPTY;
const raw = (obj as Record<string, unknown>)['answers'];
// The answer payload is the only shape we render specially; anything else
// (a JSON object without an `answers` record) falls back to raw output.
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return EMPTY;
const answers: Record<string, string | true> = {};
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
if (typeof v === 'string') answers[k] = v;
else if (v === true) answers[k] = true;
}
return {
recognized: true,
answers,
note: typeof (obj as Record<string, unknown>)['note'] === 'string'
? ((obj as Record<string, unknown>)['note'] as string)
: '',
};
}
const OPT_ID = /^opt_\d+_(\d+)$/;
/** Decode one question's flattened answer into picked option indices plus any
* free-text "Other" segment. Option ids carry their own index, so this is
* exact rather than a label match; non-`opt_` segments are treated as the
* Other text (joined back with `,` in case the free text itself contained one). */
export function resolveAnswer(value: string | true | undefined): Resolved {
if (value === undefined) return { selected: new Set(), otherText: '', indeterminate: false };
if (value === true) return { selected: new Set(), otherText: '', indeterminate: true };
const selected = new Set<number>();
const others: string[] = [];
for (const seg of value.split(',')) {
const m = OPT_ID.exec(seg);
if (m) selected.add(Number(m[1]));
else if (seg.length > 0) others.push(seg);
}
return { selected, otherText: others.join(','), indeterminate: false };
}

View file

@ -3,6 +3,7 @@ import type { Component } from 'vue';
import type { ToolCall } from '../../../types';
import { normalizeToolName } from '../../../lib/toolMeta';
import AgentTool from './AgentTool.vue';
import AskUserTool from './AskUserTool.vue';
import EditTool from './EditTool.vue';
import GenericTool from './GenericTool.vue';
import MediaTool from './MediaTool.vue';
@ -19,5 +20,6 @@ export function resolveToolRenderer(tool: ToolCall): ToolRenderer {
// `task` — `agent` here would be dead code and route subagent calls to
// GenericTool, dropping the inline "Open" button for the detail panel.
if (name === 'task') return AgentTool;
if (name === 'askuserquestion') return AskUserTool;
return GenericTool;
}

View file

@ -11,6 +11,7 @@ export default {
search: 'Search',
todo: 'Todo',
task: 'Task',
ask_user: 'Question',
},
chip: {
lines: '{count} lines',
@ -25,4 +26,11 @@ export default {
error: 'failed',
done: 'done',
},
ask: {
dismissed: 'Dismissed',
answer: '{count} answer',
answers: '{count} answers',
answered: 'Answered',
more: '(+{count} more)',
},
} as const;

View file

@ -11,6 +11,7 @@ export default {
search: '搜索',
todo: '待办',
task: '任务',
ask_user: '提问',
},
chip: {
lines: '{count} 行',
@ -25,4 +26,11 @@ export default {
error: '有失败',
done: '已完成',
},
ask: {
dismissed: '已忽略',
answer: '{count} 个回答',
answers: '{count} 个回答',
answered: '已回答',
more: '(还有 {count} 个)',
},
} as const;

View file

@ -23,6 +23,7 @@ const TOOL_LABEL_KEYS: Record<string, string> = {
search: 'tools.label.search',
todo: 'tools.label.todo',
task: 'tools.label.task',
askuserquestion: 'tools.label.ask_user',
};
// ---------------------------------------------------------------------------
@ -89,6 +90,7 @@ const TOOL_GLYPH: Record<string, IconName> = {
web_fetch: 'globe',
todo: 'check-list',
task: 'sparkles',
askuserquestion: 'help-circle',
};
export function toolGlyph(name: string): string {

View file

@ -2132,7 +2132,7 @@ onUnmounted(() => {
.p-tool-row.expanded .tr-car { transform: rotate(90deg); }
/* Detail after a row is expanded (code / output) */
.p-tool-detail { padding: 0 11px 11px 36px; background: var(--p-surface-sunken); border-top: 1px solid var(--p-line); }
.p-tool-detail { padding: 0 11px 11px; background: var(--p-surface-sunken); border-top: 1px solid var(--p-line); }
.p-tool-detail .p-code { margin-top: 10px; }
/* ===== Chat: Composer ===== */

View file

@ -0,0 +1,142 @@
import { describe, expect, it } from 'vitest';
import {
parseAskInput,
parseAskOutput,
resolveAnswer,
} from '../src/components/chat/tool-calls/askUserToolParse';
const ARG = JSON.stringify({
questions: [
{
question: 'Which auth provider?',
header: 'Auth',
multi_select: false,
options: [
{ label: 'Clerk', description: 'Native Vercel Marketplace' },
{ label: 'Auth0', description: 'Enterprise SSO' },
],
},
{
question: 'Where to deploy?',
header: 'Deploy',
multi_select: true,
options: [
{ label: 'Vercel', description: 'Zero-config' },
{ label: 'Fly.io', description: 'Edge' },
{ label: 'AWS', description: 'Full control' },
],
},
],
});
describe('parseAskInput', () => {
it('reads questions, options, header and multi_select', () => {
const qs = parseAskInput(ARG);
expect(qs).toHaveLength(2);
expect(qs[0]).toMatchObject({ header: 'Auth', multiSelect: false });
expect(qs[0].options.map(o => o.label)).toEqual(['Clerk', 'Auth0']);
expect(qs[1]).toMatchObject({ header: 'Deploy', multiSelect: true });
expect(qs[1].options).toHaveLength(3);
});
it('defaults missing optional fields and tolerates malformed input', () => {
expect(parseAskInput('')).toEqual([]);
expect(parseAskInput('not json')).toEqual([]);
expect(parseAskInput('{}')).toEqual([]);
expect(parseAskInput(JSON.stringify({ questions: 'nope' }))).toEqual([]);
// partial option entries degrade to empty strings, not a throw
const qs = parseAskInput(JSON.stringify({ questions: [{ options: [{ label: 'A' }, null] }] }));
expect(qs[0].options).toEqual([
{ label: 'A', description: '' },
{ label: '', description: '' },
]);
});
});
describe('parseAskOutput', () => {
it('recognizes an answer payload and reads answers', () => {
const out = parseAskOutput([JSON.stringify({ answers: { q_0: 'opt_0_1' }, note: '' })]);
expect(out.recognized).toBe(true);
expect(out.answers).toEqual({ q_0: 'opt_0_1' });
});
it('keeps string and true values, drops others', () => {
const out = parseAskOutput([JSON.stringify({ answers: { a: 'x', b: true, c: 3, d: null } })]);
expect(out.recognized).toBe(true);
expect(out.answers).toEqual({ a: 'x', b: true });
});
it('recognizes the dismissed payload (empty answers + note)', () => {
const out = parseAskOutput([
JSON.stringify({ answers: {}, note: 'User dismissed the question without answering.' }),
]);
expect(out.recognized).toBe(true);
expect(Object.keys(out.answers)).toHaveLength(0);
expect(out.note).toContain('dismissed');
});
it('does not recognize plain-text background output', () => {
const out = parseAskOutput(['task_id: abc\ndescription: run it\nstatus: running']);
expect(out.recognized).toBe(false);
});
it('does not recognize plain-text error output', () => {
expect(parseAskOutput(['Interactive questions are not supported in this session.']).recognized).toBe(false);
});
it('does not recognize JSON that is not the answer payload', () => {
expect(parseAskOutput([JSON.stringify({ foo: 'bar' })]).recognized).toBe(false);
expect(parseAskOutput([JSON.stringify({ answers: 'nope' })]).recognized).toBe(false);
expect(parseAskOutput([JSON.stringify(['x'])]).recognized).toBe(false);
});
it('tolerates missing output', () => {
expect(parseAskOutput(undefined)).toEqual({ recognized: false, answers: {}, note: '' });
expect(parseAskOutput([])).toEqual({ recognized: false, answers: {}, note: '' });
});
});
describe('resolveAnswer', () => {
it('decodes a single-select option id to its index', () => {
const r = resolveAnswer('opt_0_1');
expect([...r.selected]).toEqual([1]);
expect(r.otherText).toBe('');
expect(r.indeterminate).toBe(false);
});
it('decodes a comma-joined multi-select into several indices', () => {
const r = resolveAnswer('opt_1_0,opt_1_2');
expect(r.selected).toEqual(new Set([0, 2]));
});
it('treats a free-text value as an Other answer', () => {
const r = resolveAnswer('Use OIDC instead of static keys');
expect(r.selected.size).toBe(0);
expect(r.otherText).toBe('Use OIDC instead of static keys');
});
it('splits a multi+Other value into options plus the free-text segment', () => {
const r = resolveAnswer('opt_0_0,opt_0_2,Custom thing');
expect(r.selected).toEqual(new Set([0, 2]));
expect(r.otherText).toBe('Custom thing');
});
it('joins non-id segments back so Other text containing a comma survives', () => {
const r = resolveAnswer('opt_0_1,alpha,beta');
expect([...r.selected]).toEqual([1]);
expect(r.otherText).toBe('alpha,beta');
});
it('marks the literal true as indeterminate', () => {
const r = resolveAnswer(true);
expect(r.indeterminate).toBe(true);
expect(r.selected.size).toBe(0);
});
it('returns an empty result for skipped / unanswered questions', () => {
const r = resolveAnswer(undefined);
expect(r.selected.size).toBe(0);
expect(r.otherText).toBe('');
expect(r.indeterminate).toBe(false);
});
});