mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
feat: ctrl t for diff thinking rendering types
This commit is contained in:
parent
ddb0871769
commit
0fd33a98cd
7 changed files with 77 additions and 59 deletions
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue