feat: ctrl t for diff thinking rendering types

This commit is contained in:
Brooklyn Nicholson 2026-04-12 20:08:12 -05:00
parent ddb0871769
commit 0fd33a98cd
7 changed files with 77 additions and 59 deletions

View file

@ -45,6 +45,7 @@ import type {
SessionInfo,
SlashCatalog,
SudoReq,
ThinkingMode,
Usage
} from './types.js'
@ -351,6 +352,7 @@ export function App({ gw }: { gw: GatewayClient }) {
const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now())
const [bellOnComplete, setBellOnComplete] = useState(false)
const [clockNow, setClockNow] = useState(() => Date.now())
const [thinkingMode, setThinkingMode] = useState<ThinkingMode>('truncated')
// ── Refs ─────────────────────────────────────────────────────────
@ -390,6 +392,7 @@ export function App({ gw }: { gw: GatewayClient }) {
const empty = !messages.length
const isBlocked = blocked()
const hasAnyThinking = Boolean(reasoning.trim() || historyItems.some(m => m.thinking?.trim()))
// ── Resize RPC ───────────────────────────────────────────────────
@ -1181,6 +1184,16 @@ export function App({ gw }: { gw: GatewayClient }) {
return
}
if (ctrl(key, ch, 't')) {
if (hasAnyThinking) {
setThinkingMode(mode => (mode === 'collapsed' ? 'truncated' : mode === 'truncated' ? 'full' : 'collapsed'))
} else {
sys('no thinking available')
}
return
}
if (ctrl(key, ch, 'b')) {
if (voiceRecording) {
setVoiceRecording(false)
@ -2401,9 +2414,11 @@ export function App({ gw }: { gw: GatewayClient }) {
const durationLabel = sid ? fmtDuration(clockNow - sessionStartedAt) : ''
const voiceLabel = voiceRecording ? 'REC' : voiceProcessing ? 'STT' : `voice ${voiceEnabled ? 'on' : 'off'}`
const showProgressArea = Boolean(
(busy && !streaming) || (busy ? activity.length : 0) || tools.length || turnTrail.length
)
const hasReasoning = Boolean(reasoning.trim())
const showProgressArea = Boolean(busy || tools.length || turnTrail.length || hasReasoning)
const showStreamingArea = Boolean(streaming)
// ── Render ───────────────────────────────────────────────────────
@ -2420,7 +2435,7 @@ export function App({ gw }: { gw: GatewayClient }) {
) : m.kind === 'panel' && m.panelData ? (
<Panel sections={m.panelData.sections} t={theme} title={m.panelData.title} />
) : (
<MessageLine cols={cols} compact={compact} msg={m} t={theme} />
<MessageLine cols={cols} compact={compact} msg={m} t={theme} thinkingMode={thinkingMode} />
)}
</Box>
))}
@ -2431,8 +2446,9 @@ export function App({ gw }: { gw: GatewayClient }) {
<ToolTrail
activity={busy ? activity : []}
busy={busy && !streaming}
reasoning={busy && !streaming ? reasoning : ''}
reasoning={reasoning}
t={theme}
thinkingMode={hasReasoning ? thinkingMode : 'truncated'}
tools={tools}
trail={turnTrail}
/>

View file

@ -2,9 +2,9 @@ import { Ansi, Box, Text } from '@hermes/ink'
import { memo } from 'react'
import { LONG_MSG, ROLE } from '../constants.js'
import { compactPreview, hasAnsi, isPasteBackedText, stripAnsi, userDisplay } from '../lib/text.js'
import { compactPreview, hasAnsi, isPasteBackedText, stripAnsi, thinkingPreview, userDisplay } from '../lib/text.js'
import type { Theme } from '../theme.js'
import type { Msg } from '../types.js'
import type { Msg, ThinkingMode } from '../types.js'
import { Md } from './markdown.js'
import { ToolTrail } from './thinking.js'
@ -12,18 +12,20 @@ import { ToolTrail } from './thinking.js'
export const MessageLine = memo(function MessageLine({
cols,
compact,
thinkingMode = 'truncated',
msg,
t
}: {
cols: number
compact?: boolean
thinkingMode?: ThinkingMode
msg: Msg
t: Theme
}) {
if (msg.kind === 'trail' && msg.tools?.length) {
return (
<Box flexDirection="column" marginTop={1}>
<ToolTrail t={t} trail={msg.tools} />
<ToolTrail t={t} thinkingMode={thinkingMode} trail={msg.tools} />
</Box>
)
}
@ -41,6 +43,9 @@ 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 content = (() => {
if (msg.kind === 'slash') {
@ -80,18 +85,19 @@ export const MessageLine = memo(function MessageLine({
marginBottom={msg.role === 'user' ? 1 : 0}
marginTop={msg.role === 'user' || msg.kind === 'slash' ? 1 : 0}
>
{msg.thinking && (
<Text color={t.color.dim} dimColor wrap="truncate-end">
💭 {msg.thinking.replace(/\n/g, ' ').slice(0, 200)}
</Text>
)}
{msg.tools?.length ? (
<Box flexDirection="column" marginBottom={1}>
<ToolTrail t={t} trail={msg.tools} />
<ToolTrail reasoning={thinking} t={t} thinkingMode={thinkingMode} 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}>

View file

@ -329,6 +329,7 @@ export function TextInput({
k.upArrow ||
k.downArrow ||
(k.ctrl && inp === 'c') ||
(k.ctrl && inp === 't') ||
k.tab ||
(k.shift && k.tab) ||
k.pageUp ||

View file

@ -7,14 +7,12 @@ import {
formatToolCall,
parseToolTrailResultLine,
pick,
scaleHex,
THINKING_COT_FADE,
THINKING_COT_MAX,
thinkingCotTail,
thinkingPreview,
toolTrailLabel
} from '../lib/text.js'
import type { Theme } from '../theme.js'
import type { ActiveTool, ActivityItem } from '../types.js'
import type { ActiveTool, ActivityItem, ThinkingMode } from '../types.js'
const THINK: BrailleSpinnerName[] = ['helix', 'breathe', 'orbit', 'dna', 'waverows', 'snake', 'pulse']
const TOOL: BrailleSpinnerName[] = ['cascade', 'scan', 'diagswipe', 'fillsweep', 'rain', 'columns', 'sparkle']
@ -49,10 +47,10 @@ export function Spinner({ color, variant = 'think' }: { color: string; variant?:
type DetailRow = { color: string; content: ReactNode; dimColor?: boolean; key: string }
function Detail({ color, content, dimColor, t }: DetailRow & { t: Theme }) {
function Detail({ color, content, dimColor }: DetailRow) {
return (
<Text color={color} dimColor={dimColor}>
<Text dimColor> </Text>
<Text dimColor> </Text>
{content}
</Text>
)
@ -60,7 +58,15 @@ function Detail({ color, content, dimColor, t }: DetailRow & { t: Theme }) {
// ── Thinking (pre-tool fallback) ─────────────────────────────────────
export const Thinking = memo(function Thinking({ reasoning, t }: { reasoning: string; t: Theme }) {
export const Thinking = memo(function Thinking({
mode = 'truncated',
reasoning,
t
}: {
mode?: ThinkingMode
reasoning: string
t: Theme
}) {
const [tick, setTick] = useState(0)
useEffect(() => {
@ -69,8 +75,7 @@ export const Thinking = memo(function Thinking({ reasoning, t }: { reasoning: st
return () => clearInterval(id)
}, [])
const tail = thinkingCotTail(reasoning)
const clipped = reasoning.length > THINKING_COT_MAX
const preview = thinkingPreview(reasoning, mode, THINKING_COT_MAX)
return (
<Box flexDirection="column">
@ -79,18 +84,10 @@ export const Thinking = memo(function Thinking({ reasoning, t }: { reasoning: st
{VERBS[tick % VERBS.length] ?? 'thinking'}
</Text>
{tail ? (
<Text wrap="truncate-end">
{clipped &&
Array.from({ length: Math.min(THINKING_COT_FADE, tail.length) }, (_, i) => (
<Text color={scaleHex(t.color.dim, (i + 1) / (THINKING_COT_FADE + 1))} key={i}>
{tail[i]}
</Text>
))}
<Text color={t.color.dim} dimColor>
{clipped ? tail.slice(THINKING_COT_FADE) : tail}
</Text>
{preview ? (
<Text color={t.color.dim} dimColor {...(mode !== 'full' ? { wrap: 'truncate-end' as const } : {})}>
<Text dimColor> </Text>
{preview}
</Text>
) : null}
</Box>
@ -103,6 +100,7 @@ type Group = { color: string; content: ReactNode; details: DetailRow[]; key: str
export const ToolTrail = memo(function ToolTrail({
busy = false,
thinkingMode = 'truncated',
reasoning = '',
t,
tools = [],
@ -110,6 +108,7 @@ export const ToolTrail = memo(function ToolTrail({
activity = []
}: {
busy?: boolean
thinkingMode?: ThinkingMode
reasoning?: string
t: Theme
tools?: ActiveTool[]
@ -122,12 +121,15 @@ export const ToolTrail = memo(function ToolTrail({
if (!tools.length) {
return
}
const id = setInterval(() => setNow(Date.now()), 200)
return () => clearInterval(id)
}, [tools.length])
if (!busy && !trail.length && !tools.length && !activity.length) {
const reasoningTail = thinkingPreview(reasoning, thinkingMode, THINKING_COT_MAX)
if (!busy && !trail.length && !tools.length && !activity.length && !reasoningTail) {
return null
}
@ -211,11 +213,7 @@ export const ToolTrail = memo(function ToolTrail({
})
}
// ── reasoning tail → child of last group ────────────────────────
const reasoningTail = thinkingCotTail(reasoning)
if (groups.length && reasoningTail) {
if (reasoningTail && groups.length) {
detail({ color: t.color.dim, content: reasoningTail, dimColor: true, key: 'cot' })
}
@ -232,7 +230,10 @@ export const ToolTrail = memo(function ToolTrail({
return (
<Box flexDirection="column">
{busy && !groups.length && <Thinking reasoning={reasoning} t={t} />}
{busy && !groups.length && <Thinking mode={thinkingMode} reasoning={reasoning} t={t} />}
{!busy && !groups.length && reasoningTail && (
<Detail color={t.color.dim} content={reasoningTail} dimColor key="cot" />
)}
{groups.map(g => (
<Box flexDirection="column" key={g.key}>
@ -242,7 +243,7 @@ export const ToolTrail = memo(function ToolTrail({
</Text>
{g.details.map(d => (
<Detail {...d} key={d.key} t={t} />
<Detail {...d} key={d.key} />
))}
</Box>
))}

View file

@ -24,6 +24,7 @@ export const HOTKEYS: [string, string][] = [
['Ctrl+D', 'exit'],
['Ctrl+G', 'open $EDITOR for prompt'],
['Ctrl+L', 'new session (clear)'],
['Ctrl+T', 'cycle thinking detail'],
['Ctrl+V / Alt+V', 'paste clipboard image'],
['Tab', 'apply completion'],
['↑/↓', 'completions / queue edit / history'],

View file

@ -1,4 +1,5 @@
import { INTERPOLATION_RE, LONG_MSG } from '../constants.js'
import type { ThinkingMode } from '../types.js'
// eslint-disable-next-line no-control-regex
const ANSI_RE = /\x1b\[[0-9;]*m/g
@ -42,6 +43,12 @@ export const compactPreview = (s: string, max: number) => {
return !one ? '' : one.length > max ? one.slice(0, max - 1) + '…' : one
}
export const thinkingPreview = (reasoning: string, mode: ThinkingMode, max: number) => {
const text = reasoning.replace(/\n/g, ' ').trim()
return !text || mode === 'collapsed' ? '' : mode === 'full' ? text : compactPreview(text, max)
}
export const toolTrailLabel = (name: string) =>
name
.split('_')
@ -109,21 +116,6 @@ export const lastCotTrailIndex = (trail: readonly string[]) => {
}
export const THINKING_COT_MAX = 160
export const THINKING_COT_FADE = 5
export const thinkingCotTail = (reasoning: string) => reasoning.replace(/\n/g, ' ').slice(-THINKING_COT_MAX)
/** Scale #RRGGBB by k ∈ [0,1] — used for left-edge fade toward terminal bg. */
export const scaleHex = (hex: string, k: number) => {
const h = hex.replace('#', '')
const ch = (o: number) =>
Math.round(parseInt(h.slice(o, o + 2), 16) * k)
.toString(16)
.padStart(2, '0')
return `#${ch(0)}${ch(2)}${ch(4)}`
}
export const estimateRows = (text: string, w: number, compact = false) => {
let fence: { char: '`' | '~'; len: number } | null = null

View file

@ -33,6 +33,7 @@ export interface Msg {
}
export type Role = 'assistant' | 'system' | 'tool' | 'user'
export type ThinkingMode = 'collapsed' | 'truncated' | 'full'
export interface SessionInfo {
cwd?: string