SLOPSHOPPER

orange-hud

Orange HUD for Claude Code: a side pane with session, workspace and agent status, turn work folded into one line, and restyled prompts and replies

newpanebandspinnerrowsguard
v0.2.0no licenseupdated 2026-10-08diegopc99/claude-cli/orange-hud
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · orange-hud
│ ┃ HUD ✕ │ ┃ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ ┃ ┃ ▐▛███▛█ Claude Code v2.1.289 ❯ fix the failing auth test and add an audit log call │ ┃ ┃ ▝▜██████▀ Opus 5.5 │ ┃ ┃ ▝▝ ▝▝ app │ ┃ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ ┃ ⏺ Read(src/auth.ts) │ ┃ ── SESSION ───────────────────────────────── ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ context ▰▰▰▰▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱ 49% 97k/ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ 5h ▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱ 31% ⎿ 3 pass, 1 fail │ ┃ │ ┃ $0.42 · 1 prompt · 30m │ ┃ Done. I made refresh reject expired claims and added an audit call │ ┃ ── WORKSPACE ─────────────────────────────── │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ ⌂ › work/app │ ┃ › /hud │ ┃ ⎇ ⎿ orange-hud: HUD pane opened. │ ┃ │ ┃ ✓ clean │ ┃ │ ┃ ── AGENTS ────────────────────────────────── │ ┃ │ ┃ no agents yet ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ◆ Opus 5.5 ┃ ┃ app ⎇ ┃ ┃ context ▰▰▰▰▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱ 49% $0.42 ┃ ┃ ┃ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ›

Draws

Band
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ◆ Opus 5.5 ┃ ┃ app ⎇ ┃ ┃ context ▰▰▰▰▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱ 49% $0.42 ┃ ┃ ┃ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
Pane · HUD
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ▐▛███▛█ Claude Code v2.1.289 ┃ ┃ ▝▜██████▀ Opus 5.5 ┃ ┃ ▝▝ ▝▝ app ┃ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ ── SESSION ───────────────────────────────────────────────── context ▰▰▰▰▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱ 49% 97k/200k 5h ▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱ 31% $0.42 · 1 prompt · 30m ── WORKSPACE ─────────────────────────────────────────────── ⌂ › work/app ⎇ local ✓ clean ── AGENTS ────────────────────────────────────────────────── no agents yet
Your message
❯ fix the failing auth test and add an audit log call
Claude's reply
Done. I made refresh reject expired claims and added an audit call.
README

claude-cli

Terminal mods for Claude Code, packaged as a plugin marketplace.

orange-hud

An orange heads-up display for the Claude Code terminal.

  • Side pane (docked right in the fullscreen layout): Claude Code version, model, effort, the session's title, context and rate-limit gauges, cost, prompts and session time, git branch with upstream drift, staged, modified and new files, stashes, the last commit and any rebase, merge or conflict in progress, the current kube context and namespace, and every subagent with its status, tools and timing. Click a subagent to read its conversation in the pane; ← HUD goes back. The agent whose transcript Claude Code has in view is marked ▶.
  • Turn folding: the intermediate text and tool calls of each turn collapse into one line under your prompt until the final answer or a question. Click the line to expand it, or run /thoughts to expand or fold every turn.
  • Restyled transcript: your prompts as grey cards, and replies with orange headings, bullets and numbering, code in highlighted cards, and dimmed quotes.

Install

At the prompt of a Claude Code terminal session:

/plugin install orange-hud --marketplace diegopc99/claude-cli

Answer y to add the marketplace, then pick the user scope. The mod is active right away.

Commands

CommandWhat it does
/hudOpens the side pane when it is not showing
/thoughtsExpands or folds the work of every turn

Requirements and limits

  • Built and tested on Claude Code 2.1.292. It uses the function-hooks plugin API, which is early access and may change between releases.
  • The side pane docks in the fullscreen layout from 110 columns (144 when it opens on its own); elsewhere the HUD shows above the prompt.
  • Colours are tuned for the dark theme. UI labels are in Spanish.
  • It does not remove a configured statusLine; that keeps drawing under the prompt.
  • The agent viewer is drawn by the mod beside the main transcript: plugins cannot switch Claude Code's own transcript view, which only the tasks list does.
  • The side pane stays open: its × is drawn by Claude Code and cannot be hidden, so the mod ignores it.
  • Every 5 seconds it runs git --no-optional-locks -c core.fsmonitor=false status --porcelain=v2 and kubectl config view --minify in the session's directory; git rev-parse runs once per directory and git log -1 only when HEAD moves. Both tools are optional: without git or kubectl those rows are simply not shown.
  • No network access. Text from outside the session (kube contexts, branch names, agent descriptions) is stripped of control characters before it is drawn.

Uninstall

/plugin uninstall orange-hud@claude-cli
/plugin marketplace remove claude-cli

Develop

claude plugin validate orange-hud
claude plugin test orange-hud
Source 2 files
hooks/register.tsx 1737 lines
1import { atom, memberOf, read, update } from 'claude-code'
2import type {
3  AgentStatus,
4  Color,
5  EngineInterface,
6  Register,
7  RenderElement,
8  RenderInput,
9  ResolveInput,
10  SessionMessage,
11  Timer,
12} from 'claude-code'
13
14import type {
15  AgentLogEntry,
16  AgentLogView,
17  AgentRow,
18  AgentStatusLabel,
19  AgentToolOutcome,
20  GitCommit,
21  GitOperation,
22  GitState,
23  HudInfo,
24  KubeState,
25  RateLimitGauge,
26  TurnGroupView,
27} from '../types'
28
29type SiteInput = RenderInput<'Pane'> | RenderInput<'AbovePrompt'>
30type ClassicSessionFields = { effort?: { level: string }; agent_id?: string }
31type TranscriptInput =
32  | RenderInput<'AssistantMessage'>
33  | RenderInput<'ToolUse'>
34  | RenderInput<'ToolResult'>
35  | RenderInput<'ToolGroup'>
36  | RenderInput<'ToolProgress'>
37
38const ORANGE = '#D97757'
39const EYE_BACKGROUND = '#000000'
40const KUBE_BLUE = '#326CE5'
41// The dark theme's own prompt background (userMessageBackground).
42const PROMPT_CARD = '#373737'
43// The dark theme's side-panel background (composerSidebarBackground).
44const CODE_CARD = '#262626'
45const SECTION_INDENT = 2
46const PANE_GUTTER = 2
47const PANE = 'hud'
48const PANE_COLUMNS = 46
49const VIEWER_COLUMNS = 96
50const MAX_AGENTS = 50
51const MAX_LOG_ENTRIES = 300
52const INFO_MS = 5_000
53const POLL_MS = 1_000
54// A finished subagent leaves $.agent.list() a little after it ends; one that is
55// gone before a poll ever saw it is taken as done once this has passed.
56const GRACE_MS = 5_000
57const CAVEMAN_MODES = new Set([
58  'off', 'lite', 'full', 'ultra', 'wenyan-lite', 'wenyan', 'wenyan-full', 'wenyan-ultra',
59  'commit', 'review', 'compress',
60])
61const SPINNER = ['◐', '◓', '◑', '◒']
62const LIMIT_LABELS: Record<string, string> = { five_hour: '5h', seven_day: '7d' }
63
64// Clawd as the session header draws it (default pose): body glyphs, with the
65// eye spans painted over a dark background so the quadrant gaps read as eyes.
66const MASCOT: ReadonlyArray<ReadonlyArray<{ glyphs: string; isEye?: true }>> = [
67  [{ glyphs: ' ▐' }, { glyphs: '▛███▛█', isEye: true }, { glyphs: ' ' }],
68  [{ glyphs: '▝▜' }, { glyphs: '█████', isEye: true }, { glyphs: '█▀' }],
69  [{ glyphs: ' ▝▝   ▝▝ ' }],
70]
71
72const info = atom({ plugin: 'orange-hud', key: 'info' } as const, null)
73const hint = atom({ plugin: 'orange-hud', key: 'hint' } as const, '')
74const agents = atom({ plugin: 'orange-hud', key: 'agents' } as const, [])
75const now = atom({ plugin: 'orange-hud', key: 'now' } as const, 0)
76const effort = atom({ plugin: 'orange-hud', key: 'effort' } as const, null)
77const groupOf = atom({ plugin: 'orange-hud', key: 'groupOf' } as const, null)
78const turnGroup = atom({ plugin: 'orange-hud', key: 'turnGroup' } as const, null)
79const groupOpen = atom({ plugin: 'orange-hud', key: 'groupOpen' } as const, false)
80const showThoughts = atom({ plugin: 'orange-hud', key: 'showThoughts' } as const, false)
81const viewing = atom({ plugin: 'orange-hud', key: 'viewing' } as const, null)
82const agentLog = atom({ plugin: 'orange-hud', key: 'agentLog' } as const, null)
83
84let poller: Timer | undefined
85let isLogLoading = false
86let isLogStale = false
87let paneHinted = false
88let isAutoOpened = false
89let isRefreshQueued = false
90let isFullscreen: boolean | undefined
91const ASKING_TOOLS = new Set(['AskUserQuestion', 'ExitPlanMode'])
92// The main conversation's open turn: prose not yet known to be intermediate,
93// and the group its work folds into.
94const tracker: GroupTracker = { pending: [], group: null }
95let lastHint: string | undefined
96
97const isLiveLabel = (status: AgentStatusLabel) => status === 'running' || status === 'idle'
98const isLive = (row: AgentRow) => isLiveLabel(row.status)
99
100function statusLabel(status: AgentStatus): AgentStatusLabel {
101  switch (status) {
102    case 'idle':
103    case 'completed':
104    case 'failed':
105    case 'killed':
106      return status
107    default:
108      return 'running'
109  }
110}
111
112export function modelLabel(model: string): string {
113  const match = /^claude-([a-z]+)-(\d+)-(\d+)(?:-\d{8})?(\[1m\])?$/i.exec(model)
114  if (match === null) return model
115  const family = match[1] ?? ''
116  const size = match[4] === undefined ? '' : ' · 1M'
117  return `${family.charAt(0).toUpperCase()}${family.slice(1)} ${match[2]}.${match[3]}${size}`
118}
119
120export function tokens(count: number): string {
121  if (count >= 1_000_000) return `${Number((count / 1_000_000).toFixed(1))}M`
122  if (count >= 1_000) return `${Math.round(count / 1_000)}k`
123  return String(count)
124}
125
126export function elapsed(ms: number): string {
127  const seconds = Math.max(0, Math.round(ms / 1_000))
128  return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`
129}
130
131export function span(ms: number): string {
132  const minutes = Math.max(0, Math.floor(ms / 60_000))
133  if (minutes < 60) return `${minutes}m`
134  const hours = Math.floor(minutes / 60)
135  if (hours < 24) return `${hours}h ${String(minutes % 60).padStart(2, '0')}m`
136  return `${Math.floor(hours / 24)}d ${hours % 24}h`
137}
138
139export function ago(ms: number): string {
140  const minutes = Math.max(0, Math.floor(ms / 60_000))
141  if (minutes < 1) return 'now'
142  if (minutes < 60) return `${minutes}m`
143  const hours = Math.floor(minutes / 60)
144  return hours < 24 ? `${hours}h` : `${Math.floor(hours / 24)}d`
145}
146
147export type GitStatus = Pick<
148  GitState,
149  'branch' | 'isDetached' | 'oid' | 'upstream' | 'ahead' | 'behind' | 'staged' | 'modified' | 'untracked' | 'conflicts' | 'stashes'
150>
151
152// Reads `git status --porcelain=v2 --branch --show-stash`: headers, then one
153// line per path whose XY pair is the index's state and the work tree's.
154export function parseGitStatus(stdout: string): GitStatus {
155  const status: GitStatus = {
156    branch: '',
157    isDetached: false,
158    oid: null,
159    upstream: null,
160    ahead: null,
161    behind: null,
162    staged: 0,
163    modified: 0,
164    untracked: 0,
165    conflicts: 0,
166    stashes: 0,
167  }
168  for (const line of stdout.split('\n')) {
169    const [kind = '', field = '', ...rest] = line.split(' ')
170    if (kind === '#') {
171      const value = printable(rest.join(' '))
172      if (field === 'branch.oid') status.oid = value === '(initial)' ? null : value
173      if (field === 'branch.head') {
174        status.isDetached = value === '(detached)'
175        status.branch = status.isDetached ? 'detached' : value
176      }
177      if (field === 'branch.upstream') status.upstream = value
178      if (field === 'branch.ab') {
179        status.ahead = Math.abs(Number(rest[0] ?? 0))
180        status.behind = Math.abs(Number(rest[1] ?? 0))
181      }
182      if (field === 'stash') status.stashes = Number(rest[0] ?? 0)
183    } else if (kind === '1' || kind === '2') {
184      if (field[0] !== '.') status.staged++
185      if (field[1] !== '.') status.modified++
186    } else if (kind === 'u') {
187      status.conflicts++
188    } else if (kind === '?') {
189      status.untracked++
190    }
191  }
192  return status
193}
194
195const OPERATION_MARKERS: ReadonlyArray<[string, GitOperation]> = [
196  ['rebase-merge', 'rebase'],
197  ['rebase-apply', 'rebase'],
198  ['MERGE_HEAD', 'merge'],
199  ['CHERRY_PICK_HEAD', 'cherry-pick'],
200  ['REVERT_HEAD', 'revert'],
201  ['BISECT_LOG', 'bisect'],
202]
203
204export function gitOperation(names: readonly string[]): GitOperation | null {
205  const present = new Set(names)
206  return OPERATION_MARKERS.find(([marker]) => present.has(marker))?.[1] ?? null
207}
208
209export function parseKube(stdout: string): KubeState | null {
210  const [context = '', namespace = ''] = stdout.trim().split('\t')
211  const name = printable(context)
212  return name === '' ? null : { context: name, namespace: printable(namespace) || null }
213}
214
215function bar(percent: number, width: number): string {
216  const filled = Math.min(width, Math.max(0, Math.round((percent / 100) * width)))
217  return '▰'.repeat(filled) + '▱'.repeat(width - filled)
218}
219
220function levelColor(percent: number): Color {
221  if (percent >= 85) return 'error'
222  if (percent >= 60) return 'warning'
223  return ORANGE
224}
225
226function statusColor(status: AgentStatusLabel): Color {
227  switch (status) {
228    case 'completed':
229      return 'success'
230    case 'failed':
231      return 'error'
232    case 'killed':
233      return 'inactive'
234    case 'idle':
235      return 'warning'
236    default:
237      return ORANGE
238  }
239}
240
241function statusIcon(row: AgentRow, at: number): string {
242  switch (row.status) {
243    case 'completed':
244      return '✓'
245    case 'failed':
246      return '✗'
247    case 'killed':
248      return '■'
249    case 'idle':
250      return '◌'
251    default:
252      return SPINNER[Math.floor(at / 1_000) % SPINNER.length] ?? '●'
253  }
254}
255
256export type ListItem = { marker: string; text: string; depth: number }
257
258export type ReplyBlock =
259  | { kind: 'heading'; level: number; text: string }
260  | { kind: 'list'; isOrdered: boolean; items: ListItem[] }
261  | { kind: 'code'; language: string; source: string }
262  | { kind: 'quote'; lines: string[] }
263  | { kind: 'rule' }
264  | { kind: 'markdown'; text: string }
265
266const FENCE = /^\s*(`{3,}|~{3,})\s*([\w+#.-]*)/
267const HEADING = /^(#{1,6})\s+(.*?)\s*#*\s*$/
268const RULE = /^\s*([-*_])(\s*\1){2,}\s*$/
269const LIST_ITEM = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/
270const QUOTE = /^\s*>\s?(.*)$/
271
272// Splits a reply into the blocks the HUD styles itself; paragraphs and tables
273// stay markdown so the engine's renderer keeps inline formatting and tables.
274export function parseReply(text: string): ReplyBlock[] {
275  const lines = text.replace(/\r\n/g, '\n').split('\n')
276  const blocks: ReplyBlock[] = []
277  let prose: string[] = []
278  const flush = () => {
279    if (prose.some(line => line.trim() !== '')) blocks.push({ kind: 'markdown', text: prose.join('\n').trim() })
280    prose = []
281  }
282
283  for (let i = 0; i < lines.length; i++) {
284    const line = lines[i] ?? ''
285    const fence = FENCE.exec(line)
286    if (fence !== null) {
287      flush()
288      const mark = fence[1] ?? '```'
289      const body: string[] = []
290      i++
291      while (i < lines.length && !(lines[i] ?? '').trim().startsWith(mark)) body.push(lines[i++] ?? '')
292      blocks.push({ kind: 'code', language: fence[2] ?? '', source: body.join('\n') })
293      continue
294    }
295    const heading = HEADING.exec(line)
296    if (heading !== null) {
297      flush()
298      blocks.push({ kind: 'heading', level: (heading[1] ?? '#').length, text: heading[2] ?? '' })
299      continue
300    }
301    if (RULE.test(line)) {
302      flush()
303      blocks.push({ kind: 'rule' })
304      continue
305    }
306    const item = LIST_ITEM.exec(line)
307    if (item !== null) {
308      flush()
309      const isOrdered = /\d/.test(item[2] ?? '')
310      const items: ListItem[] = []
311      while (i < lines.length) {
312        const current = lines[i] ?? ''
313        const match = LIST_ITEM.exec(current)
314        if (match !== null && /\d/.test(match[2] ?? '') === isOrdered) {
315          items.push({ marker: match[2] ?? '-', text: match[3] ?? '', depth: Math.floor((match[1] ?? '').length / 2) })
316        } else if (current.trim() !== '' && /^\s+/.test(current) && items.length > 0) {
317          const last = items[items.length - 1]
318          if (last !== undefined) last.text = `${last.text}\n${current.trim()}`
319        } else if (current.trim() === '' && LIST_ITEM.test(lines[i + 1] ?? '')) {
320          // a blank line between items keeps the list going
321        } else {
322          break
323        }
324        i++
325      }
326      i--
327      blocks.push({ kind: 'list', isOrdered, items })
328      continue
329    }
330    const quote = QUOTE.exec(line)
331    if (quote !== null) {
332      flush()
333      const quoted: string[] = []
334      while (i < lines.length && QUOTE.test(lines[i] ?? '')) quoted.push(QUOTE.exec(lines[i++] ?? '')?.[1] ?? '')
335      i--
336      blocks.push({ kind: 'quote', lines: quoted })
337      continue
338    }
339    if (line.trim() === '') {
340      flush()
341      continue
342    }
343    prose.push(line)
344  }
345  flush()
346
347  return blocks
348}
349
350export type GroupRow = {
351  uuid: string
352  isPrompt: boolean
353  isAssistant: boolean
354  blocks: ReadonlyArray<{ type: string; id?: unknown; name?: unknown }>
355}
356
357export type GroupTracker = {
358  pending: Array<{ key: string; isText: boolean }>
359  group: TurnGroupView | null
360}
361
362export type GroupChange = { members: string[]; view: TurnGroupView }
363
364// Folds a turn's work into one group: prose a tool call follows, and the calls.
365// Prose no call follows (the final answer) stays out, and so does everything up
366// to a call that asks the person something, which also closes the group.
367export function noteGroupRow(tracker: GroupTracker, row: GroupRow): GroupChange | null {
368  if (row.isPrompt) {
369    tracker.pending = []
370    tracker.group = null
371    return null
372  }
373  if (!row.isAssistant) return null
374
375  const calls = row.blocks.filter(block => block.type === 'tool_use' && typeof block.id === 'string')
376  const isText = row.blocks.some(block => block.type === 'text')
377  const isProse = isText || row.blocks.some(block => block.type === 'thinking' || block.type === 'redacted_thinking')
378  if (calls.length === 0) {
379    if (isProse) tracker.pending.push({ key: row.uuid, isText })
380    return null
381  }
382  if (calls.some(call => ASKING_TOOLS.has(String(call.name)))) {
383    tracker.pending = []
384    tracker.group = null
385    return null
386  }
387
388  const prose = isProse ? [...tracker.pending, { key: row.uuid, isText }] : tracker.pending
389  const members = [...prose.map(part => part.key), ...calls.map(call => String(call.id))]
390  // The head draws the group's summary line, so it must be a row the transcript
391  // draws: thinking rows are hidden there, so a text row or the first call.
392  const head = prose.find(part => part.isText)?.key ?? String(calls[0]?.id ?? row.uuid)
393  const current = tracker.group ?? { head, texts: 0, tools: {}, last: null, isActive: true }
394  const tools = { ...current.tools }
395  for (const call of calls) {
396    const name = printable(String(call.name)).replace(/^mcp__[^_]+__/, '')
397    tools[name] = (tools[name] ?? 0) + 1
398  }
399  const view: TurnGroupView = {
400    head: current.head,
401    texts: current.texts + prose.filter(part => part.isText).length,
402    tools,
403    last: printable(String(calls.at(-1)?.name ?? '')).replace(/^mcp__[^_]+__/, '') || null,
404    isActive: true,
405  }
406  tracker.pending = []
407  tracker.group = view
408
409  return { members, view }
410}
411
412export function groupSummary(view: TurnGroupView): string {
413  const tools = Object.entries(view.tools).sort((a, b) => b[1] - a[1])
414  const calls = tools.reduce((total, [, count]) => total + count, 0)
415  const counts = [
416    view.texts > 0 ? `${view.texts} texto${view.texts === 1 ? '' : 's'}` : null,
417    `${calls} herramienta${calls === 1 ? '' : 's'}`,
418  ].filter(part => part !== null)
419  const detail = tools.slice(0, 4).map(([name, count]) => `${name} ${count}`).join(' · ')
420  const lead = view.isActive ? `trabajando${view.last !== null ? ` (${view.last})` : ''} · ` : ''
421
422  return `${lead}${counts.join(' · ')}${detail === '' ? '' : ` — ${detail}`}`
423}
424
425export type GroupDrawing = 'engine' | 'hidden' | 'summary' | 'expanded-head'
426
427export function groupDrawing(head: string | null, keys: readonly string[], isOpen: boolean, isShowingAll: boolean): GroupDrawing {
428  if (isShowingAll || head === null) return 'engine'
429  const isHead = keys.includes(head)
430  if (isOpen) return isHead ? 'expanded-head' : 'engine'
431  return isHead ? 'summary' : 'hidden'
432}
433
434// Text from outside the session (a kubeconfig, git, the model) is drawn as is,
435// so control characters that could carry terminal escape sequences are dropped.
436export function printable(text: string): string {
437  return text.replace(/[\u0000-\u001f\u007f-\u009f]/g, '')
438}
439
440function printableLines(text: string): string {
441  return text.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, '')
442}
443
444export function clip(text: string, width: number): string {
445  if (text.length <= width) return text
446  return width <= 1 ? text.slice(0, Math.max(0, width)) : `${text.slice(0, width - 1)}…`
447}
448
449const SUMMARY_KEYS = ['file_path', 'path', 'pattern', 'command', 'url', 'query', 'skill', 'description', 'prompt']
450
451export function toolSummary(input: Record<string, unknown>): string {
452  for (const key of SUMMARY_KEYS) {
453    const value = input[key]
454    if (typeof value === 'string' && value.trim() !== '') return clip(printable(value.replace(/\s+/g, ' ').trim()), 200)
455  }
456  return ''
457}
458
459// An agent's conversation as the viewer draws it: tool results are read off the
460// calls they answer, and injected reminders are not the agent's prompt.
461export function agentLogEntries(messages: readonly SessionMessage[]): AgentLogEntry[] {
462  const entries: AgentLogEntry[] = []
463  for (const message of messages) {
464    if (message.role === 'user') {
465      if ((message.toolResults?.length ?? 0) > 0) continue
466      const text = printableLines(message.text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')).trim()
467      if (text !== '') entries.push({ kind: 'prompt', text })
468      continue
469    }
470    const text = printableLines(message.text).trim()
471    if (text !== '') entries.push({ kind: 'reply', text })
472    for (const use of message.toolUses) {
473      const outcome: AgentToolOutcome =
474        use.isError === true ? 'error' : use.text === undefined && use.result === undefined ? 'pending' : 'ok'
475      entries.push({
476        kind: 'tool',
477        name: printable(use.tool).replace(/^mcp__[^_]+__/, ''),
478        summary: toolSummary(use.input),
479        outcome,
480      })
481    }
482  }
483  return entries.slice(-MAX_LOG_ENTRIES)
484}
485
486function cwdLabel(hud: HudInfo): string {
487  if (hud.home !== null && hud.cwd === hud.home) return '~'
488  return hud.cwd.split('/').filter(part => part !== '').at(-1) ?? hud.cwd
489}
490
491async function attempt<T>(work: () => Promise<T>, fallback: T): Promise<T> {
492  try {
493    return await work()
494  } catch {
495    return fallback
496  }
497}
498
499async function storeEffort($: EngineInterface, value: string): Promise<void> {
500  if ((await read($, effort)) === value) return
501  await update($, effort, () => value)
502}
503
504// No optional locks: a background poll must not take the index lock from the
505// person's own git commands. No fsmonitor: a repo's config cannot name a command.
506const GIT = ['git', '--no-optional-locks', '-c', 'core.fsmonitor=false'] as const
507
508let repoCache: { cwd: string; root: string; gitDir: string; isWorktree: boolean } | undefined
509let commitCache: { oid: string; commit: GitCommit | null } | undefined
510
511async function gitRepo($: EngineInterface, cwd: string): Promise<typeof repoCache> {
512  if (repoCache?.cwd === cwd) return repoCache
513  const run = await $.process.run(
514    [...GIT, 'rev-parse', '--path-format=absolute', '--show-toplevel', '--git-dir', '--git-common-dir'],
515    { timeoutMs: 2_000 },
516  )
517  if (run.exitCode !== 0) return undefined
518  const [root = '', gitDir = '', commonDir = ''] = run.stdout.split('\n')
519  repoCache = { cwd, root, gitDir, isWorktree: gitDir !== commonDir }
520  return repoCache
521}
522
523async function lastCommit($: EngineInterface, oid: string | null): Promise<GitCommit | null> {
524  if (oid === null) return null
525  if (commitCache?.oid === oid) return commitCache.commit
526  const run = await $.process.run([...GIT, 'log', '-1', '--format=%h%x1f%ct%x1f%s'], { timeoutMs: 2_000 })
527  const [hash = '', at = '', subject = ''] = run.exitCode === 0 ? run.stdout.trim().split('\x1f') : []
528  const commit = hash === '' ? null : { hash: printable(hash), subject: printable(subject), at: Number(at) * 1_000 }
529  commitCache = { oid, commit }
530  return commit
531}
532
533// A rebase, merge or pick in progress leaves its marker in the git dir, so a
534// listing tells without a process; a rebase's step is two numbers beside it.
535async function gitProgress($: EngineInterface, gitDir: string): Promise<Pick<GitState, 'operation' | 'step'>> {
536  const names = (await attempt(() => $.fs.list(gitDir), [])).map(entry => entry.name)
537  const operation = gitOperation(names)
538  if (operation !== 'rebase') return { operation, step: null }
539  const isMerge = names.includes('rebase-merge')
540  const dir = `${gitDir}/${isMerge ? 'rebase-merge' : 'rebase-apply'}`
541  const read = (name: string) => attempt(async () => (await $.fs.read(`${dir}/${name}`)).trim(), '')
542  const [done, total] = await Promise.all([read(isMerge ? 'msgnum' : 'next'), read(isMerge ? 'end' : 'last')])
543  return { operation, step: /^\d+$/.test(done) && /^\d+$/.test(total) ? `${done}/${total}` : null }
544}
545
546async function gitState($: EngineInterface): Promise<GitState | null> {
547  const run = await $.process.run([...GIT, 'status', '--porcelain=v2', '--branch', '--show-stash'], { timeoutMs: 2_000 })
548  if (run.exitCode !== 0) return null
549  const status = parseGitStatus(run.stdout)
550  const cwd = await $.session.cwd()
551  const repo = await gitRepo($, cwd)
552  const [progress, commit] = await Promise.all([
553    repo === undefined ? { operation: null, step: null } : gitProgress($, repo.gitDir),
554    lastCommit($, status.oid),
555  ])
556  const root = repo?.root ?? cwd
557  return {
558    ...status,
559    ...progress,
560    lastCommit: commit,
561    repo: printable(root.split('/').filter(part => part !== '').at(-1) ?? root),
562    subdir: repo !== undefined && cwd.startsWith(`${root}/`) ? printable(cwd.slice(root.length + 1)) : null,
563    isWorktree: repo?.isWorktree ?? false,
564  }
565}
566
567async function kubeState($: EngineInterface): Promise<KubeState | null> {
568  const run = await $.process.run(
569    ['kubectl', 'config', 'view', '--minify', '-o', 'jsonpath={.current-context}{"\\t"}{..namespace}'],
570    { timeoutMs: 2_000 },
571  )
572  return run.exitCode === 0 ? parseKube(run.stdout) : null
573}
574
575const TITLE_PATTERN = '"(customTitle|aiTitle)":"([^"\\\\]|\\\\.)*"'
576let transcript: { id: string; path: string } | undefined
577// The title last read, keyed by the transcript's size and mtime: re-read only once the file changed.
578let titleCache: { id: string; stamp: string; title: string | null } | undefined
579
580// The title a /rename set wins over the one Claude Code generated; both are the
581// latest of their kind in the transcript lines `grep` hands back.
582export function pickSessionTitle(lines: readonly string[]): string | null {
583  let custom: string | null = null
584  let generated: string | null = null
585  for (const line of lines) {
586    try {
587      const entry = JSON.parse(`{${line}}`) as { customTitle?: unknown; aiTitle?: unknown }
588      if (typeof entry.customTitle === 'string') custom = entry.customTitle
589      if (typeof entry.aiTitle === 'string') generated = entry.aiTitle
590    } catch {
591      // a line grep cut mid-escape is skipped
592    }
593  }
594  const title = printable(custom ?? generated ?? '').trim()
595  return title === '' ? null : title
596}
597
598export function isTitleFresh(
599  cache: { id: string; stamp: string } | undefined,
600  id: string,
601  stamp: string,
602): boolean {
603  return cache !== undefined && cache.id === id && cache.stamp === stamp
604}
605
606// No API carries the session's name, so it is read off the transcript. The file
607// outgrows $.fs.read's limit, so grep returns only the title entries.
608async function sessionTitle($: EngineInterface, configDir: string): Promise<string | null> {
609  const id = await $.session.id()
610  if (!/^[0-9a-f-]{36}$/i.test(id)) return null
611  if (transcript?.id !== id) {
612    const found = await $.process.run(
613      ['find', `${configDir}/projects`, '-maxdepth', '2', '-name', `${id}.jsonl`, '-print', '-quit'],
614      { timeoutMs: 3_000 },
615    )
616    const path = found.exitCode === 0 ? found.stdout.trim() : ''
617    if (path === '') return null
618    transcript = { id, path }
619  }
620  const stat = await $.fs.stat(transcript.path)
621  const stamp = `${stat.size}:${stat.mtimeMs}`
622  if (titleCache !== undefined && isTitleFresh(titleCache, id, stamp)) return titleCache.title
623  const run = await $.process.run(['grep', '-a', '-o', '-E', TITLE_PATTERN, transcript.path], { timeoutMs: 3_000 })
624  const title = run.exitCode === 0 ? pickSessionTitle(run.stdout.split('\n').filter(line => line !== '')) : null
625  titleCache = { id, stamp, title }
626  return title
627}
628
629// Mirrors ~/.claude/hooks/caveman-statusline.sh, which the HUD replaces; the
630// flag text is reduced to [a-z0-9-] so nothing from the file reaches the terminal raw.
631async function cavemanBadge($: EngineInterface, configDir: string): Promise<string | null> {
632  const raw = await $.fs.read(`${configDir}/.caveman-active`)
633  const mode = raw.slice(0, 64).toLowerCase().replace(/[^a-z0-9-]/g, '')
634  if (!CAVEMAN_MODES.has(mode)) return null
635  const badge = mode === 'full' ? '[CAVEMAN]' : `[CAVEMAN:${mode.toUpperCase()}]`
636  const savings = await attempt(
637    async () => (await $.fs.read(`${configDir}/.caveman-statusline-suffix`)).slice(0, 64).replace(/[\u0000-\u001f\u007f]/g, '').trim(),
638    '',
639  )
640  return savings === '' ? badge : `${badge} ${savings}`
641}
642
643async function configDirOf($: EngineInterface, home: string | undefined): Promise<string | undefined> {
644  const explicit = await attempt(() => $.env.get('CLAUDE_CONFIG_DIR'), undefined)
645  return explicit ?? (home === undefined ? undefined : `${home}/.claude`)
646}
647
648async function collectInfo($: EngineInterface): Promise<HudInfo> {
649  const home = await attempt(() => $.env.get('HOME'), undefined)
650  const configDir = await configDirOf($, home)
651  const fetchedAt = await $.clock.now()
652  const [version, model, cwd, usage, git, kube, prompts, settings, caveman, title] = await Promise.all([
653    attempt(async () => (await $.session.version()).version, ''),
654    attempt(() => $.session.model(), ''),
655    attempt(() => $.session.cwd(), ''),
656    attempt(() => $.session.usage(), undefined),
657    attempt(() => gitState($), null),
658    attempt(() => kubeState($), null),
659    attempt(() => $.session.turns(), null),
660    attempt(() => $.settings.read(), {}),
661    configDir === undefined ? Promise.resolve(null) : attempt(() => cavemanBadge($, configDir), null),
662    configDir === undefined ? Promise.resolve(null) : attempt(() => sessionTitle($, configDir), null),
663  ])
664  const rateLimits: RateLimitGauge[] = (usage?.rateLimits ?? []).map(limit => {
665    const resetsAt = limit.resetsAt === undefined ? NaN : Date.parse(limit.resetsAt)
666    return {
667      label: LIMIT_LABELS[limit.kind] ?? limit.kind,
668      percent: limit.percentUsed,
669      resetsAt: Number.isNaN(resetsAt) ? null : resetsAt,
670    }
671  })
672
673  return {
674    version,
675    model,
676    cwd,
677    home: home ?? null,
678    git,
679    kube,
680    contextPercent: usage?.context.percent ?? null,
681    contextTokens: usage?.context.tokens ?? null,
682    contextWindow: usage?.context.window ?? 0,
683    rateLimits,
684    costUsd: usage?.cost?.usd ?? null,
685    prompts,
686    sessionStartedAt: usage?.startedAt ?? null,
687    fetchedAt,
688    effortSetting: typeof settings['effortLevel'] === 'string' ? settings['effortLevel'] : null,
689    caveman,
690    sessionTitle: title,
691  }
692}
693
694// Re-reads the session title alone, for the moment a /rename has just written it.
695async function refreshTitle($: EngineInterface): Promise<void> {
696  const configDir = await configDirOf($, await attempt(() => $.env.get('HOME'), undefined))
697  if (configDir === undefined) return
698  const title = await attempt(() => sessionTitle($, configDir), null)
699  const current = await read($, info)
700  if (current === null || current.sessionTitle === title) return
701  await update($, info, value => (value === null ? value : { ...value, sessionTitle: title }))
702}
703
704async function refreshInfo($: EngineInterface): Promise<void> {
705  const fresh = await collectInfo($)
706  const current = await read($, info)
707  if (current !== null && JSON.stringify({ ...current, fetchedAt: 0 }) === JSON.stringify({ ...fresh, fetchedAt: 0 })) {
708    if (fresh.fetchedAt - current.fetchedAt < 60_000) return
709  }
710  await update($, info, () => fresh)
711}
712
713// A drawing that finds no figures yet asks for them from outside the draw.
714function queueRefresh($: EngineInterface): void {
715  if (isRefreshQueued) return
716  isRefreshQueued = true
717  $.clock.after(1, () => void refreshInfo($))
718}
719
720function noteSession($: EngineInterface, e: ClassicSessionFields): void {
721  if (e.agent_id !== undefined) return
722  if (e.effort?.level) void storeEffort($, e.effort.level)
723}
724
725async function openPane($: EngineInterface, isAsked: boolean): Promise<void> {
726  const columns = (await read($, viewing)) === null ? PANE_COLUMNS : VIEWER_COLUMNS
727  const opened = await $.ui.open({ id: PANE, title: 'HUD', columns })
728  $.ui.invalidate('ui.render')
729  if (!opened.isPlaced && !isAsked && !paneHinted) {
730    paneHinted = true
731    $.ui.toast('HUD: widen the terminal or run /hud to dock it on the right')
732  }
733}
734
735async function loadAgentLog($: EngineInterface): Promise<void> {
736  const agentId = await read($, viewing)
737  if (agentId === null) return
738  const found = await attempt(() => $.session.messages({ agentId }), { deny: 'unreadable' })
739  if ((await read($, viewing)) !== agentId) return
740  const current = await read($, agentLog)
741  if (!Array.isArray(found) && current?.agentId === agentId && current.entries.length > 0) return
742  const fresh: AgentLogView = Array.isArray(found)
743    ? { agentId, entries: agentLogEntries(found), isDenied: false }
744    : { agentId, entries: [], isDenied: true }
745  if (JSON.stringify(current) === JSON.stringify(fresh)) return
746  await update($, agentLog, () => fresh)
747}
748
749// A refresh asked for while one runs is folded into one more pass after it, so
750// an agent's last turn is never dropped behind a poll that read it a moment early.
751async function refreshAgentLog($: EngineInterface): Promise<void> {
752  if (isLogLoading) {
753    isLogStale = true
754    return
755  }
756  isLogLoading = true
757  try {
758    do {
759      isLogStale = false
760      await loadAgentLog($)
761    } while (isLogStale)
762  } finally {
763    isLogLoading = false
764  }
765}
766
767async function showAgent($: EngineInterface, agentId: string | null): Promise<void> {
768  await update($, viewing, () => agentId)
769  await update($, agentLog, () => null)
770  await openPane($, true)
771  if (agentId === null) return
772  await refreshAgentLog($)
773  await attempt(() => $.ui.scroll({ to: 'end', in: PANE }), undefined)
774}
775
776async function isHudInPane($: EngineInterface): Promise<boolean> {
777  const panes = await attempt(() => $.ui.panes(), [])
778  return panes.some(pane => pane.id === PANE && pane.isPlaced && pane.isShown)
779}
780
781async function finish(
782  $: EngineInterface,
783  matches: (row: AgentRow) => boolean,
784  status: AgentStatusLabel,
785  totals?: { tools?: number; tokens?: number },
786): Promise<void> {
787  const at = await $.clock.now()
788  await update($, agents, list =>
789    list.map(row =>
790      matches(row) && isLive(row)
791        ? { ...row, status, endedAt: at, tools: totals?.tools ?? row.tools, tokens: totals?.tokens ?? row.tokens }
792        : row,
793    ),
794  )
795}
796
797async function countTool($: EngineInterface, agentId: string, tool: string): Promise<void> {
798  const list = await read($, agents)
799  if (!list.some(row => row.id === agentId && isLive(row))) return
800  await update($, agents, rows =>
801    rows.map(row => (row.id === agentId ? { ...row, tools: row.tools + 1, lastTool: printable(tool) } : row)),
802  )
803}
804
805async function pollAgents($: EngineInterface): Promise<void> {
806  const at = await $.clock.now()
807  const rows = await read($, agents)
808  if (!rows.some(isLive)) {
809    poller?.cancel()
810    poller = undefined
811    return
812  }
813  await update($, now, () => at)
814  const listed = new Map((await $.agent.list()).map(agent => [agent.id, agent.status]))
815  await update($, agents, list =>
816    list.map((row): AgentRow => {
817      if (!isLive(row)) return row
818      const status = listed.get(row.id)
819      if (status === undefined) {
820        return at - row.startedAt > GRACE_MS ? { ...row, status: 'completed', endedAt: at } : row
821      }
822      const label = statusLabel(status)
823      if (label === row.status) return row
824      return isLiveLabel(label) ? { ...row, status: label } : { ...row, status: label, endedAt: at }
825    }),
826  )
827  const shown = await read($, viewing)
828  if (shown !== null && rows.some(row => row.id === shown && isLive(row))) void refreshAgentLog($)
829}
830
831function ensurePoller($: EngineInterface): void {
832  if (poller !== undefined) return
833  poller = $.clock.every(POLL_MS, () => void pollAgents($))
834}
835
836async function drawGrouped(
837  $: EngineInterface,
838  e: TranscriptInput,
839  keys: readonly string[],
840  draw: () => Promise<RenderElement>,
841): Promise<RenderElement> {
842  let found: string | null = null
843  for (const key of keys) {
844    found = await read($, memberOf(groupOf, { requestId: key }))
845    if (found !== null) break
846  }
847  const head = found
848  const [view, isOpen, isShowingAll] = await Promise.all([
849    head === null ? Promise.resolve(null) : read($, memberOf(turnGroup, { requestId: head })),
850    head === null ? Promise.resolve(false) : read($, memberOf(groupOpen, { requestId: head })),
851    read($, showThoughts),
852  ])
853  const drawing = groupDrawing(view === null ? null : head, keys, isOpen, isShowingAll)
854  if (drawing === 'engine' || head === null || view === null) return draw()
855
856  const { Box, Button } = $.ui.resolve(e)
857  if (drawing === 'hidden') return <Box />
858  if (drawing === 'expanded-head') {
859    const drawn = await draw()
860    return (
861      <Box flexDirection="column">
862        <Button key="group" label={`▾ ocultar · ${groupSummary(view)}`} plain dimColor onPress={() => update($, memberOf(groupOpen, { requestId: head }), () => false)} />
863        {drawn}
864      </Box>
865    )
866  }
867
868  return (
869    <Box paddingLeft={2}>
870      <Button key="group" label={`▸ ${groupSummary(view)}`} plain dimColor onPress={() => update($, memberOf(groupOpen, { requestId: head }), () => true)} />
871    </Box>
872  )
873}
874
875function replyBlock($: EngineInterface, e: ResolveInput, block: ReplyBlock): RenderElement {
876  const { Box, Code, Markdown, Text } = $.ui.resolve(e)
877  const plain = (text: string) => text.replace(/\*\*|__|`/g, '')
878
879  switch (block.kind) {
880    case 'heading':
881      return block.level <= 2 ? (
882        <Box flexDirection="column">
883          <Text color={ORANGE} bold>{`◆ ${plain(block.text)}`}</Text>
884          <Text color={ORANGE} dimColor wrap="truncate-end">{'─'.repeat(400)}</Text>
885        </Box>
886      ) : (
887        <Text color={block.level === 3 ? ORANGE : 'text'} bold>{plain(block.text)}</Text>
888      )
889    case 'list': {
890      let ordinal = 0
891      return (
892        <Box flexDirection="column">
893          {block.items.map(item => {
894            ordinal = item.depth === 0 ? ordinal + 1 : ordinal
895            const marker = block.isOrdered && item.depth === 0 ? String(ordinal) : item.depth === 0 ? '•' : '◦'
896            return (
897              <Box paddingLeft={item.depth * 2}>
898                <Box flexShrink={0} width={block.isOrdered ? 4 : 3}>
899                  <Text color={ORANGE} bold>{marker}</Text>
900                </Box>
901                <Box flexShrink={1} flexGrow={1}>
902                  <Markdown text={item.text} />
903                </Box>
904              </Box>
905            )
906          })}
907        </Box>
908      )
909    }
910    case 'code':
911      return (
912        <Box flexDirection="column" backgroundColor={CODE_CARD} paddingX={1}>
913          {block.language !== '' ? <Text color={ORANGE} dimColor>{block.language}</Text> : null}
914          {block.language !== '' ? (
915            <Code source={block.source} language={block.language} wrap="wrap" />
916          ) : (
917            <Code source={block.source} wrap="wrap" />
918          )}
919        </Box>
920      )
921    case 'quote':
922      return (
923        <Box flexDirection="column">
924          {block.lines.map(line => (
925            <Box>
926              <Box flexShrink={0}>
927                <Text dimColor>{'│ '}</Text>
928              </Box>
929              <Box flexShrink={1}>
930                <Markdown text={line === '' ? ' ' : line} dimColor />
931              </Box>
932            </Box>
933          ))}
934        </Box>
935      )
936    case 'rule':
937      return <Text dimColor wrap="truncate-end">{'─'.repeat(400)}</Text>
938    default:
939      return <Markdown text={block.text} />
940  }
941}
942
943function replyTree($: EngineInterface, e: ResolveInput, text: string, isFirst: boolean): RenderElement {
944  const { Box, Text } = $.ui.resolve(e)
945
946  return (
947    <Box marginTop={1}>
948      <Box flexShrink={0} width={3}>
949        <Text color={ORANGE}>{isFirst ? '●' : ' '}</Text>
950      </Box>
951      <Box flexDirection="column" flexShrink={1} flexGrow={1} gap={1}>
952        {parseReply(text).map(block => replyBlock($, e, block))}
953      </Box>
954    </Box>
955  )
956}
957
958function promptCard($: EngineInterface, e: ResolveInput, text: string): RenderElement {
959  const { Box, Text } = $.ui.resolve(e)
960
961  return (
962    <Box backgroundColor={PROMPT_CARD} paddingX={2} paddingY={1} marginTop={1} marginBottom={1} gap={2}>
963      <Box flexShrink={0}>
964        <Text color={ORANGE} bold>❯</Text>
965      </Box>
966      <Box flexShrink={1}>
967        <Text color="text" wrap="wrap">{text}</Text>
968      </Box>
969    </Box>
970  )
971}
972
973function sectionTitle($: EngineInterface, e: SiteInput, title: string, right?: RenderElement): RenderElement {
974  const { Box, Text } = $.ui.resolve(e)
975
976  // Fixed-width parts sit in non-shrinking boxes: Yoga shrinks every flex item
977  // in proportion to its width, so an unguarded title loses its last letter to
978  // the rule beside it and wraps. The rule wraps inside one clipped row, as
979  // truncating it would end it in an ellipsis.
980  return (
981    <Box gap={1}>
982      <Box flexShrink={0}>
983        <Text color={ORANGE} dimColor>──</Text>
984      </Box>
985      <Box flexShrink={0}>
986        <Text color={ORANGE} bold>{title}</Text>
987      </Box>
988      <Box flexGrow={1} flexShrink={1} height={1} overflow="hidden">
989        <Text color={ORANGE} dimColor wrap="wrap">{'─'.repeat(e.props.bodyColumns)}</Text>
990      </Box>
991      {right !== undefined ? <Box flexShrink={0}>{right}</Box> : null}
992    </Box>
993  )
994}
995
996function gauge($: EngineInterface, e: SiteInput, label: string, percent: number, detail: string): RenderElement {
997  const { Box, Text } = $.ui.resolve(e)
998  const width = Math.min(24, Math.max(6, e.props.bodyColumns - 26 - SECTION_INDENT))
999
1000  return (
1001    <Box gap={1}>
1002      <Box flexShrink={0} gap={1}>
1003        <Text dimColor>{label.padEnd(7)}</Text>
1004        <Text color={levelColor(percent)}>{bar(percent, width)}</Text>
1005        <Text color={levelColor(percent)} bold>{`${Math.round(percent)}%`.padStart(4)}</Text>
1006      </Box>
1007      <Box flexShrink={1}>
1008        <Text dimColor wrap="truncate-end">{detail}</Text>
1009      </Box>
1010    </Box>
1011  )
1012}
1013
1014function headerCard(
1015  $: EngineInterface,
1016  e: SiteInput,
1017  hud: HudInfo | null,
1018  effortLevel: string | null,
1019): RenderElement {
1020  const { Box, Text } = $.ui.resolve(e)
1021  const model = hud === null || hud.model === '' ? 'Claude' : modelLabel(hud.model)
1022  const level = effortLevel ?? hud?.effortSetting ?? null
1023
1024  return (
1025    <Box flexDirection="column" width={e.props.bodyColumns} borderStyle="bold" borderColor={ORANGE} paddingX={1}>
1026      <Box gap={2}>
1027        <Box flexDirection="column" flexShrink={0}>
1028          {MASCOT.map(row => (
1029            <Box>
1030              {row.map(part =>
1031                part.isEye ? (
1032                  <Text color={ORANGE} backgroundColor={EYE_BACKGROUND}>{part.glyphs}</Text>
1033                ) : (
1034                  <Text color={ORANGE}>{part.glyphs}</Text>
1035                ),
1036              )}
1037            </Box>
1038          ))}
1039        </Box>
1040        <Box flexDirection="column" flexShrink={1}>
1041          <Box gap={1}>
1042            <Box flexShrink={0}>
1043              <Text bold>Claude Code</Text>
1044            </Box>
1045            {hud !== null && hud.version !== '' ? (
1046              <Box flexShrink={1}>
1047                <Text dimColor wrap="truncate-end">v{hud.version}</Text>
1048              </Box>
1049            ) : null}
1050          </Box>
1051          <Text color={ORANGE} bold wrap="truncate-end">{model}</Text>
1052          {level !== null ? <Text dimColor wrap="truncate-end">effort {level}</Text> : null}
1053          <Text dimColor wrap="truncate-end">{hud === null ? '' : cwdLabel(hud)}</Text>
1054        </Box>
1055      </Box>
1056      {hud !== null && (hud.sessionTitle !== null || hud.caveman !== null) ? (
1057        <Box justifyContent="space-between" marginTop={1} gap={1}>
1058          <Box flexShrink={1}>
1059            <Text bold wrap="truncate-end">{hud.sessionTitle ?? ''}</Text>
1060          </Box>
1061          {hud.caveman !== null ? (
1062            <Box flexShrink={0}>
1063              <Text color={ORANGE}>{hud.caveman}</Text>
1064            </Box>
1065          ) : null}
1066        </Box>
1067      ) : null}
1068    </Box>
1069  )
1070}
1071
1072function sessionSection($: EngineInterface, e: SiteInput, hud: HudInfo | null): RenderElement {
1073  const { Box, Text } = $.ui.resolve(e)
1074  const context = hud?.contextPercent ?? null
1075  const summary = [
1076    hud?.costUsd != null ? `$${hud.costUsd.toFixed(2)}` : null,
1077    hud?.prompts != null ? `${hud.prompts} prompt${hud.prompts === 1 ? '' : 's'}` : null,
1078    hud?.sessionStartedAt != null ? span(hud.fetchedAt - hud.sessionStartedAt) : null,
1079  ].filter(part => part !== null)
1080
1081  return (
1082    <Box flexDirection="column" marginTop={1} gap={1}>
1083      {sectionTitle($, e, 'SESSION')}
1084      <Box flexDirection="column" gap={1} paddingLeft={SECTION_INDENT}>
1085        {context !== null && hud !== null
1086          ? gauge($, e, 'context', context, hud.contextTokens !== null && hud.contextWindow > 0 ? `${tokens(hud.contextTokens)}/${tokens(hud.contextWindow)}` : '')
1087          : <Text dimColor>context —</Text>}
1088        {(hud?.rateLimits ?? []).map(limit =>
1089          gauge($, e, limit.label, limit.percent, limit.resetsAt === null || hud === null ? '' : `↻ ${span(limit.resetsAt - hud.fetchedAt)}`),
1090        )}
1091        {summary.length > 0 ? <Text color={ORANGE} wrap="truncate-end">{summary.join(' · ')}</Text> : null}
1092      </Box>
1093    </Box>
1094  )
1095}
1096
1097function workspaceRow(
1098  $: EngineInterface,
1099  e: SiteInput,
1100  icon: RenderElement,
1101  body: RenderElement,
1102  right?: RenderElement,
1103): RenderElement {
1104  const { Box } = $.ui.resolve(e)
1105
1106  return (
1107    <Box gap={1}>
1108      <Box flexShrink={0}>{icon}</Box>
1109      <Box flexGrow={1} flexShrink={1}>{body}</Box>
1110      {right !== undefined ? <Box flexShrink={0}>{right}</Box> : null}
1111    </Box>
1112  )
1113}
1114
1115function syncMark($: EngineInterface, e: SiteInput, git: GitState): RenderElement {
1116  const { Box, Text } = $.ui.resolve(e)
1117  if (git.upstream === null) return <Text dimColor>local</Text>
1118  if (git.ahead === null || git.behind === null) return <Text color="warning">upstream gone</Text>
1119  if (git.ahead === 0 && git.behind === 0) return <Text dimColor>✓ synced</Text>
1120
1121  return (
1122    <Box gap={1}>
1123      {git.ahead > 0 ? <Text dimColor>{`↑${git.ahead}`}</Text> : null}
1124      {git.behind > 0 ? <Text dimColor>{`↓${git.behind}`}</Text> : null}
1125      <Text dimColor>{git.upstream.split('/')[0] ?? git.upstream}</Text>
1126    </Box>
1127  )
1128}
1129
1130function gitRows($: EngineInterface, e: SiteInput, git: GitState, fetchedAt: number): RenderElement[] {
1131  const { Box, Text } = $.ui.resolve(e)
1132  const rows: RenderElement[] = []
1133  const stash = git.stashes > 0 ? <Text dimColor>{`≡ ${git.stashes} stash${git.stashes === 1 ? '' : 'es'}`}</Text> : undefined
1134
1135  if (git.operation !== null || git.conflicts > 0) {
1136    rows.push(
1137      workspaceRow(
1138        $,
1139        e,
1140        <Text color="warning" bold>⚠</Text>,
1141        <Box gap={1}>
1142          {git.operation !== null ? (
1143            <Text color="warning" bold>{`${git.operation.toUpperCase()}${git.step === null ? '' : ` ${git.step}`}`}</Text>
1144          ) : null}
1145          {git.conflicts > 0 ? (
1146            <Text color="error" bold>{`${git.conflicts} conflict${git.conflicts === 1 ? '' : 's'}`}</Text>
1147          ) : null}
1148        </Box>,
1149      ),
1150    )
1151  }
1152  if (git.subdir !== null || git.isWorktree) {
1153    rows.push(
1154      workspaceRow(
1155        $,
1156        e,
1157        <Text color={ORANGE}>⌂</Text>,
1158        <Text dimColor wrap="truncate-end">{git.subdir === null ? git.repo : `${git.repo} › ${git.subdir}`}</Text>,
1159        git.isWorktree ? <Text dimColor>⧉ worktree</Text> : undefined,
1160      ),
1161    )
1162  }
1163  rows.push(
1164    workspaceRow(
1165      $,
1166      e,
1167      <Text color={ORANGE}>⎇</Text>,
1168      git.isDetached ? (
1169        <Text dimColor bold wrap="truncate-end">{`detached @ ${git.oid?.slice(0, 7) ?? '—'}`}</Text>
1170      ) : (
1171        <Text dimColor bold wrap="truncate-end">{git.branch}</Text>
1172      ),
1173      syncMark($, e, git),
1174    ),
1175  )
1176  const isClean = git.staged + git.modified + git.untracked + git.conflicts === 0
1177  rows.push(
1178    isClean
1179      ? workspaceRow($, e, <Text color={ORANGE}>✓</Text>, <Text dimColor>clean</Text>, stash)
1180      : workspaceRow(
1181          $,
1182          e,
1183          <Text color={ORANGE}>±</Text>,
1184          <Box gap={2}>
1185            {git.staged > 0 ? <Text dimColor>{`✚ ${git.staged} staged`}</Text> : null}
1186            {git.modified > 0 ? <Text dimColor>{`● ${git.modified} modified`}</Text> : null}
1187            {git.untracked > 0 ? <Text dimColor>{`? ${git.untracked} new`}</Text> : null}
1188          </Box>,
1189          stash,
1190        ),
1191  )
1192  if (git.lastCommit !== null) {
1193    rows.push(
1194      workspaceRow(
1195        $,
1196        e,
1197        <Text color={ORANGE}>◷</Text>,
1198        <Box gap={1}>
1199          <Box flexShrink={0}>
1200            <Text dimColor>{git.lastCommit.hash}</Text>
types/index.d.ts 115 lines
1export type GitOperation = 'rebase' | 'merge' | 'cherry-pick' | 'revert' | 'bisect'
2
3export type GitCommit = {
4  hash: string
5  subject: string
6  at: number
7}
8
9export type GitState = {
10  branch: string
11  isDetached: boolean
12  oid: string | null
13  upstream: string | null
14  ahead: number | null
15  behind: number | null
16  staged: number
17  modified: number
18  untracked: number
19  conflicts: number
20  stashes: number
21  operation: GitOperation | null
22  step: string | null
23  lastCommit: GitCommit | null
24  repo: string
25  subdir: string | null
26  isWorktree: boolean
27}
28
29export type KubeState = {
30  context: string
31  namespace: string | null
32}
33
34export type RateLimitGauge = {
35  label: string
36  percent: number
37  resetsAt: number | null
38}
39
40export type HudInfo = {
41  version: string
42  model: string
43  cwd: string
44  home: string | null
45  git: GitState | null
46  kube: KubeState | null
47  contextPercent: number | null
48  contextTokens: number | null
49  contextWindow: number
50  rateLimits: RateLimitGauge[]
51  costUsd: number | null
52  prompts: number | null
53  sessionStartedAt: number | null
54  fetchedAt: number
55  effortSetting: string | null
56  caveman: string | null
57  sessionTitle: string | null
58}
59
60export type AgentStatusLabel = 'running' | 'idle' | 'completed' | 'failed' | 'killed'
61
62export type AgentRow = {
63  id: string
64  toolUseId: string
65  description: string
66  type: string
67  model: string
68  isBackground: boolean
69  status: AgentStatusLabel
70  startedAt: number
71  endedAt: number | null
72  tools: number
73  lastTool: string | null
74  tokens: number | null
75}
76
77export type AgentToolOutcome = 'pending' | 'ok' | 'error'
78
79export type AgentLogEntry =
80  | { kind: 'prompt'; text: string }
81  | { kind: 'reply'; text: string }
82  | { kind: 'tool'; name: string; summary: string; outcome: AgentToolOutcome }
83
84export type AgentLogView = {
85  agentId: string
86  entries: AgentLogEntry[]
87  isDenied: boolean
88}
89
90export type TurnGroupView = {
91  head: string
92  texts: number
93  tools: Record<string, number>
94  last: string | null
95  isActive: boolean
96}
97
98declare module 'claude-code' {
99  interface PluginState {
100    'orange-hud': {
101      info: HudInfo | null
102      hint: string
103      agents: AgentRow[]
104      now: number
105      effort: string | null
106      groupOf: StateFamily<string | null>
107      turnGroup: StateFamily<TurnGroupView | null>
108      groupOpen: StateFamily<boolean>
109      showThoughts: boolean
110      viewing: string | null
111      agentLog: AgentLogView | null
112    }
113  }
114}
115