mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-21 10:22:18 +00:00
feat(cli,tui): show time since last final agent response on the status bar (#44265)
Adds an idle clock to the context/status bar in both the prompt_toolkit CLI and the Ink TUI: once a turn completes, a dim '✓ <elapsed>' segment shows how long the session has been idle since the last final agent response. Hidden while a turn is live (the per-prompt elapsed timer covers that) and before the first turn completes. - cli.py: track _last_turn_finished_at when the agent thread exits, surface it via _format_idle_since() in the snapshot, render in both the wide fragments path and the plain-text fallback. - ui-tui: stamp lastTurnEndedAt when busy flips false after a live turn, thread it through appStatus -> StatusRule, render via a ticking IdleSince segment sharing the duration breakpoint/width budget.
This commit is contained in:
parent
a2d7f538d4
commit
8972a151a4
7 changed files with 186 additions and 2 deletions
29
cli.py
29
cli.py
|
|
@ -3426,6 +3426,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||||
# frozen when the agent thread completes, displayed in the status bar.
|
# frozen when the agent thread completes, displayed in the status bar.
|
||||||
self._prompt_start_time: Optional[float] = None # time.time() when turn started
|
self._prompt_start_time: Optional[float] = None # time.time() when turn started
|
||||||
self._prompt_duration: float = 0.0 # frozen duration of last completed turn
|
self._prompt_duration: float = 0.0 # frozen duration of last completed turn
|
||||||
|
self._last_turn_finished_at: Optional[float] = None # time.time() when the last agent loop finished
|
||||||
# Initialize SQLite session store early so /title works before first message
|
# Initialize SQLite session store early so /title works before first message
|
||||||
self._session_db = None
|
self._session_db = None
|
||||||
try:
|
try:
|
||||||
|
|
@ -3812,6 +3813,19 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||||
emoji = "⏱" if live else "⏲"
|
emoji = "⏱" if live else "⏲"
|
||||||
return f"{emoji} {time_str}"
|
return f"{emoji} {time_str}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_idle_since(last_finished_at: Optional[float], turn_live: bool) -> str:
|
||||||
|
"""Format time since the last final agent response for the status bar.
|
||||||
|
|
||||||
|
Returns an empty string while a turn is live (the per-prompt elapsed
|
||||||
|
timer covers that case) or before the first turn has completed.
|
||||||
|
Compact read-out: ``✓ 42s`` / ``✓ 3m`` / ``✓ 1h 12m``.
|
||||||
|
"""
|
||||||
|
if turn_live or last_finished_at is None:
|
||||||
|
return ""
|
||||||
|
idle = max(0.0, time.time() - last_finished_at)
|
||||||
|
return f"✓ {format_duration_compact(idle)}"
|
||||||
|
|
||||||
def _get_status_bar_snapshot(self) -> Dict[str, Any]:
|
def _get_status_bar_snapshot(self) -> Dict[str, Any]:
|
||||||
# Prefer the agent's model name — it updates on fallback.
|
# Prefer the agent's model name — it updates on fallback.
|
||||||
# self.model reflects the originally configured model and never
|
# self.model reflects the originally configured model and never
|
||||||
|
|
@ -3835,6 +3849,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||||
getattr(self, "_prompt_duration", 0.0),
|
getattr(self, "_prompt_duration", 0.0),
|
||||||
live=getattr(self, "_prompt_start_time", None) is not None,
|
live=getattr(self, "_prompt_start_time", None) is not None,
|
||||||
),
|
),
|
||||||
|
"idle_since": self._format_idle_since(
|
||||||
|
getattr(self, "_last_turn_finished_at", None),
|
||||||
|
turn_live=getattr(self, "_prompt_start_time", None) is not None,
|
||||||
|
),
|
||||||
"context_tokens": 0,
|
"context_tokens": 0,
|
||||||
"context_length": None,
|
"context_length": None,
|
||||||
"context_percent": None,
|
"context_percent": None,
|
||||||
|
|
@ -4146,6 +4164,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||||
prompt_elapsed = snapshot.get("prompt_elapsed")
|
prompt_elapsed = snapshot.get("prompt_elapsed")
|
||||||
if prompt_elapsed:
|
if prompt_elapsed:
|
||||||
parts.append(prompt_elapsed)
|
parts.append(prompt_elapsed)
|
||||||
|
idle_since = snapshot.get("idle_since")
|
||||||
|
if idle_since:
|
||||||
|
parts.append(idle_since)
|
||||||
if yolo_active:
|
if yolo_active:
|
||||||
parts.append("⚠ YOLO")
|
parts.append("⚠ YOLO")
|
||||||
return self._trim_status_bar_text(" │ ".join(parts), width)
|
return self._trim_status_bar_text(" │ ".join(parts), width)
|
||||||
|
|
@ -4247,6 +4268,11 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||||
if prompt_elapsed:
|
if prompt_elapsed:
|
||||||
frags.append(("class:status-bar-dim", " │ "))
|
frags.append(("class:status-bar-dim", " │ "))
|
||||||
frags.append(("class:status-bar-dim", prompt_elapsed))
|
frags.append(("class:status-bar-dim", prompt_elapsed))
|
||||||
|
# Position 8: idle time since the last final agent response
|
||||||
|
idle_since = snapshot.get("idle_since")
|
||||||
|
if idle_since:
|
||||||
|
frags.append(("class:status-bar-dim", " │ "))
|
||||||
|
frags.append(("class:status-bar-dim", idle_since))
|
||||||
if yolo_active:
|
if yolo_active:
|
||||||
frags.append(("class:status-bar-dim", " │ "))
|
frags.append(("class:status-bar-dim", " │ "))
|
||||||
frags.append(("class:status-bar-yolo", "⚠ YOLO"))
|
frags.append(("class:status-bar-yolo", "⚠ YOLO"))
|
||||||
|
|
@ -10162,6 +10188,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||||
if self._prompt_start_time is not None:
|
if self._prompt_start_time is not None:
|
||||||
self._prompt_duration = max(0.0, time.time() - self._prompt_start_time)
|
self._prompt_duration = max(0.0, time.time() - self._prompt_start_time)
|
||||||
self._prompt_start_time = None
|
self._prompt_start_time = None
|
||||||
|
# Record when this agent loop finished so the status bar can show
|
||||||
|
# idle time since the last final response.
|
||||||
|
self._last_turn_finished_at = time.time()
|
||||||
|
|
||||||
# Proactively clean up async clients whose event loop is dead.
|
# Proactively clean up async clients whose event loop is dead.
|
||||||
# The agent thread may have created AsyncOpenAI clients bound
|
# The agent thread may have created AsyncOpenAI clients bound
|
||||||
|
|
|
||||||
|
|
@ -676,3 +676,54 @@ class TestStatusBarWidthSource:
|
||||||
mock_get_app.assert_not_called()
|
mock_get_app.assert_not_called()
|
||||||
mock_shutil.assert_not_called()
|
mock_shutil.assert_not_called()
|
||||||
assert len(text) > 0
|
assert len(text) > 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestIdleSinceLastTurn:
|
||||||
|
"""Time-since-last-final-agent-response read-out on the status bar."""
|
||||||
|
|
||||||
|
def test_hidden_before_first_turn(self):
|
||||||
|
assert HermesCLI._format_idle_since(None, turn_live=False) == ""
|
||||||
|
|
||||||
|
def test_hidden_while_turn_is_live(self):
|
||||||
|
assert HermesCLI._format_idle_since(time.time() - 30, turn_live=True) == ""
|
||||||
|
|
||||||
|
def test_shows_compact_idle_time_after_turn(self):
|
||||||
|
label = HermesCLI._format_idle_since(time.time() - 42, turn_live=False)
|
||||||
|
assert label.startswith("✓ ")
|
||||||
|
assert label == "✓ 42s"
|
||||||
|
|
||||||
|
def test_scales_to_minutes(self):
|
||||||
|
label = HermesCLI._format_idle_since(time.time() - 3 * 60, turn_live=False)
|
||||||
|
assert label == "✓ 3m"
|
||||||
|
|
||||||
|
def test_snapshot_carries_idle_since(self):
|
||||||
|
cli_obj = _make_cli()
|
||||||
|
cli_obj._last_turn_finished_at = time.time() - 10
|
||||||
|
cli_obj._prompt_start_time = None
|
||||||
|
cli_obj._prompt_duration = 5.0
|
||||||
|
snapshot = cli_obj._get_status_bar_snapshot()
|
||||||
|
assert snapshot["idle_since"].startswith("✓ ")
|
||||||
|
|
||||||
|
def test_snapshot_idle_empty_during_live_turn(self):
|
||||||
|
cli_obj = _make_cli()
|
||||||
|
cli_obj._last_turn_finished_at = time.time() - 10
|
||||||
|
cli_obj._prompt_start_time = time.time()
|
||||||
|
cli_obj._prompt_duration = 0.0
|
||||||
|
snapshot = cli_obj._get_status_bar_snapshot()
|
||||||
|
assert snapshot["idle_since"] == ""
|
||||||
|
|
||||||
|
def test_wide_status_bar_text_includes_idle(self):
|
||||||
|
cli_obj = _attach_agent(
|
||||||
|
_make_cli(),
|
||||||
|
prompt_tokens=10_230,
|
||||||
|
completion_tokens=2_220,
|
||||||
|
total_tokens=12_450,
|
||||||
|
api_calls=7,
|
||||||
|
context_tokens=12_450,
|
||||||
|
context_length=200_000,
|
||||||
|
)
|
||||||
|
cli_obj._last_turn_finished_at = time.time() - 42
|
||||||
|
cli_obj._prompt_start_time = None
|
||||||
|
cli_obj._prompt_duration = 7.0
|
||||||
|
text = cli_obj._build_status_bar_text(width=160)
|
||||||
|
assert "✓ 42s" in text
|
||||||
|
|
|
||||||
|
|
@ -260,3 +260,71 @@ describe('StatusRule credits notice render priority', () => {
|
||||||
expect(textContent(element)).toContain('opus 4.8')
|
expect(textContent(element)).toContain('opus 4.8')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('StatusRule idle-since read-out', () => {
|
||||||
|
// The IdleSince component uses hooks, so it can't be invoked outside a
|
||||||
|
// renderer — assert on the element tree instead (same reason the duration
|
||||||
|
// tests don't check SessionDuration's text).
|
||||||
|
const findComponentByName = (node: ReactNodeLike, name: string): React.ReactElement | null => {
|
||||||
|
if (node === null || node === undefined || typeof node === 'boolean') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
for (const child of node) {
|
||||||
|
const found = findComponentByName(child, name)
|
||||||
|
|
||||||
|
if (found) {
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!React.isValidElement(node)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof node.type === 'function' && node.type.name === name) {
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
return findComponentByName(node.props.children, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
it('shows time since the last final agent response when idle', () => {
|
||||||
|
const endedAt = Date.now() - 42_000
|
||||||
|
const element = StatusRule({
|
||||||
|
...baseProps,
|
||||||
|
lastTurnEndedAt: endedAt,
|
||||||
|
sessionStartedAt: Date.now() - 60_000
|
||||||
|
})
|
||||||
|
|
||||||
|
const idle = findComponentByName(element, 'IdleSince')
|
||||||
|
|
||||||
|
expect(idle).not.toBeNull()
|
||||||
|
expect(idle!.props.endedAt).toBe(endedAt)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is hidden while a turn is busy', () => {
|
||||||
|
const element = StatusRule({
|
||||||
|
...baseProps,
|
||||||
|
busy: true,
|
||||||
|
lastTurnEndedAt: Date.now() - 42_000,
|
||||||
|
turnStartedAt: Date.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(findComponentByName(element, 'IdleSince')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is hidden before the first turn completes', () => {
|
||||||
|
const element = StatusRule({
|
||||||
|
...baseProps,
|
||||||
|
lastTurnEndedAt: null,
|
||||||
|
sessionStartedAt: Date.now() - 60_000
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(findComponentByName(element, 'IdleSince')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -368,6 +368,7 @@ export interface AppLayoutProgressProps {
|
||||||
export interface AppLayoutStatusProps {
|
export interface AppLayoutStatusProps {
|
||||||
cwdLabel: string
|
cwdLabel: string
|
||||||
goodVibesTick: number
|
goodVibesTick: number
|
||||||
|
lastTurnEndedAt: null | number
|
||||||
sessionStartedAt: null | number
|
sessionStartedAt: null | number
|
||||||
showStickyPrompt: boolean
|
showStickyPrompt: boolean
|
||||||
statusColor: string
|
statusColor: string
|
||||||
|
|
|
||||||
|
|
@ -173,6 +173,7 @@ export function useMainApp(gw: GatewayClient) {
|
||||||
const [voiceRecordKey, setVoiceRecordKey] = useState<ParsedVoiceRecordKey>(DEFAULT_VOICE_RECORD_KEY)
|
const [voiceRecordKey, setVoiceRecordKey] = useState<ParsedVoiceRecordKey>(DEFAULT_VOICE_RECORD_KEY)
|
||||||
const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now())
|
const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now())
|
||||||
const [turnStartedAt, setTurnStartedAt] = useState<null | number>(null)
|
const [turnStartedAt, setTurnStartedAt] = useState<null | number>(null)
|
||||||
|
const [lastTurnEndedAt, setLastTurnEndedAt] = useState<null | number>(null)
|
||||||
const [goodVibesTick, setGoodVibesTick] = useState(0)
|
const [goodVibesTick, setGoodVibesTick] = useState(0)
|
||||||
const [bellOnComplete, setBellOnComplete] = useState(false)
|
const [bellOnComplete, setBellOnComplete] = useState(false)
|
||||||
|
|
||||||
|
|
@ -500,10 +501,14 @@ export function useMainApp(gw: GatewayClient) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (ui.busy) {
|
if (ui.busy) {
|
||||||
setTurnStartedAt(prev => prev ?? Date.now())
|
setTurnStartedAt(prev => prev ?? Date.now())
|
||||||
} else {
|
} else if (turnStartedAt != null) {
|
||||||
|
// Only stamp the idle marker when a turn was actually live — busy is
|
||||||
|
// also false on mount and we don't want a phantom "done" timestamp
|
||||||
|
// before the first turn has completed.
|
||||||
|
setLastTurnEndedAt(Date.now())
|
||||||
setTurnStartedAt(null)
|
setTurnStartedAt(null)
|
||||||
}
|
}
|
||||||
}, [ui.busy])
|
}, [ui.busy, turnStartedAt])
|
||||||
|
|
||||||
useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, setVoiceRecordKey, sid: ui.sid })
|
useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, setVoiceRecordKey, sid: ui.sid })
|
||||||
|
|
||||||
|
|
@ -1090,6 +1095,7 @@ export function useMainApp(gw: GatewayClient) {
|
||||||
// essentials and truncates this further on narrow terminals.
|
// essentials and truncates this further on narrow terminals.
|
||||||
cwdLabel: fmtCwdBranch(cwd, gitBranch, 28),
|
cwdLabel: fmtCwdBranch(cwd, gitBranch, 28),
|
||||||
goodVibesTick,
|
goodVibesTick,
|
||||||
|
lastTurnEndedAt: ui.sid ? lastTurnEndedAt : null,
|
||||||
sessionStartedAt: ui.sid ? sessionStartedAt : null,
|
sessionStartedAt: ui.sid ? sessionStartedAt : null,
|
||||||
showStickyPrompt: !!stickyPrompt,
|
showStickyPrompt: !!stickyPrompt,
|
||||||
statusColor: statusColorOf(ui.status, ui.theme.color),
|
statusColor: statusColorOf(ui.status, ui.theme.color),
|
||||||
|
|
@ -1103,6 +1109,7 @@ export function useMainApp(gw: GatewayClient) {
|
||||||
cwd,
|
cwd,
|
||||||
gitBranch,
|
gitBranch,
|
||||||
goodVibesTick,
|
goodVibesTick,
|
||||||
|
lastTurnEndedAt,
|
||||||
sessionStartedAt,
|
sessionStartedAt,
|
||||||
stickyPrompt,
|
stickyPrompt,
|
||||||
turnStartedAt,
|
turnStartedAt,
|
||||||
|
|
|
||||||
|
|
@ -341,6 +341,21 @@ function SessionDuration({ startedAt }: { startedAt: number }) {
|
||||||
return fmtDuration(now - startedAt)
|
return fmtDuration(now - startedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function IdleSince({ endedAt }: { endedAt: number }) {
|
||||||
|
// Time since the last final agent response. Re-ticks every second like
|
||||||
|
// SessionDuration so the read-out stays live while the session idles.
|
||||||
|
const [now, setNow] = useState(() => Date.now())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNow(Date.now())
|
||||||
|
const id = setInterval(() => setNow(Date.now()), 1000)
|
||||||
|
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [endedAt])
|
||||||
|
|
||||||
|
return `✓ ${fmtDuration(now - endedAt)}`
|
||||||
|
}
|
||||||
|
|
||||||
const effortLabel = (effort?: string) => {
|
const effortLabel = (effort?: string) => {
|
||||||
const value = String(effort ?? '')
|
const value = String(effort ?? '')
|
||||||
.trim()
|
.trim()
|
||||||
|
|
@ -400,6 +415,7 @@ export function StatusRule({
|
||||||
notice,
|
notice,
|
||||||
usage,
|
usage,
|
||||||
bgCount,
|
bgCount,
|
||||||
|
lastTurnEndedAt,
|
||||||
liveSessionCount,
|
liveSessionCount,
|
||||||
sessionStartedAt,
|
sessionStartedAt,
|
||||||
showCost,
|
showCost,
|
||||||
|
|
@ -488,6 +504,10 @@ export function StatusRule({
|
||||||
|
|
||||||
const showBar = !!bar && fits(SEP + stringWidth(`[${bar}] ${pct != null ? `${pct}%` : ''}`))
|
const showBar = !!bar && fits(SEP + stringWidth(`[${bar}] ${pct != null ? `${pct}%` : ''}`))
|
||||||
const showDuration = segs.duration && !!sessionStartedAt && fits(SEP + MAX_DURATION_WIDTH)
|
const showDuration = segs.duration && !!sessionStartedAt && fits(SEP + MAX_DURATION_WIDTH)
|
||||||
|
// Idle clock — time since the last final agent response. Hidden while busy
|
||||||
|
// (the FaceTicker's elapsed tail covers the live turn) and before the first
|
||||||
|
// turn completes. Shares the duration breakpoint and width reservation.
|
||||||
|
const showIdle = segs.duration && !busy && lastTurnEndedAt != null && fits(SEP + stringWidth('✓ ') + MAX_DURATION_WIDTH)
|
||||||
const showCompressions = segs.compressions && compressions > 0 && fits(SEP + stringWidth(`cmp ${compressions}`))
|
const showCompressions = segs.compressions && compressions > 0 && fits(SEP + stringWidth(`cmp ${compressions}`))
|
||||||
const showVoice = segs.voice && !!voiceLabel && fits(SEP + stringWidth(voiceLabel))
|
const showVoice = segs.voice && !!voiceLabel && fits(SEP + stringWidth(voiceLabel))
|
||||||
const showSessionCount = !!sessionCountText && fits(SEP + stringWidth(sessionCountText))
|
const showSessionCount = !!sessionCountText && fits(SEP + stringWidth(sessionCountText))
|
||||||
|
|
@ -567,6 +587,12 @@ export function StatusRule({
|
||||||
<SessionDuration startedAt={sessionStartedAt!} />
|
<SessionDuration startedAt={sessionStartedAt!} />
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
{showIdle ? (
|
||||||
|
<Text color={t.color.muted} wrap="truncate-end">
|
||||||
|
{' │ '}
|
||||||
|
<IdleSince endedAt={lastTurnEndedAt!} />
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
{showCompressions ? (
|
{showCompressions ? (
|
||||||
<Text color={t.color.muted} wrap="truncate-end">
|
<Text color={t.color.muted} wrap="truncate-end">
|
||||||
{' │ '}
|
{' │ '}
|
||||||
|
|
@ -725,6 +751,7 @@ export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps)
|
||||||
|
|
||||||
interface StatusRuleProps {
|
interface StatusRuleProps {
|
||||||
bgCount: number
|
bgCount: number
|
||||||
|
lastTurnEndedAt?: null | number
|
||||||
liveSessionCount: number
|
liveSessionCount: number
|
||||||
busy: boolean
|
busy: boolean
|
||||||
cols: number
|
cols: number
|
||||||
|
|
|
||||||
|
|
@ -366,6 +366,7 @@ const StatusRulePane = memo(function StatusRulePane({
|
||||||
cols={composer.cols}
|
cols={composer.cols}
|
||||||
cwdLabel={status.cwdLabel}
|
cwdLabel={status.cwdLabel}
|
||||||
indicatorStyle={ui.indicatorStyle}
|
indicatorStyle={ui.indicatorStyle}
|
||||||
|
lastTurnEndedAt={status.lastTurnEndedAt}
|
||||||
liveSessionCount={ui.liveSessionCount}
|
liveSessionCount={ui.liveSessionCount}
|
||||||
model={ui.info?.model ?? ''}
|
model={ui.info?.model ?? ''}
|
||||||
modelFast={ui.info?.fast || ui.info?.service_tier === 'priority'}
|
modelFast={ui.info?.fast || ui.info?.service_tier === 'priority'}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue