663 lines
28 KiB
TypeScript
663 lines
28 KiB
TypeScript
/**
|
|
* Slash command system — the SOLID side (spec §1; mirrors Ink
|
|
* `app/createSlashHandler.ts` + `domain/slash.ts`). Plain functions/data, NOT
|
|
* Effect; the boundary injects a Promise-returning `request` so dispatch can call
|
|
* `slash.exec` / `command.dispatch` / `commands.catalog`.
|
|
*
|
|
* Dispatch ladder (Ink parity):
|
|
* 1. client-local command (the TUI-only set — handled in-process)
|
|
* 2. `slash.exec {command, session_id}` → `{output, warning?}` → system line
|
|
* 3. on reject → `command.dispatch {arg, name, session_id}` → typed action
|
|
* (exec/plugin → system · alias → re-dispatch · skill/send → submit a turn ·
|
|
* prefill → notice). Long output routes to the pager (Phase 5a).
|
|
*/
|
|
import { DETAILS_SECTIONS, DETAILS_USAGE, type DetailsMode, nextDetailsMode, parseDetailsMode } from './details.ts'
|
|
import { formatBytes, memReport, performHeapdump } from './diagnostics.ts'
|
|
import { formatSpawnTree, formatSpawnTreeList, readSpawnTreeEntries } from './replay.ts'
|
|
import type { CompletionItem, PickerItem, PickerState, SessionItem } from './store.ts'
|
|
|
|
export interface ParsedSlash {
|
|
name: string
|
|
arg: string
|
|
}
|
|
|
|
/** Parse `/name rest…` → {name, arg}; null if not a slash command. */
|
|
export function parseSlash(input: string): ParsedSlash | null {
|
|
if (!input.startsWith('/')) return null
|
|
const body = input.slice(1).trimStart()
|
|
if (!body) return null
|
|
const sp = body.indexOf(' ')
|
|
return sp === -1 ? { arg: '', name: body } : { arg: body.slice(sp + 1).trim(), name: body.slice(0, sp) }
|
|
}
|
|
|
|
/** The host capabilities the dispatcher needs (wired by the entry boundary). */
|
|
export interface SlashContext {
|
|
/** Server RPC (resolves with the result, rejects on GatewayError). */
|
|
readonly request: (method: string, params: Record<string, unknown>) => Promise<unknown>
|
|
readonly sessionId: () => string | undefined
|
|
readonly pushSystem: (text: string) => void
|
|
/** Open the full-screen pager (long output: /status, /logs, …). */
|
|
readonly openPager: (title: string, text: string) => void
|
|
/** Submit a user turn (skill/send dispatch results). */
|
|
readonly submit: (text: string) => void
|
|
/** Open a local Y/N confirm; `onConfirm` runs on Yes. */
|
|
readonly confirm: (message: string, onConfirm: () => void) => void
|
|
readonly clearTranscript: () => void
|
|
/** Copy the n-th newest assistant response to the clipboard; returns whether something was copied. */
|
|
readonly copyResponse: (n: number) => boolean
|
|
readonly quit: () => void
|
|
/** Recent log lines for `/logs` (the ring buffer). */
|
|
readonly logTail: () => string[]
|
|
/** Fetch the resumable sessions (`session.list`) for the switcher. */
|
|
readonly listSessions: () => Promise<SessionItem[]>
|
|
/** Open the session switcher with the given rows. */
|
|
readonly openSwitcher: (sessions: SessionItem[]) => void
|
|
/** Open a generic picker (model picker, skills hub). */
|
|
readonly openPicker: (picker: PickerState) => void
|
|
/** Open the agents dashboard (/agents, /tasks). */
|
|
readonly openDashboard: () => void
|
|
/** Cached `/model` picker rows (Epic 7 instant open); undefined until prefetched. */
|
|
readonly modelItems: () => PickerItem[] | undefined
|
|
/** Update the cached `/model` picker rows. */
|
|
readonly setModelItems: (items: PickerItem[]) => void
|
|
/** Read / set the compact-transcript display flag (/compact — Epic 3). */
|
|
readonly compact: () => boolean
|
|
readonly setCompact: (on: boolean) => void
|
|
/** Read / set the global tool/reasoning detail mode (/details — Epic 3). */
|
|
readonly details: () => DetailsMode
|
|
readonly setDetails: (mode: DetailsMode) => void
|
|
/** Mounted-renderable count under the live renderer root (a /mem diagnostic);
|
|
* undefined when no renderer is reachable (tests). */
|
|
readonly renderableCount: () => number | undefined
|
|
}
|
|
|
|
function readStr(value: unknown, key: string): string | undefined {
|
|
if (!value || typeof value !== 'object') return undefined
|
|
const v = (value as { [k: string]: unknown })[key]
|
|
return typeof v === 'string' ? v : undefined
|
|
}
|
|
|
|
const titleCase = (name: string) => name.charAt(0).toUpperCase() + name.slice(1)
|
|
|
|
/** A planned completion query (item 5/13): which RPC + params, and where an
|
|
* accepted item replaces from if the RPC omits its own `replace_from`. */
|
|
export interface CompletionPlan {
|
|
method: 'complete.slash' | 'complete.path'
|
|
params: Record<string, unknown>
|
|
from: number
|
|
}
|
|
|
|
/** A path-like last token worth file/@-mention completion (mirrors Ink's TAB_PATH_RE intent). */
|
|
function isPathLike(word: string): boolean {
|
|
return (
|
|
word.startsWith('@') ||
|
|
word.startsWith('~') ||
|
|
word.startsWith('./') ||
|
|
word.startsWith('../') ||
|
|
word.startsWith('/') ||
|
|
word.includes('/')
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Decide what to complete for the current composer text (cursor assumed at end):
|
|
* - `/command [args]` → `complete.slash {text}` (the gateway completes names AND
|
|
* args, e.g. /details section names),
|
|
* - a trailing path-like word (`@…`, `~/…`, `./…`, `/…`, or anything with `/`) →
|
|
* `complete.path {word}` for file/dir tagging,
|
|
* - otherwise nothing.
|
|
* Returns null when there's no completion to run (so the dropdown clears).
|
|
*/
|
|
export function planCompletion(text: string): CompletionPlan | null {
|
|
if (text.includes('\n')) return null
|
|
if (text.startsWith('/')) return { from: 0, method: 'complete.slash', params: { text } }
|
|
const word = /(\S+)$/.exec(text)?.[1]
|
|
if (word && isPathLike(word)) {
|
|
return { from: text.length - word.length, method: 'complete.path', params: { word } }
|
|
}
|
|
return null
|
|
}
|
|
|
|
/** Read a `replace_from` offset off a completion result, falling back to `fallback`. */
|
|
export function readReplaceFrom(result: unknown, fallback: number): number {
|
|
if (result && typeof result === 'object') {
|
|
const rf = (result as { replace_from?: unknown }).replace_from
|
|
if (typeof rf === 'number') return rf
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
/** Map a `complete.slash`/`complete.path` result ({items:[{text,display,meta}]}) into candidates. */
|
|
export function mapCompletions(result: unknown): CompletionItem[] {
|
|
if (!result || typeof result !== 'object') return []
|
|
const items = (result as { items?: unknown }).items
|
|
if (!Array.isArray(items)) return []
|
|
const out: CompletionItem[] = []
|
|
for (const it of items) {
|
|
const text = readStr(it, 'text')
|
|
if (!text) continue
|
|
out.push({ display: readStr(it, 'display') ?? text, meta: readStr(it, 'meta') ?? '', text })
|
|
}
|
|
return out
|
|
}
|
|
|
|
/** Long output → the pager; short → a system line (Ink: >180 chars or >2 lines). */
|
|
function present(ctx: SlashContext, title: string, text: string): void {
|
|
const long = text.length > 180 || text.split('\n').filter(Boolean).length > 2
|
|
if (long) ctx.openPager(title, text)
|
|
else ctx.pushSystem(text)
|
|
}
|
|
|
|
const CLIENT_HELP = [
|
|
'/help — list commands',
|
|
'/model [name] — switch model (picker if bare)',
|
|
'/copy [n] — copy the last (or n-th) response',
|
|
'/skills — browse skills',
|
|
'/sessions, /resume — switch/resume a session',
|
|
'/clear, /new — clear the transcript (confirm)',
|
|
'/compact [on|off|toggle] — compact transcript spacing',
|
|
'/details [hidden|collapsed|expanded|cycle] — tool/reasoning detail',
|
|
'/replay [n|path] — inspect an archived spawn tree',
|
|
'/mem — live memory stats',
|
|
'/heapdump — write a V8 heap snapshot',
|
|
'/logs — recent engine log lines',
|
|
'/quit, /exit — quit',
|
|
'(other /commands run on the gateway)'
|
|
].join('\n')
|
|
|
|
type ClientHandler = (arg: string, ctx: SlashContext) => void | Promise<void>
|
|
|
|
/** Fetch sessions and open the switcher (shared by /sessions, /resume, /switch, /session). */
|
|
const openSwitcher: ClientHandler = async (_arg, ctx) => {
|
|
const sessions = await ctx.listSessions()
|
|
if (sessions.length) ctx.openSwitcher(sessions)
|
|
else ctx.pushSystem('No sessions to resume.')
|
|
}
|
|
|
|
/**
|
|
* Flatten `model.options` into grouped picker rows (Epic 7; v2.1 availability):
|
|
* group = the provider's display ("lab") name, haystacks = slug + lab name (so
|
|
* `oai`/`copilot`/`anthropic` fuzzy-match the whole group), value = the FULL
|
|
* switch arg `<model> --provider <slug>` so picking a model under a different
|
|
* provider actually switches provider+model (the gateway's
|
|
* `_apply_model_switch` parses `--provider` via parse_model_flags). The current
|
|
* model is flagged, not baked into the label, so the fuzzy scorer never matches
|
|
* the ✓.
|
|
*
|
|
* UNCONFIGURED providers (`authenticated: false` skeleton rows — the gateway
|
|
* sends them via `build_models_payload(include_unconfigured=True,
|
|
* picker_hints=True)`, with `key_env`/`warning` setup hints) become one
|
|
* `unavailable` hint row each (`no API key — set <ENV_VAR>`): hidden by
|
|
* default, revealed dimmed + non-selectable by the picker's Ctrl+U toggle.
|
|
*/
|
|
export function mapModelOptions(opts: unknown): PickerItem[] {
|
|
if (!opts || typeof opts !== 'object') return []
|
|
const providers = (opts as { providers?: unknown }).providers
|
|
if (!Array.isArray(providers)) return []
|
|
const current = readStr(opts, 'model')
|
|
const currentProvider = readStr(opts, 'provider')
|
|
const items: PickerItem[] = []
|
|
for (const p of providers) {
|
|
if (!p || typeof p !== 'object') continue
|
|
const slug = readStr(p, 'slug') ?? readStr(p, 'name') ?? ''
|
|
const lab = readStr(p, 'name') ?? slug
|
|
if ((p as { authenticated?: unknown }).authenticated === false) {
|
|
// Unconfigured provider → one dimmed hint row under its own group header.
|
|
// Identity (slug + display name) is the haystack so a provider-name query
|
|
// still narrows to the group; the hint text itself is not searched.
|
|
const keyEnv = readStr(p, 'key_env')
|
|
const item: PickerItem = {
|
|
group: lab || slug,
|
|
label: keyEnv ? `no API key — set ${keyEnv}` : (readStr(p, 'warning') ?? 'not configured'),
|
|
unavailable: true,
|
|
value: slug || lab
|
|
}
|
|
const hay = [slug, lab].filter(Boolean)
|
|
if (hay.length) item.haystacks = hay
|
|
items.push(item)
|
|
continue
|
|
}
|
|
if ((p as { authenticated?: unknown }).authenticated !== true) continue
|
|
// The gateway's own normalized "this row is the active provider" flag —
|
|
// more reliable than comparing `provider` to `slug` (the agent's provider
|
|
// string can be the API dialect, e.g. an openai-compatible base_url).
|
|
const rowCurrent = (p as { is_current?: unknown }).is_current === true
|
|
const models = (p as { models?: unknown }).models
|
|
if (!Array.isArray(models)) continue
|
|
for (const m of models) {
|
|
if (typeof m !== 'string') continue
|
|
const item: PickerItem = { label: m, value: slug ? `${m} --provider ${slug}` : m }
|
|
// current = same model id under the active provider (row flag first,
|
|
// then the slug comparison, then "no provider known at all").
|
|
if (m === current && (rowCurrent || currentProvider === slug || !currentProvider)) item.current = true
|
|
if (lab) item.group = lab
|
|
const haystacks = [slug, lab].filter(Boolean)
|
|
if (haystacks.length) item.haystacks = haystacks
|
|
items.push(item)
|
|
}
|
|
}
|
|
// Provider matching failed entirely (string-normalization drift) but the
|
|
// model id is known → flag the first id match so the ✓ never just vanishes.
|
|
if (current && !items.some(i => i.current)) {
|
|
const fallback = items.find(i => i.label === current)
|
|
if (fallback) fallback.current = true
|
|
}
|
|
return items
|
|
}
|
|
|
|
/**
|
|
* Provider tab order for the model picker's chip strip (picker v2.2): each
|
|
* CONFIGURED provider's group (= lab display name) in catalog order, with
|
|
* Nous-identified groups (slug or lab name containing `nous`) hoisted to the
|
|
* front. Unconfigured providers (`unavailable` hint rows) get NO tab — they
|
|
* stay reachable via Ctrl+U under the picker's trailing `All` tab (which the
|
|
* picker appends itself; it is not part of this list).
|
|
*/
|
|
export function buildModelTabs(items: readonly PickerItem[]): string[] {
|
|
const seen = new Set<string>()
|
|
const nous: string[] = []
|
|
const rest: string[] = []
|
|
for (const it of items) {
|
|
if (it.unavailable || !it.group || seen.has(it.group)) continue
|
|
seen.add(it.group)
|
|
const identity = [it.group, ...(it.haystacks ?? [])].join(' ').toLowerCase()
|
|
;(identity.includes('nous') ? nous : rest).push(it.group)
|
|
}
|
|
return [...nous, ...rest]
|
|
}
|
|
|
|
/** Flatten `skills.manage {action:'list'}` ({skills: Record<category, names[]>}) into
|
|
* grouped picker rows (category = group header; also a fuzzy haystack). */
|
|
function mapSkills(result: unknown): PickerItem[] {
|
|
if (!result || typeof result !== 'object') return []
|
|
const skills = (result as { skills?: unknown }).skills
|
|
if (!skills || typeof skills !== 'object') return []
|
|
const items: PickerItem[] = []
|
|
for (const [category, names] of Object.entries(skills as { [k: string]: unknown })) {
|
|
if (!Array.isArray(names)) continue
|
|
for (const n of names) if (typeof n === 'string') items.push({ group: category, label: n, value: n })
|
|
}
|
|
return items
|
|
}
|
|
|
|
/** Re-fetch `model.options` and update the cached picker rows. Resolves with
|
|
* the fresh rows (the open picker swaps them in live — Ctrl+R, picker v2.1);
|
|
* rejections are the CALLER's to handle (background callers fire-and-forget). */
|
|
function refreshModelItems(ctx: SlashContext): Promise<PickerItem[]> {
|
|
return ctx.request('model.options', { session_id: ctx.sessionId() }).then(opts => {
|
|
const items = mapModelOptions(opts)
|
|
if (items.length) ctx.setModelItems(items)
|
|
return items
|
|
})
|
|
}
|
|
|
|
/**
|
|
* The open picker's manual-refresh seam (picker v2.1 Ctrl+R). Whoever opens a
|
|
* picker registers (or clears) the catalog re-fetch here; the mounted Picker
|
|
* triggers it via `runPickerRefresh` and swaps in the resolved rows live. A
|
|
* module slot rather than a Picker prop because the App→Picker prop plumbing
|
|
* carries only the PickerState basics; the seam keeps the overlay generic for
|
|
* the upcoming resume-session picker (register a `session.list` re-fetch).
|
|
*/
|
|
let activePickerRefresh: (() => Promise<PickerItem[]>) | undefined
|
|
|
|
/** Register (or clear, with `undefined`) the open picker's catalog re-fetch. */
|
|
export function registerPickerRefresh(fn: (() => Promise<PickerItem[]>) | undefined): void {
|
|
activePickerRefresh = fn
|
|
}
|
|
|
|
/** Whether a refresh is registered (the picker's footer hint is gated on it). */
|
|
export function canRefreshPicker(): boolean {
|
|
return activePickerRefresh !== undefined
|
|
}
|
|
|
|
/** Run the registered catalog re-fetch; undefined when none is registered. */
|
|
export function runPickerRefresh(): Promise<PickerItem[]> | undefined {
|
|
return activePickerRefresh?.()
|
|
}
|
|
|
|
/**
|
|
* The open picker's tab-strip seam (picker v2.2 provider tabs) — same pattern
|
|
* as the refresh seam above: whoever opens a picker registers (or clears) a
|
|
* tab DERIVATION over the picker's live rows; the mounted Picker re-derives
|
|
* through it whenever the rows swap (Ctrl+R), so fresh providers grow chips
|
|
* without re-opening. `/model` registers `buildModelTabs`; pickers without
|
|
* tabs (skills) clear it and render the classic stripless view.
|
|
*/
|
|
let activePickerTabs: ((items: readonly PickerItem[]) => string[]) | undefined
|
|
|
|
/** Register (or clear, with `undefined`) the open picker's tab derivation. */
|
|
export function registerPickerTabs(fn: ((items: readonly PickerItem[]) => string[]) | undefined): void {
|
|
activePickerTabs = fn
|
|
}
|
|
|
|
/** Derive the open picker's tabs from its rows; [] when no tabs are registered. */
|
|
export function pickerTabs(items: readonly PickerItem[]): string[] {
|
|
return activePickerTabs?.(items) ?? []
|
|
}
|
|
|
|
/** Switch the model via the server (shared by `/model <name>` and the picker pick).
|
|
* A successful switch refreshes the cached rows in the background (fresh ✓). */
|
|
async function switchModel(ctx: SlashContext, name: string): Promise<void> {
|
|
try {
|
|
const r = await ctx.request('slash.exec', { command: `model ${name}`, session_id: ctx.sessionId() })
|
|
ctx.pushSystem(readStr(r, 'output') || `→ ${name}`)
|
|
void refreshModelItems(ctx).catch(() => {})
|
|
} catch (error) {
|
|
ctx.pushSystem(`/model ${name}: ${error instanceof Error ? error.message : 'switch failed'}`)
|
|
}
|
|
}
|
|
|
|
/** `/model` — bare opens the model picker; `/model <name>` switches directly.
|
|
* Opens from the CACHED catalog when present — zero RPCs, same-frame paint
|
|
* (Epic 7; the catalog is prefetched at bootstrap and refreshed on switch). */
|
|
const modelCmd: ClientHandler = async (arg, ctx) => {
|
|
if (arg.trim()) {
|
|
await switchModel(ctx, arg.trim())
|
|
return
|
|
}
|
|
const open = (items: PickerItem[]) => {
|
|
// Ctrl+R in the open picker re-fetches the catalog (and re-syncs the cache).
|
|
registerPickerRefresh(() => refreshModelItems(ctx))
|
|
// Provider chip strip (picker v2.2): Nous-first configured-provider tabs.
|
|
registerPickerTabs(buildModelTabs)
|
|
ctx.openPicker({ items, onPick: name => void switchModel(ctx, name), title: 'Switch model' })
|
|
}
|
|
const cached = ctx.modelItems()
|
|
if (cached?.length) {
|
|
open(cached)
|
|
return
|
|
}
|
|
const items = mapModelOptions(await ctx.request('model.options', { session_id: ctx.sessionId() }))
|
|
// Unavailable hint rows alone are not a usable catalog — keep the notice.
|
|
if (!items.some(i => !i.unavailable)) {
|
|
ctx.pushSystem('No models available (no authenticated providers).')
|
|
return
|
|
}
|
|
ctx.setModelItems(items)
|
|
open(items)
|
|
}
|
|
|
|
/** `/skills` — open the skills hub; picking a skill shows its info in the pager. */
|
|
const skillsCmd: ClientHandler = async (_arg, ctx) => {
|
|
const items = mapSkills(await ctx.request('skills.manage', { action: 'list' }))
|
|
if (!items.length) {
|
|
ctx.pushSystem('No skills found.')
|
|
return
|
|
}
|
|
registerPickerRefresh(undefined) // no Ctrl+R catalog re-fetch for skills (yet)
|
|
registerPickerTabs(undefined) // no tab strip for skills — classic grouped view
|
|
ctx.openPicker({
|
|
items,
|
|
onPick: name =>
|
|
void ctx
|
|
.request('skills.manage', { action: 'inspect', query: name })
|
|
.then(info => ctx.openPager(`Skill: ${name}`, readStr(info, 'info') || JSON.stringify(info, null, 2)))
|
|
.catch(() => ctx.pushSystem(`/skills: could not inspect ${name}`)),
|
|
title: 'Skills'
|
|
})
|
|
}
|
|
|
|
/** `on`/`off`/`toggle`/bare → the next flag value; null on garbage (Ink flagFromArg). */
|
|
function flagFromArg(arg: string, current: boolean): boolean | null {
|
|
const mode = arg.trim().toLowerCase()
|
|
if (!mode || mode === 'toggle') return !current
|
|
if (mode === 'on') return true
|
|
if (mode === 'off') return false
|
|
return null
|
|
}
|
|
|
|
/** `/compact [on|off|toggle]` — compact transcript spacing. The flag flips locally
|
|
* (the store drives the render); persistence mirrors Ink: a fire-and-forget
|
|
* `config.set {key:'compact'}` so the Ink TUI + future launches share the pref
|
|
* (the gateway does NOT send the persisted value to this TUI, so each launch
|
|
* starts off — see store.ts `compact`). */
|
|
const compactCmd: ClientHandler = (arg, ctx) => {
|
|
const next = flagFromArg(arg, ctx.compact())
|
|
if (next === null) {
|
|
ctx.pushSystem('usage: /compact [on|off|toggle]')
|
|
return
|
|
}
|
|
ctx.setCompact(next)
|
|
void ctx.request('config.set', { key: 'compact', value: next ? 'on' : 'off' }).catch(() => {})
|
|
ctx.pushSystem(`compact ${next ? 'on' : 'off'}`)
|
|
}
|
|
|
|
/**
|
|
* `/details [hidden|collapsed|expanded|cycle]` — GLOBAL detail mode (per-section
|
|
* overrides deferred; the gateway's arg completion also suggests section names,
|
|
* so those get an honest "not supported yet" notice). Bare `/details` reports the
|
|
* persisted mode (`config.get details_mode`) and syncs the local flag to it; a
|
|
* mode set persists via `config.set` (fire-and-forget, Ink parity).
|
|
*/
|
|
const detailsCmd: ClientHandler = async (arg, ctx) => {
|
|
const first = arg.trim().toLowerCase().split(/\s+/)[0] ?? ''
|
|
if (!first) {
|
|
try {
|
|
const r = await ctx.request('config.get', { key: 'details_mode' })
|
|
const mode = parseDetailsMode(readStr(r, 'value')) ?? ctx.details()
|
|
ctx.setDetails(mode)
|
|
ctx.pushSystem(`details: ${mode}`)
|
|
} catch {
|
|
ctx.pushSystem(`details: ${ctx.details()}`)
|
|
}
|
|
return
|
|
}
|
|
if ((DETAILS_SECTIONS as readonly string[]).includes(first)) {
|
|
ctx.pushSystem(`per-section detail overrides are not supported in the native engine yet — ${DETAILS_USAGE}`)
|
|
return
|
|
}
|
|
const next = first === 'cycle' || first === 'toggle' ? nextDetailsMode(ctx.details()) : parseDetailsMode(first)
|
|
if (!next) {
|
|
ctx.pushSystem(DETAILS_USAGE)
|
|
return
|
|
}
|
|
ctx.setDetails(next)
|
|
void ctx.request('config.set', { key: 'details_mode', value: next }).catch(() => {})
|
|
ctx.pushSystem(`details: ${next}`)
|
|
}
|
|
|
|
/** Fetch + map the session's archived spawn trees (`spawn_tree.list`). */
|
|
async function listSpawnTrees(ctx: SlashContext) {
|
|
const r = await ctx.request('spawn_tree.list', { limit: 30, session_id: ctx.sessionId() ?? 'default' })
|
|
return readSpawnTreeEntries(r)
|
|
}
|
|
|
|
/**
|
|
* `/replay [n|path]` — spawn-tree inspector through the pager (Ink renders these
|
|
* in its agents overlay; the flow + RPCs are the same): bare lists the archived
|
|
* trees with indices, `<n>` loads the n-th listed tree, anything else is treated
|
|
* as a snapshot path on disk (`load <path>` accepted for Ink muscle memory).
|
|
*/
|
|
const replayCmd: ClientHandler = async (arg, ctx) => {
|
|
const raw = arg.trim()
|
|
const lower = raw.toLowerCase()
|
|
try {
|
|
if (!raw || lower === 'list' || lower === 'ls') {
|
|
const entries = await listSpawnTrees(ctx)
|
|
if (!entries.length) {
|
|
ctx.pushSystem('no archived spawn trees for this session — completed delegations are archived automatically')
|
|
return
|
|
}
|
|
ctx.openPager('Spawn trees', formatSpawnTreeList(entries))
|
|
return
|
|
}
|
|
if (/^\d+$/.test(raw)) {
|
|
const n = Number.parseInt(raw, 10)
|
|
const entries = await listSpawnTrees(ctx)
|
|
const entry = entries[n - 1]
|
|
if (!entry) {
|
|
ctx.pushSystem(
|
|
entries.length
|
|
? `replay: index out of range 1..${entries.length} — /replay to list`
|
|
: 'no archived spawn trees for this session'
|
|
)
|
|
return
|
|
}
|
|
const tree = await ctx.request('spawn_tree.load', { path: entry.path })
|
|
ctx.openPager(`Replay ${n}`, formatSpawnTree(tree))
|
|
return
|
|
}
|
|
const path = lower.startsWith('load ') ? raw.slice(5).trim() : raw
|
|
const tree = await ctx.request('spawn_tree.load', { path })
|
|
ctx.openPager('Replay', formatSpawnTree(tree))
|
|
} catch (error) {
|
|
ctx.pushSystem(`/replay: ${error instanceof Error ? error.message : 'failed'}`)
|
|
}
|
|
}
|
|
|
|
/** `/heapdump` — write a V8 heap snapshot to `$HERMES_HOME|~/.hermes/logs/` and
|
|
* report the path + heap/rss before vs after (Ink ref debug.ts /heapdump). */
|
|
const heapdumpCmd: ClientHandler = (_arg, ctx) => {
|
|
const pre = process.memoryUsage()
|
|
ctx.pushSystem(`writing heap dump (heap ${formatBytes(pre.heapUsed)} · rss ${formatBytes(pre.rss)})…`)
|
|
try {
|
|
const { after, before, path } = performHeapdump()
|
|
ctx.pushSystem(
|
|
`heapdump: ${path}\n` +
|
|
`heap ${formatBytes(before.heapUsed)} → ${formatBytes(after.heapUsed)} · ` +
|
|
`rss ${formatBytes(before.rss)} → ${formatBytes(after.rss)}`
|
|
)
|
|
} catch (error) {
|
|
ctx.pushSystem(`heapdump failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
}
|
|
}
|
|
|
|
/** `/mem` — live V8 heap/rss numbers + uptime + the mounted-renderable count
|
|
* (the store-cap diagnostic) as one system block (Ink ref debug.ts /mem). */
|
|
const memCmd: ClientHandler = (_arg, ctx) => {
|
|
ctx.pushSystem(memReport(process.memoryUsage(), process.uptime(), ctx.renderableCount()))
|
|
}
|
|
|
|
/** `/tools` — fetch the tool roster from the gateway and show it in the pager (navigable). */
|
|
const toolsCmd: ClientHandler = async (arg, ctx) => {
|
|
const command = arg.trim() ? `tools ${arg.trim()}` : 'tools'
|
|
try {
|
|
const r = await ctx.request('slash.exec', { command, session_id: ctx.sessionId() })
|
|
ctx.openPager('Tools', readStr(r, 'output') || '(no tool info)')
|
|
} catch (error) {
|
|
ctx.pushSystem(`/tools: ${error instanceof Error ? error.message : 'failed'}`)
|
|
}
|
|
}
|
|
|
|
/** The TUI-only client commands (run in-process, never hit the gateway). */
|
|
const CLIENT: Record<string, ClientHandler> = {
|
|
agents: (_arg, ctx) => ctx.openDashboard(),
|
|
clear: (_arg, ctx) => ctx.confirm('Clear the transcript?', ctx.clearTranscript),
|
|
compact: compactCmd,
|
|
copy: (arg, ctx) => {
|
|
const n = Math.max(1, Number.parseInt(arg, 10) || 1)
|
|
if (!ctx.copyResponse(n)) ctx.pushSystem('Nothing to copy yet.')
|
|
},
|
|
detail: detailsCmd,
|
|
details: detailsCmd,
|
|
exit: (_arg, ctx) => ctx.quit(),
|
|
heapdump: heapdumpCmd,
|
|
mem: memCmd,
|
|
model: modelCmd,
|
|
replay: replayCmd,
|
|
resume: openSwitcher,
|
|
session: openSwitcher,
|
|
sessions: openSwitcher,
|
|
skills: skillsCmd,
|
|
switch: openSwitcher,
|
|
tasks: (_arg, ctx) => ctx.openDashboard(),
|
|
tools: toolsCmd,
|
|
help: async (_arg, ctx) => {
|
|
// Prefer the live catalog; fall back to the client list if it's unavailable.
|
|
try {
|
|
const cat = await ctx.request('commands.catalog', {})
|
|
ctx.pushSystem(renderCatalog(cat) || CLIENT_HELP)
|
|
} catch {
|
|
ctx.pushSystem(CLIENT_HELP)
|
|
}
|
|
},
|
|
logs: (_arg, ctx) => ctx.openPager('Logs', ctx.logTail().join('\n') || '(log empty)'),
|
|
new: (_arg, ctx) => ctx.confirm('Start fresh? (clears the transcript)', ctx.clearTranscript),
|
|
quit: (_arg, ctx) => ctx.quit()
|
|
}
|
|
|
|
/** The registered client-command names (catalog introspection — tests/menus). */
|
|
export function clientCommandNames(): string[] {
|
|
return Object.keys(CLIENT).sort()
|
|
}
|
|
|
|
/** Render the gateway `commands.catalog` into a help block (loose-typed read).
|
|
* The TUI catalog shape is `{ pairs: [["/name","desc"], …], canon, categories }`
|
|
* (tui_gateway/server.py `commands.catalog`). */
|
|
function renderCatalog(cat: unknown): string {
|
|
if (!cat || typeof cat !== 'object') return ''
|
|
const pairs = (cat as { pairs?: unknown }).pairs
|
|
if (!Array.isArray(pairs)) return ''
|
|
const lines = pairs
|
|
.map(pair => {
|
|
if (!Array.isArray(pair) || typeof pair[0] !== 'string') return null
|
|
const desc = typeof pair[1] === 'string' ? pair[1] : ''
|
|
return desc ? `${pair[0]} — ${desc}` : pair[0]
|
|
})
|
|
.filter((l): l is string => l !== null)
|
|
return lines.length ? lines.join('\n') : ''
|
|
}
|
|
|
|
function handleDispatchResult(parsed: ParsedSlash, raw: unknown, ctx: SlashContext): void {
|
|
const type = readStr(raw, 'type')
|
|
const argTail = parsed.arg ? ` ${parsed.arg}` : ''
|
|
switch (type) {
|
|
case 'exec':
|
|
case 'plugin':
|
|
ctx.pushSystem(readStr(raw, 'output') || '(no output)')
|
|
return
|
|
case 'alias': {
|
|
const target = readStr(raw, 'target')
|
|
if (target) void dispatchSlash(`/${target}${argTail}`, ctx)
|
|
return
|
|
}
|
|
case 'skill':
|
|
case 'send': {
|
|
const notice = readStr(raw, 'notice')
|
|
if (notice) ctx.pushSystem(notice)
|
|
const message = readStr(raw, 'message')
|
|
if (message?.trim()) ctx.submit(message)
|
|
else ctx.pushSystem(`/${parsed.name}: empty message`)
|
|
return
|
|
}
|
|
case 'prefill': {
|
|
// /undo etc. — composer prefill lands with the composer-ref plumbing; show it for now.
|
|
const message = readStr(raw, 'message')
|
|
ctx.pushSystem(message ? `(edit & resubmit) ${message}` : `/${parsed.name}: nothing to prefill`)
|
|
return
|
|
}
|
|
default:
|
|
ctx.pushSystem(`error: invalid response: command.dispatch`)
|
|
}
|
|
}
|
|
|
|
/** Dispatch a `/command` through the ladder. Returns once the (async) work settles. */
|
|
export async function dispatchSlash(input: string, ctx: SlashContext): Promise<void> {
|
|
const parsed = parseSlash(input)
|
|
if (!parsed) return
|
|
|
|
const client = CLIENT[parsed.name]
|
|
if (client) {
|
|
await client(parsed.arg, ctx)
|
|
return
|
|
}
|
|
|
|
const sid = ctx.sessionId()
|
|
try {
|
|
const result = await ctx.request('slash.exec', { command: input.slice(1), session_id: sid })
|
|
const output = readStr(result, 'output') || `/${parsed.name}: no output`
|
|
const warning = readStr(result, 'warning')
|
|
const text = warning ? `warning: ${warning}\n${output}` : output
|
|
// Long output → pager (Ink: >180 chars or >2 non-empty lines), else a system line.
|
|
present(ctx, titleCase(parsed.name), text)
|
|
} catch {
|
|
try {
|
|
const raw = await ctx.request('command.dispatch', { arg: parsed.arg, name: parsed.name, session_id: sid })
|
|
handleDispatchResult(parsed, raw, ctx)
|
|
} catch (error) {
|
|
ctx.pushSystem(`error: ${error instanceof Error ? error.message : String(error)}`)
|
|
}
|
|
}
|
|
}
|