mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
refactor(tui): turn elapsed lives in FaceTicker; emit done-in sys line
Drops `lastUserAt` plumbing and the right-edge idle ticker. Matches the
claude-code / opencode convention: elapsed rides with the busy indicator
(spinner verb), nothing at idle.
- `turnStartedAt` driven by a useEffect on `ui.busy` — stamps on rising
edge, clears on falling edge. Covers agent turns and !shell alike.
- FaceTicker renders ` · {fmtDuration}` while busy; 1 s clock for the
counter, existing 2500 ms cycle for face/verb rotation.
- On busy → idle, if the block ran ≥ 1 s, emit a one-shot
`done in {fmtDuration}` sys line (≡ claude-code's `thought for Ns`).
This commit is contained in:
parent
9910681b85
commit
2de1aad028
8 changed files with 43 additions and 50 deletions
|
|
@ -284,7 +284,6 @@ const buildSession = () => ({
|
|||
newSession: vi.fn(),
|
||||
resetVisibleHistory: vi.fn(),
|
||||
resumeById: vi.fn(),
|
||||
setLastUserAt: vi.fn(),
|
||||
setSessionStartedAt: vi.fn()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -237,7 +237,6 @@ export interface SlashHandlerContext {
|
|||
newSession: (msg?: string) => void
|
||||
resetVisibleHistory: (info?: null | SessionInfo) => void
|
||||
resumeById: (id: string) => void
|
||||
setLastUserAt: StateSetter<null | number>
|
||||
setSessionStartedAt: StateSetter<number>
|
||||
}
|
||||
slashFlightRef: MutableRefObject<number>
|
||||
|
|
@ -300,11 +299,11 @@ export interface AppLayoutProgressProps {
|
|||
export interface AppLayoutStatusProps {
|
||||
cwdLabel: string
|
||||
goodVibesTick: number
|
||||
lastUserAt: null | number
|
||||
sessionStartedAt: null | number
|
||||
showStickyPrompt: boolean
|
||||
statusColor: string
|
||||
stickyPrompt: string
|
||||
turnStartedAt: null | number
|
||||
voiceLabel: string
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -178,7 +178,6 @@ export const sessionCommands: SlashCommand[] = [
|
|||
void ctx.session.closeSession(prevSid)
|
||||
patchUiState({ sid: r.session_id })
|
||||
ctx.session.setSessionStartedAt(Date.now())
|
||||
ctx.session.setLastUserAt(null)
|
||||
ctx.transcript.setHistoryItems([])
|
||||
ctx.transcript.sys(`branched → ${r.title ?? ''}`)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ 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 { imageTokenMeta } from '../domain/messages.js'
|
||||
import { fmtDuration, imageTokenMeta } from '../domain/messages.js'
|
||||
import { fmtCwdBranch } from '../domain/paths.js'
|
||||
import { type GatewayClient } from '../gatewayClient.js'
|
||||
import type {
|
||||
|
|
@ -102,7 +102,7 @@ export function useMainApp(gw: GatewayClient) {
|
|||
const [voiceRecording, setVoiceRecording] = useState(false)
|
||||
const [voiceProcessing, setVoiceProcessing] = useState(false)
|
||||
const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now())
|
||||
const [lastUserAt, setLastUserAt] = useState<null | number>(null)
|
||||
const [turnStartedAt, setTurnStartedAt] = useState<null | number>(null)
|
||||
const [goodVibesTick, setGoodVibesTick] = useState(0)
|
||||
const [bellOnComplete, setBellOnComplete] = useState(false)
|
||||
|
||||
|
|
@ -276,7 +276,6 @@ export function useMainApp(gw: GatewayClient) {
|
|||
rpc,
|
||||
scrollRef,
|
||||
setHistoryItems,
|
||||
setLastUserAt,
|
||||
setLastUserMsg,
|
||||
setSessionStartedAt,
|
||||
setStickyPrompt,
|
||||
|
|
@ -285,6 +284,26 @@ export function useMainApp(gw: GatewayClient) {
|
|||
sys
|
||||
})
|
||||
|
||||
// Drive turnStartedAt from the busy edge and emit a one-shot "done in Xs"
|
||||
// line on the idle edge. Covers agent turns and `!shell` alike — only
|
||||
// suppresses when the block is under ~1s (too quick to matter).
|
||||
useEffect(() => {
|
||||
if (ui.busy && turnStartedAt === null) {
|
||||
setTurnStartedAt(Date.now())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!ui.busy && turnStartedAt !== null) {
|
||||
const elapsed = Date.now() - turnStartedAt
|
||||
setTurnStartedAt(null)
|
||||
|
||||
if (elapsed >= 1000) {
|
||||
sys(`done in ${fmtDuration(elapsed)}`)
|
||||
}
|
||||
}
|
||||
}, [sys, turnStartedAt, ui.busy])
|
||||
|
||||
useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, sid: ui.sid })
|
||||
|
||||
// ── Terminal tab title ─────────────────────────────────────────────
|
||||
|
|
@ -376,7 +395,6 @@ export function useMainApp(gw: GatewayClient) {
|
|||
composerState,
|
||||
gw,
|
||||
maybeGoodVibes,
|
||||
setLastUserAt,
|
||||
setLastUserMsg,
|
||||
slashRef,
|
||||
submitRef,
|
||||
|
|
@ -500,7 +518,6 @@ export function useMainApp(gw: GatewayClient) {
|
|||
newSession: session.newSession,
|
||||
resetVisibleHistory: session.resetVisibleHistory,
|
||||
resumeById: session.resumeById,
|
||||
setLastUserAt,
|
||||
setSessionStartedAt
|
||||
},
|
||||
slashFlightRef,
|
||||
|
|
@ -635,20 +652,20 @@ export function useMainApp(gw: GatewayClient) {
|
|||
() => ({
|
||||
cwdLabel: fmtCwdBranch(cwd, gitBranch),
|
||||
goodVibesTick,
|
||||
lastUserAt: ui.sid ? lastUserAt : null,
|
||||
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,
|
||||
lastUserAt,
|
||||
sessionStartedAt,
|
||||
stickyPrompt,
|
||||
turnStartedAt,
|
||||
ui,
|
||||
voiceEnabled,
|
||||
voiceProcessing,
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@ export interface UseSessionLifecycleOptions {
|
|||
rpc: GatewayRpc
|
||||
scrollRef: RefObject<null | ScrollBoxHandle>
|
||||
setHistoryItems: StateSetter<Msg[]>
|
||||
setLastUserAt: StateSetter<null | number>
|
||||
setLastUserMsg: StateSetter<string>
|
||||
setSessionStartedAt: StateSetter<number>
|
||||
setStickyPrompt: StateSetter<string>
|
||||
|
|
@ -62,7 +61,6 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
|
|||
rpc,
|
||||
scrollRef,
|
||||
setHistoryItems,
|
||||
setLastUserAt,
|
||||
setLastUserMsg,
|
||||
setSessionStartedAt,
|
||||
setStickyPrompt,
|
||||
|
|
@ -84,18 +82,9 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
|
|||
patchUiState({ bgTasks: new Set(), info: null, sid: null, usage: ZERO })
|
||||
setHistoryItems([])
|
||||
setLastUserMsg('')
|
||||
setLastUserAt(null)
|
||||
setStickyPrompt('')
|
||||
composerActions.setPasteSnips([])
|
||||
}, [
|
||||
composerActions,
|
||||
setHistoryItems,
|
||||
setLastUserAt,
|
||||
setLastUserMsg,
|
||||
setStickyPrompt,
|
||||
setVoiceProcessing,
|
||||
setVoiceRecording
|
||||
])
|
||||
}, [composerActions, setHistoryItems, setLastUserMsg, setStickyPrompt, setVoiceProcessing, setVoiceRecording])
|
||||
|
||||
const resetVisibleHistory = useCallback(
|
||||
(info: null | SessionInfo = null) => {
|
||||
|
|
@ -107,12 +96,11 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
|
|||
setHistoryItems(info ? [introMsg(info)] : [])
|
||||
setStickyPrompt('')
|
||||
setLastUserMsg('')
|
||||
setLastUserAt(null)
|
||||
composerActions.setPasteSnips([])
|
||||
patchTurnState({ activity: [] })
|
||||
patchUiState({ info, usage: usageFrom(info) })
|
||||
},
|
||||
[composerActions, setHistoryItems, setLastUserAt, setLastUserMsg, setStickyPrompt]
|
||||
[composerActions, setHistoryItems, setLastUserMsg, setStickyPrompt]
|
||||
)
|
||||
|
||||
const newSession = useCallback(
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@ export function useSubmission(opts: UseSubmissionOptions) {
|
|||
composerState,
|
||||
gw,
|
||||
maybeGoodVibes,
|
||||
setLastUserAt,
|
||||
setLastUserMsg,
|
||||
slashRef,
|
||||
submitRef,
|
||||
|
|
@ -60,7 +59,6 @@ export function useSubmission(opts: UseSubmissionOptions) {
|
|||
turnController.clearStatusTimer()
|
||||
maybeGoodVibes(submitText)
|
||||
setLastUserMsg(text)
|
||||
setLastUserAt(Date.now())
|
||||
appendMessage({ role: 'user', text: displayText })
|
||||
patchUiState({ busy: true, status: 'running…' })
|
||||
turnController.bufRef = ''
|
||||
|
|
@ -96,7 +94,7 @@ export function useSubmission(opts: UseSubmissionOptions) {
|
|||
})
|
||||
.catch(() => startSubmit(text, expand(text)))
|
||||
},
|
||||
[appendMessage, composerState.pasteSnips, gw, maybeGoodVibes, setLastUserAt, setLastUserMsg, sys]
|
||||
[appendMessage, composerState.pasteSnips, gw, maybeGoodVibes, setLastUserMsg, sys]
|
||||
)
|
||||
|
||||
const shellExec = useCallback(
|
||||
|
|
@ -298,7 +296,6 @@ export interface UseSubmissionOptions {
|
|||
composerState: ComposerState
|
||||
gw: GatewayClient
|
||||
maybeGoodVibes: (text: string) => void
|
||||
setLastUserAt: (value: null | number) => void
|
||||
setLastUserMsg: (value: string) => void
|
||||
slashRef: MutableRefObject<(cmd: string) => boolean>
|
||||
submitRef: MutableRefObject<(value: string) => void>
|
||||
|
|
|
|||
|
|
@ -12,18 +12,24 @@ import type { Msg, Usage } from '../types.js'
|
|||
const FACE_TICK_MS = 2500
|
||||
const HEART_COLORS = ['#ff5fa2', '#ff4d6d']
|
||||
|
||||
function FaceTicker({ color }: { color: string }) {
|
||||
function FaceTicker({ color, startedAt }: { color: string; startedAt?: null | number }) {
|
||||
const [tick, setTick] = useState(() => Math.floor(Math.random() * 1000))
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick(n => n + 1), FACE_TICK_MS)
|
||||
const face = setInterval(() => setTick(n => n + 1), FACE_TICK_MS)
|
||||
const clock = setInterval(() => setNow(Date.now()), 1000)
|
||||
|
||||
return () => clearInterval(id)
|
||||
return () => {
|
||||
clearInterval(face)
|
||||
clearInterval(clock)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Text color={color}>
|
||||
{FACES[tick % FACES.length]} {VERBS[tick % VERBS.length]}…
|
||||
{startedAt ? ` · ${fmtDuration(now - startedAt)}` : ''}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
|
@ -68,19 +74,6 @@ function SessionDuration({ startedAt }: { startedAt: number }) {
|
|||
return fmtDuration(now - startedAt)
|
||||
}
|
||||
|
||||
export function IdleSinceLastMsg({ lastUserAt, t }: { lastUserAt: number; t: Theme }) {
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
|
||||
useEffect(() => {
|
||||
setNow(Date.now())
|
||||
const id = setInterval(() => setNow(Date.now()), 1000)
|
||||
|
||||
return () => clearInterval(id)
|
||||
}, [lastUserAt])
|
||||
|
||||
return <Text color={t.color.dim}>{fmtDuration(now - lastUserAt)} </Text>
|
||||
}
|
||||
|
||||
export function GoodVibesHeart({ tick, t }: { tick: number; t: Theme }) {
|
||||
const [active, setActive] = useState(false)
|
||||
const [color, setColor] = useState(t.color.amber)
|
||||
|
|
@ -113,6 +106,7 @@ export function StatusRule({
|
|||
bgCount,
|
||||
sessionStartedAt,
|
||||
showCost,
|
||||
turnStartedAt,
|
||||
voiceLabel,
|
||||
t
|
||||
}: StatusRuleProps) {
|
||||
|
|
@ -133,7 +127,7 @@ export function StatusRule({
|
|||
<Box flexShrink={1} width={leftWidth}>
|
||||
<Text color={t.color.bronze} wrap="truncate-end">
|
||||
{'─ '}
|
||||
{busy ? <FaceTicker color={statusColor} /> : <Text color={statusColor}>{status}</Text>}
|
||||
{busy ? <FaceTicker color={statusColor} startedAt={turnStartedAt} /> : <Text color={statusColor}>{status}</Text>}
|
||||
<Text color={t.color.dim}> │ {model}</Text>
|
||||
{ctxLabel ? <Text color={t.color.dim}> │ {ctxLabel}</Text> : null}
|
||||
{bar ? (
|
||||
|
|
@ -306,6 +300,7 @@ interface StatusRuleProps {
|
|||
status: string
|
||||
statusColor: string
|
||||
t: Theme
|
||||
turnStartedAt?: null | number
|
||||
usage: Usage
|
||||
voiceLabel?: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { PLACEHOLDER } from '../content/placeholders.js'
|
|||
import type { Theme } from '../theme.js'
|
||||
import type { DetailsMode } from '../types.js'
|
||||
|
||||
import { GoodVibesHeart, IdleSinceLastMsg, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js'
|
||||
import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js'
|
||||
import { FloatingOverlays, PromptZone } from './appOverlays.js'
|
||||
import { Banner, Panel, SessionPanel } from './branding.js'
|
||||
import { MessageLine } from './messageLine.js'
|
||||
|
|
@ -194,6 +194,7 @@ const ComposerPane = memo(function ComposerPane({
|
|||
status={ui.status}
|
||||
statusColor={status.statusColor}
|
||||
t={ui.theme}
|
||||
turnStartedAt={status.turnStartedAt}
|
||||
usage={ui.usage}
|
||||
voiceLabel={status.voiceLabel}
|
||||
/>
|
||||
|
|
@ -242,9 +243,7 @@ const ComposerPane = memo(function ComposerPane({
|
|||
value={composer.input}
|
||||
/>
|
||||
|
||||
<Box flexDirection="row" position="absolute" right={0}>
|
||||
{!ui.busy && status.lastUserAt ? <IdleSinceLastMsg lastUserAt={status.lastUserAt} t={ui.theme} /> : null}
|
||||
|
||||
<Box position="absolute" right={0}>
|
||||
<GoodVibesHeart t={ui.theme} tick={status.goodVibesTick} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue