Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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`]
|
||||
]
|
||||
}
|
||||
])
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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 charge’s 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)
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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 doesn’t 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)
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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[]
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
]
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user