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

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

View File

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

View File

@@ -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)
@@ -886,6 +905,7 @@ export class QuestionDialog implements Component, Focusable {
this.done({ selected, ...(custom === '' ? {} : { custom }) })
} else if (matchesKey(data, Key.tab) || data.toLowerCase() === 'c') {
this.mode = 'custom'
this.selectedBlockPage = { offset: 0, size: 1, maxOffset: 0 }
this.error = ''
} else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) {
this.cancel()
@@ -911,80 +931,349 @@ export class QuestionDialog implements Component, Focusable {
.filter((label): label is string => label !== undefined)
}
/** 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)
}
push('')
if (this.mode === 'custom') {
for (const line of this.input.render(innerWidth)) push(line)
const controls = [
...(this.options.length > 0 && this.question.multiSelect ? [`${this.selected.size} selected`] : []),
'Enter submit',
this.options.length > 0 ? 'Esc options' : 'Esc cancel',
]
push(this.palette.dim(controls.join(' • ')))
} 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}`)
headerLines.push('')
contentLines.push('')
for (const line of wrapTextWithAnsi(displayText(this.question.detail), innerWidth)) {
headerLines.push(line)
contentLines.push(line)
}
if (options.length > this.maxVisible) push(this.palette.dim(`${this.selectedIndex + 1}/${options.length}`))
}
headerLines.push('')
const customControls = [
...(this.options.length > 0 && this.question.multiSelect ? [`${this.selected.size} selected`] : []),
'Enter submit',
this.options.length > 0 ? 'Esc options' : 'Esc cancel',
]
const customHint = this.palette.dim(customControls.join(' • '))
const footerLines: string[] = []
if (this.mode === 'custom') {
for (const line of this.input.render(innerWidth)) footerLines.push(line)
for (const line of wrapTextWithAnsi(customHint, innerWidth)) footerLines.push(line)
} else {
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,
}
}
}

View File

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

View File

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