feat: add clicky handles
This commit is contained in:
@@ -1,42 +1,39 @@
|
||||
import { Ansi, Box, Text } from '@hermes/ink'
|
||||
import { Ansi, Box, NoSelect, Text } from '@hermes/ink'
|
||||
import { memo } from 'react'
|
||||
|
||||
import { LONG_MSG, ROLE } from '../constants.js'
|
||||
import { compactPreview, hasAnsi, isPasteBackedText, stripAnsi, thinkingPreview, userDisplay } from '../lib/text.js'
|
||||
import { compactPreview, hasAnsi, isPasteBackedText, stripAnsi, userDisplay } from '../lib/text.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
import type { Msg, ThinkingMode } from '../types.js'
|
||||
|
||||
import type { DetailsMode, Msg } from '../types.js'
|
||||
import { Md } from './markdown.js'
|
||||
import { ToolTrail } from './thinking.js'
|
||||
|
||||
export const MessageLine = memo(function MessageLine({
|
||||
cols,
|
||||
compact,
|
||||
thinkingMode = 'truncated',
|
||||
detailsMode = 'collapsed',
|
||||
msg,
|
||||
t
|
||||
}: {
|
||||
cols: number
|
||||
compact?: boolean
|
||||
thinkingMode?: ThinkingMode
|
||||
detailsMode?: DetailsMode
|
||||
msg: Msg
|
||||
t: Theme
|
||||
}) {
|
||||
if (msg.kind === 'trail' && msg.tools?.length) {
|
||||
return (
|
||||
return detailsMode === 'hidden' ? null : (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<ToolTrail t={t} thinkingMode={thinkingMode} trail={msg.tools} />
|
||||
<ToolTrail detailsMode={detailsMode} t={t} trail={msg.tools} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (msg.role === 'tool') {
|
||||
const preview = compactPreview(hasAnsi(msg.text) ? stripAnsi(msg.text) : msg.text, Math.max(24, cols - 14))
|
||||
|
||||
return (
|
||||
<Box alignSelf="flex-start" borderColor={t.color.dim} borderStyle="round" marginLeft={3} paddingX={1}>
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
{preview || '(empty tool result)'}
|
||||
{compactPreview(hasAnsi(msg.text) ? stripAnsi(msg.text) : msg.text, Math.max(24, cols - 14)) || '(empty tool result)'}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
@@ -44,33 +41,19 @@ export const MessageLine = memo(function MessageLine({
|
||||
|
||||
const { body, glyph, prefix } = ROLE[msg.role](t)
|
||||
const thinking = msg.thinking?.replace(/\n/g, ' ').trim() ?? ''
|
||||
const preview = thinkingPreview(thinking, thinkingMode, Math.min(96, Math.max(32, cols - 18)))
|
||||
const showThinkingPreview = Boolean(preview && !msg.tools?.length)
|
||||
const showDetails = detailsMode !== 'hidden' && (Boolean(msg.tools?.length) || Boolean(thinking))
|
||||
|
||||
const content = (() => {
|
||||
if (msg.kind === 'slash') {
|
||||
return <Text color={t.color.dim}>{msg.text}</Text>
|
||||
}
|
||||
|
||||
if (msg.role !== 'user' && hasAnsi(msg.text)) {
|
||||
return <Ansi>{msg.text}</Ansi>
|
||||
}
|
||||
|
||||
if (msg.role === 'assistant') {
|
||||
return <Md compact={compact} t={t} text={msg.text} />
|
||||
}
|
||||
if (msg.kind === 'slash') return <Text color={t.color.dim}>{msg.text}</Text>
|
||||
if (msg.role !== 'user' && hasAnsi(msg.text)) return <Ansi>{msg.text}</Ansi>
|
||||
if (msg.role === 'assistant') return <Md compact={compact} t={t} text={msg.text} />
|
||||
|
||||
if (msg.role === 'user' && msg.text.length > LONG_MSG && isPasteBackedText(msg.text)) {
|
||||
const [head, ...rest] = userDisplay(msg.text).split('[long message]')
|
||||
|
||||
return (
|
||||
<Text color={body}>
|
||||
{head}
|
||||
|
||||
<Text color={t.color.dim} dimColor>
|
||||
[long message]
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.dim} dimColor>[long message]</Text>
|
||||
{rest.join('')}
|
||||
</Text>
|
||||
)
|
||||
@@ -85,25 +68,16 @@ export const MessageLine = memo(function MessageLine({
|
||||
marginBottom={msg.role === 'user' ? 1 : 0}
|
||||
marginTop={msg.role === 'user' || msg.kind === 'slash' ? 1 : 0}
|
||||
>
|
||||
{msg.tools?.length ? (
|
||||
{showDetails && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<ToolTrail reasoning={thinking} t={t} thinkingMode={thinkingMode} trail={msg.tools} />
|
||||
<ToolTrail detailsMode={detailsMode} reasoning={thinking} t={t} trail={msg.tools} />
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{showThinkingPreview && (
|
||||
<Text color={t.color.dim} dimColor {...(thinkingMode !== 'full' ? { wrap: 'truncate-end' as const } : {})}>
|
||||
{'└ '}
|
||||
{preview}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Box flexShrink={0} width={3}>
|
||||
<Text bold={msg.role === 'user'} color={prefix}>
|
||||
{glyph}{' '}
|
||||
</Text>
|
||||
</Box>
|
||||
<NoSelect flexShrink={0} fromLeftEdge width={3}>
|
||||
<Text bold={msg.role === 'user'} color={prefix}>{glyph}{' '}</Text>
|
||||
</NoSelect>
|
||||
|
||||
<Box width={Math.max(20, cols - 5)}>{content}</Box>
|
||||
</Box>
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { Box, Text } from '@hermes/ink'
|
||||
|
||||
import { compactPreview, fmtK } from '../lib/text.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
import type { PendingPaste } from '../types.js'
|
||||
|
||||
const TOKEN_RE = /\[\[paste:(\d+)(?:[^\n]*?)\]\]/g
|
||||
|
||||
const modeLabel = {
|
||||
attach: 'attach',
|
||||
excerpt: 'excerpt',
|
||||
inline: 'inline'
|
||||
} as const
|
||||
|
||||
export function PasteShelf({ draft, pastes, t }: { draft: string; pastes: PendingPaste[]; t: Theme }) {
|
||||
if (!pastes.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const inDraft = new Set<number>()
|
||||
|
||||
for (const m of draft.matchAll(TOKEN_RE)) {
|
||||
inDraft.add(parseInt(m[1] ?? '-1', 10))
|
||||
}
|
||||
|
||||
return (
|
||||
<Box borderColor={t.color.bronze} borderStyle="round" flexDirection="column" marginTop={1} paddingX={1}>
|
||||
<Text color={t.color.amber}>Paste shelf ({pastes.length})</Text>
|
||||
{pastes.slice(-4).map(paste => (
|
||||
<Text color={t.color.dim} key={paste.id}>
|
||||
#{paste.id} {modeLabel[paste.mode]} · {paste.lineCount}L · {fmtK(paste.tokenCount)} tok ·{' '}
|
||||
{fmtK(paste.charCount)} chars · {paste.kind}
|
||||
{inDraft.has(paste.id) ? <Text color={t.color.label}> · in draft</Text> : ''}
|
||||
{' · '}
|
||||
<Text color={t.color.cornsilk}>{compactPreview(paste.text, 44) || '(empty)'}</Text>
|
||||
</Text>
|
||||
))}
|
||||
{pastes.length > 4 && (
|
||||
<Text color={t.color.dim} dimColor>
|
||||
…and {pastes.length - 4} more
|
||||
</Text>
|
||||
)}
|
||||
<Text color={t.color.dim} dimColor>
|
||||
/paste mode {'<id>'} {'<attach|excerpt|inline>'} · /paste drop {'<id>'} · /paste clear
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -156,6 +156,55 @@ function cursorLayout(value: string, cursor: number, cols: number) {
|
||||
return { column: col, line }
|
||||
}
|
||||
|
||||
function offsetFromPosition(value: string, row: number, col: number, cols: number) {
|
||||
if (!value.length) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const targetRow = Math.max(0, Math.floor(row))
|
||||
const targetCol = Math.max(0, Math.floor(col))
|
||||
const w = Math.max(1, cols - 1)
|
||||
|
||||
let line = 0
|
||||
let column = 0
|
||||
let lastOffset = 0
|
||||
|
||||
for (const { segment, index } of seg().segment(value)) {
|
||||
lastOffset = index
|
||||
|
||||
if (segment === '\n') {
|
||||
if (line === targetRow) {
|
||||
return index
|
||||
}
|
||||
line++
|
||||
column = 0
|
||||
continue
|
||||
}
|
||||
|
||||
const sw = Math.max(1, stringWidth(segment))
|
||||
|
||||
if (column + sw > w) {
|
||||
if (line === targetRow) {
|
||||
return index
|
||||
}
|
||||
line++
|
||||
column = 0
|
||||
}
|
||||
|
||||
if (line === targetRow && targetCol <= column + Math.max(0, sw - 1)) {
|
||||
return index
|
||||
}
|
||||
|
||||
column += sw
|
||||
}
|
||||
|
||||
if (targetRow >= line) {
|
||||
return value.length
|
||||
}
|
||||
|
||||
return lastOffset
|
||||
}
|
||||
|
||||
// ── Render value with inverse-video cursor ───────────────────────────
|
||||
|
||||
function renderWithCursor(value: string, cursor: number) {
|
||||
@@ -283,6 +332,13 @@ 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(() => {
|
||||
@@ -512,7 +568,7 @@ export function TextInput({
|
||||
// ── Render ───────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<Box ref={boxRef}>
|
||||
<Box ref={boxRef} onClick={clickCursor}>
|
||||
<Text wrap="wrap">{rendered}</Text>
|
||||
</Box>
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import spinners, { type BrailleSpinnerName } from 'unicode-animations'
|
||||
|
||||
import { FACES, VERBS } from '../constants.js'
|
||||
import {
|
||||
compactPreview,
|
||||
formatToolCall,
|
||||
parseToolTrailResultLine,
|
||||
pick,
|
||||
@@ -12,23 +13,21 @@ import {
|
||||
toolTrailLabel
|
||||
} from '../lib/text.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
import type { ActiveTool, ActivityItem, ThinkingMode } from '../types.js'
|
||||
import type { ActiveTool, ActivityItem, DetailsMode, ThinkingMode } from '../types.js'
|
||||
|
||||
const THINK: BrailleSpinnerName[] = ['helix', 'breathe', 'orbit', 'dna', 'waverows', 'snake', 'pulse']
|
||||
const TOOL: BrailleSpinnerName[] = ['cascade', 'scan', 'diagswipe', 'fillsweep', 'rain', 'columns', 'sparkle']
|
||||
|
||||
const fmtElapsed = (ms: number) => {
|
||||
const sec = Math.max(0, ms) / 1000
|
||||
|
||||
return sec < 10 ? `${sec.toFixed(1)}s` : `${Math.round(sec)}s`
|
||||
}
|
||||
|
||||
// ── Spinner ──────────────────────────────────────────────────────────
|
||||
// ── Primitives ───────────────────────────────────────────────────────
|
||||
|
||||
export function Spinner({ color, variant = 'think' }: { color: string; variant?: 'think' | 'tool' }) {
|
||||
const [spin] = useState(() => {
|
||||
const raw = spinners[pick(variant === 'tool' ? TOOL : THINK)]
|
||||
|
||||
return { ...raw, frames: raw.frames.map(f => [...f][0] ?? '⠀') }
|
||||
})
|
||||
|
||||
@@ -36,15 +35,12 @@ export function Spinner({ color, variant = 'think' }: { color: string; variant?:
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setFrame(f => (f + 1) % spin.frames.length), spin.interval)
|
||||
|
||||
return () => clearInterval(id)
|
||||
}, [spin])
|
||||
|
||||
return <Text color={color}>{spin.frames[frame]}</Text>
|
||||
}
|
||||
|
||||
// ── Detail row ───────────────────────────────────────────────────────
|
||||
|
||||
type DetailRow = { color: string; content: ReactNode; dimColor?: boolean; key: string }
|
||||
|
||||
function Detail({ color, content, dimColor }: DetailRow) {
|
||||
@@ -56,54 +52,47 @@ function Detail({ color, content, dimColor }: DetailRow) {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Streaming cursor ─────────────────────────────────────────────────
|
||||
|
||||
function StreamCursor({
|
||||
color,
|
||||
dimColor,
|
||||
streaming = false,
|
||||
visible = false
|
||||
}: {
|
||||
color: string
|
||||
dimColor?: boolean
|
||||
streaming?: boolean
|
||||
visible?: boolean
|
||||
function StreamCursor({ color, dimColor, streaming = false, visible = false }: {
|
||||
color: string; dimColor?: boolean; streaming?: boolean; visible?: boolean
|
||||
}) {
|
||||
const [on, setOn] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setOn(v => !v), 420)
|
||||
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
return visible ? (
|
||||
<Text color={color} dimColor={dimColor}>
|
||||
{streaming && on ? '▍' : ' '}
|
||||
</Text>
|
||||
) : null
|
||||
return visible ? <Text color={color} dimColor={dimColor}>{streaming && on ? '▍' : ' '}</Text> : null
|
||||
}
|
||||
|
||||
// ── Thinking (pre-tool fallback) ─────────────────────────────────────
|
||||
function Chevron({ count, onClick, open, summary, t, title, tone = 'dim' }: {
|
||||
count?: number; onClick: () => void; open: boolean; summary?: string
|
||||
t: Theme; title: string; tone?: 'dim' | 'error' | 'warn'
|
||||
}) {
|
||||
const color = tone === 'error' ? t.color.error : tone === 'warn' ? t.color.warn : t.color.dim
|
||||
|
||||
return (
|
||||
<Box onClick={onClick}>
|
||||
<Text color={color} dimColor={tone === 'dim'}>
|
||||
<Text color={t.color.amber}>{open ? '▾ ' : '▸ '}</Text>
|
||||
{title}{typeof count === 'number' ? ` (${count})` : ''}
|
||||
{summary ? <Text color={t.color.dim}> · {summary}</Text> : null}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Thinking ─────────────────────────────────────────────────────────
|
||||
|
||||
export const Thinking = memo(function Thinking({
|
||||
active = false,
|
||||
mode = 'truncated',
|
||||
reasoning,
|
||||
streaming = false,
|
||||
t
|
||||
active = false, mode = 'truncated', reasoning, streaming = false, t
|
||||
}: {
|
||||
active?: boolean
|
||||
mode?: ThinkingMode
|
||||
reasoning: string
|
||||
streaming?: boolean
|
||||
t: Theme
|
||||
active?: boolean; mode?: ThinkingMode; reasoning: string; streaming?: boolean; t: Theme
|
||||
}) {
|
||||
const [tick, setTick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick(v => v + 1), 1100)
|
||||
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
@@ -132,58 +121,44 @@ export const Thinking = memo(function Thinking({
|
||||
)
|
||||
})
|
||||
|
||||
// ── ToolTrail (canonical progress block) ─────────────────────────────
|
||||
// ── ToolTrail ────────────────────────────────────────────────────────
|
||||
|
||||
type Group = { color: string; content: ReactNode; details: DetailRow[]; key: string }
|
||||
|
||||
export const ToolTrail = memo(function ToolTrail({
|
||||
busy = false,
|
||||
thinkingMode = 'truncated',
|
||||
reasoningActive = false,
|
||||
reasoning = '',
|
||||
reasoningStreaming = false,
|
||||
t,
|
||||
tools = [],
|
||||
trail = [],
|
||||
activity = []
|
||||
busy = false, detailsMode = 'collapsed', reasoningActive = false,
|
||||
reasoning = '', reasoningStreaming = false, t,
|
||||
tools = [], trail = [], activity = []
|
||||
}: {
|
||||
busy?: boolean
|
||||
thinkingMode?: ThinkingMode
|
||||
reasoningActive?: boolean
|
||||
reasoning?: string
|
||||
reasoningStreaming?: boolean
|
||||
t: Theme
|
||||
tools?: ActiveTool[]
|
||||
trail?: string[]
|
||||
activity?: ActivityItem[]
|
||||
busy?: boolean; detailsMode?: DetailsMode; reasoningActive?: boolean
|
||||
reasoning?: string; reasoningStreaming?: boolean; t: Theme
|
||||
tools?: ActiveTool[]; trail?: string[]; activity?: ActivityItem[]
|
||||
}) {
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
const [openThinking, setOpenThinking] = useState(false)
|
||||
const [openTools, setOpenTools] = useState(false)
|
||||
const [openMeta, setOpenMeta] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!tools.length) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!tools.length) return
|
||||
const id = setInterval(() => setNow(Date.now()), 200)
|
||||
|
||||
return () => clearInterval(id)
|
||||
}, [tools.length])
|
||||
|
||||
const reasoningTail = thinkingPreview(reasoning, thinkingMode, THINKING_COT_MAX)
|
||||
useEffect(() => {
|
||||
if (detailsMode === 'expanded') { setOpenThinking(true); setOpenTools(true); setOpenMeta(true) }
|
||||
if (detailsMode === 'hidden') { setOpenThinking(false); setOpenTools(false); setOpenMeta(false) }
|
||||
}, [detailsMode])
|
||||
|
||||
if (!busy && !trail.length && !tools.length && !activity.length && !reasoningTail && !reasoningActive) {
|
||||
return null
|
||||
}
|
||||
const cot = thinkingPreview(reasoning, 'full', THINKING_COT_MAX)
|
||||
|
||||
if (!busy && !trail.length && !tools.length && !activity.length && !cot && !reasoningActive) return null
|
||||
|
||||
// ── Build groups + meta ────────────────────────────────────────
|
||||
|
||||
const groups: Group[] = []
|
||||
const meta: DetailRow[] = []
|
||||
|
||||
const detail = (row: DetailRow) => {
|
||||
const g = groups.at(-1)
|
||||
g ? g.details.push(row) : meta.push(row)
|
||||
}
|
||||
|
||||
// ── trail → groups + details ────────────────────────────────────
|
||||
const pushDetail = (row: DetailRow) => (groups.at(-1)?.details ?? meta).push(row)
|
||||
|
||||
for (const [i, line] of trail.entries()) {
|
||||
const parsed = parseToolTrailResultLine(line)
|
||||
@@ -192,19 +167,12 @@ export const ToolTrail = memo(function ToolTrail({
|
||||
groups.push({
|
||||
color: parsed.mark === '✗' ? t.color.error : t.color.cornsilk,
|
||||
content: parsed.detail ? parsed.call : `${parsed.call} ${parsed.mark}`,
|
||||
details: [],
|
||||
key: `tr-${i}`
|
||||
details: [], key: `tr-${i}`
|
||||
})
|
||||
if (parsed.detail) pushDetail({
|
||||
color: parsed.mark === '✗' ? t.color.error : t.color.dim,
|
||||
content: parsed.detail, dimColor: parsed.mark !== '✗', key: `tr-${i}-d`
|
||||
})
|
||||
|
||||
if (parsed.detail) {
|
||||
detail({
|
||||
color: parsed.mark === '✗' ? t.color.error : t.color.dim,
|
||||
content: parsed.detail,
|
||||
dimColor: parsed.mark !== '✗',
|
||||
key: `tr-${i}-d`
|
||||
})
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -215,112 +183,134 @@ export const ToolTrail = memo(function ToolTrail({
|
||||
details: [{ color: t.color.dim, content: 'drafting...', dimColor: true, key: `tr-${i}-d` }],
|
||||
key: `tr-${i}`
|
||||
})
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (line === 'analyzing tool output…') {
|
||||
detail({
|
||||
color: t.color.dim,
|
||||
content: groups.length ? (
|
||||
<>
|
||||
<Spinner color={t.color.amber} variant="think" /> {line}
|
||||
</>
|
||||
) : (
|
||||
line
|
||||
),
|
||||
dimColor: true,
|
||||
key: `tr-${i}`
|
||||
pushDetail({
|
||||
color: t.color.dim, dimColor: true, key: `tr-${i}`,
|
||||
content: groups.length
|
||||
? <><Spinner color={t.color.amber} variant="think" /> {line}</>
|
||||
: line
|
||||
})
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
meta.push({ color: t.color.dim, content: line, dimColor: true, key: `tr-${i}` })
|
||||
}
|
||||
|
||||
// ── live tools → groups ─────────────────────────────────────────
|
||||
|
||||
for (const tool of tools) {
|
||||
groups.push({
|
||||
color: t.color.cornsilk,
|
||||
color: t.color.cornsilk, key: tool.id, details: [],
|
||||
content: (
|
||||
<>
|
||||
<Spinner color={t.color.amber} variant="tool" /> {formatToolCall(tool.name, tool.context || '')}
|
||||
{tool.startedAt ? ` (${fmtElapsed(now - tool.startedAt)})` : ''}
|
||||
</>
|
||||
),
|
||||
details: [],
|
||||
key: tool.id
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (reasoningTail && groups.length) {
|
||||
detail({
|
||||
color: t.color.dim,
|
||||
content: (
|
||||
<>
|
||||
{reasoningTail}
|
||||
<StreamCursor color={t.color.dim} dimColor streaming={reasoningStreaming} visible={reasoningActive} />
|
||||
</>
|
||||
),
|
||||
dimColor: true,
|
||||
key: 'cot'
|
||||
if (cot && groups.length) {
|
||||
pushDetail({
|
||||
color: t.color.dim, dimColor: true, key: 'cot',
|
||||
content: <>{cot}<StreamCursor color={t.color.dim} dimColor streaming={reasoningStreaming} visible={reasoningActive} /></>
|
||||
})
|
||||
} else if (reasoningActive && groups.length && thinkingMode === 'collapsed') {
|
||||
detail({
|
||||
color: t.color.dim,
|
||||
content: <StreamCursor color={t.color.dim} dimColor streaming={reasoningStreaming} visible={reasoningActive} />,
|
||||
dimColor: true,
|
||||
key: 'cot'
|
||||
} else if (reasoningActive && groups.length) {
|
||||
pushDetail({
|
||||
color: t.color.dim, dimColor: true, key: 'cot',
|
||||
content: <StreamCursor color={t.color.dim} dimColor streaming={reasoningStreaming} visible={reasoningActive} />
|
||||
})
|
||||
}
|
||||
|
||||
// ── activity → meta ─────────────────────────────────────────────
|
||||
|
||||
for (const item of activity.slice(-4)) {
|
||||
const glyph = item.tone === 'error' ? '✗' : item.tone === 'warn' ? '!' : '·'
|
||||
const color = item.tone === 'error' ? t.color.error : item.tone === 'warn' ? t.color.warn : t.color.dim
|
||||
|
||||
meta.push({ color, content: `${glyph} ${item.text}`, dimColor: item.tone === 'info', key: `a-${item.id}` })
|
||||
}
|
||||
|
||||
// ── render ──────────────────────────────────────────────────────
|
||||
// ── Derived ────────────────────────────────────────────────────
|
||||
|
||||
const hasTools = groups.length > 0
|
||||
const hasMeta = meta.length > 0
|
||||
const hasThinking = !hasTools && (busy || !!cot || reasoningActive)
|
||||
|
||||
// ── Hidden: errors/warnings only ──────────────────────────────
|
||||
|
||||
if (detailsMode === 'hidden') {
|
||||
const alerts = activity.filter(i => i.tone !== 'info').slice(-2)
|
||||
return alerts.length ? (
|
||||
<Box flexDirection="column">
|
||||
{alerts.map(i => (
|
||||
<Text color={i.tone === 'error' ? t.color.error : t.color.warn} key={`ha-${i.id}`}>
|
||||
{i.tone === 'error' ? '✗' : '!'} {i.text}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
) : null
|
||||
}
|
||||
|
||||
// ── Shared render fragments ────────────────────────────────────
|
||||
|
||||
const thinkingBlock = hasThinking ? (
|
||||
busy
|
||||
? <Thinking active={reasoningActive} mode="full" reasoning={reasoning} streaming={reasoningStreaming} t={t} />
|
||||
: cot
|
||||
? <Detail color={t.color.dim} content={cot} dimColor key="cot" />
|
||||
: <Detail color={t.color.dim} content={<StreamCursor color={t.color.dim} dimColor streaming={reasoningStreaming} visible={reasoningActive} />} dimColor key="cot" />
|
||||
) : null
|
||||
|
||||
const toolBlock = hasTools ? groups.map(g => (
|
||||
<Box flexDirection="column" key={g.key}>
|
||||
<Text color={g.color}>
|
||||
<Text color={t.color.amber}>● </Text>
|
||||
{g.content}
|
||||
</Text>
|
||||
{g.details.map(d => <Detail {...d} key={d.key} />)}
|
||||
</Box>
|
||||
)) : null
|
||||
|
||||
const metaBlock = hasMeta ? meta.map((row, i) => (
|
||||
<Text color={row.color} dimColor={row.dimColor} key={row.key}>
|
||||
<Text dimColor>{i === meta.length - 1 ? '└ ' : '├ '}</Text>
|
||||
{row.content}
|
||||
</Text>
|
||||
)) : null
|
||||
|
||||
// ── Expanded: flat, no accordions ──────────────────────────────
|
||||
|
||||
if (detailsMode === 'expanded') {
|
||||
return <Box flexDirection="column">{thinkingBlock}{toolBlock}{metaBlock}</Box>
|
||||
}
|
||||
|
||||
// ── Collapsed: clickable accordions ────────────────────────────
|
||||
|
||||
const metaTone: 'dim' | 'error' | 'warn' =
|
||||
activity.some(i => i.tone === 'error') ? 'error'
|
||||
: activity.some(i => i.tone === 'warn') ? 'warn' : 'dim'
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{busy && !groups.length && (
|
||||
<Thinking
|
||||
active={reasoningActive}
|
||||
mode={thinkingMode}
|
||||
reasoning={reasoning}
|
||||
streaming={reasoningStreaming}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
{!busy && !groups.length && reasoningTail && (
|
||||
<Detail color={t.color.dim} content={reasoningTail} dimColor key="cot" />
|
||||
{hasThinking && (
|
||||
<>
|
||||
<Chevron onClick={() => setOpenThinking(v => !v)} open={openThinking} summary={cot ? compactPreview(cot, 56) : busy ? 'running…' : ''} t={t} title="Thinking" />
|
||||
{openThinking && thinkingBlock}
|
||||
</>
|
||||
)}
|
||||
|
||||
{groups.map(g => (
|
||||
<Box flexDirection="column" key={g.key}>
|
||||
<Text color={g.color}>
|
||||
<Text color={t.color.amber}>● </Text>
|
||||
{g.content}
|
||||
</Text>
|
||||
{hasTools && (
|
||||
<>
|
||||
<Chevron count={groups.length} onClick={() => setOpenTools(v => !v)} open={openTools} t={t} title="Tool calls" />
|
||||
{openTools && toolBlock}
|
||||
</>
|
||||
)}
|
||||
|
||||
{g.details.map(d => (
|
||||
<Detail {...d} key={d.key} />
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{meta.map((row, i) => (
|
||||
<Text color={row.color} dimColor={row.dimColor} key={row.key}>
|
||||
<Text dimColor>{i === meta.length - 1 ? '└ ' : '├ '}</Text>
|
||||
{row.content}
|
||||
</Text>
|
||||
))}
|
||||
{hasMeta && (
|
||||
<>
|
||||
<Chevron count={meta.length} onClick={() => setOpenMeta(v => !v)} open={openMeta} t={t} title="Activity" tone={metaTone} />
|
||||
{openMeta && metaBlock}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user