Merge remote-tracking branch 'origin/master' into perf/tui-resume-scan

# Conflicts:
#	packages/ui/tui/README.i18n.yaml
This commit is contained in:
Turtle
2026-08-03 21:16:51 +08:00
86 changed files with 981 additions and 199 deletions

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/tool-ask-user/README.md
README.md: 8e779f4025c20cd200344efb7cb8cd6bc09ba64d
README.zh.md: acaffec0764404a0e0e842ffc2b4efdee8869c4f
README.md: 64da4d75d01a0df0ae51b1557ed1c796317b906f
README.zh.md: 48a5b5d0d1aadff8a522d0b6100c6d0479b948a5

View File

@@ -15,7 +15,7 @@ Model-facing `ask_user_question` tool over `ctx.userInteraction`. It lets the mo
- `options` — optional choices with `label` and `description`. If recommending a choice, put it first and append `(Recommended)` to that label.
- `multi_select` — whether that question may return more than one selected option.
The tool calls `ctx.userInteraction.ask()` and returns canonical `{ answers: [{ id, selected, custom? }] }`. `selected` contains option labels; `custom` is present only for a free-form answer and overrides selected choices. The Native renderer preserves the compact JSON text shape `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`.
The tool calls `ctx.userInteraction.ask()` and returns canonical `{ answers: [{ id, selected, custom? }] }`. `selected` contains option labels; `custom` carries a free-form answer, supplementing `selected` for a multi-select question and overriding it for a single-select question. The Native renderer preserves the compact JSON text shape `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`.
## Role

View File

@@ -15,7 +15,7 @@
- `options`:可选选项,包含 `label``description`。如需推荐某个选项,请将其置于首位,并在该标签末尾追加 `(Recommended)`
- `multi_select`:该问题是否可以返回多个选中的选项。
工具调用 `ctx.userInteraction.ask()`,并返回规范的 `{ answers: [{ id, selected, custom? }] }``selected` 包含选项标签;仅当用户自由填写回答时才会出现 `custom`并覆盖选中的选项。Native 渲染器会保留紧凑的 JSON 文本形式 `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`
工具调用 `ctx.userInteraction.ask()`,并返回规范的 `{ answers: [{ id, selected, custom? }] }``selected` 包含选项标签;`custom` 携带自由填写回答,对于多选题会补充 `selected`对于单选题则会覆盖它。Native renderer 会保留紧凑的 JSON 文本形式 `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`
## 职责

View File

@@ -140,7 +140,8 @@ describe('ask_user_question tool', () => {
async ask() {
return {
answers: [
{ id: 'targets', selected: ['tests', 'docs'] },
{ 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?' },
],
},
@@ -168,13 +175,14 @@ describe('ask_user_question tool', () => {
if (result.isError) throw new Error('expected ask_user_question success')
expect(result.value).toEqual({
answers: [
{ id: 'targets', selected: ['tests', 'docs'] },
{ 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"]},{"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: ac45a0ec9c282f3c872b325fe30a083dd1deed33
README.zh.md: 6bcb431713dd8a247d2b39d3392edc369ad00c90
README.md: 78d56a6cacd040fdd32b73f779bab3fb4c77fcce
README.zh.md: c403605bb13d252eec00a2b0ebafb5f953c884f8

View File

@@ -156,7 +156,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 or `custom` text. 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

@@ -156,7 +156,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

@@ -868,12 +868,15 @@ export class QuestionDialog implements Component, Focusable {
if (this.selected.has(this.selectedIndex)) this.selected.delete(this.selectedIndex)
else this.selected.add(this.selectedIndex)
} else if (matchesKey(data, Key.enter)) {
const indices = this.question.multiSelect ? [...this.selected].sort((a, b) => a - b) : [this.selectedIndex]
if (indices.length === 0) {
const selected = this.question.multiSelect
? this.selectedOptionLabels()
: [options[this.selectedIndex]?.label].filter((label): label is string => label !== undefined)
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: indices.map(index => options[index]?.label).filter((label): label is string => label !== undefined) })
this.done({ selected, ...(custom === '' ? {} : { custom }) })
} else if (matchesKey(data, Key.tab) || data.toLowerCase() === 'c') {
this.mode = 'custom'
this.selectedBlockPage = { offset: 0, size: 1, maxOffset: 0 }
@@ -889,7 +892,17 @@ export class QuestionDialog implements Component, Focusable {
this.error = 'Enter an answer before submitting.'
return
}
this.done({ selected: [], custom })
this.done({
selected: this.question.multiSelect ? this.selectedOptionLabels() : [],
custom,
})
}
private selectedOptionLabels(): string[] {
return [...this.selected]
.sort((a, b) => a - b)
.map(index => this.options[index]?.label)
.filter((label): label is string => label !== undefined)
}
/** Page backward through an oversized option, then through question detail. */
@@ -955,9 +968,12 @@ export class QuestionDialog implements Component, Focusable {
}
headerLines.push('')
const customHint = this.palette.dim(this.options.length > 0
? 'Enter submit • Esc options'
: 'Enter submit • Esc cancel')
const customControls = [
...(this.options.length > 0 && this.question.multiSelect ? [`${this.selected.size} selected`] : []),
'Enter submit',
this.options.length > 0 ? 'Esc options' : 'Esc cancel',
]
const customHint = this.palette.dim(customControls.join(' • '))
const footerLines: string[] = []
if (this.mode === 'custom') {
for (const line of this.input.render(innerWidth)) footerLines.push(line)

View File

@@ -5890,8 +5890,29 @@ describe('TUI user-interaction dialogs', () => {
result.terminal.send(' ')
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'] }] })
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' }] }],
@@ -5934,7 +5955,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')
@@ -5950,11 +5970,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)
})

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/user-interaction/README.md
README.md: d62e75d110b8be339c5f9449b0834320f695ac99
README.zh.md: 55258e85e56df2375ed8f195fa0b3b731a9cb816
README.md: c7fec590d6e44a13b94cc682f5e069b2d3c5e416
README.zh.md: 340af3541a09a528aa0fcc580bda070ea703f39e

View File

@@ -20,7 +20,7 @@ Abstract user-interaction seam. It owns `ctx.userInteraction`, the service a mod
- `UserInteractionProvider` — UI implementation with `ask(request)`.
- `UserInteractionError``HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `BAD_INTENT`, `NO_PROVIDER`, `DUPLICATE_PROVIDER`, and `ASK_ABORTED`.
When an answer includes `custom`, `selected` is empty; custom text is an override rather than a supplement to selected choices. A UI may preserve a skipped item as `{ id, selected: [] }`, keeping the existing answer shape while retaining other answers in the batch.
For a single-select question, `custom` overrides the selected choice and `selected` is empty. For a multi-select question, `custom` may supplement the labels in `selected`. A UI may preserve a skipped item as `{ id, selected: [] }`, keeping the existing answer shape while retaining other answers in the batch.
### Presentation intent

View File

@@ -20,7 +20,7 @@
- `UserInteractionProvider`:包含 `ask(request)` 的 UI 实现。
- `UserInteractionError``HarnessError` 的子类,包含 `EMPTY_QUESTIONS``BAD_INTENT``NO_PROVIDER``DUPLICATE_PROVIDER``ASK_ABORTED` 等代码。
当回答包含 `custom` 时,`selected` 为空;自定义文本是所选选项的替代,而不是补充。UI 可以把跳过的条目保留为 `{ id, selected: [] }`,既维持现有回答形态,也保留该批次中的其他回答。
对于单选题,`custom` 会覆盖选中的选项,且 `selected` 为空。对于多选题,`custom` 可以补充 `selected` 中的标签。UI 可以把跳过的条目保留为 `{ id, selected: [] }`,既维持现有回答形态,也保留该批次中的其他回答。
### 呈现意图

View File

@@ -53,7 +53,7 @@ export interface AskUserQuestionItem {
export interface AskUserQuestionAnswerItem {
/** The answered question id. */
id: string
/** Selected option labels. Empty for custom or unanswered choices. */
/** Selected option labels. May accompany custom text for a multi-select question. */
selected: string[]
/** Optional free-text "Other" answer. */
custom?: string