mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-30 01:41:43 +00:00
feat: just more cleaning
This commit is contained in:
parent
46cef4b7fa
commit
4b4b4d47bc
24 changed files with 2852 additions and 829 deletions
|
|
@ -1,7 +1,7 @@
|
|||
import { Box, type ScrollBoxHandle, Text } from '@hermes/ink'
|
||||
import { type ReactNode, type RefObject, useCallback, useEffect, useState, useSyncExternalStore } from 'react'
|
||||
|
||||
import { stickyPromptFromViewport } from '../app/helpers.js'
|
||||
import { fmtDuration, stickyPromptFromViewport } from '../app/helpers.js'
|
||||
import { fmtK } from '../lib/text.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
import type { Msg, Usage } from '../types.js'
|
||||
|
|
@ -33,6 +33,19 @@ function ctxBar(pct: number | undefined, w = 10) {
|
|||
return '█'.repeat(filled) + '░'.repeat(w - filled)
|
||||
}
|
||||
|
||||
function SessionDuration({ startedAt }: { startedAt: number }) {
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
|
||||
useEffect(() => {
|
||||
setNow(Date.now())
|
||||
const id = setInterval(() => setNow(Date.now()), 1000)
|
||||
|
||||
return () => clearInterval(id)
|
||||
}, [startedAt])
|
||||
|
||||
return fmtDuration(now - startedAt)
|
||||
}
|
||||
|
||||
export function StatusRule({
|
||||
cwdLabel,
|
||||
cols,
|
||||
|
|
@ -41,7 +54,7 @@ export function StatusRule({
|
|||
model,
|
||||
usage,
|
||||
bgCount,
|
||||
durationLabel,
|
||||
sessionStartedAt,
|
||||
voiceLabel,
|
||||
t
|
||||
}: {
|
||||
|
|
@ -52,7 +65,7 @@ export function StatusRule({
|
|||
model: string
|
||||
usage: Usage
|
||||
bgCount: number
|
||||
durationLabel?: string
|
||||
sessionStartedAt?: number | null
|
||||
voiceLabel?: string
|
||||
t: Theme
|
||||
}) {
|
||||
|
|
@ -83,7 +96,12 @@ export function StatusRule({
|
|||
<Text color={barColor}>[{bar}]</Text> <Text color={barColor}>{pctLabel}</Text>
|
||||
</Text>
|
||||
) : null}
|
||||
{durationLabel ? <Text color={t.color.dim}> │ {durationLabel}</Text> : null}
|
||||
{sessionStartedAt ? (
|
||||
<Text color={t.color.dim}>
|
||||
{' │ '}
|
||||
<SessionDuration startedAt={sessionStartedAt} />
|
||||
</Text>
|
||||
) : null}
|
||||
{voiceLabel ? <Text color={t.color.dim}> │ {voiceLabel}</Text> : null}
|
||||
{bgCount > 0 ? <Text color={t.color.dim}> │ {bgCount} bg</Text> : null}
|
||||
</Text>
|
||||
|
|
@ -177,6 +195,8 @@ export function TranscriptScrollbar({ scrollRef, t }: { scrollRef: RefObject<Scr
|
|||
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 thumbColor = grab !== null ? t.color.gold : hover ? t.color.amber : t.color.bronze
|
||||
const trackColor = hover ? t.color.bronze : t.color.dim
|
||||
|
||||
const jump = (row: number, offset: number) => {
|
||||
if (!s || !scrollable) {
|
||||
|
|
@ -203,25 +223,27 @@ export function TranscriptScrollbar({ scrollRef, t }: { scrollRef: RefObject<Scr
|
|||
onMouseUp={() => setGrab(null)}
|
||||
width={1}
|
||||
>
|
||||
{Array.from({ length: vp }, (_, i) => {
|
||||
const active = i >= thumbTop && i < thumbTop + thumb
|
||||
|
||||
const color = active
|
||||
? grab !== null
|
||||
? t.color.gold
|
||||
: hover
|
||||
? t.color.amber
|
||||
: t.color.bronze
|
||||
: hover
|
||||
? t.color.bronze
|
||||
: t.color.dim
|
||||
|
||||
return (
|
||||
<Text color={color} dimColor={!active && !hover} key={i}>
|
||||
{scrollable ? (active ? '┃' : '│') : ' '}
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
{!scrollable ? (
|
||||
<Text color={trackColor} dimColor>
|
||||
{' \n'.repeat(Math.max(0, vp - 1))}{' '}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
{thumbTop > 0 ? (
|
||||
<Text color={trackColor} dimColor={!hover}>
|
||||
{`${'│\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} dimColor={!hover}>
|
||||
{`${'│\n'.repeat(Math.max(0, vp - thumbTop - thumb - 1))}${vp - thumbTop - thumb > 0 ? '│' : ''}`}
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { AlternateScreen, Box, NoSelect, ScrollBox, Text } from '@hermes/ink'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { memo } from 'react'
|
||||
|
||||
import { PLACEHOLDER } from '../app/constants.js'
|
||||
import type { AppLayoutProps } from '../app/interfaces.js'
|
||||
|
|
@ -14,172 +15,203 @@ import { QueuedMessages } from './queuedMessages.js'
|
|||
import { TextInput } from './textInput.js'
|
||||
import { ToolTrail } from './thinking.js'
|
||||
|
||||
export function AppLayout({ actions, composer, mouseTracking, progress, status, transcript }: AppLayoutProps) {
|
||||
const TranscriptPane = memo(function TranscriptPane({
|
||||
actions,
|
||||
composer,
|
||||
progress,
|
||||
transcript
|
||||
}: Pick<AppLayoutProps, 'actions' | 'composer' | 'progress' | 'transcript'>) {
|
||||
const ui = useStore($uiState)
|
||||
const isBlocked = useStore($isBlocked)
|
||||
const visibleHistory = transcript.virtualRows.slice(transcript.virtualHistory.start, transcript.virtualHistory.end)
|
||||
|
||||
return (
|
||||
<AlternateScreen mouseTracking={mouseTracking}>
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
<Box flexDirection="row" flexGrow={1}>
|
||||
<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}
|
||||
<>
|
||||
<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 => (
|
||||
<Box flexDirection="column" key={row.key} ref={transcript.virtualHistory.measureRef(row.key)}>
|
||||
{row.msg.kind === 'intro' && row.msg.info ? (
|
||||
<Box flexDirection="column" paddingTop={1}>
|
||||
<Banner t={ui.theme} />
|
||||
<SessionPanel info={row.msg.info} 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={composer.cols}
|
||||
compact={ui.compact}
|
||||
detailsMode={ui.detailsMode}
|
||||
msg={row.msg}
|
||||
t={ui.theme}
|
||||
/>
|
||||
)}
|
||||
{visibleHistory.map(row => (
|
||||
<Box flexDirection="column" key={row.key} ref={transcript.virtualHistory.measureRef(row.key)}>
|
||||
{row.msg.kind === 'intro' && row.msg.info ? (
|
||||
<Box flexDirection="column" paddingTop={1}>
|
||||
<Banner t={ui.theme} />
|
||||
<SessionPanel info={row.msg.info} sid={ui.sid} t={ui.theme} />
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{transcript.virtualHistory.bottomSpacer > 0 ? (
|
||||
<Box height={transcript.virtualHistory.bottomSpacer} />
|
||||
) : null}
|
||||
|
||||
{progress.showProgressArea && (
|
||||
<ToolTrail
|
||||
activity={progress.activity}
|
||||
busy={ui.busy && !progress.streaming}
|
||||
detailsMode={ui.detailsMode}
|
||||
reasoning={progress.reasoning}
|
||||
reasoningActive={progress.reasoningActive}
|
||||
reasoningStreaming={progress.reasoningStreaming}
|
||||
reasoningTokens={progress.reasoningTokens}
|
||||
t={ui.theme}
|
||||
tools={progress.tools}
|
||||
toolTokens={progress.toolTokens}
|
||||
trail={progress.turnTrail}
|
||||
/>
|
||||
)}
|
||||
|
||||
{progress.showStreamingArea && (
|
||||
) : row.msg.kind === 'panel' && row.msg.panelData ? (
|
||||
<Panel sections={row.msg.panelData.sections} t={ui.theme} title={row.msg.panelData.title} />
|
||||
) : (
|
||||
<MessageLine
|
||||
cols={composer.cols}
|
||||
compact={ui.compact}
|
||||
detailsMode={ui.detailsMode}
|
||||
isStreaming
|
||||
msg={{ role: 'assistant', text: progress.streaming }}
|
||||
msg={row.msg}
|
||||
t={ui.theme}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</ScrollBox>
|
||||
))}
|
||||
|
||||
<NoSelect flexShrink={0} marginLeft={1}>
|
||||
<TranscriptScrollbar scrollRef={transcript.scrollRef} t={ui.theme} />
|
||||
</NoSelect>
|
||||
{transcript.virtualHistory.bottomSpacer > 0 ? <Box height={transcript.virtualHistory.bottomSpacer} /> : null}
|
||||
|
||||
<StickyPromptTracker
|
||||
messages={transcript.historyItems}
|
||||
offsets={transcript.virtualHistory.offsets}
|
||||
onChange={actions.setStickyPrompt}
|
||||
scrollRef={transcript.scrollRef}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<NoSelect flexDirection="column" flexShrink={0} fromLeftEdge paddingX={1}>
|
||||
<QueuedMessages
|
||||
cols={composer.cols}
|
||||
queued={composer.queuedDisplay}
|
||||
queueEditIdx={composer.queueEditIdx}
|
||||
t={ui.theme}
|
||||
/>
|
||||
|
||||
{ui.bgTasks.size > 0 && (
|
||||
<Text color={ui.theme.color.dim as any}>
|
||||
{ui.bgTasks.size} background {ui.bgTasks.size === 1 ? 'task' : 'tasks'} running
|
||||
</Text>
|
||||
{progress.showProgressArea && (
|
||||
<ToolTrail
|
||||
activity={progress.activity}
|
||||
busy={ui.busy && !progress.streaming}
|
||||
detailsMode={ui.detailsMode}
|
||||
reasoning={progress.reasoning}
|
||||
reasoningActive={progress.reasoningActive}
|
||||
reasoningStreaming={progress.reasoningStreaming}
|
||||
reasoningTokens={progress.reasoningTokens}
|
||||
subagents={progress.subagents}
|
||||
t={ui.theme}
|
||||
tools={progress.tools}
|
||||
toolTokens={progress.toolTokens}
|
||||
trail={progress.turnTrail}
|
||||
/>
|
||||
)}
|
||||
|
||||
{status.showStickyPrompt ? (
|
||||
<Text color={ui.theme.color.dim as any} wrap="truncate-end">
|
||||
<Text color={ui.theme.color.label as any}>↳ </Text>
|
||||
{status.stickyPrompt}
|
||||
</Text>
|
||||
) : (
|
||||
<Text> </Text>
|
||||
)}
|
||||
|
||||
<Box flexDirection="column" position="relative">
|
||||
{ui.statusBar && (
|
||||
<StatusRule
|
||||
bgCount={ui.bgTasks.size}
|
||||
cols={composer.cols}
|
||||
cwdLabel={status.cwdLabel}
|
||||
durationLabel={status.durationLabel}
|
||||
model={ui.info?.model?.split('/').pop() ?? ''}
|
||||
status={ui.status}
|
||||
statusColor={status.statusColor}
|
||||
t={ui.theme}
|
||||
usage={ui.usage}
|
||||
voiceLabel={status.voiceLabel}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AppOverlays
|
||||
{progress.showStreamingArea && (
|
||||
<MessageLine
|
||||
cols={composer.cols}
|
||||
compIdx={composer.compIdx}
|
||||
completions={composer.completions}
|
||||
onApprovalChoice={actions.answerApproval}
|
||||
onClarifyAnswer={actions.answerClarify}
|
||||
onModelSelect={actions.onModelSelect}
|
||||
onPickerSelect={actions.resumeById}
|
||||
onSecretSubmit={actions.answerSecret}
|
||||
onSudoSubmit={actions.answerSudo}
|
||||
pagerPageSize={composer.pagerPageSize}
|
||||
compact={ui.compact}
|
||||
detailsMode={ui.detailsMode}
|
||||
isStreaming
|
||||
msg={{ role: 'assistant', text: progress.streaming }}
|
||||
t={ui.theme}
|
||||
/>
|
||||
)}
|
||||
</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)
|
||||
|
||||
return (
|
||||
<NoSelect flexDirection="column" flexShrink={0} fromLeftEdge paddingX={1}>
|
||||
<QueuedMessages
|
||||
cols={composer.cols}
|
||||
queued={composer.queuedDisplay}
|
||||
queueEditIdx={composer.queueEditIdx}
|
||||
t={ui.theme}
|
||||
/>
|
||||
|
||||
{ui.bgTasks.size > 0 && (
|
||||
<Text color={ui.theme.color.dim as any}>
|
||||
{ui.bgTasks.size} background {ui.bgTasks.size === 1 ? 'task' : 'tasks'} running
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{status.showStickyPrompt ? (
|
||||
<Text color={ui.theme.color.dim as any} wrap="truncate-end">
|
||||
<Text color={ui.theme.color.label as any}>↳ </Text>
|
||||
{status.stickyPrompt}
|
||||
</Text>
|
||||
) : (
|
||||
<Text> </Text>
|
||||
)}
|
||||
|
||||
<Box flexDirection="column" position="relative">
|
||||
{ui.statusBar && (
|
||||
<StatusRule
|
||||
bgCount={ui.bgTasks.size}
|
||||
cols={composer.cols}
|
||||
cwdLabel={status.cwdLabel}
|
||||
model={ui.info?.model?.split('/').pop() ?? ''}
|
||||
sessionStartedAt={status.sessionStartedAt}
|
||||
status={ui.status}
|
||||
statusColor={status.statusColor}
|
||||
t={ui.theme}
|
||||
usage={ui.usage}
|
||||
voiceLabel={status.voiceLabel}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AppOverlays
|
||||
cols={composer.cols}
|
||||
compIdx={composer.compIdx}
|
||||
completions={composer.completions}
|
||||
onApprovalChoice={actions.answerApproval}
|
||||
onClarifyAnswer={actions.answerClarify}
|
||||
onModelSelect={actions.onModelSelect}
|
||||
onPickerSelect={actions.resumeById}
|
||||
onSecretSubmit={actions.answerSecret}
|
||||
onSudoSubmit={actions.answerSudo}
|
||||
pagerPageSize={composer.pagerPageSize}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{!isBlocked && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
{composer.inputBuf.map((line, i) => (
|
||||
<Box key={i}>
|
||||
<Box width={3}>
|
||||
<Text color={ui.theme.color.dim as any}>{i === 0 ? `${ui.theme.brand.prompt} ` : ' '}</Text>
|
||||
</Box>
|
||||
|
||||
<Text color={ui.theme.color.cornsilk as any}>{line || ' '}</Text>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
<Box>
|
||||
<Box width={3}>
|
||||
<Text bold color={ui.theme.color.gold as any}>
|
||||
{composer.inputBuf.length ? ' ' : `${ui.theme.brand.prompt} `}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<TextInput
|
||||
columns={Math.max(20, composer.cols - 3)}
|
||||
onChange={composer.updateInput}
|
||||
onPaste={composer.handleTextPaste}
|
||||
onSubmit={composer.submit}
|
||||
placeholder={composer.empty ? PLACEHOLDER : ui.busy ? 'Ctrl+C to interrupt…' : ''}
|
||||
value={composer.input}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!isBlocked && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
{composer.inputBuf.map((line, i) => (
|
||||
<Box key={i}>
|
||||
<Box width={3}>
|
||||
<Text color={ui.theme.color.dim as any}>{i === 0 ? `${ui.theme.brand.prompt} ` : ' '}</Text>
|
||||
</Box>
|
||||
{!composer.empty && !ui.sid && <Text color={ui.theme.color.dim as any}>⚕ {ui.status}</Text>}
|
||||
</NoSelect>
|
||||
)
|
||||
})
|
||||
|
||||
<Text color={ui.theme.color.cornsilk as any}>{line || ' '}</Text>
|
||||
</Box>
|
||||
))}
|
||||
export const AppLayout = memo(function AppLayout({
|
||||
actions,
|
||||
composer,
|
||||
mouseTracking,
|
||||
progress,
|
||||
status,
|
||||
transcript
|
||||
}: AppLayoutProps) {
|
||||
return (
|
||||
<AlternateScreen mouseTracking={mouseTracking}>
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
<Box flexDirection="row" flexGrow={1}>
|
||||
<TranscriptPane actions={actions} composer={composer} progress={progress} transcript={transcript} />
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Box width={3}>
|
||||
<Text bold color={ui.theme.color.gold as any}>
|
||||
{composer.inputBuf.length ? ' ' : `${ui.theme.brand.prompt} `}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<TextInput
|
||||
columns={Math.max(20, composer.cols - 3)}
|
||||
onChange={composer.updateInput}
|
||||
onPaste={composer.handleTextPaste}
|
||||
onSubmit={composer.submit}
|
||||
placeholder={composer.empty ? PLACEHOLDER : ui.busy ? 'Ctrl+C to interrupt…' : ''}
|
||||
value={composer.input}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!composer.empty && !ui.sid && <Text color={ui.theme.color.dim as any}>⚕ {ui.status}</Text>}
|
||||
</NoSelect>
|
||||
<ComposerPane actions={actions} composer={composer} status={status} />
|
||||
</Box>
|
||||
</AlternateScreen>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Box, Text } from '@hermes/ink'
|
||||
import type { ReactNode } from 'react'
|
||||
import { memo, type ReactNode, useMemo } from 'react'
|
||||
|
||||
import type { Theme } from '../theme.js'
|
||||
|
||||
|
|
@ -212,367 +212,379 @@ function MdInline({ t, text }: { t: Theme; text: string }) {
|
|||
return <Text>{parts.length ? parts : <Text>{text}</Text>}</Text>
|
||||
}
|
||||
|
||||
export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: string }) {
|
||||
const lines = text.split('\n')
|
||||
const nodes: ReactNode[] = []
|
||||
let i = 0
|
||||
interface MdProps {
|
||||
compact?: boolean
|
||||
t: Theme
|
||||
text: string
|
||||
}
|
||||
|
||||
let prevKind: 'blank' | 'code' | 'heading' | 'list' | 'paragraph' | 'quote' | 'rule' | 'table' | null = null
|
||||
function MdImpl({ compact, t, text }: MdProps) {
|
||||
const nodes = useMemo(() => {
|
||||
const lines = text.split('\n')
|
||||
const nodes: ReactNode[] = []
|
||||
let i = 0
|
||||
|
||||
const gap = () => {
|
||||
if (nodes.length && prevKind !== 'blank') {
|
||||
nodes.push(<Text key={`gap-${nodes.length}`}> </Text>)
|
||||
prevKind = 'blank'
|
||||
}
|
||||
}
|
||||
let prevKind: 'blank' | 'code' | 'heading' | 'list' | 'paragraph' | 'quote' | 'rule' | 'table' | null = null
|
||||
|
||||
const start = (kind: Exclude<typeof prevKind, null | 'blank'>) => {
|
||||
if (prevKind && prevKind !== 'blank' && prevKind !== kind) {
|
||||
gap()
|
||||
const gap = () => {
|
||||
if (nodes.length && prevKind !== 'blank') {
|
||||
nodes.push(<Text key={`gap-${nodes.length}`}> </Text>)
|
||||
prevKind = 'blank'
|
||||
}
|
||||
}
|
||||
|
||||
prevKind = kind
|
||||
}
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]!
|
||||
const key = nodes.length
|
||||
|
||||
if (compact && !line.trim()) {
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (!line.trim()) {
|
||||
gap()
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const fence = parseFence(line)
|
||||
|
||||
if (fence) {
|
||||
const block: string[] = []
|
||||
const lang = fence.lang
|
||||
|
||||
for (i++; i < lines.length && !isFenceClose(lines[i]!, fence); i++) {
|
||||
block.push(lines[i]!)
|
||||
const start = (kind: Exclude<typeof prevKind, null | 'blank'>) => {
|
||||
if (prevKind && prevKind !== 'blank' && prevKind !== kind) {
|
||||
gap()
|
||||
}
|
||||
|
||||
if (i < lines.length) {
|
||||
prevKind = kind
|
||||
}
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]!
|
||||
const key = nodes.length
|
||||
|
||||
if (compact && !line.trim()) {
|
||||
i++
|
||||
}
|
||||
|
||||
if (isMarkdownFence(lang)) {
|
||||
start('paragraph')
|
||||
nodes.push(<Md compact={compact} key={key} t={t} text={block.join('\n')} />)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
start('code')
|
||||
if (!line.trim()) {
|
||||
gap()
|
||||
i++
|
||||
|
||||
const isDiff = lang === 'diff'
|
||||
|
||||
nodes.push(
|
||||
<Box flexDirection="column" key={key} paddingLeft={2}>
|
||||
{lang && !isDiff && <Text color={t.color.dim}>{'─ ' + lang}</Text>}
|
||||
{block.map((l, j) => {
|
||||
const add = isDiff && l.startsWith('+')
|
||||
const del = isDiff && l.startsWith('-')
|
||||
const hunk = isDiff && l.startsWith('@@')
|
||||
|
||||
return (
|
||||
<Text
|
||||
backgroundColor={add ? t.color.diffAdded : del ? t.color.diffRemoved : undefined}
|
||||
color={add ? t.color.diffAddedWord : del ? t.color.diffRemovedWord : hunk ? t.color.dim : undefined}
|
||||
dimColor={isDiff && !add && !del && !hunk && l.startsWith(' ')}
|
||||
key={j}
|
||||
>
|
||||
{l}
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.trim().startsWith('$$')) {
|
||||
start('code')
|
||||
|
||||
const block: string[] = []
|
||||
|
||||
for (i++; i < lines.length; i++) {
|
||||
if (lines[i]!.trim().startsWith('$$')) {
|
||||
i++
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
block.push(lines[i]!)
|
||||
continue
|
||||
}
|
||||
|
||||
nodes.push(
|
||||
<Box flexDirection="column" key={key} paddingLeft={2}>
|
||||
<Text color={t.color.dim}>─ math</Text>
|
||||
{block.map((l, j) => (
|
||||
<Text color={t.color.amber} key={j}>
|
||||
{l}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
const fence = parseFence(line)
|
||||
|
||||
continue
|
||||
}
|
||||
if (fence) {
|
||||
const block: string[] = []
|
||||
const lang = fence.lang
|
||||
|
||||
const heading = line.match(HEADING_RE)
|
||||
for (i++; i < lines.length && !isFenceClose(lines[i]!, fence); i++) {
|
||||
block.push(lines[i]!)
|
||||
}
|
||||
|
||||
if (heading) {
|
||||
start('heading')
|
||||
nodes.push(
|
||||
<Text bold color={t.color.amber} key={key}>
|
||||
{heading[2]}
|
||||
</Text>
|
||||
)
|
||||
i++
|
||||
if (i < lines.length) {
|
||||
i++
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
if (isMarkdownFence(lang)) {
|
||||
start('paragraph')
|
||||
nodes.push(<Md compact={compact} key={key} t={t} text={block.join('\n')} />)
|
||||
|
||||
if (i + 1 < lines.length && line.trim()) {
|
||||
const setext = lines[i + 1]!.match(/^\s{0,3}(=+|-+)\s*$/)
|
||||
continue
|
||||
}
|
||||
|
||||
if (setext) {
|
||||
start('code')
|
||||
|
||||
const isDiff = lang === 'diff'
|
||||
|
||||
nodes.push(
|
||||
<Box flexDirection="column" key={key} paddingLeft={2}>
|
||||
{lang && !isDiff && <Text color={t.color.dim}>{'─ ' + lang}</Text>}
|
||||
{block.map((l, j) => {
|
||||
const add = isDiff && l.startsWith('+')
|
||||
const del = isDiff && l.startsWith('-')
|
||||
const hunk = isDiff && l.startsWith('@@')
|
||||
|
||||
return (
|
||||
<Text
|
||||
backgroundColor={add ? t.color.diffAdded : del ? t.color.diffRemoved : undefined}
|
||||
color={add ? t.color.diffAddedWord : del ? t.color.diffRemovedWord : hunk ? t.color.dim : undefined}
|
||||
dimColor={isDiff && !add && !del && !hunk && l.startsWith(' ')}
|
||||
key={j}
|
||||
>
|
||||
{l}
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.trim().startsWith('$$')) {
|
||||
start('code')
|
||||
|
||||
const block: string[] = []
|
||||
|
||||
for (i++; i < lines.length; i++) {
|
||||
if (lines[i]!.trim().startsWith('$$')) {
|
||||
i++
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
block.push(lines[i]!)
|
||||
}
|
||||
|
||||
nodes.push(
|
||||
<Box flexDirection="column" key={key} paddingLeft={2}>
|
||||
<Text color={t.color.dim}>─ math</Text>
|
||||
{block.map((l, j) => (
|
||||
<Text color={t.color.amber} key={j}>
|
||||
{l}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const heading = line.match(HEADING_RE)
|
||||
|
||||
if (heading) {
|
||||
start('heading')
|
||||
nodes.push(
|
||||
<Text bold color={t.color.amber} key={key}>
|
||||
{line.trim()}
|
||||
{heading[2]}
|
||||
</Text>
|
||||
)
|
||||
i += 2
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (HR_RE.test(line)) {
|
||||
start('rule')
|
||||
nodes.push(
|
||||
<Text color={t.color.dim} key={key}>
|
||||
{'─'.repeat(36)}
|
||||
</Text>
|
||||
)
|
||||
i++
|
||||
if (i + 1 < lines.length && line.trim()) {
|
||||
const setext = lines[i + 1]!.match(/^\s{0,3}(=+|-+)\s*$/)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const footnote = line.match(FOOTNOTE_RE)
|
||||
|
||||
if (footnote) {
|
||||
start('list')
|
||||
nodes.push(
|
||||
<Text color={t.color.dim} key={key}>
|
||||
[{footnote[1]}] <MdInline t={t} text={footnote[2] ?? ''} />
|
||||
</Text>
|
||||
)
|
||||
i++
|
||||
|
||||
while (i < lines.length && /^\s{2,}\S/.test(lines[i]!)) {
|
||||
nodes.push(
|
||||
<Box key={`${key}-cont-${i}`} paddingLeft={2}>
|
||||
<Text color={t.color.dim}>
|
||||
<MdInline t={t} text={lines[i]!.trim()} />
|
||||
if (setext) {
|
||||
start('heading')
|
||||
nodes.push(
|
||||
<Text bold color={t.color.amber} key={key}>
|
||||
{line.trim()}
|
||||
</Text>
|
||||
)
|
||||
i += 2
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (HR_RE.test(line)) {
|
||||
start('rule')
|
||||
nodes.push(
|
||||
<Text color={t.color.dim} key={key}>
|
||||
{'─'.repeat(36)}
|
||||
</Text>
|
||||
)
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const footnote = line.match(FOOTNOTE_RE)
|
||||
|
||||
if (footnote) {
|
||||
start('list')
|
||||
nodes.push(
|
||||
<Text color={t.color.dim} key={key}>
|
||||
[{footnote[1]}] <MdInline t={t} text={footnote[2] ?? ''} />
|
||||
</Text>
|
||||
)
|
||||
i++
|
||||
|
||||
while (i < lines.length && /^\s{2,}\S/.test(lines[i]!)) {
|
||||
nodes.push(
|
||||
<Box key={`${key}-cont-${i}`} paddingLeft={2}>
|
||||
<Text color={t.color.dim}>
|
||||
<MdInline t={t} text={lines[i]!.trim()} />
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
i++
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (i + 1 < lines.length && DEF_RE.test(lines[i + 1]!)) {
|
||||
start('list')
|
||||
nodes.push(
|
||||
<Text bold key={key}>
|
||||
{line.trim()}
|
||||
</Text>
|
||||
)
|
||||
i++
|
||||
|
||||
while (i < lines.length) {
|
||||
const def = lines[i]!.match(DEF_RE)
|
||||
|
||||
if (!def) {
|
||||
break
|
||||
}
|
||||
|
||||
nodes.push(
|
||||
<Text key={`${key}-def-${i}`}>
|
||||
<Text color={t.color.dim}> · </Text>
|
||||
<MdInline t={t} text={def[1]!} />
|
||||
</Text>
|
||||
)
|
||||
i++
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const bullet = line.match(/^(\s*)[-+*]\s+(.*)$/)
|
||||
|
||||
if (bullet) {
|
||||
start('list')
|
||||
const depth = indentDepth(bullet[1]!)
|
||||
const task = bullet[2]!.match(/^\[( |x|X)\]\s+(.*)$/)
|
||||
const marker = task ? (task[1]!.toLowerCase() === 'x' ? '☑' : '☐') : '•'
|
||||
const body = task ? task[2]! : bullet[2]!
|
||||
|
||||
nodes.push(
|
||||
<Text key={key}>
|
||||
<Text color={t.color.dim}>
|
||||
{' '.repeat(depth * 2)}
|
||||
{marker}{' '}
|
||||
</Text>
|
||||
<MdInline t={t} text={body} />
|
||||
</Text>
|
||||
)
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const numbered = line.match(/^(\s*)(\d+)[.)]\s+(.*)$/)
|
||||
|
||||
if (numbered) {
|
||||
start('list')
|
||||
const depth = indentDepth(numbered[1]!)
|
||||
|
||||
nodes.push(
|
||||
<Text key={key}>
|
||||
<Text color={t.color.dim}>
|
||||
{' '.repeat(depth * 2)}
|
||||
{numbered[2]}.{' '}
|
||||
</Text>
|
||||
<MdInline t={t} text={numbered[3]!} />
|
||||
</Text>
|
||||
)
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^\s*(?:>\s*)+/.test(line)) {
|
||||
start('quote')
|
||||
const quoteLines: Array<{ depth: number; text: string }> = []
|
||||
|
||||
while (i < lines.length && /^\s*(?:>\s*)+/.test(lines[i]!)) {
|
||||
const raw = lines[i]!
|
||||
const prefix = raw.match(/^\s*(?:>\s*)+/)?.[0] ?? ''
|
||||
|
||||
quoteLines.push({
|
||||
depth: (prefix.match(/>/g) ?? []).length,
|
||||
text: raw.slice(prefix.length)
|
||||
})
|
||||
i++
|
||||
}
|
||||
|
||||
nodes.push(
|
||||
<Box flexDirection="column" key={key}>
|
||||
{quoteLines.map((ql, qi) => (
|
||||
<Text color={t.color.dim} key={qi}>
|
||||
{' '.repeat(Math.max(0, ql.depth - 1) * 2)}
|
||||
{'│ '}
|
||||
<MdInline t={t} text={ql.text} />
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
if (line.includes('|') && i + 1 < lines.length && isTableDivider(lines[i + 1]!)) {
|
||||
start('table')
|
||||
const tableRows: string[][] = []
|
||||
|
||||
if (i + 1 < lines.length && DEF_RE.test(lines[i + 1]!)) {
|
||||
start('list')
|
||||
nodes.push(
|
||||
<Text bold key={key}>
|
||||
{line.trim()}
|
||||
</Text>
|
||||
)
|
||||
i++
|
||||
tableRows.push(splitTableRow(line))
|
||||
i += 2
|
||||
|
||||
while (i < lines.length) {
|
||||
const def = lines[i]!.match(DEF_RE)
|
||||
|
||||
if (!def) {
|
||||
break
|
||||
while (i < lines.length && lines[i]!.includes('|') && lines[i]!.trim()) {
|
||||
tableRows.push(splitTableRow(lines[i]!))
|
||||
i++
|
||||
}
|
||||
|
||||
nodes.push(renderTable(key, tableRows, t))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^<details\b/i.test(line) || /^<\/details>/i.test(line)) {
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const summary = line.match(/^<summary>(.*?)<\/summary>$/i)
|
||||
|
||||
if (summary) {
|
||||
start('paragraph')
|
||||
nodes.push(
|
||||
<Text key={`${key}-def-${i}`}>
|
||||
<Text color={t.color.dim}> · </Text>
|
||||
<MdInline t={t} text={def[1]!} />
|
||||
<Text color={t.color.dim} key={key}>
|
||||
▶ {summary[1]}
|
||||
</Text>
|
||||
)
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const bullet = line.match(/^(\s*)[-+*]\s+(.*)$/)
|
||||
|
||||
if (bullet) {
|
||||
start('list')
|
||||
const depth = indentDepth(bullet[1]!)
|
||||
const task = bullet[2]!.match(/^\[( |x|X)\]\s+(.*)$/)
|
||||
const marker = task ? (task[1]!.toLowerCase() === 'x' ? '☑' : '☐') : '•'
|
||||
const body = task ? task[2]! : bullet[2]!
|
||||
|
||||
nodes.push(
|
||||
<Text key={key}>
|
||||
<Text color={t.color.dim}>
|
||||
{' '.repeat(depth * 2)}
|
||||
{marker}{' '}
|
||||
if (/^<\/?[^>]+>$/.test(line.trim())) {
|
||||
start('paragraph')
|
||||
nodes.push(
|
||||
<Text color={t.color.dim} key={key}>
|
||||
{line.trim()}
|
||||
</Text>
|
||||
<MdInline t={t} text={body} />
|
||||
</Text>
|
||||
)
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const numbered = line.match(/^(\s*)(\d+)[.)]\s+(.*)$/)
|
||||
|
||||
if (numbered) {
|
||||
start('list')
|
||||
const depth = indentDepth(numbered[1]!)
|
||||
|
||||
nodes.push(
|
||||
<Text key={key}>
|
||||
<Text color={t.color.dim}>
|
||||
{' '.repeat(depth * 2)}
|
||||
{numbered[2]}.{' '}
|
||||
</Text>
|
||||
<MdInline t={t} text={numbered[3]!} />
|
||||
</Text>
|
||||
)
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^\s*(?:>\s*)+/.test(line)) {
|
||||
start('quote')
|
||||
const quoteLines: Array<{ depth: number; text: string }> = []
|
||||
|
||||
while (i < lines.length && /^\s*(?:>\s*)+/.test(lines[i]!)) {
|
||||
const raw = lines[i]!
|
||||
const prefix = raw.match(/^\s*(?:>\s*)+/)?.[0] ?? ''
|
||||
|
||||
quoteLines.push({
|
||||
depth: (prefix.match(/>/g) ?? []).length,
|
||||
text: raw.slice(prefix.length)
|
||||
})
|
||||
)
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
nodes.push(
|
||||
<Box flexDirection="column" key={key}>
|
||||
{quoteLines.map((ql, qi) => (
|
||||
<Text color={t.color.dim} key={qi}>
|
||||
{' '.repeat(Math.max(0, ql.depth - 1) * 2)}
|
||||
{'│ '}
|
||||
<MdInline t={t} text={ql.text} />
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
if (line.includes('|') && line.trim().startsWith('|')) {
|
||||
start('table')
|
||||
const tableRows: string[][] = []
|
||||
|
||||
continue
|
||||
}
|
||||
while (i < lines.length && lines[i]!.trim().startsWith('|')) {
|
||||
const row = lines[i]!.trim()
|
||||
|
||||
if (line.includes('|') && i + 1 < lines.length && isTableDivider(lines[i + 1]!)) {
|
||||
start('table')
|
||||
const tableRows: string[][] = []
|
||||
if (!/^[|\s:-]+$/.test(row)) {
|
||||
tableRows.push(splitTableRow(row))
|
||||
}
|
||||
|
||||
tableRows.push(splitTableRow(line))
|
||||
i += 2
|
||||
|
||||
while (i < lines.length && lines[i]!.includes('|') && lines[i]!.trim()) {
|
||||
tableRows.push(splitTableRow(lines[i]!))
|
||||
i++
|
||||
}
|
||||
|
||||
nodes.push(renderTable(key, tableRows, t))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^<details\b/i.test(line) || /^<\/details>/i.test(line)) {
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const summary = line.match(/^<summary>(.*?)<\/summary>$/i)
|
||||
|
||||
if (summary) {
|
||||
start('paragraph')
|
||||
nodes.push(
|
||||
<Text color={t.color.dim} key={key}>
|
||||
▶ {summary[1]}
|
||||
</Text>
|
||||
)
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^<\/?[^>]+>$/.test(line.trim())) {
|
||||
start('paragraph')
|
||||
nodes.push(
|
||||
<Text color={t.color.dim} key={key}>
|
||||
{line.trim()}
|
||||
</Text>
|
||||
)
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.includes('|') && line.trim().startsWith('|')) {
|
||||
start('table')
|
||||
const tableRows: string[][] = []
|
||||
|
||||
while (i < lines.length && lines[i]!.trim().startsWith('|')) {
|
||||
const row = lines[i]!.trim()
|
||||
|
||||
if (!/^[|\s:-]+$/.test(row)) {
|
||||
tableRows.push(splitTableRow(row))
|
||||
i++
|
||||
}
|
||||
|
||||
i++
|
||||
if (tableRows.length) {
|
||||
nodes.push(renderTable(key, tableRows, t))
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (tableRows.length) {
|
||||
nodes.push(renderTable(key, tableRows, t))
|
||||
}
|
||||
start('paragraph')
|
||||
nodes.push(<MdInline key={key} t={t} text={line} />)
|
||||
|
||||
continue
|
||||
i++
|
||||
}
|
||||
|
||||
start('paragraph')
|
||||
nodes.push(<MdInline key={key} t={t} text={line} />)
|
||||
|
||||
i++
|
||||
}
|
||||
return nodes
|
||||
}, [compact, t, text])
|
||||
|
||||
return <Box flexDirection="column">{nodes}</Box>
|
||||
}
|
||||
|
||||
export const Md = memo(MdImpl)
|
||||
|
|
|
|||
|
|
@ -2,18 +2,10 @@ import { Box, Text, useInput } from '@hermes/ink'
|
|||
import { useEffect, useState } from 'react'
|
||||
|
||||
import type { GatewayClient } from '../gatewayClient.js'
|
||||
import type { ModelOptionProvider, ModelOptionsResponse } from '../gatewayTypes.js'
|
||||
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
|
||||
interface ProviderItem {
|
||||
is_current?: boolean
|
||||
models?: string[]
|
||||
name: string
|
||||
slug: string
|
||||
total_models?: number
|
||||
warning?: string
|
||||
}
|
||||
|
||||
const VISIBLE = 12
|
||||
|
||||
const pageOffset = (count: number, sel: number) => Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), count - VISIBLE))
|
||||
|
|
@ -31,7 +23,7 @@ export function ModelPicker({
|
|||
sessionId: string | null
|
||||
t: Theme
|
||||
}) {
|
||||
const [providers, setProviders] = useState<ProviderItem[]>([])
|
||||
const [providers, setProviders] = useState<ModelOptionProvider[]>([])
|
||||
const [currentModel, setCurrentModel] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
|
@ -41,9 +33,9 @@ export function ModelPicker({
|
|||
const [stage, setStage] = useState<'model' | 'provider'>('provider')
|
||||
|
||||
useEffect(() => {
|
||||
gw.request('model.options', sessionId ? { session_id: sessionId } : {})
|
||||
.then((raw: any) => {
|
||||
const r = asRpcResult(raw)
|
||||
gw.request<ModelOptionsResponse>('model.options', sessionId ? { session_id: sessionId } : {})
|
||||
.then(raw => {
|
||||
const r = asRpcResult<ModelOptionsResponse>(raw)
|
||||
|
||||
if (!r) {
|
||||
setErr('invalid response: model.options')
|
||||
|
|
@ -52,7 +44,7 @@ export function ModelPicker({
|
|||
return
|
||||
}
|
||||
|
||||
const next = (r.providers ?? []) as ProviderItem[]
|
||||
const next = r.providers ?? []
|
||||
setProviders(next)
|
||||
setCurrentModel(String(r.model ?? ''))
|
||||
setProviderIdx(
|
||||
|
|
|
|||
|
|
@ -2,18 +2,10 @@ import { Box, Text, useInput } from '@hermes/ink'
|
|||
import { useEffect, useState } from 'react'
|
||||
|
||||
import type { GatewayClient } from '../gatewayClient.js'
|
||||
import type { SessionListItem, SessionListResponse } from '../gatewayTypes.js'
|
||||
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
|
||||
interface SessionItem {
|
||||
id: string
|
||||
title: string
|
||||
preview: string
|
||||
started_at: number
|
||||
message_count: number
|
||||
source?: string
|
||||
}
|
||||
|
||||
function age(ts: number): string {
|
||||
const d = (Date.now() / 1000 - ts) / 86400
|
||||
|
||||
|
|
@ -41,15 +33,15 @@ export function SessionPicker({
|
|||
onSelect: (id: string) => void
|
||||
t: Theme
|
||||
}) {
|
||||
const [items, setItems] = useState<SessionItem[]>([])
|
||||
const [items, setItems] = useState<SessionListItem[]>([])
|
||||
const [err, setErr] = useState('')
|
||||
const [sel, setSel] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
gw.request('session.list', { limit: 20 })
|
||||
.then((raw: any) => {
|
||||
const r = asRpcResult(raw)
|
||||
gw.request<SessionListResponse>('session.list', { limit: 20 })
|
||||
.then(raw => {
|
||||
const r = asRpcResult<SessionListResponse>(raw)
|
||||
|
||||
if (!r) {
|
||||
setErr('invalid response: session.list')
|
||||
|
|
@ -58,7 +50,7 @@ export function SessionPicker({
|
|||
return
|
||||
}
|
||||
|
||||
setItems((r?.sessions ?? []) as SessionItem[])
|
||||
setItems(r.sessions ?? [])
|
||||
setErr('')
|
||||
setLoading(false)
|
||||
})
|
||||
|
|
|
|||
15
ui-tui/src/components/sidebarRail.tsx
Normal file
15
ui-tui/src/components/sidebarRail.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { Box, NoSelect } from '@hermes/ink'
|
||||
|
||||
import type { Theme } from '../theme.js'
|
||||
import type { WidgetSpec } from '../widgets.js'
|
||||
import { WidgetHost } from '../widgets.js'
|
||||
|
||||
export function SidebarRail({ t, widgets, width }: { t: Theme; widgets: WidgetSpec[]; width: number }) {
|
||||
return (
|
||||
<NoSelect flexDirection="column" flexShrink={0} width={width}>
|
||||
<Box borderColor={t.color.bronze as any} borderStyle="round" flexDirection="column" paddingX={2} paddingY={1}>
|
||||
<WidgetHost region="sidebar" widgets={widgets} />
|
||||
</Box>
|
||||
</NoSelect>
|
||||
)
|
||||
}
|
||||
|
|
@ -29,8 +29,16 @@ const dim = (s: string) => DIM + s + DIM_OFF
|
|||
|
||||
let _seg: Intl.Segmenter | null = null
|
||||
const seg = () => (_seg ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' }))
|
||||
const STOP_CACHE_MAX = 32
|
||||
const stopCache = new Map<string, number[]>()
|
||||
|
||||
function graphemeStops(s: string) {
|
||||
const hit = stopCache.get(s)
|
||||
|
||||
if (hit) {
|
||||
return hit
|
||||
}
|
||||
|
||||
const stops = [0]
|
||||
|
||||
for (const { index } of seg().segment(s)) {
|
||||
|
|
@ -43,6 +51,16 @@ function graphemeStops(s: string) {
|
|||
stops.push(s.length)
|
||||
}
|
||||
|
||||
stopCache.set(s, stops)
|
||||
|
||||
if (stopCache.size > STOP_CACHE_MAX) {
|
||||
const oldest = stopCache.keys().next().value
|
||||
|
||||
if (oldest !== undefined) {
|
||||
stopCache.delete(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
return stops
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { memo, type ReactNode, useEffect, useMemo, useState } from 'react'
|
|||
import spinners, { type BrailleSpinnerName } from 'unicode-animations'
|
||||
|
||||
import {
|
||||
compactPreview,
|
||||
estimateTokensRough,
|
||||
fmtK,
|
||||
formatToolCall,
|
||||
|
|
@ -13,7 +14,7 @@ import {
|
|||
toolTrailLabel
|
||||
} from '../lib/text.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
import type { ActiveTool, ActivityItem, DetailsMode, ThinkingMode } from '../types.js'
|
||||
import type { ActiveTool, ActivityItem, DetailsMode, SubagentProgress, ThinkingMode } from '../types.js'
|
||||
|
||||
const THINK: BrailleSpinnerName[] = ['helix', 'breathe', 'orbit', 'dna', 'waverows', 'snake', 'pulse']
|
||||
const TOOL: BrailleSpinnerName[] = ['cascade', 'scan', 'diagswipe', 'fillsweep', 'rain', 'columns', 'sparkle']
|
||||
|
|
@ -128,6 +129,122 @@ function Chevron({
|
|||
)
|
||||
}
|
||||
|
||||
function SubagentAccordion({ expanded, item, t }: { expanded: boolean; item: SubagentProgress; t: Theme }) {
|
||||
const [open, setOpen] = useState(expanded)
|
||||
const [openThinking, setOpenThinking] = useState(expanded)
|
||||
const [openTools, setOpenTools] = useState(expanded)
|
||||
const [openNotes, setOpenNotes] = useState(expanded)
|
||||
|
||||
useEffect(() => {
|
||||
if (!expanded) {
|
||||
return
|
||||
}
|
||||
|
||||
setOpen(true)
|
||||
setOpenThinking(true)
|
||||
setOpenTools(true)
|
||||
setOpenNotes(true)
|
||||
}, [expanded])
|
||||
|
||||
const statusTone: 'dim' | 'error' | 'warn' =
|
||||
item.status === 'failed' ? 'error' : item.status === 'interrupted' ? 'warn' : 'dim'
|
||||
|
||||
const prefix = item.taskCount > 1 ? `[${item.index + 1}/${item.taskCount}] ` : ''
|
||||
const title = `${prefix}${item.goal || `Subagent ${item.index + 1}`}`
|
||||
const summary = compactPreview((item.summary || '').replace(/\s+/g, ' ').trim(), 72)
|
||||
|
||||
const suffix =
|
||||
item.status === 'running'
|
||||
? 'running'
|
||||
: `${item.status}${item.durationSeconds ? ` · ${fmtElapsed(item.durationSeconds * 1000)}` : ''}`
|
||||
|
||||
const thinkingText = item.thinking.join('\n')
|
||||
const hasThinking = Boolean(thinkingText)
|
||||
const hasTools = item.tools.length > 0
|
||||
const noteRows = [...(summary ? [summary] : []), ...item.notes]
|
||||
const hasNotes = noteRows.length > 0
|
||||
const active = expanded || open
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingLeft={1}>
|
||||
<Chevron onClick={() => setOpen(v => !v)} open={active} suffix={suffix} t={t} title={title} tone={statusTone} />
|
||||
{active && (
|
||||
<Box flexDirection="column" paddingLeft={2}>
|
||||
{hasThinking && (
|
||||
<>
|
||||
<Chevron
|
||||
count={item.thinking.length}
|
||||
onClick={() => setOpenThinking(v => !v)}
|
||||
open={expanded || openThinking}
|
||||
t={t}
|
||||
title="Thinking"
|
||||
/>
|
||||
{(expanded || openThinking) && (
|
||||
<Thinking
|
||||
active={item.status === 'running'}
|
||||
mode="full"
|
||||
reasoning={thinkingText}
|
||||
streaming={item.status === 'running'}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasTools && (
|
||||
<>
|
||||
<Chevron
|
||||
count={item.tools.length}
|
||||
onClick={() => setOpenTools(v => !v)}
|
||||
open={expanded || openTools}
|
||||
t={t}
|
||||
title="Tool calls"
|
||||
/>
|
||||
{(expanded || openTools) && (
|
||||
<Box flexDirection="column">
|
||||
{item.tools.map((line, index) => (
|
||||
<Text color={t.color.cornsilk} key={`${item.id}-tool-${index}`} wrap="wrap-trim">
|
||||
<Text color={t.color.amber}>● </Text>
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasNotes && (
|
||||
<>
|
||||
<Chevron
|
||||
count={noteRows.length}
|
||||
onClick={() => setOpenNotes(v => !v)}
|
||||
open={expanded || openNotes}
|
||||
t={t}
|
||||
title="Progress"
|
||||
tone={statusTone}
|
||||
/>
|
||||
{(expanded || openNotes) && (
|
||||
<Box flexDirection="column">
|
||||
{noteRows.map((line, index) => (
|
||||
<Text
|
||||
color={statusTone === 'error' ? t.color.error : t.color.dim}
|
||||
dimColor
|
||||
key={`${item.id}-note-${index}`}
|
||||
>
|
||||
<Text dimColor>{index === noteRows.length - 1 ? '└ ' : '├ '}</Text>
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Thinking ─────────────────────────────────────────────────────────
|
||||
|
||||
export const Thinking = memo(function Thinking({
|
||||
|
|
@ -143,7 +260,7 @@ export const Thinking = memo(function Thinking({
|
|||
streaming?: boolean
|
||||
t: Theme
|
||||
}) {
|
||||
const preview = thinkingPreview(reasoning, mode, THINKING_COT_MAX)
|
||||
const preview = useMemo(() => thinkingPreview(reasoning, mode, THINKING_COT_MAX), [mode, reasoning])
|
||||
const lines = useMemo(() => preview.split('\n').map(line => line.replace(/\t/g, ' ')), [preview])
|
||||
|
||||
return (
|
||||
|
|
@ -198,6 +315,7 @@ export const ToolTrail = memo(function ToolTrail({
|
|||
reasoning = '',
|
||||
reasoningTokens,
|
||||
reasoningStreaming = false,
|
||||
subagents = [],
|
||||
t,
|
||||
tools = [],
|
||||
toolTokens,
|
||||
|
|
@ -210,6 +328,7 @@ export const ToolTrail = memo(function ToolTrail({
|
|||
reasoning?: string
|
||||
reasoningTokens?: number
|
||||
reasoningStreaming?: boolean
|
||||
subagents?: SubagentProgress[]
|
||||
t: Theme
|
||||
tools?: ActiveTool[]
|
||||
toolTokens?: number
|
||||
|
|
@ -219,6 +338,7 @@ export const ToolTrail = memo(function ToolTrail({
|
|||
const [now, setNow] = useState(() => Date.now())
|
||||
const [openThinking, setOpenThinking] = useState(false)
|
||||
const [openTools, setOpenTools] = useState(false)
|
||||
const [openSubagents, setOpenSubagents] = useState(false)
|
||||
const [openMeta, setOpenMeta] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -235,19 +355,21 @@ export const ToolTrail = memo(function ToolTrail({
|
|||
if (detailsMode === 'expanded') {
|
||||
setOpenThinking(true)
|
||||
setOpenTools(true)
|
||||
setOpenSubagents(true)
|
||||
setOpenMeta(true)
|
||||
}
|
||||
|
||||
if (detailsMode === 'hidden') {
|
||||
setOpenThinking(false)
|
||||
setOpenTools(false)
|
||||
setOpenSubagents(false)
|
||||
setOpenMeta(false)
|
||||
}
|
||||
}, [detailsMode])
|
||||
|
||||
const cot = thinkingPreview(reasoning, 'full', THINKING_COT_MAX)
|
||||
const cot = useMemo(() => thinkingPreview(reasoning, 'full', THINKING_COT_MAX), [reasoning])
|
||||
|
||||
if (!busy && !trail.length && !tools.length && !activity.length && !cot && !reasoningActive) {
|
||||
if (!busy && !trail.length && !tools.length && !subagents.length && !activity.length && !cot && !reasoningActive) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
@ -334,6 +456,7 @@ export const ToolTrail = memo(function ToolTrail({
|
|||
// ── Derived ────────────────────────────────────────────────────
|
||||
|
||||
const hasTools = groups.length > 0
|
||||
const hasSubagents = subagents.length > 0
|
||||
const hasMeta = meta.length > 0
|
||||
const hasThinking = !!cot || reasoningActive || (busy && !hasTools)
|
||||
const thinkingLive = reasoningActive || reasoningStreaming
|
||||
|
|
@ -395,6 +518,10 @@ export const ToolTrail = memo(function ToolTrail({
|
|||
))
|
||||
: null
|
||||
|
||||
const subagentBlock = hasSubagents
|
||||
? subagents.map(item => <SubagentAccordion expanded={detailsMode === 'expanded'} item={item} key={item.id} t={t} />)
|
||||
: null
|
||||
|
||||
const metaBlock = hasMeta
|
||||
? meta.map((row, i) => (
|
||||
<Text color={row.color} dimColor={row.dimColor} key={row.key}>
|
||||
|
|
@ -418,6 +545,7 @@ export const ToolTrail = memo(function ToolTrail({
|
|||
<Box flexDirection="column">
|
||||
{thinkingBlock}
|
||||
{toolBlock}
|
||||
{subagentBlock}
|
||||
{metaBlock}
|
||||
{totalBlock}
|
||||
</Box>
|
||||
|
|
@ -468,6 +596,19 @@ export const ToolTrail = memo(function ToolTrail({
|
|||
</>
|
||||
)}
|
||||
|
||||
{hasSubagents && (
|
||||
<>
|
||||
<Chevron
|
||||
count={subagents.length}
|
||||
onClick={() => setOpenSubagents(v => !v)}
|
||||
open={openSubagents}
|
||||
t={t}
|
||||
title="Subagents"
|
||||
/>
|
||||
{openSubagents && subagentBlock}
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasMeta && (
|
||||
<>
|
||||
<Chevron
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue