Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
commit
03634b1ca3
11340 files changed
+3442369
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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 })
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 & 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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
Reference in new issue
Block a user