hermes-agent/ui-tui/src/app/useMainApp.ts
Brooklyn Nicholson dd5ead1007 fix(tui): preserve prior segment output on Ctrl+C interrupt
interruptTurn only flushed the in-flight streaming chunk (bufRef) to
the transcript before calling idle(), which wiped segmentMessages and
pendingSegmentTools. Every tool call and commentary line the agent had
already emitted in the current turn disappeared the moment the user
cancelled, even though that output is exactly what they want to keep
when they hit Ctrl+C (quote from the blitz feedback: "everything was
fine up until the point where you wanted to push to main").

Append each flushed segment message to the transcript first, then
render the in-flight partial with the `*[interrupted]*` marker and its
pendingSegmentTools. Sys-level "interrupted" note still fires when
there is nothing to preserve.
2026-04-21 14:48:50 -05:00

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 }
}