feat: refactor by splitting up app and doing proper state

This commit is contained in:
Brooklyn Nicholson 2026-04-14 22:30:18 -05:00
parent 4cbf54fb33
commit 99d859ce4a
27 changed files with 4087 additions and 2939 deletions

2
cli.py
View file

@ -3752,7 +3752,7 @@ class HermesCLI:
skin = get_active_skin() skin = get_active_skin()
separator_color = skin.get_color("banner_dim", "#B8860B") separator_color = skin.get_color("banner_dim", "#B8860B")
accent_color = skin.get_color("ui_accent", "#FFBF00") accent_color = skin.get_color("ui_accent", "#FFBF00")
label_color = skin.get_color("ui_label", "#4dd0e1") label_color = skin.get_color("ui_label", "#DAA520")
except Exception: except Exception:
separator_color, accent_color, label_color = "#B8860B", "#FFBF00", "cyan" separator_color, accent_color, label_color = "#B8860B", "#FFBF00", "cyan"
toolsets_info = "" toolsets_info = ""

View file

@ -23,7 +23,7 @@ All fields are optional. Missing values inherit from the ``default`` skin.
banner_dim: "#B8860B" # Dim/muted text (separators, labels) banner_dim: "#B8860B" # Dim/muted text (separators, labels)
banner_text: "#FFF8DC" # Body text (tool names, skill names) banner_text: "#FFF8DC" # Body text (tool names, skill names)
ui_accent: "#FFBF00" # General UI accent ui_accent: "#FFBF00" # General UI accent
ui_label: "#4dd0e1" # UI labels ui_label: "#DAA520" # UI labels (warm gold; teal clashed w/ default banner gold)
ui_ok: "#4caf50" # Success indicators ui_ok: "#4caf50" # Success indicators
ui_error: "#ef5350" # Error indicators ui_error: "#ef5350" # Error indicators
ui_warn: "#ffa726" # Warning indicators ui_warn: "#ffa726" # Warning indicators
@ -163,7 +163,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"banner_dim": "#B8860B", "banner_dim": "#B8860B",
"banner_text": "#FFF8DC", "banner_text": "#FFF8DC",
"ui_accent": "#FFBF00", "ui_accent": "#FFBF00",
"ui_label": "#4dd0e1", "ui_label": "#DAA520",
"ui_ok": "#4caf50", "ui_ok": "#4caf50",
"ui_error": "#ef5350", "ui_error": "#ef5350",
"ui_warn": "#ffa726", "ui_warn": "#ffa726",

View file

@ -9,6 +9,7 @@
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"@hermes/ink": "file:./packages/hermes-ink", "@hermes/ink": "file:./packages/hermes-ink",
"@nanostores/react": "^1.1.0",
"ink": "^6.8.0", "ink": "^6.8.0",
"ink-text-input": "^6.0.0", "ink-text-input": "^6.0.0",
"react": "^19.2.4", "react": "^19.2.4",
@ -1115,6 +1116,25 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@nanostores/react": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@nanostores/react/-/react-1.1.0.tgz",
"integrity": "sha512-MbH35fjhcf7LAubYX5vhOChYUfTLzNLqH/mBGLVsHkcvjy0F8crO1WQwdmQ2xKbAmtpalDa2zBt3Hlg5kqr8iw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"engines": {
"node": "^20.0.0 || >=22.0.0"
},
"peerDependencies": {
"nanostores": "^1.2.0",
"react": ">=18.0.0"
}
},
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz",
@ -4761,6 +4781,22 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
} }
}, },
"node_modules/nanostores": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.2.0.tgz",
"integrity": "sha512-F0wCzbsH80G7XXo0Jd9/AVQC7ouWY6idUCTnMwW5t/Rv9W8qmO6endavDwg7TNp5GbugwSukFMVZqzPSrSMndg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": "^20.0.0 || >=22.0.0"
}
},
"node_modules/natural-compare": { "node_modules/natural-compare": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",

View file

@ -17,6 +17,7 @@
}, },
"dependencies": { "dependencies": {
"@hermes/ink": "file:./packages/hermes-ink", "@hermes/ink": "file:./packages/hermes-ink",
"@nanostores/react": "^1.1.0",
"ink": "^6.8.0", "ink": "^6.8.0",
"ink-text-input": "^6.0.0", "ink-text-input": "^6.0.0",
"react": "^19.2.4", "react": "^19.2.4",

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,15 @@
import { PLACEHOLDERS } from '../constants.js'
import { pick } from '../lib/text.js'
export const PLACEHOLDER = pick(PLACEHOLDERS)
export const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim()
export const LARGE_PASTE = { chars: 8000, lines: 80 }
export const MAX_HISTORY = 800
export const REASONING_PULSE_MS = 700
export const STREAM_BATCH_MS = 16
export const WHEEL_SCROLL_STEP = 3
export const MOUSE_TRACKING = !/^(1|true|yes|on)$/.test(
(process.env.HERMES_TUI_DISABLE_MOUSE ?? '').trim().toLowerCase()
)
export const PASTE_SNIPPET_RE = /\[\[[^\n]*?\]\]/g

View file

@ -0,0 +1,487 @@
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'
import type { GatewayEvent } from '../gatewayClient.js'
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import { buildToolTrailLine, isToolTrailResultLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js'
import { fromSkin } from '../theme.js'
import type { Msg, SlashCatalog } from '../types.js'
import { introMsg, toTranscriptMessages } from './helpers.js'
import type { GatewayServices } from './interfaces.js'
import { patchOverlayState } from './overlayStore.js'
import { getUiState, patchUiState } from './uiStore.js'
import type { TurnActions, TurnRefs } from './useTurnState.js'
export interface GatewayEventHandlerContext {
composer: {
dequeue: () => string | undefined
queueEditRef: MutableRefObject<number | null>
sendQueued: (text: string) => void
}
gateway: GatewayServices
session: {
STARTUP_RESUME_ID: string
colsRef: MutableRefObject<number>
newSession: (msg?: string) => void
resetSession: () => void
setCatalog: Dispatch<SetStateAction<SlashCatalog | null>>
}
system: {
bellOnComplete: boolean
stdout?: NodeJS.WriteStream
sys: (text: string) => void
}
transcript: {
appendMessage: (msg: Msg) => void
setHistoryItems: Dispatch<SetStateAction<Msg[]>>
setMessages: Dispatch<SetStateAction<Msg[]>>
}
turn: {
actions: Pick<
TurnActions,
| 'clearReasoning'
| 'endReasoningPhase'
| 'idle'
| 'pruneTransient'
| 'pulseReasoningStreaming'
| 'pushActivity'
| 'pushTrail'
| 'scheduleReasoning'
| 'scheduleStreaming'
| 'setActivity'
| 'setStreaming'
| 'setTools'
| 'setTurnTrail'
>
refs: Pick<
TurnRefs,
| 'bufRef'
| 'interruptedRef'
| 'lastStatusNoteRef'
| 'persistedToolLabelsRef'
| 'protocolWarnedRef'
| 'reasoningRef'
| 'statusTimerRef'
| 'toolCompleteRibbonRef'
| 'turnToolsRef'
>
}
}
export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: GatewayEvent) => void {
const { dequeue, queueEditRef, sendQueued } = ctx.composer
const { gw, rpc } = ctx.gateway
const { STARTUP_RESUME_ID, colsRef, newSession, resetSession, setCatalog } = ctx.session
const { bellOnComplete, stdout, sys } = ctx.system
const { appendMessage, setHistoryItems, setMessages } = ctx.transcript
const {
clearReasoning,
endReasoningPhase,
idle,
pruneTransient,
pulseReasoningStreaming,
pushActivity,
pushTrail,
scheduleReasoning,
scheduleStreaming,
setActivity,
setStreaming,
setTools,
setTurnTrail
} = ctx.turn.actions
const {
bufRef,
interruptedRef,
lastStatusNoteRef,
persistedToolLabelsRef,
protocolWarnedRef,
reasoningRef,
statusTimerRef,
toolCompleteRibbonRef,
turnToolsRef
} = ctx.turn.refs
return (ev: GatewayEvent) => {
const sid = getUiState().sid
if (ev.session_id && sid && ev.session_id !== sid && !ev.type.startsWith('gateway.')) {
return
}
const p = ev.payload as any
switch (ev.type) {
case 'gateway.ready':
if (p?.skin) {
patchUiState({
theme: fromSkin(
p.skin.colors ?? {},
p.skin.branding ?? {},
p.skin.banner_logo ?? '',
p.skin.banner_hero ?? ''
)
})
}
rpc('commands.catalog', {})
.then((r: any) => {
if (!r?.pairs) {
return
}
setCatalog({
canon: (r.canon ?? {}) as Record<string, string>,
categories: (r.categories ?? []) as any,
pairs: r.pairs as [string, string][],
skillCount: (r.skill_count ?? 0) as number,
sub: (r.sub ?? {}) as Record<string, string[]>
})
if (r.warning) {
pushActivity(String(r.warning), 'warn')
}
})
.catch((e: unknown) => pushActivity(`command catalog unavailable: ${rpcErrorMessage(e)}`, 'warn'))
if (STARTUP_RESUME_ID) {
patchUiState({ status: 'resuming…' })
gw.request('session.resume', { cols: colsRef.current, session_id: STARTUP_RESUME_ID })
.then((raw: any) => {
const r = asRpcResult(raw)
if (!r) {
throw new Error('invalid response: session.resume')
}
resetSession()
const resumed = toTranscriptMessages(r.messages)
patchUiState({
info: r.info ?? null,
sid: r.session_id,
status: 'ready',
usage: r.info?.usage ?? getUiState().usage
})
setMessages(resumed)
setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed)
})
.catch((e: unknown) => {
sys(`resume failed: ${rpcErrorMessage(e)}`)
patchUiState({ status: 'forging session…' })
newSession('started a new session')
})
} else {
patchUiState({ status: 'forging session…' })
newSession()
}
break
case 'skin.changed':
if (p) {
patchUiState({
theme: fromSkin(p.colors ?? {}, p.branding ?? {}, p.banner_logo ?? '', p.banner_hero ?? '')
})
}
break
case 'session.info':
patchUiState(state => ({
...state,
info: p as any,
usage: p?.usage ? { ...state.usage, ...p.usage } : state.usage
}))
break
case 'thinking.delta':
if (p && Object.prototype.hasOwnProperty.call(p, 'text')) {
patchUiState({ status: p.text ? String(p.text) : getUiState().busy ? 'running…' : 'ready' })
}
break
case 'message.start':
patchUiState({ busy: true })
endReasoningPhase()
clearReasoning()
setActivity([])
setTurnTrail([])
turnToolsRef.current = []
persistedToolLabelsRef.current.clear()
break
case 'status.update':
if (p?.text) {
patchUiState({ status: p.text })
if (p.kind && p.kind !== 'status') {
if (lastStatusNoteRef.current !== p.text) {
lastStatusNoteRef.current = p.text
pushActivity(
p.text,
p.kind === 'error' ? 'error' : p.kind === 'warn' || p.kind === 'approval' ? 'warn' : 'info'
)
}
if (statusTimerRef.current) {
clearTimeout(statusTimerRef.current)
}
statusTimerRef.current = setTimeout(() => {
statusTimerRef.current = null
patchUiState({ status: getUiState().busy ? 'running…' : 'ready' })
}, 4000)
}
}
break
case 'gateway.stderr':
if (p?.line) {
const line = String(p.line).slice(0, 120)
const tone = /\b(error|traceback|exception|failed|spawn)\b/i.test(line) ? 'error' : 'warn'
pushActivity(line, tone)
}
break
case 'gateway.start_timeout':
patchUiState({ status: 'gateway startup timeout' })
pushActivity(
`gateway startup timed out${p?.python || p?.cwd ? ` · ${String(p?.python || '')} ${String(p?.cwd || '')}`.trim() : ''} · /logs to inspect`,
'error'
)
break
case 'gateway.protocol_error':
patchUiState({ status: 'protocol warning' })
if (statusTimerRef.current) {
clearTimeout(statusTimerRef.current)
}
statusTimerRef.current = setTimeout(() => {
statusTimerRef.current = null
patchUiState({ status: getUiState().busy ? 'running…' : 'ready' })
}, 4000)
if (!protocolWarnedRef.current) {
protocolWarnedRef.current = true
pushActivity('protocol noise detected · /logs to inspect', 'warn')
}
if (p?.preview) {
pushActivity(`protocol noise: ${String(p.preview).slice(0, 120)}`, 'warn')
}
break
case 'reasoning.delta':
if (p?.text) {
reasoningRef.current += p.text
scheduleReasoning()
pulseReasoningStreaming()
}
break
case 'tool.progress':
if (p?.preview) {
setTools(prev => {
const index = prev.findIndex(tool => tool.name === p.name)
return index >= 0
? [...prev.slice(0, index), { ...prev[index]!, context: p.preview as string }, ...prev.slice(index + 1)]
: prev
})
}
break
case 'tool.generating':
if (p?.name) {
pushTrail(`drafting ${p.name}`)
}
break
case 'tool.start':
pruneTransient()
endReasoningPhase()
setTools(prev => [
...prev,
{ id: p.tool_id, name: p.name, context: (p.context as string) || '', startedAt: Date.now() }
])
break
case 'tool.complete': {
toolCompleteRibbonRef.current = null
setTools(prev => {
const done = prev.find(tool => tool.id === p.tool_id)
const name = done?.name ?? p.name
const label = toolTrailLabel(name)
const line = buildToolTrailLine(
name,
done?.context || '',
!!p.error,
(p.error as string) || (p.summary as string) || ''
)
const next = [...turnToolsRef.current.filter(item => !sameToolTrailGroup(label, item)), line]
const remaining = prev.filter(tool => tool.id !== p.tool_id)
toolCompleteRibbonRef.current = { label, line }
if (!remaining.length) {
next.push('analyzing tool output…')
}
turnToolsRef.current = next.slice(-8)
setTurnTrail(turnToolsRef.current)
return remaining
})
if (p?.inline_diff) {
sys(p.inline_diff as string)
}
break
}
case 'clarify.request':
patchOverlayState({ clarify: { choices: p.choices, question: p.question, requestId: p.request_id } })
patchUiState({ status: 'waiting for input…' })
break
case 'approval.request':
patchOverlayState({ approval: { command: p.command, description: p.description } })
patchUiState({ status: 'approval needed' })
break
case 'sudo.request':
patchOverlayState({ sudo: { requestId: p.request_id } })
patchUiState({ status: 'sudo password needed' })
break
case 'secret.request':
patchOverlayState({ secret: { envVar: p.env_var, prompt: p.prompt, requestId: p.request_id } })
patchUiState({ status: 'secret input needed' })
break
case 'background.complete':
patchUiState(state => {
const next = new Set(state.bgTasks)
next.delete(p.task_id)
return { ...state, bgTasks: next }
})
sys(`[bg ${p.task_id}] ${p.text}`)
break
case 'btw.complete':
patchUiState(state => {
const next = new Set(state.bgTasks)
next.delete('btw:x')
return { ...state, bgTasks: next }
})
sys(`[btw] ${p.text}`)
break
case 'message.delta':
pruneTransient()
endReasoningPhase()
if (p?.text && !interruptedRef.current) {
bufRef.current = p.rendered ?? bufRef.current + p.text
scheduleStreaming()
}
break
case 'message.complete': {
const finalText = (p?.rendered ?? p?.text ?? bufRef.current).trimStart()
const persisted = persistedToolLabelsRef.current
const savedReasoning = reasoningRef.current.trim()
const savedTools = turnToolsRef.current.filter(
line => isToolTrailResultLine(line) && ![...persisted].some(item => sameToolTrailGroup(item, line))
)
const wasInterrupted = interruptedRef.current
idle()
clearReasoning()
setStreaming('')
if (!wasInterrupted) {
appendMessage({
role: 'assistant',
text: finalText,
thinking: savedReasoning || undefined,
tools: savedTools.length ? savedTools : undefined
})
if (bellOnComplete && stdout?.isTTY) {
stdout.write('\x07')
}
}
turnToolsRef.current = []
persistedToolLabelsRef.current.clear()
setActivity([])
bufRef.current = ''
patchUiState({ status: 'ready' })
if (p?.usage) {
patchUiState({ usage: p.usage })
}
if (queueEditRef.current !== null) {
break
}
const next = dequeue()
if (next) {
sendQueued(next)
}
break
}
case 'error':
idle()
clearReasoning()
turnToolsRef.current = []
persistedToolLabelsRef.current.clear()
if (statusTimerRef.current) {
clearTimeout(statusTimerRef.current)
statusTimerRef.current = null
}
pushActivity(String(p?.message || 'unknown error'), 'error')
sys(`error: ${p?.message}`)
patchUiState({ status: 'ready' })
break
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,24 @@
import { createContext, type ReactNode, useContext } from 'react'
import type { GatewayServices } from './interfaces.js'
const GatewayContext = createContext<GatewayServices | null>(null)
export interface GatewayProviderProps {
children: ReactNode
value: GatewayServices
}
export function GatewayProvider({ children, value }: GatewayProviderProps) {
return <GatewayContext.Provider value={value}>{children}</GatewayContext.Provider>
}
export function useGateway() {
const value = useContext(GatewayContext)
if (!value) {
throw new Error('GatewayContext missing')
}
return value
}

167
ui-tui/src/app/helpers.ts Normal file
View file

@ -0,0 +1,167 @@
import { buildToolTrailLine, fmtK, userDisplay } from '../lib/text.js'
import type { DetailsMode, Msg, SessionInfo } from '../types.js'
const DETAILS_MODES: DetailsMode[] = ['hidden', 'collapsed', 'expanded']
export interface PasteSnippet {
label: string
text: string
}
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
}
export const resolveDetailsMode = (d: any): DetailsMode =>
parseDetailsMode(d?.details_mode) ??
{ full: 'expanded' as const, collapsed: 'collapsed' as const, truncated: 'collapsed' as const }[
String(d?.thinking_mode ?? '')
.trim()
.toLowerCase()
] ??
'collapsed'
export const nextDetailsMode = (m: DetailsMode): DetailsMode =>
DETAILS_MODES[(DETAILS_MODES.indexOf(m) + 1) % DETAILS_MODES.length]!
export const introMsg = (info: SessionInfo): Msg => ({ role: 'system', text: '', kind: 'intro', info })
export const shortCwd = (cwd: string, max = 28) => {
const p = process.env.HOME && cwd.startsWith(process.env.HOME) ? `~${cwd.slice(process.env.HOME.length)}` : cwd
return p.length <= max ? p : `${p.slice(-(max - 1))}`
}
export const imageTokenMeta = (
info: { height?: number; token_estimate?: number; width?: number } | null | undefined
) => {
const dims = info?.width && info?.height ? `${info.width}x${info.height}` : ''
const tok =
typeof info?.token_estimate === 'number' && info.token_estimate > 0 ? `~${fmtK(info.token_estimate)} tok` : ''
return [dims, tok].filter(Boolean).join(' · ')
}
export const looksLikeSlashCommand = (text: string) => {
if (!text.startsWith('/')) {
return false
}
const first = text.split(/\s+/, 1)[0] || ''
return !first.slice(1).includes('/')
}
export const toTranscriptMessages = (rows: unknown): Msg[] => {
if (!Array.isArray(rows)) {
return []
}
const result: Msg[] = []
let pendingTools: string[] = []
for (const row of rows) {
if (!row || typeof row !== 'object') {
continue
}
const role = (row as any).role
const text = (row as any).text
if (role === 'tool') {
const name = (row as any).name ?? 'tool'
const ctx = (row as any).context ?? ''
pendingTools.push(buildToolTrailLine(name, ctx))
continue
}
if (typeof text !== 'string' || !text.trim()) {
continue
}
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 })
}
}
return result
}
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
if (hours > 0) {
return `${hours}h ${mins}m`
}
if (mins > 0) {
return `${mins}m ${secs}s`
}
return `${secs}s`
}
export const stickyPromptFromViewport = (
messages: readonly Msg[],
offsets: ArrayLike<number>,
top: number,
sticky: boolean
) => {
if (sticky || !messages.length) {
return ''
}
let lo = 0
let hi = offsets.length
while (lo < hi) {
const mid = (lo + hi) >> 1
if (offsets[mid]! <= top) {
lo = mid + 1
} else {
hi = mid
}
}
const first = Math.max(0, Math.min(messages.length - 1, lo - 1))
if (messages[first]?.role === 'user' && (offsets[first] ?? 0) + 1 >= top) {
return ''
}
for (let i = first - 1; i >= 0; i--) {
if (messages[i]?.role !== 'user') {
continue
}
if ((offsets[i] ?? 0) + 1 >= top) {
continue
}
return userDisplay(messages[i]!.text.trim()).replace(/\s+/g, ' ').trim()
}
return ''
}

View file

@ -0,0 +1,67 @@
import type { GatewayClient } from '../gatewayClient.js'
import type { Theme } from '../theme.js'
import type { ApprovalReq, ClarifyReq, DetailsMode, Msg, SecretReq, SessionInfo, SudoReq, Usage } from '../types.js'
export interface CompletionItem {
display: string
meta?: string
text: string
}
export interface GatewayRpc {
(method: string, params?: Record<string, unknown>): Promise<any | null>
}
export interface GatewayServices {
gw: GatewayClient
rpc: GatewayRpc
}
export interface OverlayState {
approval: ApprovalReq | null
clarify: ClarifyReq | null
modelPicker: boolean
pager: PagerState | null
picker: boolean
secret: SecretReq | null
sudo: SudoReq | null
}
export interface PagerState {
lines: string[]
offset: number
title?: string
}
export interface ToolCompleteRibbon {
label: string
line: string
}
export interface TranscriptRow {
index: number
key: string
msg: Msg
}
export interface UiState {
bgTasks: Set<string>
busy: boolean
compact: boolean
detailsMode: DetailsMode
info: SessionInfo | null
sid: string | null
status: string
statusBar: boolean
theme: Theme
usage: Usage
}
export interface VirtualHistoryState {
bottomSpacer: number
end: number
measureRef: (key: string) => (el: unknown) => void
offsets: ArrayLike<number>
start: number
topSpacer: number
}

View file

@ -0,0 +1,41 @@
import { atom, computed } from 'nanostores'
import type { OverlayState } from './interfaces.js'
function buildOverlayState(): OverlayState {
return {
approval: null,
clarify: null,
modelPicker: false,
pager: null,
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 function getOverlayState() {
return $overlayState.get()
}
export function patchOverlayState(next: Partial<OverlayState> | ((state: OverlayState) => OverlayState)) {
if (typeof next === 'function') {
$overlayState.set(next($overlayState.get()))
return
}
$overlayState.set({ ...$overlayState.get(), ...next })
}
export function resetOverlayState() {
$overlayState.set(buildOverlayState())
}

41
ui-tui/src/app/uiStore.ts Normal file
View file

@ -0,0 +1,41 @@
import { atom } from 'nanostores'
import { ZERO } from '../constants.js'
import { DEFAULT_THEME } from '../theme.js'
import type { UiState } from './interfaces.js'
function buildUiState(): UiState {
return {
bgTasks: new Set(),
busy: false,
compact: false,
detailsMode: 'collapsed',
info: null,
sid: null,
status: 'summoning hermes…',
statusBar: true,
theme: DEFAULT_THEME,
usage: ZERO
}
}
export const $uiState = atom<UiState>(buildUiState())
export function getUiState() {
return $uiState.get()
}
export function patchUiState(next: Partial<UiState> | ((state: UiState) => UiState)) {
if (typeof next === 'function') {
$uiState.set(next($uiState.get()))
return
}
$uiState.set({ ...$uiState.get(), ...next })
}
export function resetUiState() {
$uiState.set(buildUiState())
}

View file

@ -0,0 +1,199 @@
import { spawnSync } from 'node:child_process'
import { mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { useStore } from '@nanostores/react'
import { type Dispatch, type MutableRefObject, type SetStateAction, useCallback, useState } from 'react'
import type { PasteEvent } from '../components/textInput.js'
import type { GatewayClient } from '../gatewayClient.js'
import { useCompletion } from '../hooks/useCompletion.js'
import { useInputHistory } from '../hooks/useInputHistory.js'
import { useQueue } from '../hooks/useQueue.js'
import { pasteTokenLabel, stripTrailingPasteNewlines } from '../lib/text.js'
import { LARGE_PASTE } from './constants.js'
import type { PasteSnippet } from './helpers.js'
import type { CompletionItem } from './interfaces.js'
import { $isBlocked } from './overlayStore.js'
export interface ComposerPasteResult {
cursor: number
value: string
}
export interface ComposerActions {
clearIn: () => void
dequeue: () => string | undefined
enqueue: (text: string) => void
handleTextPaste: (event: PasteEvent) => ComposerPasteResult | null
openEditor: () => void
pushHistory: (text: string) => void
replaceQueue: (index: number, text: string) => void
setCompIdx: Dispatch<SetStateAction<number>>
setHistoryIdx: Dispatch<SetStateAction<number | null>>
setInput: Dispatch<SetStateAction<string>>
setInputBuf: Dispatch<SetStateAction<string[]>>
setPasteSnips: Dispatch<SetStateAction<PasteSnippet[]>>
setQueueEdit: (index: number | null) => void
syncQueue: () => void
}
export interface ComposerRefs {
historyDraftRef: MutableRefObject<string>
historyRef: MutableRefObject<string[]>
queueEditRef: MutableRefObject<number | null>
queueRef: MutableRefObject<string[]>
submitRef: MutableRefObject<(value: string) => void>
}
export interface ComposerState {
compIdx: number
compReplace: number
completions: CompletionItem[]
historyIdx: number | null
input: string
inputBuf: string[]
pasteSnips: PasteSnippet[]
queueEditIdx: number | null
queuedDisplay: string[]
}
export interface UseComposerStateOptions {
gw: GatewayClient
onClipboardPaste: (quiet?: boolean) => Promise<void> | void
submitRef: MutableRefObject<(value: string) => void>
}
export interface UseComposerStateResult {
actions: ComposerActions
refs: ComposerRefs
state: ComposerState
}
export function useComposerState({ gw, onClipboardPaste, submitRef }: UseComposerStateOptions): UseComposerStateResult {
const [input, setInput] = useState('')
const [inputBuf, setInputBuf] = useState<string[]>([])
const [pasteSnips, setPasteSnips] = useState<PasteSnippet[]>([])
const isBlocked = useStore($isBlocked)
const { queueRef, queueEditRef, queuedDisplay, queueEditIdx, enqueue, dequeue, replaceQ, setQueueEdit, syncQueue } =
useQueue()
const { historyRef, historyIdx, setHistoryIdx, historyDraftRef, pushHistory } = useInputHistory()
const { completions, compIdx, setCompIdx, compReplace } = useCompletion(input, isBlocked, gw)
const clearIn = useCallback(() => {
setInput('')
setInputBuf([])
setQueueEdit(null)
setHistoryIdx(null)
historyDraftRef.current = ''
}, [historyDraftRef, setQueueEdit, setHistoryIdx])
const handleTextPaste = useCallback(
({ bracketed, cursor, hotkey, text, value }: PasteEvent) => {
if (hotkey) {
void onClipboardPaste(false)
return null
}
const cleanedText = stripTrailingPasteNewlines(text)
if (!cleanedText || !/[^\n]/.test(cleanedText)) {
if (bracketed) {
void onClipboardPaste(true)
}
return null
}
const lineCount = cleanedText.split('\n').length
if (cleanedText.length < LARGE_PASTE.chars && lineCount < LARGE_PASTE.lines) {
return {
cursor: cursor + cleanedText.length,
value: value.slice(0, cursor) + cleanedText + value.slice(cursor)
}
}
const label = pasteTokenLabel(cleanedText, lineCount)
const lead = cursor > 0 && !/\s/.test(value[cursor - 1] ?? '') ? ' ' : ''
const tail = cursor < value.length && !/\s/.test(value[cursor] ?? '') ? ' ' : ''
const insert = `${lead}${label}${tail}`
setPasteSnips(prev => [...prev, { label, text: cleanedText }].slice(-32))
return {
cursor: cursor + insert.length,
value: value.slice(0, cursor) + insert + value.slice(cursor)
}
},
[onClipboardPaste]
)
const openEditor = useCallback(() => {
const editor = process.env.EDITOR || process.env.VISUAL || 'vi'
const file = join(mkdtempSync(join(tmpdir(), 'hermes-')), 'prompt.md')
writeFileSync(file, [...inputBuf, input].join('\n'))
process.stdout.write('\x1b[?1049l')
const { status: code } = spawnSync(editor, [file], { stdio: 'inherit' })
process.stdout.write('\x1b[?1049h\x1b[2J\x1b[H')
if (code === 0) {
const text = readFileSync(file, 'utf8').trimEnd()
if (text) {
setInput('')
setInputBuf([])
submitRef.current(text)
}
}
try {
unlinkSync(file)
} catch {
/* noop */
}
}, [input, inputBuf, submitRef])
return {
actions: {
clearIn,
dequeue,
enqueue,
handleTextPaste,
openEditor,
pushHistory,
replaceQueue: replaceQ,
setCompIdx,
setHistoryIdx,
setInput,
setInputBuf,
setPasteSnips,
setQueueEdit,
syncQueue
},
refs: {
historyDraftRef,
historyRef,
queueEditRef,
queueRef,
submitRef
},
state: {
compIdx,
compReplace,
completions,
historyIdx,
input,
inputBuf,
pasteSnips,
queueEditIdx,
queuedDisplay
}
}
}

View file

@ -0,0 +1,345 @@
import { type ScrollBoxHandle, useInput } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import type { Dispatch, RefObject, SetStateAction } from 'react'
import type { Msg } from '../types.js'
import type { GatewayServices } from './interfaces.js'
import { $isBlocked, $overlayState, patchOverlayState } from './overlayStore.js'
import { getUiState, patchUiState } from './uiStore.js'
import type { ComposerActions, ComposerRefs, ComposerState } from './useComposerState.js'
import type { TurnActions, TurnRefs } from './useTurnState.js'
export interface InputHandlerActions {
answerClarify: (answer: string) => void
appendMessage: (msg: Msg) => void
die: () => void
dispatchSubmission: (full: string) => void
guardBusySessionSwitch: (what?: string) => boolean
newSession: (msg?: string) => void
sys: (text: string) => void
}
export interface InputHandlerContext {
actions: InputHandlerActions
composer: {
actions: ComposerActions
refs: ComposerRefs
state: ComposerState
}
gateway: GatewayServices
terminal: {
hasSelection: boolean
scrollRef: RefObject<ScrollBoxHandle | null>
scrollWithSelection: (delta: number) => void
selection: {
copySelection: () => string
}
stdout?: NodeJS.WriteStream
}
turn: {
actions: TurnActions
refs: TurnRefs
}
voice: {
recording: boolean
setProcessing: Dispatch<SetStateAction<boolean>>
setRecording: Dispatch<SetStateAction<boolean>>
}
wheelStep: number
}
export interface InputHandlerResult {
pagerPageSize: number
}
export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
const { actions, composer, gateway, terminal, turn, voice, wheelStep } = ctx
const overlay = useStore($overlayState)
const isBlocked = useStore($isBlocked)
const pagerPageSize = Math.max(5, (terminal.stdout?.rows ?? 24) - 6)
const ctrl = (key: { ctrl: boolean }, ch: string, target: string) => key.ctrl && ch.toLowerCase() === target
const copySelection = () => {
if (terminal.selection.copySelection()) {
actions.sys('copied selection')
}
}
useInput((ch, key) => {
const live = getUiState()
if (isBlocked) {
if (overlay.pager) {
if (key.return || ch === ' ') {
const next = overlay.pager.offset + pagerPageSize
patchOverlayState({
pager: next >= overlay.pager.lines.length ? null : { ...overlay.pager, offset: next }
})
} else if (key.escape || ctrl(key, ch, 'c') || ch === 'q') {
patchOverlayState({ pager: null })
}
return
}
if (ctrl(key, ch, 'c')) {
if (overlay.clarify) {
actions.answerClarify('')
} else if (overlay.approval) {
gateway.rpc('approval.respond', { choice: 'deny', session_id: live.sid }).then(r => {
if (!r) {
return
}
patchOverlayState({ approval: null })
actions.sys('denied')
})
} else if (overlay.sudo) {
gateway.rpc('sudo.respond', { password: '', request_id: overlay.sudo.requestId }).then(r => {
if (!r) {
return
}
patchOverlayState({ sudo: null })
actions.sys('sudo cancelled')
})
} else if (overlay.secret) {
gateway.rpc('secret.respond', { request_id: overlay.secret.requestId, value: '' }).then(r => {
if (!r) {
return
}
patchOverlayState({ secret: null })
actions.sys('secret entry cancelled')
})
} else if (overlay.modelPicker) {
patchOverlayState({ modelPicker: false })
} else if (overlay.picker) {
patchOverlayState({ picker: false })
}
} else if (key.escape && overlay.picker) {
patchOverlayState({ picker: false })
}
return
}
if (
composer.state.completions.length &&
composer.state.input &&
composer.state.historyIdx === null &&
(key.upArrow || key.downArrow)
) {
composer.actions.setCompIdx(index =>
key.upArrow
? (index - 1 + composer.state.completions.length) % composer.state.completions.length
: (index + 1) % composer.state.completions.length
)
return
}
if (key.wheelUp) {
terminal.scrollWithSelection(-wheelStep)
return
}
if (key.wheelDown) {
terminal.scrollWithSelection(wheelStep)
return
}
if (key.shift && key.upArrow) {
terminal.scrollWithSelection(-1)
return
}
if (key.shift && key.downArrow) {
terminal.scrollWithSelection(1)
return
}
if (key.pageUp || key.pageDown) {
const viewport = terminal.scrollRef.current?.getViewportHeight() ?? Math.max(6, (terminal.stdout?.rows ?? 24) - 8)
const step = Math.max(4, viewport - 2)
terminal.scrollWithSelection(key.pageUp ? -step : step)
return
}
if (key.ctrl && key.shift && ch.toLowerCase() === 'c') {
copySelection()
return
}
if (key.upArrow && !composer.state.inputBuf.length) {
if (composer.refs.queueRef.current.length) {
const index =
composer.state.queueEditIdx === null
? 0
: (composer.state.queueEditIdx + 1) % composer.refs.queueRef.current.length
composer.actions.setQueueEdit(index)
composer.actions.setHistoryIdx(null)
composer.actions.setInput(composer.refs.queueRef.current[index] ?? '')
} else if (composer.refs.historyRef.current.length) {
const index =
composer.state.historyIdx === null
? composer.refs.historyRef.current.length - 1
: Math.max(0, composer.state.historyIdx - 1)
if (composer.state.historyIdx === null) {
composer.refs.historyDraftRef.current = composer.state.input
}
composer.actions.setHistoryIdx(index)
composer.actions.setQueueEdit(null)
composer.actions.setInput(composer.refs.historyRef.current[index] ?? '')
}
return
}
if (key.downArrow && !composer.state.inputBuf.length) {
if (composer.refs.queueRef.current.length) {
const index =
composer.state.queueEditIdx === null
? composer.refs.queueRef.current.length - 1
: (composer.state.queueEditIdx - 1 + composer.refs.queueRef.current.length) %
composer.refs.queueRef.current.length
composer.actions.setQueueEdit(index)
composer.actions.setHistoryIdx(null)
composer.actions.setInput(composer.refs.queueRef.current[index] ?? '')
} else if (composer.state.historyIdx !== null) {
const next = composer.state.historyIdx + 1
if (next >= composer.refs.historyRef.current.length) {
composer.actions.setHistoryIdx(null)
composer.actions.setInput(composer.refs.historyDraftRef.current)
} else {
composer.actions.setHistoryIdx(next)
composer.actions.setInput(composer.refs.historyRef.current[next] ?? '')
}
}
return
}
if (ctrl(key, ch, 'c')) {
if (terminal.hasSelection) {
copySelection()
} else if (live.busy && live.sid) {
turn.actions.interruptTurn({
appendMessage: actions.appendMessage,
gw: gateway.gw,
sid: live.sid,
sys: actions.sys
})
} else if (composer.state.input || composer.state.inputBuf.length) {
composer.actions.clearIn()
} else {
return actions.die()
}
return
}
if (ctrl(key, ch, 'd')) {
return actions.die()
}
if (ctrl(key, ch, 'l')) {
if (actions.guardBusySessionSwitch()) {
return
}
patchUiState({ status: 'forging session…' })
actions.newSession()
return
}
if (ctrl(key, ch, 'b')) {
if (voice.recording) {
voice.setRecording(false)
voice.setProcessing(true)
gateway
.rpc('voice.record', { action: 'stop' })
.then((r: any) => {
if (!r) {
return
}
const transcript = String(r?.text || '').trim()
if (transcript) {
composer.actions.setInput(prev =>
prev ? `${prev}${/\s$/.test(prev) ? '' : ' '}${transcript}` : transcript
)
} else {
actions.sys('voice: no speech detected')
}
})
.catch((e: Error) => actions.sys(`voice error: ${e.message}`))
.finally(() => {
voice.setProcessing(false)
patchUiState({ status: 'ready' })
})
} else {
gateway
.rpc('voice.record', { action: 'start' })
.then((r: any) => {
if (!r) {
return
}
voice.setRecording(true)
patchUiState({ status: 'recording…' })
})
.catch((e: Error) => actions.sys(`voice error: ${e.message}`))
}
return
}
if (ctrl(key, ch, 'g')) {
return composer.actions.openEditor()
}
if (key.tab && composer.state.completions.length) {
const row = composer.state.completions[composer.state.compIdx]
if (row?.text) {
const text =
composer.state.input.startsWith('/') && row.text.startsWith('/') && composer.state.compReplace > 0
? row.text.slice(1)
: row.text
composer.actions.setInput(composer.state.input.slice(0, composer.state.compReplace) + text)
}
return
}
if (ctrl(key, ch, 'k') && composer.refs.queueRef.current.length && live.sid) {
const next = composer.actions.dequeue()
if (next) {
composer.actions.setQueueEdit(null)
actions.dispatchSubmission(next)
}
}
})
return { pagerPageSize }
}

View file

@ -0,0 +1,296 @@
import {
type Dispatch,
type MutableRefObject,
type SetStateAction,
useCallback,
useEffect,
useRef,
useState
} from 'react'
import { isTransientTrailLine, sameToolTrailGroup } from '../lib/text.js'
import type { ActiveTool, ActivityItem, Msg } from '../types.js'
import { REASONING_PULSE_MS, STREAM_BATCH_MS } from './constants.js'
import type { ToolCompleteRibbon } from './interfaces.js'
import { resetOverlayState } from './overlayStore.js'
import { patchUiState } from './uiStore.js'
export interface InterruptTurnOptions {
appendMessage: (msg: Msg) => void
gw: { request: (method: string, params?: Record<string, unknown>) => Promise<unknown> }
sid: string
sys: (text: string) => void
}
export interface TurnActions {
clearReasoning: () => void
endReasoningPhase: () => void
idle: () => void
interruptTurn: (options: InterruptTurnOptions) => void
pruneTransient: () => void
pulseReasoningStreaming: () => void
pushActivity: (text: string, tone?: ActivityItem['tone'], replaceLabel?: string) => void
pushTrail: (line: string) => void
scheduleReasoning: () => void
scheduleStreaming: () => void
setActivity: Dispatch<SetStateAction<ActivityItem[]>>
setReasoning: Dispatch<SetStateAction<string>>
setReasoningActive: Dispatch<SetStateAction<boolean>>
setReasoningStreaming: Dispatch<SetStateAction<boolean>>
setStreaming: Dispatch<SetStateAction<string>>
setTools: Dispatch<SetStateAction<ActiveTool[]>>
setTurnTrail: Dispatch<SetStateAction<string[]>>
}
export interface TurnRefs {
bufRef: MutableRefObject<string>
interruptedRef: MutableRefObject<boolean>
lastStatusNoteRef: MutableRefObject<string>
persistedToolLabelsRef: MutableRefObject<Set<string>>
protocolWarnedRef: MutableRefObject<boolean>
reasoningRef: MutableRefObject<string>
reasoningStreamingTimerRef: MutableRefObject<ReturnType<typeof setTimeout> | null>
reasoningTimerRef: MutableRefObject<ReturnType<typeof setTimeout> | null>
statusTimerRef: MutableRefObject<ReturnType<typeof setTimeout> | null>
streamTimerRef: MutableRefObject<ReturnType<typeof setTimeout> | null>
toolCompleteRibbonRef: MutableRefObject<ToolCompleteRibbon | null>
turnToolsRef: MutableRefObject<string[]>
}
export interface TurnState {
activity: ActivityItem[]
reasoning: string
reasoningActive: boolean
reasoningStreaming: boolean
streaming: string
tools: ActiveTool[]
turnTrail: string[]
}
export interface UseTurnStateResult {
actions: TurnActions
refs: TurnRefs
state: TurnState
}
export function useTurnState(): UseTurnStateResult {
const [activity, setActivity] = useState<ActivityItem[]>([])
const [reasoning, setReasoning] = useState('')
const [reasoningActive, setReasoningActive] = useState(false)
const [reasoningStreaming, setReasoningStreaming] = useState(false)
const [streaming, setStreaming] = useState('')
const [tools, setTools] = useState<ActiveTool[]>([])
const [turnTrail, setTurnTrail] = useState<string[]>([])
const activityIdRef = useRef(0)
const bufRef = useRef('')
const interruptedRef = useRef(false)
const lastStatusNoteRef = useRef('')
const persistedToolLabelsRef = useRef<Set<string>>(new Set())
const protocolWarnedRef = useRef(false)
const reasoningRef = useRef('')
const reasoningStreamingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const reasoningTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const statusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const streamTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const toolCompleteRibbonRef = useRef<ToolCompleteRibbon | null>(null)
const turnToolsRef = useRef<string[]>([])
const setTrail = (next: string[]) => {
turnToolsRef.current = next
return next
}
const pulseReasoningStreaming = useCallback(() => {
if (reasoningStreamingTimerRef.current) {
clearTimeout(reasoningStreamingTimerRef.current)
}
setReasoningActive(true)
setReasoningStreaming(true)
reasoningStreamingTimerRef.current = setTimeout(() => {
reasoningStreamingTimerRef.current = null
setReasoningStreaming(false)
}, REASONING_PULSE_MS)
}, [])
const scheduleStreaming = useCallback(() => {
if (streamTimerRef.current) {
return
}
streamTimerRef.current = setTimeout(() => {
streamTimerRef.current = null
setStreaming(bufRef.current.trimStart())
}, STREAM_BATCH_MS)
}, [])
const scheduleReasoning = useCallback(() => {
if (reasoningTimerRef.current) {
return
}
reasoningTimerRef.current = setTimeout(() => {
reasoningTimerRef.current = null
setReasoning(reasoningRef.current)
}, STREAM_BATCH_MS)
}, [])
const endReasoningPhase = useCallback(() => {
if (reasoningStreamingTimerRef.current) {
clearTimeout(reasoningStreamingTimerRef.current)
reasoningStreamingTimerRef.current = null
}
setReasoningStreaming(false)
setReasoningActive(false)
}, [])
useEffect(
() => () => {
if (streamTimerRef.current) {
clearTimeout(streamTimerRef.current)
}
if (reasoningTimerRef.current) {
clearTimeout(reasoningTimerRef.current)
}
if (reasoningStreamingTimerRef.current) {
clearTimeout(reasoningStreamingTimerRef.current)
}
},
[]
)
const pushActivity = useCallback((text: string, tone: ActivityItem['tone'] = 'info', replaceLabel?: string) => {
setActivity(prev => {
const base = replaceLabel ? prev.filter(item => !sameToolTrailGroup(replaceLabel, item.text)) : prev
if (base.at(-1)?.text === text && base.at(-1)?.tone === tone) {
return base
}
activityIdRef.current++
return [...base, { id: activityIdRef.current, text, tone }].slice(-8)
})
}, [])
const pruneTransient = useCallback(() => {
setTurnTrail(prev => {
const next = prev.filter(line => !isTransientTrailLine(line))
return next.length === prev.length ? prev : setTrail(next)
})
}, [])
const pushTrail = useCallback((line: string) => {
setTurnTrail(prev =>
prev.at(-1) === line ? prev : setTrail([...prev.filter(item => !isTransientTrailLine(item)), line].slice(-8))
)
}, [])
const clearReasoning = useCallback(() => {
if (reasoningTimerRef.current) {
clearTimeout(reasoningTimerRef.current)
reasoningTimerRef.current = null
}
reasoningRef.current = ''
setReasoning('')
}, [])
const idle = useCallback(() => {
endReasoningPhase()
setTools([])
setTurnTrail([])
patchUiState({ busy: false })
resetOverlayState()
if (streamTimerRef.current) {
clearTimeout(streamTimerRef.current)
streamTimerRef.current = null
}
setStreaming('')
bufRef.current = ''
}, [endReasoningPhase])
const interruptTurn = useCallback(
({ appendMessage, gw, sid, sys }: InterruptTurnOptions) => {
interruptedRef.current = true
gw.request('session.interrupt', { session_id: sid }).catch(() => {})
const partial = (streaming || bufRef.current).trimStart()
if (partial) {
appendMessage({ role: 'assistant', text: partial + '\n\n*[interrupted]*' })
} else {
sys('interrupted')
}
idle()
clearReasoning()
setActivity([])
turnToolsRef.current = []
patchUiState({ status: 'interrupted' })
if (statusTimerRef.current) {
clearTimeout(statusTimerRef.current)
}
statusTimerRef.current = setTimeout(() => {
statusTimerRef.current = null
patchUiState({ status: 'ready' })
}, 1500)
},
[clearReasoning, idle, streaming]
)
return {
actions: {
clearReasoning,
endReasoningPhase,
idle,
interruptTurn,
pruneTransient,
pulseReasoningStreaming,
pushActivity,
pushTrail,
scheduleReasoning,
scheduleStreaming,
setActivity,
setReasoning,
setReasoningActive,
setReasoningStreaming,
setStreaming,
setTools,
setTurnTrail
},
refs: {
bufRef,
interruptedRef,
lastStatusNoteRef,
persistedToolLabelsRef,
protocolWarnedRef,
reasoningRef,
reasoningStreamingTimerRef,
reasoningTimerRef,
statusTimerRef,
streamTimerRef,
toolCompleteRibbonRef,
turnToolsRef
},
state: {
activity,
reasoning,
reasoningActive,
reasoningStreaming,
streaming,
tools,
turnTrail
}
}
}

View file

@ -1,23 +0,0 @@
import { Box, Text } from '@hermes/ink'
import type { Theme } from '../theme.js'
import type { ActivityItem } from '../types.js'
const toneColor = (item: ActivityItem, t: Theme) =>
item.tone === 'error' ? t.color.error : item.tone === 'warn' ? t.color.warn : t.color.dim
export function ActivityLane({ items, t }: { items: ActivityItem[]; t: Theme }) {
if (!items.length) {
return null
}
return (
<Box flexDirection="column" marginTop={1}>
{items.slice(-4).map(item => (
<Text color={toneColor(item, t)} dimColor={item.tone === 'info'} key={item.id}>
{t.brand.tool} {item.text}
</Text>
))}
</Box>
)
}

View file

@ -0,0 +1,227 @@
import { Box, type ScrollBoxHandle, Text } from '@hermes/ink'
import { type ReactNode, type RefObject, useCallback, useEffect, useState, useSyncExternalStore } from 'react'
import { stickyPromptFromViewport } from '../app/helpers.js'
import { fmtK } from '../lib/text.js'
import type { Theme } from '../theme.js'
import type { Msg, Usage } from '../types.js'
function ctxBarColor(pct: number | undefined, t: Theme) {
if (pct == null) {
return t.color.dim
}
if (pct >= 95) {
return t.color.statusCritical
}
if (pct > 80) {
return t.color.statusBad
}
if (pct >= 50) {
return t.color.statusWarn
}
return t.color.statusGood
}
function ctxBar(pct: number | undefined, w = 10) {
const p = Math.max(0, Math.min(100, pct ?? 0))
const filled = Math.round((p / 100) * w)
return '█'.repeat(filled) + '░'.repeat(w - filled)
}
export function StatusRule({
cwdLabel,
cols,
status,
statusColor,
model,
usage,
bgCount,
durationLabel,
voiceLabel,
t
}: {
cwdLabel: string
cols: number
status: string
statusColor: string
model: string
usage: Usage
bgCount: number
durationLabel?: string
voiceLabel?: string
t: Theme
}) {
const pct = usage.context_percent
const barColor = ctxBarColor(pct, t)
const ctxLabel = usage.context_max
? `${fmtK(usage.context_used ?? 0)}/${fmtK(usage.context_max)}`
: usage.total > 0
? `${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)
return (
<Box>
<Box flexShrink={1} width={leftWidth}>
<Text color={t.color.bronze} wrap="truncate-end">
{'─ '}
<Text color={statusColor}>{status}</Text>
<Text color={t.color.dim}> {model}</Text>
{ctxLabel ? <Text color={t.color.dim}> {ctxLabel}</Text> : null}
{bar ? (
<Text color={t.color.dim}>
{' │ '}
<Text color={barColor}>[{bar}]</Text> <Text color={barColor}>{pctLabel}</Text>
</Text>
) : null}
{durationLabel ? <Text color={t.color.dim}> {durationLabel}</Text> : null}
{voiceLabel ? <Text color={t.color.dim}> {voiceLabel}</Text> : null}
{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>
)
}
export function FloatBox({ children, color }: { children: ReactNode; color: string }) {
return (
<Box
alignSelf="flex-start"
borderColor={color}
borderStyle="double"
flexDirection="column"
marginTop={1}
opaque
paddingX={1}
>
{children}
</Box>
)
}
export function StickyPromptTracker({
messages,
offsets,
scrollRef,
onChange
}: {
messages: readonly Msg[]
offsets: ArrayLike<number>
scrollRef: RefObject<ScrollBoxHandle | null>
onChange: (text: string) => void
}) {
useSyncExternalStore(
useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]),
() => {
const s = scrollRef.current
if (!s) {
return NaN
}
const top = Math.max(0, s.getScrollTop() + s.getPendingDelta())
return s.isSticky() ? -1 - top : top
},
() => NaN
)
const s = scrollRef.current
const top = Math.max(0, (s?.getScrollTop() ?? 0) + (s?.getPendingDelta() ?? 0))
const text = stickyPromptFromViewport(messages, offsets, top, s?.isSticky() ?? true)
useEffect(() => onChange(text), [onChange, text])
return null
}
export function TranscriptScrollbar({ scrollRef, t }: { scrollRef: RefObject<ScrollBoxHandle | null>; t: Theme }) {
useSyncExternalStore(
useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]),
() => {
const s = scrollRef.current
if (!s) {
return NaN
}
return `${s.getScrollTop() + s.getPendingDelta()}:${s.getViewportHeight()}:${s.getScrollHeight()}`
},
() => ''
)
const [hover, setHover] = useState(false)
const [grab, setGrab] = useState<number | null>(null)
const s = scrollRef.current
const vp = Math.max(0, s?.getViewportHeight() ?? 0)
if (!vp) {
return <Box width={1} />
}
const total = Math.max(vp, s?.getScrollHeight() ?? vp)
const scrollable = total > vp
const thumb = scrollable ? Math.max(1, Math.round((vp * vp) / total)) : vp
const travel = Math.max(1, vp - thumb)
const pos = Math.max(0, (s?.getScrollTop() ?? 0) + (s?.getPendingDelta() ?? 0))
const thumbTop = scrollable ? Math.round((pos / Math.max(1, total - vp)) * travel) : 0
const jump = (row: number, offset: number) => {
if (!s || !scrollable) {
return
}
s.scrollTo(Math.round((Math.max(0, Math.min(travel, row - offset)) / travel) * Math.max(0, total - vp)))
}
return (
<Box
flexDirection="column"
onMouseDown={(e: { localRow?: number }) => {
const row = Math.max(0, Math.min(vp - 1, e.localRow ?? 0))
const off = row >= thumbTop && row < thumbTop + thumb ? row - thumbTop : Math.floor(thumb / 2)
setGrab(off)
jump(row, off)
}}
onMouseDrag={(e: { localRow?: number }) =>
jump(Math.max(0, Math.min(vp - 1, e.localRow ?? 0)), grab ?? Math.floor(thumb / 2))
}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
onMouseUp={() => setGrab(null)}
width={1}
>
{Array.from({ length: vp }, (_, i) => {
const active = i >= thumbTop && i < thumbTop + thumb
const color = active
? grab !== null
? t.color.gold
: hover
? t.color.amber
: t.color.bronze
: hover
? t.color.bronze
: t.color.dim
return (
<Text color={color} dimColor={!active && !hover} key={i}>
{scrollable ? (active ? '┃' : '│') : ' '}
</Text>
)
})}
</Box>
)
}

View file

@ -0,0 +1,248 @@
import { AlternateScreen, Box, NoSelect, ScrollBox, type ScrollBoxHandle, Text } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import type { RefObject } from 'react'
import { PLACEHOLDER } from '../app/constants.js'
import type { CompletionItem, TranscriptRow, VirtualHistoryState } from '../app/interfaces.js'
import { $isBlocked } from '../app/overlayStore.js'
import { $uiState } from '../app/uiStore.js'
import type { ActiveTool, ActivityItem, Msg } from '../types.js'
import { StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js'
import { AppOverlays } from './appOverlays.js'
import { Banner, Panel, SessionPanel } from './branding.js'
import { MessageLine } from './messageLine.js'
import { QueuedMessages } from './queuedMessages.js'
import type { PasteEvent } from './textInput.js'
import { TextInput } from './textInput.js'
import { ToolTrail } from './thinking.js'
export interface AppLayoutActions {
answerApproval: (choice: string) => void
answerClarify: (answer: string) => void
answerSecret: (value: string) => void
answerSudo: (pw: string) => void
onModelSelect: (value: string) => void
resumeById: (id: string) => void
setStickyPrompt: (value: string) => void
}
export interface AppLayoutComposerProps {
cols: number
compIdx: number
completions: CompletionItem[]
empty: boolean
handleTextPaste: (event: PasteEvent) => { cursor: number; value: string } | null
input: string
inputBuf: string[]
pagerPageSize: number
queueEditIdx: number | null
queuedDisplay: string[]
submit: (value: string) => void
updateInput: (next: string) => void
}
export interface AppLayoutProgressProps {
activity: ActivityItem[]
reasoning: string
reasoningActive: boolean
reasoningStreaming: boolean
showProgressArea: boolean
showStreamingArea: boolean
streaming: string
tools: ActiveTool[]
turnTrail: string[]
}
export interface AppLayoutStatusProps {
cwdLabel: string
durationLabel: string
showStickyPrompt: boolean
statusColor: string
stickyPrompt: string
voiceLabel: string
}
export interface AppLayoutTranscriptProps {
historyItems: Msg[]
scrollRef: RefObject<ScrollBoxHandle | null>
virtualHistory: VirtualHistoryState
virtualRows: TranscriptRow[]
}
export interface AppLayoutProps {
actions: AppLayoutActions
composer: AppLayoutComposerProps
mouseTracking: boolean
progress: AppLayoutProgressProps
status: AppLayoutStatusProps
transcript: AppLayoutTranscriptProps
}
export function AppLayout({ actions, composer, mouseTracking, progress, status, transcript }: AppLayoutProps) {
const ui = useStore($uiState)
const isBlocked = useStore($isBlocked)
const visibleHistory = transcript.virtualRows.slice(transcript.virtualHistory.start, transcript.virtualHistory.end)
return (
<AlternateScreen mouseTracking={mouseTracking}>
<Box flexDirection="column" flexGrow={1}>
<Box flexDirection="row" flexGrow={1}>
<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 => (
<Box flexDirection="column" key={row.key} ref={transcript.virtualHistory.measureRef(row.key)}>
{row.msg.kind === 'intro' && row.msg.info ? (
<Box flexDirection="column" paddingTop={1}>
<Banner t={ui.theme} />
<SessionPanel info={row.msg.info} sid={ui.sid} t={ui.theme} />
</Box>
) : row.msg.kind === 'panel' && row.msg.panelData ? (
<Panel sections={row.msg.panelData.sections} t={ui.theme} title={row.msg.panelData.title} />
) : (
<MessageLine
cols={composer.cols}
compact={ui.compact}
detailsMode={ui.detailsMode}
msg={row.msg}
t={ui.theme}
/>
)}
</Box>
))}
{transcript.virtualHistory.bottomSpacer > 0 ? (
<Box height={transcript.virtualHistory.bottomSpacer} />
) : null}
{progress.showProgressArea && (
<ToolTrail
activity={progress.activity}
busy={ui.busy && !progress.streaming}
detailsMode={ui.detailsMode}
reasoning={progress.reasoning}
reasoningActive={progress.reasoningActive}
reasoningStreaming={progress.reasoningStreaming}
t={ui.theme}
tools={progress.tools}
trail={progress.turnTrail}
/>
)}
{progress.showStreamingArea && (
<MessageLine
cols={composer.cols}
compact={ui.compact}
detailsMode={ui.detailsMode}
isStreaming
msg={{ role: 'assistant', text: progress.streaming }}
t={ui.theme}
/>
)}
</Box>
</ScrollBox>
<NoSelect flexShrink={0} marginLeft={1}>
<TranscriptScrollbar scrollRef={transcript.scrollRef} t={ui.theme} />
</NoSelect>
<StickyPromptTracker
messages={transcript.historyItems}
offsets={transcript.virtualHistory.offsets}
onChange={actions.setStickyPrompt}
scrollRef={transcript.scrollRef}
/>
</Box>
<NoSelect flexDirection="column" flexShrink={0} fromLeftEdge paddingX={1}>
<QueuedMessages
cols={composer.cols}
queued={composer.queuedDisplay}
queueEditIdx={composer.queueEditIdx}
t={ui.theme}
/>
{ui.bgTasks.size > 0 && (
<Text color={ui.theme.color.dim as any}>
{ui.bgTasks.size} background {ui.bgTasks.size === 1 ? 'task' : 'tasks'} running
</Text>
)}
{status.showStickyPrompt ? (
<Text color={ui.theme.color.dim as any} wrap="truncate-end">
<Text color={ui.theme.color.label as any}> </Text>
{status.stickyPrompt}
</Text>
) : (
<Text> </Text>
)}
<Box flexDirection="column" position="relative">
{ui.statusBar && (
<StatusRule
bgCount={ui.bgTasks.size}
cols={composer.cols}
cwdLabel={status.cwdLabel}
durationLabel={status.durationLabel}
model={ui.info?.model?.split('/').pop() ?? ''}
status={ui.status}
statusColor={status.statusColor}
t={ui.theme}
usage={ui.usage}
voiceLabel={status.voiceLabel}
/>
)}
<AppOverlays
cols={composer.cols}
compIdx={composer.compIdx}
completions={composer.completions}
onApprovalChoice={actions.answerApproval}
onClarifyAnswer={actions.answerClarify}
onModelSelect={actions.onModelSelect}
onPickerSelect={actions.resumeById}
onSecretSubmit={actions.answerSecret}
onSudoSubmit={actions.answerSudo}
pagerPageSize={composer.pagerPageSize}
/>
</Box>
{!isBlocked && (
<Box flexDirection="column" marginBottom={1}>
{composer.inputBuf.map((line, i) => (
<Box key={i}>
<Box width={3}>
<Text color={ui.theme.color.dim as any}>{i === 0 ? `${ui.theme.brand.prompt} ` : ' '}</Text>
</Box>
<Text color={ui.theme.color.cornsilk as any}>{line || ' '}</Text>
</Box>
))}
<Box>
<Box width={3}>
<Text bold color={ui.theme.color.gold as any}>
{composer.inputBuf.length ? ' ' : `${ui.theme.brand.prompt} `}
</Text>
</Box>
<TextInput
columns={Math.max(20, composer.cols - 3)}
onChange={composer.updateInput}
onPaste={composer.handleTextPaste}
onSubmit={composer.submit}
placeholder={composer.empty ? PLACEHOLDER : ui.busy ? 'Ctrl+C to interrupt…' : ''}
value={composer.input}
/>
</Box>
</Box>
)}
{!composer.empty && !ui.sid && <Text color={ui.theme.color.dim as any}> {ui.status}</Text>}
</NoSelect>
</Box>
</AlternateScreen>
)
}

View file

@ -0,0 +1,175 @@
import { Box, Text } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { useGateway } from '../app/gatewayContext.js'
import type { CompletionItem } from '../app/interfaces.js'
import { $overlayState, patchOverlayState } from '../app/overlayStore.js'
import { $uiState } from '../app/uiStore.js'
import { FloatBox } from './appChrome.js'
import { MaskedPrompt } from './maskedPrompt.js'
import { ModelPicker } from './modelPicker.js'
import { ApprovalPrompt, ClarifyPrompt } from './prompts.js'
import { SessionPicker } from './sessionPicker.js'
export interface AppOverlaysProps {
cols: number
compIdx: number
completions: CompletionItem[]
onApprovalChoice: (choice: string) => void
onClarifyAnswer: (value: string) => void
onModelSelect: (value: string) => void
onPickerSelect: (sessionId: string) => void
onSecretSubmit: (value: string) => void
onSudoSubmit: (pw: string) => void
pagerPageSize: number
}
export function AppOverlays({
cols,
compIdx,
completions,
onApprovalChoice,
onClarifyAnswer,
onModelSelect,
onPickerSelect,
onSecretSubmit,
onSudoSubmit,
pagerPageSize
}: AppOverlaysProps) {
const { gw } = useGateway()
const overlay = useStore($overlayState)
const ui = useStore($uiState)
if (
!(
overlay.approval ||
overlay.clarify ||
overlay.modelPicker ||
overlay.pager ||
overlay.picker ||
overlay.secret ||
overlay.sudo ||
completions.length
)
) {
return null
}
const start = Math.max(0, compIdx - 8)
return (
<Box alignItems="flex-start" bottom="100%" flexDirection="column" left={0} position="absolute" right={0}>
{overlay.clarify && (
<FloatBox color={ui.theme.color.bronze}>
<ClarifyPrompt
cols={cols}
onAnswer={onClarifyAnswer}
onCancel={() => onClarifyAnswer('')}
req={overlay.clarify}
t={ui.theme}
/>
</FloatBox>
)}
{overlay.approval && (
<FloatBox color={ui.theme.color.bronze}>
<ApprovalPrompt onChoice={onApprovalChoice} req={overlay.approval} t={ui.theme} />
</FloatBox>
)}
{overlay.sudo && (
<FloatBox color={ui.theme.color.bronze}>
<MaskedPrompt cols={cols} icon="🔐" label="sudo password required" onSubmit={onSudoSubmit} t={ui.theme} />
</FloatBox>
)}
{overlay.secret && (
<FloatBox color={ui.theme.color.bronze}>
<MaskedPrompt
cols={cols}
icon="🔑"
label={overlay.secret.prompt}
onSubmit={onSecretSubmit}
sub={`for ${overlay.secret.envVar}`}
t={ui.theme}
/>
</FloatBox>
)}
{overlay.picker && (
<FloatBox color={ui.theme.color.bronze}>
<SessionPicker
gw={gw}
onCancel={() => patchOverlayState({ picker: false })}
onSelect={onPickerSelect}
t={ui.theme}
/>
</FloatBox>
)}
{overlay.modelPicker && (
<FloatBox color={ui.theme.color.bronze}>
<ModelPicker
gw={gw}
onCancel={() => patchOverlayState({ modelPicker: false })}
onSelect={onModelSelect}
sessionId={ui.sid}
t={ui.theme}
/>
</FloatBox>
)}
{overlay.pager && (
<FloatBox color={ui.theme.color.bronze}>
<Box flexDirection="column" paddingX={1} paddingY={1}>
{overlay.pager.title && (
<Box justifyContent="center" marginBottom={1}>
<Text bold color={ui.theme.color.gold as any}>
{overlay.pager.title}
</Text>
</Box>
)}
{overlay.pager.lines.slice(overlay.pager.offset, overlay.pager.offset + pagerPageSize).map((line, i) => (
<Text key={i}>{line}</Text>
))}
<Box marginTop={1}>
<Text color={ui.theme.color.dim as any}>
{overlay.pager.offset + pagerPageSize < overlay.pager.lines.length
? `Enter/Space for more · q to close (${Math.min(overlay.pager.offset + pagerPageSize, overlay.pager.lines.length)}/${overlay.pager.lines.length})`
: `end · q to close (${overlay.pager.lines.length} lines)`}
</Text>
</Box>
</Box>
</FloatBox>
)}
{!!completions.length && (
<FloatBox color={ui.theme.color.gold}>
<Box flexDirection="column" width={Math.max(28, cols - 6)}>
{completions.slice(start, compIdx + 8).map((item, i) => {
const active = start + i === compIdx
return (
<Box
backgroundColor={active ? (ui.theme.color.completionCurrentBg as any) : undefined}
flexDirection="row"
key={item.text}
width="100%"
>
<Text bold={active} color={ui.theme.color.bronze as any}>
{' '}
{item.display}
</Text>
{item.meta ? <Text color={ui.theme.color.dim as any}> {item.meta}</Text> : null}
</Box>
)
})}
</Box>
</FloatBox>
)}
</Box>
)
}

View file

@ -44,7 +44,7 @@ export const MessageLine = memo(function MessageLine({
} }
const { body, glyph, prefix } = ROLE[msg.role](t) const { body, glyph, prefix } = ROLE[msg.role](t)
const thinking = msg.thinking?.replace(/\n/g, ' ').trim() ?? '' const thinking = msg.thinking?.trim() ?? ''
const showDetails = detailsMode !== 'hidden' && (Boolean(msg.tools?.length) || Boolean(thinking)) const showDetails = detailsMode !== 'hidden' && (Boolean(msg.tools?.length) || Boolean(thinking))
const content = (() => { const content = (() => {

View file

@ -14,16 +14,6 @@ export function getQueueWindow(queueLen: number, queueEditIdx: number | null) {
return { end, showLead: start > 0, showTail: end < queueLen, start } return { end, showLead: start > 0, showTail: end < queueLen, start }
} }
export function estimateQueuedRows(queueLen: number, queueEditIdx: number | null): number {
if (!queueLen) {
return 0
}
const win = getQueueWindow(queueLen, queueEditIdx)
return 1 + 1 + (win.showLead ? 1 : 0) + (win.end - win.start) + (win.showTail ? 1 : 0)
}
export function QueuedMessages({ export function QueuedMessages({
cols, cols,
queueEditIdx, queueEditIdx,

View file

@ -43,11 +43,16 @@ export function Spinner({ color, variant = 'think' }: { color: string; variant?:
return <Text color={color}>{spin.frames[frame]}</Text> return <Text color={color}>{spin.frames[frame]}</Text>
} }
type DetailRow = { color: string; content: ReactNode; dimColor?: boolean; key: string } interface DetailRow {
color: string
content: ReactNode
dimColor?: boolean
key: string
}
function Detail({ color, content, dimColor }: DetailRow) { function Detail({ color, content, dimColor }: DetailRow) {
return ( return (
<Text color={color} dimColor={dimColor}> <Text color={color} dimColor={dimColor} wrap="wrap-trim">
<Text dimColor> </Text> <Text dimColor> </Text>
{content} {content}
</Text> </Text>
@ -141,7 +146,7 @@ export const Thinking = memo(function Thinking({
</Text> </Text>
{preview ? ( {preview ? (
<Text color={t.color.dim} dimColor {...(mode !== 'full' ? { wrap: 'truncate-end' as const } : {})}> <Text color={t.color.dim} dimColor wrap={mode === 'full' ? 'wrap-trim' : 'truncate-end'}>
<Text dimColor> </Text> <Text dimColor> </Text>
{preview} {preview}
<StreamCursor color={t.color.dim} dimColor streaming={streaming} visible={active} /> <StreamCursor color={t.color.dim} dimColor streaming={streaming} visible={active} />
@ -158,7 +163,12 @@ export const Thinking = memo(function Thinking({
// ── ToolTrail ──────────────────────────────────────────────────────── // ── ToolTrail ────────────────────────────────────────────────────────
type Group = { color: string; content: ReactNode; details: DetailRow[]; key: string } interface Group {
color: string
content: ReactNode
details: DetailRow[]
key: string
}
export const ToolTrail = memo(function ToolTrail({ export const ToolTrail = memo(function ToolTrail({
busy = false, busy = false,

View file

@ -1,6 +1,5 @@
#!/usr/bin/env node #!/usr/bin/env node
import { render } from '@hermes/ink' import { render } from '@hermes/ink'
import React from 'react'
import { App } from './app.js' import { App } from './app.js'
import { GatewayClient } from './gatewayClient.js' import { GatewayClient } from './gatewayClient.js'

View file

@ -81,7 +81,3 @@ export function append(line: string): void {
/* ignore */ /* ignore */
} }
} }
export function all(): string[] {
return load()
}

View file

@ -74,9 +74,17 @@ export const pasteTokenLabel = (text: string, lineCount: number) => {
} }
export const thinkingPreview = (reasoning: string, mode: ThinkingMode, max: number) => { export const thinkingPreview = (reasoning: string, mode: ThinkingMode, max: number) => {
const text = reasoning.replace(/\n/g, ' ').trim() const raw = reasoning.trim()
return !text || mode === 'collapsed' ? '' : mode === 'full' ? text : compactPreview(text, max) if (!raw || mode === 'collapsed') {
return ''
}
if (mode === 'full') {
return raw
}
return compactPreview(raw.replace(/\s+/g, ' '), max)
} }
export const stripTrailingPasteNewlines = (text: string) => (/[^\n]/.test(text) ? text.replace(/\n+$/, '') : text) export const stripTrailingPasteNewlines = (text: string) => (/[^\n]/.test(text) ? text.replace(/\n+$/, '') : text)

View file

@ -4,6 +4,8 @@ export interface ThemeColors {
bronze: string bronze: string
cornsilk: string cornsilk: string
dim: string dim: string
completionBg: string
completionCurrentBg: string
label: string label: string
ok: string ok: string
@ -39,6 +41,35 @@ export interface Theme {
bannerHero: string bannerHero: string
} }
// ── Color math ───────────────────────────────────────────────────────
function parseHex(h: string): [number, number, number] | null {
const m = /^#?([0-9a-f]{6})$/i.exec(h)
if (!m) {
return null
}
const n = parseInt(m[1]!, 16)
return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]
}
function mix(a: string, b: string, t: number) {
const pa = parseHex(a)
const pb = parseHex(b)
if (!pa || !pb) {
return a
}
const lerp = (i: 0 | 1 | 2) => Math.round(pa[i] + (pb[i] - pa[i]) * t)
return '#' + ((1 << 24) | (lerp(0) << 16) | (lerp(1) << 8) | lerp(2)).toString(16).slice(1)
}
// ── Defaults ─────────────────────────────────────────────────────────
export const DEFAULT_THEME: Theme = { export const DEFAULT_THEME: Theme = {
color: { color: {
gold: '#FFD700', gold: '#FFD700',
@ -46,8 +77,10 @@ export const DEFAULT_THEME: Theme = {
bronze: '#CD7F32', bronze: '#CD7F32',
cornsilk: '#FFF8DC', cornsilk: '#FFF8DC',
dim: '#B8860B', dim: '#B8860B',
completionBg: '#FFFFFF',
completionCurrentBg: mix('#FFFFFF', '#FFBF00', 0.25),
label: '#4dd0e1', label: '#DAA520',
ok: '#4caf50', ok: '#4caf50',
error: '#ef5350', error: '#ef5350',
warn: '#ffa726', warn: '#ffa726',
@ -78,6 +111,8 @@ export const DEFAULT_THEME: Theme = {
bannerHero: '' bannerHero: ''
} }
// ── Skin → Theme ─────────────────────────────────────────────────────
export function fromSkin( export function fromSkin(
colors: Record<string, string>, colors: Record<string, string>,
branding: Record<string, string>, branding: Record<string, string>,
@ -87,6 +122,8 @@ export function fromSkin(
const d = DEFAULT_THEME const d = DEFAULT_THEME
const c = (k: string) => colors[k] const c = (k: string) => colors[k]
const accent = c('banner_accent') ?? c('banner_title') ?? d.color.amber
return { return {
color: { color: {
gold: c('banner_title') ?? d.color.gold, gold: c('banner_title') ?? d.color.gold,
@ -94,6 +131,8 @@ export function fromSkin(
bronze: c('banner_border') ?? d.color.bronze, bronze: c('banner_border') ?? d.color.bronze,
cornsilk: c('banner_text') ?? d.color.cornsilk, cornsilk: c('banner_text') ?? d.color.cornsilk,
dim: c('banner_dim') ?? d.color.dim, dim: c('banner_dim') ?? d.color.dim,
completionBg: c('completion_menu_bg') ?? '#FFFFFF',
completionCurrentBg: c('completion_menu_current_bg') ?? mix('#FFFFFF', accent, 0.25),
label: c('ui_label') ?? d.color.label, label: c('ui_label') ?? d.color.label,
ok: c('ui_ok') ?? d.color.ok, ok: c('ui_ok') ?? d.color.ok,