mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-08 03:01:47 +00:00
* feat(tui): pluggable busy-indicator styles (kaomoji/emoji/unicode/ascii) The status-bar `FaceTicker` rotated through wide-and-variable kaomoji glyphs (`(。•́︿•̀。)`, `( ͡° ͜ʖ ͡°)`, …) every 2.5s. Real display widths range from ~5 to ~16 columns, so the rest of the bar (cwd, ctx %, voice, bg counter) shifted on every cycle. Padding the verb alone (#17116) helped but didn't address the dominant jitter source — the glyph itself. Add four indicator styles, configurable + hot-swappable: * `kaomoji` (default — preserves the existing vibe; verb is now pad-stable so the only width churn left is the kaomoji itself). * `emoji` — single 2-col emoji frame (`⚕ 🌀 🤔 ✨ 🍵 🔮`). * `unicode` — `unicode-animations` braille spinner (1-col, smooth). * `ascii` — `| / - \` (1-col, max compat). Wires: * `display.tui_status_indicator` in `DEFAULT_CONFIG` (default `kaomoji`). * New JSON-RPC `config.set/get indicator` keys, narrow allow-list. * `applyDisplay` reads the field and patches `UiState.indicatorStyle`, so the existing `mtime` poll picks up `~/.hermes/config.yaml` edits within ~5s without a TUI restart. * `/indicator [style]` slash command (alias `/indicator-style`, subcommand completion `kaomoji|emoji|unicode|ascii`). Bare form shows the current style; setter fires `config.set` and optimistically `patchUiState({ indicatorStyle })` so the live TUI swaps immediately, matching the `/skin` UX. * `CommandDef("indicator", ..., subcommands=...)` so classic CLI autocomplete + TUI `complete.slash` both surface it. * `FaceTicker` decouples spinner cadence from verb cadence — the glyph runs at the spinner's authored interval (or `FACE_TICK_MS` for kaomoji), the verb stays on the original 2.5s cycle, and both re-arm cleanly when style changes. Tests: * `normalizeIndicatorStyle` rejects unknown / non-string input. * `applyDisplay → tui_status_indicator` covers fan-out + fallback. * `/indicator <style>` hot-swaps `UiState.indicatorStyle` after a successful `config.set`. * `/indicator sparkle` rejects with the usage hint and never hits the gateway. * Slash-parity matrix gets `'/indicator'` → `config.get`. Validation: cd ui-tui && npm run type-check — clean; npm test --run — 398/398. scripts/run_tests.sh tests/test_tui_gateway_server.py tests/hermes_cli/test_commands.py — 220/220. * chore(tui): drop /indicator-style alias to declutter autocomplete * fix(tui): drop verb-width pad — /indicator handles glyph jitter directly * fix(tui): unicode indicator style hides the verb (cleanest option) * refactor(tui): single source of truth for INDICATOR_STYLES; cleaner error format Round 1 Copilot review on PR #17150: - Exported `INDICATOR_STYLES` const tuple from `interfaces.ts`; `IndicatorStyle` union type is derived from it. `useConfigSync` builds its validation Set from the tuple, and `session.ts` uses it for both the usage hint and the runtime allow-list — adding/removing a style now touches one line. - Backend `config.set indicator` error message: switched `sorted(allowed)` list repr to `pick one of ascii|emoji|kaomoji|unicode` (matches the TUI usage hint), and reports the normalized `raw` instead of the original `value`. Backend allowed tuple now has a comment pointing back at `INDICATOR_STYLES` so the two stay aligned. Note: kept the verb portion unpadded per design intent — fixed-width padding was the exact UX the `/indicator` command was added to remove. Stable width comes from the glyph; verbs cycling is part of the kawaii aesthetic. Reply on the verb thread will explain. * fix(tui): drop type collapse + gate verb timer + DEFAULT_INDICATOR_STYLE Round 2 Copilot review on PR #17150: - `tui_status_indicator?: 'ascii' | ... | string` collapses to `string` in TS — consumers got no narrowing. Documented as plain `string` with a comment about runtime validation via `normalizeIndicatorStyle`. - `FaceTicker` always started a 2.5s verb interval, even for the `unicode` style which hides the verb entirely. Now gated on `showVerb` from `renderIndicator` — `unicode` stays calm. Pre-emptive self-review (avoid round 3): - Three call sites duplicated the literal `'kaomoji'` default (uiStore, normalizeIndicatorStyle, slash command). Added `DEFAULT_INDICATOR_STYLE` to interfaces.ts and threaded it through so changing the default touches one line. * fix(tui-gateway): normalize config.get indicator output to match TUI render Round 4 Copilot review on PR #17150: `config.get` for `indicator` returned the raw `display.tui_status_indicator` value without validation, so a hand-edited config.yaml with stray casing or an unknown style would leave `/indicator` printing one thing while the TUI rendered the kaomoji default (frontend's `normalizeIndicatorStyle` does this normalization on receive). Lifted the allow-list to module scope as `_INDICATOR_STYLES` / `_INDICATOR_DEFAULT`, reused by both `config.set` and `config.get`. Comment notes the alignment with `INDICATOR_STYLES` / `DEFAULT_INDICATOR_STYLE` in interfaces.ts so adding/removing a style is a one-line change on each end. Tests cover: known value verbatim, casing/whitespace normalize, unknown→default, unset→default. * fix(tui-gateway): preserve falsy-input diagnostics in config.set indicator error Round 5 Copilot review on PR #17150: `raw = str(value or "").strip().lower()` collapsed any falsy non-string (`0`, `False`, `[]`) to empty string, so the error message read `unknown indicator: ` with nothing after — losing the original input. Switched to `("" if value is None else str(value)).strip().lower()` so only `None` (the genuine 'no value' case) becomes blank. Used `{raw!r}` in the error so the diagnostic is unambiguous (`'0'` vs `0`). Tests: - known-value happy path (`'EMOJI'` → `'emoji'`) - falsy non-string inputs (`0` / `False` / `[]`) surface meaningfully - `None` keeps the blank-repr error
480 lines
15 KiB
TypeScript
480 lines
15 KiB
TypeScript
import { attachedImageNotice, introMsg, toTranscriptMessages } from '../../../domain/messages.js'
|
|
import { TUI_SESSION_MODEL_FLAG } from '../../../domain/slash.js'
|
|
import type {
|
|
BackgroundStartResponse,
|
|
ConfigGetValueResponse,
|
|
ConfigSetResponse,
|
|
ImageAttachResponse,
|
|
SessionBranchResponse,
|
|
SessionCompressResponse,
|
|
SessionUsageResponse,
|
|
VoiceToggleResponse
|
|
} from '../../../gatewayTypes.js'
|
|
import { fmtK } from '../../../lib/text.js'
|
|
import type { PanelSection } from '../../../types.js'
|
|
import { DEFAULT_INDICATOR_STYLE, INDICATOR_STYLES, type IndicatorStyle } from '../../interfaces.js'
|
|
import { patchOverlayState } from '../../overlayStore.js'
|
|
import { patchUiState } from '../../uiStore.js'
|
|
import type { SlashCommand } from '../types.js'
|
|
|
|
const TUI_SESSION_MODEL_RE = new RegExp(`(?:^|\\s)${TUI_SESSION_MODEL_FLAG}(?:\\s|$)`)
|
|
const TUI_SESSION_STRIP_RE = new RegExp(`\\s*${TUI_SESSION_MODEL_FLAG}\\b\\s*`, 'g')
|
|
|
|
const stripTuiSessionFlag = (trimmed: string) => trimmed.replace(TUI_SESSION_STRIP_RE, ' ').replace(/\s+/g, ' ').trim()
|
|
|
|
const modelValueForConfigSet = (arg: string) => {
|
|
const trimmed = arg.trim()
|
|
|
|
if (!trimmed) {
|
|
return trimmed
|
|
}
|
|
|
|
if (TUI_SESSION_MODEL_RE.test(trimmed)) {
|
|
return stripTuiSessionFlag(trimmed)
|
|
}
|
|
|
|
return trimmed
|
|
}
|
|
|
|
export const sessionCommands: SlashCommand[] = [
|
|
{
|
|
aliases: ['bg', 'btw'],
|
|
help: 'launch a background prompt',
|
|
name: 'background',
|
|
run: (arg, ctx) => {
|
|
if (!arg) {
|
|
return ctx.transcript.sys('/background <prompt>')
|
|
}
|
|
|
|
ctx.gateway.rpc<BackgroundStartResponse>('prompt.background', { session_id: ctx.sid, text: arg }).then(
|
|
ctx.guarded<BackgroundStartResponse>(r => {
|
|
if (!r.task_id) {
|
|
return
|
|
}
|
|
|
|
patchUiState(state => ({ ...state, bgTasks: new Set(state.bgTasks).add(r.task_id!) }))
|
|
ctx.transcript.sys(`bg ${r.task_id} started`)
|
|
})
|
|
)
|
|
}
|
|
},
|
|
|
|
{
|
|
help: 'change or show model',
|
|
aliases: ['provider'],
|
|
name: 'model',
|
|
run: (arg, ctx) => {
|
|
if (ctx.session.guardBusySessionSwitch('change models')) {
|
|
return
|
|
}
|
|
|
|
if (!arg.trim()) {
|
|
return patchOverlayState({ modelPicker: true })
|
|
}
|
|
|
|
ctx.gateway
|
|
.rpc<ConfigSetResponse>('config.set', { key: 'model', session_id: ctx.sid, value: modelValueForConfigSet(arg) })
|
|
.then(
|
|
ctx.guarded<ConfigSetResponse>(r => {
|
|
if (!r.value) {
|
|
return ctx.transcript.sys('error: invalid response: model switch')
|
|
}
|
|
|
|
ctx.transcript.sys(`model → ${r.value}`)
|
|
ctx.local.maybeWarn(r)
|
|
|
|
patchUiState(state => ({
|
|
...state,
|
|
info: state.info ? { ...state.info, model: r.value! } : { model: r.value!, skills: {}, tools: {} }
|
|
}))
|
|
})
|
|
)
|
|
}
|
|
},
|
|
|
|
{
|
|
help: 'attach an image',
|
|
name: 'image',
|
|
run: (arg, ctx) => {
|
|
ctx.gateway.rpc<ImageAttachResponse>('image.attach', { path: arg, session_id: ctx.sid }).then(
|
|
ctx.guarded<ImageAttachResponse>(r => {
|
|
ctx.transcript.sys(attachedImageNotice(r))
|
|
|
|
if (r.remainder) {
|
|
ctx.composer.setInput(r.remainder)
|
|
}
|
|
})
|
|
)
|
|
}
|
|
},
|
|
|
|
{
|
|
help: 'switch or reset personality (history reset on set)',
|
|
name: 'personality',
|
|
run: (arg, ctx) => {
|
|
if (!arg) {
|
|
return
|
|
}
|
|
|
|
ctx.gateway.rpc<ConfigSetResponse>('config.set', { key: 'personality', session_id: ctx.sid, value: arg }).then(
|
|
ctx.guarded<ConfigSetResponse>(r => {
|
|
if (r.history_reset) {
|
|
ctx.session.resetVisibleHistory(r.info ?? null)
|
|
}
|
|
|
|
ctx.transcript.sys(`personality: ${r.value || 'default'}${r.history_reset ? ' · transcript cleared' : ''}`)
|
|
ctx.local.maybeWarn(r)
|
|
})
|
|
)
|
|
}
|
|
},
|
|
|
|
{
|
|
help: 'compress transcript',
|
|
name: 'compress',
|
|
run: (arg, ctx) => {
|
|
ctx.gateway
|
|
.rpc<SessionCompressResponse>('session.compress', {
|
|
session_id: ctx.sid,
|
|
...(arg ? { focus_topic: arg } : {})
|
|
})
|
|
.then(
|
|
ctx.guarded<SessionCompressResponse>(r => {
|
|
if (Array.isArray(r.messages)) {
|
|
const rows = toTranscriptMessages(r.messages)
|
|
|
|
ctx.transcript.setHistoryItems(r.info ? [introMsg(r.info), ...rows] : rows)
|
|
}
|
|
|
|
if (r.info) {
|
|
patchUiState({ info: r.info })
|
|
}
|
|
|
|
if (r.usage) {
|
|
patchUiState(state => ({ ...state, usage: { ...state.usage, ...r.usage } }))
|
|
}
|
|
|
|
if ((r.removed ?? 0) <= 0) {
|
|
return ctx.transcript.sys('nothing to compress')
|
|
}
|
|
|
|
ctx.transcript.sys(
|
|
`compressed ${r.removed} messages${r.usage?.total ? ` · ${fmtK(r.usage.total)} tok` : ''}`
|
|
)
|
|
})
|
|
)
|
|
}
|
|
},
|
|
|
|
{
|
|
aliases: ['fork'],
|
|
help: 'branch the session',
|
|
name: 'branch',
|
|
run: (arg, ctx) => {
|
|
const prevSid = ctx.sid
|
|
|
|
ctx.gateway.rpc<SessionBranchResponse>('session.branch', { name: arg, session_id: ctx.sid }).then(
|
|
ctx.guarded<SessionBranchResponse>(r => {
|
|
if (!r.session_id) {
|
|
return
|
|
}
|
|
|
|
void ctx.session.closeSession(prevSid)
|
|
patchUiState({ sid: r.session_id })
|
|
ctx.session.setSessionStartedAt(Date.now())
|
|
ctx.transcript.setHistoryItems([])
|
|
ctx.transcript.sys(`branched → ${r.title ?? ''}`)
|
|
})
|
|
)
|
|
}
|
|
},
|
|
|
|
{
|
|
help: 'voice mode: [on|off|tts|status]',
|
|
name: 'voice',
|
|
run: (arg, ctx) => {
|
|
const normalized = (arg ?? '').trim().toLowerCase()
|
|
|
|
const action =
|
|
normalized === 'on' || normalized === 'off' || normalized === 'tts' || normalized === 'status'
|
|
? normalized
|
|
: 'status'
|
|
|
|
ctx.gateway.rpc<VoiceToggleResponse>('voice.toggle', { action }).then(
|
|
ctx.guarded<VoiceToggleResponse>(r => {
|
|
ctx.voice.setVoiceEnabled(!!r.enabled)
|
|
|
|
// Match CLI's _show_voice_status / _enable_voice_mode /
|
|
// _toggle_voice_tts output shape so users don't have to learn
|
|
// two vocabularies.
|
|
if (action === 'status') {
|
|
const mode = r.enabled ? 'ON' : 'OFF'
|
|
const tts = r.tts ? 'ON' : 'OFF'
|
|
ctx.transcript.sys('Voice Mode Status')
|
|
ctx.transcript.sys(` Mode: ${mode}`)
|
|
ctx.transcript.sys(` TTS: ${tts}`)
|
|
ctx.transcript.sys(' Record key: Ctrl+B')
|
|
|
|
// CLI's "Requirements:" block — surfaces STT/audio setup issues
|
|
// so the user sees "STT provider: MISSING ..." instead of
|
|
// silently failing on every Ctrl+B press.
|
|
if (r.details) {
|
|
ctx.transcript.sys('')
|
|
ctx.transcript.sys(' Requirements:')
|
|
|
|
for (const line of r.details.split('\n')) {
|
|
if (line.trim()) {
|
|
ctx.transcript.sys(` ${line}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
if (action === 'tts') {
|
|
ctx.transcript.sys(`Voice TTS ${r.tts ? 'enabled' : 'disabled'}.`)
|
|
|
|
return
|
|
}
|
|
|
|
// on/off — mirror cli.py:_enable_voice_mode's 3-line output
|
|
if (r.enabled) {
|
|
const tts = r.tts ? ' (TTS enabled)' : ''
|
|
ctx.transcript.sys(`Voice mode enabled${tts}`)
|
|
ctx.transcript.sys(' Ctrl+B to start/stop recording')
|
|
ctx.transcript.sys(' /voice tts to toggle speech output')
|
|
ctx.transcript.sys(' /voice off to disable voice mode')
|
|
} else {
|
|
ctx.transcript.sys('Voice mode disabled.')
|
|
}
|
|
})
|
|
)
|
|
}
|
|
},
|
|
|
|
{
|
|
help: 'switch theme skin (fires skin.changed)',
|
|
name: 'skin',
|
|
run: (arg, ctx) => {
|
|
if (!arg) {
|
|
return ctx.gateway
|
|
.rpc<ConfigGetValueResponse>('config.get', { key: 'skin' })
|
|
.then(ctx.guarded<ConfigGetValueResponse>(r => ctx.transcript.sys(`skin: ${r.value || 'default'}`)))
|
|
}
|
|
|
|
ctx.gateway
|
|
.rpc<ConfigSetResponse>('config.set', { key: 'skin', value: arg })
|
|
.then(ctx.guarded<ConfigSetResponse>(r => r.value && ctx.transcript.sys(`skin → ${r.value}`)))
|
|
}
|
|
},
|
|
|
|
{
|
|
help: 'pick the busy indicator: kaomoji (default), emoji, unicode (braille), or ascii',
|
|
name: 'indicator',
|
|
usage: `/indicator [${INDICATOR_STYLES.join('|')}]`,
|
|
run: (arg, ctx) => {
|
|
const value = arg.trim().toLowerCase()
|
|
|
|
if (!value) {
|
|
return ctx.gateway
|
|
.rpc<ConfigGetValueResponse>('config.get', { key: 'indicator' })
|
|
.then(
|
|
ctx.guarded<ConfigGetValueResponse>(r =>
|
|
ctx.transcript.sys(`indicator: ${r.value || DEFAULT_INDICATOR_STYLE}`)
|
|
)
|
|
)
|
|
}
|
|
|
|
if (!(INDICATOR_STYLES as readonly string[]).includes(value)) {
|
|
return ctx.transcript.sys(`usage: /indicator [${INDICATOR_STYLES.join('|')}]`)
|
|
}
|
|
|
|
ctx.gateway
|
|
.rpc<ConfigSetResponse>('config.set', { key: 'indicator', value })
|
|
.then(
|
|
ctx.guarded<ConfigSetResponse>(r => {
|
|
if (!r.value) {
|
|
return
|
|
}
|
|
|
|
// Hot-swap the running TUI immediately so the next render
|
|
// uses the new style without waiting for the 5s mtime poll
|
|
// to re-apply config.full.
|
|
patchUiState({ indicatorStyle: value as IndicatorStyle })
|
|
ctx.transcript.sys(`indicator → ${r.value}`)
|
|
})
|
|
)
|
|
}
|
|
},
|
|
|
|
{
|
|
help: 'toggle yolo mode (per-session approvals)',
|
|
name: 'yolo',
|
|
run: (_arg, ctx) => {
|
|
ctx.gateway
|
|
.rpc<ConfigSetResponse>('config.set', { key: 'yolo', session_id: ctx.sid })
|
|
.then(ctx.guarded<ConfigSetResponse>(r => ctx.transcript.sys(`yolo ${r.value === '1' ? 'on' : 'off'}`)))
|
|
}
|
|
},
|
|
|
|
{
|
|
help: 'inspect or set reasoning effort (updates live agent)',
|
|
name: 'reasoning',
|
|
run: (arg, ctx) => {
|
|
if (!arg) {
|
|
return ctx.gateway
|
|
.rpc<ConfigGetValueResponse>('config.get', { key: 'reasoning' })
|
|
.then(
|
|
ctx.guarded<ConfigGetValueResponse>(
|
|
r => r.value && ctx.transcript.sys(`reasoning: ${r.value} · display ${r.display || 'hide'}`)
|
|
)
|
|
)
|
|
}
|
|
|
|
ctx.gateway
|
|
.rpc<ConfigSetResponse>('config.set', { key: 'reasoning', session_id: ctx.sid, value: arg })
|
|
.then(ctx.guarded<ConfigSetResponse>(r => r.value && ctx.transcript.sys(`reasoning: ${r.value}`)))
|
|
}
|
|
},
|
|
|
|
{
|
|
help: 'toggle fast mode [normal|fast|status|on|off|toggle]',
|
|
name: 'fast',
|
|
run: (arg, ctx) => {
|
|
const mode = arg.trim().toLowerCase()
|
|
const valid = new Set(['', 'status', 'normal', 'fast', 'on', 'off', 'toggle'])
|
|
|
|
if (!valid.has(mode)) {
|
|
return ctx.transcript.sys('usage: /fast [normal|fast|status|on|off|toggle]')
|
|
}
|
|
|
|
if (!mode || mode === 'status') {
|
|
return ctx.gateway
|
|
.rpc<ConfigGetValueResponse>('config.get', { key: 'fast', session_id: ctx.sid })
|
|
.then(
|
|
ctx.guarded<ConfigGetValueResponse>(r =>
|
|
ctx.transcript.sys(`fast mode: ${r.value === 'fast' ? 'fast' : 'normal'}`)
|
|
)
|
|
)
|
|
.catch(ctx.guardedErr)
|
|
}
|
|
|
|
ctx.gateway
|
|
.rpc<ConfigSetResponse>('config.set', { key: 'fast', session_id: ctx.sid, value: mode })
|
|
.then(
|
|
ctx.guarded<ConfigSetResponse>(r => {
|
|
const next = r.value === 'fast' ? 'fast' : 'normal'
|
|
ctx.transcript.sys(`fast mode: ${next}`)
|
|
patchUiState(state => ({
|
|
...state,
|
|
info: state.info
|
|
? {
|
|
...state.info,
|
|
fast: next === 'fast',
|
|
service_tier: next === 'fast' ? 'priority' : ''
|
|
}
|
|
: state.info
|
|
}))
|
|
})
|
|
)
|
|
.catch(ctx.guardedErr)
|
|
}
|
|
},
|
|
|
|
{
|
|
help: 'control busy enter mode [queue|steer|interrupt|status]',
|
|
name: 'busy',
|
|
run: (arg, ctx) => {
|
|
const mode = arg.trim().toLowerCase()
|
|
const valid = new Set(['', 'status', 'queue', 'steer', 'interrupt'])
|
|
|
|
if (!valid.has(mode)) {
|
|
return ctx.transcript.sys('usage: /busy [queue|steer|interrupt|status]')
|
|
}
|
|
|
|
if (!mode || mode === 'status') {
|
|
return ctx.gateway
|
|
.rpc<ConfigGetValueResponse>('config.get', { key: 'busy' })
|
|
.then(
|
|
ctx.guarded<ConfigGetValueResponse>(r => {
|
|
const current = r.value || 'interrupt'
|
|
ctx.transcript.sys(`busy input mode: ${current}`)
|
|
})
|
|
)
|
|
.catch(ctx.guardedErr)
|
|
}
|
|
|
|
ctx.gateway
|
|
.rpc<ConfigSetResponse>('config.set', { key: 'busy', value: mode })
|
|
.then(
|
|
ctx.guarded<ConfigSetResponse>(r => {
|
|
const next = r.value || mode
|
|
ctx.transcript.sys(`busy input mode: ${next}`)
|
|
})
|
|
)
|
|
.catch(ctx.guardedErr)
|
|
}
|
|
},
|
|
|
|
{
|
|
help: 'cycle verbose tool-output mode (updates live agent)',
|
|
name: 'verbose',
|
|
run: (arg, ctx) => {
|
|
ctx.gateway
|
|
.rpc<ConfigSetResponse>('config.set', { key: 'verbose', session_id: ctx.sid, value: arg || 'cycle' })
|
|
.then(ctx.guarded<ConfigSetResponse>(r => r.value && ctx.transcript.sys(`verbose: ${r.value}`)))
|
|
}
|
|
},
|
|
|
|
{
|
|
help: 'session usage (live counts — worker sees zeros)',
|
|
name: 'usage',
|
|
run: (_arg, ctx) => {
|
|
ctx.gateway.rpc<SessionUsageResponse>('session.usage', { session_id: ctx.sid }).then(r => {
|
|
if (ctx.stale()) {
|
|
return
|
|
}
|
|
|
|
if (r) {
|
|
patchUiState({
|
|
usage: { calls: r.calls ?? 0, input: r.input ?? 0, output: r.output ?? 0, total: r.total ?? 0 }
|
|
})
|
|
}
|
|
|
|
if (!r?.calls) {
|
|
return ctx.transcript.sys('no API calls yet')
|
|
}
|
|
|
|
const f = (v: number | undefined) => (v ?? 0).toLocaleString()
|
|
const cost = r.cost_usd != null ? `${r.cost_status === 'estimated' ? '~' : ''}$${r.cost_usd.toFixed(4)}` : null
|
|
|
|
const rows: [string, string][] = [
|
|
['Model', r.model ?? ''],
|
|
['Input tokens', f(r.input)],
|
|
['Cache read tokens', f(r.cache_read)],
|
|
['Cache write tokens', f(r.cache_write)],
|
|
['Output tokens', f(r.output)],
|
|
['Total tokens', f(r.total)],
|
|
['API calls', f(r.calls)]
|
|
]
|
|
|
|
if (cost) {
|
|
rows.push(['Cost', cost])
|
|
}
|
|
|
|
const sections: PanelSection[] = [{ rows }]
|
|
|
|
if (r.context_max) {
|
|
sections.push({ text: `Context: ${f(r.context_used)} / ${f(r.context_max)} (${r.context_percent}%)` })
|
|
}
|
|
|
|
if (r.compressions) {
|
|
sections.push({ text: `Compressions: ${r.compressions}` })
|
|
}
|
|
|
|
ctx.transcript.panel('Usage', sections)
|
|
})
|
|
}
|
|
}
|
|
]
|