Download ui-tui/src/components/activeSessionSwitcher.tsx from SaylorTwift/hermes-agent: direct link, hf CLI and curl.
- Browser
- Download file 29 kB
-
https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/ui-tui/src/components/activeSessionSwitcher.tsx
- Command line
-
hf download hf://SaylorTwift/hermes-agent/ui-tui/src/components/activeSessionSwitcher.tsx
-
curl -L -o activeSessionSwitcher.tsx https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/ui-tui/src/components/activeSessionSwitcher.tsx
29 kB
| import { Box, Text, useInput, useStdout } from '@hermes/ink' | |
| import type { SessionListResult, SessionListRow } from '@hermes/shared/gateway-events' | |
| 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 | |
| } 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 SessionListRow[], 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' || part === '--reasoning') { | |
| 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<SessionListRow[]>([]) | |
| 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<SessionListRow[]>([]) | |
| // 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<SessionListRow[]>([]) | |
| 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<SessionListResult>('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<SessionListResult>(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 | |
| } | |