mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-27 01:11:40 +00:00
Selected rows in the model/session/skills pickers and approval/clarify prompts only changed from dim gray to cornsilk, which reads as low contrast on lighter themes and LCDs (reported during TUI v2 blitz). Switch the selected row to `inverse bold` with the brand accent color across modelPicker, sessionPicker, skillsHub, and prompts so the highlight is terminal-portable and unambiguous. Unselected rows stay dim. Also extends the sessionPicker middle meta column (which was always dim) to inherit the row's selection state.
698 lines
20 KiB
TypeScript
698 lines
20 KiB
TypeScript
import { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout, useTerminalTitle } from '@hermes/ink'
|
|
import { useStore } from '@nanostores/react'
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
|
|
import { STARTUP_RESUME_ID } from '../config/env.js'
|
|
import { MAX_HISTORY, WHEEL_SCROLL_STEP } from '../config/limits.js'
|
|
import { attachedImageNotice, imageTokenMeta } from '../domain/messages.js'
|
|
import { fmtCwdBranch } from '../domain/paths.js'
|
|
import { type GatewayClient } from '../gatewayClient.js'
|
|
import type {
|
|
ClarifyRespondResponse,
|
|
ClipboardPasteResponse,
|
|
GatewayEvent,
|
|
TerminalResizeResponse
|
|
} from '../gatewayTypes.js'
|
|
import { useGitBranch } from '../hooks/useGitBranch.js'
|
|
import { useVirtualHistory } from '../hooks/useVirtualHistory.js'
|
|
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
|
|
import { terminalParityHints } from '../lib/terminalParity.js'
|
|
import { buildToolTrailLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js'
|
|
import type { Msg, PanelSection, SlashCatalog } from '../types.js'
|
|
|
|
import { createGatewayEventHandler } from './createGatewayEventHandler.js'
|
|
import { createSlashHandler } from './createSlashHandler.js'
|
|
import { type GatewayRpc, type TranscriptRow } from './interfaces.js'
|
|
import { $overlayState, patchOverlayState } from './overlayStore.js'
|
|
import { turnController } from './turnController.js'
|
|
import { $turnState, patchTurnState } from './turnStore.js'
|
|
import { $uiState, getUiState, patchUiState } from './uiStore.js'
|
|
import { useComposerState } from './useComposerState.js'
|
|
import { useConfigSync } from './useConfigSync.js'
|
|
import { useInputHandlers } from './useInputHandlers.js'
|
|
import { useLongRunToolCharms } from './useLongRunToolCharms.js'
|
|
import { useSessionLifecycle } from './useSessionLifecycle.js'
|
|
import { useSubmission } from './useSubmission.js'
|
|
|
|
const GOOD_VIBES_RE = /\b(good bot|thanks|thank you|thx|ty|ily|love you)\b/i
|
|
const BRACKET_PASTE_ON = '\x1b[?2004h'
|
|
const BRACKET_PASTE_OFF = '\x1b[?2004l'
|
|
|
|
const capHistory = (items: Msg[]): Msg[] => {
|
|
if (items.length <= MAX_HISTORY) {
|
|
return items
|
|
}
|
|
|
|
return items[0]?.kind === 'intro' ? [items[0]!, ...items.slice(-(MAX_HISTORY - 1))] : items.slice(-MAX_HISTORY)
|
|
}
|
|
|
|
const statusColorOf = (status: string, t: { dim: string; error: string; ok: string; warn: string }) => {
|
|
if (status === 'ready') {
|
|
return t.ok
|
|
}
|
|
|
|
if (status.startsWith('error')) {
|
|
return t.error
|
|
}
|
|
|
|
if (status === 'interrupted') {
|
|
return t.warn
|
|
}
|
|
|
|
return t.dim
|
|
}
|
|
|
|
interface SelectionSnap {
|
|
anchor?: { row: number }
|
|
focus?: { row: number }
|
|
isDragging?: boolean
|
|
}
|
|
|
|
export function useMainApp(gw: GatewayClient) {
|
|
const { exit } = useApp()
|
|
const { stdout } = useStdout()
|
|
const [cols, setCols] = useState(stdout?.columns ?? 80)
|
|
|
|
useEffect(() => {
|
|
if (!stdout) {
|
|
return
|
|
}
|
|
|
|
const sync = () => setCols(stdout.columns ?? 80)
|
|
|
|
stdout.on('resize', sync)
|
|
|
|
if (stdout.isTTY) {
|
|
stdout.write(BRACKET_PASTE_ON)
|
|
}
|
|
|
|
return () => {
|
|
stdout.off('resize', sync)
|
|
|
|
if (stdout.isTTY) {
|
|
stdout.write(BRACKET_PASTE_OFF)
|
|
}
|
|
}
|
|
}, [stdout])
|
|
|
|
const [historyItems, setHistoryItems] = useState<Msg[]>(() => [{ kind: 'intro', role: 'system', text: '' }])
|
|
const [lastUserMsg, setLastUserMsg] = useState('')
|
|
const [stickyPrompt, setStickyPrompt] = useState('')
|
|
const [catalog, setCatalog] = useState<null | SlashCatalog>(null)
|
|
const [voiceEnabled, setVoiceEnabled] = useState(false)
|
|
const [voiceRecording, setVoiceRecording] = useState(false)
|
|
const [voiceProcessing, setVoiceProcessing] = useState(false)
|
|
const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now())
|
|
const [turnStartedAt, setTurnStartedAt] = useState<null | number>(null)
|
|
const [goodVibesTick, setGoodVibesTick] = useState(0)
|
|
const [bellOnComplete, setBellOnComplete] = useState(false)
|
|
|
|
const ui = useStore($uiState)
|
|
const overlay = useStore($overlayState)
|
|
const turn = useStore($turnState)
|
|
|
|
const slashFlightRef = useRef(0)
|
|
const slashRef = useRef<(cmd: string) => boolean>(() => false)
|
|
const colsRef = useRef(cols)
|
|
const scrollRef = useRef<null | ScrollBoxHandle>(null)
|
|
const onEventRef = useRef<(ev: GatewayEvent) => void>(() => {})
|
|
const clipboardPasteRef = useRef<(quiet?: boolean) => Promise<void> | void>(() => {})
|
|
const submitRef = useRef<(value: string) => void>(() => {})
|
|
const terminalHintsShownRef = useRef(new Set<string>())
|
|
const historyItemsRef = useRef(historyItems)
|
|
const lastUserMsgRef = useRef(lastUserMsg)
|
|
const msgIdsRef = useRef(new WeakMap<Msg, string>())
|
|
const nextMsgIdRef = useRef(0)
|
|
|
|
colsRef.current = cols
|
|
historyItemsRef.current = historyItems
|
|
lastUserMsgRef.current = lastUserMsg
|
|
|
|
const hasSelection = useHasSelection()
|
|
const selection = useSelection()
|
|
|
|
useEffect(() => {
|
|
selection.setSelectionBgColor(ui.theme.color.selectionBg)
|
|
}, [selection, ui.theme.color.selectionBg])
|
|
|
|
const composer = useComposerState({
|
|
gw,
|
|
onClipboardPaste: quiet => clipboardPasteRef.current(quiet),
|
|
onImageAttached: info => {
|
|
sys(attachedImageNotice(info))
|
|
},
|
|
submitRef
|
|
})
|
|
|
|
const { actions: composerActions, refs: composerRefs, state: composerState } = composer
|
|
const empty = !historyItems.some(msg => msg.kind !== 'intro')
|
|
|
|
useEffect(() => {
|
|
void terminalParityHints()
|
|
.then(hints => {
|
|
for (const hint of hints) {
|
|
if (terminalHintsShownRef.current.has(hint.key)) {
|
|
continue
|
|
}
|
|
|
|
terminalHintsShownRef.current.add(hint.key)
|
|
turnController.pushActivity(hint.message, hint.tone)
|
|
}
|
|
})
|
|
.catch(() => {})
|
|
}, [])
|
|
|
|
const messageId = useCallback((msg: Msg) => {
|
|
const hit = msgIdsRef.current.get(msg)
|
|
|
|
if (hit) {
|
|
return hit
|
|
}
|
|
|
|
const next = `m${++nextMsgIdRef.current}`
|
|
|
|
msgIdsRef.current.set(msg, next)
|
|
|
|
return next
|
|
}, [])
|
|
|
|
const virtualRows = useMemo<TranscriptRow[]>(
|
|
() => historyItems.map((msg, index) => ({ index, key: messageId(msg), msg })),
|
|
[historyItems, messageId]
|
|
)
|
|
|
|
const virtualHistory = useVirtualHistory(scrollRef, virtualRows, cols)
|
|
|
|
const scrollWithSelection = useCallback(
|
|
(delta: number) => {
|
|
const s = scrollRef.current
|
|
|
|
if (!s) {
|
|
return
|
|
}
|
|
|
|
const sel = selection.getState() as null | SelectionSnap
|
|
const top = s.getViewportTop()
|
|
const bottom = top + s.getViewportHeight() - 1
|
|
|
|
if (
|
|
!sel?.anchor ||
|
|
!sel.focus ||
|
|
sel.anchor.row < top ||
|
|
sel.anchor.row > bottom ||
|
|
(!sel.isDragging && (sel.focus.row < top || sel.focus.row > bottom))
|
|
) {
|
|
return s.scrollBy(delta)
|
|
}
|
|
|
|
const max = Math.max(0, s.getScrollHeight() - s.getViewportHeight())
|
|
const cur = s.getScrollTop() + s.getPendingDelta()
|
|
const actual = Math.max(0, Math.min(max, cur + delta)) - cur
|
|
|
|
if (actual === 0) {
|
|
return
|
|
}
|
|
|
|
const shift = sel!.isDragging ? selection.shiftAnchor : selection.shiftSelection
|
|
|
|
if (actual > 0) {
|
|
selection.captureScrolledRows(top, top + actual - 1, 'above')
|
|
} else {
|
|
selection.captureScrolledRows(bottom + actual + 1, bottom, 'below')
|
|
}
|
|
|
|
shift(-actual, top, bottom)
|
|
s.scrollBy(delta)
|
|
},
|
|
[selection]
|
|
)
|
|
|
|
const appendMessage = useCallback((msg: Msg) => setHistoryItems(prev => capHistory([...prev, msg])), [])
|
|
|
|
const sys = useCallback((text: string) => appendMessage({ role: 'system', text }), [appendMessage])
|
|
|
|
const page = useCallback(
|
|
(text: string, title?: string) => patchOverlayState({ pager: { lines: text.split('\n'), offset: 0, title } }),
|
|
[]
|
|
)
|
|
|
|
const panel = useCallback(
|
|
(title: string, sections: PanelSection[]) =>
|
|
appendMessage({ kind: 'panel', panelData: { sections, title }, role: 'system', text: '' }),
|
|
[appendMessage]
|
|
)
|
|
|
|
const maybeWarn = useCallback(
|
|
(value: unknown) => {
|
|
const warning = (value as { warning?: unknown } | null)?.warning
|
|
|
|
if (typeof warning === 'string' && warning) {
|
|
sys(`warning: ${warning}`)
|
|
}
|
|
},
|
|
[sys]
|
|
)
|
|
|
|
const maybeGoodVibes = useCallback((text: string) => {
|
|
if (GOOD_VIBES_RE.test(text)) {
|
|
setGoodVibesTick(v => v + 1)
|
|
}
|
|
}, [])
|
|
|
|
const rpc: GatewayRpc = useCallback(
|
|
async <T extends Record<string, any> = Record<string, any>>(
|
|
method: string,
|
|
params: Record<string, unknown> = {}
|
|
) => {
|
|
try {
|
|
const result = asRpcResult<T>(await gw.request<T>(method, params))
|
|
|
|
if (result) {
|
|
return result
|
|
}
|
|
|
|
sys(`error: invalid response: ${method}`)
|
|
} catch (e) {
|
|
sys(`error: ${rpcErrorMessage(e)}`)
|
|
}
|
|
|
|
return null
|
|
},
|
|
[gw, sys]
|
|
)
|
|
|
|
const gateway = useMemo(() => ({ gw, rpc }), [gw, rpc])
|
|
|
|
const die = useCallback(() => {
|
|
gw.kill()
|
|
exit()
|
|
}, [exit, gw])
|
|
|
|
const session = useSessionLifecycle({
|
|
colsRef,
|
|
composerActions,
|
|
gw,
|
|
panel,
|
|
rpc,
|
|
scrollRef,
|
|
setHistoryItems,
|
|
setLastUserMsg,
|
|
setSessionStartedAt,
|
|
setStickyPrompt,
|
|
setVoiceProcessing,
|
|
setVoiceRecording,
|
|
sys
|
|
})
|
|
|
|
useEffect(() => {
|
|
if (ui.busy) {
|
|
setTurnStartedAt(prev => prev ?? Date.now())
|
|
} else {
|
|
setTurnStartedAt(null)
|
|
}
|
|
}, [ui.busy])
|
|
|
|
useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, sid: ui.sid })
|
|
|
|
// ── Terminal tab title ─────────────────────────────────────────────
|
|
// Show model name + status so users can identify the Hermes tab.
|
|
const shortModel = ui.info?.model?.replace(/^.*\//, '') ?? ''
|
|
const titleStatus = ui.busy ? '⏳' : '✓'
|
|
const terminalTitle = shortModel ? `${titleStatus} ${shortModel} — Hermes` : 'Hermes'
|
|
useTerminalTitle(terminalTitle)
|
|
|
|
useEffect(() => {
|
|
if (!ui.sid || !stdout) {
|
|
return
|
|
}
|
|
|
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
|
|
const onResize = () => {
|
|
clearTimeout(timer)
|
|
timer = setTimeout(() => {
|
|
timer = undefined
|
|
void rpc<TerminalResizeResponse>('terminal.resize', { cols: stdout.columns ?? 80, session_id: ui.sid })
|
|
}, 100)
|
|
}
|
|
|
|
stdout.on('resize', onResize)
|
|
|
|
return () => {
|
|
clearTimeout(timer)
|
|
stdout.off('resize', onResize)
|
|
}
|
|
}, [rpc, stdout, ui.sid])
|
|
|
|
const answerClarify = useCallback(
|
|
(answer: string) => {
|
|
const clarify = overlay.clarify
|
|
|
|
if (!clarify) {
|
|
return
|
|
}
|
|
|
|
const label = toolTrailLabel('clarify')
|
|
|
|
turnController.turnTools = turnController.turnTools.filter(line => !sameToolTrailGroup(label, line))
|
|
patchTurnState({ turnTrail: turnController.turnTools })
|
|
|
|
rpc<ClarifyRespondResponse>('clarify.respond', { answer, request_id: clarify.requestId }).then(r => {
|
|
if (!r) {
|
|
return
|
|
}
|
|
|
|
if (answer) {
|
|
turnController.persistedToolLabels.add(label)
|
|
appendMessage({
|
|
kind: 'trail',
|
|
role: 'system',
|
|
text: '',
|
|
tools: [buildToolTrailLine('clarify', clarify.question)]
|
|
})
|
|
appendMessage({ role: 'user', text: answer })
|
|
patchUiState({ status: 'running…' })
|
|
} else {
|
|
sys('prompt cancelled')
|
|
}
|
|
|
|
patchOverlayState({ clarify: null })
|
|
})
|
|
},
|
|
[appendMessage, overlay.clarify, rpc, sys]
|
|
)
|
|
|
|
const paste = useCallback(
|
|
(quiet = false) =>
|
|
rpc<ClipboardPasteResponse>('clipboard.paste', { session_id: getUiState().sid }).then(r => {
|
|
if (!r) {
|
|
return
|
|
}
|
|
|
|
if (r.attached) {
|
|
const meta = imageTokenMeta(r)
|
|
|
|
return sys(`📎 Image #${r.count} attached from clipboard${meta ? ` · ${meta}` : ''}`)
|
|
}
|
|
|
|
if (!quiet) {
|
|
sys(r.message || 'No image found in clipboard')
|
|
}
|
|
}),
|
|
[rpc, sys]
|
|
)
|
|
|
|
clipboardPasteRef.current = paste
|
|
|
|
const { dispatchSubmission, send, sendQueued, shellExec, submit } = useSubmission({
|
|
appendMessage,
|
|
composerActions,
|
|
composerRefs,
|
|
composerState,
|
|
gw,
|
|
maybeGoodVibes,
|
|
setLastUserMsg,
|
|
slashRef,
|
|
submitRef,
|
|
sys
|
|
})
|
|
|
|
// Drain one queued message whenever the session settles (busy → false):
|
|
// agent turn ends, interrupt, shell.exec finishes, error recovered, or the
|
|
// session first comes up with pre-queued messages. Without this, shell.exec
|
|
// and error paths never emit message.complete, so anything enqueued while
|
|
// `!sleep` / a failed turn was running would stay stuck forever.
|
|
useEffect(() => {
|
|
if (
|
|
!ui.sid ||
|
|
ui.busy ||
|
|
composerRefs.queueEditRef.current !== null ||
|
|
composerRefs.queueRef.current.length === 0
|
|
) {
|
|
return
|
|
}
|
|
|
|
const next = composerActions.dequeue()
|
|
|
|
if (next) {
|
|
sendQueued(next)
|
|
}
|
|
}, [ui.sid, ui.busy, composerActions, composerRefs, sendQueued])
|
|
|
|
const { pagerPageSize } = useInputHandlers({
|
|
actions: {
|
|
answerClarify,
|
|
appendMessage,
|
|
die,
|
|
dispatchSubmission,
|
|
guardBusySessionSwitch: session.guardBusySessionSwitch,
|
|
newSession: session.newSession,
|
|
sys
|
|
},
|
|
composer: { actions: composerActions, refs: composerRefs, state: composerState },
|
|
gateway,
|
|
terminal: { hasSelection, scrollRef, scrollWithSelection, selection, stdout },
|
|
voice: { recording: voiceRecording, setProcessing: setVoiceProcessing, setRecording: setVoiceRecording },
|
|
wheelStep: WHEEL_SCROLL_STEP
|
|
})
|
|
|
|
const onEvent = useMemo(
|
|
() =>
|
|
createGatewayEventHandler({
|
|
gateway,
|
|
session: {
|
|
STARTUP_RESUME_ID,
|
|
colsRef,
|
|
newSession: session.newSession,
|
|
resetSession: session.resetSession,
|
|
resumeById: session.resumeById,
|
|
setCatalog
|
|
},
|
|
system: { bellOnComplete, stdout, sys },
|
|
transcript: { appendMessage, panel, setHistoryItems }
|
|
}),
|
|
[
|
|
appendMessage,
|
|
bellOnComplete,
|
|
gateway,
|
|
panel,
|
|
session.newSession,
|
|
session.resetSession,
|
|
session.resumeById,
|
|
stdout,
|
|
sys
|
|
]
|
|
)
|
|
|
|
onEventRef.current = onEvent
|
|
|
|
useEffect(() => {
|
|
const handler = (ev: GatewayEvent) => onEventRef.current(ev)
|
|
|
|
const exitHandler = () => {
|
|
turnController.reset()
|
|
patchUiState({ busy: false, sid: null, status: 'gateway exited' })
|
|
turnController.pushActivity('gateway exited · /logs to inspect', 'error')
|
|
sys('error: gateway exited')
|
|
}
|
|
|
|
gw.on('event', handler)
|
|
gw.on('exit', exitHandler)
|
|
gw.drain()
|
|
|
|
return () => {
|
|
gw.off('event', handler)
|
|
gw.off('exit', exitHandler)
|
|
gw.kill()
|
|
}
|
|
}, [gw, sys])
|
|
|
|
useLongRunToolCharms(ui.busy, turn.tools)
|
|
|
|
const slash = useMemo(
|
|
() =>
|
|
createSlashHandler({
|
|
composer: {
|
|
enqueue: composerActions.enqueue,
|
|
hasSelection,
|
|
paste,
|
|
queueRef: composerRefs.queueRef,
|
|
selection,
|
|
setInput: composerActions.setInput
|
|
},
|
|
gateway,
|
|
local: {
|
|
catalog,
|
|
getHistoryItems: () => historyItemsRef.current,
|
|
getLastUserMsg: () => lastUserMsgRef.current,
|
|
maybeWarn
|
|
},
|
|
session: {
|
|
closeSession: session.closeSession,
|
|
die,
|
|
guardBusySessionSwitch: session.guardBusySessionSwitch,
|
|
newSession: session.newSession,
|
|
resetVisibleHistory: session.resetVisibleHistory,
|
|
resumeById: session.resumeById,
|
|
setSessionStartedAt
|
|
},
|
|
slashFlightRef,
|
|
transcript: { page, panel, send, setHistoryItems, sys, trimLastExchange: session.trimLastExchange },
|
|
voice: { setVoiceEnabled }
|
|
}),
|
|
[
|
|
catalog,
|
|
composerActions,
|
|
composerRefs,
|
|
die,
|
|
gateway,
|
|
hasSelection,
|
|
maybeWarn,
|
|
page,
|
|
panel,
|
|
paste,
|
|
selection,
|
|
send,
|
|
session,
|
|
sys
|
|
]
|
|
)
|
|
|
|
slashRef.current = slash
|
|
|
|
const respondWith = useCallback(
|
|
(method: string, params: Record<string, unknown>, done: () => void) => rpc(method, params).then(r => r && done()),
|
|
[rpc]
|
|
)
|
|
|
|
const answerApproval = useCallback(
|
|
(choice: string) =>
|
|
respondWith('approval.respond', { choice, session_id: ui.sid }, () => {
|
|
patchOverlayState({ approval: null })
|
|
patchTurnState({ outcome: choice === 'deny' ? 'denied' : `approved (${choice})` })
|
|
patchUiState({ status: 'running…' })
|
|
}),
|
|
[respondWith, ui.sid]
|
|
)
|
|
|
|
const answerSudo = useCallback(
|
|
(pw: string) => {
|
|
if (!overlay.sudo) {
|
|
return
|
|
}
|
|
|
|
return respondWith('sudo.respond', { password: pw, request_id: overlay.sudo.requestId }, () => {
|
|
patchOverlayState({ sudo: null })
|
|
patchUiState({ status: 'running…' })
|
|
})
|
|
},
|
|
[overlay.sudo, respondWith]
|
|
)
|
|
|
|
const answerSecret = useCallback(
|
|
(value: string) => {
|
|
if (!overlay.secret) {
|
|
return
|
|
}
|
|
|
|
return respondWith('secret.respond', { request_id: overlay.secret.requestId, value }, () => {
|
|
patchOverlayState({ secret: null })
|
|
patchUiState({ status: 'running…' })
|
|
})
|
|
},
|
|
[overlay.secret, respondWith]
|
|
)
|
|
|
|
const onModelSelect = useCallback((value: string) => {
|
|
patchOverlayState({ modelPicker: false })
|
|
slashRef.current(`/model ${value}`)
|
|
}, [])
|
|
|
|
const hasReasoning = Boolean(turn.reasoning.trim())
|
|
|
|
const showProgressArea =
|
|
ui.detailsMode === 'hidden'
|
|
? turn.activity.some(item => item.tone !== 'info')
|
|
: Boolean(
|
|
ui.busy ||
|
|
turn.outcome ||
|
|
turn.streamPendingTools.length ||
|
|
turn.streamSegments.length ||
|
|
turn.subagents.length ||
|
|
turn.tools.length ||
|
|
turn.turnTrail.length ||
|
|
hasReasoning ||
|
|
turn.activity.length
|
|
)
|
|
|
|
const appActions = useMemo(
|
|
() => ({
|
|
answerApproval,
|
|
answerClarify,
|
|
answerSecret,
|
|
answerSudo,
|
|
onModelSelect,
|
|
resumeById: session.resumeById,
|
|
setStickyPrompt
|
|
}),
|
|
[answerApproval, answerClarify, answerSecret, answerSudo, onModelSelect, session.resumeById]
|
|
)
|
|
|
|
const appComposer = useMemo(
|
|
() => ({
|
|
cols,
|
|
compIdx: composerState.compIdx,
|
|
completions: composerState.completions,
|
|
empty,
|
|
handleTextPaste: composerActions.handleTextPaste,
|
|
input: composerState.input,
|
|
inputBuf: composerState.inputBuf,
|
|
pagerPageSize,
|
|
queueEditIdx: composerState.queueEditIdx,
|
|
queuedDisplay: composerState.queuedDisplay,
|
|
submit,
|
|
updateInput: composerActions.setInput
|
|
}),
|
|
[cols, composerActions, composerState, empty, pagerPageSize, submit]
|
|
)
|
|
|
|
const appProgress = useMemo(
|
|
() => ({ ...turn, showProgressArea, showStreamingArea: Boolean(turn.streaming) }),
|
|
[turn, showProgressArea]
|
|
)
|
|
|
|
const cwd = ui.info?.cwd || process.env.HERMES_CWD || process.cwd()
|
|
const gitBranch = useGitBranch(cwd)
|
|
|
|
const appStatus = useMemo(
|
|
() => ({
|
|
cwdLabel: fmtCwdBranch(cwd, gitBranch),
|
|
goodVibesTick,
|
|
sessionStartedAt: ui.sid ? sessionStartedAt : null,
|
|
showStickyPrompt: !!stickyPrompt,
|
|
statusColor: statusColorOf(ui.status, ui.theme.color),
|
|
stickyPrompt,
|
|
turnStartedAt: ui.sid ? turnStartedAt : null,
|
|
voiceLabel: voiceRecording ? 'REC' : voiceProcessing ? 'STT' : `voice ${voiceEnabled ? 'on' : 'off'}`
|
|
}),
|
|
[
|
|
cwd,
|
|
gitBranch,
|
|
goodVibesTick,
|
|
sessionStartedAt,
|
|
stickyPrompt,
|
|
turnStartedAt,
|
|
ui,
|
|
voiceEnabled,
|
|
voiceProcessing,
|
|
voiceRecording
|
|
]
|
|
)
|
|
|
|
const appTranscript = useMemo(
|
|
() => ({ historyItems, scrollRef, virtualHistory, virtualRows }),
|
|
[historyItems, virtualHistory, virtualRows]
|
|
)
|
|
|
|
return { appActions, appComposer, appProgress, appStatus, appTranscript, gateway }
|
|
}
|