diff --git a/.changeset/web-ask-user-question-result.md b/.changeset/web-ask-user-question-result.md new file mode 100644 index 000000000..cd5146ccc --- /dev/null +++ b/.changeset/web-ask-user-question-result.md @@ -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. diff --git a/.changeset/web-markdown-diff-block.md b/.changeset/web-markdown-diff-block.md new file mode 100644 index 000000000..3679acf5b --- /dev/null +++ b/.changeset/web-markdown-diff-block.md @@ -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. diff --git a/.changeset/web-tool-row-body-padding.md b/.changeset/web-tool-row-body-padding.md new file mode 100644 index 000000000..ce4f4d896 --- /dev/null +++ b/.changeset/web-tool-row-body-padding.md @@ -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. diff --git a/apps/kimi-web/src/components/chat/Markdown.vue b/apps/kimi-web/src/components/chat/Markdown.vue index cf6b83fa3..65397f4e1 100644 --- a/apps/kimi-web/src/components/chat/Markdown.vue +++ b/apps/kimi-web/src/components/chat/Markdown.vue @@ -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(() => { 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) {
diff -
{{ ln.text }}
+ class="diff-line" + :class="`diff-${ln.type}`" + >{{ ln.sign }}{{ ln.text }} @@ -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); } diff --git a/apps/kimi-web/src/components/chat/ToolRow.vue b/apps/kimi-web/src/components/chat/ToolRow.vue index 3c26e26ba..239d1a4cb 100644 --- a/apps/kimi-web/src/components/chat/ToolRow.vue +++ b/apps/kimi-web/src/components/chat/ToolRow.vue @@ -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); diff --git a/apps/kimi-web/src/components/chat/tool-calls/AskUserTool.vue b/apps/kimi-web/src/components/chat/tool-calls/AskUserTool.vue new file mode 100644 index 000000000..f14b19df8 --- /dev/null +++ b/apps/kimi-web/src/components/chat/tool-calls/AskUserTool.vue @@ -0,0 +1,266 @@ + + + + + + diff --git a/apps/kimi-web/src/components/chat/tool-calls/askUserToolParse.ts b/apps/kimi-web/src/components/chat/tool-calls/askUserToolParse.ts new file mode 100644 index 000000000..9e0c07604 --- /dev/null +++ b/apps/kimi-web/src/components/chat/tool-calls/askUserToolParse.ts @@ -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, note? } +// qid = `q_`; value = `opt__` (single), +// `opt__,opt__` (multi, comma-joined), free-text +// (Other), or `opt_…,` (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; + note: string; +} + +export interface Resolved { + /** Option indices picked for this question. */ + selected: Set; + /** 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; + 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; + const opts: AskOption[] = Array.isArray(qr['options']) + ? (qr['options'] as unknown[]).map(o => { + const or = (o && typeof o === 'object' ? o : {}) as Record; + 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)['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 = {}; + for (const [k, v] of Object.entries(raw as Record)) { + if (typeof v === 'string') answers[k] = v; + else if (v === true) answers[k] = true; + } + return { + recognized: true, + answers, + note: typeof (obj as Record)['note'] === 'string' + ? ((obj as Record)['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(); + 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 }; +} diff --git a/apps/kimi-web/src/components/chat/tool-calls/toolRegistry.ts b/apps/kimi-web/src/components/chat/tool-calls/toolRegistry.ts index fa65832de..f21e6857d 100644 --- a/apps/kimi-web/src/components/chat/tool-calls/toolRegistry.ts +++ b/apps/kimi-web/src/components/chat/tool-calls/toolRegistry.ts @@ -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; } diff --git a/apps/kimi-web/src/i18n/locales/en/tools.ts b/apps/kimi-web/src/i18n/locales/en/tools.ts index 04de2f1a2..fe81c4acd 100644 --- a/apps/kimi-web/src/i18n/locales/en/tools.ts +++ b/apps/kimi-web/src/i18n/locales/en/tools.ts @@ -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; diff --git a/apps/kimi-web/src/i18n/locales/zh/tools.ts b/apps/kimi-web/src/i18n/locales/zh/tools.ts index bc2782d4f..1fe96fda9 100644 --- a/apps/kimi-web/src/i18n/locales/zh/tools.ts +++ b/apps/kimi-web/src/i18n/locales/zh/tools.ts @@ -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; diff --git a/apps/kimi-web/src/lib/toolMeta.ts b/apps/kimi-web/src/lib/toolMeta.ts index d1e7842a7..38bc2e453 100644 --- a/apps/kimi-web/src/lib/toolMeta.ts +++ b/apps/kimi-web/src/lib/toolMeta.ts @@ -23,6 +23,7 @@ const TOOL_LABEL_KEYS: Record = { 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 = { web_fetch: 'globe', todo: 'check-list', task: 'sparkles', + askuserquestion: 'help-circle', }; export function toolGlyph(name: string): string { diff --git a/apps/kimi-web/src/views/DesignSystemView.vue b/apps/kimi-web/src/views/DesignSystemView.vue index 0e757b7cf..df7067dfe 100644 --- a/apps/kimi-web/src/views/DesignSystemView.vue +++ b/apps/kimi-web/src/views/DesignSystemView.vue @@ -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 ===== */ diff --git a/apps/kimi-web/test/ask-user-tool-parse.test.ts b/apps/kimi-web/test/ask-user-tool-parse.test.ts new file mode 100644 index 000000000..33b73a0eb --- /dev/null +++ b/apps/kimi-web/test/ask-user-tool-parse.test.ts @@ -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); + }); +});