Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+186
View File
@@ -0,0 +1,186 @@
import { parseSlashCommand } from '../domain/slash.js'
import type { SlashExecResponse } from '../gatewayTypes.js'
import { asCommandDispatch, rpcErrorMessage } from '../lib/rpc.js'
import { launchWidget } from '../sdk/host.js'
import { getWidgetApp } from '../sdk/registry.js'
import type { SlashHandlerContext } from './interfaces.js'
import { scoreSlashMenuItem } from './slash/fuzzyScore.js'
import { findSlashCommand } from './slash/registry.js'
import type { SlashRunCtx } from './slash/types.js'
import { getUiState } from './uiStore.js'
export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => boolean {
const { gw } = ctx.gateway
const { catalog } = ctx.local
const { page, send, sys } = ctx.transcript
const handler = (cmd: string): boolean => {
const flight = ++ctx.slashFlightRef.current
const ui = getUiState()
const sid = ui.sid
const parsed = parseSlashCommand(cmd)
const argTail = parsed.arg ? ` ${parsed.arg}` : ''
const stale = () => flight !== ctx.slashFlightRef.current || getUiState().sid !== sid
const guarded =
<T>(fn: (r: T) => void) =>
(r: null | T): void => {
if (!stale() && r) {
fn(r)
}
}
const guardedErr = (e: unknown) => {
if (!stale()) {
sys(`error: ${rpcErrorMessage(e)}`)
}
}
const runCtx: SlashRunCtx = { ...ctx, flight, guarded, guardedErr, sid, stale, ui }
const found = findSlashCommand(parsed.name)
if (found) {
found.run(parsed.arg, runCtx, cmd)
return true
}
// Registry-first fallback: widget apps registered AFTER the static
// command table was built (user widgets from $HERMES_HOME/tui-widgets,
// /widgets-reload) dispatch straight off the live registry.
if (getWidgetApp(parsed.name)) {
const err = launchWidget(parsed.name, parsed.arg)
if (err) {
sys(err)
}
return true
}
if (catalog?.canon) {
const needle = `/${parsed.name}`.toLowerCase()
const exact = Object.entries(catalog.canon).find(([alias]) => alias.toLowerCase() === needle)?.[1]
if (exact) {
if (exact.toLowerCase() !== needle) {
return handler(`${exact}${argTail}`)
}
} else {
// Tiered name scoring (ported from grok-cli's slash menu): prefix
// matches rank above substring matches, so `/hea` still resolves to
// /heartbeat while `/beat` now finds it too instead of dead-ending.
// Only the best tier survives — a substring hit never widens an
// unambiguous prefix hit into an "ambiguous command" complaint.
// Description tiers (score >= 3) are a completion-menu concern and
// never auto-execute a command here.
const scored = Object.entries(catalog.canon)
.map(([alias, canon]) => ({ canon, score: scoreSlashMenuItem({ id: alias.slice(1) }, needle.slice(1)) }))
.filter(entry => entry.score < 3)
const best = Math.min(...scored.map(entry => entry.score))
const matches = [...new Set(scored.filter(entry => entry.score === best).map(entry => entry.canon))]
if (matches.length === 1 && matches[0]!.toLowerCase() !== needle) {
return handler(`${matches[0]}${argTail}`)
}
if (matches.length > 1) {
sys(`ambiguous command: ${matches.slice(0, 6).join(', ')}${matches.length > 6 ? ', …' : ''}`)
return true
}
}
}
const handleDispatch = (raw: unknown): void => {
const d = asCommandDispatch(raw)
if (!d) {
return sys('error: invalid response: command.dispatch')
}
if (d.type === 'exec' || d.type === 'plugin') {
return sys(d.output || '(no output)')
}
if (d.type === 'alias') {
return void handler(`/${d.target}${argTail}`)
}
// A skill/bundle dispatch's `message` is the expanded skill body —
// model-facing scaffolding. `display` is the invocation the gateway
// projected; the transcript shows that instead. An ordinary send has no
// projection and goes through unchanged. No client-side fallback here:
// the TUI spawns its gateway from this same checkout, so the two can't
// version-skew (unlike the desktop, which can meet an older backend).
const sendDispatch = (display: string | undefined, message: string) => {
const shown = display?.trim()
return shown ? send(message, true, shown) : send(message)
}
if (d.type === 'skill') {
return d.message?.trim()
? sendDispatch(d.display, d.message)
: sys(`/${parsed.name}: skill payload missing message`)
}
if (d.type === 'send') {
if (d.notice?.trim()) {
sys(d.notice)
}
return d.message?.trim() ? sendDispatch(d.display, d.message) : sys(`/${parsed.name}: empty message`)
}
if (d.type === 'prefill') {
// /undo returns prefill: drop the backed-up message text into
// the composer so the user can edit and resubmit, instead of
// submitting it immediately like 'send'.
if (d.notice?.trim()) {
sys(d.notice)
}
if (d.message) {
ctx.composer.setInput(d.message)
}
}
}
gw.request<SlashExecResponse>('slash.exec', { command: cmd.slice(1), session_id: sid })
.then(r => {
if (stale()) {
return
}
if (asCommandDispatch(r)) {
return handleDispatch(r)
}
const body = r?.output || `/${parsed.name}: no output`
const text = r?.warning ? `warning: ${r.warning}\n${body}` : body
const long = text.length > 180 || text.split('\n').filter(Boolean).length > 2
long ? page(text, parsed.name[0]!.toUpperCase() + parsed.name.slice(1)) : sys(text)
})
.catch(() => {
gw.request('command.dispatch', { arg: parsed.arg, name: parsed.name, session_id: sid })
.then((raw: unknown) => {
if (stale()) {
return
}
handleDispatch(raw)
})
.catch(guardedErr)
})
return true
}
return handler
}
+77
View File
@@ -0,0 +1,77 @@
import { atom } from 'nanostores'
import type { DelegationStatusResponse } from '../gatewayTypes.js'
export interface DelegationState {
// Last known caps from `delegation.status` RPC. null until fetched.
maxConcurrentChildren: null | number
maxSpawnDepth: null | number
// True when spawning is globally paused (see tools/delegate_tool.py).
paused: boolean
// Monotonic clock of the last successful status fetch.
updatedAt: null | number
}
const buildState = (): DelegationState => ({
maxConcurrentChildren: null,
maxSpawnDepth: null,
paused: false,
updatedAt: null
})
export const $delegationState = atom<DelegationState>(buildState())
export const getDelegationState = () => $delegationState.get()
export const patchDelegationState = (next: Partial<DelegationState>) =>
$delegationState.set({ ...$delegationState.get(), ...next })
export const resetDelegationState = () => $delegationState.set(buildState())
// ── Overlay accordion open-state ──────────────────────────────────────
//
// Lifted out of OverlaySection's local useState so collapse choices
// survive:
// - navigating to a different subagent (Detail remounts)
// - switching list ↔ detail mode (Detail unmounts in list mode)
// - walking history (←/→)
// Keyed by section title; missing entries fall back to the section's
// `defaultOpen` prop.
export const $overlaySectionsOpen = atom<Record<string, boolean>>({})
export const toggleOverlaySection = (title: string, defaultOpen: boolean) => {
const state = $overlaySectionsOpen.get()
const current = title in state ? state[title]! : defaultOpen
$overlaySectionsOpen.set({ ...state, [title]: !current })
}
export const getOverlaySectionOpen = (title: string, defaultOpen: boolean): boolean => {
const state = $overlaySectionsOpen.get()
return title in state ? state[title]! : defaultOpen
}
/** Merge a raw RPC response into the store. Tolerant of partial/omitted fields. */
export const applyDelegationStatus = (r: DelegationStatusResponse | null | undefined) => {
if (!r) {
return
}
const patch: Partial<DelegationState> = { updatedAt: Date.now() }
if (typeof r.max_spawn_depth === 'number') {
patch.maxSpawnDepth = r.max_spawn_depth
}
if (typeof r.max_concurrent_children === 'number') {
patch.maxConcurrentChildren = r.max_concurrent_children
}
if (typeof r.paused === 'boolean') {
patch.paused = r.paused
}
patchDelegationState(patch)
}
+19
View File
@@ -0,0 +1,19 @@
import { createContext, useContext } from 'react'
import type { GatewayProviderProps, GatewayServices } from './interfaces.js'
const GatewayContext = createContext<GatewayServices | null>(null)
export function GatewayProvider({ children, value }: GatewayProviderProps) {
return <GatewayContext.Provider value={value}>{children}</GatewayContext.Provider>
}
export function useGateway() {
const value = useContext(GatewayContext)
if (!value) {
throw new Error('GatewayContext missing')
}
return value
}
+35
View File
@@ -0,0 +1,35 @@
// Crash-recovery budget for the gateway exit handler. A gateway that
// crash-loops on startup must not let the TUI spawn-storm, so respawn+resume
// attempts are capped to GATEWAY_RECOVERY_LIMIT within a sliding
// GATEWAY_RECOVERY_WINDOW_MS; past the budget the app falls back to the inert
// "gateway exited" state. Kept pure (no refs/UI) so the bound — including the
// crash-loop case — is unit-testable.
export const GATEWAY_RECOVERY_LIMIT = 3
export const GATEWAY_RECOVERY_WINDOW_MS = 60_000
export interface RecoveryPlan {
// Attempt timestamps to persist (the pruned window, plus `now` iff recovering).
attempts: number[]
recover: boolean
// Session to resume — the live sid, or the not-yet-consumed recovery target
// when the live sid was already cleared by a prior exit.
sid: null | string
}
// Decide whether to respawn+resume after a gateway death. `liveSid` is the
// current session (nulled on the first exit); `recoverSid` is a pending
// recovery target carried across a respawn that died before gateway.ready —
// so a startup crash-loop keeps retrying the same session up to the budget
// instead of stranding it after one attempt.
export function planGatewayRecovery(
liveSid: null | string,
recoverSid: null | string,
attempts: number[],
now: number
): RecoveryPlan {
const sid = liveSid ?? recoverSid
const recent = attempts.filter(t => now - t < GATEWAY_RECOVERY_WINDOW_MS)
const recover = Boolean(sid) && recent.length < GATEWAY_RECOVERY_LIMIT
return { attempts: recover ? [...recent, now] : recent, recover, sid }
}
+17
View File
@@ -0,0 +1,17 @@
import { atom } from 'nanostores'
export interface InputSelection {
clear: () => void
collapseToEnd: () => void
copy: () => void
cut: () => void
end: number
start: number
value: string
}
export const $inputSelection = atom<InputSelection | null>(null)
export const setInputSelection = (next: InputSelection | null) => $inputSelection.set(next)
export const getInputSelection = () => $inputSelection.get()
+657
View File
@@ -0,0 +1,657 @@
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,
SessionCloseResponse,
SubscriptionPreviewResponse,
SubscriptionStateResponse,
SubscriptionUpgradeResponse
} from '../gatewayTypes.js'
import type { QueueItem } from '../hooks/useQueue.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
/** Completion class from the gateway; `skill` is the only kind offered for
* an inline `/skill` reference typed mid-message. */
kind?: 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
// Context compaction in progress (idle/preflight/auto). Distinct from
// `compact`, which is the /compact layout-density flag.
compacting: boolean
destructiveSlashConfirm: boolean
detailsMode: DetailsMode
detailsModeCommandOverride: boolean
// Focus view (/focus) — display-only reduced-output mode. Drives the
// persistent `◉ focus` status-bar badge; never affects request payloads.
focusView: 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
// display.status_bar.fields — visibility filter for status-rule segments,
// shared with the classic CLI bar. null = user has not customized (show
// the default set).
statusBarFields: null | ReadonlySet<string>
streaming: boolean
theme: Theme
// `display.timestamps` — dim [HH:MM] labels on user/assistant transcript
// rows, the same config key the classic CLI honors (#41531).
timestamps: boolean
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 {
/** Pull an image off the system clipboard in as a token. */
attachClipboardImage: () => void
/** Attach an image by path in as a token. */
attachImagePath: (path: string) => void
clearIn: () => void
dequeue: () => string | undefined
enqueue: (text: string, display?: string) => void
handleTextPaste: (event: PasteEvent) => MaybePromise<ComposerPasteResult | null>
openEditor: () => Promise<void>
prependQueue: (item: QueueItem) => void
pushHistory: (text: string) => void
removeQueue: (index: number) => void
setCompIdx: StateSetter<number>
setComposerTokens: StateSetter<ComposerToken[]>
setHistoryIdx: StateSetter<null | number>
setInput: StateSetter<string>
setInputBuf: StateSetter<string[]>
setQueueEdit: (index: null | number) => void
takeQueue: (index: number, editedDisplay?: string) => QueueItem | undefined
/** Reconcile attached payloads against tokens still present in the text. */
syncTokens: (value: string) => void
}
export interface ComposerRefs {
historyDraftRef: MutableRefObject<string>
historyRef: MutableRefObject<string[]>
queueEditRef: MutableRefObject<null | number>
queueRef: MutableRefObject<QueueItem[]>
submitRef: MutableRefObject<(value: string) => void>
tokensRef: MutableRefObject<ComposerToken[]>
}
export interface ComposerState {
compIdx: number
compReplace: number
completions: CompletionItem[]
historyIdx: null | number
input: string
inputBuf: string[]
queueEditIdx: null | number
queuedDisplay: string[]
tokens: ComposerToken[]
}
export interface UseComposerStateOptions {
gw: GatewayClient
submitRef: MutableRefObject<(value: string) => void>
sys: (text: 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: {
/** Submit text literally as a prompt — no slash/!/interpolation dispatch.
* Used for `-q` startup queries, which are arbitrary launcher-provided
* text (parity with one-shot's literal prompt handling). */
submitLiteralRef: MutableRefObject<(value: string) => void>
submitRef: MutableRefObject<(value: string) => void>
}
system: {
bellOnComplete: boolean
bellOnPrompt?: 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: {
attachClipboardImage: () => void
attachImagePath: (path: string) => void
enqueue: (text: string, display?: string) => void
hasSelection: boolean
openEditor: () => Promise<void>
queueRef: MutableRefObject<QueueItem[]>
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, showUserMessage?: boolean, displayText?: 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
answerClarifyQuestion: (qid: string, 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
sessionTitle: string
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
onClarifyQuestionAnswer: (qid: string, 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
}
/**
* A `[[ … ]]` token sitting in the composer text, plus the payload it stands
* for. `paste` tokens expand back into their text at submit; `image` tokens
* are a receipt for a file the gateway already holds, and expand to nothing.
*
* `index` is the user-facing number in `[[ Image 2 ]]`; `path` is the gateway
* path, used to detach the image when its token is deleted.
*/
export type ComposerToken =
| { index: number; kind: 'image'; label: string; path: string; text?: undefined }
| { index?: undefined; kind: 'paste'; label: string; path?: string; text: string }
+163
View File
@@ -0,0 +1,163 @@
import { atom, computed } from 'nanostores'
import type { OverlayState } from './interfaces.js'
import { $uiState } from './uiStore.js'
const buildOverlayState = (): OverlayState => ({
agents: false,
agentsInitialHistoryIndex: 0,
approval: null,
billing: null,
clarify: null,
confirm: null,
ambient: [],
widget: null,
journey: false,
modelPicker: false,
pager: null,
petPicker: false,
pluginsHub: false,
secret: null,
sessions: false,
skillsHub: false,
subscription: null,
sudo: null
})
export const $overlayState = atom<OverlayState>(buildOverlayState())
export const $isBlocked = computed(
$overlayState,
({
agents,
approval,
billing,
clarify,
confirm,
journey,
modelPicker,
pager,
petPicker,
pluginsHub,
secret,
sessions,
skillsHub,
subscription,
sudo,
widget
}) =>
Boolean(
agents ||
approval ||
billing ||
clarify ||
confirm ||
journey ||
modelPicker ||
pager ||
petPicker ||
pluginsHub ||
secret ||
sessions ||
skillsHub ||
subscription ||
sudo ||
widget
)
)
/**
* Does an open overlay actually PAINT OVER the status rule?
*
* Deliberately NOT `$isBlocked`. That aggregate answers a different
* question — "is text input suspended" — and `appLayout` uses it only to hide
* the input rows (`appLayout.tsx:384`). `StatusRulePane` is rendered OUTSIDE
* that guard (`appLayout.tsx:365` for `at="top"`, `:449` for `at="bottom"`),
* so most `$isBlocked` fields leave the rule fully visible.
*
* Occluding (included here):
*
* - `widget` — the modal widget slot renders at viewport level
* (`ActiveWidgetSlot`, `sdk/host.tsx:209`, outside the ComposerPane
* subtree) so it can anchor the full-screen absolute `Overlay`
* (`components/overlay.tsx`) against the whole terminal.
* - The FloatingOverlays set — `modelPicker`, `pager`, `petPicker`,
* `sessions`, `skillsHub`, `pluginsHub` — but ONLY when the rule sits at
* the top. That panel is `position="absolute" bottom="100%"` inside
* ComposerPane's relative Box (`appOverlays.tsx:387`), so it grows UPWARD
* over the `at="top"` rule and never reaches the `at="bottom"` one.
*
* NOT occluding (deliberately excluded):
*
* - The PromptZone flow states — `approval`, `billing`, `subscription`,
* `confirm`, `clarify`, `sudo`, `secret` (`appOverlays.tsx:58-162`). They
* render in NORMAL FLOW above ComposerPane (`appLayout.tsx:553-568`): they
* push content down, they do not cover it. The rule stays on screen and
* its clock must keep running.
* - `agents` and `journey`. They unmount the entire ComposerPane subtree
* (`appLayout.tsx:553`), so `StatusRule` unmounts with it and React's own
* effect cleanup clears the intervals. Gating on them would be dead code.
* - `ambient` — a glanceable in-flow dock that reserves its own rows.
* - Composer completions. They share the FloatingOverlays grid and do
* occlude, but they are a render prop rather than store state and they
* change on every keystroke; re-arming a 1s interval per character would
* restart the countdown each time and starve the tick outright — strictly
* worse than the churn this gate removes.
*
* `statusBar: 'off'` needs no branch: `StatusRulePane` returns null for both
* slots, so the timers are never mounted in the first place.
*/
/**
* True when any floating overlay PANEL is open (widget overlays and inline
* completions are separate concerns — see $isStatusRuleOccluded for why
* completions never occlude the status rule).
*
* SINGLE SOURCE for the floating-panel kind set: consumed by both
* FloatingOverlays' render gate (plus completions, which it adds locally)
* and $isStatusRuleOccluded's top-statusbar occlusion arm. Add new floating
* panels HERE so the timer gate can't silently miss them.
*/
export const hasFloatingPanel = (overlay: OverlayState): boolean =>
Boolean(
overlay.modelPicker ||
overlay.pager ||
overlay.petPicker ||
overlay.pluginsHub ||
overlay.sessions ||
overlay.skillsHub
)
export const $isStatusRuleOccluded = computed([$overlayState, $uiState], (overlay, ui) =>
Boolean(overlay.widget || (ui.statusBar === 'top' && hasFloatingPanel(overlay)))
)
export const getOverlayState = () => $overlayState.get()
export const patchOverlayState = (next: Partial<OverlayState> | ((state: OverlayState) => OverlayState)) =>
$overlayState.set(typeof next === 'function' ? next($overlayState.get()) : { ...$overlayState.get(), ...next })
/** Full reset — used by session/turn teardown and tests. */
export const resetOverlayState = () => $overlayState.set(buildOverlayState())
/**
* Soft reset: drop FLOW-scoped overlays (approval / clarify / confirm / sudo
* / secret / pager) but PRESERVE user-toggled ones — agents dashboard, model
* picker, skills hub, sessions overlay. Those are opened deliberately and
* shouldn't vanish when a turn ends. Called from turnController.idle() on
* every turn completion / interrupt; the old "reset everything" behaviour
* silently closed /agents the moment delegation finished.
*/
export const resetFlowOverlays = () =>
$overlayState.set({
...buildOverlayState(),
agents: $overlayState.get().agents,
agentsInitialHistoryIndex: $overlayState.get().agentsInitialHistoryIndex,
ambient: $overlayState.get().ambient,
widget: $overlayState.get().widget,
journey: $overlayState.get().journey,
modelPicker: $overlayState.get().modelPicker,
petPicker: $overlayState.get().petPicker,
pluginsHub: $overlayState.get().pluginsHub,
sessions: $overlayState.get().sessions,
skillsHub: $overlayState.get().skillsHub
})
+28
View File
@@ -0,0 +1,28 @@
import { atom } from 'nanostores'
import type { PetState } from './usePet.js'
interface PetFlash {
state: PetState
until: number
}
// Transient reaction beats (wave/jump/failed) the pet shows for a moment at
// turn end before falling back to its steady state. The gateway event handler
// sets these; usePet reads them with priority over the derived state.
export const $petFlash = atom<PetFlash | null>(null)
export const flashPet = (state: PetState, ms = 1600) => $petFlash.set({ state, until: Date.now() + ms })
// Affection-heart beat: a monotonic tick the status-bar ♥ flashes on. Bumped by
// the gateway `reaction` event (core-detected ily / <3 / good bot) — the TUI's
// share of the same signal that plays the desktop's floating hearts.
export const $goodVibesTick = atom(0)
export const flashGoodVibes = () => $goodVibesTick.set($goodVibesTick.get() + 1)
// The floating pet's footprint, or null when no pet is shown. The transcript
// keeps its text clear of the pet responsively: on wide terminals it reserves a
// right gutter (`width`) so lines wrap to the pet's LEFT; on narrow terminals it
// reserves bottom rows (`height`) so lines stay full-width and sit ABOVE it.
export const $petBox = atom<{ width: number; height: number } | null>(null)
+74
View File
@@ -0,0 +1,74 @@
import type { ScrollBoxHandle } from '@hermes/ink'
import type { SelectionApi } from './interfaces.js'
export interface SelectionSnap {
anchor?: { row: number } | null
focus?: { row: number } | null
isDragging?: boolean
}
export interface ScrollWithSelectionOptions {
readonly scrollRef: { readonly current: ScrollBoxHandle | null }
readonly selection: SelectionApi
}
function scrollBoundsForDelta(s: ScrollBoxHandle, cur: number, delta: number) {
const viewport = Math.max(0, s.getViewportHeight())
const cachedHeight = Math.max(viewport, s.getScrollHeight())
let max = Math.max(0, cachedHeight - viewport)
// getScrollHeight() is render-time cached. After the streaming tail is
// committed into virtual history, the Yoga height can be fresher than the
// cached value; if we clamp only against the cached fake bottom, wheel-down
// becomes a no-op and no render is scheduled to reveal the real tail.
if (delta > 0 && cur + delta >= max - 1) {
const freshHeight = Math.max(viewport, s.getFreshScrollHeight())
max = Math.max(0, freshHeight - viewport)
}
return { max, viewport }
}
export function scrollWithSelectionBy(delta: number, { scrollRef, selection }: ScrollWithSelectionOptions): void {
const s = scrollRef.current
if (!s) {
return
}
const cur = s.getScrollTop() + s.getPendingDelta()
const { max, viewport } = scrollBoundsForDelta(s, cur, delta)
const actual = Math.max(0, Math.min(max, cur + delta)) - cur
if (actual === 0) {
return
}
const sel = selection.getState() as null | SelectionSnap
const top = s.getViewportTop()
const bottom = top + viewport - 1
if (
sel?.anchor &&
sel.focus &&
sel.anchor.row >= top &&
sel.anchor.row <= bottom &&
(sel.isDragging || (sel.focus.row >= top && sel.focus.row <= bottom))
) {
const shift = sel.isDragging ? selection.shiftAnchor : selection.shiftSelection
if (actual > 0) {
selection.captureScrolledRows(top, top + actual - 1, 'above')
} else {
selection.captureScrolledRows(bottom + actual + 1, bottom, 'below')
}
shift(-actual, top, bottom)
}
// The target is already accepted and clamped here. Commit it directly so
// wheel/page input does not enter ScrollBox's multi-frame pending-delta
// drain and produce visible stair steps at virtual row boundaries.
s.scrollTo(cur + actual)
}
+121
View File
@@ -0,0 +1,121 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
const { evictInkCachesMock, forceRedrawMock } = vi.hoisted(() => ({
evictInkCachesMock: vi.fn(),
forceRedrawMock: vi.fn()
}))
vi.mock('@hermes/ink', () => ({
evictInkCaches: evictInkCachesMock,
forceRedraw: forceRedrawMock
}))
import { refreshSessionView, scheduleResumeScrollToBottom } from './sessionResumeView.js'
describe('refreshSessionView', () => {
afterEach(() => {
evictInkCachesMock.mockReset()
forceRedrawMock.mockReset()
})
it('evicts Ink caches and forces a full repaint', () => {
const stdout = {} as NodeJS.WriteStream
refreshSessionView(stdout)
expect(evictInkCachesMock).toHaveBeenCalledWith('all')
expect(forceRedrawMock).toHaveBeenCalledWith(stdout)
})
})
describe('scheduleResumeScrollToBottom', () => {
afterEach(() => {
vi.useRealTimers()
evictInkCachesMock.mockReset()
forceRedrawMock.mockReset()
})
it('re-snaps while sticky and stops when the user scrolls away', () => {
vi.useFakeTimers()
let sticky = true
let lastManualScrollAt = 0
const scrollToBottom = vi.fn()
const cancel = scheduleResumeScrollToBottom(
{
current: {
getLastManualScrollAt: () => lastManualScrollAt,
isSticky: () => sticky,
scrollToBottom
}
} as any,
[0, 80, 240]
)
vi.advanceTimersByTime(0)
expect(scrollToBottom).toHaveBeenCalledTimes(1)
expect(evictInkCachesMock).toHaveBeenCalledWith('all')
expect(forceRedrawMock).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(80)
expect(scrollToBottom).toHaveBeenCalledTimes(2)
expect(forceRedrawMock).toHaveBeenCalledTimes(1)
sticky = false
lastManualScrollAt = Date.now() + 1
vi.advanceTimersByTime(160)
expect(scrollToBottom).toHaveBeenCalledTimes(2)
cancel()
})
it('cancels pending resume snaps', () => {
vi.useFakeTimers()
const scrollToBottom = vi.fn()
const cancel = scheduleResumeScrollToBottom(
{
current: {
getLastManualScrollAt: () => 0,
isSticky: () => true,
scrollToBottom
}
} as any,
[20]
)
cancel()
vi.advanceTimersByTime(20)
expect(scrollToBottom).not.toHaveBeenCalled()
expect(forceRedrawMock).not.toHaveBeenCalled()
})
it('keeps the immediate resume snap even before sticky state settles', () => {
vi.useFakeTimers()
let sticky = false
const scrollToBottom = vi.fn()
const cancel = scheduleResumeScrollToBottom(
{
current: {
getLastManualScrollAt: () => 0,
isSticky: () => sticky,
scrollToBottom
}
} as any,
[0, 80]
)
vi.advanceTimersByTime(0)
expect(scrollToBottom).toHaveBeenCalledTimes(1)
expect(forceRedrawMock).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(80)
expect(scrollToBottom).toHaveBeenCalledTimes(1)
expect(forceRedrawMock).toHaveBeenCalledTimes(1)
sticky = true
cancel()
})
})
+41
View File
@@ -0,0 +1,41 @@
import type { ScrollBoxHandle } from '@hermes/ink'
import { evictInkCaches, forceRedraw } from '@hermes/ink'
import type { RefObject } from 'react'
export const refreshSessionView = (stdout: NodeJS.WriteStream = process.stdout) => {
evictInkCaches('all')
forceRedraw(stdout)
}
export const scheduleResumeScrollToBottom = (
scrollRef: RefObject<null | ScrollBoxHandle>,
delays: readonly number[] = [0, 80, 240]
) => {
const startedAt = Date.now()
const timers = delays.map((delay, index) =>
setTimeout(() => {
const scroll = scrollRef.current
if (!scroll) {
return
}
const manuallyScrolledAfterResume = scroll.getLastManualScrollAt() > startedAt
if (!manuallyScrolledAfterResume && (index === 0 || scroll.isSticky())) {
scroll.scrollToBottom()
if (index === 0) {
refreshSessionView()
}
}
}, delay)
)
return () => {
for (const timer of timers) {
clearTimeout(timer)
}
}
}
+54
View File
@@ -0,0 +1,54 @@
import type { RunExternalProcess } from '@hermes/ink'
import type { SetupStatusResponse } from '../gatewayTypes.js'
import type { LaunchResult } from '../lib/externalCli.js'
import type { SlashHandlerContext } from './interfaces.js'
import { patchUiState } from './uiStore.js'
export interface RunExternalSetupOptions {
args: string[]
ctx: Pick<SlashHandlerContext, 'gateway' | 'session' | 'transcript'>
done: string
launcher: (args: string[]) => Promise<LaunchResult>
suspend: (run: RunExternalProcess) => Promise<void>
}
export async function runExternalSetup({ args, ctx, done, launcher, suspend }: RunExternalSetupOptions) {
const { gateway, session, transcript } = ctx
transcript.sys(`launching \`hermes ${args.join(' ')}\``)
patchUiState({ status: 'setup running…' })
let result: LaunchResult = { code: null }
await suspend(async () => {
result = await launcher(args)
})
if (result.error) {
transcript.sys(`error launching hermes: ${result.error}`)
patchUiState({ status: 'setup required' })
return
}
if (result.code !== 0) {
transcript.sys(`hermes ${args[0]} exited with code ${result.code}`)
patchUiState({ status: 'setup required' })
return
}
const setup = await gateway.rpc<SetupStatusResponse>('setup.status', {})
if (setup?.provider_configured === false) {
transcript.sys('still no provider configured')
patchUiState({ status: 'setup required' })
return
}
transcript.sys(done)
session.newSession()
}
+770
View File
@@ -0,0 +1,770 @@
import { forceRedraw, type MouseTrackingMode } from '@hermes/ink'
import { DASHBOARD_TUI_MODE, NO_CONFIRM_DESTRUCTIVE } from '../../../config/env.js'
import { dailyFortune, randomFortune } from '../../../content/fortunes.js'
import { HOTKEYS } from '../../../content/hotkeys.js'
import { isSectionName, nextDetailsMode, parseDetailsMode, SECTION_NAMES } from '../../../domain/details.js'
import type {
ConfigGetValueResponse,
ConfigSetResponse,
SessionSaveResponse,
SessionStatusResponse,
SessionSteerResponse,
SessionTitleResponse,
SessionUndoResponse,
SystemBatteryResponse
} from '../../../gatewayTypes.js'
import { writeClipboardText } from '../../../lib/clipboard.js'
import { writeOsc52Clipboard } from '../../../lib/osc52.js'
import {
configureDetectedTerminalKeybindings,
configureTerminalKeybindings,
isRemoteShellSession
} from '../../../lib/terminalSetup.js'
import type { Msg, PanelSection } from '../../../types.js'
import type { StatusBarMode } from '../../interfaces.js'
import { patchOverlayState } from '../../overlayStore.js'
import { patchUiState } from '../../uiStore.js'
import type { SlashCommand } from '../types.js'
const flagFromArg = (arg: string, current: boolean): boolean | null => {
if (!arg) {
return !current
}
const mode = arg.trim().toLowerCase()
if (mode === 'on') {
return true
}
if (mode === 'off') {
return false
}
if (mode === 'toggle') {
return !current
}
return null
}
// `/mouse` toggles between full tracking and off when called bare so the
// old binary muscle-memory still works. Explicit presets (wheel / buttons /
// all) target the tmux-friendly hover-free subsets.
const MOUSE_MODE_ALIASES: Record<string, MouseTrackingMode> = {
all: 'all',
any: 'all',
button: 'buttons',
buttons: 'buttons',
click: 'buttons',
full: 'all',
off: 'off',
on: 'all',
scroll: 'wheel',
wheel: 'wheel'
}
const mouseModeFromArg = (arg: string, current: MouseTrackingMode): MouseTrackingMode | null => {
if (!arg || arg.trim().toLowerCase() === 'toggle') {
return current === 'off' ? 'all' : 'off'
}
return MOUSE_MODE_ALIASES[arg.trim().toLowerCase()] ?? null
}
const RESET_WORDS = new Set(['reset', 'clear', 'default'])
const CYCLE_WORDS = new Set(['cycle', 'toggle'])
const DETAILS_USAGE =
'usage: /details [hidden|collapsed|expanded|cycle] or /details <section> [hidden|collapsed|expanded|reset]'
const DETAILS_SECTION_USAGE = 'usage: /details <section> [hidden|collapsed|expanded|reset]'
// Shown when /exit or /quit is refused in the hosted dashboard chat. Kept as a
// constant so the test asserts against the same source of truth as production.
export const DASHBOARD_EXIT_DISABLED_MESSAGE =
'exit is disabled in hosted dashboard chat — use /new to start a fresh session'
export const DASHBOARD_UPDATE_DISABLED_MESSAGE =
'update is disabled in hosted dashboard chat — the hosted environment is managed separately'
export const coreCommands: SlashCommand[] = [
{
help: 'list commands + hotkeys',
name: 'help',
run: (_arg, ctx) => {
const sections: PanelSection[] = (ctx.local.catalog?.categories ?? []).map(cat => ({
rows: cat.pairs,
title: cat.name
}))
if (ctx.local.catalog?.skillCount) {
sections.push({ text: `${ctx.local.catalog.skillCount} skill commands available — /skills to browse` })
}
sections.push(
{
rows: [
['/details [hidden|collapsed|expanded|cycle]', 'set global agent detail visibility mode'],
[
'/details <section> [hidden|collapsed|expanded|reset]',
'override one section (thinking/tools/subagents/activity)'
],
['/fortune [random|daily]', 'show a random or daily local fortune'],
['/grid-test [cols]x[rows]', 'open the interactive widget-grid demo'],
['/dialog-test [zone]', 'open a sample dialog overlay with a faked backdrop']
],
title: 'TUI'
},
{ rows: HOTKEYS, title: 'Hotkeys' }
)
ctx.transcript.panel(ctx.ui.theme.brand.helpHeader, sections)
}
},
{
aliases: ['exit'],
help: 'exit hermes',
name: 'quit',
run: (_arg, ctx) => {
// In the hosted dashboard chat there is no in-page restart path after
// the PTY child exits, so quitting bricks the tab until a refresh. The
// keyboard idle-exit (Ctrl+C / Ctrl+D) and SIGINT handling already refuse
// to die in this mode (see useInputHandlers + entry.tsx); gate /exit and
// /quit on the same DASHBOARD_TUI_MODE flag. Unlike the keyboard path
// (which auto-starts a fresh chat), the explicit quit command refuses and
// instructs the user to run /new themselves.
if (DASHBOARD_TUI_MODE) {
ctx.transcript.sys(DASHBOARD_EXIT_DISABLED_MESSAGE)
return
}
ctx.session.die()
}
},
{
help: 'update Hermes Agent to the latest version (exits TUI)',
name: 'update',
run: (_arg, ctx) => {
if (DASHBOARD_TUI_MODE) {
ctx.transcript.sys(DASHBOARD_UPDATE_DISABLED_MESSAGE)
return
}
ctx.transcript.sys('exiting TUI to run update...')
// Exit code 42 signals the Python wrapper to exec `hermes update`.
// Use dieWithCode for proper cleanup (gateway kill + Ink unmount).
setTimeout(() => ctx.session.dieWithCode(42), 100)
}
},
{
aliases: ['scroll'],
help: 'set mouse tracking preset [on|off|toggle|wheel|buttons|all]',
name: 'mouse',
run: (arg, ctx) => {
const current = ctx.ui.mouseTracking
const next = mouseModeFromArg(arg, current)
if (next === null) {
return ctx.transcript.sys('usage: /mouse [on|off|toggle|wheel|buttons|all]')
}
patchUiState({ mouseTracking: next })
ctx.gateway.rpc<ConfigSetResponse>('config.set', { key: 'mouse', value: next }).catch(() => {})
queueMicrotask(() => ctx.transcript.sys(`mouse tracking ${next}`))
}
},
{
aliases: ['new'],
help: 'start a new session',
name: 'clear',
run: (arg, ctx, cmd) => {
if (ctx.session.guardBusySessionSwitch('switch sessions')) {
return
}
const isNew = cmd.startsWith('/new')
const requestedTitle = isNew ? arg.trim() : ''
const commit = () => {
patchUiState({ status: 'forging session…' })
ctx.session.newSession(isNew ? 'new session started' : undefined, requestedTitle || undefined)
}
if (NO_CONFIRM_DESTRUCTIVE || !ctx.ui.destructiveSlashConfirm) {
return commit()
}
patchOverlayState({
confirm: {
cancelLabel: 'No, keep going',
confirmLabel: isNew ? 'Yes, start a new session' : 'Yes, clear the session',
danger: true,
detail: 'This ends the current conversation and clears the transcript.',
onConfirm: commit,
title: isNew ? 'Start a new session?' : 'Clear the current session?'
}
})
}
},
{
help: 'force a full UI repaint',
name: 'redraw',
run: (_arg, ctx) => {
forceRedraw(process.stdout)
ctx.transcript.sys('ui redrawn')
}
},
{
help: 'show live session info',
name: 'status',
run: (_arg, ctx) => {
if (!ctx.sid) {
return ctx.transcript.sys('no active session')
}
ctx.gateway
.rpc<SessionStatusResponse>('session.status', { session_id: ctx.sid })
.then(ctx.guarded<SessionStatusResponse>(r => ctx.transcript.page(r.output || '(no status)', 'Status')))
.catch(ctx.guardedErr)
}
},
{
help: 'set or show current session title',
name: 'title',
run: (arg, ctx) => {
if (!ctx.sid) {
return ctx.transcript.sys('no active session')
}
const title = arg.trim()
if (!arg) {
ctx.gateway
.rpc<SessionTitleResponse>('session.title', { session_id: ctx.sid })
.then(
ctx.guarded<SessionTitleResponse>(r => {
const current = (r?.title ?? '').trim()
ctx.transcript.sys(current ? `title: ${current}` : 'no title set')
})
)
.catch(ctx.guardedErr)
return
}
if (!title) {
return ctx.transcript.sys('usage: /title <your session title>')
}
ctx.gateway
.rpc<SessionTitleResponse>('session.title', { session_id: ctx.sid, title })
.then(
ctx.guarded<SessionTitleResponse>(r => {
const next = (r?.title ?? title).trim()
const suffix = r?.pending ? ' (queued while session initializes)' : ''
patchUiState({ sessionTitle: next })
ctx.transcript.sys(`session title set: ${next}${suffix}`)
})
)
.catch(ctx.guardedErr)
}
},
{
help: 'toggle compact display',
name: 'density',
run: (arg, ctx) => {
const next = flagFromArg(arg, ctx.ui.compact)
if (next === null) {
return ctx.transcript.sys('usage: /density [on|off|toggle]')
}
patchUiState({ compact: next })
ctx.gateway.rpc<ConfigSetResponse>('config.set', { key: 'density', value: next ? 'on' : 'off' }).catch(() => {})
queueMicrotask(() => ctx.transcript.sys(`density ${next ? 'on' : 'off'}`))
}
},
{
aliases: ['detail'],
help: 'control agent detail visibility (global or per-section)',
name: 'details',
run: (arg, ctx) => {
const { gateway, transcript, ui } = ctx
if (!arg) {
gateway
.rpc<ConfigGetValueResponse>('config.get', { key: 'details_mode' })
.then(r => {
if (ctx.stale()) {
return
}
const mode = parseDetailsMode(r?.value) ?? ui.detailsMode
patchUiState({ detailsMode: mode, detailsModeCommandOverride: false })
const overrides = SECTION_NAMES.filter(s => ui.sections[s])
.map(s => `${s}=${ui.sections[s]}`)
.join(' ')
transcript.sys(`details: ${mode}${overrides ? ` (${overrides})` : ''}`)
})
.catch(() => !ctx.stale() && transcript.sys(`details: ${ui.detailsMode}`))
return
}
const [first, second] = arg.trim().toLowerCase().split(/\s+/)
if (second && isSectionName(first)) {
const reset = RESET_WORDS.has(second)
const mode = reset ? null : parseDetailsMode(second)
if (!reset && !mode) {
return transcript.sys(DETAILS_SECTION_USAGE)
}
const { [first]: _drop, ...rest } = ui.sections
patchUiState({ sections: mode ? { ...rest, [first]: mode } : rest })
gateway
.rpc<ConfigSetResponse>('config.set', { key: `details_mode.${first}`, value: mode ?? '' })
.catch(() => {})
transcript.sys(`details ${first}: ${mode ?? 'reset'}`)
return
}
const next = CYCLE_WORDS.has(first ?? '') ? nextDetailsMode(ui.detailsMode) : parseDetailsMode(first)
if (!next) {
return transcript.sys(DETAILS_USAGE)
}
const sections = Object.fromEntries(SECTION_NAMES.map(section => [section, next]))
patchUiState({ detailsMode: next, detailsModeCommandOverride: true, sections })
gateway.rpc<ConfigSetResponse>('config.set', { key: 'details_mode', value: next }).catch(() => {})
transcript.sys(`details: ${next}`)
}
},
{
help: 'local fortune',
name: 'fortune',
run: (arg, ctx) => {
const key = arg.trim().toLowerCase()
if (!arg || key === 'random') {
return ctx.transcript.sys(randomFortune())
}
if (['daily', 'stable', 'today'].includes(key)) {
return ctx.transcript.sys(dailyFortune(ctx.sid))
}
ctx.transcript.sys('usage: /fortune [random|daily]')
}
},
{
help: 'copy selection or assistant message',
name: 'copy',
run: async (arg, ctx) => {
const { sys } = ctx.transcript
if (!arg && ctx.composer.hasSelection) {
const text = await ctx.composer.selection.copySelection()
if (text) {
return sys(`copied ${text.length} characters`)
} else {
return sys('clipboard copy failed — try HERMES_TUI_FORCE_OSC52=1 to force the escape sequence')
}
}
if (arg && Number.isNaN(parseInt(arg, 10))) {
return sys('usage: /copy [number]')
}
const all = ctx.local.getHistoryItems().filter(m => m.role === 'assistant')
const target = all[arg ? Math.min(parseInt(arg, 10), all.length) - 1 : all.length - 1]
if (!target) {
return sys('nothing to copy — start a conversation first')
}
const shouldUseTerminalClipboard = isRemoteShellSession(process.env)
if (shouldUseTerminalClipboard) {
writeOsc52Clipboard(target.text)
return sys('sent OSC52 copy sequence (terminal support required)')
}
void writeClipboardText(target.text)
.then(nativeOk => {
if (ctx.stale()) {
return
}
if (nativeOk) {
sys('copied to clipboard')
} else {
writeOsc52Clipboard(target.text)
sys('sent OSC52 copy sequence (terminal support required)')
}
})
.catch(error => {
if (!ctx.stale()) {
sys(`copy failed: ${String(error)}`)
}
})
}
},
{
help: 'attach clipboard image',
name: 'paste',
run: (arg, ctx) => (arg ? ctx.transcript.sys('usage: /paste') : ctx.composer.attachClipboardImage())
},
{
aliases: ['compose'],
help: 'compose your next prompt in $EDITOR (same as Ctrl+G)',
name: 'prompt',
run: (arg, ctx) => {
if (arg) {
// The TUI editor opens with the current composer draft; there is no
// separate seed arg. Drop any inline text into the composer first so
// it carries into the editor, matching the CLI's /prompt <text>.
ctx.composer.setInput(arg)
}
void ctx.composer.openEditor().catch((err: unknown) => {
ctx.transcript.sys(`editor failed: ${String(err)}`)
})
}
},
{
help: 'configure IDE terminal keybindings for multiline + undo/redo',
name: 'terminal-setup',
run: (arg, ctx) => {
const target = arg.trim().toLowerCase()
if (target && !['auto', 'cursor', 'vscode', 'windsurf'].includes(target)) {
return ctx.transcript.sys('usage: /terminal-setup [auto|vscode|cursor|windsurf]')
}
const runner =
!target || target === 'auto'
? configureDetectedTerminalKeybindings()
: configureTerminalKeybindings(target as 'cursor' | 'vscode' | 'windsurf')
void runner
.then(result => {
if (ctx.stale()) {
return
}
ctx.transcript.sys(result.message)
if (result.success && result.requiresRestart) {
ctx.transcript.sys('restart the IDE terminal for the new keybindings to take effect')
}
})
.catch(error => {
if (!ctx.stale()) {
ctx.transcript.sys(`terminal setup failed: ${String(error)}`)
}
})
}
},
{
help: 'view gateway logs',
name: 'logs',
run: (arg, ctx) => {
const text = ctx.gateway.gw.getLogTail(Math.min(80, Math.max(1, parseInt(arg, 10) || 20)))
text ? ctx.transcript.page(text, 'Logs') : ctx.transcript.sys('no gateway logs')
}
},
{
help: 'view current transcript (user + assistant messages)',
name: 'history',
run: (arg, ctx) => {
// The CLI-side `/history` runs in a detached slash-worker subprocess
// that never sees the TUI's turns — it only surfaces whatever was
// persisted before this process started. Render the TUI's own
// transcript so `/history` actually reflects what the user just did.
const items = ctx.local.getHistoryItems().filter(m => m.role === 'user' || m.role === 'assistant')
if (!items.length) {
return ctx.transcript.sys('no conversation yet')
}
const preview = Math.max(80, parseInt(arg, 10) || 400)
const lines = items.map((m, i) => {
const tag = m.role === 'user' ? `You #${i + 1}` : `Hermes #${i + 1}`
const body = m.text.trim() || (m.tools?.length ? `(${m.tools.length} tool calls)` : '(empty)')
const clipped = body.length > preview ? `${body.slice(0, preview).trimEnd()}` : body
return `[${tag}]\n${clipped}`
})
ctx.transcript.page(lines.join('\n\n'), 'History')
}
},
{
help: 'save the current transcript to JSON',
name: 'save',
run: (_arg, ctx) => {
const hasConversation = ctx.local
.getHistoryItems()
.some(m => m.role === 'user' || m.role === 'assistant' || m.role === 'tool')
if (!hasConversation) {
return ctx.transcript.sys('no conversation yet')
}
if (!ctx.sid) {
return ctx.transcript.sys('no active session — nothing to save')
}
ctx.gateway
.rpc<SessionSaveResponse>('session.save', { session_id: ctx.sid })
.then(
ctx.guarded<SessionSaveResponse>(r => {
const file = r?.file
if (file) {
ctx.transcript.sys(`conversation saved to: ${file}`)
} else {
ctx.transcript.sys('failed to save')
}
})
)
.catch(ctx.guardedErr)
}
},
{
help: 'toggle focus view — show only your prompt and the final response [on|off|status]',
name: 'focus',
run: (arg, ctx) => {
const mode = arg.trim().toLowerCase()
const current = ctx.ui.focusView
// `/focus status` reports without writing, matching the CLI surface.
if (mode === 'status' || mode === 'show' || mode === '?') {
return ctx.transcript.sys(
current ? 'focus view on — only your prompt and the final response' : 'focus view off'
)
}
const next = flagFromArg(mode, current)
if (next === null) {
return ctx.transcript.sys('usage: /focus [on|off|status]')
}
// Display-only: Python owns the tool_progress stash/restore so /focus off
// returns to whatever /verbose mode the user had. Optimistically patch the
// badge so the status bar flips on the same frame.
patchUiState({ focusView: next })
ctx.gateway.rpc<ConfigSetResponse>('config.set', { key: 'focus', value: next ? 'on' : 'off' }).catch(() => {})
queueMicrotask(() =>
ctx.transcript.sys(
next ? 'focus view enabled — just your prompt and the final response' : 'focus view disabled'
)
)
}
},
{
aliases: ['sb'],
help: 'status bar position (on|off|top|bottom)',
name: 'statusbar',
run: (arg, ctx) => {
const mode = arg.trim().toLowerCase()
const toggle: StatusBarMode = ctx.ui.statusBar === 'off' ? 'top' : 'off'
const next: null | StatusBarMode =
!mode || mode === 'toggle'
? toggle
: mode === 'on' || mode === 'top'
? 'top'
: mode === 'off' || mode === 'bottom'
? mode
: null
if (!next) {
return ctx.transcript.sys('usage: /statusbar [on|off|top|bottom|toggle]')
}
patchUiState({ statusBar: next })
ctx.gateway.rpc<ConfigSetResponse>('config.set', { key: 'statusbar', value: next }).catch(() => {})
queueMicrotask(() => ctx.transcript.sys(`status bar ${next}`))
}
},
{
help: 'toggle a color-coded battery indicator in the status bar [on|off|status]',
name: 'battery',
run: (arg, ctx) => {
const mode = arg.trim().toLowerCase()
// `/battery status` reports the current setting plus a live reading,
// matching the CLI surface. Fetch on demand so it works even while the
// indicator (and its poller) is off.
if (mode === 'status' || mode === 'show') {
const state = ctx.ui.battery ? 'on' : 'off'
ctx.gateway
.rpc<SystemBatteryResponse>('system.battery', {})
.then(r => {
if (r?.available && typeof r.percent === 'number') {
ctx.transcript.sys(`battery indicator ${state} — currently ${r.plugged ? '⚡' : '🔋'} ${r.percent}%`)
} else {
ctx.transcript.sys(`battery indicator ${state} — no battery detected on this machine`)
}
})
.catch(() => ctx.transcript.sys(`battery indicator ${state}`))
return
}
const next = flagFromArg(arg, ctx.ui.battery)
if (next === null) {
return ctx.transcript.sys('usage: /battery [on|off|status]')
}
patchUiState({ battery: next, ...(next ? {} : { batteryStatus: null }) })
ctx.gateway.rpc<ConfigSetResponse>('config.set', { key: 'battery', value: next ? 'on' : 'off' }).catch(() => {})
queueMicrotask(() => ctx.transcript.sys(`battery indicator ${next ? 'on' : 'off'}`))
}
},
{
aliases: ['q'],
help: 'inspect or enqueue a message',
name: 'queue',
run: (arg, ctx) => {
if (!arg) {
return ctx.transcript.sys(`${ctx.composer.queueRef.current.length} queued message(s)`)
}
ctx.composer.enqueue(arg)
ctx.transcript.sys(`queued: "${arg.slice(0, 50)}${arg.length > 50 ? '…' : ''}"`)
}
},
{
help: 'inject a message after the next tool call (no interrupt)',
name: 'steer',
run: (arg, ctx) => {
const payload = arg?.trim() ?? ''
if (!payload) {
return ctx.transcript.sys('usage: /steer <prompt>')
}
// If the agent isn't running, fall back to the queue so the user's
// message isn't lost — identical semantics to the gateway handler.
if (!ctx.ui.busy || !ctx.sid) {
ctx.composer.enqueue(payload)
ctx.transcript.sys(
`no active turn — queued for next: "${payload.slice(0, 50)}${payload.length > 50 ? '…' : ''}"`
)
return
}
ctx.gateway
.rpc<SessionSteerResponse>('session.steer', { session_id: ctx.sid, text: payload })
.then(
ctx.guarded<SessionSteerResponse>(r => {
if (r?.status === 'queued') {
ctx.transcript.sys(
`steer queued — arrives after next tool call: "${payload.slice(0, 50)}${payload.length > 50 ? '…' : ''}"`
)
} else {
ctx.transcript.sys('steer rejected')
}
})
)
.catch(ctx.guardedErr)
}
},
{
help: 'undo last exchange',
name: 'undo',
run: (_arg, ctx) => {
if (!ctx.sid) {
return ctx.transcript.sys('nothing to undo')
}
ctx.gateway.rpc<SessionUndoResponse>('session.undo', { session_id: ctx.sid }).then(
ctx.guarded<SessionUndoResponse>(r => {
if ((r.removed ?? 0) > 0) {
ctx.transcript.setHistoryItems((prev: Msg[]) => ctx.transcript.trimLastExchange(prev))
ctx.transcript.sys(`undid ${r.removed} messages`)
} else {
ctx.transcript.sys('nothing to undo')
}
})
)
}
},
{
help: 'retry last user message',
name: 'retry',
run: (_arg, ctx) => {
const last = ctx.local.getLastUserMsg()
if (!last) {
return ctx.transcript.sys('nothing to retry')
}
if (!ctx.sid) {
return ctx.transcript.send(last)
}
ctx.gateway.rpc<SessionUndoResponse>('session.undo', { session_id: ctx.sid }).then(
ctx.guarded<SessionUndoResponse>(r => {
if ((r.removed ?? 0) <= 0) {
return ctx.transcript.sys('nothing to retry')
}
ctx.transcript.setHistoryItems((prev: Msg[]) => ctx.transcript.trimLastExchange(prev))
ctx.transcript.send(last)
})
)
}
}
]
+115
View File
@@ -0,0 +1,115 @@
// Importing the apps barrel registers the reference apps before launch.
import '../../../sdk/apps/index.js'
import { terminalBackgroundHex } from '@hermes/ink'
import { formatBytes, performHeapDump } from '../../../lib/memory.js'
import { launchWidget } from '../../../sdk/host.js'
import { listWidgetApps } from '../../../sdk/registry.js'
import { loadUserWidgets } from '../../../sdk/userWidgets.js'
import { detectLightMode } from '../../../theme.js'
import { getUiState } from '../../uiStore.js'
import type { SlashCommand } from '../types.js'
/** The registry IS the catalog: every registered widget app becomes a slash
* command carrying the app's own help/usage — nothing hardcoded per app.
* The app owns parsing (init), keybindings (reduce), placement (render). */
export const widgetAppCommands: SlashCommand[] = listWidgetApps().map(app => ({
help: app.help,
name: app.id,
run: (arg, ctx) => {
const err = launchWidget(app.id, arg)
if (err) {
ctx.transcript.sys(err)
}
}
}))
export const debugCommands: SlashCommand[] = [
...widgetAppCommands,
{
help: 'rescan $HERMES_HOME/tui-widgets and (re)register user widget apps',
name: 'widgets-reload',
run: (_arg, ctx) => {
void loadUserWidgets().then(({ errors, loaded }) => {
const parts = [
loaded.length ? `loaded: ${loaded.join(', ')}` : 'no user widgets found',
...errors.map(e => `${e.file}: ${e.message}`)
]
ctx.transcript.sys(`widgets — ${parts.join(' · ')}`)
})
}
},
{
help: 'write a V8 heap snapshot + memory diagnostics (see HERMES_HEAPDUMP_DIR)',
name: 'heapdump',
run: (_arg, ctx) => {
const { heapUsed, rss } = process.memoryUsage()
ctx.transcript.sys(`writing heap dump (heap ${formatBytes(heapUsed)} · rss ${formatBytes(rss)})…`)
void performHeapDump('manual').then(r => {
if (ctx.stale()) {
return
}
if (!r.success) {
return ctx.transcript.sys(`heapdump failed: ${r.error ?? 'unknown error'}`)
}
ctx.transcript.sys(`heapdump: ${r.heapPath}`)
ctx.transcript.sys(`diagnostics: ${r.diagPath}`)
})
}
},
{
help: 'print live theme diagnostics (background probe, light mode, palette)',
name: 'theme-info',
run: (_arg, ctx) => {
const { theme } = getUiState()
ctx.transcript.panel('Theme', [
{
rows: [
['OSC-11 background', terminalBackgroundHex() ?? '(no reply)'],
['HERMES_TUI_BACKGROUND', process.env.HERMES_TUI_BACKGROUND ?? '(unset)'],
['HERMES_TUI_THEME', process.env.HERMES_TUI_THEME ?? '(unset)'],
['COLORFGBG', process.env.COLORFGBG ?? '(unset)'],
['TERM_PROGRAM', process.env.TERM_PROGRAM ?? '(unset)'],
['detected mode', detectLightMode() ? 'light' : 'dark'],
['text', theme.color.text],
['completionBg', theme.color.completionBg],
['selectionBg', theme.color.selectionBg],
['statusBg', theme.color.statusBg]
]
}
])
}
},
{
help: 'print live V8 heap + rss numbers',
name: 'mem',
run: (_arg, ctx) => {
const { arrayBuffers, external, heapTotal, heapUsed, rss } = process.memoryUsage()
ctx.transcript.panel('Memory', [
{
rows: [
['heap used', formatBytes(heapUsed)],
['heap total', formatBytes(heapTotal)],
['external', formatBytes(external)],
['array buffers', formatBytes(arrayBuffers)],
['rss', formatBytes(rss)],
['uptime', `${process.uptime().toFixed(0)}s`]
]
}
])
}
}
]
+762
View File
@@ -0,0 +1,762 @@
import type {
BrowserManageResponse,
CommandsCatalogResponse,
DelegationPauseResponse,
ProcessStopResponse,
ReloadEnvResponse,
ReloadMcpResponse,
RollbackDiffResponse,
RollbackListResponse,
RollbackRestoreResponse,
SlashExecResponse,
SpawnTreeListResponse,
SpawnTreeLoadResponse,
ToolsConfigureResponse
} from '../../../gatewayTypes.js'
import type { PanelSection } from '../../../types.js'
import { applyDelegationStatus, getDelegationState } from '../../delegationStore.js'
import { patchOverlayState } from '../../overlayStore.js'
import { getSpawnHistory, pushDiskSnapshot, setDiffPair, type SpawnSnapshot } from '../../spawnHistoryStore.js'
import type { SlashCommand } from '../types.js'
interface SkillInfo {
category?: string
description?: string
name?: string
path?: string
}
interface SkillsListResponse {
skills?: Record<string, string[]>
}
interface SkillsInspectResponse {
info?: SkillInfo
}
interface SkillsSearchResponse {
results?: { description?: string; name: string }[]
}
interface SkillsInstallResponse {
installed?: boolean
name?: string
}
interface SkillsBrowseItem {
description?: string
name: string
source?: string
trust?: string
}
interface SkillsBrowseResponse {
items?: SkillsBrowseItem[]
page?: number
total?: number
total_pages?: number
}
interface SkillsReloadResponse {
output?: string
}
export const opsCommands: SlashCommand[] = [
{
help: 'stop background processes',
name: 'stop',
run: (_arg, ctx) => {
ctx.gateway
.rpc<ProcessStopResponse>('process.stop', {})
.then(
ctx.guarded<ProcessStopResponse>(r => {
const killed = Number(r.killed ?? 0)
const noun = killed === 1 ? 'process' : 'processes'
ctx.transcript.sys(`stopped ${killed} background ${noun}`)
})
)
.catch(ctx.guardedErr)
}
},
{
aliases: ['reload_mcp'],
help: 'reload MCP servers in the live session (warns about prompt cache invalidation)',
name: 'reload-mcp',
run: (arg, ctx) => {
// Parse arg: `now` / `always` skip the confirmation gate.
// `always` additionally persists approvals.mcp_reload_confirm=false.
const a = (arg || '').trim().toLowerCase()
const params: { session_id: string | null; confirm?: boolean; always?: boolean } = {
session_id: ctx.sid
}
if (a === 'now' || a === 'approve' || a === 'once' || a === 'yes') {
params.confirm = true
} else if (a === 'always') {
params.confirm = true
params.always = true
}
ctx.gateway
.rpc<ReloadMcpResponse>('reload.mcp', params)
.then(
ctx.guarded<ReloadMcpResponse>(r => {
if (r.status === 'confirm_required') {
ctx.transcript.sys(r.message || '/reload-mcp requires confirmation')
return
}
if (r.status === 'reloaded') {
ctx.transcript.sys(
params.always
? 'MCP servers reloaded · future /reload-mcp will run without confirmation'
: 'MCP servers reloaded'
)
return
}
ctx.transcript.sys('reload complete')
})
)
.catch(ctx.guardedErr)
}
},
{
help: 're-read ~/.hermes/.env into the running gateway (CLI parity)',
name: 'reload',
run: (_arg, ctx) => {
ctx.gateway
.rpc<ReloadEnvResponse>('reload.env', {})
.then(
ctx.guarded<ReloadEnvResponse>(r => {
const n = Number(r.updated ?? 0)
const noun = n === 1 ? 'var' : 'vars'
ctx.transcript.sys(`reloaded .env (${n} ${noun} updated)`)
})
)
.catch(ctx.guardedErr)
}
},
{
help: 'manage browser CDP connection [connect|disconnect|status]',
name: 'browser',
run: (arg, ctx) => {
const [rawAction = 'status', ...rest] = arg.trim().split(/\s+/).filter(Boolean)
const action = rawAction.toLowerCase()
if (!['connect', 'disconnect', 'status'].includes(action)) {
return ctx.transcript.sys(
'usage: /browser [connect|disconnect|status] [url] · persistent: set browser.cdp_url in config.yaml'
)
}
const sid = ctx.sid ?? null
const url = action === 'connect' ? rest.join(' ').trim() || 'http://127.0.0.1:9222' : undefined
if (url) {
ctx.transcript.sys(`checking Chromium-family browser remote debugging at ${url}...`)
}
ctx.gateway
.rpc<BrowserManageResponse>('browser.manage', { action, session_id: sid, ...(url && { url }) })
.then(
ctx.guarded<BrowserManageResponse>(r => {
// Without a session we can't subscribe to streamed
// browser.progress events, so flush the bundled list.
if (!sid) {
r.messages?.forEach(message => ctx.transcript.sys(message))
}
if (action === 'status') {
return ctx.transcript.sys(
r.connected
? `browser connected: ${r.url || '(url unavailable)'}`
: 'browser not connected (try /browser connect <url> or set browser.cdp_url in config.yaml)'
)
}
if (action === 'disconnect') {
return ctx.transcript.sys('browser disconnected')
}
if (r.connected) {
ctx.transcript.sys('Browser connected to live Chromium-family browser via CDP')
ctx.transcript.sys(`Endpoint: ${r.url || '(url unavailable)'}`)
ctx.transcript.sys('next browser tool call will use this CDP endpoint')
}
})
)
.catch(ctx.guardedErr)
}
},
{
help: 'list, diff, or restore checkpoints',
name: 'rollback',
run: (arg, ctx) => {
if (!ctx.sid) {
return ctx.transcript.sys('no active session — nothing to rollback')
}
const trimmed = arg.trim()
const [first = '', ...rest] = trimmed.split(/\s+/).filter(Boolean)
const lower = first.toLowerCase()
if (!trimmed || lower === 'list' || lower === 'ls') {
return ctx.gateway
.rpc<RollbackListResponse>('rollback.list', { session_id: ctx.sid })
.then(
ctx.guarded<RollbackListResponse>(r => {
if (!r.enabled) {
return ctx.transcript.sys('checkpoints are not enabled')
}
const checkpoints = r.checkpoints ?? []
if (!checkpoints.length) {
return ctx.transcript.sys('no checkpoints found')
}
ctx.transcript.panel('Rollback checkpoints', [
{
rows: checkpoints.map((c, idx) => [
`${idx + 1}. ${c.hash.slice(0, 10)}`,
[c.timestamp, c.message].filter(Boolean).join(' · ') || '(no metadata)'
])
}
])
})
)
.catch(ctx.guardedErr)
}
if (lower === 'diff') {
const hash = rest[0]
if (!hash) {
return ctx.transcript.sys('usage: /rollback diff <checkpoint>')
}
return ctx.gateway
.rpc<RollbackDiffResponse>('rollback.diff', { hash, session_id: ctx.sid })
.then(
ctx.guarded<RollbackDiffResponse>(r => {
const body = (r.rendered || r.diff || '').trim()
if (!body && !r.stat) {
return ctx.transcript.sys('no changes since this checkpoint')
}
const text = [r.stat || '', body].filter(Boolean).join('\n\n')
ctx.transcript.page(text, 'Rollback diff')
})
)
.catch(ctx.guardedErr)
}
const hash = first
const filePath = rest.join(' ').trim()
return ctx.gateway
.rpc<RollbackRestoreResponse>('rollback.restore', {
...(filePath ? { file_path: filePath } : {}),
hash,
session_id: ctx.sid
})
.then(
ctx.guarded<RollbackRestoreResponse>(r => {
if (!r.success) {
return ctx.transcript.sys(`rollback failed: ${r.error || r.message || 'unknown error'}`)
}
const target = filePath || 'workspace'
const detail = r.reason || r.message || r.restored_to || 'restored'
ctx.transcript.sys(`rollback restored ${target}: ${detail}`)
if ((r.history_removed ?? 0) > 0) {
ctx.transcript.setHistoryItems(prev => ctx.transcript.trimLastExchange(prev))
}
})
)
.catch(ctx.guardedErr)
}
},
{
aliases: ['tasks'],
help: 'open the spawn-tree dashboard (live audit + kill/pause controls)',
name: 'agents',
run: (arg, ctx) => {
const sub = arg.trim().toLowerCase()
// Stay compatible with the gateway `/agents [pause|resume|status]` CLI —
// explicit subcommands skip the overlay and act directly so scripts and
// multi-step flows can drive it without entering interactive mode.
if (sub === 'pause' || sub === 'resume' || sub === 'unpause') {
const paused = sub === 'pause'
ctx.gateway.gw
.request<DelegationPauseResponse>('delegation.pause', { paused })
.then(r => {
applyDelegationStatus({ paused: r?.paused })
ctx.transcript.sys(`delegation · ${r?.paused ? 'paused' : 'resumed'}`)
})
.catch(ctx.guardedErr)
return
}
if (sub === 'status') {
const d = getDelegationState()
ctx.transcript.sys(
`delegation · ${d.paused ? 'paused' : 'active'} · caps d${d.maxSpawnDepth ?? '?'}/${d.maxConcurrentChildren ?? '?'}`
)
return
}
patchOverlayState({ agents: true, agentsInitialHistoryIndex: 0 })
}
},
{
aliases: ['learning', 'memory-graph'],
help: 'open your learning journey — skills + memories on a timeline',
name: 'journey',
run: (_arg, ctx) => {
void ctx
patchOverlayState({ journey: true })
}
},
{
help: 'replay a completed spawn tree · `/replay [N|last|list|load <path>]`',
name: 'replay',
run: (arg, ctx) => {
const history = getSpawnHistory()
const raw = arg.trim()
const lower = raw.toLowerCase()
// ── Disk-backed listing ─────────────────────────────────────
if (lower === 'list' || lower === 'ls') {
ctx.gateway
.rpc<SpawnTreeListResponse>('spawn_tree.list', {
limit: 30,
session_id: ctx.sid ?? 'default'
})
.then(
ctx.guarded<SpawnTreeListResponse>(r => {
const entries = r.entries ?? []
if (!entries.length) {
return ctx.transcript.sys('no archived spawn trees on disk for this session')
}
const rows: [string, string][] = entries.map(e => {
const ts = e.finished_at ? new Date(e.finished_at * 1000).toLocaleString() : '?'
const label = e.label || `${e.count} subagents`
return [`${ts} · ${e.count}×`, `${label}\n ${e.path}`]
})
ctx.transcript.panel('Archived spawn trees', [{ rows }])
})
)
.catch(ctx.guardedErr)
return
}
// ── Disk-backed load by path ─────────────────────────────────
if (lower.startsWith('load ')) {
const path = raw.slice(5).trim()
if (!path) {
return ctx.transcript.sys('usage: /replay load <path>')
}
ctx.gateway
.rpc<SpawnTreeLoadResponse>('spawn_tree.load', { path })
.then(
ctx.guarded<SpawnTreeLoadResponse>(r => {
if (!r.subagents?.length) {
return ctx.transcript.sys('snapshot empty or unreadable')
}
// Push onto the in-memory history so the overlay picks it up
// by index 1 just like any other snapshot.
pushDiskSnapshot(r, path)
patchOverlayState({ agents: true, agentsInitialHistoryIndex: 1 })
})
)
.catch(ctx.guardedErr)
return
}
// ── In-memory nav (same-session) ─────────────────────────────
if (!history.length) {
return ctx.transcript.sys('no completed spawn trees this session · try /replay list')
}
let index = 1
if (raw && lower !== 'last') {
const parsed = parseInt(raw, 10)
if (Number.isNaN(parsed) || parsed < 1 || parsed > history.length) {
return ctx.transcript.sys(`replay: index out of range 1..${history.length} · use /replay list for disk`)
}
index = parsed
}
patchOverlayState({ agents: true, agentsInitialHistoryIndex: index })
}
},
{
help: 'diff two completed spawn trees · `/replay-diff <baseline> <candidate>` (indexes from /replay list or history N)',
name: 'replay-diff',
run: (arg, ctx) => {
const parts = arg.trim().split(/\s+/).filter(Boolean)
if (parts.length !== 2) {
return ctx.transcript.sys('usage: /replay-diff <a> <b> (e.g. /replay-diff 1 2 for last two)')
}
const [a, b] = parts
const history = getSpawnHistory()
const resolve = (token: string): null | SpawnSnapshot => {
const n = parseInt(token!, 10)
if (Number.isFinite(n) && n >= 1 && n <= history.length) {
return history[n - 1] ?? null
}
return null
}
const baseline = resolve(a!)
const candidate = resolve(b!)
if (!baseline || !candidate) {
return ctx.transcript.sys(`replay-diff: could not resolve indices · history has ${history.length} entries`)
}
setDiffPair({ baseline, candidate })
patchOverlayState({ agents: true, agentsInitialHistoryIndex: 0 })
}
},
{
aliases: ['reload_skills'],
help: 're-scan installed skills in the live TUI gateway',
name: 'reload-skills',
run: (_arg, ctx) => {
ctx.gateway
.rpc<SkillsReloadResponse>('skills.reload', {})
.then(
ctx.guarded<SkillsReloadResponse>(r => {
ctx.transcript.page(r.output || 'skills reloaded', 'Reload Skills')
ctx.gateway
.rpc<CommandsCatalogResponse>('commands.catalog', {})
.then(
ctx.guarded<CommandsCatalogResponse>(catalog => {
if (!catalog?.pairs) {
return
}
ctx.local.setCatalog({
canon: (catalog.canon ?? {}) as Record<string, string>,
categories: catalog.categories ?? [],
pairs: catalog.pairs as [string, string][],
skillCount: (catalog.skill_count ?? 0) as number,
sub: (catalog.sub ?? {}) as Record<string, string[]>
})
})
)
.catch(() => {})
})
)
.catch(ctx.guardedErr)
}
},
{
help: 'browse, inspect, install skills',
name: 'skills',
run: (arg, ctx, cmd) => {
const text = arg.trim()
if (!text) {
return patchOverlayState({ skillsHub: true })
}
const [sub, ...rest] = text.split(/\s+/)
const query = rest.join(' ').trim()
const { rpc } = ctx.gateway
const { panel, sys } = ctx.transcript
const runViaSlashWorker = () => {
ctx.gateway.gw
.request<SlashExecResponse>('slash.exec', { command: cmd.slice(1), session_id: ctx.sid })
.then(r => {
if (ctx.stale()) {
return
}
const body = r?.output || '/skills: no output'
const formatted = r?.warning ? `warning: ${r.warning}\n${body}` : body
const long = formatted.length > 180 || formatted.split('\n').filter(Boolean).length > 2
long ? ctx.transcript.page(formatted, 'Skills') : ctx.transcript.sys(formatted)
})
.catch(ctx.guardedErr)
}
if (sub === 'list') {
rpc<SkillsListResponse>('skills.manage', { action: 'list' })
.then(
ctx.guarded<SkillsListResponse>(r => {
const cats = Object.entries(r.skills ?? {}).sort()
if (!cats.length) {
return sys('no skills available')
}
panel(
'Skills',
cats.map<PanelSection>(([title, items]) => ({ items, title }))
)
})
)
.catch(ctx.guardedErr)
return
}
if (sub === 'inspect') {
if (!query) {
return sys('usage: /skills inspect <name>')
}
rpc<SkillsInspectResponse>('skills.manage', { action: 'inspect', query })
.then(
ctx.guarded<SkillsInspectResponse>(r => {
const info = r.info ?? {}
if (!info.name) {
return sys(`unknown skill: ${query}`)
}
const rows: [string, string][] = [
['Name', String(info.name)],
['Category', String(info.category ?? '')],
['Path', String(info.path ?? '')]
]
const sections: PanelSection[] = [{ rows }]
if (info.description) {
sections.push({ text: String(info.description) })
}
panel('Skill', sections)
})
)
.catch(ctx.guardedErr)
return
}
if (sub === 'search') {
if (!query) {
return sys('usage: /skills search <query>')
}
rpc<SkillsSearchResponse>('skills.manage', { action: 'search', query })
.then(
ctx.guarded<SkillsSearchResponse>(r => {
const results = r.results ?? []
if (!results.length) {
return sys(`no results for: ${query}`)
}
panel(`Search: ${query}`, [{ rows: results.map(s => [s.name, s.description ?? '']) }])
})
)
.catch(ctx.guardedErr)
return
}
if (sub === 'install') {
if (!query) {
return sys('usage: /skills install <name or url>')
}
sys(`installing ${query}`)
rpc<SkillsInstallResponse>('skills.manage', { action: 'install', query })
.then(
ctx.guarded<SkillsInstallResponse>(r =>
sys(r.installed ? `installed ${r.name ?? query}` : 'install failed')
)
)
.catch(ctx.guardedErr)
return
}
if (sub === 'browse') {
const pageNum = query ? parseInt(query, 10) : 1
if (Number.isNaN(pageNum) || pageNum < 1) {
return sys('usage: /skills browse [page] (page must be a positive number)')
}
sys('fetching community skills (scans 6 sources, may take ~15s)…')
rpc<SkillsBrowseResponse>('skills.manage', { action: 'browse', page: pageNum })
.then(
ctx.guarded<SkillsBrowseResponse>(r => {
const items = r.items ?? []
if (!items.length) {
return sys(`no skills on page ${pageNum}${r.total ? ` (total ${r.total})` : ''}`)
}
const rows: [string, string][] = items.map(s => [
s.trust ? `${s.name} · ${s.trust}` : s.name,
String(s.description ?? '').slice(0, 160)
])
const footer: string[] = []
if (r.page && r.total_pages) {
footer.push(`page ${r.page} of ${r.total_pages}`)
}
if (r.total) {
footer.push(`${r.total} skills total`)
}
if (r.page && r.total_pages && r.page < r.total_pages) {
footer.push(`/skills browse ${r.page + 1} for more`)
}
panel(`Browse Skills${pageNum > 1 ? ` — p${pageNum}` : ''}`, [
{ rows },
...(footer.length ? [{ text: footer.join(' · ') }] : [])
])
})
)
.catch(ctx.guardedErr)
return
}
runViaSlashWorker()
}
},
{
help: 'view & toggle plugins (no arg opens the hub; enable/disable <name> for direct toggle)',
name: 'plugins',
run: (arg, ctx, cmd) => {
// No argument → open the interactive Plugins Hub overlay. Any
// subcommand (enable/disable/list/install/…) falls through to the
// text slash worker so it stays at parity with `hermes plugins`.
if (!arg.trim()) {
return patchOverlayState({ pluginsHub: true })
}
ctx.gateway.gw
.request<SlashExecResponse>('slash.exec', { command: cmd.slice(1), session_id: ctx.sid })
.then(r => {
if (ctx.stale()) {
return
}
const body = r?.output || '/plugins: no output'
const text = r?.warning ? `warning: ${r.warning}\n${body}` : body
const long = text.length > 180 || text.split('\n').filter(Boolean).length > 2
long ? ctx.transcript.page(text, 'Plugins') : ctx.transcript.sys(text)
})
.catch(ctx.guardedErr)
}
},
{
help: 'enable or disable tools (client-side history reset on change)',
name: 'tools',
run: (arg, ctx, cmd) => {
const [subcommand, ...names] = arg.trim().split(/\s+/).filter(Boolean)
if (subcommand !== 'disable' && subcommand !== 'enable') {
ctx.gateway.gw
.request<SlashExecResponse>('slash.exec', { command: cmd.slice(1), session_id: ctx.sid })
.then(r => {
if (ctx.stale()) {
return
}
const body = r?.output || '/tools: no output'
const text = r?.warning ? `warning: ${r.warning}\n${body}` : body
const long = text.length > 180 || text.split('\n').filter(Boolean).length > 2
long ? ctx.transcript.page(text, 'Tools') : ctx.transcript.sys(text)
})
.catch(ctx.guardedErr)
return
}
if (!names.length) {
ctx.transcript.sys(`usage: /tools ${subcommand} <name> [name ...]`)
ctx.transcript.sys(`built-in toolset: /tools ${subcommand} web`)
ctx.transcript.sys(`MCP tool: /tools ${subcommand} github:create_issue`)
return
}
ctx.gateway
.rpc<ToolsConfigureResponse>('tools.configure', { action: subcommand, names, session_id: ctx.sid })
.then(
ctx.guarded<ToolsConfigureResponse>(r => {
if (r.info) {
ctx.session.setSessionStartedAt(Date.now())
ctx.session.resetVisibleHistory(r.info)
}
if (r.changed?.length) {
ctx.transcript.sys(`${subcommand === 'disable' ? 'disabled' : 'enabled'}: ${r.changed.join(', ')}`)
}
if (r.unknown?.length) {
ctx.transcript.sys(`unknown toolsets: ${r.unknown.join(', ')}`)
}
if (r.missing_servers?.length) {
ctx.transcript.sys(`missing MCP servers: ${r.missing_servers.join(', ')}`)
}
if (r.reset) {
ctx.transcript.sys('session reset. new tool configuration is active.')
}
})
)
.catch(ctx.guardedErr)
}
}
]
+759
View File
@@ -0,0 +1,759 @@
import { usageBarsText } from '../../../components/overlayPrimitives.js'
import { introMsg, toTranscriptMessages } from '../../../domain/messages.js'
import { sessionScopedModelArg, TUI_SESSION_MODEL_FLAG } from '../../../domain/slash.js'
import type {
BackgroundStartResponse,
ConfigGetValueResponse,
ConfigSetResponse,
SessionBranchResponse,
SessionCompressResponse,
SessionUsageResponse,
SlashExecResponse,
VoiceToggleResponse
} from '../../../gatewayTypes.js'
import { formatVoiceRecordKey, parseVoiceRecordKey } from '../../../lib/platform.js'
import { fmtK } from '../../../lib/text.js'
import type { PanelSection } from '../../../types.js'
import { applyConfiguredTuiTheme } from '../../createGatewayEventHandler.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 USAGE_CTA = 'Run /subscription to change plan · /topup to add to your balance'
const TUI_SESSION_MODEL_RE = new RegExp(`(?:^|\\s)${TUI_SESSION_MODEL_FLAG}(?:\\s|$)`)
const REASONING_SESSION_FLAGS = new Set(['--session'])
const REASONING_GLOBAL_FLAGS = new Set(['--global'])
const modelValueForConfigSet = (arg: string) => {
const trimmed = arg.trim()
if (!trimmed) {
return trimmed
}
if (TUI_SESSION_MODEL_RE.test(trimmed)) {
return sessionScopedModelArg(trimmed)
}
return trimmed
}
const reasoningConfigPayload = (arg: string, sid: string) => {
const parts = arg.trim().split(/\s+/).filter(Boolean)
let scope = ''
const valueParts: string[] = []
for (const part of parts) {
const flag = part.toLowerCase()
if (REASONING_GLOBAL_FLAGS.has(flag)) {
scope = 'global'
continue
}
if (REASONING_SESSION_FLAGS.has(flag)) {
// Session scope is the default; accept the flag for parity with /model.
if (!scope) {
scope = 'session'
}
continue
}
valueParts.push(part)
}
const value = valueParts.join(' ')
return {
key: 'reasoning',
session_id: sid,
value,
...(scope ? { scope } : {})
}
}
export const sessionCommands: SlashCommand[] = [
{
aliases: ['background'],
help: 'launch a background prompt',
name: 'bg',
run: (arg, ctx) => {
if (!arg) {
return ctx.transcript.sys('/bg <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: 'ask a side question about this conversation',
name: 'btw',
run: (arg, ctx) => {
if (!arg) {
return ctx.transcript.sys('/btw <question>')
}
ctx.gateway.rpc<BackgroundStartResponse>('prompt.btw', { session_id: ctx.sid, text: arg }).then(
ctx.guarded<BackgroundStartResponse>(r => {
if (!r.task_id) {
return
}
ctx.transcript.sys(`btw ${r.task_id} — answering from a conversation snapshot`)
})
)
}
},
{
help: 'change or show model',
name: 'model',
run: (arg, ctx) => {
// No busy guard here (unlike session switching). A model change is a
// session-scoped config.set: idle it switches immediately; mid-turn the
// gateway QUEUES it and applies it at the next turn start (returning
// deferred:true) instead of rejecting. Either way the pick sticks without
// interrupting the stream or waiting on the swap.
if (!arg.trim()) {
return patchOverlayState({ modelPicker: true })
}
if (arg.trim() === '--refresh') {
return patchOverlayState({ modelPicker: { refresh: true } })
}
const switchModel = (confirmExpensiveModel = false) =>
ctx.gateway
.rpc<ConfigSetResponse>('config.set', {
confirm_expensive_model: confirmExpensiveModel,
key: 'model',
session_id: ctx.sid,
value: modelValueForConfigSet(arg)
})
.then(
ctx.guarded<ConfigSetResponse>(r => {
if (r.confirm_required) {
patchOverlayState({
confirm: {
cancelLabel: 'Cancel',
confirmLabel: 'Switch anyway',
danger: true,
detail: r.confirm_message || r.warning || 'This model has unusually high known pricing.',
onConfirm: () => switchModel(true),
title: 'Expensive model selection'
}
})
return
}
if (!r.value) {
return ctx.transcript.sys('error: invalid response: model switch')
}
ctx.transcript.sys(r.deferred ? `model → ${r.value} (applies next turn)` : `model → ${r.value}`)
ctx.local.maybeWarn(r)
patchUiState(state => ({
...state,
info: state.info ? { ...state.info, model: r.value! } : { model: r.value!, skills: {}, tools: {} }
}))
})
)
switchModel()
}
},
{
aliases: ['switch', 'session', 'resume'],
help: 'browse, switch, or resume sessions',
name: 'sessions',
run: (arg, ctx) => {
const trimmed = arg.trim()
// A new *live* session keeps the current one running in the background
// (it doesn't close it), so fanning out while busy is allowed — that's
// the whole point of multiple live sessions.
if (trimmed.toLowerCase() === 'new') {
return ctx.session.newLiveSession()
}
// `/resume <id|title>` (and `/sessions <id>`) load a cold session and
// CLOSE the current one, so guard it while a turn is in-flight to avoid
// corrupting streaming/busy state. Bare opens the overlay to browse.
if (trimmed) {
if (ctx.session.guardBusySessionSwitch('switch sessions')) {
return
}
return ctx.session.resumeById(trimmed)
}
patchOverlayState({ sessions: true })
}
},
{
help: 'attach an image',
name: 'image',
run: (arg, ctx) => ctx.composer.attachImagePath(arg)
},
{
help: 'switch personality for this session',
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.summary?.headline) {
const prefix = r.summary.noop ? '' : '✓ '
ctx.transcript.sys(`${prefix}${r.summary.headline}`)
if (r.summary.token_line) {
ctx.transcript.sys(` ${r.summary.token_line}`)
}
if (r.summary.note) {
ctx.transcript.sys(` ${r.summary.note}`)
}
return
}
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` : ''}`
)
})
)
.catch(ctx.guardedErr)
}
},
{
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.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)
ctx.voice.setVoiceTts(!!r.tts)
// Render the configured record key (config.yaml ``voice.record_key``)
// instead of hardcoded "Ctrl+B" — the gateway response carries the
// current value so /voice status and /voice on stay in sync with
// both the CLI and the TUI's actual binding (#18994).
//
// Copilot review on #19835 caught that rendering from the fresh
// backend response WITHOUT updating the frontend ``voice.recordKey``
// state would skew display and binding between config-edit and
// the next ``mtime`` poll (~5s). Parse once, push into state so
// ``useInputHandlers()`` picks up the new binding immediately.
//
// Round-2 follow-up: only push state when the response actually
// carries ``record_key`` — otherwise an older gateway (or a future
// branch that forgets to include it) would clobber a custom user
// binding back to the default on every /voice invocation. The
// label still falls back to the documented default for display.
const parsed = r.record_key ? parseVoiceRecordKey(r.record_key) : undefined
if (parsed) {
ctx.voice.setVoiceRecordKey(parsed)
}
const recordKeyLabel = formatVoiceRecordKey(parsed ?? parseVoiceRecordKey('ctrl+b'))
// 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: ${recordKeyLabel}`)
// CLI's "Requirements:" block — surfaces STT/audio setup issues
// so the user sees "STT provider: MISSING ..." instead of
// silently failing on every record-key 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(` ${recordKeyLabel} to start/stop recording`)
// Spoken-stop hint — backend-sourced from voice.stop_phrases so a
// custom phrase renders correctly; absent/empty means the feature
// is disabled (stop_phrases: []) and no hint is shown.
if (r.stop_hint) {
ctx.transcript.sys(` ${r.stop_hint}`)
}
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: 'toggle / adopt / resize an animated pet',
name: 'pet',
usage: '/pet [toggle | list | scale <n> | <slug>]',
run: (arg, ctx, cmd) => {
const sub = arg.trim().toLowerCase()
// Gallery picker — the interactive browse surface.
if (sub === 'list') {
return patchOverlayState({ petPicker: true })
}
// Bare /pet and /pet toggle flip display.pet.enabled via the slash worker.
ctx.gateway.gw
.request<SlashExecResponse>('slash.exec', { command: cmd.slice(1), session_id: ctx.sid })
.then(
ctx.guarded<SlashExecResponse>(r => {
const body = r.output || '/pet: no output'
ctx.transcript.sys(r.warning ? `warning: ${r.warning}\n${body}` : body)
})
)
.catch(ctx.guardedErr)
}
},
{
help: 'pin light/dark mode or trust auto-detection (usage: /theme [auto|light|dark])',
name: 'theme',
usage: '/theme [auto|light|dark]',
run: (arg, ctx) => {
const value = arg.trim().toLowerCase()
if (!value) {
return ctx.gateway
.rpc<ConfigGetValueResponse>('config.get', { key: 'theme' })
.then(ctx.guarded<ConfigGetValueResponse>(r => ctx.transcript.sys(`theme: ${r.value || 'auto'}`)))
}
if (!['auto', 'light', 'dark'].includes(value)) {
return ctx.transcript.sys('usage: /theme [auto|light|dark]')
}
// Apply only after the write is confirmed (mirrors /indicator): a
// failed config.set must not leave the session showing a theme that
// reverts on restart. A few ms later than an optimistic flip, but the
// env/theme state and config.yaml never disagree.
ctx.gateway
.rpc<ConfigSetResponse>('config.set', { key: 'theme', value })
.then(
ctx.guarded<ConfigSetResponse>(r => {
if (r.value === undefined) {
return
}
applyConfiguredTuiTheme(value)
ctx.transcript.sys(`theme → ${value}`)
})
)
.catch(ctx.guardedErr)
}
},
{
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', session_id: ctx.sid })
.then(
ctx.guarded<ConfigGetValueResponse>(
r => r.value && ctx.transcript.sys(`reasoning: ${r.value} · display ${r.display || 'hide'}`)
)
)
}
ctx.gateway.rpc<ConfigSetResponse>('config.set', reasoningConfigPayload(arg, ctx.sid ?? '')).then(
ctx.guarded<ConfigSetResponse>(r => {
if (!r.value) {
return
}
if (r.value === 'hide') {
patchUiState(state => ({
...state,
sections: { ...state.sections, thinking: 'hidden' },
showReasoning: false
}))
} else if (r.value === 'show') {
patchUiState(state => ({
...state,
sections: { ...state.sections, thinking: 'expanded' },
showReasoning: true
}))
}
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 + Nous credits',
name: 'usage',
run: (_arg, ctx) => {
ctx.gateway.rpc<SessionUsageResponse>('session.usage', { session_id: ctx.sid }).then(r => {
if (ctx.stale()) {
return
}
const sys = ctx.transcript.sys
if (r) {
patchUiState({
usage: { calls: r.calls ?? 0, input: r.input ?? 0, output: r.output ?? 0, total: r.total ?? 0 }
})
}
// Nous balance block is agent-independent (a portal fetch), so it shows
// even with zero API calls or on a resumed session. Prefer the shared
// dollar usage model (two-bar view, dollars-only); fall back to the
// legacy text lines only when the model is unavailable.
const usageModel = r?.usage
const barLines = usageBarsText(usageModel)
let showedBalance = false
if (usageModel?.available && (barLines.length || usageModel.status === 'free')) {
const sections: PanelSection[] = []
const plan = usageModel.plan_name ?? (usageModel.status === 'free' ? 'Free' : null)
if (plan) {
sections.push({
text: `Plan: ${plan}${usageModel.renews_display ? ` · renews ${usageModel.renews_display}` : ''}`
})
}
if (barLines.length) {
sections.push({ text: barLines.join('\n') })
}
if (usageModel.status === 'free') {
sections.push({ text: '> Free · free models only. Run /subscription to reach paid models.' })
} else if (usageModel.status === 'low') {
sections.push({
text: `! Low balance · ${usageModel.total_spendable_display ?? 'under $5'} left. Run /topup or /subscription.`
})
}
ctx.transcript.panel('Balance', sections)
showedBalance = true
} else {
const creditsLines = r?.credits_lines ?? []
if (creditsLines.length) {
ctx.transcript.panel('Nous balance', [{ text: creditsLines.join('\n') }])
showedBalance = true
}
}
if (!r?.calls) {
if (!showedBalance) {
sys('no API calls yet')
}
sys(USAGE_CTA)
return
}
const f = (v: number | undefined) => (v ?? 0).toLocaleString()
const rows: [string, string][] = [
['Model', r.model ?? ''],
['Input tokens', f(r.input)],
['Output tokens', f(r.output)],
['Total tokens', f(r.total)],
['API calls', f(r.calls)]
]
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)
sys(USAGE_CTA)
})
}
}
]
+20
View File
@@ -0,0 +1,20 @@
import { withInkSuspended } from '@hermes/ink'
import { launchHermesCommand } from '../../../lib/externalCli.js'
import { runExternalSetup } from '../../setupHandoff.js'
import type { SlashCommand } from '../types.js'
export const setupCommands: SlashCommand[] = [
{
help: 'run full setup wizard (launches `hermes setup`)',
name: 'setup',
run: (arg, ctx) =>
void runExternalSetup({
args: ['setup', ...arg.split(/\s+/).filter(Boolean)],
ctx,
done: 'setup complete — starting session…',
launcher: launchHermesCommand,
suspend: withInkSuspended
})
}
]
@@ -0,0 +1,176 @@
import type {
BillingMutationResponse,
BillingStateResponse,
SubscriptionPreviewResponse,
SubscriptionStateResponse,
SubscriptionUpgradeResponse
} from '../../../gatewayTypes.js'
import { openExternalUrl } from '../../../lib/openExternalUrl.js'
import type { SubscriptionOverlayCtx } from '../../interfaces.js'
import { patchOverlayState } from '../../overlayStore.js'
import type { SlashCommand, SlashRunCtx } from '../types.js'
type Sys = (text: string) => void
/**
* Build the manage-subscription URL locally from the loaded subscription state.
*
* Uses `portal_url` (the resolved portal base URL carried in the state) and
* `org_id` to construct `{portal_base}/manage-subscription?org_id=<id>`.
* `org_id` pins the page to the correct account in multi-org situations.
* Falls back to bare `/manage-subscription` if org_id is absent.
*/
function buildManageUrl(s: SubscriptionStateResponse, tierId?: string): string | null {
// portal_url is already an absolute URL resolved by resolve_portal_base_url()
// on the Python side (e.g. https://portal.nousresearch.com/billing). Strip any
// path so we can attach /manage-subscription cleanly.
let base: string | null = null
if (s.portal_url) {
try {
base = new URL(s.portal_url).origin
} catch {
// A malformed portal_url must not throw out of the Ink key handler
// (it would crash the overlay) — treat it as "no manage URL".
return null
}
}
if (!base) {
return null
}
const url = new URL('/manage-subscription', base)
if (s.org_id) {
url.searchParams.set('org_id', s.org_id)
}
if (tierId) {
url.searchParams.set('plan', tierId)
}
return url.toString()
}
/**
* Build the ctx the overlay uses to talk to the gateway + emit transcript
* lines. Mirrors topup.ts's buildOverlayCtx — all RPC + error-mapping logic
* lives here (single source of truth); the overlay only renders + routes keys.
*/
const buildSubscriptionCtx = (
ctx: SlashRunCtx,
sys: Sys,
initialState: SubscriptionStateResponse
): SubscriptionOverlayCtx => ({
fetchCard: () =>
ctx.gateway
.rpc<BillingStateResponse>('billing.state', {})
.then(r => (r?.ok ? (r.card ?? null) : null))
.catch(() => null),
openManageLink: (tierId?: string) => {
const url = buildManageUrl(initialState, tierId)
if (!url) {
sys('Could not build manage URL — is your portal configured?')
return Promise.resolve(false)
}
const opened = openExternalUrl(url)
if (opened) {
sys('Opening your subscription page in the browser — finish there, then re-run /subscription.')
} else {
sys('Could not open browser — visit your subscription page manually at ' + url)
}
return Promise.resolve(opened)
},
openPortal: (url: string) => {
if (openExternalUrl(url)) {
sys('Opening the portal in your browser — finish there, then re-run /subscription.')
} else {
sys('Could not open browser — visit ' + url + ' to finish.')
}
},
preview: tierId =>
ctx.gateway
.rpc<SubscriptionPreviewResponse>('subscription.preview', { subscription_type_id: tierId })
.then(r => r ?? null)
.catch(() => null),
refreshState: () =>
ctx.gateway
.rpc<SubscriptionStateResponse>('subscription.state', {})
.then(r => r ?? null)
.catch(() => null),
requestRemoteSpending: () =>
ctx.gateway
.rpc<BillingMutationResponse>('billing.step_up', { session_id: ctx.sid ?? undefined })
// Carry the typed denial (session_revoked / remote_spending_revoked /
// rate_limited / …) so the stepup screen shows the right recovery.
.then(r => ({ error: r?.error, granted: !!(r && r.ok && r.granted), message: r?.message }))
.catch(() => ({
granted: false,
message: 'Could not reach the billing service — check your connection, then retry.'
})),
resume: () =>
ctx.gateway
.rpc<BillingMutationResponse>('subscription.resume', {})
.then(r => r ?? null)
.catch(() => null),
scheduleCancellation: () =>
ctx.gateway
.rpc<BillingMutationResponse>('subscription.change', { cancel: true })
.then(r => r ?? null)
.catch(() => null),
scheduleChange: tierId =>
ctx.gateway
.rpc<BillingMutationResponse>('subscription.change', { subscription_type_id: tierId })
.then(r => r ?? null)
.catch(() => null),
sys,
upgrade: (tierId, idempotencyKey) =>
ctx.gateway
.rpc<SubscriptionUpgradeResponse>('subscription.upgrade', {
subscription_type_id: tierId,
...(idempotencyKey ? { idempotency_key: idempotencyKey } : {})
})
.then(r => r ?? null)
.catch(() => null)
})
export const subscriptionCommands: SlashCommand[] = [
{
help: 'View or change your Nous subscription plan',
name: 'subscription',
aliases: ['upgrade'],
// ZERO sub-commands: bare `/subscription` fetches state and opens the
// overlay's in-terminal change flow (only /upgrade's charge_now confirm
// moves money, via the V3 upgrade route).
run: (_arg, ctx) => {
const sys: Sys = ctx.transcript.sys
ctx.gateway
.rpc<SubscriptionStateResponse>('subscription.state', {})
.then(
ctx.guarded<SubscriptionStateResponse>(s => {
if (!s.logged_in) {
sys('Not logged into Nous Portal — run /portal to log in, then /subscription.')
return
}
patchOverlayState({
subscription: {
ctx: buildSubscriptionCtx(ctx, sys, s),
screen: 'overview',
state: s
}
})
})
)
.catch(ctx.guardedErr)
}
}
]
+422
View File
@@ -0,0 +1,422 @@
import { driveChargeSettlement, type SettlementOutcome } from '@hermes/shared/charge-settlement'
import type {
BillingChargeResponse,
BillingChargeStatusResponse,
BillingErrorPayload,
BillingMutationResponse,
BillingStateResponse
} from '../../../gatewayTypes.js'
import { openExternalUrl } from '../../../lib/openExternalUrl.js'
import type { BillingChargeOutcome, BillingOverlayCtx } from '../../interfaces.js'
import { patchOverlayState } from '../../overlayStore.js'
import type { SlashCommand, SlashRunCtx } from '../types.js'
const UNCONFIRMED_CHARGE_MESSAGE =
'🟡 Your last charges outcome is unconfirmed — check your balance/history before retrying.'
type Sys = (text: string) => void
/** Map a typed billing error envelope to user-facing copy + portal funnel. */
const renderBillingError = (
sys: Sys,
ctx: SlashRunCtx,
env: {
actor?: string
code?: string
error?: string
message?: string
payload?: BillingErrorPayload
portal_url?: string | null
recovery?: string
retry_after?: number | null
}
): void => {
const portal = env.portal_url
switch (env.error) {
case 'insufficient_scope':
// Reached by non-charge mutations (e.g. auto-reload config) that need
// Remote Spending allowed. The resumable step-up lives on the buy/charge
// path; point the user there rather than leaking the raw scope name.
sys('This needs Remote Spending allowed. Start a top-up to allow it, then retry.')
break
case 'remote_spending_revoked': {
// CF-4: this terminal's spend was revoked. Kill the spend UI NOW (don't
// wait for the token refresh ~15 min away) and tell the user who did it.
patchOverlayState({ billing: null })
const who =
env.actor === 'admin'
? 'An admin stopped remote spending for this terminal.'
: 'You stopped remote spending for this terminal.'
sys(`${who} Reconnect to restore — run /portal to re-authorize this terminal.`)
return
}
case 'session_revoked':
// Stronger than a spend-revoke: the whole session is gone → full re-login.
patchOverlayState({ billing: null })
sys('Your session was logged out. Run /portal to log in again.')
return
case 'cli_billing_disabled':
case 'remote_spending_disabled':
// Account-wide switch is OFF (dual-emitted error/code). A billing admin can
// turn it on from the portal's Hermes Agent page; this is NOT a per-terminal stop.
sys(
"Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page."
)
break
case 'role_required':
sys(
'Adding funds needs someone with billing permissions (owner, admin, or finance admin), or manage this on the portal.'
)
break
case 'consent_required':
sys('This action needs a one-time card confirmation and consent step on the portal before it can proceed.')
break
case 'org_access_denied':
sys("This token isn't bound to an org you can manage. Sign in with the right org, or manage this on the portal.")
break
case 'upgrade_cap_exceeded':
sys('🔴 Daily plan-change limit reached (5 per org) — try again tomorrow, or manage this on the portal.')
break
case 'auto_top_up_disabled_failures':
sys(
'Auto-reload was turned off after repeated charge failures. Fix the card issue, then re-enable it from /topup → Auto-reload.'
)
break
case 'idempotency_conflict':
sys('🔴 That charge key was already used for a different amount. Start a fresh top-up.')
break
case 'no_payment_method':
sys(
'💳 No saved card for terminal charges yet. Set one up on the portal ' +
"(one-time credit buys don't save a reusable card)."
)
break
case 'monthly_cap_exceeded': {
// Surface the remaining headroom the server attaches (parity with the CLI).
const remaining = env.payload?.remainingUsd
sys(
remaining != null
? `🔴 Monthly spend cap reached — $${remaining} headroom left.`
: '🔴 Monthly spend cap reached.'
)
break
}
case 'rate_limited':
case 'temporarily_unavailable': {
// 429 throttle OR 503 gate-fail-closed: NOT a payment failure, NOT a
// revoke. Back off and tell the user to retry.
const mins = env.retry_after ? ` (try again in ~${Math.max(1, Math.round(env.retry_after / 60))} min)` : ''
sys(`🟡 Too many charges right now${mins}. This isn't a payment failure.`)
break
}
case 'stripe_unavailable': {
const mins = env.retry_after ? ` (try again in ~${Math.max(1, Math.round(env.retry_after / 60))} min)` : ''
sys(`🟡 Stripe is having trouble right now — try again shortly${mins}.`)
break
}
default:
sys(`🔴 ${env.message || env.error || 'Billing request failed.'}`)
}
if (portal) {
sys(`Portal: ${portal}`)
}
}
/**
* Run the Remote-Spending device flow and resolve whether the grant landed.
*
* The browser opens via the gateway's out-of-band `billing.step_up.verification`
* event (handled globally in createGatewayEventHandler), so this just kicks the
* blocking `billing.step_up` RPC and awaits its result. A reject (the device
* flow can outlive the RPC's timeout while the user is still authorizing) is
* treated as "not yet granted" — non-fatal; the grant persists gateway-side.
*
* NOTE: never surface the raw `billing:manage` scope — the user-facing concept
* is "Remote Spending".
*/
const requestRemoteSpending = (ctx: SlashRunCtx): Promise<boolean> =>
ctx.gateway
.rpc<BillingMutationResponse>('billing.step_up', { session_id: ctx.sid ?? undefined })
.then(r => !!(r && r.ok && r.granted))
.catch(() => false)
/** Poll a charge to a terminal state (settled/failed/timeout). Non-blocking. */
const pollCharge = (sys: Sys, ctx: SlashRunCtx, chargeId: string, portalUrl?: string | null): void => {
const renderOutcome = (outcome: SettlementOutcome): void => {
switch (outcome.kind) {
case 'settled':
sys(`${outcome.status.amount_usd ? `$${outcome.status.amount_usd}` : 'Credits'} added.`)
return
case 'failed':
renderChargeFailed(sys, outcome.status.reason, portalUrl)
return
case 'refused':
sys(`🔴 Could not check the charge: ${outcome.status.message || outcome.status.error || 'error'}`)
return
case 'ambiguous':
if (outcome.status) {
renderBillingError(sys, ctx, outcome.status)
sys(UNCONFIRMED_CHARGE_MESSAGE)
return
}
if ('cause' in outcome) {
ctx.guardedErr(outcome.cause)
}
if (!ctx.stale()) {
sys(UNCONFIRMED_CHARGE_MESSAGE)
}
return
case 'timed_out':
sys(
'🟡 Still processing after 5 minutes — this is a timeout, not a failure. ' +
'Check /topup or the portal shortly.'
)
if (portalUrl) {
sys(`Portal: ${portalUrl}`)
}
return
case 'cancelled':
return
}
}
void driveChargeSettlement({
fetchStatus: async () => {
const status = await ctx.gateway.rpc<BillingChargeStatusResponse>('billing.charge_status', {
charge_id: chargeId
})
if (!status) {
throw new Error('billing.charge_status returned no response')
}
return status
},
isCancelled: () => ctx.stale(),
now: () => Date.now(),
sleep: ms => new Promise(resolve => setTimeout(resolve, ms))
}).then(outcome => {
if (outcome.kind === 'ambiguous' && !outcome.status) {
renderOutcome(outcome)
return
}
ctx.guarded<SettlementOutcome>(renderOutcome)(outcome)
})
}
const renderChargeFailed = (sys: Sys, reason?: string | null, portalUrl?: string | null): void => {
switch ((reason || '').trim()) {
case 'authentication_required':
sys('🔴 Your bank requires verification (3DS). Complete it on the portal to finish this purchase.')
break
case 'payment_method_expired':
sys('🔴 Your card has expired. Update it on the portal.')
break
case 'card_declined':
sys('🔴 Your card was declined. Try another card on the portal.')
break
case 'processing_error':
sys("🔴 The charge didn't go through (processing_error).")
break
default:
sys(`🔴 The charge didn't go through (${reason || 'processing_error'}).`)
}
// Funnel to the portal after any failure (parity with cli.py _billing_portal_hint).
if (portalUrl) {
sys(`Portal: ${portalUrl}`)
}
}
/** Validate a custom amount against state bounds + 2dp, mirroring the server. */
const validateAmount = (raw: string, s: BillingStateResponse): { amount?: string; error?: string } => {
const cleaned = raw.trim().replace(/^\$/, '').trim()
if (!cleaned || !/^\d+(\.\d{1,2})?$/.test(cleaned)) {
return { error: 'Enter a dollar amount, e.g. 100 (max 2 decimal places).' }
}
const value = Number(cleaned)
if (!(value > 0)) {
return { error: 'Amount must be greater than $0.' }
}
if (s.min_usd != null && value < Number(s.min_usd)) {
return { error: `Minimum is $${s.min_usd}.` }
}
if (s.max_usd != null && value > Number(s.max_usd)) {
return { error: `Maximum is $${s.max_usd}.` }
}
return { amount: cleaned }
}
/**
* Build the closure bundle the BillingOverlay needs to talk to the gateway
* and emit transcript lines. Keeps ALL RPC + error-mapping logic here
* (single source of truth) — the overlay only renders + routes keys.
*/
const buildOverlayCtx = (ctx: SlashRunCtx, sys: Sys, s: BillingStateResponse): BillingOverlayCtx => ({
applyAutoReload: (enabled, threshold, topUp) =>
ctx.gateway
.rpc<BillingMutationResponse>('billing.auto_reload', {
enabled,
...(threshold != null ? { threshold } : {}),
...(topUp != null ? { top_up_amount: topUp } : {})
})
.then(r => {
if (r && r.ok) {
return true
}
if (r) {
renderBillingError(sys, ctx, r)
}
return false
})
.catch(e => {
ctx.guardedErr(e)
return false
}),
charge: (amount: string, idempotencyKey?: string): Promise<BillingChargeOutcome> => {
sys('💳 Charge submitted — confirming settlement…')
return ctx.gateway
.rpc<BillingChargeResponse>('billing.charge', {
amount_usd: amount,
...(idempotencyKey ? { idempotency_key: idempotencyKey } : {})
})
.then((r): BillingChargeOutcome => {
if (!r) {
return 'error'
}
if (r.ok && r.charge_id) {
pollCharge(sys, ctx, r.charge_id, s.portal_url)
return 'submitted'
}
// insufficient_scope → the overlay routes to the resumable step-up
// (no error line here; the stepup screen owns that UX).
if (r.error === 'insufficient_scope') {
return 'needs_remote_spending'
}
renderBillingError(sys, ctx, r)
return 'error'
})
.catch((e): BillingChargeOutcome => {
ctx.guardedErr(e)
return 'error'
})
},
requestRemoteSpending: () => requestRemoteSpending(ctx),
openPortal: (url: string) => {
openExternalUrl(url)
sys(`Opening portal: ${url}`)
},
refreshState: () =>
ctx.gateway
.rpc<BillingStateResponse>('billing.state', {})
.then(r => (r?.ok ? r : null))
.catch(() => null),
sys,
validate: (raw: string) => validateAmount(raw, s)
})
export const topupCommands: SlashCommand[] = [
{
help: 'Show your balance and manage billing — add funds, auto-reload, limits',
name: 'topup',
// ZERO sub-commands (plan §0.4): any arg is ignored. Bare `/topup`
// fetches state and opens the interactive overlay (CLI/TUI parity).
run: (_arg, ctx) => {
const sys: Sys = ctx.transcript.sys
ctx.gateway
.rpc<BillingStateResponse>('billing.state', {})
.then(
ctx.guarded<BillingStateResponse>(s => {
if (!s.logged_in) {
sys('💳 Not logged into Nous Portal — run /portal to log in, then /topup.')
return
}
patchOverlayState({
billing: {
ctx: buildOverlayCtx(ctx, sys, s),
pendingCharge: null,
screen: 'overview',
state: s
}
})
})
)
.catch(ctx.guardedErr)
}
}
]
+132
View File
@@ -0,0 +1,132 @@
import type { WakeStartResponse, WakeStatusResponse, WakeStopResponse } from '../../../gatewayTypes.js'
import { setWakeUserDisabled } from '../../wakeState.js'
import type { SlashCommand, SlashRunCtx } from '../types.js'
const WAKE_SUBCOMMANDS = ['on', 'off', 'status'] as const
type WakeSub = (typeof WAKE_SUBCOMMANDS)[number]
const isWakeSub = (value: string): value is WakeSub => (WAKE_SUBCOMMANDS as readonly string[]).includes(value)
// Friendly text for the gateway's wake.start refusal codes. Unknown codes
// fall through to the raw reason so new server-side codes stay visible.
const START_REASON_TEXT: Record<string, string> = {
disabled: 'disabled (config wake_word.enabled)',
disabled_for_surface: 'scoped to another surface (config wake_word.surface)',
not_owner: 'another surface owns the listener',
owned: 'another surface owns the listener',
unavailable: 'unavailable'
}
const startFailureLine = (r: WakeStartResponse): string => {
const reason = r.reason ?? 'unknown'
const base = START_REASON_TEXT[reason] ?? reason
const owner = r.owner_surface ? ` (owned by ${r.owner_surface})` : ''
const hint = r.hint?.trim() ? `${r.hint.trim()}` : ''
return `wake: not started — ${base}${owner}${hint}`
}
const statusLine = (r: WakeStatusResponse): string => {
const phrase = r.phrase ? ` for “${r.phrase}` : ''
const provider = r.provider ? ` · ${r.provider}` : ''
if (r.listening) {
if (r.audio_silent) {
const hint = r.hint?.trim() ? `${r.hint.trim()}` : ''
return `wake: listening${phrase}${provider} · ⚠ mic delivers only silence${hint}`
}
return `wake: listening${phrase}${provider}`
}
if (r.owner_surface && !r.owned_by_caller) {
return `wake: off here · listener owned by ${r.owner_surface}${phrase}${provider}`
}
if (r.available === false) {
const hint = r.hint?.trim() ? `${r.hint.trim()}` : ''
return `wake: unavailable${hint}`
}
return `wake: off${phrase}${provider} · /wake on to arm`
}
const runOn = (ctx: SlashRunCtx): void => {
setWakeUserDisabled(false)
// persist: true — an explicit /wake on writes wake_word.enabled to config
// so the choice survives restarts (the backend only persists on gesture
// paths; reconnect auto-arm never does).
ctx.gateway
.rpc<WakeStartResponse>('wake.start', { persist: true, surface: 'tui' })
.then(
ctx.guarded<WakeStartResponse>(r => {
if (!r.started) {
return ctx.transcript.sys(startFailureLine(r))
}
const phrase = r.phrase ? ` for “${r.phrase}` : ''
const provider = r.provider ? ` · ${r.provider}` : ''
const saved = r.enabled_persisted ? ' · enabled in config' : ''
ctx.transcript.sys(`wake: listening${phrase}${provider}${saved}`)
})
)
.catch(ctx.guardedErr)
}
const runOff = (ctx: SlashRunCtx): void => {
// Remember the explicit opt-out so gateway reconnects don't re-arm the
// listener behind the user's back (see wakeState.ts).
setWakeUserDisabled(true)
ctx.gateway
.rpc<WakeStopResponse>('wake.stop', { persist: true })
.then(
ctx.guarded<WakeStopResponse>(r => {
const saved = r.disabled_persisted ? ' · disabled in config' : ''
if (r.stopped) {
return ctx.transcript.sys(`wake: listener off${saved}`)
}
const reason = r.reason === 'not_owner' ? 'this surface doesnt own the listener' : (r.reason ?? 'not running')
ctx.transcript.sys(`wake: nothing to stop — ${reason}${saved}`)
})
)
.catch(ctx.guardedErr)
}
const runStatus = (ctx: SlashRunCtx): void => {
ctx.gateway
.rpc<WakeStatusResponse>('wake.status', {})
.then(ctx.guarded<WakeStatusResponse>(r => ctx.transcript.sys(statusLine(r))))
.catch(ctx.guardedErr)
}
const WAKE_RUNNERS: Record<WakeSub, (ctx: SlashRunCtx) => void> = {
off: runOff,
on: runOn,
status: runStatus
}
export const wakeCommands: SlashCommand[] = [
{
help: "toggle the 'Hey Hermes' wake word listener [on|off|status]",
name: 'wake',
usage: '/wake [on|off|status]',
run: (arg, ctx) => {
const sub = arg.trim().toLowerCase()
if (sub && !isWakeSub(sub)) {
return ctx.transcript.sys('usage: /wake [on|off|status]')
}
WAKE_RUNNERS[sub && isWakeSub(sub) ? sub : 'status'](ctx)
}
}
]
+90
View File
@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest'
import { normalizeSlashSearchQuery, rankSlashItems, scoreSlashMenuItem, tokenizeSearchText } from './fuzzyScore.js'
describe('normalizeSlashSearchQuery', () => {
it('trims, strips leading slashes, and lowercases', () => {
expect(normalizeSlashSearchQuery(' /Model ')).toBe('model')
expect(normalizeSlashSearchQuery('//help')).toBe('help')
expect(normalizeSlashSearchQuery('plain')).toBe('plain')
})
})
describe('tokenizeSearchText', () => {
it('returns the full lowercased value plus alphanumeric word tokens', () => {
expect(tokenizeSearchText('Commit & Push')).toEqual(['commit & push', 'commit', 'push'])
})
})
describe('scoreSlashMenuItem', () => {
const item = {
aliases: ['recap', 'summary'],
description: 'Turn session recaps on/off',
id: 'recaps',
label: 'recaps'
}
it('scores exact name matches at tier 0', () => {
expect(scoreSlashMenuItem(item, 'recaps')).toBe(0)
})
it('scores exact alias matches at tier 0', () => {
expect(scoreSlashMenuItem(item, 'summary')).toBe(0)
})
it('scores name prefixes at tier 1 and name substrings at tier 2', () => {
expect(scoreSlashMenuItem(item, 'rec')).toBe(1)
expect(scoreSlashMenuItem(item, 'caps')).toBe(2)
})
it('scores description matches at the +3 offset, below any name tier', () => {
expect(scoreSlashMenuItem({ description: 'Turn session recaps on/off', id: 'other' }, 'session')).toBe(3)
expect(scoreSlashMenuItem({ description: 'Turn session recaps on/off', id: 'other' }, 'sess')).toBe(4)
expect(scoreSlashMenuItem({ description: 'Turn session recaps on/off', id: 'other' }, 'essio')).toBe(5)
})
it('prefers the name tier when both name and description match', () => {
expect(scoreSlashMenuItem(item, 'recap')).toBe(0)
})
it('returns Infinity when nothing matches', () => {
expect(scoreSlashMenuItem(item, 'zzz')).toBe(Number.POSITIVE_INFINITY)
})
})
describe('rankSlashItems', () => {
const apps = [
{ help: 'Show available commands', id: 'help' },
{ help: 'Start a countdown timer', id: 'clock' },
{ help: 'Select a model', id: 'models' }
]
const toScoreItem = (app: (typeof apps)[number]) => ({ description: app.help, id: app.id })
it('returns the list untouched for an empty query', () => {
expect(rankSlashItems(apps, '/', toScoreItem)).toEqual(apps)
})
it('surfaces description matches the prefix filter would miss', () => {
expect(rankSlashItems(apps, '/timer', toScoreItem).map(app => app.id)).toEqual(['clock'])
})
it('ranks name matches above description matches and drops non-matches', () => {
const ranked = rankSlashItems([{ help: 'model picker widget', id: 'gallery' }, ...apps], '/model', toScoreItem)
expect(ranked.map(app => app.id)).toEqual(['models', 'gallery'])
})
it('keeps original order within a score tier', () => {
const ranked = rankSlashItems(
[
{ help: '', id: 'mod-b' },
{ help: '', id: 'mod-a' }
],
'/mod',
toScoreItem
)
expect(ranked.map(app => app.id)).toEqual(['mod-b', 'mod-a'])
})
})
+75
View File
@@ -0,0 +1,75 @@
/** Description-aware fuzzy scoring for the slash-command menu.
*
* Ported from superagent-ai/grok-cli `src/ui/slash-menu.ts`: candidates are
* scored in tiers — exact match on id/label/alias (0), prefix (1), substring
* (2) — and the DESCRIPTION text is tokenized and matched at a +3 offset
* (exact word 3, word prefix 4, word substring 5). Typing `/summary` thus
* surfaces a command whose description mentions summaries even though no
* command name starts with it. Lower score wins; `Infinity` means no match.
*/
export interface SlashScoreItem {
aliases?: string[]
description?: string
id: string
label?: string
}
/** Lowercase the value and return it alongside its alphanumeric word tokens. */
export function tokenizeSearchText(value: string): string[] {
const normalized = value.toLowerCase()
return [normalized, ...normalized.split(/[^a-z0-9]+/).filter(Boolean)]
}
/** Trim, drop leading slashes, lowercase — `/Model ` and `model` score alike. */
export function normalizeSlashSearchQuery(query: string): string {
return query.trim().replace(/^\/+/, '').toLowerCase()
}
function scoreFields(fields: string[], query: string, offset: number): number {
for (const field of fields) {
if (field === query || `/${field}` === query) {
return offset
}
}
for (const field of fields) {
if (field.startsWith(query) || `/${field}`.startsWith(query)) {
return offset + 1
}
}
for (const field of fields) {
if (field.includes(query)) {
return offset + 2
}
}
return Number.POSITIVE_INFINITY
}
/** Score one item against a normalized query. Lower is better; Infinity = no match. */
export function scoreSlashMenuItem(item: SlashScoreItem, query: string): number {
const commandFields = [item.id, item.label ?? '', ...(item.aliases ?? [])].filter(Boolean).flatMap(tokenizeSearchText)
const descriptionFields = tokenizeSearchText(item.description ?? '')
return Math.min(scoreFields(commandFields, query, 0), scoreFields(descriptionFields, query, 3))
}
/** Filter and stable-sort `items` by score (then original order). An empty
* query returns the list untouched so browsing keeps the caller's order. */
export function rankSlashItems<T>(items: T[], query: string, toScoreItem: (item: T) => SlashScoreItem): T[] {
const normalized = normalizeSlashSearchQuery(query)
if (!normalized) {
return items
}
return items
.map((item, index) => ({ index, item, score: scoreSlashMenuItem(toScoreItem(item), normalized) }))
.filter(entry => entry.score !== Number.POSITIVE_INFINITY)
.sort((a, b) => a.score - b.score || a.index - b.index)
.map(entry => entry.item)
}
+26
View File
@@ -0,0 +1,26 @@
import { coreCommands } from './commands/core.js'
import { debugCommands } from './commands/debug.js'
import { opsCommands } from './commands/ops.js'
import { sessionCommands } from './commands/session.js'
import { setupCommands } from './commands/setup.js'
import { subscriptionCommands } from './commands/subscription.js'
import { topupCommands } from './commands/topup.js'
import { wakeCommands } from './commands/wake.js'
import type { SlashCommand } from './types.js'
export const SLASH_COMMANDS: SlashCommand[] = [
...coreCommands,
...topupCommands,
...sessionCommands,
...subscriptionCommands,
...opsCommands,
...wakeCommands,
...setupCommands,
...debugCommands
]
const byName = new Map<string, SlashCommand>(
SLASH_COMMANDS.flatMap(cmd => [cmd.name, ...(cmd.aliases ?? [])].map(name => [name, cmd] as const))
)
export const findSlashCommand = (name: string) => byName.get(name.toLowerCase())
+21
View File
@@ -0,0 +1,21 @@
import type { MutableRefObject } from 'react'
import type { SlashHandlerContext, UiState } from '../interfaces.js'
export interface SlashRunCtx extends SlashHandlerContext {
flight: number
guarded: <T>(fn: (r: T) => void) => (r: null | T) => void
guardedErr: (e: unknown) => void
sid: null | string
slashFlightRef: MutableRefObject<number>
stale: () => boolean
ui: UiState
}
export interface SlashCommand {
aliases?: string[]
help?: string
name: string
run: (arg: string, ctx: SlashRunCtx, cmd: string) => void
usage?: string
}
+159
View File
@@ -0,0 +1,159 @@
import { atom } from 'nanostores'
import type { SpawnTreeLoadResponse } from '../gatewayTypes.js'
import type { SubagentProgress, SubagentStatus } from '../types.js'
export interface SpawnSnapshot {
finishedAt: number
fromDisk?: boolean
id: string
label: string
path?: string
sessionId: null | string
startedAt: number
subagents: SubagentProgress[]
}
export interface SpawnDiffPair {
baseline: SpawnSnapshot
candidate: SpawnSnapshot
}
const HISTORY_LIMIT = 10
const KNOWN_SUBAGENT_STATUSES = new Set<SubagentStatus>([
'completed',
'error',
'failed',
'interrupted',
'queued',
'running',
'timeout'
])
const normalizeSubagentStatus = (status: unknown, fallback: SubagentStatus): SubagentStatus => {
if (typeof status !== 'string') {
return fallback
}
const normalized = status.toLowerCase() as SubagentStatus
return KNOWN_SUBAGENT_STATUSES.has(normalized) ? normalized : fallback
}
export const $spawnHistory = atom<SpawnSnapshot[]>([])
export const $spawnDiff = atom<null | SpawnDiffPair>(null)
export const getSpawnHistory = () => $spawnHistory.get()
export const getSpawnDiff = () => $spawnDiff.get()
export const clearSpawnHistory = () => $spawnHistory.set([])
export const clearDiffPair = () => $spawnDiff.set(null)
export const setDiffPair = (pair: SpawnDiffPair) => $spawnDiff.set(pair)
/**
* Commit a finished turn's spawn tree to history. Keeps the last 10
* non-empty snapshots — empty turns (no subagents) are dropped.
*
* Why in-memory? The primary investigation loop is "I just ran a fan-out,
* it misbehaved, let me look at what happened" — same-session debugging.
* Disk persistence across process restarts is a natural extension but
* adds RPC surface for a less-common path.
*/
export const pushSnapshot = (
subagents: readonly SubagentProgress[],
meta: { sessionId?: null | string; startedAt?: null | number }
) => {
if (!subagents.length) {
return
}
const now = Date.now()
const started = meta.startedAt ?? Math.min(...subagents.map(s => s.startedAt ?? now))
const snap: SpawnSnapshot = {
finishedAt: now,
id: `snap-${now.toString(36)}`,
label: summarizeLabel(subagents),
sessionId: meta.sessionId ?? null,
startedAt: Number.isFinite(started) ? started : now,
subagents: subagents.map(item => ({ ...item }))
}
const next = [snap, ...$spawnHistory.get()].slice(0, HISTORY_LIMIT)
$spawnHistory.set(next)
}
function summarizeLabel(subagents: readonly SubagentProgress[]): string {
const top = subagents
.filter(s => s.parentId == null || subagents.every(o => o.id !== s.parentId))
.slice(0, 2)
.map(s => s.goal || 'subagent')
.join(' · ')
return top || `${subagents.length} agent${subagents.length === 1 ? '' : 's'}`
}
/**
* Push a disk-loaded snapshot onto the front of the history stack so the
* overlay can pick it up at index 1 via /replay load. Normalises the
* server payload (arbitrary list) into the same SubagentProgress shape
* used for live data — defensive against cross-version reads.
*/
export const pushDiskSnapshot = (r: SpawnTreeLoadResponse, path: string) => {
const raw = Array.isArray(r.subagents) ? r.subagents : []
const normalised = raw.map(normaliseSubagent)
if (!normalised.length) {
return
}
const snap: SpawnSnapshot = {
finishedAt: (r.finished_at ?? Date.now() / 1000) * 1000,
fromDisk: true,
id: `disk-${path}`,
label: r.label || `${normalised.length} subagents`,
path,
sessionId: r.session_id ?? null,
startedAt: (r.started_at ?? r.finished_at ?? Date.now() / 1000) * 1000,
subagents: normalised
}
const next = [snap, ...$spawnHistory.get()].slice(0, HISTORY_LIMIT)
$spawnHistory.set(next)
}
function normaliseSubagent(raw: unknown): SubagentProgress {
const o = raw as Record<string, unknown>
const s = (v: unknown) => (typeof v === 'string' ? v : undefined)
const n = (v: unknown) => (typeof v === 'number' ? v : undefined)
const arr = <T>(v: unknown): T[] | undefined => (Array.isArray(v) ? (v as T[]) : undefined)
return {
apiCalls: n(o.apiCalls),
costUsd: n(o.costUsd),
depth: typeof o.depth === 'number' ? o.depth : 0,
durationSeconds: n(o.durationSeconds),
filesRead: arr<string>(o.filesRead),
filesWritten: arr<string>(o.filesWritten),
goal: s(o.goal) ?? 'subagent',
id: s(o.id) ?? `sa-${Math.random().toString(36).slice(2, 8)}`,
index: typeof o.index === 'number' ? o.index : 0,
inputTokens: n(o.inputTokens),
iteration: n(o.iteration),
model: s(o.model),
notes: (arr<string>(o.notes) ?? []).filter(x => typeof x === 'string'),
outputTail: arr(o.outputTail) as SubagentProgress['outputTail'],
outputTokens: n(o.outputTokens),
parentId: s(o.parentId) ?? null,
reasoningTokens: n(o.reasoningTokens),
startedAt: n(o.startedAt),
status: normalizeSubagentStatus(o.status, 'completed'),
summary: s(o.summary),
taskCount: typeof o.taskCount === 'number' ? o.taskCount : 1,
thinking: (arr<string>(o.thinking) ?? []).filter(x => typeof x === 'string'),
toolCount: typeof o.toolCount === 'number' ? o.toolCount : 0,
tools: (arr<string>(o.tools) ?? []).filter(x => typeof x === 'string'),
toolsets: arr<string>(o.toolsets)
}
}
+132
View File
@@ -0,0 +1,132 @@
import type { GatewayClient } from '../gatewayClient.js'
import type { InputDetectDropResponse, PromptSubmitResponse } from '../gatewayTypes.js'
import type { Msg } from '../types.js'
import { turnController } from './turnController.js'
import { getUiState, patchUiState } from './uiStore.js'
const SESSION_BUSY_RE = /session busy|waiting for model response/i
export const isSessionBusyError = (e: unknown) => e instanceof Error && SESSION_BUSY_RE.test(e.message)
export interface SubmitPromptDeps {
appendMessage: (msg: Msg) => void
enqueue: (text: string) => void
expand: (text: string) => string
gw: GatewayClient
setLastUserMsg: (value: string) => void
sys: (text: string) => void
}
// Optimistically flip the session to busy the INSTANT a prompt is accepted for
// submission — synchronously, before we await anything.
//
// This is the fix for the queue-mode race (display.busy_input_mode: queue):
// the submit path first fires an async `input.detect_drop` RPC and only marked
// the session busy inside that RPC's `.then`. A second Enter pressed inside
// that round-trip window read `busy === false` in dispatchSubmission and raced
// a second `prompt.submit` onto the backend instead of landing in the local
// queue. That produced the reported symptom: the second message "waited for
// the first to respond, then went to the queue", and the client lost track of
// it (the backend accepts a mid-turn submit as {status:"queued"} — a success,
// not an error — so the local drain effect that watches the client-side queue
// never fires, leaving the UI stuck on "analyzing…" until Ctrl+C).
//
// Marking busy at the choke point closes the gap for every caller: the mainline
// submit, queue-edit picks, and the drain effect all funnel through here.
export function markSubmitting(): void {
patchUiState({ busy: true, status: 'running…' })
}
// Submit a ready prompt (already resolved to be neither a slash command nor a
// shell escape, with a live session). Pulled out of useSubmission so the
// synchronous-busy invariant above is unit-testable without React test infra.
//
// `displayOverride` is what the transcript shows when it differs from what the
// agent receives — a `/skill` invocation expands into the whole skill body, and
// that scaffolding is model-facing only.
export function submitPrompt(
text: string,
deps: SubmitPromptDeps,
showUserMessage = true,
displayOverride?: string,
opts: { skipDetectDrop?: boolean } = {}
): void {
const sid = getUiState().sid
if (!sid) {
return deps.sys('session not ready yet')
}
// Close the async-busy gap up front, before the detect_drop round-trip.
markSubmitting()
const startSubmit = (displayText: string, submitText: string, show = true) => {
const liveSid = getUiState().sid
if (!liveSid) {
return deps.sys('session not ready yet')
}
turnController.clearStatusTimer()
deps.setLastUserMsg(text)
if (show) {
deps.appendMessage({ role: 'user', text: displayOverride || displayText })
}
patchUiState({ busy: true, status: 'running…' })
turnController.bufRef = ''
turnController.interrupted = false
deps.gw
.request<PromptSubmitResponse>('prompt.submit', { session_id: liveSid, text: submitText })
.then(r => {
// The gateway consumed a typed voice stop phrase server-side (voice
// chat ended, no turn started) — release the busy latch; the
// voice.transcript {stop_phrase} event handles the mode flags + notice.
if (r?.voice_stopped) {
patchUiState({ busy: false, status: 'ready' })
}
})
.catch((e: Error) => {
// Defensive: prompt.submit no longer rejects a mid-turn send with
// "session busy" (the gateway queues it and returns success), but keep
// the re-queue path as a safety net for any future/legacy gateway that
// still errors, so a message is never silently dropped.
if (isSessionBusyError(e)) {
deps.enqueue(submitText)
patchUiState({ busy: true, status: 'queued for next turn' })
return deps.sys(`queued: "${submitText.slice(0, 50)}${submitText.length > 50 ? '…' : ''}"`)
}
deps.sys(`error: ${e.message}`)
patchUiState({ busy: false, status: 'ready' })
})
}
// Always ask the backend whether this looks like a file drop. The backend's
// _detect_file_drop handles paths with spaces, quotes, Windows drive letters,
// and escaped characters correctly. Literal submissions (startup -q queries)
// skip it: launcher-provided text must reach the agent untouched.
//
// No notice is emitted for a match: an image dropped into the composer already
// shows as an `[[ Image N ]]` token, and a matched non-image path is rewritten
// in place. Announcing it a second time above the status bar was the old
// out-of-band attachment UI.
if (opts.skipDetectDrop) {
return startSubmit(text, deps.expand(text), showUserMessage)
}
deps.gw
.request<InputDetectDropResponse>('input.detect_drop', { session_id: sid, text })
.then(r => {
if (!r?.matched) {
return startSubmit(text, deps.expand(text), showUserMessage)
}
startSubmit(r.text || text, deps.expand(r.text || text), showUserMessage)
})
.catch(() => startSubmit(text, deps.expand(text), showUserMessage))
}
File diff suppressed because it is too large Load Diff
+85
View File
@@ -0,0 +1,85 @@
import { atom } from 'nanostores'
import { useSyncExternalStore } from 'react'
import { isTodoDone } from '../lib/liveProgress.js'
import type { ActiveTool, ActivityItem, Msg, SubagentProgress, TodoItem } from '../types.js'
const buildTurnState = (): TurnState => ({
activity: [],
outcome: '',
reasoning: '',
reasoningActive: false,
reasoningStreaming: false,
reasoningTokens: 0,
streamPendingTools: [],
streamSegments: [],
streaming: '',
subagents: [],
todoCollapsed: false,
todos: [],
toolTokens: 0,
tools: [],
turnTrail: []
})
export const $turnState = atom<TurnState>(buildTurnState())
export const getTurnState = () => $turnState.get()
const subscribeTurn = (cb: () => void) => $turnState.listen(() => cb())
export const useTurnSelector = <T>(selector: (state: TurnState) => T): T =>
useSyncExternalStore(
subscribeTurn,
() => selector($turnState.get()),
() => selector($turnState.get())
)
export const patchTurnState = (next: Partial<TurnState> | ((state: TurnState) => TurnState)) =>
$turnState.set(typeof next === 'function' ? next($turnState.get()) : { ...$turnState.get(), ...next })
export const toggleTodoCollapsed = () => patchTurnState(state => ({ ...state, todoCollapsed: !state.todoCollapsed }))
export const archiveDoneTodos = () => archiveTodosAtTurnEnd()
export const archiveTodosAtTurnEnd = () => {
const state = $turnState.get()
if (!state.todos.length) {
return []
}
const done = isTodoDone(state.todos)
const msg: Msg = {
kind: 'trail',
role: 'system',
text: '',
todos: state.todos,
...(done ? { todoCollapsedByDefault: true } : { todoIncomplete: true })
}
patchTurnState({ todoCollapsed: false, todos: [] })
return [msg]
}
export const resetTurnState = () => $turnState.set(buildTurnState())
export interface TurnState {
activity: ActivityItem[]
outcome: string
reasoning: string
reasoningActive: boolean
reasoningStreaming: boolean
reasoningTokens: number
streamPendingTools: string[]
streamSegments: Msg[]
streaming: string
subagents: SubagentProgress[]
todoCollapsed: boolean
todos: TodoItem[]
toolTokens: number
tools: ActiveTool[]
turnTrail: string[]
}
+55
View File
@@ -0,0 +1,55 @@
import { atom, computed } from 'nanostores'
import { MOUSE_TRACKING } from '../config/env.js'
import { ZERO } from '../domain/usage.js'
import { bootTheme } from '../lib/themeBoot.js'
import { DEFAULT_THEME } from '../theme.js'
import { DEFAULT_INDICATOR_STYLE, type UiState } from './interfaces.js'
const buildUiState = (): UiState => ({
battery: false,
batteryStatus: null,
bgTasks: new Set(),
busy: false,
busyInputMode: 'queue',
compact: false,
compacting: false,
destructiveSlashConfirm: true,
detailsMode: 'collapsed',
detailsModeCommandOverride: false,
focusView: false,
indicatorStyle: DEFAULT_INDICATOR_STYLE,
info: null,
liveSessionCount: 0,
inlineDiffs: true,
mouseTracking: MOUSE_TRACKING,
notice: null,
pasteCollapseLines: 5,
pasteCollapseChars: 2000,
sections: {},
sessionTitle: '',
showReasoning: false,
sid: null,
status: 'summoning hermes…',
statusBar: 'top',
statusBarFields: null,
streaming: true,
timestamps: false,
// Last session's resolved theme paints frame one (flash-free boot, like
// the desktop's hermes-boot-* keys); DEFAULT_THEME only on first launch.
theme: bootTheme ?? DEFAULT_THEME,
usage: ZERO
})
export const $uiState = atom<UiState>(buildUiState())
export const $uiTheme = computed($uiState, state => state.theme)
export const $uiSessionId = computed($uiState, state => state.sid)
export const getUiState = () => $uiState.get()
export const patchUiState = (next: Partial<UiState> | ((state: UiState) => UiState)) =>
$uiState.set(typeof next === 'function' ? next($uiState.get()) : { ...$uiState.get(), ...next })
export const resetUiState = () => $uiState.set(buildUiState())
+77
View File
@@ -0,0 +1,77 @@
import { useStore } from '@nanostores/react'
import { useEffect } from 'react'
import type { GatewayClient } from '../gatewayClient.js'
import type { SystemBatteryResponse } from '../gatewayTypes.js'
import { asRpcResult } from '../lib/rpc.js'
import type { BatteryCategory, BatteryInfo } from './interfaces.js'
import { $uiState, patchUiState } from './uiStore.js'
const BATTERY_POLL_MS = 30_000
const CATEGORIES: ReadonlySet<BatteryCategory> = new Set(['bad', 'critical', 'dim', 'good', 'warn'])
const normalizeCategory = (raw: unknown): BatteryCategory =>
typeof raw === 'string' && CATEGORIES.has(raw as BatteryCategory) ? (raw as BatteryCategory) : 'dim'
/** Coerce a `system.battery` RPC payload into the UI's BatteryInfo shape. */
export const toBatteryInfo = (r: null | SystemBatteryResponse): BatteryInfo | null => {
if (!r) {
return null
}
const percent =
typeof r.percent === 'number' && Number.isFinite(r.percent)
? Math.max(0, Math.min(100, Math.round(r.percent)))
: null
return {
available: !!r.available,
category: normalizeCategory(r.category),
percent,
plugged: typeof r.plugged === 'boolean' ? r.plugged : null
}
}
/**
* Poll the host battery while the status-bar indicator is enabled.
*
* The reading is a system property (not per-session), so this runs whenever
* `display.battery` is on — no `sid` gate. Python memoises the read, so a
* 30s cadence is plenty to keep the read-out fresh without churn. When the
* indicator is toggled off the cached reading is cleared.
*/
export function useBatteryPoll(gw: GatewayClient) {
const enabled = useStore($uiState).battery
useEffect(() => {
if (!enabled) {
patchUiState({ batteryStatus: null })
return
}
let cancelled = false
const poll = async () => {
try {
const r = asRpcResult<SystemBatteryResponse>(await gw.request<SystemBatteryResponse>('system.battery', {}))
if (!cancelled) {
patchUiState({ batteryStatus: toBatteryInfo(r) })
}
} catch {
// Keep the last-good reading on a transient RPC failure.
}
}
void poll()
const id = setInterval(() => void poll(), BATTERY_POLL_MS)
return () => {
cancelled = true
clearInterval(id)
}
}, [enabled, gw])
}
+497
View File
@@ -0,0 +1,497 @@
import { spawnSync } from 'node:child_process'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { useStdin, withInkSuspended } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { useCallback, useMemo, useRef, useState } from 'react'
import type { PasteEvent } from '../components/textInput.js'
import { droppedTokens, imageToken, nextImageIndex } from '../domain/attachments.js'
import type { ClipboardPasteResponse, ImageAttachResponse, InputDetectDropResponse } from '../gatewayTypes.js'
import { useCompletion } from '../hooks/useCompletion.js'
import { useInputHistory } from '../hooks/useInputHistory.js'
import { useQueue } from '../hooks/useQueue.js'
import { isUsableClipboardText, readClipboardText } from '../lib/clipboard.js'
import { resolveEditor } from '../lib/editor.js'
import { readOsc52Clipboard } from '../lib/osc52.js'
import { isRemoteShellSession } from '../lib/terminalSetup.js'
import { pasteTokenLabel, stripTrailingPasteNewlines } from '../lib/text.js'
import type {
ComposerPasteResult,
ComposerToken,
MaybePromise,
StateSetter,
UseComposerStateOptions,
UseComposerStateResult
} from './interfaces.js'
import { $isBlocked } from './overlayStore.js'
import { getUiState } from './uiStore.js'
const TOKEN_MAX_COUNT = 32
const TOKEN_MAX_TOTAL_BYTES = 4 * 1024 * 1024
const trimTokens = (tokens: ComposerToken[]): ComposerToken[] => {
let total = 0
const out: ComposerToken[] = []
for (let i = tokens.length - 1; i >= 0; i--) {
const token = tokens[i]!
const size = token.text?.length ?? 0
if (out.length >= TOKEN_MAX_COUNT || total + size > TOKEN_MAX_TOTAL_BYTES) {
break
}
total += size
out.unshift(token)
}
return out.length === tokens.length ? tokens : out
}
/** Insert text at the cursor position, adding spacing to separate from adjacent non-whitespace. */
function insertAtCursor(value: string, cursor: number, text: string): { cursor: number; value: string } {
const lead = cursor > 0 && !/\s/.test(value[cursor - 1] ?? '') ? ' ' : ''
const tail = cursor < value.length && !/\s/.test(value[cursor] ?? '') ? ' ' : ''
const insert = `${lead}${text}${tail}`
return {
cursor: cursor + insert.length,
value: value.slice(0, cursor) + insert + value.slice(cursor)
}
}
/**
* Quick client-side heuristic to detect text that looks like a dropped file path.
* When this returns true the composer sends RPC calls to the server for actual
* validation. Keep in sync with _detect_file_drop() in cli.py — see that
* function for the canonical prefix list.
*/
export function looksLikeDroppedPath(text: string): boolean {
const trimmed = text.trim()
if (!trimmed || trimmed.includes('\n')) {
return false
}
// file:// URIs, relative, home-relative, quoted, and Windows drive paths
if (
trimmed.startsWith('file://') ||
trimmed.startsWith('~/') ||
trimmed.startsWith('./') ||
trimmed.startsWith('../') ||
trimmed.startsWith('"/') ||
trimmed.startsWith("'/") ||
trimmed.startsWith('"~') ||
trimmed.startsWith("'~") ||
/^[A-Za-z]:[/\\]/.test(trimmed) ||
/^["'][A-Za-z]:[/\\]/.test(trimmed)
) {
return true
}
// Bare absolute paths (start with /) — require a second '/' or a '.' to avoid
// false positives on short strings like "/api" or "/help" which would trigger
// unnecessary RPC round-trips.
if (trimmed.startsWith('/')) {
const rest = trimmed.slice(1)
return rest.includes('/') || rest.includes('.')
}
return false
}
export function useComposerState({ gw, submitRef, sys }: UseComposerStateOptions): UseComposerStateResult {
const [input, setInputState] = useState('')
const [inputBuf, setInputBuf] = useState<string[]>([])
const [tokens, setTokens] = useState<ComposerToken[]>([])
// Tokens and the input line are read from keystroke handlers that run several
// times before React re-renders, so the refs — not the state — are the source
// of truth for "what is in the composer right now".
const inputRef = useRef('')
const tokensRef = useRef<ComposerToken[]>([])
const setInput = useCallback<StateSetter<string>>(next => {
inputRef.current = typeof next === 'function' ? next(inputRef.current) : next
setInputState(inputRef.current)
}, [])
const setComposerTokens = useCallback<StateSetter<ComposerToken[]>>(next => {
tokensRef.current = typeof next === 'function' ? next(tokensRef.current) : next
setTokens(tokensRef.current)
}, [])
const isBlocked = useStore($isBlocked)
const { querier } = useStdin() as { querier: Parameters<typeof readOsc52Clipboard>[0] }
const {
queueRef,
queueEditRef,
queuedDisplay,
queueEditIdx,
enqueue,
dequeue,
prependQ,
removeQ,
setQueueEdit,
takeQ
} = useQueue()
const { historyRef, historyIdx, setHistoryIdx, historyDraftRef, pushHistory } = useInputHistory()
const { completions, compIdx, setCompIdx, compReplace } = useCompletion(input, isBlocked, gw)
const clearIn = useCallback(() => {
setInput('')
setInputBuf([])
setComposerTokens([])
setQueueEdit(null)
setHistoryIdx(null)
historyDraftRef.current = ''
}, [historyDraftRef, setComposerTokens, setHistoryIdx, setInput, setQueueEdit])
/**
* Deleting an `[[ Image N ]]` token IS how you unattach the image — there is
* no separate control. Reconcile on every edit so the gateway's
* `attached_images` never outlives the token the user just erased, which is
* what used to make a stale image ride along on the next unrelated turn.
*/
const syncTokens = useCallback(
(value: string) => {
const gone = droppedTokens(tokensRef.current, value)
if (!gone.length) {
return
}
for (const token of gone) {
if (token.kind === 'image') {
void gw.request('image.detach', { path: token.path, session_id: getUiState().sid }).catch(() => {})
}
}
setComposerTokens(prev => prev.filter(token => !gone.includes(token)))
},
[gw, setComposerTokens]
)
/**
* Attach an image the gateway already resolved: a token at the cursor,
* followed by whatever non-path text came along with it (a drag-drop paste
* of `~/shot.png look at this` keeps the caption).
*/
const attachImageToken = useCallback(
(attached: ImageAttachResponse & { path?: string }, value: string, cursor: number): ComposerPasteResult => {
const index = nextImageIndex(tokensRef.current)
const label = imageToken(index)
setComposerTokens(prev => trimTokens([...prev, { index, kind: 'image', label, path: attached.path ?? '' }]))
const withToken = insertAtCursor(value, cursor, label)
const remainder = attached.remainder?.trim() ?? ''
return remainder ? insertAtCursor(withToken.value, withToken.cursor, remainder) : withToken
},
[setComposerTokens]
)
/**
* Pull an image off the system clipboard into the composer as a token.
*
* `quiet` is the empty-bracketed-paste probe: the terminal delivers an image
* paste as zero text, so we speculatively ask the gateway and stay silent if
* there was nothing there. An explicit `/paste` reports the miss.
*/
const pasteClipboardImage = useCallback(
async (value: string, cursor: number, quiet: boolean): Promise<ComposerPasteResult | null> => {
const sid = getUiState().sid
if (!sid) {
return null
}
const r = await gw
.request<ClipboardPasteResponse & { path?: string }>('clipboard.paste', { session_id: sid })
.catch(() => null)
if (r?.attached) {
return attachImageToken(r, value, cursor)
}
if (!quiet) {
sys(r?.message || 'No image found in clipboard')
}
return null
},
[attachImageToken, gw, sys]
)
const handleResolvedPaste = useCallback(
async ({ bracketed, cursor, text, value }: Omit<PasteEvent, 'hotkey'>): Promise<ComposerPasteResult | null> => {
const cleanedText = stripTrailingPasteNewlines(text)
if (!cleanedText || !/[^\n]/.test(cleanedText)) {
return bracketed ? pasteClipboardImage(value, cursor, true) : null
}
const sid = getUiState().sid
if (sid && looksLikeDroppedPath(cleanedText)) {
try {
const attached = await gw.request<ImageAttachResponse>('image.attach', {
path: cleanedText,
session_id: sid
})
if (attached?.name) {
// Drop an `[[ Image N ]]` token where the path was typed. The old
// path printed a notice above the status bar and left the composer
// untouched, so the only trace of the attachment lived outside the
// input the user was editing.
return attachImageToken(attached, value, cursor)
}
} catch {
// Fall back to generic file-drop detection below.
}
try {
const dropped = await gw.request<InputDetectDropResponse>('input.detect_drop', {
session_id: sid,
text: cleanedText
})
if (dropped?.matched && dropped.text) {
return insertAtCursor(value, cursor, dropped.text)
}
} catch {
// Fall through to normal text paste behavior.
}
}
const lineCount = cleanedText.split('\n').length
const pasteCollapseLines = getUiState().pasteCollapseLines
const pasteCollapseChars = getUiState().pasteCollapseChars
const linesHit = pasteCollapseLines > 0 && lineCount >= pasteCollapseLines
const charsHit = pasteCollapseChars > 0 && cleanedText.length >= pasteCollapseChars
if (!linesHit && !charsHit) {
return {
cursor: cursor + cleanedText.length,
value: value.slice(0, cursor) + cleanedText + value.slice(cursor)
}
}
const label = pasteTokenLabel(cleanedText, lineCount)
const inserted = insertAtCursor(value, cursor, label)
setComposerTokens(prev => trimTokens([...prev, { kind: 'paste', label, text: cleanedText }]))
void gw
.request<{ path?: string }>('paste.collapse', { text: cleanedText })
.then(r => {
const path = r?.path
if (!path) {
return
}
setComposerTokens(prev => prev.map(t => (t.label === label ? { ...t, path } : t)))
})
.catch(() => {})
return inserted
},
[attachImageToken, gw, pasteClipboardImage, setComposerTokens]
)
const handleTextPaste = useCallback(
({ bracketed, cursor, hotkey, text, value }: PasteEvent): MaybePromise<ComposerPasteResult | null> => {
if (hotkey) {
const preferOsc52 = isRemoteShellSession(process.env)
const readPreferredText = preferOsc52
? readOsc52Clipboard(querier).then(async osc52Text => {
if (isUsableClipboardText(osc52Text)) {
return osc52Text
}
return readClipboardText()
})
: readClipboardText().then(async clipText => {
if (isUsableClipboardText(clipText)) {
return clipText
}
return readOsc52Clipboard(querier)
})
return readPreferredText.then(async preferredText => {
if (isUsableClipboardText(preferredText)) {
return handleResolvedPaste({ bracketed: false, cursor, text: preferredText, value })
}
// No text on the clipboard — an image paste looks exactly like this.
return pasteClipboardImage(value, cursor, false)
})
}
return handleResolvedPaste({ bracketed: !!bracketed, cursor, text, value })
},
[handleResolvedPaste, pasteClipboardImage, querier]
)
/**
* `/paste` and `/image` attach without a cursor of their own — the token
* lands at the end of whatever is currently typed.
*/
const appendAttachment = useCallback(
(attach: (value: string, cursor: number) => Promise<ComposerPasteResult | null>) => {
const current = inputRef.current
void attach(current, current.length).then(next => {
if (next) {
setInput(next.value)
}
})
},
[setInput]
)
const attachClipboardImage = useCallback(
() => appendAttachment((value, cursor) => pasteClipboardImage(value, cursor, false)),
[appendAttachment, pasteClipboardImage]
)
const attachImagePath = useCallback(
(path: string) =>
appendAttachment(async (value, cursor) => {
const sid = getUiState().sid
if (!sid || !path.trim()) {
return null
}
const attached = await gw
.request<ImageAttachResponse & { path?: string }>('image.attach', { path, session_id: sid })
.catch((e: Error) => {
sys(`error: ${e.message}`)
return null
})
return attached?.name ? attachImageToken(attached, value, cursor) : null
}),
[appendAttachment, attachImageToken, gw, sys]
)
const openEditor = useCallback(async () => {
const dir = mkdtempSync(join(tmpdir(), 'hermes-'))
const file = join(dir, 'prompt.md')
const [cmd, ...args] = resolveEditor()
writeFileSync(file, [...inputBuf, input].join('\n'))
let exitCode: null | number = null
await withInkSuspended(async () => {
exitCode = spawnSync(cmd!, [...args, file], { stdio: 'inherit' }).status
})
try {
if (exitCode !== 0) {
return
}
const text = readFileSync(file, 'utf8').trimEnd()
if (!text) {
return
}
setInput('')
setInputBuf([])
submitRef.current(text)
} finally {
rmSync(dir, { force: true, recursive: true })
}
}, [input, inputBuf, setInput, submitRef])
const actions = useMemo(
() => ({
attachClipboardImage,
attachImagePath,
clearIn,
dequeue,
enqueue,
handleTextPaste,
openEditor,
prependQueue: prependQ,
pushHistory,
removeQueue: removeQ,
setCompIdx,
setComposerTokens,
setHistoryIdx,
setInput,
setInputBuf,
setQueueEdit,
takeQueue: takeQ,
syncTokens
}),
[
attachClipboardImage,
attachImagePath,
clearIn,
dequeue,
enqueue,
handleTextPaste,
openEditor,
prependQ,
pushHistory,
removeQ,
setCompIdx,
setComposerTokens,
setHistoryIdx,
setInput,
setQueueEdit,
takeQ,
syncTokens
]
)
const refs = useMemo(
() => ({
historyDraftRef,
historyRef,
queueEditRef,
queueRef,
submitRef,
tokensRef
}),
[historyDraftRef, historyRef, queueEditRef, queueRef, submitRef]
)
const state = useMemo(
() => ({
compIdx,
compReplace,
completions,
historyIdx,
input,
inputBuf,
queueEditIdx,
queuedDisplay,
tokens
}),
[compIdx, compReplace, completions, historyIdx, input, inputBuf, queueEditIdx, queuedDisplay, tokens]
)
return {
actions,
refs,
state
}
}
+410
View File
@@ -0,0 +1,410 @@
import type { MouseTrackingMode } from '@hermes/ink'
import { useEffect, useRef } from 'react'
import { resolveDetailsMode, resolveSections } from '../domain/details.js'
import type { GatewayClient } from '../gatewayClient.js'
import type { ConfigFullResponse, ConfigMtimeResponse, ReloadMcpResponse } from '../gatewayTypes.js'
import { DEFAULT_VOICE_RECORD_KEY, type ParsedVoiceRecordKey, parseVoiceRecordKey } from '../lib/platform.js'
import { asRpcResult } from '../lib/rpc.js'
import { applyConfiguredTuiTheme } from './createGatewayEventHandler.js'
import {
type BusyInputMode,
DEFAULT_INDICATOR_STYLE,
INDICATOR_STYLES,
type IndicatorStyle,
type StatusBarMode
} from './interfaces.js'
import { turnController } from './turnController.js'
import { patchUiState } from './uiStore.js'
const STATUSBAR_ALIAS: Record<string, StatusBarMode> = {
bottom: 'bottom',
off: 'off',
on: 'top',
top: 'top'
}
export const normalizeStatusBar = (raw: unknown): StatusBarMode =>
raw === false ? 'off' : typeof raw === 'string' ? (STATUSBAR_ALIAS[raw.trim().toLowerCase()] ?? 'top') : 'top'
// `display.status_bar.fields` — the SAME key the classic CLI bar honors
// (PR #98250). A non-empty list filters status-rule segments; missing/empty/
// malformed = null (user hasn't customized → show the default set). Unknown
// names pass through harmlessly — the renderer only tests membership.
export const normalizeStatusBarFields = (raw: unknown): null | ReadonlySet<string> => {
if (!Array.isArray(raw) || raw.length === 0) {
return null
}
const cleaned = raw.map(v => String(v).trim().toLowerCase()).filter(Boolean)
return cleaned.length ? new Set(cleaned) : null
}
const BUSY_MODES = new Set<BusyInputMode>(['interrupt', 'queue', 'steer'])
// TUI defaults to `queue` even though the framework default
// (`hermes_cli/config.py`) is `interrupt`. Rationale: in a full-screen
// TUI you're typically authoring the next prompt while the agent is
// still streaming, and an unintended interrupt loses work. Set
// `display.busy_input_mode: interrupt` (or `steer`) explicitly to
// opt out per-config; CLI / messaging adapters keep their `interrupt`
// default unchanged.
const TUI_BUSY_DEFAULT: BusyInputMode = 'queue'
export const normalizeBusyInputMode = (raw: unknown): BusyInputMode => {
if (typeof raw !== 'string') {
return TUI_BUSY_DEFAULT
}
const v = raw.trim().toLowerCase() as BusyInputMode
return BUSY_MODES.has(v) ? v : TUI_BUSY_DEFAULT
}
const INDICATOR_STYLE_SET: ReadonlySet<IndicatorStyle> = new Set(INDICATOR_STYLES)
export const normalizeIndicatorStyle = (raw: unknown): IndicatorStyle => {
if (typeof raw !== 'string') {
return DEFAULT_INDICATOR_STYLE
}
const v = raw.trim().toLowerCase() as IndicatorStyle
return INDICATOR_STYLE_SET.has(v) ? v : DEFAULT_INDICATOR_STYLE
}
const FALSEY_MOUSE = new Set(['0', 'false', 'no', 'off'])
const TRUTHY_MOUSE_ALL = new Set(['1', 'true', 'yes', 'on', 'all', 'full', 'any'])
const hasOwn = (obj: object, key: PropertyKey) => Object.prototype.hasOwnProperty.call(obj, key)
// `display.mouse_tracking` accepts boolean (`true` ⇒ all modes, `false` ⇒ off)
// for back-compat, plus the string presets `off|wheel|buttons|all` (aliases:
// `on`/`full`/`any`/`1`/`true`/... → `all`; `0`/`false`/`no`/`off` → `off`).
// `wheel` enables 1000+1006 — scroll wheel + click only, no drag or hover,
// which silences tmux's "No image in clipboard" spam over the prompt row.
// `buttons` adds 1002 so terminal-side text selection drags still register.
// Legacy `tui_mouse` is honored only if `mouse_tracking` is absent.
export const normalizeMouseTracking = (display: {
mouse_tracking?: unknown
tui_mouse?: unknown
}): MouseTrackingMode => {
const raw = hasOwn(display, 'mouse_tracking') ? display.mouse_tracking : display.tui_mouse
if (raw === false || raw === 0) {
return 'off'
}
if (raw === true || raw === undefined || raw === null) {
return 'all'
}
if (typeof raw === 'number') {
return 'all'
}
if (typeof raw !== 'string') {
return 'all'
}
const v = raw.trim().toLowerCase()
if (FALSEY_MOUSE.has(v)) {
return 'off'
}
if (TRUTHY_MOUSE_ALL.has(v)) {
return 'all'
}
if (v === 'wheel' || v === 'scroll') {
return 'wheel'
}
if (v === 'buttons' || v === 'button' || v === 'click') {
return 'buttons'
}
return 'all'
}
const MTIME_POLL_MS = 5000
const quietRpc = async <T extends Record<string, any> = Record<string, any>>(
gw: GatewayClient,
method: string,
params: Record<string, unknown> = {}
): Promise<null | T> => {
try {
return asRpcResult<T>(await gw.request<T>(method, params))
} catch {
return null
}
}
// ── MCP revision handshake ───────────────────────────────────────────
//
// The poll must not ack an MCP config revision until the server confirms it
// actually LOADED it. Advancing `accepted` before the reload succeeds loses
// revisions permanently: quietRpc collapses a failed reload to null, the
// next poll sees the same mcp_rev, and the new config never applies until
// an unrelated MCP edit. So `accepted` only moves on a confirmed reload —
// to the server's loaded_rev (what discovery actually read), falling back
// to the requested rev for older gateways. Retries are decoupled from
// mtime: every poll re-compares, so a transiently broken server heals on
// the next tick.
export interface McpRevState {
/** Last revision the server CONFIRMED it loaded (or boot baseline). */
accepted: string
/** A reload RPC is outstanding — don't stack another every poll tick. */
inFlight: boolean
}
export const syncMcpReload = async (
gw: GatewayClient,
sid: string,
nextMcpRev: string,
state: McpRevState,
onReloaded?: () => void
): Promise<void> => {
if (!nextMcpRev || nextMcpRev === state.accepted || state.inFlight) {
return
}
state.inFlight = true
try {
const r = await quietRpc<ReloadMcpResponse>(gw, 'reload.mcp', {
confirm: true,
rev: nextMcpRev,
session_id: sid
})
if (r?.status === 'reloaded') {
state.accepted = String(r.loaded_rev || nextMcpRev)
onReloaded?.()
}
// Failure (null) or confirm_required: leave `accepted` unchanged so the
// next poll tick retries the same revision.
} finally {
state.inFlight = false
}
}
const _voiceRecordKeyFromConfig = (cfg: ConfigFullResponse | null): ParsedVoiceRecordKey => {
const raw = cfg?.config?.voice?.record_key
return raw ? parseVoiceRecordKey(raw) : DEFAULT_VOICE_RECORD_KEY
}
const _pasteCollapseLinesFromConfig = (cfg: ConfigFullResponse | null): number => {
if (!cfg?.config) {
return 5
}
const raw = cfg.config.paste_collapse_threshold
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) {
return Math.round(raw)
}
if (typeof raw === 'string') {
const n = parseInt(raw, 10)
if (Number.isFinite(n) && n >= 0) {
return n
}
}
return 5
}
const _pasteCollapseCharsFromConfig = (cfg: ConfigFullResponse | null): number => {
if (!cfg?.config) {
return 2000
}
const raw = cfg.config.paste_collapse_char_threshold
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) {
return Math.round(raw)
}
if (typeof raw === 'string') {
const n = parseInt(raw, 10)
if (Number.isFinite(n) && n >= 0) {
return n
}
}
return 2000
}
/** Fetch ``config.get full`` and fan the result through ``applyDisplay``.
*
* Extracted so the mtime-reload path can be exercised by the test
* suite without a React runtime (Copilot round-12 review on #19835).
* Both the initial hydration and the mtime poller use this shared
* helper, so a regression in the fetch/apply plumbing now fails the
* useConfigSync tests instead of only being visible at runtime. */
export async function hydrateFullConfig(
gw: GatewayClient,
setBell: (v: boolean) => void,
setVoiceRecordKey?: (v: ParsedVoiceRecordKey) => void,
setBellOnPrompt?: (v: boolean) => void
): Promise<ConfigFullResponse | null> {
const cfg = await quietRpc<ConfigFullResponse>(gw, 'config.get', { key: 'full' })
applyDisplay(cfg, setBell, setVoiceRecordKey, setBellOnPrompt)
return cfg
}
export const applyDisplay = (
cfg: ConfigFullResponse | null,
setBell: (v: boolean) => void,
setVoiceRecordKey?: (v: ParsedVoiceRecordKey) => void,
setBellOnPrompt?: (v: boolean) => void
) => {
const d = cfg?.config?.display ?? {}
const approvals = cfg?.config?.approvals
setBell(!!d.bell_on_complete)
setBellOnPrompt?.(!!d.bell_on_prompt)
applyConfiguredTuiTheme(d.tui_theme)
// Only push the voice record key when the RPC actually returned a
// config payload. ``quietRpc()`` collapses failures to ``null``; if we
// reset the cached shortcut on every null we would clobber a custom
// binding after one transient RPC error until the next config edit
// (Copilot round-8 review on #19835). The mtime-poll loop advances
// ``mtimeRef`` before this call, so staying silent on null preserves
// the last-good state and lets the next successful poll refresh it.
if (setVoiceRecordKey && cfg) {
setVoiceRecordKey(_voiceRecordKeyFromConfig(cfg))
}
patchUiState({
battery: !!d.battery,
busyInputMode: normalizeBusyInputMode(d.busy_input_mode),
compact: !!d.tui_compact,
// Fail safe: only YAML boolean false disables the prompt. A transient
// config RPC failure (cfg=null) preserves the last known policy instead
// of silently changing approval behavior until the next successful poll.
...(cfg ? { destructiveSlashConfirm: approvals?.destructive_slash_confirm !== false } : {}),
detailsMode: resolveDetailsMode(d),
detailsModeCommandOverride: false,
focusView: !!d.focus_view,
indicatorStyle: normalizeIndicatorStyle(d.tui_status_indicator),
inlineDiffs: d.inline_diffs !== false,
mouseTracking: normalizeMouseTracking(d),
pasteCollapseLines: _pasteCollapseLinesFromConfig(cfg),
pasteCollapseChars: _pasteCollapseCharsFromConfig(cfg),
sections: resolveSections(d.sections),
showReasoning: !!d.show_reasoning,
statusBar: normalizeStatusBar(d.tui_statusbar),
statusBarFields: normalizeStatusBarFields(d.status_bar?.fields),
streaming: d.streaming !== false,
// The SAME key that stamps [HH:MM] on classic-CLI labels (#41531) —
// no separate TUI knob.
timestamps: d.timestamps === true
})
}
export function useConfigSync({
gw,
setBellOnComplete,
setBellOnPrompt,
setVoiceEnabled,
setVoiceRecordKey,
sid
}: UseConfigSyncOptions) {
const mtimeRef = useRef(0)
const mcpRevRef = useRef<McpRevState>({ accepted: '', inFlight: false })
useEffect(() => {
if (!sid) {
return
}
// Keep startup cheap: voice.toggle status probes optional audio/STT deps and
// can run long enough to delay prompt.submit on the single stdio RPC pipe.
// Environment flags are enough to initialize the UI bit; the heavier status
// check still runs when the user opens /voice.
setVoiceEnabled(process.env.HERMES_VOICE === '1')
quietRpc<ConfigMtimeResponse>(gw, 'config.get', { key: 'mtime' }).then(r => {
mtimeRef.current = Number(r?.mtime ?? 0)
// Seed the MCP revision baseline too: after a normal boot mtime is
// already non-zero, so the poller's baseline branch never runs, and an
// unset baseline would make the FIRST cosmetic write (mtime bump, same
// mcp_rev) look like an MCP change and fire a needless reload.mcp.
mcpRevRef.current.accepted = String(r?.mcp_rev ?? '')
})
void hydrateFullConfig(gw, setBellOnComplete, setVoiceRecordKey, setBellOnPrompt)
}, [gw, setBellOnComplete, setBellOnPrompt, setVoiceEnabled, setVoiceRecordKey, sid])
useEffect(() => {
if (!sid) {
return
}
const id = setInterval(() => {
quietRpc<ConfigMtimeResponse>(gw, 'config.get', { key: 'mtime' }).then(r => {
const next = Number(r?.mtime ?? 0)
const nextMcpRev = String(r?.mcp_rev ?? '')
if (!mtimeRef.current) {
if (next) {
mtimeRef.current = next
mcpRevRef.current.accepted = nextMcpRev
}
return
}
// Reload MCP only when the MCP-relevant config actually changed.
// Cosmetic writes (/skin, /statusbar, /theme) bump mtime constantly;
// reconnecting every MCP server for those costs seconds and made
// skin switching feel glacial. The handshake runs on EVERY poll tick
// (not just mtime changes) so a failed reload retries until the
// server confirms the revision was loaded.
if (nextMcpRev) {
void syncMcpReload(gw, sid, nextMcpRev, mcpRevRef.current, () =>
turnController.pushActivity('MCP reloaded after config change')
)
}
if (!next || next === mtimeRef.current) {
return
}
mtimeRef.current = next
// Older gateways don't send mcp_rev — fall back to
// reload-on-any-change there (no ack tracking possible).
if (!nextMcpRev) {
quietRpc<ReloadMcpResponse>(gw, 'reload.mcp', { session_id: sid, confirm: true }).then(
r => r && turnController.pushActivity('MCP reloaded after config change')
)
}
void hydrateFullConfig(gw, setBellOnComplete, setVoiceRecordKey, setBellOnPrompt)
})
}, MTIME_POLL_MS)
return () => clearInterval(id)
}, [gw, setBellOnComplete, setBellOnPrompt, setVoiceRecordKey, sid])
}
export interface UseConfigSyncOptions {
gw: GatewayClient
setBellOnComplete: (v: boolean) => void
setBellOnPrompt?: (v: boolean) => void
setVoiceEnabled: (v: boolean) => void
setVoiceRecordKey?: (v: ParsedVoiceRecordKey) => void
sid: null | string
}
+741
View File
@@ -0,0 +1,741 @@
import { forceRedraw, useInput } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { useEffect, useRef } from 'react'
import { DASHBOARD_TUI_MODE } from '../config/env.js'
import { DOUBLE_ESC_MS, TYPING_IDLE_MS } from '../config/timing.js'
import { applyCompletion } from '../domain/slash.js'
import type {
ApprovalRespondResponse,
ConfigSetResponse,
SecretRespondResponse,
SudoRespondResponse,
VoiceRecordResponse
} from '../gatewayTypes.js'
import { isAction, isCopyShortcut, isMac, isVoiceToggleKey } from '../lib/platform.js'
import { computePrecisionWheelStep, initPrecisionWheel } from '../lib/precisionWheel.js'
import { computeWheelStep, initWheelAccelForHost } from '../lib/wheelAccel.js'
import { closeWidget, dispatchWidgetInput } from '../sdk/host.js'
import { getInputSelection } from './inputSelectionStore.js'
import {
type GatewayRpc,
type InputHandlerActions,
type InputHandlerContext,
type InputHandlerResult,
type OverlayState
} from './interfaces.js'
import { $isBlocked, $overlayState, patchOverlayState } from './overlayStore.js'
import { turnController } from './turnController.js'
import { patchTurnState } from './turnStore.js'
import { getUiState } from './uiStore.js'
const isCtrl = (key: { ctrl: boolean }, ch: string, target: string) => key.ctrl && ch.toLowerCase() === target
const DASHBOARD_NEW_SESSION_MESSAGE = 'starting a fresh dashboard chat...'
export const shouldAllowIdleHotkeyExit = (dashboardTuiMode = DASHBOARD_TUI_MODE) => !dashboardTuiMode
export function handleInputSelectionClipboard(
selection: ReturnType<typeof getInputSelection>,
action: 'copy' | 'cut'
): boolean {
if (!selection || selection.end <= selection.start) {
return false
}
selection[action]()
return true
}
export function handleIdleHotkeyExit(
actions: Pick<InputHandlerActions, 'die' | 'sys'>,
dashboardTuiMode = DASHBOARD_TUI_MODE,
requestDashboardNewSession?: () => void
) {
if (!shouldAllowIdleHotkeyExit(dashboardTuiMode)) {
requestDashboardNewSession?.()
return actions.sys(DASHBOARD_NEW_SESSION_MESSAGE)
}
return actions.die()
}
export type CtrlCComposerAction = 'clear' | 'interrupt' | 'exit'
/**
* Ctrl+C (and terminals that rewrite Cmd+C to it) is clear / interrupt / exit
* in that order. A non-empty composer always wins — mid-stream, the chord
* used to interrupt the turn even when the user was trying to dump a draft.
*/
export function resolveCtrlCComposerAction(opts: {
busy: boolean
hasDraft: boolean
hasSession: boolean
}): CtrlCComposerAction {
if (opts.hasDraft) {
return 'clear'
}
if (opts.busy && opts.hasSession) {
return 'interrupt'
}
return 'exit'
}
/**
* Approval / clarify / confirm overlays mount their own `useInput` handlers
* for the in-prompt keys (arrows, numbers, Enter, sometimes Esc). The global
* input handler used to early-return for any other key while one of those
* overlays was up, which silently disabled transcript scrolling — the user
* couldn't read context above the prompt that the prompt itself was asking
* about. Returns true when the key is a transcript-scroll input that should
* fall through to the global scroll handlers even while a prompt is active.
*
* Modifier-held wheel (precision mode) is included — a user who wants to
* scroll a single line at a time during a prompt expects it to work.
*/
export function shouldFallThroughForScroll(key: {
downArrow: boolean
pageDown: boolean
pageUp: boolean
shift: boolean
upArrow: boolean
wheelDown: boolean
wheelUp: boolean
}): boolean {
if (key.wheelUp || key.wheelDown) {
return true
}
if (key.pageUp || key.pageDown) {
return true
}
if (key.shift && (key.upArrow || key.downArrow)) {
return true
}
return false
}
export function applyVoiceRecordResponse(
response: null | VoiceRecordResponse,
starting: boolean,
voice: Pick<InputHandlerContext['voice'], 'setProcessing' | 'setRecording'>,
sys: (text: string) => void
) {
if (!starting || response?.status === 'recording') {
return
}
voice.setRecording(false)
if (response?.status === 'busy') {
voice.setProcessing(true)
sys('voice: still transcribing; try again shortly')
} else {
voice.setProcessing(false)
}
}
export function dismissSensitivePrompt(
overlay: Pick<OverlayState, 'secret' | 'sudo'>,
rpc: GatewayRpc,
sys: (text: string) => void
) {
if (overlay.sudo) {
const requestId = overlay.sudo.requestId
patchOverlayState({ sudo: null })
sys('sudo cancelled')
return rpc<SudoRespondResponse>('sudo.respond', { password: '', request_id: requestId })
}
if (overlay.secret) {
const requestId = overlay.secret.requestId
patchOverlayState({ secret: null })
sys('secret entry cancelled')
return rpc<SecretRespondResponse>('secret.respond', { request_id: requestId, value: '' })
}
}
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value))
export function shouldDetachEditedHistoryInput(historyIdx: null | number, history: readonly string[], value: string) {
return historyIdx !== null && value !== history[historyIdx]
}
export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
const { actions, composer, gateway, terminal, voice, wheelStep } = ctx
const { actions: cActions, refs: cRefs, state: cState } = composer
const overlay = useStore($overlayState)
const isBlocked = useStore($isBlocked)
const pagerPageSize = Math.max(5, (terminal.stdout?.rows ?? 24) - 6)
const scrollIdleTimer = useRef<null | ReturnType<typeof setTimeout>>(null)
// Wheel accel ported from claude-code: inter-event timing drives step size,
// direction flips reset. wheelStep (WHEEL_SCROLL_STEP) is the base; final
// rows = wheelStep × accelMult. State mutates in place across renders.
const wheelAccelRef = useRef(initWheelAccelForHost())
const precisionWheelRef = useRef(initPrecisionWheel())
useEffect(() => () => clearTimeout(scrollIdleTimer.current ?? undefined), [])
const scrollTranscript = (delta: number) => {
if (getUiState().busy) {
turnController.boostStreamingForScroll()
clearTimeout(scrollIdleTimer.current ?? undefined)
scrollIdleTimer.current = setTimeout(() => {
scrollIdleTimer.current = null
turnController.relaxStreaming()
}, TYPING_IDLE_MS)
}
terminal.scrollWithSelection(delta)
}
const copySelection = () => {
// ink's copySelection() already calls setClipboard() which handles
// pbcopy (macOS), wl-copy/xclip (Linux), tmux, and OSC 52 fallback.
terminal.selection.copySelection()
}
const clearSelection = () => {
terminal.selection.clearSelection()
}
const cancelOverlayFromCtrlC = () => {
if (overlay.clarify) {
return actions.answerClarify('')
}
if (overlay.approval) {
return gateway
.rpc<ApprovalRespondResponse>('approval.respond', { choice: 'deny', session_id: getUiState().sid })
.then(r => r && (patchOverlayState({ approval: null }), patchTurnState({ outcome: 'denied' })))
}
if (overlay.sudo || overlay.secret) {
return dismissSensitivePrompt(overlay, gateway.rpc, actions.sys)
}
if (overlay.modelPicker) {
return patchOverlayState({ modelPicker: false })
}
if (overlay.petPicker) {
return patchOverlayState({ petPicker: false })
}
if (overlay.billing) {
return patchOverlayState({ billing: null })
}
if (overlay.subscription) {
return patchOverlayState({ subscription: null })
}
if (overlay.skillsHub) {
return patchOverlayState({ skillsHub: false })
}
if (overlay.pluginsHub) {
return patchOverlayState({ pluginsHub: false })
}
if (overlay.sessions) {
return patchOverlayState({ sessions: false })
}
if (overlay.agents) {
return patchOverlayState({ agents: false })
}
if (overlay.journey) {
return patchOverlayState({ journey: false })
}
if (overlay.widget) {
return closeWidget()
}
}
const cycleQueue = (dir: 1 | -1) => {
const len = cRefs.queueRef.current.length
if (!len) {
return false
}
const index = cState.queueEditIdx === null ? (dir > 0 ? 0 : len - 1) : (cState.queueEditIdx + dir + len) % len
cActions.setQueueEdit(index)
cActions.setHistoryIdx(null)
cActions.setInput(cRefs.queueRef.current[index]?.display ?? '')
return true
}
const cycleHistory = (dir: 1 | -1) => {
const h = cRefs.historyRef.current
const cur = cState.historyIdx
if (dir < 0) {
if (!h.length) {
return
}
if (cur === null) {
cRefs.historyDraftRef.current = cState.input
}
const index = cur === null ? h.length - 1 : Math.max(0, cur - 1)
cActions.setHistoryIdx(index)
cActions.setQueueEdit(null)
cActions.setInput(h[index] ?? '')
return
}
if (cur === null) {
return
}
const next = cur + 1
if (next >= h.length) {
cActions.setHistoryIdx(null)
cActions.setInput(cRefs.historyDraftRef.current)
} else {
cActions.setHistoryIdx(next)
cActions.setInput(h[next] ?? '')
}
}
// CLI parity: Ctrl+B toggles a VAD-bounded push-to-talk capture
// (NOT the voice-mode umbrella bit). The mode is enabled via /voice on;
// Ctrl+B while the mode is off sys-nudges the user. While the mode is
// on, the first press starts a single VAD-bounded capture
// (gateway -> start_continuous(auto_restart=false), VAD auto-stop ->
// transcribe -> idle), a subsequent press stops and transcribes it.
// The gateway publishes voice.status + voice.transcript events that
// createGatewayEventHandler turns into UI badges and composer injection.
const voiceRecordToggle = () => {
if (!voice.enabled) {
return actions.sys('voice: mode is off — enable with /voice on')
}
const starting = !voice.recording
const action = starting ? 'start' : 'stop'
// Optimistic UI — flip the REC badge immediately so the user gets
// feedback while the RPC round-trips; the voice.status event is the
// authoritative source and may correct us.
if (starting) {
voice.setRecording(true)
} else {
voice.setRecording(false)
voice.setProcessing(false)
}
gateway
.rpc<VoiceRecordResponse>('voice.record', { action, session_id: getUiState().sid })
.then(r => applyVoiceRecordResponse(r, starting, voice, actions.sys))
.catch((e: Error) => {
// Revert optimistic UI on failure.
if (starting) {
voice.setRecording(false)
}
actions.sys(`voice error: ${e.message}`)
})
}
// Double-Esc discards the draft, matching Claude Code / Gemini CLI. It
// sits above the isBlocked early-return so a prompt overlay cannot swallow
// it. Ctrl+C now clears a non-empty composer even mid-stream; Esc Esc is
// still the dedicated discard (pushes the draft to history so Up recalls it).
const lastEscRef = useRef(0)
useInput((ch, key) => {
const live = getUiState()
if (key.escape) {
const now = Date.now()
const isDouble = now - lastEscRef.current <= DOUBLE_ESC_MS
lastEscRef.current = isDouble ? 0 : now
if (isDouble && (cState.input || cState.inputBuf.length)) {
if (cState.input.trim()) {
cActions.pushHistory(cState.input)
}
cActions.clearIn()
return
}
}
if (isBlocked) {
// When approval/clarify/confirm overlays are active, their own useInput
// handlers must receive keystrokes (arrow keys, numbers, Enter). Only
// intercept Ctrl+C here so the user can deny/dismiss — all other keys
// fall through to the component-level handlers.
//
// Scroll inputs (wheel / PageUp / PageDown / Shift+↑↓) are special:
// they must reach the transcript scroll handlers below even with a
// prompt up. Long-thread context the prompt is asking about often
// lives above the visible viewport, and being unable to read it while
// answering felt like the prompt had locked the entire UI. Explicitly
// skip the prompt-overlay early-return for scroll keys so they fall
// through to the wheel / PageUp / Shift+arrow handlers below.
const promptOverlay =
overlay.approval || overlay.billing || overlay.clarify || overlay.confirm || overlay.subscription
const fallThroughForScroll = promptOverlay && shouldFallThroughForScroll(key)
if (promptOverlay && !fallThroughForScroll) {
if (isCtrl(key, ch, 'c')) {
cancelOverlayFromCtrlC()
}
return
}
if (overlay.pager) {
if (key.escape || isCtrl(key, ch, 'c') || ch === 'q') {
return patchOverlayState({ pager: null })
}
const move = (delta: number | 'top' | 'bottom') =>
patchOverlayState(prev => {
if (!prev.pager) {
return prev
}
const { lines, offset } = prev.pager
const max = Math.max(0, lines.length - pagerPageSize)
const step = delta === 'top' ? -lines.length : delta === 'bottom' ? lines.length : delta
const next = Math.max(0, Math.min(offset + step, max))
return next === offset ? prev : { ...prev, pager: { ...prev.pager, offset: next } }
})
if (key.upArrow || ch === 'k') {
return move(-1)
}
if (key.downArrow || ch === 'j') {
return move(1)
}
if (key.pageUp || ch === 'b') {
return move(-pagerPageSize)
}
if (ch === 'g') {
return move('top')
}
if (ch === 'G') {
return move('bottom')
}
if (key.return || ch === ' ' || key.pageDown) {
patchOverlayState(prev => {
if (!prev.pager) {
return prev
}
const { lines, offset } = prev.pager
const max = Math.max(0, lines.length - pagerPageSize)
// Auto-close only when already at the last page — otherwise clamp
// to `max` so the offset matches what the line/page-back handlers
// can reach (prevents a snap-back jump on the next ↑/↓/PgUp).
return offset >= max
? { ...prev, pager: null }
: { ...prev, pager: { ...prev.pager, offset: Math.min(offset + pagerPageSize, max) } }
})
}
return
}
// Widget apps (SDK): the active app owns every key while open. This
// supersedes the demo-only handleStackedModalInput routing from #68999
// — grid-test/dialog are now widget apps, so the topmost-modal-owns-
// input contract is enforced structurally by the single active widget.
if (overlay.widget && dispatchWidgetInput({ ch, key })) {
return
}
if (isCtrl(key, ch, 'c') || (key.escape && (overlay.secret || overlay.sudo))) {
cancelOverlayFromCtrlC()
} else if (key.escape && overlay.sessions) {
patchOverlayState({ sessions: false })
}
// When a prompt overlay is up and the user pressed a scroll key, fall
// through to the global scroll handlers below instead of returning.
// Otherwise nothing above this comment matched, and there's nothing
// useful to do for an arbitrary key while blocked.
if (!fallThroughForScroll) {
return
}
}
if (cState.completions.length && cState.input && cState.historyIdx === null && (key.upArrow || key.downArrow)) {
const len = cState.completions.length
cActions.setCompIdx(i => (key.upArrow ? (i - 1 + len) % len : (i + 1) % len))
return
}
if (key.wheelUp || key.wheelDown) {
const dir: -1 | 1 = key.wheelUp ? -1 : 1
const now = Date.now()
// Modifier-held wheel = precision mode: one row per frame, no accel.
// Smooth mice / trackpads emit tiny same-frame bursts; coalesce those
// without the old 80ms throttle that made opt-scroll feel stepped.
// SGR/X10 mouse encoding only carries shift/meta/ctrl bits; Cmd on
// macOS is intercepted by the terminal, so we honor Option (meta) on
// Mac / Alt (meta) on Win+Linux / Ctrl as a portable fallback. Shift
// is reserved for selection extension.
const hasModifier = key.meta || key.ctrl
const precision = computePrecisionWheelStep(precisionWheelRef.current, dir, hasModifier, now)
if (precision.active) {
// Entering precision mode must discard any accelerated wheel state;
// otherwise the next normal wheel event inherits stale momentum.
if (precision.entered) {
wheelAccelRef.current = initWheelAccelForHost()
}
return precision.rows ? scrollTranscript(dir * wheelStep) : undefined
}
// 0 = direction-flip bounce deferred; skip the no-op scroll.
const rows = computeWheelStep(wheelAccelRef.current, dir, now)
return rows ? scrollTranscript(dir * rows * wheelStep) : undefined
}
if (key.shift && key.upArrow) {
return scrollTranscript(-1)
}
if (key.shift && key.downArrow) {
return scrollTranscript(1)
}
if (key.pageUp || key.pageDown) {
// Half-viewport keeps 50% continuity and stays under Ink's
// `delta < innerHeight` DECSTBM fast-path threshold.
const viewport = terminal.scrollRef.current?.getViewportHeight() ?? Math.max(6, (terminal.stdout?.rows ?? 24) - 8)
const step = Math.max(4, Math.floor(viewport / 2))
return scrollTranscript(key.pageUp ? -step : step)
}
// Escape-based voice bindings (ctrl/alt/super+escape) must win before the
// generic Esc handlers below; otherwise queue-edit cancel / selection-clear
// would swallow the chord and /voice would advertise a shortcut that never
// actually toggles recording in those UI states.
if (key.escape && isVoiceToggleKey(key, ch, voice.recordKey)) {
return voiceRecordToggle()
}
// Queue-edit cancel beats selection-clear for plain Esc: the queue header
// explicitly promises "Esc cancel", so honoring it takes priority over the
// implicit selection-dismissal convention. Without an active edit, fall through.
if (key.escape && cState.queueEditIdx !== null) {
return cActions.clearIn()
}
if (key.escape && terminal.hasSelection) {
return clearSelection()
}
if (key.upArrow && !cState.inputBuf.length) {
const inputSel = getInputSelection()
const cursor = inputSel && inputSel.start === inputSel.end ? inputSel.start : null
const noLineAbove =
!cState.input || (cursor !== null && cState.input.lastIndexOf('\n', Math.max(0, cursor - 1)) < 0)
if (noLineAbove) {
cycleQueue(1) || cycleHistory(-1)
return
}
}
if (key.downArrow && !cState.inputBuf.length) {
const inputSel = getInputSelection()
const cursor = inputSel && inputSel.start === inputSel.end ? inputSel.start : null
const noLineBelow = !cState.input || (cursor !== null && cState.input.indexOf('\n', cursor) < 0)
if (noLineBelow || cState.historyIdx !== null) {
cycleQueue(-1) || cycleHistory(1)
return
}
}
if (isCopyShortcut(key, ch)) {
if (terminal.hasSelection) {
return copySelection()
}
const inputSel = getInputSelection()
if (handleInputSelectionClipboard(inputSel, 'copy')) {
return
}
// On macOS, Cmd+C with no selection is a no-op (Ctrl+C below handles interrupt).
// On non-macOS, isAction uses Ctrl, so fall through to interrupt/clear/exit.
if (isMac) {
return
}
}
if (isCtrl(key, ch, 'x') && handleInputSelectionClipboard(getInputSelection(), 'cut')) {
return
}
if (isCtrl(key, ch, 'x') && cState.queueEditIdx !== null) {
cActions.removeQueue(cState.queueEditIdx)
return cActions.clearIn()
}
if (isCtrl(key, ch, 'x')) {
return patchOverlayState({ sessions: true })
}
// Ctrl+O opens the model picker without disturbing a typed draft — the
// same overlay `/model` opens, but reachable without clearing what you've
// typed to run the command. Works mid-stream: picking a model writes the
// session model (config.set), which the next turn reads while the in-flight
// turn keeps streaming.
if (isCtrl(key, ch, 'o')) {
return patchOverlayState({ modelPicker: true })
}
if (key.ctrl && ch.toLowerCase() === 'c') {
const ctrlC = resolveCtrlCComposerAction({
busy: live.busy,
hasDraft: Boolean(cState.input || cState.inputBuf.length),
hasSession: Boolean(live.sid)
})
if (ctrlC === 'clear') {
return cActions.clearIn()
}
if (ctrlC === 'interrupt' && live.sid) {
return turnController.interruptTurn({
appendMessage: actions.appendMessage,
gw: gateway.gw,
sid: live.sid,
sys: actions.sys
})
}
return handleIdleHotkeyExit(actions, DASHBOARD_TUI_MODE, () => {
gateway.gw.publishLocalEvent({
payload: { reason: 'idle_exit_hotkey' },
session_id: live.sid ?? undefined,
type: 'dashboard.new_session_requested'
})
})
}
if (isAction(key, ch, 'd')) {
return handleIdleHotkeyExit(actions, DASHBOARD_TUI_MODE, () => {
gateway.gw.publishLocalEvent({
payload: { reason: 'idle_exit_hotkey' },
session_id: live.sid ?? undefined,
type: 'dashboard.new_session_requested'
})
})
}
if (isAction(key, ch, 'l')) {
clearSelection()
forceRedraw(terminal.stdout ?? process.stdout)
return
}
if (isVoiceToggleKey(key, ch, voice.recordKey)) {
return voiceRecordToggle()
}
// Cmd/Ctrl+G, plus Alt+G fallback for VSCode/Cursor (they bind the
// primary keystroke to "Find Next" before the TUI sees it; Alt+G
// arrives as meta+g across platforms).
if (ch.toLowerCase() === 'g' && (isAction(key, ch, 'g') || key.meta)) {
return void cActions.openEditor().catch((err: unknown) => {
actions.sys(err instanceof Error ? `failed to open editor: ${err.message}` : 'failed to open editor')
})
}
// shift-tab flips yolo without spending a turn (claude-code parity)
if (key.shift && key.tab && !cState.completions.length) {
if (!live.sid) {
return void actions.sys('yolo needs an active session')
}
// gateway.rpc swallows errors with its own sys() message and resolves to null,
// so we only speak when it came back with a real shape. null = rpc already spoke.
return void gateway.rpc<ConfigSetResponse>('config.set', { key: 'yolo', session_id: live.sid }).then(r => {
if (r?.value === '1') {
return actions.sys('yolo on')
}
if (r?.value === '0') {
return actions.sys('yolo off')
}
if (r) {
actions.sys('failed to toggle yolo')
}
})
}
if (key.tab && cState.completions.length) {
const row = cState.completions[cState.compIdx]
if (row?.text) {
cActions.setInput(applyCompletion(cState.input, row.text, cState.compReplace))
}
return
}
if (isAction(key, ch, 'k') && cRefs.queueRef.current.length && live.sid) {
const next = cActions.dequeue()
if (next) {
cActions.setQueueEdit(null)
actions.dispatchSubmission(next)
}
}
})
return { pagerPageSize }
}
+69
View File
@@ -0,0 +1,69 @@
import { useEffect, useRef } from 'react'
import { LONG_RUN_CHARMS } from '../content/charms.js'
import { pick, toolTrailLabel } from '../lib/text.js'
import { turnController } from './turnController.js'
import { useTurnSelector } from './turnStore.js'
import { getUiState } from './uiStore.js'
const DELAY_MS = 8_000
const INTERVAL_MS = 10_000
const MAX_CHARMS_PER_TOOL = 2
interface Slot {
count: number
lastAt: number
}
export function useLongRunToolCharms() {
const tools = useTurnSelector(state => state.tools)
const slots = useRef(new Map<string, Slot>())
useEffect(() => {
if (!getUiState().busy || !tools.length) {
slots.current.clear()
return
}
const tick = () => {
if (!getUiState().busy) {
slots.current.clear()
return
}
const now = Date.now()
const liveIds = new Set(tools.map(t => t.id))
for (const key of Array.from(slots.current.keys())) {
if (!liveIds.has(key)) {
slots.current.delete(key)
}
}
for (const tool of tools) {
if (!tool.startedAt || now - tool.startedAt < DELAY_MS) {
continue
}
const slot = slots.current.get(tool.id) ?? { count: 0, lastAt: 0 }
if (slot.count >= MAX_CHARMS_PER_TOOL || now - slot.lastAt < INTERVAL_MS) {
continue
}
slots.current.set(tool.id, { count: slot.count + 1, lastAt: now })
turnController.pushActivity(
`${pick(LONG_RUN_CHARMS)} (${toolTrailLabel(tool.name)} · ${Math.round((now - tool.startedAt) / 1000)}s)`
)
}
}
tick()
const id = setInterval(tick, 1000)
return () => clearInterval(id)
}, [tools])
}
File diff suppressed because it is too large Load Diff
+352
View File
@@ -0,0 +1,352 @@
import { useStdout } from '@hermes/ink'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { PetGrid } from '../components/petSprite.js'
import { createPetSingleFlight, requestPetUpdate } from '../lib/petPolling.js'
import { useGateway } from './gatewayContext.js'
import { $overlayState, getOverlayState } from './overlayStore.js'
import { $petFlash } from './petFlashStore.js'
import { $turnState } from './turnStore.js'
import { $uiState } from './uiStore.js'
export type PetState = 'idle' | 'wave' | 'run' | 'failed' | 'review' | 'jump' | 'waiting'
interface PetActivity {
busy: boolean
toolRunning: boolean
reasoning: boolean
awaitingInput: boolean
}
/**
* Resolve the animation state — mirrors `agent.pet.state.derive_pet_state`
* (and the desktop's `derivePetState`) so all surfaces agree. `awaitingInput`
* (a clarify/approval blocking on the user) outranks the in-flight signals
* because the turn is paused on you, not working.
*/
export function derivePetState({ busy, toolRunning, reasoning, awaitingInput }: PetActivity): PetState {
if (awaitingInput) {
return 'waiting'
}
if (toolRunning) {
return 'run'
}
if (reasoning) {
return 'review'
}
if (busy) {
return 'run'
}
return 'idle'
}
// The overlays that mean "the agent is blocked on the user" (vs. user-toggled
// pickers like model/sessions, which aren't the agent waiting).
function isAwaitingInput(): boolean {
const o = getOverlayState()
return Boolean(o.clarify || o.approval || o.sudo || o.secret || o.confirm)
}
// A kitty Unicode-placeholder frame set: a static placeholder grid (painted by
// Ink in the image-id color) plus per-frame transmit escapes written straight
// to the terminal out-of-band.
interface KittyView {
color: string
placeholder: string[]
}
interface PetCellsResult {
color?: string
enabled?: boolean
frameMs?: number
// unicode mode: cell grids; kitty mode: transmit-escape strings.
frames?: PetGrid[] | string[]
graphics?: string
imageId?: number
placeholder?: string[]
scale?: number
slug?: string
state?: string
}
type CacheEntry =
| { kind: 'cells'; frameMs: number; frames: PetGrid[] }
| { kind: 'kitty'; frameMs: number; frames: string[]; placeholder: string[]; color: string }
const FRAME_MS = 160
const POLL_MS = 2500
// Only the standalone TUI owns a real terminal it can splat image escapes into;
// when piped (or running under the dashboard PTY the gateway resolves to
// half-blocks anyway) we never ask for graphics.
const IS_TTY = Boolean(process.stdout?.isTTY)
export interface PetRender {
enabled: boolean
grid: PetGrid | null
kitty: KittyView | null
}
/**
* Drives the TUI pet. Fetches each (slug, state)'s frames via the `pet.cells`
* RPC (cached) and animates the frame index. Two render paths:
*
* - **kitty** (Ghostty/kitty): the engine returns a static placeholder grid +
* per-frame transmit escapes. We paint the placeholder with Ink and write the
* current frame's escape to the terminal out-of-band, so the image animates
* underneath without Ink ever repainting.
* - **cells** (everywhere else): truecolor half-block grids painted by Ink.
*
* A steady poll keeps it reactive to config changes made elsewhere (`/pet`, the
* picker, `hermes pets select`) so adopting/switching/disabling takes effect
* live. Disabled/cached pets use the cheap inline `pet.info.meta` probe; only
* uncached enabled states request `pet.cells` from the long-handler pool.
*/
export function usePet(): PetRender {
const { gw } = useGateway()
const { write } = useStdout()
const [enabled, setEnabled] = useState(false)
const [grid, setGrid] = useState<PetGrid | null>(null)
const [kitty, setKitty] = useState<KittyView | null>(null)
const cache = useRef<Map<string, CacheEntry>>(new Map())
const slugRef = useRef('')
const scaleRef = useRef(0)
const revisionRef = useRef('')
const imageIdRef = useRef(0)
const stateRef = useRef<PetState>('idle')
const frameRef = useRef(0)
const runSingleFlight = useRef(createPetSingleFlight()).current
const [petState, setPetState] = useState<PetState>('idle')
// Recompute the desired state on every turn/ui/flash change. A transient
// flash (wave/jump/failed) wins until it expires; a timer re-runs at expiry.
useEffect(() => {
let expiry: ReturnType<typeof setTimeout> | undefined
const apply = (next: PetState) => {
if (next !== stateRef.current) {
stateRef.current = next
frameRef.current = 0
setPetState(next)
}
}
const recompute = () => {
clearTimeout(expiry)
const flash = $petFlash.get()
const now = Date.now()
if (flash && now < flash.until) {
apply(flash.state)
expiry = setTimeout(recompute, flash.until - now)
return
}
const turn = $turnState.get()
const ui = $uiState.get()
apply(
derivePetState({
awaitingInput: isAwaitingInput(),
busy: ui.busy,
reasoning: turn.reasoningActive,
toolRunning: turn.tools.length > 0
})
)
}
recompute()
const unsubTurn = $turnState.listen(recompute)
const unsubUi = $uiState.listen(recompute)
const unsubFlash = $petFlash.listen(recompute)
const unsubOverlay = $overlayState.listen(recompute)
return () => {
clearTimeout(expiry)
unsubTurn()
unsubUi()
unsubFlash()
unsubOverlay()
}
}, [])
// Free the terminal-side image when the pet goes away or the hook unmounts.
const releaseKitty = useCallback(() => {
if (imageIdRef.current) {
try {
write(`\x1b_Ga=d,d=i,i=${imageIdRef.current},q=2\x1b\\`)
} catch {
// best-effort cleanup
}
imageIdRef.current = 0
}
}, [write])
const disablePet = useCallback(() => {
releaseKitty()
slugRef.current = ''
scaleRef.current = 0
revisionRef.current = ''
cache.current.clear()
setGrid(null)
setKitty(null)
setEnabled(false)
}, [releaseKitty])
// Probe the active selection cheaply, then fetch + cache one uncached state.
const sync = useCallback(
(state: PetState) =>
runSingleFlight(async () => {
const update = await requestPetUpdate<PetCellsResult>(gw, state, IS_TTY, meta => {
const slug = meta.slug ?? ''
const scale = meta.scale ?? 0
const revision = meta.spritesheetRevision ?? ''
const selectionChanged =
slug !== slugRef.current || scale !== scaleRef.current || revision !== revisionRef.current
if (selectionChanged) {
releaseKitty()
slugRef.current = slug
scaleRef.current = scale
revisionRef.current = revision
cache.current.clear()
frameRef.current = 0
}
return !cache.current.has(`${slug}:${state}`)
})
if (!update) {
return
}
if (!update.meta.enabled) {
disablePet()
return
}
const res = update.cells
if (!res) {
setEnabled(true)
return
}
if (!res.enabled) {
disablePet()
return
}
const slug = res.slug ?? update.meta.slug ?? ''
const scale = res.scale ?? update.meta.scale ?? 0
// Config may change between the metadata and frame calls. Keep the
// frame response authoritative and force a fresh metadata revision on
// the next poll when the response moved to another selection.
if (slug !== slugRef.current || scale !== scaleRef.current) {
releaseKitty()
slugRef.current = slug
scaleRef.current = scale
revisionRef.current = slug === update.meta.slug ? revisionRef.current : ''
cache.current.clear()
frameRef.current = 0
}
if (res.graphics === 'kitty' && res.frames?.length && res.placeholder?.length) {
imageIdRef.current = res.imageId ?? 0
cache.current.set(`${slug}:${state}`, {
color: res.color ?? '#000001',
frameMs: res.frameMs ?? FRAME_MS,
frames: res.frames as string[],
kind: 'kitty',
placeholder: res.placeholder
})
} else if (res.frames?.length) {
cache.current.set(`${slug}:${state}`, {
frameMs: res.frameMs ?? FRAME_MS,
frames: res.frames as PetGrid[],
kind: 'cells'
})
}
setEnabled(true)
}),
[disablePet, gw, releaseKitty, runSingleFlight]
)
// Pull frames whenever the state changes (if not already cached for the
// active pet), plus a steady poll that catches adopt/switch/disable.
useEffect(() => {
if (!cache.current.has(`${slugRef.current}:${petState}`)) {
void sync(petState)
}
const timer = setInterval(() => void sync(stateRef.current), POLL_MS)
return () => clearInterval(timer)
}, [petState, sync])
useEffect(() => releaseKitty, [releaseKitty])
// Animation timer.
useEffect(() => {
if (!enabled) {
return
}
const tick = () => {
const entry = cache.current.get(`${slugRef.current}:${stateRef.current}`)
if (!entry?.frames.length) {
return // keep the last frame painted while the new state loads
}
const idx = frameRef.current % entry.frames.length
frameRef.current = idx + 1
if (entry.kind === 'kitty') {
// Transmit this frame's image under the shared id; the static
// placeholder cells (set below) render it. No Ink repaint needed.
try {
write(entry.frames[idx] ?? '')
} catch {
// ignore transmit failures
}
setGrid(null)
setKitty(prev =>
prev && prev.color === entry.color && prev.placeholder === entry.placeholder
? prev
: { color: entry.color, placeholder: entry.placeholder }
)
return
}
setKitty(null)
setGrid(entry.frames[idx] ?? null)
}
tick()
const interval = setInterval(tick, FRAME_MS)
return () => clearInterval(interval)
}, [enabled, petState, write])
return { enabled, grid, kitty }
}
+425
View File
@@ -0,0 +1,425 @@
import { writeFileSync } from 'node:fs'
import type { ScrollBoxHandle } from '@hermes/ink'
import { evictInkCaches } from '@hermes/ink'
import { type RefObject, useCallback, useEffect, useMemo, useRef } from 'react'
import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js'
import { introMsg, toTranscriptMessages } from '../domain/messages.js'
import { ZERO } from '../domain/usage.js'
import { type GatewayClient } from '../gatewayClient.js'
import type {
SessionActivateResponse,
SessionCloseResponse,
SessionCreateResponse,
SessionInflightTurn,
SessionResumeResponse,
SessionTitleResponse,
SetupStatusResponse
} from '../gatewayTypes.js'
import { asRpcResult } from '../lib/rpc.js'
import type { Msg, PanelSection, SessionInfo, Usage } from '../types.js'
import type { ComposerActions, GatewayRpc, StateSetter } from './interfaces.js'
import { patchOverlayState } from './overlayStore.js'
import { scheduleResumeScrollToBottom } from './sessionResumeView.js'
import { turnController } from './turnController.js'
import { patchTurnState } from './turnStore.js'
import { getUiState, patchUiState } from './uiStore.js'
export { refreshSessionView, scheduleResumeScrollToBottom } from './sessionResumeView.js'
const usageFrom = (info: null | SessionInfo): Usage => (info?.usage ? { ...ZERO, ...info.usage } : ZERO)
const statusFromLiveSession = (status?: string, running = false) => {
if (status === 'waiting') {
return 'waiting for input…'
}
if (status === 'starting') {
return 'starting agent…'
}
return running || status === 'working' ? 'running…' : 'ready'
}
export const writeActiveSessionFile = (sessionId: null | string, file = process.env.HERMES_TUI_ACTIVE_SESSION_FILE) => {
if (!file || !sessionId) {
return
}
try {
writeFileSync(file, JSON.stringify({ session_id: sessionId }), { mode: 0o600 })
} catch {
// Best-effort shell epilogue hint only; never break live session changes.
}
}
export const liveSessionInflightMessages = (inflight?: null | SessionInflightTurn): Msg[] => {
const user = String(inflight?.user ?? '').trim()
return user ? [{ role: 'user', text: user }] : []
}
export const hydrateLiveSessionInflight = (inflight?: null | SessionInflightTurn) => {
const assistant = String(inflight?.assistant ?? '')
if (!assistant && !inflight?.streaming) {
return
}
turnController.hydrateStreamingText(assistant)
}
export const signalFreshSessionBoundary = (
previousSid: null | string,
nextSid: null | string,
onFreshSessionStarted?: (sessionId: string) => void
) => {
if (!previousSid || !nextSid || previousSid === nextSid || !onFreshSessionStarted) {
return false
}
onFreshSessionStarted(nextSid)
return true
}
const trimTail = (items: Msg[]) => {
const q = [...items]
while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') {
q.pop()
}
if (q.at(-1)?.role === 'user') {
q.pop()
}
return q
}
export interface UseSessionLifecycleOptions {
colsRef: { current: number }
composerActions: ComposerActions
gw: GatewayClient
onFreshSessionStarted?: (sessionId: string) => void
panel: (title: string, sections: PanelSection[]) => void
rpc: GatewayRpc
scrollRef: RefObject<null | ScrollBoxHandle>
setHistoryItems: StateSetter<Msg[]>
setLastUserMsg: StateSetter<string>
setSessionStartedAt: StateSetter<number>
setStickyPrompt: StateSetter<string>
setVoiceProcessing: StateSetter<boolean>
setVoiceRecording: StateSetter<boolean>
sys: (text: string) => void
}
export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
const {
colsRef,
composerActions,
gw,
onFreshSessionStarted,
panel,
rpc,
scrollRef,
setHistoryItems,
setLastUserMsg,
setSessionStartedAt,
setStickyPrompt,
setVoiceProcessing,
setVoiceRecording,
sys
} = opts
const closeSession = useCallback(
(targetSid?: null | string) =>
targetSid ? rpc<SessionCloseResponse>('session.close', { session_id: targetSid }) : Promise.resolve(null),
[rpc]
)
const cancelResumeScrollRef = useRef<null | (() => void)>(null)
const resetSession = useCallback(() => {
cancelResumeScrollRef.current?.()
cancelResumeScrollRef.current = null
turnController.fullReset()
setVoiceRecording(false)
setVoiceProcessing(false)
patchUiState({ bgTasks: new Set(), info: null, sid: null, usage: ZERO })
setHistoryItems([])
setLastUserMsg('')
setStickyPrompt('')
composerActions.setComposerTokens([])
// Half-prune: new session has new keys, but keep a warm pool in case
// the user resumes back to the prior session.
evictInkCaches('half')
}, [composerActions, setHistoryItems, setLastUserMsg, setStickyPrompt, setVoiceProcessing, setVoiceRecording])
useEffect(
() => () => {
cancelResumeScrollRef.current?.()
cancelResumeScrollRef.current = null
},
[]
)
const resetVisibleHistory = useCallback(
(info: null | SessionInfo = null) => {
turnController.idle()
turnController.clearReasoning()
turnController.turnTools = []
turnController.persistedToolLabels.clear()
setHistoryItems(info ? [introMsg(info)] : [])
setStickyPrompt('')
setLastUserMsg('')
composerActions.setComposerTokens([])
patchTurnState({ activity: [] })
patchUiState({ info, usage: usageFrom(info) })
},
[composerActions, setHistoryItems, setLastUserMsg, setStickyPrompt]
)
const startNewSession = useCallback(
async (msg?: string, title?: string, keepCurrent = false) => {
const setup = await rpc<SetupStatusResponse>('setup.status', {})
if (setup?.provider_configured === false) {
panel(SETUP_REQUIRED_TITLE, buildSetupRequiredSections())
patchUiState({ status: 'setup required' })
return null
}
const previousSid = getUiState().sid
if (!keepCurrent) {
await closeSession(previousSid)
}
const r = await rpc<SessionCreateResponse>('session.create', { cols: colsRef.current })
if (!r) {
patchUiState({ status: 'ready' })
return null
}
const info = r.info ?? null
const requestedTitle = title?.trim() ?? ''
resetSession()
setSessionStartedAt(Date.now())
writeActiveSessionFile(r.session_id)
patchUiState({
info,
sid: r.session_id,
status: info?.version ? 'ready' : 'starting agent…',
usage: usageFrom(info)
})
if (info) {
setHistoryItems([introMsg(info)])
}
if (info?.credential_warning) {
sys(`warning: ${info.credential_warning}`)
}
if (info?.config_warning) {
sys(`warning: ${info.config_warning}`)
}
if (msg) {
sys(msg)
}
if (requestedTitle) {
rpc<SessionTitleResponse>('session.title', {
session_id: r.session_id,
title: requestedTitle
})
.then(result => {
if (!result || getUiState().sid !== r.session_id) {
return
}
const nextTitle = (result.title ?? requestedTitle).trim()
const suffix = result.pending ? ' (queued while session initializes)' : ''
patchUiState({ sessionTitle: nextTitle })
sys(`session title set: ${nextTitle}${suffix}`)
})
.catch((err: unknown) => {
if (getUiState().sid !== r.session_id) {
return
}
const message = err instanceof Error ? err.message : String(err)
sys(`warning: failed to set session title: ${message}`)
})
}
signalFreshSessionBoundary(previousSid, r.session_id, onFreshSessionStarted)
return r.session_id
},
[closeSession, colsRef, onFreshSessionStarted, panel, resetSession, rpc, setHistoryItems, setSessionStartedAt, sys]
)
const newSession = useCallback(
(msg?: string, title?: string) => startNewSession(msg, title, false),
[startNewSession]
)
const newLiveSession = useCallback(
(msg = 'new live session started', title?: string) => {
patchOverlayState({ sessions: false })
return startNewSession(msg, title, true)
},
[startNewSession]
)
const activateLiveSession = useCallback(
(id: string) => {
patchOverlayState({ sessions: false })
patchUiState({ status: 'switching session…' })
gw.request<SessionActivateResponse>('session.activate', { session_id: id })
.then(raw => {
const r = asRpcResult<SessionActivateResponse>(raw)
if (!r) {
sys('error: invalid response: session.activate')
return patchUiState({ status: 'ready' })
}
const info = r.info ?? null
const running = Boolean(r.running || r.status === 'working' || r.status === 'waiting')
resetSession()
setSessionStartedAt(r.started_at ? r.started_at * 1000 : Date.now())
const transcript = [...toTranscriptMessages(r.messages), ...liveSessionInflightMessages(r.inflight)]
setHistoryItems(info ? [introMsg(info), ...transcript] : transcript)
writeActiveSessionFile(r.session_key ?? r.session_id)
patchUiState({
busy: running,
info,
sid: r.session_id,
status: statusFromLiveSession(r.status, running),
usage: usageFrom(info)
})
hydrateLiveSessionInflight(r.inflight)
cancelResumeScrollRef.current?.()
cancelResumeScrollRef.current = scheduleResumeScrollToBottom(scrollRef)
})
.catch((e: Error) => {
sys(`error: ${e.message}`)
patchUiState({ status: 'ready' })
})
},
[gw, resetSession, scrollRef, setHistoryItems, setSessionStartedAt, sys]
)
const resumeById = useCallback(
(id: string) => {
patchOverlayState({ sessions: false })
patchUiState({ status: 'resuming…' })
rpc<SetupStatusResponse>('setup.status', {}).then(setup => {
if (setup?.provider_configured === false) {
panel(SETUP_REQUIRED_TITLE, buildSetupRequiredSections())
patchUiState({ status: 'setup required' })
return
}
const previousSid = getUiState().sid
gw.request<SessionResumeResponse>('session.resume', { cols: colsRef.current, session_id: id })
.then(raw => {
const r = asRpcResult<SessionResumeResponse>(raw)
if (!r) {
sys('error: invalid response: session.resume')
return patchUiState({ status: 'ready' })
}
const info = r.info ?? null
const running = Boolean(r.running || r.status === 'working' || r.status === 'waiting')
resetSession()
setSessionStartedAt(r.started_at ? r.started_at * 1000 : Date.now())
const resumed = [...toTranscriptMessages(r.messages), ...liveSessionInflightMessages(r.inflight)]
setHistoryItems(info ? [introMsg(info), ...resumed] : resumed)
writeActiveSessionFile(r.resumed ?? r.session_id)
patchUiState({
busy: running,
info,
sid: r.session_id,
status: statusFromLiveSession(r.status, running),
usage: usageFrom(info)
})
hydrateLiveSessionInflight(r.inflight)
cancelResumeScrollRef.current?.()
cancelResumeScrollRef.current = scheduleResumeScrollToBottom(scrollRef)
if (previousSid && previousSid !== r.session_id) {
void closeSession(previousSid)
}
})
.catch((e: Error) => {
sys(`error: ${e.message}`)
patchUiState({ status: 'ready' })
})
})
},
[closeSession, colsRef, gw, panel, resetSession, rpc, scrollRef, setHistoryItems, setSessionStartedAt, sys]
)
const guardBusySessionSwitch = useCallback(
(what = 'switch sessions') => {
if (!getUiState().busy) {
return false
}
sys(`interrupt the current turn before trying to ${what}`)
return true
},
[sys]
)
return useMemo(
() => ({
activateLiveSession,
closeSession,
guardBusySessionSwitch,
newLiveSession,
newSession,
resetSession,
resetVisibleHistory,
resumeById,
trimLastExchange: trimTail
}),
[
activateLiveSession,
closeSession,
guardBusySessionSwitch,
newLiveSession,
newSession,
resetSession,
resetVisibleHistory,
resumeById,
trimTail
]
)
}
+441
View File
@@ -0,0 +1,441 @@
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import { TYPING_IDLE_MS } from '../config/timing.js'
import { expandTokens } from '../domain/attachments.js'
import { completionToApplyOnSubmit, looksLikeSlashCommand, parseSlashCommand } from '../domain/slash.js'
import type { GatewayClient } from '../gatewayClient.js'
import type { SessionSteerResponse, ShellExecResponse } from '../gatewayTypes.js'
import { queueItem, type QueueItem } from '../hooks/useQueue.js'
import { asRpcResult } from '../lib/rpc.js'
import { hasInterpolation, INTERPOLATION_RE } from '../protocol/interpolation.js'
import type { Msg } from '../types.js'
import type { ComposerActions, ComposerRefs, ComposerState, ComposerToken } from './interfaces.js'
import { submitPrompt } from './submissionCore.js'
import { turnController } from './turnController.js'
import { getUiState, patchUiState } from './uiStore.js'
const DOUBLE_ENTER_MS = 450
const spliceMatches = (text: string, matches: RegExpMatchArray[], results: string[]) =>
matches.reduceRight((acc, m, i) => acc.slice(0, m.index!) + results[i] + acc.slice(m.index! + m[0].length), text)
export const expandPasteTokens = (tokens: ComposerToken[]) =>
expandTokens(tokens.filter(token => token.kind === 'paste'))
const slashArgument = (command: string) => /^\/\S+\s+([\s\S]+)$/.exec(command)?.[1] ?? ''
export const queueItemFromSlash = (displayCommand: string, expandedCommand: string): QueueItem | undefined => {
const display = slashArgument(displayCommand)
if (!display.trim()) {
return undefined
}
return queueItem(slashArgument(expandedCommand), display)
}
export const prepareSubmission = (display: string, tokens: ComposerToken[]) => ({
display,
text: expandTokens(tokens)(display)
})
/**
* Split a slash submission into the two things it has to be at once.
*
* A slash command's argument is ordinary user text, so a collapsed paste in it
* must resolve BEFORE the command runs — otherwise `/pr-triage [[ … [412 lines]
* … ]]` hands the skill the label and the agent faithfully reports that the
* paste is truncated. The transcript still shows the compact form, because a
* 412-line paste inlined into the scrollback is exactly what collapsing it was
* for.
*
* Image tokens stay as labels: the gateway already holds those files in
* `attached_images` and splices them in at submit.
*/
export const prepareSlashSubmission = (display: string, tokens: ComposerToken[]) => ({
command: expandPasteTokens(tokens)(display),
display
})
export const shouldInterpolateSubmission = (display: string) => hasInterpolation(display)
export function useSubmission(opts: UseSubmissionOptions) {
const { appendMessage, composerActions, composerRefs, composerState, gw, setLastUserMsg, slashRef, submitRef, sys } =
opts
const lastEmptyAt = useRef(0)
const typingIdleTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (typingIdleTimer.current) {
clearTimeout(typingIdleTimer.current)
typingIdleTimer.current = null
}
if (!composerState.input && !composerState.inputBuf.length) {
turnController.relaxStreaming()
return
}
if (getUiState().busy) {
turnController.boostStreamingForTyping()
}
typingIdleTimer.current = setTimeout(() => {
typingIdleTimer.current = null
turnController.relaxStreaming()
}, TYPING_IDLE_MS)
return () => {
if (typingIdleTimer.current) {
clearTimeout(typingIdleTimer.current)
typingIdleTimer.current = null
}
}
}, [composerState.input, composerState.inputBuf])
const send = useCallback(
(
text: string,
showUserMessage = true,
displayText?: string,
expandOverride?: (value: string) => string,
submitOpts: { skipDetectDrop?: boolean } = {}
) => {
// Read tokens off the ref, not render state: a paste immediately followed
// by Enter submits before React has re-rendered with the new token.
const expand = expandOverride ?? expandTokens(composerRefs.tokensRef.current)
submitPrompt(
text,
{
appendMessage,
enqueue: composerActions.enqueue,
expand,
gw,
setLastUserMsg,
sys
},
showUserMessage,
displayText,
submitOpts
)
},
[appendMessage, composerActions, composerRefs, gw, setLastUserMsg, sys]
)
const shellExec = useCallback(
(cmd: string) => {
appendMessage({ role: 'user', text: `!${cmd}` })
patchUiState({ busy: true, status: 'running…' })
gw.request<ShellExecResponse>('shell.exec', { command: cmd })
.then(raw => {
const r = asRpcResult<ShellExecResponse>(raw)
if (!r) {
return sys('error: invalid response: shell.exec')
}
const out = [r.stdout, r.stderr].filter(Boolean).join('\n').trim()
if (out) {
sys(out)
}
if (r.code !== 0 || !out) {
sys(`exit ${r.code}`)
}
})
.catch((e: Error) => sys(`error: ${e.message}`))
.finally(() => patchUiState({ busy: false, status: 'ready' }))
},
[appendMessage, gw, sys]
)
const interpolate = useCallback(
(text: string, then: (result: string) => void) => {
patchUiState({ status: 'interpolating…' })
const matches = [...text.matchAll(new RegExp(INTERPOLATION_RE.source, 'g'))]
Promise.all(
matches.map(m =>
gw
.request<ShellExecResponse>('shell.exec', { command: m[1]! })
.then(raw => {
const r = asRpcResult<ShellExecResponse>(raw)
return [r?.stdout, r?.stderr].filter(Boolean).join('\n').trim()
})
.catch(() => '(error)')
)
).then(results => then(spliceMatches(text, matches, results)))
},
[gw]
)
const sendQueued = useCallback(
(text: string) => {
if (text.startsWith('!')) {
return shellExec(text.slice(1).trim())
}
if (hasInterpolation(text)) {
patchUiState({ busy: true })
return interpolate(text, send)
}
send(text)
},
[interpolate, send, shellExec]
)
// Honors `display.busy_input_mode` from config.yaml (CLI parity):
// - 'queue' (legacy): append to queueRef; drains on busy → false
// - 'steer' : inject into the current turn via session.steer; falls
// back to queue when steer is rejected (no agent / no
// tool window).
// - 'interrupt' (default): submit immediately; the backend redirects the
// active model request (or safely steers after a tool),
// with legacy interrupt + queue as its compatibility path.
//
// `opts.fallbackToFront` re-inserts at the queue head (queue-edit picks keep
// their position); the mainline submit path appends.
const handleBusyInput = useCallback(
(item: QueueItem, opts: { fallbackToFront?: boolean } = {}) => {
const live = getUiState()
const mode = live.busyInputMode
const enqueueText = () => {
if (opts.fallbackToFront) {
composerActions.prependQueue(item)
} else {
composerActions.enqueue(item.text, item.display)
}
}
const fallback = (note: string) => {
enqueueText()
sys(note)
}
if (mode === 'queue') {
return enqueueText()
}
if (mode === 'steer' && live.sid) {
gw.request<SessionSteerResponse>('session.steer', { session_id: live.sid, text: item.text })
.then(raw => {
const r = asRpcResult<SessionSteerResponse>(raw)
if (r?.status !== 'queued') {
fallback('steer rejected — message queued for next turn')
}
})
.catch(() => fallback('steer failed — message queued for next turn'))
return
}
// The gateway owns the atomic redirect decision because it knows whether
// the agent is in model generation, tool execution, or an older runtime.
// Reuse the normal submit pipeline so the correction gets its user bubble
// and file-drop interpolation exactly once.
send(item.text)
},
[composerActions, gw, send, sys]
)
const dispatchSubmission = useCallback(
(full: string) => {
if (!full.trim()) {
return
}
// History stores resolved content, not `[[…]]` labels: tokens are cleared
// on submit, so recall must be self-contained. Image tokens resolve to
// nothing — a detached image can't be re-attached by recalling the text.
// Idempotent on token-free text, so re-submitting a recalled entry is
// stable.
const submissionTokens = [...composerRefs.tokensRef.current]
const submission = prepareSubmission(full, submissionTokens)
const toHistory = submission.text
if (looksLikeSlashCommand(full)) {
const slash = prepareSlashSubmission(full, submissionTokens)
appendMessage({ kind: 'slash', role: 'system', text: slash.display })
composerActions.pushHistory(toHistory)
const parsed = parseSlashCommand(full)
const queued =
parsed.name === 'queue' || parsed.name === 'q' ? queueItemFromSlash(slash.display, slash.command) : undefined
if (queued) {
composerActions.enqueue(queued.text, queued.display)
sys(`queued: "${queued.display.slice(0, 50)}${queued.display.length > 50 ? '…' : ''}"`)
} else {
slashRef.current(slash.command)
}
composerActions.clearIn()
return
}
if (full.startsWith('!')) {
composerActions.clearIn()
return shellExec(full.slice(1).trim())
}
const live = getUiState()
if (!live.sid) {
composerActions.pushHistory(toHistory)
composerActions.enqueue(full)
composerActions.clearIn()
return
}
const editIdx = composerRefs.queueEditRef.current
composerActions.clearIn()
if (editIdx !== null) {
const picked = composerActions.takeQueue(editIdx, full)
composerActions.setQueueEdit(null)
if (!picked || !live.sid) {
return
}
if (getUiState().busy) {
// 'interrupt' / 'steer' should reach the live turn instead of
// silently going back to the queue. handleBusyInput resolves
// mode-specific behavior (interrupt-and-send, steer, or queue).
if (getUiState().busyInputMode === 'queue') {
return composerActions.prependQueue(picked)
}
return handleBusyInput(picked, { fallbackToFront: true })
}
return sendQueued(picked.text)
}
composerActions.pushHistory(toHistory)
if (getUiState().busy) {
return handleBusyInput(queueItem(full))
}
if (shouldInterpolateSubmission(full)) {
patchUiState({ busy: true })
return interpolate(full, text =>
send(prepareSubmission(text, submissionTokens).text, true, text, value => value)
)
}
send(submission.text, true, submission.display, value => value)
},
[
appendMessage,
composerActions,
composerRefs,
handleBusyInput,
interpolate,
send,
sendQueued,
shellExec,
slashRef,
sys
]
)
const submit = useCallback(
(value: string) => {
if (composerState.completions.length) {
const row = composerState.completions[composerState.compIdx]
const next = completionToApplyOnSubmit(value, row?.text, composerState.compReplace)
if (next !== null) {
return composerActions.setInput(next)
}
}
if (!value.trim() && !composerState.inputBuf.length) {
const live = getUiState()
const now = Date.now()
const doubleTap = now - lastEmptyAt.current < DOUBLE_ENTER_MS
lastEmptyAt.current = now
if (doubleTap && live.busy && live.sid) {
// Force-send: keep busy when a message is queued so the settle edge
// drains it once (no race). Empty queue = plain Stop → 'ready'.
const hasQueued = composerRefs.queueRef.current.length > 0
return turnController.interruptTurn({ appendMessage, gw, sid: live.sid, sys }, { keepBusy: hasQueued })
}
if (doubleTap && live.sid && composerRefs.queueRef.current.length) {
const next = composerActions.dequeue()
if (next) {
composerActions.setQueueEdit(null)
dispatchSubmission(next)
}
}
return
}
lastEmptyAt.current = 0
if (value.endsWith('\\')) {
composerActions.setInputBuf(prev => [...prev, value.slice(0, -1)])
return composerActions.setInput('')
}
dispatchSubmission([...composerState.inputBuf, value].join('\n'))
},
[appendMessage, composerActions, composerRefs, composerState, dispatchSubmission, gw, sys]
)
submitRef.current = submit
// Literal submission: route text straight to the prompt pipeline, skipping
// slash-command routing, `!` shell dispatch, [[token]] expansion, and
// $(...) interpolation. Startup `-q` queries use this — they're arbitrary
// launcher/script text, and one-shot mode already treats them literally.
const submitLiteral = useCallback(
(value: string) => {
if (!value.trim()) {
return
}
send(value, true, value, v => v, { skipDetectDrop: true })
},
[send]
)
return { dispatchSubmission, send, sendQueued, submit, submitLiteral }
}
export interface UseSubmissionOptions {
appendMessage: (msg: Msg) => void
composerActions: ComposerActions
composerRefs: ComposerRefs
composerState: ComposerState
gw: GatewayClient
setLastUserMsg: (value: string) => void
slashRef: MutableRefObject<(cmd: string) => boolean>
submitRef: MutableRefObject<(value: string) => void>
sys: (text: string) => void
}
+15
View File
@@ -0,0 +1,15 @@
// Session-scoped memory of an explicit `/wake off`.
//
// The gateway auto-arms the "Hey Hermes" listener on every `gateway.ready`
// (see createGatewayEventHandler.ts). When the user explicitly disables the
// listener with `/wake off`, a reconnect must NOT silently re-arm it — this
// module-level flag records that intent for the lifetime of the process.
// `/wake on` clears it. Deliberately not persisted: config (`wake_word.*`)
// remains the durable on/off switch; this is only per-session steering.
let wakeUserDisabled = false
export const isWakeUserDisabled = (): boolean => wakeUserDisabled
export const setWakeUserDisabled = (disabled: boolean): void => {
wakeUserDisabled = disabled
}