feat: scroll aware sticky prompt

This commit is contained in:
Brooklyn Nicholson 2026-04-14 11:49:32 -05:00
commit 9a3a2925ed
141 changed files with 8867 additions and 829 deletions

View file

@ -5,6 +5,7 @@ import { LONG_MSG, ROLE } from '../constants.js'
import { compactPreview, hasAnsi, isPasteBackedText, stripAnsi, userDisplay } from '../lib/text.js'
import type { Theme } from '../theme.js'
import type { DetailsMode, Msg } from '../types.js'
import { Md } from './markdown.js'
import { ToolTrail } from './thinking.js'
@ -12,12 +13,14 @@ export const MessageLine = memo(function MessageLine({
cols,
compact,
detailsMode = 'collapsed',
isStreaming = false,
msg,
t
}: {
cols: number
compact?: boolean
detailsMode?: DetailsMode
isStreaming?: boolean
msg: Msg
t: Theme
}) {
@ -33,7 +36,8 @@ export const MessageLine = memo(function MessageLine({
return (
<Box alignSelf="flex-start" borderColor={t.color.dim} borderStyle="round" marginLeft={3} paddingX={1}>
<Text color={t.color.dim} wrap="truncate-end">
{compactPreview(hasAnsi(msg.text) ? stripAnsi(msg.text) : msg.text, Math.max(24, cols - 14)) || '(empty tool result)'}
{compactPreview(hasAnsi(msg.text) ? stripAnsi(msg.text) : msg.text, Math.max(24, cols - 14)) ||
'(empty tool result)'}
</Text>
</Box>
)
@ -44,16 +48,27 @@ export const MessageLine = memo(function MessageLine({
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 isStreaming ? <Text color={body}>{msg.text}</Text> : <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>
)
@ -76,7 +91,9 @@ export const MessageLine = memo(function MessageLine({
<Box>
<NoSelect flexShrink={0} fromLeftEdge width={3}>
<Text bold={msg.role === 'user'} color={prefix}>{glyph}{' '}</Text>
<Text bold={msg.role === 'user'} color={prefix}>
{glyph}{' '}
</Text>
</NoSelect>
<Box width={Math.max(20, cols - 5)}>{content}</Box>

View file

@ -176,8 +176,10 @@ function offsetFromPosition(value: string, row: number, col: number, cols: numbe
if (line === targetRow) {
return index
}
line++
column = 0
continue
}
@ -187,6 +189,7 @@ function offsetFromPosition(value: string, row: number, col: number, cols: numbe
if (line === targetRow) {
return index
}
line++
column = 0
}
@ -333,7 +336,9 @@ export function TextInput({
}, [cur, display, focus, placeholder])
const clickCursor = (e: { localRow?: number; localCol?: number }) => {
if (!focus) return
if (!focus) {
return
}
const next = offsetFromPosition(display, e.localRow ?? 0, e.localCol ?? 0, columns)
setCur(next)
curRef.current = next
@ -442,7 +447,6 @@ export function TextInput({
k.upArrow ||
k.downArrow ||
(k.ctrl && inp === 'c') ||
(k.ctrl && inp === 't') ||
k.tab ||
(k.shift && k.tab) ||
k.pageUp ||
@ -568,7 +572,7 @@ export function TextInput({
// ── Render ───────────────────────────────────────────────────────
return (
<Box ref={boxRef} onClick={clickCursor}>
<Box onClick={clickCursor} ref={boxRef}>
<Text wrap="wrap">{rendered}</Text>
</Box>
)

View file

@ -4,7 +4,6 @@ import spinners, { type BrailleSpinnerName } from 'unicode-animations'
import { FACES, VERBS } from '../constants.js'
import {
compactPreview,
formatToolCall,
parseToolTrailResultLine,
pick,
@ -20,6 +19,7 @@ const TOOL: BrailleSpinnerName[] = ['cascade', 'scan', 'diagswipe', 'fillsweep',
const fmtElapsed = (ms: number) => {
const sec = Math.max(0, ms) / 1000
return sec < 10 ? `${sec.toFixed(1)}s` : `${Math.round(sec)}s`
}
@ -28,6 +28,7 @@ const fmtElapsed = (ms: number) => {
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] ?? '') }
})
@ -35,6 +36,7 @@ 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])
@ -52,22 +54,46 @@ function Detail({ color, content, dimColor }: DetailRow) {
)
}
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
}
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'
function Chevron({
count,
onClick,
open,
t,
title,
tone = 'dim'
}: {
count?: number
onClick: () => void
open: boolean
t: Theme
title: string
tone?: 'dim' | 'error' | 'warn'
}) {
const color = tone === 'error' ? t.color.error : tone === 'warn' ? t.color.warn : t.color.dim
@ -75,8 +101,8 @@ function Chevron({ count, onClick, open, summary, t, title, tone = 'dim' }: {
<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}
{title}
{typeof count === 'number' ? ` (${count})` : ''}
</Text>
</Box>
)
@ -85,14 +111,23 @@ function Chevron({ count, onClick, open, summary, t, title, tone = 'dim' }: {
// ── 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)
}, [])
@ -126,13 +161,25 @@ export const Thinking = memo(function Thinking({
type Group = { color: string; content: ReactNode; details: DetailRow[]; key: string }
export const ToolTrail = memo(function ToolTrail({
busy = false, detailsMode = 'collapsed', reasoningActive = false,
reasoning = '', reasoningStreaming = false, t,
tools = [], trail = [], activity = []
busy = false,
detailsMode = 'collapsed',
reasoningActive = false,
reasoning = '',
reasoningStreaming = false,
t,
tools = [],
trail = [],
activity = []
}: {
busy?: boolean; detailsMode?: DetailsMode; 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)
@ -140,19 +187,33 @@ export const ToolTrail = memo(function ToolTrail({
const [openMeta, setOpenMeta] = useState(false)
useEffect(() => {
if (!tools.length) return
const id = setInterval(() => setNow(Date.now()), 200)
if (!tools.length || (detailsMode === 'collapsed' && !openTools)) {
return
}
const id = setInterval(() => setNow(Date.now()), 500)
return () => clearInterval(id)
}, [tools.length])
}, [detailsMode, openTools, tools.length])
useEffect(() => {
if (detailsMode === 'expanded') { setOpenThinking(true); setOpenTools(true); setOpenMeta(true) }
if (detailsMode === 'hidden') { setOpenThinking(false); setOpenTools(false); setOpenMeta(false) }
if (detailsMode === 'expanded') {
setOpenThinking(true)
setOpenTools(true)
setOpenMeta(true)
}
if (detailsMode === 'hidden') {
setOpenThinking(false)
setOpenTools(false)
setOpenMeta(false)
}
}, [detailsMode])
const cot = thinkingPreview(reasoning, 'full', THINKING_COT_MAX)
if (!busy && !trail.length && !tools.length && !activity.length && !cot && !reasoningActive) return null
if (!busy && !trail.length && !tools.length && !activity.length && !cot && !reasoningActive) {
return null
}
// ── Build groups + meta ────────────────────────────────────────
@ -167,12 +228,19 @@ 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}`
})
if (parsed.detail) pushDetail({
color: parsed.mark === '✗' ? t.color.error : t.color.dim,
content: parsed.detail, dimColor: parsed.mark !== '✗', key: `tr-${i}-d`
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`
})
}
continue
}
@ -183,16 +251,24 @@ 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…') {
pushDetail({
color: t.color.dim, dimColor: true, key: `tr-${i}`,
content: groups.length
? <><Spinner color={t.color.amber} variant="think" /> {line}</>
: line
color: t.color.dim,
dimColor: true,
key: `tr-${i}`,
content: groups.length ? (
<>
<Spinner color={t.color.amber} variant="think" /> {line}
</>
) : (
line
)
})
continue
}
@ -201,7 +277,9 @@ export const ToolTrail = memo(function ToolTrail({
for (const tool of tools) {
groups.push({
color: t.color.cornsilk, key: tool.id, details: [],
color: t.color.cornsilk,
key: tool.id,
details: [],
content: (
<>
<Spinner color={t.color.amber} variant="tool" /> {formatToolCall(tool.name, tool.context || '')}
@ -211,18 +289,6 @@ export const ToolTrail = memo(function ToolTrail({
})
}
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) {
pushDetail({
color: t.color.dim, dimColor: true, key: 'cot',
content: <StreamCursor color={t.color.dim} dimColor streaming={reasoningStreaming} visible={reasoningActive} />
})
}
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
@ -233,12 +299,13 @@ export const ToolTrail = memo(function ToolTrail({
const hasTools = groups.length > 0
const hasMeta = meta.length > 0
const hasThinking = !hasTools && (busy || !!cot || reasoningActive)
const hasThinking = !!cot || reasoningActive || (busy && !hasTools)
// ── 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 => (
@ -253,61 +320,95 @@ export const ToolTrail = memo(function ToolTrail({
// ── 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" />
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 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
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>
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'
const metaTone: 'dim' | 'error' | 'warn' = activity.some(i => i.tone === 'error')
? 'error'
: activity.some(i => i.tone === 'warn')
? 'warn'
: 'dim'
return (
<Box flexDirection="column">
{hasThinking && (
<>
<Chevron onClick={() => setOpenThinking(v => !v)} open={openThinking} summary={cot ? compactPreview(cot, 56) : busy ? 'running…' : ''} t={t} title="Thinking" />
<Chevron onClick={() => setOpenThinking(v => !v)} open={openThinking} t={t} title="Thinking" />
{openThinking && thinkingBlock}
</>
)}
{hasTools && (
<>
<Chevron count={groups.length} onClick={() => setOpenTools(v => !v)} open={openTools} t={t} title="Tool calls" />
<Chevron
count={groups.length}
onClick={() => setOpenTools(v => !v)}
open={openTools}
t={t}
title="Tool calls"
/>
{openTools && toolBlock}
</>
)}
{hasMeta && (
<>
<Chevron count={meta.length} onClick={() => setOpenMeta(v => !v)} open={openMeta} t={t} title="Activity" tone={metaTone} />
<Chevron
count={meta.length}
onClick={() => setOpenMeta(v => !v)}
open={openMeta}
t={t}
title="Activity"
tone={metaTone}
/>
{openMeta && metaBlock}
</>
)}