Merge pull request #16657 from NousResearch/bb/tui-keybinding-model-parity
fix(tui): align Ctrl+L and /model default scope with classic CLI
This commit is contained in:
2
ui-tui/packages/hermes-ink/index.d.ts
vendored
2
ui-tui/packages/hermes-ink/index.d.ts
vendored
@@ -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 { useTerminalTitle } from './src/ink/hooks/use-terminal-title.ts'
|
||||||
export { useTerminalViewport } from './src/ink/hooks/use-terminal-viewport.ts'
|
export { useTerminalViewport } from './src/ink/hooks/use-terminal-viewport.ts'
|
||||||
export { default as measureElement } from './src/ink/measure-element.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 type { Instance, RenderOptions, Root } from './src/ink/root.ts'
|
||||||
export { stringWidth } from './src/ink/stringWidth.ts'
|
export { stringWidth } from './src/ink/stringWidth.ts'
|
||||||
export { default as TextInput, UncontrolledTextInput } from 'ink-text-input'
|
export { default as TextInput, UncontrolledTextInput } from 'ink-text-input'
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export { useTerminalTitle } from './ink/hooks/use-terminal-title.js'
|
|||||||
export { useTerminalViewport } from './ink/hooks/use-terminal-viewport.js'
|
export { useTerminalViewport } from './ink/hooks/use-terminal-viewport.js'
|
||||||
export { default as measureElement } from './ink/measure-element.js'
|
export { default as measureElement } from './ink/measure-element.js'
|
||||||
export { scrollFastPathStats, type ScrollFastPathStats } from './ink/render-node-to-output.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 { stringWidth } from './ink/stringWidth.js'
|
||||||
export { isXtermJs } from './ink/terminal.js'
|
export { isXtermJs } from './ink/terminal.js'
|
||||||
export { default as TextInput, UncontrolledTextInput } from 'ink-text-input'
|
export { default as TextInput, UncontrolledTextInput } from 'ink-text-input'
|
||||||
|
|||||||
@@ -73,6 +73,16 @@ export type Root = {
|
|||||||
waitUntilExit: () => Promise<void>
|
waitUntilExit: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
* Mount a component and render the output.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ describe('constants', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('documents Ctrl/Cmd+L as non-destructive redraw', () => {
|
||||||
|
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)', () => {
|
it('TOOL_VERBS maps known tools (verb-only, no emoji)', () => {
|
||||||
expect(TOOL_VERBS.terminal).toBe('terminal')
|
expect(TOOL_VERBS.terminal).toBe('terminal')
|
||||||
expect(TOOL_VERBS.read_file).toBe('reading')
|
expect(TOOL_VERBS.read_file).toBe('reading')
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import { createSlashHandler } from '../app/createSlashHandler.js'
|
import { createSlashHandler } from '../app/createSlashHandler.js'
|
||||||
import { TUI_SESSION_MODEL_FLAG } from '../domain/slash.js'
|
|
||||||
import { getOverlayState, resetOverlayState } from '../app/overlayStore.js'
|
import { getOverlayState, resetOverlayState } from '../app/overlayStore.js'
|
||||||
import { getUiState, patchUiState, resetUiState } from '../app/uiStore.js'
|
import { getUiState, patchUiState, resetUiState } from '../app/uiStore.js'
|
||||||
|
import { TUI_SESSION_MODEL_FLAG } from '../domain/slash.js'
|
||||||
|
|
||||||
describe('createSlashHandler', () => {
|
describe('createSlashHandler', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -26,7 +26,7 @@ describe('createSlashHandler', () => {
|
|||||||
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
|
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' })
|
patchUiState({ sid: 'sid-abc' })
|
||||||
|
|
||||||
const ctx = buildCtx({
|
const ctx = buildCtx({
|
||||||
@@ -40,7 +40,7 @@ describe('createSlashHandler', () => {
|
|||||||
expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {
|
expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {
|
||||||
key: 'model',
|
key: 'model',
|
||||||
session_id: 'sid-abc',
|
session_id: 'sid-abc',
|
||||||
value: 'x-model --global'
|
value: 'x-model'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -55,9 +55,7 @@ describe('createSlashHandler', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
createSlashHandler(ctx)(
|
createSlashHandler(ctx)(`/model anthropic/claude-sonnet-4.6 --provider openrouter ${TUI_SESSION_MODEL_FLAG}`)
|
||||||
`/model anthropic/claude-sonnet-4.6 --provider openrouter ${TUI_SESSION_MODEL_FLAG}`
|
|
||||||
)
|
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {
|
expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {
|
||||||
key: 'model',
|
key: 'model',
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import type {
|
|||||||
ConfigGetValueResponse,
|
ConfigGetValueResponse,
|
||||||
ConfigSetResponse,
|
ConfigSetResponse,
|
||||||
SessionSaveResponse,
|
SessionSaveResponse,
|
||||||
SessionTitleResponse,
|
|
||||||
SessionSteerResponse,
|
SessionSteerResponse,
|
||||||
|
SessionTitleResponse,
|
||||||
SessionUndoResponse
|
SessionUndoResponse
|
||||||
} from '../../../gatewayTypes.js'
|
} from '../../../gatewayTypes.js'
|
||||||
import { writeOsc52Clipboard } from '../../../lib/osc52.js'
|
import { writeOsc52Clipboard } from '../../../lib/osc52.js'
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { attachedImageNotice, introMsg, toTranscriptMessages } from '../../../domain/messages.js'
|
import { attachedImageNotice, introMsg, toTranscriptMessages } from '../../../domain/messages.js'
|
||||||
|
import { TUI_SESSION_MODEL_FLAG } from '../../../domain/slash.js'
|
||||||
import type {
|
import type {
|
||||||
BackgroundStartResponse,
|
BackgroundStartResponse,
|
||||||
ConfigGetValueResponse,
|
ConfigGetValueResponse,
|
||||||
@@ -10,25 +11,15 @@ import type {
|
|||||||
VoiceToggleResponse
|
VoiceToggleResponse
|
||||||
} from '../../../gatewayTypes.js'
|
} from '../../../gatewayTypes.js'
|
||||||
import { fmtK } from '../../../lib/text.js'
|
import { fmtK } from '../../../lib/text.js'
|
||||||
import { TUI_SESSION_MODEL_FLAG } from '../../../domain/slash.js'
|
|
||||||
import type { PanelSection } from '../../../types.js'
|
import type { PanelSection } from '../../../types.js'
|
||||||
import { patchOverlayState } from '../../overlayStore.js'
|
import { patchOverlayState } from '../../overlayStore.js'
|
||||||
import { patchUiState } from '../../uiStore.js'
|
import { patchUiState } from '../../uiStore.js'
|
||||||
import type { SlashCommand } from '../types.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_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 TUI_SESSION_STRIP_RE = new RegExp(`\\s*${TUI_SESSION_MODEL_FLAG}\\b\\s*`, 'g')
|
||||||
|
|
||||||
const persistedModelArg = (arg: string) => {
|
const stripTuiSessionFlag = (trimmed: string) => trimmed.replace(TUI_SESSION_STRIP_RE, ' ').replace(/\s+/g, ' ').trim()
|
||||||
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()
|
|
||||||
|
|
||||||
const modelValueForConfigSet = (arg: string) => {
|
const modelValueForConfigSet = (arg: string) => {
|
||||||
const trimmed = arg.trim()
|
const trimmed = arg.trim()
|
||||||
@@ -41,7 +32,7 @@ const modelValueForConfigSet = (arg: string) => {
|
|||||||
return stripTuiSessionFlag(trimmed)
|
return stripTuiSessionFlag(trimmed)
|
||||||
}
|
}
|
||||||
|
|
||||||
return persistedModelArg(trimmed)
|
return trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
export const sessionCommands: SlashCommand[] = [
|
export const sessionCommands: SlashCommand[] = [
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useInput } from '@hermes/ink'
|
import { forceRedraw, useInput } from '@hermes/ink'
|
||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
import { useEffect, useRef } from '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 { $isBlocked, $overlayState, patchOverlayState } from './overlayStore.js'
|
||||||
import { turnController } from './turnController.js'
|
import { turnController } from './turnController.js'
|
||||||
import { patchTurnState } from './turnStore.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
|
const isCtrl = (key: { ctrl: boolean }, ch: string, target: string) => key.ctrl && ch.toLowerCase() === target
|
||||||
|
|
||||||
@@ -379,13 +379,9 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isAction(key, ch, 'l')) {
|
if (isAction(key, ch, 'l')) {
|
||||||
if (actions.guardBusySessionSwitch()) {
|
clearSelection()
|
||||||
return
|
forceRedraw(terminal.stdout ?? process.stdout)
|
||||||
}
|
return
|
||||||
|
|
||||||
patchUiState({ status: 'forging session…' })
|
|
||||||
|
|
||||||
return actions.newSession()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isVoiceToggleKey(key, ch)) {
|
if (isVoiceToggleKey(key, ch)) {
|
||||||
|
|||||||
@@ -112,9 +112,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
|
|||||||
const model = models[modelIdx]
|
const model = models[modelIdx]
|
||||||
|
|
||||||
if (provider && model) {
|
if (provider && model) {
|
||||||
onSelect(
|
onSelect(`${model} --provider ${provider.slug}${persistGlobal ? ' --global' : ` ${TUI_SESSION_MODEL_FLAG}`}`)
|
||||||
`${model} --provider ${provider.slug}${persistGlobal ? ' --global' : ` ${TUI_SESSION_MODEL_FLAG}`}`
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
setStage('provider')
|
setStage('provider')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const HOTKEYS: [string, string][] = [
|
|||||||
...copyHotkeys,
|
...copyHotkeys,
|
||||||
[action + '+D', 'exit'],
|
[action + '+D', 'exit'],
|
||||||
[action + '+G / Alt+G', 'open $EDITOR (Alt+G fallback for VSCode/Cursor)'],
|
[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'],
|
[paste + '+V / /paste', 'paste text; /paste attaches clipboard image'],
|
||||||
['Tab', 'apply completion'],
|
['Tab', 'apply completion'],
|
||||||
['↑/↓', 'completions / queue edit / history'],
|
['↑/↓', 'completions / queue edit / history'],
|
||||||
|
|||||||
Reference in New Issue
Block a user