fix(user-interaction): address review feedback

This commit is contained in:
Yichen Jiang
2026-07-30 17:59:37 +08:00
parent 31a498b1db
commit 3ba4d40e6a
16 changed files with 119 additions and 37 deletions

View File

@@ -238,6 +238,11 @@ describe('QuestionComposer', () => {
fireEvent.keyDown(custom, { key: 'Enter' })
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
fireEvent.click(screen.getByRole('button', { name: '提交' }))
expect(respond).toHaveBeenNthCalledWith(1, answeredEnvelope('second', [
{ id: 'profile', selected: ['工程落地型 (Recommended)'] },
{ id: 'detail', selected: [], custom: 'x' },
{ id: 'signals', selected: ['系统设计'] },
]))
expect(await screen.findByText('网络中断')).toBeTruthy()
expect(screen.getByRole<HTMLButtonElement>('button', { name: '提交' }).disabled).toBe(false)

View File

@@ -280,8 +280,10 @@ function matchesQuestions(payload: QuestionResponsePayload, pending: PendingQues
if (new Set(answer.selected).size !== answer.selected.length) return false
const custom = answer.custom?.trim()
if (custom !== undefined && custom === '') return false
if (custom !== undefined && answer.selected.length > 0 && question.multiSelect !== true) return false
if (question.multiSelect !== true && answer.selected.length > 1) return false
if (question.multiSelect !== true) {
if (custom !== undefined && answer.selected.length > 0) return false
if (answer.selected.length > 1) return false
}
const labels = new Set(question.options?.map(option => option.label) ?? [])
return answer.selected.every(label => labels.has(label))
})

View File

@@ -141,6 +141,7 @@ describe('ask_user_question tool', () => {
return {
answers: [
{ id: 'targets', selected: ['tests', 'docs'], custom: 'release notes' },
{ id: 'labels-only', selected: ['tests'] },
{ id: 'notes', selected: [], custom: 'ship today' },
],
}
@@ -159,6 +160,12 @@ describe('ask_user_question tool', () => {
options: [{ label: 'tests' }, { label: 'docs' }],
multi_select: true,
},
{
id: 'labels-only',
question: 'Which labels should I keep?',
options: [{ label: 'tests' }, { label: 'docs' }],
multi_select: true,
},
{ id: 'notes', question: 'Any note?' },
],
},
@@ -169,12 +176,13 @@ describe('ask_user_question tool', () => {
expect(result.value).toEqual({
answers: [
{ id: 'targets', selected: ['tests', 'docs'], custom: 'release notes' },
{ id: 'labels-only', selected: ['tests'] },
{ id: 'notes', selected: [], custom: 'ship today' },
],
})
expect(result.content).toEqual([{
type: 'text',
text: '{"answers":[{"id":"targets","selected":["tests","docs"],"custom":"release notes"},{"id":"notes","selected":[],"custom":"ship today"}]}',
text: '{"answers":[{"id":"targets","selected":["tests","docs"],"custom":"release notes"},{"id":"labels-only","selected":["tests"]},{"id":"notes","selected":[],"custom":"ship today"}]}',
}])
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/tui/README.md
README.md: c8eb81b2d76c1616647baba37692ed8cd42e89dc
README.zh.md: 680bb89f12cbbad871010ed025cfa6d4369bb0a3
README.md: 3b1c67dceadfe18a8d72bedc6a321a3fa86a3c90
README.zh.md: a87858833eeb9220c709748eb5bbee3ff132eb8f

View File

@@ -153,7 +153,7 @@ Append-only; newly visible content follows the reusable request prefix and does
#### What the model sees
When a consumer calls `ctx.userInteraction.ask()`, this provider presents each question in order and returns selected option labels, `custom` text, or both for a multi-select question. Abort, cancellation, or UI disposal becomes `Error: ask_user_question was interrupted before the user answered` through `dsh-tool-ask-user`.
When a consumer calls `ctx.userInteraction.ask()`, this provider presents each question in order and returns selected option labels, `custom` text, or both for a multi-select question. Pending custom text survives switching back to options and joins checked labels on a later options-mode submit. Abort, cancellation, or UI disposal becomes `Error: ask_user_question was interrupted before the user answered` through `dsh-tool-ask-user`.
#### Token effect

View File

@@ -153,7 +153,7 @@ Paths prefixed with @ are files explicitly referenced by the user. Use the read
#### 模型看到的内容
消费方调用 `ctx.userInteraction.ask()` 时,此提供方会按顺序显示各个问题,并返回选中选项标签、`custom` 文本,或为多选题同时返回两者。中止、取消或 UI dispose 会变为 `Error: ask_user_question was interrupted before the user answered`;该转换由 `dsh-tool-ask-user` 完成。
消费方调用 `ctx.userInteraction.ask()` 时,此提供方会按顺序显示各个问题,并返回选中选项标签、`custom` 文本,或为多选题同时返回两者。切回选项后,待提交的自定义文本仍会保留,并在之后从选项模式提交时与已勾选的标签一同返回。中止、取消或 UI dispose 会变为 `Error: ask_user_question was interrupted before the user answered`;该转换由 `dsh-tool-ask-user` 完成。
#### Token 影响

View File

@@ -804,11 +804,12 @@ export class QuestionDialog implements Component, Focusable {
const selected = this.question.multiSelect
? this.selectedOptionLabels()
: [options[this.selectedIndex]?.label].filter((label): label is string => label !== undefined)
if (selected.length === 0) {
const custom = this.question.multiSelect ? this.input.getValue().trim() : ''
if (selected.length === 0 && custom === '') {
this.error = 'Select at least one option, or press Tab for a custom answer.'
return
}
this.done({ selected })
this.done({ selected, ...(custom === '' ? {} : { custom }) })
} else if (matchesKey(data, Key.tab) || data.toLowerCase() === 'c') {
this.mode = 'custom'
this.error = ''
@@ -854,7 +855,12 @@ export class QuestionDialog implements Component, Focusable {
push('')
if (this.mode === 'custom') {
for (const line of this.input.render(innerWidth)) push(line)
push(this.palette.dim(this.options.length > 0 ? 'Enter submit • Esc options' : 'Enter submit • Esc cancel'))
const controls = [
...(this.options.length > 0 && this.question.multiSelect ? [`${this.selected.size} selected`] : []),
'Enter submit',
this.options.length > 0 ? 'Esc options' : 'Esc cancel',
]
push(this.palette.dim(controls.join(' • ')))
} else {
const options = this.options
const start = Math.max(0, Math.min(

View File

@@ -4697,12 +4697,29 @@ describe('TUI user-interaction dialogs', () => {
result.terminal.send('\x1b[B')
result.terminal.send(' ')
result.terminal.send('\t')
await tick()
expect(result.terminal.output).toContain('2 selected • Enter submit • Esc options')
result.terminal.send('Tests')
result.terminal.send('\r')
await expect(multi).resolves.toEqual({
answers: [{ id: 'targets', selected: ['Code', 'Docs'], custom: 'Tests' }],
})
const labelsOnly = result.ctx.userInteraction.ask({
questions: [{
id: 'labels-only',
question: 'Pick one target',
multiSelect: true,
options: [{ label: 'Code' }, { label: 'Docs' }],
}],
})
await tick()
result.terminal.send(' ')
result.terminal.send('\r')
await expect(labelsOnly).resolves.toEqual({
answers: [{ id: 'labels-only', selected: ['Code'] }],
})
const custom = result.ctx.userInteraction.ask({
questions: [{ id: 'other', question: 'Choose or type', options: [{ label: 'Default' }] }],
})
@@ -4744,7 +4761,6 @@ describe('TUI user-interaction dialogs', () => {
options: [{ label: 'One', description: 'first' }, { label: 'Two' }],
}],
})
const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
await tick()
result.terminal.send('\x1b[A')
result.terminal.send('\x1b[B')
@@ -4760,11 +4776,17 @@ describe('TUI user-interaction dialogs', () => {
})
result.terminal.send('c')
await tick()
result.terminal.send('keep this')
await tick()
expect(result.terminal.output).toContain('0 selected • Enter submit • Esc options')
result.terminal.send('\x1b')
await tick()
expect(result.terminal.output).toContain('Space toggle')
result.terminal.send('\x03')
await rejected
result.terminal.send(' ')
result.terminal.send('\r')
await expect(answer).resolves.toEqual({
answers: [{ id: 'options', selected: ['One'], custom: 'keep this' }],
})
await dispose(result)
})