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

Terminal mods for Claude Code, packaged as a plugin marketplace.
An orange heads-up display for the Claude Code terminal.
← HUD goes back. The agent whose transcript Claude Code has in view is marked ▶./thoughts to expand or fold every turn.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.
| Command | What it does |
|---|---|
/hud | Opens the side pane when it is not showing |
/thoughts | Expands or folds the work of every turn |
statusLine; that keeps drawing under the prompt.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./plugin uninstall orange-hud@claude-cli
/plugin marketplace remove claude-cli
claude plugin validate orange-hud
claude plugin test orange-hudhooks/register.tsx 1737 lines1import { 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 lines1export 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