From c3b8c8e42cb120be4ce972f984b74c3dccfec18b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 21 Apr 2026 10:45:19 -0500 Subject: [PATCH 1/4] fix(tui): stabilize model picker viewport height MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Warning row, "↑ N more" / "↓ N more" hints, and the items list were all conditionally rendered, so the picker jumped in size as the selection moved or providers without a warning slid into view. Render every slot unconditionally: warning falls back to a blank line, hints render an empty string when at the edge, and the items grid always emits VISIBLE rows padded with blanks. Height is now constant across providers, model counts, and scroll position. --- ui-tui/src/components/modelPicker.tsx | 39 ++++++++++++++++++++------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/ui-tui/src/components/modelPicker.tsx b/ui-tui/src/components/modelPicker.tsx index 5ee19e407..395ad4cca 100644 --- a/ui-tui/src/components/modelPicker.tsx +++ b/ui-tui/src/components/modelPicker.tsx @@ -174,13 +174,14 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke Current model: {currentModel || '(unknown)'} - {provider?.warning ? warning: {provider.warning} : null} - {off > 0 && ↑ {off} more} + {provider?.warning ? `warning: ${provider.warning}` : ' '} + {off > 0 ? ` ↑ ${off} more` : ' '} - {items.map((row, i) => { + {Array.from({ length: VISIBLE }, (_, i) => { + const row = items[i] const idx = off + i - return ( + return row ? ( + ) : ( + ) })} - {off + VISIBLE < rows.length && ↓ {rows.length - off - VISIBLE} more} + + {off + VISIBLE < rows.length ? ` ↓ ${rows.length - off - VISIBLE} more` : ' '} + + persist: {persistGlobal ? 'global' : 'session'} · g toggle ↑/↓ select · Enter choose · 1-9,0 quick · Esc cancel @@ -207,13 +213,23 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke {names[providerIdx] || '(unknown provider)'} - {!models.length ? no models listed for this provider : null} - {provider?.warning ? warning: {provider.warning} : null} - {off > 0 && ↑ {off} more} + {provider?.warning ? `warning: ${provider.warning}` : ' '} + {off > 0 ? ` ↑ ${off} more` : ' '} - {items.map((row, i) => { + {Array.from({ length: VISIBLE }, (_, i) => { + const row = items[i] const idx = off + i + if (!row) { + return !models.length && i === 0 ? ( + + no models listed for this provider + + ) : ( + + ) + } + return ( ↓ {models.length - off - VISIBLE} more} + + {off + VISIBLE < models.length ? ` ↓ ${models.length - off - VISIBLE} more` : ' '} + + persist: {persistGlobal ? 'global' : 'session'} · g toggle {models.length ? '↑/↓ select · Enter switch · 1-9,0 quick · Esc back' : 'Enter/Esc back'} From fc6a27098e4bfcbcb6943159f0a89ede577fbd08 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 21 Apr 2026 10:47:31 -0500 Subject: [PATCH 2/4] fix(tui): raise picker selection contrast with inverse + bold Selected rows in the model/session/skills pickers and approval/clarify prompts only changed from dim gray to cornsilk, which reads as low contrast on lighter themes and LCDs (reported during TUI v2 blitz). Switch the selected row to `inverse bold` with the brand accent color across modelPicker, sessionPicker, skillsHub, and prompts so the highlight is terminal-portable and unambiguous. Unselected rows stay dim. Also extends the sessionPicker middle meta column (which was always dim) to inherit the row's selection state. --- .../src/ink/events/cmd-shortcuts.test.ts | 4 +- ui-tui/src/__tests__/clipboard.test.ts | 25 +++++++-- ui-tui/src/__tests__/osc52.test.ts | 1 + ui-tui/src/__tests__/platform.test.ts | 1 + ui-tui/src/__tests__/terminalParity.test.ts | 53 ++++++++++++++----- ui-tui/src/__tests__/terminalSetup.test.ts | 16 ++++-- ui-tui/src/__tests__/useComposerState.test.ts | 10 ++-- ui-tui/src/app/slash/commands/core.ts | 34 +++++++----- ui-tui/src/app/slash/commands/session.ts | 2 +- ui-tui/src/app/useComposerState.ts | 37 ++++++++++--- ui-tui/src/app/useInputHandlers.ts | 1 - ui-tui/src/app/useMainApp.ts | 2 +- ui-tui/src/components/appChrome.tsx | 9 ++-- ui-tui/src/components/modelPicker.tsx | 28 ++++++---- ui-tui/src/components/prompts.tsx | 13 ++--- ui-tui/src/components/sessionPicker.tsx | 13 +++-- ui-tui/src/components/skillsHub.tsx | 14 ++++- ui-tui/src/components/textInput.tsx | 11 ++-- ui-tui/src/content/hotkeys.ts | 14 +++-- ui-tui/src/lib/clipboard.ts | 7 ++- ui-tui/src/lib/osc52.ts | 1 + ui-tui/src/lib/platform.ts | 2 +- ui-tui/src/lib/terminalParity.ts | 21 ++++++-- ui-tui/src/lib/terminalSetup.ts | 22 +++++++- 24 files changed, 248 insertions(+), 93 deletions(-) diff --git a/ui-tui/packages/hermes-ink/src/ink/events/cmd-shortcuts.test.ts b/ui-tui/packages/hermes-ink/src/ink/events/cmd-shortcuts.test.ts index 69e6fdbd0..1abd7bbe0 100644 --- a/ui-tui/packages/hermes-ink/src/ink/events/cmd-shortcuts.test.ts +++ b/ui-tui/packages/hermes-ink/src/ink/events/cmd-shortcuts.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it } from 'vitest' -import { InputEvent } from './input-event.js' import { parseMultipleKeypresses } from '../parse-keypress.js' +import { InputEvent } from './input-event.js' + function parseOne(sequence: string) { const [keys] = parseMultipleKeypresses({ incomplete: '', mode: 'NORMAL' }, sequence) expect(keys).toHaveLength(1) + return keys[0]! } diff --git a/ui-tui/src/__tests__/clipboard.test.ts b/ui-tui/src/__tests__/clipboard.test.ts index 3470e4e08..ba14e9beb 100644 --- a/ui-tui/src/__tests__/clipboard.test.ts +++ b/ui-tui/src/__tests__/clipboard.test.ts @@ -28,7 +28,9 @@ describe('readClipboardText', () => { it('tries powershell.exe first on WSL', async () => { const run = vi.fn().mockResolvedValue({ stdout: 'from wsl\n' }) - await expect(readClipboardText('linux', run, { WSL_INTEROP: '/tmp/socket' } as NodeJS.ProcessEnv)).resolves.toBe('from wsl\n') + await expect(readClipboardText('linux', run, { WSL_INTEROP: '/tmp/socket' } as NodeJS.ProcessEnv)).resolves.toBe( + 'from wsl\n' + ) expect(run).toHaveBeenCalledWith( 'powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', 'Get-Clipboard -Raw'], @@ -39,7 +41,9 @@ describe('readClipboardText', () => { it('uses wl-paste on Wayland Linux', async () => { const run = vi.fn().mockResolvedValue({ stdout: 'from wayland\n' }) - await expect(readClipboardText('linux', run, { WAYLAND_DISPLAY: 'wayland-1' } as NodeJS.ProcessEnv)).resolves.toBe('from wayland\n') + await expect(readClipboardText('linux', run, { WAYLAND_DISPLAY: 'wayland-1' } as NodeJS.ProcessEnv)).resolves.toBe( + 'from wayland\n' + ) expect(run).toHaveBeenCalledWith( 'wl-paste', ['--type', 'text'], @@ -53,7 +57,9 @@ describe('readClipboardText', () => { .mockRejectedValueOnce(new Error('wl-paste missing')) .mockResolvedValueOnce({ stdout: 'from xclip\n' }) - await expect(readClipboardText('linux', run, { WAYLAND_DISPLAY: 'wayland-1' } as NodeJS.ProcessEnv)).resolves.toBe('from xclip\n') + await expect(readClipboardText('linux', run, { WAYLAND_DISPLAY: 'wayland-1' } as NodeJS.ProcessEnv)).resolves.toBe( + 'from xclip\n' + ) expect(run).toHaveBeenNthCalledWith( 1, 'wl-paste', @@ -71,7 +77,9 @@ describe('readClipboardText', () => { it('returns null when every clipboard backend fails', async () => { const run = vi.fn().mockRejectedValue(new Error('clipboard failed')) - await expect(readClipboardText('linux', run, { WAYLAND_DISPLAY: 'wayland-1' } as NodeJS.ProcessEnv)).resolves.toBeNull() + await expect( + readClipboardText('linux', run, { WAYLAND_DISPLAY: 'wayland-1' } as NodeJS.ProcessEnv) + ).resolves.toBeNull() }) }) @@ -101,6 +109,7 @@ describe('writeClipboardText', () => { it('writes text to pbcopy on macOS', async () => { const stdin = { end: vi.fn() } + const child = { once: vi.fn((event: string, cb: (code?: number) => void) => { if (event === 'close') { @@ -111,10 +120,15 @@ describe('writeClipboardText', () => { }), stdin } + const start = vi.fn().mockReturnValue(child) await expect(writeClipboardText('hello world', 'darwin', start as any)).resolves.toBe(true) - expect(start).toHaveBeenCalledWith('pbcopy', [], expect.objectContaining({ stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true })) + expect(start).toHaveBeenCalledWith( + 'pbcopy', + [], + expect.objectContaining({ stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true }) + ) expect(stdin.end).toHaveBeenCalledWith('hello world') }) @@ -129,6 +143,7 @@ describe('writeClipboardText', () => { }), stdin: { end: vi.fn() } } + const start = vi.fn().mockReturnValue(child) await expect(writeClipboardText('hello world', 'darwin', start as any)).resolves.toBe(false) diff --git a/ui-tui/src/__tests__/osc52.test.ts b/ui-tui/src/__tests__/osc52.test.ts index 3d845d5ef..a1f5242dd 100644 --- a/ui-tui/src/__tests__/osc52.test.ts +++ b/ui-tui/src/__tests__/osc52.test.ts @@ -49,6 +49,7 @@ describe('readOsc52Clipboard', () => { data: `c;${Buffer.from('queried text', 'utf8').toString('base64')}`, type: 'osc' }) + const flush = vi.fn().mockResolvedValue(undefined) await expect(readOsc52Clipboard({ flush, send })).resolves.toBe('queried text') diff --git a/ui-tui/src/__tests__/platform.test.ts b/ui-tui/src/__tests__/platform.test.ts index 8465ef0f1..1d2f73fe4 100644 --- a/ui-tui/src/__tests__/platform.test.ts +++ b/ui-tui/src/__tests__/platform.test.ts @@ -5,6 +5,7 @@ const originalPlatform = process.platform async function importPlatform(platform: NodeJS.Platform) { vi.resetModules() Object.defineProperty(process, 'platform', { value: platform }) + return import('../lib/platform.js') } diff --git a/ui-tui/src/__tests__/terminalParity.test.ts b/ui-tui/src/__tests__/terminalParity.test.ts index 224199389..005434396 100644 --- a/ui-tui/src/__tests__/terminalParity.test.ts +++ b/ui-tui/src/__tests__/terminalParity.test.ts @@ -17,28 +17,55 @@ describe('terminalParityHints', () => { it('suggests IDE setup only for VS Code-family terminals that still need bindings', async () => { const readFile = vi.fn().mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) - const hints = await terminalParityHints( - { TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv, - { fileOps: { readFile }, homeDir: '/tmp/fake-home' } - ) + const hints = await terminalParityHints({ TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv, { + fileOps: { readFile }, + homeDir: '/tmp/fake-home' + }) + expect(hints.some(h => h.key === 'ide-setup')).toBe(true) }) it('suppresses IDE setup hint when keybindings are already configured', async () => { const readFile = vi.fn().mockResolvedValue( JSON.stringify([ - { key: 'shift+enter', command: 'workbench.action.terminal.sendSequence', when: 'terminalFocus', args: { text: '\\\r\n' } }, - { key: 'ctrl+enter', command: 'workbench.action.terminal.sendSequence', when: 'terminalFocus', args: { text: '\\\r\n' } }, - { key: 'cmd+enter', command: 'workbench.action.terminal.sendSequence', when: 'terminalFocus', args: { text: '\\\r\n' } }, - { key: 'cmd+z', command: 'workbench.action.terminal.sendSequence', when: 'terminalFocus', args: { text: '\u001b[122;9u' } }, - { key: 'shift+cmd+z', command: 'workbench.action.terminal.sendSequence', when: 'terminalFocus', args: { text: '\u001b[122;10u' } } + { + key: 'shift+enter', + command: 'workbench.action.terminal.sendSequence', + when: 'terminalFocus', + args: { text: '\\\r\n' } + }, + { + key: 'ctrl+enter', + command: 'workbench.action.terminal.sendSequence', + when: 'terminalFocus', + args: { text: '\\\r\n' } + }, + { + key: 'cmd+enter', + command: 'workbench.action.terminal.sendSequence', + when: 'terminalFocus', + args: { text: '\\\r\n' } + }, + { + key: 'cmd+z', + command: 'workbench.action.terminal.sendSequence', + when: 'terminalFocus', + args: { text: '\u001b[122;9u' } + }, + { + key: 'shift+cmd+z', + command: 'workbench.action.terminal.sendSequence', + when: 'terminalFocus', + args: { text: '\u001b[122;10u' } + } ]) ) - const hints = await terminalParityHints( - { TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv, - { fileOps: { readFile }, homeDir: '/tmp/fake-home' } - ) + const hints = await terminalParityHints({ TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv, { + fileOps: { readFile }, + homeDir: '/tmp/fake-home' + }) + expect(hints.some(h => h.key === 'ide-setup')).toBe(false) }) }) diff --git a/ui-tui/src/__tests__/terminalSetup.test.ts b/ui-tui/src/__tests__/terminalSetup.test.ts index 7a5a31cd3..de23176f2 100644 --- a/ui-tui/src/__tests__/terminalSetup.test.ts +++ b/ui-tui/src/__tests__/terminalSetup.test.ts @@ -21,10 +21,17 @@ describe('terminalSetup helpers', () => { expect(getVSCodeStyleConfigDir('Code', 'darwin', {} as NodeJS.ProcessEnv, '/home/me')).toBe( '/home/me/Library/Application Support/Code/User' ) - expect(getVSCodeStyleConfigDir('Code', 'linux', {} as NodeJS.ProcessEnv, '/home/me')).toBe('/home/me/.config/Code/User') - expect(getVSCodeStyleConfigDir('Code', 'win32', { APPDATA: 'C:/Users/me/AppData/Roaming' } as NodeJS.ProcessEnv, '/home/me')).toBe( - 'C:/Users/me/AppData/Roaming/Code/User' + expect(getVSCodeStyleConfigDir('Code', 'linux', {} as NodeJS.ProcessEnv, '/home/me')).toBe( + '/home/me/.config/Code/User' ) + expect( + getVSCodeStyleConfigDir( + 'Code', + 'win32', + { APPDATA: 'C:/Users/me/AppData/Roaming' } as NodeJS.ProcessEnv, + '/home/me' + ) + ).toBe('C:/Users/me/AppData/Roaming/Code/User') }) it('strips line comments from keybindings JSON', () => { @@ -79,6 +86,7 @@ describe('configureTerminalKeybindings', () => { it('reports conflicts without overwriting existing bindings', async () => { const mkdir = vi.fn().mockResolvedValue(undefined) + const readFile = vi.fn().mockResolvedValue( JSON.stringify([ { @@ -89,6 +97,7 @@ describe('configureTerminalKeybindings', () => { } ]) ) + const writeFile = vi.fn().mockResolvedValue(undefined) const copyFile = vi.fn().mockResolvedValue(undefined) @@ -209,6 +218,7 @@ describe('configureTerminalKeybindings', () => { } ]) ) + await expect( shouldPromptForTerminalSetup({ env: { TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv, diff --git a/ui-tui/src/__tests__/useComposerState.test.ts b/ui-tui/src/__tests__/useComposerState.test.ts index 204ed6fe6..ff446153a 100644 --- a/ui-tui/src/__tests__/useComposerState.test.ts +++ b/ui-tui/src/__tests__/useComposerState.test.ts @@ -1,11 +1,15 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { looksLikeDroppedPath } from '../app/useComposerState.js' describe('looksLikeDroppedPath', () => { it('recognizes macOS screenshot temp paths and file URIs', () => { - expect(looksLikeDroppedPath('/var/folders/x/T/TemporaryItems/Screenshot\\ 2026-04-21\\ at\\ 1.04.43 PM.png')).toBe(true) - expect(looksLikeDroppedPath('file:///var/folders/x/T/TemporaryItems/Screenshot%202026-04-21%20at%201.04.43%20PM.png')).toBe(true) + expect(looksLikeDroppedPath('/var/folders/x/T/TemporaryItems/Screenshot\\ 2026-04-21\\ at\\ 1.04.43 PM.png')).toBe( + true + ) + expect( + looksLikeDroppedPath('file:///var/folders/x/T/TemporaryItems/Screenshot%202026-04-21%20at%201.04.43%20PM.png') + ).toBe(true) }) it('rejects normal multiline or plain text paste', () => { diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index bde9f9c59..3a254b293 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -240,22 +240,28 @@ export const coreCommands: SlashCommand[] = [ return ctx.transcript.sys('usage: /terminal-setup [auto|vscode|cursor|windsurf]') } - const runner = !target || target === 'auto' ? configureDetectedTerminalKeybindings() : configureTerminalKeybindings(target as 'cursor' | 'vscode' | 'windsurf') + const runner = + !target || target === 'auto' + ? configureDetectedTerminalKeybindings() + : configureTerminalKeybindings(target as 'cursor' | 'vscode' | 'windsurf') - void runner.then(result => { - if (ctx.stale()) { - return - } + void runner + .then(result => { + if (ctx.stale()) { + return + } - ctx.transcript.sys(result.message) - if (result.success && result.requiresRestart) { - ctx.transcript.sys('restart the IDE terminal for the new keybindings to take effect') - } - }).catch(error => { - if (!ctx.stale()) { - ctx.transcript.sys(`terminal setup failed: ${String(error)}`) - } - }) + ctx.transcript.sys(result.message) + + if (result.success && result.requiresRestart) { + ctx.transcript.sys('restart the IDE terminal for the new keybindings to take effect') + } + }) + .catch(error => { + if (!ctx.stale()) { + ctx.transcript.sys(`terminal setup failed: ${String(error)}`) + } + }) } }, diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index 080ed167f..5f17667f0 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -1,4 +1,4 @@ -import { introMsg, toTranscriptMessages, attachedImageNotice } from '../../../domain/messages.js' +import { attachedImageNotice, introMsg, toTranscriptMessages } from '../../../domain/messages.js' import type { BackgroundStartResponse, BtwStartResponse, diff --git a/ui-tui/src/app/useComposerState.ts b/ui-tui/src/app/useComposerState.ts index 9c52473f9..f229067ed 100644 --- a/ui-tui/src/app/useComposerState.ts +++ b/ui-tui/src/app/useComposerState.ts @@ -3,12 +3,13 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { useStdin } from '@hermes/ink' import { useStore } from '@nanostores/react' import { useCallback, useMemo, useState } from 'react' -import { useStdin } from '@hermes/ink' import type { PasteEvent } from '../components/textInput.js' import { LARGE_PASTE } from '../config/limits.js' +import type { ImageAttachResponse, InputDetectDropResponse } from '../gatewayTypes.js' import { useCompletion } from '../hooks/useCompletion.js' import { useInputHistory } from '../hooks/useInputHistory.js' import { useQueue } from '../hooks/useQueue.js' @@ -16,7 +17,6 @@ import { isUsableClipboardText, readClipboardText } from '../lib/clipboard.js' import { readOsc52Clipboard } from '../lib/osc52.js' import { isRemoteShellSession } from '../lib/terminalSetup.js' import { pasteTokenLabel, stripTrailingPasteNewlines } from '../lib/text.js' -import type { ImageAttachResponse, InputDetectDropResponse } from '../gatewayTypes.js' import type { MaybePromise, PasteSnippet, UseComposerStateOptions, UseComposerStateResult } from './interfaces.js' import { $isBlocked } from './overlayStore.js' @@ -79,8 +79,8 @@ export function looksLikeDroppedPath(text: string): boolean { trimmed.startsWith("'/") || trimmed.startsWith('"~') || trimmed.startsWith("'~") || - (/^[A-Za-z]:[/\\]/.test(trimmed)) || - (/^["'][A-Za-z]:[/\\]/.test(trimmed)) + /^[A-Za-z]:[/\\]/.test(trimmed) || + /^["'][A-Za-z]:[/\\]/.test(trimmed) ) { return true } @@ -90,13 +90,19 @@ export function looksLikeDroppedPath(text: string): boolean { // unnecessary RPC round-trips. if (trimmed.startsWith('/')) { const rest = trimmed.slice(1) + return rest.includes('/') || rest.includes('.') } return false } -export function useComposerState({ gw, onClipboardPaste, onImageAttached, submitRef }: UseComposerStateOptions): UseComposerStateResult { +export function useComposerState({ + gw, + onClipboardPaste, + onImageAttached, + submitRef +}: UseComposerStateOptions): UseComposerStateResult { const [input, setInput] = useState('') const [inputBuf, setInputBuf] = useState([]) const [pasteSnips, setPasteSnips] = useState([]) @@ -119,7 +125,12 @@ export function useComposerState({ gw, onClipboardPaste, onImageAttached, submit }, [historyDraftRef, setQueueEdit, setHistoryIdx]) const handleResolvedPaste = useCallback( - async ({ bracketed, cursor, text, value }: Omit): Promise => { + async ({ + bracketed, + cursor, + text, + value + }: Omit): Promise => { const cleanedText = stripTrailingPasteNewlines(text) if (!cleanedText || !/[^\n]/.test(cleanedText)) { @@ -131,6 +142,7 @@ export function useComposerState({ gw, onClipboardPaste, onImageAttached, submit } const sid = getUiState().sid + if (sid && looksLikeDroppedPath(cleanedText)) { try { const attached = await gw.request('image.attach', { @@ -141,6 +153,7 @@ export function useComposerState({ gw, onClipboardPaste, onImageAttached, submit if (attached?.name) { onImageAttached?.(attached) const remainder = attached.remainder?.trim() ?? '' + if (!remainder) { return { cursor, value } } @@ -198,20 +211,29 @@ export function useComposerState({ gw, onClipboardPaste, onImageAttached, submit ) const handleTextPaste = useCallback( - ({ bracketed, cursor, hotkey, text, value }: PasteEvent): MaybePromise => { + ({ + bracketed, + cursor, + hotkey, + text, + value + }: PasteEvent): MaybePromise => { if (hotkey) { const preferOsc52 = isRemoteShellSession(process.env) + const readPreferredText = preferOsc52 ? readOsc52Clipboard(querier).then(async osc52Text => { if (isUsableClipboardText(osc52Text)) { return osc52Text } + return readClipboardText() }) : readClipboardText().then(async clipText => { if (isUsableClipboardText(clipText)) { return clipText } + return readOsc52Clipboard(querier) }) @@ -221,6 +243,7 @@ export function useComposerState({ gw, onClipboardPaste, onImageAttached, submit } void onClipboardPaste(false) + return null }) } diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index be2e5379e..a2b8afb7c 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -7,7 +7,6 @@ import type { SudoRespondResponse, VoiceRecordResponse } from '../gatewayTypes.js' - import { isAction, isMac } from '../lib/platform.js' import { getInputSelection } from './inputSelectionStore.js' diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 0c4023a62..a415d3437 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -5,7 +5,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { STARTUP_RESUME_ID } from '../config/env.js' import { MAX_HISTORY, WHEEL_SCROLL_STEP } from '../config/limits.js' import { attachedImageNotice, imageTokenMeta } from '../domain/messages.js' -import { terminalParityHints } from '../lib/terminalParity.js' import { fmtCwdBranch } from '../domain/paths.js' import { type GatewayClient } from '../gatewayClient.js' import type { @@ -17,6 +16,7 @@ import type { import { useGitBranch } from '../hooks/useGitBranch.js' import { useVirtualHistory } from '../hooks/useVirtualHistory.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' +import { terminalParityHints } from '../lib/terminalParity.js' import { buildToolTrailLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js' import type { Msg, PanelSection, SlashCatalog } from '../types.js' diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index da5507e28..28f7b324e 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -28,8 +28,7 @@ function FaceTicker({ color, startedAt }: { color: string; startedAt?: null | nu return ( - {FACES[tick % FACES.length]} {VERBS[tick % VERBS.length]}… - {startedAt ? ` · ${fmtDuration(now - startedAt)}` : ''} + {FACES[tick % FACES.length]} {VERBS[tick % VERBS.length]}…{startedAt ? ` · ${fmtDuration(now - startedAt)}` : ''} ) } @@ -127,7 +126,11 @@ export function StatusRule({ {'─ '} - {busy ? : {status}} + {busy ? ( + + ) : ( + {status} + )} │ {model} {ctxLabel ? │ {ctxLabel} : null} {bar ? ( diff --git a/ui-tui/src/components/modelPicker.tsx b/ui-tui/src/components/modelPicker.tsx index 395ad4cca..1c618c58e 100644 --- a/ui-tui/src/components/modelPicker.tsx +++ b/ui-tui/src/components/modelPicker.tsx @@ -174,7 +174,9 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke Current model: {currentModel || '(unknown)'} - {provider?.warning ? `warning: ${provider.warning}` : ' '} + + {provider?.warning ? `warning: ${provider.warning}` : ' '} + {off > 0 ? ` ↑ ${off} more` : ' '} {Array.from({ length: VISIBLE }, (_, i) => { @@ -183,20 +185,22 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke return row ? ( {providerIdx === idx ? '▸ ' : ' '} {i + 1}. {row} ) : ( - + + {' '} + ) })} - - {off + VISIBLE < rows.length ? ` ↓ ${rows.length - off - VISIBLE} more` : ' '} - + {off + VISIBLE < rows.length ? ` ↓ ${rows.length - off - VISIBLE} more` : ' '} persist: {persistGlobal ? 'global' : 'session'} · g toggle ↑/↓ select · Enter choose · 1-9,0 quick · Esc cancel @@ -213,7 +217,9 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke {names[providerIdx] || '(unknown provider)'} - {provider?.warning ? `warning: ${provider.warning}` : ' '} + + {provider?.warning ? `warning: ${provider.warning}` : ' '} + {off > 0 ? ` ↑ ${off} more` : ' '} {Array.from({ length: VISIBLE }, (_, i) => { @@ -226,13 +232,17 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke no models listed for this provider ) : ( - + + {' '} + ) } return ( {modelIdx === idx ? '▸ ' : ' '} diff --git a/ui-tui/src/components/prompts.tsx b/ui-tui/src/components/prompts.tsx index 967634d41..1be68da17 100644 --- a/ui-tui/src/components/prompts.tsx +++ b/ui-tui/src/components/prompts.tsx @@ -1,11 +1,11 @@ import { Box, Text, useInput } from '@hermes/ink' import { useState } from 'react' +import { isMac } from '../lib/platform.js' import type { Theme } from '../theme.js' import type { ApprovalReq, ClarifyReq, ConfirmReq } from '../types.js' import { TextInput } from './textInput.js' -import { isMac } from '../lib/platform.js' const OPTS = ['once', 'session', 'always', 'deny'] as const const LABELS = { always: 'Always allow', deny: 'Deny', once: 'Allow once', session: 'Allow this session' } as const @@ -64,8 +64,8 @@ export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) { {OPTS.map((o, i) => ( - {sel === i ? '▸ ' : ' '} - + + {sel === i ? '▸ ' : ' '} {i + 1}. {LABELS[o]} @@ -130,7 +130,8 @@ export function ClarifyPrompt({ cols = 80, onAnswer, onCancel, req, t }: Clarify - Enter send · Esc {choices.length ? 'back' : 'cancel'} · {isMac ? 'Cmd+C copy · Cmd+V paste · Ctrl+C cancel' : 'Ctrl+C cancel'} + Enter send · Esc {choices.length ? 'back' : 'cancel'} ·{' '} + {isMac ? 'Cmd+C copy · Cmd+V paste · Ctrl+C cancel' : 'Ctrl+C cancel'} ) @@ -142,8 +143,8 @@ export function ClarifyPrompt({ cols = 80, onAnswer, onCancel, req, t }: Clarify {[...choices, 'Other (type your answer)'].map((c, i) => ( - {sel === i ? '▸ ' : ' '} - + + {sel === i ? '▸ ' : ' '} {i + 1}. {c} diff --git a/ui-tui/src/components/sessionPicker.tsx b/ui-tui/src/components/sessionPicker.tsx index 905fa707e..51bd451c3 100644 --- a/ui-tui/src/components/sessionPicker.tsx +++ b/ui-tui/src/components/sessionPicker.tsx @@ -108,24 +108,29 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps) {items.slice(off, off + VISIBLE).map((s, vi) => { const i = off + vi + const selected = sel === i return ( - {sel === i ? '▸ ' : ' '} + + {selected ? '▸ ' : ' '} + - + {String(i + 1).padStart(2)}. [{s.id}] - + ({s.message_count} msgs, {age(s.started_at)}, {s.source || 'tui'}) - {s.title || s.preview || '(untitled)'} + + {s.title || s.preview || '(untitled)'} + ) })} diff --git a/ui-tui/src/components/skillsHub.tsx b/ui-tui/src/components/skillsHub.tsx index 877bb0ef3..48790eff6 100644 --- a/ui-tui/src/components/skillsHub.tsx +++ b/ui-tui/src/components/skillsHub.tsx @@ -219,7 +219,12 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { const idx = off + i return ( - + {catIdx === idx ? '▸ ' : ' '} {i + 1}. {row} @@ -249,7 +254,12 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { const idx = off + i return ( - + {skillIdx === idx ? '▸ ' : ' '} {i + 1}. {row} diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 78693aa2d..25da66acc 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -277,8 +277,9 @@ function useFwdDelete(active: boolean) { type PasteResult = { cursor: number; value: string } | null -const isPasteResultPromise = (value: PasteResult | Promise | null | undefined): value is Promise => - !!value && typeof (value as PromiseLike).then === 'function' +const isPasteResultPromise = ( + value: PasteResult | Promise | null | undefined +): value is Promise => !!value && typeof (value as PromiseLike).then === 'function' export function TextInput({ columns = 80, @@ -522,9 +523,11 @@ export function TextInput({ } const range = selRange() + const nextValue = range ? vRef.current.slice(0, range.start) + cleaned + vRef.current.slice(range.end) : vRef.current.slice(0, curRef.current) + cleaned + vRef.current.slice(curRef.current) + const nextCursor = range ? range.start + cleaned.length : curRef.current + cleaned.length commit(nextValue, nextCursor) @@ -778,7 +781,9 @@ interface TextInputProps { focus?: boolean mask?: string onChange: (v: string) => void - onPaste?: (e: PasteEvent) => { cursor: number; value: string } | Promise<{ cursor: number; value: string } | null> | null + onPaste?: ( + e: PasteEvent + ) => { cursor: number; value: string } | Promise<{ cursor: number; value: string } | null> | null onSubmit?: (v: string) => void placeholder?: string value: string diff --git a/ui-tui/src/content/hotkeys.ts b/ui-tui/src/content/hotkeys.ts index 902b86459..b0938e18e 100644 --- a/ui-tui/src/content/hotkeys.ts +++ b/ui-tui/src/content/hotkeys.ts @@ -4,14 +4,12 @@ const action = isMac ? 'Cmd' : 'Ctrl' const paste = isMac ? 'Cmd' : 'Alt' export const HOTKEYS: [string, string][] = [ - ...( - isMac - ? ([ - ['Cmd+C', 'copy selection'], - ['Ctrl+C', 'interrupt / clear draft / exit'] - ] as [string, string][]) - : ([['Ctrl+C', 'copy selection / interrupt / clear draft / exit']] as [string, string][]) - ), + ...(isMac + ? ([ + ['Cmd+C', 'copy selection'], + ['Ctrl+C', 'interrupt / clear draft / exit'] + ] as [string, string][]) + : ([['Ctrl+C', 'copy selection / interrupt / clear draft / exit']] as [string, string][])), [action + '+D', 'exit'], [action + '+G', 'open $EDITOR for prompt'], [action + '+L', 'new session (clear)'], diff --git a/ui-tui/src/lib/clipboard.ts b/ui-tui/src/lib/clipboard.ts index 82ce8b34c..23e03e5fe 100644 --- a/ui-tui/src/lib/clipboard.ts +++ b/ui-tui/src/lib/clipboard.ts @@ -17,9 +17,11 @@ export function isUsableClipboardText(text: null | string): text is string { } let suspicious = 0 + for (const ch of text) { const code = ch.charCodeAt(0) const isControl = code < 0x20 && ch !== '\n' && ch !== '\r' && ch !== '\t' + if (isControl || ch === '\ufffd') { suspicious += 1 } @@ -28,7 +30,10 @@ export function isUsableClipboardText(text: null | string): text is string { return suspicious <= Math.max(2, Math.floor(text.length * 0.02)) } -function readClipboardCommands(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): Array<{ args: readonly string[]; cmd: string }> { +function readClipboardCommands( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv +): Array<{ args: readonly string[]; cmd: string }> { if (platform === 'darwin') { return [{ cmd: 'pbpaste', args: [] }] } diff --git a/ui-tui/src/lib/osc52.ts b/ui-tui/src/lib/osc52.ts index 5f5a5a8ae..aaeecf4c9 100644 --- a/ui-tui/src/lib/osc52.ts +++ b/ui-tui/src/lib/osc52.ts @@ -54,6 +54,7 @@ export async function readOsc52Clipboard(querier: null | OscQuerier, timeoutMs = } const timeout = new Promise(resolve => setTimeout(resolve, timeoutMs)) + const query = querier.send({ request: buildOsc52ClipboardQuery(), match: (r: unknown): r is OscResponse => { diff --git a/ui-tui/src/lib/platform.ts b/ui-tui/src/lib/platform.ts index eb2e2e10c..f4a524733 100644 --- a/ui-tui/src/lib/platform.ts +++ b/ui-tui/src/lib/platform.ts @@ -13,7 +13,7 @@ export const isMac = process.platform === 'darwin' /** True when the platform action-modifier is pressed (Cmd on macOS, Ctrl elsewhere). */ export const isActionMod = (key: { ctrl: boolean; meta: boolean; super?: boolean }): boolean => - (isMac ? key.meta || key.super === true : key.ctrl) + isMac ? key.meta || key.super === true : key.ctrl /** * Some macOS terminals rewrite Cmd navigation/deletion into readline control keys. diff --git a/ui-tui/src/lib/terminalParity.ts b/ui-tui/src/lib/terminalParity.ts index 72a511a05..9010dedfc 100644 --- a/ui-tui/src/lib/terminalParity.ts +++ b/ui-tui/src/lib/terminalParity.ts @@ -1,4 +1,9 @@ -import { detectVSCodeLikeTerminal, isRemoteShellSession, shouldPromptForTerminalSetup, type FileOps } from './terminalSetup.js' +import { + detectVSCodeLikeTerminal, + type FileOps, + isRemoteShellSession, + shouldPromptForTerminalSetup +} from './terminalSetup.js' export type MacTerminalHint = { key: string @@ -31,7 +36,10 @@ export async function terminalParityHints( const ctx = detectMacTerminalContext(env) const hints: MacTerminalHint[] = [] - if (ctx.vscodeLike && (await shouldPromptForTerminalSetup({ env, fileOps: options?.fileOps, homeDir: options?.homeDir }))) { + if ( + ctx.vscodeLike && + (await shouldPromptForTerminalSetup({ env, fileOps: options?.fileOps, homeDir: options?.homeDir })) + ) { hints.push({ key: 'ide-setup', tone: 'info', @@ -43,7 +51,8 @@ export async function terminalParityHints( hints.push({ key: 'apple-terminal', tone: 'warn', - message: 'Apple Terminal detected · use /paste for image-only clipboard fallback, and try Ctrl+A / Ctrl+E / Ctrl+U if Cmd+←/→/⌫ gets rewritten' + message: + 'Apple Terminal detected · use /paste for image-only clipboard fallback, and try Ctrl+A / Ctrl+E / Ctrl+U if Cmd+←/→/⌫ gets rewritten' }) } @@ -51,7 +60,8 @@ export async function terminalParityHints( hints.push({ key: 'tmux', tone: 'warn', - message: 'tmux detected · clipboard copy/paste uses passthrough when available; allow-passthrough improves OSC52 reliability' + message: + 'tmux detected · clipboard copy/paste uses passthrough when available; allow-passthrough improves OSC52 reliability' }) } @@ -59,7 +69,8 @@ export async function terminalParityHints( hints.push({ key: 'remote', tone: 'warn', - message: 'SSH session detected · text clipboard can bridge via OSC52, but image clipboard and local screenshot paths still depend on the machine running Hermes' + message: + 'SSH session detected · text clipboard can bridge via OSC52, but image clipboard and local screenshot paths still depend on the machine running Hermes' }) } diff --git a/ui-tui/src/lib/terminalSetup.ts b/ui-tui/src/lib/terminalSetup.ts index 32cf62c39..3c17734c6 100644 --- a/ui-tui/src/lib/terminalSetup.ts +++ b/ui-tui/src/lib/terminalSetup.ts @@ -26,6 +26,7 @@ export type TerminalSetupResult = { const DEFAULT_FILE_OPS: FileOps = { copyFile, mkdir, readFile, writeFile } const MULTILINE_SEQUENCE = '\\\r\n' + const TERMINAL_META: Record = { vscode: { appName: 'Code', label: 'VS Code' }, cursor: { appName: 'Cursor', label: 'Cursor' }, @@ -99,18 +100,22 @@ export function stripJsonComments(content: string): string { // String literal — copy as-is, including any comment-like chars inside if (ch === '"') { let j = i + 1 + while (j < len) { if (content[j] === '\\') { j += 2 // skip escaped char } else if (content[j] === '"') { j++ + break } else { j++ } } + result += content.slice(i, j) i = j + continue } @@ -118,6 +123,7 @@ export function stripJsonComments(content: string): string { if (ch === '/' && content[i + 1] === '/') { const eol = content.indexOf('\n', i) i = eol === -1 ? len : eol + continue } @@ -125,6 +131,7 @@ export function stripJsonComments(content: string): string { if (ch === '/' && content[i + 1] === '*') { const end = content.indexOf('*/', i + 2) i = end === -1 ? len : end + 2 + continue } @@ -208,19 +215,23 @@ export async function configureTerminalKeybindings( let keybindings: unknown[] = [] let hasExistingFile = false + try { const content = await ops.readFile(keybindingsFile, 'utf8') hasExistingFile = true const parsed: unknown = JSON.parse(stripJsonComments(content)) + if (!Array.isArray(parsed)) { return { success: false, message: `${meta.label} keybindings.json is not a JSON array: ${keybindingsFile}` } } + keybindings = parsed } catch (error) { const code = (error as NodeJS.ErrnoException | undefined)?.code + if (code !== 'ENOENT') { return { success: false, @@ -230,7 +241,9 @@ export async function configureTerminalKeybindings( } const conflicts = TARGET_BINDINGS.filter(target => - keybindings.some(existing => isKeybinding(existing) && existing.key === target.key && !sameBinding(existing, target)) + keybindings.some( + existing => isKeybinding(existing) && existing.key === target.key && !sameBinding(existing, target) + ) ) if (conflicts.length) { @@ -242,8 +255,10 @@ export async function configureTerminalKeybindings( } let added = 0 + for (const target of TARGET_BINDINGS.slice().reverse()) { const exists = keybindings.some(existing => isKeybinding(existing) && sameBinding(existing, target)) + if (!exists) { keybindings.unshift(target) added += 1 @@ -320,11 +335,14 @@ export async function shouldPromptForTerminalSetup(options?: { try { const content = await ops.readFile(join(configDir, 'keybindings.json'), 'utf8') const parsed: unknown = JSON.parse(stripJsonComments(content)) + if (!Array.isArray(parsed)) { return true } - return TARGET_BINDINGS.some(target => !parsed.some(existing => isKeybinding(existing) && sameBinding(existing, target))) + return TARGET_BINDINGS.some( + target => !parsed.some(existing => isKeybinding(existing) && sameBinding(existing, target)) + ) } catch { return true } From 4ada76b6ede68afb982540adec2c451f640c16e0 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 21 Apr 2026 13:49:52 -0500 Subject: [PATCH 3/4] fix(tui): truncate long picker rows so the height stays stable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A6 added a fixed-height grid (Array.from({length: VISIBLE})), but the row itself had no wrap prop so Ink defaulted to wrap="wrap". A sufficiently long model or provider name would wrap to a second visual line and bounce the overall picker height right back — which is exactly what reappeared during the TUI v2 blitz retest on /model. Pin every picker row (and the empty-state / padding rows) to wrap="truncate-end" so each slot is guaranteed one line. Applies across modelPicker, sessionPicker, and skillsHub. --- ui-tui/src/components/modelPicker.tsx | 63 ++++++++++++++++++------- ui-tui/src/components/sessionPicker.tsx | 11 +++-- ui-tui/src/components/skillsHub.tsx | 19 +++++--- 3 files changed, 66 insertions(+), 27 deletions(-) diff --git a/ui-tui/src/components/modelPicker.tsx b/ui-tui/src/components/modelPicker.tsx index 1c618c58e..7927f3b73 100644 --- a/ui-tui/src/components/modelPicker.tsx +++ b/ui-tui/src/components/modelPicker.tsx @@ -1,4 +1,4 @@ -import { Box, Text, useInput } from '@hermes/ink' +import { Box, Text, useInput, useStdout } from '@hermes/ink' import { useEffect, useMemo, useState } from 'react' import { providerDisplayNames } from '../domain/providers.js' @@ -8,6 +8,8 @@ import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' import type { Theme } from '../theme.js' const VISIBLE = 12 +const MIN_WIDTH = 40 +const MAX_WIDTH = 90 const pageOffset = (count: number, sel: number) => Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), count - VISIBLE)) @@ -27,6 +29,13 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke const [modelIdx, setModelIdx] = useState(0) const [stage, setStage] = useState<'model' | 'provider'>('provider') + const { stdout } = useStdout() + // Pin the picker to a stable width so the FloatBox parent (which shrinks- + // to-fit with alignSelf="flex-start") doesn't resize as long provider / + // model names scroll into view, and so `wrap="truncate-end"` on each row + // has an actual constraint to truncate against. + const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6)) + useEffect(() => { gw.request('model.options', sessionId ? { session_id: sessionId } : {}) .then(raw => { @@ -168,16 +177,20 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke const { items, off } = visibleItems(rows, providerIdx) return ( - - + + Select Provider - Current model: {currentModel || '(unknown)'} + + Current model: {currentModel || '(unknown)'} + {provider?.warning ? `warning: ${provider.warning}` : ' '} - {off > 0 ? ` ↑ ${off} more` : ' '} + + {off > 0 ? ` ↑ ${off} more` : ' '} + {Array.from({ length: VISIBLE }, (_, i) => { const row = items[i] @@ -189,21 +202,28 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke color={providerIdx === idx ? t.color.amber : t.color.dim} inverse={providerIdx === idx} key={providers[idx]?.slug ?? `row-${idx}`} + wrap="truncate-end" > {providerIdx === idx ? '▸ ' : ' '} {i + 1}. {row} ) : ( - + {' '} ) })} - {off + VISIBLE < rows.length ? ` ↓ ${rows.length - off - VISIBLE} more` : ' '} + + {off + VISIBLE < rows.length ? ` ↓ ${rows.length - off - VISIBLE} more` : ' '} + - persist: {persistGlobal ? 'global' : 'session'} · g toggle - ↑/↓ select · Enter choose · 1-9,0 quick · Esc cancel + + persist: {persistGlobal ? 'global' : 'session'} · g toggle + + + ↑/↓ select · Enter choose · 1-9,0 quick · Esc cancel + ) } @@ -211,16 +231,20 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke const { items, off } = visibleItems(models, modelIdx) return ( - - + + Select Model - {names[providerIdx] || '(unknown provider)'} + + {names[providerIdx] || '(unknown provider)'} + {provider?.warning ? `warning: ${provider.warning}` : ' '} - {off > 0 ? ` ↑ ${off} more` : ' '} + + {off > 0 ? ` ↑ ${off} more` : ' '} + {Array.from({ length: VISIBLE }, (_, i) => { const row = items[i] @@ -228,11 +252,11 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke if (!row) { return !models.length && i === 0 ? ( - + no models listed for this provider ) : ( - + {' '} ) @@ -244,6 +268,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke color={modelIdx === idx ? t.color.amber : t.color.dim} inverse={modelIdx === idx} key={`${provider?.slug ?? 'prov'}:${idx}:${row}`} + wrap="truncate-end" > {modelIdx === idx ? '▸ ' : ' '} {i + 1}. {row} @@ -251,12 +276,14 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke ) })} - + {off + VISIBLE < models.length ? ` ↓ ${models.length - off - VISIBLE} more` : ' '} - persist: {persistGlobal ? 'global' : 'session'} · g toggle - + + persist: {persistGlobal ? 'global' : 'session'} · g toggle + + {models.length ? '↑/↓ select · Enter switch · 1-9,0 quick · Esc back' : 'Enter/Esc back'} diff --git a/ui-tui/src/components/sessionPicker.tsx b/ui-tui/src/components/sessionPicker.tsx index 51bd451c3..c84078239 100644 --- a/ui-tui/src/components/sessionPicker.tsx +++ b/ui-tui/src/components/sessionPicker.tsx @@ -1,4 +1,4 @@ -import { Box, Text, useInput } from '@hermes/ink' +import { Box, Text, useInput, useStdout } from '@hermes/ink' import { useEffect, useState } from 'react' import type { GatewayClient } from '../gatewayClient.js' @@ -7,6 +7,8 @@ import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' import type { Theme } from '../theme.js' const VISIBLE = 15 +const MIN_WIDTH = 60 +const MAX_WIDTH = 120 const age = (ts: number) => { const d = (Date.now() / 1000 - ts) / 86400 @@ -28,6 +30,9 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps) const [sel, setSel] = useState(0) const [loading, setLoading] = useState(true) + const { stdout } = useStdout() + const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6)) + useEffect(() => { gw.request('session.list', { limit: 20 }) .then(raw => { @@ -99,7 +104,7 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps) const off = Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), items.length - VISIBLE)) return ( - + Resume Session @@ -128,7 +133,7 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps) - + {s.title || s.preview || '(untitled)'} diff --git a/ui-tui/src/components/skillsHub.tsx b/ui-tui/src/components/skillsHub.tsx index 48790eff6..1bff92c0c 100644 --- a/ui-tui/src/components/skillsHub.tsx +++ b/ui-tui/src/components/skillsHub.tsx @@ -1,4 +1,4 @@ -import { Box, Text, useInput } from '@hermes/ink' +import { Box, Text, useInput, useStdout } from '@hermes/ink' import { useEffect, useState } from 'react' import type { GatewayClient } from '../gatewayClient.js' @@ -6,6 +6,8 @@ import { rpcErrorMessage } from '../lib/rpc.js' import type { Theme } from '../theme.js' const VISIBLE = 12 +const MIN_WIDTH = 40 +const MAX_WIDTH = 90 const pageOffset = (count: number, sel: number) => Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), count - VISIBLE)) @@ -26,6 +28,9 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { const [err, setErr] = useState('') const [loading, setLoading] = useState(true) + const { stdout } = useStdout() + const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6)) + useEffect(() => { gw.request<{ skills?: Record }>('skills.manage', { action: 'list' }) .then(r => { @@ -186,7 +191,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { if (err && stage === 'category') { return ( - + error: {err} Esc to cancel @@ -195,7 +200,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { if (!cats.length) { return ( - + no skills available Esc to cancel @@ -207,7 +212,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { const { items, off } = visibleItems(rows, catIdx) return ( - + Skills Hub @@ -224,6 +229,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { color={catIdx === idx ? t.color.amber : t.color.dim} inverse={catIdx === idx} key={row} + wrap="truncate-end" > {catIdx === idx ? '▸ ' : ' '} {i + 1}. {row} @@ -241,7 +247,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { const { items, off } = visibleItems(skills, skillIdx) return ( - + {selectedCat} @@ -259,6 +265,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { color={skillIdx === idx ? t.color.amber : t.color.dim} inverse={skillIdx === idx} key={row} + wrap="truncate-end" > {skillIdx === idx ? '▸ ' : ' '} {i + 1}. {row} @@ -275,7 +282,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { } return ( - + {info?.name ?? skillName} From 34f24daa8d8aa51a1edb0402bbe8e3a5e10de5fb Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 21 Apr 2026 14:19:05 -0500 Subject: [PATCH 4/4] fix(tui): stabilize slash-completion dropdown height MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The completion popup (e.g. typing `/model`) grew from 8 rows at compIdx=0 up to 16 rows at compIdx≥8 — the slice end was `compIdx + 8` so every arrow-down added another rendered row until the window filled. Reported during TUI v2 retest: "as i scroll and more options appear, for some reason more options appear and it expands the height". Fixed viewport (`COMPLETION_WINDOW = 16`) centered on compIdx, clamped so it never slides past the array bounds. Renders exactly `min(WINDOW, completions.length)` rows every frame. --- ui-tui/src/components/appOverlays.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx index 844996af3..0d08c5897 100644 --- a/ui-tui/src/components/appOverlays.tsx +++ b/ui-tui/src/components/appOverlays.tsx @@ -13,6 +13,8 @@ import { ApprovalPrompt, ClarifyPrompt, ConfirmPrompt } from './prompts.js' import { SessionPicker } from './sessionPicker.js' import { SkillsHub } from './skillsHub.js' +const COMPLETION_WINDOW = 16 + export function PromptZone({ cols, onApprovalChoice, @@ -106,7 +108,12 @@ export function FloatingOverlays({ return null } - const start = Math.max(0, compIdx - 8) + // Fixed viewport centered on compIdx — previously the slice end was + // compIdx + 8 so the dropdown grew from 8 rows to 16 as the user scrolled + // down, bouncing the height on every keystroke. + const viewportSize = Math.min(COMPLETION_WINDOW, completions.length) + + const start = Math.max(0, Math.min(compIdx - Math.floor(COMPLETION_WINDOW / 2), completions.length - viewportSize)) return ( @@ -168,7 +175,7 @@ export function FloatingOverlays({ {!!completions.length && ( - {completions.slice(start, compIdx + 8).map((item, i) => { + {completions.slice(start, start + viewportSize).map((item, i) => { const active = start + i === compIdx return (