A quiet two-pane band above the prompt: turn pulse, task and agents on the left; model, session cost, cache warmth with a one-shot Warm button, path and git…

A Claude Code mod that draws a quiet two-pane band above the prompt.
turn pulse │ model · effort · session cost · cache hit · warm
task + agents │ path · git · worktree
(status, file/command) │ last error
│ 5h limit · 7d limit
│ context · legend
▶/✓ turn pulse (elapsed · tools · files · tokens out · failures), current task, one ⧗ row per background shell or monitor (refreshed at turn end), agents with status and their latest file or command.Warm button (no automatic warming); path then ⎇ branch · ahead/behind · changes with ⊂ linked worktree (repo name only when the path does not already end with it); last failure; 5h and 7d limits side by side with pace marks; context bar by category with the auto-compact mark, usage as used/auto-max with the auto-compact threshold in the mark's colour, and the legend.Below 100 columns only the right pane is shown, full width.
At the prompt of a terminal session:
/plugin install simple-status-line --marketplace scolladon/simple-status-line
Answer y to add the marketplace, then pick a scope.
From a shell:
claude plugin install simple-status-line --marketplace scolladon/simple-status-line
In the desktop app: click + next to the prompt box, then Plugins, Add marketplace with scolladon/simple-status-line, then Add plugin.
cacheTtl: 1h (default, most subscriptions) or 5m (API-key billing).
claude --plugin-dir .
claude plugin validate .
claude plugin test .hooks/register.tsx 557 lines1import { atom, read, update } from 'claude-code'
2import type {
3 Color,
4 Elements,
5 EngineInterface,
6 ModelUsage,
7 Register,
8 SessionContextBreakdown,
9 SessionRateLimit,
10} from 'claude-code'
11
12import type { CacheView, ContextView, LimitWindow, Slice } from '../types'
13
14import type { Line } from './line'
15import { activityPane, pollAgents, registerActivity, startActivity } from './activity'
16import { modelLabel, recordStep, startModel } from './model'
17import { addOutput, endPulse, failureLine, pulseLine, readCost, registerPulse, saveCost, startPulse, usd } from './pulse'
18import { refreshWorkspace, registerWorkspace, startWorkspace, workspaceLine } from './workspace'
19
20const SECOND = 1000
21const MINUTE = 60 * SECOND
22const HOUR = 60 * MINUTE
23const DAY = 24 * HOUR
24const WINDOW_MS: Record<string, number> = { five_hour: 5 * HOUR, seven_day: 7 * DAY }
25const WINDOW_LABEL: Record<string, string> = { five_hour: '5h', seven_day: '7d' }
26const SHOWN_WINDOWS = ['five_hour', 'seven_day']
27const TTL_MS: Record<string, number> = { '1h': HOUR, '5m': 5 * MINUTE }
28const LIMITS_KEY = 'limits'
29const WARM_PROMPT = 'Reply with the single word OK.'
30
31const MIN_BAR = 8
32const MIN_LIMIT_BAR = 3
33const WINDOW_TAG_WIDTH = 2
34const PERCENT_WIDTH = 4
35const RESET_WIDTH = 6
36// Tag, percent, reset and the three gaps around the bar and the percent.
37const LIMIT_WINDOW_FIXED = WINDOW_TAG_WIDTH + PERCENT_WIDTH + RESET_WIDTH + 3
38const LIMIT_WINDOW_GAP = 2
39const LEGEND_SIZE = 5
40const SPLIT_MIN_COLUMNS = 100
41// Wide enough for both limit bars and the context bar to stay readable.
42const GAUGE_WIDTH = 48
43const RIGHT_MAX_SHARE = 0.6
44const DOT_GAP = ' · '
45// Reserved at their widest ('warm 59m', '[ Warming… ]') so the countdown does not shift the panes.
46const WARMTH_WIDTH = 8
47const WARM_BUTTON_WIDTH = 12
48const AUTO_COLOR: Color = 'warning'
49const SEPARATOR_WIDTH = 3
50const SEPARATOR = '│'
51const MIN_LEFT_ROWS = 3
52const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
53const FULL = '█'
54const EMPTY = '─'
55const MARK = '│'
56const SQUARE = '■'
57const SHORT_LABEL: Record<string, string> = {
58 'System prompt': 'system',
59 'System tools': 'tools',
60 'MCP tools': 'mcp',
61 'Custom agents': 'agents',
62 'Memory files': 'memory',
63 Skills: 'skills',
64 Messages: 'messages',
65}
66
67type Tags = Pick<Elements['terminal'], 'Box' | 'Text'>
68type ButtonTags = Tags & Pick<Elements['terminal'], 'Button'>
69type Stored = { at: number; windows: LimitWindow[] }
70
71const limits = atom({ plugin: 'simple-status-line', key: 'limits' } as const, [])
72const context = atom({ plugin: 'simple-status-line', key: 'context' } as const, null)
73const cache = atom({ plugin: 'simple-status-line', key: 'cache' } as const, {
74 hitPercent: null,
75 sentAt: null,
76 isWarming: false,
77})
78const now = atom({ plugin: 'simple-status-line', key: 'now' } as const, 0)
79
80// Module-local on purpose: a reload starts these over, which only costs one missed tick.
81let isTurnRunning = false
82let isForking = false
83let limitsAt = 0
84
85const tokens = (count: number) => {
86 if (count < 1000) {
87 return String(count)
88 }
89
90 return count < 1_000_000 ? `${Math.round(count / 1000)}k` : `${(count / 1_000_000).toFixed(1)}M`
91}
92
93const span = (duration: number) => {
94 const days = Math.floor(duration / DAY)
95 const hours = Math.floor((duration % DAY) / HOUR)
96 const minutes = Math.floor((duration % HOUR) / MINUTE)
97
98 if (days > 0) {
99 return `${days}d ${hours}h`
100 }
101
102 return hours > 0 ? `${hours}h${String(minutes).padStart(2, '0')}` : `${Math.max(1, minutes)}m`
103}
104
105const warmLeft = (duration: number) =>
106 duration >= MINUTE ? `${Math.floor(duration / MINUTE)}m` : `${Math.ceil(duration / SECOND)}s`
107
108const limitTone = (percent: number): Color =>
109 percent >= 90 ? 'error' : percent >= 75 ? 'warning' : 'success'
110
111const clamp = (value: number, max: number) => Math.max(0, Math.min(max, value))
112
113const shortLabel = (name: string) => SHORT_LABEL[name] ?? name.toLowerCase()
114
115const hitOf = (usage: ModelUsage) => {
116 const total = usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
117
118 return total > 0 ? (usage.cache_read_input_tokens / total) * 100 : null
119}
120
121const toView = (breakdown: SessionContextBreakdown): ContextView => ({
122 slices: breakdown.categories
123 .filter(row => row.kind === 'used' && row.tokens > 0)
124 .map(row => ({ label: shortLabel(row.name), tokens: row.tokens, color: row.color })),
125 used: breakdown.totalTokens,
126 max: breakdown.rawMaxTokens,
127 autoAt: breakdown.isAutoCompactEnabled ? (breakdown.autoCompactThreshold ?? null) : null,
128})
129
130const effectiveWindow = (window: LimitWindow, at: number): LimitWindow =>
131 window.resetsAt !== undefined && Date.parse(window.resetsAt) <= at
132 ? { kind: window.kind, percentUsed: 0 }
133 : window
134
135const pace = (window: LimitWindow, at: number) => {
136 const length = WINDOW_MS[window.kind]
137
138 if (length === undefined || window.resetsAt === undefined) {
139 return null
140 }
141
142 return clamp(1 - (Date.parse(window.resetsAt) - at) / length, 1)
143}
144
145async function refreshContext($: EngineInterface) {
146 const usage = await $.session.usage({ breakdown: 'summary' })
147 const breakdown = usage.context.breakdown
148 await update($, context, () => (breakdown === undefined ? null : toView(breakdown)))
149}
150
151async function saveLimits($: EngineInterface, list: readonly SessionRateLimit[]) {
152 if (list.length === 0) {
153 return
154 }
155
156 const held = await read($, limits)
157 const kept = held.filter(window => !list.some(fresh => fresh.kind === window.kind))
158 const windows = [...list, ...kept]
159 limitsAt = await $.clock.now()
160 await update($, limits, () => windows)
161 await $.store.set(LIMITS_KEY, { at: limitsAt, windows })
162}
163
164// Another session may have heard fresher figures; an idle one would otherwise show stale limits.
165async function adoptStoredLimits($: EngineInterface) {
166 const stored = (await $.store.get(LIMITS_KEY)) as Stored | undefined
167
168 if (stored === undefined || stored.at <= limitsAt) {
169 return
170 }
171
172 limitsAt = stored.at
173 await update($, limits, () => stored.windows)
174}
175
176async function tick($: EngineInterface, ttlMs: number) {
177 const at = await $.clock.now()
178 const previous = await read($, now)
179 const { sentAt } = await read($, cache)
180 const isCountingDown = isTurnRunning || (sentAt !== null && at - sentAt <= ttlMs + 2 * SECOND)
181 const isNewMinute = Math.floor(at / MINUTE) !== Math.floor(previous / MINUTE)
182
183 if (isNewMinute) {
184 await adoptStoredLimits($)
185 }
186
187 await pollAgents($, at)
188
189 if (isCountingDown || isNewMinute) {
190 await update($, now, () => at)
191 }
192}
193
194async function warm($: EngineInterface) {
195 const held = await read($, cache)
196
197 if (isTurnRunning || held.isWarming) {
198 return
199 }
200
201 isForking = true
202 await update($, cache, view => ({ ...view, isWarming: true }))
203
204 try {
205 const at = await $.clock.now()
206 const result = await $.model.fork({ prompt: WARM_PROMPT })
207
208 if (result.isAnswered) {
209 await update($, cache, view => ({ ...view, sentAt: at }))
210 } else {
211 $.ui.toast(`simple-status-line: warm-up not sent (${result.reason})`)
212 }
213 } finally {
214 isForking = false
215 await update($, cache, view => ({ ...view, isWarming: false }))
216 }
217}
218
219const limitBar = ({ Text }: Tags, percent: number, paceAt: number | null, width: number) => {
220 const eighths = Math.round((clamp(percent, 100) / 100) * width * 8)
221 const full = Math.floor(eighths / 8)
222 const partial = EIGHTHS[eighths % 8] ?? ''
223 const emptyCount = width - full - (partial === '' ? 0 : 1)
224 const markIndex = paceAt === null ? -1 : Math.min(width - 1, Math.floor(paceAt * width))
225 const empties = Array.from({ length: emptyCount }, (_, i) =>
226 width - emptyCount + i === markIndex ? MARK : EMPTY,
227 ).join('')
228
229 return (
230 <Text>
231 <Text color={limitTone(percent)}>{FULL.repeat(full) + partial}</Text>
232 <Text dimColor>{empties}</Text>
233 </Text>
234 )
235}
236
237const limitWindow = (tags: Tags, window: LimitWindow, at: number, barWidth: number) => {
238 const { Box, Text } = tags
239 const shown = effectiveWindow(window, at)
240 const left = shown.resetsAt === undefined ? null : Date.parse(shown.resetsAt) - at
241
242 return (
243 <Box key={window.kind} flexDirection="row" gap={1} flexShrink={0}>
244 <Text dimColor>{WINDOW_LABEL[window.kind] ?? window.kind}</Text>
245 {limitBar(tags, shown.percentUsed, pace(shown, at), barWidth)}
246 <Box minWidth={PERCENT_WIDTH} justifyContent="flex-end">
247 <Text color={limitTone(shown.percentUsed)}>{`${Math.round(shown.percentUsed)}%`}</Text>
248 </Box>
249 <Box minWidth={RESET_WIDTH} justifyContent="flex-end">
250 <Text dimColor>{left === null ? '' : span(left)}</Text>
251 </Box>
252 </Box>
253 )
254}
255
256const limitRow = (tags: Tags, list: readonly LimitWindow[], at: number, width: number) => {
257 const shown = SHOWN_WINDOWS.map(kind => list.find(window => window.kind === kind))
258 .filter((window): window is LimitWindow => window !== undefined)
259
260 if (shown.length === 0) {
261 return null
262 }
263
264 const gaps = (shown.length - 1) * LIMIT_WINDOW_GAP
265 const barWidth = Math.max(MIN_LIMIT_BAR, Math.floor((width - gaps) / shown.length) - LIMIT_WINDOW_FIXED)
266
267 return (
268 <tags.Box key="limits" flexDirection="row" justifyContent="space-between" width={width}>
269 {shown.map(window => limitWindow(tags, window, at, barWidth))}
270 </tags.Box>
271 )
272}
273
274const stackedBar = ({ Text }: Tags, view: ContextView, width: number) => {
275 let cumulative = 0
276 let drawn = 0
277 const pieces = view.slices.map(slice => {
278 cumulative += slice.tokens
279 const edge = Math.min(width, Math.round((cumulative / view.max) * width))
280 const cells = Math.max(0, edge - drawn)
281 drawn += cells
282
283 return <Text key={slice.label} color={slice.color}>{FULL.repeat(cells)}</Text>
284 })
285 const markIndex = view.autoAt === null ? -1 : Math.min(width - 1, Math.round((view.autoAt / view.max) * width))
286 const isMarkShown = markIndex >= drawn
287 const before = (isMarkShown ? markIndex : width) - drawn
288 const after = isMarkShown ? width - markIndex - 1 : 0
289
290 return (
291 <Text>
292 {pieces}
293 <Text dimColor>{EMPTY.repeat(before)}</Text>
294 {isMarkShown && <Text color={AUTO_COLOR}>{MARK}</Text>}
295 <Text dimColor>{EMPTY.repeat(after)}</Text>
296 </Text>
297 )
298}
299
300const legendText = (slice: Slice) => `${slice.label} ${tokens(slice.tokens)}`
301
302// Each item takes its square, a space, its text and a two-cell margin.
303const fitting = (slices: readonly Slice[], width: number) => {
304 let used = 0
305
306 return slices.filter(slice => {
307 used += legendText(slice).length + 4
308
309 return used <= width
310 })
311}
312
313const legend = ({ Box, Text }: Tags, slices: readonly Slice[], width: number) =>
314 fitting([...slices].sort((a, b) => b.tokens - a.tokens).slice(0, LEGEND_SIZE), width)
315 .map(slice => (
316 <Box key={slice.label} flexDirection="row" marginLeft={2} flexShrink={0}>
317 <Text color={slice.color}>{SQUARE} </Text>
318 <Text dimColor>{legendText(slice)}</Text>
319 </Box>
320 ))
321
322const contextRows = (tags: Tags, view: ContextView | null, width: number) => {
323 const { Box, Text } = tags
324
325 if (view === null || view.max === 0) {
326 return null
327 }
328
329 const percent = `${Math.round((view.used / view.max) * 100)}%`
330 const used = `${tokens(view.used)}/`
331 const autoAt = view.autoAt === null ? '' : tokens(view.autoAt)
332 const max = view.autoAt === null ? tokens(view.max) : `-${tokens(view.max)}`
333 const isNearAuto = view.autoAt !== null && view.used >= view.autoAt * 0.9
334 const tailWidth = percent.length + 1 + used.length + autoAt.length + max.length + 1
335 const barWidth = Math.max(MIN_BAR, width - tailWidth)
336
337 return (
338 <Box key="context" flexDirection="column">
339 <Box flexDirection="row" gap={1}>
340 {stackedBar(tags, view, barWidth)}
341 <Text color={isNearAuto ? 'warning' : 'text'}>{percent}</Text>
342 <Text>
343 <Text dimColor>{used}</Text>
344 {autoAt !== '' && <Text color={AUTO_COLOR}>{autoAt}</Text>}
345 <Text dimColor>{max}</Text>
346 </Text>
347 </Box>
348 <Box flexDirection="row" justifyContent="flex-end" width={width}>
349 {legend(tags, view.slices, width)}
350 </Box>
351 </Box>
352 )
353}
354
355const warmthText = ({ Text }: Tags, view: CacheView, at: number, ttlMs: number) => {
356 if (view.sentAt === null) {
357 return null
358 }
359
360 const left = view.sentAt + ttlMs - at
361
362 return left > 0 ? (
363 <Text color="success">{`warm ${warmLeft(left)}`}</Text>
364 ) : (
365 <Text color="warning">cold</Text>
366 )
367}
368
369const summaryWords = (model: string | null, sessionCost: number | null, view: CacheView) =>
370 [
371 model,
372 sessionCost === null ? null : usd(sessionCost),
373 view.hitPercent === null ? '–' : `${Math.round(view.hitPercent)}% hit`,
374 ].filter((word): word is string => word !== null)
375
376const warmthWidth = DOT_GAP.length + WARMTH_WIDTH + 1 + WARM_BUTTON_WIDTH
377
378const summaryLine = (
379 tags: ButtonTags,
380 view: CacheView,
381 at: number,
382 ttlMs: number,
383 model: string | null,
384 sessionCost: number | null,
385 onWarm: () => void,
386): Line => {
387 const { Box, Button, Text } = tags
388 const words = summaryWords(model, sessionCost, view)
389 const isWarmShown = view.sentAt !== null
390 const width = words.join(DOT_GAP).length + (isWarmShown ? warmthWidth : 0)
391
392 return {
393 width,
394 node: (
395 <Box key="summary" flexDirection="row" justifyContent="flex-end" gap={1}>
396 {words.flatMap((word, index) => [
397 ...(index === 0 ? [] : [<Text key={`dot-${index}`} dimColor>·</Text>]),
398 <Box key={`word-${index}`} flexShrink={index === 0 ? 1 : 0}>
399 <Text wrap="truncate-end">{word}</Text>
400 </Box>,
401 ])}
402 {isWarmShown && <Text dimColor>·</Text>}
403 {isWarmShown && <Box minWidth={WARMTH_WIDTH}>{warmthText(tags, view, at, ttlMs)}</Box>}
404 {isWarmShown && (
405 <Box minWidth={WARM_BUTTON_WIDTH}>
406 <Button
407 key="warm"
408 label={view.isWarming ? 'Warming…' : 'Warm'}
409 variant="secondary"
410 onPress={onWarm}
411 />
412 </Box>
413 )}
414 </Box>
415 ),
416 }
417}
418
419const rightWidthOf = (columns: number, lines: readonly Line[]) =>
420 Math.min(Math.floor(columns * RIGHT_MAX_SHARE), Math.max(GAUGE_WIDTH, ...lines.map(line => line.width)))
421
422export const register: Register = (on, options) => {
423 const ttlMs = TTL_MS[String(options.cacheTtl ?? '1h')] ?? HOUR
424 registerActivity(on)
425 registerWorkspace(on)
426 registerPulse(on)
427
428 on('session.start', async ($, e, next) => {
429 const startedAt = await $.clock.now()
430 await update($, now, () => startedAt)
431 await adoptStoredLimits($)
432 const usage = await $.session.usage()
433 await saveLimits($, usage.rateLimits)
434 await saveCost($, usage.cost?.usd)
435 await refreshContext($)
436 await startActivity($, e.cwd)
437 await startWorkspace($, e.cwd)
438 await startModel($)
439 $.clock.every(SECOND, () => void tick($, ttlMs))
440
441 return next(e)
442 })
443
444 // A /clear wipes plugin state and raises no session.start, only this.
445 on('classic.SessionStart', async ($, e, next) => {
446 if (e.source === 'clear') {
447 await adoptStoredLimits($)
448 await refreshContext($)
449 }
450
451 return next(e)
452 }).catch(($, e, next) => next(e))
453
454 on('session.measure', async ($, e, next) => {
455 if (e.changed.includes('rateLimits')) {
456 await saveLimits($, e.rateLimits)
457 }
458
459 await saveCost($, e.cost?.usd)
460
461 return next(e)
462 })
463
464 on('turn.step', async function* ($, e, next) {
465 if (isForking) {
466 return yield* next(e)
467 }
468
469 if (e.agentId === undefined) {
470 isTurnRunning = true
471 const at = await $.clock.now()
472 await update($, cache, view => ({ ...view, sentAt: at }))
473 await recordStep($, e.effort)
474
475 if (e.index === 0) {
476 await startPulse($, at)
477 }
478 }
479
480 const result = yield* next(e)
481 await addOutput($, result.usage?.output_tokens ?? 0)
482
483 return result
484 })
485
486 on('turn.complete', async ($, e, next) => {
487 if (e.agentId !== undefined) {
488 return next(e)
489 }
490
491 isTurnRunning = false
492 const hitPercent = e.usage === undefined ? null : hitOf(e.usage)
493
494 if (hitPercent !== null) {
495 await update($, cache, view => ({ ...view, hitPercent }))
496 }
497
498 await refreshContext($)
499 await endPulse($, await $.clock.now())
500 void refreshWorkspace($)
501
502 return next(e)
503 })
504
505 on('session.compact', async ($, e, next) => {
506 const result = await next(e)
507 await refreshContext($)
508
509 return result
510 }).catch(($, e, next) => next(e))
511
512 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
513 const below = await next(e)
514
515 if (e.props.hasSurvey) {
516 return below
517 }
518
519 const tags = $.ui.resolve(e)
520 const at = await read($, now)
521 const columns = e.props.bodyColumns
522 const isSplit = columns >= SPLIT_MIN_COLUMNS
523 const summary = summaryLine(tags, await read($, cache), at, ttlMs, await modelLabel($), await readCost($), () => void warm($))
524 const workspaceRow = await workspaceLine($, tags)
525 const lines = workspaceRow === null ? [summary] : [summary, workspaceRow]
526 const width = isSplit ? rightWidthOf(columns, lines) : columns
527 const contextView = await read($, context)
528 const limitLine = limitRow(tags, await read($, limits), at, width)
529 const failure = await failureLine($, tags)
530 const rows = [...lines.map(line => line.node), ...failure, limitLine, contextRows(tags, contextView, width)]
531 const rightHeight = lines.length + failure.length + (limitLine === null ? 0 : 1) + (contextView === null ? 0 : 2)
532 const height = Math.max(MIN_LEFT_ROWS, rightHeight)
533 const pulse = isSplit ? await pulseLine($, tags, at) : []
534 const right = <tags.Box key="right" flexDirection="column" width={width} flexShrink={0}>{rows}</tags.Box>
535
536 return (
537 <tags.Box flexDirection="column">
538 {isSplit ? (
539 <tags.Box flexDirection="row">
540 <tags.Box key="left" flexDirection="column" width={columns - width - SEPARATOR_WIDTH} flexShrink={0}>
541 {pulse}
542 {await activityPane($, tags, e.props.isWorking, height - pulse.length)}
543 </tags.Box>
544 <tags.Box width={SEPARATOR_WIDTH} flexShrink={0} justifyContent="center">
545 <tags.Text dimColor>{Array.from({ length: height }, () => SEPARATOR).join('\n')}</tags.Text>
546 </tags.Box>
547 {right}
548 </tags.Box>
549 ) : (
550 right
551 )}
552 {below}
553 </tags.Box>
554 )
555 })
556}
557hooks/line.ts 5 lines1import type { RenderElement } from 'claude-code'
2
3// A drawn row and how many cells it needs, so a pane can size itself to its content.
4export type Line = { width: number; node: RenderElement }
5hooks/activity.tsx 317 lines1import { atom, read, update } from 'claude-code'
2import type { AgentStatus, BuiltinToolInputs, Color, Elements, EngineInterface, On, ToolUseSummary } from 'claude-code'
3
4import type { Action, ActionKind, AgentView, BackgroundView, TaskItem } from '../types'
5
6const MAIN = 'main'
7const MAIN_LABEL = 'main'
8const NAME_MAX = 12
9const AGENT_REFRESH_MS = 2000
10const LIVE: readonly string[] = ['pending', 'running', 'waiting']
11
12const STATUS_GLYPH: Record<AgentStatus, { glyph: string; color: Color }> = {
13 running: { glyph: '●', color: 'success' },
14 pending: { glyph: '○', color: 'inactive' },
15 waiting: { glyph: '◐', color: 'warning' },
16 idle: { glyph: '○', color: 'inactive' },
17 completed: { glyph: '✓', color: 'inactive' },
18 failed: { glyph: '✗', color: 'error' },
19 killed: { glyph: '✗', color: 'inactive' },
20}
21const ACTION_GLYPH: Record<ActionKind, string> = { read: '›', write: '✎', command: '$' }
22const BACKGROUND_GLYPH: Record<string, string> = { shell: '$', monitor: '◉' }
23const BACKGROUND_FALLBACK_GLYPH = '◆'
24// Background subagents already have their own agent row.
25const SUBAGENT_KIND = 'subagent'
26
27type Tags = Pick<Elements['terminal'], 'Box' | 'Text'>
28type Row = { id: string; name: string; status: AgentStatus; action: Action | undefined }
29type BackgroundSummary = { id: string; type: string; description: string; command?: string }
30type TaskCall =
31 | { tool: 'TodoWrite'; input: BuiltinToolInputs['TodoWrite'] }
32 | { tool: 'TaskCreate'; input: BuiltinToolInputs['TaskCreate']; result: unknown }
33 | { tool: 'TaskUpdate'; input: BuiltinToolInputs['TaskUpdate'] }
34
35const TASK_TOOLS: readonly string[] = ['TodoWrite', 'TaskCreate', 'TaskUpdate']
36
37const tasks = atom({ plugin: 'simple-status-line', key: 'tasks' } as const, [])
38const actions = atom({ plugin: 'simple-status-line', key: 'actions' } as const, {})
39const agents = atom({ plugin: 'simple-status-line', key: 'agents' } as const, [])
40const background = atom({ plugin: 'simple-status-line', key: 'background' } as const, [])
41
42let cwd = ''
43let lastAgentRefresh = 0
44
45const relative = (path: string) =>
46 cwd !== '' && path.startsWith(`${cwd}/`) ? path.slice(cwd.length + 1) : path
47
48const firstLine = (text: string) => text.trim().split('\n')[0] ?? ''
49
50async function record($: EngineInterface, agentId: string | undefined, kind: ActionKind, text: string) {
51 const action: Action = { kind, text }
52 await update($, actions, held => ({ ...held, [agentId ?? MAIN]: action }))
53}
54
55async function refreshAgents($: EngineInterface) {
56 lastAgentRefresh = await $.clock.now()
57 const list = await $.agent.list()
58 const views: AgentView[] = list.map(agent => ({ id: agent.id, name: agent.type, status: agent.status }))
59 await update($, agents, () => views)
60}
61
62export async function pollAgents($: EngineInterface, at: number) {
63 const held = await read($, agents)
64 const hasLive = held.some(agent => LIVE.includes(agent.status))
65
66 if (hasLive && at - lastAgentRefresh >= AGENT_REFRESH_MS) {
67 await refreshAgents($)
68 }
69}
70
71// This build types TaskCreate's result loosely, so its `{ task: { id } }` shape is checked here.
72const createdTaskId = (result: unknown): string | undefined => {
73 if (typeof result !== 'object' || result === null || !('task' in result)) {
74 return undefined
75 }
76
77 const { task } = result
78
79 return typeof task === 'object' && task !== null && 'id' in task && typeof task.id === 'string'
80 ? task.id
81 : undefined
82}
83
84const patchTask = (list: readonly TaskItem[], id: string, patch: Partial<TaskItem>) =>
85 list.map(task => (task.id === id ? { ...task, ...patch } : task))
86
87const todoList = (input: BuiltinToolInputs['TodoWrite']): TaskItem[] =>
88 input.todos.map((todo, index) => ({
89 id: String(index),
90 subject: todo.content,
91 activeForm: todo.activeForm,
92 status: todo.status,
93 }))
94
95const createdTask = (input: BuiltinToolInputs['TaskCreate'], result: unknown): TaskItem[] => {
96 const id = createdTaskId(result)
97
98 return id === undefined ? [] : [{ id, subject: input.subject, activeForm: input.activeForm, status: 'pending' }]
99}
100
101const updatedTasks = (list: readonly TaskItem[], input: BuiltinToolInputs['TaskUpdate']) => {
102 if (input.status === 'deleted') {
103 return list.filter(task => task.id !== input.taskId)
104 }
105
106 const patch: Partial<TaskItem> = {
107 ...(input.status === undefined ? {} : { status: input.status }),
108 ...(input.subject === undefined ? {} : { subject: input.subject }),
109 ...(input.activeForm === undefined ? {} : { activeForm: input.activeForm }),
110 }
111
112 return patchTask(list, input.taskId, patch)
113}
114
115const applyTaskCall = (list: readonly TaskItem[], call: TaskCall): TaskItem[] => {
116 switch (call.tool) {
117 case 'TodoWrite':
118 return todoList(call.input)
119 case 'TaskCreate':
120 return [...list, ...createdTask(call.input, call.result)]
121 case 'TaskUpdate':
122 return updatedTasks(list, call.input)
123 }
124}
125
126// Only answered calls changed the list; the transcript keeps only inputs the tool's schema accepted.
127const taskCallOf = (use: ToolUseSummary): TaskCall[] =>
128 TASK_TOOLS.includes(use.tool) && use.isError !== true && use.text !== undefined
129 ? [{ tool: use.tool, input: use.input, result: use.result } as TaskCall]
130 : []
131
132// A resumed session holds its tasks only in the transcript; nothing re-raises their calls.
133async function replayTasks($: EngineInterface) {
134 const messages = await $.session.messages()
135 const calls = messages.flatMap(message => message.toolUses).flatMap(taskCallOf)
136 await update($, tasks, () => calls.reduce(applyTaskCall, []))
137}
138
139const toBackground = (summaries: readonly BackgroundSummary[]): BackgroundView[] =>
140 summaries
141 .filter(summary => summary.type !== SUBAGENT_KIND)
142 .map(summary => ({
143 id: summary.id,
144 kind: summary.type,
145 label: firstLine(summary.command ?? summary.description),
146 }))
147
148async function recordBackground($: EngineInterface, summaries: readonly BackgroundSummary[] | undefined) {
149 await update($, background, () => toBackground(summaries ?? []))
150}
151
152export async function startActivity($: EngineInterface, workingDirectory: string) {
153 cwd = workingDirectory
154 await replayTasks($)
155 await refreshAgents($)
156}
157
158export const registerActivity = (on: On) => {
159 on('tool.call', { tool: 'Read' }, async ($, e, next) => {
160 await record($, e.agentId, 'read', relative(e.file_path))
161
162 return next(e)
163 }).catch(($, e, next) => next(e))
164
165 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
166 await record($, e.agentId, 'write', relative(e.file_path))
167
168 return next(e)
169 }).catch(($, e, next) => next(e))
170
171 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
172 await record($, e.agentId, 'write', relative(e.file_path))
173
174 return next(e)
175 }).catch(($, e, next) => next(e))
176
177 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
178 await record($, e.agentId, 'command', firstLine(e.command))
179
180 return next(e)
181 }).catch(($, e, next) => next(e))
182
183 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
184 await update($, tasks, held => applyTaskCall(held, { tool: 'TodoWrite', input: e }))
185
186 return next(e)
187 }).catch(($, e, next) => next(e))
188
189 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
190 const ran = await next(e)
191 await update($, tasks, held => applyTaskCall(held, { tool: 'TaskCreate', input: e, result: ran.result }))
192
193 return ran
194 }).catch(($, e, next) => next(e))
195
196 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
197 await update($, tasks, held => applyTaskCall(held, { tool: 'TaskUpdate', input: e }))
198
199 return next(e)
200 }).catch(($, e, next) => next(e))
201
202 on('classic.SubagentStart', async ($, e, next) => {
203 await refreshAgents($)
204
205 return next(e)
206 }).catch(($, e, next) => next(e))
207
208 on('classic.SubagentStop', async ($, e, next) => {
209 const result = await next(e)
210 await refreshAgents($)
211 await recordBackground($, e.background_tasks)
212
213 return result
214 }).catch(($, e, next) => next(e))
215
216 on('classic.Stop', async ($, e, next) => {
217 await recordBackground($, e.background_tasks)
218
219 return next(e)
220 }).catch(($, e, next) => next(e))
221}
222
223const rowsOf = (views: readonly AgentView[], held: Record<string, Action>, isWorking: boolean): Row[] => {
224 const main: Row = { id: MAIN, name: MAIN_LABEL, status: isWorking ? 'running' : 'idle', action: held[MAIN] }
225 const others = views.map(view => ({ id: view.id, name: view.name, status: view.status, action: held[view.id] }))
226 const live = others.filter(row => LIVE.includes(row.status))
227 const done = others.filter(row => !LIVE.includes(row.status)).reverse()
228
229 return [main, ...live, ...done]
230}
231
232const taskRow = ({ Box, Text }: Tags, list: readonly TaskItem[], isDim: boolean) => {
233 if (list.length === 0) {
234 return (
235 <Box key="task">
236 <Text dimColor>▸ no todos</Text>
237 </Box>
238 )
239 }
240
241 const current = list.find(task => task.status === 'in_progress') ?? list.find(task => task.status === 'pending')
242 const doneCount = list.filter(task => task.status === 'completed').length
243 const title = current === undefined ? 'all done' : (current.activeForm ?? current.subject)
244
245 return (
246 <Box key="task" flexDirection="row" justifyContent="space-between" gap={1}>
247 <Box flexShrink={1}>
248 <Text bold={!isDim} dimColor={isDim} wrap="truncate-end">{`▸ ${title}`}</Text>
249 </Box>
250 <Text dimColor>{`${doneCount}/${list.length}`}</Text>
251 </Box>
252 )
253}
254
255const backgroundRow = ({ Box, Text }: Tags, view: BackgroundView, isDim: boolean) => (
256 <Box key={`bg-${view.id}`}>
257 <Text dimColor={isDim} wrap="truncate-end">
258 {`⧗ ${BACKGROUND_GLYPH[view.kind] ?? BACKGROUND_FALLBACK_GLYPH} ${view.label}`}
259 </Text>
260 </Box>
261)
262
263const actionText = ({ Text }: Tags, action: Action | undefined, isDim: boolean) => {
264 if (action === undefined) {
265 return <Text dimColor>–</Text>
266 }
267
268 return (
269 <Text dimColor={isDim} wrap={action.kind === 'command' ? 'truncate-end' : 'truncate-start'}>
270 {`${ACTION_GLYPH[action.kind]} ${action.text}`}
271 </Text>
272 )
273}
274
275const agentRow = (tags: Tags, row: Row, nameWidth: number, isPaneIdle: boolean) => {
276 const { Box, Text } = tags
277 const { glyph, color } = STATUS_GLYPH[row.status]
278 const isDim = isPaneIdle || !LIVE.includes(row.status) && row.id !== MAIN
279
280 return (
281 <Box key={row.id} flexDirection="row" gap={1}>
282 <Text color={color}>{glyph}</Text>
283 <Box width={nameWidth} flexShrink={0}>
284 <Text dimColor={isDim} wrap="truncate-end">{row.name}</Text>
285 </Box>
286 <Box flexShrink={1} flexGrow={1}>
287 {row.status === 'waiting' ? <Text color="warning">waiting</Text> : actionText(tags, row.action, isDim)}
288 </Box>
289 </Box>
290 )
291}
292
293export async function activityPane($: EngineInterface, tags: Tags, isWorking: boolean, height: number) {
294 const list = await read($, tasks)
295 const rows = rowsOf(await read($, agents), await read($, actions), isWorking)
296 const running = await read($, background)
297 const isPaneIdle = !isWorking && !rows.some(row => row.id !== MAIN && LIVE.includes(row.status))
298 const slots = Math.max(1, height - 1)
299 const total = running.length + rows.length
300 const isOverflowing = total > slots
301 const budget = isOverflowing ? slots - 1 : slots
302 const shownBackground = running.slice(0, budget)
303 const shown = rows.slice(0, budget - shownBackground.length)
304 const nameWidth = Math.min(NAME_MAX, Math.max(0, ...shown.map(row => row.name.length)))
305
306 return (
307 <tags.Box flexDirection="column">
308 {taskRow(tags, list, isPaneIdle)}
309 {shownBackground.map(view => backgroundRow(tags, view, isPaneIdle))}
310 {shown.map(row => agentRow(tags, row, nameWidth, isPaneIdle))}
311 {isOverflowing && (
312 <tags.Text key="more" dimColor>{`+${total - shownBackground.length - shown.length} more`}</tags.Text>
313 )}
314 </tags.Box>
315 )
316}
317hooks/model.tsx 33 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface } from 'claude-code'
3
4import type { ModelView } from '../types'
5
6const model = atom({ plugin: 'simple-status-line', key: 'model' } as const, null)
7
8// $.session.model() names the model as /model shows it; the step's id is the API's, less readable.
9async function refreshModel($: EngineInterface, effort: string | null) {
10 const name = await $.session.model()
11 await update($, model, () => ({ name, effort }))
12}
13
14export async function startModel($: EngineInterface) {
15 const held = await read($, model)
16 await refreshModel($, held?.effort ?? null)
17}
18
19// Effort is only exposed on a step, so it shows from the first request on.
20export async function recordStep($: EngineInterface, effort: string | number | undefined) {
21 await refreshModel($, effort === undefined ? null : String(effort))
22}
23
24export async function modelLabel($: EngineInterface) {
25 const view = await read($, model)
26
27 if (view === null) {
28 return null
29 }
30
31 return view.effort === null ? view.name : `${view.name} · ${view.effort}`
32}
33hooks/pulse.tsx 173 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, On } from 'claude-code'
3
4import type { Failure, PulseView } from '../types'
5import { refreshWorkspace } from './workspace'
6
7const SECOND = 1000
8const MINUTE = 60 * SECOND
9const EXIT_CODE = /^Exit code (\d+)$/
10const DENIED_GLYPH = '⊘'
11const FAILED_GLYPH = '✗'
12const RUNNING_GLYPH = '▶ '
13const DONE_GLYPH = '✓ '
14
15type Tags = Pick<Elements['terminal'], 'Box' | 'Text'>
16
17const EMPTY: PulseView = {
18 startedAt: null,
19 endedAt: null,
20 tools: 0,
21 files: [],
22 outTokens: 0,
23 errors: 0,
24 lastFailure: null,
25}
26
27// A hot reload keeps $.state, so a value saved by an older version may lack newer fields.
28export const normalizePulse = (held: Partial<PulseView>): PulseView => ({ ...EMPTY, ...held })
29
30const pulse = atom({ plugin: 'simple-status-line', key: 'pulse' } as const, EMPTY)
31
32async function readPulse($: EngineInterface) {
33 return normalizePulse(await read($, pulse))
34}
35
36async function updatePulse($: EngineInterface, change: (held: PulseView) => PulseView) {
37 return update($, pulse, stored => change(normalizePulse(stored)))
38}
39const cost = atom({ plugin: 'simple-status-line', key: 'cost' } as const, null)
40
41const fileOf = (e: object) => ('file_path' in e && typeof e.file_path === 'string' ? e.file_path : null)
42
43const commandOf = (e: object) =>
44 'command' in e && typeof e.command === 'string' ? (e.command.trim().split('\n')[0] ?? '') : null
45
46const meaningfulLines = (text: string) =>
47 text.split('\n').map(line => line.trim()).filter(line => line !== '')
48
49// Bash reports a non-zero exit as "Exit code N" on its first line, the output after it.
50export const failureOf = (tool: string, target: string | null, text: string, isDenied: boolean): Failure => {
51 const [first = '', ...rest] = meaningfulLines(text)
52 const exitCode = first.match(EXIT_CODE)?.[1]
53
54 return {
55 tool,
56 target,
57 isDenied,
58 exitCode: exitCode === undefined ? null : Number(exitCode),
59 message: (exitCode === undefined ? first : rest[0]) ?? '',
60 }
61}
62
63const elapsed = (duration: number) => {
64 const minutes = Math.floor(duration / MINUTE)
65 const seconds = Math.floor((duration % MINUTE) / SECOND)
66
67 return minutes > 0 ? `${minutes}m${String(seconds).padStart(2, '0')}s` : `${seconds}s`
68}
69
70const tokens = (count: number) => (count < 1000 ? String(count) : `${(count / 1000).toFixed(1)}k`)
71
72export const usd = (value: number) => `$${value.toFixed(2)}`
73
74export async function saveCost($: EngineInterface, value: number | undefined) {
75 if (value !== undefined) {
76 await update($, cost, () => value)
77 }
78}
79
80export async function startPulse($: EngineInterface, at: number) {
81 await update($, pulse, () => ({ ...EMPTY, startedAt: at }))
82}
83
84export async function addOutput($: EngineInterface, count: number) {
85 await updatePulse($, held => ({ ...held, outTokens: held.outTokens + count }))
86}
87
88export async function endPulse($: EngineInterface, at: number) {
89 await updatePulse($, held => (held.startedAt === null ? held : { ...held, endedAt: at }))
90}
91
92export async function readCost($: EngineInterface) {
93 return read($, cost)
94}
95
96async function recordCall($: EngineInterface, file: string | null, failure: Failure | null) {
97 const isError = failure !== null
98 await updatePulse($, held => ({
99 ...held,
100 lastFailure: failure ?? held.lastFailure,
101 tools: held.tools + 1,
102 errors: held.errors + (isError ? 1 : 0),
103 files: file === null || held.files.includes(file) ? held.files : [...held.files, file],
104 }))
105}
106
107export const registerPulse = (on: On) => {
108 on('tool.call', async ($, e, next) => {
109 const ran = await next(e)
110 const target = fileOf(e) ?? commandOf(e)
111 const failure =
112 ran.deny !== undefined
113 ? failureOf(e.tool, target, ran.deny, true)
114 : ran.isError === true
115 ? failureOf(e.tool, target, ran.text ?? '', false)
116 : null
117 await recordCall($, fileOf(e), failure)
118
119 if (e.tool === 'Bash') {
120 void refreshWorkspace($)
121 }
122
123 return ran
124 }).catch(($, e, next) => next(e))
125}
126
127const stats = ({ Text }: Tags, view: PulseView, at: number) => {
128 const startedAt = view.startedAt ?? at
129 const parts = [
130 elapsed((view.endedAt ?? at) - startedAt),
131 `${view.tools} tools`,
132 `${view.files.length} files`,
133 `${tokens(view.outTokens)} out`,
134 ]
135
136 const isRunning = view.endedAt === null
137
138 return (
139 <Text wrap="truncate-end">
140 <Text color={isRunning ? 'claude' : 'success'}>{isRunning ? RUNNING_GLYPH : DONE_GLYPH}</Text>
141 <Text color={isRunning ? 'claude' : 'text'}>{parts[0]}</Text>
142 <Text dimColor>{` · ${parts.slice(1).join(' · ')}`}</Text>
143 {view.errors > 0 && <Text color="error">{` · ${view.errors} failed`}</Text>}
144 </Text>
145 )
146}
147
148const failureRow = ({ Text }: Tags, failure: Failure) => {
149 const parts = [
150 failure.target ?? failure.tool,
151 ...(failure.exitCode === null ? [] : [`exit ${failure.exitCode}`]),
152 ...(failure.message === '' ? [] : [failure.message]),
153 ]
154
155 return (
156 <Text key="failure" color={failure.isDenied ? 'warning' : 'error'} wrap="truncate-end">
157 {`${failure.isDenied ? DENIED_GLYPH : FAILED_GLYPH} ${parts.join(' · ')}`}
158 </Text>
159 )
160}
161
162export async function pulseLine($: EngineInterface, tags: Tags, at: number) {
163 const view = await readPulse($)
164
165 return view.startedAt === null ? [] : [<tags.Box key="turn">{stats(tags, view, at)}</tags.Box>]
166}
167
168export async function failureLine($: EngineInterface, tags: Tags) {
169 const view = await readPulse($)
170
171 return view.startedAt === null || view.lastFailure === null ? [] : [failureRow(tags, view.lastFailure)]
172}
173hooks/workspace.tsx 154 lines1import { atom, read, update } from 'claude-code'
2import type { Color, Elements, EngineInterface, On } from 'claude-code'
3
4import type { WorkspaceView } from '../types'
5import type { Line } from './line'
6
7const GIT_TIMEOUT_MS = 5000
8const BRANCH_GLYPH = '⎇ '
9const WORKTREE_GLYPH = '⊂'
10
11type Tags = Pick<Elements['terminal'], 'Box' | 'Text'>
12type GitStatus = Pick<WorkspaceView, 'branch' | 'ahead' | 'behind' | 'changed'>
13
14const workspace = atom({ plugin: 'simple-status-line', key: 'workspace' } as const, null)
15
16let cwd = ''
17let home = ''
18let isRefreshing = false
19
20const basename = (path: string) => path.split('/').filter(part => part !== '').pop() ?? path
21
22const tilde = (path: string) =>
23 home !== '' && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path
24
25const git = async ($: EngineInterface, args: readonly string[]) => {
26 const ran = await $.process.run(['git', ...args], { cwd, timeoutMs: GIT_TIMEOUT_MS })
27
28 return ran.exitCode === 0 ? ran.stdout : null
29}
30
31const parseStatus = (porcelain: string): GitStatus => {
32 const lines = porcelain.split('\n').filter(line => line !== '')
33 const head = lines.find(line => line.startsWith('# branch.head '))?.slice('# branch.head '.length)
34 const counts = lines.find(line => line.startsWith('# branch.ab '))?.match(/\+(\d+) -(\d+)/)
35
36 return {
37 branch: head === undefined || head === '(detached)' ? null : head,
38 ahead: Number(counts?.[1] ?? 0),
39 behind: Number(counts?.[2] ?? 0),
40 changed: lines.filter(line => !line.startsWith('#')).length,
41 }
42}
43
44async function readWorkspace($: EngineInterface): Promise<WorkspaceView> {
45 const notRepo: WorkspaceView = { path: tilde(cwd), repo: null, branch: null, ahead: 0, behind: 0, changed: 0, worktree: null }
46 const roots = await git($, ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-dir', '--git-common-dir'])
47 const [top, gitDir, commonDir] = roots?.trim().split('\n') ?? []
48
49 if (top === undefined || gitDir === undefined || commonDir === undefined) {
50 return notRepo
51 }
52
53 const status = parseStatus((await git($, ['status', '--porcelain=v2', '--branch'])) ?? '')
54 // A linked worktree keeps its own git dir under the main repository's common dir.
55 const isLinkedWorktree = gitDir !== commonDir
56
57 return {
58 ...notRepo,
59 ...status,
60 repo: basename(isLinkedWorktree ? commonDir.replace(/\/\.git$/, '') : top),
61 worktree: isLinkedWorktree ? basename(top) : null,
62 }
63}
64
65export async function refreshWorkspace($: EngineInterface) {
66 if (isRefreshing || cwd === '') {
67 return
68 }
69
70 isRefreshing = true
71
72 try {
73 const view = await readWorkspace($)
74 await update($, workspace, () => view)
75 } catch (error) {
76 // The previous view stays on screen; git missing or hung is not worth a toast.
77 $.ui.log(`simple-status-line: git refresh failed in ${cwd}: ${String(error)}`, { to: 'debug' })
78 } finally {
79 isRefreshing = false
80 }
81}
82
83export async function startWorkspace($: EngineInterface, workingDirectory: string) {
84 cwd = workingDirectory
85 home = (await $.env.get('HOME')) ?? ''
86 await refreshWorkspace($)
87}
88
89export const registerWorkspace = (on: On) => {
90 on('classic.CwdChanged', async ($, e, next) => {
91 const result = await next(e)
92 cwd = e.new_cwd
93 await refreshWorkspace($)
94
95 return result
96 }).catch(($, e, next) => next(e))
97}
98
99const PATH_GAP = ' · '
100
101type Segment = { text: string; color?: Color; isBold?: boolean; isDim?: boolean }
102
103const gitSegments = (view: WorkspaceView): Segment[] => {
104 if (view.repo === null) {
105 return [{ text: 'not a repository', isDim: true }]
106 }
107
108 const sync = [view.ahead > 0 ? `↑${view.ahead}` : '', view.behind > 0 ? `↓${view.behind}` : ''].filter(part => part !== '')
109 // The path already ends with the repo name when opened at its root; repeating it is noise.
110 const repo: Segment[] = basename(view.path) === view.repo ? [] : [{ text: view.repo, isBold: true }, { text: ' · ', isDim: true }]
111
112 return [
113 { text: BRANCH_GLYPH, isDim: true },
114 ...repo,
115 { text: view.branch ?? 'detached', color: 'suggestion' },
116 ...(view.worktree === null ? [] : [{ text: ` ${WORKTREE_GLYPH} ${view.worktree}`, color: 'warning' }]),
117 ...(sync.length === 0 ? [] : [{ text: ` ${sync.join(' ')}`, color: 'warning' }]),
118 { text: view.changed > 0 ? ` · ${view.changed} changed` : ' · clean', isDim: true },
119 ]
120}
121
122const segmentText = ({ Text }: Tags, segment: Segment, index: number) => (
123 <Text key={String(index)} color={segment.color} bold={segment.isBold} dimColor={segment.isDim}>{segment.text}</Text>
124)
125
126// The width lets the right pane size itself to this line rather than to a share of the band.
127export async function workspaceLine($: EngineInterface, tags: Tags): Promise<Line | null> {
128 const view = await read($, workspace)
129
130 if (view === null) {
131 return null
132 }
133
134 const segments = gitSegments(view)
135 const width = [view.path, PATH_GAP, ...segments.map(segment => segment.text)].join('').length
136
137 return {
138 width,
139 node: (
140 <tags.Box key="workspace" flexDirection="row">
141 <tags.Box flexShrink={1}>
142 <tags.Text wrap="truncate-start">{view.path}</tags.Text>
143 </tags.Box>
144 <tags.Box flexShrink={0}>
145 <tags.Text dimColor>{PATH_GAP}</tags.Text>
146 </tags.Box>
147 <tags.Box flexShrink={2}>
148 <tags.Text wrap="truncate-end">{segments.map((segment, index) => segmentText(tags, segment, index))}</tags.Text>
149 </tags.Box>
150 </tags.Box>
151 ),
152 }
153}
154types/index.d.ts 81 lines1export type LimitWindow = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type Slice = { label: string; tokens: number; color: string }
4
5export type ContextView = {
6 slices: Slice[]
7 used: number
8 max: number
9 autoAt: number | null
10}
11
12export type CacheView = { hitPercent: number | null; sentAt: number | null; isWarming: boolean }
13
14export type ActionKind = 'read' | 'write' | 'command'
15
16export type Action = { kind: ActionKind; text: string }
17
18export type AgentView = {
19 id: string
20 name: string
21 status: 'pending' | 'running' | 'waiting' | 'idle' | 'completed' | 'failed' | 'killed'
22}
23
24export type BackgroundView = { id: string; kind: string; label: string }
25
26export type TaskItem = {
27 id: string
28 subject: string
29 activeForm?: string
30 status: 'pending' | 'in_progress' | 'completed'
31}
32
33export type WorkspaceView = {
34 path: string
35 repo: string | null
36 branch: string | null
37 ahead: number
38 behind: number
39 changed: number
40 worktree: string | null
41}
42
43export type ModelView = { name: string; effort: string | null }
44
45export type Failure = {
46 tool: string
47 target: string | null
48 isDenied: boolean
49 exitCode: number | null
50 message: string
51}
52
53export type PulseView = {
54 startedAt: number | null
55 endedAt: number | null
56 tools: number
57 files: string[]
58 outTokens: number
59 errors: number
60 lastFailure: Failure | null
61}
62
63declare module 'claude-code' {
64 interface PluginState {
65 'simple-status-line': {
66 limits: LimitWindow[]
67 context: ContextView | null
68 cache: CacheView
69 now: number
70 tasks: TaskItem[]
71 actions: Record<string, Action>
72 agents: AgentView[]
73 background: BackgroundView[]
74 workspace: WorkspaceView | null
75 pulse: PulseView
76 cost: number | null
77 model: ModelView | null
78 }
79 }
80}
81