Merge remote-tracking branch 'origin/master' into mergebot/pr927-r3

# Conflicts:
#	packages/ui/tui/README.i18n.yaml
#	packages/ui/tui/src/components/dialogs.ts
This commit is contained in:
imccyu
2026-08-03 17:57:50 +08:00
35 changed files with 1356 additions and 217 deletions

View File

@@ -50,6 +50,7 @@ import {
disposeTuiTestHarness,
type TuiHarnessOptions,
} from './harness.ts'
import { HeadlessTerminal } from './headless-terminal.ts'
import { TestSessionQueryService } from './session-query.ts'
const UNUSED_TOOL_OUTPUT: ToolDefinition['output'] = {
@@ -5510,6 +5511,54 @@ describe('tool cards and surface replay', () => {
})
describe('TUI user-interaction dialogs', () => {
it('limits the visible option window to maxQuestionOptions', async () => {
const result = await setup({
config: { maxQuestionOptions: 1, questionDialogWidth: 60, questionDialogMaxHeight: 20 },
})
const answer = result.ctx.userInteraction.ask({
questions: [{
id: 'cap',
question: 'Pick one',
options: [{ label: 'Visible first' }, { label: 'Hidden second' }],
}],
})
const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
await tick()
expect(result.terminal.output).toContain('Visible first')
expect(result.terminal.output).not.toContain('Hidden second')
expect(result.terminal.output).toContain('↓ 1 more')
result.terminal.send('\x03')
await rejected
await dispose(result)
})
it('renders a pending question between the transcript and editor', async () => {
const result = await setup({
config: { questionDialogWidth: 40, questionDialogMaxHeight: 10 },
})
result.terminal.send('draft input')
const answer = result.ctx.userInteraction.ask({
questions: [{
id: 'placement',
question: 'Pick one',
options: [{ label: 'First' }, { label: 'Second' }],
}],
})
const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
await tick()
result.terminal.resize(60, 20)
await tick()
const render = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J'))
const questionIndex = render.indexOf('Pick one')
const editorIndex = render.indexOf('draft input')
expect(questionIndex).toBeGreaterThanOrEqual(0)
expect(editorIndex).toBeGreaterThan(questionIndex)
result.terminal.send('\x03')
await rejected
await dispose(result)
})
it('answers single-select, multi-select, custom, and optionless questions', async () => {
const result = await setup({ config: { maxQuestionOptions: 1 } })
@@ -5629,6 +5678,468 @@ describe('TUI user-interaction dialogs', () => {
await dispose(result)
})
it('scrolls tall option lists with ↑/↓ overflow markers when the dialog height is capped', async () => {
const result = await setup({
config: {
questionDialogWidth: 60,
questionDialogMaxHeight: 12,
maxQuestionOptions: 8,
},
})
const answer = result.ctx.userInteraction.ask({
questions: [{
id: 'scroll',
question: 'Pick one',
options: [
{ label: 'Alpha', description: 'first choice with a description that will wrap to multiple lines when the dialog is narrow' },
{ label: 'Bravo', description: 'second choice' },
{ label: 'Charlie', description: 'third choice' },
{ label: 'Delta', description: 'fourth choice' },
{ label: 'Echo', description: 'fifth choice' },
{ label: 'Foxtrot', description: 'sixth choice' },
],
}],
})
const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
await tick()
expect(result.terminal.output).toContain('↓')
expect(result.terminal.output).toContain('more')
for (let step = 0; step < 5; step += 1) result.terminal.send('\x1b[B')
await tick()
expect(result.terminal.output).toContain('↑')
result.terminal.send('\x03')
await rejected
await dispose(result)
})
it('keeps controls visible when the selected option block exceeds the row budget', async () => {
const result = await setup({
config: { questionDialogWidth: 40, questionDialogMaxHeight: 10 },
})
const answer = result.ctx.userInteraction.ask({
questions: [{
id: 'oversize',
question: 'Pick one',
options: [
{ label: 'Huge', description: `start ${'middle '.repeat(40)}visible tail` },
{ label: 'Other' },
],
}],
})
const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
await tick()
expect(result.terminal.output).toContain('Huge')
expect(result.terminal.output).toContain('PgUp/PgDn')
expect(result.terminal.output).toContain('↑↓ Tab ↵ Esc')
expect(result.terminal.output).not.toContain('visible tail')
for (let page = 0; page < 30; page += 1) result.terminal.send('\x1b[6~')
await tick()
expect(result.terminal.output).toContain('visible tail')
for (let page = 0; page < 30; page += 1) result.terminal.send('\x1b[5~')
result.terminal.send('\x1b[6~')
await tick()
expect(result.terminal.output).toContain('start middle')
result.terminal.send('\x03')
await rejected
await dispose(result)
})
it('pages long question detail so every plan-review line remains reachable', async () => {
const result = await setup({
config: { questionDialogWidth: 20, questionDialogMaxHeight: 10 },
})
const answer = result.ctx.userInteraction.ask({
questions: [{
id: 'long-detail',
question: 'Approve this plan?',
detail: `visible start ${'review step '.repeat(60)}visible tail`,
options: [{ label: 'Approve' }, { label: 'Reject' }],
}],
})
const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
await tick()
result.terminal.resize(60, 20)
await tick()
const initialRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J'))
expect(initialRender).toContain('plan?')
expect(initialRender).toContain('visible start')
expect(initialRender).not.toContain('visible tail')
expect(initialRender).toMatch(/PgUp\/PgDn \d+\/\d+/u)
for (let page = 0; page < 30; page += 1) result.terminal.send('\x1b[6~')
await tick()
const finalRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J'))
expect(finalRender).toContain('visible tail')
expect(finalRender).toContain('Approve')
result.terminal.send('\x1b[B')
await tick()
const movedRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J'))
expect(movedRender).toContain('visible tail')
expect(movedRender).toContain('Reject')
result.terminal.send('\x1b[A')
result.terminal.send('\t')
await tick()
result.terminal.send('\x1b[6~')
await tick()
result.terminal.send('\x1b[5~')
result.terminal.resize(61, 20)
await tick()
const customPagedRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J'))
expect(customPagedRender).not.toContain('visible tail')
expect(customPagedRender).toContain('Esc options')
result.terminal.send('\x1b')
for (let page = 0; page < 30; page += 1) result.terminal.send('\x1b[5~')
await tick()
const restoredRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J'))
expect(restoredRender).toContain('visible start')
result.terminal.send('\x03')
await rejected
await dispose(result)
})
it('reclaims enough rows to keep selected content, paging, and option markers visible', async () => {
const result = await setup({
config: { questionDialogWidth: 60, questionDialogMaxHeight: 8 },
})
const answer = result.ctx.userInteraction.ask({
questions: [{
id: 'one-row',
question: 'Pick one',
options: [
{ label: 'Selected first', description: `start ${'middle '.repeat(30)}visible tail` },
{ label: 'Hidden second' },
],
}],
})
const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
await tick()
expect(result.terminal.output).toContain('Selected first')
expect(result.terminal.output).not.toContain('Hidden second')
expect(result.terminal.output).toContain('↓ 1 more')
expect(result.terminal.output).toContain('PgUp/PgDn')
expect(result.terminal.output).toContain('Esc interrupt')
for (let page = 0; page < 30; page += 1) result.terminal.send('\x1b[6~')
await tick()
expect(result.terminal.output).toContain('visible tail')
result.terminal.send('\x03')
await rejected
await dispose(result)
})
it('preserves both option markers and controls at the minimum configured height', async () => {
const result = await setup({
config: { questionDialogWidth: 60, questionDialogMaxHeight: 6 },
})
const answer = result.ctx.userInteraction.ask({
questions: [{
id: 'minimum-options',
question: 'Pick one',
multiSelect: true,
options: ['One', 'Two', 'Three', 'Four', 'Five'].map(label => ({
label,
description: `${label} ${'wrapped detail '.repeat(20)}`,
})),
}],
})
const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
await tick()
result.terminal.send('\x1b[B')
result.terminal.send('\x1b[B')
await tick()
expect(result.terminal.output).toContain('↑ 2 more')
expect(result.terminal.output).toContain('Three')
expect(result.terminal.output).toContain('PgUp/PgDn')
expect(result.terminal.output).toContain('↓ 2 more')
expect(result.terminal.output).toContain('Tab custom')
expect(result.terminal.output).toContain('Space toggle')
expect(result.terminal.output).toContain('Esc interrupt')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('Error: Select at least one')
result.terminal.resize(61)
await tick()
const validationRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J'))
expect(validationRender).toContain('Tab custom')
expect(validationRender).toContain('Space toggle')
result.terminal.send('\x03')
await rejected
await dispose(result)
})
it('preserves detail text and every action when one compact header row remains', async () => {
const result = await setup({
config: { questionDialogWidth: 20, questionDialogMaxHeight: 6 },
})
const answer = result.ctx.userInteraction.ask({
questions: [{
id: 'one-header-row',
question: 'Plan?',
detail: 'abcdvisible tail',
multiSelect: true,
options: [
{ label: 'Yes', description: 'accept' },
{ label: 'No', description: 'reject' },
],
}],
})
const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
await tick()
result.terminal.resize(60, 20)
await tick()
const initialRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J'))
expect(initialRender).toContain('P↑↓ ↑↓ Tab S↵Esc')
result.terminal.send('\x1b[6~')
result.terminal.send('\x1b[6~')
await tick()
const detailRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J'))
expect(detailRender).toContain('visible tail')
result.terminal.send('\x03')
await rejected
const single = result.ctx.userInteraction.ask({
questions: [{
id: 'one-header-row-single',
question: 'Plan?',
detail: 'abcdvisible tail',
options: [
{ label: 'Yes', description: 'accept' },
{ label: 'No', description: 'reject' },
],
}],
})
const singleRejected = expect(single).rejects.toMatchObject({ code: 'ASK_ABORTED' })
await tick()
result.terminal.resize(61, 20)
await tick()
const singleRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J'))
expect(singleRender).toContain('P↑↓ ↑↓ Tab↵Esc')
result.terminal.send('\x03')
await singleRejected
const compact = result.ctx.userInteraction.ask({
questions: [{
id: 'one-header-row-compact',
question: 'Pick?',
multiSelect: true,
options: [
{ label: 'Yes', description: 'accept' },
{ label: 'No', description: 'reject' },
],
}],
})
const compactRejected = expect(compact).rejects.toMatchObject({ code: 'ASK_ABORTED' })
await tick()
result.terminal.resize(60, 20)
await tick()
const compactRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J'))
expect(compactRender).toContain('↑↓ Tab Sp ↵Esc')
result.terminal.send('\x03')
await compactRejected
const oneOption = result.ctx.userInteraction.ask({
questions: [{
id: 'one-header-row-one-option',
question: 'Pick?',
detail: 'Review every line.',
options: [{ label: 'Yes', description: 'wrapped detail '.repeat(8) }],
}],
})
const oneOptionRejected = expect(oneOption).rejects.toMatchObject({ code: 'ASK_ABORTED' })
await tick()
result.terminal.resize(61, 20)
await tick()
const oneOptionRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J'))
expect(oneOptionRender).toContain('P↑↓ Tab↵Esc')
expect(oneOptionRender).not.toContain('P↑↓ ↑↓')
result.terminal.send('\x03')
await oneOptionRejected
await dispose(result)
})
it('expands the visible option window forward and backward around the selection', async () => {
const result = await setup({
config: {
questionDialogWidth: 60,
questionDialogMaxHeight: 14,
maxQuestionOptions: 8,
},
})
const answer = result.ctx.userInteraction.ask({
questions: [{
id: 'middle-scroll',
question: 'Pick one',
options: [
{ label: 'One', description: 'a' },
{ label: 'Two', description: 'b' },
{ label: 'Three', description: 'c' },
{ label: 'Four', description: 'd' },
{ label: 'Five', description: 'e' },
{ label: 'Six', description: 'f' },
{ label: 'Seven', description: 'g' },
{ label: 'Eight', description: 'h' },
{ label: 'Nine', description: 'i' },
{ label: 'Ten', description: 'j' },
],
}],
})
const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
await tick()
for (let step = 0; step < 4; step += 1) result.terminal.send('\x1b[B')
await tick()
expect(result.terminal.output).toContain('↑')
expect(result.terminal.output).toContain('↓')
expect(result.terminal.output).toContain('Five')
result.terminal.send('\x03')
await rejected
await dispose(result)
})
it('wraps a long option label across multiple lines instead of truncating it', async () => {
const result = await setup({ config: { questionDialogWidth: 40 } })
const longLabel = 'this is a very long option label that will not fit on one line in a narrow dialog'
const answer = result.ctx.userInteraction.ask({
questions: [{
id: 'long-label',
question: 'Pick one',
options: [{ label: longLabel }, { label: 'Short' }],
}],
})
const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
await tick()
expect(result.terminal.output).toContain('narrow dialog')
result.terminal.send('\x03')
await rejected
await dispose(result)
})
it('wraps fixed question chrome within the minimum dialog width', async () => {
const result = await setup({ config: { questionDialogWidth: 20 } })
const answer = result.ctx.userInteraction.ask({
questions: [{ id: 'narrow', question: 'Answer?' }],
})
const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
await tick()
expect(result.terminal.output).not.toContain('Question 1/1 (1 unanswered)')
expect(result.terminal.output).toContain('unanswered)')
expect(result.terminal.output).not.toContain('Enter submit • Esc cancel')
expect(result.terminal.output).toContain('Esc cancel')
result.terminal.send('\x03')
await rejected
await dispose(result)
})
it('keeps custom controls visible at the minimum dialog height', async () => {
const result = await setup({
config: { questionDialogWidth: 20, questionDialogMaxHeight: 6 },
})
const answer = result.ctx.userInteraction.ask({
questions: [{ id: 'short-viewport', question: 'Answer this deliberately long question?' }],
})
const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
await tick()
expect(result.terminal.output).toContain('long question?')
expect(result.terminal.output).toContain('Esc cancel')
result.terminal.resize(60, 4)
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('Enter an answer')
expect(result.terminal.output).toContain('long question?')
result.terminal.send('\x03')
await rejected
await dispose(result)
})
it('compacts custom controls for a question that also has options', async () => {
const result = await setup({
config: { questionDialogWidth: 20, questionDialogMaxHeight: 6 },
})
const answer = result.ctx.userInteraction.ask({
questions: [{
id: 'compact-custom-options',
question: 'Choose or type a deliberately long answer',
options: [{ label: 'Default' }],
}],
})
const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
await tick()
result.terminal.send('\t')
await tick()
expect(result.terminal.output).toContain('Esc options')
result.terminal.send('\x1b')
await tick()
result.terminal.send('\x03')
await rejected
await dispose(result)
})
it('reports hidden question rows when the viewport leaves one row', async () => {
const result = await setup({
config: { questionDialogWidth: 60, questionDialogMaxHeight: 6 },
})
result.terminal.resize(60, 2)
const answer = result.ctx.userInteraction.ask({
questions: [{ id: 'one-row-dialog', question: 'Answer this deliberately long question?' }],
})
const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
await tick()
expect(result.terminal.output).toContain('lines hidden')
result.terminal.send('\x03')
await rejected
await dispose(result)
})
it('keeps question text when the viewport leaves two question rows', async () => {
const result = await setup({
config: { questionDialogWidth: 60, questionDialogMaxHeight: 6 },
})
result.terminal.resize(60, 3)
const answer = result.ctx.userInteraction.ask({
questions: [{ id: 'two-row-dialog', question: 'Answer this deliberately long question?' }],
})
const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
await tick()
expect(result.terminal.output).toContain('long question?')
result.terminal.send('\x03')
await rejected
await dispose(result)
})
it('bounds option mode when the viewport leaves three question rows', async () => {
const result = await setup({
config: { questionDialogWidth: 60, questionDialogMaxHeight: 6 },
})
result.terminal.resize(60, 4)
const answer = result.ctx.userInteraction.ask({
questions: [{
id: 'three-row-options',
question: 'Pick one',
options: [{ label: 'First' }, { label: 'Second' }],
}],
})
const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
await tick()
expect(result.terminal.output).toContain('lines hidden')
result.terminal.send('\x03')
await rejected
await dispose(result)
})
it('keeps question rows within a sub-five-column viewport', async () => {
const terminal = new HeadlessTerminal(4, 12)
const result = await createTuiTestHarness(terminal, vi.fn(), {
config: { questionDialogWidth: 20 },
})
const beforeQuestion = terminal.frames
const answer = result.ctx.userInteraction.ask({
questions: [{ id: 'narrow-viewport', question: 'Pick?', options: [{ label: 'Yes' }] }],
})
const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
await terminal.waitForFrame(beforeQuestion)
await expect(terminal.snapshot()).resolves.toContain('terminal 4x12')
terminal.send('\x03')
await rejected
await disposeTuiTestHarness(result)
})
it('asks batches in order and rejects cancelled or aborted work', async () => {
const result = await setup()
const preAborted = new AbortController()