import figures from 'figures' import type { RefObject } from 'react' import React, { useCallback, useMemo, useRef } from 'react' import { Box, Text } from '@anthropic/ink' import { useKeybindings } from '../keybindings/useKeybinding.js' import { logEvent } from '../services/analytics/index.js' import type { NormalizedUserMessage, RenderableMessage, } from '../types/message.js' import { isEmptyMessageText, SYNTHETIC_MESSAGES } from '../utils/messages.js' // Helper type: narrow the first element of MessageContent to a block with known shape. // MessageContent = string | ContentBlockParam[] | ContentBlock[], so indexing gives // string | ContentBlockParam | ContentBlock which doesn't expose .type/.text directly. type ContentBlock = { type: string; text?: string; name?: string; input?: unknown; id?: string; content?: unknown; [key: string]: unknown } const firstBlock = (content: unknown): ContentBlock | undefined => { if (!Array.isArray(content)) return undefined const b = content[0] if (b == null || typeof b === 'string') return undefined return b as ContentBlock } const NAVIGABLE_TYPES = [ 'user', 'assistant', 'grouped_tool_use', 'collapsed_read_search', 'system', 'attachment', ] as const export type NavigableType = (typeof NAVIGABLE_TYPES)[number] export type NavigableOf = Extract< RenderableMessage, { type: T } > export type NavigableMessage = RenderableMessage // Tier-2 blocklist (tier-1 is height > 0) — things that render but aren't actionable. export function isNavigableMessage(msg: NavigableMessage): boolean { switch (msg.type) { case 'assistant': { const b = firstBlock(msg.message.content) // Text responses (minus AssistantTextMessage's return-null cases — tier-1 // misses unmeasured virtual items), or tool calls with extractable input. return ( (b?.type === 'text' && !isEmptyMessageText(b.text!) && !SYNTHETIC_MESSAGES.has(b.text!)) || (b?.type === 'tool_use' && b.name! in PRIMARY_INPUT) ) } case 'user': { if (msg.isMeta || msg.isCompactSummary) return false const b = firstBlock(msg.message.content) if (b?.type !== 'text') return false // Interrupt etc. — synthetic, not user-authored. if (SYNTHETIC_MESSAGES.has(b.text!)) return false // Same filter as VirtualMessageList sticky-prompt: XML-wrapped (command // expansions, bash-stdout, etc.) aren't real prompts. return !stripSystemReminders(b.text!).startsWith('<') } case 'system': switch (msg.subtype) { case 'api_metrics': case 'stop_hook_summary': case 'turn_duration': case 'memory_saved': case 'agents_killed': case 'away_summary': case 'thinking': return false } return true case 'grouped_tool_use': case 'collapsed_read_search': return true case 'attachment': switch (msg.attachment.type) { case 'queued_command': case 'diagnostics': case 'hook_blocking_error': case 'hook_error_during_execution': return true } return false } return false } type PrimaryInput = { label: string extract: (input: Record) => string | undefined } const str = (k: string) => (i: Record) => typeof i[k] === 'string' ? i[k] : undefined const PRIMARY_INPUT: Record = { Read: { label: 'path', extract: str('file_path') }, Edit: { label: 'path', extract: str('file_path') }, Write: { label: 'path', extract: str('file_path') }, NotebookEdit: { label: 'path', extract: str('notebook_path') }, Bash: { label: 'command', extract: str('command') }, Grep: { label: 'pattern', extract: str('pattern') }, Glob: { label: 'pattern', extract: str('pattern') }, WebFetch: { label: 'url', extract: str('url') }, WebSearch: { label: 'query', extract: str('query') }, Task: { label: 'prompt', extract: str('prompt') }, Agent: { label: 'prompt', extract: str('prompt') }, Tmux: { label: 'command', extract: i => Array.isArray(i.args) ? `tmux ${i.args.join(' ')}` : undefined, }, } // Only AgentTool has renderGroupedToolUse — Edit/Bash/etc. stay as assistant tool_use blocks. export function toolCallOf( msg: NavigableMessage, ): { name: string; input: Record } | undefined { if (msg.type === 'assistant') { const b = firstBlock(msg.message.content) if (b?.type === 'tool_use') return { name: b.name!, input: b.input as Record } } if (msg.type === 'grouped_tool_use') { const b = firstBlock(msg.messages[0]?.message.content) if (b?.type === 'tool_use') return { name: msg.toolName, input: b.input as Record } } return undefined } export type MessageActionCaps = { copy: (text: string) => void edit: (msg: NormalizedUserMessage) => Promise } // Identity builder — preserves tuple type so `run`'s param narrows (array literal widens without this). function action(a: { key: K label: string | ((s: MessageActionsState) => string) types: readonly T[] applies?: (s: MessageActionsState) => boolean stays?: true run: (m: NavigableOf, caps: MessageActionCaps) => void }) { return a } export const MESSAGE_ACTIONS = [ action({ key: 'enter', label: s => (s.expanded ? 'collapse' : 'expand'), types: [ 'grouped_tool_use', 'collapsed_read_search', 'attachment', 'system', ], stays: true, // Empty — `stays` handled inline by dispatch. run: () => {}, }), action({ key: 'enter', label: 'edit', types: ['user'], run: (m, c) => void c.edit(m), }), action({ key: 'c', label: 'copy', types: NAVIGABLE_TYPES, run: (m, c) => c.copy(copyTextOf(m)), }), action({ key: 'p', // `!` safe: applies() guarantees toolName ∈ PRIMARY_INPUT. label: s => `copy ${PRIMARY_INPUT[s.toolName!]!.label}`, types: ['grouped_tool_use', 'assistant'], applies: s => s.toolName != null && s.toolName in PRIMARY_INPUT, run: (m, c) => { const tc = toolCallOf(m) if (!tc) return const val = PRIMARY_INPUT[tc.name]?.extract(tc.input) if (val) c.copy(val) }, }), ] as const function isApplicable( a: (typeof MESSAGE_ACTIONS)[number], c: MessageActionsState, ): boolean { if (!(a.types as readonly string[]).includes(c.msgType)) return false return !a.applies || a.applies(c) } export type MessageActionsState = { uuid: string msgType: NavigableType expanded: boolean toolName?: string } export type MessageActionsNav = { enterCursor: () => void navigatePrev: () => void navigateNext: () => void navigatePrevUser: () => void navigateNextUser: () => void navigateTop: () => void navigateBottom: () => void getSelected: () => NavigableMessage | null } export const MessageActionsSelectedContext = React.createContext(false) export const InVirtualListContext = React.createContext(false) // bg must go on the Box that HAS marginTop (margin stays outside paint) — that's inside each consumer. export function useSelectedMessageBg(): 'messageActionsBackground' | undefined { return React.useContext(MessageActionsSelectedContext) ? 'messageActionsBackground' : undefined } // Can't call useKeybindings here — hook runs outside provider. Returns handlers instead. export function useMessageActions( cursor: MessageActionsState | null, setCursor: React.Dispatch>, navRef: RefObject, caps: MessageActionCaps, ): { enter: () => void handlers: Record void> } { // Refs keep handlers stable — no useKeybindings re-register per message append. const cursorRef = useRef(cursor) cursorRef.current = cursor const capsRef = useRef(caps) capsRef.current = caps const handlers = useMemo(() => { const h: Record void> = { 'messageActions:prev': () => navRef.current?.navigatePrev(), 'messageActions:next': () => navRef.current?.navigateNext(), 'messageActions:prevUser': () => navRef.current?.navigatePrevUser(), 'messageActions:nextUser': () => navRef.current?.navigateNextUser(), 'messageActions:top': () => navRef.current?.navigateTop(), 'messageActions:bottom': () => navRef.current?.navigateBottom(), 'messageActions:escape': () => setCursor(c => (c?.expanded ? { ...c, expanded: false } : null)), // ctrl+c skips the collapse step — from expanded-during-streaming, two-stage // would mean 3 presses to interrupt (collapse→null→cancel). 'messageActions:ctrlc': () => setCursor(null), } for (const key of new Set(MESSAGE_ACTIONS.map(a => a.key))) { h[`messageActions:${key}`] = () => { const c = cursorRef.current if (!c) return const a = MESSAGE_ACTIONS.find(a => a.key === key && isApplicable(a, c)) if (!a) return if (a.stays) { setCursor(c => (c ? { ...c, expanded: !c.expanded } : null)) return } const m = navRef.current?.getSelected() if (!m) return ;(a.run as (m: NavigableMessage, c: MessageActionCaps) => void)( m, capsRef.current, ) setCursor(null) } } return h }, [setCursor, navRef]) const enter = useCallback(() => { logEvent('tengu_message_actions_enter', {}) navRef.current?.enterCursor() }, [navRef]) return { enter, handlers } } // Must mount inside . export function MessageActionsKeybindings({ handlers, isActive, }: { handlers: Record void> isActive: boolean }): null { useKeybindings(handlers, { context: 'MessageActions', isActive }) return null } // borderTop-only Box matches PromptInput's ─── line for stable footer height. export function MessageActionsBar({ cursor, }: { cursor: MessageActionsState }): React.ReactNode { const applicable = MESSAGE_ACTIONS.filter(a => isApplicable(a, cursor)) return ( {applicable.map((a, i) => { const label = typeof a.label === 'function' ? a.label(cursor) : a.label return ( {i > 0 && · } {/* dimColor={false} forces SGR 22 — borderDimColor sibling bleeds dim into first cell */} {a.key} {label} ) })} · {figures.arrowUp} {figures.arrowDown} navigate · esc back ) } export function stripSystemReminders(text: string): string { const CLOSE = '' let t = text.trimStart() while (t.startsWith('')) { const end = t.indexOf(CLOSE) if (end < 0) break t = t.slice(end + CLOSE.length).trimStart() } return t } export function copyTextOf(msg: NavigableMessage): string { switch (msg.type) { case 'user': { const b = firstBlock(msg.message.content) return b?.type === 'text' ? stripSystemReminders(b.text!) : '' } case 'assistant': { const b = firstBlock(msg.message.content) if (b?.type === 'text') return b.text! const tc = toolCallOf(msg) return tc ? (PRIMARY_INPUT[tc.name]?.extract(tc.input) ?? '') : '' } case 'grouped_tool_use': return msg.results.map(toolResultText).filter(Boolean).join('\n\n') case 'collapsed_read_search': return msg.messages .flatMap(m => m.type === 'user' ? [toolResultText(m)] : m.type === 'grouped_tool_use' ? m.results.map(toolResultText) : [], ) .filter(Boolean) .join('\n\n') case 'system': if ('content' in msg) return String(msg.content) if ('error' in msg) return String(msg.error) return String(msg.subtype ?? '') case 'attachment': { const a = msg.attachment if (a.type === 'queued_command') { const p = (a as { prompt?: unknown }).prompt return typeof p === 'string' ? p : (p as Array<{ type: string; text?: string }>).flatMap(b => (b.type === 'text' ? [b.text ?? ''] : [])).join('\n') } return `[${a.type}]` } } return '' } function toolResultText(r: NormalizedUserMessage): string { const b = firstBlock(r.message.content) if (b?.type !== 'tool_result') return '' const c = b.content if (typeof c === 'string') return c if (!c) return '' return (c as Array<{ type: string; text?: string }>).flatMap(x => (x.type === 'text' ? [x.text ?? ''] : [])).join('\n') }