fix(tui): blitz closeout — input wrap parity, shift-tab yolo, bottom statusline

- input wrap: add <Text wrap="wrap-char"> mode that drives wrap-ansi with
  wordWrap:false, and align cursorLayout/offsetFromPosition to that same
  boundary (w=cols, trailing-cell overflow). Word-wrap's whitespace
  reshuffle was causing the cursor to jump a word left/right on each
  keystroke near the right edge — blitz row 9
- shift-tab: toggle per-session yolo without submitting a turn (mirrors
  Claude Code's in-place dangerously-approve); slash /yolo still works
  for discoverability — blitz row 5 sub-item 11
- statusline: lift StatusRule out of ComposerPane to a new StatusRulePane
  anchored at the bottom of AppLayout, below the input — blitz row 5
  sub-item 12
This commit is contained in:
Brooklyn Nicholson 2026-04-22 13:28:44 -05:00
parent 88564ad8bc
commit 7027ce42ef
8 changed files with 140 additions and 24 deletions

View file

@ -69,6 +69,12 @@ const memoizedStylesForWrap: Record<NonNullable<Styles['textWrap']>, Styles> = {
flexDirection: 'row',
textWrap: 'wrap'
},
'wrap-char': {
flexGrow: 0,
flexShrink: 1,
flexDirection: 'row',
textWrap: 'wrap-char'
},
'wrap-trim': {
flexGrow: 0,
flexShrink: 1,

View file

@ -343,7 +343,7 @@ function wrapWithSoftWrap(
maxWidth: number,
textWrap: Parameters<typeof wrapText>[2]
): { wrapped: string; softWrap: boolean[] | undefined } {
if (textWrap !== 'wrap' && textWrap !== 'wrap-trim') {
if (textWrap !== 'wrap' && textWrap !== 'wrap-char' && textWrap !== 'wrap-trim') {
return {
wrapped: wrapText(plainText, maxWidth, textWrap),
softWrap: undefined

View file

@ -55,6 +55,7 @@ export type TextStyles = {
export type Styles = {
readonly textWrap?:
| 'wrap'
| 'wrap-char'
| 'wrap-trim'
| 'end'
| 'middle'

View file

@ -50,6 +50,19 @@ export default function wrapText(text: string, maxWidth: number, wrapType: Style
})
}
// Char-granularity wrap: break at exact column boundaries regardless of
// whitespace. Used for text inputs where the cursor position must track
// the wrap boundary deterministically — word-wrap's whitespace-preferring
// reshuffle causes visible cursor flicker as each keystroke can push a
// word across a line break.
if (wrapType === 'wrap-char') {
return wrapAnsi(text, maxWidth, {
trim: false,
hard: true,
wordWrap: false
})
}
if (wrapType === 'wrap-trim') {
return wrapAnsi(text, maxWidth, {
trim: true,

View file

@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import { cursorLayout, offsetFromPosition } from '../components/textInput.js'
describe('cursorLayout — char-wrap parity with wrap-ansi', () => {
it('places cursor mid-line at its column', () => {
expect(cursorLayout('hello world', 6, 40)).toEqual({ column: 6, line: 0 })
})
it('places cursor at end of a non-full line', () => {
expect(cursorLayout('hi', 2, 10)).toEqual({ column: 2, line: 0 })
})
it('wraps to next line when cursor lands exactly at the right edge', () => {
// 8 chars on an 8-col line: text fills the row exactly; the cursor's
// inverted-space cell overflows to col 0 of the next row.
expect(cursorLayout('abcdefgh', 8, 8)).toEqual({ column: 0, line: 1 })
})
it('tracks a word across a char-wrap boundary without jumping', () => {
// With wordWrap:false, "hello world" at cols=8 is "hello wo\nrld" —
// typing incremental letters doesn't reshuffle the word across lines.
expect(cursorLayout('hello wo', 8, 8)).toEqual({ column: 0, line: 1 })
expect(cursorLayout('hello wor', 9, 8)).toEqual({ column: 1, line: 1 })
expect(cursorLayout('hello worl', 10, 8)).toEqual({ column: 2, line: 1 })
})
it('honours explicit newlines', () => {
expect(cursorLayout('one\ntwo', 5, 40)).toEqual({ column: 1, line: 1 })
expect(cursorLayout('one\ntwo', 4, 40)).toEqual({ column: 0, line: 1 })
})
it('does not wrap when cursor is before the right edge', () => {
expect(cursorLayout('abcdefg', 7, 8)).toEqual({ column: 7, line: 0 })
})
})
describe('offsetFromPosition — char-wrap inverse of cursorLayout', () => {
it('returns 0 for empty input', () => {
expect(offsetFromPosition('', 0, 0, 10)).toBe(0)
})
it('maps clicks within a single line', () => {
expect(offsetFromPosition('hello', 0, 3, 40)).toBe(3)
})
it('maps clicks past end to value length', () => {
expect(offsetFromPosition('hi', 0, 10, 40)).toBe(2)
})
it('maps clicks on a wrapped second row at cols boundary', () => {
// "abcdefghij" at cols=8 wraps to "abcdefgh\nij" — click at row 1 col 0
// should land on 'i' (offset 8).
expect(offsetFromPosition('abcdefghij', 1, 0, 8)).toBe(8)
})
it('maps clicks past a \\n into the target line', () => {
expect(offsetFromPosition('one\ntwo', 1, 2, 40)).toBe(6)
})
})

View file

@ -3,6 +3,7 @@ import { useStore } from '@nanostores/react'
import type {
ApprovalRespondResponse,
ConfigSetResponse,
SecretRespondResponse,
SudoRespondResponse,
VoiceRecordResponse
@ -377,6 +378,16 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
return cActions.openEditor()
}
// Shift-Tab toggles per-session yolo without submitting a turn — mirrors
// Claude Code's in-place dangerously-approve toggle. Slash /yolo keeps
// working for discoverability; this just skips the inference round-trip
// when you only want to flip the flag mid-flow (blitz #5 sub-item 11).
if (key.shift && key.tab && !cState.completions.length) {
return void gateway
.rpc<ConfigSetResponse>('config.set', { key: 'yolo', session_id: live.sid })
.then(r => actions.sys(`yolo ${r?.value === '1' ? 'on' : 'off'}`))
}
if (key.tab && cState.completions.length) {
const row = cState.completions[cState.compIdx]

View file

@ -184,24 +184,6 @@ const ComposerPane = memo(function ComposerPane({
)}
<Box flexDirection="column" position="relative">
{ui.statusBar && (
<StatusRule
bgCount={ui.bgTasks.size}
busy={ui.busy}
cols={composer.cols}
cwdLabel={status.cwdLabel}
model={ui.info?.model?.split('/').pop() ?? ''}
sessionStartedAt={status.sessionStartedAt}
showCost={ui.showCost}
status={ui.status}
statusColor={status.statusColor}
t={ui.theme}
turnStartedAt={status.turnStartedAt}
usage={ui.usage}
voiceLabel={status.voiceLabel}
/>
)}
<FloatingOverlays
cols={composer.cols}
compIdx={composer.compIdx}
@ -273,6 +255,32 @@ const AgentsOverlayPane = memo(function AgentsOverlayPane() {
)
})
const StatusRulePane = memo(function StatusRulePane({ composer, status }: Pick<AppLayoutProps, 'composer' | 'status'>) {
const ui = useStore($uiState)
if (!ui.statusBar) {
return null
}
return (
<StatusRule
bgCount={ui.bgTasks.size}
busy={ui.busy}
cols={composer.cols}
cwdLabel={status.cwdLabel}
model={ui.info?.model?.split('/').pop() ?? ''}
sessionStartedAt={status.sessionStartedAt}
showCost={ui.showCost}
status={ui.status}
statusColor={status.statusColor}
t={ui.theme}
turnStartedAt={status.turnStartedAt}
usage={ui.usage}
voiceLabel={status.voiceLabel}
/>
)
})
export const AppLayout = memo(function AppLayout({
actions,
composer,
@ -305,6 +313,8 @@ export const AppLayout = memo(function AppLayout({
)}
{!overlay.agents && <ComposerPane actions={actions} composer={composer} status={status} />}
{!overlay.agents && <StatusRulePane composer={composer} status={status} />}
</Box>
</AlternateScreen>
)

View file

@ -167,9 +167,14 @@ export function lineNav(s: string, p: number, dir: -1 | 1): null | number {
return snapPos(s, Math.min(nextBreak + 1 + col, lineEnd))
}
function cursorLayout(value: string, cursor: number, cols: number) {
// Cursor layout mirrors `wrap-ansi(text, cols, { wordWrap: false, hard: true })`
// which is what `<Text wrap="wrap-char">` ends up feeding to the renderer.
// Char-granularity wrap keeps wrap boundaries deterministic as the user
// types — word-wrap's whitespace-preferring reshuffle causes the cursor
// to flicker as each keystroke moves a word across a line break (blitz #9).
export function cursorLayout(value: string, cursor: number, cols: number) {
const pos = Math.max(0, Math.min(cursor, value.length))
const w = Math.max(1, cols - 1)
const w = Math.max(1, cols)
let col = 0,
line = 0
@ -200,17 +205,27 @@ function cursorLayout(value: string, cursor: number, cols: number) {
col += sw
}
// The cursor renders as an inverted cell AFTER the character at `pos`
// (or as a standalone trailing cell when `pos === value.length`). If
// col has reached the wrap column, that cell overflows to the next row
// — match wrap-ansi's behavior so the declared cursor doesn't sit past
// the visual edge.
if (col >= w) {
line++
col = 0
}
return { column: col, line }
}
function offsetFromPosition(value: string, row: number, col: number, cols: number) {
export function offsetFromPosition(value: string, row: number, col: number, cols: number) {
if (!value.length) {
return 0
}
const targetRow = Math.max(0, Math.floor(row))
const targetCol = Math.max(0, Math.floor(col))
const w = Math.max(1, cols - 1)
const w = Math.max(1, cols)
let line = 0
let column = 0
@ -800,7 +815,7 @@ export function TextInput({
}}
ref={boxRef}
>
<Text wrap="wrap">{rendered}</Text>
<Text wrap="wrap-char">{rendered}</Text>
</Box>
)
}