mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
Copilot review on #14968 caught that the early returns gated on the global `detailsMode === 'hidden'` short-circuited every render path before sectionMode() got a chance to apply per-section overrides — so `details_mode: hidden` + `sections.tools: expanded` was silently a no-op. Three call sites had the same bug shape; all now key off the resolved section modes: - ToolTrail: replace the `detailsMode === 'hidden'` early return with an `allHidden = every section resolved to hidden` check. When that's true, fall back to the floating-alert backstop (errors/warnings) so quiet-mode users aren't blind to ambient failures, and update the comment block to match the actual condition. - messageLine.tsx: drop the same `detailsMode === 'hidden'` pre-check on `msg.kind === 'trail'`; only skip rendering the wrapper when every section resolves to hidden (`SECTION_NAMES.some(...) !== 'hidden'`). - useMainApp.ts: rebuild `showProgressArea` around `anyPanelVisible` instead of branching on the global mode. This also fixes the suppressed Copilot concern about an empty wrapper Box rendering above the streaming area when ToolTrail returns null. Regression test in details.test.ts pins the override-escapes-hidden behaviour for tools/thinking/activity. 271/271 vitest, lints clean.
749 lines
21 KiB
TypeScript
749 lines
21 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 { SECTION_NAMES, sectionMode } from '../domain/details.js'
|
|
import { attachedImageNotice, imageTokenMeta } from '../domain/messages.js'
|
|
import { fmtCwdBranch, shortCwd } 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 })
|
|
|
|
// Tab title: `⚠` waiting on approval/sudo/secret/clarify, `⏳` busy, `✓` idle.
|
|
const model = ui.info?.model?.replace(/^.*\//, '') ?? ''
|
|
|
|
const marker = overlay.approval || overlay.sudo || overlay.secret || overlay.clarify ? '⚠' : ui.busy ? '⏳' : '✓'
|
|
|
|
const tabCwd = ui.info?.cwd
|
|
|
|
useTerminalTitle(model ? `${marker} ${model}${tabCwd ? ` · ${shortCwd(tabCwd, 24)}` : ''}` : 'Hermes')
|
|
|
|
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: {
|
|
enabled: voiceEnabled,
|
|
recording: voiceRecording,
|
|
setProcessing: setVoiceProcessing,
|
|
setRecording: setVoiceRecording,
|
|
setVoiceEnabled
|
|
},
|
|
wheelStep: WHEEL_SCROLL_STEP
|
|
})
|
|
|
|
const onEvent = useMemo(
|
|
() =>
|
|
createGatewayEventHandler({
|
|
composer: { setInput: composerActions.setInput },
|
|
gateway,
|
|
session: {
|
|
STARTUP_RESUME_ID,
|
|
colsRef,
|
|
newSession: session.newSession,
|
|
resetSession: session.resetSession,
|
|
resumeById: session.resumeById,
|
|
setCatalog
|
|
},
|
|
submission: { submitRef },
|
|
system: { bellOnComplete, stdout, sys },
|
|
transcript: { appendMessage, panel, setHistoryItems },
|
|
voice: {
|
|
setProcessing: setVoiceProcessing,
|
|
setRecording: setVoiceRecording,
|
|
setVoiceEnabled
|
|
}
|
|
}),
|
|
[
|
|
appendMessage,
|
|
bellOnComplete,
|
|
composerActions.setInput,
|
|
gateway,
|
|
panel,
|
|
session.newSession,
|
|
session.resetSession,
|
|
session.resumeById,
|
|
setVoiceEnabled,
|
|
setVoiceProcessing,
|
|
setVoiceRecording,
|
|
stdout,
|
|
submitRef,
|
|
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())
|
|
|
|
// Per-section overrides win over the global mode — when every section is
|
|
// resolved to hidden, the only thing ToolTrail will surface is the
|
|
// floating-alert backstop (errors/warnings). Mirror that so we don't
|
|
// render an empty wrapper Box above the streaming area in quiet mode.
|
|
const anyPanelVisible = SECTION_NAMES.some(s => sectionMode(s, ui.detailsMode, ui.sections) !== 'hidden')
|
|
|
|
const showProgressArea = anyPanelVisible
|
|
? Boolean(
|
|
ui.busy ||
|
|
turn.outcome ||
|
|
turn.streamPendingTools.length ||
|
|
turn.streamSegments.length ||
|
|
turn.subagents.length ||
|
|
turn.tools.length ||
|
|
turn.turnTrail.length ||
|
|
hasReasoning ||
|
|
turn.activity.length
|
|
)
|
|
: turn.activity.some(item => item.tone !== 'info')
|
|
|
|
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 liveTailVisible = (() => {
|
|
const s = scrollRef.current
|
|
|
|
if (!s) {
|
|
return true
|
|
}
|
|
|
|
const top = Math.max(0, s.getScrollTop() + s.getPendingDelta())
|
|
const vp = Math.max(0, s.getViewportHeight())
|
|
const total = Math.max(vp, s.getScrollHeight())
|
|
|
|
return top + vp >= total - 3
|
|
})()
|
|
|
|
const liveProgress = useMemo(
|
|
() => ({ ...turn, showProgressArea, showStreamingArea: Boolean(turn.streaming) }),
|
|
[turn, showProgressArea]
|
|
)
|
|
|
|
const frozenProgressRef = useRef(liveProgress)
|
|
|
|
// Freeze the offscreen live tail so scroll doesn't rebuild unseen streaming UI.
|
|
if (liveTailVisible || !ui.busy) {
|
|
frozenProgressRef.current = liveProgress
|
|
}
|
|
|
|
const appProgress = liveTailVisible || !ui.busy ? liveProgress : frozenProgressRef.current
|
|
|
|
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,
|
|
// CLI parity: the classic prompt_toolkit status bar shows a red dot
|
|
// on REC (cli.py:_get_voice_status_fragments line 2344).
|
|
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 }
|
|
}
|