mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-22 10:32:00 +00:00
Some checks are pending
Deploy Site / deploy-vercel (push) Waiting to run
Deploy Site / deploy-docs (push) Waiting to run
Docker Build and Publish / build-amd64 (push) Waiting to run
Docker Build and Publish / build-arm64 (push) Waiting to run
Docker Build and Publish / merge (push) Blocked by required conditions
Lint (ruff + ty) / ruff + ty diff (push) Waiting to run
Lint (ruff + ty) / ruff enforcement (blocking) (push) Waiting to run
Lint (ruff + ty) / Windows footguns (blocking) (push) Waiting to run
Nix Lockfile Fix / auto-fix-main (push) Waiting to run
Nix Lockfile Fix / fix (push) Waiting to run
Nix / nix (macos-latest) (push) Waiting to run
Nix / nix (ubuntu-latest) (push) Waiting to run
OSV-Scanner / Scan lockfiles (push) Waiting to run
Tests / test (1) (push) Waiting to run
Tests / test (2) (push) Waiting to run
Tests / test (3) (push) Waiting to run
Tests / test (4) (push) Waiting to run
Tests / test (5) (push) Waiting to run
Tests / test (6) (push) Waiting to run
Tests / save-durations (push) Blocked by required conditions
Tests / e2e (push) Waiting to run
uv.lock check / uv lock --check (push) Waiting to run
* feat(tui): HERMES_DEV_CREDITS live-spend dev readout (L0 tracer for usage-aware credits)
L0 of the usage-aware-credits feature: a dev-only, env-gated tracer that
exercises the real header -> CreditsState -> TUI pipe end-to-end behind
HERMES_DEV_CREDITS, de-risking the L1/L5 build before the notice policy exists.
- agent/credits_tracker.py: CreditsState + parse_credits_headers (headers are
strings -> paid_access via == "true", never bool(); retain-last-known; only
subscription_micros may be negative; *_usd kept verbatim).
- run_agent.py: _capture_credits / get_credits_state / get_credits_spent_micros,
session-start baseline latch, + dev-gated "credits" capture log.
- agent/chat_completion_helpers.py: capture on the streaming response.
- agent/agent_init.py: init _credits_state + _credits_session_start_micros.
- tui_gateway/server.py: _get_usage emits dev_credits_spent_micros only when flagged.
- ui-tui appChrome.tsx / types.ts: cents delta status segment + "(dev credits)" banner.
Off by default; silent for normal users. Validated live against staging
(capture log delta matches the TUI segment). Throwaway consumer (readout/log/
banner); credits_tracker + the capture plumbing are the real feature foundation.
* test(credits): lock parser under 9-state matrix + harden validation (L2)
Add tests/agent/test_credits_tracker.py with 92 tests covering the 9-state
matrix (healthy, sub_90pct, grant_exhausted, purchased_only, tool_pool_free,
depleted, debt, missing, no_org) plus validation edge cases: version strict==1
with warn-once latch for v>1, bool-string trap (paid_access/tool_pool_gated_off
== "true"/"false", never bool()), half-pair subscription limit treated as
both-absent while parse succeeds, USD regex ^-?\d+\.\d{2}$, non-int micros
→ None, negative non-subscription micros → None, as_of_ms junk → None, zero
limit ZeroDivision guard.
Harden agent/credits_tracker.py to match the spec:
- Add tool_pool_micros/tool_pool_gated_off/from_header fields to CreditsState
- Add depleted property (== not paid_access, never remaining==0)
- Change used_fraction guard to key off subscription_limit_micros (the actual
denominator) not denominator_kind (metadata)
- Replace fail-soft _safe_int with a sentinel-returning variant; full validation
now returns None on any malformed field rather than silently defaulting
- Add module-level warn-once latch for version > 1
- Add USD regex validation; add denominator_kind allow-list check
- Parse x-nous-tool-pool-* prefix headers (not x-nous-credits-tool-pool-*)
* feat(credits): notice spine — AgentNotice + notice_callback/notice_clear_callback + TUI binding (L1)
L1 of usage-aware credits: the driver-agnostic notice delivery spine that L4's
policy will fire through and L5's TUI render will consume.
- agent/credits_tracker.py: AgentNotice dataclass (text/level/kind/ttl_ms/key/id;
kind defaults "sticky", kept TTL-expressive for a future config seam).
- run_agent.py: AIAgent gains notice_callback + notice_clear_callback slots and
_emit_notice / _emit_notice_clear emitters (swallow all callback errors — a
notice must never break the agent loop; no-op when unbound).
- agent/agent_init.py: thread both callbacks through init_agent.
- tui_gateway/server.py: bind both in _agent_cbs → notification.show / notification.clear
WS events (snake_case payload, matching the existing gateway-event convention).
- ui-tui/src/gatewayTypes.ts: notification.show / notification.clear arms on GatewayEvent.
- tests/run_agent/test_notice_spine.py: 15 tests (emitter fire + fail-open + no-op,
signature threading, TUI binding payload shape).
Messaging push is out of v1 (binds neither callback). CLI binding + the TUI render/
decode land with L4 (firing) and L5 (render) so turn-end flush is wired correctly.
* feat(credits): threshold reconciliation policy + tests (L4.1)
* feat(credits): wire threshold policy into capture + latch (L4.2)
After a fresh header parse, _capture_credits runs evaluate_credits_notices against
the agent's _credits_latch and emits the result — clears first, then shows (so a
recovered depletion clears before the "restored" success lands, and depleted wins
the latest-wins slot). Gated on a bound notice_callback: messaging (no callbacks)
still caches state for /usage but runs no policy. Parse stays fail-open (miss →
keep last-known); the eval/emit path warns on failure rather than swallowing, so a
depletion-notice bug can't vanish silently.
- run_agent.py: _capture_credits split into parse (swallow→miss) + policy (warn);
latch lazy-guarded (object.__new__ safety).
- agent/agent_init.py: init agent._credits_latch = {"active": set(), "seen_below_90": False}.
* feat(tui): render credits notices in the status bar (L5, Strategy B)
The TUI now renders the notification.show / notification.clear gateway events the
agent emits — a level-colored notice overrides the status/verb slot when not busy.
- Notice state machine on turnController (pendingNotice + dedicated noticeTimer +
show/clear/applyNotice/flushPendingNotice/clearNoticeState). createGatewayEventHandler
decodes the events and delegates.
- Render priority busy > notice > status (appChrome StatusRule); notice text rendered
verbatim (its glyph comes from the policy), shrinkable so it never clips model│ctx;
dev-credits banner + Δ segment preserved. UiState.notice is snake_case (matches wire).
- Busy-wins: a notice arriving mid-turn is held and flushed at the THREE turn-end sites
(recordMessageComplete / interruptTurn / recordError) — never idle(), which reset()
also calls (would leak across sessions); reset() clears instead.
- Dedicated noticeTimer (never statusTimer); TTL starts on visibility with an id-guard;
latest-wins cancels the prior timer; clear is key-matched (no-op on mismatch); a sticky
survives a turn (flush no-ops with no pending); session reset clears (no cross-session leak).
- 20 tests (handler/turnController logic incl. R3-C2 timer isolation + render priority).
* feat(credits): cold-start seed for new Nous sessions (L3)
A genuinely-new Nous session has no inference header yet, so seed credits state from
the authoritative GET /api/oauth/account snapshot at session start (in the new-session
branch of _restore_or_build_system_prompt — inline, since the on_session_start plugin
hook gets no agent reference). The seed runs the shared notice policy, so a session that
opens already depleted warns IMMEDIATELY rather than only after the first turn.
- Maps the nested account fields (paid_service_access → paid_access; total_usable /
subscription / purchased on paid_service_access_info; rollover on subscription), each
None-guarded; float dollars → micros via round(d*1e6), *_usd left "" (render formats
from micros — never synthesize a verbatim usd from a float).
- Magnitudes-only: no monthlyCredits on the endpoint → subscription_limit_* unset →
used_fraction None → no warn90 from the seed (% only once a header lands, per D-E).
- Provider-guarded to Nous; fail-open (any error leaves _credits_state None, never
blocks startup); paid_access unknown ⇒ True (never falsely depleted).
- run_agent.py: extracted the warm-path policy/emit block into a shared
_emit_credits_notices() so capture and the seed fire notices identically.
* feat(credits): /usage Nous credits magnitudes view + recovery trigger (L6)
Add Nous credit dollar magnitudes to /usage (subscription / top-up / total
+ rollover + renewal + portal CTA), magnitudes-only per v1 (no % until the
account endpoint exposes a denominator). Reuses the existing account-usage
render machinery via a new pure build_nous_credits_snapshot() that maps a
NousPortalAccountInfo to an AccountUsageSnapshot; no nous branch is added to
fetch_account_usage (keeps the per-provider boundary intact).
CLI /usage also doubles as a depletion-recovery trigger: a force_fresh
account fetch, kept in a SEPARATE local so it never clobbers the
header-sourced agent._credits_state (which alone carries used_fraction). If
paid access recovered while credits.depleted is latched and a notice
consumer is bound, it reuses agent._emit_credits_notices() to clear it.
Gateway /usage displays magnitudes only — messaging binds no notice
consumer, so it performs no recovery emit.
Fail-open throughout: any portal hiccup leaves /usage unaffected.
* refactor(credits): dedupe HERMES_DEV_CREDITS flag parse via shared helpers
The dev-flag truthy check was inlined in three places. Replace with the shared
utils.is_truthy_value (run_agent.py, tui_gateway/server.py — also drops a
redundant inline `import os`) and a hoisted DEV_CREDITS_MODE export in
ui-tui/src/config/env.ts (consumed by appChrome, which also stops recomputing the
env check on every render). Behaviour-preserving; identical truthy set.
* fix(credits): cut dead /usage recovery trigger + bound portal fetches (L6 review)
Adversarial review found the /usage depletion-recovery trigger dead AND broken:
the CLI binds no notice_clear_callback, the TUI runs /usage in a separate
slash-worker subprocess (its own agent/latch), and the no-clobber rule made it
evaluate stale paid_access anyway. Recovery already happens on the next inference
(warm path), so the trigger was redundant — remove it and stop the depleted
notice over-promising.
- cli.py: remove the dead recovery block; bound the /usage portal fetch with a
10s wall-clock timeout (ThreadPoolExecutor) like the per-provider fetch —
urllib's per-socket timeout is not a wall-clock guarantee.
- agent/credits_tracker.py: reword the depleted CTA to "run /usage for balance"
(no false recovery promise; /usage shows fresh magnitudes, sticky clears next turn).
- agent/conversation_loop.py: same wall-clock timeout on the cold-start seed fetch
so a stalled portal can't hang session startup; tidy its time import.
* chore(credits): dev notice-state fixtures (HERMES_DEV_CREDITS_FIXTURE)
Throwaway dev scaffolding to exercise the notice pipeline without real spend or
Redis seeding. Set HERMES_DEV_CREDITS_FIXTURE to a state name (healthy / sub_90pct
/ grant_exhausted / depleted / clear) or a file path whose contents name a state
(re-read each turn → flip states live for recovery testing). _capture_credits
injects the chosen CreditsState instead of parsing real headers and runs the
shared notice policy. Deletable with the rest of the HERMES_DEV_CREDITS scaffolding.
* feat(credits): /usage monthly-grant % gauge
The portal /api/oauth/account subscription block now carries monthly_credits
(the per-period grant allowance, the % denominator). The consumer parsed
monthly_charge but dropped monthly_credits, so /usage stayed magnitudes-only.
Capture monthly_credits into NousPortalSubscriptionInfo + _subscription_from_payload.
build_nous_credits_snapshot emits a Subscription usage window (real % used, routed
through the existing render machinery) when monthly_credits is a finite positive
denominator and credits_remaining is finite and <= cap; otherwise it degrades to
magnitudes-only (older portals, rollover-over-cap, or non-finite payloads).
Guards (adversarial-review-driven): reject non-finite operands (json.loads parses
bare NaN/Infinity by default → would render $nan + a false 100% used), reject
bools, guard div-by-zero (cap>0), and suppress the gauge when remaining > cap
(rollover spanning the period makes the cap a nonsensical denominator → the
$X-of-$Y detail would read as a contradiction). Debt (remaining<0) clamps to 100%.
Money rule preserved: the ratio + magnitudes are computed from numeric float
account fields via display formatting, never by parsing a server *_usd string
(there are none on these dataclasses).
13 gauge tests added (tests/agent/test_nous_credits_gauge.py).
* fix(credits): show /usage Nous block whenever a Nous account is present
/usage runs in a slash-worker subprocess whose resolved inference provider is
often not "nous" even when the user has a Nous account, so gating the Nous
credits block on (provider == "nous") hid it entirely — the account data was
fully available but never rendered.
Gate instead on "a Nous account is logged in": a cheap local auth-state lookup
(get_provider_auth_state('nous') has an access_token) decides whether to attempt
the portal fetch, regardless of which provider inference runs on. In the gateway
the block is also lifted out of the 'if provider:' scope so a Nous-credentialled
user with another (or no) resident inference provider still sees their balance.
Fail-open and the per-fetch wall-clock timeout are preserved.
* fix(credits): show /usage Nous block when there's no live agent (TUI slash-worker)
In the TUI, /usage runs in a slash-worker subprocess that resumes the session
WITHOUT building an agent (self.agent is None), so _show_usage early-returned
"(._.) No active agent" before ever reaching the Nous credits block — which is
agent-independent (a portal fetch gated on Nous auth-state). Extract the block
into _print_nous_credits_block() and run it at the no-agent / no-calls
early-returns too (returns True if it printed, so the fallback message only
shows when there's genuinely nothing).
Verified live against staging: the block + monthly-grant gauge now render in the
slash-worker /usage path (previously hidden). The plain CLI REPL + messaging
paths are unchanged (they have a live agent).
* feat(credits): escalating 50/75/90 usage bands (single status line)
Replace the lone 90%-used warning with three escalating bands (50 info, 75 warn,
90 warn) shown as ONE status-bar line: it displays the highest band the
subscription grant has crossed, replaces the line as usage climbs, steps back
down on recovery, and clears below 50%. No stacking, no per-turn churn.
Bands live in a tunable CREDITS_USAGE_BANDS list; the policy derives everything
from it. Single notice key (credits.usage) with a usage_band latch field so the
notice only re-emits when the band actually changes. The crossing gate
(seen_below_90) is preserved so a fresh live session that opens mid-range stays
quiet until it has been observed below the lowest band (cold-start primes it when
it wants an open-high warning). Denominator math unchanged: % = subscription
grant burn (cap - grant_remaining)/cap, clamped [0,1]; top-up never moves the %.
Migrated test_credits_policy.py to the new key + added TestUsageBands (climb,
step-down, recovery-clear, idempotent, inclusive boundaries).
* feat(credits): hydrate notices at session OPEN via shared seed (TUI + first-turn)
Notices previously only fired inside a conversation turn (first message), so a
session that opened already depleted / past a usage band showed nothing at
'ready'. Extract the cold-start seed into a shared seed_credits_at_session_start()
and call it (a) in the TUI/desktop agent build right after the notice callback is
wired (fires at 'ready', before any message) and (b) as the first-turn fallback in
conversation_loop. Idempotent (skips once _credits_state exists) and fail-open.
The seed now maps monthly_credits -> subscription_limit_micros +
denominator_kind='subscription_cap', so used_fraction is computable at seed time
and usage-band warnings (not just depletion) hydrate on open. Primes the crossing
latch so a session opening already in a band warns immediately. Degrades to
depletion-only when monthly_credits is absent (older portals).
Adds test_credits_cold_start.py covering open-at-band, depletion, debt, no-cap
degradation, and the shared seed (fires/idempotent/skips-non-nous).
* feat(credits): /usage monthly-grant % gauge + fixture support + TUI surfacing
agent/account_usage.py: build_nous_credits_snapshot emits a subscription %% gauge
when the portal supplies a positive, finite monthly_credits denominator with
remaining <= cap (guards reject NaN/Infinity and rollover-over-cap, which would
render $nan or a contradictory $X-of-$Y); degrades to magnitudes-only otherwise.
Adds shared nous_credits_lines() (auth-gated, wall-clock-bounded portal fetch) so
the CLI and TUI /usage render the same block, and _snapshot_from_credits_state()
so HERMES_DEV_CREDITS_FIXTURE drives /usage offline too.
TUI: session.usage RPC carries credits_lines (agent-independent) and the /usage
panel renders them regardless of API-call count or resume state — previously the
TUI's separate /usage implementation only showed token counts.
Money rule preserved: %% and magnitudes come from numeric float account fields via
display formatting, never by parsing a server *_usd string.
* feat(credits): CLI REPL inline notices (parity with TUI)
The plain CLI agent bound no notice callbacks, so credit notices were TUI-only.
Bind notice_callback/notice_clear_callback on the CLI AIAgent; _on_notice renders
a single level-colored line above the prompt (error red / warn yellow / success
green / info dim) via _cprint, and seed credits at session open so a depletion or
usage-band warning shows before the first message — the same hydration the TUI
got. _on_notice_clear is a no-op (the REPL prints lines, no persistent slot).
* test(credits): add sub_50pct + sub_75pct dev fixtures for the new usage bands
The fixture set jumped 10%% -> 90%%; add sub_50pct (uf 0.5 -> band 50 info) and
sub_75pct (uf 0.75 -> band 75 warn) so the new escalating bands are exercisable
via HERMES_DEV_CREDITS_FIXTURE across all three surfaces (notice, session-open
seed, /usage gauge).
* fix(credits): usage-band notice clears on next prompt (not sticky-forever)
A 50/75/90 usage heads-up was sticky and camped the status bar indefinitely. Clear
the visible credits.usage notice when a new turn starts (startMessage), so it shows
until your next prompt then yields. The server latch is unchanged, so it won't
re-nag at the same band — it only re-shows when the band actually changes (climb)
or clears when usage drops below the lowest band. Depletion stays sticky.
* refactor(credits): consolidate the /usage credits block behind nous_credits_lines()
The CLI (_print_nous_credits_block) and the messaging gateway (_handle_usage_command)
each re-implemented the auth-gate + portal fetch + render, and both bypassed the
dev-fixture short-circuit that only the TUI honored — so /usage ignored
HERMES_DEV_CREDITS_FIXTURE on the CLI and in chat. Route both through the shared
agent.account_usage.nous_credits_lines() helper: one fetch/render path, one auth
gate, and the fixture works on every surface (~60 fewer duplicated lines).
The gateway usage test recorded only the last asyncio.to_thread call; /usage now
dispatches both the account fetch and the credits fetch, so it records every call
and matches the account fetch by its provider arg.
* fix(credits): keep the /usage gauge type-safe and log its fail-open path
_is_finite_num is now a TypeGuard[float], so the type checker narrows the gauge
operands (monthly_credits / credits_remaining) and the magnitudes passed to
_fmt_usd through it — no more None-operand warnings on the arithmetic. Add a debug
breadcrumb on the nous_credits_lines portal-fetch fail-open so a dead /usage block
is diagnosable in agent.log without a dev flag.
* fix(credits): harden the header tracker — prod-leak gate, hot-path probe, fire-and-forget seed
- Prod-leak guard: dev fixtures (HERMES_DEV_CREDITS_FIXTURE) now also require
HERMES_DEV_CREDITS, so a stray fixture var can't surface fabricated balances on a
real account. Matches the documented run workflow (both vars set together).
- Hot-path probe: parse_credits_headers checks for the version sentinel header
before allocating a lowercased copy of the response headers — skips that work on
every non-Nous API call. Behaviour-identical and still case-insensitive.
- Fire-and-forget seed: the real portal fetch in seed_credits_at_session_start now
runs in a daemon thread, so a slow/unreachable portal never delays session "ready"
(previously blocked up to 10s). The dev-fixture path stays synchronous; the thread
re-checks idempotency before hydrating (a live header may land first).
- Diagnostics: debug breadcrumbs on the parse and seed fail-open paths so a crashed
parser / dead seed is distinguishable from a legitimate no-headers miss.
Cold-start tests set HERMES_DEV_CREDITS alongside the fixture to match the gate.
* test(tui): fix env-timing in the StatusRule dev-credits assertion
DEV_CREDITS_MODE is read once at module load (config/env), so mutating
process.env.HERMES_DEV_CREDITS inside the test couldn't flip it — the dev-banner
assertion only passed if the env was exported before vitest started, and failed in a
normal run. Move that assertion to a sibling file that mocks config/env with
DEV_CREDITS_MODE: true (scoped, no module-reset / React-identity hazard).
* test(credits): cover the dev-fixture /usage render and usage-band clear-on-prompt
- _snapshot_from_credits_state (the offline /usage renderer) had no direct test:
lock the gauge math, the verbatim *_usd magnitudes, the depletion line and the
fixture marker, plus the no-cap (no gauge) and None-state cases.
- turnController.startMessage had no test for clearing the credits.usage notice on
the next prompt while leaving credits.depleted sticky.
* feat(credits): deliver credit notices over messaging gateways
Bind notice_callback/notice_clear_callback on the per-turn gateway agent
so usage-band / depletion / restored notices reach Telegram/Discord/Slack/
etc. Previously the messaging gateway bound neither callback, so the agent's
_emit_credits_notices early-returned and a chat user crossing a band got
nothing unless they ran /usage manually.
- render_notice_line(): AgentNotice -> single plaintext line (level glyph +
text), plaintext-only so it renders uniformly without per-platform escaping.
Fail-soft on malformed/empty notices.
- Standalone push for every notice (messaging has no persistent status bar):
route through the shared _deliver_platform_notice rail (honors private/
public delivery + thread metadata), scheduled onto the gateway loop via
safe_schedule_threadsafe from the agent's sync worker thread — same pattern
as _status_callback_sync.
- The fired-once latch lives on the cached (reused-in-place) agent and
persists across turns, so a band crosses once -> one push, no per-turn
re-nag. Re-fires only after idle-eviction rebuilds the agent (a reminder).
- Recovery ('Credit access restored') rides the show path (emitted as a
success notice, not a clear). notice_clear_callback is a no-op: a sent
platform message can't be cleanly retracted.
Tests: render glyph/levels/fail-soft + public/private delivery seam through
_deliver_platform_notice + no-adapter no-op.
* fix(credits): don't double the glyph on messaging notices
render_notice_line prepended a per-level glyph, but the notice policy already
bakes the glyph into the text (and the TUI + CLI render it verbatim) — so every
credit notice over messaging came out doubled ("⚠ ⚠ Credits 90% used",
"⛔ ✕ Credit access paused"). Emit the text verbatim instead; drop the now-dead
level→glyph map.
The render tests fed glyph-less text (and the success case only checked
startswith), so the doubling slipped through. Rework them around the verbatim
contract and add an end-to-end regression that runs real evaluate_credits_notices
output through render_notice_line and asserts the line is returned unchanged.
758 lines
27 KiB
TypeScript
758 lines
27 KiB
TypeScript
import { Box, type ScrollBoxHandle, stringWidth, Text } from '@hermes/ink'
|
|
import { useStore } from '@nanostores/react'
|
|
import { type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from 'react'
|
|
import unicodeSpinners from 'unicode-animations'
|
|
|
|
import { $delegationState } from '../app/delegationStore.js'
|
|
import type { IndicatorStyle, Notice } from '../app/interfaces.js'
|
|
import { useTurnSelector } from '../app/turnStore.js'
|
|
import { DEV_CREDITS_MODE } from '../config/env.js'
|
|
import { FACES } from '../content/faces.js'
|
|
import { VERBS } from '../content/verbs.js'
|
|
import { fmtDuration } from '../domain/messages.js'
|
|
import { stickyPromptFromViewport } from '../domain/viewport.js'
|
|
import { buildSubagentTree, treeTotals, widthByDepth } from '../lib/subagentTree.js'
|
|
import { fmtK } from '../lib/text.js'
|
|
import { useScrollbarSnapshot, useViewportSnapshot } from '../lib/viewportStore.js'
|
|
import type { Theme } from '../theme.js'
|
|
import type { Msg, Usage } from '../types.js'
|
|
|
|
const FACE_TICK_MS = 2500
|
|
const HEART_COLORS = ['#ff5fa2', '#ff4d6d']
|
|
|
|
// Keep verb segment width stable so status-bar content to the right doesn't
|
|
// jitter when the ticker rotates between short/long verbs.
|
|
export const VERB_PAD_LEN = VERBS.reduce((max, v) => Math.max(max, v.length), 0) + 1 // + ellipsis
|
|
export const padVerb = (verb: string) => `${verb}…`.padEnd(VERB_PAD_LEN, ' ')
|
|
|
|
// Compact alternates for the `emoji` and `ascii` indicator styles.
|
|
// Each entry is a fixed-width (display-width) glyph.
|
|
const EMOJI_FRAMES = ['⚕ ', '🌀', '🤔', '✨', '🍵', '🔮']
|
|
const ASCII_FRAMES = ['|', '/', '-', '\\']
|
|
|
|
// Faster tick for spinner-style indicators — they read as motion only
|
|
// at frame rates closer to their authored interval.
|
|
const SPINNER_TICK_MS = 100
|
|
|
|
interface IndicatorRender {
|
|
frame: string
|
|
intervalMs: number
|
|
// When false, FaceTicker hides the rotating verb and just shows the
|
|
// glyph + duration. Lets `unicode` stay minimal while the other
|
|
// styles keep the verb-rotation flavour users associate with the
|
|
// running… status.
|
|
showVerb: boolean
|
|
}
|
|
|
|
const renderIndicator = (style: IndicatorStyle, tick: number): IndicatorRender => {
|
|
if (style === 'kaomoji') {
|
|
return { frame: FACES[tick % FACES.length] ?? '', intervalMs: FACE_TICK_MS, showVerb: true }
|
|
}
|
|
|
|
if (style === 'emoji') {
|
|
return {
|
|
frame: EMOJI_FRAMES[tick % EMOJI_FRAMES.length] ?? '⚕ ',
|
|
intervalMs: SPINNER_TICK_MS * 6,
|
|
showVerb: true
|
|
}
|
|
}
|
|
|
|
if (style === 'ascii') {
|
|
return {
|
|
frame: ASCII_FRAMES[tick % ASCII_FRAMES.length] ?? '|',
|
|
intervalMs: SPINNER_TICK_MS,
|
|
showVerb: true
|
|
}
|
|
}
|
|
|
|
// 'unicode' — braille spinner (fixed 1-col). Authored interval is
|
|
// ~80ms; honour it but bound below at a safe minimum so React
|
|
// re-renders stay reasonable. This style is for users who want
|
|
// the cleanest possible status, so no verb rotation either.
|
|
const spinner = unicodeSpinners.braille
|
|
const frame = spinner.frames[tick % spinner.frames.length] ?? '⠋'
|
|
|
|
return { frame, intervalMs: Math.max(SPINNER_TICK_MS, spinner.interval), showVerb: false }
|
|
}
|
|
|
|
// `FACES` / `EMOJI_FRAMES` are static, so measure their widest glyph once at
|
|
// module load instead of rescanning on every status render.
|
|
const KAOMOJI_FRAME_WIDTH = FACES.reduce((max, f) => Math.max(max, stringWidth(f)), 1)
|
|
const EMOJI_FRAME_WIDTH = EMOJI_FRAMES.reduce((max, f) => Math.max(max, stringWidth(f)), 1)
|
|
|
|
const indicatorFrameWidth = (style: IndicatorStyle): number => {
|
|
if (style === 'kaomoji') {
|
|
return KAOMOJI_FRAME_WIDTH
|
|
}
|
|
|
|
if (style === 'emoji') {
|
|
return EMOJI_FRAME_WIDTH
|
|
}
|
|
|
|
// 'ascii' and 'unicode' are single-column glyphs.
|
|
return 1
|
|
}
|
|
|
|
// Bounded width of the elapsed-time clock, derived from `fmtDuration` itself so
|
|
// the reservation/budget stays consistent with what actually renders (it emits
|
|
// a space between units, e.g. `59m 59s` / `99h 59m`). Durations beyond this
|
|
// (100h+) are left to clip rather than reserving unbounded width.
|
|
export const MAX_DURATION_WIDTH = Math.max(
|
|
stringWidth(fmtDuration(59 * 60_000 + 59_000)), // "59m 59s"
|
|
stringWidth(fmtDuration(99 * 3_600_000 + 59 * 60_000)) // "99h 59m"
|
|
)
|
|
|
|
// Display width to reserve for the busy indicator so its verb + elapsed-time
|
|
// tail can't shove the model off-screen on narrow terminals. Style-aware:
|
|
// `unicode` is a bare 1-col braille spinner with no verb, while kaomoji/emoji/
|
|
// ascii add a fixed-width verb; any style adds a bounded elapsed-time tail.
|
|
// Mirrors FaceTicker's `frame + verbSegment + durationSegment` layout.
|
|
export const busyIndicatorWidth = (style: IndicatorStyle, hasDuration: boolean): number => {
|
|
const { showVerb } = renderIndicator(style, 0)
|
|
const verb = showVerb ? 1 + VERB_PAD_LEN : 0
|
|
// ` · ` plus the bounded clock (e.g. `59m 59s`).
|
|
const duration = hasDuration ? stringWidth(' · ') + MAX_DURATION_WIDTH : 0
|
|
|
|
return indicatorFrameWidth(style) + verb + duration
|
|
}
|
|
|
|
function FaceTicker({ color, startedAt, style }: { color: string; startedAt?: null | number; style: IndicatorStyle }) {
|
|
const [tick, setTick] = useState(() => Math.floor(Math.random() * 1000))
|
|
const [verbTick, setVerbTick] = useState(() => Math.floor(Math.random() * VERBS.length))
|
|
const [now, setNow] = useState(() => Date.now())
|
|
|
|
// Pre-compute cadence + verb-visibility for the active style so an
|
|
// `/indicator` switch re-arms the interval (and skips the verb timer
|
|
// for verb-less styles like `unicode`) without leaving the previous
|
|
// timer dangling.
|
|
const { intervalMs, showVerb } = renderIndicator(style, 0)
|
|
|
|
useEffect(() => {
|
|
const glyph = setInterval(() => setTick(n => n + 1), intervalMs)
|
|
const clock = setInterval(() => setNow(Date.now()), 1000)
|
|
// Verb timer is gated on `showVerb` — `unicode` style hides the verb
|
|
// entirely, so cycling `verbTick` would be an avoidable re-render.
|
|
const verb = showVerb ? setInterval(() => setVerbTick(n => n + 1), FACE_TICK_MS) : null
|
|
|
|
return () => {
|
|
clearInterval(glyph)
|
|
clearInterval(clock)
|
|
|
|
if (verb !== null) {
|
|
clearInterval(verb)
|
|
}
|
|
}
|
|
}, [intervalMs, showVerb])
|
|
|
|
const { frame } = renderIndicator(style, tick)
|
|
const verb = VERBS[verbTick % VERBS.length] ?? ''
|
|
const verbSegment = showVerb ? ` ${padVerb(verb)}` : ''
|
|
// Leading space keeps a gap between the frame and the duration when the
|
|
// verb segment is hidden (e.g. `unicode` spinner style). When the verb
|
|
// IS shown, its trailing padding already provides the gap, so the extra
|
|
// space is harmless.
|
|
const durationSegment = startedAt ? ` · ${fmtDuration(now - startedAt)}` : ''
|
|
|
|
return (
|
|
<Text color={color}>
|
|
{frame}
|
|
{verbSegment}
|
|
{durationSegment}
|
|
</Text>
|
|
)
|
|
}
|
|
|
|
function ctxBarColor(pct: number | undefined, t: Theme) {
|
|
if (pct == null) {
|
|
return t.color.muted
|
|
}
|
|
|
|
if (pct >= 95) {
|
|
return t.color.statusCritical
|
|
}
|
|
|
|
if (pct > 80) {
|
|
return t.color.statusBad
|
|
}
|
|
|
|
if (pct >= 50) {
|
|
return t.color.statusWarn
|
|
}
|
|
|
|
return t.color.statusGood
|
|
}
|
|
|
|
function statusSessionCountLabel(count: number) {
|
|
return `${count} ${count === 1 ? 'session' : 'sessions'}`
|
|
}
|
|
|
|
// Colour a credits notice by its level. The notice TEXT already carries its
|
|
// own glyph (⚠ • ✕ ✓) from the Python policy — we only tint it here, never
|
|
// prepend another glyph. `success` maps to the theme's green status colour.
|
|
function noticeColor(level: Notice['level'], t: Theme): string {
|
|
if (level === 'error') {
|
|
return t.color.error
|
|
}
|
|
|
|
if (level === 'warn') {
|
|
return t.color.warn
|
|
}
|
|
|
|
if (level === 'success') {
|
|
return t.color.statusGood
|
|
}
|
|
|
|
// 'info' / undefined — keep it readable but understated.
|
|
return t.color.accent
|
|
}
|
|
|
|
function ctxBar(pct: number | undefined, w = 10) {
|
|
const p = Math.max(0, Math.min(100, pct ?? 0))
|
|
const filled = Math.round((p / 100) * w)
|
|
|
|
return '█'.repeat(filled) + '░'.repeat(w - filled)
|
|
}
|
|
|
|
// `minLeftContent` is the display width of the high-priority left segments
|
|
// (status indicator + model + context). Reserving it makes the cwd/branch
|
|
// segment on the right yield FIRST on narrow terminals, instead of squeezing
|
|
// the loading indicator and model down to nothing.
|
|
export function statusRuleWidths(cols: number, cwdLabel: string, minLeftContent = 0) {
|
|
const width = Math.max(1, Math.floor(cols || 1))
|
|
const desiredSeparatorWidth = width >= 24 ? 3 : 1
|
|
const baseMinLeft = width >= 24 ? 8 : 1
|
|
// Never reserve more than the terminal width; never less than the historical
|
|
// floor. With the default `minLeftContent = 0` this is identical to the old
|
|
// behaviour, so callers that don't pass content are unaffected.
|
|
const minLeftWidth = Math.min(width, Math.max(baseMinLeft, Math.floor(minLeftContent)))
|
|
const maxRightWidth = Math.max(0, width - desiredSeparatorWidth - minLeftWidth)
|
|
|
|
if (!cwdLabel || maxRightWidth <= 0) {
|
|
return { leftWidth: width, rightWidth: 0, separatorWidth: 0 }
|
|
}
|
|
|
|
const rightWidth = Math.max(0, Math.min(stringWidth(cwdLabel), maxRightWidth))
|
|
const separatorWidth = rightWidth > 0 ? desiredSeparatorWidth : 0
|
|
const leftWidth = Math.max(1, width - separatorWidth - rightWidth)
|
|
|
|
return { leftWidth, rightWidth, separatorWidth }
|
|
}
|
|
|
|
// Progressive disclosure for the status rule's lower-priority tail segments.
|
|
// As the terminal narrows we shed the least important pieces first (cost →
|
|
// bg → voice → compressions → duration → context bar), and below the bar
|
|
// breakpoint the context read-out collapses to a bare token count. Status and
|
|
// model are never gated here — they're guaranteed room by `statusRuleWidths`.
|
|
export interface StatusBarSegments {
|
|
bar: boolean
|
|
bg: boolean
|
|
compactCtx: boolean
|
|
compressions: boolean
|
|
cost: boolean
|
|
duration: boolean
|
|
voice: boolean
|
|
}
|
|
|
|
export function statusBarSegments(cols: number): StatusBarSegments {
|
|
const w = Math.max(1, Math.floor(cols || 1))
|
|
|
|
return {
|
|
compactCtx: w < 72,
|
|
bar: w >= 72,
|
|
duration: w >= 76,
|
|
compressions: w >= 80,
|
|
voice: w >= 84,
|
|
bg: w >= 88,
|
|
cost: w >= 96
|
|
}
|
|
}
|
|
|
|
function SpawnHud({ t }: { t: Theme }) {
|
|
// Tight HUD that only appears when the session is actually fanning out.
|
|
// Colour escalates to warn/error as depth or concurrency approaches the cap.
|
|
const delegation = useStore($delegationState)
|
|
const subagents = useTurnSelector(state => state.subagents)
|
|
|
|
const tree = useMemo(() => buildSubagentTree(subagents), [subagents])
|
|
const totals = useMemo(() => treeTotals(tree), [tree])
|
|
|
|
if (!totals.descendantCount && !delegation.paused) {
|
|
return null
|
|
}
|
|
|
|
const maxDepth = delegation.maxSpawnDepth
|
|
const maxConc = delegation.maxConcurrentChildren
|
|
const depth = Math.max(0, totals.maxDepthFromHere)
|
|
const active = totals.activeCount
|
|
|
|
// `max_concurrent_children` is a per-parent cap, not a global one.
|
|
// `activeCount` sums every running agent across the tree and would
|
|
// over-warn for multi-orchestrator runs. The widest level of the tree
|
|
// is a closer proxy to "most concurrent spawns that could be hitting a
|
|
// single parent's slot budget".
|
|
const widestLevel = widthByDepth(tree).reduce((a, b) => Math.max(a, b), 0)
|
|
const depthRatio = maxDepth ? depth / maxDepth : 0
|
|
const concRatio = maxConc ? widestLevel / maxConc : 0
|
|
const ratio = Math.max(depthRatio, concRatio)
|
|
|
|
const color = delegation.paused || ratio >= 1 ? t.color.error : ratio >= 0.66 ? t.color.warn : t.color.muted
|
|
|
|
const pieces: string[] = []
|
|
|
|
if (delegation.paused) {
|
|
pieces.push('⏸ paused')
|
|
}
|
|
|
|
if (totals.descendantCount > 0) {
|
|
const depthLabel = maxDepth ? `${depth}/${maxDepth}` : `${depth}`
|
|
pieces.push(`d${depthLabel}`)
|
|
|
|
if (active > 0) {
|
|
// Label pairs the widest-level count (drives concRatio above) with
|
|
// the total active count for context. `W/cap` triggers the warn,
|
|
// `+N` is everything else currently running across the tree.
|
|
const extra = Math.max(0, active - widestLevel)
|
|
const widthLabel = maxConc ? `${widestLevel}/${maxConc}` : `${widestLevel}`
|
|
const suffix = extra > 0 ? `+${extra}` : ''
|
|
pieces.push(`⚡${widthLabel}${suffix}`)
|
|
}
|
|
}
|
|
|
|
const atCap = depthRatio >= 1 || concRatio >= 1
|
|
|
|
return (
|
|
<Text color={color}>
|
|
{atCap ? ' │ ⚠ ' : ' │ '}
|
|
{pieces.join(' ')}
|
|
</Text>
|
|
)
|
|
}
|
|
|
|
function SessionDuration({ startedAt }: { startedAt: number }) {
|
|
const [now, setNow] = useState(() => Date.now())
|
|
|
|
useEffect(() => {
|
|
setNow(Date.now())
|
|
const id = setInterval(() => setNow(Date.now()), 1000)
|
|
|
|
return () => clearInterval(id)
|
|
}, [startedAt])
|
|
|
|
return fmtDuration(now - startedAt)
|
|
}
|
|
|
|
const effortLabel = (effort?: string) => {
|
|
const value = String(effort ?? '')
|
|
.trim()
|
|
.toLowerCase()
|
|
|
|
return value && value !== 'medium' && value !== 'normal' && value !== 'default' ? value : ''
|
|
}
|
|
|
|
const shortModelLabel = (model: string) =>
|
|
model
|
|
.split('/')
|
|
.pop()!
|
|
.replace(/^claude[-_]/, '')
|
|
.replace(/^anthropic[-_]/, '')
|
|
.replace(/[-_]/g, ' ')
|
|
.replace(/\b(\d+)\s+(\d+)\b/g, '$1.$2')
|
|
.trim()
|
|
|
|
const modelLabel = (model: string, effort?: string, fast?: boolean) =>
|
|
[shortModelLabel(model), effortLabel(effort), fast ? 'fast' : ''].filter(Boolean).join(' ')
|
|
|
|
export function GoodVibesHeart({ tick, t }: { tick: number; t: Theme }) {
|
|
const [active, setActive] = useState(false)
|
|
const [color, setColor] = useState(t.color.accent)
|
|
|
|
useEffect(() => {
|
|
if (tick <= 0) {
|
|
return
|
|
}
|
|
|
|
const palette = [t.color.error, t.color.warn, t.color.accent]
|
|
setColor(palette[Math.floor(Math.random() * palette.length)]!)
|
|
setActive(true)
|
|
|
|
const id = setTimeout(() => setActive(false), 650)
|
|
|
|
return () => clearTimeout(id)
|
|
}, [t.color.accent, tick])
|
|
|
|
if (!active) {
|
|
return null
|
|
}
|
|
|
|
return <Text color={color}>♥</Text>
|
|
}
|
|
|
|
export function StatusRule({
|
|
cwdLabel,
|
|
cols,
|
|
busy,
|
|
status,
|
|
statusColor,
|
|
model,
|
|
modelFast,
|
|
modelReasoningEffort,
|
|
indicatorStyle = 'kaomoji',
|
|
notice,
|
|
usage,
|
|
bgCount,
|
|
liveSessionCount,
|
|
sessionStartedAt,
|
|
showCost,
|
|
turnStartedAt,
|
|
voiceLabel,
|
|
onSessionCountClick,
|
|
t
|
|
}: StatusRuleProps) {
|
|
const pct = usage.context_percent
|
|
const barColor = ctxBarColor(pct, t)
|
|
const segs = statusBarSegments(cols)
|
|
|
|
// On narrow terminals the context read-out collapses to a bare token count
|
|
// (`12k tok`) and the visual fill bar is dropped entirely.
|
|
const ctxLabel = usage.context_max
|
|
? segs.compactCtx
|
|
? `${fmtK(usage.context_used ?? 0)} tok`
|
|
: `${fmtK(usage.context_used ?? 0)}/${fmtK(usage.context_max)}`
|
|
: usage.total > 0
|
|
? `${fmtK(usage.total)} tok`
|
|
: ''
|
|
|
|
const bar = !segs.compactCtx && usage.context_max ? ctxBar(pct) : ''
|
|
const modelText = modelLabel(model, modelReasoningEffort, modelFast)
|
|
|
|
// A credits notice replaces the status/verb slot, but only when idle —
|
|
// while busy the FaceTicker always wins (R1 render priority). The notice
|
|
// text carries its own glyph; we only tint it (R1) and let it shrink (R3-M7).
|
|
const showNotice = !busy && !!notice?.text
|
|
// The notice slot is shrinkable (flexShrink={1}, truncate-end), so reserve
|
|
// only a small bounded width for it in the essentials budget — enough that
|
|
// a short notice never gets crushed, but a long one ellipsizes instead of
|
|
// shoving `model │ ctx` off-screen (R3-M7). Cap at the notice's own width
|
|
// so short notices reserve exactly what they need.
|
|
const NOTICE_RESERVE_MAX = 24
|
|
const noticeReserve = showNotice ? Math.min(stringWidth(notice!.text), NOTICE_RESERVE_MAX) : 0
|
|
|
|
// Width of the must-keep left segments (indicator + model + context). They
|
|
// are pinned (never shrink) and reserved so the cwd/branch on the right
|
|
// yields first. The busy face width depends on the active /indicator style
|
|
// (kaomoji is wide + verb; unicode is a bare 1-col spinner). When a notice
|
|
// occupies the slot it reserves only `noticeReserve` (it shrinks/truncates).
|
|
const slotWidth = busy
|
|
? busyIndicatorWidth(indicatorStyle, turnStartedAt != null)
|
|
: showNotice
|
|
? noticeReserve
|
|
: stringWidth(status)
|
|
|
|
const essentialWidth =
|
|
stringWidth('─ ') +
|
|
slotWidth +
|
|
stringWidth(' │ ') +
|
|
stringWidth(modelText) +
|
|
(ctxLabel ? stringWidth(' │ ') + stringWidth(ctxLabel) : 0)
|
|
|
|
const { leftWidth, rightWidth, separatorWidth } = statusRuleWidths(cols, cwdLabel, essentialWidth)
|
|
|
|
// Whole-segment progressive disclosure for the tail: a segment renders only
|
|
// if it fits in the space left after the pinned essentials, evaluated in
|
|
// descending priority order — bar, duration, compressions, voice, session
|
|
// count, bg, cost. Lower-priority segments drop first and nothing truncates
|
|
// mid-segment, so status/model/context are never crushed.
|
|
const SEP = stringWidth(' │ ')
|
|
let tailBudget = Math.max(0, leftWidth - essentialWidth)
|
|
const fits = (w: number) => {
|
|
if (tailBudget >= w) {
|
|
tailBudget -= w
|
|
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
const sessionCountText = liveSessionCount > 0 ? statusSessionCountLabel(liveSessionCount) : ''
|
|
const compressions = typeof usage.compressions === 'number' ? usage.compressions : 0
|
|
const costText = typeof usage.cost_usd === 'number' ? `$${usage.cost_usd.toFixed(4)}` : ''
|
|
// Dev-only readout (HERMES_DEV_CREDITS). The server omits the key entirely unless the
|
|
// flag is on, so this segment self-hides for normal users. micros→cents is allowed money
|
|
// math (display formatting) — never parseFloat a *_usd. Signed: a mid-session top-up that
|
|
// raises remaining nets a negative Δ (honest).
|
|
const devCreditsText =
|
|
typeof usage.dev_credits_spent_micros === 'number'
|
|
? `Δ ${(usage.dev_credits_spent_micros / 10000).toFixed(1)}¢`
|
|
: ''
|
|
|
|
const showBar = !!bar && fits(SEP + stringWidth(`[${bar}] ${pct != null ? `${pct}%` : ''}`))
|
|
const showDuration = segs.duration && !!sessionStartedAt && fits(SEP + MAX_DURATION_WIDTH)
|
|
const showCompressions = segs.compressions && compressions > 0 && fits(SEP + stringWidth(`cmp ${compressions}`))
|
|
const showVoice = segs.voice && !!voiceLabel && fits(SEP + stringWidth(voiceLabel))
|
|
const showSessionCount = !!sessionCountText && fits(SEP + stringWidth(sessionCountText))
|
|
const showBg = segs.bg && bgCount > 0 && fits(SEP + stringWidth(`${bgCount} bg`))
|
|
const showCostSeg = segs.cost && showCost && !!costText && fits(SEP + stringWidth(costText))
|
|
// No segs flag / no showCost coupling — it's a server-gated dev readout, lowest priority,
|
|
// so it consumes tail budget LAST and drops first on a narrow terminal.
|
|
const showDevCredits = !!devCreditsText && fits(SEP + stringWidth(devCreditsText))
|
|
|
|
const handleSessionCountClick = (event: { stopImmediatePropagation?: () => void }) => {
|
|
event.stopImmediatePropagation?.()
|
|
onSessionCountClick?.()
|
|
}
|
|
|
|
const sessionCountNode = onSessionCountClick ? (
|
|
<Box flexShrink={0} onClick={handleSessionCountClick}>
|
|
<Text color={t.color.accent}> │ {sessionCountText}</Text>
|
|
</Box>
|
|
) : (
|
|
<Text color={t.color.muted}> │ {sessionCountText}</Text>
|
|
)
|
|
|
|
return (
|
|
<Box height={1}>
|
|
<Box flexDirection="row" flexShrink={1} overflow="hidden" width={leftWidth}>
|
|
{/* Leading pinned chrome: border + busy face / idle status. When a
|
|
notice occupies the slot the status text is dropped — the notice
|
|
renders as a separate shrinkable box below so a long notice
|
|
ellipsizes instead of crushing model │ ctx (R3-M7). */}
|
|
<Box flexDirection="row" flexShrink={0}>
|
|
<Text color={t.color.border}>{'─ '}</Text>
|
|
{busy ? (
|
|
<FaceTicker color={statusColor} startedAt={turnStartedAt} style={indicatorStyle} />
|
|
) : showNotice ? null : (
|
|
<Text color={statusColor} wrap="truncate-end">
|
|
{status}
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
{/* Notice slot — the only shrinkable left element (R3-M7). Sits in a
|
|
flexShrink={1} box with truncate-end so it yields/ellipsizes
|
|
before the pinned model │ ctx box ever clips. */}
|
|
{showNotice ? (
|
|
<Box flexDirection="row" flexShrink={1} overflow="hidden">
|
|
<Text color={noticeColor(notice!.level, t)} wrap="truncate-end">
|
|
{notice!.text}
|
|
</Text>
|
|
</Box>
|
|
) : null}
|
|
{/* Pinned essentials — model + context never shrink, always visible. */}
|
|
<Box flexDirection="row" flexShrink={0}>
|
|
{DEV_CREDITS_MODE ? (
|
|
<Text color={t.color.warn} wrap="truncate-end">
|
|
{' (dev credits)'}
|
|
</Text>
|
|
) : null}
|
|
<Text color={t.color.muted} wrap="truncate-end">
|
|
{' │ '}
|
|
{modelText}
|
|
</Text>
|
|
{ctxLabel ? (
|
|
<Text color={t.color.muted} wrap="truncate-end">
|
|
{' │ '}
|
|
{ctxLabel}
|
|
</Text>
|
|
) : null}
|
|
</Box>
|
|
{showBar ? (
|
|
<Text color={t.color.muted} wrap="truncate-end">
|
|
{' │ '}
|
|
<Text color={barColor}>[{bar}]</Text> <Text color={barColor}>{pct != null ? `${pct}%` : ''}</Text>
|
|
</Text>
|
|
) : null}
|
|
{showDuration ? (
|
|
<Text color={t.color.muted} wrap="truncate-end">
|
|
{' │ '}
|
|
<SessionDuration startedAt={sessionStartedAt!} />
|
|
</Text>
|
|
) : null}
|
|
{showCompressions ? (
|
|
<Text color={t.color.muted} wrap="truncate-end">
|
|
{' │ '}
|
|
<Text color={compressions >= 10 ? t.color.error : compressions >= 5 ? t.color.warn : t.color.muted}>
|
|
cmp {compressions}
|
|
</Text>
|
|
</Text>
|
|
) : null}
|
|
{showVoice ? (
|
|
<Text
|
|
color={
|
|
voiceLabel!.startsWith('●') ? t.color.error : voiceLabel!.startsWith('◉') ? t.color.warn : t.color.muted
|
|
}
|
|
wrap="truncate-end"
|
|
>
|
|
{' │ '}
|
|
{voiceLabel}
|
|
</Text>
|
|
) : null}
|
|
{showSessionCount ? sessionCountNode : null}
|
|
{showBg ? (
|
|
<Text color={t.color.muted} wrap="truncate-end">
|
|
{' │ '}
|
|
{bgCount} bg
|
|
</Text>
|
|
) : null}
|
|
{showCostSeg ? (
|
|
<Text color={t.color.muted} wrap="truncate-end">
|
|
{' │ '}
|
|
{costText}
|
|
</Text>
|
|
) : null}
|
|
{showDevCredits ? (
|
|
<Text color={t.color.accent} wrap="truncate-end">
|
|
{' │ '}
|
|
{devCreditsText}
|
|
</Text>
|
|
) : null}
|
|
{/* SpawnHud isn't part of the tail budget (its width is dynamic), so it
|
|
renders last — any overflow truncates the HUD itself rather than the
|
|
budgeted segments before it. It self-hides when no delegation runs. */}
|
|
<SpawnHud t={t} />
|
|
</Box>
|
|
|
|
{rightWidth > 0 ? (
|
|
<>
|
|
<Text color={t.color.border}>{separatorWidth >= 3 ? ' ─ ' : ' '}</Text>
|
|
<Box flexShrink={0} width={rightWidth}>
|
|
<Text color={t.color.label} wrap="truncate-end">
|
|
{cwdLabel}
|
|
</Text>
|
|
</Box>
|
|
</>
|
|
) : null}
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
export function FloatBox({ children, color }: { children: ReactNode; color: string }) {
|
|
return (
|
|
<Box
|
|
alignSelf="flex-start"
|
|
borderColor={color}
|
|
borderStyle="double"
|
|
flexDirection="column"
|
|
marginTop={1}
|
|
opaque
|
|
paddingX={1}
|
|
>
|
|
{children}
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
export function StickyPromptTracker({ messages, offsets, scrollRef, onChange }: StickyPromptTrackerProps) {
|
|
const { atBottom, bottom, top } = useViewportSnapshot(scrollRef)
|
|
const text = stickyPromptFromViewport(messages, offsets, top, bottom, atBottom)
|
|
|
|
useEffect(() => onChange(text), [onChange, text])
|
|
|
|
return null
|
|
}
|
|
|
|
export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps) {
|
|
const [hover, setHover] = useState(false)
|
|
const [grab, setGrab] = useState<number | null>(null)
|
|
const grabRef = useRef<number | null>(null)
|
|
const { scrollHeight: total, top: pos, viewportHeight: vp } = useScrollbarSnapshot(scrollRef)
|
|
|
|
if (!vp) {
|
|
return <Box width={1} />
|
|
}
|
|
|
|
const s = scrollRef.current
|
|
const scrollable = total > vp
|
|
const thumb = scrollable ? Math.max(1, Math.round((vp * vp) / total)) : vp
|
|
const travel = Math.max(1, vp - thumb)
|
|
const thumbTop = scrollable ? Math.round((pos / Math.max(1, total - vp)) * travel) : 0
|
|
const thumbColor = grab !== null ? t.color.primary : hover ? t.color.accent : t.color.border
|
|
const trackColor = hover ? t.color.border : t.color.muted
|
|
|
|
const jump = (row: number, offset: number) => {
|
|
if (!s || !scrollable) {
|
|
return
|
|
}
|
|
|
|
s.scrollTo(Math.round((Math.max(0, Math.min(travel, row - offset)) / travel) * Math.max(0, total - vp)))
|
|
}
|
|
|
|
return (
|
|
<Box
|
|
flexDirection="column"
|
|
onMouseDown={(e: { localRow?: number }) => {
|
|
const row = Math.max(0, Math.min(vp - 1, e.localRow ?? 0))
|
|
const off = row >= thumbTop && row < thumbTop + thumb ? row - thumbTop : Math.floor(thumb / 2)
|
|
|
|
grabRef.current = off
|
|
setGrab(off)
|
|
jump(row, off)
|
|
}}
|
|
onMouseDrag={(e: { localRow?: number }) =>
|
|
jump(Math.max(0, Math.min(vp - 1, e.localRow ?? 0)), grabRef.current ?? Math.floor(thumb / 2))
|
|
}
|
|
onMouseEnter={() => setHover(true)}
|
|
onMouseLeave={() => setHover(false)}
|
|
onMouseUp={() => {
|
|
grabRef.current = null
|
|
setGrab(null)
|
|
}}
|
|
width={1}
|
|
>
|
|
{!scrollable ? (
|
|
<Text color={trackColor} dim>
|
|
{' \n'.repeat(Math.max(0, vp - 1))}{' '}
|
|
</Text>
|
|
) : (
|
|
<>
|
|
{thumbTop > 0 ? (
|
|
<Text color={trackColor} dim={!hover}>
|
|
{`${'│\n'.repeat(Math.max(0, thumbTop - 1))}${thumbTop > 0 ? '│' : ''}`}
|
|
</Text>
|
|
) : null}
|
|
{thumb > 0 ? (
|
|
<Text color={thumbColor}>{`${'┃\n'.repeat(Math.max(0, thumb - 1))}${thumb > 0 ? '┃' : ''}`}</Text>
|
|
) : null}
|
|
{vp - thumbTop - thumb > 0 ? (
|
|
<Text color={trackColor} dim={!hover}>
|
|
{`${'│\n'.repeat(Math.max(0, vp - thumbTop - thumb - 1))}${vp - thumbTop - thumb > 0 ? '│' : ''}`}
|
|
</Text>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
interface StatusRuleProps {
|
|
bgCount: number
|
|
liveSessionCount: number
|
|
busy: boolean
|
|
cols: number
|
|
cwdLabel: string
|
|
model: string
|
|
modelFast?: boolean
|
|
modelReasoningEffort?: string
|
|
indicatorStyle?: IndicatorStyle
|
|
notice?: Notice | null
|
|
sessionStartedAt?: null | number
|
|
showCost: boolean
|
|
status: string
|
|
statusColor: string
|
|
t: Theme
|
|
turnStartedAt?: null | number
|
|
usage: Usage
|
|
voiceLabel?: string
|
|
onSessionCountClick?: () => void
|
|
}
|
|
|
|
interface StickyPromptTrackerProps {
|
|
messages: readonly Msg[]
|
|
offsets: ArrayLike<number>
|
|
onChange: (text: string) => void
|
|
scrollRef: RefObject<ScrollBoxHandle | null>
|
|
}
|
|
|
|
interface TranscriptScrollbarProps {
|
|
scrollRef: RefObject<ScrollBoxHandle | null>
|
|
t: Theme
|
|
}
|