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()
|
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 = ""
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
36
ui-tui/package-lock.json
generated
36
ui-tui/package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
3272
ui-tui/src/app.tsx
3272
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 { 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 = (() => {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,3 @@ export function append(line: string): void {
|
||||||
/* ignore */
|
/* 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) => {
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue