Merge pull request #14145 from NousResearch/bb/tui-polish

fix(tui): input wrap, shift-tab yolo, statusline, clean boot
This commit is contained in:
brooklyn! 2026-04-22 16:48:37 -05:00 committed by GitHub
commit a1d57292af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 715 additions and 229 deletions

View file

@ -2,6 +2,7 @@ import React, { type PropsWithChildren, useContext, useInsertionEffect } from 'r
import { c as _c } from 'react/compiler-runtime'
import instances from '../instances.js'
import { CURSOR_HOME, ERASE_SCREEN, ERASE_SCROLLBACK } from '../termio/csi.js'
import { DISABLE_MOUSE_TRACKING, ENABLE_MOUSE_TRACKING, ENTER_ALT_SCREEN, EXIT_ALT_SCREEN } from '../termio/dec.js'
import { TerminalWriteContext } from '../useTerminalNotification.js'
@ -51,7 +52,9 @@ export function AlternateScreen(t0: Props) {
return
}
writeRaw(ENTER_ALT_SCREEN + '\x1B[2J\x1B[H' + (mouseTracking ? ENABLE_MOUSE_TRACKING : ''))
writeRaw(
ENTER_ALT_SCREEN + ERASE_SCROLLBACK + ERASE_SCREEN + CURSOR_HOME + (mouseTracking ? ENABLE_MOUSE_TRACKING : '')
)
ink?.setAltScreenActive(true, mouseTracking)
return () => {

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,10 @@ export default function wrapText(text: string, maxWidth: number, wrapType: Style
})
}
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

@ -395,10 +395,7 @@ describe('topLevelSubagents', () => {
})
it('excludes children whose parent is present', () => {
const items = [
makeItem({ id: 'p', index: 0 }),
makeItem({ depth: 1, id: 'c', index: 0, parentId: 'p' })
]
const items = [makeItem({ id: 'p', index: 0 }), makeItem({ depth: 1, id: 'c', index: 0, parentId: 'p' })]
expect(topLevelSubagents(items).map(s => s.id)).toEqual(['p'])
})

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

@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { $uiState, resetUiState } from '../app/uiStore.js'
import { applyDisplay } from '../app/useConfigSync.js'
import { applyDisplay, normalizeStatusBar } from '../app/useConfigSync.js'
describe('applyDisplay', () => {
beforeEach(() => {
@ -36,10 +36,20 @@ describe('applyDisplay', () => {
expect(s.inlineDiffs).toBe(false)
expect(s.showCost).toBe(true)
expect(s.showReasoning).toBe(true)
expect(s.statusBar).toBe(false)
expect(s.statusBar).toBe('off')
expect(s.streaming).toBe(false)
})
it('coerces legacy true + "on" alias to top', () => {
const setBell = vi.fn()
applyDisplay({ config: { display: { tui_statusbar: true as unknown as 'on' } } }, setBell)
expect($uiState.get().statusBar).toBe('top')
applyDisplay({ config: { display: { tui_statusbar: 'on' } } }, setBell)
expect($uiState.get().statusBar).toBe('top')
})
it('applies v1 parity defaults when display fields are missing', () => {
const setBell = vi.fn()
@ -50,7 +60,7 @@ describe('applyDisplay', () => {
expect(s.inlineDiffs).toBe(true)
expect(s.showCost).toBe(false)
expect(s.showReasoning).toBe(false)
expect(s.statusBar).toBe(true)
expect(s.statusBar).toBe('top')
expect(s.streaming).toBe(true)
})
@ -64,4 +74,42 @@ describe('applyDisplay', () => {
expect(s.inlineDiffs).toBe(true)
expect(s.streaming).toBe(true)
})
it('accepts the new string statusBar modes', () => {
const setBell = vi.fn()
applyDisplay({ config: { display: { tui_statusbar: 'bottom' } } }, setBell)
expect($uiState.get().statusBar).toBe('bottom')
applyDisplay({ config: { display: { tui_statusbar: 'top' } } }, setBell)
expect($uiState.get().statusBar).toBe('top')
})
})
describe('normalizeStatusBar', () => {
it('maps legacy bool + on alias to top/off', () => {
expect(normalizeStatusBar(true)).toBe('top')
expect(normalizeStatusBar(false)).toBe('off')
expect(normalizeStatusBar('on')).toBe('top')
})
it('passes through the canonical enum', () => {
expect(normalizeStatusBar('off')).toBe('off')
expect(normalizeStatusBar('top')).toBe('top')
expect(normalizeStatusBar('bottom')).toBe('bottom')
})
it('defaults missing/unknown values to top', () => {
expect(normalizeStatusBar(undefined)).toBe('top')
expect(normalizeStatusBar(null)).toBe('top')
expect(normalizeStatusBar('sideways')).toBe('top')
expect(normalizeStatusBar(42)).toBe('top')
})
it('trims whitespace and folds case', () => {
expect(normalizeStatusBar(' Bottom ')).toBe('bottom')
expect(normalizeStatusBar('TOP')).toBe('top')
expect(normalizeStatusBar(' on ')).toBe('top')
expect(normalizeStatusBar('OFF')).toBe('off')
})
})

View file

@ -27,6 +27,8 @@ export interface StateSetter<T> {
(value: SetStateAction<T>): void
}
export type StatusBarMode = 'bottom' | 'off' | 'top'
export interface SelectionApi {
clearSelection: () => void
copySelection: () => string
@ -89,7 +91,7 @@ export interface UiState {
showReasoning: boolean
sid: null | string
status: string
statusBar: boolean
statusBar: StatusBarMode
streaming: boolean
theme: Theme
usage: Usage

View file

@ -11,6 +11,7 @@ import type {
import { writeOsc52Clipboard } from '../../../lib/osc52.js'
import { configureDetectedTerminalKeybindings, configureTerminalKeybindings } from '../../../lib/terminalSetup.js'
import type { DetailsMode, Msg, PanelSection } from '../../../types.js'
import type { StatusBarMode } from '../../interfaces.js'
import { patchOverlayState } from '../../overlayStore.js'
import { patchUiState } from '../../uiStore.js'
import type { SlashCommand } from '../types.js'
@ -305,19 +306,29 @@ export const coreCommands: SlashCommand[] = [
{
aliases: ['sb'],
help: 'toggle status bar',
help: 'status bar position (on|off|top|bottom)',
name: 'statusbar',
run: (arg, ctx) => {
const next = flagFromArg(arg, ctx.ui.statusBar)
const mode = arg.trim().toLowerCase()
const toggle: StatusBarMode = ctx.ui.statusBar === 'off' ? 'top' : 'off'
if (next === null) {
return ctx.transcript.sys('usage: /statusbar [on|off|toggle]')
const next: null | StatusBarMode =
!mode || mode === 'toggle'
? toggle
: mode === 'on' || mode === 'top'
? 'top'
: mode === 'off' || mode === 'bottom'
? mode
: null
if (!next) {
return ctx.transcript.sys('usage: /statusbar [on|off|top|bottom|toggle]')
}
patchUiState({ statusBar: next })
ctx.gateway.rpc<ConfigSetResponse>('config.set', { key: 'statusbar', value: next ? 'on' : 'off' }).catch(() => {})
ctx.gateway.rpc<ConfigSetResponse>('config.set', { key: 'statusbar', value: next }).catch(() => {})
queueMicrotask(() => ctx.transcript.sys(`status bar ${next ? 'on' : 'off'}`))
queueMicrotask(() => ctx.transcript.sys(`status bar ${next}`))
}
},

View file

@ -16,7 +16,7 @@ const buildUiState = (): UiState => ({
showReasoning: false,
sid: null,
status: 'summoning hermes…',
statusBar: true,
statusBar: 'top',
streaming: true,
theme: DEFAULT_THEME,
usage: ZERO

View file

@ -10,9 +10,20 @@ import type {
} from '../gatewayTypes.js'
import { asRpcResult } from '../lib/rpc.js'
import type { StatusBarMode } from './interfaces.js'
import { turnController } from './turnController.js'
import { patchUiState } from './uiStore.js'
const STATUSBAR_ALIAS: Record<string, StatusBarMode> = {
bottom: 'bottom',
off: 'off',
on: 'top',
top: 'top'
}
export const normalizeStatusBar = (raw: unknown): StatusBarMode =>
raw === false ? 'off' : typeof raw === 'string' ? (STATUSBAR_ALIAS[raw.trim().toLowerCase()] ?? 'top') : 'top'
const MTIME_POLL_MS = 5000
const quietRpc = async <T extends Record<string, any> = Record<string, any>>(
@ -37,7 +48,7 @@ export const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolea
inlineDiffs: d.inline_diffs !== false,
showCost: !!d.show_cost,
showReasoning: !!d.show_reasoning,
statusBar: d.tui_statusbar !== false,
statusBar: normalizeStatusBar(d.tui_statusbar),
streaming: d.streaming !== false
})
}

View file

@ -3,6 +3,7 @@ import { useStore } from '@nanostores/react'
import type {
ApprovalRespondResponse,
ConfigSetResponse,
SecretRespondResponse,
SudoRespondResponse,
VoiceRecordResponse
@ -377,6 +378,29 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
return cActions.openEditor()
}
// shift-tab flips yolo without spending a turn (claude-code parity)
if (key.shift && key.tab && !cState.completions.length) {
if (!live.sid) {
return void actions.sys('yolo needs an active session')
}
// gateway.rpc swallows errors with its own sys() message and resolves to null,
// so we only speak when it came back with a real shape. null = rpc already spoke.
return void gateway.rpc<ConfigSetResponse>('config.set', { key: 'yolo', session_id: live.sid }).then(r => {
if (r?.value === '1') {
return actions.sys('yolo on')
}
if (r?.value === '0') {
return actions.sys('yolo off')
}
if (r) {
actions.sys('failed to toggle yolo')
}
})
}
if (key.tab && cState.completions.length) {
const row = cState.completions[cState.compIdx]

View file

@ -5,7 +5,7 @@ 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 { fmtCwdBranch } from '../domain/paths.js'
import { fmtCwdBranch, shortCwd } from '../domain/paths.js'
import { type GatewayClient } from '../gatewayClient.js'
import type {
ClarifyRespondResponse,
@ -314,12 +314,14 @@ export function useMainApp(gw: GatewayClient) {
useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, sid: ui.sid })
// ── Terminal tab title ─────────────────────────────────────────────
// Show model name + status so users can identify the Hermes tab.
const shortModel = ui.info?.model?.replace(/^.*\//, '') ?? ''
const titleStatus = ui.busy ? '⏳' : '✓'
const terminalTitle = shortModel ? `${titleStatus} ${shortModel} — Hermes` : 'Hermes'
useTerminalTitle(terminalTitle)
// Tab title: `⚠` waiting on approval/sudo/secret/clarify, `⏳` busy, `✓` idle.
const model = ui.info?.model?.replace(/^.*\//, '') ?? ''
const marker = overlay.approval || overlay.sudo || overlay.secret || overlay.clarify ? '⚠' : ui.busy ? '⏳' : '✓'
const tabCwd = ui.info?.cwd
useTerminalTitle(model ? `${marker} ${model}${tabCwd ? ` · ${shortCwd(tabCwd, 24)}` : ''}` : 'Hermes')
useEffect(() => {
if (!ui.sid || !stdout) {

View file

@ -1,26 +0,0 @@
const GOLD = '\x1b[38;2;255;215;0m'
const AMBER = '\x1b[38;2;255;191;0m'
const BRONZE = '\x1b[38;2;205;127;50m'
const DIM = '\x1b[38;2;184;134;11m'
const RESET = '\x1b[0m'
const LOGO = [
'██╗ ██╗███████╗██████╗ ███╗ ███╗███████╗███████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗',
'██║ ██║██╔════╝██╔══██╗████╗ ████║██╔════╝██╔════╝ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝',
'███████║█████╗ ██████╔╝██╔████╔██║█████╗ ███████╗█████╗███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║ ',
'██╔══██║██╔══╝ ██╔══██╗██║╚██╔╝██║██╔══╝ ╚════██║╚════╝██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║ ',
'██║ ██║███████╗██║ ██║██║ ╚═╝ ██║███████╗███████║ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║ ',
'╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ '
]
const GRADIENT = [GOLD, GOLD, AMBER, AMBER, BRONZE, BRONZE] as const
const LOGO_WIDTH = 98
const TAGLINE = `${DIM}⚕ Nous Research · Messenger of the Digital Gods${RESET}`
const FALLBACK = `\x1b[1m${GOLD}⚕ NOUS HERMES${RESET}`
export function bootBanner(cols: number = process.stdout.columns || 80): string {
const body = cols >= LOGO_WIDTH ? LOGO.map((text, i) => `${GRADIENT[i]}${text}${RESET}`).join('\n') : FALLBACK
return `\n${body}\n${TAGLINE}\n\n`
}

View file

@ -156,7 +156,11 @@ export function GoodVibesHeart({ tick, t }: { tick: number; t: Theme }) {
return () => clearTimeout(id)
}, [t.color.amber, tick])
return <Text color={color}>{active ? '♥' : ' '}</Text>
if (!active) {
return null
}
return <Text color={color}></Text>
}
export function StatusRule({
@ -187,7 +191,7 @@ export function StatusRule({
const leftWidth = Math.max(12, cols - cwdLabel.length - 3)
return (
<Box>
<Box height={1}>
<Box flexShrink={1} width={leftWidth}>
<Text color={t.color.bronze} wrap="truncate-end">
{'─ '}

View file

@ -183,37 +183,19 @@ const ComposerPane = memo(function ComposerPane({
<Text> </Text>
)}
<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}
completions={composer.completions}
onModelSelect={actions.onModelSelect}
onPickerSelect={actions.resumeById}
pagerPageSize={composer.pagerPageSize}
/>
</Box>
<StatusRulePane at="top" composer={composer} status={status} />
{!isBlocked && (
<Box flexDirection="column" marginBottom={1}>
<Box flexDirection="column" marginTop={ui.statusBar === 'top' ? 0 : 1} position="relative">
<FloatingOverlays
cols={composer.cols}
compIdx={composer.compIdx}
completions={composer.completions}
onModelSelect={actions.onModelSelect}
onPickerSelect={actions.resumeById}
pagerPageSize={composer.pagerPageSize}
/>
{composer.inputBuf.map((line, i) => (
<Box key={i}>
<Box width={3}>
@ -236,8 +218,9 @@ const ComposerPane = memo(function ComposerPane({
</Box>
<Box flexGrow={1} position="relative">
{/* subtract NoSelect paddingX={1} (2 cols) + pw so wrap-ansi and cursorLayout agree */}
<TextInput
columns={Math.max(20, composer.cols - pw)}
columns={Math.max(20, composer.cols - pw - 2)}
onChange={composer.updateInput}
onPaste={composer.handleTextPaste}
onSubmit={composer.submit}
@ -273,6 +256,38 @@ const AgentsOverlayPane = memo(function AgentsOverlayPane() {
)
})
const StatusRulePane = memo(function StatusRulePane({
at,
composer,
status
}: Pick<AppLayoutProps, 'composer' | 'status'> & { at: 'bottom' | 'top' }) {
const ui = useStore($uiState)
if (ui.statusBar !== at) {
return null
}
return (
<Box marginTop={at === 'top' ? 1 : 0}>
<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}
/>
</Box>
)
})
export const AppLayout = memo(function AppLayout({
actions,
composer,
@ -295,16 +310,20 @@ export const AppLayout = memo(function AppLayout({
</Box>
{!overlay.agents && (
<PromptZone
cols={composer.cols}
onApprovalChoice={actions.answerApproval}
onClarifyAnswer={actions.answerClarify}
onSecretSubmit={actions.answerSecret}
onSudoSubmit={actions.answerSudo}
/>
)}
<>
<PromptZone
cols={composer.cols}
onApprovalChoice={actions.answerApproval}
onClarifyAnswer={actions.answerClarify}
onSecretSubmit={actions.answerSecret}
onSudoSubmit={actions.answerSudo}
/>
{!overlay.agents && <ComposerPane actions={actions} composer={composer} status={status} />}
<ComposerPane actions={actions} composer={composer} status={status} />
<StatusRulePane at="bottom" composer={composer} status={status} />
</>
)}
</Box>
</AlternateScreen>
)

View file

@ -167,9 +167,11 @@ 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) {
// mirrors wrap-ansi(..., { wordWrap: false, hard: true }) so the declared
// cursor lines up with what <Text wrap="wrap-char"> actually renders
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 +202,23 @@ function cursorLayout(value: string, cursor: number, cols: number) {
col += sw
}
// trailing cursor-cell overflows to the next row at the wrap column
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
@ -802,7 +810,7 @@ export function TextInput({
}}
ref={boxRef}
>
<Text wrap="wrap">{rendered}</Text>
<Text wrap="wrap-char">{rendered}</Text>
</Box>
)
}

View file

@ -24,6 +24,6 @@ export const HOTKEYS: [string, string][] = [
['Home/End', 'start / end of line'],
['Shift+Enter / Alt+Enter', 'insert newline'],
['\\+Enter', 'multi-line continuation (fallback)'],
['!cmd', 'run shell command'],
['{!cmd}', 'interpolate shell output inline']
['!<cmd>', 'run a shell command (e.g. !ls, !git status)'],
['{!<cmd>}', 'interpolate shell output inline (e.g. "branch is {!git branch --show-current}")']
]

View file

@ -1,5 +1,4 @@
#!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc
import { bootBanner } from './bootBanner.js'
import { GatewayClient } from './gatewayClient.js'
import { setupGracefulExit } from './lib/gracefulExit.js'
import { formatBytes, type HeapDumpResult, performHeapDump } from './lib/memory.js'
@ -10,8 +9,6 @@ if (!process.stdin.isTTY) {
process.exit(0)
}
process.stdout.write(bootBanner())
const gw = new GatewayClient()
gw.start()

View file

@ -60,7 +60,7 @@ export interface ConfigDisplayConfig {
streaming?: boolean
thinking_mode?: string
tui_compact?: boolean
tui_statusbar?: boolean
tui_statusbar?: 'bottom' | 'off' | 'on' | 'top' | boolean
}
export interface ConfigFullResponse {