Просмотр исходного кода

feat(tui): wrap and page question dialogs

Turtle 1 месяц назад
Родитель
Сommit
3b58ed65b7

+ 6 - 0
.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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 .agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.md
+2026-07-24-tui-question-dialog-multiline.md: fc6e9bceeee4abc46a69a23124d09fcd4f3c7224
+2026-07-24-tui-question-dialog-multiline.zh.md: a56821921bad1016009687bde63eae5f4d893cdf

+ 41 - 0
.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.md

@@ -0,0 +1,41 @@
+# Agent Note: TUI QuestionDialog renders options across multiple lines
+
+Status: implemented
+
+English | [中文](2026-07-24-tui-question-dialog-multiline.zh.md)
+
+## Problem
+
+`ctx.userInteraction.ask()` must keep question text, supporting `detail`, option labels, descriptions, validation, and controls readable inside configured width and height bounds. The question panel also belongs directly above the editor: placing it at the terminal edge separates the pending decision from both the transcript that prompted it and the input that follows it.
+
+## Decision
+
+The TUI renders a pending question as an inline modal between the transcript/status area and the editor while retaining the shared FIFO with model and plugin overlays:
+
+- `InlineModalComponent` applies `questionDialogWidth` and `questionDialogMaxHeight` inside the normal component flow. The effective question height is additionally clamped to the current viewport after reserving the editor, so the editor remains below the question during resize.
+- `renderOptionBlock` wraps each label beneath its cursor/number prefix and renders the muted description on separately wrapped, equally indented lines. The progress header, question, custom-answer hint, validation text, and final rows are width-bounded as well; the final ellipsis clamp is only a safety boundary for prefixes or other indivisible content. The explicit `↑ N lines hidden` fallback is reserved for a viewport below the configured minimum, where the whole semantic layout cannot fit.
+- When question text or `detail` exceeds the header allocation, the header becomes a paged line viewport with its own `… lines A-B/N • PgUp/PgDn` status row. Page Up and Page Down traverse both line viewports: forward navigation exhausts the header/detail pages before entering oversized selected-option pages, and backward navigation reverses that order. This keeps plan-review detail reachable rather than leaving it behind the height clamp.
+- The option-line budget subtracts padding, header, position, and footer rows before `windowBlocks` runs. The window obeys both `maxQuestionOptions` and the remaining row budget, keeps the selected option visible, and renders omitted options as `↑ N more` / `↓ N more` markers. If fixed chrome would leave fewer than four option rows, the compact header becomes the line pager so selected content, paging status, and both option markers still fit.
+- When one selected block exceeds its allocation, it becomes a line viewport with a `lines A-B/N • PgUp/PgDn` status row. Page Up and Page Down expose every wrapped line without allowing the block to hide the option markers, validation, or controls.
+
+Package tests pin count and height bounds, header and selected-block paging order, narrow-width wrapping, selection behavior, and placement relative to retained editor input. Semantic TUI snapshots pin the assembled terminal layout, header/detail and selected-option page transitions, and validation state.
+
+## Alternatives considered
+
+**Ellipsis-only horizontal truncation.** Keeping one option per row would signal lost text without making the description readable and would not address vertical bounds. The implementation wraps readable content and retains an ellipsis only as a final safety boundary.
+
+**Wrap the combined label and description.** A composite row couples their widths, so either side can starve the other. Separate lines keep both widths predictable.
+
+**Keep the question as a bottom-edge overlay.** A terminal-edge anchor can place the panel after the editor or cover lower chrome, depending on transcript and viewport height. The inline modal preserves ordering while the modal manager retains focus and FIFO ownership.
+
+**Push the bounds into pi-tui.** Generic overlay slicing cannot identify option boundaries, selected content, controls, or the inline editor relationship. The owning dialog therefore applies semantic count, row, and paging rules.
+
+**Use only the option-count cap.** `maxQuestionOptions` remains a public count bound, but it cannot contain wrapped blocks by itself. The dialog enforces the count and row bounds together.
+
+## Consequences
+
+- Descriptions consume additional rows, so fewer options can be visible than `maxQuestionOptions`; markers state the omitted option counts.
+- Long question text and plan-review detail remain reachable inside a height-bounded panel, at the cost of sharing Page Up and Page Down with selected-option paging.
+- An oversized selected block reserves one status row and requires Page Up or Page Down to read beyond the current line page.
+- The inline question can displace older transcript rows from a short viewport. Below the configured minimum height, the final fallback can collapse upper rows behind an explicit hidden-line marker so the input controls and editor remain available.
+- The model-facing schema, selected labels, abort/cancel behavior, and ACP elicitation path are unchanged.

+ 41 - 0
.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.zh.md

@@ -0,0 +1,41 @@
+# Agent Note: TUI QuestionDialog 以多行方式渲染选项
+
+Status: implemented
+
+[English](2026-07-24-tui-question-dialog-multiline.md) | 中文
+
+## 问题
+
+`ctx.userInteraction.ask()` 必须确保问题正文、`detail` 补充内容、选项标签、描述、校验信息和控件在已配置的宽度与高度边界内均可读。问题面板也直接位于编辑器上方:若将其置于终端边缘,待处理决策就会同时脱离触发该决策的 transcript(文本记录)和后续输入。
+
+## 决策
+
+TUI 将待处理问题渲染为位于 transcript/状态区域与编辑器之间的内联模态框,同时仍与模型浮层和插件浮层共享 FIFO:
+
+- `InlineModalComponent` 在正常组件流内应用 `questionDialogWidth` 和 `questionDialogMaxHeight`。系统在为编辑器预留空间后,还会根据当前视口限制问题的实际高度,因此调整窗口大小时,编辑器仍位于问题下方。
+- `renderOptionBlock` 将每个标签换行到光标/编号前缀下方,并在另行换行且缩进相同的行上渲染弱化的描述。进度标题、问题、自定义答案提示、校验文本和末尾行也受宽度边界约束;最终的省略号截断仅作为前缀或其他不可拆分内容的安全边界。明确的 `↑ N lines hidden` 回退仅用于低于已配置最小值、无法容纳完整语义布局的视口。
+- 当问题正文或 `detail` 超出头部分配的空间时,头部会成为带有独立 `… lines A-B/N • PgUp/PgDn` 状态行的分页行视口。Page Up 和 Page Down 会遍历这两个行视口:向前导航先翻完问题正文/`detail` 页面,再进入超大选中选项页面;向后导航则采用相反顺序。这样可确保计划评审的 `detail` 内容始终可达,而不会被高度边界挡住。
+- 在 `windowBlocks` 运行前,选项行预算会扣除内边距、标题行、位置行和页脚行。窗口同时遵守 `maxQuestionOptions` 和剩余行预算,保持选中项可见,并将省略的选项渲染为 `↑ N more`/`↓ N more` 标记。若固定界面元素会使选项行少于四行,紧凑头部会转为行分页器,从而容纳选中内容、分页状态和上下两个选项标记。
+- 当一个选中块超出分配空间时,它会成为带有 `lines A-B/N • PgUp/PgDn` 状态行的行视口。Page Up 和 Page Down 可展示每一行已换行内容,同时防止该块遮住选项标记、校验信息或控件。
+
+包(package)测试固定数量和高度边界、头部与选中块的分页顺序、窄宽度换行、选择行为,以及问题相对于保留的编辑器输入的位置。语义 TUI 快照固定组装后的终端布局、头部/详情与选中选项的分页转换,以及校验状态。
+
+## 备选方案
+
+**仅用省略号进行横向截断。** 保持每个选项占一行,只能提示文本有所丢失,无法使描述变得可读,也无法处理纵向边界。该实现会对可读内容换行,仅将省略号保留为最终安全边界。
+
+**将标签与描述合并后换行。** 组合行会将两者的宽度耦合在一起,任一方都可能挤占另一方的空间。分行渲染可使二者的宽度保持可预测。
+
+**将问题保留为终端底边浮层。** 根据 transcript 和视口高度,锚定在终端边缘的面板可能出现在编辑器之后,也可能遮盖下方界面元素。内联模态框可保留顺序,同时由模态管理器继续负责焦点和 FIFO 所有权。
+
+**将边界处理下推至 pi-tui。** 通用浮层切片无法识别选项边界、选中内容、控件或内联编辑器关系。因此,负责该语义的对话框会应用数量、行数和分页规则。
+
+**仅使用选项数量上限。** `maxQuestionOptions` 仍是公开的数量边界,但仅靠它无法容纳已换行的块。对话框会同时执行数量边界和行数边界。
+
+## 后果
+
+- 描述会占用额外行,因此可见选项数可能少于 `maxQuestionOptions`;标记会说明省略的选项数量。
+- 较长的问题正文和计划评审 `detail` 在受高度约束的面板内仍然可达,代价是 Page Up 和 Page Down 需要与选中选项分页共用。
+- 超出空间的选中块会预留一行状态信息;若要阅读当前页面之外的行,必须使用 Page Up 或 Page Down。
+- 在较矮的视口内,内联问题可能将较早的 transcript 行挤出可见区域。低于已配置最小高度时,最终回退可能将上部行折叠到明确的隐藏行标记之后,从而让输入控件和编辑器仍然可用。
+- 面向模型的 schema、选中的标签、中止/取消行为,以及 ACP(Agent Client Protocol)的 elicitation 路径均保持不变。

+ 1 - 1
docs/cordis-catalog/services.md

@@ -2451,7 +2451,7 @@ The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugi
 abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession
 ```
 
-Source: [`packages/ui/tui/src/index.ts:244`](../../packages/ui/tui/src/index.ts)
+Source: [`packages/ui/tui/src/index.ts:245`](../../packages/ui/tui/src/index.ts)
 
 ## `ctx.typert` — `TypertRegistry`
 

+ 2 - 2
packages/ui/tui/README.i18n.yaml

@@ -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: 60e42a64305931a6bf93a470f851f662be33e2bb
-README.zh.md: fc12b75a1e320103a1d4cc70cc00adfeb639a3d9
+README.md: e441cf949d15ea76f93efc89f970aa2096e83153
+README.zh.md: cc98ea4405ff57b56059e283ecf1b9c8d4d95b91

Разница между файлами не показана из-за своего большого размера
+ 0 - 1
packages/ui/tui/README.md


Разница между файлами не показана из-за своего большого размера
+ 0 - 1
packages/ui/tui/README.zh.md


+ 6 - 4
packages/ui/tui/src/chat/questions.ts

@@ -29,7 +29,10 @@ interface PendingQuestion {
 }
 
 /** Collaborators the question queue needs from the chat channel. */
-export type QuestionQueueDeps = ChatChannelDeps
+export interface QuestionQueueDeps extends ChatChannelDeps {
+  /** Current row budget after reserving the editor. */
+  questionMaxHeight(): number
+}
 
 /** Ask-user-question controller for one chat channel. */
 export interface QuestionQueue {
@@ -85,6 +88,7 @@ export function createQuestionQueue(deps: QuestionQueueDeps): QuestionQueue {
           pending.request.questions.length,
           pending.request.questions.length - pending.answers.length,
           resolved.maxQuestionOptions,
+          () => deps.questionMaxHeight(),
           palette,
           (selection) => {
             pending.overlay = undefined
@@ -102,10 +106,8 @@ export function createQuestionQueue(deps: QuestionQueueDeps): QuestionQueue {
         options: {
           width: resolved.questionDialogWidth,
           maxHeight: resolved.questionDialogMaxHeight,
-          anchor: 'bottom-left',
-          margin: { bottom: 1 },
         },
-      })
+      }, 'inline')
       pending.overlay = session
       void session.closed.then((result) => {
         if (pending.overlay !== session) return

+ 341 - 50
packages/ui/tui/src/components/dialogs.ts

@@ -822,10 +822,18 @@ export class ResumePicker implements Component, Focusable {
   }
 }
 
-/** Bottom-anchored dialog for one user question with option or custom-answer modes. */
+interface SelectedBlockPage {
+  offset: number
+  size: number
+  maxOffset: number
+}
+
+/** Inline dialog for one user question with option or custom-answer modes. */
 export class QuestionDialog implements Component, Focusable {
   private selectedIndex = 0
   private selected = new Set<number>()
+  private headerPage: SelectedBlockPage = { offset: 0, size: 1, maxOffset: 0 }
+  private selectedBlockPage: SelectedBlockPage = { offset: 0, size: 1, maxOffset: 0 }
   private mode: 'options' | 'custom'
   private error = ''
   private readonly input = new Input()
@@ -838,6 +846,7 @@ export class QuestionDialog implements Component, Focusable {
     private readonly total: number,
     private readonly unanswered: number,
     private readonly maxVisible: number,
+    private readonly maxHeight: () => number,
     private readonly palette: Palette,
     private readonly done: (selection: QuestionSelection) => void,
     private readonly cancel: () => void,
@@ -861,6 +870,14 @@ export class QuestionDialog implements Component, Focusable {
 
   handleInput(data: string): void {
     this.invalidate()
+    if (matchesKey(data, Key.pageUp)) {
+      this.pageBackward()
+      return
+    }
+    if (matchesKey(data, Key.pageDown)) {
+      this.pageForward()
+      return
+    }
     if (this.mode === 'custom') {
       this.input.focused = this.focused
       this.input.handleInput(data)
@@ -868,8 +885,10 @@ export class QuestionDialog implements Component, Focusable {
     }
     const options = this.options
     if (matchesKey(data, Key.up)) {
+      this.selectedBlockPage = { offset: 0, size: 1, maxOffset: 0 }
       this.selectedIndex = this.selectedIndex === 0 ? options.length - 1 : this.selectedIndex - 1
     } else if (matchesKey(data, Key.down)) {
+      this.selectedBlockPage = { offset: 0, size: 1, maxOffset: 0 }
       this.selectedIndex = this.selectedIndex === options.length - 1 ? 0 : this.selectedIndex + 1
     } else if (matchesKey(data, Key.space) && this.question.multiSelect) {
       if (this.selected.has(this.selectedIndex)) this.selected.delete(this.selectedIndex)
@@ -883,6 +902,7 @@ export class QuestionDialog implements Component, Focusable {
       this.done({ selected: indices.map(index => options[index]?.label).filter((label): label is string => label !== undefined) })
     } else if (matchesKey(data, Key.tab) || data.toLowerCase() === 'c') {
       this.mode = 'custom'
+      this.selectedBlockPage = { offset: 0, size: 1, maxOffset: 0 }
       this.error = ''
     } else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) {
       this.cancel()
@@ -898,75 +918,346 @@ export class QuestionDialog implements Component, Focusable {
     this.done({ selected: [], custom })
   }
 
+  /** Page backward through an oversized option, then through question detail. */
+  private pageBackward(): void {
+    if (this.mode === 'options' && this.selectedBlockPage.offset > 0) {
+      this.selectedBlockPage = {
+        ...this.selectedBlockPage,
+        offset: Math.max(0, this.selectedBlockPage.offset - this.selectedBlockPage.size),
+      }
+      return
+    }
+    this.headerPage = {
+      ...this.headerPage,
+      offset: Math.max(0, this.headerPage.offset - this.headerPage.size),
+    }
+  }
+
+  /** Page forward through question detail, then through an oversized option. */
+  private pageForward(): void {
+    if (this.headerPage.offset < this.headerPage.maxOffset) {
+      this.headerPage = {
+        ...this.headerPage,
+        offset: Math.min(
+          this.headerPage.maxOffset,
+          this.headerPage.offset + this.headerPage.size,
+        ),
+      }
+      return
+    }
+    if (this.mode === 'custom') return
+    this.selectedBlockPage = {
+      ...this.selectedBlockPage,
+      offset: Math.min(
+        this.selectedBlockPage.maxOffset,
+        this.selectedBlockPage.offset + this.selectedBlockPage.size,
+      ),
+    }
+  }
+
   render(width: number): string[] {
     this.input.focused = this.focused
-    const innerWidth = Math.max(1, width - 4)
+    const horizontalPadding = Math.min(2, Math.max(0, Math.floor((width - 1) / 2)))
+    const innerWidth = Math.max(1, width - horizontalPadding * 2)
     const header = `Question ${this.position}/${this.total} (${this.unanswered} unanswered)${this.question.header === undefined ? '' : ` · ${displayText(this.question.header)}`}`
-    const lines = [
-      this.palette.dim(header),
-      ...wrapTextWithAnsi(this.palette.text(displayText(this.question.question)), innerWidth),
+    const questionLines = wrapTextWithAnsi(
+      this.palette.text(displayText(this.question.question)),
+      innerWidth,
+    )
+    const contentLines = [...questionLines]
+    const headerLines: string[] = [
+      ...wrapTextWithAnsi(this.palette.dim(header), innerWidth),
+      ...questionLines,
     ]
-    const push = (line: string): void => { lines.push(line) }
     // Supporting detail (e.g. the full plan under review) renders between the
     // question and the answer surface, kept out of option labels.
     if (this.question.detail !== undefined) {
-      push('')
-      for (const line of wrapTextWithAnsi(displayText(this.question.detail), innerWidth)) push(line)
+      headerLines.push('')
+      contentLines.push('')
+      for (const line of wrapTextWithAnsi(displayText(this.question.detail), innerWidth)) {
+        headerLines.push(line)
+        contentLines.push(line)
+      }
     }
-    push('')
+    headerLines.push('')
+
+    const customHint = this.palette.dim(this.options.length > 0
+      ? 'Enter submit • Esc options'
+      : 'Enter submit • Esc cancel')
+    const footerLines: string[] = []
     if (this.mode === 'custom') {
-      for (const line of this.input.render(innerWidth)) push(line)
-      push(this.palette.dim(this.options.length > 0 ? 'Enter submit • Esc options' : 'Enter submit • Esc cancel'))
+      for (const line of this.input.render(innerWidth)) footerLines.push(line)
+      for (const line of wrapTextWithAnsi(customHint, innerWidth)) footerLines.push(line)
     } else {
-      const options = this.options
-      const start = Math.max(0, Math.min(
-        this.selectedIndex - Math.floor(this.maxVisible / 2),
-        options.length - this.maxVisible,
-      ))
-      const end = Math.min(options.length, start + this.maxVisible)
-      const optionRows = options.slice(start, end).map((option, offset) => {
-        const index = start + offset
-        const mark = this.question.multiSelect
-          ? this.selected.has(index) ? '[x] ' : '[ ] '
-          : ''
-        return `${index === this.selectedIndex ? '›' : ' '} ${index + 1}. ${mark}${displayText(option.label)}`
-      })
-      const descriptionColumn = Math.min(
-        Math.max(...optionRows.map(row => visibleWidth(row))) + 2,
-        Math.max(1, Math.floor(innerWidth * 0.55)),
-      )
-      for (let index = start; index < end; index += 1) {
-        // `index < end <= options.length`; the options array is borrowed immutably for this dialog.
-        const option = options[index] as NonNullable<AskUserQuestionItem['options']>[number]
-        const mark = this.question.multiSelect
-          ? this.selected.has(index) ? '[x] ' : '[ ] '
-          : ''
-        const left = `${index === this.selectedIndex ? '›' : ' '} ${index + 1}. ${mark}${displayText(option.label)}`
-        const leftStyled = index === this.selectedIndex
-          ? this.palette.bold(this.palette.accent(left))
-          : left
-        const description = option.description === undefined
-          ? ''
-          : `${' '.repeat(Math.max(1, descriptionColumn - visibleWidth(left)))}${this.palette.dim(displayText(option.description))}`
-        push(`${leftStyled}${description}`)
-      }
-      if (options.length > this.maxVisible) push(this.palette.dim(`${this.selectedIndex + 1}/${options.length}`))
       const controls = [
         'Tab custom answer',
-        ...(options.length > 1 ? ['↑/↓ navigate'] : []),
+        ...(this.options.length > 1 ? ['↑/↓ navigate'] : []),
         ...(this.question.multiSelect ? ['Space toggle'] : []),
         'Enter submit',
         'Esc interrupt',
       ]
       const hint = this.palette.dim(controls.join(' • '))
-      for (const line of wrapTextWithAnsi(hint, innerWidth)) push(line)
+      for (const line of wrapTextWithAnsi(hint, innerWidth)) footerLines.push(line)
     }
     if (this.error) {
-      for (const line of wrapTextWithAnsi(this.palette.error(this.error), innerWidth)) push(line)
+      for (const line of wrapTextWithAnsi(this.palette.error(this.error), innerWidth)) footerLines.push(line)
     }
-    return ['', ...lines, ''].map((line) => {
-      const clipped = truncateToWidth(line, innerWidth, '')
-      return `  ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))}  `
+    const positionLines = this.mode === 'options' && this.options.length > this.maxVisible
+      ? [this.palette.dim(`${this.selectedIndex + 1}/${this.options.length}`)]
+      : []
+
+    // Options receive only the rows left after fixed chrome and outer padding.
+    // The final height window handles fixed chrome that cannot fit even alone.
+    const paddingRows = 2
+    const maxHeight = this.maxHeight()
+    const availableForOptions = Math.max(
+      this.mode === 'options' ? 4 : 1,
+      maxHeight - paddingRows - headerLines.length - positionLines.length - footerLines.length,
+    )
+
+    const body: string[] = [...headerLines]
+    const optionLines: string[] = []
+    if (this.mode === 'custom') {
+      for (const line of footerLines) body.push(line)
+    } else {
+      const optionBlocks = this.options.map((option, index) => this.renderOptionBlock(option, index, innerWidth))
+      const { visibleBlocks, hiddenBefore, hiddenAfter } = this.windowBlocks(optionBlocks, availableForOptions, innerWidth)
+      if (hiddenBefore > 0) optionLines.push(this.palette.dim(`↑ ${hiddenBefore} more`))
+      for (const block of visibleBlocks) {
+        for (const line of block) optionLines.push(line)
+      }
+      if (hiddenAfter > 0) optionLines.push(this.palette.dim(`↓ ${hiddenAfter} more`))
+      for (const line of optionLines) body.push(line)
+      for (const line of positionLines) body.push(line)
+      for (const line of footerLines) body.push(line)
+    }
+
+    const rows = ['', ...body, '']
+    let visibleRows = rows
+    if (rows.length <= maxHeight) this.headerPage = { offset: 0, size: 1, maxOffset: 0 }
+    if (rows.length > maxHeight && this.mode === 'options' && maxHeight >= 6) {
+      const headerBudget = Math.max(
+        0,
+        maxHeight - optionLines.length - (this.error === '' ? 1 : 2),
+      )
+      const compactFooter = [
+        ...this.error === ''
+          ? []
+          : [truncateToWidth(this.palette.error(`Error: ${this.error}`), innerWidth, '…')],
+        this.compactOptionControls(
+          innerWidth,
+          headerBudget === 1 && contentLines.length > headerBudget,
+        ),
+      ]
+      const compactHeader = this.compactQuestionHeader(contentLines, headerBudget, innerWidth)
+      visibleRows = [...compactHeader, ...optionLines, ...compactFooter]
+    } else if (rows.length > maxHeight && this.mode === 'custom' && maxHeight >= 2) {
+      const compactFooterSource = [
+        ...this.input.render(innerWidth),
+        this.compactCustomControls(innerWidth),
+        ...this.error === ''
+          ? []
+          : [truncateToWidth(this.palette.error(this.error), innerWidth, '…')],
+      ]
+      const footerBudget = Math.max(1, maxHeight - 1)
+      const compactFooter = compactFooterSource.length <= footerBudget
+        ? compactFooterSource
+        : footerBudget === 1
+          ? compactFooterSource.slice(0, 1)
+          : [
+            ...compactFooterSource.slice(0, 1),
+            ...compactFooterSource.slice(-(footerBudget - 1)),
+          ]
+      const compactHeader = this.compactQuestionHeader(
+        contentLines,
+        Math.max(0, maxHeight - compactFooter.length),
+        innerWidth,
+      )
+      visibleRows = [...compactHeader, ...compactFooter]
+    }
+    if (visibleRows.length > maxHeight) {
+      visibleRows = maxHeight === 1
+        ? [this.palette.dim(`↑ ${visibleRows.length} lines hidden`)]
+        : [
+          this.palette.dim(`↑ ${visibleRows.length - maxHeight + 1} lines hidden`),
+          ...visibleRows.slice(-(maxHeight - 1)),
+        ]
+    }
+    return visibleRows.map((line) => {
+      const bounded = truncateToWidth(line, innerWidth, '…')
+      const pad = ' '.repeat(Math.max(0, innerWidth - visibleWidth(bounded)))
+      const outerPad = ' '.repeat(horizontalPadding)
+      return `${outerPad}${bounded}${pad}${outerPad}`
     })
   }
+
+  /** Render one option as wrapped label and indented description lines. */
+  private renderOptionBlock(
+    option: NonNullable<AskUserQuestionItem['options']>[number],
+    index: number,
+    innerWidth: number,
+  ): string[] {
+    const cursor = index === this.selectedIndex ? '›' : ' '
+    const number = `${index + 1}. `
+    const mark = this.question.multiSelect
+      ? this.selected.has(index) ? '[x] ' : '[ ] '
+      : ''
+    const labelPrefixPlain = ` ${cursor} ${number}${mark}`
+    const labelPrefixWidth = visibleWidth(labelPrefixPlain)
+    const labelBodyWidth = Math.max(1, innerWidth - labelPrefixWidth)
+    const labelLines = wrapTextWithAnsi(displayText(option.label), labelBodyWidth)
+    const continuation = ' '.repeat(labelPrefixWidth)
+    const lines: string[] = []
+    for (const [lineIndex, labelLine] of labelLines.entries()) {
+      const prefix = lineIndex === 0 ? labelPrefixPlain : continuation
+      const composed = `${prefix}${labelLine}`
+      lines.push(index === this.selectedIndex ? this.palette.bold(this.palette.accent(composed)) : composed)
+    }
+    if (option.description !== undefined) {
+      const descIndent = ' '.repeat(labelPrefixWidth)
+      const descBodyWidth = Math.max(1, innerWidth - labelPrefixWidth)
+      const descLines = wrapTextWithAnsi(displayText(option.description), descBodyWidth)
+      for (const descLine of descLines) lines.push(`${descIndent}${this.palette.dim(descLine)}`)
+    }
+    return lines
+  }
+
+  /** Keep the question visible when fixed chrome must be compacted. */
+  private compactQuestionHeader(
+    contentLines: readonly string[],
+    budget: number,
+    innerWidth: number,
+  ): string[] {
+    if (budget <= 0) return []
+    if (contentLines.length <= budget) {
+      this.headerPage = { offset: 0, size: 1, maxOffset: 0 }
+      return [...contentLines]
+    }
+    const pageSize = Math.max(1, budget - 1)
+    const maxOffset = Math.max(0, contentLines.length - pageSize)
+    const offset = Math.min(this.headerPage.offset, maxOffset)
+    this.headerPage = { offset, size: pageSize, maxOffset }
+    const keptLines = contentLines.slice(offset, offset + pageSize)
+    if (budget === 1) {
+      // A page is non-empty because pageSize is one and offset is clamped inside contentLines.
+      return [keptLines[0] as string]
+    }
+    return [
+      ...keptLines,
+      this.pagerStatus(offset + 1, offset + keptLines.length, contentLines.length, innerWidth),
+    ]
+  }
+
+  /** Keep Page Up / Page Down discoverable when a full pager status cannot fit. */
+  private pagerStatus(first: number, last: number, total: number, innerWidth: number): string {
+    const full = `… lines ${first}-${last}/${total} • PgUp/PgDn`
+    const compact = `PgUp/PgDn ${first}/${total}`
+    return this.palette.dim(truncateToWidth(
+      visibleWidth(full) <= innerWidth ? full : compact,
+      innerWidth,
+      '…',
+    ))
+  }
+
+  /** Render custom-mode controls on one row when the header must compact. */
+  private compactCustomControls(innerWidth: number): string {
+    const controls = this.options.length > 0
+      ? 'Enter submit • Esc options'
+      : 'Enter submit • Esc cancel'
+    const fallback = this.options.length > 0 ? '↵ Esc options' : 'Enter Esc cancel'
+    const line = visibleWidth(controls) <= innerWidth ? controls : fallback
+    return this.palette.dim(truncateToWidth(line, innerWidth, '…'))
+  }
+
+  /** Render a one-row option footer that retains every mode-specific control. */
+  private compactOptionControls(innerWidth: number, showPager = false): string {
+    const controls = [
+      ...(this.options.length > 1 ? ['↑/↓'] : []),
+      'Tab custom',
+      ...(this.question.multiSelect ? ['Space toggle'] : []),
+      'Enter',
+      'Esc interrupt',
+      ...(showPager ? ['PgUp/PgDn'] : []),
+    ].join(' • ')
+    const optionNavigation = this.options.length > 1 ? '↑↓ ' : ''
+    const fallback = showPager
+      ? `P↑↓ ${optionNavigation}Tab${this.question.multiSelect ? ' S' : ''}↵Esc`
+      : this.question.multiSelect ? `${optionNavigation}Tab Sp ↵Esc` : `${optionNavigation}Tab ↵ Esc`
+    const line = visibleWidth(controls) <= innerWidth ? controls : fallback
+    return this.palette.dim(truncateToWidth(line, innerWidth, '…'))
+  }
+
+  /**
+   * Choose option blocks that fit while keeping the selected option visible.
+   * Omitted blocks are counted at each end for explicit overflow markers.
+   */
+  private windowBlocks(
+    blocks: readonly string[][],
+    budget: number,
+    innerWidth: number,
+  ): { visibleBlocks: string[][]; hiddenBefore: number; hiddenAfter: number } {
+    const totalLines = blocks.reduce((sum, block) => sum + block.length, 0)
+    if (totalLines <= budget && blocks.length <= this.maxVisible) {
+      return { visibleBlocks: [...blocks], hiddenBefore: 0, hiddenAfter: 0 }
+    }
+    // `blocks` is dense and selectedIndex is derived from the same options.
+    let start = this.selectedIndex
+    let end = this.selectedIndex + 1
+    /* v8 ignore next -- selectedIndex stays inside [0, options.length). */
+    let used = blocks[this.selectedIndex]?.length ?? 0
+    const markerLines = (before: number, after: number): number =>
+      (before > 0 ? 1 : 0) + (after > 0 ? 1 : 0)
+    const fits = (nextStart: number, nextEnd: number, nextUsed: number): boolean =>
+      nextEnd - nextStart <= this.maxVisible
+      && nextUsed + markerLines(nextStart, blocks.length - nextEnd) <= budget
+    const selectedMarkers = markerLines(start, blocks.length - end)
+    if (used + selectedMarkers > budget) {
+      /* v8 ignore next -- selectedIndex stays inside [0, options.length). */
+      const selectedBlock = blocks[this.selectedIndex] ?? []
+      const hiddenBefore = start
+      const hiddenAfter = blocks.length - end
+      const pageSize = budget - selectedMarkers - 1
+      const maxOffset = Math.max(0, selectedBlock.length - pageSize)
+      const offset = Math.min(this.selectedBlockPage.offset, maxOffset)
+      this.selectedBlockPage = { offset, size: pageSize, maxOffset }
+      const keptLines = selectedBlock.slice(offset, offset + pageSize)
+      const first = offset + 1
+      const last = offset + keptLines.length
+      const overflow = this.pagerStatus(first, last, selectedBlock.length, innerWidth)
+      return {
+        visibleBlocks: [[...keptLines, overflow]],
+        hiddenBefore,
+        hiddenAfter,
+      }
+    }
+    this.selectedBlockPage = { offset: 0, size: 1, maxOffset: 0 }
+    let expanded = true
+    while (expanded && (start > 0 || end < blocks.length)) {
+      expanded = false
+      if (end < blocks.length) {
+        /* v8 ignore next -- guarded by `end < blocks.length` above. */
+        const next = blocks[end]?.length ?? 0
+        if (fits(start, end + 1, used + next)) {
+          used += next
+          end += 1
+          expanded = true
+          continue
+        }
+      }
+      if (start > 0) {
+        /* v8 ignore next -- guarded by `start > 0` above. */
+        const previous = blocks[start - 1]?.length ?? 0
+        if (fits(start - 1, end, used + previous)) {
+          used += previous
+          start -= 1
+          expanded = true
+        }
+      }
+    }
+    return {
+      visibleBlocks: blocks.slice(start, end),
+      hiddenBefore: start,
+      hiddenAfter: blocks.length - end,
+    }
+  }
 }

+ 16 - 8
packages/ui/tui/src/extension/overlay-manager.ts

@@ -12,7 +12,6 @@ import type { TuiExtensionService } from '../index.ts'
 import type {
   Component,
   Focusable,
-  OverlayHandle,
 } from '@earendil-works/pi-tui'
 import type {
   TuiComponent,
@@ -36,14 +35,20 @@ export interface TuiOverlayDriver {
   theme(): TuiTheme
   /** Escape text at the terminal display boundary. */
   display(value: string): string
-  /** Mount one guarded component and return its private pi-tui handle. */
-  show(component: Component, options: TuiOverlayOptions | undefined): OverlayHandle
+  /** Mount one guarded modal and return its private focus/lifecycle handle. */
+  show(component: Component, options: TuiOverlayOptions | undefined, placement: TuiOverlayPlacement): TuiModalHandle
   /** Invalidate the mounted UI and request a render. */
   invalidate(): void
   /** Report a contained extension failure. */
   reportError(error: unknown): void
 }
 
+type TuiOverlayPlacement = 'overlay' | 'inline'
+
+interface TuiModalHandle {
+  hide(): void
+}
+
 interface OverlayEntry {
   readonly request: TuiOverlayRequest
   readonly controller: AbortController
@@ -51,9 +56,10 @@ interface OverlayEntry {
   readonly closed: Promise<TuiOverlayOutcome>
   readonly resolveClosed: (outcome: TuiOverlayOutcome) => void
   readonly session: TuiOverlaySession
+  readonly placement: TuiOverlayPlacement
   state: TuiOverlayState
   component?: GuardedOverlayComponent
-  handle?: OverlayHandle
+  handle?: TuiModalHandle
   removeRequestAbort?: () => void
   outcome?: TuiOverlayOutcome
   failing?: boolean
@@ -165,11 +171,12 @@ export class TuiOverlayManager {
   }
 
   /**
-   * Queue one overlay without assigning Cordis ownership.
+   * Queue one modal without assigning Cordis ownership.
    * @param request - component factory, constraints, and request signal.
+   * @param placement - terminal overlay for extensions, or inline for the built-in question panel.
    * @returns an internal session that can close with an ownership reason.
    */
-  open(request: TuiOverlayRequest): TuiOverlaySession & {
+  open(request: TuiOverlayRequest, placement: TuiOverlayPlacement = 'overlay'): TuiOverlaySession & {
     closeWith(reason: Exclude<TuiOverlayCloseReason, 'error'>): Promise<TuiOverlayOutcome>
   } {
     if (!this.accepting) throw new Error('TUI is shutting down')
@@ -202,6 +209,7 @@ export class TuiOverlayManager {
       closed: deferred.promise,
       resolveClosed: deferred.resolve,
       session,
+      placement,
       state: 'queued',
     }
     if (requestSignal?.aborted === true) {
@@ -251,7 +259,7 @@ export class TuiOverlayManager {
     })
     entry.component = guarded
     try {
-      const handle = this.driver.show(guarded, entry.request.options)
+      const handle = this.driver.show(guarded, entry.request.options, entry.placement)
       if (this.active !== entry) {
         this.hide(handle)
         return
@@ -306,7 +314,7 @@ export class TuiOverlayManager {
     }
   }
 
-  private hide(handle: OverlayHandle): void {
+  private hide(handle: TuiModalHandle): void {
     try {
       handle.hide()
     } catch (error) {

+ 54 - 8
packages/ui/tui/src/index.ts

@@ -15,6 +15,7 @@ import {
   ProcessTerminal,
   matchesKey,
   visibleWidth,
+  type Component,
   type EditorTheme,
   type SlashCommand,
   type TerminalColorScheme,
@@ -289,6 +290,23 @@ interface FadingStatus {
   timer: ReturnType<typeof setInterval>
 }
 
+/** Width/height adapter for a modal component rendered inside the base TUI flow. */
+class InlineModalComponent extends Container {
+  constructor(
+    component: Component,
+    private readonly width: number,
+    private readonly maxHeight: number,
+  ) {
+    super()
+    this.addChild(component)
+  }
+
+  override render(width: number): string[] {
+    const lines = super.render(Math.max(1, Math.min(width, this.width)))
+    return lines.slice(0, Math.max(1, this.maxHeight))
+  }
+}
+
 /** Lifecycle handle for a mounted interactive terminal channel. */
 export interface TuiController {
   /** Stop rendering, restore the terminal, and reject pending questions. */
@@ -316,6 +334,7 @@ export function createTuiChat(
   const ui = new TUI(runtime.terminal, resolved.showHardwareCursor)
   const chat = new Container()
   const todoContainer = new Container()
+  const questionContainer = new Container()
   const inputTemplate = parseTuiPromptTemplate(displayInlineText(resolved.theme.inputPrompt))
   const renderInputPrompt = (): string => renderTuiPromptTemplate(inputTemplate, valueName => ctx.tuiPrompt.get(valueName))
   const initialInputPrompt = renderInputPrompt()
@@ -480,6 +499,7 @@ export function createTuiChat(
   ui.addChild(todoContainer)
   ui.addChild(compactionStatusLine)
   ui.addChild(promptContext)
+  ui.addChild(questionContainer)
   ui.addChild(editor)
   ui.setFocus(editor)
   const updateTerminalTitle = (): void => {
@@ -529,14 +549,32 @@ export function createTuiChat(
     }),
     theme: () => extensionTheme,
     display: displayText,
-    show: (component, options) => ui.showOverlay(component, options === undefined
-      ? undefined
-      : {
-        ...options,
-        ...typeof options.margin === 'object'
-          ? { margin: { ...options.margin } }
-          : {},
-      }),
+    show: (component, options, placement) => {
+      if (placement === 'overlay') {
+        return ui.showOverlay(component, options === undefined
+          ? undefined
+          : {
+            ...options,
+            ...typeof options.margin === 'object'
+              ? { margin: { ...options.margin } }
+              : {},
+          })
+      }
+      const modal = new InlineModalComponent(
+        component,
+        resolved.questionDialogWidth,
+        resolved.questionDialogMaxHeight,
+      )
+      questionContainer.clear()
+      questionContainer.addChild(modal)
+      ui.setFocus(component)
+      return {
+        hide(): void {
+          questionContainer.clear()
+          ui.setFocus(editor)
+        },
+      }
+    },
     invalidate: requestRender,
     reportError: (error) => {
       const message = errorChain(error)
@@ -956,6 +994,14 @@ export function createTuiChat(
     overlayManager,
     requestRender,
     isDisposed,
+    questionMaxHeight: () => {
+      const width = runtime.terminal.columns
+      const editorRows = editor.render(width).length
+      return Math.max(1, Math.min(
+        resolved.questionDialogMaxHeight,
+        runtime.terminal.rows - editorRows,
+      ))
+    },
   })
 
   const resume = createResumeController({

+ 39 - 0
packages/ui/tui/tests/snapshots/question-dialog-detail-paged.expected.txt

@@ -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

+ 39 - 0
packages/ui/tui/tests/snapshots/question-dialog-paged.expected.txt

@@ -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

+ 15 - 15
packages/ui/tui/tests/snapshots/question-dialog-single-option.expected.txt

@@ -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>

+ 36 - 36
packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt

@@ -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

+ 33 - 33
packages/ui/tui/tests/snapshots/question-dialog.expected.txt

@@ -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

+ 27 - 12
packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt

@@ -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

+ 15 - 1
packages/ui/tui/tests/tui.snapshot.ts

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

+ 511 - 0
packages/ui/tui/tests/tui.spec.ts

@@ -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 } })
 
@@ -5603,6 +5652,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()

Некоторые файлы не были показаны из-за большого количества измененных файлов