SLOPSHOPPER

simple-status-line

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…

newbandguardtoastmodelprocess
★ 1v0.3.2MITupdated 2026-10-22scolladon/simple-status-line
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · simple-status-line
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ▸ no todos │ │ │ claude-opus-5-5 · $0.42 · 93% hit ○ main $ cat .env /work/app · not a repository 5h ██████████▎────────────────────── ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▸ no todos │ │ │ claude-opus-5-5 · $0.42 · 93% hit ○ main $ cat .env /work/app · not a repository 5h ██████████▎────────────────────── ⟨Claude Code's own drawing⟩
README

simple-status-line

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
  • Left, every column the right pane leaves: ▶/✓ 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.
  • Right, as wide as its longest line (at least 48 columns, at most 60% of the band), untitled rows: model and effort (effort from the first request on) then session cost, cache hit rate and time until cold with a one-shot 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.

Install

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.

Options

cacheTtl: 1h (default, most subscriptions) or 5m (API-key billing).

Develop

claude --plugin-dir .
claude plugin validate .
claude plugin test .
Source 7 files
hooks/register.tsx 557 lines
1import { 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}
557
hooks/line.ts 5 lines
1import 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 }
5
hooks/activity.tsx 317 lines
1import { 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}
317
hooks/model.tsx 33 lines
1import { 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}
33
hooks/pulse.tsx 173 lines
1import { 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}
173
hooks/workspace.tsx 154 lines
1import { 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}
154
types/index.d.ts 81 lines
1export 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