mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-07 02:51:50 +00:00
feat: good vibes indi
This commit is contained in:
parent
baa0de7649
commit
c9f78d110a
6 changed files with 763 additions and 298 deletions
|
|
@ -29,6 +29,13 @@ import { asRpcResult, rpcErrorMessage } from './lib/rpc.js'
|
||||||
import { buildToolTrailLine, hasInterpolation, sameToolTrailGroup, toolTrailLabel } from './lib/text.js'
|
import { buildToolTrailLine, hasInterpolation, sameToolTrailGroup, toolTrailLabel } from './lib/text.js'
|
||||||
import type { Msg, PanelSection, SessionInfo, SlashCatalog } from './types.js'
|
import type { Msg, PanelSection, SessionInfo, SlashCatalog } from './types.js'
|
||||||
|
|
||||||
|
const GOOD_VIBES_RE = /\b(good bot|thanks|thank you|thx|ty|ily|love you)\b/i
|
||||||
|
const LONG_RUN_CHARM_DELAY_MS = 8_000
|
||||||
|
const LONG_RUN_CHARM_INTERVAL_MS = 10_000
|
||||||
|
const LONG_RUN_CHARM_MAX = 2
|
||||||
|
|
||||||
|
const LONG_RUN_CHARMS = ['still cooking…', 'polishing edges…', 'asking the void nicely…']
|
||||||
|
|
||||||
// ── App ──────────────────────────────────────────────────────────────
|
// ── App ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function App({ gw }: { gw: GatewayClient }) {
|
export function App({ gw }: { gw: GatewayClient }) {
|
||||||
|
|
@ -68,6 +75,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
const [voiceRecording, setVoiceRecording] = useState(false)
|
const [voiceRecording, setVoiceRecording] = useState(false)
|
||||||
const [voiceProcessing, setVoiceProcessing] = useState(false)
|
const [voiceProcessing, setVoiceProcessing] = useState(false)
|
||||||
const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now())
|
const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now())
|
||||||
|
const [goodVibesTick, setGoodVibesTick] = useState(0)
|
||||||
const [bellOnComplete, setBellOnComplete] = useState(false)
|
const [bellOnComplete, setBellOnComplete] = useState(false)
|
||||||
const ui = useStore($uiState)
|
const ui = useStore($uiState)
|
||||||
const overlay = useStore($overlayState)
|
const overlay = useStore($overlayState)
|
||||||
|
|
@ -85,6 +93,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
const configMtimeRef = useRef(0)
|
const configMtimeRef = useRef(0)
|
||||||
const historyItemsRef = useRef(historyItems)
|
const historyItemsRef = useRef(historyItems)
|
||||||
const lastUserMsgRef = useRef(lastUserMsg)
|
const lastUserMsgRef = useRef(lastUserMsg)
|
||||||
|
const longRunCharmRef = useRef(new Map<string, { count: number; lastAt: number }>())
|
||||||
const msgIdsRef = useRef(new WeakMap<Msg, string>())
|
const msgIdsRef = useRef(new WeakMap<Msg, string>())
|
||||||
const nextMsgIdRef = useRef(0)
|
const nextMsgIdRef = useRef(0)
|
||||||
colsRef.current = cols
|
colsRef.current = cols
|
||||||
|
|
@ -226,6 +235,17 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
[sys]
|
[sys]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const maybeGoodVibes = useCallback(
|
||||||
|
(text: string) => {
|
||||||
|
if (!GOOD_VIBES_RE.test(text)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setGoodVibesTick(v => v + 1)
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
const applyDisplayConfig = useCallback((cfg: ConfigFullResponse | null) => {
|
const applyDisplayConfig = useCallback((cfg: ConfigFullResponse | null) => {
|
||||||
const display = cfg?.config?.display ?? {}
|
const display = cfg?.config?.display ?? {}
|
||||||
|
|
||||||
|
|
@ -571,6 +591,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
turnRefs.statusTimerRef.current = null
|
turnRefs.statusTimerRef.current = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
maybeGoodVibes(submitText)
|
||||||
setLastUserMsg(text)
|
setLastUserMsg(text)
|
||||||
appendMessage({ role: 'user', text: displayText })
|
appendMessage({ role: 'user', text: displayText })
|
||||||
patchUiState({ busy: true, status: 'running…' })
|
patchUiState({ busy: true, status: 'running…' })
|
||||||
|
|
@ -610,7 +631,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
})
|
})
|
||||||
.catch(() => startSubmit(text, expandPasteSnips(text)))
|
.catch(() => startSubmit(text, expandPasteSnips(text)))
|
||||||
},
|
},
|
||||||
[appendMessage, composerState.pasteSnips, gw, turnActions, sys, turnRefs]
|
[appendMessage, composerState.pasteSnips, gw, maybeGoodVibes, turnActions, sys, turnRefs]
|
||||||
)
|
)
|
||||||
|
|
||||||
const shellExec = useCallback(
|
const shellExec = useCallback(
|
||||||
|
|
@ -909,6 +930,50 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
}
|
}
|
||||||
}, [gw, turnActions, sys])
|
}, [gw, turnActions, sys])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ui.busy || !turnState.tools.length) {
|
||||||
|
longRunCharmRef.current.clear()
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
const now = Date.now()
|
||||||
|
const liveIds = new Set(turnState.tools.map(tool => tool.id))
|
||||||
|
|
||||||
|
for (const key of [...longRunCharmRef.current.keys()]) {
|
||||||
|
if (!liveIds.has(key)) {
|
||||||
|
longRunCharmRef.current.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const tool of turnState.tools) {
|
||||||
|
if (!tool.startedAt || now - tool.startedAt < LONG_RUN_CHARM_DELAY_MS) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const slot = longRunCharmRef.current.get(tool.id) ?? { count: 0, lastAt: 0 }
|
||||||
|
|
||||||
|
if (slot.count >= LONG_RUN_CHARM_MAX || now - slot.lastAt < LONG_RUN_CHARM_INTERVAL_MS) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
slot.count += 1
|
||||||
|
slot.lastAt = now
|
||||||
|
longRunCharmRef.current.set(tool.id, slot)
|
||||||
|
|
||||||
|
const charm = LONG_RUN_CHARMS[Math.floor(Math.random() * LONG_RUN_CHARMS.length)]!
|
||||||
|
const sec = Math.round((now - tool.startedAt) / 1000)
|
||||||
|
turnActions.pushActivity(`${charm} (${toolTrailLabel(tool.name)} · ${sec}s)`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tick()
|
||||||
|
const id = setInterval(tick, 1000)
|
||||||
|
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [turnActions, turnState.tools, ui.busy])
|
||||||
|
|
||||||
// ── Slash commands ───────────────────────────────────────────────
|
// ── Slash commands ───────────────────────────────────────────────
|
||||||
|
|
||||||
const slash = useMemo(
|
const slash = useMemo(
|
||||||
|
|
@ -1198,13 +1263,14 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
const appStatus = useMemo(
|
const appStatus = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
cwdLabel,
|
cwdLabel,
|
||||||
|
goodVibesTick,
|
||||||
sessionStartedAt: sessionStarted,
|
sessionStartedAt: sessionStarted,
|
||||||
showStickyPrompt,
|
showStickyPrompt,
|
||||||
statusColor,
|
statusColor,
|
||||||
stickyPrompt,
|
stickyPrompt,
|
||||||
voiceLabel
|
voiceLabel
|
||||||
}),
|
}),
|
||||||
[cwdLabel, sessionStarted, showStickyPrompt, statusColor, stickyPrompt, voiceLabel]
|
[cwdLabel, goodVibesTick, sessionStarted, showStickyPrompt, statusColor, stickyPrompt, voiceLabel]
|
||||||
)
|
)
|
||||||
|
|
||||||
const appTranscript = useMemo(
|
const appTranscript = useMemo(
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,56 @@ import type { SlashHandlerContext } from './interfaces.js'
|
||||||
import { patchOverlayState } from './overlayStore.js'
|
import { patchOverlayState } from './overlayStore.js'
|
||||||
import { getUiState, patchUiState } from './uiStore.js'
|
import { getUiState, patchUiState } from './uiStore.js'
|
||||||
|
|
||||||
|
const FORTUNES = [
|
||||||
|
'you are one clean refactor away from clarity',
|
||||||
|
'a tiny rename today prevents a huge bug tomorrow',
|
||||||
|
'your next commit message will be immaculate',
|
||||||
|
'the edge case you are ignoring is already solved in your head',
|
||||||
|
'minimal diff, maximal calm',
|
||||||
|
'today favors bold deletions over new abstractions',
|
||||||
|
'the right helper is already in your codebase',
|
||||||
|
'you will ship before overthinking catches up',
|
||||||
|
'tests are about to save your future self',
|
||||||
|
'your instincts are correctly suspicious of that one branch'
|
||||||
|
]
|
||||||
|
|
||||||
|
const LEGENDARY_FORTUNES = [
|
||||||
|
'legendary drop: one-line fix, first try',
|
||||||
|
'legendary drop: every flaky test passes cleanly',
|
||||||
|
'legendary drop: your diff teaches by itself'
|
||||||
|
]
|
||||||
|
|
||||||
|
const hash = (input: string) => {
|
||||||
|
let out = 2166136261
|
||||||
|
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
out ^= input.charCodeAt(i)
|
||||||
|
out = Math.imul(out, 16777619)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out >>> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const fortuneFromScore = (score: number) => {
|
||||||
|
const rare = score % 20 === 0
|
||||||
|
const bag = rare ? LEGENDARY_FORTUNES : FORTUNES
|
||||||
|
|
||||||
|
return `${rare ? '🌟' : '🔮'} ${bag[score % bag.length]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const randomFortune = () => {
|
||||||
|
const score = Math.floor(Math.random() * 0x7fffffff)
|
||||||
|
|
||||||
|
return fortuneFromScore(score)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dailyFortune = (sid: string | null) => {
|
||||||
|
const seed = `${sid || 'anon'}|${new Date().toDateString()}`
|
||||||
|
const score = hash(seed)
|
||||||
|
|
||||||
|
return fortuneFromScore(score)
|
||||||
|
}
|
||||||
|
|
||||||
export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => boolean {
|
export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => boolean {
|
||||||
const { enqueue, hasSelection, paste, queueRef, selection, setInput } = ctx.composer
|
const { enqueue, hasSelection, paste, queueRef, selection, setInput } = ctx.composer
|
||||||
const { gw, rpc } = ctx.gateway
|
const { gw, rpc } = ctx.gateway
|
||||||
|
|
@ -71,7 +121,10 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
|
||||||
|
|
||||||
sections.push({
|
sections.push({
|
||||||
title: 'TUI',
|
title: 'TUI',
|
||||||
rows: [['/details [hidden|collapsed|expanded|cycle]', 'set agent detail visibility mode']]
|
rows: [
|
||||||
|
['/details [hidden|collapsed|expanded|cycle]', 'set agent detail visibility mode'],
|
||||||
|
['/fortune [random|daily]', 'show a random or daily local fortune']
|
||||||
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
sections.push({ title: 'Hotkeys', rows: HOTKEYS })
|
sections.push({ title: 'Hotkeys', rows: HOTKEYS })
|
||||||
|
|
@ -171,6 +224,23 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
|
||||||
sys(`details: ${next}`)
|
sys(`details: ${next}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
|
||||||
|
case 'fortune':
|
||||||
|
if (!arg || arg.trim().toLowerCase() === 'random') {
|
||||||
|
sys(randomFortune())
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['daily', 'today', 'stable'].includes(arg.trim().toLowerCase())) {
|
||||||
|
sys(dailyFortune(sid))
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
sys('usage: /fortune [random|daily]')
|
||||||
|
|
||||||
return true
|
return true
|
||||||
case 'copy': {
|
case 'copy': {
|
||||||
if (!arg && hasSelection) {
|
if (!arg && hasSelection) {
|
||||||
|
|
|
||||||
|
|
@ -399,6 +399,7 @@ export interface AppLayoutProgressProps {
|
||||||
|
|
||||||
export interface AppLayoutStatusProps {
|
export interface AppLayoutStatusProps {
|
||||||
cwdLabel: string
|
cwdLabel: string
|
||||||
|
goodVibesTick: number
|
||||||
sessionStartedAt: number | null
|
sessionStartedAt: number | null
|
||||||
showStickyPrompt: boolean
|
showStickyPrompt: boolean
|
||||||
statusColor: string
|
statusColor: string
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,29 @@ function SessionDuration({ startedAt }: { startedAt: number }) {
|
||||||
return fmtDuration(now - startedAt)
|
return fmtDuration(now - startedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GoodVibesHeart({ tick, t }: { tick: number; t: Theme }) {
|
||||||
|
const [active, setActive] = useState(false)
|
||||||
|
const [color, setColor] = useState(t.color.amber)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tick <= 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = ['#ff5fa2', '#ff4d6d', t.color.amber]
|
||||||
|
const picked = options[Math.floor(Math.random() * options.length)]!
|
||||||
|
|
||||||
|
setColor(picked)
|
||||||
|
setActive(true)
|
||||||
|
|
||||||
|
const id = setTimeout(() => setActive(false), 650)
|
||||||
|
|
||||||
|
return () => clearTimeout(id)
|
||||||
|
}, [t.color.amber, tick])
|
||||||
|
|
||||||
|
return <Text color={color as any}>{active ? '♥' : ' '}</Text>
|
||||||
|
}
|
||||||
|
|
||||||
export function StatusRule({
|
export function StatusRule({
|
||||||
cwdLabel,
|
cwdLabel,
|
||||||
cols,
|
cols,
|
||||||
|
|
@ -85,29 +108,29 @@ export function StatusRule({
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Box flexShrink={1} width={leftWidth}>
|
<Box flexShrink={1} width={leftWidth}>
|
||||||
<Text color={t.color.bronze} wrap="truncate-end">
|
<Text color={t.color.bronze as any} wrap="truncate-end">
|
||||||
{'─ '}
|
{'─ '}
|
||||||
<Text color={statusColor}>{status}</Text>
|
<Text color={statusColor as any}>{status}</Text>
|
||||||
<Text color={t.color.dim}> │ {model}</Text>
|
<Text color={t.color.dim as any}> │ {model}</Text>
|
||||||
{ctxLabel ? <Text color={t.color.dim}> │ {ctxLabel}</Text> : null}
|
{ctxLabel ? <Text color={t.color.dim as any}> │ {ctxLabel}</Text> : null}
|
||||||
{bar ? (
|
{bar ? (
|
||||||
<Text color={t.color.dim}>
|
<Text color={t.color.dim as any}>
|
||||||
{' │ '}
|
{' │ '}
|
||||||
<Text color={barColor}>[{bar}]</Text> <Text color={barColor}>{pctLabel}</Text>
|
<Text color={barColor as any}>[{bar}]</Text> <Text color={barColor as any}>{pctLabel}</Text>
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
{sessionStartedAt ? (
|
{sessionStartedAt ? (
|
||||||
<Text color={t.color.dim}>
|
<Text color={t.color.dim as any}>
|
||||||
{' │ '}
|
{' │ '}
|
||||||
<SessionDuration startedAt={sessionStartedAt} />
|
<SessionDuration startedAt={sessionStartedAt} />
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
{voiceLabel ? <Text color={t.color.dim}> │ {voiceLabel}</Text> : null}
|
{voiceLabel ? <Text color={t.color.dim as any}> │ {voiceLabel}</Text> : null}
|
||||||
{bgCount > 0 ? <Text color={t.color.dim}> │ {bgCount} bg</Text> : null}
|
{bgCount > 0 ? <Text color={t.color.dim as any}> │ {bgCount} bg</Text> : null}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Text color={t.color.bronze}> ─ </Text>
|
<Text color={t.color.bronze as any}> ─ </Text>
|
||||||
<Text color={t.color.label}>{cwdLabel}</Text>
|
<Text color={t.color.label as any}>{cwdLabel}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -116,7 +139,7 @@ export function FloatBox({ children, color }: { children: ReactNode; color: stri
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
alignSelf="flex-start"
|
alignSelf="flex-start"
|
||||||
borderColor={color}
|
borderColor={color as any}
|
||||||
borderStyle="double"
|
borderStyle="double"
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
marginTop={1}
|
marginTop={1}
|
||||||
|
|
@ -224,21 +247,21 @@ export function TranscriptScrollbar({ scrollRef, t }: { scrollRef: RefObject<Scr
|
||||||
width={1}
|
width={1}
|
||||||
>
|
>
|
||||||
{!scrollable ? (
|
{!scrollable ? (
|
||||||
<Text color={trackColor} dimColor>
|
<Text color={trackColor as any} dim>
|
||||||
{' \n'.repeat(Math.max(0, vp - 1))}{' '}
|
{' \n'.repeat(Math.max(0, vp - 1))}{' '}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{thumbTop > 0 ? (
|
{thumbTop > 0 ? (
|
||||||
<Text color={trackColor} dimColor={!hover}>
|
<Text color={trackColor as any} dim={!hover}>
|
||||||
{`${'│\n'.repeat(Math.max(0, thumbTop - 1))}${thumbTop > 0 ? '│' : ''}`}
|
{`${'│\n'.repeat(Math.max(0, thumbTop - 1))}${thumbTop > 0 ? '│' : ''}`}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
{thumb > 0 ? (
|
{thumb > 0 ? (
|
||||||
<Text color={thumbColor}>{`${'┃\n'.repeat(Math.max(0, thumb - 1))}${thumb > 0 ? '┃' : ''}`}</Text>
|
<Text color={thumbColor as any}>{`${'┃\n'.repeat(Math.max(0, thumb - 1))}${thumb > 0 ? '┃' : ''}`}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
{vp - thumbTop - thumb > 0 ? (
|
{vp - thumbTop - thumb > 0 ? (
|
||||||
<Text color={trackColor} dimColor={!hover}>
|
<Text color={trackColor as any} dim={!hover}>
|
||||||
{`${'│\n'.repeat(Math.max(0, vp - thumbTop - thumb - 1))}${vp - thumbTop - thumb > 0 ? '│' : ''}`}
|
{`${'│\n'.repeat(Math.max(0, vp - thumbTop - thumb - 1))}${vp - thumbTop - thumb > 0 ? '│' : ''}`}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import type { AppLayoutProps } from '../app/interfaces.js'
|
||||||
import { $isBlocked } from '../app/overlayStore.js'
|
import { $isBlocked } from '../app/overlayStore.js'
|
||||||
import { $uiState } from '../app/uiStore.js'
|
import { $uiState } from '../app/uiStore.js'
|
||||||
|
|
||||||
import { StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js'
|
import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js'
|
||||||
import { AppOverlays } from './appOverlays.js'
|
import { AppOverlays } from './appOverlays.js'
|
||||||
import { Banner, Panel, SessionPanel } from './branding.js'
|
import { Banner, Panel, SessionPanel } from './branding.js'
|
||||||
import { MessageLine } from './messageLine.js'
|
import { MessageLine } from './messageLine.js'
|
||||||
|
|
@ -106,7 +106,6 @@ const ComposerPane = memo(function ComposerPane({
|
||||||
}: Pick<AppLayoutProps, 'actions' | 'composer' | 'status'>) {
|
}: Pick<AppLayoutProps, 'actions' | 'composer' | 'status'>) {
|
||||||
const ui = useStore($uiState)
|
const ui = useStore($uiState)
|
||||||
const isBlocked = useStore($isBlocked)
|
const isBlocked = useStore($isBlocked)
|
||||||
|
|
||||||
const sh = (composer.inputBuf[0] ?? composer.input).startsWith('!')
|
const sh = (composer.inputBuf[0] ?? composer.input).startsWith('!')
|
||||||
const pw = sh ? 2 : 3
|
const pw = sh ? 2 : 3
|
||||||
|
|
||||||
|
|
@ -177,7 +176,7 @@ const ComposerPane = memo(function ComposerPane({
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<Box>
|
<Box position="relative">
|
||||||
<Box width={pw}>
|
<Box width={pw}>
|
||||||
{sh ? (
|
{sh ? (
|
||||||
<Text color={ui.theme.color.shellDollar as any}>$ </Text>
|
<Text color={ui.theme.color.shellDollar as any}>$ </Text>
|
||||||
|
|
@ -188,14 +187,19 @@ const ComposerPane = memo(function ComposerPane({
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<TextInput
|
<Box flexGrow={1} position="relative">
|
||||||
columns={Math.max(20, composer.cols - pw)}
|
<TextInput
|
||||||
onChange={composer.updateInput}
|
columns={Math.max(20, composer.cols - pw)}
|
||||||
onPaste={composer.handleTextPaste}
|
onChange={composer.updateInput}
|
||||||
onSubmit={composer.submit}
|
onPaste={composer.handleTextPaste}
|
||||||
placeholder={composer.empty ? PLACEHOLDER : ui.busy ? 'Ctrl+C to interrupt…' : ''}
|
onSubmit={composer.submit}
|
||||||
value={composer.input}
|
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>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Box, Text } from '@hermes/ink'
|
import { Box, NoSelect, Text } from '@hermes/ink'
|
||||||
import { memo, type ReactNode, useEffect, useMemo, useState } from 'react'
|
import { memo, type ReactNode, useEffect, useMemo, useState } from 'react'
|
||||||
import spinners, { type BrailleSpinnerName } from 'unicode-animations'
|
import spinners, { type BrailleSpinnerName } from 'unicode-animations'
|
||||||
|
|
||||||
|
|
@ -25,24 +25,132 @@ const fmtElapsed = (ms: number) => {
|
||||||
return sec < 10 ? `${sec.toFixed(1)}s` : `${Math.round(sec)}s`
|
return sec < 10 ? `${sec.toFixed(1)}s` : `${Math.round(sec)}s`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TreeBranch = 'mid' | 'last'
|
||||||
|
type TreeRails = readonly boolean[]
|
||||||
|
|
||||||
|
const nextTreeRails = (rails: TreeRails, branch: TreeBranch) => [...rails, branch === 'mid']
|
||||||
|
|
||||||
|
const treeLead = (rails: TreeRails, branch: TreeBranch) =>
|
||||||
|
`${rails.map(on => (on ? '│ ' : ' ')).join('')}${branch === 'mid' ? '├─ ' : '└─ '}`
|
||||||
|
|
||||||
// ── Primitives ───────────────────────────────────────────────────────
|
// ── Primitives ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function Spinner({ color, variant = 'think' }: { color: string; variant?: 'think' | 'tool' }) {
|
function TreeRow({
|
||||||
const [spin] = useState(() => {
|
branch,
|
||||||
|
children,
|
||||||
|
rails = [],
|
||||||
|
stemColor,
|
||||||
|
stemDim = true,
|
||||||
|
t
|
||||||
|
}: {
|
||||||
|
branch: TreeBranch
|
||||||
|
children: ReactNode
|
||||||
|
rails?: TreeRails
|
||||||
|
stemColor?: string
|
||||||
|
stemDim?: boolean
|
||||||
|
t: Theme
|
||||||
|
}) {
|
||||||
|
const lead = treeLead(rails, branch)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<NoSelect flexShrink={0} fromLeftEdge width={lead.length}>
|
||||||
|
<Text color={(stemColor ?? t.color.dim) as any} dim={stemDim}>
|
||||||
|
{lead}
|
||||||
|
</Text>
|
||||||
|
</NoSelect>
|
||||||
|
<Box flexDirection="column" flexGrow={1}>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TreeTextRow({
|
||||||
|
branch,
|
||||||
|
color,
|
||||||
|
content,
|
||||||
|
dimColor,
|
||||||
|
rails = [],
|
||||||
|
t,
|
||||||
|
wrap = 'wrap-trim'
|
||||||
|
}: {
|
||||||
|
branch: TreeBranch
|
||||||
|
color: string
|
||||||
|
content: ReactNode
|
||||||
|
dimColor?: boolean
|
||||||
|
rails?: TreeRails
|
||||||
|
t: Theme
|
||||||
|
wrap?: 'truncate-end' | 'wrap' | 'wrap-trim'
|
||||||
|
}) {
|
||||||
|
const text = dimColor ? (
|
||||||
|
<Text color={color as any} dim wrap={wrap}>
|
||||||
|
{content}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text color={color as any} wrap={wrap}>
|
||||||
|
{content}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TreeRow branch={branch} rails={rails} t={t}>
|
||||||
|
{text}
|
||||||
|
</TreeRow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TreeNode({
|
||||||
|
branch,
|
||||||
|
children,
|
||||||
|
header,
|
||||||
|
open,
|
||||||
|
rails = [],
|
||||||
|
t
|
||||||
|
}: {
|
||||||
|
branch: TreeBranch
|
||||||
|
children?: (rails: boolean[]) => ReactNode
|
||||||
|
header: ReactNode
|
||||||
|
open: boolean
|
||||||
|
rails?: TreeRails
|
||||||
|
t: Theme
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<TreeRow branch={branch} rails={rails} t={t}>
|
||||||
|
{header}
|
||||||
|
</TreeRow>
|
||||||
|
{open ? children?.(nextTreeRails(rails, branch)) : null}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Spinner({
|
||||||
|
color,
|
||||||
|
variant = 'think'
|
||||||
|
}: {
|
||||||
|
color: string
|
||||||
|
variant?: 'think' | 'tool'
|
||||||
|
}) {
|
||||||
|
const spin = useMemo(() => {
|
||||||
const raw = spinners[pick(variant === 'tool' ? TOOL : THINK)]
|
const raw = spinners[pick(variant === 'tool' ? TOOL : THINK)]
|
||||||
|
|
||||||
return { ...raw, frames: raw.frames.map(f => [...f][0] ?? '⠀') }
|
return { ...raw, frames: raw.frames.map(f => [...f][0] ?? '⠀') }
|
||||||
})
|
}, [variant])
|
||||||
|
|
||||||
const [frame, setFrame] = useState(0)
|
const [frame, setFrame] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFrame(0)
|
||||||
|
}, [spin])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = setInterval(() => setFrame(f => (f + 1) % spin.frames.length), spin.interval)
|
const id = setInterval(() => setFrame(f => (f + 1) % spin.frames.length), spin.interval)
|
||||||
|
|
||||||
return () => clearInterval(id)
|
return () => clearInterval(id)
|
||||||
}, [spin])
|
}, [spin])
|
||||||
|
|
||||||
return <Text color={color}>{spin.frames[frame]}</Text>
|
return <Text color={color as any}>{spin.frames[frame]}</Text>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DetailRow {
|
interface DetailRow {
|
||||||
|
|
@ -52,13 +160,15 @@ interface DetailRow {
|
||||||
key: string
|
key: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function Detail({ color, content, dimColor }: DetailRow) {
|
function Detail({
|
||||||
return (
|
branch = 'last',
|
||||||
<Text color={color} dimColor={dimColor} wrap="wrap-trim">
|
color,
|
||||||
<Text dimColor>└ </Text>
|
content,
|
||||||
{content}
|
dimColor,
|
||||||
</Text>
|
rails = [],
|
||||||
)
|
t
|
||||||
|
}: DetailRow & { branch?: TreeBranch; rails?: TreeRails; t: Theme }) {
|
||||||
|
return <TreeTextRow branch={branch} color={color} content={content} dimColor={dimColor} rails={rails} t={t} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function StreamCursor({
|
function StreamCursor({
|
||||||
|
|
@ -86,11 +196,17 @@ function StreamCursor({
|
||||||
return () => clearInterval(id)
|
return () => clearInterval(id)
|
||||||
}, [streaming, visible])
|
}, [streaming, visible])
|
||||||
|
|
||||||
return visible ? (
|
if (!visible) {
|
||||||
<Text color={color} dimColor={dimColor}>
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return dimColor ? (
|
||||||
|
<Text color={color as any} dim>
|
||||||
{streaming && on ? '▍' : ' '}
|
{streaming && on ? '▍' : ' '}
|
||||||
</Text>
|
</Text>
|
||||||
) : null
|
) : (
|
||||||
|
<Text color={color as any}>{streaming && on ? '▍' : ' '}</Text>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Chevron({
|
function Chevron({
|
||||||
|
|
@ -113,13 +229,13 @@ function Chevron({
|
||||||
const color = tone === 'error' ? t.color.error : tone === 'warn' ? t.color.warn : t.color.dim
|
const color = tone === 'error' ? t.color.error : tone === 'warn' ? t.color.warn : t.color.dim
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box onClick={(e: { ctrlKey?: boolean; shiftKey?: boolean }) => onClick(!!e?.shiftKey || !!e?.ctrlKey)}>
|
<Box onClick={(e: any) => onClick(!!e?.shiftKey || !!e?.ctrlKey)}>
|
||||||
<Text color={color} dimColor={tone === 'dim'}>
|
<Text color={color as any} dim={tone === 'dim'}>
|
||||||
<Text color={t.color.amber}>{open ? '▾ ' : '▸ '}</Text>
|
<Text color={t.color.amber as any}>{open ? '▾ ' : '▸ '}</Text>
|
||||||
{title}
|
{title}
|
||||||
{typeof count === 'number' ? ` (${count})` : ''}
|
{typeof count === 'number' ? ` (${count})` : ''}
|
||||||
{suffix ? (
|
{suffix ? (
|
||||||
<Text color={t.color.statusFg} dimColor>
|
<Text color={t.color.statusFg as any} dim>
|
||||||
{' '}
|
{' '}
|
||||||
{suffix}
|
{suffix}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
@ -129,7 +245,19 @@ function Chevron({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SubagentAccordion({ expanded, item, t }: { expanded: boolean; item: SubagentProgress; t: Theme }) {
|
function SubagentAccordion({
|
||||||
|
branch,
|
||||||
|
expanded,
|
||||||
|
item,
|
||||||
|
rails = [],
|
||||||
|
t
|
||||||
|
}: {
|
||||||
|
branch: TreeBranch
|
||||||
|
expanded: boolean
|
||||||
|
item: SubagentProgress
|
||||||
|
rails?: TreeRails
|
||||||
|
t: Theme
|
||||||
|
}) {
|
||||||
const [open, setOpen] = useState(expanded)
|
const [open, setOpen] = useState(expanded)
|
||||||
const [deep, setDeep] = useState(expanded)
|
const [deep, setDeep] = useState(expanded)
|
||||||
const [openThinking, setOpenThinking] = useState(expanded)
|
const [openThinking, setOpenThinking] = useState(expanded)
|
||||||
|
|
@ -175,95 +303,175 @@ function SubagentAccordion({ expanded, item, t }: { expanded: boolean; item: Sub
|
||||||
const noteRows = [...(summary ? [summary] : []), ...item.notes]
|
const noteRows = [...(summary ? [summary] : []), ...item.notes]
|
||||||
const hasNotes = noteRows.length > 0
|
const hasNotes = noteRows.length > 0
|
||||||
const showChildren = expanded || deep
|
const showChildren = expanded || deep
|
||||||
|
const noteColor = statusTone === 'error' ? t.color.error : statusTone === 'warn' ? t.color.warn : t.color.dim
|
||||||
|
|
||||||
|
const sections: {
|
||||||
|
header: ReactNode
|
||||||
|
key: string
|
||||||
|
open: boolean
|
||||||
|
render: (rails: boolean[]) => ReactNode
|
||||||
|
}[] = []
|
||||||
|
|
||||||
|
if (hasThinking) {
|
||||||
|
sections.push({
|
||||||
|
header: (
|
||||||
|
<Chevron
|
||||||
|
count={item.thinking.length}
|
||||||
|
onClick={shift => {
|
||||||
|
if (shift) {
|
||||||
|
expandAll()
|
||||||
|
} else {
|
||||||
|
setOpenThinking(v => !v)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
open={showChildren || openThinking}
|
||||||
|
t={t}
|
||||||
|
title="Thinking"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
key: 'thinking',
|
||||||
|
open: showChildren || openThinking,
|
||||||
|
render: childRails => (
|
||||||
|
<Thinking
|
||||||
|
active={item.status === 'running'}
|
||||||
|
branch="last"
|
||||||
|
mode="full"
|
||||||
|
rails={childRails}
|
||||||
|
reasoning={thinkingText}
|
||||||
|
streaming={item.status === 'running'}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasTools) {
|
||||||
|
sections.push({
|
||||||
|
header: (
|
||||||
|
<Chevron
|
||||||
|
count={item.tools.length}
|
||||||
|
onClick={shift => {
|
||||||
|
if (shift) {
|
||||||
|
expandAll()
|
||||||
|
} else {
|
||||||
|
setOpenTools(v => !v)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
open={showChildren || openTools}
|
||||||
|
t={t}
|
||||||
|
title="Tool calls"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
key: 'tools',
|
||||||
|
open: showChildren || openTools,
|
||||||
|
render: childRails => (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{item.tools.map((line, index) => (
|
||||||
|
<TreeTextRow
|
||||||
|
branch={index === item.tools.length - 1 ? 'last' : 'mid'}
|
||||||
|
color={t.color.cornsilk}
|
||||||
|
content={
|
||||||
|
<>
|
||||||
|
<Text color={t.color.amber as any}>● </Text>
|
||||||
|
{line}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
key={`${item.id}-tool-${index}`}
|
||||||
|
rails={childRails}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasNotes) {
|
||||||
|
sections.push({
|
||||||
|
header: (
|
||||||
|
<Chevron
|
||||||
|
count={noteRows.length}
|
||||||
|
onClick={shift => {
|
||||||
|
if (shift) {
|
||||||
|
expandAll()
|
||||||
|
} else {
|
||||||
|
setOpenNotes(v => !v)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
open={showChildren || openNotes}
|
||||||
|
t={t}
|
||||||
|
title="Progress"
|
||||||
|
tone={statusTone}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
key: 'notes',
|
||||||
|
open: showChildren || openNotes,
|
||||||
|
render: childRails => (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{noteRows.map((line, index) => (
|
||||||
|
<TreeTextRow
|
||||||
|
branch={index === noteRows.length - 1 ? 'last' : 'mid'}
|
||||||
|
color={noteColor}
|
||||||
|
content={line}
|
||||||
|
dimColor={statusTone === 'dim'}
|
||||||
|
key={`${item.id}-note-${index}`}
|
||||||
|
rails={childRails}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" paddingLeft={1}>
|
<TreeNode
|
||||||
<Chevron
|
branch={branch}
|
||||||
onClick={shift => shift ? expandAll() : setOpen(v => { if (!v) setDeep(false); return !v })}
|
header={
|
||||||
open={open}
|
<Chevron
|
||||||
suffix={suffix}
|
onClick={shift => {
|
||||||
t={t}
|
if (shift) {
|
||||||
title={title}
|
expandAll()
|
||||||
tone={statusTone}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{open && (
|
return
|
||||||
<Box flexDirection="column" paddingLeft={2}>
|
}
|
||||||
{hasThinking && (
|
|
||||||
<>
|
|
||||||
<Chevron
|
|
||||||
count={item.thinking.length}
|
|
||||||
onClick={shift => { if (shift) expandAll(); else setOpenThinking(v => !v) }}
|
|
||||||
open={showChildren || openThinking}
|
|
||||||
t={t}
|
|
||||||
title="Thinking"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{(showChildren || openThinking) && (
|
setOpen(v => {
|
||||||
<Thinking
|
if (!v) {
|
||||||
active={item.status === 'running'}
|
setDeep(false)
|
||||||
mode="full"
|
}
|
||||||
reasoning={thinkingText}
|
|
||||||
streaming={item.status === 'running'}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasTools && (
|
return !v
|
||||||
<>
|
})
|
||||||
<Chevron
|
}}
|
||||||
count={item.tools.length}
|
open={open}
|
||||||
onClick={shift => { if (shift) expandAll(); else setOpenTools(v => !v) }}
|
suffix={suffix}
|
||||||
open={showChildren || openTools}
|
t={t}
|
||||||
t={t}
|
title={title}
|
||||||
title="Tool calls"
|
tone={statusTone}
|
||||||
/>
|
/>
|
||||||
|
}
|
||||||
{(showChildren || openTools) && (
|
open={open}
|
||||||
<Box flexDirection="column">
|
rails={rails}
|
||||||
{item.tools.map((line, index) => (
|
t={t}
|
||||||
<Text color={t.color.cornsilk} key={`${item.id}-tool-${index}`} wrap="wrap-trim">
|
>
|
||||||
<Text color={t.color.amber}>● </Text>
|
{childRails => (
|
||||||
{line}
|
<Box flexDirection="column">
|
||||||
</Text>
|
{sections.map((section, index) => (
|
||||||
))}
|
<TreeNode
|
||||||
</Box>
|
branch={index === sections.length - 1 ? 'last' : 'mid'}
|
||||||
)}
|
header={section.header}
|
||||||
</>
|
key={`${item.id}-${section.key}`}
|
||||||
)}
|
open={section.open}
|
||||||
|
rails={childRails}
|
||||||
{hasNotes && (
|
t={t}
|
||||||
<>
|
>
|
||||||
<Chevron
|
{section.render}
|
||||||
count={noteRows.length}
|
</TreeNode>
|
||||||
onClick={shift => { if (shift) expandAll(); else setOpenNotes(v => !v) }}
|
))}
|
||||||
open={showChildren || openNotes}
|
|
||||||
t={t}
|
|
||||||
title="Progress"
|
|
||||||
tone={statusTone}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{(showChildren || 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>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</TreeNode>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -271,13 +479,17 @@ function SubagentAccordion({ expanded, item, t }: { expanded: boolean; item: Sub
|
||||||
|
|
||||||
export const Thinking = memo(function Thinking({
|
export const Thinking = memo(function Thinking({
|
||||||
active = false,
|
active = false,
|
||||||
|
branch = 'last',
|
||||||
mode = 'truncated',
|
mode = 'truncated',
|
||||||
|
rails = [],
|
||||||
reasoning,
|
reasoning,
|
||||||
streaming = false,
|
streaming = false,
|
||||||
t
|
t
|
||||||
}: {
|
}: {
|
||||||
active?: boolean
|
active?: boolean
|
||||||
|
branch?: TreeBranch
|
||||||
mode?: ThinkingMode
|
mode?: ThinkingMode
|
||||||
|
rails?: TreeRails
|
||||||
reasoning: string
|
reasoning: string
|
||||||
streaming?: boolean
|
streaming?: boolean
|
||||||
t: Theme
|
t: Theme
|
||||||
|
|
@ -285,39 +497,36 @@ export const Thinking = memo(function Thinking({
|
||||||
const preview = useMemo(() => thinkingPreview(reasoning, mode, THINKING_COT_MAX), [mode, reasoning])
|
const preview = useMemo(() => thinkingPreview(reasoning, mode, THINKING_COT_MAX), [mode, reasoning])
|
||||||
const lines = useMemo(() => preview.split('\n').map(line => line.replace(/\t/g, ' ')), [preview])
|
const lines = useMemo(() => preview.split('\n').map(line => line.replace(/\t/g, ' ')), [preview])
|
||||||
|
|
||||||
|
if (!preview && !active) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column">
|
<TreeRow branch={branch} rails={rails} t={t}>
|
||||||
{preview ? (
|
<Box flexDirection="column" flexGrow={1}>
|
||||||
mode === 'full' ? (
|
{preview ? (
|
||||||
<Box flexDirection="row">
|
mode === 'full' ? (
|
||||||
<Text color={t.color.dim} dimColor>
|
lines.map((line, index) => (
|
||||||
└{' '}
|
<Text color={t.color.dim as any} dim key={index} wrap="wrap-trim">
|
||||||
|
{line || ' '}
|
||||||
|
{index === lines.length - 1 ? (
|
||||||
|
<StreamCursor color={t.color.dim} dimColor streaming={streaming} visible={active} />
|
||||||
|
) : null}
|
||||||
|
</Text>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Text color={t.color.dim as any} dim wrap="truncate-end">
|
||||||
|
{preview}
|
||||||
|
<StreamCursor color={t.color.dim} dimColor streaming={streaming} visible={active} />
|
||||||
</Text>
|
</Text>
|
||||||
<Box flexDirection="column" flexGrow={1}>
|
)
|
||||||
{lines.map((line, index) => (
|
|
||||||
<Text color={t.color.dim} dimColor key={index} wrap="wrap-trim">
|
|
||||||
{line || ' '}
|
|
||||||
{index === lines.length - 1 ? (
|
|
||||||
<StreamCursor color={t.color.dim} dimColor streaming={streaming} visible={active} />
|
|
||||||
) : null}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
) : (
|
) : (
|
||||||
<Text color={t.color.dim} dimColor wrap="truncate-end">
|
<Text color={t.color.dim as any} dim>
|
||||||
<Text dimColor>└ </Text>
|
|
||||||
{preview}
|
|
||||||
<StreamCursor color={t.color.dim} dimColor streaming={streaming} visible={active} />
|
<StreamCursor color={t.color.dim} dimColor streaming={streaming} visible={active} />
|
||||||
</Text>
|
</Text>
|
||||||
)
|
)}
|
||||||
) : active ? (
|
</Box>
|
||||||
<Text color={t.color.dim} dimColor>
|
</TreeRow>
|
||||||
<Text dimColor>└ </Text>
|
|
||||||
<StreamCursor color={t.color.dim} dimColor streaming={streaming} visible={active} />
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</Box>
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -328,6 +537,7 @@ interface Group {
|
||||||
content: ReactNode
|
content: ReactNode
|
||||||
details: DetailRow[]
|
details: DetailRow[]
|
||||||
key: string
|
key: string
|
||||||
|
label: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ToolTrail = memo(function ToolTrail({
|
export const ToolTrail = memo(function ToolTrail({
|
||||||
|
|
@ -410,7 +620,8 @@ export const ToolTrail = memo(function ToolTrail({
|
||||||
color: parsed.mark === '✗' ? t.color.error : t.color.cornsilk,
|
color: parsed.mark === '✗' ? t.color.error : t.color.cornsilk,
|
||||||
content: parsed.detail ? parsed.call : `${parsed.call} ${parsed.mark}`,
|
content: parsed.detail ? parsed.call : `${parsed.call} ${parsed.mark}`,
|
||||||
details: [],
|
details: [],
|
||||||
key: `tr-${i}`
|
key: `tr-${i}`,
|
||||||
|
label: parsed.call
|
||||||
})
|
})
|
||||||
|
|
||||||
if (parsed.detail) {
|
if (parsed.detail) {
|
||||||
|
|
@ -426,11 +637,14 @@ export const ToolTrail = memo(function ToolTrail({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (line.startsWith('drafting ')) {
|
if (line.startsWith('drafting ')) {
|
||||||
|
const label = toolTrailLabel(line.slice(9).replace(/…$/, '').trim())
|
||||||
|
|
||||||
groups.push({
|
groups.push({
|
||||||
color: t.color.cornsilk,
|
color: t.color.cornsilk,
|
||||||
content: toolTrailLabel(line.slice(9).replace(/…$/, '').trim()),
|
content: label,
|
||||||
details: [{ color: t.color.dim, content: 'drafting...', dimColor: true, key: `tr-${i}-d` }],
|
details: [{ color: t.color.dim, content: 'drafting...', dimColor: true, key: `tr-${i}-d` }],
|
||||||
key: `tr-${i}`
|
key: `tr-${i}`,
|
||||||
|
label
|
||||||
})
|
})
|
||||||
|
|
||||||
continue
|
continue
|
||||||
|
|
@ -457,13 +671,16 @@ export const ToolTrail = memo(function ToolTrail({
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const tool of tools) {
|
for (const tool of tools) {
|
||||||
|
const label = formatToolCall(tool.name, tool.context || '')
|
||||||
|
|
||||||
groups.push({
|
groups.push({
|
||||||
color: t.color.cornsilk,
|
color: t.color.cornsilk,
|
||||||
key: tool.id,
|
key: tool.id,
|
||||||
|
label,
|
||||||
details: [],
|
details: [],
|
||||||
content: (
|
content: (
|
||||||
<>
|
<>
|
||||||
<Spinner color={t.color.amber} variant="tool" /> {formatToolCall(tool.name, tool.context || '')}
|
<Spinner color={t.color.amber} variant="tool" /> {label}
|
||||||
{tool.startedAt ? ` (${fmtElapsed(now - tool.startedAt)})` : ''}
|
{tool.startedAt ? ` (${fmtElapsed(now - tool.startedAt)})` : ''}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
@ -493,6 +710,8 @@ export const ToolTrail = memo(function ToolTrail({
|
||||||
const toolTokensLabel = toolTokens !== undefined && toolTokens > 0 ? `~${fmtK(toolTokens)} tokens` : undefined
|
const toolTokensLabel = toolTokens !== undefined && toolTokens > 0 ? `~${fmtK(toolTokens)} tokens` : undefined
|
||||||
|
|
||||||
const totalTokensLabel = tokenCount > 0 && toolTokenCount > 0 ? `~${fmtK(totalTokenCount)} total` : null
|
const totalTokensLabel = tokenCount > 0 && toolTokenCount > 0 ? `~${fmtK(totalTokenCount)} total` : null
|
||||||
|
const delegateGroups = groups.filter(g => g.label.startsWith('Delegate Task'))
|
||||||
|
const inlineDelegateKey = hasSubagents && delegateGroups.length === 1 ? delegateGroups[0]!.key : null
|
||||||
|
|
||||||
// ── Hidden: errors/warnings only ──────────────────────────────
|
// ── Hidden: errors/warnings only ──────────────────────────────
|
||||||
|
|
||||||
|
|
@ -502,7 +721,7 @@ export const ToolTrail = memo(function ToolTrail({
|
||||||
return alerts.length ? (
|
return alerts.length ? (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
{alerts.map(i => (
|
{alerts.map(i => (
|
||||||
<Text color={i.tone === 'error' ? t.color.error : t.color.warn} key={`ha-${i.id}`}>
|
<Text color={(i.tone === 'error' ? t.color.error : t.color.warn) as any} key={`ha-${i.id}`}>
|
||||||
{i.tone === 'error' ? '✗' : '!'} {i.text}
|
{i.tone === 'error' ? '✗' : '!'} {i.text}
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
|
|
@ -510,74 +729,7 @@ export const ToolTrail = memo(function ToolTrail({
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Shared render fragments ────────────────────────────────────
|
// ── Tree 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 subagentBlock = hasSubagents
|
|
||||||
? subagents.map(item => (
|
|
||||||
<SubagentAccordion expanded={detailsMode === 'expanded' || deepSubagents} 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}>
|
|
||||||
<Text dimColor>{i === meta.length - 1 ? '└ ' : '├ '}</Text>
|
|
||||||
{row.content}
|
|
||||||
</Text>
|
|
||||||
))
|
|
||||||
: null
|
|
||||||
|
|
||||||
const totalBlock = totalTokensLabel ? (
|
|
||||||
<Text color={t.color.statusFg} dimColor>
|
|
||||||
<Text color={t.color.amber}>Σ </Text>
|
|
||||||
{totalTokensLabel}
|
|
||||||
</Text>
|
|
||||||
) : null
|
|
||||||
|
|
||||||
// ── Expanded: flat, no accordions ──────────────────────────────
|
|
||||||
|
|
||||||
if (detailsMode === 'expanded') {
|
|
||||||
return (
|
|
||||||
<Box flexDirection="column">
|
|
||||||
{thinkingBlock}
|
|
||||||
{toolBlock}
|
|
||||||
{subagentBlock}
|
|
||||||
{metaBlock}
|
|
||||||
{totalBlock}
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Collapsed: clickable accordions ────────────────────────────
|
|
||||||
|
|
||||||
const expandAll = () => {
|
const expandAll = () => {
|
||||||
setOpenThinking(true)
|
setOpenThinking(true)
|
||||||
|
|
@ -593,78 +745,227 @@ export const ToolTrail = memo(function ToolTrail({
|
||||||
? 'warn'
|
? 'warn'
|
||||||
: 'dim'
|
: 'dim'
|
||||||
|
|
||||||
return (
|
const renderSubagentList = (rails: boolean[]) => (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
{hasThinking && (
|
{subagents.map((item, index) => (
|
||||||
<>
|
<SubagentAccordion
|
||||||
<Box onClick={(e: { ctrlKey?: boolean; shiftKey?: boolean }) => (e?.shiftKey || e?.ctrlKey) ? expandAll() : setOpenThinking(v => !v)}>
|
branch={index === subagents.length - 1 ? 'last' : 'mid'}
|
||||||
<Text color={t.color.dim} dimColor={!thinkingLive}>
|
expanded={detailsMode === 'expanded' || deepSubagents}
|
||||||
<Text color={t.color.amber}>{openThinking ? '▾ ' : '▸ '}</Text>
|
item={item}
|
||||||
<Text bold={thinkingLive} color={thinkingLive ? t.color.cornsilk : t.color.dim} dimColor={!thinkingLive}>
|
key={item.id}
|
||||||
|
rails={rails}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
|
||||||
|
const sections: {
|
||||||
|
header: ReactNode
|
||||||
|
key: string
|
||||||
|
open: boolean
|
||||||
|
render: (rails: boolean[]) => ReactNode
|
||||||
|
}[] = []
|
||||||
|
|
||||||
|
if (hasThinking) {
|
||||||
|
sections.push({
|
||||||
|
header: (
|
||||||
|
<Box
|
||||||
|
onClick={(e: any) => {
|
||||||
|
if (e?.shiftKey || e?.ctrlKey) {
|
||||||
|
expandAll()
|
||||||
|
} else {
|
||||||
|
setOpenThinking(v => !v)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text color={t.color.dim as any} dim={!thinkingLive}>
|
||||||
|
<Text color={t.color.amber as any}>{detailsMode === 'expanded' || openThinking ? '▾ ' : '▸ '}</Text>
|
||||||
|
{thinkingLive ? (
|
||||||
|
<Text bold color={t.color.cornsilk as any}>
|
||||||
Thinking
|
Thinking
|
||||||
</Text>
|
</Text>
|
||||||
{thinkingTokensLabel ? (
|
) : (
|
||||||
<Text color={t.color.statusFg} dimColor>
|
<Text color={t.color.dim as any} dim>
|
||||||
{' '}
|
Thinking
|
||||||
{thinkingTokensLabel}
|
</Text>
|
||||||
</Text>
|
)}
|
||||||
) : null}
|
{thinkingTokensLabel ? (
|
||||||
</Text>
|
<Text color={t.color.statusFg as any} dim>
|
||||||
</Box>
|
{' '}
|
||||||
{openThinking && thinkingBlock}
|
{thinkingTokensLabel}
|
||||||
</>
|
</Text>
|
||||||
)}
|
) : null}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
key: 'thinking',
|
||||||
|
open: detailsMode === 'expanded' || openThinking,
|
||||||
|
render: rails => (
|
||||||
|
<Thinking
|
||||||
|
active={reasoningActive}
|
||||||
|
branch="last"
|
||||||
|
mode="full"
|
||||||
|
rails={rails}
|
||||||
|
reasoning={busy ? reasoning : cot}
|
||||||
|
streaming={busy && reasoningStreaming}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
{hasTools && (
|
if (hasTools) {
|
||||||
<>
|
sections.push({
|
||||||
<Chevron
|
header: (
|
||||||
count={groups.length}
|
<Chevron
|
||||||
onClick={shift => shift ? expandAll() : setOpenTools(v => !v)}
|
count={groups.length}
|
||||||
open={openTools}
|
onClick={shift => {
|
||||||
suffix={toolTokensLabel}
|
if (shift) {
|
||||||
t={t}
|
expandAll()
|
||||||
title="Tool calls"
|
} else {
|
||||||
/>
|
setOpenTools(v => !v)
|
||||||
{openTools && toolBlock}
|
}
|
||||||
</>
|
}}
|
||||||
)}
|
open={detailsMode === 'expanded' || openTools}
|
||||||
|
suffix={toolTokensLabel}
|
||||||
|
t={t}
|
||||||
|
title="Tool calls"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
key: 'tools',
|
||||||
|
open: detailsMode === 'expanded' || openTools,
|
||||||
|
render: rails => (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{groups.map((group, index) => {
|
||||||
|
const branch: TreeBranch = index === groups.length - 1 ? 'last' : 'mid'
|
||||||
|
const childRails = nextTreeRails(rails, branch)
|
||||||
|
const hasInlineSubagents = inlineDelegateKey === group.key
|
||||||
|
|
||||||
{hasSubagents && (
|
return (
|
||||||
<>
|
<Box flexDirection="column" key={group.key}>
|
||||||
<Chevron
|
<TreeTextRow
|
||||||
count={subagents.length}
|
branch={branch}
|
||||||
onClick={shift => {
|
color={group.color}
|
||||||
if (shift) {
|
content={
|
||||||
expandAll()
|
<>
|
||||||
setDeepSubagents(true)
|
<Text color={t.color.amber as any}>● </Text>
|
||||||
} else {
|
{group.content}
|
||||||
setOpenSubagents(v => !v)
|
</>
|
||||||
setDeepSubagents(false)
|
}
|
||||||
}
|
rails={rails}
|
||||||
}}
|
t={t}
|
||||||
open={openSubagents}
|
/>
|
||||||
t={t}
|
{group.details.map((detail, detailIndex) => (
|
||||||
title="Subagents"
|
<Detail
|
||||||
/>
|
{...detail}
|
||||||
{openSubagents && subagentBlock}
|
branch={detailIndex === group.details.length - 1 && !hasInlineSubagents ? 'last' : 'mid'}
|
||||||
</>
|
key={detail.key}
|
||||||
)}
|
rails={childRails}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{hasInlineSubagents ? renderSubagentList(childRails) : null}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
{hasMeta && (
|
if (hasSubagents && !inlineDelegateKey) {
|
||||||
<>
|
sections.push({
|
||||||
<Chevron
|
header: (
|
||||||
count={meta.length}
|
<Chevron
|
||||||
onClick={shift => shift ? expandAll() : setOpenMeta(v => !v)}
|
count={subagents.length}
|
||||||
open={openMeta}
|
onClick={shift => {
|
||||||
t={t}
|
if (shift) {
|
||||||
title="Activity"
|
expandAll()
|
||||||
tone={metaTone}
|
setDeepSubagents(true)
|
||||||
/>
|
} else {
|
||||||
{openMeta && metaBlock}
|
setOpenSubagents(v => !v)
|
||||||
</>
|
setDeepSubagents(false)
|
||||||
)}
|
}
|
||||||
|
}}
|
||||||
|
open={detailsMode === 'expanded' || openSubagents}
|
||||||
|
t={t}
|
||||||
|
title="Subagents"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
key: 'subagents',
|
||||||
|
open: detailsMode === 'expanded' || openSubagents,
|
||||||
|
render: renderSubagentList
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
{totalBlock}
|
if (hasMeta) {
|
||||||
|
sections.push({
|
||||||
|
header: (
|
||||||
|
<Chevron
|
||||||
|
count={meta.length}
|
||||||
|
onClick={shift => {
|
||||||
|
if (shift) {
|
||||||
|
expandAll()
|
||||||
|
} else {
|
||||||
|
setOpenMeta(v => !v)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
open={detailsMode === 'expanded' || openMeta}
|
||||||
|
t={t}
|
||||||
|
title="Activity"
|
||||||
|
tone={metaTone}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
key: 'meta',
|
||||||
|
open: detailsMode === 'expanded' || openMeta,
|
||||||
|
render: rails => (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{meta.map((row, index) => (
|
||||||
|
<TreeTextRow
|
||||||
|
branch={index === meta.length - 1 ? 'last' : 'mid'}
|
||||||
|
color={row.color}
|
||||||
|
content={row.content}
|
||||||
|
dimColor={row.dimColor}
|
||||||
|
key={row.key}
|
||||||
|
rails={rails}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const topCount = sections.length + (totalTokensLabel ? 1 : 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{sections.map((section, index) => (
|
||||||
|
<TreeNode
|
||||||
|
branch={index === topCount - 1 ? 'last' : 'mid'}
|
||||||
|
header={section.header}
|
||||||
|
key={section.key}
|
||||||
|
open={section.open}
|
||||||
|
t={t}
|
||||||
|
>
|
||||||
|
{section.render}
|
||||||
|
</TreeNode>
|
||||||
|
))}
|
||||||
|
{totalTokensLabel ? (
|
||||||
|
<TreeTextRow
|
||||||
|
branch="last"
|
||||||
|
color={t.color.statusFg}
|
||||||
|
content={
|
||||||
|
<>
|
||||||
|
<Text color={t.color.amber as any}>Σ </Text>
|
||||||
|
{totalTokensLabel}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
dimColor
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue