From 4909b94f9955f5e66b48eae38b85598472d5623f Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Apr 2026 12:23:56 -0500 Subject: [PATCH 1/4] fix(tui): align Ctrl+L and /model with classic CLI semantics Make Ctrl+L non-destructive by redrawing the current screen state instead of starting a new session, and stop auto-appending --global for typed /model commands so session scope remains the default unless explicitly requested. --- ui-tui/src/__tests__/constants.test.ts | 5 +++++ ui-tui/src/__tests__/createSlashHandler.test.ts | 4 ++-- ui-tui/src/app/slash/commands/session.ts | 10 +--------- ui-tui/src/app/useInputHandlers.ts | 9 ++------- ui-tui/src/content/hotkeys.ts | 2 +- 5 files changed, 11 insertions(+), 19 deletions(-) diff --git a/ui-tui/src/__tests__/constants.test.ts b/ui-tui/src/__tests__/constants.test.ts index d069d24c2d..318ae71421 100644 --- a/ui-tui/src/__tests__/constants.test.ts +++ b/ui-tui/src/__tests__/constants.test.ts @@ -26,6 +26,11 @@ describe('constants', () => { }) }) + it('documents Ctrl/Cmd+L as non-destructive redraw', () => { + expect(HOTKEYS.some(([, d]) => d === 'redraw / repaint')).toBe(true) + expect(HOTKEYS.some(([, d]) => d.includes('new session'))).toBe(false) + }) + it('TOOL_VERBS maps known tools (verb-only, no emoji)', () => { expect(TOOL_VERBS.terminal).toBe('terminal') expect(TOOL_VERBS.read_file).toBe('reading') diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index dba3548712..005ff7387f 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -26,7 +26,7 @@ describe('createSlashHandler', () => { expect(ctx.gateway.gw.request).not.toHaveBeenCalled() }) - it('persists typed /model switches by default', async () => { + it('keeps typed /model switches session-scoped by default', async () => { patchUiState({ sid: 'sid-abc' }) const ctx = buildCtx({ @@ -40,7 +40,7 @@ describe('createSlashHandler', () => { expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', { key: 'model', session_id: 'sid-abc', - value: 'x-model --global' + value: 'x-model' }) }) diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index a31a4cbe43..e4e2d71c4f 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -16,17 +16,9 @@ import { patchOverlayState } from '../../overlayStore.js' import { patchUiState } from '../../uiStore.js' import type { SlashCommand } from '../types.js' -const GLOBAL_MODEL_FLAG_RE = /(?:^|\s)--global(?:\s|$)/ - const TUI_SESSION_MODEL_RE = new RegExp(`(?:^|\\s)${TUI_SESSION_MODEL_FLAG}(?:\\s|$)`) const TUI_SESSION_STRIP_RE = new RegExp(`\\s*${TUI_SESSION_MODEL_FLAG}\\b\\s*`, 'g') -const persistedModelArg = (arg: string) => { - const trimmed = arg.trim() - - return !trimmed || GLOBAL_MODEL_FLAG_RE.test(trimmed) ? trimmed : `${trimmed} --global` -} - const stripTuiSessionFlag = (trimmed: string) => trimmed.replace(TUI_SESSION_STRIP_RE, ' ').replace(/\s+/g, ' ').trim() @@ -41,7 +33,7 @@ const modelValueForConfigSet = (arg: string) => { return stripTuiSessionFlag(trimmed) } - return persistedModelArg(trimmed) + return trimmed } export const sessionCommands: SlashCommand[] = [ diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 0441c1d2c7..7843fc6288 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -379,13 +379,8 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { } if (isAction(key, ch, 'l')) { - if (actions.guardBusySessionSwitch()) { - return - } - - patchUiState({ status: 'forging session…' }) - - return actions.newSession() + clearSelection() + return patchUiState(state => ({ ...state })) } if (isVoiceToggleKey(key, ch)) { diff --git a/ui-tui/src/content/hotkeys.ts b/ui-tui/src/content/hotkeys.ts index 9a079fd2c6..50c293acd8 100644 --- a/ui-tui/src/content/hotkeys.ts +++ b/ui-tui/src/content/hotkeys.ts @@ -19,7 +19,7 @@ export const HOTKEYS: [string, string][] = [ ...copyHotkeys, [action + '+D', 'exit'], [action + '+G / Alt+G', 'open $EDITOR (Alt+G fallback for VSCode/Cursor)'], - [action + '+L', 'new session (clear)'], + [action + '+L', 'redraw / repaint'], [paste + '+V / /paste', 'paste text; /paste attaches clipboard image'], ['Tab', 'apply completion'], ['↑/↓', 'completions / queue edit / history'], From 17029a64e88c2119c111a834e4f96caab6eb041f Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Apr 2026 12:25:27 -0500 Subject: [PATCH 2/4] chore(ui-tui): apply npm run fix formatting pass Run ui-tui lint autofix + prettier and commit the resulting formatting-only changes for the keybinding/model parity branch. --- ui-tui/src/__tests__/createSlashHandler.test.ts | 6 ++---- ui-tui/src/app/slash/commands/core.ts | 2 +- ui-tui/src/app/slash/commands/session.ts | 5 ++--- ui-tui/src/app/useInputHandlers.ts | 1 + ui-tui/src/components/modelPicker.tsx | 4 +--- 5 files changed, 7 insertions(+), 11 deletions(-) diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index 005ff7387f..e2af401fd6 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -1,9 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { createSlashHandler } from '../app/createSlashHandler.js' -import { TUI_SESSION_MODEL_FLAG } from '../domain/slash.js' import { getOverlayState, resetOverlayState } from '../app/overlayStore.js' import { getUiState, patchUiState, resetUiState } from '../app/uiStore.js' +import { TUI_SESSION_MODEL_FLAG } from '../domain/slash.js' describe('createSlashHandler', () => { beforeEach(() => { @@ -55,9 +55,7 @@ describe('createSlashHandler', () => { }) expect( - createSlashHandler(ctx)( - `/model anthropic/claude-sonnet-4.6 --provider openrouter ${TUI_SESSION_MODEL_FLAG}` - ) + createSlashHandler(ctx)(`/model anthropic/claude-sonnet-4.6 --provider openrouter ${TUI_SESSION_MODEL_FLAG}`) ).toBe(true) expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', { key: 'model', diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index 91f06bb570..2cad70b9a5 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -6,8 +6,8 @@ import type { ConfigGetValueResponse, ConfigSetResponse, SessionSaveResponse, - SessionTitleResponse, SessionSteerResponse, + SessionTitleResponse, SessionUndoResponse } from '../../../gatewayTypes.js' import { writeOsc52Clipboard } from '../../../lib/osc52.js' diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index e4e2d71c4f..38bc127b7b 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -1,4 +1,5 @@ import { attachedImageNotice, introMsg, toTranscriptMessages } from '../../../domain/messages.js' +import { TUI_SESSION_MODEL_FLAG } from '../../../domain/slash.js' import type { BackgroundStartResponse, ConfigGetValueResponse, @@ -10,7 +11,6 @@ import type { VoiceToggleResponse } from '../../../gatewayTypes.js' import { fmtK } from '../../../lib/text.js' -import { TUI_SESSION_MODEL_FLAG } from '../../../domain/slash.js' import type { PanelSection } from '../../../types.js' import { patchOverlayState } from '../../overlayStore.js' import { patchUiState } from '../../uiStore.js' @@ -19,8 +19,7 @@ import type { SlashCommand } from '../types.js' const TUI_SESSION_MODEL_RE = new RegExp(`(?:^|\\s)${TUI_SESSION_MODEL_FLAG}(?:\\s|$)`) const TUI_SESSION_STRIP_RE = new RegExp(`\\s*${TUI_SESSION_MODEL_FLAG}\\b\\s*`, 'g') -const stripTuiSessionFlag = (trimmed: string) => - trimmed.replace(TUI_SESSION_STRIP_RE, ' ').replace(/\s+/g, ' ').trim() +const stripTuiSessionFlag = (trimmed: string) => trimmed.replace(TUI_SESSION_STRIP_RE, ' ').replace(/\s+/g, ' ').trim() const modelValueForConfigSet = (arg: string) => { const trimmed = arg.trim() diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 7843fc6288..e8651cdd3f 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -380,6 +380,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { if (isAction(key, ch, 'l')) { clearSelection() + return patchUiState(state => ({ ...state })) } diff --git a/ui-tui/src/components/modelPicker.tsx b/ui-tui/src/components/modelPicker.tsx index b5882a1352..8164147fa8 100644 --- a/ui-tui/src/components/modelPicker.tsx +++ b/ui-tui/src/components/modelPicker.tsx @@ -112,9 +112,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke const model = models[modelIdx] if (provider && model) { - onSelect( - `${model} --provider ${provider.slug}${persistGlobal ? ' --global' : ` ${TUI_SESSION_MODEL_FLAG}`}` - ) + onSelect(`${model} --provider ${provider.slug}${persistGlobal ? ' --global' : ` ${TUI_SESSION_MODEL_FLAG}`}`) } else { setStage('provider') } From da6f8449a5a69e236372ca20d31297c37d56caf2 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Apr 2026 12:30:40 -0500 Subject: [PATCH 3/4] test(tui): tighten redraw hotkey review follow-ups Use explicit repaint patch semantics for Ctrl/Cmd+L and narrow the hotkey assertion to the actual +L entry so unrelated descriptions do not cause false failures. --- ui-tui/src/__tests__/constants.test.ts | 5 +++-- ui-tui/src/app/useInputHandlers.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/ui-tui/src/__tests__/constants.test.ts b/ui-tui/src/__tests__/constants.test.ts index 318ae71421..5f95078787 100644 --- a/ui-tui/src/__tests__/constants.test.ts +++ b/ui-tui/src/__tests__/constants.test.ts @@ -27,8 +27,9 @@ describe('constants', () => { }) it('documents Ctrl/Cmd+L as non-destructive redraw', () => { - expect(HOTKEYS.some(([, d]) => d === 'redraw / repaint')).toBe(true) - expect(HOTKEYS.some(([, d]) => d.includes('new session'))).toBe(false) + const hotkey = HOTKEYS.find(([k]) => k.endsWith('+L')) + expect(hotkey).toBeDefined() + expect(hotkey?.[1]).toBe('redraw / repaint') }) it('TOOL_VERBS maps known tools (verb-only, no emoji)', () => { diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index e8651cdd3f..fe143ee36a 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -381,7 +381,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { if (isAction(key, ch, 'l')) { clearSelection() - return patchUiState(state => ({ ...state })) + return patchUiState({}) } if (isVoiceToggleKey(key, ch)) { From b3e7a412e24e8a2ee97c28b511440808e161628c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Apr 2026 12:44:24 -0500 Subject: [PATCH 4/4] fix(tui): wire Ctrl+L to Ink forceRedraw path Expose a small forceRedraw API from @hermes/ink and use it for Ctrl/Cmd+L so the hotkey performs a real terminal clear + full repaint instead of a no-op state patch. --- ui-tui/packages/hermes-ink/index.d.ts | 2 +- ui-tui/packages/hermes-ink/src/entry-exports.ts | 2 +- ui-tui/packages/hermes-ink/src/ink/root.ts | 10 ++++++++++ ui-tui/src/app/useInputHandlers.ts | 8 ++++---- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/ui-tui/packages/hermes-ink/index.d.ts b/ui-tui/packages/hermes-ink/index.d.ts index 9375cb4b3a..92aeb0a4f2 100644 --- a/ui-tui/packages/hermes-ink/index.d.ts +++ b/ui-tui/packages/hermes-ink/index.d.ts @@ -30,7 +30,7 @@ export { useTerminalFocus } from './src/ink/hooks/use-terminal-focus.ts' export { useTerminalTitle } from './src/ink/hooks/use-terminal-title.ts' export { useTerminalViewport } from './src/ink/hooks/use-terminal-viewport.ts' export { default as measureElement } from './src/ink/measure-element.ts' -export { createRoot, default as render, renderSync } from './src/ink/root.ts' +export { createRoot, default as render, forceRedraw, renderSync } from './src/ink/root.ts' export type { Instance, RenderOptions, Root } from './src/ink/root.ts' export { stringWidth } from './src/ink/stringWidth.ts' export { default as TextInput, UncontrolledTextInput } from 'ink-text-input' diff --git a/ui-tui/packages/hermes-ink/src/entry-exports.ts b/ui-tui/packages/hermes-ink/src/entry-exports.ts index d56387dd5b..6bfb0e4955 100644 --- a/ui-tui/packages/hermes-ink/src/entry-exports.ts +++ b/ui-tui/packages/hermes-ink/src/entry-exports.ts @@ -23,7 +23,7 @@ export { useTerminalTitle } from './ink/hooks/use-terminal-title.js' export { useTerminalViewport } from './ink/hooks/use-terminal-viewport.js' export { default as measureElement } from './ink/measure-element.js' export { scrollFastPathStats, type ScrollFastPathStats } from './ink/render-node-to-output.js' -export { createRoot, default as render, renderSync } from './ink/root.js' +export { createRoot, default as render, forceRedraw, renderSync } from './ink/root.js' export { stringWidth } from './ink/stringWidth.js' export { isXtermJs } from './ink/terminal.js' export { default as TextInput, UncontrolledTextInput } from 'ink-text-input' diff --git a/ui-tui/packages/hermes-ink/src/ink/root.ts b/ui-tui/packages/hermes-ink/src/ink/root.ts index 27ace59a6b..fdfb97a6e7 100644 --- a/ui-tui/packages/hermes-ink/src/ink/root.ts +++ b/ui-tui/packages/hermes-ink/src/ink/root.ts @@ -73,6 +73,16 @@ export type Root = { waitUntilExit: () => Promise } +export const forceRedraw = (stdout: NodeJS.WriteStream = process.stdout): boolean => { + const instance = instances.get(stdout) + if (!instance) { + return false + } + + instance.forceRedraw() + return true +} + /** * Mount a component and render the output. */ diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index fe143ee36a..5aab1d1bf8 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -1,4 +1,4 @@ -import { useInput } from '@hermes/ink' +import { forceRedraw, useInput } from '@hermes/ink' import { useStore } from '@nanostores/react' import { useEffect, useRef } from 'react' @@ -18,7 +18,7 @@ import type { InputHandlerContext, InputHandlerResult } from './interfaces.js' import { $isBlocked, $overlayState, patchOverlayState } from './overlayStore.js' import { turnController } from './turnController.js' import { patchTurnState } from './turnStore.js' -import { getUiState, patchUiState } from './uiStore.js' +import { getUiState } from './uiStore.js' const isCtrl = (key: { ctrl: boolean }, ch: string, target: string) => key.ctrl && ch.toLowerCase() === target @@ -380,8 +380,8 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { if (isAction(key, ch, 'l')) { clearSelection() - - return patchUiState({}) + forceRedraw(terminal.stdout ?? process.stdout) + return } if (isVoiceToggleKey(key, ch)) {