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

@@ -0,0 +1,39 @@
terminal 56x20 buffer=normal length=25 base=5 viewport=5
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=56 viewportRow=13 bufferRow=18
viewport
5| "Model wait 0.0s "
style 0-14 dim
6| <blank>
7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 "
style 0-17 fg=bright-magenta bold
style 18-31 dim
style 34-50 dim
style 53-55 dim
8| " Review the complete plan including every required "
9| " checkpoint including every required checkpoint "
10| " including every required checkpoint including every "
11| " required checkpoint including every required "
12| " checkpoint including every required checkpoint "
13| " including every required checkpoint including every "
14| " required checkpoint including every required "
15| " checkpoint including every required checkpoint "
16| " including every required checkpoint including every "
17| " required checkpoint visible plan tail "
18| " … lines 4-13/13 • PgUp/PgDn "
style 2-28 dim
19| " › 1. [ ] Code Mode "
style 2-20 fg=bright-magenta bold
20| " run_code programs and captured output with "
style 12-53 dim
21| " … lines 1-2/12 • PgUp/PgDn "
style 2-27 dim
22| " ↓ 3 more "
style 2-9 dim
23| " ↑↓ Tab Sp ↵Esc "
style 2-15 dim
24| " dsh > "
style 1-3 fg=bright-magenta bold
style 5-6 dim
style 7-7 inverse

View File

@@ -0,0 +1,39 @@
terminal 56x20 buffer=normal length=25 base=5 viewport=5
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=56 viewportRow=16 bufferRow=21
viewport
5| "Model wait 0.0s "
style 0-14 dim
6| <blank>
7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 "
style 0-17 fg=bright-magenta bold
style 18-31 dim
style 34-50 dim
style 53-55 dim
8| " Review the complete plan including every required "
9| " checkpoint including every required checkpoint "
10| " including every required checkpoint including every "
11| " required checkpoint including every required "
12| " checkpoint including every required checkpoint "
13| " including every required checkpoint including every "
14| " required checkpoint including every required "
15| " checkpoint including every required checkpoint "
16| " including every required checkpoint including every "
17| " required checkpoint visible plan tail "
18| " … lines 4-13/13 • PgUp/PgDn "
style 2-28 dim
19| " detail with complete wrapped detail "
style 12-46 dim
20| " visible tail "
style 12-23 dim
21| " … lines 11-12/12 • PgUp/PgDn "
style 2-29 dim
22| " ↓ 3 more "
style 2-9 dim
23| " ↑↓ Tab Sp ↵Esc "
style 2-15 dim
24| " dsh > "
style 1-3 fg=bright-magenta bold
style 5-6 dim
style 7-7 inverse

View File

@@ -1,7 +1,7 @@
terminal 56x20 buffer=normal length=20 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=0 viewportRow=19 bufferRow=19
cursor hidden column=56 viewportRow=16 bufferRow=16
viewport
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-magenta bold
@@ -21,20 +21,20 @@ viewport
style 18-31 dim
style 34-50 dim
style 53-55 dim
8| " dsh > "
8| " "
9| " Question 1/1 (1 unanswered) · Confirm "
style 2-38 dim
10| " Continue with this change? "
11| " "
12| " › 1. Proceed "
style 2-14 fg=bright-magenta bold
13| " Apply the proposed change "
style 8-32 dim
14| " Tab custom answer • Enter submit • Esc interrupt "
style 2-49 dim
15| " "
16| " dsh > "
style 1-3 fg=bright-magenta bold
style 5-6 dim
style 7-7 inverse
9-11| <blank>
12| " "
13| " Question 1/1 (1 unanswered) · Confirm "
style 2-38 dim
14| " Continue with this change? "
15| " "
16| " › 1. Proceed Apply the proposed change "
style 2-13 fg=bright-magenta bold
style 16-40 dim
17| " Tab custom answer • Enter submit • Esc interrupt "
style 2-49 dim
18| " "
19| <blank>
17-19| <blank>

View File

@@ -1,40 +1,40 @@
terminal 56x20 buffer=normal length=20 base=0 viewport=0
terminal 56x20 buffer=normal length=25 base=5 viewport=5
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=56 viewportRow=17 bufferRow=17
cursor hidden column=56 viewportRow=17 bufferRow=22
viewport
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-magenta bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| " "
6| " Question 1/3 (3 unanswered) · Coverage "
style 2-39 dim
7| " Which advanced TUI states belong in the required "
8| " matrix? "
9| " "
10| " › 1. [ ] Code Mode run_code programs and capture "
style 2-19 fg=bright-magenta bold
style 25-53 dim
11| " 2. [ ] Workflows phases and parallel agents "
style 25-50 dim
12| " 3. [ ] Cordis tools inspect, mount, and unmount "
style 25-51 dim
13| " 1/4 "
style 2-4 dim
14| " Tab custom answer • ↑/↓ navigate • Space toggle • "
style 2-55 dim
15| " Enter submit • Esc interrupt "
5| "Model wait 0.0s "
style 0-14 dim
6| <blank>
7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 "
style 0-17 fg=bright-magenta bold
style 18-31 dim
style 34-50 dim
style 53-55 dim
8| " Review the complete plan including every required "
9| " checkpoint including every required checkpoint "
10| " including every required checkpoint including every "
11| " required checkpoint including every required "
12| " checkpoint including every required checkpoint "
13| " including every required checkpoint including every "
14| " required checkpoint including every required "
15| " checkpoint including every required checkpoint "
16| " including every required checkpoint including every "
17| " … lines 4-12/13 • PgUp/PgDn "
style 2-28 dim
18| " detail with complete wrapped detail "
style 12-46 dim
19| " visible tail "
style 12-23 dim
20| " … lines 11-12/12 • PgUp/PgDn "
style 2-29 dim
16| " Select at least one option, or press Tab for a "
style 2-55 fg=red
17| " custom answer. "
style 2-15 fg=red
18| " "
19| <blank>
21| " ↓ 3 more "
style 2-9 dim
22| " Error: Select at least one option, or press Tab for… "
style 2-52 fg=red
23| " ↑↓ Tab Sp ↵Esc "
style 2-15 dim
24| " dsh > "
style 1-3 fg=bright-magenta bold
style 5-6 dim
style 7-7 inverse

View File

@@ -1,39 +1,39 @@
terminal 56x20 buffer=normal length=20 base=0 viewport=0
terminal 56x20 buffer=normal length=25 base=5 viewport=5
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=0 viewportRow=19 bufferRow=19
cursor hidden column=56 viewportRow=19 bufferRow=24
viewport
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-magenta bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| "Model wait 0.0s "
style 0-14 dim
6| <blank>
7| " "
8| " Question 1/3 (3 unanswered) · Coverage "
style 2-39 dim
9| " Which advanced TUI states belong in the required "
10| " matrix? "
11| " "
12| " › 1. [ ] Code Mode run_code programs and capture "
style 2-19 fg=bright-magenta bold
style 25-53 dim
13| " 2. [ ] Workflows phases and parallel agents "
style 25-50 dim
14| " 3. [ ] Cordis tools inspect, mount, and unmount "
style 25-51 dim
15| " 1/4 "
style 2-4 dim
16| " Tab custom answer • ↑/↓ navigate • Space toggle • "
style 2-55 dim
17| " Enter submit • Esc interrupt "
style 2-29 dim
18| " "
19| <blank>
7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 "
style 0-17 fg=bright-magenta bold
style 18-31 dim
style 34-50 dim
style 53-55 dim
8| " Which advanced TUI states belong in the required "
9| " matrix? "
10| " "
11| " Review the complete plan including every required "
12| " checkpoint including every required checkpoint "
13| " including every required checkpoint including every "
14| " required checkpoint including every required "
15| " checkpoint including every required checkpoint "
16| " including every required checkpoint including every "
17| " required checkpoint including every required "
18| " … lines 1-10/13 • PgUp/PgDn "
style 2-28 dim
19| " › 1. [ ] Code Mode "
style 2-20 fg=bright-magenta bold
20| " run_code programs and captured output with "
style 12-53 dim
21| " … lines 1-2/12 • PgUp/PgDn "
style 2-27 dim
22| " ↓ 3 more "
style 2-9 dim
23| " ↑↓ Tab Sp ↵Esc "
style 2-15 dim
24| " dsh > "
style 1-3 fg=bright-magenta bold
style 5-6 dim
style 7-7 inverse

View File

@@ -1,7 +1,7 @@
terminal 100x34 buffer=normal length=34 base=0 viewport=0
terminal 100x34 buffer=normal length=40 base=6 viewport=6
lifecycle started=1 stopped=0 progress=inactive
title "Unsafe terminal title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m"
cursor hidden column=0 viewportRow=33 bufferRow=33
cursor hidden column=100 viewportRow=33 bufferRow=39
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-magenta bold
@@ -48,15 +48,30 @@ buffer
24| <blank>
25| "Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 0-62 fg=red
26| " "
27| " Question 1/1 (1 unanswered) · Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
26-27| <blank>
28| "Plan"
style 0-3 fg=bright-magenta bold
29| " ● Unsafe todo \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m"
style 2-2 fg=yellow
30| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
style 0-17 fg=bright-magenta bold
style 18-31 dim
style 34-50 dim
style 53-57 dim
style 60-69 dim
31| " "
32| " Question 1/1 (1 unanswered) · Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 2-90 dim
28| " Unsafe question \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
29| " "
30| " › 1. Unsafe option \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m Unsafe detail \\x1b]2;snapshot-c "
style 2-65 fg=bright-magenta bold
style 67-97 dim
31| " Tab custom answer • Enter submit • Esc interrupt "
33| " Unsafe question \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
34| " "
35| " › 1. Unsafe option \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 2-66 fg=bright-magenta bold
36| " Unsafe detail \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 8-66 dim
37| " Tab custom answer • Enter submit • Esc interrupt "
style 2-49 dim
32| " "
33| <blank>
38| " "
39| " dsh > "
style 1-3 fg=bright-magenta bold
style 5-6 dim
style 7-7 inverse

View File

@@ -49,6 +49,8 @@ const CHECKPOINTS = [
'details-selector',
'untrusted-controls',
'question-dialog',
'question-dialog-detail-paged',
'question-dialog-paged',
'question-dialog-single-option',
'question-dialog-validation',
'surface-before-compaction',
@@ -786,9 +788,13 @@ describe('TUI terminal-state snapshots', () => {
id: 'coverage',
header: 'Coverage',
question: 'Which advanced TUI states belong in the required matrix?',
detail: `Review the complete plan ${'including every required checkpoint '.repeat(12)}visible plan tail`,
multiSelect: true,
options: [
{ label: 'Code Mode', description: 'run_code programs and captured output' },
{
label: 'Code Mode',
description: `run_code programs and captured output ${'with complete wrapped detail '.repeat(12)}visible tail`,
},
{ label: 'Workflows', description: 'phases and parallel agents' },
{ label: 'Cordis tools', description: 'inspect, mount, and unmount' },
{ label: 'Compaction', description: 'surface replacement and reflow' },
@@ -803,6 +809,14 @@ describe('TUI terminal-state snapshots', () => {
await harness.terminal.waitForFrame(beforeQuestion)
await checkpoint('question-dialog', harness.terminal)
await renderAfter(harness, () => { harness.terminal.send('\x1b[6~') })
await checkpoint('question-dialog-detail-paged', harness.terminal)
await renderAfter(harness, () => {
for (let page = 0; page < 30; page += 1) harness.terminal.send('\x1b[6~')
})
await checkpoint('question-dialog-paged', harness.terminal)
await renderAfter(harness, () => { harness.terminal.send('\r') })
await checkpoint('question-dialog-validation', harness.terminal)
controller.abort()

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()