mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
When the viewport is away from the bottom, keep the last visible progress snapshot instead of rebuilding the streaming/thinking subtree on every turn-store update. This cuts scroll-time churn while preserving live updates near the tail and on turn completion.
725 lines
20 KiB
TypeScript
725 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, 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 AppLayoutProgressProps, 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: { 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 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<AppLayoutProgressProps>(
|
|
() => ({ ...turn, showProgressArea, showStreamingArea: Boolean(turn.streaming) }),
|
|
[turn, showProgressArea]
|
|
)
|
|
|
|
const frozenProgressRef = useRef(liveProgress)
|
|
|
|
// When the live tail is offscreen, freeze its snapshot so scroll work doesn't
|
|
// keep rebuilding the streaming/thinking subtree the user can't see. Thaw as
|
|
// soon as the viewport comes back near the bottom or the turn finishes.
|
|
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,
|
|
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 }
|
|
}
|