fix(tui): render explicit prompt gap
Reserve the composer prompt gap as layout instead of relying on terminal handling of trailing spaces.
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
import { offsetFromPosition } from '../components/textInput.js'
|
import { offsetFromPosition } from '../components/textInput.js'
|
||||||
import { cursorLayout, inputVisualHeight, stableComposerColumns } from '../lib/inputMetrics.js'
|
import { composerPromptWidth, cursorLayout, inputVisualHeight, stableComposerColumns } from '../lib/inputMetrics.js'
|
||||||
|
|
||||||
describe('cursorLayout — char-wrap parity with wrap-ansi', () => {
|
describe('cursorLayout — char-wrap parity with wrap-ansi', () => {
|
||||||
it('places cursor mid-line at its column', () => {
|
it('places cursor mid-line at its column', () => {
|
||||||
@@ -42,6 +42,12 @@ describe('input metrics helpers', () => {
|
|||||||
expect(inputVisualHeight('one\ntwo', 40)).toBe(2)
|
expect(inputVisualHeight('one\ntwo', 40)).toBe(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('counts the prompt gap as its own cell', () => {
|
||||||
|
expect(composerPromptWidth('>')).toBe(2)
|
||||||
|
expect(composerPromptWidth('❯')).toBe(2)
|
||||||
|
expect(composerPromptWidth('Ψ >')).toBe(4)
|
||||||
|
})
|
||||||
|
|
||||||
it('reserves gutters on wide panes without starving narrow composer width', () => {
|
it('reserves gutters on wide panes without starving narrow composer width', () => {
|
||||||
expect(stableComposerColumns(100, 3)).toBe(93)
|
expect(stableComposerColumns(100, 3)).toBe(93)
|
||||||
expect(stableComposerColumns(100, 5)).toBe(91)
|
expect(stableComposerColumns(100, 5)).toBe(91)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { $uiState } from '../app/uiStore.js'
|
|||||||
import { INLINE_MODE, SHOW_FPS } from '../config/env.js'
|
import { INLINE_MODE, SHOW_FPS } from '../config/env.js'
|
||||||
import { FULL_RENDER_TAIL_ITEMS } from '../config/limits.js'
|
import { FULL_RENDER_TAIL_ITEMS } from '../config/limits.js'
|
||||||
import { PLACEHOLDER } from '../content/placeholders.js'
|
import { PLACEHOLDER } from '../content/placeholders.js'
|
||||||
import { inputVisualHeight, stableComposerColumns } from '../lib/inputMetrics.js'
|
import { composerPromptWidth, inputVisualHeight, stableComposerColumns } from '../lib/inputMetrics.js'
|
||||||
import { PerfPane } from '../lib/perfPane.js'
|
import { PerfPane } from '../lib/perfPane.js'
|
||||||
|
|
||||||
import { AgentsOverlay } from './agentsOverlay.js'
|
import { AgentsOverlay } from './agentsOverlay.js'
|
||||||
@@ -22,6 +22,33 @@ import { QueuedMessages } from './queuedMessages.js'
|
|||||||
import { LiveTodoPanel, StreamingAssistant } from './streamingAssistant.js'
|
import { LiveTodoPanel, StreamingAssistant } from './streamingAssistant.js'
|
||||||
import { TextInput, type TextInputMouseApi } from './textInput.js'
|
import { TextInput, type TextInputMouseApi } from './textInput.js'
|
||||||
|
|
||||||
|
const PROMPT_GAP_WIDTH = 1
|
||||||
|
|
||||||
|
const PromptPrefix = memo(function PromptPrefix({
|
||||||
|
bold = false,
|
||||||
|
color,
|
||||||
|
promptText,
|
||||||
|
width
|
||||||
|
}: {
|
||||||
|
bold?: boolean
|
||||||
|
color: string
|
||||||
|
promptText: string
|
||||||
|
width: number
|
||||||
|
}) {
|
||||||
|
const glyphWidth = Math.max(1, stringWidth(promptText))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box width={width}>
|
||||||
|
<Box width={glyphWidth}>
|
||||||
|
<Text bold={bold} color={color}>
|
||||||
|
{promptText}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box width={PROMPT_GAP_WIDTH} />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const TranscriptPane = memo(function TranscriptPane({
|
const TranscriptPane = memo(function TranscriptPane({
|
||||||
actions,
|
actions,
|
||||||
composer,
|
composer,
|
||||||
@@ -125,8 +152,8 @@ const ComposerPane = memo(function ComposerPane({
|
|||||||
const isBlocked = useStore($isBlocked)
|
const isBlocked = useStore($isBlocked)
|
||||||
const sh = (composer.inputBuf[0] ?? composer.input).startsWith('!')
|
const sh = (composer.inputBuf[0] ?? composer.input).startsWith('!')
|
||||||
const promptText = sh ? '$' : ui.theme.brand.prompt
|
const promptText = sh ? '$' : ui.theme.brand.prompt
|
||||||
const promptLabel = `${promptText} `
|
const promptWidth = composerPromptWidth(promptText)
|
||||||
const promptWidth = Math.max(1, stringWidth(promptLabel))
|
const promptBlank = ' '.repeat(promptWidth)
|
||||||
const inputColumns = stableComposerColumns(composer.cols, promptWidth)
|
const inputColumns = stableComposerColumns(composer.cols, promptWidth)
|
||||||
const inputHeight = inputVisualHeight(composer.input, inputColumns)
|
const inputHeight = inputVisualHeight(composer.input, inputColumns)
|
||||||
const inputMouseRef = useRef<null | TextInputMouseApi>(null)
|
const inputMouseRef = useRef<null | TextInputMouseApi>(null)
|
||||||
@@ -217,7 +244,11 @@ const ComposerPane = memo(function ComposerPane({
|
|||||||
{composer.inputBuf.map((line, i) => (
|
{composer.inputBuf.map((line, i) => (
|
||||||
<Box key={i}>
|
<Box key={i}>
|
||||||
<Box width={promptWidth}>
|
<Box width={promptWidth}>
|
||||||
<Text color={ui.theme.color.muted}>{i === 0 ? promptLabel : ' '.repeat(promptWidth)}</Text>
|
{i === 0 ? (
|
||||||
|
<PromptPrefix color={ui.theme.color.muted} promptText={promptText} width={promptWidth} />
|
||||||
|
) : (
|
||||||
|
<Text color={ui.theme.color.muted}>{promptBlank}</Text>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Text color={ui.theme.color.text}>{line || ' '}</Text>
|
<Text color={ui.theme.color.text}>{line || ' '}</Text>
|
||||||
@@ -232,11 +263,11 @@ const ComposerPane = memo(function ComposerPane({
|
|||||||
>
|
>
|
||||||
<Box width={promptWidth}>
|
<Box width={promptWidth}>
|
||||||
{sh ? (
|
{sh ? (
|
||||||
<Text color={ui.theme.color.shellDollar}>{promptLabel}</Text>
|
<PromptPrefix color={ui.theme.color.shellDollar} promptText={promptText} width={promptWidth} />
|
||||||
|
) : composer.inputBuf.length ? (
|
||||||
|
<Text color={ui.theme.color.prompt}>{promptBlank}</Text>
|
||||||
) : (
|
) : (
|
||||||
<Text bold color={ui.theme.color.prompt}>
|
<PromptPrefix bold color={ui.theme.color.prompt} promptText={promptText} width={promptWidth} />
|
||||||
{composer.inputBuf.length ? ' '.repeat(promptWidth) : promptLabel}
|
|
||||||
</Text>
|
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ export function inputVisualHeight(value: string, columns: number) {
|
|||||||
return cursorLayout(value, value.length, columns).line + 1
|
return cursorLayout(value, value.length, columns).line + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function composerPromptWidth(promptText: string) {
|
||||||
|
return Math.max(1, stringWidth(promptText)) + 1
|
||||||
|
}
|
||||||
|
|
||||||
export function stableComposerColumns(totalCols: number, promptWidth: number) {
|
export function stableComposerColumns(totalCols: number, promptWidth: number) {
|
||||||
// Physical render/wrap width. Always reserve outer composer padding and
|
// Physical render/wrap width. Always reserve outer composer padding and
|
||||||
// prompt prefix. Only reserve the transcript scrollbar gutter when the
|
// prompt prefix. Only reserve the transcript scrollbar gutter when the
|
||||||
|
|||||||
Reference in New Issue
Block a user