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

This commit is contained in:
yilsem committed 2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 files changed
+3442369

No files matched your search

+58
View File
@@ -0,0 +1,58 @@
import { Box, Text } from '@hermes/ink'
import { type ReactNode, useState } from 'react'
import type { Theme } from '../theme.js'
/**
* THE expand/collapse primitive — the session panel's tool/skill sections
* and widget-app accordions are the same component. Click the header to
* toggle (mouse works even in ambient widgets, which receive no keys);
* modal apps may instead drive `open` from reducer state (controlled).
* Uncontrolled by default: pass `defaultOpen` and forget it.
*/
export function Accordion({
children,
count,
defaultOpen = false,
onToggle,
open,
suffix,
t,
title
}: {
children: ReactNode
count?: number
defaultOpen?: boolean
/** Controlled open state; omit for internal (click-toggled) state. */
open?: boolean
onToggle?: () => void
suffix?: string
t: Theme
title: string
}) {
const [uncontrolled, setUncontrolled] = useState(defaultOpen)
const isOpen = open ?? uncontrolled
const toggle = () => {
onToggle?.()
if (open === undefined) {
setUncontrolled(v => !v)
}
}
return (
<Box flexDirection="column">
<Box onClick={toggle}>
<Text color={t.color.accent}>{isOpen ? '▾ ' : '▸ '}</Text>
<Text bold color={t.color.accent}>
{title}
</Text>
{typeof count === 'number' ? <Text color={t.color.muted}> ({count})</Text> : null}
{suffix ? <Text color={t.color.muted}> {suffix}</Text> : null}
</Box>
{isOpen ? children : null}
</Box>
)
}
@@ -0,0 +1,917 @@
import { Box, Text, useInput, useStdout } from '@hermes/ink'
import { useCallback, useEffect, useRef, useState } from 'react'
import { sessionScopedModelArg } from '../domain/slash.js'
import type { GatewayClient } from '../gatewayClient.js'
import type {
SessionActiveItem,
SessionActiveListResponse,
SessionCloseResponse,
SessionDeleteResponse,
SessionListItem,
SessionListResponse
} from '../gatewayTypes.js'
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import type { Theme } from '../theme.js'
import { ModelPicker } from './modelPicker.js'
import { windowOffset } from './overlayControls.js'
import { clampOverlayWidth, listRowStyle } from './overlayPrimitives.js'
import { TextInput } from './textInput.js'
const VISIBLE = 12
const MIN_WIDTH = 64
const MAX_WIDTH = 128
const TITLE_MAX = 64
const STATUS_GLYPH: Record<string, string> = {
idle: '✓',
starting: '…',
waiting: '?',
working: '▶'
}
const STATUS_LABEL: Record<string, string> = {
idle: 'idle',
starting: 'starting',
waiting: 'waiting',
working: 'working'
}
const CTRL_OFFSET = 96
const shortModel = (model = '') => model.replace(/^.*\//, '') || 'model?'
const ctrlChar = (letter: string) => String.fromCharCode(letter.charCodeAt(0) - CTRL_OFFSET)
export const fixedSessionColumnStyle = () => ({ flexShrink: 0 })
export const activeSessionCountLabel = (count: number) => `${count} live ${count === 1 ? 'session' : 'sessions'}`
export const sessionsCountLabel = (liveCount: number, resumableCount: number) =>
`${liveCount} live · ${resumableCount} resumable`
export type SessionRowKind = 'history' | 'live' | 'new'
/**
* Map a flat row index into the merged Sessions list to its kind. Rows are
* ordered [new][live…][history…] — the "+ new" row is pinned first so it is
* always visible no matter how long the resumable history grows.
*/
export const sessionRowKindAt = (index: number, liveCount: number): SessionRowKind => {
if (index <= 0) {
return 'new'
}
return index - 1 < liveCount ? 'live' : 'history'
}
export const relativeSessionAge = (ts?: number) => {
if (!ts) {
return ''
}
const days = (Date.now() / 1000 - ts) / 86400
if (days < 1) {
return 'today'
}
if (days < 2) {
return 'yesterday'
}
return `${Math.floor(days)}d ago`
}
/** Drop already-live sessions from the resumable history list (dedupe by id). */
export const resumableHistory = (history: readonly SessionListItem[], live: readonly SessionActiveItem[]) => {
const liveIds = new Set(live.map(s => s.id))
return history.filter(h => !liveIds.has(h.id))
}
export const resumeRowContextHintSegments: OrchestratorHintSegment[] = [
{ role: 'label', text: 'Resumable:' },
{ role: 'text', text: ' ' },
{ role: 'hotkey', text: 'Enter' },
{ role: 'text', text: ' resume · ' },
{ role: 'hotkey', text: 'd' },
{ role: 'text', text: ' delete' }
]
export type OrchestratorHintRole = 'hotkey' | 'label' | 'text'
export interface OrchestratorHintSegment {
role: OrchestratorHintRole
text: string
}
export const orchestratorContextHintSegments = (newSelected: boolean): OrchestratorHintSegment[] =>
newSelected
? [
{ role: 'label', text: 'New row:' },
{ role: 'text', text: ' type prompt · ' },
{ role: 'hotkey', text: 'Enter' },
{ role: 'text', text: ' start · ' },
{ role: 'hotkey', text: 'Tab' },
{ role: 'text', text: ' model' }
]
: [
{ role: 'label', text: 'Session row:' },
{ role: 'text', text: ' ' },
{ role: 'hotkey', text: 'Enter' },
{ role: 'text', text: ' switch · ' },
{ role: 'hotkey', text: 'Ctrl+D' },
{ role: 'text', text: ' close' }
]
export const orchestratorGlobalHotkeyHintSegments: OrchestratorHintSegment[] = [
{ role: 'hotkey', text: '↑↓' },
{ role: 'text', text: ' move · ' },
{ role: 'hotkey', text: 'Ctrl+N' },
{ role: 'text', text: ' new · ' },
{ role: 'hotkey', text: 'Ctrl+R' },
{ role: 'text', text: ' refresh · ' },
{ role: 'hotkey', text: 'Esc' },
{ role: 'text', text: ' close' }
]
const hintText = (segments: readonly OrchestratorHintSegment[]) => segments.map(segment => segment.text).join('')
export const orchestratorContextHint = (newSelected: boolean) => hintText(orchestratorContextHintSegments(newSelected))
export const orchestratorGlobalHotkeyHint = hintText(orchestratorGlobalHotkeyHintSegments)
export const orchestratorHintSegmentColor = (t: Theme, role: OrchestratorHintRole) => {
if (role === 'hotkey') {
return t.color.accent
}
if (role === 'label') {
return t.color.label
}
return t.color.muted
}
// Delegates to the shared list-row primitive so the session switcher and the
// completions popover cannot disagree about what "selected" looks like.
// (`selectionBg` remains the TEXT-selection highlight — a different semantic.)
export const selectedSessionRowStyle = (t: Theme) => ({
backgroundColor: listRowStyle(t, true).backgroundColor,
color: listRowStyle(t, true).color
})
export const newSessionMarkerColor = (t: Theme, selected: boolean) =>
selected ? selectedSessionRowStyle(t).color : t.color.label
export const newSessionRowIndex = (sessionCount: number) => Math.max(0, sessionCount)
export const isNewSessionRow = (index: number, sessionCount: number) => index >= newSessionRowIndex(sessionCount)
export const canTypeOrchestratorPrompt = (index: number, sessionCount: number) => isNewSessionRow(index, sessionCount)
export const clampOrchestratorSelection = (index: number, sessionCount: number) =>
Math.max(0, Math.min(index, newSessionRowIndex(sessionCount)))
export const currentSessionSelectionIndex = (
sessions: readonly SessionActiveItem[],
currentSessionId: null | string
) => {
const index = sessions.findIndex(s => Boolean(s.current) || (!!currentSessionId && s.id === currentSessionId))
return index >= 0 ? index : 0
}
export const orchestratorVisibleRowIndexes = (sessionCount: number, selected: number, visible = VISIBLE) => {
const total = Math.max(0, sessionCount) + 1
const clamped = clampOrchestratorSelection(selected, sessionCount)
const offset = windowOffset(total, clamped, visible)
const count = Math.min(visible, total - offset)
return Array.from({ length: count }, (_, i) => offset + i)
}
export type CloseFallback = { action: 'activate'; sessionId: string } | { action: 'new' } | { action: 'stay' }
export const closeFallbackAfterClose = (
closedId: string,
currentSessionId: null | string,
remaining: readonly SessionActiveItem[]
): CloseFallback => {
if (!currentSessionId || closedId !== currentSessionId) {
return { action: 'stay' }
}
const next = remaining.find(s => s.id !== closedId)
return next ? { action: 'activate', sessionId: next.id } : { action: 'new' }
}
export const draftModelArgFromPickerValue = (value: string) => {
return sessionScopedModelArg(value)
}
export const draftModelNameFromArg = (value: string) => {
const parts = draftModelArgFromPickerValue(value).split(/\s+/).filter(Boolean)
const modelParts: string[] = []
for (let i = 0; i < parts.length; i++) {
const part = parts[i]!
if (part === '--provider') {
i++
continue
}
if (part.startsWith('--')) {
continue
}
modelParts.push(part)
}
return modelParts.join(' ').trim()
}
export const draftModelDisplayLabel = (value: string) => {
const modelName = draftModelNameFromArg(value)
return modelName ? shortModel(modelName) : 'current/default'
}
export type OrchestratorRowClickAction = { action: 'activate'; sessionId: string } | { action: 'select-new' }
export const orchestratorRowClickAction = (
index: number,
sessions: readonly SessionActiveItem[]
): OrchestratorRowClickAction => {
const target = sessions[index]
return target && !isNewSessionRow(index, sessions.length)
? { action: 'activate', sessionId: target.id }
: { action: 'select-new' }
}
export const draftTitleFromPrompt = (prompt: string, max = TITLE_MAX) => {
const compact = prompt.replace(/\s+/g, ' ').trim()
if (compact.length <= max) {
return compact
}
return `${compact.slice(0, Math.max(0, max - 1)).trimEnd()}…`
}
function OrchestratorHintSegments({ segments, t }: OrchestratorHintTextProps) {
return (
<>
{segments.map((segment, index) => (
<Text color={orchestratorHintSegmentColor(t, segment.role)} key={`${segment.role}-${index}`}>
{segment.text}
</Text>
))}
</>
)
}
function OrchestratorHintText({ segments, t }: OrchestratorHintTextProps) {
return (
<Text color={orchestratorHintSegmentColor(t, 'text')} wrap="truncate-end">
<OrchestratorHintSegments segments={segments} t={t} />
</Text>
)
}
export function ActiveSessionSwitcher({
currentSessionId,
gw,
maxWidth,
onCancel,
onClose,
onNew,
onNewPrompt,
onResume,
onSelect,
t
}: ActiveSessionSwitcherProps) {
const [items, setItems] = useState<SessionActiveItem[]>([])
const [history, setHistory] = useState<SessionListItem[]>([])
const [err, setErr] = useState('')
const [sel, setSel] = useState(0)
const [loading, setLoading] = useState(true)
const [draft, setDraft] = useState('')
const [draftModel, setDraftModel] = useState('')
const [pickingModel, setPickingModel] = useState(false)
const [closingId, setClosingId] = useState('')
// When non-null, the user pressed `d` on this (history) session and we await
// a second `d` to confirm deletion. Tracked by session id (not row index) so
// the 1.5s live-status poll re-indexing rows can't redirect the delete to a
// different session. Any other key cancels the prompt.
const [confirmDelete, setConfirmDelete] = useState<null | string>(null)
const [deleting, setDeleting] = useState(false)
const initialSelectionAppliedRef = useRef(false)
// Holds the RAW `session.list` results (pre-dedupe). The quiet 1.5s poll
// re-derives the resumable list from this against the latest live set, so a
// session that was hidden while live reappears in history once it closes —
// without re-querying the DB. Only refreshed on a full (includeHistory) load.
const rawHistoryRef = useRef<SessionListItem[]>([])
// Mirror the displayed lists so the async poll can re-anchor the selection to
// the *same* row (by session id) after live sessions appear/disappear, rather
// than keeping a now-stale flat index.
const itemsRef = useRef<SessionActiveItem[]>([])
const historyDisplayRef = useRef<SessionListItem[]>([])
const { stdout } = useStdout()
// Optional maxWidth lets grid layouts hand the switcher its cell budget.
const preferredWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))
const width = clampOverlayWidth(preferredWidth, maxWidth)
const promptColumns = Math.max(20, width - 11)
// Rows are [new][live…][history…]: the "+ new" row is pinned first (index 0,
// always rendered) and the live+history list is windowed below it. `total`
// is the count of selectable rows (incl. the new row).
const liveCount = items.length
const histCount = history.length
const listLen = liveCount + histCount
const total = listLen + 1
const rowKind = useCallback((index: number) => sessionRowKindAt(index, liveCount), [liveCount])
const load = useCallback(
// `quiet` skips the loading spinner (used by the live-status poll);
// `includeHistory` re-queries the resumable DB list (skipped on the 1.5s
// poll, which only needs fresh live-session status).
async (quiet = false, includeHistory = true) => {
if (!quiet) {
setLoading(true)
}
try {
// Fetch independently (allSettled) so a failing session.list can't
// wipe the live-session list: live sessions still render and the
// resumable history degrades on its own.
const [liveRes, histRes] = await Promise.allSettled([
gw.request<SessionActiveListResponse>('session.active_list', {
current_session_id: currentSessionId
}),
includeHistory ? gw.request<SessionListResponse>('session.list', { limit: 200 }) : Promise.resolve(null)
])
const r = liveRes.status === 'fulfilled' ? asRpcResult<SessionActiveListResponse>(liveRes.value) : null
if (!r) {
setErr('invalid response: session.active_list')
setLoading(false)
return []
}
const next = r.sessions ?? []
// Surface a garbled/failed session.list rather than silently blanking
// the resumable section; keep the last good raw history so a transient
// failure doesn't wipe it.
let histError = ''
if (includeHistory) {
if (histRes.status === 'fulfilled') {
const parsedHist = asRpcResult<SessionListResponse>(histRes.value)
if (parsedHist) {
rawHistoryRef.current = parsedHist.sessions ?? []
} else {
histError = 'invalid response: session.list'
}
} else {
histError = 'could not load resumable sessions'
}
}
const hist = resumableHistory(rawHistoryRef.current, next)
const initializeSelection = !initialSelectionAppliedRef.current
initialSelectionAppliedRef.current = true
const maxSel = next.length + hist.length // == total - 1 (new row is index 0)
setItems(next)
setHistory(hist)
// Re-anchor selection to the same row by identity (the live list can
// grow/shrink between polls, which would otherwise drift a flat index).
setSel(s => {
if (initializeSelection) {
// Land on the current live session (shifted +1 past the pinned new
// row); with no live sessions, start on the new row itself.
return next.length ? Math.min(currentSessionSelectionIndex(next, currentSessionId) + 1, maxSel) : 0
}
if (s <= 0) {
return 0 // "+ new" row
}
const prevItems = itemsRef.current
const prevHist = historyDisplayRef.current
const clamp = () => Math.max(0, Math.min(s, maxSel))
if (s - 1 < prevItems.length) {
const id = prevItems[s - 1]?.id
const i = id ? next.findIndex(x => x.id === id) : -1
return i >= 0 ? i + 1 : clamp()
}
const id = prevHist[s - 1 - prevItems.length]?.id
const i = id ? hist.findIndex(x => x.id === id) : -1
return i >= 0 ? 1 + next.length + i : clamp()
})
setErr(histError)
setLoading(false)
return next
} catch (e: unknown) {
setErr(rpcErrorMessage(e))
setLoading(false)
return []
}
},
[currentSessionId, gw]
)
useEffect(() => {
itemsRef.current = items
historyDisplayRef.current = history
}, [items, history])
useEffect(() => {
void load()
const timer = setInterval(() => void load(true, false), 1500)
return () => clearInterval(timer)
}, [load])
const submitDraft = useCallback(
(value: string) => {
const prompt = value.trim()
if (!prompt) {
return
}
setDraft('')
onNewPrompt(prompt, draftModel || undefined)
},
[draftModel, onNewPrompt]
)
const closeSelected = useCallback(async () => {
const target = items[sel - 1]
if (!target || rowKind(sel) !== 'live' || closingId) {
return
}
setErr('')
setClosingId(target.id)
try {
const result = await onClose(target.id)
const closed = Boolean(result?.closed ?? result?.ok)
if (!closed) {
setErr('session was already closed')
return
}
const remaining = await load(true)
const fallback = closeFallbackAfterClose(target.id, currentSessionId, remaining)
if (fallback.action === 'activate') {
onSelect(fallback.sessionId)
} else if (fallback.action === 'new') {
onNew()
} else {
setSel(s => Math.max(0, Math.min(s, remaining.length + history.length)))
}
} catch (e: unknown) {
setErr(rpcErrorMessage(e))
} finally {
setClosingId('')
}
}, [closingId, currentSessionId, history.length, items, load, onClose, onNew, onSelect, rowKind, sel])
const performDelete = useCallback(
(id: string) => {
const target = history.find(h => h.id === id)
if (!target || deleting) {
return
}
setDeleting(true)
gw.request<SessionDeleteResponse>('session.delete', { session_id: target.id })
.then(raw => {
const r = asRpcResult<SessionDeleteResponse>(raw)
if (!r || r.deleted !== target.id) {
setErr('invalid response: session.delete')
setDeleting(false)
return
}
rawHistoryRef.current = rawHistoryRef.current.filter(h => h.id !== target.id)
setHistory(prev => prev.filter(h => h.id !== target.id))
setSel(s => Math.max(0, Math.min(s, items.length + history.length - 1)))
setErr('')
setDeleting(false)
})
.catch((e: unknown) => {
setErr(rpcErrorMessage(e))
setDeleting(false)
})
},
[deleting, gw, history, items.length]
)
const handleRowClick = useCallback(
(index: number) => (event: { stopImmediatePropagation?: () => void }) => {
event.stopImmediatePropagation?.()
const kind = rowKind(index)
const clamped = Math.max(0, Math.min(index, total - 1))
if (kind === 'live') {
setSel(clamped)
onSelect(items[index - 1]!.id)
return
}
if (kind === 'history') {
setSel(clamped)
onResume(history[index - 1 - items.length]!.id)
return
}
setSel(0)
},
[history, items, onResume, onSelect, rowKind, total]
)
const selectedKind = rowKind(sel)
const newSelected = selectedKind === 'new'
const draftHasText = Boolean(draft.trim())
useInput((ch, key) => {
if (pickingModel || deleting) {
return
}
// Two-press history delete: once armed, only a second `d` deletes; any
// other key cancels the prompt (mirrors the standalone resume picker).
if (confirmDelete !== null) {
if (ch?.toLowerCase() === 'd') {
const id = confirmDelete
setConfirmDelete(null)
performDelete(id)
} else {
setConfirmDelete(null)
}
return
}
const lower = ch?.toLowerCase() ?? ''
const isCtrl = (letter: string) => key.ctrl && (lower === letter || ch === ctrlChar(letter))
if (key.escape) {
return onCancel()
}
if (isCtrl('n')) {
return onNew()
}
if (isCtrl('r')) {
void load()
return
}
if (key.tab) {
if (newSelected) {
setPickingModel(true)
}
return
}
if (isCtrl('d')) {
if (selectedKind === 'live') {
void closeSelected()
}
return
}
// `d` arms deletion on a resumable history row. (On the New row `d` is
// captured by the prompt's TextInput, so it never reaches here.)
if (lower === 'd' && !key.ctrl && selectedKind === 'history') {
setConfirmDelete(history[sel - 1 - items.length]?.id ?? null)
return
}
if (newSelected && draftHasText) {
return
}
if (key.upArrow && sel > 0) {
return setSel(s => Math.max(0, s - 1))
}
if (key.downArrow && sel < total - 1) {
return setSel(s => Math.min(total - 1, s + 1))
}
if (key.return) {
if (newSelected) {
if (!draftHasText) {
return onNew()
}
return
}
if (selectedKind === 'live' && items[sel - 1]) {
return onSelect(items[sel - 1]!.id)
}
if (selectedKind === 'history' && history[sel - 1 - items.length]) {
return onResume(history[sel - 1 - items.length]!.id)
}
}
})
if (pickingModel) {
return (
<ModelPicker
allowPersistGlobal={false}
gw={gw}
onCancel={() => setPickingModel(false)}
onSelect={value => {
setDraftModel(draftModelArgFromPickerValue(value))
setPickingModel(false)
}}
sessionId={currentSessionId}
t={t}
/>
)
}
if (loading) {
return <Text color={t.color.muted}>loading sessions…</Text>
}
// The "+ new" row (sel 0) is pinned at the top so it's always visible; the
// live + history list is windowed beneath it.
const listSel = sel > 0 ? sel - 1 : 0
const offset = windowOffset(listLen, listSel, VISIBLE)
const visibleCount = Math.max(0, Math.min(VISIBLE, listLen - offset))
const visibleRows = Array.from({ length: visibleCount }, (_, k) => offset + k + 1)
const newSelectedRow = sel === 0
const newRowStyle = newSelectedRow ? selectedSessionRowStyle(t) : null
const newRowTextColor = newRowStyle?.color
const newRowMarkerColor = newSessionMarkerColor(t, newSelectedRow)
const promptTitle = draftTitleFromPrompt(draft) || 'Start a new live session'
return (
<Box flexDirection="column" width={width}>
<Text bold color={t.color.accent}>
Sessions
</Text>
<Text color={t.color.muted}>{sessionsCountLabel(items.length, history.length)}</Text>
{err && <Text color={t.color.label}>error: {err}</Text>}
<Box backgroundColor={newRowStyle?.backgroundColor} flexDirection="row" onClick={handleRowClick(0)} width="100%">
<Text bold={newSelectedRow} color={newRowTextColor ?? t.color.muted}>
{newSelectedRow ? '▸ ' : ' '}
</Text>
<Box {...fixedSessionColumnStyle()} width={5}>
<Text bold={newSelectedRow} color={newRowMarkerColor}>
{'+'.padStart(2)}
</Text>
</Box>
<Box {...fixedSessionColumnStyle()} width={11}>
<Text bold={newSelectedRow} color={newRowMarkerColor} wrap="truncate-end">
new
</Text>
</Box>
<Box {...fixedSessionColumnStyle()} width={11}>
<Text color={newRowTextColor ?? t.color.muted} wrap="truncate-end">
✎ draft
</Text>
</Box>
<Box {...fixedSessionColumnStyle()} width={18}>
<Text color={newRowTextColor ?? t.color.muted} wrap="truncate-end">
{draftModelDisplayLabel(draftModel)}
</Text>
</Box>
<Box flexGrow={1} flexShrink={1} minWidth={0}>
<Text bold={newSelectedRow} color={newRowTextColor ?? t.color.muted} wrap="truncate-end">
{promptTitle}
</Text>
</Box>
</Box>
{offset > 0 && <Text color={t.color.muted}> ↑ {offset} more</Text>}
{!listLen && <Text color={t.color.muted}>no other sessions — Enter on +new to start one</Text>}
{visibleRows.map(i => {
const selected = sel === i
const selectedStyle = selected ? selectedSessionRowStyle(t) : null
const rowTextColor = selectedStyle?.color
const kind = rowKind(i)
if (kind === 'history') {
const h = history[i - 1 - items.length]!
const pendingDelete = confirmDelete === h.id
const title = pendingDelete
? 'press d again to delete'
: deleting && selected
? 'deleting…'
: h.title || h.preview || '(untitled)'
return (
<Box
backgroundColor={selectedStyle?.backgroundColor}
flexDirection="row"
key={h.id}
onClick={handleRowClick(i)}
width="100%"
>
<Text bold={selected} color={rowTextColor ?? t.color.muted}>
{selected ? '▸ ' : ' '}
</Text>
<Box {...fixedSessionColumnStyle()} width={5}>
<Text bold={selected} color={rowTextColor ?? t.color.muted}>
{String(i).padStart(2)}.
</Text>
</Box>
<Box {...fixedSessionColumnStyle()} width={11}>
<Text bold={selected} color={rowTextColor ?? t.color.muted} wrap="truncate-end">
{h.id}
</Text>
</Box>
<Box {...fixedSessionColumnStyle()} width={11}>
<Text color={rowTextColor ?? t.color.muted} wrap="truncate-end">
{relativeSessionAge(h.started_at)}
</Text>
</Box>
<Box {...fixedSessionColumnStyle()} width={18}>
<Text color={rowTextColor ?? t.color.muted} wrap="truncate-end">
{h.message_count} msgs
</Text>
</Box>
<Box flexGrow={1} flexShrink={1} minWidth={0}>
<Text
bold={selected}
color={pendingDelete ? t.color.label : (rowTextColor ?? t.color.muted)}
wrap="truncate-end"
>
{title}
</Text>
</Box>
</Box>
)
}
const s = items[i - 1]!
const status = s.status ?? 'idle'
const current = s.current || s.id === currentSessionId
const title = closingId === s.id ? 'closing…' : s.title || s.preview || '(untitled)'
return (
<Box
backgroundColor={selectedStyle?.backgroundColor}
flexDirection="row"
key={s.id}
onClick={handleRowClick(i)}
width="100%"
>
<Text bold={selected} color={rowTextColor ?? t.color.muted}>
{selected ? '▸ ' : ' '}
</Text>
<Box {...fixedSessionColumnStyle()} width={5}>
<Text bold={selected} color={rowTextColor ?? t.color.muted}>
{String(i).padStart(2)}.
</Text>
</Box>
<Box {...fixedSessionColumnStyle()} width={11}>
<Text
bold={selected}
color={rowTextColor ?? (current ? t.color.label : t.color.muted)}
wrap="truncate-end"
>
{current ? 'current' : s.id}
</Text>
</Box>
<Box {...fixedSessionColumnStyle()} width={11}>
<Text
color={
rowTextColor ??
(status === 'working' ? t.color.ok : status === 'waiting' ? t.color.label : t.color.muted)
}
wrap="truncate-end"
>
{STATUS_GLYPH[status] ?? '·'} {STATUS_LABEL[status] ?? status}
</Text>
</Box>
<Box {...fixedSessionColumnStyle()} width={18}>
<Text color={rowTextColor ?? t.color.muted} wrap="truncate-end">
{shortModel(s.model)}
</Text>
</Box>
<Box flexGrow={1} flexShrink={1} minWidth={0}>
<Text bold={selected} color={rowTextColor ?? t.color.muted} wrap="truncate-end">
{title}
</Text>
</Box>
</Box>
)
})}
{offset + VISIBLE < listLen && <Text color={t.color.muted}> ↓ {listLen - offset - VISIBLE} more</Text>}
{newSelected ? (
<>
<Box marginTop={1}>
<Text color={t.color.label}>prompt › </Text>
<TextInput
color={t.color.text}
columns={promptColumns}
onChange={setDraft}
onSubmit={submitDraft}
value={draft}
/>
</Box>
<OrchestratorHintText segments={orchestratorContextHintSegments(true)} t={t} />
<Text color={t.color.muted} wrap="truncate-end">
model: {draftModelDisplayLabel(draftModel)}
</Text>
</>
) : (
<Box flexDirection="column" marginTop={1}>
<OrchestratorHintText
segments={
selectedKind === 'history' ? resumeRowContextHintSegments : orchestratorContextHintSegments(false)
}
t={t}
/>
<Text color={t.color.muted} wrap="truncate-end">
Select <Text color={newSessionMarkerColor(t, false)}>+new</Text> to type a prompt
</Text>
</Box>
)}
<OrchestratorHintText segments={orchestratorGlobalHotkeyHintSegments} t={t} />
</Box>
)
}
interface OrchestratorHintTextProps {
segments: readonly OrchestratorHintSegment[]
t: Theme
}
interface ActiveSessionSwitcherProps {
currentSessionId: null | string
gw: GatewayClient
maxWidth?: number
onCancel: () => void
onClose: (id: string) => Promise<null | SessionCloseResponse>
onNew: () => void
onNewPrompt: (prompt: string, modelArg?: string) => void
onResume: (id: string) => void
onSelect: (id: string) => void
t: Theme
}
+976
View File
@@ -0,0 +1,976 @@
import { Box, NoSelect, ScrollBox, type ScrollBoxHandle, Text, useInput, useStdout } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'
import {
$delegationState,
$overlaySectionsOpen,
applyDelegationStatus,
toggleOverlaySection
} from '../app/delegationStore.js'
import { patchOverlayState } from '../app/overlayStore.js'
import { $spawnDiff, $spawnHistory, clearDiffPair, type SpawnSnapshot } from '../app/spawnHistoryStore.js'
import { useTurnSelector } from '../app/turnStore.js'
import type { GatewayClient } from '../gatewayClient.js'
import type { DelegationPauseResponse, DelegationStatusResponse, SubagentInterruptResponse } from '../gatewayTypes.js'
import { asRpcResult } from '../lib/rpc.js'
import {
buildSubagentTree,
descendantIds,
flattenTree,
fmtDuration,
fmtTokens,
formatSummary,
hotnessBucket,
peakHotness,
sparkline,
topLevelSubagents,
treeTotals,
widthByDepth
} from '../lib/subagentTree.js'
import { compactPreview } from '../lib/text.js'
import type { Theme } from '../theme.js'
import type { SubagentNode, SubagentProgress } from '../types.js'
import { listRowStyle } from './overlayPrimitives.js'
import { OverlayScrollbar } from './overlayScrollbar.js'
// ── Types + lookup tables ────────────────────────────────────────────
type SortMode = 'depth-first' | 'duration-desc' | 'status' | 'tools-desc'
type FilterMode = 'all' | 'failed' | 'leaf' | 'running'
type Status = SubagentProgress['status']
const SORT_ORDER: readonly SortMode[] = ['depth-first', 'tools-desc', 'duration-desc', 'status']
const FILTER_ORDER: readonly FilterMode[] = ['all', 'running', 'failed', 'leaf']
const SORT_LABEL: Record<SortMode, string> = {
'depth-first': 'spawn order',
'duration-desc': 'slowest',
status: 'status',
'tools-desc': 'busiest'
}
const FILTER_LABEL: Record<FilterMode, string> = {
all: 'all',
failed: 'failed',
leaf: 'leaves',
running: 'running'
}
const STATUS_RANK: Record<Status, number> = {
error: 0,
failed: 0,
interrupted: 1,
timeout: 1,
running: 2,
queued: 3,
completed: 4
}
const statusRank = (status: string): number => STATUS_RANK[status as Status] ?? STATUS_RANK.error
const SORT_COMPARATORS: Record<SortMode, (a: SubagentNode, b: SubagentNode) => number> = {
'depth-first': (a, b) => a.item.depth - b.item.depth || a.item.index - b.item.index,
'tools-desc': (a, b) => b.aggregate.totalTools - a.aggregate.totalTools,
'duration-desc': (a, b) => b.aggregate.totalDuration - a.aggregate.totalDuration,
status: (a, b) => statusRank(a.item.status) - statusRank(b.item.status)
}
const FILTER_PREDICATES: Record<FilterMode, (n: SubagentNode) => boolean> = {
all: () => true,
leaf: n => n.children.length === 0,
running: n => n.item.status === 'running' || n.item.status === 'queued',
failed: n =>
n.item.status === 'error' ||
n.item.status === 'failed' ||
n.item.status === 'interrupted' ||
n.item.status === 'timeout'
}
const STATUS_GLYPH: Record<Status, { color: (t: Theme) => string; glyph: string }> = {
running: { color: t => t.color.accent, glyph: '●' },
queued: { color: t => t.color.muted, glyph: '○' },
completed: { color: t => t.color.statusGood, glyph: '✓' },
interrupted: { color: t => t.color.warn, glyph: '■' },
failed: { color: t => t.color.error, glyph: '✗' },
timeout: { color: t => t.color.warn, glyph: '⌛' },
error: { color: t => t.color.error, glyph: '⚠' }
}
// Heatmap palette — cold → hot, resolved against the active theme.
const heatPalette = (t: Theme) => [t.color.border, t.color.accent, t.color.primary, t.color.warn, t.color.error]
// ── Pure helpers ─────────────────────────────────────────────────────
const fmtDur = (seconds?: number) => (seconds == null || seconds <= 0 ? '' : fmtDuration(seconds))
const fmtElapsedLabel = (seconds: number) => (seconds < 0 ? '' : fmtDuration(seconds))
const displayElapsedSeconds = (item: SubagentProgress, nowMs: number): number | null => {
if (item.durationSeconds != null) {
return item.durationSeconds
}
if (item.startedAt != null && (item.status === 'running' || item.status === 'queued')) {
return Math.max(0, (nowMs - item.startedAt) / 1000)
}
return null
}
const indentFor = (depth: number): string => ' '.repeat(Math.max(0, depth))
const formatRowId = (n: number): string => String(n + 1).padStart(2, ' ')
const cycle = <T,>(order: readonly T[], current: T): T => order[(order.indexOf(current) + 1) % order.length]!
const statusGlyph = (item: SubagentProgress, t: Theme) => {
// Defensive fallback for cross-version snapshots with unknown statuses.
const g = STATUS_GLYPH[item.status] ?? STATUS_GLYPH.error
return { color: g.color(t), glyph: g.glyph }
}
const prepareRows = (tree: SubagentNode[], sort: SortMode, filter: FilterMode): SubagentNode[] =>
tree.length === 0 ? [] : flattenTree([...tree].sort(SORT_COMPARATORS[sort])).filter(FILTER_PREDICATES[filter])
const diffMetricLine = (name: string, a: number, b: number, fmt: (n: number) => string) => {
const d = b - a
const sign = d === 0 ? '' : d > 0 ? '+' : '-'
return `${name}: ${fmt(a)} → ${fmt(b)} (${sign}${fmt(Math.abs(d)) || '0'})`
}
// ── Sub-components ───────────────────────────────────────────────────
function GanttStrip({
cols,
cursor,
flatNodes,
maxRows,
now,
t
}: {
cols: number
cursor: number
flatNodes: SubagentNode[]
maxRows: number
now: number
t: Theme
}) {
const spans = flatNodes
.map((node, idx) => {
const started = node.item.startedAt ?? now
const ended =
node.item.durationSeconds != null && node.item.startedAt != null
? node.item.startedAt + node.item.durationSeconds * 1000
: now
return { endAt: ended, idx, node, startAt: started }
})
.filter(s => s.endAt >= s.startAt)
if (!spans.length) {
return null
}
const globalStart = Math.min(...spans.map(s => s.startAt))
const globalEnd = Math.max(...spans.map(s => s.endAt))
const totalSpan = Math.max(1, globalEnd - globalStart)
const totalSeconds = (globalEnd - globalStart) / 1000
// 5-col id gutter (" 12 ") so the bar doesn't press against the id.
// 10-col right reserve: pad + up to `12m 30s`-style label without
// truncate-end against a full-width bar.
const idGutter = 5
const labelReserve = 10
const barWidth = Math.max(10, cols - idGutter - labelReserve)
const startIdx = Math.max(0, Math.min(Math.max(0, spans.length - maxRows), cursor - Math.floor(maxRows / 2)))
const shown = spans.slice(startIdx, startIdx + maxRows)
const bar = (startAt: number, endAt: number) => {
const s = Math.floor(((startAt - globalStart) / totalSpan) * barWidth)
const e = Math.min(barWidth, Math.ceil(((endAt - globalStart) / totalSpan) * barWidth))
const fill = Math.max(1, e - s)
return ' '.repeat(s) + '█'.repeat(fill) + ' '.repeat(Math.max(0, barWidth - s - fill))
}
const charStep = totalSeconds < 20 && barWidth > 20 ? 5 : 10
const ruler = Array.from({ length: barWidth }, (_, i) => {
if (i > 0 && i % 10 === 0) {
return '┼'
}
if (i > 0 && i % 5 === 0) {
return '·'
}
return '─'
}).join('')
const rulerLabels = (() => {
const chars = new Array(barWidth).fill(' ')
for (let pos = 0; pos < barWidth; pos += charStep) {
const secs = (pos / barWidth) * totalSeconds
const label = pos === 0 ? '0' : secs >= 1 ? `${Math.round(secs)}s` : `${secs.toFixed(1)}s`
for (let j = 0; j < label.length && pos + j < barWidth; j++) {
chars[pos + j] = label[j]!
}
}
return chars.join('')
})()
const windowLabel =
spans.length > maxRows ? ` (${startIdx + 1}-${Math.min(spans.length, startIdx + maxRows)}/${spans.length})` : ''
return (
<Box flexDirection="column" marginBottom={1}>
<Text color={t.color.muted}>
Timeline · {fmtElapsedLabel(Math.max(0, totalSeconds))}
{windowLabel}
</Text>
{shown.map(({ endAt, idx, node, startAt }) => {
const active = idx === cursor
const { color } = statusGlyph(node.item, t)
const accent = active ? t.color.accent : t.color.muted
const elSec = displayElapsedSeconds(node.item, now)
const elLabel = elSec != null ? fmtElapsedLabel(elSec) : ''
return (
<Text key={node.item.id} wrap="truncate-end">
<Text bold={active} color={accent}>
{formatRowId(idx)}
{' '}
</Text>
<Text color={active ? t.color.accent : color}>{bar(startAt, endAt)}</Text>
{elLabel ? (
<Text color={accent}>
{' '}
{elLabel}
</Text>
) : null}
</Text>
)
})}
<Text color={t.color.muted} dim>
{' '}
{ruler}
</Text>
{totalSeconds > 0 ? (
<Text color={t.color.muted} dim>
{' '}
{rulerLabels}
</Text>
) : null}
</Box>
)
}
function OverlaySection({
children,
count,
defaultOpen = false,
title,
t
}: {
children: ReactNode
count?: number
defaultOpen?: boolean
title: string
t: Theme
}) {
const openMap = useStore($overlaySectionsOpen)
const open = title in openMap ? openMap[title]! : defaultOpen
return (
<Box flexDirection="column" marginTop={1}>
<Box onClick={() => toggleOverlaySection(title, defaultOpen)}>
<Text color={t.color.label}>
<Text color={t.color.accent}>{open ? '▾ ' : '▸ '}</Text>
{title}
{typeof count === 'number' ? ` (${count})` : ''}
</Text>
</Box>
{open ? <Box flexDirection="column">{children}</Box> : null}
</Box>
)
}
function Field({ name, t, value }: { name: string; t: Theme; value: ReactNode }) {
return (
<Text wrap="truncate-end">
<Text color={t.color.label}>{name} · </Text>
<Text color={t.color.text}>{value}</Text>
</Text>
)
}
function Detail({ id, node, t }: { id?: string; node: SubagentNode; t: Theme }) {
const { aggregate: agg, item } = node
const { color, glyph } = statusGlyph(item, t)
const inputTokens = item.inputTokens ?? 0
const outputTokens = item.outputTokens ?? 0
const localTokens = inputTokens + outputTokens
const subtreeTokens = agg.inputTokens + agg.outputTokens - localTokens
const filesRead = item.filesRead ?? []
const filesWritten = item.filesWritten ?? []
const outputTail = item.outputTail ?? []
// Tool calls: prefer the live stream; for archived / post-turn views
// that stream is often empty even when tool_count > 0, so fall back to
// the tool names captured in outputTail at subagent.complete time.
const toolLines = item.tools.length > 0 ? item.tools : outputTail.map(e => e.tool).filter(Boolean)
const filesOverflow = Math.max(0, filesRead.length - 8) + Math.max(0, filesWritten.length - 8)
return (
<Box flexDirection="column">
<Text bold color={t.color.text} wrap="wrap">
{id ? <Text color={t.color.accent}>#{id} </Text> : null}
<Text color={color}>{glyph}</Text> {item.goal}
</Text>
<Box flexDirection="column" marginTop={1}>
<Field name="depth" t={t} value={`${item.depth} · ${item.status}`} />
{item.model ? <Field name="model" t={t} value={item.model} /> : null}
{item.toolsets?.length ? <Field name="toolsets" t={t} value={item.toolsets.join(', ')} /> : null}
<Field name="tools" t={t} value={`${item.toolCount ?? 0} (subtree ${agg.totalTools})`} />
<Field
name="subtree"
t={t}
value={`${agg.descendantCount} agent${agg.descendantCount === 1 ? '' : 's'} · d${agg.maxDepthFromHere} · ⚡${agg.activeCount}`}
/>
{item.durationSeconds ? <Field name="elapsed" t={t} value={fmtDur(item.durationSeconds)} /> : null}
{item.iteration != null ? <Field name="iteration" t={t} value={String(item.iteration)} /> : null}
{item.apiCalls ? <Field name="api calls" t={t} value={String(item.apiCalls)} /> : null}
</Box>
{localTokens > 0 ? (
<OverlaySection defaultOpen t={t} title="Budget">
{localTokens > 0 ? (
<Field
name="tokens"
t={t}
value={
<>
{fmtTokens(inputTokens)} in · {fmtTokens(outputTokens)} out
{item.reasoningTokens ? ` · ${fmtTokens(item.reasoningTokens)} reasoning` : ''}
</>
}
/>
) : null}
{subtreeTokens > 0 ? <Field name="subtree tokens" t={t} value={`+${fmtTokens(subtreeTokens)}`} /> : null}
</OverlaySection>
) : null}
{filesRead.length > 0 || filesWritten.length > 0 ? (
<OverlaySection count={filesRead.length + filesWritten.length} t={t} title="Files">
{filesWritten.slice(0, 8).map((p, i) => (
<Text color={t.color.statusGood} key={`w-${i}`} wrap="truncate-end">
+{p}
</Text>
))}
{filesRead.slice(0, 8).map((p, i) => (
<Text color={t.color.text} key={`r-${i}`} wrap="truncate-end">
<Text color={t.color.muted}>·</Text> {p}
</Text>
))}
{filesOverflow > 0 ? <Text color={t.color.muted}>…+{filesOverflow} more</Text> : null}
</OverlaySection>
) : null}
{toolLines.length > 0 ? (
<OverlaySection count={toolLines.length} defaultOpen t={t} title="Tool calls">
{toolLines.map((line, i) => (
<Text color={t.color.text} key={i} wrap="wrap">
<Text color={t.color.muted}>·</Text> {line}
</Text>
))}
</OverlaySection>
) : null}
{outputTail.length > 0 ? (
<OverlaySection count={outputTail.length} defaultOpen t={t} title="Output">
{outputTail.map((entry, i) => (
<Text color={entry.isError ? t.color.error : t.color.text} key={i} wrap="wrap">
<Text bold color={entry.isError ? t.color.error : t.color.accent}>
{entry.tool}
</Text>{' '}
{entry.preview}
</Text>
))}
</OverlaySection>
) : null}
{item.notes.length ? (
<OverlaySection count={item.notes.length} t={t} title="Progress">
{item.notes.slice(-6).map((line, i) => (
<Text color={t.color.text} key={i} wrap="wrap">
<Text color={t.color.label}>·</Text> {line}
</Text>
))}
</OverlaySection>
) : null}
{item.summary ? (
<OverlaySection defaultOpen t={t} title="Summary">
<Text color={t.color.text} wrap="wrap">
{item.summary}
</Text>
</OverlaySection>
) : null}
</Box>
)
}
function ListRow({
active,
index,
node,
peak,
t,
width
}: {
active: boolean
index: number
node: SubagentNode
peak: number
t: Theme
width: number
}) {
const { color, glyph } = statusGlyph(node.item, t)
const palette = heatPalette(t)
const heatIdx = hotnessBucket(node.aggregate.hotness, peak, palette.length)
const heatMarker = heatIdx >= 2 ? palette[heatIdx]! : null
const goal = compactPreview(node.item.goal || 'subagent', width - 28 - node.item.depth * 2)
const toolsCount = node.aggregate.totalTools > 0 ? ` ·${node.aggregate.totalTools}t` : ''
const kids = node.children.length ? ` ·${node.children.length}↓` : ''
const line = node.item.status === 'running' ? node.item.tools.at(-1) : undefined
const paren = line ? line.indexOf('(') : -1
const toolShort = line ? (paren > 0 ? line.slice(0, paren) : line).trim() : ''
const trailing = toolShort ? ` · ${compactPreview(toolShort, 14)}` : ''
// Selection chip, not `inverse` — inverse swaps against the terminal's
// unknowable defaults (black slab on transparent profiles).
const row = listRowStyle(t, active)
const fg = active ? (row.color ?? t.color.accent) : t.color.text
return (
<Text backgroundColor={row.backgroundColor} bold={active} color={fg} wrap="truncate-end">
{' '}
<Text color={active ? fg : t.color.muted}>{formatRowId(index)} </Text>
{indentFor(node.item.depth)}
{heatMarker ? <Text color={active ? fg : heatMarker}>▍</Text> : null}
<Text color={active ? fg : color}>{glyph}</Text> {goal}
<Text color={active ? fg : t.color.muted}>
{toolsCount}
{kids}
{trailing}
</Text>
</Text>
)
}
function DiffPane({
label,
snapshot,
t,
totals,
width
}: {
label: string
snapshot: SpawnSnapshot
t: Theme
totals: ReturnType<typeof treeTotals>
width: number
}) {
return (
<Box flexDirection="column" width={width}>
<Text bold color={t.color.text}>
{label}
</Text>
<Text color={t.color.muted} wrap="truncate-end">
{snapshot.label}
</Text>
<Box marginTop={1}>
<Text color={t.color.muted} wrap="truncate-end">
{formatSummary(totals)}
</Text>
</Box>
<Box flexDirection="column" marginTop={1}>
{topLevelSubagents(snapshot.subagents)
.slice(0, 8)
.map(s => {
const { color, glyph } = statusGlyph(s, t)
return (
<Text color={t.color.muted} key={s.id} wrap="truncate-end">
<Text color={color}>{glyph}</Text> {s.goal || 'subagent'}
</Text>
)
})}
</Box>
</Box>
)
}
function DiffView({
cols,
onClose,
pair,
t
}: {
cols: number
onClose: () => void
pair: { baseline: SpawnSnapshot; candidate: SpawnSnapshot }
t: Theme
}) {
const aTotals = useMemo(() => treeTotals(buildSubagentTree(pair.baseline.subagents)), [pair.baseline])
const bTotals = useMemo(() => treeTotals(buildSubagentTree(pair.candidate.subagents)), [pair.candidate])
const paneWidth = Math.floor((cols - 4) / 2)
useInput((ch, key) => {
if (key.escape || ch === 'q') {
onClose()
}
})
const round = (n: number) => String(Math.round(n))
const sumTokens = (x: typeof aTotals) => x.inputTokens + x.outputTokens
return (
<Box flexDirection="column" flexGrow={1} paddingX={1} paddingY={1}>
<Box flexDirection="column" marginBottom={1}>
<Text bold color={t.color.border}>
Replay diff
</Text>
<Text color={t.color.muted}>baseline vs candidate · esc/q close</Text>
</Box>
<Box flexDirection="row" marginBottom={1}>
<DiffPane label="A · baseline" snapshot={pair.baseline} t={t} totals={aTotals} width={paneWidth} />
<Box width={2} />
<DiffPane label="B · candidate" snapshot={pair.candidate} t={t} totals={bTotals} width={paneWidth} />
</Box>
<Box flexDirection="column" marginTop={1}>
<Text bold color={t.color.accent}>
Δ
</Text>
<Text color={t.color.text}>
{diffMetricLine('agents', aTotals.descendantCount, bTotals.descendantCount, round)}
</Text>
<Text color={t.color.text}>{diffMetricLine('tools', aTotals.totalTools, bTotals.totalTools, round)}</Text>
<Text color={t.color.text}>
{diffMetricLine('depth', aTotals.maxDepthFromHere, bTotals.maxDepthFromHere, round)}
</Text>
<Text color={t.color.text}>
{diffMetricLine('duration', aTotals.totalDuration, bTotals.totalDuration, n => `${n.toFixed(1)}s`)}
</Text>
<Text color={t.color.text}>{diffMetricLine('tokens', sumTokens(aTotals), sumTokens(bTotals), fmtTokens)}</Text>
</Box>
</Box>
)
}
// ── Main overlay ─────────────────────────────────────────────────────
export function AgentsOverlay({ gw, initialHistoryIndex = 0, onClose, t }: AgentsOverlayProps) {
const liveSubagents = useTurnSelector(state => state.subagents)
const delegation = useStore($delegationState)
const history = useStore($spawnHistory)
const diffPair = useStore($spawnDiff)
const { stdout } = useStdout()
// historyIndex === 0: live turn. 1..N pulls the Nth-most-recent archived
// snapshot. /replay passes N on open.
const [historyIndex, setHistoryIndex] = useState(() =>
Math.max(0, Math.min(history.length, Math.floor(initialHistoryIndex)))
)
const [sort, setSort] = useState<SortMode>('depth-first')
const [filter, setFilter] = useState<FilterMode>('all')
const [cursor, setCursor] = useState(0)
const [flash, setFlash] = useState<string>('')
const [now, setNow] = useState(() => Date.now())
// cc-style view switching: list = full-width row picker, detail = full-width
// scrollable pane. Two panes side-by-side in Ink fought Yoga flex.
const [mode, setMode] = useState<'detail' | 'list'>('list')
const detailScrollRef = useRef<null | ScrollBoxHandle>(null)
const prevLiveCountRef = useRef(liveSubagents.length)
// ── Derived state ──────────────────────────────────────────────────
const activeSnapshot = historyIndex > 0 ? history[historyIndex - 1] : null
// Instant fallback to history[0] the moment the live list clears — avoids
// a one-frame "no subagents" flash while the auto-follow effect fires.
const justFinishedSnapshot = historyIndex === 0 && liveSubagents.length === 0 ? (history[0] ?? null) : null
const effectiveSnapshot = activeSnapshot ?? justFinishedSnapshot
const replayMode = effectiveSnapshot != null
const subagents = replayMode ? effectiveSnapshot.subagents : liveSubagents
const tree = useMemo(() => buildSubagentTree(subagents), [subagents])
const totals = useMemo(() => treeTotals(tree), [tree])
const widths = useMemo(() => widthByDepth(tree), [tree])
const spark = useMemo(() => sparkline(widths), [widths])
const peak = useMemo(() => peakHotness(tree), [tree])
const rows = useMemo(() => prepareRows(tree, sort, filter), [tree, sort, filter])
const selected = rows[cursor] ?? null
const cols = stdout?.columns ?? 80
const rowsH = Math.max(8, (stdout?.rows ?? 24) - 10)
const listWindowStart = Math.max(0, cursor - Math.floor(rowsH / 2))
// ── Effects ────────────────────────────────────────────────────────
useEffect(() => {
// Ticker drives both the live gantt and OverlayScrollbar content-reflow
// detection. Slower in replay (nothing's growing) but not stopped
// because accordions still expand.
const id = setInterval(() => setNow(Date.now()), replayMode ? 300 : 500)
return () => clearInterval(id)
}, [replayMode])
useEffect(() => {
// Clamp stale index when history grows/shrinks beneath us.
if (historyIndex > history.length) {
setHistoryIndex(history.length)
}
}, [history.length, historyIndex])
useEffect(() => {
// Auto-follow the just-finished turn onto history[1] so the user isn't
// dropped into an empty live view. Fires only when transitioning from
// "had live subagents" → "live empty" while in live mode.
const prev = prevLiveCountRef.current
prevLiveCountRef.current = liveSubagents.length
if (historyIndex === 0 && prev > 0 && liveSubagents.length === 0 && history.length > 0) {
setHistoryIndex(1)
setCursor(0)
setFlash('turn finished · inspect freely · q to close')
}
}, [history.length, historyIndex, liveSubagents.length])
useEffect(() => {
// Reset detail scroll on navigation so the top of the new node shows.
detailScrollRef.current?.scrollTo(0)
}, [cursor, historyIndex, mode])
useEffect(() => {
// Warm caps + paused flag on open.
gw.request<DelegationStatusResponse>('delegation.status', {})
.then(r => applyDelegationStatus(asRpcResult<DelegationStatusResponse>(r)))
.catch(() => {})
}, [gw])
useEffect(() => {
if (cursor >= rows.length) {
setCursor(Math.max(0, rows.length - 1))
}
}, [cursor, rows.length])
// ── Actions ────────────────────────────────────────────────────────
const guardLive = (action: () => void) => {
if (replayMode) {
setFlash('replay mode — controls disabled')
} else {
action()
}
}
const interrupt = (id: string) => gw.request<SubagentInterruptResponse>('subagent.interrupt', { subagent_id: id })
const killOne = (id: string) =>
guardLive(() => {
interrupt(id)
.then(raw => {
const r = asRpcResult<SubagentInterruptResponse>(raw)
setFlash(r?.found ? `killing ${id}` : `not found: ${id}`)
})
.catch(() => setFlash(`kill failed: ${id}`))
})
const killSubtree = (node: SubagentNode) =>
guardLive(() => {
const ids = [node.item.id, ...descendantIds(node)]
ids.forEach(id => interrupt(id).catch(() => {}))
setFlash(`killing subtree · ${ids.length} node${ids.length === 1 ? '' : 's'}`)
})
const togglePause = () =>
guardLive(() => {
gw.request<DelegationPauseResponse>('delegation.pause', { paused: !delegation.paused })
.then(raw => {
const r = asRpcResult<DelegationPauseResponse>(raw)
applyDelegationStatus({ paused: r?.paused })
setFlash(r?.paused ? 'spawning paused' : 'spawning resumed')
})
.catch(() => setFlash('pause failed'))
})
const stepHistory = (delta: -1 | 1) =>
setHistoryIndex(idx => {
const next = Math.max(0, Math.min(history.length, idx + delta))
if (next !== idx) {
setCursor(0)
setFlash(next === 0 ? 'live turn' : `replay · ${next}/${history.length}`)
}
return next
})
const closeWithCleanup = () => {
clearDiffPair()
onClose()
}
// ── Input ──────────────────────────────────────────────────────────
const detailPageSize = Math.max(4, rowsH - 2)
const wheelDetailDy = 3
const scrollDetail = (dy: number) => detailScrollRef.current?.scrollBy(dy)
useInput((ch, key) => {
if (ch === 'q') {
return closeWithCleanup()
}
if (key.escape) {
return mode === 'detail' ? setMode('list') : closeWithCleanup()
}
// Shared actions (both modes).
if (ch === '<' || ch === '[') {
return stepHistory(1)
}
if (ch === '>' || ch === ']') {
return stepHistory(-1)
}
if (ch === 'p') {
return togglePause()
}
if (ch === 'x' && selected) {
return killOne(selected.item.id)
}
if (ch === 'X' && selected) {
return killSubtree(selected)
}
if (mode === 'detail') {
if (key.leftArrow || ch === 'h') {
return setMode('list')
}
if (key.pageUp || (key.ctrl && ch === 'u')) {
return scrollDetail(-detailPageSize)
}
if (key.pageDown || (key.ctrl && ch === 'd')) {
return scrollDetail(detailPageSize)
}
if (key.wheelUp) {
return scrollDetail(-wheelDetailDy)
}
if (key.wheelDown) {
return scrollDetail(wheelDetailDy)
}
if (key.upArrow || ch === 'k') {
return scrollDetail(-2)
}
if (key.downArrow || ch === 'j') {
return scrollDetail(2)
}
if (ch === 'g') {
return detailScrollRef.current?.scrollTo(0)
}
if (ch === 'G') {
return detailScrollRef.current?.scrollToBottom?.()
}
return
}
// List mode.
if ((key.return || key.rightArrow || ch === 'l') && selected) {
return setMode('detail')
}
if (key.upArrow || ch === 'k' || key.wheelUp) {
return setCursor(c => Math.max(0, c - 1))
}
if (key.downArrow || ch === 'j' || key.wheelDown) {
return setCursor(c => Math.min(Math.max(0, rows.length - 1), c + 1))
}
if (ch === 'g') {
return setCursor(0)
}
if (ch === 'G') {
return setCursor(Math.max(0, rows.length - 1))
}
if (ch === 's') {
return setSort(m => cycle(SORT_ORDER, m))
}
if (ch === 'f') {
return setFilter(m => cycle(FILTER_ORDER, m))
}
})
// ── Header assembly ────────────────────────────────────────────────
const mix = Object.entries(
subagents.reduce<Record<string, number>>((acc, it) => {
const key = it.model ? it.model.split('/').pop()! : 'inherit'
acc[key] = (acc[key] ?? 0) + 1
return acc
}, {})
)
.sort((a, b) => b[1] - a[1])
.slice(0, 4)
.map(([k, v]) => `${k}×${v}`)
.join(' · ')
const capsLabel = delegation.maxSpawnDepth
? `caps d${delegation.maxSpawnDepth}/${delegation.maxConcurrentChildren ?? '?'}`
: ''
const title =
replayMode && effectiveSnapshot
? `${historyIndex > 0 ? `Replay ${historyIndex}/${history.length}` : 'Last turn'} · finished ${new Date(
effectiveSnapshot.finishedAt
).toLocaleTimeString()}`
: `Spawn tree${delegation.paused ? ' · ⏸ paused' : ''}`
const metaLine = [formatSummary(totals), spark, capsLabel, mix ? `· ${mix}` : ''].filter(Boolean).join(' ')
const controlsHint = replayMode
? ' · controls locked'
: ` · x kill · X subtree · p ${delegation.paused ? 'resume' : 'pause'}`
// ── Rendering ──────────────────────────────────────────────────────
if (diffPair) {
return <DiffView cols={cols} onClose={closeWithCleanup} pair={diffPair} t={t} />
}
return (
<Box alignItems="stretch" flexDirection="column" flexGrow={1} paddingX={1} paddingY={1}>
<Box flexDirection="column" marginBottom={1}>
<Text wrap="truncate-end">
<Text bold color={replayMode ? t.color.border : t.color.primary}>
{title}
</Text>
{metaLine ? (
<Text color={t.color.muted}>
{' '}
{metaLine}
</Text>
) : null}
</Text>
</Box>
{rows.length === 0 ? (
<Box flexDirection="column" flexGrow={1}>
<Text color={t.color.muted}>No subagents this turn. Trigger delegate_task to populate the tree.</Text>
</Box>
) : mode === 'list' ? (
<Box flexDirection="column" flexGrow={1} flexShrink={1} minHeight={0}>
<GanttStrip cols={cols} cursor={cursor} flatNodes={rows} maxRows={6} now={now} t={t} />
<Box flexDirection="column" flexGrow={0} flexShrink={0} overflow="hidden">
{rows.slice(listWindowStart, listWindowStart + rowsH).map((node, i) => (
<ListRow
active={listWindowStart + i === cursor}
index={listWindowStart + i}
key={node.item.id}
node={node}
peak={peak}
t={t}
width={cols}
/>
))}
</Box>
</Box>
) : (
<Box flexDirection="row" flexGrow={1} flexShrink={1} minHeight={0}>
<ScrollBox flexDirection="column" flexGrow={1} flexShrink={1} ref={detailScrollRef}>
<Box flexDirection="column" paddingBottom={4} paddingRight={1}>
{selected ? <Detail id={formatRowId(cursor).trim()} node={selected} t={t} /> : null}
</Box>
</ScrollBox>
<NoSelect flexShrink={0} marginLeft={1}>
<OverlayScrollbar scrollRef={detailScrollRef} t={t} tick={now} />
</NoSelect>
</Box>
)}
<Box flexDirection="column" marginTop={1}>
{flash ? <Text color={t.color.accent}>{flash}</Text> : null}
{mode === 'list' ? (
<Text color={t.color.muted}>
↑↓/jk move · g/G top/bottom · Enter/→ open detail{controlsHint} · s sort:{SORT_LABEL[sort]} · f filter:
{FILTER_LABEL[filter]}
{history.length > 0 ? ` · [ / ] history ${historyIndex}/${history.length}` : ''}
{' · q close'}
</Text>
) : (
<Text color={t.color.muted}>
↑↓/jk scroll · PgUp/PgDn page · g/G top/bottom · Esc/← back to list{controlsHint} · q close
</Text>
)}
</Box>
</Box>
)
}
interface AgentsOverlayProps {
gw: GatewayClient
initialHistoryIndex?: number
onClose: () => void
t: Theme
}
export const closeAgentsOverlay = () => patchOverlayState({ agents: false })
export const openAgentsOverlay = () => patchOverlayState({ agents: true })
+974
View File
@@ -0,0 +1,974 @@
import { Box, type ScrollBoxHandle, stringWidth, Text } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from 'react'
import unicodeSpinners from 'unicode-animations'
import { $delegationState } from '../app/delegationStore.js'
import type { BatteryInfo, IndicatorStyle, Notice } from '../app/interfaces.js'
import { $isStatusRuleOccluded } from '../app/overlayStore.js'
import { useTurnSelector } from '../app/turnStore.js'
import { DEV_CREDITS_MODE } from '../config/env.js'
import { FACES } from '../content/faces.js'
import { VERBS } from '../content/verbs.js'
import { fmtDuration } from '../domain/messages.js'
import { stickyPromptFromViewport } from '../domain/viewport.js'
import { buildSubagentTree, treeTotals, widthByDepth } from '../lib/subagentTree.js'
import { fmtK } from '../lib/text.js'
import { useScrollbarSnapshot, useViewportSnapshot } from '../lib/viewportStore.js'
import type { Theme } from '../theme.js'
import type { Msg, Usage } from '../types.js'
import { scrollbarColors } from './overlayPrimitives.js'
const FACE_TICK_MS = 2500
const HEART_COLORS = ['#ff5fa2', '#ff4d6d']
// Keep verb segment width stable so status-bar content to the right doesn't
// jitter when the ticker rotates between short/long verbs.
export const VERB_PAD_LEN = VERBS.reduce((max, v) => Math.max(max, v.length), 0) + 1 // + ellipsis
export const padVerb = (verb: string) => `${verb}…`.padEnd(VERB_PAD_LEN, ' ')
// Compact alternates for the `emoji` and `ascii` indicator styles.
// Each entry is a fixed-width (display-width) glyph.
const EMOJI_FRAMES = ['⚕ ', '🌀', '🤔', '✨', '🍵', '🔮']
const ASCII_FRAMES = ['|', '/', '-', '\\']
// Faster tick for spinner-style indicators — they read as motion only
// at frame rates closer to their authored interval.
const SPINNER_TICK_MS = 100
interface IndicatorRender {
frame: string
intervalMs: number
// When false, FaceTicker hides the rotating verb and just shows the
// glyph + duration. Lets `unicode` stay minimal while the other
// styles keep the verb-rotation flavour users associate with the
// running… status.
showVerb: boolean
}
const renderIndicator = (style: IndicatorStyle, tick: number): IndicatorRender => {
if (style === 'kaomoji') {
return { frame: FACES[tick % FACES.length] ?? '', intervalMs: FACE_TICK_MS, showVerb: true }
}
if (style === 'emoji') {
return {
frame: EMOJI_FRAMES[tick % EMOJI_FRAMES.length] ?? '⚕ ',
intervalMs: SPINNER_TICK_MS * 6,
showVerb: true
}
}
if (style === 'ascii') {
return {
frame: ASCII_FRAMES[tick % ASCII_FRAMES.length] ?? '|',
intervalMs: SPINNER_TICK_MS,
showVerb: true
}
}
// 'unicode' — braille spinner (fixed 1-col). Authored interval is
// ~80ms; honour it but bound below at a safe minimum so React
// re-renders stay reasonable. This style is for users who want
// the cleanest possible status, so no verb rotation either.
const spinner = unicodeSpinners.braille
const frame = spinner.frames[tick % spinner.frames.length] ?? '⠋'
return { frame, intervalMs: Math.max(SPINNER_TICK_MS, spinner.interval), showVerb: false }
}
// `FACES` / `EMOJI_FRAMES` are static, so measure their widest glyph once at
// module load instead of rescanning on every status render.
const KAOMOJI_FRAME_WIDTH = FACES.reduce((max, f) => Math.max(max, stringWidth(f)), 1)
const EMOJI_FRAME_WIDTH = EMOJI_FRAMES.reduce((max, f) => Math.max(max, stringWidth(f)), 1)
const indicatorFrameWidth = (style: IndicatorStyle): number => {
if (style === 'kaomoji') {
return KAOMOJI_FRAME_WIDTH
}
if (style === 'emoji') {
return EMOJI_FRAME_WIDTH
}
// 'ascii' and 'unicode' are single-column glyphs.
return 1
}
// Bounded width of the elapsed-time clock, derived from `fmtDuration` itself so
// the reservation/budget stays consistent with what actually renders (it emits
// a space between units, e.g. `59m 59s` / `99h 59m`). Durations beyond this
// (100h+) are left to clip rather than reserving unbounded width.
export const MAX_DURATION_WIDTH = Math.max(
stringWidth(fmtDuration(59 * 60_000 + 59_000)), // "59m 59s"
stringWidth(fmtDuration(99 * 3_600_000 + 59 * 60_000)) // "99h 59m"
)
// Display width to reserve for the busy indicator so its verb + elapsed-time
// tail can't shove the model off-screen on narrow terminals. Style-aware:
// `unicode` is a bare 1-col braille spinner with no verb, while kaomoji/emoji/
// ascii add a fixed-width verb; any style adds a bounded elapsed-time tail.
// Mirrors FaceTicker's `frame + verbSegment + durationSegment` layout.
export const busyIndicatorWidth = (style: IndicatorStyle, hasDuration: boolean): number => {
const { showVerb } = renderIndicator(style, 0)
const verb = showVerb ? 1 + VERB_PAD_LEN : 0
// ` · ` plus the bounded clock (e.g. `59m 59s`).
const duration = hasDuration ? stringWidth(' · ') + MAX_DURATION_WIDTH : 0
return indicatorFrameWidth(style) + verb + duration
}
function FaceTicker({
color,
startedAt,
style,
verbOverride
}: {
color: string
startedAt?: null | number
style: IndicatorStyle
verbOverride?: string
}) {
const [tick, setTick] = useState(() => Math.floor(Math.random() * 1000))
const [verbTick, setVerbTick] = useState(() => Math.floor(Math.random() * VERBS.length))
const [now, setNow] = useState(() => Date.now())
const isOccluded = useStore($isStatusRuleOccluded)
// Pre-compute cadence + verb-visibility for the active style so an
// `/indicator` switch re-arms the interval (and skips the verb timer
// for verb-less styles like `unicode`) without leaving the previous
// timer dangling. A frozen override (idle compaction) always shows the
// verb so "compacting…" is visible even in unicode style (#97239).
const { intervalMs, showVerb } = renderIndicator(style, 0)
const freezeVerb = Boolean(verbOverride)
const displayVerb = freezeVerb || showVerb
useEffect(() => {
// An overlay is painted OVER the status rule (the modal widget slot, or a
// floating panel growing up over the top rule), so every tick below is a
// re-render nobody can see — in an Ink TUI that churn reads as the dialog
// tearing. Arm nothing while occluded. The effect re-runs when the rule
// is revealed again and re-seeds `now` from the wall clock, so the elapsed
// read-out resumes live rather than frozen at the moment it was covered.
// See `$isStatusRuleOccluded` for why this is NOT `$isBlocked`.
if (isOccluded) {
return
}
setNow(Date.now())
const glyph = setInterval(() => setTick(n => n + 1), intervalMs)
const clock = setInterval(() => setNow(Date.now()), 1000)
// Verb timer is gated on `displayVerb` — `unicode` style hides the verb
// entirely, so cycling `verbTick` would be an avoidable re-render. A
// frozen override does not rotate.
const verb = displayVerb && !freezeVerb ? setInterval(() => setVerbTick(n => n + 1), FACE_TICK_MS) : null
return () => {
clearInterval(glyph)
clearInterval(clock)
if (verb !== null) {
clearInterval(verb)
}
}
}, [displayVerb, freezeVerb, intervalMs, isOccluded])
const { frame } = renderIndicator(style, tick)
const verb = verbOverride ?? VERBS[verbTick % VERBS.length] ?? ''
const verbSegment = displayVerb ? ` ${padVerb(verb)}` : ''
// Leading space keeps a gap between the frame and the duration when the
// verb segment is hidden (e.g. `unicode` spinner style). When the verb
// IS shown, its trailing padding already provides the gap, so the extra
// space is harmless.
const durationSegment = startedAt ? ` · ${fmtDuration(now - startedAt)}` : ''
return (
<Text color={color}>
{frame}
{verbSegment}
{durationSegment}
</Text>
)
}
function ctxBarColor(pct: number | undefined, t: Theme) {
if (pct == null) {
return t.color.muted
}
if (pct >= 95) {
return t.color.statusCritical
}
if (pct > 80) {
return t.color.statusBad
}
if (pct >= 50) {
return t.color.statusWarn
}
return t.color.statusGood
}
function statusSessionCountLabel(count: number) {
return `${count} ${count === 1 ? 'session' : 'sessions'}`
}
// Colour the battery read-out by its (Python-computed) category. Inverted vs
// the context bar — a full battery is "good", an empty one "critical".
function batteryColor(info: BatteryInfo, t: Theme): string {
if (info.category === 'good') {
return t.color.statusGood
}
if (info.category === 'warn') {
return t.color.statusWarn
}
if (info.category === 'bad') {
return t.color.statusBad
}
if (info.category === 'critical') {
return t.color.statusCritical
}
return t.color.muted
}
// Compact battery label: a bolt while charging, else a battery glyph.
// Renders `--` for an unknown percent so a null can never surface as "null%".
function batteryLabel(info: BatteryInfo): string {
return `${info.plugged ? '⚡' : '🔋'} ${info.percent ?? '--'}%`
}
// Colour a credits notice by its level. The notice TEXT already carries its
// own glyph (⚠ • ✕ ✓) from the Python policy — we only tint it here, never
// prepend another glyph. `success` maps to the theme's green status colour.
function noticeColor(level: Notice['level'], t: Theme): string {
if (level === 'error') {
return t.color.error
}
if (level === 'warn') {
return t.color.warn
}
if (level === 'success') {
return t.color.statusGood
}
// 'info' / undefined — keep it readable but understated.
return t.color.accent
}
function ctxBar(pct: number | undefined, w = 10) {
const p = Math.max(0, Math.min(100, pct ?? 0))
const filled = Math.round((p / 100) * w)
return '█'.repeat(filled) + '░'.repeat(w - filled)
}
// `minLeftContent` is the display width of the high-priority left segments
// (status indicator + model + context). Reserving it makes the cwd/branch
// segment on the right yield FIRST on narrow terminals, instead of squeezing
// the loading indicator and model down to nothing.
export function statusRuleWidths(cols: number, cwdLabel: string, minLeftContent = 0) {
const width = Math.max(1, Math.floor(cols || 1))
const desiredSeparatorWidth = width >= 24 ? 3 : 1
const baseMinLeft = width >= 24 ? 8 : 1
// Never reserve more than the terminal width; never less than the historical
// floor. With the default `minLeftContent = 0` this is identical to the old
// behaviour, so callers that don't pass content are unaffected.
const minLeftWidth = Math.min(width, Math.max(baseMinLeft, Math.floor(minLeftContent)))
const maxRightWidth = Math.max(0, width - desiredSeparatorWidth - minLeftWidth)
if (!cwdLabel || maxRightWidth <= 0) {
return { leftWidth: width, rightWidth: 0, separatorWidth: 0 }
}
const rightWidth = Math.max(0, Math.min(stringWidth(cwdLabel), maxRightWidth))
const separatorWidth = rightWidth > 0 ? desiredSeparatorWidth : 0
const leftWidth = Math.max(1, width - separatorWidth - rightWidth)
return { leftWidth, rightWidth, separatorWidth }
}
// Progressive disclosure for the status rule's lower-priority tail segments.
// As the terminal narrows we shed the least important pieces first (cost →
// bg → voice → compressions → duration → context bar), and below the bar
// breakpoint the context read-out collapses to a bare token count. Status and
// model are never gated here — they're guaranteed room by `statusRuleWidths`.
export interface StatusBarSegments {
bar: boolean
bg: boolean
cacheHit: boolean
compactCtx: boolean
compressions: boolean
duration: boolean
latency: boolean
subagents: boolean
tps: boolean
voice: boolean
}
export function statusBarSegments(cols: number): StatusBarSegments {
const w = Math.max(1, Math.floor(cols || 1))
return {
compactCtx: w < 72,
bar: w >= 72,
duration: w >= 76,
compressions: w >= 80,
voice: w >= 84,
bg: w >= 88,
subagents: w >= 92,
cacheHit: w >= 96,
latency: w >= 104,
tps: w >= 110
}
}
function SpawnHud({ t }: { t: Theme }) {
// Tight HUD that only appears when the session is actually fanning out.
// Colour escalates to warn/error as depth or concurrency approaches the cap.
const delegation = useStore($delegationState)
const subagents = useTurnSelector(state => state.subagents)
const tree = useMemo(() => buildSubagentTree(subagents), [subagents])
const totals = useMemo(() => treeTotals(tree), [tree])
if (!totals.descendantCount && !delegation.paused) {
return null
}
const maxDepth = delegation.maxSpawnDepth
const maxConc = delegation.maxConcurrentChildren
const depth = Math.max(0, totals.maxDepthFromHere)
const active = totals.activeCount
// `max_concurrent_children` is a per-parent cap, not a global one.
// `activeCount` sums every running agent across the tree and would
// over-warn for multi-orchestrator runs. The widest level of the tree
// is a closer proxy to "most concurrent spawns that could be hitting a
// single parent's slot budget".
const widestLevel = widthByDepth(tree).reduce((a, b) => Math.max(a, b), 0)
const depthRatio = maxDepth ? depth / maxDepth : 0
const concRatio = maxConc ? widestLevel / maxConc : 0
const ratio = Math.max(depthRatio, concRatio)
const color = delegation.paused || ratio >= 1 ? t.color.error : ratio >= 0.66 ? t.color.warn : t.color.muted
const pieces: string[] = []
if (delegation.paused) {
pieces.push('⏸ paused')
}
if (totals.descendantCount > 0) {
const depthLabel = maxDepth ? `${depth}/${maxDepth}` : `${depth}`
pieces.push(`d${depthLabel}`)
if (active > 0) {
// Label pairs the widest-level count (drives concRatio above) with
// the total active count for context. `W/cap` triggers the warn,
// `+N` is everything else currently running across the tree.
const extra = Math.max(0, active - widestLevel)
const widthLabel = maxConc ? `${widestLevel}/${maxConc}` : `${widestLevel}`
const suffix = extra > 0 ? `+${extra}` : ''
pieces.push(`⚡${widthLabel}${suffix}`)
}
}
const atCap = depthRatio >= 1 || concRatio >= 1
return (
<Text color={color}>
{atCap ? ' │ ⚠ ' : ' │ '}
{pieces.join(' ')}
</Text>
)
}
function SessionDuration({ startedAt }: { startedAt: number }) {
const [now, setNow] = useState(() => Date.now())
const isOccluded = useStore($isStatusRuleOccluded)
useEffect(() => {
// Paused only while an overlay actually covers the status rule — see
// FaceTicker. The `setNow` below already re-seeds from the wall clock
// on every re-arm, so it doubles as the reveal catch-up.
if (isOccluded) {
return
}
setNow(Date.now())
const id = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(id)
}, [isOccluded, startedAt])
return fmtDuration(now - startedAt)
}
function IdleSince({ endedAt }: { endedAt: number }) {
// Time since the last final agent response. Re-ticks every second like
// SessionDuration so the read-out stays live while the session idles.
const [now, setNow] = useState(() => Date.now())
const isOccluded = useStore($isStatusRuleOccluded)
useEffect(() => {
// Paused only while an overlay actually covers the status rule — see
// FaceTicker. The `setNow` below re-seeds from the wall clock on reveal
// so the idle read-out is not frozen when the overlay closes.
if (isOccluded) {
return
}
setNow(Date.now())
const id = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(id)
}, [endedAt, isOccluded])
return `✓ ${fmtDuration(now - endedAt)}`
}
const effortLabel = (effort?: string) => {
const value = String(effort ?? '')
.trim()
.toLowerCase()
return value && value !== 'medium' && value !== 'normal' && value !== 'default' ? value : ''
}
const shortModelLabel = (model: string) =>
model
.split('/')
.pop()!
.replace(/^claude[-_]/, '')
.replace(/^anthropic[-_]/, '')
.replace(/[-_]/g, ' ')
.replace(/\b(\d+)\s+(\d+)\b/g, '$1.$2')
.trim()
const modelLabel = (model: string, effort?: string, fast?: boolean) =>
[shortModelLabel(model), effortLabel(effort), fast ? 'fast' : ''].filter(Boolean).join(' ')
export function GoodVibesHeart({ tick, t }: { tick: number; t: Theme }) {
const [active, setActive] = useState(false)
const [color, setColor] = useState(t.color.accent)
useEffect(() => {
if (tick <= 0) {
return
}
const palette = [t.color.error, t.color.warn, t.color.accent]
setColor(palette[Math.floor(Math.random() * palette.length)]!)
setActive(true)
const id = setTimeout(() => setActive(false), 650)
return () => clearTimeout(id)
}, [t.color.accent, t.color.error, t.color.warn, tick])
if (!active) {
return null
}
return <Text color={color}>♥</Text>
}
export function StatusRule({
battery,
focusView,
cwdLabel,
cols,
busy,
compacting = false,
status,
statusBarFields = null,
statusColor,
model,
modelFast,
modelReasoningEffort,
indicatorStyle = 'kaomoji',
notice,
usage,
bgCount,
lastTurnEndedAt,
liveSessionCount,
sessionTitle,
sessionStartedAt,
turnStartedAt,
voiceLabel,
onSessionCountClick,
t
}: StatusRuleProps) {
const pct = usage.context_percent
const barColor = ctxBarColor(pct, t)
const segs = statusBarSegments(cols)
// display.status_bar.fields visibility gate (same key + names as the
// classic CLI bar). null = user hasn't customized → everything shows.
const ok = (name: string) => statusBarFields === null || statusBarFields.has(name)
// On narrow terminals the context read-out collapses to a bare token count
// (`12k tok`) and the visual fill bar is dropped entirely.
const ctxLabel =
ok('context_detail') || ok('context_pct')
? usage.context_max
? segs.compactCtx
? `${fmtK(usage.context_used ?? 0)} tok`
: `${fmtK(usage.context_used ?? 0)}/${fmtK(usage.context_max)}`
: usage.total > 0
? `${fmtK(usage.total)} tok`
: ''
: ''
const bar = !segs.compactCtx && usage.context_max && ok('context_pct') ? ctxBar(pct) : ''
const modelText = modelLabel(model, modelReasoningEffort, modelFast)
// Battery read-out — the first (pinned) status-bar element when enabled.
const showBattery = !!battery && battery.available && battery.percent != null && ok('battery')
const batteryText = showBattery ? batteryLabel(battery!) : ''
const batteryColorVal = showBattery ? batteryColor(battery!, t) : ''
const batteryWidth = showBattery ? stringWidth(`${batteryText} │ `) : 0
// A credits notice replaces the status/verb slot, but only when idle —
// while busy the FaceTicker always wins (R1 render priority). The notice
// text carries its own glyph; we only tint it (R1) and let it shrink (R3-M7).
const showNotice = !busy && !!notice?.text
// The notice slot is shrinkable (flexShrink={1}, truncate-end), so reserve
// only a small bounded width for it in the essentials budget — enough that
// a short notice never gets crushed, but a long one ellipsizes instead of
// shoving `model │ ctx` off-screen (R3-M7). Cap at the notice's own width
// so short notices reserve exactly what they need.
const NOTICE_RESERVE_MAX = 24
const noticeReserve = showNotice ? Math.min(stringWidth(notice!.text), NOTICE_RESERVE_MAX) : 0
// Width of the must-keep left segments (indicator + model + context). They
// are pinned (never shrink) and reserved so the cwd/branch on the right
// yields first. The busy face width depends on the active /indicator style
// (kaomoji is wide + verb; unicode is a bare 1-col spinner). When a notice
// occupies the slot it reserves only `noticeReserve` (it shrinks/truncates).
const slotWidth = busy
? busyIndicatorWidth(indicatorStyle, turnStartedAt != null)
: showNotice
? noticeReserve
: stringWidth(status)
const essentialWidth =
stringWidth('─ ') +
batteryWidth +
slotWidth +
stringWidth(' │ ') +
stringWidth(modelText) +
(ctxLabel ? stringWidth(' │ ') + stringWidth(ctxLabel) : 0)
const rightLabel = sessionTitle && ok('title') ? ` ${sessionTitle} ` : cwdLabel
const { leftWidth, rightWidth, separatorWidth } = statusRuleWidths(cols, rightLabel, essentialWidth)
// Whole-segment progressive disclosure for the tail: a segment renders only
// if it fits in the space left after the pinned essentials, evaluated in
// descending priority order — bar, duration, compressions, voice, session
// count, bg, cost. Lower-priority segments drop first and nothing truncates
// mid-segment, so status/model/context are never crushed.
const SEP = stringWidth(' │ ')
let tailBudget = Math.max(0, leftWidth - essentialWidth)
const fits = (w: number) => {
if (tailBudget >= w) {
tailBudget -= w
return true
}
return false
}
const sessionCountText = liveSessionCount > 0 ? statusSessionCountLabel(liveSessionCount) : ''
const compressions = typeof usage.compressions === 'number' ? usage.compressions : 0
// Dev-only readout (HERMES_DEV_CREDITS). The server omits the key entirely unless the
// flag is on, so this segment self-hides for normal users. micros→cents is allowed money
// math (display formatting) — never parseFloat a *_usd. Signed: a mid-session top-up that
// raises remaining nets a negative Δ (honest).
const devCreditsText =
typeof usage.dev_credits_spent_micros === 'number'
? `Δ ${(usage.dev_credits_spent_micros / 10000).toFixed(1)}¢`
: ''
const showBar = !!bar && fits(SEP + stringWidth(`[${bar}] ${pct != null ? `${pct}%` : ''}`))
const showDuration = segs.duration && ok('duration') && !!sessionStartedAt && fits(SEP + MAX_DURATION_WIDTH)
// Idle clock — time since the last final agent response. Hidden while busy
// (the FaceTicker's elapsed tail covers the live turn) and before the first
// turn completes. Shares the duration breakpoint and width reservation.
const showIdle =
segs.duration && !busy && lastTurnEndedAt != null && fits(SEP + stringWidth('✓ ') + MAX_DURATION_WIDTH)
const showCompressions =
segs.compressions && ok('compressions') && compressions > 0 && fits(SEP + stringWidth(`cmp ${compressions}`))
// Cache-hit % + rolling latency / tokens-per-sec — mirrored from the classic
// CLI bar (PR #98250). The server omits the keys when no data exists (zero
// cache reads, Codex app-server with no latency), so these self-hide.
const cacheHitText = typeof usage.cache_hit_pct === 'number' ? `◎ ${usage.cache_hit_pct}%` : ''
const showCacheHit = segs.cacheHit && ok('cache_hit') && !!cacheHitText && fits(SEP + stringWidth(cacheHitText))
const latencyText = typeof usage.avg_latency_s === 'number' ? `◷ ${usage.avg_latency_s.toFixed(1)}s` : ''
const showLatency = segs.latency && ok('latency') && !!latencyText && fits(SEP + stringWidth(latencyText))
const tpsText = typeof usage.avg_tps === 'number' ? `↑ ${Math.round(usage.avg_tps)} t/s` : ''
const showTps = segs.tps && ok('tps') && !!tpsText && fits(SEP + stringWidth(tpsText))
const showVoice = segs.voice && ok('voice') && !!voiceLabel && fits(SEP + stringWidth(voiceLabel))
const showSessionCount = !!sessionCountText && fits(SEP + stringWidth(sessionCountText))
const showBg = segs.bg && ok('bg_tasks') && bgCount > 0 && fits(SEP + stringWidth(`${bgCount} bg`))
const subagentCount = typeof usage.active_subagents === 'number' ? usage.active_subagents : 0
const showSubagents =
segs.subagents && ok('bg_subagents') && subagentCount > 0 && fits(SEP + stringWidth(`⛓ ${subagentCount}`))
// Parked-background reassurance: a top-level delegate_task runs in the
// background, so the turn ends (idle) while the subagent keeps working and its
// result re-enters as a fresh turn later. When idle with work still in flight,
// spell out that the agent resumes on its own — no spinner, nothing to poll.
// Width-budgeted like every tail segment, so it drops first on a tight
// terminal where ⛓ already carries the signal.
const resumeHintText =
subagentCount === 1 ? '↩ resumes when subagent finishes' : `↩ resumes when ${subagentCount} subagents finish`
const showResumeHint = !busy && subagentCount > 0 && fits(SEP + stringWidth(resumeHintText))
// Dev-gated readout (HERMES_DEV_CREDITS), lowest priority,
// so it consumes tail budget LAST and drops first on a narrow terminal.
const showDevCredits = !!devCreditsText && fits(SEP + stringWidth(devCreditsText))
// Focus-view badge. Pinned (not tail-budgeted) on purpose: the whole point of
// the indicator is that the user can never be in reduced-output mode without
// seeing it, so it must not drop off a narrow terminal.
const showFocus = !!focusView
const handleSessionCountClick = (event: { stopImmediatePropagation?: () => void }) => {
event.stopImmediatePropagation?.()
onSessionCountClick?.()
}
const sessionCountNode = onSessionCountClick ? (
<Box flexShrink={0} onClick={handleSessionCountClick}>
<Text color={t.color.accent}> │ {sessionCountText}</Text>
</Box>
) : (
<Text color={t.color.muted}> │ {sessionCountText}</Text>
)
return (
<Box height={1}>
<Box flexDirection="row" flexShrink={1} overflow="hidden" width={leftWidth}>
{/* Leading pinned chrome: border + busy face / idle status. When a
notice occupies the slot the status text is dropped — the notice
renders as a separate shrinkable box below so a long notice
ellipsizes instead of crushing model │ ctx (R3-M7). */}
<Box flexDirection="row" flexShrink={0}>
<Text color={t.color.border}>{'─ '}</Text>
{showBattery ? (
<Text color={batteryColorVal}>
{batteryText}
<Text color={t.color.muted}>{' │ '}</Text>
</Text>
) : null}
{busy ? (
<FaceTicker
color={statusColor}
startedAt={turnStartedAt}
style={indicatorStyle}
verbOverride={compacting ? 'compacting' : undefined}
/>
) : showNotice ? null : (
<Text color={statusColor} wrap="truncate-end">
{status}
</Text>
)}
</Box>
{/* Notice slot — the only shrinkable left element (R3-M7). Sits in a
flexShrink={1} box with truncate-end so it yields/ellipsizes
before the pinned model │ ctx box ever clips. */}
{showNotice ? (
<Box flexDirection="row" flexShrink={1} overflow="hidden">
<Text color={noticeColor(notice!.level, t)} wrap="truncate-end">
{notice!.text}
</Text>
</Box>
) : null}
{/* Pinned essentials — model + context never shrink, always visible. */}
<Box flexDirection="row" flexShrink={0}>
{DEV_CREDITS_MODE ? (
<Text color={t.color.warn} wrap="truncate-end">
{' (dev credits)'}
</Text>
) : null}
<Text color={t.color.muted} wrap="truncate-end">
{' │ '}
{modelText}
</Text>
{ctxLabel ? (
<Text color={t.color.muted} wrap="truncate-end">
{' │ '}
{ctxLabel}
</Text>
) : null}
</Box>
{showFocus ? (
<Box flexDirection="row" flexShrink={0}>
<Text color={t.color.muted}>{' │ '}</Text>
<Text color={t.color.warn}>◉ focus</Text>
</Box>
) : null}
{showBar ? (
<Text color={t.color.muted} wrap="truncate-end">
{' │ '}
<Text color={barColor}>[{bar}]</Text> <Text color={barColor}>{pct != null ? `${pct}%` : ''}</Text>
</Text>
) : null}
{showDuration ? (
<Text color={t.color.muted} wrap="truncate-end">
{' │ '}
<SessionDuration startedAt={sessionStartedAt!} />
</Text>
) : null}
{showIdle ? (
<Text color={t.color.muted} wrap="truncate-end">
{' │ '}
<IdleSince endedAt={lastTurnEndedAt!} />
</Text>
) : null}
{showCompressions ? (
<Text color={t.color.muted} wrap="truncate-end">
{' │ '}
<Text color={compressions >= 10 ? t.color.error : compressions >= 5 ? t.color.warn : t.color.muted}>
cmp {compressions}
</Text>
</Text>
) : null}
{showCacheHit ? (
<Text color={t.color.muted} wrap="truncate-end">
{' │ '}
<Text
color={
usage.cache_hit_pct! >= 70
? t.color.statusGood
: usage.cache_hit_pct! >= 40
? t.color.statusWarn
: t.color.muted
}
>
{cacheHitText}
</Text>
</Text>
) : null}
{showLatency ? (
<Text color={t.color.muted} wrap="truncate-end">
{' │ '}
{latencyText}
</Text>
) : null}
{showTps ? (
<Text color={t.color.muted} wrap="truncate-end">
{' │ '}
{tpsText}
</Text>
) : null}
{showVoice ? (
<Text
color={
voiceLabel!.startsWith('●') ? t.color.error : voiceLabel!.startsWith('◉') ? t.color.warn : t.color.muted
}
wrap="truncate-end"
>
{' │ '}
{voiceLabel}
</Text>
) : null}
{showSessionCount ? sessionCountNode : null}
{showBg ? (
<Text color={t.color.muted} wrap="truncate-end">
{' │ '}
{bgCount} bg
</Text>
) : null}
{showSubagents ? (
<Text color={t.color.muted} wrap="truncate-end">
{' │ '}⛓ {subagentCount}
</Text>
) : null}
{showResumeHint ? (
<Text color={t.color.muted} dim wrap="truncate-end">
{' │ '}
{resumeHintText}
</Text>
) : null}
{showDevCredits ? (
<Text color={t.color.accent} wrap="truncate-end">
{' │ '}
{devCreditsText}
</Text>
) : null}
{/* SpawnHud isn't part of the tail budget (its width is dynamic), so it
renders last — any overflow truncates the HUD itself rather than the
budgeted segments before it. It self-hides when no delegation runs. */}
<SpawnHud t={t} />
</Box>
{rightWidth > 0 ? (
<>
<Text color={t.color.border}>{separatorWidth >= 3 ? ' ─ ' : ' '}</Text>
<Box flexShrink={0} width={rightWidth}>
<Text bold={!!sessionTitle} color={sessionTitle ? t.color.accent : t.color.label} wrap="truncate-end">
{rightLabel}
</Text>
</Box>
</>
) : null}
</Box>
)
}
export function FloatBox({ children, color }: { children: ReactNode; color: string }) {
return (
<Box
alignSelf="flex-start"
borderColor={color}
borderStyle="double"
flexDirection="column"
marginTop={1}
opaque
paddingX={1}
>
{children}
</Box>
)
}
export function StickyPromptTracker({ messages, offsets, scrollRef, onChange }: StickyPromptTrackerProps) {
const { atBottom, bottom, top } = useViewportSnapshot(scrollRef)
const text = stickyPromptFromViewport(messages, offsets, top, bottom, atBottom)
useEffect(() => onChange(text), [onChange, text])
return null
}
export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps) {
const [hover, setHover] = useState(false)
const [grab, setGrab] = useState<number | null>(null)
const grabRef = useRef<number | null>(null)
const { scrollHeight: total, top: pos, viewportHeight: vp } = useScrollbarSnapshot(scrollRef)
if (!vp) {
return <Box width={1} />
}
const s = scrollRef.current
const scrollable = total > vp
const thumb = scrollable ? Math.max(1, Math.round((vp * vp) / total)) : vp
const travel = Math.max(1, vp - thumb)
const thumbTop = scrollable ? Math.round((pos / Math.max(1, total - vp)) * travel) : 0
const { thumb: thumbColor, track: trackColor } = scrollbarColors(t, hover, grab !== null)
const jump = (row: number, offset: number) => {
if (!s || !scrollable) {
return
}
s.scrollTo(Math.round((Math.max(0, Math.min(travel, row - offset)) / travel) * Math.max(0, total - vp)))
}
return (
<Box
flexDirection="column"
onMouseDown={(e: { localRow?: number }) => {
const row = Math.max(0, Math.min(vp - 1, e.localRow ?? 0))
const off = row >= thumbTop && row < thumbTop + thumb ? row - thumbTop : Math.floor(thumb / 2)
grabRef.current = off
setGrab(off)
jump(row, off)
}}
onMouseDrag={(e: { localRow?: number }) =>
jump(Math.max(0, Math.min(vp - 1, e.localRow ?? 0)), grabRef.current ?? Math.floor(thumb / 2))
}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
onMouseUp={() => {
grabRef.current = null
setGrab(null)
}}
width={1}
>
{/* Nothing to scroll → draw nothing (the width={1} Box still reserves
the column). Drawn-blank cells composite to a black bar on
transparent terminals — same class as the removed opaque fills. */}
{!scrollable ? null : (
<>
{thumbTop > 0 ? (
<Text color={trackColor}>{`${'│\n'.repeat(Math.max(0, thumbTop - 1))}${thumbTop > 0 ? '│' : ''}`}</Text>
) : null}
{thumb > 0 ? (
<Text color={thumbColor}>{`${'┃\n'.repeat(Math.max(0, thumb - 1))}${thumb > 0 ? '┃' : ''}`}</Text>
) : null}
{vp - thumbTop - thumb > 0 ? (
<Text
color={trackColor}
>{`${'│\n'.repeat(Math.max(0, vp - thumbTop - thumb - 1))}${vp - thumbTop - thumb > 0 ? '│' : ''}`}</Text>
) : null}
</>
)}
</Box>
)
}
interface StatusRuleProps {
battery?: BatteryInfo | null
// Focus view (/focus) badge — display-only reduced-output indicator.
focusView?: boolean
bgCount: number
lastTurnEndedAt?: null | number
liveSessionCount: number
busy: boolean
// Context compaction in progress — FaceTicker freezes on "compacting".
compacting?: boolean
cols: number
cwdLabel: string
model: string
modelFast?: boolean
modelReasoningEffort?: string
indicatorStyle?: IndicatorStyle
notice?: Notice | null
sessionStartedAt?: null | number
sessionTitle?: string
status: string
// display.status_bar.fields — segment visibility filter shared with the
// classic CLI bar. null = defaults (everything shows).
statusBarFields?: null | ReadonlySet<string>
statusColor: string
t: Theme
turnStartedAt?: null | number
usage: Usage
voiceLabel?: string
onSessionCountClick?: () => void
}
interface StickyPromptTrackerProps {
messages: readonly Msg[]
offsets: ArrayLike<number>
onChange: (text: string) => void
scrollRef: RefObject<ScrollBoxHandle | null>
}
interface TranscriptScrollbarProps {
scrollRef: RefObject<ScrollBoxHandle | null>
t: Theme
}
+597
View File
@@ -0,0 +1,597 @@
// Importing the apps barrel registers the reference widget apps at startup.
import '../sdk/apps/index.js'
import { AlternateScreen, Box, NoSelect, ScrollBox, Text } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { Fragment, memo, useEffect, useMemo, useRef } from 'react'
import { useGateway } from '../app/gatewayContext.js'
import type { AppLayoutProps } from '../app/interfaces.js'
import { $isBlocked, $overlayState, patchOverlayState } from '../app/overlayStore.js'
import { $petBox } from '../app/petFlashStore.js'
import { $uiState } from '../app/uiStore.js'
import { usePet } from '../app/usePet.js'
import { INLINE_MODE, SHOW_FPS, TERMUX_TUI_MODE } from '../config/env.js'
import { PLACEHOLDER } from '../content/placeholders.js'
import { prevRenderedMsg } from '../domain/blockLayout.js'
import {
COMPOSER_PROMPT_GAP_WIDTH,
composerPromptWidth,
inputVisualHeight,
stableComposerColumns
} from '../lib/inputMetrics.js'
import { PerfPane } from '../lib/perfPane.js'
import { composerPromptText } from '../lib/prompt.js'
import { ActiveWidgetSlot, AmbientDock, AmbientRail, useAmbientRailWidth } from '../sdk/host.js'
import { AgentsOverlay } from './agentsOverlay.js'
import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js'
import { FloatingOverlays, PromptZone } from './appOverlays.js'
import { Banner, Panel, SessionPanel } from './branding.js'
import { FpsOverlay } from './fpsOverlay.js'
import { HelpHint } from './helpHint.js'
import { Journey } from './journey.js'
import { MessageLine } from './messageLine.js'
import { PetKitty, PetSprite } from './petSprite.js'
import { QueuedMessages } from './queuedMessages.js'
import { LiveTodoPanel, StreamingAssistant } from './streamingAssistant.js'
import { TextInput, type TextInputMouseApi } from './textInput.js'
// Box geometry, kept here so the transcript's reservation math matches the
// rendered overlay exactly.
const PET_BOTTOM = 3 // rows the pet floats above the screen bottom (over the composer)
const PET_PAD_LEFT = 2
const PET_RIGHT = 1
const PET_GUTTER_GAP = 1
const KITTY_PLACEHOLDER = '\u{10eeee}'
// Below this many columns of remaining text width, the right gutter is too
// cramped, so the transcript collapses to reserving bottom rows instead.
const MIN_GUTTER_BODY_COLS = 72
// Petdex mascot — a small floating overlay riding the bottom-right corner just
// above the status bar, with a little top/left breathing room. It reserves no
// layout rows (the transcript scrolls underneath); instead it publishes its
// footprint so the transcript can keep its text clear of it (right gutter on
// wide terminals, reserved bottom rows on narrow ones). Renders nothing unless
// a pet is installed + enabled.
export const PetPane = memo(function PetPane() {
const { enabled, grid, kitty } = usePet()
// Footprint in cells. For kitty we count real placeholder cells (zero-width
// diacritics make string length lie); for half-blocks it's the grid shape.
const { width, height } = useMemo(() => {
if (kitty) {
return {
height: kitty.placeholder.length,
width: Math.max(0, ...kitty.placeholder.map(row => [...row].filter(ch => ch === KITTY_PLACEHOLDER).length))
}
}
if (grid) {
return { height: grid.length, width: Math.max(0, ...grid.map(row => row.length)) }
}
return { height: 0, width: 0 }
}, [grid, kitty])
const active = enabled && width > 0 && height > 0
useEffect(() => {
$petBox.set(
active
? {
// Bottom PET_BOTTOM rows sit over the composer, so the transcript
// only needs to clear the rest in the row-reservation (band) mode.
height: Math.max(0, height - PET_BOTTOM),
width: width + PET_PAD_LEFT + PET_RIGHT + PET_GUTTER_GAP
}
: null
)
return () => $petBox.set(null)
}, [active, height, width])
if (!active) {
return null
}
return (
<NoSelect
bottom={PET_BOTTOM}
flexShrink={0}
paddingLeft={PET_PAD_LEFT}
paddingTop={1}
position="absolute"
right={PET_RIGHT}
>
{kitty ? <PetKitty color={kitty.color} placeholder={kitty.placeholder} /> : null}
{!kitty && grid ? <PetSprite grid={grid} /> : null}
</NoSelect>
)
})
const PromptPrefix = memo(function PromptPrefix({
bold = false,
color,
promptText,
width
}: {
bold?: boolean
color: string
promptText: string
width: number
}) {
const glyphWidth = Math.max(1, width - COMPOSER_PROMPT_GAP_WIDTH)
return (
<Box width={width}>
<Box width={glyphWidth}>
<Text bold={bold} color={color}>
{promptText}
</Text>
</Box>
<Box width={COMPOSER_PROMPT_GAP_WIDTH} />
</Box>
)
})
const TranscriptPane = memo(function TranscriptPane({
actions,
composer,
progress,
transcript
}: Pick<AppLayoutProps, 'actions' | 'composer' | 'progress' | 'transcript'>) {
const ui = useStore($uiState)
const petBox = useStore($petBox)
const railCols = useAmbientRailWidth('left') + useAmbientRailWidth('right')
// Keep transcript text clear of the floating pet, responsively:
// - wide terminals: reserve a right gutter so lines wrap to the pet's left
// (as long as enough width is left for comfortable reading);
// - narrow terminals: keep full width and reserve bottom rows instead, so
// the newest lines sit above the pet rather than getting cramped.
const useGutter = !!petBox && composer.cols - railCols - petBox.width >= MIN_GUTTER_BODY_COLS
const bodyCols = Math.max(28, (useGutter && petBox ? composer.cols - petBox.width : composer.cols) - railCols)
const petBandRows = petBox && !useGutter ? petBox.height : 0
// LiveTodoPanel rides as a child of the latest user-message row so it
// visually belongs to the prompt and follows it during scroll. -1 when
// empty → row.index === -1 is always false → no render.
const lastUserIdx = useMemo(() => {
const items = transcript.historyItems
for (let i = items.length - 1; i >= 0; i--) {
if (items[i].role === 'user') {
return i
}
}
return -1
}, [transcript.historyItems])
// Index of the first user-role message; every later user message gets a
// small dash above it so multi-turn transcripts visually segment by
// turn. -1 when no user message has been sent yet → no separator ever
// renders.
const firstUserIdx = useMemo(
() => transcript.historyItems.findIndex(m => m.role === 'user'),
[transcript.historyItems]
)
return (
<>
<ScrollBox
flexDirection="column"
flexGrow={1}
flexShrink={1}
onClick={(e: { cellIsBlank?: boolean }) => {
if (e.cellIsBlank) {
actions.clearSelection()
}
}}
ref={transcript.scrollRef}
stickyScroll
>
<Box flexDirection="column" paddingX={1}>
{transcript.virtualHistory.topSpacer > 0 ? <Box height={transcript.virtualHistory.topSpacer} /> : null}
{transcript.virtualRows.slice(transcript.virtualHistory.start, transcript.virtualHistory.end).map(row => (
<Box flexDirection="column" key={row.key} ref={transcript.virtualHistory.measureRef(row.key)}>
{row.msg.role === 'user' && firstUserIdx >= 0 && row.index > firstUserIdx && (
<Box marginTop={1}>
<Text color={ui.theme.color.border}>───</Text>
</Box>
)}
{row.msg.kind === 'intro' ? (
<Box flexDirection="column" paddingTop={1}>
<Banner maxWidth={Math.max(1, composer.cols - 2)} t={ui.theme} />
{row.msg.info && (
<SessionPanel
info={row.msg.info}
maxWidth={Math.max(1, composer.cols - 2)}
sid={ui.sid}
t={ui.theme}
/>
)}
</Box>
) : row.msg.kind === 'panel' && row.msg.panelData ? (
<Panel sections={row.msg.panelData.sections} t={ui.theme} title={row.msg.panelData.title} />
) : (
<MessageLine
cols={bodyCols}
compact={ui.compact}
detailsMode={ui.detailsMode}
detailsModeCommandOverride={ui.detailsModeCommandOverride}
msg={row.msg}
prev={prevRenderedMsg(i => transcript.virtualRows[i]?.msg, row.index, {
commandOverride: ui.detailsModeCommandOverride,
detailsMode: ui.detailsMode,
sections: ui.sections
})}
sections={ui.sections}
t={ui.theme}
timestamps={ui.timestamps}
/>
)}
{row.index === lastUserIdx && <LiveTodoPanel />}
</Box>
))}
{transcript.virtualHistory.bottomSpacer > 0 ? <Box height={transcript.virtualHistory.bottomSpacer} /> : null}
<StreamingAssistant
cols={bodyCols}
compact={ui.compact}
detailsMode={ui.detailsMode}
detailsModeCommandOverride={ui.detailsModeCommandOverride}
prevMsg={transcript.historyItems[transcript.historyItems.length - 1]}
progress={progress}
sections={ui.sections}
/>
{/* Narrow terminals: reserve rows so the newest lines sit above the pet. */}
{petBandRows > 0 ? <Box height={petBandRows} /> : null}
</Box>
</ScrollBox>
<NoSelect flexShrink={0} marginLeft={1}>
<TranscriptScrollbar scrollRef={transcript.scrollRef} t={ui.theme} />
</NoSelect>
<StickyPromptTracker
messages={transcript.historyItems}
offsets={transcript.virtualHistory.offsets}
onChange={actions.setStickyPrompt}
scrollRef={transcript.scrollRef}
/>
</>
)
})
const ComposerPane = memo(function ComposerPane({
actions,
composer,
status
}: Pick<AppLayoutProps, 'actions' | 'composer' | 'status'>) {
const ui = useStore($uiState)
const isBlocked = useStore($isBlocked)
const sh = (composer.inputBuf[0] ?? composer.input).startsWith('!')
const promptText = composerPromptText(
ui.theme.brand.prompt,
ui.info?.profile_name,
sh,
TERMUX_TUI_MODE,
composer.cols
)
const promptWidth = composerPromptWidth(promptText)
const promptBlank = ' '.repeat(promptWidth)
const inputColumns = stableComposerColumns(composer.cols, promptWidth, TERMUX_TUI_MODE)
const inputHeight = inputVisualHeight(composer.input, inputColumns)
const inputMouseRef = useRef<null | TextInputMouseApi>(null)
const captureInputDrag = (e: GutterMouseEvent) => {
if (e.button !== 0) {
return
}
e.stopImmediatePropagation?.()
inputMouseRef.current?.startAtBeginning()
}
// Drag origin matches the input box's top-left, so localRow / localCol
// map directly into TextInput coords (after backing out the prompt cell).
const dragFromPromptRow = (e: GutterMouseEvent) => {
if (e.button !== 0) {
return
}
e.stopImmediatePropagation?.()
inputMouseRef.current?.dragAt(e.localRow ?? 0, (e.localCol ?? 0) - promptWidth)
}
// Spacer rows live on a different vertical origin; only the column is
// parent-aligned with the input. Force row=0 so vertical drags can't
// jump the cursor to the wrong wrapped line.
const dragFromSpacer = (e: GutterMouseEvent) => {
if (e.button !== 0) {
return
}
e.stopImmediatePropagation?.()
inputMouseRef.current?.dragAt(0, (e.localCol ?? 0) - promptWidth)
}
const endInputDrag = () => inputMouseRef.current?.end()
return (
<NoSelect
flexDirection="column"
flexShrink={0}
fromLeftEdge
onClick={(e: { cellIsBlank?: boolean }) => {
if (e.cellIsBlank) {
actions.clearSelection()
}
}}
paddingX={1}
>
<QueuedMessages
cols={composer.cols}
queued={composer.queuedDisplay}
queueEditIdx={composer.queueEditIdx}
t={ui.theme}
/>
{ui.bgTasks.size > 0 && (
<Text color={ui.theme.color.muted}>
{ui.bgTasks.size} background {ui.bgTasks.size === 1 ? 'task' : 'tasks'} running
</Text>
)}
{status.showStickyPrompt ? (
<Text color={ui.theme.color.muted} wrap="truncate-end">
<Text color={ui.theme.color.label}>↳ </Text>
{status.stickyPrompt}
</Text>
) : (
<Box height={1} onMouseDown={captureInputDrag} onMouseDrag={dragFromSpacer} onMouseUp={endInputDrag} />
)}
<StatusRulePane at="top" composer={composer} status={status} />
<AmbientDock placement="dock-top" />
<Box flexDirection="column" marginTop={ui.statusBar === 'top' ? 0 : 1} position="relative">
<FloatingOverlays
cols={composer.cols}
compIdx={composer.compIdx}
completions={composer.completions}
onActiveSessionClose={actions.closeLiveSession}
onActiveSessionSelect={actions.activateLiveSession}
onModelSelect={actions.onModelSelect}
onNewLiveSession={actions.newLiveSession}
onNewPromptSession={actions.newPromptSession}
onResumeSelect={actions.resumeById}
pagerPageSize={composer.pagerPageSize}
/>
{composer.input === '?' && !composer.inputBuf.length && <HelpHint t={ui.theme} />}
{!isBlocked && (
<>
{composer.inputBuf.map((line, i) => (
<Box key={i}>
<Box width={promptWidth}>
{i === 0 ? (
<PromptPrefix color={ui.theme.color.muted} promptText={promptText} width={promptWidth} />
) : (
<Text color={ui.theme.color.muted}>{promptBlank}</Text>
)}
</Box>
<Text color={ui.theme.color.text}>{line || ' '}</Text>
</Box>
))}
<Box
onMouseDown={captureInputDrag}
onMouseDrag={dragFromPromptRow}
onMouseUp={endInputDrag}
position="relative"
width={Math.max(1, composer.cols - 2)}
>
<Box width={promptWidth}>
{sh ? (
<PromptPrefix color={ui.theme.color.shellDollar} promptText={promptText} width={promptWidth} />
) : composer.inputBuf.length ? (
<Text color={ui.theme.color.prompt}>{promptBlank}</Text>
) : (
<PromptPrefix bold color={ui.theme.color.prompt} promptText={promptText} width={promptWidth} />
)}
</Box>
<Box flexGrow={0} flexShrink={0} height={inputHeight} width={inputColumns}>
{/* Reserve the transcript scrollbar gutter too so typing never rewraps when the scrollbar column repaints. */}
<TextInput
accentColor={ui.theme.color.accent}
color={ui.theme.color.text}
columns={inputColumns}
mouseApiRef={inputMouseRef}
onChange={composer.updateInput}
onPaste={composer.handleTextPaste}
onSubmit={composer.submit}
placeholder={composer.empty ? PLACEHOLDER : ui.busy ? 'Ctrl+C to interrupt…' : ''}
// Exactly the "(and N more toolsets…)" tone. `muted` is a
// MID-luminance family tone, so it reads receded on both
// poles even when polarity detection is wrong (transparent
// terminals lie about their background); anything blended
// toward the resolved surface inherits that wrong polarity.
placeholderColor={ui.theme.color.muted}
value={composer.input}
voiceRecordKey={composer.voiceRecordKey}
/>
</Box>
<Box position="absolute" right={0}>
<GoodVibesHeart t={ui.theme} tick={status.goodVibesTick} />
</Box>
</Box>
</>
)}
</Box>
{!composer.empty && !ui.sid && <Text color={ui.theme.color.muted}>⚕ {ui.status}</Text>}
<AmbientDock placement="dock-bottom" />
<StatusRulePane at="bottom" composer={composer} status={status} />
</NoSelect>
)
})
const AgentsOverlayPane = memo(function AgentsOverlayPane() {
const { gw } = useGateway()
const ui = useStore($uiState)
const overlay = useStore($overlayState)
return (
<AgentsOverlay
gw={gw}
initialHistoryIndex={overlay.agentsInitialHistoryIndex}
onClose={() => patchOverlayState({ agents: false, agentsInitialHistoryIndex: 0 })}
t={ui.theme}
/>
)
})
const JourneyPane = memo(function JourneyPane() {
const { gw } = useGateway()
const ui = useStore($uiState)
return <Journey gw={gw} onClose={() => patchOverlayState({ journey: false })} t={ui.theme} />
})
const StatusRulePane = memo(function StatusRulePane({
at,
composer,
status
}: Pick<AppLayoutProps, 'composer' | 'status'> & { at: 'bottom' | 'top' }) {
const ui = useStore($uiState)
if (ui.statusBar !== at) {
return null
}
return (
<Box marginTop={at === 'top' ? 1 : 0}>
<StatusRule
battery={ui.battery ? ui.batteryStatus : null}
bgCount={ui.bgTasks.size}
busy={ui.busy}
cols={composer.cols}
compacting={ui.compacting}
cwdLabel={status.cwdLabel}
focusView={ui.focusView}
indicatorStyle={ui.indicatorStyle}
lastTurnEndedAt={status.lastTurnEndedAt}
liveSessionCount={ui.liveSessionCount}
model={ui.info?.model ?? ''}
modelFast={ui.info?.fast || ui.info?.service_tier === 'priority'}
modelReasoningEffort={ui.info?.reasoning_effort}
notice={ui.notice}
onSessionCountClick={() => patchOverlayState({ sessions: true })}
sessionStartedAt={status.sessionStartedAt}
sessionTitle={status.sessionTitle}
status={ui.status}
statusBarFields={ui.statusBarFields}
statusColor={status.statusColor}
t={ui.theme}
turnStartedAt={status.turnStartedAt}
usage={ui.usage}
voiceLabel={status.voiceLabel}
/>
</Box>
)
})
export const AppLayout = memo(function AppLayout({
actions,
composer,
mouseTracking,
progress,
status,
transcript
}: AppLayoutProps) {
const overlay = useStore($overlayState)
const ui = useStore($uiState)
// Inline mode skips AlternateScreen so the host terminal's native
// scrollback captures rows scrolled off the top; composer + progress
// stay anchored via normal flex-column flow.
const Shell = INLINE_MODE ? Fragment : AlternateScreen
const shellProps = INLINE_MODE ? {} : { mouseTracking }
return (
<Shell {...shellProps}>
<Box flexDirection="column" flexGrow={1} position="relative">
<Box flexDirection="row" flexGrow={1}>
{!overlay.agents && !overlay.journey && <AmbientRail side="left" />}
{overlay.agents ? (
<PerfPane id="agents">
<AgentsOverlayPane />
</PerfPane>
) : overlay.journey ? (
<PerfPane id="journey">
<JourneyPane />
</PerfPane>
) : (
<PerfPane id="transcript">
<TranscriptPane actions={actions} composer={composer} progress={progress} transcript={transcript} />
</PerfPane>
)}
{!overlay.agents && !overlay.journey && <AmbientRail side="right" />}
</Box>
{!overlay.agents && !overlay.journey && (
<>
<PerfPane id="prompt">
<PromptZone
cols={composer.cols}
onApprovalChoice={actions.answerApproval}
onClarifyAnswer={actions.answerClarify}
onClarifyQuestionAnswer={actions.answerClarifyQuestion}
onSecretSubmit={actions.answerSecret}
onSudoSubmit={actions.answerSudo}
/>
</PerfPane>
<PerfPane id="composer">
<ComposerPane actions={actions} composer={composer} status={status} />
</PerfPane>
{SHOW_FPS && (
<Box flexShrink={0} justifyContent="flex-end" paddingRight={1}>
<FpsOverlay t={ui.theme} />
</Box>
)}
</>
)}
{!overlay.agents && <PetPane />}
</Box>
<ActiveWidgetSlot />
</Shell>
)
})
type GutterMouseEvent = {
button: number
localCol?: number
localRow?: number
stopImmediatePropagation?: () => void
}
+389
View File
@@ -0,0 +1,389 @@
import { Box, stringWidth, Text } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import type { ReactNode } from 'react'
import { useGateway } from '../app/gatewayContext.js'
import type { AppOverlaysProps } from '../app/interfaces.js'
import { $overlayState, hasFloatingPanel, patchOverlayState } from '../app/overlayStore.js'
import { $uiSessionId, $uiTheme } from '../app/uiStore.js'
import { ActiveSessionSwitcher } from './activeSessionSwitcher.js'
import { FloatBox } from './appChrome.js'
import { BillingOverlay } from './billingOverlay.js'
import { MaskedPrompt } from './maskedPrompt.js'
import { ModelPicker } from './modelPicker.js'
import { OverlayHint } from './overlayControls.js'
import { listRowStyle } from './overlayPrimitives.js'
import { PetPicker } from './petPicker.js'
import { PluginsHub } from './pluginsHub.js'
import { ApprovalPrompt, ClarifyPrompt, ConfirmPrompt } from './prompts.js'
import { SkillsHub } from './skillsHub.js'
import { SubscriptionOverlay } from './subscriptionOverlay.js'
import { WidgetGrid, type WidgetGridWidget } from './widgetGrid.js'
const COMPLETION_WINDOW = 16
/**
* A prompt hosted in a single-cell WidgetGrid with the classic 1-cell padding.
* The inner full-width column restores the horizontal stretch the old plain
* padded Box gave its child, so rendering is identical; routing through the
* grid makes the prompt zone a layout-engine surface like the desktop app's
* pane shell.
*/
function PromptCell({ children, cols, id }: { children: ReactNode; cols: number; id: string }) {
return (
<Box flexDirection="column" flexShrink={0}>
<WidgetGrid
cols={cols}
columns={1}
gap={0}
paddingX={1}
paddingY={1}
rowGap={0}
widgets={[
{
children: (
<Box flexDirection="column" width="100%">
{children}
</Box>
),
id
}
]}
/>
</Box>
)
}
export function PromptZone({
cols,
onApprovalChoice,
onClarifyAnswer,
onClarifyQuestionAnswer,
onSecretSubmit,
onSudoSubmit
}: Pick<
AppOverlaysProps,
'cols' | 'onApprovalChoice' | 'onClarifyAnswer' | 'onClarifyQuestionAnswer' | 'onSecretSubmit' | 'onSudoSubmit'
>) {
const overlay = useStore($overlayState)
const theme = useStore($uiTheme)
if (overlay.approval) {
return (
<PromptCell cols={cols} id="approval">
<ApprovalPrompt cols={cols} onChoice={onApprovalChoice} req={overlay.approval} t={theme} />
</PromptCell>
)
}
if (overlay.billing) {
const current = overlay.billing
const onPatch = (next: Partial<typeof current>) =>
patchOverlayState(prev => (prev.billing ? { ...prev, billing: { ...prev.billing, ...next } } : prev))
const onClose = () => patchOverlayState({ billing: null })
return (
<PromptCell cols={cols} id="billing">
<BillingOverlay onClose={onClose} onPatch={onPatch} overlay={current} t={theme} />
</PromptCell>
)
}
if (overlay.subscription) {
const current = overlay.subscription
const onPatch = (next: Partial<typeof current>) =>
patchOverlayState(prev =>
prev.subscription ? { ...prev, subscription: { ...prev.subscription, ...next } } : prev
)
const onClose = () => patchOverlayState({ subscription: null })
return (
<PromptCell cols={cols} id="subscription">
<SubscriptionOverlay onClose={onClose} onPatch={onPatch} overlay={current} t={theme} />
</PromptCell>
)
}
if (overlay.confirm) {
const req = overlay.confirm
const onConfirm = () => {
patchOverlayState({ confirm: null })
req.onConfirm()
}
const onCancel = () => patchOverlayState({ confirm: null })
return (
<PromptCell cols={cols} id="confirm">
<ConfirmPrompt onCancel={onCancel} onConfirm={onConfirm} req={req} t={theme} />
</PromptCell>
)
}
if (overlay.clarify) {
return (
<PromptCell cols={cols} id="clarify">
<ClarifyPrompt
cols={cols}
onAnswer={onClarifyAnswer}
onCancel={() => onClarifyAnswer('')}
onQuestionAnswer={onClarifyQuestionAnswer}
req={overlay.clarify}
t={theme}
/>
</PromptCell>
)
}
if (overlay.sudo) {
return (
<PromptCell cols={cols} id="sudo">
<MaskedPrompt cols={cols} icon="🔐" label="sudo password required" onSubmit={onSudoSubmit} t={theme} />
</PromptCell>
)
}
if (overlay.secret) {
return (
<PromptCell cols={cols} id="secret">
<MaskedPrompt
cols={cols}
icon="🔑"
label={overlay.secret.prompt}
onSubmit={onSecretSubmit}
sub={`for ${overlay.secret.envVar}`}
t={theme}
/>
</PromptCell>
)
}
return null
}
export function FloatingOverlays({
cols,
compIdx,
completions,
onActiveSessionSelect,
onActiveSessionClose,
onModelSelect,
onNewLiveSession,
onNewPromptSession,
onResumeSelect,
pagerPageSize
}: Pick<
AppOverlaysProps,
| 'cols'
| 'compIdx'
| 'completions'
| 'onActiveSessionSelect'
| 'onActiveSessionClose'
| 'onModelSelect'
| 'onNewLiveSession'
| 'onNewPromptSession'
| 'onResumeSelect'
| 'pagerPageSize'
>) {
const { gw } = useGateway()
const overlay = useStore($overlayState)
const sid = useStore($uiSessionId)
const theme = useStore($uiTheme)
const hasAny = hasFloatingPanel(overlay) || completions.length
if (!hasAny) {
return null
}
// Fixed viewport centered on compIdx — previously the slice end was
// compIdx + 8 so the dropdown grew from 8 rows to 16 as the user scrolled
// down, bouncing the height on every keystroke.
const viewportSize = Math.min(COMPLETION_WINDOW, completions.length)
const start = Math.max(0, Math.min(compIdx - Math.floor(COMPLETION_WINDOW / 2), completions.length - viewportSize))
// Every floating panel is a widget in a single-column grid. Panels keep
// their intrinsic (content-hugging) widths inside full-width cells today;
// multi-column tiling on wide terminals is a `columns`/track change here,
// not a rewrite. `maxWidth` hands each panel its cell budget — with one
// column it never binds, so rendering is identical to the pre-grid layout.
const widgets: WidgetGridWidget[] = []
if (overlay.sessions) {
widgets.push({
id: 'sessions',
render: width => (
<FloatBox color={theme.color.border}>
<ActiveSessionSwitcher
currentSessionId={sid}
gw={gw}
maxWidth={width}
onCancel={() => patchOverlayState({ sessions: false })}
onClose={onActiveSessionClose}
onNew={onNewLiveSession}
onNewPrompt={onNewPromptSession}
onResume={onResumeSelect}
onSelect={onActiveSessionSelect}
t={theme}
/>
</FloatBox>
)
})
}
if (overlay.modelPicker) {
const initialRefresh = typeof overlay.modelPicker === 'object' && overlay.modelPicker.refresh === true
widgets.push({
id: 'model-picker',
render: width => (
<FloatBox color={theme.color.border}>
<ModelPicker
gw={gw}
initialRefresh={initialRefresh}
maxWidth={width}
onCancel={() => patchOverlayState({ modelPicker: false })}
onSelect={onModelSelect}
sessionId={sid}
t={theme}
/>
</FloatBox>
)
})
}
if (overlay.petPicker) {
widgets.push({
id: 'pet-picker',
render: width => (
<FloatBox color={theme.color.border}>
<PetPicker gw={gw} maxWidth={width} onClose={() => patchOverlayState({ petPicker: false })} t={theme} />
</FloatBox>
)
})
}
if (overlay.skillsHub) {
widgets.push({
id: 'skills-hub',
render: width => (
<FloatBox color={theme.color.border}>
<SkillsHub gw={gw} maxWidth={width} onClose={() => patchOverlayState({ skillsHub: false })} t={theme} />
</FloatBox>
)
})
}
if (overlay.pluginsHub) {
widgets.push({
id: 'plugins-hub',
render: width => (
<FloatBox color={theme.color.border}>
<PluginsHub gw={gw} maxWidth={width} onClose={() => patchOverlayState({ pluginsHub: false })} t={theme} />
</FloatBox>
)
})
}
const pager = overlay.pager
if (pager) {
widgets.push({
id: 'pager',
render: () => (
<FloatBox color={theme.color.border}>
<Box flexDirection="column" paddingX={1} paddingY={1}>
{pager.title && (
<Box justifyContent="center" marginBottom={1}>
<Text bold color={theme.color.primary}>
{pager.title}
</Text>
</Box>
)}
{pager.lines.slice(pager.offset, pager.offset + pagerPageSize).map((line, i) => (
<Text key={i}>{line}</Text>
))}
<Box marginTop={1}>
<OverlayHint t={theme}>
{pager.offset + pagerPageSize < pager.lines.length
? `↑↓/jk line · Enter/Space/PgDn page · b/PgUp back · g/G top/bottom · Esc/q close (${Math.min(pager.offset + pagerPageSize, pager.lines.length)}/${pager.lines.length})`
: `end · ↑↓/jk · b/PgUp back · g top · Esc/q close (${pager.lines.length} lines)`}
</OverlayHint>
</Box>
</Box>
</FloatBox>
)
})
}
if (completions.length) {
widgets.push({
id: 'completions',
render: () => (
<FloatBox color={theme.color.primary}>
{/* No painted panel fill: FloatBox is `opaque`, so rows sit on the
terminal's own background — the one color that is always right
on a canvas we don't own (a full completionBg fill was the lone
surface painting its own background, which is why it could
disagree with every other overlay). Only the ACTIVE row carries
a selection chip, mirroring the session switcher. */}
<Box flexDirection="column" width={Math.max(28, cols - 6)}>
{(() => {
const visible = completions.slice(start, start + viewportSize)
// Two-column grid: the name track auto-sizes to the widest
// visible command, so descriptions align — and wrapped
// description lines stay inside their own column instead of
// running under the names.
const nameW = Math.max(...visible.map(item => stringWidth(item.display))) + 2
return visible.map((item, i) => {
const active = start + i === compIdx
const row = listRowStyle(theme, active)
return (
<Box
backgroundColor={row.backgroundColor}
flexDirection="row"
key={`${start + i}:${item.text}:${item.display}:${item.meta ?? ''}`}
width="100%"
>
<Box flexShrink={0} width={nameW}>
<Text bold color={theme.color.label}>
{' '}
{item.display}
</Text>
</Box>
{item.meta ? (
// Descriptions in the neutral gray, NOT a gold-family
// tone — label vs muted are near-twins on some skins,
// which made command and description read as one run.
// Active row: meta rides the chip, so it uses row ink.
<Text backgroundColor={row.backgroundColor} color={active ? row.color : theme.color.statusFg}>
{item.meta}
</Text>
) : null}
</Box>
)
})
})()}
</Box>
</FloatBox>
)
})
}
return (
<Box alignItems="flex-start" bottom="100%" flexDirection="column" left={0} position="absolute" right={0}>
<WidgetGrid cols={cols} columns={1} gap={0} paddingX={0} paddingY={0} rowGap={0} widgets={widgets} />
</Box>
)
}
+950
View File
@@ -0,0 +1,950 @@
import { randomUUID } from 'node:crypto'
import { Box, Text, useInput } from '@hermes/ink'
import { useRef, useState } from 'react'
import type { BillingOverlayState } from '../app/interfaces.js'
import type { BillingStateResponse } from '../gatewayTypes.js'
import type { Theme } from '../theme.js'
import { ActionRow, footer, MenuRow, type MenuRowSpec, UsageBars, useMenu } from './overlayPrimitives.js'
import { TextInput } from './textInput.js'
interface BillingOverlayProps {
/** Replace the overlay slot (screen transitions + pending data). */
onPatch: (next: Partial<BillingOverlayState>) => void
/** Close the overlay entirely. */
onClose: () => void
overlay: BillingOverlayState
t: Theme
}
function autoReloadLine(s: BillingStateResponse): null | string {
if (!s.auto_reload) {
return null
}
return s.auto_reload.enabled
? `Auto-reload: on (below ${s.auto_reload.threshold_display} → ${s.auto_reload.reload_to_display})`
: 'Auto-reload: off'
}
/**
* The /billing modal. A self-contained state machine:
* overview → buy | autoreload | limit (and buy → confirm).
* Esc from a sub-screen returns to overview; Esc from overview closes.
* All RPCs + error mapping live in billing.ts and are reached through
* `overlay.ctx` — this component only renders + routes keys.
*/
export function BillingOverlay({ onClose, onPatch, overlay, t }: BillingOverlayProps) {
const { ctx, screen, state: s } = overlay
return (
<Box borderColor={t.color.accent} borderStyle="round" flexDirection="column" paddingX={1}>
{screen === 'overview' && <OverviewScreen ctx={ctx} onClose={onClose} onPatch={onPatch} s={s} t={t} />}
{screen === 'buy' && <BuyScreen ctx={ctx} onClose={onClose} onPatch={onPatch} s={s} t={t} />}
{screen === 'confirm' && (
<ConfirmScreen
amount={overlay.pendingCharge?.amount ?? ''}
ctx={ctx}
idempotencyKey={overlay.pendingCharge?.idempotencyKey}
onBack={() => onPatch({ pendingCharge: null, screen: 'buy' })}
onClose={onClose}
onPatch={onPatch}
s={s}
t={t}
/>
)}
{screen === 'autoreload' && <AutoReloadScreen ctx={ctx} onClose={onClose} onPatch={onPatch} s={s} t={t} />}
{screen === 'limit' && <LimitScreen ctx={ctx} onClose={onClose} onPatch={onPatch} s={s} t={t} />}
{screen === 'stepup' && (
<StepUpScreen
amount={overlay.pendingCharge?.amount ?? ''}
ctx={ctx}
idempotencyKey={overlay.pendingCharge?.idempotencyKey}
onClose={onClose}
t={t}
/>
)}
</Box>
)
}
// ── Screen 1: Overview ────────────────────────────────────────────────
interface ScreenProps {
ctx: BillingOverlayState['ctx']
onClose: () => void
onPatch: (next: Partial<BillingOverlayState>) => void
s: BillingStateResponse
t: Theme
}
function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) {
// Full charge menu only for an admin with the org kill-switch on; otherwise it
// collapses to Manage-on-portal / Close + a one-line note. NOTE: this is the
// ORG-level gate (cli_billing_enabled), NOT the per-terminal remote spending scope —
// that's discovered reactively at pay time (a charge 403s insufficient_scope
// and the confirm screen routes into the resumable step-up). We deliberately
// do NOT preflight the scope here.
const full = s.is_admin && s.cli_billing_enabled
const note = !s.is_admin
? 'Billing actions need someone with billing permissions (owner, admin, or finance admin).'
: !s.cli_billing_enabled
? "Remote spending is off for this org — a billing admin can turn it on from the portal's Hermes Agent page."
: null
// Always show the full billing menu for an admin/billing-on org — a missing
// card does NOT mean nothing can be done (the org may already have balance,
// auto-reload, a limit). The card only matters at CHARGE time: with no card
// on file, "Add funds" opens the guided add-card path (portal + check-again)
// instead of an amount picker that would 403 no_payment_method.
const items = full
? ['Add funds', 'Auto-reload', 'Monthly limit', 'Manage on portal', 'Cancel']
: ['Manage on portal', 'Cancel']
const choose = (i: number) => {
if (full) {
if (i === 0) {
onPatch({ screen: 'buy' })
} else if (i === 1) {
onPatch({ screen: 'autoreload' })
} else if (i === 2) {
onPatch({ screen: 'limit' })
} else {
if (i === 3 && s.portal_url) {
ctx.openPortal(s.portal_url)
}
onClose()
}
return
}
if (i === 0 && s.portal_url) {
ctx.openPortal(s.portal_url)
}
onClose()
}
const rows: MenuRowSpec[] = items.map((label, i) => ({ label, run: () => choose(i) }))
const sel = useMenu(rows, onClose)
const auto = autoReloadLine(s)
// Balance leads, in the title — the first thing seen (review feedback).
const title = `Top up · balance ${s.balance_display}`
return (
<Box flexDirection="column">
<Text bold color={t.color.accent}>
{title}
</Text>
{s.org_name && (
<Text color={t.color.muted}>
Org: {s.org_name}
{s.role ? ` · ${s.role}` : ''}
</Text>
)}
{/* The shared two-bar dollar usage (plan + top-up), same as /usage and
/subscription. Renders nothing when no usage model is available. */}
<UsageBars model={s.usage} t={t} />
{auto && <Text color={t.color.muted}>{auto}</Text>}
{/* Card presence at a glance: which card a charge would use (with why —
"the card on your subscription"), or that none is saved. Only for the
full menu — members/billing-off get the portal note instead. */}
{full && (
<Text color={t.color.muted}>
{s.card
? `Card: ${s.card.display ?? s.card.masked}`
: 'No saved card on file — “Add funds” walks you through adding one.'}
</Text>
)}
{note && (
<Box marginTop={1}>
<Text color={t.color.warn}>{note}</Text>
</Box>
)}
<Text />
{items.map((label, i) => (
<MenuRow active={sel === i} index={i + 1} key={label} label={label} t={t} />
))}
<Text />
{footer(`↑/↓ select · 1-${items.length} quick pick · Enter confirm · Esc close`, t)}
</Box>
)
}
// ── Screen 2: Buy credits ─────────────────────────────────────────────
function BuyScreen({ ctx, onPatch, s, t }: ScreenProps) {
const presets = s.charge_presets_display
const rawPresets = s.charge_presets
// No card on file → the buy screen becomes the ADD-CARD path: cards are added
// on the portal (never in-terminal), and "check again" re-fetches state so the
// flow continues right here once the card is saved. Card present → the normal
// preset menu. (The card display is best-effort server-side, so "check again"
// also recovers a transient miss.)
const noCard = !s.card
const rows = noCard
? ['Add a card on the portal', 'I’ve added it — check again', 'Back']
: [...presets, 'Custom amount…', 'Cancel']
const customIdx = presets.length
const [sel, setSel] = useState(0)
const [typing, setTyping] = useState(false)
const [custom, setCustom] = useState('')
const [error, setError] = useState<null | string>(null)
const [checking, setChecking] = useState(false)
// Synchronous guard: double-Enter on "check again" must not stack re-fetches.
const checkingRef = useRef(false)
const recheck = () => {
if (checkingRef.current) {
return
}
checkingRef.current = true
setChecking(true)
void ctx.refreshState().then(fresh => {
checkingRef.current = false
setChecking(false)
if (!fresh) {
return setError('Could not refresh billing state — try again in a moment.')
}
setError(null)
// Re-render with the fresh state: if the card is now on file, this same
// screen flips into the preset menu and the purchase continues here.
onPatch({ state: fresh })
if (fresh.card) {
ctx.sys(`✓ Card found: ${fresh.card.display ?? fresh.card.masked} — pick an amount.`)
} else {
ctx.sys('Still no card on file — finish adding it on the portal, then check again.')
}
})
}
const toConfirm = (amount: string) => {
// Mint the idempotency key here (purchase identity = this amount). It rides
// pendingCharge into Confirm AND the step-up replay, so a retried charge
// dedups server-side; a fresh amount selection gets a fresh key.
onPatch({ pendingCharge: { amount, idempotencyKey: randomUUID() }, screen: 'confirm' })
}
const pickPreset = (i: number) => {
// Prefer the raw (numeric) preset for the amount; fall back to stripping $.
const raw = (rawPresets[i] ?? presets[i] ?? '').replace(/^\$/, '').trim()
const v = ctx.validate(raw)
if (v.error || !v.amount) {
setError(v.error ?? 'Invalid preset.')
return
}
toConfirm(v.amount)
}
const submitCustom = (raw: string) => {
const v = ctx.validate(raw)
if (v.error || !v.amount) {
setError(v.error ?? 'Invalid amount.')
return
}
toConfirm(v.amount)
}
const choose = (i: number) => {
if (noCard) {
if (i === 0) {
if (s.portal_url) {
ctx.openPortal(s.portal_url)
ctx.sys('Add a card on the billing page, then come back and pick “check again”.')
} else {
setError('Could not build the portal link — is your portal configured?')
}
return
}
if (i === 1) {
return recheck()
}
return onPatch({ screen: 'overview' })
}
if (i < presets.length) {
pickPreset(i)
} else if (i === customIdx) {
setError(null)
setTyping(true)
} else {
onPatch({ screen: 'overview' })
}
}
useInput((ch, key) => {
if (key.escape) {
return typing ? (setTyping(false), setError(null)) : onPatch({ screen: 'overview' })
}
if (typing) {
return
}
if (key.upArrow && sel > 0) {
setSel(v => v - 1)
}
if (key.downArrow && sel < rows.length - 1) {
setSel(v => v + 1)
}
if (key.return) {
return choose(Math.min(sel, rows.length - 1))
}
const n = parseInt(ch, 10)
if (n >= 1 && n <= rows.length) {
return choose(n - 1)
}
})
// sel can go stale when a refresh flips the row set (3 add-card rows ↔ N
// preset rows) — clamp for render + Enter.
const cSel = Math.min(sel, rows.length - 1)
const payLine = s.card ? `Payment: ${s.card.display ?? s.card.masked}` : 'No saved card on file'
if (typing) {
return (
<Box flexDirection="column">
<Text bold color={t.color.accent}>
Add funds
</Text>
<Text color={t.color.muted}>{payLine}</Text>
<Text />
<Text color={t.color.label}>Enter a custom amount:</Text>
<Box>
<Text color={t.color.label}>{'$'}</Text>
<TextInput color={t.color.text} columns={20} onChange={setCustom} onSubmit={submitCustom} value={custom} />
</Box>
{error && <Text color={t.color.error}>{error}</Text>}
<Text />
{footer('Enter confirm · Esc back', t)}
</Box>
)
}
if (noCard) {
return (
<Box flexDirection="column">
<Text bold color={t.color.accent}>
Add funds
</Text>
<Text color={t.color.text}>No saved card on file.</Text>
<Text color={t.color.muted}>
Add a card once on the portal billing page — after that you can top up right from the terminal.
</Text>
<Text />
{rows.map((label, i) => (
<MenuRow active={cSel === i} index={i + 1} key={label} label={label} t={t} />
))}
{error && <Text color={t.color.error}>{error}</Text>}
<Text />
{footer(
checking ? 'Checking for a card…' : `↑/↓ select · 1-${rows.length} quick pick · Enter confirm · Esc back`,
t
)}
</Box>
)
}
return (
<Box flexDirection="column">
<Text bold color={t.color.accent}>
Add funds
</Text>
<Text color={t.color.muted}>{payLine}</Text>
<Text />
{rows.map((label, i) => (
<MenuRow active={cSel === i} index={i + 1} key={label} label={label} t={t} />
))}
{error && <Text color={t.color.error}>{error}</Text>}
<Text />
{footer(`↑/↓ select · 1-${rows.length} quick pick · Enter confirm · Esc back`, t)}
</Box>
)
}
// ── Screen 3: Confirm purchase ────────────────────────────────────────
function ConfirmScreen({
amount,
ctx,
idempotencyKey,
onBack,
onClose,
onPatch,
s,
t
}: {
amount: string
ctx: BillingOverlayState['ctx']
idempotencyKey?: string
onBack: () => void
onClose: () => void
onPatch: (next: Partial<BillingOverlayState>) => void
s: BillingStateResponse
t: Theme
}) {
// rows: Pay $X now / Cancel
const [sel, setSel] = useState(0)
const [submitting, setSubmitting] = useState(false)
// Synchronous guard: two key events can both observe `submitting === false`
// before React commits the state update, double-firing the charge (and the
// gateway mints a fresh idempotency key per call → two charges).
const submittingRef = useRef(false)
const pay = () => {
if (submittingRef.current || submitting) {
return
}
submittingRef.current = true
setSubmitting(true)
void ctx.charge(amount, idempotencyKey).then(outcome => {
if (outcome === 'needs_remote_spending') {
// Resumable step-up: keep the modal MOUNTED, switch to the stepup
// screen (which holds pendingCharge.amount for the post-grant replay).
onPatch({ screen: 'stepup' })
return
}
// submitted (settlement reported via transcript) or error (already
// surfaced) → close the overlay. The transcript carries the outcome.
onClose()
})
}
const back = () => onBack()
useInput((ch, key) => {
if (key.escape) {
return back()
}
const lower = ch.toLowerCase()
if (lower === 'y') {
return pay()
}
if (lower === 'n') {
return back()
}
if (key.upArrow) {
setSel(0)
}
if (key.downArrow) {
setSel(1)
}
if (key.return) {
return sel === 0 ? pay() : back()
}
})
const payLine = s.card ? `Payment: ${s.card.display ?? s.card.masked}` : 'No saved card on file'
return (
<Box flexDirection="column">
<Text bold color={t.color.accent}>
Confirm purchase
</Text>
<Text color={t.color.text}>Total: ${amount}</Text>
<Text color={t.color.muted}>{payLine}</Text>
{/* Provenance-less payloads (older NAS) keep the generic line; when the
resolver says WHY this card, payLine already carries it. */}
{s.card && !s.card.resolved_via && (
<Text color={t.color.muted}>Your card saved on the portal will be charged.</Text>
)}
<Text color={t.color.muted}>By confirming, you allow Nous Research to charge your card.</Text>
<Text />
<ActionRow active={sel === 0} color={t.color.ok} label={`Pay $${amount} now`} t={t} />
<ActionRow active={sel === 1} label="Cancel" t={t} />
<Text />
{footer('↑/↓ select · Enter confirm · Y/N quick · Esc back', t)}
</Box>
)
}
// ── Screen: Step-up (resumable "Allow Remote Spending") ─────────────
// Reached ONLY when a charge returns insufficient_scope — there is no preflight
// or scope check anywhere; the buy path discovers it reactively. The modal stays
// MOUNTED through the browser device-flow:
// prompt (heads-up) → waiting (browser authorize) → granted (press Enter to
// resume) → replay the held charge (pendingCharge.amount) → settle → close.
// Never leaks the raw billing:manage scope — the user-facing concept is
// "Remote Spending".
function StepUpScreen({
amount,
ctx,
idempotencyKey,
onClose,
t
}: {
amount: string
ctx: BillingOverlayState['ctx']
idempotencyKey?: string
onClose: () => void
t: Theme
}) {
const [sel, setSel] = useState(0)
const [phase, setPhase] = useState<'granted' | 'prompt' | 'resuming' | 'waiting'>('prompt')
const allow = () => {
if (phase !== 'prompt') {
return
}
setPhase('waiting')
ctx.sys('Opening your browser to allow Remote Spending…')
void ctx.requestRemoteSpending().then(granted => {
if (!granted) {
ctx.sys(
"! Couldn't allow Remote Spending — someone with billing permissions (owner, admin, or finance admin) has to approve it. Your card was not charged."
)
onClose()
return
}
// Granted → hold here and wait for an explicit Enter to resume the held
// purchase (the reassuring "you're back, press Enter" beat).
setPhase('granted')
})
}
const resume = () => {
if (phase !== 'granted') {
return
}
setPhase('resuming')
ctx.sys('✓ Remote Spending allowed — resuming your purchase.')
void ctx.charge(amount, idempotencyKey).then(outcome => {
// If the replay STILL can't spend (grant raced/expired or downscoped),
// say so — don't close on a reassuring line with no charge made.
if (outcome === 'needs_remote_spending') {
ctx.sys('! Remote Spending still needs approval — run /topup to try again. Your card was not charged.')
}
onClose()
})
}
const decline = () => {
ctx.sys('No charge made. Run /topup when you want to allow Remote Spending.')
onClose()
}
useInput((ch, key) => {
if (phase === 'waiting' || phase === 'resuming') {
// While the device flow / replay runs, only Esc (give up) is live.
if (key.escape) {
onClose()
}
return
}
if (phase === 'granted') {
// Back from the browser — Enter resumes, Esc abandons.
if (key.escape) {
return onClose()
}
if (key.return) {
return resume()
}
return
}
// phase === 'prompt'
if (key.escape) {
return decline()
}
const lower = ch.toLowerCase()
if (lower === 'y') {
return allow()
}
if (lower === 'n') {
return decline()
}
if (key.upArrow) {
setSel(0)
}
if (key.downArrow) {
setSel(1)
}
if (key.return) {
return sel === 0 ? allow() : decline()
}
})
if (phase === 'waiting') {
return (
<Box flexDirection="column">
<Text bold color={t.color.accent}>
Allow Remote Spending
</Text>
<Text color={t.color.warn}>Waiting for your browser…</Text>
<Text color={t.color.muted}>Approve in the page that just opened.</Text>
<Text color={t.color.muted}>Your ${amount} top-up is held here and resumes when you&apos;re done.</Text>
<Text />
{footer('Esc cancel', t)}
</Box>
)
}
if (phase === 'granted') {
return (
<Box flexDirection="column">
<Text bold color={t.color.ok}>
Remote Spending allowed
</Text>
<Text color={t.color.text}>Your ${amount} top-up is ready to finish.</Text>
<Text />
<ActionRow active color={t.color.ok} label="Press Enter to resume" t={t} />
<Text />
{footer('Enter resume · Esc cancel', t)}
</Box>
)
}
if (phase === 'resuming') {
return (
<Box flexDirection="column">
<Text bold color={t.color.accent}>
Allow Remote Spending
</Text>
<Text color={t.color.muted}>Resuming your ${amount} top-up…</Text>
<Text />
{footer('Esc cancel', t)}
</Box>
)
}
// phase === 'prompt' — the one heads-up, triggered only by the 403.
return (
<Box flexDirection="column">
<Text bold color={t.color.warn}>
One-time setup
</Text>
<Text color={t.color.text}>To charge from this terminal, allow Remote Spending once.</Text>
<Text color={t.color.muted}>
It opens your browser to authorize, then your ${amount} top-up picks up right here.
</Text>
<Text />
<ActionRow active={sel === 0} color={t.color.ok} label="Allow Remote Spending" t={t} />
<ActionRow active={sel === 1} label="Not now" t={t} />
<Text />
{footer('↑/↓ select · Enter confirm · Y/N quick · Esc cancel', t)}
</Box>
)
}
// ── Screen 4: Auto-reload (the 2-field form) ──────────────────────────
function AutoReloadScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) {
const ar = s.auto_reload
const enabled = Boolean(ar?.enabled)
const distinctCard = ar?.card?.kind === 'distinct' ? ar.card : null
const distinctCardName = distinctCard
? [distinctCard.brand, distinctCard.last4 ? `••${distinctCard.last4}` : null].filter(Boolean).join(' ') ||
'a different card'
: null
const manageCardLabel = 'Use your card on file — manage on portal'
// Prefill from state (strip the $ from the *_usd raw fields if present).
const prefill = (raw?: null | string) => (raw == null ? '' : String(raw).replace(/^\$/, '').trim())
const [threshold, setThreshold] = useState(prefill(ar?.threshold_usd))
const [reloadTo, setReloadTo] = useState(prefill(ar?.reload_to_usd))
const [field, setField] = useState<'reloadTo' | 'threshold'>('threshold')
const [error, setError] = useState<null | string>(null)
// focusRow: 0=threshold field, 1=reloadTo field, 2=Agree, 3=Turn off (if enabled), last=Cancel
const manageCardRows = distinctCard && s.portal_url ? [manageCardLabel] : []
const actionRows = enabled
? ['Agree and turn on', 'Turn off', ...manageCardRows, 'Cancel']
: ['Agree and turn on', ...manageCardRows, 'Cancel']
const actionColors: Record<string, string> = {
'Agree and turn on': t.color.ok,
'Turn off': t.color.warn,
[manageCardLabel]: t.color.accent
}
const FIELD_ROWS = 2
const [row, setRow] = useState(0)
const noCard = !s.card
const validatePair = (): null | { reloadTo: string; threshold: string } => {
const tv = ctx.validate(threshold)
if (tv.error || !tv.amount) {
setError(`Threshold: ${tv.error ?? 'invalid'}`)
return null
}
const rv = ctx.validate(reloadTo)
if (rv.error || !rv.amount) {
setError(`Reload-to: ${rv.error ?? 'invalid'}`)
return null
}
if (Number(rv.amount) <= Number(tv.amount)) {
setError('Reload-to amount must be greater than the threshold.')
return null
}
setError(null)
return { reloadTo: rv.amount, threshold: tv.amount }
}
const turnOn = () => {
if (noCard) {
ctx.sys('🔴 No saved card — manage billing on the portal.')
if (s.portal_url) {
ctx.openPortal(s.portal_url)
}
onClose()
return
}
const pair = validatePair()
if (!pair) {
return
}
void ctx.applyAutoReload(true, Number(pair.threshold), Number(pair.reloadTo)).then(ok => {
if (ok) {
ctx.sys(`✅ Auto-reload on: below $${pair.threshold} → reload to $${pair.reloadTo}.`)
}
})
onClose()
}
const turnOff = () => {
// The PATCH requires threshold/top_up_amount even when disabling (parity
// with the CLI's _billing_auto_reload_disable) — echo the current values,
// else the gateway rejects with invalid_request and auto-reload stays ON.
const thr = Number(prefill(ar?.threshold_usd)) || 0
const rel = Number(prefill(ar?.reload_to_usd)) || 0
void ctx.applyAutoReload(false, thr, rel).then(ok => {
if (ok) {
ctx.sys('✅ Auto-reload turned off.')
}
})
onClose()
}
const onAction = (label: string) => {
if (label === 'Agree and turn on') {
turnOn()
} else if (label === 'Turn off') {
turnOff()
} else if (label === manageCardLabel) {
if (s.portal_url) {
ctx.openPortal(s.portal_url)
}
onClose()
} else {
onPatch({ screen: 'overview' })
}
}
const editingField = row < FIELD_ROWS
useInput((ch, key) => {
if (key.escape) {
return onPatch({ screen: 'overview' })
}
if (key.upArrow && row > 0) {
setRow(v => v - 1)
setField(row - 1 === 0 ? 'threshold' : 'reloadTo')
}
if (key.downArrow && row < FIELD_ROWS + actionRows.length - 1) {
setRow(v => v + 1)
setField(row + 1 === 0 ? 'threshold' : 'reloadTo')
}
// Tab cycles between the two fields when focused on a field.
if (key.tab && editingField) {
const next = field === 'threshold' ? 'reloadTo' : 'threshold'
setField(next)
setRow(next === 'threshold' ? 0 : 1)
}
if (key.return && !editingField) {
const idx = row - FIELD_ROWS
return onAction(actionRows[idx] ?? 'Cancel')
}
// a number quick-picks an action row (1..actionRows.length)
if (!editingField) {
const n = parseInt(ch, 10)
if (n >= 1 && n <= actionRows.length) {
return onAction(actionRows[n - 1]!)
}
}
})
const cardLine = s.card ? `Card on file: ${s.card.masked}` : 'No saved card on file'
const chargeCardName = distinctCardName ?? (s.card ? s.card.masked : 'your card')
const fieldBox = (label: string, value: string, onChange: (v: string) => void, focused: boolean, key: string) => (
<Box flexDirection="column" key={key}>
<Text color={focused ? t.color.label : t.color.muted}>{label}</Text>
<Box borderColor={focused ? t.color.accent : t.color.border} borderStyle="round" paddingX={1}>
<Text color={t.color.label}>{'$'}</Text>
<TextInput
color={t.color.text}
columns={16}
focus={focused}
onChange={onChange}
onSubmit={() => {
// Enter inside the threshold field jumps to reload-to; inside
// reload-to jumps to the Agree action.
if (key === 'threshold') {
setField('reloadTo')
setRow(1)
} else {
setRow(FIELD_ROWS)
}
}}
value={value}
/>
</Box>
</Box>
)
return (
<Box flexDirection="column">
<Text bold color={t.color.accent}>
Auto-reload
</Text>
<Text color={t.color.muted}>Automatically add funds when your balance is low.</Text>
<Text color={t.color.muted}>{cardLine}</Text>
{distinctCardName && (
<Text color={t.color.warn}>⚠ Auto-refill is charging {distinctCardName} — not your card on file.</Text>
)}
<Text />
{fieldBox('When balance falls below:', threshold, setThreshold, row === 0, 'threshold')}
{fieldBox('Reload balance to:', reloadTo, setReloadTo, row === 1, 'reloadTo')}
<Text />
<Text color={t.color.muted}>
By confirming, you authorize Nous Research to charge {chargeCardName} whenever your balance falls below the
threshold. Turn off any time here or on the portal.
</Text>
{error && <Text color={t.color.error}>{error}</Text>}
<Text />
{actionRows.map((label, i) => (
<ActionRow
active={!editingField && row - FIELD_ROWS === i}
color={actionColors[label] ?? t.color.text}
key={label}
label={label}
t={t}
/>
))}
<Text />
{footer('↑/↓ move · Tab switch field · Enter next/confirm · Esc back', t)}
</Box>
)
}
// ── Screen 5: Monthly spend limit (read-only) ─────────────────────────
function LimitScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) {
const labels = ['Manage on portal', 'Cancel']
const choose = (i: number) => {
if (i === 0 && s.portal_url) {
ctx.openPortal(s.portal_url)
return onClose()
}
onPatch({ screen: 'overview' })
}
const rows: MenuRowSpec[] = labels.map((label, i) => ({ label, run: () => choose(i) }))
const sel = useMenu(rows, () => onPatch({ screen: 'overview' }))
const cap = s.monthly_cap
const usageLine =
cap && cap.limit_usd != null
? `${cap.spent_display} of ${cap.limit_display} used this month${cap.is_default_ceiling ? ' (default ceiling)' : ''}`
: 'No monthly cap visible (managed on the portal).'
return (
<Box flexDirection="column">
<Text bold color={t.color.accent}>
Monthly spend limit
</Text>
<Text color={t.color.text}>{usageLine}</Text>
<Text color={t.color.muted}>The monthly limit is set on the portal — shown here read-only.</Text>
<Text />
{labels.map((label, i) => (
<MenuRow active={sel === i} index={i + 1} key={label} label={label} t={t} />
))}
<Text />
{footer(`↑/↓ select · 1-${labels.length} quick pick · Enter confirm · Esc back`, t)}
</Box>
)
}
+562
View File
@@ -0,0 +1,562 @@
import { Box, Text, useStdout } from '@hermes/ink'
import { useEffect, useState } from 'react'
import unicodeSpinners from 'unicode-animations'
import { artWidth, caduceus, CADUCEUS_WIDTH, logo, LOGO_WIDTH } from '../banner.js'
import { mix } from '../lib/color.js'
import { flat } from '../lib/text.js'
import type { Theme } from '../theme.js'
import type { PanelSection, SessionInfo } from '../types.js'
import { Accordion } from './accordion.js'
import { ShimmerRows } from './loaders.js'
import { WidgetGrid } from './widgetGrid.js'
const LOADER_TICK_MS = 120
function InlineLoader({ label, t }: { label: string; t: Theme }) {
const [tick, setTick] = useState(0)
const spinner = unicodeSpinners.braille
const frame = spinner.frames[tick % spinner.frames.length] ?? '⠋'
useEffect(() => {
const id = setInterval(() => setTick(n => n + 1), Math.max(LOADER_TICK_MS, spinner.interval))
return () => clearInterval(id)
}, [spinner.interval])
return (
<Text color={t.color.muted} wrap="truncate">
<Text color={t.color.accent}>{frame}</Text> {label}
</Text>
)
}
export function ArtLines({ lines }: { lines: [string, string][] }) {
// No `opaque`: the banner is top-level content with nothing behind it, so
// it never needs the opaque space-fill (that's for absolute overlays). On a
// transparent terminal (terminal.background #00000000) the fill's "default
// background" spaces composite to black bars instead of the intended
// see-through — the reported ugly banner. Glyphs paint fine on their own.
return (
<Box flexDirection="column" height={lines.length} width={artWidth(lines)}>
{lines.map(([c, text], i) => (
<Text color={c} key={i} wrap="truncate-end">
{text}
</Text>
))}
</Box>
)
}
// Responsive Banner: full art → compact rule → text → hidden.
//
// Terminals can't scale glyphs, so "responsive" means picking a layout that
// fits the available columns. Thresholds are picked so each tier reads
// comfortably without forcing wrap or truncation drift on box-drawing edges.
const TAG_FULL = 'Nous Research · Messenger of the Digital Gods'
const TAG_MID = 'Messenger of the Digital Gods'
const TAG_TINY = 'Nous Research'
const HIDE_BELOW = 34
const COMPACT_FROM = 58
const clip = (s: string, w: number) => (w <= 0 ? '' : s.length > w ? `${s.slice(0, Math.max(0, w - 1))}…` : s)
const centerIn = (s: string, w: number) => {
const f = clip(s, w)
const slack = Math.max(0, w - f.length)
const left = slack >> 1
return `${' '.repeat(left)}${f}${' '.repeat(slack - left)}`
}
const ruleIn = (label: string, w: number) => {
const f = clip(label, Math.max(1, w - 4))
const slack = Math.max(0, w - f.length - 2)
const left = slack >> 1
return `${'─'.repeat(left)} ${f} ${'─'.repeat(slack - left)}`
}
function CompactBanner({ cols, t }: { cols: number; t: Theme }) {
// -4 keeps a margin so exact-edge rows don't trip terminal pending-wrap.
const w = Math.max(28, cols - 4)
// No `opaque` (see ArtLines): the dashed rules are glyphs and the tagline's
// centering spaces carry the text's own fg style, so every cell paints with
// a real see-through background. The opaque fill was writing default-bg
// spaces that a transparent terminal renders as black bars.
// NOT bold: on Cursor's transparent-background terminal, a full-width run
// of BOLD box-drawing dashes renders with an opaque black cell background
// (the plain-dash rule right below renders clean — pixel-diffed live; the
// only stylistic delta was bold). Bold on short label runs is fine; bold on
// full-width box-drawing rows is what triggers the slab.
return (
<Box flexDirection="column" height={3} marginBottom={1} width={w}>
<Text color={t.color.primary}>{ruleIn(t.brand.name, w)}</Text>
<Text color={t.color.muted}>{centerIn(TAG_FULL, w)}</Text>
<Text color={t.color.primary}>{'─'.repeat(w)}</Text>
</Box>
)
}
export function Banner({ maxWidth, t }: { maxWidth?: number; t: Theme }) {
const term = useStdout().stdout?.columns ?? 80
const cols = Math.max(1, Math.min(term, maxWidth ?? term))
if (cols < HIDE_BELOW) {
return null
}
const logoLines = logo(t.color, t.bannerLogo || undefined)
const logoW = t.bannerLogo ? artWidth(logoLines) : LOGO_WIDTH
// Each tier renders its rows through a single-column WidgetGrid sized to
// the available columns — same visual output as the old plain flex column
// (cells clip where truncate-end used to), but the banner is now a
// layout-engine surface.
if (cols >= logoW + 2) {
return (
<Box flexDirection="column" marginBottom={1}>
<WidgetGrid
cols={cols}
columns={1}
gap={0}
paddingX={0}
paddingY={0}
rowGap={0}
widgets={[
{ children: <ArtLines lines={logoLines} />, id: 'banner-art' },
{
children: (
<Text color={t.color.muted} wrap="truncate-end">
{t.brand.icon} {TAG_FULL}
</Text>
),
id: 'banner-tagline'
}
]}
/>
</Box>
)
}
if (cols >= COMPACT_FROM) {
return (
<WidgetGrid
cols={cols}
columns={1}
gap={0}
paddingX={0}
paddingY={0}
rowGap={0}
widgets={[{ children: <CompactBanner cols={cols} t={t} />, id: 'banner-compact' }]}
/>
)
}
const name = cols >= 52 ? t.brand.name : (t.brand.name.split(' ')[0] ?? t.brand.name)
const tag = cols >= 64 ? TAG_FULL : cols >= 46 ? TAG_MID : TAG_TINY
return (
<Box flexDirection="column" marginBottom={1}>
<WidgetGrid
cols={cols}
columns={1}
gap={0}
paddingX={0}
paddingY={0}
rowGap={0}
widgets={[
{
children: (
<Text bold color={t.color.primary} wrap="truncate-end">
{t.brand.icon} {name}
</Text>
),
id: 'banner-name'
},
{
children: (
<Text color={t.color.muted} wrap="truncate-end">
{t.brand.icon} {tag}
</Text>
),
id: 'banner-tag'
}
]}
/>
</Box>
)
}
// ── Skeleton ─────────────────────────────────────────────────────────
//
// Lazy sections render shimmer rows shaped like the real content (label
// block + value run) instead of a blank gap that pops when data lands.
// Row widths mirror the typical toolsets listing.
const SKELETON_ROWS: readonly (readonly [number, number])[] = [
[7, 30],
[7, 9],
[14, 12],
[12, 12],
[7, 7],
[10, 13]
]
// ── SessionPanel ─────────────────────────────────────────────────────
const SKILLS_MAX = 8
const TOOLSETS_MAX = 8
export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) {
const term = useStdout().stdout?.columns ?? 100
const cols = Math.max(20, Math.min(term, maxWidth ?? term))
const heroLines = caduceus(t.color, t.bannerHero || undefined)
const leftW = Math.min((artWidth(heroLines) || CADUCEUS_WIDTH) + 4, Math.floor(cols * 0.4))
const wide = cols >= 90 && leftW + 40 < cols
const w = Math.max(20, wide ? cols - leftW - 14 : cols - 12)
const lineBudget = Math.max(12, w - 2)
const strip = (s: string) => (s.endsWith('_tools') ? s.slice(0, -6) : s)
// Hierarchy: labels lead in the label tone; member lists recede in the
// muted/text midpoint. Anchoring on MUTED (mid-luminance by construction)
// keeps the fade readable on both poles even when polarity detection is
// wrong — surface-relative blends go invisible when text is already pale.
const listFade = mix(t.color.muted, t.color.text, 0.5)
// ── Local collapse state for each section ──
const [toolsOpen, setToolsOpen] = useState(true)
const [skillsOpen, setSkillsOpen] = useState(false)
const [systemOpen, setSystemOpen] = useState(false)
const [mcpOpen, setMcpOpen] = useState(false)
const truncLine = (pfx: string, items: string[]) => {
let line = ''
let shown = 0
for (const item of [...items].sort()) {
const next = line ? `${line}, ${item}` : item
if (pfx.length + next.length > lineBudget) {
return line ? `${line}, …+${items.length - shown}` : `${item}, …`
}
line = next
shown++
}
return line
}
// ── Collapsible skills section ──
const skillEntries = Object.entries(info.skills).sort()
const skillsTotal = flat(info.skills).length
const skillsCatCount = skillEntries.length
const skillsBody = () => {
if (info.lazy && skillEntries.length === 0) {
return <InlineLoader label="scanning skills" t={t} />
}
const shown = skillEntries.slice(0, SKILLS_MAX)
const overflow = skillEntries.length - SKILLS_MAX
return (
<>
{shown.map(([k, vs]) => (
<Text key={k} wrap="truncate">
<Text color={t.color.label}>{strip(k)}: </Text>
<Text color={listFade}>{truncLine(strip(k) + ': ', vs)}</Text>
</Text>
))}
{overflow > 0 && <Text color={t.color.muted}>(and {overflow} more categories…)</Text>}
</>
)
}
// ── Collapsible tools section ──
const toolEntries = Object.entries(info.tools).sort()
const toolsTotal = flat(info.tools).length
// MCP headline counts *connected* servers, not configured-but-disabled ones,
// so it matches the classic CLI banner (`sum(s.connected)` in
// hermes_cli/banner.py) and the "connected" label on the collapse toggle.
const mcpServers = info.mcp_servers ?? []
const mcpConnected = mcpServers.filter(s => s.connected).length
const toolsBody = () => {
if (info.lazy && toolEntries.length === 0) {
return <ShimmerRows color={listFade} highlight={t.color.label} rows={SKELETON_ROWS} />
}
const shown = toolEntries.slice(0, TOOLSETS_MAX)
const overflow = toolEntries.length - TOOLSETS_MAX
return (
<>
{shown.map(([k, vs]) => (
<Text key={k} wrap="truncate">
<Text color={t.color.label}>{strip(k)}: </Text>
<Text color={listFade}>{truncLine(strip(k) + ': ', vs)}</Text>
</Text>
))}
{overflow > 0 && <Text color={t.color.muted}>(and {overflow} more toolsets…)</Text>}
</>
)
}
// ── Collapsible MCP section ──
const mcpBody = () => (
<>
{(info.mcp_servers ?? []).map(s => (
<Text key={s.name} wrap="truncate">
<Text color={t.color.muted}>{` ${s.name} `}</Text>
<Text color={t.color.muted}>{`[${s.transport}]`}</Text>
<Text color={t.color.muted}>: </Text>
{s.connected ? (
<Text color={t.color.text}>
{s.tools} tool{s.tools === 1 ? '' : 's'}
</Text>
) : s.disabled || s.status === 'disabled' ? (
<Text color={t.color.muted}>disabled</Text>
) : s.status === 'connecting' ? (
<Text color={t.color.warn}>connecting</Text>
) : s.status === 'configured' ? (
<Text color={t.color.muted}>configured</Text>
) : (
<Text color={t.color.error}>failed</Text>
)}
</Text>
))}
</>
)
// ── System prompt body ──
const sysPromptLen = (info.system_prompt ?? '').length
const systemBody = () => {
if (sysPromptLen === 0) {
return <Text color={t.color.muted}>No system prompt loaded.</Text>
}
return <Text color={t.color.muted}>{info.system_prompt}</Text>
}
// The wide layout is a real two-column grid: a fixed-width hero track and a
// flexible info track (grid-template-columns: <leftW> 1fr, gap 2) — the
// terminal equivalent of the desktop pane shell's fixed-vs-flex tracks.
// Narrow drops to a single flexible track. Track math reproduces the old
// hand-rolled widths exactly: usable = (leftW + 2 + w) - gap = leftW + w.
const heroColumn = wide ? (
<Box flexDirection="column" width="100%">
<ArtLines lines={heroLines} />
<Text />
<Text color={t.color.accent}>
{info.model.split('/').pop()}
<Text color={t.color.muted}> · Nous Research</Text>
</Text>
<Text color={t.color.muted} wrap="truncate-end">
{info.cwd || process.cwd()}
</Text>
{sid && (
<Text>
<Text color={t.color.sessionLabel}>Session: </Text>
<Text color={t.color.sessionBorder}>{sid}</Text>
</Text>
)}
</Box>
) : null
const infoColumn = (
<Box flexDirection="column" width="100%">
{wide ? (
<Box justifyContent="center" marginBottom={1}>
<Text bold color={t.color.primary}>
{t.brand.name}
{info.version ? ` v${info.version}` : ''}
{info.release_date ? ` (${info.release_date})` : ''}
</Text>
</Box>
) : (
// Narrow layout hides the hero column; surface model/cwd/session
// here so they aren't lost.
<Box flexDirection="column" marginBottom={1}>
<Text color={t.color.accent} wrap="truncate-end">
{info.model.split('/').pop()}
<Text color={t.color.muted}> · Nous Research</Text>
</Text>
<Text color={t.color.muted} wrap="truncate-end">
{info.cwd || process.cwd()}
</Text>
{sid && (
<Text wrap="truncate-end">
<Text color={t.color.sessionLabel}>Session: </Text>
<Text color={t.color.sessionBorder}>{sid}</Text>
</Text>
)}
</Box>
)}
{/* ── Tools (expanded by default) ── */}
<Box flexDirection="column" marginTop={1}>
<Accordion onToggle={() => setToolsOpen(v => !v)} open={toolsOpen} t={t} title="Available Tools">
{toolsBody()}
</Accordion>
</Box>
{/* ── Skills (collapsed by default) ── */}
<Box flexDirection="column" marginTop={1}>
<Accordion
count={skillsTotal}
onToggle={() => setSkillsOpen(v => !v)}
open={skillsOpen}
suffix={skillsCatCount > 0 ? `in ${skillsCatCount} categor${skillsCatCount === 1 ? 'y' : 'ies'}` : undefined}
t={t}
title="Available Skills"
>
{skillsBody()}
</Accordion>
</Box>
{/* ── System Prompt (collapsed by default) ── */}
{sysPromptLen > 0 && (
<Box flexDirection="column" marginTop={1}>
<Accordion
onToggle={() => setSystemOpen(v => !v)}
open={systemOpen}
suffix={`— ${sysPromptLen.toLocaleString()} chars`}
t={t}
title="System Prompt"
>
{systemBody()}
</Accordion>
</Box>
)}
{/* ── MCP Servers (collapsed by default) ── */}
{mcpServers.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Accordion
count={mcpConnected}
onToggle={() => setMcpOpen(v => !v)}
open={mcpOpen}
suffix="connected"
t={t}
title="MCP Servers"
>
{mcpBody()}
</Accordion>
</Box>
)}
<Text />
<Text color={t.color.text}>
{/* Lazy boot: never print "0 tools · 0 skills" while counts load. */}
{info.lazy && !toolsTotal ? '… ' : `${toolsTotal} `}tools{' · '}
{info.lazy && !skillsTotal ? '… ' : `${skillsTotal} `}skills
{mcpConnected ? ` · ${mcpConnected} MCP` : ''}
{' · '}
<Text color={t.color.muted}>/help for commands</Text>
</Text>
{typeof info.update_behind === 'number' && info.update_behind > 0 && (
<Text bold color={t.color.warn}>
! {info.update_behind} {info.update_behind === 1 ? 'commit' : 'commits'} behind
<Text bold={false} color={t.color.warn} dimColor>
{' '}
- run{' '}
</Text>
<Text bold color={t.color.warn}>
{info.update_command || 'hermes update'}
</Text>
<Text bold={false} color={t.color.warn} dimColor>
{' '}
to update
</Text>
</Text>
)}
{info.install_warning && (
<Text bold color={t.color.warn} wrap="wrap">
! {info.install_warning}
</Text>
)}
</Box>
)
return (
<Box borderColor={t.color.border} borderStyle="round" marginBottom={1} paddingX={2} paddingY={1}>
<WidgetGrid
cols={wide ? leftW + 2 + w : w}
columns={wide ? [leftW, { fr: 1 }] : 1}
gap={2}
paddingX={0}
paddingY={0}
rowGap={0}
widgets={
wide
? [
{ children: heroColumn, id: 'session-hero' },
{ children: infoColumn, id: 'session-info' }
]
: [{ children: infoColumn, id: 'session-info' }]
}
/>
</Box>
)
}
export function Panel({ sections, t, title }: PanelProps) {
return (
<Box borderColor={t.color.border} borderStyle="round" flexDirection="column" paddingX={2} paddingY={1}>
<Box justifyContent="center" marginBottom={1}>
<Text bold color={t.color.primary}>
{title}
</Text>
</Box>
{sections.map((sec, si) => (
<Box flexDirection="column" key={si} marginTop={si > 0 ? 1 : 0}>
{sec.title && (
<Text bold color={t.color.accent}>
{sec.title}
</Text>
)}
{sec.rows?.map(([k, v], ri) => (
<Text key={ri} wrap="truncate">
<Text color={t.color.muted}>{k.padEnd(20)}</Text>
<Text color={t.color.text}>{v}</Text>
</Text>
))}
{sec.items?.map((item, ii) => (
<Text color={t.color.text} key={ii} wrap="truncate">
{item}
</Text>
))}
{sec.text && <Text color={t.color.muted}>{sec.text}</Text>}
</Box>
))}
</Box>
)
}
interface PanelProps {
sections: PanelSection[]
t: Theme
title: string
}
interface SessionPanelProps {
info: SessionInfo
maxWidth?: number
sid?: string | null
t: Theme
}
+30
View File
@@ -0,0 +1,30 @@
// FPS counter overlay (HERMES_TUI_FPS=1). Zero-cost when disabled.
import { Text } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { SHOW_FPS } from '../config/env.js'
import { $fpsState } from '../lib/fpsStore.js'
import type { Theme } from '../theme.js'
const fpsColor = (fps: number, t: Theme) =>
fps >= 50 ? t.color.statusGood : fps >= 30 ? t.color.statusWarn : t.color.error
export function FpsOverlay({ t }: { t: Theme }) {
if (!SHOW_FPS) {
return null
}
return <FpsOverlayInner t={t} />
}
function FpsOverlayInner({ t }: { t: Theme }) {
const { fps, lastDurationMs, totalFrames } = useStore($fpsState)
// Zero-pad widths so digit churn doesn't jitter the corner.
return (
<Text color={fpsColor(fps, t)}>
{fps.toFixed(1).padStart(5)}fps · {lastDurationMs.toFixed(1).padStart(5)}ms · #{totalFrames}
</Text>
)
}
+364
View File
@@ -0,0 +1,364 @@
import { Box, Text } from '@hermes/ink'
import { memo, type ReactNode, useEffect, useRef, useState } from 'react'
import { sparkRows } from '../lib/charts.js'
import type { GridAreaCell, GridTrackSize } from '../lib/widgetGrid.js'
import type { GridTestState } from '../sdk/apps/gridTestState.js'
import type { Theme } from '../theme.js'
import { GridAreas, type GridAreaWidget } from './widgetGrid.js'
// The streams demo tiles six independently-ticking panels through
// `layoutGridAreas`: one panel owns a promoted 2x2 slot, the rest auto-place
// densely around it. Promoting a different panel reorders/respans the widget
// list — but cells are keyed by id, so every stream keeps its history while
// the grid reshapes around it. That relayout-without-reset is the point of
// the demo.
const HEADER_ROWS = 3
const STREAM_ROWS = 3
const GRID_HEIGHT = HEADER_ROWS + STREAM_ROWS * 6
interface StreamDef {
id: string
render: (inner: { height: number; t: Theme; width: number }) => ReactNode
title: string
}
// ── tick + history hooks ────────────────────────────────────────────────────
const useTick = (ms: number) => {
const [tick, setTick] = useState(0)
useEffect(() => {
const timer = setInterval(() => setTick(v => v + 1), ms)
return () => clearInterval(timer)
}, [ms])
return tick
}
/** Ring-buffered sample history: one `sample()` per tick, capped at `cap`. */
const useHistory = (tick: number, sample: () => number, cap = 240) => {
const historyRef = useRef<number[]>([])
useEffect(() => {
historyRef.current.push(sample())
if (historyRef.current.length > cap) {
historyRef.current.splice(0, historyRef.current.length - cap)
}
// The sampler is intentionally re-run per tick, not per identity.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tick])
return historyRef.current
}
// ── stream panels ───────────────────────────────────────────────────────────
const TOKEN_WORDS = (
`Hermes streams tokens into the promoted cell while the grid reshapes around it. ` +
`Cells are keyed by id, so promotion never resets a panel — history, cursors and ` +
`tickers all survive the relayout. Row and column tracks re-solve to integer ` +
`terminal cells on every change, spans bridge the gaps they cross, and dense ` +
`auto-placement backfills the holes the promoted panel leaves behind. `
).split(' ')
function TokenStream({ height, t, width }: { height: number; t: Theme; width: number }) {
const tick = useTick(90)
const wordCount = tick % (TOKEN_WORDS.length * 4)
// Keep roughly enough trailing text to fill the cell, on word boundaries.
const budget = Math.max(16, width * height)
const words: string[] = []
let used = 0
for (let i = wordCount; i >= 0 && used < budget; i--) {
const word = TOKEN_WORDS[i % TOKEN_WORDS.length]!
words.unshift(word)
used += word.length + 1
}
return (
<Text color={t.color.text} wrap="wrap">
{words.join(' ')}
<Text color={t.color.primary}>▌</Text>
</Text>
)
}
function SparkStream({
color,
format,
height,
interval,
sample,
t,
width
}: {
color: string
format: (value: number) => string
height: number
interval: number
sample: () => number
t: Theme
width: number
}) {
const tick = useTick(interval)
const history = useHistory(tick, sample)
const current = history[history.length - 1] ?? 0
// Chart height grows with the cell — a promoted cell gets a taller chart.
const rows = Math.max(1, height - 1)
return (
<Box flexDirection="column">
<Text color={t.color.muted} wrap="truncate">
{format(current)}
</Text>
{sparkRows(history, width, rows).map((line, idx) => (
<Text color={color} key={idx} wrap="truncate">
{line}
</Text>
))}
</Box>
)
}
const FAKE_TOOLS = [
'┊ read_file src/app/chat.tsx',
'┊ terminal npm run typecheck',
'┊ search_files "GridAreas"',
'┊ patch ui-tui/src/lib/widgetGrid.ts',
'┊ web_search yoga absolute layout',
'┊ delegate_task refactor sparkline',
'┊ terminal scripts/run_tests.sh',
'┊ vision_analyze screenshot.png'
]
function ToolTicker({ height, t }: { height: number; t: Theme; width: number }) {
const tick = useTick(600)
const visible = Math.max(1, height)
const lines = Array.from({ length: Math.min(visible, tick + 1) }, (_, idx) => {
const line = FAKE_TOOLS[(tick - idx) % FAKE_TOOLS.length]!
return { line, recent: idx === 0 }
}).reverse()
return (
<Box flexDirection="column">
{lines.map(({ line, recent }, idx) => (
<Text color={recent ? t.color.text : t.color.muted} key={idx} wrap="truncate">
{line}
</Text>
))}
</Box>
)
}
function MetaPanel({ t }: { height: number; t: Theme; width: number }) {
const tick = useTick(1000)
const startedRef = useRef(Date.now())
const uptime = Math.floor((Date.now() - startedRef.current) / 1000)
return (
<Box flexDirection="column">
<Text color={t.color.text}>{new Date().toLocaleTimeString()}</Text>
<Text color={t.color.muted}>{`up ${Math.floor(uptime / 60)}m ${uptime % 60}s`}</Text>
<Text color={t.color.muted}>{`${tick} ticks`}</Text>
</Box>
)
}
// ── stream registry ─────────────────────────────────────────────────────────
const sineSampler = () => (Math.sin(Date.now() / 700) + 1) / 2 + Math.random() * 0.15
let walkValue = 0.5
const walkSampler = () => {
walkValue = Math.min(1, Math.max(0, walkValue + (Math.random() - 0.5) * 0.2))
return walkValue
}
/** Exported so tests can assert the count matches GRID_STREAM_COUNT (focus wraps mod it). */
export const STREAM_DEFS: StreamDef[] = [
{
id: 'tokens',
render: ({ height, t, width }) => <TokenStream height={height} t={t} width={width} />,
title: 'token stream'
},
{
id: 'throughput',
render: ({ height, t, width }) => (
<SparkStream
color={t.color.accent}
format={v => `${Math.round(20 + v * 40)} tok/s`}
height={height}
interval={150}
sample={sineSampler}
t={t}
width={width}
/>
),
title: 'throughput'
},
{
id: 'heap',
render: ({ height, t, width }) => (
<SparkStream
color={t.color.ok}
format={v => `heap ${(v / 1024 / 1024).toFixed(1)} MB`}
height={height}
interval={500}
sample={() => process.memoryUsage().heapUsed}
t={t}
width={width}
/>
),
title: 'memory'
},
{
id: 'latency',
render: ({ height, t, width }) => (
<SparkStream
color={t.color.warn}
format={v => `${Math.round(20 + v * 180)} ms`}
height={height}
interval={250}
sample={walkSampler}
t={t}
width={width}
/>
),
title: 'latency'
},
{
id: 'tools',
render: ({ height, t, width }) => <ToolTicker height={height} t={t} width={width} />,
title: 'tool feed'
},
{
id: 'meta',
render: ({ height, t, width }) => <MetaPanel height={height} t={t} width={width} />,
title: 'session'
}
]
// ── the demo surface ────────────────────────────────────────────────────────
function StreamPanel({
cell,
focused,
main,
t,
title,
children
}: {
cell: GridAreaCell
children: (inner: { height: number; t: Theme; width: number }) => ReactNode
focused: boolean
main: boolean
t: Theme
title: string
}) {
const innerWidth = Math.max(1, cell.width - 4)
const innerHeight = Math.max(1, cell.height - 3)
const borderColor = focused ? t.color.primary : main ? t.color.accent : t.color.border
return (
<Box
borderColor={borderColor}
borderStyle="round"
flexDirection="column"
height={cell.height}
paddingX={1}
width={cell.width}
>
{/* No phantom icon column: unfocused titles sit flush left — the ▸
appears (and shifts the title) only while focused. */}
<Text bold={focused} color={focused ? t.color.primary : t.color.label} wrap="truncate">
{focused ? '▸ ' : ''}
{title}
{main ? ' ·' : ''}
</Text>
<Box flexDirection="column" height={innerHeight} overflow="hidden" width={innerWidth}>
{children({ height: innerHeight, t, width: innerWidth })}
</Box>
</Box>
)
}
export const GridStreamsDemo = memo(function GridStreamsDemo({
cols,
state,
t
}: {
cols: number
state: GridTestState
t: Theme
}) {
const columnTracks: GridTrackSize[] = [{ fr: 1 }, { fr: 1 }, { fr: 1 }]
const main = STREAM_DEFS[state.streamMain % STREAM_DEFS.length]!
// Promoted panel first so dense placement gives it the top-left 2x2; the
// rest backfill in definition order. Ids never change, so React reconciles
// each panel across promotions and its ticking state survives.
const ordered = [main, ...STREAM_DEFS.filter(def => def.id !== main.id)]
const widgets: GridAreaWidget[] = [
{
colSpan: 3,
id: 'stream-header',
render: cell => (
<Box
alignItems="center"
borderColor={t.color.border}
borderStyle="round"
height={cell.height}
justifyContent="space-between"
paddingX={1}
width={cell.width}
>
<Text bold color={t.color.primary}>
hermes mission control
</Text>
<Text color={t.color.muted}>{`main: ${main.title}`}</Text>
</Box>
)
},
...ordered.map((def, idx) => ({
colSpan: idx === 0 ? 2 : 1,
id: `stream-${def.id}`,
render: (cell: GridAreaCell) => (
<StreamPanel
cell={cell}
focused={STREAM_DEFS[state.streamFocus % STREAM_DEFS.length]!.id === def.id}
main={def.id === main.id}
t={t}
title={def.title}
>
{def.render}
</StreamPanel>
),
rowSpan: idx === 0 ? 2 : 1
}))
]
return (
<GridAreas
columns={columnTracks}
gap={1}
height={GRID_HEIGHT}
rowGap={0}
rows={[HEADER_ROWS, { fr: 1 }, { fr: 1 }, { fr: 1 }]}
widgets={widgets}
width={cols}
/>
)
})
+318
View File
@@ -0,0 +1,318 @@
import { Box, Text } from '@hermes/ink'
import type { GridAreaCell, GridTrackSize } from '../lib/widgetGrid.js'
import type { GridTestState } from '../sdk/apps/gridTestState.js'
import type { Theme } from '../theme.js'
import { GridStreamsDemo } from './gridStreamsDemo.js'
import { GridAreas, type GridAreaWidget, WidgetGrid, type WidgetGridWidget } from './widgetGrid.js'
interface GridTestOverlayProps {
cols: number
state: GridTestState
t: Theme
}
const NESTED_GAP = 1
// Heights are odd so a single line is true-centered: border, blank, text, blank, border.
const FLAT_CELL_HEIGHT = 5
const NESTED_CELL_HEIGHT = 9
const MINI_CELL_HEIGHT = 3
// Sparse "every-other" pattern so the nested toggle visibly differs from the active cell.
const showsNestedPreview = (row: number, col: number) => row % 2 === 0 && col % 2 === 0
export function GridTestOverlay({ cols, state, t }: GridTestOverlayProps) {
const gridCols = Math.max(12, cols)
const activeIdx = state.activeRow * state.cols + state.activeCol
const activeLabel = `c${activeIdx + 1}`
const widgets: WidgetGridWidget[] = Array.from({ length: state.rows * state.cols }, (_, idx) => {
const row = Math.floor(idx / state.cols)
const col = idx % state.cols
const active = idx === activeIdx
const label = `c${idx + 1}`
return {
id: `cell-${idx}`,
render: width => (
<GridCell
active={active}
label={label}
nested={state.nested && showsNestedPreview(row, col)}
nestedMode={state.nested}
t={t}
width={width}
/>
)
}
})
return (
<Box flexDirection="column" paddingY={1} width={gridCols}>
<Box justifyContent="space-between" marginBottom={1} width="100%">
<Text bold color={t.color.primary}>
{state.zoomed ? `/grid-test / r${state.activeRow + 1} c${state.activeCol + 1}` : '/grid-test'}
</Text>
<Text color={t.color.muted}>{state.streams ? 'streams' : `${state.cols}x${state.rows} grid`}</Text>
</Box>
<Text color={t.color.muted} wrap="truncate">
{state.zoomed
? 'arrows/hjkl switch cell · Esc/q back · Ctrl+C close'
: state.streams
? 'arrows/hjkl focus · Enter promote · d dialog · Esc/q back · Ctrl+C close'
: 'arrows/hjkl move · Enter zoom · d dialog · a areas · s streams · +/- cols · [] rows · g gap · p pad · n nest · q close'}
</Text>
<Box marginTop={1}>
{state.zoomed ? (
<ZoomedGridCell cols={gridCols} parentLabel={activeLabel} t={t} />
) : state.streams ? (
<GridStreamsDemo cols={gridCols} state={state} t={t} />
) : state.areas ? (
<AreasDemo cols={gridCols} state={state} t={t} />
) : (
<WidgetGrid
cols={gridCols}
columns={state.cols}
gap={state.gap ?? (state.nested ? NESTED_GAP : undefined)}
minColumnWidth={1}
paddingX={state.paddingX ?? undefined}
rowGap={0}
widgets={widgets}
/>
)}
</Box>
{!state.zoomed && !state.streams && (
<Box marginTop={1}>
<Text color={t.color.muted} wrap="truncate">
gap {state.gap ?? 'auto'} · pad {state.paddingX ?? 'auto'} · nested {state.nested ? 'on' : 'off'} · areas{' '}
{state.areas ? 'on (2fr first col · c1 spans rows · c2 spans cols)' : 'off'}
</Text>
</Box>
)}
</Box>
)
}
/**
* Areas-mode demo: the same cols×rows surface driven by `GridAreas` — a
* weighted first column (2fr vs 1fr when there's room), `c1` spanning two
* rows, `c2` spanning two columns, everything else auto-placed dense. The
* cursor highlights whichever cell's row/col range contains it, so moving
* onto a spanned cell lights the whole merged area.
*/
function AreasDemo({ cols, state, t }: { cols: number; state: GridTestState; t: Theme }) {
const height = state.rows * FLAT_CELL_HEIGHT
const columnTracks: GridTrackSize[] =
state.cols >= 3
? [{ fr: 2 }, ...Array.from({ length: state.cols - 1 }, () => ({ fr: 1 }))]
: Array.from({ length: state.cols }, () => ({ fr: 1 }))
const rowSpanFirst = state.rows >= 2 ? 2 : 1
const colSpanSecond = state.cols >= 2 ? 2 : 1
const extraSlots = rowSpanFirst - 1 + (colSpanSecond - 1)
const itemCount = Math.max(1, state.rows * state.cols - extraSlots)
const cursorInside = (cell: GridAreaCell) =>
state.activeRow >= cell.row &&
state.activeRow < cell.row + cell.rowSpan &&
state.activeCol >= cell.col &&
state.activeCol < cell.col + cell.colSpan
const widgets: GridAreaWidget[] = Array.from({ length: itemCount }, (_, idx) => ({
colSpan: idx === 1 ? colSpanSecond : 1,
id: `area-c${idx + 1}`,
render: (cell: GridAreaCell) => (
<AreaDemoCell active={cursorInside(cell)} cell={cell} label={`c${idx + 1}`} t={t} />
),
rowSpan: idx === 0 ? rowSpanFirst : 1
}))
return (
<GridAreas
columns={columnTracks}
gap={state.gap ?? 1}
height={height}
rowGap={0}
rows={state.rows}
widgets={widgets}
width={cols}
/>
)
}
function AreaDemoCell({ active, cell, label, t }: { active: boolean; cell: GridAreaCell; label: string; t: Theme }) {
const borderColor = active ? t.color.primary : t.color.border
const labelColor = active ? t.color.primary : t.color.label
return (
<Box
alignItems="center"
borderColor={borderColor}
borderStyle="round"
flexDirection="column"
height={cell.height}
justifyContent="center"
width={cell.width}
>
<Text bold={active} color={labelColor}>
{label}
</Text>
{cell.height >= 5 && (
<Text color={t.color.muted}>
{cell.colSpan > 1 || cell.rowSpan > 1 ? `${cell.colSpan}x${cell.rowSpan}` : `${cell.width}w`}
</Text>
)}
</Box>
)
}
function GridCell({
active,
label,
nested,
nestedMode,
t,
width
}: {
active: boolean
label: string
nested: boolean
nestedMode: boolean
t: Theme
width: number
}) {
const padX = width >= 14 ? 1 : 0
const inner = Math.max(1, width - 2 - padX * 2)
const borderColor = active ? t.color.primary : t.color.border
const height = nestedMode ? NESTED_CELL_HEIGHT : FLAT_CELL_HEIGHT
const labelColor = active ? t.color.primary : t.color.label
return (
<Box
borderColor={borderColor}
borderStyle="round"
flexDirection="column"
height={height}
paddingX={padX}
width={width}
>
{nested && width >= 10 ? (
<>
<Box justifyContent="center" width={inner}>
<Text bold={active} color={labelColor}>
{label}
</Text>
</Box>
<WidgetGrid
cols={inner}
columns={2}
depth={1}
gap={NESTED_GAP}
minColumnWidth={1}
paddingX={0}
rowGap={0}
widgets={childCellWidgets(t, 3, 2)}
/>
</>
) : (
<Box alignItems="center" flexGrow={1} justifyContent="center" width={inner}>
<Text bold={active} color={labelColor}>
{label}
</Text>
</Box>
)}
</Box>
)
}
function ZoomedGridCell({ cols, parentLabel, t }: { cols: number; parentLabel: string; t: Theme }) {
const childColumns = cols >= 72 ? 4 : 2
const contentWidth = Math.max(1, cols - 4)
return (
<Box
borderColor={t.color.primary}
borderStyle="round"
flexDirection="column"
paddingX={1}
paddingY={1}
width={cols}
>
<WidgetGrid
cols={contentWidth}
columns={2}
depth={1}
gap={1}
minColumnWidth={1}
paddingX={0}
rowGap={0}
widgets={[
{
children: (
<Text bold color={t.color.primary}>
parent {parentLabel}
</Text>
),
id: 'header-title'
},
{
children: (
<Box justifyContent="flex-end" width="100%">
<Text color={t.color.muted}>nested child grid</Text>
</Box>
),
id: 'header-meta'
}
]}
/>
<Box height={1} />
<WidgetGrid
cols={contentWidth}
columns={childColumns}
depth={1}
gap={NESTED_GAP}
minColumnWidth={1}
paddingX={0}
rowGap={0}
widgets={childCellWidgets(t, childColumns * 2, childColumns)}
/>
</Box>
)
}
const childCellWidgets = (t: Theme, count: number, columns: number): WidgetGridWidget[] => {
const colors = [t.color.ok, t.color.warn, t.color.accent, t.color.muted, t.color.primary]
// 3-cell preview: two side-by-side, third spans the full row.
const lastSpansRow = count === 3
return Array.from({ length: count }, (_, idx) => ({
colSpan: lastSpansRow && idx === count - 1 ? columns : 1,
id: `child-c${idx + 1}`,
render: w => <MiniCell color={colors[idx % colors.length]!} label={`c${idx + 1}`} width={w} />
}))
}
function MiniCell({ color, label, width }: { color: string; label: string; width: number }) {
return (
<Box
alignItems="center"
borderColor={color}
borderStyle="single"
height={MINI_CELL_HEIGHT}
justifyContent="center"
overflow="hidden"
width={width}
>
<Text color={color}>{label}</Text>
</Box>
)
}
+68
View File
@@ -0,0 +1,68 @@
import { Box, Text } from '@hermes/ink'
import { HOTKEYS } from '../content/hotkeys.js'
import type { Theme } from '../theme.js'
const COMMON_COMMANDS: [string, string][] = [
['/help', 'full list of commands + hotkeys'],
['/clear', 'start a new session'],
['/resume', 'switch live or resume past sessions'],
['/details', 'control transcript detail level'],
['/copy', 'copy selection or last assistant message'],
['/quit', 'exit hermes']
]
const HOTKEY_PREVIEW = HOTKEYS.slice(0, 8)
export function HelpHint({ t }: { t: Theme }) {
const labelW = Math.max(...COMMON_COMMANDS.map(([k]) => k.length), ...HOTKEY_PREVIEW.map(([k]) => k.length))
const pad = (s: string) => s + ' '.repeat(Math.max(0, labelW - s.length + 2))
return (
<Box alignItems="flex-start" bottom="100%" flexDirection="column" left={0} position="absolute" right={0}>
<Box
alignSelf="flex-start"
borderColor={t.color.primary}
borderStyle="round"
flexDirection="column"
marginBottom={1}
opaque
paddingX={1}
>
<Text>
<Text bold color={t.color.primary}>
? quick help
</Text>
<Text color={t.color.muted}>{' · type /help for the full panel · backspace to dismiss'}</Text>
</Text>
<Box marginTop={1}>
<Text bold color={t.color.accent}>
Common commands
</Text>
</Box>
{COMMON_COMMANDS.map(([k, v]) => (
<Text key={k}>
<Text color={t.color.label}>{pad(k)}</Text>
<Text color={t.color.muted}>{v}</Text>
</Text>
))}
<Box marginTop={1}>
<Text bold color={t.color.accent}>
Hotkeys
</Text>
</Box>
{HOTKEY_PREVIEW.map(([k, v]) => (
<Text key={k}>
<Text color={t.color.label}>{pad(k)}</Text>
<Text color={t.color.muted}>{v}</Text>
</Text>
))}
</Box>
</Box>
)
}
+595
View File
@@ -0,0 +1,595 @@
import { Box, NoSelect, ScrollBox, type ScrollBoxHandle, Text, useInput, useStdout } from '@hermes/ink'
import { useEffect, useRef, useState } from 'react'
import type { GatewayClient } from '../gatewayClient.js'
import { openInEditor } from '../lib/editor.js'
import { rpcErrorMessage } from '../lib/rpc.js'
import { deriveStarmapPalette, fadeHex, fadeInk, type StarmapPalette } from '../lib/starmapPalette.js'
import type { Theme } from '../theme.js'
import { listRowStyle } from './overlayPrimitives.js'
import { OverlayScrollbar } from './overlayScrollbar.js'
interface MutationResult {
message: string
ok: boolean
}
interface NodeDetail extends MutationResult {
content?: string
kind?: string
}
// A run is [text, styleKey, alpha?, hexOverride?] from learning_graph_render.py.
type Run = [string, string, number?, (string | null)?]
interface LegendItem {
color?: string
glyph: string
label: string
style?: string
}
interface BucketNode {
body?: string
fullLabel?: string
glyph: string
id: string
label: string
meta: string
style: string
}
interface BucketRow {
category?: string | null
color?: string | null
date: string
index: number
label: string
memories: number
nodes: BucketNode[]
skills: number
}
interface FramesResponse {
axis: { end: string; start: string }
buckets?: BucketRow[]
categories?: LegendItem[]
count: number
frames: { grid: Run[][] }[]
legend: LegendItem[]
summary: string[]
}
interface JourneyProps {
gw: GatewayClient
onClose: () => void
t: Theme
}
// Flattened timeline tree: each slice header is preceded by a blank gap row
// (except the first) and followed by its chronological items.
type TreeRow =
| { bucket: BucketRow; kind: 'node'; last: boolean; node: BucketNode }
| { bucket: BucketRow; kind: 'slice' }
| { kind: 'gap' }
type Cell = { color?: string; text: string }
const MAX_CHART_ROWS = 8
const rowText = (row: Run[]) => row.map(run => run[0]).join('')
const buildTree = (buckets: BucketRow[]): TreeRow[] => {
const out: TreeRow[] = []
buckets.forEach((bucket, b) => {
if (b > 0) {
out.push({ kind: 'gap' }) // breathing room between groups
}
out.push({ bucket, kind: 'slice' })
bucket.nodes.forEach((node, j) => out.push({ bucket, kind: 'node', last: j === bucket.nodes.length - 1, node }))
})
return out
}
// Center a fixed-height window on the cursor, clamped to list bounds.
const windowStart = (cursor: number, len: number, h: number) =>
Math.max(0, Math.min(Math.max(0, len - h), cursor - Math.floor(h / 2)))
function ChartRow({ palette, row }: { palette: StarmapPalette; row: Run[] }) {
if (!row.length) {
return <Text> </Text>
}
return (
<Text>
{row.map((run, i) => (
<Text color={run[3] ? fadeHex(palette, run[3], run[2] ?? 1) : fadeInk(palette, run[1], run[2] ?? 1)} key={i}>
{run[0]}
</Text>
))}
</Text>
)
}
// Full-width selectable row, matching the /agents list treatment: the active
// row carries the shared selection chip (never `inverse`, which swaps against
// the terminal's unknowable defaults) and collapses onto the chip ink.
function ListRow({ active, cells, t }: { active: boolean; cells: Cell[]; t: Theme }) {
const row = listRowStyle(t, active)
const fg = active ? (row.color ?? t.color.accent) : t.color.text
return (
<Text backgroundColor={row.backgroundColor} bold={active} color={fg} wrap="truncate-end">
{cells.map((c, i) => (
<Text color={active ? fg : (c.color ?? t.color.text)} key={i}>
{c.text}
</Text>
))}
</Text>
)
}
export function Journey({ gw, onClose, t }: JourneyProps) {
const { stdout } = useStdout()
const cols = Math.max(40, (stdout?.columns ?? 90) - 3)
const rows = Math.max(16, (stdout?.rows ?? 30) - 2)
const chartRows = Math.max(5, Math.min(MAX_CHART_ROWS, Math.floor(rows * 0.32)))
const page = Math.max(4, rows - 6)
const palette = deriveStarmapPalette(t.color.primary, t.color.text)
const [data, setData] = useState<FramesResponse | null>(null)
const [err, setErr] = useState('')
const [cursor, setCursor] = useState(0)
const [mode, setMode] = useState<'item' | 'timeline'>('timeline')
const [tick, setTick] = useState(0)
const [reloadKey, setReloadKey] = useState(0)
const [confirmDelete, setConfirmDelete] = useState(false)
const [busy, setBusy] = useState(false)
const [notice, setNotice] = useState('')
const itemScroll = useRef<null | ScrollBoxHandle>(null)
// The renderer is size-aware, so refetch when the terminal resizes (or after a
// mutation bumps reloadKey).
useEffect(() => {
let alive = true
setData(null)
setErr('')
gw.request<FramesResponse>('learning.frames', { cols, frames: 2, rows: chartRows })
.then(r => {
if (!alive) {
return
}
setData(r)
setCursor(Math.max(0, buildTree(r?.buckets ?? []).length - 1)) // open on the newest entry
setMode('timeline')
})
.catch((e: unknown) => alive && setErr(rpcErrorMessage(e)))
return () => {
alive = false
}
}, [gw, cols, chartRows, reloadKey])
const tree = buildTree(data?.buckets ?? [])
const activeRow = tree[Math.min(cursor, Math.max(0, tree.length - 1))]
const activeNode = activeRow?.kind === 'node' ? activeRow.node : undefined
const activeBucket = activeRow && activeRow.kind !== 'gap' ? activeRow.bucket : undefined
const doDelete = () => {
const node = activeNode
if (!node) {
return
}
setBusy(true)
gw.request<MutationResult>('learning.delete', { id: node.id })
.then(res => {
setNotice(res.message)
if (res.ok) {
setMode('timeline')
setReloadKey(k => k + 1)
}
})
.catch((e: unknown) => setNotice(rpcErrorMessage(e)))
.finally(() => {
setBusy(false)
setConfirmDelete(false)
})
}
const doEdit = async () => {
const node = activeNode
if (!node) {
return
}
setBusy(true)
try {
const detail = await gw.request<NodeDetail>('learning.detail', { id: node.id })
if (!detail.ok || detail.content == null) {
return setNotice(detail.message || 'cannot edit')
}
const edited = await openInEditor(detail.content, detail.kind === 'skill' ? '.md' : '.txt')
if (edited == null || edited.trim() === detail.content.trim()) {
return setNotice('no changes')
}
const res = await gw.request<MutationResult>('learning.edit', { content: edited, id: node.id })
setNotice(res.message)
if (res.ok) {
setReloadKey(k => k + 1)
}
} catch (e) {
setNotice(rpcErrorMessage(e))
} finally {
setBusy(false)
}
}
useEffect(() => {
if (mode === 'item') {
itemScroll.current?.scrollTo(0)
setTick(x => x + 1)
}
}, [mode, cursor])
const scrollItem = (dy: number) => {
itemScroll.current?.scrollBy(dy)
setTick(x => x + 1)
}
// Cursor only ever rests on real rows — gaps are visual padding.
const stepRow = (from: number, dir: -1 | 1) => {
let i = from + dir
while (tree[i]?.kind === 'gap') {
i += dir
}
return i >= 0 && i < tree.length ? i : from
}
const snapRow = (i: number) => {
const c = Math.max(0, Math.min(tree.length - 1, i))
if (tree[c]?.kind !== 'gap') {
return c
}
return stepRow(c, 1) === c ? stepRow(c, -1) : stepRow(c, 1)
}
useInput((ch, key) => {
if (busy) {
return
}
// Pending delete confirmation swallows the next key (y confirms, else cancel).
if (confirmDelete) {
if (ch === 'y' || ch === 'Y') {
return doDelete()
}
return setConfirmDelete(false)
}
const back = key.escape || key.leftArrow || ch === 'h'
if (ch === 'q') {
return onClose()
}
// Edit / delete work in both modes whenever a node is selected.
if (activeNode && ch === 'd' && !key.ctrl) {
setNotice('')
return setConfirmDelete(true)
}
if (activeNode && ch === 'e') {
setNotice('')
return void doEdit()
}
if (mode === 'item') {
if (back) {
return setMode('timeline')
}
if (key.upArrow || ch === 'k') {
return scrollItem(-2)
}
if (key.downArrow || ch === 'j') {
return scrollItem(2)
}
if (key.pageUp || (key.ctrl && ch === 'u')) {
return scrollItem(-page)
}
if (key.pageDown || (key.ctrl && ch === 'd') || ch === ' ') {
return scrollItem(page)
}
if (ch === 'g') {
itemScroll.current?.scrollTo(0)
return setTick(x => x + 1)
}
if (ch === 'G') {
itemScroll.current?.scrollToBottom()
return setTick(x => x + 1)
}
return
}
if (back) {
return onClose()
}
// Only memories carry body text; everything else is already fully shown
// inline in the tree, so don't drill into an empty page.
if ((key.return || key.rightArrow || ch === 'l') && activeNode?.body) {
return setMode('item')
}
if (key.upArrow || ch === 'k') {
return setCursor(v => stepRow(v, -1))
}
if (key.downArrow || ch === 'j') {
return setCursor(v => stepRow(v, 1))
}
if (key.pageUp || (key.ctrl && ch === 'u')) {
return setCursor(v => snapRow(v - page))
}
if (key.pageDown || (key.ctrl && ch === 'd')) {
return setCursor(v => snapRow(v + page))
}
if (ch === 'g') {
return setCursor(0)
}
if (ch === 'G') {
return setCursor(Math.max(0, tree.length - 1))
}
})
if (err) {
return (
<Shell t={t}>
<Text color={t.color.error}>error: {err}</Text>
</Shell>
)
}
if (!data) {
return (
<Shell t={t}>
<Text color={t.color.muted}>assembling your learning map…</Text>
</Shell>
)
}
if (!data.count) {
return (
<Shell t={t}>
<Text color={t.color.muted}>
No learning yet — your learned skills and memories will start mapping out here as you use Hermes.
</Text>
</Shell>
)
}
// ── Item: a single memory, body scrolled via the shared ScrollBox ──
if (mode === 'item' && activeBucket && activeNode) {
const body = activeNode.body ? activeNode.body.split(/\r?\n/) : ['No additional detail recorded yet.']
return (
<Box alignItems="stretch" flexDirection="column" flexGrow={1} paddingX={1} paddingY={1}>
<Box flexDirection="column" marginBottom={1}>
<Text wrap="truncate-end">
<Text bold color={fadeInk(palette, activeNode.style, 1)}>
{activeNode.glyph} {activeNode.fullLabel || activeNode.label}
</Text>
</Text>
<Text color={t.color.muted}>
{activeBucket.label} · {activeNode.meta}
</Text>
</Box>
<Box flexDirection="row" flexGrow={1} flexShrink={1} minHeight={0}>
<ScrollBox flexDirection="column" flexGrow={1} flexShrink={1} ref={itemScroll}>
<Box flexDirection="column" paddingBottom={2} paddingRight={1}>
{body.map((line, i) => (
<Text color={t.color.text} key={i} wrap="wrap">
{line || ' '}
</Text>
))}
</Box>
</ScrollBox>
<NoSelect flexShrink={0} marginLeft={1}>
<OverlayScrollbar scrollRef={itemScroll} t={t} tick={tick} />
</NoSelect>
</Box>
<Footer>
<StatusLines confirm={confirmDelete} label={activeNode.fullLabel || activeNode.label} notice={notice} t={t} />
<Hint t={t}>↑↓/jk scroll · PgUp/PgDn page · e edit · d delete · Esc/← back · q close</Hint>
</Footer>
</Box>
)
}
// ── Timeline: static chart overview + a chronological slice/item tree ──
const axisGap = Math.max(1, cols - 2 - data.axis.start.length - data.axis.end.length)
const dataGrid = data.frames.at(-1)?.grid.filter(r => !rowText(r).trimStart().startsWith('trajectory')) ?? []
const chartGrid = dataGrid.slice(-MAX_CHART_ROWS)
const listH = Math.max(3, rows - chartGrid.length - (data.categories?.length ? 11 : 10))
const start = windowStart(cursor, tree.length, listH)
return (
<Box alignItems="stretch" flexDirection="column" flexGrow={1} paddingX={1} paddingY={1}>
<Box flexDirection="column" marginBottom={1}>
<Text wrap="truncate-end">
<Text bold color={t.color.primary}>
✦ Journey
</Text>
<Text color={t.color.muted}> learned skills &amp; memories over time</Text>
</Text>
<Text wrap="wrap">
{data.legend.map((item, i) => (
<Text key={item.label}>
{i ? ' ' : ''}
<Text color={fadeInk(palette, item.style ?? 'dim', 1)}>{item.glyph} </Text>
<Text color={t.color.muted}>{item.label}</Text>
</Text>
))}
</Text>
{data.categories?.length ? (
<Text wrap="wrap">
{data.categories.map((item, i) => (
<Text key={item.label}>
{i ? ' ' : ''}
<Text color={item.color ? fadeHex(palette, item.color, 1) : t.color.muted}>{item.glyph} </Text>
<Text color={t.color.muted}>{item.label}</Text>
</Text>
))}
</Text>
) : null}
</Box>
<Box flexDirection="column" marginBottom={1}>
{chartGrid.map((row, i) => (
<ChartRow key={i} palette={palette} row={row} />
))}
<Text color={t.color.muted}>
{data.axis.start}
{' '.repeat(axisGap)}
{data.axis.end}
</Text>
</Box>
<Box flexDirection="column" flexGrow={1} flexShrink={1} minHeight={0} overflow="hidden">
{tree.slice(start, start + listH).map((row, i) => (
<TreeLine active={start + i === cursor} key={start + i} palette={palette} row={row} t={t} />
))}
</Box>
<Footer>
<StatusLines
confirm={confirmDelete}
label={activeNode ? activeNode.fullLabel || activeNode.label : ''}
notice={notice}
t={t}
/>
{!confirmDelete && !notice && data.summary.length ? <Hint t={t}>{data.summary.join(' · ')}</Hint> : null}
<Hint t={t}>
↑↓/jk move{activeNode?.body ? ' · Enter/→ open' : ''}
{activeNode ? ' · e edit · d delete' : ''} · g/G top/bottom · q close
</Hint>
</Footer>
</Box>
)
}
function TreeLine({ active, palette, row, t }: { active: boolean; palette: StarmapPalette; row: TreeRow; t: Theme }) {
if (row.kind === 'gap') {
return <Text> </Text>
}
if (row.kind === 'slice') {
const { bucket } = row
return (
<ListRow
active={active}
cells={[
{ color: bucket.color ? fadeHex(palette, bucket.color, 0.85) : t.color.label, text: bucket.label },
{
color: t.color.muted,
text: ` · ${bucket.skills} skills · ${bucket.memories} memories${bucket.category ? ` · ${bucket.category}` : ''}`
}
]}
t={t}
/>
)
}
const { last, node } = row
return (
<ListRow
active={active}
cells={[
{ color: t.color.muted, text: ` ${last ? '└─' : '├─'} ` },
{ color: fadeInk(palette, node.style, 1), text: `${node.glyph} ${node.fullLabel || node.label}` },
{ color: t.color.muted, text: ` ${node.meta}${node.body ? ' ›' : ''}` }
]}
t={t}
/>
)
}
function Shell({ children, t }: { children: React.ReactNode; t: Theme }) {
return (
<Box flexDirection="column" paddingX={1} paddingY={1}>
<Text bold color={t.color.primary}>
✦ Journey
</Text>
{children}
<Text color={t.color.muted}>Esc/q close</Text>
</Box>
)
}
function StatusLines({ confirm, label, notice, t }: { confirm: boolean; label: string; notice: string; t: Theme }) {
if (confirm) {
return <Text color={t.color.error}>delete {label}? y/N</Text>
}
if (notice) {
return <Text color={t.color.accent}>{notice}</Text>
}
return null
}
function Footer({ children }: { children: React.ReactNode }) {
return (
<Box flexDirection="column" marginTop={1}>
{children}
</Box>
)
}
function Hint({ children, t }: { children: React.ReactNode; t: Theme }) {
return (
<Text color={t.color.muted} wrap="truncate-end">
{children}
</Text>
)
}
+172
View File
@@ -0,0 +1,172 @@
import { Box, Text } from '@hermes/ink'
import { useEffect, useState } from 'react'
import { mix } from '../lib/color.js'
/**
* Animated ASCII loaders — THE loading-state primitives (session panel
* skeleton, widget apps via the SDK). A highlight band sweeps across block
* runs; rows offset their phase for a diagonal shimmer. One interval per
* composition (the parent ticks, rows are pure), colors are caller-owned
* theme tones — never hardcoded.
*/
const BAND = 7
/** Pure band math: [pre, band, post] cell widths for a sweep at `phase`.
* The band enters from off-left and exits off-right, wrapping. */
export function shimmerSegments(width: number, phase: number, band = BAND): [number, number, number] {
const cycle = width + band
const start = (((phase % cycle) + cycle) % cycle) - band
const from = Math.max(0, start)
const to = Math.min(width, start + band)
return to <= from ? [width, 0, 0] : [from, to - from, width - to]
}
/** One shimmering run. Controlled: the parent owns `phase` so sibling rows
* stay in lockstep (offset it per row for the diagonal). */
export function Shimmer({
char = '▁',
color,
highlight,
phase,
width
}: {
char?: string
color: string
highlight: string
phase: number
width: number
}) {
const [pre, band, post] = shimmerSegments(width, phase)
return (
<Text>
{pre > 0 && <Text color={color}>{char.repeat(pre)}</Text>}
{band > 0 && <Text color={highlight}>{char.repeat(band)}</Text>}
{post > 0 && <Text color={color}>{char.repeat(post)}</Text>}
</Text>
)
}
// ── Shared shimmer clock ─────────────────────────────────────────────
//
// ONE interval drives every mounted shimmer composition (review on #20379,
// finding 5: the session panel could mount independent 90 ms intervals for
// lazy skills AND lazy tools — ~22 state updates/sec on an otherwise-idle
// TUI). Subscribers share the tick; the interval exists only while
// subscribers do, and all updates land in one timer callback so React
// batches them into a single render pass.
const TICK_MS = 90
/** Animation budget per mount. A lazy watch session can stay lazy
* indefinitely — after the budget the skeleton freezes in place (still
* reads as "loading") instead of repainting forever. */
export const SHIMMER_ANIMATE_MS = 30_000
const clockListeners = new Set<(phase: number) => void>()
let clockId: NodeJS.Timeout | null = null
let clockPhase = 0
/** Subscribe to the shared clock (exported for tests). Returns unsubscribe;
* the interval stops with the last subscriber. */
export function subscribeShimmerClock(fn: (phase: number) => void): () => void {
clockListeners.add(fn)
if (!clockId) {
clockId = setInterval(() => {
clockPhase += 1
for (const listener of clockListeners) {
listener(clockPhase)
}
}, TICK_MS)
clockId.unref?.()
}
return () => {
clockListeners.delete(fn)
if (!clockListeners.size && clockId) {
clearInterval(clockId)
clockId = null
}
}
}
/** Phase from the shared clock, bounded: stops advancing (and stops costing
* renders) after `animateMs`. */
export function useShimmerPhase(animateMs = SHIMMER_ANIMATE_MS): number {
const [phase, setPhase] = useState(clockPhase)
useEffect(() => {
const startedAt = Date.now()
let unsubscribe: (() => void) | null = subscribeShimmerClock(next => {
if (Date.now() - startedAt >= animateMs) {
unsubscribe?.()
unsubscribe = null
return
}
setPhase(next)
})
return () => {
unsubscribe?.()
unsubscribe = null
}
}, [animateMs])
return phase
}
/** Skeleton rows shaped like `label: value` content, diagonal shimmer.
*
* Ergonomic for generated code (the primary author is an agent):
* - `rows` — explicit `[labelWidth, valueWidth][]` mirroring real layout,
* OR just a count (row widths derive from `width`, staggered).
* - colors — explicit `color`/`highlight`, OR pass a theme `t` and they
* derive (muted-toward-surface base, label highlight). */
export function ShimmerRows({
color,
highlight,
rows,
t,
width = 24
}: {
color?: string
highlight?: string
rows: number | readonly (readonly [number, number])[]
t?: { color: { completionBg: string; label: string; muted: string } }
width?: number
}) {
const phase = useShimmerPhase()
const base = color ?? (t ? mix(t.color.muted, t.color.completionBg, 0.5) : '#808080')
const glow = highlight ?? t?.color.label ?? '#a0a0a0'
const spec: readonly (readonly [number, number])[] =
typeof rows === 'number'
? Array.from({ length: Math.max(1, rows) }, (_, i) => {
const label = Math.max(4, Math.round(width * 0.3) - (i % 3))
return [label, Math.max(4, width - label - 1)] as const
})
: rows
return (
<Box flexDirection="column">
{spec.map(([labelWidth, valueWidth], i) => (
<Text key={i}>
<Shimmer color={base} highlight={glow} phase={phase - i * 2} width={labelWidth} />
<Text> </Text>
<Shimmer color={base} highlight={glow} phase={phase - i * 2 - labelWidth} width={valueWidth} />
</Text>
))}
</Box>
)
}
File diff suppressed because it is too large. Load diff
+41
View File
@@ -0,0 +1,41 @@
import { Box, Text } from '@hermes/ink'
import { useState } from 'react'
import type { Theme } from '../theme.js'
import { TextInput } from './textInput.js'
export function MaskedPrompt({ cols = 80, icon, label, onSubmit, sub, t }: MaskedPromptProps) {
const [value, setValue] = useState('')
return (
<Box flexDirection="column">
<Text bold color={t.color.warn}>
{icon} {label}
</Text>
{sub && <Text color={t.color.muted}> {sub}</Text>}
<Box>
<Text color={t.color.label}>{'> '}</Text>
<TextInput
color={t.color.text}
columns={Math.max(20, cols - 6)}
mask="*"
onChange={setValue}
onSubmit={onSubmit}
value={value}
/>
</Box>
</Box>
)
}
interface MaskedPromptProps {
cols?: number
icon: string
label: string
onSubmit: (v: string) => void
sub?: string
t: Theme
}
+359
View File
@@ -0,0 +1,359 @@
import { Ansi, Box, NoSelect, Text } from '@hermes/ink'
import { memo, useState } from 'react'
import { TERMUX_TUI_MODE } from '../config/env.js'
import { LONG_MSG } from '../config/limits.js'
import { hasLeadGap } from '../domain/blockLayout.js'
import { splitComposerHighlights } from '../domain/composerHighlights.js'
import { sectionMode } from '../domain/details.js'
import { userDisplay } from '../domain/messages.js'
import { ROLE } from '../domain/roles.js'
import { transcriptBodyWidth, transcriptGutterWidth } from '../lib/inputMetrics.js'
import {
boundedLiveRenderText,
compactPreview,
hasAnsi,
isPasteBackedText,
sanitizeAnsiForRender,
stripAnsi
} from '../lib/text.js'
import type { Theme } from '../theme.js'
import type { ActiveTool, DetailsMode, Msg, SectionVisibility } from '../types.js'
import { Md } from './markdown.js'
import { StreamingMd } from './streamingMarkdown.js'
import { ToolTrail } from './thinking.js'
import { TodoPanel } from './todoPanel.js'
// Collapse threshold for long system messages (system prompt etc.)
const SYSTEM_COLLAPSE_CHARS = 400
// `display.timestamps` label — same HH:MM shape the classic CLI's default
// `display.timestamp_format` ("%H:%M") produces on its message labels, so
// one config key reads identically across surfaces (#41531).
export const fmtMsgTimestamp = (createdAt: number | undefined): null | string => {
if (typeof createdAt !== 'number' || !Number.isFinite(createdAt) || createdAt <= 0) {
return null
}
const date = new Date(createdAt * 1000)
if (Number.isNaN(date.getTime())) {
return null
}
const hh = String(date.getHours()).padStart(2, '0')
const mm = String(date.getMinutes()).padStart(2, '0')
return `[${hh}:${mm}]`
}
export const MessageLine = memo(function MessageLine({
cols,
compact,
detailsMode = 'collapsed',
detailsModeCommandOverride = false,
isStreaming = false,
liveDetails = false,
msg,
prev,
reasoningActive = false,
sections,
t,
timestamps = false,
tools = []
}: MessageLineProps) {
// Per-section overrides win over the global mode, so resolve each section
// we might consume here once and gate visibility on the *content-bearing*
// sections only — never on the global mode. A `trail` message feeds Tool
// calls + Activity; an assistant message with thinking/tools metadata
// feeds Thinking + Tool calls. Gating on every section would let
// `thinking` (expanded by default) keep an empty wrapper alive when only
// `tools` is hidden — exactly the empty-Box bug Copilot caught.
const thinkingMode = sectionMode('thinking', detailsMode, sections, detailsModeCommandOverride)
const toolsMode = sectionMode('tools', detailsMode, sections, detailsModeCommandOverride)
const activityMode = sectionMode('activity', detailsMode, sections, detailsModeCommandOverride)
const thinking = msg.thinking?.trim() ?? ''
// One blank line above this block iff it opens a new visual group relative
// to the block directly above it (`prev`) — the flex-grouping rule. Applied
// intrinsically on each *rendered* element (not via an outer wrapper) so a
// block that renders nothing — e.g. a tool trail hidden by /details — emits
// no floating gap. Streaming-safe: the gap is derived from the stable
// predecessor, never this block's own live content. See domain/blockLayout.
const leadGap = hasLeadGap(prev, msg)
// Collapse toggle for long system messages
const systemIsLong = msg.role === 'system' && msg.text.length > SYSTEM_COLLAPSE_CHARS
const [systemOpen, setSystemOpen] = useState(false)
if (msg.kind === 'trail' && msg.todos?.length) {
return (
<TodoPanel
defaultCollapsed={msg.todoCollapsedByDefault}
incomplete={msg.todoIncomplete}
t={t}
todos={msg.todos}
/>
)
}
if (msg.kind === 'trail' && (msg.tools?.length || tools.length || thinking)) {
return shouldShowThinkingTrail(msg, thinkingMode, toolsMode, activityMode) ? (
<Box flexDirection="column" marginTop={leadGap ? 1 : 0}>
<ToolTrail
commandOverride={detailsModeCommandOverride}
detailsMode={detailsMode}
preferExpandedThinking={liveDetails}
reasoning={thinking}
reasoningActive={reasoningActive}
reasoningAlwaysVisible={msg.isMoaReference}
reasoningTokens={msg.thinkingTokens}
sections={sections}
t={t}
tools={tools}
toolTokens={msg.toolTokens}
trail={msg.tools ?? []}
/>
</Box>
) : null
}
// A trail with no reasoning, tools, or todos to show (e.g. the finalDetails
// segment message.complete appends carrying only a token tally) has nothing
// to draw — render nothing instead of an empty gutter row. blockRenders()
// agrees, so it also stays transparent to grouping and never opens a gap.
if (msg.kind === 'trail') {
return null
}
if (msg.role === 'tool') {
const maxChars = Math.max(24, cols - 14)
const stripped = hasAnsi(msg.text) ? stripAnsi(msg.text) : msg.text
const safeAnsi = hasAnsi(msg.text) ? sanitizeAnsiForRender(msg.text) : msg.text
const preview = compactPreview(stripped, maxChars) || '(empty tool result)'
return (
<Box alignSelf="flex-start" borderColor={t.color.muted} borderStyle="round" marginLeft={3} paddingX={1}>
{hasAnsi(msg.text) ? (
<Text wrap="truncate-end">
<Ansi>{safeAnsi}</Ansi>
</Text>
) : (
<Text color={t.color.muted} wrap="truncate-end">
{preview}
</Text>
)}
</Box>
)
}
// Timeline events (model switches, delegation completions) render as
// dim ◈ markers with no gutter — not as opaque user messages.
if (msg.kind === 'event') {
const eventGutterWidth = transcriptGutterWidth('system', t.brand.prompt)
return (
<Box marginBottom={1} marginTop={leadGap ? 1 : 0}>
<NoSelect flexShrink={0} fromLeftEdge width={eventGutterWidth}>
<Text> </Text>
</NoSelect>
<Text color={t.color.muted} dimColor>
◈ {msg.text}
</Text>
</Box>
)
}
const { body, glyph, prefix } = ROLE[msg.role](t)
const gutterWidth = transcriptGutterWidth(msg.role, t.brand.prompt)
const showDetails =
(toolsMode !== 'hidden' && Boolean(msg.tools?.length)) || (thinkingMode !== 'hidden' && Boolean(thinking))
const showResponseSeparator = shouldShowResponseSeparator(msg, showDetails)
const content = (() => {
if (msg.kind === 'slash') {
return <Text color={t.color.muted}>{msg.text}</Text>
}
// ── Collapsible long system message (system prompt, AGENTS.md, etc.) ──
// MUST come before the hasAnsi check — system messages from the backend
// contain Rich markup escape codes that would otherwise hit <Ansi> full render.
if (systemIsLong) {
const firstLine = (msg.text.split('\n')[0] ?? '').trim().slice(0, 120) || '(system message)'
return (
<Box flexDirection="column">
<Box onClick={() => setSystemOpen(v => !v)}>
<Text color={t.color.accent}>{systemOpen ? '▾ ' : '▸ '}</Text>
<Text color={t.color.muted}>{firstLine}</Text>
<Text color={t.color.muted} dimColor>
{' — '}
{msg.text.length.toLocaleString()} chars
</Text>
</Box>
{systemOpen && <Ansi>{sanitizeAnsiForRender(msg.text)}</Ansi>}
</Box>
)
}
if (msg.role !== 'user' && hasAnsi(msg.text)) {
return <Ansi>{sanitizeAnsiForRender(msg.text)}</Ansi>
}
if (msg.role === 'assistant') {
const bodyWidth = transcriptBodyWidth(cols, msg.role, t.brand.prompt, TERMUX_TUI_MODE)
return isStreaming ? (
// Incremental markdown: split at the last stable block boundary so
// only the in-flight tail re-tokenizes per delta. See
// streamingMarkdown.tsx for the cost model.
<StreamingMd cols={bodyWidth} compact={compact} t={t} text={boundedLiveRenderText(msg.text)} />
) : (
<Md cols={bodyWidth} compact={compact} t={t} text={msg.text} />
)
}
if (msg.role === 'user' && msg.text.length > LONG_MSG && isPasteBackedText(msg.text)) {
const [head, ...rest] = userDisplay(msg.text).split('[long message]')
return (
<Text color={body}>
{head}
<Text color={t.color.muted} dimColor>
[long message]
</Text>
{rest.join('')}
</Text>
)
}
// A skill, `@ref`, or attachment token the user put in the message keeps
// the accent it wore in the composer, instead of flattening back into the
// body text.
if (msg.role === 'user') {
const segments = splitComposerHighlights(msg.text)
return (
<Text {...(body ? { color: body } : {})}>
{segments.map((segment, i) =>
segment.ref ? (
<Text color={t.color.accent} key={i}>
{segment.text}
</Text>
) : (
segment.text
)
)}
</Text>
)
}
return <Text {...(body ? { color: body } : {})}>{msg.text}</Text>
})()
// Diff segments (emitted by pushInlineDiffSegment between narration
// segments) keep a blank line on both sides so the patch doesn't butt up
// against the prose around it.
const isDiffSegment = msg.kind === 'diff'
// `display.timestamps`: dim [HH:MM] beside the gutter glyph on user and
// assistant rows only — event/trail/system chrome stays unstamped, matching
// the classic CLI which stamps its user/assistant labels (#41531).
const stamp =
timestamps && (msg.role === 'user' || msg.role === 'assistant') && !msg.kind ? fmtMsgTimestamp(msg.createdAt) : null
return (
<Box
flexDirection="column"
marginBottom={msg.role === 'user' || isDiffSegment ? 1 : 0}
marginTop={msg.role === 'user' || msg.kind === 'slash' || isDiffSegment || leadGap ? 1 : 0}
>
{showDetails && (
<Box flexDirection="column" marginBottom={1}>
<ToolTrail
commandOverride={detailsModeCommandOverride}
detailsMode={detailsMode}
preferExpandedThinking={liveDetails}
reasoning={thinking}
reasoningActive={reasoningActive}
reasoningTokens={msg.thinkingTokens}
sections={sections}
t={t}
toolTokens={msg.toolTokens}
trail={msg.tools}
/>
</Box>
)}
{showResponseSeparator && (
<Box marginBottom={1}>
<NoSelect flexShrink={0} fromLeftEdge width={gutterWidth}>
<Text color={t.color.border}>└─ </Text>
</NoSelect>
<Text color={t.color.muted} dim>
Response
</Text>
</Box>
)}
{stamp && (
<Box>
<NoSelect flexShrink={0} fromLeftEdge width={gutterWidth}>
<Text> </Text>
</NoSelect>
<Text color={t.color.muted} dim>
{stamp}
</Text>
</Box>
)}
<Box>
<NoSelect flexShrink={0} fromLeftEdge width={gutterWidth}>
<Text bold={msg.role === 'user'} color={prefix}>
{glyph}{' '}
</Text>
</NoSelect>
<Box width={transcriptBodyWidth(cols, msg.role, t.brand.prompt, TERMUX_TUI_MODE)}>{content}</Box>
</Box>
</Box>
)
})
export const shouldShowResponseSeparator = (msg: Msg, showDetails: boolean): boolean =>
msg.role === 'assistant' && showDetails && /\S/.test(msg.text)
// A MoA reference block (msg.isMoaReference) is the user-facing
// mixture-of-agents process the user opted into, not private model
// reasoning — it must stay visible even when every other trail section is
// hidden (#64657).
export const shouldShowThinkingTrail = (
msg: Msg,
thinkingMode: DetailsMode,
toolsMode: DetailsMode,
activityMode: DetailsMode
): boolean =>
Boolean(msg.isMoaReference) || thinkingMode !== 'hidden' || toolsMode !== 'hidden' || activityMode !== 'hidden'
interface MessageLineProps {
cols: number
compact?: boolean
detailsMode?: DetailsMode
detailsModeCommandOverride?: boolean
isStreaming?: boolean
liveDetails?: boolean
msg: Msg
// The block rendered directly above this one. Drives the group-boundary
// lead gap (see domain/blockLayout.ts::hasLeadGap). Undefined at the top of
// the transcript or when spacing is irrelevant.
prev?: Msg
reasoningActive?: boolean
sections?: SectionVisibility
t: Theme
/** `display.timestamps` — dim [HH:MM] label on user/assistant rows. */
timestamps?: boolean
tools?: ActiveTool[]
}
+710
View File
@@ -0,0 +1,710 @@
import { Box, Text, useInput, useStdout } from '@hermes/ink'
import { useEffect, useMemo, useState } from 'react'
import { providerDisplayNames } from '../domain/providers.js'
import { TUI_SESSION_MODEL_FLAG } from '../domain/slash.js'
import type { GatewayClient } from '../gatewayClient.js'
import type { ModelOptionProvider, ModelOptionsResponse } from '../gatewayTypes.js'
import { fuzzyRank } from '../lib/fuzzy.js'
import { modelSearchText } from '../lib/model-search-text.js'
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import type { Theme } from '../theme.js'
import { OverlayHint, useOverlayKeys, windowItems } from './overlayControls.js'
import { chipRowProps, clampOverlayWidth } from './overlayPrimitives.js'
const VISIBLE = 12
const MIN_WIDTH = 40
const MAX_WIDTH = 90
type Stage = 'provider' | 'key' | 'model' | 'disconnect'
type ProviderRow = { name: string; provider: ModelOptionProvider }
export function providerIndexAfterClearingFilter(
providerRows: ProviderRow[],
provider: ModelOptionProvider | undefined
) {
if (!provider) {
return -1
}
return providerRows.findIndex(row => row.provider.slug === provider.slug)
}
export function ModelPicker({
allowPersistGlobal = true,
gw,
initialRefresh = false,
maxWidth,
onCancel,
onSelect,
sessionId,
t
}: ModelPickerProps) {
const [providers, setProviders] = useState<ModelOptionProvider[]>([])
const [currentModel, setCurrentModel] = useState('')
const [err, setErr] = useState('')
const [loading, setLoading] = useState(true)
const [persistGlobal, setPersistGlobal] = useState(false)
const [providerIdx, setProviderIdx] = useState(0)
const [modelIdx, setModelIdx] = useState(0)
const [stage, setStage] = useState<Stage>('provider')
const [keyInput, setKeyInput] = useState('')
const [keySaving, setKeySaving] = useState(false)
const [keyError, setKeyError] = useState('')
// Type-to-filter query, scoped per stage (cleared on stage change).
const [filter, setFilter] = useState('')
const { stdout } = useStdout()
// Pin the picker to a stable width so the FloatBox parent (which shrinks-
// to-fit with alignSelf="flex-start") doesn't resize as long provider /
// model names scroll into view, and so `wrap="truncate-end"` on each row
// has an actual constraint to truncate against. Optional maxWidth lets
// grid layouts hand the picker its cell budget.
const preferredWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))
const width = clampOverlayWidth(preferredWidth, maxWidth)
useEffect(() => {
gw.request<ModelOptionsResponse>('model.options', {
...(sessionId ? { session_id: sessionId } : {}),
...(initialRefresh ? { refresh: true } : {}),
// The TUI picker shows the full provider universe with setup
// affordances ("paste KEY to activate"), so opt into unconfigured
// rows — the backend now defaults to the configured subset for
// desktop chat pickers (#56974).
include_unconfigured: true
})
.then(raw => {
const r = asRpcResult<ModelOptionsResponse>(raw)
if (!r) {
setErr('invalid response: model.options')
setLoading(false)
return
}
const next = r.providers ?? []
setProviders(next)
setCurrentModel(String(r.model ?? ''))
setProviderIdx(
Math.max(
0,
next.findIndex(p => p.is_current)
)
)
setModelIdx(0)
setStage('provider')
setErr('')
setLoading(false)
})
.catch((e: unknown) => {
setErr(rpcErrorMessage(e))
setLoading(false)
})
}, [gw, initialRefresh, sessionId])
const names = useMemo(() => providerDisplayNames(providers), [providers])
// Provider rows carry their display name so fuzzy filtering can match on
// name + slug while keeping the name/provider pairing intact across ranking.
const providerRows = useMemo(
() => providers.map((p, i) => ({ provider: p, name: names[i] ?? p.name ?? p.slug })),
[providers, names]
)
// providerIdx / modelIdx always index into the *displayed* (filtered) lists.
// With an empty filter the filtered list equals the full list, so navigation
// behaves exactly as before. Filtering only applies on the relevant stage.
const filteredProviderRows = useMemo(() => {
if (stage !== 'provider' || !filter.trim()) {
return providerRows
}
return fuzzyRank(
providerRows,
filter,
row => `${row.name} ${row.provider.slug} ${(row.provider.models ?? []).join(' ')}`
).map(r => r.item)
}, [providerRows, filter, stage])
const provider = filteredProviderRows[providerIdx]?.provider
const allModels = useMemo(() => provider?.models ?? [], [provider])
const filteredModels = useMemo(() => {
if (stage !== 'model' || !filter.trim()) {
return allModels
}
// modelSearchText adds aliases for brand-less wire ids (e.g. Kimi
// Coding `k3` still matches a "kimi" query).
return fuzzyRank(allModels, filter, modelSearchText).map(r => r.item)
}, [allModels, filter, stage])
const models = filteredModels
// Keep the active selection within the (possibly filtered) list bounds.
useEffect(() => {
if (providerIdx >= filteredProviderRows.length && filteredProviderRows.length > 0) {
setProviderIdx(0)
}
}, [filteredProviderRows.length, providerIdx])
useEffect(() => {
if (modelIdx >= models.length && models.length > 0) {
setModelIdx(0)
}
}, [models.length, modelIdx])
const back = () => {
// Esc first clears an active filter on the list stages, before navigating.
if ((stage === 'provider' || stage === 'model') && filter.trim()) {
// Preserve the selected provider across filter clear (same fix as
// Enter→key/model and Ctrl+D transitions above).
const fullProviderIdx = providerIndexAfterClearingFilter(providerRows, provider)
if (fullProviderIdx >= 0) {
setProviderIdx(fullProviderIdx)
} else if (stage === 'provider') {
setProviderIdx(0)
}
setFilter('')
setModelIdx(0)
return
}
if (stage === 'model' || stage === 'key' || stage === 'disconnect') {
setStage('provider')
setModelIdx(0)
setKeyInput('')
setKeyError('')
setKeySaving(false)
setFilter('')
return
}
onCancel()
}
// On the list stages we capture printable keys (including 'q') into the
// filter, so the shared overlay q/Esc handler must yield to our own handler.
const listStage = stage === 'provider' || stage === 'model'
useOverlayKeys({ disabled: listStage, onBack: back, onClose: onCancel })
useInput((ch, key) => {
// Key entry stage handles its own input
if (stage === 'key') {
if (keySaving) {
return
}
if (key.return) {
if (!keyInput.trim()) {
return
}
setKeySaving(true)
setKeyError('')
gw.request<{ provider?: ModelOptionProvider }>('model.save_key', {
slug: provider?.slug,
api_key: keyInput.trim(),
...(sessionId ? { session_id: sessionId } : {})
})
.then(raw => {
const r = asRpcResult<{ provider?: ModelOptionProvider }>(raw)
if (!r?.provider) {
setKeyError('failed to save key')
setKeySaving(false)
return
}
// Update the provider in our list with fresh data
setProviders(prev => prev.map(p => (p.slug === r.provider!.slug ? r.provider! : p)))
setKeyInput('')
setKeySaving(false)
setStage('model')
setModelIdx(0)
})
.catch((e: unknown) => {
setKeyError(rpcErrorMessage(e))
setKeySaving(false)
})
return
}
if (key.backspace || key.delete) {
setKeyInput(v => v.slice(0, -1))
return
}
// ctrl+u clears input
if (ch === '\u0015') {
setKeyInput('')
return
}
if (ch && !key.ctrl && !key.meta) {
setKeyInput(v => v + ch)
}
return
}
// Disconnect confirmation stage
if (stage === 'disconnect') {
if (ch.toLowerCase() === 'y' || key.return) {
if (!provider) {
setStage('provider')
return
}
setKeySaving(true)
gw.request<{ disconnected?: boolean }>('model.disconnect', {
slug: provider.slug,
...(sessionId ? { session_id: sessionId } : {})
})
.then(raw => {
const r = asRpcResult<{ disconnected?: boolean }>(raw)
if (r?.disconnected) {
// Mark provider as unauthenticated in local state
setProviders(prev =>
prev.map(p =>
p.slug === provider.slug
? {
...p,
authenticated: false,
models: [],
total_models: 0,
warning: p.key_env ? `paste ${p.key_env} to activate` : 'run `hermes model` to configure'
}
: p
)
)
}
setKeySaving(false)
setStage('provider')
})
.catch(() => {
setKeySaving(false)
setStage('provider')
})
return
}
if (ch.toLowerCase() === 'n' || key.escape) {
setStage('provider')
return
}
return
}
// List-stage Esc/q handling (overlay keys are disabled while on a list
// stage so 'q' can be typed into the filter).
if (key.escape) {
back()
return
}
if (ch === 'q' && !filter) {
onCancel()
return
}
const count = stage === 'provider' ? filteredProviderRows.length : models.length
const sel = stage === 'provider' ? providerIdx : modelIdx
const setSel = stage === 'provider' ? setProviderIdx : setModelIdx
if (key.upArrow && sel > 0) {
setSel(v => v - 1)
return
}
if (key.downArrow && sel < count - 1) {
setSel(v => v + 1)
return
}
if (key.return) {
if (stage === 'provider') {
if (!provider) {
return
}
if (provider.authenticated === false) {
// api_key providers: prompt for key inline
if (provider.auth_type === 'api_key' && provider.key_env) {
const fullProviderIdx = providerIndexAfterClearingFilter(providerRows, provider)
if (fullProviderIdx >= 0) {
setProviderIdx(fullProviderIdx)
}
setStage('key')
setKeyInput('')
setKeyError('')
setFilter('')
}
// Other auth types: no-op (warning shown tells them to run hermes model)
return
}
const fullProviderIdx = providerIndexAfterClearingFilter(providerRows, provider)
if (fullProviderIdx >= 0) {
setProviderIdx(fullProviderIdx)
}
setStage('model')
setModelIdx(0)
setFilter('')
return
}
const model = models[modelIdx]
if (provider && model) {
onSelect(
`${model} --provider ${provider.slug}${allowPersistGlobal && persistGlobal ? ' --global' : ` ${TUI_SESSION_MODEL_FLAG}`}`
)
} else {
setStage('provider')
}
return
}
// Backspace removes the last filter character; Esc (above) clears a
// non-empty filter before navigating back.
if (key.backspace || key.delete) {
setFilter(v => v.slice(0, -1))
setSel(0)
return
}
// Ctrl+U clears the filter. (Ctrl held → ch is the key name 'u'.)
if (key.ctrl && ch === 'u') {
setFilter('')
setSel(0)
return
}
// Persist-global toggle moved to Ctrl+G so 'g' can be typed into the
// filter. With Ctrl held, @hermes/ink reports `ch` as the key name ('g'),
// not the raw control byte (see input-event.ts: input = ctrl ? name : seq).
if (allowPersistGlobal && key.ctrl && ch === 'g') {
setPersistGlobal(v => !v)
return
}
// Disconnect (Ctrl+D): only in provider stage, only for authenticated providers.
if (key.ctrl && ch === 'd' && stage === 'provider' && provider?.authenticated !== false) {
const fullProviderIdx = providerIndexAfterClearingFilter(providerRows, provider)
if (fullProviderIdx >= 0) {
setProviderIdx(fullProviderIdx)
}
setStage('disconnect')
setFilter('')
return
}
// Any other printable single character extends the filter.
if (ch && !key.ctrl && !key.meta && ch.length === 1 && ch >= ' ') {
setFilter(v => v + ch)
setSel(0)
}
})
if (loading) {
return <Text color={t.color.muted}>loading models…</Text>
}
if (err) {
return (
<Box flexDirection="column">
<Text color={t.color.label}>error: {err}</Text>
<OverlayHint t={t}>Esc/q cancel</OverlayHint>
</Box>
)
}
if (!providers.length) {
return (
<Box flexDirection="column">
<Text color={t.color.muted}>no providers available</Text>
<OverlayHint t={t}>Esc/q cancel</OverlayHint>
</Box>
)
}
// ── Key entry stage ──────────────────────────────────────────────────
if (stage === 'key' && provider) {
const masked = keyInput ? '•'.repeat(Math.min(keyInput.length, 40)) : ''
return (
<Box flexDirection="column" width={width}>
<Text bold color={t.color.accent} wrap="truncate-end">
Configure {provider.name}
</Text>
<Text color={t.color.muted} wrap="truncate-end">
Paste your API key below (saved to ~/.hermes/.env)
</Text>
<Text color={t.color.muted} wrap="truncate-end">
{' '}
</Text>
<Text color={t.color.muted} wrap="truncate-end">
{provider.key_env}:
</Text>
<Text color={t.color.accent} wrap="truncate-end">
{' '}
{masked || '(empty)'}
{keySaving ? '' : '▎'}
</Text>
<Text color={t.color.muted} wrap="truncate-end">
{' '}
</Text>
{keyError ? (
<Text color={t.color.label} wrap="truncate-end">
error: {keyError}
</Text>
) : keySaving ? (
<Text color={t.color.muted} wrap="truncate-end">
saving…
</Text>
) : (
<Text color={t.color.muted} wrap="truncate-end">
{' '}
</Text>
)}
<OverlayHint t={t}>Enter save · Ctrl+U clear · Esc back</OverlayHint>
</Box>
)
}
// ── Disconnect confirmation stage ─────────────────────────────────────
if (stage === 'disconnect' && provider) {
return (
<Box flexDirection="column" width={width}>
<Text bold color={t.color.accent} wrap="truncate-end">
Disconnect {provider.name}?
</Text>
<Text color={t.color.muted} wrap="truncate-end">
{' '}
</Text>
<Text color={t.color.muted} wrap="truncate-end">
This removes saved credentials for {provider.name}.
</Text>
<Text color={t.color.muted} wrap="truncate-end">
You can re-authenticate later by selecting it again.
</Text>
<Text color={t.color.muted} wrap="truncate-end">
{' '}
</Text>
{keySaving ? (
<Text color={t.color.muted} wrap="truncate-end">
disconnecting…
</Text>
) : (
<OverlayHint t={t}>y/Enter confirm · n/Esc cancel</OverlayHint>
)}
</Box>
)
}
// ── Provider selection stage ─────────────────────────────────────────
if (stage === 'provider') {
const rows = filteredProviderRows.map(({ provider: p, name }) => {
const authMark = p.authenticated === false ? '○' : p.is_current ? '*' : '●'
const modelCount = p.total_models ?? p.models?.length ?? 0
const suffix =
p.authenticated === false ? (p.auth_type === 'api_key' ? '(no key)' : '(needs setup)') : `${modelCount} models`
return `${authMark} ${name} · ${suffix}`
})
const { items, offset } = windowItems(rows, providerIdx, VISIBLE)
const noMatches = !!filter.trim() && rows.length === 0
return (
<Box flexDirection="column" width={width}>
<Text bold color={t.color.accent} wrap="truncate-end">
Select provider (step 1/2)
</Text>
<Text color={t.color.muted} wrap="truncate-end">
Full model IDs on the next step · Enter to continue
</Text>
<Text color={t.color.muted} wrap="truncate-end">
Current: {currentModel || '(unknown)'}
</Text>
<Text color={filter ? t.color.accent : t.color.muted} wrap="truncate-end">
{filter ? `filter: ${filter}▎` : 'type to filter · ↑/↓ select'}
</Text>
<Text color={t.color.label} wrap="truncate-end">
{provider?.warning ? `warning: ${provider.warning}` : ' '}
</Text>
<Text color={t.color.muted} wrap="truncate-end">
{offset > 0 ? ` ↑ ${offset} more` : ' '}
</Text>
{noMatches ? (
<Text color={t.color.muted} wrap="truncate-end">
no providers match
</Text>
) : (
Array.from({ length: VISIBLE }, (_, i) => {
const row = items[i]
const idx = offset + i
const p = filteredProviderRows[idx]?.provider
const dimmed = p?.authenticated === false
return row ? (
<Text
color={dimmed ? t.color.label : t.color.muted}
{...chipRowProps(t, providerIdx === idx)}
key={p?.slug ?? `row-${idx}`}
wrap="truncate-end"
>
{providerIdx === idx ? '▸ ' : ' '}
{idx + 1}. {row}
</Text>
) : (
<Text color={t.color.muted} key={`pad-${i}`} wrap="truncate-end">
{' '}
</Text>
)
})
)}
<Text color={t.color.muted} wrap="truncate-end">
{offset + VISIBLE < rows.length ? ` ↓ ${rows.length - offset - VISIBLE} more` : ' '}
</Text>
<Text color={t.color.muted} wrap="truncate-end">
persist: {allowPersistGlobal ? (persistGlobal ? 'global' : 'session') : 'session'}
{allowPersistGlobal ? ' · ^g toggle' : ' only'}
</Text>
<OverlayHint t={t}>↑/↓ select · Enter choose · ^d disconnect · Esc clear/back · q close</OverlayHint>
</Box>
)
}
// ── Model selection stage ────────────────────────────────────────────
const { items, offset } = windowItems(models, modelIdx, VISIBLE)
const noModelMatches = !!filter.trim() && models.length === 0
return (
<Box flexDirection="column" width={width}>
<Text bold color={t.color.accent} wrap="truncate-end">
Select model (step 2/2)
</Text>
<Text color={t.color.muted} wrap="truncate-end">
{filteredProviderRows[providerIdx]?.name || '(unknown provider)'} · Esc back
</Text>
<Text color={filter ? t.color.accent : t.color.muted} wrap="truncate-end">
{filter ? `filter: ${filter}▎` : 'type to filter · ↑/↓ select'}
</Text>
<Text color={t.color.label} wrap="truncate-end">
{provider?.warning ? `warning: ${provider.warning}` : ' '}
</Text>
<Text color={t.color.muted} wrap="truncate-end">
{offset > 0 ? ` ↑ ${offset} more` : ' '}
</Text>
{Array.from({ length: VISIBLE }, (_, i) => {
const row = items[i]
const idx = offset + i
if (!row) {
return (!allModels.length || noModelMatches) && i === 0 ? (
<Text color={t.color.muted} key="empty" wrap="truncate-end">
{noModelMatches ? 'no models match filter' : 'no models listed for this provider'}
</Text>
) : (
<Text color={t.color.muted} key={`pad-${i}`} wrap="truncate-end">
{' '}
</Text>
)
}
const prefix = modelIdx === idx ? '▸ ' : row === currentModel ? '* ' : ' '
return (
<Text
color={t.color.muted}
{...chipRowProps(t, modelIdx === idx)}
key={`${provider?.slug ?? 'prov'}:${idx}:${row}`}
wrap="truncate-end"
>
{prefix}
{idx + 1}. {row}
</Text>
)
})}
<Text color={t.color.muted} wrap="truncate-end">
{offset + VISIBLE < models.length ? ` ↓ ${models.length - offset - VISIBLE} more` : ' '}
</Text>
<Text color={t.color.muted} wrap="truncate-end">
persist: {allowPersistGlobal ? (persistGlobal ? 'global' : 'session') : 'session'}
{allowPersistGlobal ? ' · ^g toggle' : ' only'}
</Text>
<OverlayHint t={t}>
{models.length ? '↑/↓ select · Enter switch · Esc clear/back · q close' : 'Esc back · q close'}
</OverlayHint>
</Box>
)
}
interface ModelPickerProps {
allowPersistGlobal?: boolean
gw: GatewayClient
initialRefresh?: boolean
maxWidth?: number
onCancel: () => void
onSelect: (value: string) => void
sessionId: string | null
t: Theme
}
+133
View File
@@ -0,0 +1,133 @@
import { Box, Text, useStdout } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { type ReactNode } from 'react'
import { $uiTheme } from '../app/uiStore.js'
export type OverlayZone =
'bottom' | 'bottom-left' | 'bottom-right' | 'center' | 'left' | 'right' | 'top' | 'top-left' | 'top-right'
interface OverlayProps {
/** Render a faux scrim behind the content (lipgloss-style: spaces + bg color). */
backdrop?: boolean
/** Background color used to paint the scrim. Defaults to `theme.color.statusBg`. */
backdropColor?: string
children: ReactNode
/** Nine CSS-grid-style zones. Defaults to `center`. */
zone?: OverlayZone
}
/**
* Viewport-level overlay primitive. Positions its child in one of nine zones
* and optionally paints a scrim behind it.
*
* Backdrop uses the canonical TUI pattern (cf. `lipgloss.Place`): each cell is
* a SPACE with a backgroundColor, so the area reads as a clean dimmed plane
* over the transcript. Ink only paints `backgroundColor` on cells with
* content, so the scrim is rendered as explicit lines of spaces — a `<Box>`
* with a bg alone would be invisible. Uses stdout dims so placement is
* deterministic regardless of tree depth.
*/
export function Overlay({ backdrop = false, backdropColor, children, zone = 'center' }: OverlayProps) {
const { stdout } = useStdout()
const theme = useStore($uiTheme)
const cols = stdout?.columns ?? 80
const rows = stdout?.rows ?? 24
const [justify, align] = zoneFlex(zone)
const scrimBg = backdropColor ?? theme.color.statusBg
const scrimLine = ' '.repeat(cols)
return (
<>
{backdrop && (
<Box flexDirection="column" height={rows} left={0} position="absolute" top={0} width={cols}>
{Array.from({ length: rows }, (_, i) => (
<Text backgroundColor={scrimBg} key={i}>
{scrimLine}
</Text>
))}
</Box>
)}
<Box
alignItems={align}
flexDirection="row"
height={rows}
justifyContent={justify}
left={0}
position="absolute"
top={0}
width={cols}
>
{children}
</Box>
</>
)
}
interface DialogProps {
children: ReactNode
hint?: ReactNode
title?: string
width?: number
}
/** Bordered card with optional title + hint. Pair with `Overlay` for centered modals. */
export function Dialog({ children, hint, title, width }: DialogProps) {
const theme = useStore($uiTheme)
const innerWidth = width !== undefined ? Math.max(1, width - 6) : undefined
return (
<Box
borderColor={theme.color.primary}
borderStyle="round"
flexDirection="column"
opaque
paddingX={2}
paddingY={1}
width={width}
>
{title && (
<Box justifyContent="center" marginBottom={1} width={innerWidth}>
<Text bold color={theme.color.primary}>
{title}
</Text>
</Box>
)}
{children}
{hint && (
<Box marginTop={1}>{typeof hint === 'string' ? <Text color={theme.color.muted}>{hint}</Text> : hint}</Box>
)}
</Box>
)
}
const zoneFlex = (zone: OverlayZone): ['center' | 'flex-end' | 'flex-start', 'center' | 'flex-end' | 'flex-start'] => {
const horizontal = {
bottom: 'center',
'bottom-left': 'flex-start',
'bottom-right': 'flex-end',
center: 'center',
left: 'flex-start',
right: 'flex-end',
top: 'center',
'top-left': 'flex-start',
'top-right': 'flex-end'
} as const
const vertical = {
bottom: 'flex-end',
'bottom-left': 'flex-end',
'bottom-right': 'flex-end',
center: 'center',
left: 'center',
right: 'center',
top: 'flex-start',
'top-left': 'flex-start',
'top-right': 'flex-start'
} as const
return [horizontal[zone], vertical[zone]]
}
+50
View File
@@ -0,0 +1,50 @@
import { Text, useInput } from '@hermes/ink'
import type { Theme } from '../theme.js'
export function useOverlayKeys({ disabled = false, onBack, onClose }: OverlayKeysOptions) {
useInput((ch, key) => {
if (disabled) {
return
}
if (ch === 'q') {
return onClose()
}
if (key.escape) {
return onBack ? onBack() : onClose()
}
})
}
export function OverlayHint({ children, t }: OverlayHintProps) {
return (
<Text color={t.color.muted} wrap="truncate-end">
{children}
</Text>
)
}
export const windowOffset = (count: number, selected: number, visible: number) =>
Math.max(0, Math.min(selected - Math.floor(visible / 2), count - visible))
export function windowItems<T>(items: T[], selected: number, visible: number) {
const offset = windowOffset(items.length, selected, visible)
return {
items: items.slice(offset, offset + visible),
offset
}
}
interface OverlayHintProps {
children: string
t: Theme
}
interface OverlayKeysOptions {
disabled?: boolean
onBack?: () => void
onClose: () => void
}
+247
View File
@@ -0,0 +1,247 @@
import type { Key } from '@hermes/ink'
import { Text, useInput } from '@hermes/ink'
import { type ReactNode, useState } from 'react'
import type { UsageModelData } from '../gatewayTypes.js'
import { liftForContrast, mix } from '../lib/color.js'
import type { Theme } from '../theme.js'
/**
* Overlay width clamp: prefer `preferred`, honor the caller's `maxWidth`
* ABSOLUTELY (a grid cell knows its budget — overflowing it clips at the
* terminal edge), and keep a usability floor only when the cap allows it.
*/
export function clampOverlayWidth(preferred: number, maxWidth?: number, min = 24): number {
const cap = maxWidth === undefined ? Number.MAX_SAFE_INTEGER : Math.max(1, Math.trunc(maxWidth))
return Math.max(Math.min(min, cap), Math.min(preferred, cap))
}
/**
* THE scrollbar treatment (transcript + overlays): thumb rides the theme
* base, accent while interacting; track recedes via an explicit blend toward
* the surface. Never SGR dim — terminal-interpreted, it renders as a black
* slab on transparent profiles (terminal.background #00000000).
*/
export function scrollbarColors(t: Theme, hover: boolean, grabbed: boolean): { thumb: string; track: string } {
return {
thumb: grabbed || hover ? t.color.accent : t.color.primary,
track: mix(hover ? t.color.border : t.color.muted, t.color.completionBg, hover ? 0.25 : 0.55)
}
}
export interface MenuRowSpec {
color?: string
label: string
run: () => void
}
/**
* ↑/↓ + Enter + number-key selection over `rows`; Esc runs `onEscape`.
* `onKey`, when given, runs first on every keypress — return `true` to mark
* the key fully handled and skip the default escape/arrow/enter/number
* handling for that keypress (e.g. a screen with a text-input sub-mode).
*/
export function useMenu(rows: MenuRowSpec[], onEscape: () => void, onKey?: (ch: string, key: Key) => boolean): number {
const [sel, setSel] = useState(0)
useInput((ch, key) => {
if (onKey?.(ch, key)) {
return
}
if (key.escape) {
return onEscape()
}
if (key.upArrow && sel > 0) {
setSel(v => v - 1)
}
if (key.downArrow && sel < rows.length - 1) {
setSel(v => v + 1)
}
if (key.return) {
return rows[sel]?.run()
}
const n = parseInt(ch, 10)
if (n >= 1 && n <= rows.length) {
return rows[n - 1]?.run()
}
})
return Math.min(sel, Math.max(0, rows.length - 1))
}
/**
* THE selected-row treatment for every list surface (completions popover,
* session switcher, pickers): a `selection` chip on the active row, nothing
* painted otherwise. Panels never paint their own full background — floating
* surfaces are `opaque` (terminal-native canvas) and only the active row
* carries color, so list surfaces cannot disagree about "selected" and stay
* correct on any terminal background. Callers own layout; this owns color.
*/
export function listRowStyle(t: Theme, active: boolean): { backgroundColor?: string; color?: string } {
if (!active) {
return {}
}
const backgroundColor = t.color.completionCurrentBg
// The chip guarantees its own ink: a cross-polarity theme (dark palette on
// a light terminal) pairs pale text with a light chip, so lift the ink
// against the ACTUAL chip fill with the xterm algorithm.
return { backgroundColor, color: liftForContrast(t.color.text, backgroundColor, 4.5) }
}
/** Spreadable props for a selectable row: chip bg + ink + bold when active.
* Spread AFTER `color` so the chip ink wins on the active row. Replaces
* `inverse`, which swaps against the terminal's unknowable default colors
* (a black slab on transparent profiles). */
export function chipRowProps(t: Theme, active: boolean): { backgroundColor?: string; bold: boolean; color?: string } {
const row = listRowStyle(t, active)
return { backgroundColor: row.backgroundColor, bold: active, ...(row.color ? { color: row.color } : {}) }
}
/** A numbered menu row with the ▸ cursor (mirrors ClarifyPrompt). Active rows
* carry the shared list-row selection chip — same treatment as completions
* and the session switcher — instead of `inverse`, whose contrast depends on
* the terminal's unknowable default colors. */
export function MenuRow({ active, index, label, t }: { active: boolean; index: number; label: string; t: Theme }) {
const row = listRowStyle(t, active)
return (
<Text>
<Text
backgroundColor={row.backgroundColor}
bold={active}
color={active ? (row.color ?? t.color.label) : t.color.muted}
>
{active ? '▸ ' : ' '}
{index}. {label}
</Text>
</Text>
)
}
/** Plain (non-numbered) action row with the ▸ cursor (confirm screens). */
export function ActionRow({ active, label, color, t }: { active: boolean; label: string; color?: string; t: Theme }) {
return (
<Text>
<Text color={active ? t.color.accent : t.color.muted}>{active ? '▸ ' : ' '}</Text>
<Text bold={active} color={active ? (color ?? t.color.text) : t.color.muted}>
{label}
</Text>
</Text>
)
}
export const BAR_CELLS = 10
/** ratio in [0,1] -> { bar: '█…░…', pct: 0-100 } using `cells` cells. */
export function barCells(ratio: number, cells: number = BAR_CELLS): { bar: string; pct: number } {
const r = Math.max(0, Math.min(1, ratio))
const filled = Math.round(r * cells)
return { bar: '█'.repeat(filled) + '░'.repeat(cells - filled), pct: Math.round(r * 100) }
}
/**
* Two-bar dollar usage view (decided with the user over a crammed three-segment
* bar: at terminal widths a single fill glyph per full-resolution bar is the
* only legible option). The plan bar is labeled with the plan name and shows
* the allowance detail + % used; the top-up bar shows purchased dollars (no
* denominator, renders full = balance, rolls over). Dollars only — never
* "credits". Each row:
* `Plus [██████░░░░] $14.00 of $20.00 · 30% used`
* Renders nothing for a free account (no bars to draw — caller shows upsell).
*/
export function UsageBars({ model, t }: { model: undefined | UsageModelData; t: Theme }) {
if (!model || !model.available) {
return null
}
const rows: ReactNode[] = []
// Label the plan bar with the plan name (padded for column alignment with the
// top-up row). Falls back to 'plan' when the name is absent.
const planLabel = (model.plan_name || 'plan').padEnd(8).slice(0, 8)
if (model.plan_bar) {
const b = model.plan_bar
const { bar } = barCells(b.fill_fraction)
const pct = b.pct_used == null ? '' : ` · ${b.pct_used}% used`
rows.push(
<Text color={t.color.text} key="plan">
{planLabel}
<Text color={t.color.muted}>[</Text>
<Text color={t.color.accent}>{bar}</Text>
<Text color={t.color.muted}>]</Text>
{` ${b.remaining_display} left of ${b.total_display}${pct}`}
</Text>
)
}
if (model.topup_bar) {
const b = model.topup_bar
const { bar } = barCells(1)
rows.push(
<Text color={t.color.text} key="topup">
{'top-up '}
<Text color={t.color.muted}>[</Text>
<Text color={t.color.ok}>{bar}</Text>
<Text color={t.color.muted}>]</Text>
{` ${b.remaining_display} · never expires`}
</Text>
)
}
if (rows.length === 0) {
return null
}
return <>{rows}</>
}
/**
* Plain-text version of the two-bar usage view, for text-only surfaces (the
* /usage transcript panel). Returns one string per line: a plan bar, a top-up
* bar, and a total-spendable summary, whichever apply. Dollars only.
*/
export function usageBarsText(model: undefined | UsageModelData): string[] {
if (!model || !model.available) {
return []
}
const lines: string[] = []
const planLabel = (model.plan_name || 'plan').padEnd(8).slice(0, 8)
if (model.plan_bar) {
const b = model.plan_bar
const { bar } = barCells(b.fill_fraction)
const pct = b.pct_used == null ? '' : ` · ${b.pct_used}% used`
lines.push(`${planLabel}[${bar}] ${b.remaining_display} left of ${b.total_display}${pct}`)
}
if (model.topup_bar) {
const b = model.topup_bar
const { bar } = barCells(1)
lines.push(`top-up [${bar}] ${b.remaining_display} · never expires`)
}
if (model.total_spendable_display && model.has_topup) {
lines.push(`Total spendable: ${model.total_spendable_display}`)
}
return lines
}
export const footer = (extra: string, t: Theme) => <Text color={t.color.muted}>{extra}</Text>
@@ -0,0 +1,84 @@
import { Box, type ScrollBoxHandle, Text } from '@hermes/ink'
import { type RefObject, useState } from 'react'
import type { Theme } from '../theme.js'
import { scrollbarColors } from './overlayPrimitives.js'
/**
* Mouse-draggable scrollbar bound to a `ScrollBox` ref. Re-renders off the
* parent `tick` so accordions / async content can resize the thumb without a
* scroll event. Shared by every full-screen overlay that scrolls a pane.
*/
export function OverlayScrollbar({
scrollRef,
t,
tick
}: {
scrollRef: RefObject<null | ScrollBoxHandle>
t: Theme
tick: number
}) {
void tick
const [hover, setHover] = useState(false)
const [grab, setGrab] = useState<null | number>(null)
const s = scrollRef.current
const vp = Math.max(0, s?.getViewportHeight() ?? 0)
if (!vp) {
return <Box width={1} />
}
const total = Math.max(vp, s?.getScrollHeight() ?? vp)
const scrollable = total > vp
const thumb = scrollable ? Math.max(1, Math.round((vp * vp) / total)) : vp
const travel = Math.max(1, vp - thumb)
const pos = Math.max(0, (s?.getScrollTop() ?? 0) + (s?.getPendingDelta() ?? 0))
const thumbTop = scrollable ? Math.round((pos / Math.max(1, total - vp)) * travel) : 0
const below = Math.max(0, vp - thumbTop - thumb)
const vBar = (n: number) => (n > 0 ? `${'│\n'.repeat(n - 1)}│` : '')
const thumbBody = `${'┃\n'.repeat(Math.max(0, thumb - 1))}┃`
const { thumb: thumbColor, track: trackColor } = scrollbarColors(t, hover, grab !== null)
const jump = (row: number, offset: number) => {
if (!s || !scrollable) {
return
}
s.scrollTo(Math.round((Math.max(0, Math.min(travel, row - offset)) / travel) * Math.max(0, total - vp)))
}
return (
<Box
flexDirection="column"
onMouseDown={(e: { localRow?: number }) => {
const row = Math.max(0, Math.min(vp - 1, e.localRow ?? 0))
const off = row >= thumbTop && row < thumbTop + thumb ? row - thumbTop : Math.floor(thumb / 2)
setGrab(off)
jump(row, off)
}}
onMouseDrag={(e: { localRow?: number }) =>
jump(Math.max(0, Math.min(vp - 1, e.localRow ?? 0)), grab ?? Math.floor(thumb / 2))
}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
onMouseUp={() => setGrab(null)}
width={1}
>
{!scrollable ? (
<Text color={trackColor}>{vBar(vp)}</Text>
) : (
<>
{thumbTop > 0 ? <Text color={trackColor}>{vBar(thumbTop)}</Text> : null}
<Text color={thumbColor}>{thumbBody}</Text>
{below > 0 ? <Text color={trackColor}>{vBar(below)}</Text> : null}
</>
)}
</Box>
)
}
+187
View File
@@ -0,0 +1,187 @@
import { Box, Text, useInput, useStdout } from '@hermes/ink'
import { useEffect, useMemo, useState } from 'react'
import type { GatewayClient } from '../gatewayClient.js'
import { rpcErrorMessage } from '../lib/rpc.js'
import type { Theme } from '../theme.js'
import { OverlayHint, windowItems } from './overlayControls.js'
import { chipRowProps, clampOverlayWidth } from './overlayPrimitives.js'
const VISIBLE = 10
const MIN_WIDTH = 40
const MAX_WIDTH = 90
interface GalleryPet {
slug: string
displayName: string
installed: boolean
curated?: boolean
}
interface Gallery {
enabled: boolean
active: string
pets: GalleryPet[]
}
/**
* Interactive petdex picker overlay. Pulls the gallery via `pet.gallery`,
* filters as you type, and adopts the highlighted pet with `pet.select`
* (install-on-demand). The mascot lights up live once `usePet` next polls —
* no restart. This is the interactive sibling of the text `/pet <slug>` path.
*/
export function PetPicker({ gw, maxWidth, onClose, t }: PetPickerProps) {
const [gallery, setGallery] = useState<Gallery | null>(null)
const [query, setQuery] = useState('')
const [idx, setIdx] = useState(0)
const [busy, setBusy] = useState(false)
const [err, setErr] = useState('')
const [loading, setLoading] = useState(true)
const { stdout } = useStdout()
// Optional maxWidth lets grid layouts hand the picker its cell budget.
const preferredWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))
const width = clampOverlayWidth(preferredWidth, maxWidth)
useEffect(() => {
gw.request<Gallery>('pet.gallery')
.then(r => {
setGallery(r)
setErr('')
})
.catch((e: unknown) => setErr(rpcErrorMessage(e)))
.finally(() => setLoading(false))
}, [gw])
const enabled = gallery?.enabled ?? false
const active = gallery?.active ?? ''
// Rank by the signals petdex gives us — active, then installed, then curated
// (its official set), then the rest — and hide the clawd placeholders.
const view = useMemo(() => {
const pets = (gallery?.pets ?? []).filter(p => !/^clawd(-|$)/i.test(p.slug))
const needle = query.trim().toLowerCase()
const matched = needle
? pets.filter(p => p.slug.toLowerCase().includes(needle) || p.displayName.toLowerCase().includes(needle))
: pets
const rank = (p: GalleryPet) => (enabled && p.slug === active ? 4 : 0) + (p.installed ? 2 : 0) + (p.curated ? 1 : 0)
return [...matched].sort((a, b) => rank(b) - rank(a))
}, [gallery, query, enabled, active])
const adopt = (slug: string) => {
setBusy(true)
setErr('')
gw.request('pet.select', { slug })
.then(() => onClose())
.catch((e: unknown) => {
setErr(rpcErrorMessage(e))
setBusy(false)
})
}
useInput((input, key) => {
if (busy) {
return
}
if (key.escape) {
return onClose()
}
if (key.upArrow) {
return setIdx(i => Math.max(0, i - 1))
}
if (key.downArrow) {
return setIdx(i => Math.min(view.length - 1, i + 1))
}
if (key.return) {
const pet = view[idx]
return pet ? adopt(pet.slug) : undefined
}
if (key.backspace || key.delete) {
setQuery(q => q.slice(0, -1))
return setIdx(0)
}
// Printable char → extend the filter (ignore control/chorded keys).
if (input && input.length === 1 && input >= ' ' && !key.ctrl && !key.meta) {
setQuery(q => q + input)
setIdx(0)
}
})
if (loading) {
return <Text color={t.color.muted}>loading pets…</Text>
}
if (err && !gallery) {
return (
<Box flexDirection="column" width={width}>
<Text color={t.color.label}>error: {err}</Text>
<OverlayHint t={t}>Esc cancel</OverlayHint>
</Box>
)
}
const { items, offset } = windowItems(view, idx, VISIBLE)
return (
<Box flexDirection="column" width={width}>
<Text bold color={t.color.accent}>
Pets
</Text>
<Text color={t.color.muted} wrap="truncate-end">
{query ? `filter: ${query}` : 'type to filter'} · {view.length} pet{view.length === 1 ? '' : 's'}
</Text>
{offset > 0 && <Text color={t.color.muted}> ↑ {offset} more</Text>}
{view.length === 0 ? (
<Text color={t.color.muted}>{query ? `no pets match "${query}"` : 'no pets available'}</Text>
) : (
items.map((pet, i) => {
const at = offset + i === idx
const isActive = enabled && pet.slug === active
const mark = isActive ? '●' : pet.installed ? '✓' : ' '
const tag = pet.installed ? '' : pet.curated ? ' · official' : ''
return (
<Text color={t.color.muted} {...chipRowProps(t, at)} key={pet.slug} wrap="truncate-end">
{at ? '▸ ' : ' '}
{mark} {pet.displayName}
<Text color={at ? t.color.accent : t.color.muted}>
{' '}
({pet.slug}
{tag})
</Text>
</Text>
)
})
)}
{offset + VISIBLE < view.length && <Text color={t.color.muted}> ↓ {view.length - offset - VISIBLE} more</Text>}
{err ? <Text color={t.color.label}>error: {err}</Text> : null}
{busy ? <Text color={t.color.accent}>adopting…</Text> : null}
<OverlayHint t={t}>↑/↓ select · Enter adopt · type to filter · Esc cancel</OverlayHint>
</Box>
)
}
interface PetPickerProps {
gw: GatewayClient
maxWidth?: number
onClose: () => void
t: Theme
}
+93
View File
@@ -0,0 +1,93 @@
import { Box, Text } from '@hermes/ink'
import { memo } from 'react'
// A cell is [tr,tg,tb,ta, br,bg,bb,ba] — the top + bottom pixel of one
// half-block, as produced by the `pet.cells` gateway RPC.
export type PetCell = number[]
export type PetGrid = PetCell[][]
const UPPER_HALF = '▀'
const LOWER_HALF = '▄'
const hex = (r: number, g: number, b: number) =>
`#${[r, g, b]
.map(v =>
Math.max(0, Math.min(255, v | 0))
.toString(16)
.padStart(2, '0')
)
.join('')}`
/**
* Renders one petdex frame as truecolor half-blocks using native Ink color
* props (no raw ANSI, so width measurement stays correct). The engine
* (`agent/pet/render.py`) does the decode + downscale; this is a thin painter.
*/
export const PetSprite = memo(function PetSprite({ grid }: { grid: PetGrid }) {
if (!grid.length) {
return null
}
return (
<Box flexDirection="column">
{grid.map((row, y) => (
<Box key={y}>
{row.map((cell, x) => {
const [tr, tg, tb, ta, br, bg, bb, ba] = cell
const top = (ta ?? 0) >= 32
const bot = (ba ?? 0) >= 32
if (!top && !bot) {
return <Text key={x}> </Text>
}
// Both halves opaque → fg=top over bg=bottom. One half opaque →
// draw it fg-only so the other stays the terminal bg (no black
// boxes bleeding around transparent sprite edges).
if (top && bot) {
return (
<Text backgroundColor={hex(br, bg, bb)} color={hex(tr, tg, tb)} key={x}>
{UPPER_HALF}
</Text>
)
}
return top ? (
<Text color={hex(tr, tg, tb)} key={x}>
{UPPER_HALF}
</Text>
) : (
<Text color={hex(br, bg, bb)} key={x}>
{LOWER_HALF}
</Text>
)
})}
</Box>
))}
</Box>
)
})
/**
* Renders a kitty Unicode-placeholder grid: each line is a row of U+10EEEE
* cells whose foreground color encodes the image id. The actual pixels are
* drawn by the terminal (the frame image is transmitted out-of-band by
* `usePet`); this only emits the placeholder text Ink can measure as width-1
* cells. Truecolor-only — the color must reach the terminal verbatim for the
* id to decode, which Ghostty/kitty support.
*/
export const PetKitty = memo(function PetKitty({ color, placeholder }: { color: string; placeholder: string[] }) {
if (!placeholder.length) {
return null
}
return (
<Box flexDirection="column">
{placeholder.map((row, y) => (
<Text color={color} key={y}>
{row}
</Text>
))}
</Box>
)
})
+241
View File
@@ -0,0 +1,241 @@
import { Box, Text, useInput, useStdout } from '@hermes/ink'
import { useEffect, useState } from 'react'
import type { GatewayClient } from '../gatewayClient.js'
import { rpcErrorMessage } from '../lib/rpc.js'
import type { Theme } from '../theme.js'
import { OverlayHint, useOverlayKeys, windowItems, windowOffset } from './overlayControls.js'
import { chipRowProps, clampOverlayWidth } from './overlayPrimitives.js'
const VISIBLE = 12
const MIN_WIDTH = 44
const MAX_WIDTH = 96
interface PluginRow {
description?: string
name: string
source?: string
status?: string
version?: string
}
interface PluginsListResponse {
bundled_count?: number
plugins?: PluginRow[]
user_count?: number
}
interface PluginsToggleResponse {
name?: string
ok?: boolean
plugin?: PluginRow
unchanged?: boolean
}
type Scope = 'all' | 'user'
const GLYPH: Record<string, string> = {
disabled: '✗',
enabled: '✓'
}
export function PluginsHub({ gw, maxWidth, onClose, t }: PluginsHubProps) {
const [rows, setRows] = useState<PluginRow[]>([])
const [bundledCount, setBundledCount] = useState(0)
const [userCount, setUserCount] = useState(0)
const [idx, setIdx] = useState(0)
const [scope, setScope] = useState<Scope>('user')
const [busy, setBusy] = useState(false)
const [err, setErr] = useState('')
const [loading, setLoading] = useState(true)
const { stdout } = useStdout()
// Optional maxWidth lets grid layouts hand the hub its cell budget.
const preferredWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))
const width = clampOverlayWidth(preferredWidth, maxWidth)
const load = () => {
gw.request<PluginsListResponse>('plugins.manage', { action: 'list' })
.then(r => {
setRows(r?.plugins ?? [])
setUserCount(Number(r?.user_count ?? 0))
setBundledCount(Number(r?.bundled_count ?? 0))
setErr('')
setLoading(false)
})
.catch((e: unknown) => {
setErr(rpcErrorMessage(e))
setLoading(false)
})
}
useEffect(load, [gw])
// Default to user plugins; fall back to all when there are none so the
// overlay is never empty when bundled plugins exist.
const visibleRows = scope === 'user' ? rows.filter(r => r.source !== 'bundled') : rows
const effectiveRows = scope === 'user' && !visibleRows.length && rows.length ? rows : visibleRows
const effectiveScope: Scope = effectiveRows === visibleRows ? scope : 'all'
const clampedIdx = Math.min(idx, Math.max(0, effectiveRows.length - 1))
useOverlayKeys({ disabled: busy, onClose })
const toggle = (row: PluginRow) => {
if (busy || !row) {
return
}
const enable = row.status !== 'enabled'
setBusy(true)
setErr('')
gw.request<PluginsToggleResponse>('plugins.manage', { action: 'toggle', enable, name: row.name })
.then(r => {
if (r?.plugin) {
setRows(prev => prev.map(p => (p.name === r.plugin!.name ? r.plugin! : p)))
} else {
load()
}
})
.catch((e: unknown) => setErr(rpcErrorMessage(e)))
.finally(() => setBusy(false))
}
useInput((ch, key) => {
if (busy) {
return
}
const count = effectiveRows.length
if (key.upArrow && clampedIdx > 0) {
setIdx(clampedIdx - 1)
return
}
if (key.downArrow && clampedIdx < count - 1) {
setIdx(clampedIdx + 1)
return
}
// Tab toggles user-only vs all (bundled) scope.
if (key.tab) {
setScope(s => (s === 'user' ? 'all' : 'user'))
setIdx(0)
return
}
if (key.return || ch === ' ') {
const row = effectiveRows[clampedIdx]
if (row) {
toggle(row)
}
return
}
const n = ch === '0' ? 10 : parseInt(ch, 10)
if (!Number.isNaN(n) && n >= 1 && n <= Math.min(10, count)) {
const next = windowOffset(count, clampedIdx, VISIBLE) + n - 1
const row = effectiveRows[next]
if (row) {
setIdx(next)
toggle(row)
}
}
})
if (loading) {
return <Text color={t.color.muted}>loading plugins…</Text>
}
if (err && !rows.length) {
return (
<Box flexDirection="column" width={width}>
<Text color={t.color.label}>error: {err}</Text>
<OverlayHint t={t}>Esc/q close</OverlayHint>
</Box>
)
}
if (!rows.length) {
return (
<Box flexDirection="column" width={width}>
<Text bold color={t.color.accent}>
Plugins Hub
</Text>
<Text color={t.color.muted}>no plugins installed</Text>
<Text color={t.color.muted}>install: hermes plugins install owner/repo</Text>
<OverlayHint t={t}>Esc/q close</OverlayHint>
</Box>
)
}
const labels = effectiveRows.map(r => {
const status = r.status ?? 'not enabled'
const glyph = GLYPH[status] ?? '○'
const ver = r.version ? ` v${r.version}` : ''
const src = effectiveScope === 'all' && r.source === 'bundled' ? ' [bundled]' : ''
const state = status === 'enabled' ? '' : ` (${status})`
return `${glyph} ${r.name}${ver}${src}${state}`
})
const { items, offset } = windowItems(labels, clampedIdx, VISIBLE)
const scopeLabel =
effectiveScope === 'user'
? `${userCount} user plugin(s)${bundledCount ? ` · +${bundledCount} bundled (Tab)` : ''}`
: `all ${rows.length} plugins`
return (
<Box flexDirection="column" width={width}>
<Text bold color={t.color.accent}>
Plugins Hub
</Text>
<Text color={t.color.muted}>{scopeLabel}</Text>
{offset > 0 && <Text color={t.color.muted}> ↑ {offset} more</Text>}
{items.map((row, i) => {
const lineIdx = offset + i
const active = clampedIdx === lineIdx
return (
<Text
color={t.color.muted}
{...chipRowProps(t, active)}
key={effectiveRows[lineIdx]?.name ?? row}
wrap="truncate-end"
>
{active ? '▸ ' : ' '}
{i + 1}. {row}
</Text>
)
})}
{offset + VISIBLE < labels.length && (
<Text color={t.color.muted}> ↓ {labels.length - offset - VISIBLE} more</Text>
)}
{err ? <Text color={t.color.label}>error: {err}</Text> : null}
{busy ? <Text color={t.color.accent}>updating…</Text> : null}
<OverlayHint t={t}>↑/↓ select · Enter/Space toggle · Tab user/all · 1-9,0 quick · Esc/q close</OverlayHint>
</Box>
)
}
interface PluginsHubProps {
gw: GatewayClient
maxWidth?: number
onClose: () => void
t: Theme
}
+501
View File
@@ -0,0 +1,501 @@
import { Box, Text, useInput, wrapAnsi } from '@hermes/ink'
import { useEffect, useState } from 'react'
import { isMac } from '../lib/platform.js'
import { clarifyBatchRevisitState } from '../lib/text.js'
import type { Theme } from '../theme.js'
import type { ApprovalReq, ClarifyReq, ConfirmReq } from '../types.js'
import { chipRowProps } from './overlayPrimitives.js'
import { TextInput } from './textInput.js'
const APPROVAL_OPTS = ['once', 'session', 'always', 'deny'] as const
// tirith warning present → backend downgrades "always" to session scope, so drop it.
const APPROVAL_OPTS_NO_ALWAYS = APPROVAL_OPTS.filter(o => o !== 'always')
const APPROVAL_OPTS_SMART_DENY = ['once', 'deny'] as const
const LABELS = { always: 'Always allow', deny: 'Deny', once: 'Allow once', session: 'Allow this session' } as const
const CMD_PREVIEW_LINES = 10
type ApprovalChoice = 'always' | 'deny' | 'once' | 'session'
export function approvalOptions(req: ApprovalReq): readonly ApprovalChoice[] {
if (req.choices) {
return req.choices.filter((choice): choice is ApprovalChoice => APPROVAL_OPTS.includes(choice as ApprovalChoice))
}
if (req.smartDenied) {
return APPROVAL_OPTS_SMART_DENY
}
return req.allowPermanent === false ? APPROVAL_OPTS_NO_ALWAYS : APPROVAL_OPTS
}
type ApprovalKey = {
downArrow?: boolean
escape?: boolean
return?: boolean
upArrow?: boolean
}
type ApprovalAction = { kind: 'choose'; choice: ApprovalChoice } | { kind: 'move'; delta: -1 | 1 } | { kind: 'noop' }
/**
* Pure key-dispatch for the approval prompt — exported so the regression
* matrix (Esc, Ctrl+C-equivalent, number keys, Enter, ↑↓) is testable
* without mounting React + Ink + a fake stdin. The component just maps the
* action onto its own state setters.
*
* Esc and number keys both terminate the prompt; Esc maps to deny (parity
* with the global Ctrl+C handler that already calls cancelOverlayFromCtrlC
* for approvals). Numbers 1..opts.length pick the labelled choice. Enter
* confirms the current selection. ↑/↓ moves the selection within bounds.
*/
export function approvalAction(
ch: string,
key: ApprovalKey,
sel: number,
opts: readonly ApprovalChoice[] = APPROVAL_OPTS
): ApprovalAction {
if (key.escape) {
return { kind: 'choose', choice: 'deny' }
}
const n = parseInt(ch, 10)
if (n >= 1 && n <= opts.length) {
return { kind: 'choose', choice: opts[n - 1]! }
}
if (key.return) {
return { kind: 'choose', choice: opts[sel]! }
}
if (key.upArrow && sel > 0) {
return { kind: 'move', delta: -1 }
}
if (key.downArrow && sel < opts.length - 1) {
return { kind: 'move', delta: 1 }
}
return { kind: 'noop' }
}
export function ApprovalPrompt({ cols = 80, onChoice, req, t }: ApprovalPromptProps) {
const [sel, setSel] = useState(0)
const opts = approvalOptions(req)
useInput((ch, key) => {
const action = approvalAction(ch, key, sel, opts)
if (action.kind === 'choose') {
onChoice(action.choice)
} else if (action.kind === 'move') {
setSel(s => s + action.delta)
}
})
// Wrap long single-line commands to the panel width instead of clipping the
// tail (mirrors the CLI approval panel fix — the full command must be
// reviewable before approving). Border + paddingX + inner padding ≈ 8 cols.
const innerWidth = Math.max(20, cols - 8)
const rawLines = req.command
.split('\n')
.flatMap(line => wrapAnsi(line, innerWidth, { hard: true, trim: false }).split('\n'))
const shown = rawLines.slice(0, CMD_PREVIEW_LINES)
const overflow = rawLines.length - shown.length
return (
<Box borderColor={t.color.warn} borderStyle="double" flexDirection="column" paddingX={1}>
<Text bold color={t.color.warn}>
⚠ approval required · {req.description}
</Text>
<Box flexDirection="column" paddingLeft={1}>
{shown.map((line, i) => (
<Text color={t.color.text} key={i} wrap="truncate-end">
{line || ' '}
</Text>
))}
{overflow > 0 ? (
<Text color={t.color.muted}>
… +{overflow} more line{overflow === 1 ? '' : 's'} (full text above)
</Text>
) : null}
</Box>
<Text />
{opts.map((o, i) => (
<Text key={o}>
<Text color={t.color.muted} {...chipRowProps(t, sel === i)}>
{sel === i ? '▸ ' : ' '}
{i + 1}. {LABELS[o]}
</Text>
</Text>
))}
<Text color={t.color.muted}>↑/↓ select · Enter confirm · 1-{opts.length} quick pick · Esc/Ctrl+C deny</Text>
</Box>
)
}
export function ClarifyPrompt({ cols = 80, onAnswer, onCancel, onQuestionAnswer, req, t }: ClarifyPromptProps) {
const [sel, setSel] = useState(0)
const [custom, setCustom] = useState('')
const [typing, setTyping] = useState(false)
const choices = req.choices ?? []
const batch = req.questions ?? []
const isBatch = batch.length > 0
// ── Batch (A-compact) state: status list + one expanded active question.
// `active` walks the QUESTION list (Tab/Shift-Tab cycle it, any order);
// `sel` is reused as the cursor within the active question's choice rows.
const answers = req.answers ?? {}
const firstUnanswered = batch.findIndex(q => answers[q.qid] === undefined)
const [active, setActive] = useState(Math.max(0, firstUnanswered))
const moveActive = (delta: number) => {
const next = (active + delta + batch.length) % batch.length
const question = batch[next]
// Re-visit restore, same model as the CLI panel: a choice answer puts
// the cursor back on its row; a typed answer lands on Other with the
// text staged so Enter edits it instead of retyping.
const restored = clarifyBatchRevisitState(question?.choices ?? [], question ? answers[question.qid] : undefined)
setActive(next)
setSel(restored.sel)
setCustom(restored.custom)
setTyping(false)
}
// After a lock the overlay is re-patched with the new answers map — jump
// the cursor to the next unanswered question (stay put when editing).
useEffect(() => {
if (!isBatch) {
return
}
const current = batch[active]
if (current && answers[current.qid] === undefined) {
return
}
const next = batch.findIndex(q => answers[q.qid] === undefined)
if (next >= 0) {
setActive(next)
setSel(0)
setCustom('')
setTyping(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed by the answers map only
}, [req.answers])
const heading = (
<Text bold>
<Text color={t.color.accent}>ask</Text>
<Text color={t.color.text}> {isBatch ? `${batch.length} questions` : req.question}</Text>
</Text>
)
const activeQuestion = isBatch ? batch[active] : undefined
const activeChoices = activeQuestion ? (activeQuestion.choices ?? []) : choices
const answeredCount = isBatch ? batch.filter(q => answers[q.qid] !== undefined).length : 0
const remainingCount = isBatch ? batch.length - answeredCount : 0
const lockActive = (value: string) => {
if (activeQuestion) {
onQuestionAnswer?.(activeQuestion.qid, value)
setSel(0)
setCustom('')
setTyping(false)
}
}
useInput((ch, key) => {
if (key.escape) {
if (typing) {
setTyping(false)
return
}
onCancel()
return
}
if (typing) {
return
}
if (isBatch) {
// Tab / Shift-Tab cycle the active question (with wrap) — the
// selected question is always the expanded one, like the CLI panel.
if (key.tab) {
moveActive(key.shift ? -1 : 1)
return
}
if (!activeQuestion) {
return
}
if (activeChoices.length === 0) {
// Open-ended question: any keypress starts typing (TextInput below).
setTyping(true)
return
}
if (key.upArrow && sel > 0) {
setSel(s => s - 1)
}
if (key.downArrow && sel < activeChoices.length) {
setSel(s => s + 1)
}
if (key.return) {
if (sel === activeChoices.length) {
setTyping(true)
} else if (activeChoices[sel]) {
lockActive(activeChoices[sel]!)
}
return
}
const n = parseInt(ch)
if (n >= 1 && n <= activeChoices.length) {
lockActive(activeChoices[n - 1]!)
}
return
}
if (!choices.length) {
return
}
if (key.upArrow && sel > 0) {
setSel(s => s - 1)
}
if (key.downArrow && sel < choices.length) {
setSel(s => s + 1)
}
if (key.return) {
sel === choices.length ? setTyping(true) : choices[sel] && onAnswer(choices[sel]!)
}
const n = parseInt(ch)
if (n >= 1 && n <= choices.length) {
onAnswer(choices[n - 1]!)
}
})
if (isBatch) {
const hint = typing
? `Enter ${remainingCount === 1 ? 'confirm and continue' : 'lock answer'} · Esc back`
: `↑/↓ select · Enter ${remainingCount === 1 ? 'confirm and continue' : 'lock answer'} · Tab/Shift+Tab switch question · Esc/Ctrl+C cancel`
return (
<Box flexDirection="column">
{heading}
{batch.map((q, i) => {
const answer = answers[q.qid]
const isActive = i === active
const marker = answer !== undefined ? '✓' : isActive ? '▸' : '·'
return (
<Box flexDirection="column" key={q.qid}>
<Text>
<Text bold={isActive} color={isActive ? t.color.text : t.color.muted}>
{marker} {q.question}
</Text>
</Text>
{answer !== undefined ? (
// The locked answer on its own line, in the ok color, so the
// current answers stay readable while Tab walks the list.
<Box paddingLeft={2}>
<Text color={answer ? t.color.ok : t.color.muted} italic={!answer}>
{answer || '(skipped)'}
</Text>
</Box>
) : null}
{isActive ? (
typing || activeChoices.length === 0 ? (
<Box paddingLeft={2}>
<Text color={t.color.label}>{'> '}</Text>
<TextInput
color={t.color.text}
columns={Math.max(20, cols - 8)}
onChange={setCustom}
onSubmit={lockActive}
value={custom}
/>
</Box>
) : (
<Box flexDirection="column" paddingLeft={2}>
{[...activeChoices, 'Other (type your answer)'].map((c, ci) => (
<Text key={ci}>
<Text color={t.color.muted} {...chipRowProps(t, sel === ci)}>
{sel === ci ? '▸ ' : ' '}
{ci + 1}. {c}
</Text>
</Text>
))}
</Box>
)
) : null}
</Box>
)
})}
<Text color={t.color.muted}>
{answeredCount}/{batch.length} answered · {hint}
</Text>
</Box>
)
}
if (typing || !choices.length) {
return (
<Box flexDirection="column">
{heading}
<Box>
<Text color={t.color.label}>{'> '}</Text>
<TextInput
color={t.color.text}
columns={Math.max(20, cols - 6)}
onChange={setCustom}
onSubmit={onAnswer}
value={custom}
/>
</Box>
<Text color={t.color.muted}>
Enter send · Esc {choices.length ? 'back' : 'cancel'} ·{' '}
{isMac ? 'Cmd+C copy · Cmd+V paste · Ctrl+C cancel' : 'Ctrl+C cancel'}
</Text>
</Box>
)
}
return (
<Box flexDirection="column">
{heading}
{[...choices, 'Other (type your answer)'].map((c, i) => (
<Text key={i}>
<Text color={t.color.muted} {...chipRowProps(t, sel === i)}>
{sel === i ? '▸ ' : ' '}
{i + 1}. {c}
</Text>
</Text>
))}
<Text color={t.color.muted}>↑/↓ select · Enter confirm · 1-{choices.length} quick pick · Esc/Ctrl+C cancel</Text>
</Box>
)
}
export function ConfirmPrompt({ onCancel, onConfirm, req, t }: ConfirmPromptProps) {
const [sel, setSel] = useState(0)
useInput((ch, key) => {
const lower = ch.toLowerCase()
if (key.escape || (key.ctrl && lower === 'c') || lower === 'n') {
return onCancel()
}
if (lower === 'y') {
return onConfirm()
}
if (key.upArrow) {
setSel(0)
}
if (key.downArrow) {
setSel(1)
}
if (key.return) {
sel === 0 ? onCancel() : onConfirm()
}
})
const accent = req.danger ? t.color.error : t.color.warn
const rows = [
{ color: t.color.text, label: req.cancelLabel ?? 'No' },
{ color: req.danger ? t.color.error : t.color.text, label: req.confirmLabel ?? 'Yes' }
]
return (
<Box borderColor={accent} borderStyle="double" flexDirection="column" paddingX={1}>
<Text bold color={accent}>
{req.danger ? '⚠' : '?'} {req.title}
</Text>
{req.detail ? (
<Box paddingLeft={1}>
<Text color={t.color.text} wrap="truncate-end">
{req.detail}
</Text>
</Box>
) : null}
<Text />
{rows.map((row, i) => (
<Text key={row.label}>
<Text color={sel === i ? accent : t.color.muted}>{sel === i ? '▸ ' : ' '}</Text>
<Text color={sel === i ? row.color : t.color.muted}>{row.label}</Text>
</Text>
))}
<Text color={t.color.muted}>↑/↓ select · Enter confirm · Y/N quick · Esc cancel</Text>
</Box>
)
}
interface ApprovalPromptProps {
cols?: number
onChoice: (s: string) => void
req: ApprovalReq
t: Theme
}
interface ClarifyPromptProps {
cols?: number
onAnswer: (s: string) => void
onCancel: () => void
/** Batch mode: lock one question's answer (clarify.respond + question_id). */
onQuestionAnswer?: (qid: string, s: string) => void
req: ClarifyReq
t: Theme
}
interface ConfirmPromptProps {
onCancel: () => void
onConfirm: () => void
req: ConfirmReq
t: Theme
}
+64
View File
@@ -0,0 +1,64 @@
import { Box, Text } from '@hermes/ink'
import { compactPreview } from '../lib/text.js'
import type { Theme } from '../theme.js'
export const QUEUE_WINDOW = 3
export function getQueueWindow(queueLen: number, queueEditIdx: number | null) {
const start =
queueEditIdx === null ? 0 : Math.max(0, Math.min(queueEditIdx - 1, Math.max(0, queueLen - QUEUE_WINDOW)))
const end = Math.min(queueLen, start + QUEUE_WINDOW)
return { end, showLead: start > 0, showTail: end < queueLen, start }
}
export function QueuedMessages({ cols, queueEditIdx, queued, t }: QueuedMessagesProps) {
if (!queued.length) {
return null
}
const q = getQueueWindow(queued.length, queueEditIdx)
return (
<Box flexDirection="column" marginTop={1}>
<Text color={t.color.muted} dimColor>
{`queued (${queued.length})${
queueEditIdx !== null ? ` · editing ${queueEditIdx + 1} · Ctrl+X delete · Esc cancel` : ''
}`}
</Text>
{q.showLead && (
<Text color={t.color.muted} dimColor>
{' '}
…
</Text>
)}
{queued.slice(q.start, q.end).map((item, i) => {
const idx = q.start + i
const active = queueEditIdx === idx
return (
<Text color={active ? t.color.accent : t.color.muted} dimColor key={`${idx}-${item.slice(0, 16)}`}>
{active ? '▸' : ' '} {idx + 1}. {compactPreview(item, Math.max(16, cols - 10))}
</Text>
)
})}
{q.showTail && (
<Text color={t.color.muted} dimColor>
{' '}…and {queued.length - q.end} more
</Text>
)}
</Box>
)
}
interface QueuedMessagesProps {
cols: number
queueEditIdx: number | null
queued: string[]
t: Theme
}
+301
View File
@@ -0,0 +1,301 @@
import { Box, Text, useInput, useStdout } from '@hermes/ink'
import { useEffect, useState } from 'react'
import type { GatewayClient } from '../gatewayClient.js'
import { rpcErrorMessage } from '../lib/rpc.js'
import type { Theme } from '../theme.js'
import { OverlayHint, useOverlayKeys, windowItems, windowOffset } from './overlayControls.js'
import { chipRowProps } from './overlayPrimitives.js'
import { clampOverlayWidth } from './overlayPrimitives.js'
const VISIBLE = 12
const MIN_WIDTH = 40
const MAX_WIDTH = 90
export function SkillsHub({ gw, maxWidth, onClose, t }: SkillsHubProps) {
const [skillsByCat, setSkillsByCat] = useState<Record<string, string[]>>({})
const [selectedCat, setSelectedCat] = useState('')
const [catIdx, setCatIdx] = useState(0)
const [skillIdx, setSkillIdx] = useState(0)
const [stage, setStage] = useState<'actions' | 'category' | 'skill'>('category')
const [info, setInfo] = useState<null | SkillInfo>(null)
const [installing, setInstalling] = useState(false)
const [err, setErr] = useState('')
const [loading, setLoading] = useState(true)
const { stdout } = useStdout()
const terminalWidth = Math.max(1, (stdout?.columns ?? 80) - 6)
const preferredWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, terminalWidth))
const width = clampOverlayWidth(preferredWidth, maxWidth)
useEffect(() => {
gw.request<{ skills?: Record<string, string[]> }>('skills.manage', { action: 'list' })
.then(r => {
setSkillsByCat(r?.skills ?? {})
setErr('')
setLoading(false)
})
.catch((e: unknown) => {
setErr(rpcErrorMessage(e))
setLoading(false)
})
}, [gw])
const cats = Object.keys(skillsByCat).sort()
const skills = selectedCat ? (skillsByCat[selectedCat] ?? []) : []
const skillName = skills[skillIdx] ?? ''
const back = () => {
if (stage === 'actions') {
setStage('skill')
setInfo(null)
setErr('')
return
}
if (stage === 'skill') {
setStage('category')
setSkillIdx(0)
return
}
onClose()
}
useOverlayKeys({ disabled: installing, onBack: back, onClose })
const inspect = (name: string) => {
setInfo(null)
setErr('')
gw.request<{ info?: SkillInfo }>('skills.manage', { action: 'inspect', query: name })
.then(r => setInfo(r?.info ?? { name }))
.catch((e: unknown) => setErr(rpcErrorMessage(e)))
}
const install = (name: string) => {
setInstalling(true)
setErr('')
gw.request<{ installed?: boolean; name?: string }>('skills.manage', { action: 'install', query: name })
.then(() => onClose())
.catch((e: unknown) => setErr(rpcErrorMessage(e)))
.finally(() => setInstalling(false))
}
useInput((ch, key) => {
if (installing) {
return
}
if (stage === 'actions') {
if (key.return) {
setStage('skill')
setInfo(null)
setErr('')
return
}
if (ch.toLowerCase() === 'x' && skillName) {
install(skillName)
return
}
if (ch.toLowerCase() === 'i' && skillName) {
inspect(skillName)
}
return
}
const count = stage === 'category' ? cats.length : skills.length
const sel = stage === 'category' ? catIdx : skillIdx
const setSel = stage === 'category' ? setCatIdx : setSkillIdx
if (key.upArrow && sel > 0) {
setSel(v => v - 1)
return
}
if (key.downArrow && sel < count - 1) {
setSel(v => v + 1)
return
}
if (key.return) {
if (stage === 'category') {
const cat = cats[catIdx]
if (!cat) {
return
}
setSelectedCat(cat)
setSkillIdx(0)
setStage('skill')
return
}
const name = skills[skillIdx]
if (name) {
setStage('actions')
inspect(name)
}
return
}
const n = ch === '0' ? 10 : parseInt(ch, 10)
if (!Number.isNaN(n) && n >= 1 && n <= Math.min(10, count)) {
const next = windowOffset(count, sel, VISIBLE) + n - 1
if (stage === 'category') {
const cat = cats[next]
if (cat) {
setSelectedCat(cat)
setCatIdx(next)
setSkillIdx(0)
setStage('skill')
}
return
}
const name = skills[next]
if (name) {
setSkillIdx(next)
setStage('actions')
inspect(name)
}
}
})
if (loading) {
return <Text color={t.color.muted}>loading skills…</Text>
}
if (err && stage === 'category') {
return (
<Box flexDirection="column" width={width}>
<Text color={t.color.label}>error: {err}</Text>
<OverlayHint t={t}>Esc/q cancel</OverlayHint>
</Box>
)
}
if (!cats.length) {
return (
<Box flexDirection="column" width={width}>
<Text color={t.color.muted}>no skills available</Text>
<OverlayHint t={t}>Esc/q cancel</OverlayHint>
</Box>
)
}
if (stage === 'category') {
const rows = cats.map(c => `${c} · ${skillsByCat[c]?.length ?? 0} skills`)
const { items, offset } = windowItems(rows, catIdx, VISIBLE)
return (
<Box flexDirection="column" width={width}>
<Text bold color={t.color.accent}>
Skills Hub
</Text>
<Text color={t.color.muted}>select a category</Text>
{offset > 0 && <Text color={t.color.muted}> ↑ {offset} more</Text>}
{items.map((row, i) => {
const idx = offset + i
return (
<Text color={t.color.muted} {...chipRowProps(t, catIdx === idx)} key={row} wrap="truncate-end">
{catIdx === idx ? '▸ ' : ' '}
{i + 1}. {row}
</Text>
)
})}
{offset + VISIBLE < rows.length && <Text color={t.color.muted}> ↓ {rows.length - offset - VISIBLE} more</Text>}
<OverlayHint t={t}>↑/↓ select · Enter open · 1-9,0 quick · Esc/q cancel</OverlayHint>
</Box>
)
}
if (stage === 'skill') {
const { items, offset } = windowItems(skills, skillIdx, VISIBLE)
return (
<Box flexDirection="column" width={width}>
<Text bold color={t.color.accent}>
{selectedCat}
</Text>
<Text color={t.color.muted}>{skills.length} skill(s)</Text>
{!skills.length ? <Text color={t.color.muted}>no skills in this category</Text> : null}
{offset > 0 && <Text color={t.color.muted}> ↑ {offset} more</Text>}
{items.map((row, i) => {
const idx = offset + i
return (
<Text color={t.color.muted} {...chipRowProps(t, skillIdx === idx)} key={row} wrap="truncate-end">
{skillIdx === idx ? '▸ ' : ' '}
{i + 1}. {row}
</Text>
)
})}
{offset + VISIBLE < skills.length && (
<Text color={t.color.muted}> ↓ {skills.length - offset - VISIBLE} more</Text>
)}
<OverlayHint t={t}>
{skills.length ? '↑/↓ select · Enter open · 1-9,0 quick · Esc back · q close' : 'Esc back · q close'}
</OverlayHint>
</Box>
)
}
return (
<Box flexDirection="column" width={width}>
<Text bold color={t.color.accent}>
{info?.name ?? skillName}
</Text>
<Text color={t.color.muted}>{info?.category ?? selectedCat}</Text>
{info?.description ? <Text color={t.color.text}>{info.description}</Text> : null}
{info?.path ? <Text color={t.color.muted}>path: {info.path}</Text> : null}
{!info && !err ? <Text color={t.color.muted}>loading…</Text> : null}
{err ? <Text color={t.color.label}>error: {err}</Text> : null}
{installing ? <Text color={t.color.accent}>installing…</Text> : null}
<OverlayHint t={t}>i reinspect · x reinstall · Enter/Esc back · q close</OverlayHint>
</Box>
)
}
interface SkillInfo {
category?: string
description?: string
name?: string
path?: string
}
interface SkillsHubProps {
gw: GatewayClient
maxWidth?: number
onClose: () => void
t: Theme
}
@@ -0,0 +1,120 @@
import { useStore } from '@nanostores/react'
import { memo } from 'react'
import type { AppLayoutProgressProps } from '../app/interfaces.js'
import { toggleTodoCollapsed, useTurnSelector } from '../app/turnStore.js'
import { $uiState } from '../app/uiStore.js'
import { blockRenders } from '../domain/blockLayout.js'
import { appendToolShelfMessage } from '../lib/liveProgress.js'
import type { ActiveTool, DetailsMode, Msg, SectionVisibility } from '../types.js'
import { MessageLine } from './messageLine.js'
import { TodoPanel } from './todoPanel.js'
const groupedSegments = (segments: Msg[]): Msg[] =>
segments.reduce<Msg[]>((acc, msg) => appendToolShelfMessage(acc, msg), [])
interface LiveBlock {
isStreaming?: boolean
key: string
msg: Msg
tools?: ActiveTool[]
}
export const StreamingAssistant = memo(function StreamingAssistant({
cols,
compact,
detailsMode,
detailsModeCommandOverride,
prevMsg,
progress,
sections
}: StreamingAssistantProps) {
const ui = useStore($uiState)
const streamSegments = useTurnSelector(state => state.streamSegments)
const streamPendingTools = useTurnSelector(state => state.streamPendingTools)
const streaming = useTurnSelector(state => state.streaming)
const activeTools = useTurnSelector(state => state.tools)
const showStreamingArea = Boolean(streaming)
if (!progress.showProgressArea && !showStreamingArea && !activeTools.length) {
return null
}
// Flatten the live area into one ordered list so each block's leading gap
// can be derived from the block directly above it — including the boundary
// back into settled history (prevMsg). Tracking the predecessor rather than
// the live text is what keeps the streaming block from jumping when it
// flushes into a settled segment.
const blocks: LiveBlock[] = groupedSegments(streamSegments).map((msg, i) => ({ key: `seg:${i}`, msg }))
if (activeTools.length) {
blocks.push({ key: 'active-tools', msg: { kind: 'trail', role: 'system', text: '' }, tools: activeTools })
}
if (showStreamingArea) {
blocks.push({
isStreaming: true,
key: 'streaming',
msg: { role: 'assistant', text: streaming, ...(streamPendingTools.length && { tools: streamPendingTools }) }
})
} else if (streamPendingTools.length) {
blocks.push({ key: 'pending-tools', msg: { kind: 'trail', role: 'system', text: '', tools: streamPendingTools } })
}
const detailsCtx = { commandOverride: detailsModeCommandOverride, detailsMode, sections }
let prev = prevMsg
return (
<>
{blocks.map(block => {
const node = (
<MessageLine
cols={cols}
compact={compact}
detailsMode={detailsMode}
detailsModeCommandOverride={detailsModeCommandOverride}
isStreaming={block.isStreaming}
key={block.key}
liveDetails
msg={block.msg}
prev={prev}
reasoningActive={block.msg.isLiveReasoning === true}
sections={sections}
t={ui.theme}
{...(block.tools ? { tools: block.tools } : {})}
/>
)
// Advance the grouping predecessor only past blocks that actually
// paint, so a trail hidden by /details stays transparent here too
// (active tools live in the prop, so fold them into the check).
const checkMsg = block.tools?.length ? { ...block.msg, tools: block.tools.map(tool => tool.name) } : block.msg
if (blockRenders(checkMsg, detailsCtx)) {
prev = block.msg
}
return node
})}
</>
)
})
export const LiveTodoPanel = memo(function LiveTodoPanel() {
const ui = useStore($uiState)
const todos = useTurnSelector(state => state.todos)
const collapsed = useTurnSelector(state => state.todoCollapsed)
return <TodoPanel collapsed={collapsed} onToggle={toggleTodoCollapsed} t={ui.theme} todos={todos} />
})
interface StreamingAssistantProps {
cols: number
compact?: boolean
detailsMode: DetailsMode
detailsModeCommandOverride: boolean
prevMsg?: Msg
progress: AppLayoutProgressProps
sections?: SectionVisibility
}
+166
View File
@@ -0,0 +1,166 @@
// StreamingMd — incremental markdown renderer for in-flight assistant text.
//
// Rendering <Md text={full}/> per delta re-tokenizes the whole message every
// time (O(total) × deltas). The prior stable-prefix split fixed the per-delta
// cost but not the per-block cliff: each advanced boundary re-tokenized the
// entire prefix from scratch — O(blocks²) — plus an O(total) fence rescan.
//
// This is fully incremental. A forward scanner keeps fence/math state + scan
// position in a ref across deltas, so each delta touches only newly-arrived
// complete lines. Settled top-level blocks (split on "\n\n" outside a fence)
// are frozen into an append-only array, each its own <Md> memoized on text
// that never changes — every block tokenizes exactly once. Only the in-flight
// tail re-parses per delta (O(tail)).
//
// Invariants that keep it correct:
// · Only newline-terminated lines are scanned; a partial trailing line may
// yet become a fence opener, so it stays in the tail.
// · Blank-line boundaries can't be retroactively merged (a setext underline
// only binds the contiguous line above it, never across a committed "\n\n").
// · An unmatched `$$` / `\[` opener is treated as open forever — more
// conservative than markdown.tsx's full-text fallback, because a committed
// block is frozen and can't be un-decided once the closer streams in.
// · State only advances (idempotent under StrictMode). The component
// unmounts between turns; if `text` stops extending `scanned` (turn reuse,
// or boundedLiveRenderText front-trimming a huge reply) the scanner resets
// and <Md>'s LRU absorbs the re-parse.
//
// Layout: the <Md> subtrees MUST stack in a column — the messageLine.tsx
// parent is a default row Box, so bare siblings render side-by-side.
import { Box } from '@hermes/ink'
import { memo, useRef } from 'react'
import type { Theme } from '../theme.js'
import { Md } from './markdown.js'
export interface StreamScanState {
/** Settled top-level block strings, in order. Append-only. */
blocks: string[]
/** Inside an unclosed ``` / ~~~ fence at the scan position. */
codeOpen: boolean
/** Non-null inside an unclosed display-math block at the scan position. */
mathOpener: '$$' | '\\[' | null
/** Prefix whose complete lines have been scanned (kept as text so the reset
* guard can confirm the scanned region — and thus fence state — still holds). */
scanned: string
/** Length of the committed prefix (blocks.join('').length). */
settledLen: number
}
export const createScanState = (): StreamScanState => ({
blocks: [],
codeOpen: false,
mathOpener: null,
scanned: '',
settledLen: 0
})
// Fold one complete line into the fence/math state: ``` / ~~~ toggle the code
// fence; `$$` / `\[` open display math unless the line also closes it; closers
// count only against a pending opener; math inside an open code fence is inert.
const applyLine = (state: StreamScanState, line: string) => {
if (/^(?:`{3,}|~{3,})/.test(line)) {
state.codeOpen = !state.codeOpen
return
}
if (state.codeOpen) {
return
}
if (!state.mathOpener) {
if (/^\$\$/.test(line) && !(line.length >= 4 && /\$\$$/.test(line))) {
state.mathOpener = '$$'
} else if (/^\\\[/.test(line) && !/\\\]$/.test(line)) {
state.mathOpener = '\\['
}
} else if (state.mathOpener === '$$' && /\$\$$/.test(line)) {
state.mathOpener = null
} else if (state.mathOpener === '\\[' && /\\\]$/.test(line)) {
state.mathOpener = null
}
}
// Consume newly-arrived complete lines, committing a settled block at every
// "\n\n" outside a fence. Whitespace-only runs stay with the next block, never
// committed as empty <Md>s. Mutates `state`; re-calling with the same text is
// a no-op (idempotent).
export const advanceScan = (text: string, state: StreamScanState) => {
const start = state.scanned.length
let i = start
while (i < text.length) {
const nl = text.indexOf('\n', i)
if (nl < 0) {
break // partial trailing line — could still open a fence; keep in tail
}
if (nl === i) {
// Second half of a "\n\n" outside any fence → prior text is a block.
if (i > 0 && !state.codeOpen && !state.mathOpener) {
const block = text.slice(state.settledLen, nl + 1)
if (/\S/.test(block)) {
state.blocks.push(block)
state.settledLen = nl + 1
}
}
} else {
applyLine(state, text.slice(i, nl).trim())
}
i = nl + 1
}
if (i > start) {
state.scanned += text.slice(start, i)
}
}
// Index just past the last committed boundary, or -1 if nothing has settled.
// Thin wrapper over the scanner for boundary-semantics tests.
export const findStableBoundary = (text: string) => {
const state = createScanState()
advanceScan(text, state)
return state.settledLen > 0 ? state.settledLen : -1
}
export const StreamingMd = memo(function StreamingMd({ cols, compact, t, text }: StreamingMdProps) {
const scanRef = useRef<StreamScanState>(createScanState())
let state = scanRef.current
// Reset if `text` no longer extends the scanned prefix (turn reuse, or a
// front-trim), which would invalidate the persisted fence state.
if (!text.startsWith(state.scanned)) {
state = scanRef.current = createScanState()
}
advanceScan(text, state)
const tail = text.slice(state.settledLen)
return (
<Box flexDirection="column">
{state.blocks.map((block, i) => (
<Md cols={cols} compact={compact} key={i} t={t} text={block} />
))}
{tail ? <Md cols={cols} compact={compact} t={t} text={tail} /> : null}
</Box>
)
})
interface StreamingMdProps {
cols?: number
compact?: boolean
t: Theme
text: string
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+30
View File
@@ -0,0 +1,30 @@
import { Text } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import type { ReactNode } from 'react'
import { $uiState } from '../app/uiStore.js'
import type { ThemeColors } from '../theme.js'
export function Fg({ bold, c, children, dim, italic, literal, strikethrough, underline, wrap }: FgProps) {
const { theme } = useStore($uiState)
return (
<Text color={literal ?? (c && theme.color[c])} dimColor={dim} {...{ bold, italic, strikethrough, underline, wrap }}>
{children}
</Text>
)
}
export type ThemeColor = keyof ThemeColors
export interface FgProps {
bold?: boolean
c?: ThemeColor
children?: ReactNode
dim?: boolean
italic?: boolean
literal?: string
strikethrough?: boolean
underline?: boolean
wrap?: 'end' | 'middle' | 'truncate' | 'truncate-end' | 'truncate-middle' | 'truncate-start' | 'wrap' | 'wrap-trim'
}
File diff suppressed because it is too large. Load diff
+95
View File
@@ -0,0 +1,95 @@
import { Box, Text } from '@hermes/ink'
import { memo, useState } from 'react'
import { countPendingTodos } from '../lib/liveProgress.js'
import { todoGlyph, todoTone, todoTree } from '../lib/todo.js'
import type { Theme } from '../theme.js'
import type { TodoItem } from '../types.js'
const rowColor = (t: Theme, status: TodoItem['status']) => {
const tone = todoTone(status)
return tone === 'active' ? t.color.text : tone === 'body' ? t.color.statusFg : t.color.muted
}
export const TodoPanel = memo(function TodoPanel({
collapsed,
defaultCollapsed = false,
incomplete = false,
onToggle,
t,
todos
}: {
collapsed?: boolean
defaultCollapsed?: boolean
incomplete?: boolean
onToggle?: () => void
t: Theme
todos: TodoItem[]
}) {
// Fallback local state for archived todos in transcript where there's no
// external controller. Live TodoPanel passes collapsed+onToggle from the
// turn store so clicks still work there.
const [localCollapsed, setLocalCollapsed] = useState(defaultCollapsed)
const isControlled = typeof collapsed === 'boolean'
const effectiveCollapsed = isControlled ? collapsed : localCollapsed
const handleToggle = () => {
if (onToggle) {
onToggle()
return
}
if (!isControlled) {
setLocalCollapsed(v => !v)
}
}
if (!todos.length) {
return null
}
const done = todos.filter(todo => todo.status === 'completed').length
const pending = countPendingTodos(todos)
return (
<Box flexDirection="column" marginBottom={1}>
<Box onClick={handleToggle}>
<Text color={t.color.muted}>
<Text color={t.color.accent}>{effectiveCollapsed ? '▸ ' : '▾ '}</Text>
<Text bold color={t.color.text}>
Todo
</Text>{' '}
<Text color={t.color.statusFg} dim>
({done}/{todos.length})
</Text>
{incomplete && pending > 0 && (
<Text color={t.color.muted} dim>
{' '}
· incomplete · {pending} still {pending === 1 ? 'pending' : 'pending/in_progress'}
</Text>
)}
</Text>
</Box>
{!effectiveCollapsed && (
<Box flexDirection="column" marginLeft={2}>
{todoTree(todos).map(([todo, depth]) => {
const tone = todoTone(todo.status)
const color = rowColor(t, todo.status)
return (
<Box key={todo.id} marginLeft={Math.min(depth, 4) * 2}>
<Text color={color} dim={tone === 'dim'}>
<Text color={color}>{todoGlyph(todo.status)} </Text>
{todo.content}
</Text>
</Box>
)
})}
</Box>
)}
</Box>
)
})
+267
View File
@@ -0,0 +1,267 @@
import { Box } from '@hermes/ink'
import { Fragment, memo, type ReactNode, useMemo } from 'react'
import {
type GridAreaCell,
type GridAreaItem,
type GridTrackSize,
layoutGridAreas,
layoutWidgetGrid,
type WidgetGridCell,
type WidgetGridItem,
widgetGridSpanWidth
} from '../lib/widgetGrid.js'
export interface WidgetGridRenderContext {
cell: WidgetGridCell
width: number
}
type WidgetGridChildren = ((ctx: WidgetGridRenderContext) => ReactNode) | ReactNode
/**
* A grid item with optional content. Use `children` for static or stateful
* React subtrees (including a nested `WidgetGrid`) and `render` for a width-
* aware factory; if both are provided, `render` wins.
*/
export interface WidgetGridWidget extends WidgetGridItem {
children?: WidgetGridChildren
render?: (width: number, cell: WidgetGridCell) => ReactNode
}
/**
* `WidgetGrid` lays out children into rows/cols using the same primitives as
* CSS grid: explicit `columns` count or a width-derived auto count, per-item
* `colStart` / `colSpan`, and uniform `gap` / `rowGap`. Cells clip their
* contents (`overflow: hidden`) so child overflow can never bleed into the
* neighbouring cell or break the parent border.
*/
interface WidgetGridProps {
/** Column count (equal shares) or grid-template-style track list. */
columns?: GridTrackSize[] | number
cols: number
depth?: number
gap?: number
maxColumns?: number
minColumnWidth?: number
paddingX?: number
paddingY?: number
rowGap?: number
widgets: WidgetGridWidget[]
}
const toInt = (value: number, fallback: number) => (Number.isFinite(value) ? Math.trunc(value) : fallback)
const columnCountHint = (columns: GridTrackSize[] | number | undefined) =>
Array.isArray(columns) ? columns.length : (columns ?? 0)
const inferredGap = (cols: number, columns: GridTrackSize[] | number | undefined, depth: number) => {
const count = columnCountHint(columns)
if (cols < 36 || count >= 8) {
return 0
}
if (depth > 0 || cols < 72 || count >= 4) {
return 1
}
return 2
}
const inferredPaddingX = (cols: number, depth: number) => {
if (depth <= 0 || cols < 24) {
return 0
}
return cols >= 56 ? 2 : 1
}
const inferredRowGap = (depth: number) => (depth > 0 ? 0 : 1)
export const WidgetGrid = memo(function WidgetGrid({
columns,
cols,
depth = 0,
gap,
maxColumns = 2,
minColumnWidth = 46,
paddingX,
paddingY,
rowGap,
widgets
}: WidgetGridProps) {
const safeCols = Math.max(1, toInt(cols, 1))
const safePaddingX = Math.max(0, toInt(paddingX ?? inferredPaddingX(safeCols, depth), 0))
const safePaddingY = Math.max(0, toInt(paddingY ?? 0, 0))
const innerCols = Math.max(1, safeCols - safePaddingX * 2)
const safeGap = Math.max(0, toInt(gap ?? inferredGap(innerCols, columns, depth), 0))
const safeRowGap = Math.max(0, toInt(rowGap ?? inferredRowGap(depth), 0))
const layout = useMemo(
() =>
layoutWidgetGrid({
columns,
gap: safeGap,
items: widgets.map(({ colSpan, colStart, id, span }) => ({ colSpan, colStart, id, span })),
maxColumns,
minColumnWidth,
width: innerCols
}),
[columns, innerCols, maxColumns, minColumnWidth, safeGap, widgets]
)
const widgetById = useMemo(() => new Map(widgets.map(widget => [widget.id, widget])), [widgets])
if (!layout.rows.length) {
return null
}
return (
<Box flexDirection="column" paddingX={safePaddingX} paddingY={safePaddingY} width={safeCols}>
{layout.rows.map((row, rowIdx) => (
<Box flexDirection="column" key={`row-${rowIdx}`}>
<Box flexDirection="row">
<WidgetRow cells={row} columns={layout.columns} gap={safeGap} widgetById={widgetById} />
</Box>
{safeRowGap > 0 && rowIdx < layout.rows.length - 1 ? <Box height={safeRowGap} /> : null}
</Box>
))}
</Box>
)
})
const WidgetRow = memo(function WidgetRow({
cells,
columns,
gap,
widgetById
}: {
cells: WidgetGridCell[]
columns: number[]
gap: number
widgetById: Map<string, WidgetGridWidget>
}) {
return (
<>
{cells.map((cell, idx) => {
const cursor = idx === 0 ? 0 : cells[idx - 1]!.col + cells[idx - 1]!.span
const spacerWidth =
cell.col === 0
? 0
: cursor === 0
? widgetGridSpanWidth(columns, 0, cell.col, gap) + gap
: gap + (cell.col > cursor ? widgetGridSpanWidth(columns, cursor, cell.col - cursor, gap) + gap : 0)
return (
<Fragment key={cell.id}>
{spacerWidth > 0 ? <Box flexShrink={0} width={spacerWidth} /> : null}
<WidgetCell cell={cell} widget={widgetById.get(cell.id)} />
</Fragment>
)
})}
</>
)
})
const WidgetCell = memo(function WidgetCell({ cell, widget }: { cell: WidgetGridCell; widget?: WidgetGridWidget }) {
const node =
widget?.render?.(cell.width, cell) ??
(typeof widget?.children === 'function' ? widget.children({ cell, width: cell.width }) : widget?.children) ??
null
return (
<Box flexShrink={0} overflow="hidden" width={cell.width}>
{node}
</Box>
)
})
// ── GridAreas: the two-axis workspace mode ──────────────────────────────────
type GridAreaChildren = ((cell: GridAreaCell) => ReactNode) | ReactNode
/**
* An area widget: placement (`col`/`row`/`colSpan`/`rowSpan`) plus content.
* `render` receives the solved cell (with `width`/`height` in terminal
* cells); `children` accepts a static subtree or a factory. `render` wins
* when both are given, mirroring `WidgetGridWidget`.
*/
export interface GridAreaWidget extends GridAreaItem {
children?: GridAreaChildren
render?: (cell: GridAreaCell) => ReactNode
}
interface GridAreasProps {
/** Column tracks: a count (equal shares) or explicit fixed/`fr` tracks. */
columns: GridTrackSize[] | number
gap?: number
height: number
rowGap?: number
/** Row tracks. Omitted: every row is an equal `fr` share of `height`. */
rows?: GridTrackSize[] | number
widgets: GridAreaWidget[]
width: number
}
/**
* `GridAreas` renders widgets into a fully two-dimensional grid: explicit
* column AND row tracks (fixed cells or weighted `fr` shares), `colSpan` /
* `rowSpan`, `col` / `row` pins, and dense auto-placement. Unlike the flowing
* `WidgetGrid` (which stacks rows and cannot express `rowSpan`), every cell
* here is solved to a rect and absolutely positioned inside a fixed-size box,
* so a cell can span rows the same way a merged FancyZones cell does on the
* desktop app. Requires a known `height`.
*/
export const GridAreas = memo(function GridAreas({
columns,
gap = 1,
height,
rowGap = 0,
rows,
widgets,
width
}: GridAreasProps) {
const layout = useMemo(
() =>
layoutGridAreas({
columns,
gap,
height,
items: widgets.map(({ col, colSpan, id, row, rowSpan }) => ({ col, colSpan, id, row, rowSpan })),
rowGap,
rows,
width
}),
[columns, gap, height, rowGap, rows, widgets, width]
)
const widgetById = useMemo(() => new Map(widgets.map(widget => [widget.id, widget])), [widgets])
if (!layout.cells.length) {
return null
}
return (
<Box flexDirection="column" height={layout.height} overflow="hidden" width={layout.width}>
{layout.cells.map(cell => (
<AreaCell cell={cell} key={cell.id} widget={widgetById.get(cell.id)} />
))}
</Box>
)
})
const AreaCell = memo(function AreaCell({ cell, widget }: { cell: GridAreaCell; widget?: GridAreaWidget }) {
const node =
widget?.render?.(cell) ??
(typeof widget?.children === 'function' ? widget.children(cell) : widget?.children) ??
null
return (
<Box height={cell.height} left={cell.x} overflow="hidden" position="absolute" top={cell.y} width={cell.width}>
{node}
</Box>
)
})