mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC
Full codebase pass using the /clean doctrine (KISS/DRY, no one-off
helpers, no variables-used-once, pure functional where natural,
inlined obvious one-liners, killed dead exports, narrowed types,
spaced JSX). All contracts preserved — no RPC method, event name,
or exported type shape changed.
app/ — 15 files, -134 LOC
- inlined 4 one-off helpers (titleCase, isLong, statusToneFrom,
focusOutside predicate)
- stores to arrow-const style (buildUiState, buildTurnState,
buildOverlayState plus get/patch/reset triplets)
- functional slash/registry byName map (flatMap over for-loops)
- dropped dead param `live` in cancelOverlayFromCtrlC
- DRY'd duplicate shift() call in scrollWithSelection
- consolidated sections.push calls in /help
components/ — 12 files, -40 LOC
- extracted inline prop types to interfaces at file bottom (13×)
- inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint)
- promoted HEART_COLORS + OPTS/LABELS to module scope
- JSX sibling spacing across 9 files
- un-shadowed `raw` in textInput
- components/thinking.tsx + components/markdown.tsx untouched
(structurally load-bearing / edge-case-heavy)
config content domain protocol/ — 8 files, -77 LOC
- tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand,
hasInterpolation — dropped stateful lastIndex dance)
- dead export ParsedSlashCommand removed
- MODES narrowed to `as const`, `.find(m => m === s)` replaces
`.includes() ? (as cast) : null`
- fortunes.ts hash via reduce
- fmtDuration ternary chain
- inlined aboveViewport predicate in viewport.ts
hooks/ + lib/ — 9 files, -38 LOC
- ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module
scope (no more eslint-disable no-control-regex)
- compactPreview/edgePreview/thinkingPreview → ternary arrows
- useCompletion: hoisted pathReplace, moved stale-ref guard earlier
- useInputHistory: dropped useCallback wrapper (append is stable)
- useVirtualHistory: replaced 4× any with unknown + narrow
MeasuredNode interface + one cast site
root TS — 3 files, -63 LOC
- banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex,
artWidth via reduce
- gatewayClient.ts: resolvePython candidate list collapse, inlined
one-branch guards in dispatch/pushLog/drain/request
- types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq
members
eslint config
- disabled react-hooks/exhaustive-deps on packages/hermes-ink/**
(compiled by react/compiler, deps live in $[N] memo arrays that
eslint can't introspect) and removed the now-orphan in-file
disable directive in ScrollBox.tsx
fixes (not from the cleaner pass)
- useComposerState: unlinkSync(file) + try/catch → rmSync(file,
{ force: true }) — kills the no-empty lint error and is more
idiomatic
- useConfigSync: added setBellOnComplete + setVoiceEnabled to the
two useEffect dep arrays (they're stable React setState setters;
adding is safe and silences exhaustive-deps)
verification
- npx eslint src/ packages/ → 0 errors, 0 warnings
- npm run type-check → clean
- npm test → 50/50
- npm run build → 394.8kb ink-bundle.js, 11ms esbuild
- pytest tests/tui_gateway/ tests/test_tui_gateway_server.py
tests/hermes_cli/test_tui_resume_flow.py
tests/hermes_cli/test_tui_npm_install.py → 57/57
This commit is contained in:
parent
c730ab8ad7
commit
39231f29c6
49 changed files with 527 additions and 744 deletions
|
|
@ -88,7 +88,8 @@ export default [
|
|||
'@typescript-eslint/consistent-type-imports': 'off',
|
||||
'no-constant-condition': 'off',
|
||||
'no-empty': 'off',
|
||||
'no-redeclare': 'off'
|
||||
'no-redeclare': 'off',
|
||||
'react-hooks/exhaustive-deps': 'off'
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -235,7 +235,7 @@ function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren<
|
|||
// notify/scrollMutated are inline (no useCallback) but only close over
|
||||
// refs + imports — stable. Empty deps avoids rebuilding the handle on
|
||||
// every render (which re-registers the ref = churn).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
[]
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -35,9 +35,6 @@ const dropBgTask = (taskId: string) =>
|
|||
return { ...state, bgTasks: next }
|
||||
})
|
||||
|
||||
const statusToneFrom = (kind: string): 'error' | 'info' | 'warn' =>
|
||||
kind === 'error' ? 'error' : kind === 'warn' || kind === 'approval' ? 'warn' : 'info'
|
||||
|
||||
const pushUnique =
|
||||
(max: number) =>
|
||||
<T>(xs: T[], x: T): T[] =>
|
||||
|
|
@ -213,7 +210,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
|
||||
if (turnController.lastStatusNote !== p.text) {
|
||||
turnController.lastStatusNote = p.text
|
||||
turnController.pushActivity(p.text, statusToneFrom(p.kind))
|
||||
turnController.pushActivity(
|
||||
p.text,
|
||||
p.kind === 'error' ? 'error' : p.kind === 'warn' || p.kind === 'approval' ? 'warn' : 'info'
|
||||
)
|
||||
}
|
||||
|
||||
restoreStatusAfter(4000)
|
||||
|
|
|
|||
|
|
@ -7,10 +7,6 @@ import { findSlashCommand } from './slash/registry.js'
|
|||
import type { SlashRunCtx } from './slash/types.js'
|
||||
import { getUiState } from './uiStore.js'
|
||||
|
||||
const titleCase = (name: string) => name.charAt(0).toUpperCase() + name.slice(1)
|
||||
|
||||
const isLong = (text: string) => text.length > 180 || text.split('\n').filter(Boolean).length > 2
|
||||
|
||||
export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => boolean {
|
||||
const { gw } = ctx.gateway
|
||||
const { catalog } = ctx.local
|
||||
|
|
@ -79,8 +75,9 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
|
|||
|
||||
const body = r?.output || `/${parsed.name}: no output`
|
||||
const text = r?.warning ? `warning: ${r.warning}\n${body}` : body
|
||||
const long = text.length > 180 || text.split('\n').filter(Boolean).length > 2
|
||||
|
||||
isLong(text) ? page(text, titleCase(parsed.name)) : sys(text)
|
||||
long ? page(text, parsed.name[0]!.toUpperCase() + parsed.name.slice(1)) : sys(text)
|
||||
})
|
||||
.catch(() => {
|
||||
gw.request('command.dispatch', { arg: parsed.arg, name: parsed.name, session_id: sid })
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@ import { atom, computed } from 'nanostores'
|
|||
|
||||
import type { OverlayState } from './interfaces.js'
|
||||
|
||||
function buildOverlayState(): OverlayState {
|
||||
return {
|
||||
const buildOverlayState = (): OverlayState => ({
|
||||
approval: null,
|
||||
clarify: null,
|
||||
modelPicker: false,
|
||||
|
|
@ -11,31 +10,17 @@ function buildOverlayState(): OverlayState {
|
|||
picker: false,
|
||||
secret: null,
|
||||
sudo: null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const $overlayState = atom<OverlayState>(buildOverlayState())
|
||||
|
||||
export const $isBlocked = computed($overlayState, state =>
|
||||
Boolean(
|
||||
state.approval || state.clarify || state.modelPicker || state.pager || state.picker || state.secret || state.sudo
|
||||
)
|
||||
export const $isBlocked = computed($overlayState, ({ approval, clarify, modelPicker, pager, picker, secret, sudo }) =>
|
||||
Boolean(approval || clarify || modelPicker || pager || picker || secret || sudo)
|
||||
)
|
||||
|
||||
export function getOverlayState() {
|
||||
return $overlayState.get()
|
||||
}
|
||||
export const getOverlayState = () => $overlayState.get()
|
||||
|
||||
export function patchOverlayState(next: Partial<OverlayState> | ((state: OverlayState) => OverlayState)) {
|
||||
if (typeof next === 'function') {
|
||||
$overlayState.set(next($overlayState.get()))
|
||||
export const patchOverlayState = (next: Partial<OverlayState> | ((state: OverlayState) => OverlayState)) =>
|
||||
$overlayState.set(typeof next === 'function' ? next($overlayState.get()) : { ...$overlayState.get(), ...next })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
$overlayState.set({ ...$overlayState.get(), ...next })
|
||||
}
|
||||
|
||||
export function resetOverlayState() {
|
||||
$overlayState.set(buildOverlayState())
|
||||
}
|
||||
export const resetOverlayState = () => $overlayState.set(buildOverlayState())
|
||||
|
|
|
|||
|
|
@ -9,12 +9,12 @@ import { patchUiState } from '../../uiStore.js'
|
|||
import type { SlashCommand } from '../types.js'
|
||||
|
||||
const flagFromArg = (arg: string, current: boolean): boolean | null => {
|
||||
const mode = arg.trim().toLowerCase()
|
||||
|
||||
if (!arg) {
|
||||
return !current
|
||||
}
|
||||
|
||||
const mode = arg.trim().toLowerCase()
|
||||
|
||||
if (mode === 'on') {
|
||||
return true
|
||||
}
|
||||
|
|
@ -46,14 +46,16 @@ export const coreCommands: SlashCommand[] = [
|
|||
sections.push({ text: `${ctx.local.catalog.skillCount} skill commands available — /skills to browse` })
|
||||
}
|
||||
|
||||
sections.push({
|
||||
sections.push(
|
||||
{
|
||||
rows: [
|
||||
['/details [hidden|collapsed|expanded|cycle]', 'set agent detail visibility mode'],
|
||||
['/fortune [random|daily]', 'show a random or daily local fortune']
|
||||
],
|
||||
title: 'TUI'
|
||||
})
|
||||
sections.push({ rows: HOTKEYS, title: 'Hotkeys' })
|
||||
},
|
||||
{ rows: HOTKEYS, title: 'Hotkeys' }
|
||||
)
|
||||
|
||||
ctx.transcript.panel(ctx.ui.theme.brand.helpHeader, sections)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export const opsCommands: SlashCommand[] = [
|
|||
const [subcommand, ...names] = arg.trim().split(/\s+/).filter(Boolean)
|
||||
|
||||
if (subcommand !== 'disable' && subcommand !== 'enable') {
|
||||
return // py prints lists / show / usage
|
||||
return
|
||||
}
|
||||
|
||||
if (!names.length) {
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ export const sessionCommands: SlashCommand[] = [
|
|||
name: 'personality',
|
||||
run: (arg, ctx) => {
|
||||
if (!arg) {
|
||||
return // py handles listing
|
||||
return
|
||||
}
|
||||
|
||||
ctx.gateway.rpc<ConfigSetResponse>('config.set', { key: 'personality', session_id: ctx.sid, value: arg }).then(
|
||||
|
|
@ -200,11 +200,6 @@ export const sessionCommands: SlashCommand[] = [
|
|||
}
|
||||
},
|
||||
|
||||
// The four shims below call `config.set` directly because Python's `slash.exec`
|
||||
// worker is a separate subprocess — it writes config but does NOT fire the
|
||||
// live side-effects (`skin.changed` event, agent.reasoning_config,
|
||||
// agent.verbose_logging, per-session yolo flip). Direct RPC does.
|
||||
|
||||
{
|
||||
help: 'switch theme skin (fires skin.changed)',
|
||||
name: 'skin',
|
||||
|
|
|
|||
|
|
@ -5,14 +5,8 @@ import type { SlashCommand } from './types.js'
|
|||
|
||||
export const SLASH_COMMANDS: SlashCommand[] = [...coreCommands, ...sessionCommands, ...opsCommands]
|
||||
|
||||
const byName = new Map<string, SlashCommand>()
|
||||
const byName = new Map<string, SlashCommand>(
|
||||
SLASH_COMMANDS.flatMap(cmd => [cmd.name, ...(cmd.aliases ?? [])].map(name => [name, cmd] as const))
|
||||
)
|
||||
|
||||
for (const cmd of SLASH_COMMANDS) {
|
||||
byName.set(cmd.name, cmd)
|
||||
|
||||
for (const alias of cmd.aliases ?? []) {
|
||||
byName.set(alias, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
export const findSlashCommand = (name: string): SlashCommand | undefined => byName.get(name.toLowerCase())
|
||||
export const findSlashCommand = (name: string) => byName.get(name.toLowerCase())
|
||||
|
|
|
|||
|
|
@ -2,6 +2,28 @@ import { atom } from 'nanostores'
|
|||
|
||||
import type { ActiveTool, ActivityItem, SubagentProgress } from '../types.js'
|
||||
|
||||
const buildTurnState = (): TurnState => ({
|
||||
activity: [],
|
||||
reasoning: '',
|
||||
reasoningActive: false,
|
||||
reasoningStreaming: false,
|
||||
reasoningTokens: 0,
|
||||
streaming: '',
|
||||
subagents: [],
|
||||
toolTokens: 0,
|
||||
tools: [],
|
||||
turnTrail: []
|
||||
})
|
||||
|
||||
export const $turnState = atom<TurnState>(buildTurnState())
|
||||
|
||||
export const getTurnState = () => $turnState.get()
|
||||
|
||||
export const patchTurnState = (next: Partial<TurnState> | ((state: TurnState) => TurnState)) =>
|
||||
$turnState.set(typeof next === 'function' ? next($turnState.get()) : { ...$turnState.get(), ...next })
|
||||
|
||||
export const resetTurnState = () => $turnState.set(buildTurnState())
|
||||
|
||||
export interface TurnState {
|
||||
activity: ActivityItem[]
|
||||
reasoning: string
|
||||
|
|
@ -14,34 +36,3 @@ export interface TurnState {
|
|||
tools: ActiveTool[]
|
||||
turnTrail: string[]
|
||||
}
|
||||
|
||||
function buildTurnState(): TurnState {
|
||||
return {
|
||||
activity: [],
|
||||
reasoning: '',
|
||||
reasoningActive: false,
|
||||
reasoningStreaming: false,
|
||||
reasoningTokens: 0,
|
||||
streaming: '',
|
||||
subagents: [],
|
||||
toolTokens: 0,
|
||||
tools: [],
|
||||
turnTrail: []
|
||||
}
|
||||
}
|
||||
|
||||
export const $turnState = atom<TurnState>(buildTurnState())
|
||||
|
||||
export const getTurnState = () => $turnState.get()
|
||||
|
||||
export const patchTurnState = (next: Partial<TurnState> | ((state: TurnState) => TurnState)) => {
|
||||
if (typeof next === 'function') {
|
||||
$turnState.set(next($turnState.get()))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
$turnState.set({ ...$turnState.get(), ...next })
|
||||
}
|
||||
|
||||
export const resetTurnState = () => $turnState.set(buildTurnState())
|
||||
|
|
|
|||
|
|
@ -5,8 +5,7 @@ import { DEFAULT_THEME } from '../theme.js'
|
|||
|
||||
import type { UiState } from './interfaces.js'
|
||||
|
||||
function buildUiState(): UiState {
|
||||
return {
|
||||
const buildUiState = (): UiState => ({
|
||||
bgTasks: new Set(),
|
||||
busy: false,
|
||||
compact: false,
|
||||
|
|
@ -17,25 +16,13 @@ function buildUiState(): UiState {
|
|||
statusBar: true,
|
||||
theme: DEFAULT_THEME,
|
||||
usage: ZERO
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const $uiState = atom<UiState>(buildUiState())
|
||||
|
||||
export function getUiState() {
|
||||
return $uiState.get()
|
||||
}
|
||||
export const getUiState = () => $uiState.get()
|
||||
|
||||
export function patchUiState(next: Partial<UiState> | ((state: UiState) => UiState)) {
|
||||
if (typeof next === 'function') {
|
||||
$uiState.set(next($uiState.get()))
|
||||
export const patchUiState = (next: Partial<UiState> | ((state: UiState) => UiState)) =>
|
||||
$uiState.set(typeof next === 'function' ? next($uiState.get()) : { ...$uiState.get(), ...next })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
$uiState.set({ ...$uiState.get(), ...next })
|
||||
}
|
||||
|
||||
export function resetUiState() {
|
||||
$uiState.set(buildUiState())
|
||||
}
|
||||
export const resetUiState = () => $uiState.set(buildUiState())
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
|
|
@ -97,11 +97,7 @@ export function useComposerState({ gw, onClipboardPaste, submitRef }: UseCompose
|
|||
}
|
||||
}
|
||||
|
||||
try {
|
||||
unlinkSync(file)
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
rmSync(file, { force: true })
|
||||
}, [input, inputBuf, submitRef])
|
||||
|
||||
const actions = useMemo(
|
||||
|
|
|
|||
|
|
@ -15,23 +15,16 @@ import { patchUiState } from './uiStore.js'
|
|||
const MTIME_POLL_MS = 5000
|
||||
|
||||
const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolean) => void) => {
|
||||
const display = cfg?.config?.display ?? {}
|
||||
const d = cfg?.config?.display ?? {}
|
||||
|
||||
setBell(!!display.bell_on_complete)
|
||||
setBell(!!d.bell_on_complete)
|
||||
patchUiState({
|
||||
compact: !!display.tui_compact,
|
||||
detailsMode: resolveDetailsMode(display),
|
||||
statusBar: display.tui_statusbar !== false
|
||||
compact: !!d.tui_compact,
|
||||
detailsMode: resolveDetailsMode(d),
|
||||
statusBar: d.tui_statusbar !== false
|
||||
})
|
||||
}
|
||||
|
||||
export interface UseConfigSyncOptions {
|
||||
rpc: GatewayRpc
|
||||
setBellOnComplete: (v: boolean) => void
|
||||
setVoiceEnabled: (v: boolean) => void
|
||||
sid: null | string
|
||||
}
|
||||
|
||||
export function useConfigSync({ rpc, setBellOnComplete, setVoiceEnabled, sid }: UseConfigSyncOptions) {
|
||||
const mtimeRef = useRef(0)
|
||||
|
||||
|
|
@ -45,8 +38,7 @@ export function useConfigSync({ rpc, setBellOnComplete, setVoiceEnabled, sid }:
|
|||
mtimeRef.current = Number(r?.mtime ?? 0)
|
||||
})
|
||||
rpc<ConfigFullResponse>('config.get', { key: 'full' }).then(r => applyDisplay(r, setBellOnComplete))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rpc, sid])
|
||||
}, [rpc, setBellOnComplete, setVoiceEnabled, sid])
|
||||
|
||||
useEffect(() => {
|
||||
if (!sid) {
|
||||
|
|
@ -79,6 +71,12 @@ export function useConfigSync({ rpc, setBellOnComplete, setVoiceEnabled, sid }:
|
|||
}, MTIME_POLL_MS)
|
||||
|
||||
return () => clearInterval(id)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rpc, sid])
|
||||
}, [rpc, setBellOnComplete, sid])
|
||||
}
|
||||
|
||||
export interface UseConfigSyncOptions {
|
||||
rpc: GatewayRpc
|
||||
setBellOnComplete: (v: boolean) => void
|
||||
setVoiceEnabled: (v: boolean) => void
|
||||
sid: null | string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,14 +29,14 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
|||
}
|
||||
}
|
||||
|
||||
const cancelOverlayFromCtrlC = (live: ReturnType<typeof getUiState>) => {
|
||||
const cancelOverlayFromCtrlC = () => {
|
||||
if (overlay.clarify) {
|
||||
return actions.answerClarify('')
|
||||
}
|
||||
|
||||
if (overlay.approval) {
|
||||
return gateway
|
||||
.rpc<ApprovalRespondResponse>('approval.respond', { choice: 'deny', session_id: live.sid })
|
||||
.rpc<ApprovalRespondResponse>('approval.respond', { choice: 'deny', session_id: getUiState().sid })
|
||||
.then(r => r && (patchOverlayState({ approval: null }), actions.sys('denied')))
|
||||
}
|
||||
|
||||
|
|
@ -172,7 +172,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
|||
}
|
||||
|
||||
if (isCtrl(key, ch, 'c')) {
|
||||
cancelOverlayFromCtrlC(live)
|
||||
cancelOverlayFromCtrlC()
|
||||
} else if (key.escape && overlay.picker) {
|
||||
patchOverlayState({ picker: false })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,10 +47,9 @@ export function useLongRunToolCharms(busy: boolean, tools: ActiveTool[]) {
|
|||
}
|
||||
|
||||
slots.current.set(tool.id, { count: slot.count + 1, lastAt: now })
|
||||
|
||||
const sec = Math.round((now - tool.startedAt) / 1000)
|
||||
|
||||
turnController.pushActivity(`${pick(LONG_RUN_CHARMS)} (${toolTrailLabel(tool.name)} · ${sec}s)`)
|
||||
turnController.pushActivity(
|
||||
`${pick(LONG_RUN_CHARMS)} (${toolTrailLabel(tool.name)} · ${Math.round((now - tool.startedAt) / 1000)}s)`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -170,18 +170,16 @@ export function useMainApp(gw: GatewayClient) {
|
|||
}
|
||||
|
||||
const sel = selection.getState() as null | SelectionSnap
|
||||
const top = s.getViewportTop()
|
||||
const bottom = top + s.getViewportHeight() - 1
|
||||
|
||||
const focusOutside = (top: number, bottom: number) =>
|
||||
if (
|
||||
!sel?.anchor ||
|
||||
!sel.focus ||
|
||||
sel.anchor.row < top ||
|
||||
sel.anchor.row > bottom ||
|
||||
(!sel.isDragging && (sel.focus.row < top || sel.focus.row > bottom))
|
||||
|
||||
const top = s.getViewportTop()
|
||||
const bottom = top + s.getViewportHeight() - 1
|
||||
|
||||
if (focusOutside(top, bottom)) {
|
||||
) {
|
||||
return s.scrollBy(delta)
|
||||
}
|
||||
|
||||
|
|
@ -197,12 +195,11 @@ export function useMainApp(gw: GatewayClient) {
|
|||
|
||||
if (actual > 0) {
|
||||
selection.captureScrolledRows(top, top + actual - 1, 'above')
|
||||
shift(-actual, top, bottom)
|
||||
} else {
|
||||
selection.captureScrolledRows(bottom + actual + 1, bottom, 'below')
|
||||
shift(-actual, top, bottom)
|
||||
}
|
||||
|
||||
shift(-actual, top, bottom)
|
||||
s.scrollBy(delta)
|
||||
},
|
||||
[selection]
|
||||
|
|
|
|||
|
|
@ -29,19 +29,6 @@ const expandSnips = (snips: PasteSnippet[]) => {
|
|||
const spliceMatches = (text: string, matches: RegExpMatchArray[], results: string[]) =>
|
||||
matches.reduceRight((acc, m, i) => acc.slice(0, m.index!) + results[i] + acc.slice(m.index! + m[0].length), text)
|
||||
|
||||
export interface UseSubmissionOptions {
|
||||
appendMessage: (msg: Msg) => void
|
||||
composerActions: ComposerActions
|
||||
composerRefs: ComposerRefs
|
||||
composerState: ComposerState
|
||||
gw: GatewayClient
|
||||
maybeGoodVibes: (text: string) => void
|
||||
setLastUserMsg: (value: string) => void
|
||||
slashRef: MutableRefObject<(cmd: string) => boolean>
|
||||
submitRef: MutableRefObject<(value: string) => void>
|
||||
sys: (text: string) => void
|
||||
}
|
||||
|
||||
export function useSubmission(opts: UseSubmissionOptions) {
|
||||
const {
|
||||
appendMessage,
|
||||
|
|
@ -183,7 +170,6 @@ export function useSubmission(opts: UseSubmissionOptions) {
|
|||
return
|
||||
}
|
||||
|
||||
// Slash + shell run regardless of session state (each handles its own sid needs).
|
||||
if (looksLikeSlashCommand(full)) {
|
||||
appendMessage({ kind: 'slash', role: 'system', text: full })
|
||||
composerActions.pushHistory(full)
|
||||
|
|
@ -201,7 +187,6 @@ export function useSubmission(opts: UseSubmissionOptions) {
|
|||
|
||||
const live = getUiState()
|
||||
|
||||
// No session yet — queue the text and let the ready-flush effect send it.
|
||||
if (!live.sid) {
|
||||
composerActions.pushHistory(full)
|
||||
composerActions.enqueue(full)
|
||||
|
|
@ -246,7 +231,7 @@ export function useSubmission(opts: UseSubmissionOptions) {
|
|||
|
||||
send(full)
|
||||
},
|
||||
[appendMessage, composerActions, composerRefs, interpolate, send, sendQueued, shellExec, slashRef, sys]
|
||||
[appendMessage, composerActions, composerRefs, interpolate, send, sendQueued, shellExec, slashRef]
|
||||
)
|
||||
|
||||
const submit = useCallback(
|
||||
|
|
@ -256,7 +241,6 @@ export function useSubmission(opts: UseSubmissionOptions) {
|
|||
|
||||
if (row?.text) {
|
||||
const text = row.text.startsWith('/') && composerState.compReplace > 0 ? row.text.slice(1) : row.text
|
||||
|
||||
const next = value.slice(0, composerState.compReplace) + text
|
||||
|
||||
if (next !== value) {
|
||||
|
|
@ -304,3 +288,16 @@ export function useSubmission(opts: UseSubmissionOptions) {
|
|||
|
||||
return { dispatchSubmission, send, sendQueued, shellExec, submit }
|
||||
}
|
||||
|
||||
export interface UseSubmissionOptions {
|
||||
appendMessage: (msg: Msg) => void
|
||||
composerActions: ComposerActions
|
||||
composerRefs: ComposerRefs
|
||||
composerState: ComposerState
|
||||
gw: GatewayClient
|
||||
maybeGoodVibes: (text: string) => void
|
||||
setLastUserMsg: (value: string) => void
|
||||
slashRef: MutableRefObject<(cmd: string) => boolean>
|
||||
submitRef: MutableRefObject<(value: string) => void>
|
||||
sys: (text: string) => void
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
import type { ThemeColors } from './theme.js'
|
||||
|
||||
type Line = [string, string]
|
||||
|
||||
// ── Rich markup parser ──────────────────────────────────────────────
|
||||
// Parses Python Rich markup like "[bold #A3261F]text[/]" into Line[].
|
||||
const RICH_RE = /\[(?:bold\s+)?(?:dim\s+)?(#(?:[0-9a-fA-F]{3,8}))\]([\s\S]*?)(\[\/\])/g
|
||||
|
||||
export function parseRichMarkup(markup: string): Line[] {
|
||||
|
|
@ -18,28 +14,29 @@ export function parseRichMarkup(markup: string): Line[] {
|
|||
continue
|
||||
}
|
||||
|
||||
let lastIndex = 0
|
||||
let matched = false
|
||||
let m: RegExpExecArray | null
|
||||
const matches = [...trimmed.matchAll(RICH_RE)]
|
||||
|
||||
RICH_RE.lastIndex = 0
|
||||
if (!matches.length) {
|
||||
lines.push(['', trimmed])
|
||||
|
||||
while ((m = RICH_RE.exec(trimmed)) !== null) {
|
||||
matched = true
|
||||
const before = trimmed.slice(lastIndex, m.index)
|
||||
continue
|
||||
}
|
||||
|
||||
let cursor = 0
|
||||
|
||||
for (const m of matches) {
|
||||
const before = trimmed.slice(cursor, m.index)
|
||||
|
||||
if (before) {
|
||||
lines.push(['', before])
|
||||
}
|
||||
|
||||
lines.push([m[1]!, m[2]!])
|
||||
lastIndex = m.index + m[0].length
|
||||
cursor = m.index! + m[0].length
|
||||
}
|
||||
|
||||
if (!matched) {
|
||||
lines.push(['', trimmed])
|
||||
} else if (lastIndex < trimmed.length) {
|
||||
lines.push(['', trimmed.slice(lastIndex)])
|
||||
if (cursor < trimmed.length) {
|
||||
lines.push(['', trimmed.slice(cursor)])
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -76,10 +73,10 @@ const CADUCEUS_ART = [
|
|||
const LOGO_GRADIENT = [0, 0, 1, 1, 2, 2] as const
|
||||
const CADUC_GRADIENT = [2, 2, 1, 1, 0, 0, 1, 1, 2, 2, 3, 3, 3, 3, 3] as const
|
||||
|
||||
function colorize(art: string[], gradient: readonly number[], c: ThemeColors): Line[] {
|
||||
const palette = [c.gold, c.amber, c.bronze, c.dim]
|
||||
const colorize = (art: string[], gradient: readonly number[], c: ThemeColors): Line[] => {
|
||||
const p = [c.gold, c.amber, c.bronze, c.dim]
|
||||
|
||||
return art.map((text, i) => [palette[gradient[i]] ?? c.dim, text])
|
||||
return art.map((text, i) => [p[gradient[i]!] ?? c.dim, text])
|
||||
}
|
||||
|
||||
export const LOGO_WIDTH = 98
|
||||
|
|
@ -91,14 +88,6 @@ export const logo = (c: ThemeColors, customLogo?: string): Line[] =>
|
|||
export const caduceus = (c: ThemeColors, customHero?: string): Line[] =>
|
||||
customHero ? parseRichMarkup(customHero) : colorize(CADUCEUS_ART, CADUC_GRADIENT, c)
|
||||
|
||||
export function artWidth(lines: Line[]): number {
|
||||
let max = 0
|
||||
export const artWidth = (lines: Line[]) => lines.reduce((m, [, t]) => Math.max(m, t.length), 0)
|
||||
|
||||
for (const [, text] of lines) {
|
||||
if (text.length > max) {
|
||||
max = text.length
|
||||
}
|
||||
}
|
||||
|
||||
return max
|
||||
}
|
||||
type Line = [string, string]
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type { Theme } from '../theme.js'
|
|||
import type { Msg, Usage } from '../types.js'
|
||||
|
||||
const FACE_TICK_MS = 2500
|
||||
const HEART_COLORS = ['#ff5fa2', '#ff4d6d']
|
||||
|
||||
function FaceTicker({ color }: { color: string }) {
|
||||
const [tick, setTick] = useState(() => Math.floor(Math.random() * 1000))
|
||||
|
|
@ -76,10 +77,8 @@ export function GoodVibesHeart({ tick, t }: { tick: number; t: Theme }) {
|
|||
return
|
||||
}
|
||||
|
||||
const options = ['#ff5fa2', '#ff4d6d', t.color.amber]
|
||||
const picked = options[Math.floor(Math.random() * options.length)]!
|
||||
|
||||
setColor(picked)
|
||||
const palette = [...HEART_COLORS, t.color.amber]
|
||||
setColor(palette[Math.floor(Math.random() * palette.length)]!)
|
||||
setActive(true)
|
||||
|
||||
const id = setTimeout(() => setActive(false), 650)
|
||||
|
|
@ -102,19 +101,7 @@ export function StatusRule({
|
|||
sessionStartedAt,
|
||||
voiceLabel,
|
||||
t
|
||||
}: {
|
||||
cwdLabel: string
|
||||
cols: number
|
||||
busy: boolean
|
||||
status: string
|
||||
statusColor: string
|
||||
model: string
|
||||
usage: Usage
|
||||
bgCount: number
|
||||
sessionStartedAt?: number | null
|
||||
voiceLabel?: string
|
||||
t: Theme
|
||||
}) {
|
||||
}: StatusRuleProps) {
|
||||
const pct = usage.context_percent
|
||||
const barColor = ctxBarColor(pct, t)
|
||||
|
||||
|
|
@ -124,7 +111,6 @@ export function StatusRule({
|
|||
? `${fmtK(usage.total)} tok`
|
||||
: ''
|
||||
|
||||
const pctLabel = pct != null ? `${pct}%` : ''
|
||||
const bar = usage.context_max ? ctxBar(pct) : ''
|
||||
const leftWidth = Math.max(12, cols - cwdLabel.length - 3)
|
||||
|
||||
|
|
@ -139,7 +125,7 @@ export function StatusRule({
|
|||
{bar ? (
|
||||
<Text color={t.color.dim}>
|
||||
{' │ '}
|
||||
<Text color={barColor}>[{bar}]</Text> <Text color={barColor}>{pctLabel}</Text>
|
||||
<Text color={barColor}>[{bar}]</Text> <Text color={barColor}>{pct != null ? `${pct}%` : ''}</Text>
|
||||
</Text>
|
||||
) : null}
|
||||
{sessionStartedAt ? (
|
||||
|
|
@ -152,6 +138,7 @@ export function StatusRule({
|
|||
{bgCount > 0 ? <Text color={t.color.dim}> │ {bgCount} bg</Text> : null}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Text color={t.color.bronze}> ─ </Text>
|
||||
<Text color={t.color.label}>{cwdLabel}</Text>
|
||||
</Box>
|
||||
|
|
@ -174,17 +161,7 @@ export function FloatBox({ children, color }: { children: ReactNode; color: stri
|
|||
)
|
||||
}
|
||||
|
||||
export function StickyPromptTracker({
|
||||
messages,
|
||||
offsets,
|
||||
scrollRef,
|
||||
onChange
|
||||
}: {
|
||||
messages: readonly Msg[]
|
||||
offsets: ArrayLike<number>
|
||||
scrollRef: RefObject<ScrollBoxHandle | null>
|
||||
onChange: (text: string) => void
|
||||
}) {
|
||||
export function StickyPromptTracker({ messages, offsets, scrollRef, onChange }: StickyPromptTrackerProps) {
|
||||
useSyncExternalStore(
|
||||
useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]),
|
||||
() => {
|
||||
|
|
@ -210,13 +187,9 @@ export function StickyPromptTracker({
|
|||
return null
|
||||
}
|
||||
|
||||
export function TranscriptScrollbar({ scrollRef, t }: { scrollRef: RefObject<ScrollBoxHandle | null>; t: Theme }) {
|
||||
export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps) {
|
||||
useSyncExternalStore(
|
||||
useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]),
|
||||
// Quantize the scroll snapshot to the values the thumb actually renders
|
||||
// with — thumbTop + thumbSize + viewport height. Streaming drives
|
||||
// scrollHeight up by ~1 row at a time, but the quantized thumb usually
|
||||
// doesn't move, so we skip thousands of render cycles mid-turn.
|
||||
() => {
|
||||
const s = scrollRef.current
|
||||
|
||||
|
|
@ -304,3 +277,29 @@ export function TranscriptScrollbar({ scrollRef, t }: { scrollRef: RefObject<Scr
|
|||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
interface StatusRuleProps {
|
||||
bgCount: number
|
||||
busy: boolean
|
||||
cols: number
|
||||
cwdLabel: string
|
||||
model: string
|
||||
sessionStartedAt?: number | null
|
||||
status: string
|
||||
statusColor: string
|
||||
t: Theme
|
||||
usage: Usage
|
||||
voiceLabel?: string
|
||||
}
|
||||
|
||||
interface StickyPromptTrackerProps {
|
||||
messages: readonly Msg[]
|
||||
offsets: ArrayLike<number>
|
||||
onChange: (text: string) => void
|
||||
scrollRef: RefObject<ScrollBoxHandle | null>
|
||||
}
|
||||
|
||||
interface TranscriptScrollbarProps {
|
||||
scrollRef: RefObject<ScrollBoxHandle | null>
|
||||
t: Theme
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,20 +24,13 @@ const StreamingAssistant = memo(function StreamingAssistant({
|
|||
detailsMode,
|
||||
progress,
|
||||
t
|
||||
}: {
|
||||
busy: boolean
|
||||
cols: number
|
||||
compact?: boolean
|
||||
detailsMode: DetailsMode
|
||||
progress: AppLayoutProgressProps
|
||||
t: Theme
|
||||
}) {
|
||||
}: StreamingAssistantProps) {
|
||||
if (!progress.showProgressArea && !progress.showStreamingArea) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<>
|
||||
{progress.showProgressArea && (
|
||||
<Box flexDirection="column" marginBottom={progress.showStreamingArea ? 1 : 0}>
|
||||
<ToolTrail
|
||||
|
|
@ -67,7 +60,7 @@ const StreamingAssistant = memo(function StreamingAssistant({
|
|||
t={t}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -79,15 +72,13 @@ const TranscriptPane = memo(function TranscriptPane({
|
|||
}: Pick<AppLayoutProps, 'actions' | 'composer' | 'progress' | 'transcript'>) {
|
||||
const ui = useStore($uiState)
|
||||
|
||||
const visibleHistory = transcript.virtualRows.slice(transcript.virtualHistory.start, transcript.virtualHistory.end)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollBox flexDirection="column" flexGrow={1} flexShrink={1} ref={transcript.scrollRef} stickyScroll>
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
{transcript.virtualHistory.topSpacer > 0 ? <Box height={transcript.virtualHistory.topSpacer} /> : null}
|
||||
|
||||
{visibleHistory.map(row => (
|
||||
{transcript.virtualRows.slice(transcript.virtualHistory.start, transcript.virtualHistory.end).map(row => (
|
||||
<Box flexDirection="column" key={row.key} ref={transcript.virtualHistory.measureRef(row.key)}>
|
||||
{row.msg.kind === 'intro' ? (
|
||||
<Box flexDirection="column" paddingTop={1}>
|
||||
|
|
@ -234,6 +225,7 @@ const ComposerPane = memo(function ComposerPane({
|
|||
placeholder={composer.empty ? PLACEHOLDER : ui.busy ? 'Ctrl+C to interrupt…' : ''}
|
||||
value={composer.input}
|
||||
/>
|
||||
|
||||
<Box position="absolute" right={0}>
|
||||
<GoodVibesHeart t={ui.theme} tick={status.goodVibesTick} />
|
||||
</Box>
|
||||
|
|
@ -267,3 +259,12 @@ export const AppLayout = memo(function AppLayout({
|
|||
</AlternateScreen>
|
||||
)
|
||||
})
|
||||
|
||||
interface StreamingAssistantProps {
|
||||
busy: boolean
|
||||
cols: number
|
||||
compact?: boolean
|
||||
detailsMode: DetailsMode
|
||||
progress: AppLayoutProgressProps
|
||||
t: Theme
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,8 +28,7 @@ export function AppOverlays({
|
|||
const overlay = useStore($overlayState)
|
||||
const ui = useStore($uiState)
|
||||
|
||||
if (
|
||||
!(
|
||||
const hasAny =
|
||||
overlay.approval ||
|
||||
overlay.clarify ||
|
||||
overlay.modelPicker ||
|
||||
|
|
@ -38,8 +37,8 @@ export function AppOverlays({
|
|||
overlay.secret ||
|
||||
overlay.sudo ||
|
||||
completions.length
|
||||
)
|
||||
) {
|
||||
|
||||
if (!hasAny) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,11 +20,10 @@ export function ArtLines({ lines }: { lines: [string, string][] }) {
|
|||
export function Banner({ t }: { t: Theme }) {
|
||||
const cols = useStdout().stdout?.columns ?? 80
|
||||
const logoLines = logo(t.color, t.bannerLogo || undefined)
|
||||
const logoW = t.bannerLogo ? artWidth(logoLines) : LOGO_WIDTH
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
{cols >= logoW ? (
|
||||
{cols >= (t.bannerLogo ? artWidth(logoLines) : LOGO_WIDTH) ? (
|
||||
<ArtLines lines={logoLines} />
|
||||
) : (
|
||||
<Text bold color={t.color.gold}>
|
||||
|
|
@ -37,18 +36,14 @@ export function Banner({ t }: { t: Theme }) {
|
|||
)
|
||||
}
|
||||
|
||||
export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string | null; t: Theme }) {
|
||||
export function SessionPanel({ info, sid, t }: SessionPanelProps) {
|
||||
const cols = useStdout().stdout?.columns ?? 100
|
||||
const heroLines = caduceus(t.color, t.bannerHero || undefined)
|
||||
const heroW = artWidth(heroLines) || CADUCEUS_WIDTH
|
||||
const leftW = Math.min(heroW + 4, Math.floor(cols * 0.4))
|
||||
const leftW = Math.min((artWidth(heroLines) || CADUCEUS_WIDTH) + 4, Math.floor(cols * 0.4))
|
||||
const wide = cols >= 90 && leftW + 40 < cols
|
||||
// Keep an explicit gutter so right border never gets overwritten by long lines.
|
||||
const w = Math.max(20, wide ? cols - leftW - 14 : cols - 12)
|
||||
const lineBudget = Math.max(12, w - 2)
|
||||
const cwd = info.cwd || process.cwd()
|
||||
const strip = (s: string) => (s.endsWith('_tools') ? s.slice(0, -6) : s)
|
||||
const title = `${t.brand.name}${info.version ? ` v${info.version}` : ''}${info.release_date ? ` (${info.release_date})` : ''}`
|
||||
|
||||
const truncLine = (pfx: string, items: string[]) => {
|
||||
let line = ''
|
||||
|
|
@ -78,12 +73,14 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string
|
|||
<Text bold color={t.color.amber}>
|
||||
Available {title}
|
||||
</Text>
|
||||
|
||||
{shown.map(([k, vs]) => (
|
||||
<Text key={k} wrap="truncate">
|
||||
<Text color={t.color.dim}>{strip(k)}: </Text>
|
||||
<Text color={t.color.cornsilk}>{truncLine(strip(k) + ': ', vs)}</Text>
|
||||
</Text>
|
||||
))}
|
||||
|
||||
{overflow > 0 && (
|
||||
<Text color={t.color.dim}>
|
||||
(and {overflow} {overflowLabel})
|
||||
|
|
@ -99,13 +96,16 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string
|
|||
<Box flexDirection="column" marginRight={2} width={leftW}>
|
||||
<ArtLines lines={heroLines} />
|
||||
<Text />
|
||||
|
||||
<Text color={t.color.amber}>
|
||||
{info.model.split('/').pop()}
|
||||
<Text color={t.color.dim}> · Nous Research</Text>
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
{cwd}
|
||||
{info.cwd || process.cwd()}
|
||||
</Text>
|
||||
|
||||
{sid && (
|
||||
<Text>
|
||||
<Text color={t.color.sessionLabel}>Session: </Text>
|
||||
|
|
@ -114,21 +114,27 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string
|
|||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box flexDirection="column" width={w}>
|
||||
<Box justifyContent="center" marginBottom={1}>
|
||||
<Text bold color={t.color.gold}>
|
||||
{title}
|
||||
{t.brand.name}
|
||||
{info.version ? ` v${info.version}` : ''}
|
||||
{info.release_date ? ` (${info.release_date})` : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{section('Tools', info.tools, 8, 'more toolsets…')}
|
||||
{section('Skills', info.skills)}
|
||||
<Text />
|
||||
|
||||
<Text color={t.color.cornsilk}>
|
||||
{flat(info.tools).length} tools{' · '}
|
||||
{flat(info.skills).length} skills
|
||||
{' · '}
|
||||
<Text color={t.color.dim}>/help for commands</Text>
|
||||
</Text>
|
||||
|
||||
{typeof info.update_behind === 'number' && info.update_behind > 0 && (
|
||||
<Text bold color="yellow">
|
||||
! {info.update_behind} {info.update_behind === 1 ? 'commit' : 'commits'} behind
|
||||
|
|
@ -150,7 +156,7 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string
|
|||
)
|
||||
}
|
||||
|
||||
export function Panel({ sections, t, title }: { sections: PanelSection[]; t: Theme; title: string }) {
|
||||
export function Panel({ sections, t, title }: PanelProps) {
|
||||
return (
|
||||
<Box borderColor={t.color.bronze} borderStyle="round" flexDirection="column" paddingX={2} paddingY={1}>
|
||||
<Box justifyContent="center" marginBottom={1}>
|
||||
|
|
@ -186,3 +192,15 @@ export function Panel({ sections, t, title }: { sections: PanelSection[]; t: The
|
|||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
interface PanelProps {
|
||||
sections: PanelSection[]
|
||||
t: Theme
|
||||
title: string
|
||||
}
|
||||
|
||||
interface SessionPanelProps {
|
||||
info: SessionInfo
|
||||
sid?: string | null
|
||||
t: Theme
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,21 +5,7 @@ import type { Theme } from '../theme.js'
|
|||
|
||||
import { TextInput } from './textInput.js'
|
||||
|
||||
export function MaskedPrompt({
|
||||
cols = 80,
|
||||
icon,
|
||||
label,
|
||||
onSubmit,
|
||||
sub,
|
||||
t
|
||||
}: {
|
||||
cols?: number
|
||||
icon: string
|
||||
label: string
|
||||
onSubmit: (v: string) => void
|
||||
sub?: string
|
||||
t: Theme
|
||||
}) {
|
||||
export function MaskedPrompt({ cols = 80, icon, label, onSubmit, sub, t }: MaskedPromptProps) {
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
return (
|
||||
|
|
@ -27,6 +13,7 @@ export function MaskedPrompt({
|
|||
<Text bold color={t.color.warn}>
|
||||
{icon} {label}
|
||||
</Text>
|
||||
|
||||
{sub && <Text color={t.color.dim}> {sub}</Text>}
|
||||
|
||||
<Box>
|
||||
|
|
@ -36,3 +23,12 @@ export function MaskedPrompt({
|
|||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
interface MaskedPromptProps {
|
||||
cols?: number
|
||||
icon: string
|
||||
label: string
|
||||
onSubmit: (v: string) => void
|
||||
sub?: string
|
||||
t: Theme
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,14 +18,7 @@ export const MessageLine = memo(function MessageLine({
|
|||
isStreaming = false,
|
||||
msg,
|
||||
t
|
||||
}: {
|
||||
cols: number
|
||||
compact?: boolean
|
||||
detailsMode?: DetailsMode
|
||||
isStreaming?: boolean
|
||||
msg: Msg
|
||||
t: Theme
|
||||
}) {
|
||||
}: MessageLineProps) {
|
||||
if (msg.kind === 'trail' && msg.tools?.length) {
|
||||
return detailsMode === 'hidden' ? null : (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
|
|
@ -110,3 +103,12 @@ export const MessageLine = memo(function MessageLine({
|
|||
</Box>
|
||||
)
|
||||
})
|
||||
|
||||
interface MessageLineProps {
|
||||
cols: number
|
||||
compact?: boolean
|
||||
detailsMode?: DetailsMode
|
||||
isStreaming?: boolean
|
||||
msg: Msg
|
||||
t: Theme
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,19 +10,13 @@ const VISIBLE = 12
|
|||
|
||||
const pageOffset = (count: number, sel: number) => Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), count - VISIBLE))
|
||||
|
||||
export function ModelPicker({
|
||||
gw,
|
||||
onCancel,
|
||||
onSelect,
|
||||
sessionId,
|
||||
t
|
||||
}: {
|
||||
gw: GatewayClient
|
||||
onCancel: () => void
|
||||
onSelect: (value: string) => void
|
||||
sessionId: string | null
|
||||
t: Theme
|
||||
}) {
|
||||
const visibleItems = (items: string[], sel: number) => {
|
||||
const off = pageOffset(items.length, sel)
|
||||
|
||||
return { items: items.slice(off, off + VISIBLE), off }
|
||||
}
|
||||
|
||||
export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPickerProps) {
|
||||
const [providers, setProviders] = useState<ModelOptionProvider[]>([])
|
||||
const [currentModel, setCurrentModel] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
|
|
@ -66,12 +60,6 @@ export function ModelPicker({
|
|||
const provider = providers[providerIdx]
|
||||
const models = provider?.models ?? []
|
||||
|
||||
const visibleItems = (items: string[], sel: number) => {
|
||||
const off = pageOffset(items.length, sel)
|
||||
|
||||
return { items: items.slice(off, off + VISIBLE), off }
|
||||
}
|
||||
|
||||
useInput((ch, key) => {
|
||||
if (key.escape) {
|
||||
if (stage === 'model') {
|
||||
|
|
@ -182,9 +170,11 @@ export function ModelPicker({
|
|||
<Text bold color={t.color.amber}>
|
||||
Select Provider
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.dim}>Current model: {currentModel || '(unknown)'}</Text>
|
||||
{provider?.warning ? <Text color={t.color.label}>warning: {provider.warning}</Text> : null}
|
||||
{off > 0 && <Text color={t.color.dim}> ↑ {off} more</Text>}
|
||||
|
||||
{items.map((row, i) => {
|
||||
const idx = off + i
|
||||
|
||||
|
|
@ -195,6 +185,7 @@ export function ModelPicker({
|
|||
</Text>
|
||||
)
|
||||
})}
|
||||
|
||||
{off + VISIBLE < rows.length && <Text color={t.color.dim}> ↓ {rows.length - off - VISIBLE} more</Text>}
|
||||
<Text color={t.color.dim}>persist: {persistGlobal ? 'global' : 'session'} · g toggle</Text>
|
||||
<Text color={t.color.dim}>↑/↓ select · Enter choose · 1-9,0 quick · Esc cancel</Text>
|
||||
|
|
@ -209,10 +200,12 @@ export function ModelPicker({
|
|||
<Text bold color={t.color.amber}>
|
||||
Select Model
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.dim}>{provider?.name || '(unknown provider)'}</Text>
|
||||
{!models.length ? <Text color={t.color.dim}>no models listed for this provider</Text> : null}
|
||||
{provider?.warning ? <Text color={t.color.label}>warning: {provider.warning}</Text> : null}
|
||||
{off > 0 && <Text color={t.color.dim}> ↑ {off} more</Text>}
|
||||
|
||||
{items.map((row, i) => {
|
||||
const idx = off + i
|
||||
|
||||
|
|
@ -223,6 +216,7 @@ export function ModelPicker({
|
|||
</Text>
|
||||
)
|
||||
})}
|
||||
|
||||
{off + VISIBLE < models.length && <Text color={t.color.dim}> ↓ {models.length - off - VISIBLE} more</Text>}
|
||||
<Text color={t.color.dim}>persist: {persistGlobal ? 'global' : 'session'} · g toggle</Text>
|
||||
<Text color={t.color.dim}>
|
||||
|
|
@ -231,3 +225,11 @@ export function ModelPicker({
|
|||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
interface ModelPickerProps {
|
||||
gw: GatewayClient
|
||||
onCancel: () => void
|
||||
onSelect: (value: string) => void
|
||||
sessionId: string | null
|
||||
t: Theme
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,11 @@ import type { ApprovalReq, ClarifyReq } from '../types.js'
|
|||
|
||||
import { TextInput } from './textInput.js'
|
||||
|
||||
export function ApprovalPrompt({ onChoice, req, t }: { onChoice: (s: string) => void; req: ApprovalReq; t: Theme }) {
|
||||
const OPTS = ['once', 'session', 'always', 'deny'] as const
|
||||
const LABELS = { always: 'Always allow', deny: 'Deny', once: 'Allow once', session: 'Allow this session' } as const
|
||||
|
||||
export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) {
|
||||
const [sel, setSel] = useState(3)
|
||||
const opts = ['once', 'session', 'always', 'deny'] as const
|
||||
const labels = { always: 'Always allow', deny: 'Deny', once: 'Allow once', session: 'Allow this session' } as const
|
||||
|
||||
useInput((ch, key) => {
|
||||
if (key.upArrow && sel > 0) {
|
||||
|
|
@ -21,7 +22,7 @@ export function ApprovalPrompt({ onChoice, req, t }: { onChoice: (s: string) =>
|
|||
}
|
||||
|
||||
if (key.return) {
|
||||
onChoice(opts[sel]!)
|
||||
onChoice(OPTS[sel]!)
|
||||
}
|
||||
|
||||
if (ch === 'o') {
|
||||
|
|
@ -46,34 +47,25 @@ export function ApprovalPrompt({ onChoice, req, t }: { onChoice: (s: string) =>
|
|||
<Text bold color={t.color.warn}>
|
||||
! DANGEROUS COMMAND: {req.description}
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.dim}> {req.command}</Text>
|
||||
<Text />
|
||||
{opts.map((o, i) => (
|
||||
|
||||
{OPTS.map((o, i) => (
|
||||
<Text key={o}>
|
||||
<Text color={sel === i ? t.color.warn : t.color.dim}>{sel === i ? '▸ ' : ' '}</Text>
|
||||
<Text color={sel === i ? t.color.cornsilk : t.color.dim}>
|
||||
[{o[0]}] {labels[o]}
|
||||
[{o[0]}] {LABELS[o]}
|
||||
</Text>
|
||||
</Text>
|
||||
))}
|
||||
|
||||
<Text color={t.color.dim}>↑/↓ select · Enter confirm · o/s/a/d quick pick</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export function ClarifyPrompt({
|
||||
cols = 80,
|
||||
onAnswer,
|
||||
onCancel,
|
||||
req,
|
||||
t
|
||||
}: {
|
||||
cols?: number
|
||||
onAnswer: (s: string) => void
|
||||
onCancel: () => void
|
||||
req: ClarifyReq
|
||||
t: Theme
|
||||
}) {
|
||||
export function ClarifyPrompt({ cols = 80, onAnswer, onCancel, req, t }: ClarifyPromptProps) {
|
||||
const [sel, setSel] = useState(0)
|
||||
const [custom, setCustom] = useState('')
|
||||
const [typing, setTyping] = useState(false)
|
||||
|
|
@ -117,8 +109,6 @@ export function ClarifyPrompt({
|
|||
})
|
||||
|
||||
if (typing || !choices.length) {
|
||||
const hint = choices.length ? 'Enter send · Esc back · Ctrl+C cancel' : 'Enter send · Esc cancel · Ctrl+C cancel'
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{heading}
|
||||
|
|
@ -128,7 +118,7 @@ export function ClarifyPrompt({
|
|||
<TextInput columns={Math.max(20, cols - 6)} onChange={setCustom} onSubmit={onAnswer} value={custom} />
|
||||
</Box>
|
||||
|
||||
<Text color={t.color.dim}>{hint}</Text>
|
||||
<Text color={t.color.dim}>Enter send · Esc {choices.length ? 'back' : 'cancel'} · Ctrl+C cancel</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -150,3 +140,17 @@ export function ClarifyPrompt({
|
|||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
interface ApprovalPromptProps {
|
||||
onChoice: (s: string) => void
|
||||
req: ApprovalReq
|
||||
t: Theme
|
||||
}
|
||||
|
||||
interface ClarifyPromptProps {
|
||||
cols?: number
|
||||
onAnswer: (s: string) => void
|
||||
onCancel: () => void
|
||||
req: ClarifyReq
|
||||
t: Theme
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,17 +14,7 @@ export function getQueueWindow(queueLen: number, queueEditIdx: number | null) {
|
|||
return { end, showLead: start > 0, showTail: end < queueLen, start }
|
||||
}
|
||||
|
||||
export function QueuedMessages({
|
||||
cols,
|
||||
queueEditIdx,
|
||||
queued,
|
||||
t
|
||||
}: {
|
||||
cols: number
|
||||
queueEditIdx: number | null
|
||||
queued: string[]
|
||||
t: Theme
|
||||
}) {
|
||||
export function QueuedMessages({ cols, queueEditIdx, queued, t }: QueuedMessagesProps) {
|
||||
if (!queued.length) {
|
||||
return null
|
||||
}
|
||||
|
|
@ -36,12 +26,14 @@ export function QueuedMessages({
|
|||
<Text color={t.color.dim} dimColor>
|
||||
queued ({queued.length}){queueEditIdx !== null ? ` · editing ${queueEditIdx + 1}` : ''}
|
||||
</Text>
|
||||
|
||||
{q.showLead && (
|
||||
<Text color={t.color.dim} dimColor>
|
||||
{' '}
|
||||
…
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{queued.slice(q.start, q.end).map((item, i) => {
|
||||
const idx = q.start + i
|
||||
const active = queueEditIdx === idx
|
||||
|
|
@ -52,6 +44,7 @@ export function QueuedMessages({
|
|||
</Text>
|
||||
)
|
||||
})}
|
||||
|
||||
{q.showTail && (
|
||||
<Text color={t.color.dim} dimColor>
|
||||
{' '}…and {queued.length - q.end} more
|
||||
|
|
@ -60,3 +53,10 @@ export function QueuedMessages({
|
|||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
interface QueuedMessagesProps {
|
||||
cols: number
|
||||
queueEditIdx: number | null
|
||||
queued: string[]
|
||||
t: Theme
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ import type { SessionListItem, SessionListResponse } from '../gatewayTypes.js'
|
|||
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
|
||||
function age(ts: number): string {
|
||||
const VISIBLE = 15
|
||||
|
||||
const age = (ts: number) => {
|
||||
const d = (Date.now() / 1000 - ts) / 86400
|
||||
|
||||
if (d < 1) {
|
||||
|
|
@ -20,19 +22,7 @@ function age(ts: number): string {
|
|||
return `${Math.floor(d)}d ago`
|
||||
}
|
||||
|
||||
const VISIBLE = 15
|
||||
|
||||
export function SessionPicker({
|
||||
gw,
|
||||
onCancel,
|
||||
onSelect,
|
||||
t
|
||||
}: {
|
||||
gw: GatewayClient
|
||||
onCancel: () => void
|
||||
onSelect: (id: string) => void
|
||||
t: Theme
|
||||
}) {
|
||||
export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps) {
|
||||
const [items, setItems] = useState<SessionListItem[]>([])
|
||||
const [err, setErr] = useState('')
|
||||
const [sel, setSel] = useState(0)
|
||||
|
|
@ -107,36 +97,48 @@ export function SessionPicker({
|
|||
}
|
||||
|
||||
const off = Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), items.length - VISIBLE))
|
||||
const visible = items.slice(off, off + VISIBLE)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={t.color.amber}>
|
||||
Resume Session
|
||||
</Text>
|
||||
|
||||
{off > 0 && <Text color={t.color.dim}> ↑ {off} more</Text>}
|
||||
{visible.map((s, vi) => {
|
||||
|
||||
{items.slice(off, off + VISIBLE).map((s, vi) => {
|
||||
const i = off + vi
|
||||
|
||||
return (
|
||||
<Box key={s.id}>
|
||||
<Text color={sel === i ? t.color.label : t.color.dim}>{sel === i ? '▸ ' : ' '}</Text>
|
||||
|
||||
<Box width={30}>
|
||||
<Text color={sel === i ? t.color.cornsilk : t.color.dim}>
|
||||
{String(i + 1).padStart(2)}. [{s.id}]
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box width={30}>
|
||||
<Text color={t.color.dim}>
|
||||
({s.message_count} msgs, {age(s.started_at)}, {s.source || 'tui'})
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Text color={sel === i ? t.color.cornsilk : t.color.dim}>{s.title || s.preview || '(untitled)'}</Text>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
|
||||
{off + VISIBLE < items.length && <Text color={t.color.dim}> ↓ {items.length - off - VISIBLE} more</Text>}
|
||||
<Text color={t.color.dim}>↑/↓ select · Enter resume · 1-9 quick · Esc cancel</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
interface SessionPickerProps {
|
||||
gw: GatewayClient
|
||||
onCancel: () => void
|
||||
onSelect: (id: string) => void
|
||||
t: Theme
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,8 +11,6 @@ type InkExt = typeof Ink & {
|
|||
const ink = Ink as unknown as InkExt
|
||||
const { Box, Text, useStdin, useInput, stringWidth, useDeclaredCursor, useTerminalFocus } = ink
|
||||
|
||||
// ── ANSI escapes ─────────────────────────────────────────────────────
|
||||
|
||||
const ESC = '\x1b'
|
||||
const INV = `${ESC}[7m`
|
||||
const INV_OFF = `${ESC}[27m`
|
||||
|
|
@ -25,8 +23,6 @@ const BRACKET_PASTE = new RegExp(`${ESC}?\\[20[01]~`, 'g')
|
|||
const invert = (s: string) => INV + s + INV_OFF
|
||||
const dim = (s: string) => DIM + s + DIM_OFF
|
||||
|
||||
// ── Grapheme segmenter (lazy singleton) ──────────────────────────────
|
||||
|
||||
let _seg: Intl.Segmenter | null = null
|
||||
const seg = () => (_seg ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' }))
|
||||
const STOP_CACHE_MAX = 32
|
||||
|
|
@ -106,8 +102,6 @@ function nextPos(s: string, p: number) {
|
|||
return s.length
|
||||
}
|
||||
|
||||
// ── Word movement ────────────────────────────────────────────────────
|
||||
|
||||
function wordLeft(s: string, p: number) {
|
||||
let i = snapPos(s, p) - 1
|
||||
|
||||
|
|
@ -136,8 +130,6 @@ function wordRight(s: string, p: number) {
|
|||
return i
|
||||
}
|
||||
|
||||
// ── Cursor layout (line/column from offset + terminal width) ─────────
|
||||
|
||||
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)
|
||||
|
|
@ -226,8 +218,6 @@ function offsetFromPosition(value: string, row: number, col: number, cols: numbe
|
|||
return lastOffset
|
||||
}
|
||||
|
||||
// ── Render value with inverse-video cursor ───────────────────────────
|
||||
|
||||
function renderWithCursor(value: string, cursor: number) {
|
||||
const pos = Math.max(0, Math.min(cursor, value.length))
|
||||
|
||||
|
|
@ -250,8 +240,6 @@ function renderWithCursor(value: string, cursor: number) {
|
|||
return done ? out : out + invert(' ')
|
||||
}
|
||||
|
||||
// ── Forward-delete detection hook ────────────────────────────────────
|
||||
|
||||
function useFwdDelete(active: boolean) {
|
||||
const ref = useRef(false)
|
||||
const { inputEmitter: ee } = useStdin()
|
||||
|
|
@ -275,29 +263,6 @@ function useFwdDelete(active: boolean) {
|
|||
return ref
|
||||
}
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PasteEvent {
|
||||
bracketed?: boolean
|
||||
cursor: number
|
||||
hotkey?: boolean
|
||||
text: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
columns?: number
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
onSubmit?: (v: string) => void
|
||||
onPaste?: (e: PasteEvent) => { cursor: number; value: string } | null
|
||||
mask?: string
|
||||
placeholder?: string
|
||||
focus?: boolean
|
||||
}
|
||||
|
||||
// ── Component ────────────────────────────────────────────────────────
|
||||
|
||||
export function TextInput({
|
||||
columns = 80,
|
||||
value,
|
||||
|
|
@ -307,7 +272,7 @@ export function TextInput({
|
|||
mask,
|
||||
placeholder = '',
|
||||
focus = true
|
||||
}: Props) {
|
||||
}: TextInputProps) {
|
||||
const [cur, setCur] = useState(value.length)
|
||||
const fwdDel = useFwdDelete(focus)
|
||||
const termFocus = useTerminalFocus()
|
||||
|
|
@ -331,8 +296,6 @@ export function TextInput({
|
|||
const raw = self.current ? vRef.current : value
|
||||
const display = mask ? raw.replace(/[^\n]/g, mask[0] ?? '*') : raw
|
||||
|
||||
// ── Cursor declaration ───────────────────────────────────────────
|
||||
|
||||
const layout = useMemo(() => cursorLayout(display, cur, columns), [columns, cur, display])
|
||||
|
||||
const boxRef = useDeclaredCursor({
|
||||
|
|
@ -353,18 +316,6 @@ export function TextInput({
|
|||
return renderWithCursor(display, cur)
|
||||
}, [cur, display, focus, placeholder])
|
||||
|
||||
const clickCursor = (e: { localRow?: number; localCol?: number }) => {
|
||||
if (!focus) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = offsetFromPosition(display, e.localRow ?? 0, e.localCol ?? 0, columns)
|
||||
setCur(next)
|
||||
curRef.current = next
|
||||
}
|
||||
|
||||
// ── Sync external value changes ──────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (self.current) {
|
||||
self.current = false
|
||||
|
|
@ -386,8 +337,6 @@ export function TextInput({
|
|||
[]
|
||||
)
|
||||
|
||||
// ── Buffer ops (synchronous, ref-based) ──────────────────────────
|
||||
|
||||
const commit = (next: string, nextCur: number, track = true) => {
|
||||
const prev = vRef.current
|
||||
const c = snapPos(next, nextCur)
|
||||
|
|
@ -450,18 +399,14 @@ export function TextInput({
|
|||
|
||||
const ins = (v: string, c: number, s: string) => v.slice(0, c) + s + v.slice(c)
|
||||
|
||||
// ── Input handler ────────────────────────────────────────────────
|
||||
|
||||
useInput(
|
||||
(inp: string, k: Key, event: InputEvent) => {
|
||||
const raw = event.keypress.raw
|
||||
const metaPaste = raw === '\x1bv' || raw === '\x1bV'
|
||||
const eventRaw = event.keypress.raw
|
||||
|
||||
if (metaPaste) {
|
||||
if (eventRaw === '\x1bv' || eventRaw === '\x1bV') {
|
||||
return void emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current })
|
||||
}
|
||||
|
||||
// Delegated to App
|
||||
if (
|
||||
k.upArrow ||
|
||||
k.downArrow ||
|
||||
|
|
@ -487,7 +432,6 @@ export function TextInput({
|
|||
let v = vRef.current
|
||||
const mod = k.ctrl || k.meta
|
||||
|
||||
// Undo / redo
|
||||
if (k.ctrl && inp === 'z') {
|
||||
return swap(undo, redo)
|
||||
}
|
||||
|
|
@ -496,7 +440,6 @@ export function TextInput({
|
|||
return swap(redo, undo)
|
||||
}
|
||||
|
||||
// Navigation
|
||||
if (k.home || (k.ctrl && inp === 'a')) {
|
||||
c = 0
|
||||
} else if (k.end || (k.ctrl && inp === 'e')) {
|
||||
|
|
@ -509,10 +452,7 @@ export function TextInput({
|
|||
c = wordLeft(v, c)
|
||||
} else if (k.meta && inp === 'f') {
|
||||
c = wordRight(v, c)
|
||||
}
|
||||
|
||||
// Deletion
|
||||
else if ((k.backspace || k.delete) && !fwdDel.current && c > 0) {
|
||||
} else if ((k.backspace || k.delete) && !fwdDel.current && c > 0) {
|
||||
if (mod) {
|
||||
const t = wordLeft(v, c)
|
||||
v = v.slice(0, t) + v.slice(c)
|
||||
|
|
@ -538,31 +478,28 @@ export function TextInput({
|
|||
c = 0
|
||||
} else if (k.ctrl && inp === 'k') {
|
||||
v = v.slice(0, c)
|
||||
}
|
||||
|
||||
// Text insertion / paste buffering
|
||||
else if (inp.length > 0) {
|
||||
} else if (inp.length > 0) {
|
||||
const bracketed = inp.includes('[200~')
|
||||
const raw = inp.replace(BRACKET_PASTE, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||
const text = inp.replace(BRACKET_PASTE, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||
|
||||
if (bracketed && emitPaste({ bracketed: true, cursor: c, text: raw, value: v })) {
|
||||
if (bracketed && emitPaste({ bracketed: true, cursor: c, text, value: v })) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!raw) {
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
|
||||
if (raw === '\n') {
|
||||
if (text === '\n') {
|
||||
return commit(ins(v, c, '\n'), c + 1)
|
||||
}
|
||||
|
||||
if (raw.length > 1 || raw.includes('\n')) {
|
||||
if (text.length > 1 || text.includes('\n')) {
|
||||
if (!pasteBuf.current) {
|
||||
pastePos.current = c
|
||||
}
|
||||
|
||||
pasteBuf.current += raw
|
||||
pasteBuf.current += text
|
||||
|
||||
if (pasteTimer.current) {
|
||||
clearTimeout(pasteTimer.current)
|
||||
|
|
@ -573,9 +510,9 @@ export function TextInput({
|
|||
return
|
||||
}
|
||||
|
||||
if (PRINTABLE.test(raw)) {
|
||||
v = v.slice(0, c) + raw + v.slice(c)
|
||||
c += raw.length
|
||||
if (PRINTABLE.test(text)) {
|
||||
v = v.slice(0, c) + text + v.slice(c)
|
||||
c += text.length
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
|
@ -588,11 +525,39 @@ export function TextInput({
|
|||
{ isActive: focus }
|
||||
)
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<Box onClick={clickCursor} ref={boxRef}>
|
||||
<Box
|
||||
onClick={(e: { localRow?: number; localCol?: number }) => {
|
||||
if (!focus) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = offsetFromPosition(display, e.localRow ?? 0, e.localCol ?? 0, columns)
|
||||
setCur(next)
|
||||
curRef.current = next
|
||||
}}
|
||||
ref={boxRef}
|
||||
>
|
||||
<Text wrap="wrap">{rendered}</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export interface PasteEvent {
|
||||
bracketed?: boolean
|
||||
cursor: number
|
||||
hotkey?: boolean
|
||||
text: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface TextInputProps {
|
||||
columns?: number
|
||||
focus?: boolean
|
||||
mask?: string
|
||||
onChange: (v: string) => void
|
||||
onPaste?: (e: PasteEvent) => { cursor: number; value: string } | null
|
||||
onSubmit?: (v: string) => void
|
||||
placeholder?: string
|
||||
value: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,16 @@ import type { ReactNode } from 'react'
|
|||
import { $uiState } from '../app/uiStore.js'
|
||||
import type { ThemeColors } from '../theme.js'
|
||||
|
||||
export function Fg({ bold, c, children, dim, italic, literal, strikethrough, underline, wrap }: FgProps) {
|
||||
const { theme } = useStore($uiState)
|
||||
|
||||
return (
|
||||
<Text color={literal ?? (c && theme.color[c])} dimColor={dim} {...{ bold, italic, strikethrough, underline, wrap }}>
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
export type ThemeColor = keyof ThemeColors
|
||||
|
||||
export interface FgProps {
|
||||
|
|
@ -18,28 +28,3 @@ export interface FgProps {
|
|||
underline?: boolean
|
||||
wrap?: 'end' | 'middle' | 'truncate' | 'truncate-end' | 'truncate-middle' | 'truncate-start' | 'wrap' | 'wrap-trim'
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme-aware text. `literal` wins; otherwise `c` is a palette key.
|
||||
*
|
||||
* <Fg c="amber">hi</Fg> // amber
|
||||
* <Fg c="dim" dim>…</Fg> // dim cornsilk
|
||||
* <Fg literal="#ff00ff">x</Fg> // raw hex
|
||||
*/
|
||||
export function Fg({ bold, c, children, dim, italic, literal, strikethrough, underline, wrap }: FgProps) {
|
||||
const { theme } = useStore($uiState)
|
||||
|
||||
return (
|
||||
<Text
|
||||
bold={bold}
|
||||
color={literal ?? (c && theme.color[c])}
|
||||
dimColor={dim}
|
||||
italic={italic}
|
||||
strikethrough={strikethrough}
|
||||
underline={underline}
|
||||
wrap={wrap}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,2 @@
|
|||
export const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim()
|
||||
|
||||
export const MOUSE_TRACKING = !/^(1|true|yes|on)$/.test(
|
||||
(process.env.HERMES_TUI_DISABLE_MOUSE ?? '').trim().toLowerCase()
|
||||
)
|
||||
export const MOUSE_TRACKING = !/^(?:1|true|yes|on)$/i.test((process.env.HERMES_TUI_DISABLE_MOUSE ?? '').trim())
|
||||
|
|
|
|||
|
|
@ -11,30 +11,20 @@ const FORTUNES = [
|
|||
'your instincts are correctly suspicious of that one branch'
|
||||
]
|
||||
|
||||
const LEGENDARY_FORTUNES = [
|
||||
const LEGENDARY = [
|
||||
'legendary drop: one-line fix, first try',
|
||||
'legendary drop: every flaky test passes cleanly',
|
||||
'legendary drop: your diff teaches by itself'
|
||||
]
|
||||
|
||||
const hash = (input: string) => {
|
||||
let out = 2166136261
|
||||
const hash = (s: string) => [...s].reduce((h, c) => Math.imul(h ^ c.charCodeAt(0), 16777619), 2166136261) >>> 0
|
||||
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
out ^= input.charCodeAt(i)
|
||||
out = Math.imul(out, 16777619)
|
||||
}
|
||||
const fromScore = (n: number) => {
|
||||
const rare = n % 20 === 0
|
||||
const bag = rare ? LEGENDARY : FORTUNES
|
||||
|
||||
return out >>> 0
|
||||
}
|
||||
|
||||
const fromScore = (score: number) => {
|
||||
const rare = score % 20 === 0
|
||||
const bag = rare ? LEGENDARY_FORTUNES : FORTUNES
|
||||
|
||||
return `${rare ? '🌟' : '🔮'} ${bag[score % bag.length]}`
|
||||
return `${rare ? '🌟' : '🔮'} ${bag[n % bag.length]}`
|
||||
}
|
||||
|
||||
export const randomFortune = () => fromScore(Math.floor(Math.random() * 0x7fffffff))
|
||||
|
||||
export const dailyFortune = (seed: null | string) => fromScore(hash(`${seed || 'anon'}|${new Date().toDateString()}`))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { DetailsMode } from '../types.js'
|
||||
|
||||
const DETAILS_MODES: DetailsMode[] = ['hidden', 'collapsed', 'expanded']
|
||||
const MODES = ['hidden', 'collapsed', 'expanded'] as const
|
||||
|
||||
const THINKING_FALLBACK: Record<string, DetailsMode> = {
|
||||
collapsed: 'collapsed',
|
||||
|
|
@ -11,12 +11,10 @@ const THINKING_FALLBACK: Record<string, DetailsMode> = {
|
|||
export const parseDetailsMode = (v: unknown): DetailsMode | null => {
|
||||
const s = typeof v === 'string' ? v.trim().toLowerCase() : ''
|
||||
|
||||
return DETAILS_MODES.includes(s as DetailsMode) ? (s as DetailsMode) : null
|
||||
return MODES.find(m => m === s) ?? null
|
||||
}
|
||||
|
||||
export const resolveDetailsMode = (
|
||||
d: { details_mode?: unknown; thinking_mode?: unknown } | null | undefined
|
||||
): DetailsMode =>
|
||||
export const resolveDetailsMode = (d?: { details_mode?: unknown; thinking_mode?: unknown } | null): DetailsMode =>
|
||||
parseDetailsMode(d?.details_mode) ??
|
||||
THINKING_FALLBACK[
|
||||
String(d?.thinking_mode ?? '')
|
||||
|
|
@ -25,5 +23,4 @@ export const resolveDetailsMode = (
|
|||
] ??
|
||||
'collapsed'
|
||||
|
||||
export const nextDetailsMode = (m: DetailsMode): DetailsMode =>
|
||||
DETAILS_MODES[(DETAILS_MODES.indexOf(m) + 1) % DETAILS_MODES.length]!
|
||||
export const nextDetailsMode = (m: DetailsMode): DetailsMode => MODES[(MODES.indexOf(m) + 1) % MODES.length]!
|
||||
|
|
|
|||
|
|
@ -2,30 +2,17 @@ import { LONG_MSG } from '../config/limits.js'
|
|||
import { buildToolTrailLine, fmtK } from '../lib/text.js'
|
||||
import type { Msg, SessionInfo } from '../types.js'
|
||||
|
||||
interface ImageMeta {
|
||||
height?: number
|
||||
token_estimate?: number
|
||||
width?: number
|
||||
}
|
||||
|
||||
interface TranscriptRow {
|
||||
context?: string
|
||||
name?: string
|
||||
role?: string
|
||||
text?: string
|
||||
}
|
||||
|
||||
export const introMsg = (info: SessionInfo): Msg => ({ info, kind: 'intro', role: 'system', text: '' })
|
||||
|
||||
export const imageTokenMeta = (info: ImageMeta | null | undefined) =>
|
||||
[
|
||||
info?.width && info.height ? `${info.width}x${info.height}` : '',
|
||||
typeof info?.token_estimate === 'number' && info.token_estimate > 0 ? `~${fmtK(info.token_estimate)} tok` : ''
|
||||
]
|
||||
export const imageTokenMeta = (info?: ImageMeta | null) => {
|
||||
const { width, height, token_estimate: t } = info ?? {}
|
||||
|
||||
return [width && height ? `${width}x${height}` : '', (t ?? 0) > 0 ? `~${fmtK(t!)} tok` : '']
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
}
|
||||
|
||||
export const userDisplay = (text: string): string => {
|
||||
export const userDisplay = (text: string) => {
|
||||
if (text.length <= LONG_MSG) {
|
||||
return text
|
||||
}
|
||||
|
|
@ -42,8 +29,8 @@ export const toTranscriptMessages = (rows: unknown): Msg[] => {
|
|||
return []
|
||||
}
|
||||
|
||||
const result: Msg[] = []
|
||||
let pendingTools: string[] = []
|
||||
const out: Msg[] = []
|
||||
let pending: string[] = []
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row || typeof row !== 'object') {
|
||||
|
|
@ -53,7 +40,7 @@ export const toTranscriptMessages = (rows: unknown): Msg[] => {
|
|||
const { context, name, role, text } = row as TranscriptRow
|
||||
|
||||
if (role === 'tool') {
|
||||
pendingTools.push(buildToolTrailLine(name ?? 'tool', context ?? ''))
|
||||
pending.push(buildToolTrailLine(name ?? 'tool', context ?? ''))
|
||||
|
||||
continue
|
||||
}
|
||||
|
|
@ -63,40 +50,35 @@ export const toTranscriptMessages = (rows: unknown): Msg[] => {
|
|||
}
|
||||
|
||||
if (role === 'assistant') {
|
||||
const msg: Msg = { role, text }
|
||||
|
||||
if (pendingTools.length) {
|
||||
msg.tools = pendingTools
|
||||
pendingTools = []
|
||||
}
|
||||
|
||||
result.push(msg)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (role === 'user' || role === 'system') {
|
||||
pendingTools = []
|
||||
result.push({ role, text })
|
||||
out.push({ role, text, ...(pending.length && { tools: pending }) })
|
||||
pending = []
|
||||
} else if (role === 'user' || role === 'system') {
|
||||
out.push({ role, text })
|
||||
pending = []
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
return out
|
||||
}
|
||||
|
||||
export function fmtDuration(ms: number) {
|
||||
const total = Math.max(0, Math.floor(ms / 1000))
|
||||
const hours = Math.floor(total / 3600)
|
||||
const mins = Math.floor((total % 3600) / 60)
|
||||
const secs = total % 60
|
||||
export const fmtDuration = (ms: number) => {
|
||||
const t = Math.max(0, Math.floor(ms / 1000))
|
||||
const h = Math.floor(t / 3600)
|
||||
const m = Math.floor((t % 3600) / 60)
|
||||
const s = t % 60
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${mins}m`
|
||||
return h > 0 ? `${h}h ${m}m` : m > 0 ? `${m}m ${s}s` : `${s}s`
|
||||
}
|
||||
|
||||
if (mins > 0) {
|
||||
return `${mins}m ${secs}s`
|
||||
interface ImageMeta {
|
||||
height?: number
|
||||
token_estimate?: number
|
||||
width?: number
|
||||
}
|
||||
|
||||
return `${secs}s`
|
||||
interface TranscriptRow {
|
||||
context?: string
|
||||
name?: string
|
||||
role?: string
|
||||
text?: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export const shortCwd = (cwd: string, max = 28) => {
|
||||
const p = process.env.HOME && cwd.startsWith(process.env.HOME) ? `~${cwd.slice(process.env.HOME.length)}` : cwd
|
||||
const h = process.env.HOME
|
||||
const p = h && cwd.startsWith(h) ? `~${cwd.slice(h.length)}` : cwd
|
||||
|
||||
return p.length <= max ? p : `…${p.slice(-(max - 1))}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,7 @@
|
|||
export interface ParsedSlashCommand {
|
||||
arg: string
|
||||
cmd: string
|
||||
name: string
|
||||
}
|
||||
export const looksLikeSlashCommand = (text: string) => /^\/[^\s/]*(?:\s|$)/.test(text)
|
||||
|
||||
export const looksLikeSlashCommand = (text: string) => {
|
||||
if (!text.startsWith('/')) {
|
||||
return false
|
||||
}
|
||||
export const parseSlashCommand = (cmd: string) => {
|
||||
const [name = '', ...rest] = cmd.slice(1).split(/\s+/)
|
||||
|
||||
const first = text.split(/\s+/, 1)[0] || ''
|
||||
|
||||
return !first.slice(1).includes('/')
|
||||
}
|
||||
|
||||
export const parseSlashCommand = (cmd: string): ParsedSlashCommand => {
|
||||
const [rawName = '', ...rest] = cmd.slice(1).split(/\s+/)
|
||||
|
||||
return {
|
||||
arg: rest.join(' '),
|
||||
cmd,
|
||||
name: rawName.toLowerCase()
|
||||
}
|
||||
return { arg: rest.join(' '), cmd, name: name.toLowerCase() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,17 +26,13 @@ export const stickyPromptFromViewport = (
|
|||
}
|
||||
|
||||
const first = Math.max(0, Math.min(messages.length - 1, upperBound(offsets, top) - 1))
|
||||
const aboveViewport = (i: number) => (offsets[i] ?? 0) + 1 < top
|
||||
|
||||
// Walk backward from the first visible row. The nearest user message wins:
|
||||
// if it's still on screen, no sticky is needed; if it's already scrolled
|
||||
// above the top, its text becomes the floating breadcrumb.
|
||||
for (let i = first; i >= 0; i--) {
|
||||
if (messages[i]?.role !== 'user') {
|
||||
continue
|
||||
}
|
||||
|
||||
return aboveViewport(i) ? userDisplay(messages[i]!.text.trim()).replace(/\s+/g, ' ').trim() : ''
|
||||
return (offsets[i] ?? 0) + 1 < top ? userDisplay(messages[i]!.text.trim()).replace(/\s+/g, ' ').trim() : ''
|
||||
}
|
||||
|
||||
return ''
|
||||
|
|
|
|||
|
|
@ -12,49 +12,34 @@ const STARTUP_TIMEOUT_MS = Math.max(5000, parseInt(process.env.HERMES_TUI_STARTU
|
|||
const REQUEST_TIMEOUT_MS = Math.max(30000, parseInt(process.env.HERMES_TUI_RPC_TIMEOUT_MS ?? '120000', 10) || 120000)
|
||||
|
||||
const resolvePython = (root: string) => {
|
||||
const configured = process.env.HERMES_PYTHON?.trim()
|
||||
const configured = process.env.HERMES_PYTHON?.trim() || process.env.PYTHON?.trim()
|
||||
|
||||
if (configured) {
|
||||
return configured
|
||||
}
|
||||
|
||||
const envPython = process.env.PYTHON?.trim()
|
||||
|
||||
if (envPython) {
|
||||
return envPython
|
||||
}
|
||||
|
||||
const venv = process.env.VIRTUAL_ENV?.trim()
|
||||
|
||||
const candidates = [
|
||||
venv ? resolve(venv, 'bin/python') : '',
|
||||
venv ? resolve(venv, 'Scripts/python.exe') : '',
|
||||
const hit = [
|
||||
venv && resolve(venv, 'bin/python'),
|
||||
venv && resolve(venv, 'Scripts/python.exe'),
|
||||
resolve(root, '.venv/bin/python'),
|
||||
resolve(root, '.venv/bin/python3'),
|
||||
resolve(root, 'venv/bin/python'),
|
||||
resolve(root, 'venv/bin/python3')
|
||||
].filter(Boolean)
|
||||
].find(p => p && existsSync(p))
|
||||
|
||||
const hit = candidates.find(path => existsSync(path))
|
||||
|
||||
if (hit) {
|
||||
return hit
|
||||
return hit || (process.platform === 'win32' ? 'python' : 'python3')
|
||||
}
|
||||
|
||||
return process.platform === 'win32' ? 'python' : 'python3'
|
||||
}
|
||||
|
||||
const asGatewayEvent = (value: unknown): GatewayEvent | null => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return typeof (value as { type?: unknown }).type === 'string' ? (value as GatewayEvent) : null
|
||||
}
|
||||
const asGatewayEvent = (value: unknown): GatewayEvent | null =>
|
||||
value && typeof value === 'object' && !Array.isArray(value) && typeof (value as { type?: unknown }).type === 'string'
|
||||
? (value as GatewayEvent)
|
||||
: null
|
||||
|
||||
interface Pending {
|
||||
resolve: (v: unknown) => void
|
||||
reject: (e: Error) => void
|
||||
resolve: (v: unknown) => void
|
||||
}
|
||||
|
||||
export class GatewayClient extends EventEmitter {
|
||||
|
|
@ -81,9 +66,7 @@ export class GatewayClient extends EventEmitter {
|
|||
}
|
||||
|
||||
if (this.subscribed) {
|
||||
this.emit('event', ev)
|
||||
|
||||
return
|
||||
return void this.emit('event', ev)
|
||||
}
|
||||
|
||||
this.bufferedEvents.push(ev)
|
||||
|
|
@ -94,8 +77,9 @@ export class GatewayClient extends EventEmitter {
|
|||
const python = resolvePython(root)
|
||||
const cwd = process.env.HERMES_CWD || root
|
||||
const env = { ...process.env }
|
||||
const pyPath = (env.PYTHONPATH ?? '').trim()
|
||||
const pyPath = env.PYTHONPATH?.trim()
|
||||
env.PYTHONPATH = pyPath ? `${root}${delimiter}${pyPath}` : root
|
||||
|
||||
this.ready = false
|
||||
this.bufferedEvents = []
|
||||
this.pendingExit = undefined
|
||||
|
|
@ -121,11 +105,7 @@ export class GatewayClient extends EventEmitter {
|
|||
this.publish({ type: 'gateway.start_timeout', payload: { cwd, python } })
|
||||
}, STARTUP_TIMEOUT_MS)
|
||||
|
||||
this.proc = spawn(python, ['-m', 'tui_gateway.entry'], {
|
||||
cwd,
|
||||
env,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
})
|
||||
this.proc = spawn(python, ['-m', 'tui_gateway.entry'], { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] })
|
||||
|
||||
this.stdoutRl = createInterface({ input: this.proc.stdout! })
|
||||
this.stdoutRl.on('line', raw => {
|
||||
|
|
@ -133,8 +113,9 @@ export class GatewayClient extends EventEmitter {
|
|||
this.dispatch(JSON.parse(raw))
|
||||
} catch {
|
||||
const preview = raw.trim().slice(0, MAX_LOG_PREVIEW) || '(empty line)'
|
||||
|
||||
this.pushLog(`[protocol] malformed stdout: ${preview}`)
|
||||
this.publish({ type: 'gateway.protocol_error', payload: { preview } } satisfies GatewayEvent)
|
||||
this.publish({ type: 'gateway.protocol_error', payload: { preview } })
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -147,13 +128,13 @@ export class GatewayClient extends EventEmitter {
|
|||
}
|
||||
|
||||
this.pushLog(line)
|
||||
this.publish({ type: 'gateway.stderr', payload: { line } } satisfies GatewayEvent)
|
||||
this.publish({ type: 'gateway.stderr', payload: { line } })
|
||||
})
|
||||
|
||||
this.proc.on('error', err => {
|
||||
this.pushLog(`[spawn] ${err.message}`)
|
||||
this.rejectPending(new Error(`gateway error: ${err.message}`))
|
||||
this.publish({ type: 'gateway.stderr', payload: { line: `[spawn] ${err.message}` } } satisfies GatewayEvent)
|
||||
this.publish({ type: 'gateway.stderr', payload: { line: `[spawn] ${err.message}` } })
|
||||
})
|
||||
|
||||
this.proc.on('exit', code => {
|
||||
|
|
@ -181,6 +162,7 @@ export class GatewayClient extends EventEmitter {
|
|||
|
||||
if (msg.error) {
|
||||
const err = msg.error as { message?: unknown } | null | undefined
|
||||
|
||||
p.reject(new Error(typeof err?.message === 'string' ? err.message : 'request failed'))
|
||||
} else {
|
||||
p.resolve(msg.result)
|
||||
|
|
@ -199,30 +181,29 @@ export class GatewayClient extends EventEmitter {
|
|||
}
|
||||
|
||||
private pushLog(line: string) {
|
||||
this.logs.push(line)
|
||||
|
||||
if (this.logs.length > MAX_GATEWAY_LOG_LINES) {
|
||||
if (this.logs.push(line) > MAX_GATEWAY_LOG_LINES) {
|
||||
this.logs.splice(0, this.logs.length - MAX_GATEWAY_LOG_LINES)
|
||||
}
|
||||
}
|
||||
|
||||
private rejectPending(err: Error) {
|
||||
for (const [id, pending] of this.pending) {
|
||||
this.pending.delete(id)
|
||||
pending.reject(err)
|
||||
for (const p of this.pending.values()) {
|
||||
p.reject(err)
|
||||
}
|
||||
|
||||
this.pending.clear()
|
||||
}
|
||||
|
||||
drain() {
|
||||
this.subscribed = true
|
||||
const pending = this.bufferedEvents.splice(0)
|
||||
|
||||
for (const ev of pending) {
|
||||
for (const ev of this.bufferedEvents.splice(0)) {
|
||||
this.emit('event', ev)
|
||||
}
|
||||
|
||||
if (this.pendingExit !== undefined) {
|
||||
const code = this.pendingExit
|
||||
|
||||
this.pendingExit = undefined
|
||||
this.emit('exit', code)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient
|
|||
ref.current = input
|
||||
|
||||
const isSlash = input.startsWith('/')
|
||||
const pathWord = !isSlash ? (input.match(TAB_PATH_RE)?.[1] ?? null) : null
|
||||
const pathWord = isSlash ? null : (input.match(TAB_PATH_RE)?.[1] ?? null)
|
||||
|
||||
if (!isSlash && !pathWord) {
|
||||
clear()
|
||||
|
|
@ -42,6 +42,8 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient
|
|||
return
|
||||
}
|
||||
|
||||
const pathReplace = input.length - (pathWord?.length ?? 0)
|
||||
|
||||
const t = setTimeout(() => {
|
||||
if (ref.current !== input) {
|
||||
return
|
||||
|
|
@ -53,15 +55,15 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient
|
|||
|
||||
req
|
||||
.then(raw => {
|
||||
const r = asRpcResult<CompletionResponse>(raw)
|
||||
|
||||
if (ref.current !== input) {
|
||||
return
|
||||
}
|
||||
|
||||
const r = asRpcResult<CompletionResponse>(raw)
|
||||
|
||||
setCompletions(r?.items ?? [])
|
||||
setCompIdx(0)
|
||||
setCompReplace(isSlash ? (r?.replace_from ?? 1) : input.length - (pathWord?.length ?? 0))
|
||||
setCompReplace(isSlash ? (r?.replace_from ?? 1) : pathReplace)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (ref.current !== input) {
|
||||
|
|
@ -76,7 +78,7 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient
|
|||
}
|
||||
])
|
||||
setCompIdx(0)
|
||||
setCompReplace(isSlash ? 1 : input.length - (pathWord?.length ?? 0))
|
||||
setCompReplace(isSlash ? 1 : pathReplace)
|
||||
})
|
||||
}, 60)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
import * as inputHistory from '../lib/history.js'
|
||||
|
||||
|
|
@ -7,9 +7,5 @@ export function useInputHistory() {
|
|||
const [historyIdx, setHistoryIdx] = useState<number | null>(null)
|
||||
const historyDraftRef = useRef('')
|
||||
|
||||
const pushHistory = useCallback((text: string) => {
|
||||
inputHistory.append(text)
|
||||
}, [])
|
||||
|
||||
return { historyRef, historyIdx, setHistoryIdx, historyDraftRef, pushHistory }
|
||||
return { historyRef, historyIdx, setHistoryIdx, historyDraftRef, pushHistory: inputHistory.append }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,9 +6,7 @@ export function useQueue() {
|
|||
const queueEditRef = useRef<number | null>(null)
|
||||
const [queueEditIdx, setQueueEditIdx] = useState<number | null>(null)
|
||||
|
||||
const syncQueue = useCallback(() => {
|
||||
setQueuedDisplay([...queueRef.current])
|
||||
}, [])
|
||||
const syncQueue = useCallback(() => setQueuedDisplay([...queueRef.current]), [])
|
||||
|
||||
const setQueueEdit = useCallback((idx: number | null) => {
|
||||
queueEditRef.current = idx
|
||||
|
|
@ -39,12 +37,12 @@ export function useQueue() {
|
|||
)
|
||||
|
||||
return {
|
||||
queueRef,
|
||||
queueEditRef,
|
||||
queuedDisplay,
|
||||
queueEditIdx,
|
||||
enqueue,
|
||||
dequeue,
|
||||
enqueue,
|
||||
queueEditIdx,
|
||||
queueEditRef,
|
||||
queueRef,
|
||||
queuedDisplay,
|
||||
replaceQ,
|
||||
setQueueEdit,
|
||||
syncQueue
|
||||
|
|
|
|||
|
|
@ -33,9 +33,9 @@ export function useVirtualHistory(
|
|||
items: readonly { key: string }[],
|
||||
{ estimate = ESTIMATE, overscan = OVERSCAN, maxMounted = MAX_MOUNTED, coldStartCount = COLD_START } = {}
|
||||
) {
|
||||
const nodes = useRef(new Map<string, any>())
|
||||
const nodes = useRef(new Map<string, unknown>())
|
||||
const heights = useRef(new Map<string, number>())
|
||||
const refs = useRef(new Map<string, (el: any) => void>())
|
||||
const refs = useRef(new Map<string, (el: unknown) => void>())
|
||||
const [ver, setVer] = useState(0)
|
||||
|
||||
useSyncExternalStore(
|
||||
|
|
@ -108,7 +108,7 @@ export function useVirtualHistory(
|
|||
let fn = refs.current.get(key)
|
||||
|
||||
if (!fn) {
|
||||
fn = (el: any) => (el ? nodes.current.set(key, el) : nodes.current.delete(key))
|
||||
fn = (el: unknown) => (el ? nodes.current.set(key, el) : nodes.current.delete(key))
|
||||
refs.current.set(key, fn)
|
||||
}
|
||||
|
||||
|
|
@ -125,7 +125,7 @@ export function useVirtualHistory(
|
|||
continue
|
||||
}
|
||||
|
||||
const h = Math.ceil(nodes.current.get(k)?.yogaNode?.getComputedHeight?.() ?? 0)
|
||||
const h = Math.ceil((nodes.current.get(k) as MeasuredNode | undefined)?.yogaNode?.getComputedHeight?.() ?? 0)
|
||||
|
||||
if (h > 0 && heights.current.get(k) !== h) {
|
||||
heights.current.set(k, h)
|
||||
|
|
@ -139,11 +139,15 @@ export function useVirtualHistory(
|
|||
}, [end, items, start])
|
||||
|
||||
return {
|
||||
start,
|
||||
end,
|
||||
offsets,
|
||||
topSpacer: offsets[start] ?? 0,
|
||||
bottomSpacer: Math.max(0, total - (offsets[end] ?? total)),
|
||||
measureRef
|
||||
end,
|
||||
measureRef,
|
||||
offsets,
|
||||
start,
|
||||
topSpacer: offsets[start] ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
interface MeasuredNode {
|
||||
yogaNode?: { getComputedHeight?: () => number } | null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@ import { homedir } from 'node:os'
|
|||
import { join } from 'node:path'
|
||||
|
||||
const MAX = 1000
|
||||
const dir = join(process.env.HERMES_HOME ?? join(homedir(), '.hermes'))
|
||||
const dir = process.env.HERMES_HOME ?? join(homedir(), '.hermes')
|
||||
const file = join(dir, '.hermes_history')
|
||||
|
||||
let cache: string[] | null = null
|
||||
|
||||
export function load(): string[] {
|
||||
export function load() {
|
||||
if (cache) {
|
||||
return cache
|
||||
}
|
||||
|
|
@ -20,11 +20,10 @@ export function load(): string[] {
|
|||
return cache
|
||||
}
|
||||
|
||||
const lines = readFileSync(file, 'utf8').split('\n')
|
||||
const entries: string[] = []
|
||||
let current: string[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
for (const line of readFileSync(file, 'utf8').split('\n')) {
|
||||
if (line.startsWith('+')) {
|
||||
current.push(line.slice(1))
|
||||
} else if (current.length) {
|
||||
|
|
@ -45,7 +44,7 @@ export function load(): string[] {
|
|||
return cache
|
||||
}
|
||||
|
||||
export function append(line: string): void {
|
||||
export function append(line: string) {
|
||||
const trimmed = line.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
|
|
@ -73,11 +72,11 @@ export function append(line: string): void {
|
|||
|
||||
const encoded = trimmed
|
||||
.split('\n')
|
||||
.map(l => '+' + l)
|
||||
.map(l => `+${l}`)
|
||||
.join('\n')
|
||||
|
||||
appendFileSync(file, `\n# ${ts}\n${encoded}\n`)
|
||||
} catch {
|
||||
/* ignore */
|
||||
void 0
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import type { Msg, Role } from '../types.js'
|
||||
|
||||
export function upsert(prev: Msg[], role: Role, text: string): Msg[] {
|
||||
return prev.at(-1)?.role === role ? [...prev.slice(0, -1), { role, text }] : [...prev, { role, text }]
|
||||
}
|
||||
export const upsert = (prev: Msg[], role: Role, text: string): Msg[] =>
|
||||
prev.at(-1)?.role === role ? [...prev.slice(0, -1), { role, text }] : [...prev, { role, text }]
|
||||
|
|
|
|||
|
|
@ -1,3 +1,2 @@
|
|||
export function writeOsc52Clipboard(s: string): void {
|
||||
process.stdout.write('\x1b]52;c;' + Buffer.from(s, 'utf8').toString('base64') + '\x07')
|
||||
}
|
||||
export const writeOsc52Clipboard = (s: string) =>
|
||||
process.stdout.write(`\x1b]52;c;${Buffer.from(s, 'utf8').toString('base64')}\x07`)
|
||||
|
|
|
|||
|
|
@ -2,13 +2,8 @@ import type { CommandDispatchResponse } from '../gatewayTypes.js'
|
|||
|
||||
export type RpcResult = Record<string, any>
|
||||
|
||||
export const asRpcResult = <T extends RpcResult = RpcResult>(value: unknown): T | null => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return value as T
|
||||
}
|
||||
export const asRpcResult = <T extends RpcResult = RpcResult>(value: unknown): T | null =>
|
||||
!value || typeof value !== 'object' || Array.isArray(value) ? null : (value as T)
|
||||
|
||||
export const asCommandDispatch = (value: unknown): CommandDispatchResponse | null => {
|
||||
const o = asRpcResult(value)
|
||||
|
|
@ -28,24 +23,11 @@ export const asCommandDispatch = (value: unknown): CommandDispatchResponse | nul
|
|||
}
|
||||
|
||||
if (t === 'skill' && typeof o.name === 'string') {
|
||||
return {
|
||||
type: 'skill',
|
||||
name: o.name,
|
||||
message: typeof o.message === 'string' ? o.message : undefined
|
||||
}
|
||||
return { type: 'skill', name: o.name, message: typeof o.message === 'string' ? o.message : undefined }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const rpcErrorMessage = (err: unknown) => {
|
||||
if (err instanceof Error && err.message) {
|
||||
return err.message
|
||||
}
|
||||
|
||||
if (typeof err === 'string' && err.trim()) {
|
||||
return err
|
||||
}
|
||||
|
||||
return 'request failed'
|
||||
}
|
||||
export const rpcErrorMessage = (err: unknown) =>
|
||||
err instanceof Error && err.message ? err.message : typeof err === 'string' && err.trim() ? err : 'request failed'
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import { THINKING_COT_MAX } from '../config/limits.js'
|
||||
import type { ThinkingMode } from '../types.js'
|
||||
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const ANSI_RE = /\x1b\[[0-9;]*m/g
|
||||
const ESC = String.fromCharCode(27)
|
||||
const ANSI_RE = new RegExp(`${ESC}\\[[0-9;]*m`, 'g')
|
||||
const WS_RE = /\s+/g
|
||||
|
||||
export const stripAnsi = (s: string) => s.replace(ANSI_RE, '')
|
||||
|
||||
export const hasAnsi = (s: string) => s.includes('\x1b[') || s.includes('\x1b]')
|
||||
export const hasAnsi = (s: string) => s.includes(`${ESC}[`) || s.includes(`${ESC}]`)
|
||||
|
||||
const renderEstimateLine = (line: string) => {
|
||||
const trimmed = line.trim()
|
||||
|
|
@ -38,7 +39,7 @@ const renderEstimateLine = (line: string) => {
|
|||
}
|
||||
|
||||
export const compactPreview = (s: string, max: number) => {
|
||||
const one = s.replace(/\s+/g, ' ').trim()
|
||||
const one = s.replace(WS_RE, ' ').trim()
|
||||
|
||||
return !one ? '' : one.length > max ? one.slice(0, max - 1) + '…' : one
|
||||
}
|
||||
|
|
@ -46,17 +47,13 @@ export const compactPreview = (s: string, max: number) => {
|
|||
export const estimateTokensRough = (text: string) => (!text ? 0 : (text.length + 3) >> 2)
|
||||
|
||||
export const edgePreview = (s: string, head = 16, tail = 28) => {
|
||||
const one = s.replace(/\s+/g, ' ').trim().replace(/\]\]/g, '] ]')
|
||||
const one = s.replace(WS_RE, ' ').trim().replace(/\]\]/g, '] ]')
|
||||
|
||||
if (!one) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (one.length <= head + tail + 4) {
|
||||
return one
|
||||
}
|
||||
|
||||
return `${one.slice(0, head).trimEnd()}.. ${one.slice(-tail).trimStart()}`
|
||||
return !one
|
||||
? ''
|
||||
: one.length <= head + tail + 4
|
||||
? one
|
||||
: `${one.slice(0, head).trimEnd()}.. ${one.slice(-tail).trimStart()}`
|
||||
}
|
||||
|
||||
export const pasteTokenLabel = (text: string, lineCount: number) => {
|
||||
|
|
@ -76,15 +73,7 @@ export const pasteTokenLabel = (text: string, lineCount: number) => {
|
|||
export const thinkingPreview = (reasoning: string, mode: ThinkingMode, max: number = THINKING_COT_MAX) => {
|
||||
const raw = reasoning.trim()
|
||||
|
||||
if (!raw || mode === 'collapsed') {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (mode === 'full') {
|
||||
return raw
|
||||
}
|
||||
|
||||
return compactPreview(raw.replace(/\s+/g, ' '), max)
|
||||
return !raw || mode === 'collapsed' ? '' : mode === 'full' ? raw : compactPreview(raw.replace(WS_RE, ' '), max)
|
||||
}
|
||||
|
||||
export const stripTrailingPasteNewlines = (text: string) => (/[^\n]/.test(text) ? text.replace(/\n+$/, '') : text)
|
||||
|
|
@ -97,18 +86,18 @@ export const toolTrailLabel = (name: string) =>
|
|||
.join(' ') || name
|
||||
|
||||
export const formatToolCall = (name: string, context = '') => {
|
||||
const label = toolTrailLabel(name)
|
||||
const preview = compactPreview(context, 64)
|
||||
|
||||
return preview ? `${toolTrailLabel(name)}("${preview}")` : toolTrailLabel(name)
|
||||
return preview ? `${label}("${preview}")` : label
|
||||
}
|
||||
|
||||
export const buildToolTrailLine = (name: string, context: string, error?: boolean, note?: string): string => {
|
||||
export const buildToolTrailLine = (name: string, context: string, error?: boolean, note?: string) => {
|
||||
const detail = compactPreview(note ?? '', 72)
|
||||
|
||||
return `${formatToolCall(name, context)}${detail ? ` :: ${detail}` : ''} ${error ? ' ✗' : ' ✓'}`
|
||||
}
|
||||
|
||||
/** Tool completed / failed row in the inline trail (not CoT prose). */
|
||||
export const isToolTrailResultLine = (line: string) => line.endsWith(' ✓') || line.endsWith(' ✗')
|
||||
|
||||
export const parseToolTrailResultLine = (line: string) => {
|
||||
|
|
@ -133,10 +122,8 @@ export const parseToolTrailResultLine = (line: string) => {
|
|||
return { call: body, detail: '', mark }
|
||||
}
|
||||
|
||||
/** Ephemeral status lines that should vanish once the next phase starts. */
|
||||
export const isTransientTrailLine = (line: string) => line.startsWith('drafting ') || line === 'analyzing tool output…'
|
||||
|
||||
/** Whether a persisted/activity tool line belongs to the same tool label as a newer line. */
|
||||
export const sameToolTrailGroup = (label: string, entry: string) =>
|
||||
entry === `${label} ✓` ||
|
||||
entry === `${label} ✗` ||
|
||||
|
|
@ -144,7 +131,6 @@ export const sameToolTrailGroup = (label: string, entry: string) =>
|
|||
entry.startsWith(`${label} ::`) ||
|
||||
entry.startsWith(`${label}:`)
|
||||
|
||||
/** Index of the last non-result trail line, or -1. */
|
||||
export const lastCotTrailIndex = (trail: readonly string[]) => {
|
||||
for (let i = trail.length - 1; i >= 0; i--) {
|
||||
if (!isToolTrailResultLine(trail[i]!)) {
|
||||
|
|
@ -168,10 +154,7 @@ export const estimateRows = (text: string, w: number, compact = false) => {
|
|||
const lang = maybeFence[2]!.trim()
|
||||
|
||||
if (!fence) {
|
||||
fence = {
|
||||
char: marker[0] as '`' | '~',
|
||||
len: marker.length
|
||||
}
|
||||
fence = { char: marker[0] as '`' | '~', len: marker.length }
|
||||
|
||||
if (lang) {
|
||||
rows += Math.ceil((`─ ${lang}`.length || 1) / w)
|
||||
|
|
@ -204,14 +187,11 @@ export const estimateRows = (text: string, w: number, compact = false) => {
|
|||
|
||||
export const flat = (r: Record<string, string[]>) => Object.values(r).flat()
|
||||
|
||||
const COMPACT_NUMBER = new Intl.NumberFormat('en-US', {
|
||||
maximumFractionDigits: 1,
|
||||
notation: 'compact'
|
||||
})
|
||||
const COMPACT_NUMBER = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1, notation: 'compact' })
|
||||
|
||||
export const fmtK = (n: number) => COMPACT_NUMBER.format(n).replace(/[KMBT]$/, s => s.toLowerCase())
|
||||
|
||||
export const pick = <T>(a: T[]) => a[Math.floor(Math.random() * a.length)]!
|
||||
|
||||
export const isPasteBackedText = (text: string): boolean =>
|
||||
export const isPasteBackedText = (text: string) =>
|
||||
/\[\[paste:\d+(?:[^\n]*?)\]\]|\[paste #\d+ (?:attached|excerpt)(?:[^\n]*?)\]/.test(text)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,3 @@
|
|||
export const INTERPOLATION_RE = /\{!(.+?)\}/g
|
||||
|
||||
export const hasInterpolation = (s: string) => {
|
||||
INTERPOLATION_RE.lastIndex = 0
|
||||
|
||||
return INTERPOLATION_RE.test(s)
|
||||
}
|
||||
export const hasInterpolation = (s: string) => /\{!.+?\}/.test(s)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
export interface ActiveTool {
|
||||
context?: string
|
||||
id: string
|
||||
name: string
|
||||
context?: string
|
||||
startedAt?: number
|
||||
}
|
||||
|
||||
|
|
@ -36,11 +36,11 @@ export interface ClarifyReq {
|
|||
}
|
||||
|
||||
export interface Msg {
|
||||
info?: SessionInfo
|
||||
kind?: 'intro' | 'panel' | 'slash' | 'trail'
|
||||
panelData?: PanelData
|
||||
role: Role
|
||||
text: string
|
||||
kind?: 'intro' | 'panel' | 'slash' | 'trail'
|
||||
info?: SessionInfo
|
||||
panelData?: PanelData
|
||||
thinking?: string
|
||||
thinkingTokens?: number
|
||||
toolTokens?: number
|
||||
|
|
@ -76,6 +76,7 @@ export interface Usage {
|
|||
export interface SudoReq {
|
||||
requestId: string
|
||||
}
|
||||
|
||||
export interface SecretReq {
|
||||
envVar: string
|
||||
prompt: string
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue