hermes-agent/ui-tui/src/app/interfaces.ts
Brooklyn Nicholson 76b58d6127 feat(ui-tui): ambient widget mode — registry-driven slash catalog + in-flow dock
Widgets can render as ambient (glanceable, non-blocking) instead of modal,
docked in the normal layout flow above/below the status bar rather than taking
over the screen. The slash catalog is generated from the widget registry so new
apps surface automatically, and /ticker lands as the first live-animation
ambient demo.
2026-07-21 20:59:53 -05:00

619 lines
21 KiB
TypeScript

import type { MouseTrackingMode, ScrollBoxHandle } from '@hermes/ink'
import type { MutableRefObject, ReactNode, RefObject, SetStateAction } from 'react'
import type { PasteEvent } from '../components/textInput.js'
import type { GatewayClient } from '../gatewayClient.js'
import type {
BillingCardInfo,
BillingMutationResponse,
BillingStateResponse,
ImageAttachResponse,
SessionCloseResponse,
SubscriptionPreviewResponse,
SubscriptionStateResponse,
SubscriptionUpgradeResponse
} from '../gatewayTypes.js'
import type { ParsedVoiceRecordKey } from '../lib/platform.js'
import type { RpcResult } from '../lib/rpc.js'
import type { ActiveWidget } from '../sdk/types.js'
import type { Theme } from '../theme.js'
import type {
ApprovalReq,
ClarifyReq,
ConfirmReq,
DetailsMode,
Msg,
PanelSection,
SecretReq,
SectionVisibility,
SessionInfo,
SlashCatalog,
SudoReq,
Usage
} from '../types.js'
export interface StateSetter<T> {
(value: SetStateAction<T>): void
}
export type StatusBarMode = 'bottom' | 'off' | 'top'
export type BatteryCategory = 'bad' | 'critical' | 'dim' | 'good' | 'warn'
// A single battery reading pushed from the Python gateway (`system.battery`).
// `available` is false on machines without a battery; `percent` is 0-100.
export interface BatteryInfo {
available: boolean
category: BatteryCategory
percent: null | number
plugged: null | boolean
}
export type BusyInputMode = 'interrupt' | 'queue' | 'steer'
export type NoticeLevel = 'error' | 'info' | 'success' | 'warn'
// Credits/usage notice surfaced in the status bar. Shape is snake_case to
// match the gateway WS wire (`notification.show` payload) and the existing
// `Usage` type — no camelCase mapping layer. The `text` already carries its
// own leading glyph (⚠ • ✕ ✓) from the Python policy, so the renderer only
// colours it by `level` and never adds another glyph.
export interface Notice {
id?: string
key?: string
kind?: 'sticky' | 'ttl'
level?: NoticeLevel
text: string
ttl_ms?: null | number
}
// Single source of truth for indicator style names. Union type is
// derived from this tuple so adding/removing a style only touches one
// line — `useConfigSync` (validation) and `session.ts` (slash arg
// validation + usage hint) both import it.
export const INDICATOR_STYLES = ['ascii', 'emoji', 'kaomoji', 'unicode'] as const
export type IndicatorStyle = (typeof INDICATOR_STYLES)[number]
export const DEFAULT_INDICATOR_STYLE: IndicatorStyle = 'kaomoji'
export interface SelectionApi {
captureScrolledRows: (firstRow: number, lastRow: number, side: 'above' | 'below') => void
clearSelection: () => void
copySelection: () => Promise<string>
copySelectionNoClear: () => Promise<string>
getState: () => unknown
version: () => number
shiftAnchor: (dRow: number, minRow: number, maxRow: number) => void
shiftSelection: (dRow: number, minRow: number, maxRow: number) => void
}
export interface CompletionItem {
display: string
meta?: string
text: string
}
export interface GatewayRpc {
<T extends RpcResult = RpcResult>(method: string, params?: Record<string, unknown>): Promise<null | T>
}
export interface GatewayServices {
gw: GatewayClient
rpc: GatewayRpc
}
export interface GatewayProviderProps {
children: ReactNode
value: GatewayServices
}
// ── Billing overlay (Phase 2b: full-modal TUI parity) ────────────────
// The /billing command no longer parses sub-commands; bare `/billing`
// fetches `billing.state` and opens this overlay. The overlay is a small
// state machine (overview → buy|autoreload|limit → confirm) that performs
// the SAME RPCs as the old slash flows (billing.charge / charge_status /
// auto_reload / step_up). Backend is unchanged & shared with the CLI.
export type BillingScreen = 'autoreload' | 'buy' | 'confirm' | 'limit' | 'overview' | 'stepup'
/** Outcome of a charge attempt — lets the overlay route without tearing down. */
export type BillingChargeOutcome =
| 'submitted' // 202 accepted; settlement is reported via transcript lines
| 'needs_remote_spending' // insufficient_scope → route to the stepup screen
| 'error' // any other failure (already surfaced via sys)
/**
* The functions the overlay needs to talk to the gateway and emit
* transcript lines. Built once in `billing.ts` (closing over the live
* SlashRunCtx) and stashed in the overlay slot, mirroring how a ConfirmReq
* stashes its `onConfirm` closure. Keeps all RPC + error-mapping logic in
* billing.ts (single source of truth) — the overlay only renders + routes.
*/
export interface BillingOverlayCtx {
/** Run `billing.auto_reload` (enabled/threshold/top_up) → resolve ok/false. */
applyAutoReload: (enabled: boolean, threshold?: number, topUp?: number) => Promise<boolean>
/**
* Submit `billing.charge` for `amount` and poll to settlement. Resolves a
* discriminated outcome so the overlay can route to the resumable step-up on
* `needs_remote_spending` instead of tearing down. Settlement/most errors are
* still reported via transcript lines (the poll is non-blocking).
*/
charge: (amount: string, idempotencyKey?: string) => Promise<BillingChargeOutcome>
/**
* Run the `billing.step_up` device flow (allow Remote Spending). Resolves
* `true` when the grant lands. The browser opens via the gateway's
* out-of-band `billing.step_up.verification` event — the overlay just awaits.
*/
requestRemoteSpending: () => Promise<boolean>
/** Open the portal in the browser + echo a transcript line. */
openPortal: (url: string) => void
/**
* Re-fetch billing state (`billing.state`) — used by the add-card path's
* "I've added it — check again" so a card saved on the portal appears without
* re-running /topup. Resolves null on failure (caller keeps the old state).
*/
refreshState: () => Promise<BillingStateResponse | null>
/** Emit a transcript system line. */
sys: (text: string) => void
/** Validate a custom amount against state bounds + 2dp (mirrors the server). */
validate: (raw: string) => { amount?: string; error?: string }
}
/** Pending confirm built when leaving the buy/autoreload screen. */
export interface BillingPendingCharge {
amount: string
/**
* Stable idempotency key for THIS purchase, minted when the amount is chosen.
* Reused across the step-up replay so a re-charge after the grant dedups
* server-side (and a double-submit collapses to one charge).
*/
idempotencyKey?: string
}
export interface BillingOverlayState {
ctx: BillingOverlayCtx
/** Set when on the 'confirm' screen for a buy. */
pendingCharge?: BillingPendingCharge | null
screen: BillingScreen
state: BillingStateResponse
}
// ── Subscription overlay (in-terminal plan change, V3) ──
// A small state machine: overview → picker → confirm → result, with a stepup
// screen spliced in on demand.
// overview — plan + status, entry to the picker / resume / manage-on-portal.
// picker — the tier catalog (up/down direction hints; current tier shown,
// not selectable).
// confirm — the previewed effect of the chosen change (charge $X now /
// scheduled at date / no-op / blocked) + the apply action.
// result — the outcome, including an SCA/decline upgrade handed off to the
// portal.
// stepup — reached when a mutation returns insufficient_scope: allows remote
// spending in place, then auto-replays the held action.
export type SubscriptionScreen = 'confirm' | 'overview' | 'picker' | 'result' | 'stepup'
// The action held while the stepup screen allows remote spending, replayed after
// approval: re-preview a tier, re-apply the confirmed pending change, or re-resume.
export type SubscriptionStepUpRetry = { kind: 'apply' } | { kind: 'preview'; tierId: string } | { kind: 'resume' }
/** Outcome of a remote-spending step-up: granted, plus the typed denial (for copy). */
export interface StepUpResult {
granted: boolean
error?: string
message?: string
}
export interface SubscriptionOverlayCtx {
/**
* Best-effort card lookup (`billing.state`) for the upgrade confirm — shows
* WHICH card the upgrade will charge. Resolves null on any failure or when
* the server doesn't say (older NAS): the confirm keeps its generic line.
*/
fetchCard: () => Promise<BillingCardInfo | null>
/**
* Build {portal}/manage-subscription?org_id=… locally and open it. Resolves
* ok/false. Pass `tierId` to deep-link a specific plan via `?plan=`.
*/
openManageLink: (tierId?: string) => Promise<boolean>
/** Open an arbitrary portal recovery URL (e.g. an upgrade's SCA handoff). */
openPortal: (url: string) => void
/** Re-fetch subscription.state. */
refreshState: () => Promise<SubscriptionStateResponse | null>
/** POST /preview a change to `tierId` → the chargeless effect quote (or typed error). */
preview: (tierId: string) => Promise<SubscriptionPreviewResponse | null>
/** PUT pending-change: schedule a downgrade / same-price change to `tierId`. */
scheduleChange: (tierId: string) => Promise<BillingMutationResponse | null>
/** PUT pending-change: schedule a cancellation at period end. */
scheduleCancellation: () => Promise<BillingMutationResponse | null>
/** DELETE pending-change: clear a scheduled downgrade / cancellation (resume). */
resume: () => Promise<BillingMutationResponse | null>
/** POST /upgrade: charge the card on the subscription + flip the plan now. */
upgrade: (tierId: string, idempotencyKey?: string) => Promise<SubscriptionUpgradeResponse | null>
/**
* Run the `billing.step_up` device flow (allow remote spending / "Remote
* Spending"). Resolves `{granted}` plus the typed denial (`error`/`message`) so
* the stepup screen shows the right recovery. The browser opens via the
* gateway's out-of-band verification event — the stepup screen just awaits.
*/
requestRemoteSpending: () => Promise<StepUpResult>
/** Emit a transcript system line. */
sys: (text: string) => void
}
/** What the confirm screen is about to apply, plus its preview quote. */
export interface SubscriptionPendingChange {
/** The target tier (null for a cancellation). */
targetTierId: string | null
/** How it will be applied — drives which ctx call confirm makes. */
kind: 'cancellation' | 'tier_change' | 'upgrade'
/** The preview quote shown on confirm (null = the quote call failed). */
preview?: null | SubscriptionPreviewResponse
/**
* Stable idempotency key for an upgrade charge, minted when confirm opens.
* Reused on retry so a re-submit dedups server-side.
*/
idempotencyKey?: string
}
/** The outcome rendered on the result screen. */
export interface SubscriptionResult {
message: string
ok: boolean
/** Set on a successful upgrade; drives the ResultScreen apply-poll. */
pendingTierId?: null | string
/** A portal URL to finish an SCA/declined upgrade, when present. */
recoveryUrl?: null | string
}
export interface SubscriptionOverlayState {
ctx: SubscriptionOverlayCtx
/** Set on the 'confirm' screen: the change being confirmed + its preview. */
pending?: null | SubscriptionPendingChange
/** Set on the 'result' screen: the outcome to render. */
result?: null | SubscriptionResult
screen: SubscriptionScreen
state: SubscriptionStateResponse
/** Held while on the 'stepup' screen: the action to replay once the grant lands. */
stepUpRetry?: null | SubscriptionStepUpRetry
}
export interface OverlayState {
agents: boolean
agentsInitialHistoryIndex: number
approval: ApprovalReq | null
billing: BillingOverlayState | null
clarify: ClarifyReq | null
confirm: ConfirmReq | null
/** Ambient widget apps — glanceable dock, non-blocking (never in $isBlocked). */
ambient: ActiveWidget[]
/** Modal widget app — owns input, blocks the composer. */
widget: ActiveWidget | null
journey: boolean
modelPicker: boolean | { refresh?: boolean }
pager: null | PagerState
petPicker: boolean
pluginsHub: boolean
secret: null | SecretReq
sessions: boolean
skillsHub: boolean
subscription: SubscriptionOverlayState | null
sudo: null | SudoReq
}
export interface PagerState {
lines: string[]
offset: number
title?: string
}
export interface TranscriptRow {
index: number
key: string
msg: Msg
}
export interface UiState {
battery: boolean
batteryStatus: BatteryInfo | null
bgTasks: Set<string>
busy: boolean
busyInputMode: BusyInputMode
compact: boolean
detailsMode: DetailsMode
detailsModeCommandOverride: boolean
info: null | SessionInfo
liveSessionCount: number
inlineDiffs: boolean
mouseTracking: MouseTrackingMode
notice: Notice | null
pasteCollapseLines: number
pasteCollapseChars: number
sections: SectionVisibility
sessionTitle: string
showReasoning: boolean
indicatorStyle: IndicatorStyle
sid: null | string
status: string
statusBar: StatusBarMode
streaming: boolean
theme: Theme
usage: Usage
}
export interface VirtualHistoryState {
bottomSpacer: number
end: number
measureRef: (key: string) => (el: unknown) => void
offsets: ArrayLike<number>
start: number
topSpacer: number
}
export interface ComposerPasteResult {
cursor: number
value: string
}
export type MaybePromise<T> = Promise<T> | T
export interface ComposerActions {
clearIn: () => void
dequeue: () => string | undefined
enqueue: (text: string) => void
handleTextPaste: (event: PasteEvent) => MaybePromise<ComposerPasteResult | null>
openEditor: () => Promise<void>
pushHistory: (text: string) => void
removeQueue: (index: number) => void
replaceQueue: (index: number, text: string) => void
setCompIdx: StateSetter<number>
setHistoryIdx: StateSetter<null | number>
setInput: StateSetter<string>
setInputBuf: StateSetter<string[]>
setPasteSnips: StateSetter<PasteSnippet[]>
setQueueEdit: (index: null | number) => void
syncQueue: () => void
}
export interface ComposerRefs {
historyDraftRef: MutableRefObject<string>
historyRef: MutableRefObject<string[]>
queueEditRef: MutableRefObject<null | number>
queueRef: MutableRefObject<string[]>
submitRef: MutableRefObject<(value: string) => void>
}
export interface ComposerState {
compIdx: number
compReplace: number
completions: CompletionItem[]
historyIdx: null | number
input: string
inputBuf: string[]
pasteSnips: PasteSnippet[]
queueEditIdx: null | number
queuedDisplay: string[]
}
export interface UseComposerStateOptions {
gw: GatewayClient
onClipboardPaste: (quiet?: boolean) => Promise<void> | void
onImageAttached?: (info: ImageAttachResponse) => void
submitRef: MutableRefObject<(value: string) => void>
}
export interface UseComposerStateResult {
actions: ComposerActions
refs: ComposerRefs
state: ComposerState
}
export interface InputHandlerActions {
answerClarify: (answer: string) => void
appendMessage: (msg: Msg) => void
die: () => void
dispatchSubmission: (full: string) => void
guardBusySessionSwitch: (what?: string) => boolean
newSession: (msg?: string, title?: string) => void
sys: (text: string) => void
}
export interface InputHandlerContext {
actions: InputHandlerActions
composer: {
actions: ComposerActions
refs: ComposerRefs
state: ComposerState
}
gateway: GatewayServices
terminal: {
hasSelection: boolean
scrollRef: RefObject<null | ScrollBoxHandle>
scrollWithSelection: (delta: number) => void
selection: SelectionApi
stdout?: NodeJS.WriteStream
}
voice: {
enabled: boolean
recordKey: ParsedVoiceRecordKey
recording: boolean
setProcessing: StateSetter<boolean>
setRecording: StateSetter<boolean>
setVoiceEnabled: StateSetter<boolean>
setVoiceTts: StateSetter<boolean>
}
wheelStep: number
}
export interface InputHandlerResult {
pagerPageSize: number
}
export interface GatewayEventHandlerContext {
composer: {
setInput: StateSetter<string>
}
gateway: GatewayServices
session: {
STARTUP_RESUME_ID: string
colsRef: MutableRefObject<number>
newSession: (msg?: string, title?: string) => void
// Set by useMainApp's exit handler to the session that was live when the
// gateway died unexpectedly; consumed once by the next `gateway.ready` so a
// respawn resumes that session instead of forging a fresh one.
recoverSidRef?: MutableRefObject<null | string>
resetSession: () => void
resumeById: (id: string) => void
setCatalog: StateSetter<null | SlashCatalog>
}
submission: {
submitRef: MutableRefObject<(value: string) => void>
}
system: {
bellOnComplete: boolean
stdout?: NodeJS.WriteStream
sys: (text: string) => void
}
transcript: {
appendMessage: (msg: Msg) => void
panel: (title: string, sections: PanelSection[]) => void
setHistoryItems: StateSetter<Msg[]>
}
voice: {
setProcessing: StateSetter<boolean>
setRecording: StateSetter<boolean>
setVoiceEnabled: StateSetter<boolean>
setVoiceTts: StateSetter<boolean>
}
}
export interface SlashHandlerContext {
composer: {
enqueue: (text: string) => void
hasSelection: boolean
openEditor: () => Promise<void>
paste: (quiet?: boolean) => void
queueRef: MutableRefObject<string[]>
selection: SelectionApi
setInput: StateSetter<string>
}
gateway: GatewayServices
local: {
catalog: null | SlashCatalog
getHistoryItems: () => Msg[]
getLastUserMsg: () => string
maybeWarn: (value: unknown) => void
setCatalog: StateSetter<null | SlashCatalog>
}
session: {
closeSession: (targetSid?: null | string) => Promise<unknown>
die: () => void
dieWithCode: (code: number) => void
guardBusySessionSwitch: (what?: string) => boolean
newLiveSession: (msg?: string, title?: string) => void
newSession: (msg?: string, title?: string) => void
resetVisibleHistory: (info?: null | SessionInfo) => void
resumeById: (id: string) => void
setSessionStartedAt: StateSetter<number>
}
slashFlightRef: MutableRefObject<number>
transcript: {
page: (text: string, title?: string) => void
panel: (title: string, sections: PanelSection[]) => void
send: (text: string) => void
setHistoryItems: StateSetter<Msg[]>
sys: (text: string) => void
trimLastExchange: (items: Msg[]) => Msg[]
}
voice: {
setVoiceEnabled: StateSetter<boolean>
setVoiceRecordKey: (v: ParsedVoiceRecordKey) => void
setVoiceTts: StateSetter<boolean>
}
}
export interface AppLayoutActions {
answerApproval: (choice: string) => void
answerClarify: (answer: string) => void
answerSecret: (value: string) => void
answerSudo: (pw: string) => void
clearSelection: () => void
activateLiveSession: (id: string) => void
closeLiveSession: (id: string) => Promise<null | SessionCloseResponse>
newLiveSession: () => void
newPromptSession: (prompt: string, modelArg?: string) => void
onModelSelect: (value: string) => void
resumeById: (id: string) => void
setStickyPrompt: (value: string) => void
}
export interface AppLayoutComposerProps {
cols: number
compIdx: number
completions: CompletionItem[]
empty: boolean
handleTextPaste: (event: PasteEvent) => MaybePromise<ComposerPasteResult | null>
input: string
inputBuf: string[]
pagerPageSize: number
queueEditIdx: null | number
queuedDisplay: string[]
submit: (value: string) => void
updateInput: StateSetter<string>
voiceRecordKey: ParsedVoiceRecordKey
}
export interface AppLayoutProgressProps {
showProgressArea: boolean
}
export interface AppLayoutStatusProps {
cwdLabel: string
goodVibesTick: number
lastTurnEndedAt: null | number
sessionStartedAt: null | number
showStickyPrompt: boolean
statusColor: string
stickyPrompt: string
turnStartedAt: null | number
voiceLabel: string
}
export interface AppLayoutTranscriptProps {
historyItems: Msg[]
scrollRef: RefObject<null | ScrollBoxHandle>
virtualHistory: VirtualHistoryState
virtualRows: TranscriptRow[]
}
export interface AppLayoutProps {
actions: AppLayoutActions
composer: AppLayoutComposerProps
mouseTracking: MouseTrackingMode
progress: AppLayoutProgressProps
status: AppLayoutStatusProps
transcript: AppLayoutTranscriptProps
}
export interface AppOverlaysProps {
cols: number
compIdx: number
completions: CompletionItem[]
onApprovalChoice: (choice: string) => void
onClarifyAnswer: (value: string) => void
onActiveSessionSelect: (sessionId: string) => void
onActiveSessionClose: (sessionId: string) => Promise<null | SessionCloseResponse>
onModelSelect: (value: string) => void
onNewLiveSession: () => void
onNewPromptSession: (prompt: string, modelArg?: string) => void
onResumeSelect: (sessionId: string) => void
onSecretSubmit: (value: string) => void
onSudoSubmit: (pw: string) => void
pagerPageSize: number
}
export interface PasteSnippet {
label: string
path?: string
text: string
}