diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 0cf63fd53c..e0ccff15bc 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -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('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 ? ( ) : ( - + )} ))} @@ -2431,8 +2446,9 @@ export function App({ gw }: { gw: GatewayClient }) { diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 07a8fbd5af..2ab0e22728 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -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 ( - + ) } @@ -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 && ( - - 💭 {msg.thinking.replace(/\n/g, ' ').slice(0, 200)} - - )} - {msg.tools?.length ? ( - + ) : null} + {showThinkingPreview && ( + + {'└ '} + {preview} + + )} + diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 6378a55c48..385dd5f48b 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -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 || diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index a5f876da1f..4e609f5e6e 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -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 ( - + {content} ) @@ -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 ( @@ -79,18 +84,10 @@ export const Thinking = memo(function Thinking({ reasoning, t }: { reasoning: st {VERBS[tick % VERBS.length] ?? 'thinking'}… - {tail ? ( - - {clipped && - Array.from({ length: Math.min(THINKING_COT_FADE, tail.length) }, (_, i) => ( - - {tail[i]} - - ))} - - - {clipped ? tail.slice(THINKING_COT_FADE) : tail} - + {preview ? ( + + + {preview} ) : null} @@ -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 ( - {busy && !groups.length && } + {busy && !groups.length && } + {!busy && !groups.length && reasoningTail && ( + + )} {groups.map(g => ( @@ -242,7 +243,7 @@ export const ToolTrail = memo(function ToolTrail({ {g.details.map(d => ( - + ))} ))} diff --git a/ui-tui/src/constants.ts b/ui-tui/src/constants.ts index 59fc639282..9f1c487711 100644 --- a/ui-tui/src/constants.ts +++ b/ui-tui/src/constants.ts @@ -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'], diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index 30dcd67e31..9bed6c3c15 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -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 diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index ddffc566c5..8f24ba7ac5 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -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