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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user