mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat: refactor by splitting up app and doing proper state
This commit is contained in:
parent
4cbf54fb33
commit
99d859ce4a
27 changed files with 4087 additions and 2939 deletions
2
cli.py
2
cli.py
|
|
@ -3752,7 +3752,7 @@ class HermesCLI:
|
|||
skin = get_active_skin()
|
||||
separator_color = skin.get_color("banner_dim", "#B8860B")
|
||||
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:
|
||||
separator_color, accent_color, label_color = "#B8860B", "#FFBF00", "cyan"
|
||||
toolsets_info = ""
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ All fields are optional. Missing values inherit from the ``default`` skin.
|
|||
banner_dim: "#B8860B" # Dim/muted text (separators, labels)
|
||||
banner_text: "#FFF8DC" # Body text (tool names, skill names)
|
||||
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_error: "#ef5350" # Error indicators
|
||||
ui_warn: "#ffa726" # Warning indicators
|
||||
|
|
@ -163,7 +163,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
|
|||
"banner_dim": "#B8860B",
|
||||
"banner_text": "#FFF8DC",
|
||||
"ui_accent": "#FFBF00",
|
||||
"ui_label": "#4dd0e1",
|
||||
"ui_label": "#DAA520",
|
||||
"ui_ok": "#4caf50",
|
||||
"ui_error": "#ef5350",
|
||||
"ui_warn": "#ffa726",
|
||||
|
|
|
|||
36
ui-tui/package-lock.json
generated
36
ui-tui/package-lock.json
generated
|
|
@ -9,6 +9,7 @@
|
|||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@hermes/ink": "file:./packages/hermes-ink",
|
||||
"@nanostores/react": "^1.1.0",
|
||||
"ink": "^6.8.0",
|
||||
"ink-text-input": "^6.0.0",
|
||||
"react": "^19.2.4",
|
||||
|
|
@ -1115,6 +1116,25 @@
|
|||
"@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": {
|
||||
"version": "1.1.3",
|
||||
"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_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": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@hermes/ink": "file:./packages/hermes-ink",
|
||||
"@nanostores/react": "^1.1.0",
|
||||
"ink": "^6.8.0",
|
||||
"ink-text-input": "^6.0.0",
|
||||
"react": "^19.2.4",
|
||||
|
|
|
|||
3482
ui-tui/src/app.tsx
3482
ui-tui/src/app.tsx
File diff suppressed because it is too large
Load diff
15
ui-tui/src/app/constants.ts
Normal file
15
ui-tui/src/app/constants.ts
Normal 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
|
||||
487
ui-tui/src/app/createGatewayEventHandler.ts
Normal file
487
ui-tui/src/app/createGatewayEventHandler.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
1058
ui-tui/src/app/createSlashHandler.ts
Normal file
1058
ui-tui/src/app/createSlashHandler.ts
Normal file
File diff suppressed because it is too large
Load diff
24
ui-tui/src/app/gatewayContext.tsx
Normal file
24
ui-tui/src/app/gatewayContext.tsx
Normal 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
167
ui-tui/src/app/helpers.ts
Normal 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 ''
|
||||
}
|
||||
67
ui-tui/src/app/interfaces.ts
Normal file
67
ui-tui/src/app/interfaces.ts
Normal 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
|
||||
}
|
||||
41
ui-tui/src/app/overlayStore.ts
Normal file
41
ui-tui/src/app/overlayStore.ts
Normal 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
41
ui-tui/src/app/uiStore.ts
Normal 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())
|
||||
}
|
||||
199
ui-tui/src/app/useComposerState.ts
Normal file
199
ui-tui/src/app/useComposerState.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
345
ui-tui/src/app/useInputHandlers.ts
Normal file
345
ui-tui/src/app/useInputHandlers.ts
Normal 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 }
|
||||
}
|
||||
296
ui-tui/src/app/useTurnState.ts
Normal file
296
ui-tui/src/app/useTurnState.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
227
ui-tui/src/components/appChrome.tsx
Normal file
227
ui-tui/src/components/appChrome.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
248
ui-tui/src/components/appLayout.tsx
Normal file
248
ui-tui/src/components/appLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
175
ui-tui/src/components/appOverlays.tsx
Normal file
175
ui-tui/src/components/appOverlays.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -44,7 +44,7 @@ export const MessageLine = memo(function MessageLine({
|
|||
}
|
||||
|
||||
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 content = (() => {
|
||||
|
|
|
|||
|
|
@ -14,16 +14,6 @@ export function getQueueWindow(queueLen: number, queueEditIdx: number | null) {
|
|||
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({
|
||||
cols,
|
||||
queueEditIdx,
|
||||
|
|
|
|||
|
|
@ -43,11 +43,16 @@ export function Spinner({ color, variant = 'think' }: { color: string; variant?:
|
|||
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) {
|
||||
return (
|
||||
<Text color={color} dimColor={dimColor}>
|
||||
<Text color={color} dimColor={dimColor} wrap="wrap-trim">
|
||||
<Text dimColor>└ </Text>
|
||||
{content}
|
||||
</Text>
|
||||
|
|
@ -141,7 +146,7 @@ export const Thinking = memo(function Thinking({
|
|||
</Text>
|
||||
|
||||
{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>
|
||||
{preview}
|
||||
<StreamCursor color={t.color.dim} dimColor streaming={streaming} visible={active} />
|
||||
|
|
@ -158,7 +163,12 @@ export const Thinking = memo(function Thinking({
|
|||
|
||||
// ── 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({
|
||||
busy = false,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
#!/usr/bin/env node
|
||||
import { render } from '@hermes/ink'
|
||||
import React from 'react'
|
||||
|
||||
import { App } from './app.js'
|
||||
import { GatewayClient } from './gatewayClient.js'
|
||||
|
|
|
|||
|
|
@ -81,7 +81,3 @@ export function append(line: string): void {
|
|||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function all(): string[] {
|
||||
return load()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,9 +74,17 @@ export const pasteTokenLabel = (text: string, lineCount: 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)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ export interface ThemeColors {
|
|||
bronze: string
|
||||
cornsilk: string
|
||||
dim: string
|
||||
completionBg: string
|
||||
completionCurrentBg: string
|
||||
|
||||
label: string
|
||||
ok: string
|
||||
|
|
@ -39,6 +41,35 @@ export interface Theme {
|
|||
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 = {
|
||||
color: {
|
||||
gold: '#FFD700',
|
||||
|
|
@ -46,8 +77,10 @@ export const DEFAULT_THEME: Theme = {
|
|||
bronze: '#CD7F32',
|
||||
cornsilk: '#FFF8DC',
|
||||
dim: '#B8860B',
|
||||
completionBg: '#FFFFFF',
|
||||
completionCurrentBg: mix('#FFFFFF', '#FFBF00', 0.25),
|
||||
|
||||
label: '#4dd0e1',
|
||||
label: '#DAA520',
|
||||
ok: '#4caf50',
|
||||
error: '#ef5350',
|
||||
warn: '#ffa726',
|
||||
|
|
@ -78,6 +111,8 @@ export const DEFAULT_THEME: Theme = {
|
|||
bannerHero: ''
|
||||
}
|
||||
|
||||
// ── Skin → Theme ─────────────────────────────────────────────────────
|
||||
|
||||
export function fromSkin(
|
||||
colors: Record<string, string>,
|
||||
branding: Record<string, string>,
|
||||
|
|
@ -87,6 +122,8 @@ export function fromSkin(
|
|||
const d = DEFAULT_THEME
|
||||
const c = (k: string) => colors[k]
|
||||
|
||||
const accent = c('banner_accent') ?? c('banner_title') ?? d.color.amber
|
||||
|
||||
return {
|
||||
color: {
|
||||
gold: c('banner_title') ?? d.color.gold,
|
||||
|
|
@ -94,6 +131,8 @@ export function fromSkin(
|
|||
bronze: c('banner_border') ?? d.color.bronze,
|
||||
cornsilk: c('banner_text') ?? d.color.cornsilk,
|
||||
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,
|
||||
ok: c('ui_ok') ?? d.color.ok,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue