mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-30 01:41:43 +00:00
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC
Full codebase pass using the /clean doctrine (KISS/DRY, no one-off
helpers, no variables-used-once, pure functional where natural,
inlined obvious one-liners, killed dead exports, narrowed types,
spaced JSX). All contracts preserved — no RPC method, event name,
or exported type shape changed.
app/ — 15 files, -134 LOC
- inlined 4 one-off helpers (titleCase, isLong, statusToneFrom,
focusOutside predicate)
- stores to arrow-const style (buildUiState, buildTurnState,
buildOverlayState plus get/patch/reset triplets)
- functional slash/registry byName map (flatMap over for-loops)
- dropped dead param `live` in cancelOverlayFromCtrlC
- DRY'd duplicate shift() call in scrollWithSelection
- consolidated sections.push calls in /help
components/ — 12 files, -40 LOC
- extracted inline prop types to interfaces at file bottom (13×)
- inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint)
- promoted HEART_COLORS + OPTS/LABELS to module scope
- JSX sibling spacing across 9 files
- un-shadowed `raw` in textInput
- components/thinking.tsx + components/markdown.tsx untouched
(structurally load-bearing / edge-case-heavy)
config content domain protocol/ — 8 files, -77 LOC
- tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand,
hasInterpolation — dropped stateful lastIndex dance)
- dead export ParsedSlashCommand removed
- MODES narrowed to `as const`, `.find(m => m === s)` replaces
`.includes() ? (as cast) : null`
- fortunes.ts hash via reduce
- fmtDuration ternary chain
- inlined aboveViewport predicate in viewport.ts
hooks/ + lib/ — 9 files, -38 LOC
- ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module
scope (no more eslint-disable no-control-regex)
- compactPreview/edgePreview/thinkingPreview → ternary arrows
- useCompletion: hoisted pathReplace, moved stale-ref guard earlier
- useInputHistory: dropped useCallback wrapper (append is stable)
- useVirtualHistory: replaced 4× any with unknown + narrow
MeasuredNode interface + one cast site
root TS — 3 files, -63 LOC
- banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex,
artWidth via reduce
- gatewayClient.ts: resolvePython candidate list collapse, inlined
one-branch guards in dispatch/pushLog/drain/request
- types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq
members
eslint config
- disabled react-hooks/exhaustive-deps on packages/hermes-ink/**
(compiled by react/compiler, deps live in $[N] memo arrays that
eslint can't introspect) and removed the now-orphan in-file
disable directive in ScrollBox.tsx
fixes (not from the cleaner pass)
- useComposerState: unlinkSync(file) + try/catch → rmSync(file,
{ force: true }) — kills the no-empty lint error and is more
idiomatic
- useConfigSync: added setBellOnComplete + setVoiceEnabled to the
two useEffect dep arrays (they're stable React setState setters;
adding is safe and silences exhaustive-deps)
verification
- npx eslint src/ packages/ → 0 errors, 0 warnings
- npm run type-check → clean
- npm test → 50/50
- npm run build → 394.8kb ink-bundle.js, 11ms esbuild
- pytest tests/tui_gateway/ tests/test_tui_gateway_server.py
tests/hermes_cli/test_tui_resume_flow.py
tests/hermes_cli/test_tui_npm_install.py → 57/57
This commit is contained in:
parent
c730ab8ad7
commit
39231f29c6
49 changed files with 527 additions and 744 deletions
|
|
@ -10,6 +10,7 @@ import type { Theme } from '../theme.js'
|
|||
import type { Msg, Usage } from '../types.js'
|
||||
|
||||
const FACE_TICK_MS = 2500
|
||||
const HEART_COLORS = ['#ff5fa2', '#ff4d6d']
|
||||
|
||||
function FaceTicker({ color }: { color: string }) {
|
||||
const [tick, setTick] = useState(() => Math.floor(Math.random() * 1000))
|
||||
|
|
@ -76,10 +77,8 @@ export function GoodVibesHeart({ tick, t }: { tick: number; t: Theme }) {
|
|||
return
|
||||
}
|
||||
|
||||
const options = ['#ff5fa2', '#ff4d6d', t.color.amber]
|
||||
const picked = options[Math.floor(Math.random() * options.length)]!
|
||||
|
||||
setColor(picked)
|
||||
const palette = [...HEART_COLORS, t.color.amber]
|
||||
setColor(palette[Math.floor(Math.random() * palette.length)]!)
|
||||
setActive(true)
|
||||
|
||||
const id = setTimeout(() => setActive(false), 650)
|
||||
|
|
@ -102,19 +101,7 @@ export function StatusRule({
|
|||
sessionStartedAt,
|
||||
voiceLabel,
|
||||
t
|
||||
}: {
|
||||
cwdLabel: string
|
||||
cols: number
|
||||
busy: boolean
|
||||
status: string
|
||||
statusColor: string
|
||||
model: string
|
||||
usage: Usage
|
||||
bgCount: number
|
||||
sessionStartedAt?: number | null
|
||||
voiceLabel?: string
|
||||
t: Theme
|
||||
}) {
|
||||
}: StatusRuleProps) {
|
||||
const pct = usage.context_percent
|
||||
const barColor = ctxBarColor(pct, t)
|
||||
|
||||
|
|
@ -124,7 +111,6 @@ export function StatusRule({
|
|||
? `${fmtK(usage.total)} tok`
|
||||
: ''
|
||||
|
||||
const pctLabel = pct != null ? `${pct}%` : ''
|
||||
const bar = usage.context_max ? ctxBar(pct) : ''
|
||||
const leftWidth = Math.max(12, cols - cwdLabel.length - 3)
|
||||
|
||||
|
|
@ -139,7 +125,7 @@ export function StatusRule({
|
|||
{bar ? (
|
||||
<Text color={t.color.dim}>
|
||||
{' │ '}
|
||||
<Text color={barColor}>[{bar}]</Text> <Text color={barColor}>{pctLabel}</Text>
|
||||
<Text color={barColor}>[{bar}]</Text> <Text color={barColor}>{pct != null ? `${pct}%` : ''}</Text>
|
||||
</Text>
|
||||
) : null}
|
||||
{sessionStartedAt ? (
|
||||
|
|
@ -152,6 +138,7 @@ export function StatusRule({
|
|||
{bgCount > 0 ? <Text color={t.color.dim}> │ {bgCount} bg</Text> : null}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Text color={t.color.bronze}> ─ </Text>
|
||||
<Text color={t.color.label}>{cwdLabel}</Text>
|
||||
</Box>
|
||||
|
|
@ -174,17 +161,7 @@ export function FloatBox({ children, color }: { children: ReactNode; color: stri
|
|||
)
|
||||
}
|
||||
|
||||
export function StickyPromptTracker({
|
||||
messages,
|
||||
offsets,
|
||||
scrollRef,
|
||||
onChange
|
||||
}: {
|
||||
messages: readonly Msg[]
|
||||
offsets: ArrayLike<number>
|
||||
scrollRef: RefObject<ScrollBoxHandle | null>
|
||||
onChange: (text: string) => void
|
||||
}) {
|
||||
export function StickyPromptTracker({ messages, offsets, scrollRef, onChange }: StickyPromptTrackerProps) {
|
||||
useSyncExternalStore(
|
||||
useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]),
|
||||
() => {
|
||||
|
|
@ -210,13 +187,9 @@ export function StickyPromptTracker({
|
|||
return null
|
||||
}
|
||||
|
||||
export function TranscriptScrollbar({ scrollRef, t }: { scrollRef: RefObject<ScrollBoxHandle | null>; t: Theme }) {
|
||||
export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps) {
|
||||
useSyncExternalStore(
|
||||
useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]),
|
||||
// Quantize the scroll snapshot to the values the thumb actually renders
|
||||
// with — thumbTop + thumbSize + viewport height. Streaming drives
|
||||
// scrollHeight up by ~1 row at a time, but the quantized thumb usually
|
||||
// doesn't move, so we skip thousands of render cycles mid-turn.
|
||||
() => {
|
||||
const s = scrollRef.current
|
||||
|
||||
|
|
@ -304,3 +277,29 @@ export function TranscriptScrollbar({ scrollRef, t }: { scrollRef: RefObject<Scr
|
|||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
interface StatusRuleProps {
|
||||
bgCount: number
|
||||
busy: boolean
|
||||
cols: number
|
||||
cwdLabel: string
|
||||
model: string
|
||||
sessionStartedAt?: number | null
|
||||
status: string
|
||||
statusColor: string
|
||||
t: Theme
|
||||
usage: Usage
|
||||
voiceLabel?: string
|
||||
}
|
||||
|
||||
interface StickyPromptTrackerProps {
|
||||
messages: readonly Msg[]
|
||||
offsets: ArrayLike<number>
|
||||
onChange: (text: string) => void
|
||||
scrollRef: RefObject<ScrollBoxHandle | null>
|
||||
}
|
||||
|
||||
interface TranscriptScrollbarProps {
|
||||
scrollRef: RefObject<ScrollBoxHandle | null>
|
||||
t: Theme
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,20 +24,13 @@ const StreamingAssistant = memo(function StreamingAssistant({
|
|||
detailsMode,
|
||||
progress,
|
||||
t
|
||||
}: {
|
||||
busy: boolean
|
||||
cols: number
|
||||
compact?: boolean
|
||||
detailsMode: DetailsMode
|
||||
progress: AppLayoutProgressProps
|
||||
t: Theme
|
||||
}) {
|
||||
}: StreamingAssistantProps) {
|
||||
if (!progress.showProgressArea && !progress.showStreamingArea) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<>
|
||||
{progress.showProgressArea && (
|
||||
<Box flexDirection="column" marginBottom={progress.showStreamingArea ? 1 : 0}>
|
||||
<ToolTrail
|
||||
|
|
@ -67,7 +60,7 @@ const StreamingAssistant = memo(function StreamingAssistant({
|
|||
t={t}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -79,15 +72,13 @@ const TranscriptPane = memo(function TranscriptPane({
|
|||
}: Pick<AppLayoutProps, 'actions' | 'composer' | 'progress' | 'transcript'>) {
|
||||
const ui = useStore($uiState)
|
||||
|
||||
const visibleHistory = transcript.virtualRows.slice(transcript.virtualHistory.start, transcript.virtualHistory.end)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollBox flexDirection="column" flexGrow={1} flexShrink={1} ref={transcript.scrollRef} stickyScroll>
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
{transcript.virtualHistory.topSpacer > 0 ? <Box height={transcript.virtualHistory.topSpacer} /> : null}
|
||||
|
||||
{visibleHistory.map(row => (
|
||||
{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.kind === 'intro' ? (
|
||||
<Box flexDirection="column" paddingTop={1}>
|
||||
|
|
@ -234,6 +225,7 @@ const ComposerPane = memo(function ComposerPane({
|
|||
placeholder={composer.empty ? PLACEHOLDER : ui.busy ? 'Ctrl+C to interrupt…' : ''}
|
||||
value={composer.input}
|
||||
/>
|
||||
|
||||
<Box position="absolute" right={0}>
|
||||
<GoodVibesHeart t={ui.theme} tick={status.goodVibesTick} />
|
||||
</Box>
|
||||
|
|
@ -267,3 +259,12 @@ export const AppLayout = memo(function AppLayout({
|
|||
</AlternateScreen>
|
||||
)
|
||||
})
|
||||
|
||||
interface StreamingAssistantProps {
|
||||
busy: boolean
|
||||
cols: number
|
||||
compact?: boolean
|
||||
detailsMode: DetailsMode
|
||||
progress: AppLayoutProgressProps
|
||||
t: Theme
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,18 +28,17 @@ export function AppOverlays({
|
|||
const overlay = useStore($overlayState)
|
||||
const ui = useStore($uiState)
|
||||
|
||||
if (
|
||||
!(
|
||||
overlay.approval ||
|
||||
overlay.clarify ||
|
||||
overlay.modelPicker ||
|
||||
overlay.pager ||
|
||||
overlay.picker ||
|
||||
overlay.secret ||
|
||||
overlay.sudo ||
|
||||
completions.length
|
||||
)
|
||||
) {
|
||||
const hasAny =
|
||||
overlay.approval ||
|
||||
overlay.clarify ||
|
||||
overlay.modelPicker ||
|
||||
overlay.pager ||
|
||||
overlay.picker ||
|
||||
overlay.secret ||
|
||||
overlay.sudo ||
|
||||
completions.length
|
||||
|
||||
if (!hasAny) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,11 +20,10 @@ export function ArtLines({ lines }: { lines: [string, string][] }) {
|
|||
export function Banner({ t }: { t: Theme }) {
|
||||
const cols = useStdout().stdout?.columns ?? 80
|
||||
const logoLines = logo(t.color, t.bannerLogo || undefined)
|
||||
const logoW = t.bannerLogo ? artWidth(logoLines) : LOGO_WIDTH
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
{cols >= logoW ? (
|
||||
{cols >= (t.bannerLogo ? artWidth(logoLines) : LOGO_WIDTH) ? (
|
||||
<ArtLines lines={logoLines} />
|
||||
) : (
|
||||
<Text bold color={t.color.gold}>
|
||||
|
|
@ -37,18 +36,14 @@ export function Banner({ t }: { t: Theme }) {
|
|||
)
|
||||
}
|
||||
|
||||
export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string | null; t: Theme }) {
|
||||
export function SessionPanel({ info, sid, t }: SessionPanelProps) {
|
||||
const cols = useStdout().stdout?.columns ?? 100
|
||||
const heroLines = caduceus(t.color, t.bannerHero || undefined)
|
||||
const heroW = artWidth(heroLines) || CADUCEUS_WIDTH
|
||||
const leftW = Math.min(heroW + 4, Math.floor(cols * 0.4))
|
||||
const leftW = Math.min((artWidth(heroLines) || CADUCEUS_WIDTH) + 4, Math.floor(cols * 0.4))
|
||||
const wide = cols >= 90 && leftW + 40 < cols
|
||||
// Keep an explicit gutter so right border never gets overwritten by long lines.
|
||||
const w = Math.max(20, wide ? cols - leftW - 14 : cols - 12)
|
||||
const lineBudget = Math.max(12, w - 2)
|
||||
const cwd = info.cwd || process.cwd()
|
||||
const strip = (s: string) => (s.endsWith('_tools') ? s.slice(0, -6) : s)
|
||||
const title = `${t.brand.name}${info.version ? ` v${info.version}` : ''}${info.release_date ? ` (${info.release_date})` : ''}`
|
||||
|
||||
const truncLine = (pfx: string, items: string[]) => {
|
||||
let line = ''
|
||||
|
|
@ -78,12 +73,14 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string
|
|||
<Text bold color={t.color.amber}>
|
||||
Available {title}
|
||||
</Text>
|
||||
|
||||
{shown.map(([k, vs]) => (
|
||||
<Text key={k} wrap="truncate">
|
||||
<Text color={t.color.dim}>{strip(k)}: </Text>
|
||||
<Text color={t.color.cornsilk}>{truncLine(strip(k) + ': ', vs)}</Text>
|
||||
</Text>
|
||||
))}
|
||||
|
||||
{overflow > 0 && (
|
||||
<Text color={t.color.dim}>
|
||||
(and {overflow} {overflowLabel})
|
||||
|
|
@ -99,13 +96,16 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string
|
|||
<Box flexDirection="column" marginRight={2} width={leftW}>
|
||||
<ArtLines lines={heroLines} />
|
||||
<Text />
|
||||
|
||||
<Text color={t.color.amber}>
|
||||
{info.model.split('/').pop()}
|
||||
<Text color={t.color.dim}> · Nous Research</Text>
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
{cwd}
|
||||
{info.cwd || process.cwd()}
|
||||
</Text>
|
||||
|
||||
{sid && (
|
||||
<Text>
|
||||
<Text color={t.color.sessionLabel}>Session: </Text>
|
||||
|
|
@ -114,21 +114,27 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string
|
|||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box flexDirection="column" width={w}>
|
||||
<Box justifyContent="center" marginBottom={1}>
|
||||
<Text bold color={t.color.gold}>
|
||||
{title}
|
||||
{t.brand.name}
|
||||
{info.version ? ` v${info.version}` : ''}
|
||||
{info.release_date ? ` (${info.release_date})` : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{section('Tools', info.tools, 8, 'more toolsets…')}
|
||||
{section('Skills', info.skills)}
|
||||
<Text />
|
||||
|
||||
<Text color={t.color.cornsilk}>
|
||||
{flat(info.tools).length} tools{' · '}
|
||||
{flat(info.skills).length} skills
|
||||
{' · '}
|
||||
<Text color={t.color.dim}>/help for commands</Text>
|
||||
</Text>
|
||||
|
||||
{typeof info.update_behind === 'number' && info.update_behind > 0 && (
|
||||
<Text bold color="yellow">
|
||||
! {info.update_behind} {info.update_behind === 1 ? 'commit' : 'commits'} behind
|
||||
|
|
@ -150,7 +156,7 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string
|
|||
)
|
||||
}
|
||||
|
||||
export function Panel({ sections, t, title }: { sections: PanelSection[]; t: Theme; title: string }) {
|
||||
export function Panel({ sections, t, title }: PanelProps) {
|
||||
return (
|
||||
<Box borderColor={t.color.bronze} borderStyle="round" flexDirection="column" paddingX={2} paddingY={1}>
|
||||
<Box justifyContent="center" marginBottom={1}>
|
||||
|
|
@ -186,3 +192,15 @@ export function Panel({ sections, t, title }: { sections: PanelSection[]; t: The
|
|||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
interface PanelProps {
|
||||
sections: PanelSection[]
|
||||
t: Theme
|
||||
title: string
|
||||
}
|
||||
|
||||
interface SessionPanelProps {
|
||||
info: SessionInfo
|
||||
sid?: string | null
|
||||
t: Theme
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,21 +5,7 @@ import type { Theme } from '../theme.js'
|
|||
|
||||
import { TextInput } from './textInput.js'
|
||||
|
||||
export function MaskedPrompt({
|
||||
cols = 80,
|
||||
icon,
|
||||
label,
|
||||
onSubmit,
|
||||
sub,
|
||||
t
|
||||
}: {
|
||||
cols?: number
|
||||
icon: string
|
||||
label: string
|
||||
onSubmit: (v: string) => void
|
||||
sub?: string
|
||||
t: Theme
|
||||
}) {
|
||||
export function MaskedPrompt({ cols = 80, icon, label, onSubmit, sub, t }: MaskedPromptProps) {
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
return (
|
||||
|
|
@ -27,6 +13,7 @@ export function MaskedPrompt({
|
|||
<Text bold color={t.color.warn}>
|
||||
{icon} {label}
|
||||
</Text>
|
||||
|
||||
{sub && <Text color={t.color.dim}> {sub}</Text>}
|
||||
|
||||
<Box>
|
||||
|
|
@ -36,3 +23,12 @@ export function MaskedPrompt({
|
|||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
interface MaskedPromptProps {
|
||||
cols?: number
|
||||
icon: string
|
||||
label: string
|
||||
onSubmit: (v: string) => void
|
||||
sub?: string
|
||||
t: Theme
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,14 +18,7 @@ export const MessageLine = memo(function MessageLine({
|
|||
isStreaming = false,
|
||||
msg,
|
||||
t
|
||||
}: {
|
||||
cols: number
|
||||
compact?: boolean
|
||||
detailsMode?: DetailsMode
|
||||
isStreaming?: boolean
|
||||
msg: Msg
|
||||
t: Theme
|
||||
}) {
|
||||
}: MessageLineProps) {
|
||||
if (msg.kind === 'trail' && msg.tools?.length) {
|
||||
return detailsMode === 'hidden' ? null : (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
|
|
@ -110,3 +103,12 @@ export const MessageLine = memo(function MessageLine({
|
|||
</Box>
|
||||
)
|
||||
})
|
||||
|
||||
interface MessageLineProps {
|
||||
cols: number
|
||||
compact?: boolean
|
||||
detailsMode?: DetailsMode
|
||||
isStreaming?: boolean
|
||||
msg: Msg
|
||||
t: Theme
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,19 +10,13 @@ const VISIBLE = 12
|
|||
|
||||
const pageOffset = (count: number, sel: number) => Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), count - VISIBLE))
|
||||
|
||||
export function ModelPicker({
|
||||
gw,
|
||||
onCancel,
|
||||
onSelect,
|
||||
sessionId,
|
||||
t
|
||||
}: {
|
||||
gw: GatewayClient
|
||||
onCancel: () => void
|
||||
onSelect: (value: string) => void
|
||||
sessionId: string | null
|
||||
t: Theme
|
||||
}) {
|
||||
const visibleItems = (items: string[], sel: number) => {
|
||||
const off = pageOffset(items.length, sel)
|
||||
|
||||
return { items: items.slice(off, off + VISIBLE), off }
|
||||
}
|
||||
|
||||
export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPickerProps) {
|
||||
const [providers, setProviders] = useState<ModelOptionProvider[]>([])
|
||||
const [currentModel, setCurrentModel] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
|
|
@ -66,12 +60,6 @@ export function ModelPicker({
|
|||
const provider = providers[providerIdx]
|
||||
const models = provider?.models ?? []
|
||||
|
||||
const visibleItems = (items: string[], sel: number) => {
|
||||
const off = pageOffset(items.length, sel)
|
||||
|
||||
return { items: items.slice(off, off + VISIBLE), off }
|
||||
}
|
||||
|
||||
useInput((ch, key) => {
|
||||
if (key.escape) {
|
||||
if (stage === 'model') {
|
||||
|
|
@ -182,9 +170,11 @@ export function ModelPicker({
|
|||
<Text bold color={t.color.amber}>
|
||||
Select Provider
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.dim}>Current model: {currentModel || '(unknown)'}</Text>
|
||||
{provider?.warning ? <Text color={t.color.label}>warning: {provider.warning}</Text> : null}
|
||||
{off > 0 && <Text color={t.color.dim}> ↑ {off} more</Text>}
|
||||
|
||||
{items.map((row, i) => {
|
||||
const idx = off + i
|
||||
|
||||
|
|
@ -195,6 +185,7 @@ export function ModelPicker({
|
|||
</Text>
|
||||
)
|
||||
})}
|
||||
|
||||
{off + VISIBLE < rows.length && <Text color={t.color.dim}> ↓ {rows.length - off - VISIBLE} more</Text>}
|
||||
<Text color={t.color.dim}>persist: {persistGlobal ? 'global' : 'session'} · g toggle</Text>
|
||||
<Text color={t.color.dim}>↑/↓ select · Enter choose · 1-9,0 quick · Esc cancel</Text>
|
||||
|
|
@ -209,10 +200,12 @@ export function ModelPicker({
|
|||
<Text bold color={t.color.amber}>
|
||||
Select Model
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.dim}>{provider?.name || '(unknown provider)'}</Text>
|
||||
{!models.length ? <Text color={t.color.dim}>no models listed for this provider</Text> : null}
|
||||
{provider?.warning ? <Text color={t.color.label}>warning: {provider.warning}</Text> : null}
|
||||
{off > 0 && <Text color={t.color.dim}> ↑ {off} more</Text>}
|
||||
|
||||
{items.map((row, i) => {
|
||||
const idx = off + i
|
||||
|
||||
|
|
@ -223,6 +216,7 @@ export function ModelPicker({
|
|||
</Text>
|
||||
)
|
||||
})}
|
||||
|
||||
{off + VISIBLE < models.length && <Text color={t.color.dim}> ↓ {models.length - off - VISIBLE} more</Text>}
|
||||
<Text color={t.color.dim}>persist: {persistGlobal ? 'global' : 'session'} · g toggle</Text>
|
||||
<Text color={t.color.dim}>
|
||||
|
|
@ -231,3 +225,11 @@ export function ModelPicker({
|
|||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
interface ModelPickerProps {
|
||||
gw: GatewayClient
|
||||
onCancel: () => void
|
||||
onSelect: (value: string) => void
|
||||
sessionId: string | null
|
||||
t: Theme
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,11 @@ import type { ApprovalReq, ClarifyReq } from '../types.js'
|
|||
|
||||
import { TextInput } from './textInput.js'
|
||||
|
||||
export function ApprovalPrompt({ onChoice, req, t }: { onChoice: (s: string) => void; req: ApprovalReq; t: Theme }) {
|
||||
const OPTS = ['once', 'session', 'always', 'deny'] as const
|
||||
const LABELS = { always: 'Always allow', deny: 'Deny', once: 'Allow once', session: 'Allow this session' } as const
|
||||
|
||||
export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) {
|
||||
const [sel, setSel] = useState(3)
|
||||
const opts = ['once', 'session', 'always', 'deny'] as const
|
||||
const labels = { always: 'Always allow', deny: 'Deny', once: 'Allow once', session: 'Allow this session' } as const
|
||||
|
||||
useInput((ch, key) => {
|
||||
if (key.upArrow && sel > 0) {
|
||||
|
|
@ -21,7 +22,7 @@ export function ApprovalPrompt({ onChoice, req, t }: { onChoice: (s: string) =>
|
|||
}
|
||||
|
||||
if (key.return) {
|
||||
onChoice(opts[sel]!)
|
||||
onChoice(OPTS[sel]!)
|
||||
}
|
||||
|
||||
if (ch === 'o') {
|
||||
|
|
@ -46,34 +47,25 @@ export function ApprovalPrompt({ onChoice, req, t }: { onChoice: (s: string) =>
|
|||
<Text bold color={t.color.warn}>
|
||||
! DANGEROUS COMMAND: {req.description}
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.dim}> {req.command}</Text>
|
||||
<Text />
|
||||
{opts.map((o, i) => (
|
||||
|
||||
{OPTS.map((o, i) => (
|
||||
<Text key={o}>
|
||||
<Text color={sel === i ? t.color.warn : t.color.dim}>{sel === i ? '▸ ' : ' '}</Text>
|
||||
<Text color={sel === i ? t.color.cornsilk : t.color.dim}>
|
||||
[{o[0]}] {labels[o]}
|
||||
[{o[0]}] {LABELS[o]}
|
||||
</Text>
|
||||
</Text>
|
||||
))}
|
||||
|
||||
<Text color={t.color.dim}>↑/↓ select · Enter confirm · o/s/a/d quick pick</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export function ClarifyPrompt({
|
||||
cols = 80,
|
||||
onAnswer,
|
||||
onCancel,
|
||||
req,
|
||||
t
|
||||
}: {
|
||||
cols?: number
|
||||
onAnswer: (s: string) => void
|
||||
onCancel: () => void
|
||||
req: ClarifyReq
|
||||
t: Theme
|
||||
}) {
|
||||
export function ClarifyPrompt({ cols = 80, onAnswer, onCancel, req, t }: ClarifyPromptProps) {
|
||||
const [sel, setSel] = useState(0)
|
||||
const [custom, setCustom] = useState('')
|
||||
const [typing, setTyping] = useState(false)
|
||||
|
|
@ -117,8 +109,6 @@ export function ClarifyPrompt({
|
|||
})
|
||||
|
||||
if (typing || !choices.length) {
|
||||
const hint = choices.length ? 'Enter send · Esc back · Ctrl+C cancel' : 'Enter send · Esc cancel · Ctrl+C cancel'
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{heading}
|
||||
|
|
@ -128,7 +118,7 @@ export function ClarifyPrompt({
|
|||
<TextInput columns={Math.max(20, cols - 6)} onChange={setCustom} onSubmit={onAnswer} value={custom} />
|
||||
</Box>
|
||||
|
||||
<Text color={t.color.dim}>{hint}</Text>
|
||||
<Text color={t.color.dim}>Enter send · Esc {choices.length ? 'back' : 'cancel'} · Ctrl+C cancel</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -150,3 +140,17 @@ export function ClarifyPrompt({
|
|||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
interface ApprovalPromptProps {
|
||||
onChoice: (s: string) => void
|
||||
req: ApprovalReq
|
||||
t: Theme
|
||||
}
|
||||
|
||||
interface ClarifyPromptProps {
|
||||
cols?: number
|
||||
onAnswer: (s: string) => void
|
||||
onCancel: () => void
|
||||
req: ClarifyReq
|
||||
t: Theme
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,17 +14,7 @@ export function getQueueWindow(queueLen: number, queueEditIdx: number | null) {
|
|||
return { end, showLead: start > 0, showTail: end < queueLen, start }
|
||||
}
|
||||
|
||||
export function QueuedMessages({
|
||||
cols,
|
||||
queueEditIdx,
|
||||
queued,
|
||||
t
|
||||
}: {
|
||||
cols: number
|
||||
queueEditIdx: number | null
|
||||
queued: string[]
|
||||
t: Theme
|
||||
}) {
|
||||
export function QueuedMessages({ cols, queueEditIdx, queued, t }: QueuedMessagesProps) {
|
||||
if (!queued.length) {
|
||||
return null
|
||||
}
|
||||
|
|
@ -36,12 +26,14 @@ export function QueuedMessages({
|
|||
<Text color={t.color.dim} dimColor>
|
||||
queued ({queued.length}){queueEditIdx !== null ? ` · editing ${queueEditIdx + 1}` : ''}
|
||||
</Text>
|
||||
|
||||
{q.showLead && (
|
||||
<Text color={t.color.dim} dimColor>
|
||||
{' '}
|
||||
…
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{queued.slice(q.start, q.end).map((item, i) => {
|
||||
const idx = q.start + i
|
||||
const active = queueEditIdx === idx
|
||||
|
|
@ -52,6 +44,7 @@ export function QueuedMessages({
|
|||
</Text>
|
||||
)
|
||||
})}
|
||||
|
||||
{q.showTail && (
|
||||
<Text color={t.color.dim} dimColor>
|
||||
{' '}…and {queued.length - q.end} more
|
||||
|
|
@ -60,3 +53,10 @@ export function QueuedMessages({
|
|||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
interface QueuedMessagesProps {
|
||||
cols: number
|
||||
queueEditIdx: number | null
|
||||
queued: string[]
|
||||
t: Theme
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ import type { SessionListItem, SessionListResponse } from '../gatewayTypes.js'
|
|||
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
|
||||
function age(ts: number): string {
|
||||
const VISIBLE = 15
|
||||
|
||||
const age = (ts: number) => {
|
||||
const d = (Date.now() / 1000 - ts) / 86400
|
||||
|
||||
if (d < 1) {
|
||||
|
|
@ -20,19 +22,7 @@ function age(ts: number): string {
|
|||
return `${Math.floor(d)}d ago`
|
||||
}
|
||||
|
||||
const VISIBLE = 15
|
||||
|
||||
export function SessionPicker({
|
||||
gw,
|
||||
onCancel,
|
||||
onSelect,
|
||||
t
|
||||
}: {
|
||||
gw: GatewayClient
|
||||
onCancel: () => void
|
||||
onSelect: (id: string) => void
|
||||
t: Theme
|
||||
}) {
|
||||
export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps) {
|
||||
const [items, setItems] = useState<SessionListItem[]>([])
|
||||
const [err, setErr] = useState('')
|
||||
const [sel, setSel] = useState(0)
|
||||
|
|
@ -107,36 +97,48 @@ export function SessionPicker({
|
|||
}
|
||||
|
||||
const off = Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), items.length - VISIBLE))
|
||||
const visible = items.slice(off, off + VISIBLE)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={t.color.amber}>
|
||||
Resume Session
|
||||
</Text>
|
||||
|
||||
{off > 0 && <Text color={t.color.dim}> ↑ {off} more</Text>}
|
||||
{visible.map((s, vi) => {
|
||||
|
||||
{items.slice(off, off + VISIBLE).map((s, vi) => {
|
||||
const i = off + vi
|
||||
|
||||
return (
|
||||
<Box key={s.id}>
|
||||
<Text color={sel === i ? t.color.label : t.color.dim}>{sel === i ? '▸ ' : ' '}</Text>
|
||||
|
||||
<Box width={30}>
|
||||
<Text color={sel === i ? t.color.cornsilk : t.color.dim}>
|
||||
{String(i + 1).padStart(2)}. [{s.id}]
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box width={30}>
|
||||
<Text color={t.color.dim}>
|
||||
({s.message_count} msgs, {age(s.started_at)}, {s.source || 'tui'})
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Text color={sel === i ? t.color.cornsilk : t.color.dim}>{s.title || s.preview || '(untitled)'}</Text>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
|
||||
{off + VISIBLE < items.length && <Text color={t.color.dim}> ↓ {items.length - off - VISIBLE} more</Text>}
|
||||
<Text color={t.color.dim}>↑/↓ select · Enter resume · 1-9 quick · Esc cancel</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
interface SessionPickerProps {
|
||||
gw: GatewayClient
|
||||
onCancel: () => void
|
||||
onSelect: (id: string) => void
|
||||
t: Theme
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,8 +11,6 @@ type InkExt = typeof Ink & {
|
|||
const ink = Ink as unknown as InkExt
|
||||
const { Box, Text, useStdin, useInput, stringWidth, useDeclaredCursor, useTerminalFocus } = ink
|
||||
|
||||
// ── ANSI escapes ─────────────────────────────────────────────────────
|
||||
|
||||
const ESC = '\x1b'
|
||||
const INV = `${ESC}[7m`
|
||||
const INV_OFF = `${ESC}[27m`
|
||||
|
|
@ -25,8 +23,6 @@ const BRACKET_PASTE = new RegExp(`${ESC}?\\[20[01]~`, 'g')
|
|||
const invert = (s: string) => INV + s + INV_OFF
|
||||
const dim = (s: string) => DIM + s + DIM_OFF
|
||||
|
||||
// ── Grapheme segmenter (lazy singleton) ──────────────────────────────
|
||||
|
||||
let _seg: Intl.Segmenter | null = null
|
||||
const seg = () => (_seg ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' }))
|
||||
const STOP_CACHE_MAX = 32
|
||||
|
|
@ -106,8 +102,6 @@ function nextPos(s: string, p: number) {
|
|||
return s.length
|
||||
}
|
||||
|
||||
// ── Word movement ────────────────────────────────────────────────────
|
||||
|
||||
function wordLeft(s: string, p: number) {
|
||||
let i = snapPos(s, p) - 1
|
||||
|
||||
|
|
@ -136,8 +130,6 @@ function wordRight(s: string, p: number) {
|
|||
return i
|
||||
}
|
||||
|
||||
// ── Cursor layout (line/column from offset + terminal width) ─────────
|
||||
|
||||
function cursorLayout(value: string, cursor: number, cols: number) {
|
||||
const pos = Math.max(0, Math.min(cursor, value.length))
|
||||
const w = Math.max(1, cols - 1)
|
||||
|
|
@ -226,8 +218,6 @@ function offsetFromPosition(value: string, row: number, col: number, cols: numbe
|
|||
return lastOffset
|
||||
}
|
||||
|
||||
// ── Render value with inverse-video cursor ───────────────────────────
|
||||
|
||||
function renderWithCursor(value: string, cursor: number) {
|
||||
const pos = Math.max(0, Math.min(cursor, value.length))
|
||||
|
||||
|
|
@ -250,8 +240,6 @@ function renderWithCursor(value: string, cursor: number) {
|
|||
return done ? out : out + invert(' ')
|
||||
}
|
||||
|
||||
// ── Forward-delete detection hook ────────────────────────────────────
|
||||
|
||||
function useFwdDelete(active: boolean) {
|
||||
const ref = useRef(false)
|
||||
const { inputEmitter: ee } = useStdin()
|
||||
|
|
@ -275,29 +263,6 @@ function useFwdDelete(active: boolean) {
|
|||
return ref
|
||||
}
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PasteEvent {
|
||||
bracketed?: boolean
|
||||
cursor: number
|
||||
hotkey?: boolean
|
||||
text: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
columns?: number
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
onSubmit?: (v: string) => void
|
||||
onPaste?: (e: PasteEvent) => { cursor: number; value: string } | null
|
||||
mask?: string
|
||||
placeholder?: string
|
||||
focus?: boolean
|
||||
}
|
||||
|
||||
// ── Component ────────────────────────────────────────────────────────
|
||||
|
||||
export function TextInput({
|
||||
columns = 80,
|
||||
value,
|
||||
|
|
@ -307,7 +272,7 @@ export function TextInput({
|
|||
mask,
|
||||
placeholder = '',
|
||||
focus = true
|
||||
}: Props) {
|
||||
}: TextInputProps) {
|
||||
const [cur, setCur] = useState(value.length)
|
||||
const fwdDel = useFwdDelete(focus)
|
||||
const termFocus = useTerminalFocus()
|
||||
|
|
@ -331,8 +296,6 @@ export function TextInput({
|
|||
const raw = self.current ? vRef.current : value
|
||||
const display = mask ? raw.replace(/[^\n]/g, mask[0] ?? '*') : raw
|
||||
|
||||
// ── Cursor declaration ───────────────────────────────────────────
|
||||
|
||||
const layout = useMemo(() => cursorLayout(display, cur, columns), [columns, cur, display])
|
||||
|
||||
const boxRef = useDeclaredCursor({
|
||||
|
|
@ -353,18 +316,6 @@ export function TextInput({
|
|||
return renderWithCursor(display, cur)
|
||||
}, [cur, display, focus, placeholder])
|
||||
|
||||
const clickCursor = (e: { localRow?: number; localCol?: number }) => {
|
||||
if (!focus) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = offsetFromPosition(display, e.localRow ?? 0, e.localCol ?? 0, columns)
|
||||
setCur(next)
|
||||
curRef.current = next
|
||||
}
|
||||
|
||||
// ── Sync external value changes ──────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (self.current) {
|
||||
self.current = false
|
||||
|
|
@ -386,8 +337,6 @@ export function TextInput({
|
|||
[]
|
||||
)
|
||||
|
||||
// ── Buffer ops (synchronous, ref-based) ──────────────────────────
|
||||
|
||||
const commit = (next: string, nextCur: number, track = true) => {
|
||||
const prev = vRef.current
|
||||
const c = snapPos(next, nextCur)
|
||||
|
|
@ -450,18 +399,14 @@ export function TextInput({
|
|||
|
||||
const ins = (v: string, c: number, s: string) => v.slice(0, c) + s + v.slice(c)
|
||||
|
||||
// ── Input handler ────────────────────────────────────────────────
|
||||
|
||||
useInput(
|
||||
(inp: string, k: Key, event: InputEvent) => {
|
||||
const raw = event.keypress.raw
|
||||
const metaPaste = raw === '\x1bv' || raw === '\x1bV'
|
||||
const eventRaw = event.keypress.raw
|
||||
|
||||
if (metaPaste) {
|
||||
if (eventRaw === '\x1bv' || eventRaw === '\x1bV') {
|
||||
return void emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current })
|
||||
}
|
||||
|
||||
// Delegated to App
|
||||
if (
|
||||
k.upArrow ||
|
||||
k.downArrow ||
|
||||
|
|
@ -487,7 +432,6 @@ export function TextInput({
|
|||
let v = vRef.current
|
||||
const mod = k.ctrl || k.meta
|
||||
|
||||
// Undo / redo
|
||||
if (k.ctrl && inp === 'z') {
|
||||
return swap(undo, redo)
|
||||
}
|
||||
|
|
@ -496,7 +440,6 @@ export function TextInput({
|
|||
return swap(redo, undo)
|
||||
}
|
||||
|
||||
// Navigation
|
||||
if (k.home || (k.ctrl && inp === 'a')) {
|
||||
c = 0
|
||||
} else if (k.end || (k.ctrl && inp === 'e')) {
|
||||
|
|
@ -509,10 +452,7 @@ export function TextInput({
|
|||
c = wordLeft(v, c)
|
||||
} else if (k.meta && inp === 'f') {
|
||||
c = wordRight(v, c)
|
||||
}
|
||||
|
||||
// Deletion
|
||||
else if ((k.backspace || k.delete) && !fwdDel.current && c > 0) {
|
||||
} else if ((k.backspace || k.delete) && !fwdDel.current && c > 0) {
|
||||
if (mod) {
|
||||
const t = wordLeft(v, c)
|
||||
v = v.slice(0, t) + v.slice(c)
|
||||
|
|
@ -538,31 +478,28 @@ export function TextInput({
|
|||
c = 0
|
||||
} else if (k.ctrl && inp === 'k') {
|
||||
v = v.slice(0, c)
|
||||
}
|
||||
|
||||
// Text insertion / paste buffering
|
||||
else if (inp.length > 0) {
|
||||
} else if (inp.length > 0) {
|
||||
const bracketed = inp.includes('[200~')
|
||||
const raw = inp.replace(BRACKET_PASTE, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||
const text = inp.replace(BRACKET_PASTE, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||
|
||||
if (bracketed && emitPaste({ bracketed: true, cursor: c, text: raw, value: v })) {
|
||||
if (bracketed && emitPaste({ bracketed: true, cursor: c, text, value: v })) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!raw) {
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
|
||||
if (raw === '\n') {
|
||||
if (text === '\n') {
|
||||
return commit(ins(v, c, '\n'), c + 1)
|
||||
}
|
||||
|
||||
if (raw.length > 1 || raw.includes('\n')) {
|
||||
if (text.length > 1 || text.includes('\n')) {
|
||||
if (!pasteBuf.current) {
|
||||
pastePos.current = c
|
||||
}
|
||||
|
||||
pasteBuf.current += raw
|
||||
pasteBuf.current += text
|
||||
|
||||
if (pasteTimer.current) {
|
||||
clearTimeout(pasteTimer.current)
|
||||
|
|
@ -573,9 +510,9 @@ export function TextInput({
|
|||
return
|
||||
}
|
||||
|
||||
if (PRINTABLE.test(raw)) {
|
||||
v = v.slice(0, c) + raw + v.slice(c)
|
||||
c += raw.length
|
||||
if (PRINTABLE.test(text)) {
|
||||
v = v.slice(0, c) + text + v.slice(c)
|
||||
c += text.length
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
|
@ -588,11 +525,39 @@ export function TextInput({
|
|||
{ isActive: focus }
|
||||
)
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<Box onClick={clickCursor} ref={boxRef}>
|
||||
<Box
|
||||
onClick={(e: { localRow?: number; localCol?: number }) => {
|
||||
if (!focus) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = offsetFromPosition(display, e.localRow ?? 0, e.localCol ?? 0, columns)
|
||||
setCur(next)
|
||||
curRef.current = next
|
||||
}}
|
||||
ref={boxRef}
|
||||
>
|
||||
<Text wrap="wrap">{rendered}</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export interface PasteEvent {
|
||||
bracketed?: boolean
|
||||
cursor: number
|
||||
hotkey?: boolean
|
||||
text: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface TextInputProps {
|
||||
columns?: number
|
||||
focus?: boolean
|
||||
mask?: string
|
||||
onChange: (v: string) => void
|
||||
onPaste?: (e: PasteEvent) => { cursor: number; value: string } | null
|
||||
onSubmit?: (v: string) => void
|
||||
placeholder?: string
|
||||
value: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,16 @@ 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 {
|
||||
|
|
@ -18,28 +28,3 @@ export interface FgProps {
|
|||
underline?: boolean
|
||||
wrap?: 'end' | 'middle' | 'truncate' | 'truncate-end' | 'truncate-middle' | 'truncate-start' | 'wrap' | 'wrap-trim'
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme-aware text. `literal` wins; otherwise `c` is a palette key.
|
||||
*
|
||||
* <Fg c="amber">hi</Fg> // amber
|
||||
* <Fg c="dim" dim>…</Fg> // dim cornsilk
|
||||
* <Fg literal="#ff00ff">x</Fg> // raw hex
|
||||
*/
|
||||
export function Fg({ bold, c, children, dim, italic, literal, strikethrough, underline, wrap }: FgProps) {
|
||||
const { theme } = useStore($uiState)
|
||||
|
||||
return (
|
||||
<Text
|
||||
bold={bold}
|
||||
color={literal ?? (c && theme.color[c])}
|
||||
dimColor={dim}
|
||||
italic={italic}
|
||||
strikethrough={strikethrough}
|
||||
underline={underline}
|
||||
wrap={wrap}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue