SLOPSHOPPER

opencode-sidebar

No description.

panespinnerrowsguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · opencode-sidebar
│ ┃ opencode-sidebar ✕ ⟨Claude Code's own drawing⟩ │ ┃ fix the failing auth test │ ┃ ⏺ Read(src/auth.ts) │ ┃ section-context ⎿ Read 6 lines │ ┃ 97,400 tokens ⟨Claude Code's own drawing⟩ │ ┃ ▰▰▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱ 49% used ⏺ Bash(bun test) │ ┃ $0.42 spent ⎿ 3 pass, 1 fail │ ┃ Opus 5.5 │ ┃ ⟨Claude Code's own drawing⟩ │ ┃ section-usage │ ┃ 5h ▮▮▯▯▯ 31% resets now ✻ Worked… │ ┃ │ ┃ section-mcp › /sidebar │ ┃ No MCP servers connected ⟨Claude Code's own drawing⟩ │ ┃ │ ┃ section-files │ ┃ No changes │ ┃ │ ┃ /work/app: │ ┃ ● Claude Code 2.1.289 │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ✻ Thinking… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⟨Claude Code's own drawing⟩ ● Opus 5.5 cycle-effort toggle-sidebar

Draws

Pane · opencode-sidebar
fix the failing auth test section-context 97,400 tokens ▰▰▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱ 49% used $0.42 spent Opus 5.5 section-usage 5h ▮▮▯▯▯ 31% resets now section-mcp No MCP servers connected section-files No changes /work/app: ● Claude Code 2.1.289
Spinner
✻ Thinking…
Turn line
✻ Worked…
Your message
⟨Claude Code's own drawing⟩
Claude's reply
⟨Claude Code's own drawing⟩
Tool row
⟨Claude Code's own drawing⟩
Command output
⟨Claude Code's own drawing⟩
README

Dotfiles

Installation

Install chezmoi

sh -c "$(curl -fsLS get.chezmoi.io)"

Add chezmoi to PATH:

export PATH="$HOME/.local/bin:$PATH"

Initialize dotfiles

You can use either SSH or HTTPS depending on whether you need to push changes:

Option 1: SSH (recommended - allows committing changes)

If you have SSH keys configured or plan to use 1Password for SSH:

chezmoi init git@github.com:woodjme/chezmoi.git

During setup, you'll be prompted:

  • Install 1Password for SSH keys? Say yes if you want to use 1Password's SSH agent
  • If yes and you're using HTTPS, you'll be asked if you want to switch to SSH after 1Password is configured
Option 2: HTTPS (simpler, read-only)

If you don't need to push changes or don't have SSH keys:

chezmoi init https://github.com/woodjme/chezmoi

During setup, when prompted Install 1Password for SSH keys? answer no.

Note: With HTTPS, you can pull updates but won't be able to push changes back to the repo.

Switching from HTTPS to SSH later

If you started with HTTPS and want to switch to SSH:

cd $(chezmoi source-path)
git remote set-url origin git@github.com:woodjme/chezmoi.git
Source 2 files
hooks/register.tsx 911 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement } from 'claude-code'
3
4import type {
5  ChangedFile,
6  Effort,
7  EffortLevel,
8  McpServer,
9  RateWindow,
10  Section,
11  Snapshot,
12  Todo,
13  TodoStatus,
14} from '../types'
15
16const PANE = 'opencode-sidebar'
17const PANE_TITLE = 'Sidebar'
18const PANE_COLUMNS = 42
19const POLL_MS = 4000
20const MAX_FILES = 60
21const MAX_UNTRACKED_COUNTED = 15
22const AUTO_OPEN_KEY = 'autoOpen'
23const LIMITS_KEY = 'limits'
24const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
25const USAGE_REFRESH_MS = 60_000
26const USAGE_AFTER_TURN_MS = 15_000
27const LIVE_KINDS: Readonly<Record<string, string>> = { five_hour: 'session', seven_day: 'weekly_all' }
28// An engine action with no default key: the chord the person binds to it presses the band's toggle.
29const TOGGLE_ACTION = 'app:toggleReplTab'
30// Same trick for ctrl+t: this action's own handler only mounts while the diff panel is open.
31const EFFORT_ACTION = 'app:toggleDiffNoiseFilter'
32const EFFORT_LEVELS: readonly EffortLevel[] = ['low', 'medium', 'high', 'xhigh', 'max']
33// Side gutter for the chat, as OpenCode has; the prompt box and the startup logo are not render sites.
34const GUTTER = 2
35const TRANSCRIPT_ROWS = [
36  'UserMessage',
37  'AssistantMessage',
38  'ToolUse',
39  'ToolResult',
40  'ToolGroup',
41  'ToolProgress',
42  'CommandOutput',
43  'Spinner',
44  'TurnDuration',
45  'InfoNotice',
46] as const
47const MINUTE = 60_000
48const HOUR = 60 * MINUTE
49const DAY = 24 * HOUR
50
51const EMPTY: Snapshot = {
52  cwd: '',
53  home: '',
54  isRepo: false,
55  branch: null,
56  files: [],
57  model: '',
58  tokens: null,
59  window: 0,
60  percent: null,
61  costUsd: null,
62  mcp: [],
63  version: '',
64  limits: [],
65  minute: 0,
66}
67
68const snapshot = atom({ plugin: 'opencode-sidebar', key: 'snapshot' } as const, EMPTY)
69const todos = atom({ plugin: 'opencode-sidebar', key: 'todos' } as const, [] as Todo[])
70const title = atom({ plugin: 'opencode-sidebar', key: 'title' } as const, '')
71const collapsed = atom(
72  { plugin: 'opencode-sidebar', key: 'collapsed' } as const,
73  {} as Partial<Record<Section, boolean>>,
74)
75const effort = atom({ plugin: 'opencode-sidebar', key: 'effort' } as const, {
76  model: '',
77  engine: null,
78  override: null,
79  inheritedEnv: null,
80  isStale: false,
81} as Effort)
82
83type $ = EngineInterface
84
85let lastSnapshotJson = ''
86let isRefreshing = false
87let isRefreshQueued = false
88let accountUsage: RateWindow[] | null = null
89let accountUsageFetchedAt = 0
90let isAccountUsageStale = false
91
92export const register: Register = on => {
93  on('session.start', async ($, e, next) => {
94    const started = await next(e)
95    await $.command.register({
96      name: 'sidebar',
97      description: 'Toggle the OpenCode-style sidebar (context, files, todos, directory)',
98    })
99    await restoreTitle($)
100    await refresh($)
101    $.clock.every(POLL_MS, () => void refresh($))
102    if ((await $.store.get(AUTO_OPEN_KEY)) !== false) {
103      void $.ui.open({ id: PANE, title: PANE_TITLE, columns: PANE_COLUMNS })
104    }
105
106    return started
107  })
108
109  on('command.run', { command: 'sidebar' }, async $ => {
110    const isOpen = await toggleSidebar($)
111
112    return { text: isOpen ? 'Sidebar opened.' : 'Sidebar closed.' }
113  })
114
115  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
116    const modes = await next(e)
117    if (e.surface !== 'terminal') return modes
118    const { Box, Text, Button } = $.ui.resolve(e)
119    const snap = await read($, snapshot)
120    const level = effortOf(await read($, effort))
121
122    return (
123      <Box flexDirection="row" columnGap={2}>
124        {e.props.modes.length > 0 && <Box flexShrink={1}>{modes}</Box>}
125        <Box flexShrink={0} flexDirection="row" columnGap={2}>
126          <Text>
127            <Text color="claude">● </Text>
128            <Text>{prettyModel(snap.model) || 'Claude'}</Text>
129            {level !== null && <Text dimColor> · </Text>}
130            {level !== null && <Text>{level}</Text>}
131          </Text>
132          <Button key="cycle-effort" plain action={EFFORT_ACTION} onPress={() => cycleEffort($)}>
133            <Text dimColor>ctrl+t effort</Text>
134          </Button>
135          <Button key="toggle-sidebar" plain action={TOGGLE_ACTION} onPress={() => toggleSidebar($)}>
136            <Text dimColor>ctrl+x s sidebar</Text>
137          </Button>
138        </Box>
139      </Box>
140    )
141  })
142
143  on('ui.render', { component: TRANSCRIPT_ROWS }, async ($, e, next) => {
144    const row = await next(e)
145    if (e.surface !== 'terminal') return row
146    const { Box } = $.ui.resolve(e)
147
148    return (
149      <Box paddingX={GUTTER} flexDirection="column">
150        {row}
151      </Box>
152    )
153  }).catch(($, e, next) => next(e))
154
155  on('turn.start', async ($, e, next) => {
156    if (e.agentId === undefined) await syncEffortModel($, await $.session.model())
157
158    return next(e)
159  }).catch(($, e, next) => next(e))
160
161  on('turn.step', async function* ($, e, next) {
162    if (e.agentId === undefined && (await read($, effort)).override === null) {
163      const engine = typeof e.effort === 'string' ? toEffortLevel(e.effort) : e.effort === undefined ? 'none' : null
164      await changeEffort($, current => ({ ...current, engine, isStale: false }))
165    }
166
167    return yield* next(e)
168  }).catch(async function* ($, e, next) {
169    return yield* next(e)
170  })
171
172  // The engine refuses /effort while the variable is set, so a typed /effort hands control back first.
173  // Its panel prints no text, and may still be waiting on a confirmation, so the saved level is
174  // re-read on each refresh until the next request reports the level in force.
175  on('command.run', { command: 'effort' }, async ($, e, next) => {
176    await setOverride($, null)
177    const ran = await next(e)
178    await changeEffort($, current => ({ ...current, isStale: true }))
179    await refreshStaleEffort($)
180
181    return ran
182  }).catch(($, e, next) => next(e))
183
184  on('ui.close', async ($, e, next) => {
185    if (e.id === PANE && e.origin.kind === 'person') await $.store.set(AUTO_OPEN_KEY, false)
186
187    return next(e)
188  }).catch(($, e, next) => next(e))
189
190  on('session.measure', async ($, e, next) => {
191    void refresh($)
192
193    return next(e)
194  })
195
196  on('turn.complete', async ($, e, next) => {
197    if (e.agentId === undefined) {
198      isAccountUsageStale = true
199      void refresh($)
200    }
201
202    return next(e)
203  })
204
205  on('session.end', async ($, e, next) => {
206    if (e.reason === 'clear') {
207      await $.state.set({ plugin: 'opencode-sidebar', key: 'title' }, '')
208      await $.state.set({ plugin: 'opencode-sidebar', key: 'todos' }, [])
209    }
210
211    return next(e)
212  })
213
214  on('prompt.submit', async ($, e, next) => {
215    if (!e.isMeta && e.command === undefined) {
216      const current = await read($, title)
217      if (current === '') await update($, title, () => toTitle(e.text))
218    }
219
220    return next(e)
221  }).catch(($, e, next) => next(e))
222
223  on('tool.call', async ($, e, next) => {
224    const ran = await next(e)
225    if (e.agentId !== undefined || 'deny' in ran || ran.isError) return ran
226
227    if (e.tool === 'TodoWrite') {
228      const list = e.todos.map((todo, index): Todo => ({
229        id: `todo-${index}`,
230        content: todo.content,
231        status: todo.status,
232        source: 'todo',
233      }))
234      await update($, todos, current => [...current.filter(t => t.source !== 'todo'), ...list])
235    } else if (e.tool === 'TaskCreate') {
236      const id = taskIdOf(ran.result)
237      if (id !== null) {
238        const created: Todo = { id, content: e.subject, status: 'pending', source: 'task' }
239        await update($, todos, current => [...current.filter(t => t.id !== id), created])
240      }
241    } else if (e.tool === 'TaskUpdate') {
242      const { taskId, status, subject } = e
243      await update($, todos, current =>
244        status === 'deleted'
245          ? current.filter(t => t.id !== taskId)
246          : current.map(t =>
247              t.source === 'task' && t.id === taskId
248                ? { ...t, status: status ?? t.status, content: subject ?? t.content }
249                : t,
250            ),
251      )
252    } else if (isMutating(String(e.tool))) {
253      void refresh($)
254    }
255
256    return ran
257  }).catch(($, e, next) => next(e))
258
259  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
260    const { Box, Text, Button } = $.ui.resolve(e)
261    const snap = await read($, snapshot)
262    const list = await read($, todos)
263    const heading = await read($, title)
264    const folded = await read($, collapsed)
265    const level = effortOf(await read($, effort))
266    const columns = e.props.bodyColumns
267    const done = list.filter(t => t.status === 'completed').length
268
269    if (e.props.placement === 'inline') {
270      return (
271        <Box flexDirection="row" flexWrap="wrap" columnGap={2} paddingX={1}>
272          <Text weight="bold">{heading || 'New session'}</Text>
273          <Text color={contextColor(snap.percent)}>{formatPercent(snap.percent)} context</Text>
274          <Text dimColor>{formatCost(snap.costUsd)}</Text>
275          <Text dimColor>{snap.files.length} changed</Text>
276          {list.length > 0 && <Text dimColor>todo {done}/{list.length}</Text>}
277          <Text dimColor>{locationOf(snap)}</Text>
278        </Box>
279      )
280    }
281
282    const isFolded = (section: Section, size: number | null) => folded[section] ?? (size !== null && size > 5)
283    const header = (section: Section, label: string, size: number | null) => {
284      const isClosed = isFolded(section, size)
285
286      return (
287        <Button
288          key={`section-${section}`}
289          plain
290          onPress={() => update($, collapsed, c => ({ ...c, [section]: !isClosed }))}
291        >
292          <Text dimColor>{isClosed ? '▶' : '▼'} </Text>
293          <Text weight="bold">{label}</Text>
294          {size !== null && size > 0 && <Text dimColor> {size}</Text>}
295        </Button>
296      )
297    }
298
299    const contextBody = (
300      <Box flexDirection="column">
301        <Text>
302          {formatTokens(snap.tokens)} <Text dimColor>tokens</Text>
303        </Text>
304        <Text>
305          <Text color={contextColor(snap.percent)}>{bar(snap.percent, Math.min(20, columns - 12))}</Text>{' '}
306          {formatPercent(snap.percent)} <Text dimColor>used</Text>
307        </Text>
308        <Text>
309          {formatCost(snap.costUsd)} <Text dimColor>spent</Text>
310        </Text>
311        {snap.model !== '' && (
312          <Text dimColor wrap="truncate-end">
313            {prettyModel(snap.model)}
314            {level !== null && ` · ${level} effort`}
315          </Text>
316        )}
317      </Box>
318    )
319
320    const labelWidth = Math.max(5, ...snap.limits.map(limit => limit.label.length))
321    const usageBody = (
322      <Box flexDirection="column">
323        {snap.limits.length === 0 && <Text dimColor>No reading yet</Text>}
324        {snap.limits.map(limit => (
325          <Box key={`usage-${limit.kind}`} flexDirection="row" justifyContent="space-between">
326            <Text>
327              <Text dimColor>{limit.label.padEnd(labelWidth)} </Text>
328              <Text color={limitTone(limit.percentUsed)}>{gauge(limit.percentUsed)}</Text>
329              <Text dimColor>{'▯'.repeat(5 - gaugeFill(limit.percentUsed))} </Text>
330              <Text color={limitTone(limit.percentUsed)}>{Math.round(limit.percentUsed)}%</Text>
331            </Text>
332            {limit.resetsAt !== null && (
333              <Box flexShrink={0} marginLeft={1}>
334                <Text dimColor>resets {untilReset(limit.resetsAt, snap.minute)}</Text>
335              </Box>
336            )}
337          </Box>
338        ))}
339      </Box>
340    )
341
342    const mcpBody = (
343      <Box flexDirection="column">
344        {snap.mcp.length === 0 && <Text dimColor>No MCP servers connected</Text>}
345        {snap.mcp.map(server => (
346          <Box key={`mcp-${server.isCloud ? 'cloud' : 'local'}-${server.name}`} flexDirection="row" justifyContent="space-between">
347            <Box flexShrink={1}>
348              <Text wrap="truncate-end" dimColor={server.needsAuth}>
349                {server.isCloud ? <Text color="suggestion">☁ </Text> : <Text color="success">• </Text>}
350                {server.name}
351              </Text>
352            </Box>
353            <Box flexShrink={0} marginLeft={1}>
354              {server.needsAuth ? (
355                <Text color="warning">needs auth</Text>
356              ) : (
357                <Text dimColor>
358                  {server.tools} {server.tools === 1 ? 'tool' : 'tools'}
359                </Text>
360              )}
361            </Box>
362          </Box>
363        ))}
364      </Box>
365    )
366
367    const filesBody = (
368      <Box flexDirection="column">
369        {!snap.isRepo && <Text dimColor>Not a git repository</Text>}
370        {snap.isRepo && snap.files.length === 0 && <Text dimColor>No changes</Text>}
371        {snap.files.map(file => (
372          <Box key={`file-${file.path}`} flexDirection="row" justifyContent="space-between">
373            <Box flexShrink={1}>
374              <Text wrap="truncate-start" color={fileColor(file.status)}>
375                {file.path}
376              </Text>
377            </Box>
378            <Box flexShrink={0} marginLeft={1}>
379              <Text>
380                {file.added > 0 && <Text color="success">+{file.added}</Text>}
381                {file.added > 0 && file.removed > 0 && ' '}
382                {file.removed > 0 && <Text color="error">-{file.removed}</Text>}
383                {file.added === 0 && file.removed === 0 && <Text dimColor>{statusLabel(file.status)}</Text>}
384              </Text>
385            </Box>
386          </Box>
387        ))}
388      </Box>
389    )
390
391    const todoBody = (
392      <Box flexDirection="column">
393        {list.map(todo => (
394          <Box key={`todo-${todo.id}`} flexDirection="row">
395            <Box flexShrink={0}>
396              <Text color={todoColor(todo.status)}>{todoMark(todo.status)} </Text>
397            </Box>
398            <Text
399              dimColor={todo.status === 'completed'}
400              strikethrough={todo.status === 'completed'}
401              color={todo.status === 'in_progress' ? 'warning' : undefined}
402            >
403              {todo.content}
404            </Text>
405          </Box>
406        ))}
407      </Box>
408    )
409
410    const section = (key: Section, label: string, size: number | null, body: RenderElement) => (
411      <Box key={`block-${key}`} flexDirection="column">
412        {header(key, label, size)}
413        {!isFolded(key, size) && body}
414      </Box>
415    )
416
417    return (
418      <Box flexDirection="column" paddingX={1} minHeight={Math.max(1, e.props.scroll.bodyRows)}>
419        <Box flexDirection="column" rowGap={1} flexGrow={1}>
420          <Text weight="bold">{heading || 'New session'}</Text>
421          {section('context', 'Context', null, contextBody)}
422          {section('usage', 'Usage', null, usageBody)}
423          {section('mcp', 'MCP', snap.mcp.length, mcpBody)}
424          {section('files', 'Modified Files', snap.files.length, filesBody)}
425          {list.length > 0 && section('todo', `Todo ${done}/${list.length}`, list.length, todoBody)}
426        </Box>
427        <Box flexDirection="column" marginTop={1}>
428          <Text wrap="truncate-start">
429            <Text dimColor>{shortPath(snap.cwd, snap.home)}</Text>
430            {snap.branch !== null && <Text color="suggestion">:{snap.branch}</Text>}
431          </Text>
432          <Text>
433            <Text color="claude">● </Text>
434            <Text weight="bold">Claude Code</Text>
435            {snap.version !== '' && <Text dimColor> {snap.version}</Text>}
436          </Text>
437        </Box>
438      </Box>
439    )
440  })
441}
442
443async function toggleSidebar($: $): Promise<boolean> {
444  const isShown = (await $.ui.panes()).some(pane => pane.id === PANE && pane.isPlaced)
445  if (isShown) {
446    await $.ui.close({ id: PANE })
447    await $.store.set(AUTO_OPEN_KEY, false)
448
449    return false
450  }
451  await $.store.set(AUTO_OPEN_KEY, true)
452  void refresh($)
453  await $.ui.open({ id: PANE, title: PANE_TITLE, columns: PANE_COLUMNS })
454
455  return true
456}
457
458async function cycleEffort($: $): Promise<void> {
459  const current = await read($, effort)
460  if (current.engine === 'none') {
461    await $.ui.toast(`${prettyModel(current.model) || 'This model'} has no effort setting`)
462
463    return
464  }
465  const shown = effortOf(current) ?? 'high'
466  const next = EFFORT_LEVELS[(EFFORT_LEVELS.indexOf(shown) + 1) % EFFORT_LEVELS.length] ?? 'low'
467  await setOverride($, next === current.engine ? null : next)
468}
469
470// The engine reads this variable on every request, so its header, spinner and subagents all follow it,
471// and unlike /effort nothing is saved for new sessions.
472async function setOverride($: $, level: EffortLevel | null): Promise<void> {
473  const current = await read($, effort)
474  if (current.override === level) return
475  const inheritedEnv =
476    current.override === null ? ((await $.env.get('CLAUDE_CODE_EFFORT_LEVEL')) ?? null) : current.inheritedEnv
477  await $.env.set('CLAUDE_CODE_EFFORT_LEVEL', level ?? inheritedEnv ?? undefined)
478  await changeEffort($, state => ({ ...state, override: level, inheritedEnv }))
479}
480
481function effortOf(state: Effort): EffortLevel | null {
482  if (state.override !== null) return state.override
483  if (state.engine === 'none') return null
484
485  return state.engine
486}
487
488async function changeEffort($: $, change: (state: Effort) => Effort): Promise<Effort> {
489  const before = await read($, effort)
490  const after = change(before)
491  if (JSON.stringify(after) === JSON.stringify(before)) return before
492
493  return update($, effort, change)
494}
495
496async function syncEffortModel($: $, model: string): Promise<void> {
497  if (model === '' || (await read($, effort)).model === model) return
498  await setOverride($, null)
499  const saved = await savedEffort($, model)
500  await changeEffort($, state => ({ ...state, model, engine: saved }))
501}
502
503async function refreshStaleEffort($: $): Promise<void> {
504  const current = await read($, effort)
505  if (!current.isStale || current.override !== null) return
506  const saved = await savedEffort($, current.model)
507  await changeEffort($, state => ({ ...state, engine: saved }))
508}
509
510async function savedEffort($: $, model: string): Promise<EffortLevel | null> {
511  const settings = await $.settings.read()
512  const perModel = settings.modelSettings as Record<string, { effortLevel?: unknown } | undefined> | undefined
513  const saved = perModel?.[model.replace(/\[1m\]$/i, '')]?.effortLevel ?? settings.effortLevel
514
515  return typeof saved === 'string' ? toEffortLevel(saved) : null
516}
517
518function toEffortLevel(value: string): EffortLevel | null {
519  const level = value.toLowerCase()
520
521  return (EFFORT_LEVELS as readonly string[]).includes(level) ? (level as EffortLevel) : null
522}
523
524async function refresh($: $): Promise<void> {
525  if (isRefreshing) {
526    isRefreshQueued = true
527
528    return
529  }
530  isRefreshing = true
531  try {
532    const next = await collect($)
533    const json = JSON.stringify(next)
534    if (json !== lastSnapshotJson) {
535      lastSnapshotJson = json
536      await $.state.set({ plugin: 'opencode-sidebar', key: 'snapshot' }, next)
537    }
538    await syncEffortModel($, next.model)
539    await refreshStaleEffort($)
540  } catch (error) {
541    $.ui.log(`opencode-sidebar: refresh failed: ${String(error)}`, { to: 'debug' })
542  } finally {
543    isRefreshing = false
544    if (isRefreshQueued) {
545      isRefreshQueued = false
546      void refresh($)
547    }
548  }
549}
550
551async function collect($: $): Promise<Snapshot> {
552  const cwd = await $.session.cwd()
553  const [home, usage, model, tools, version, repo, now] = await Promise.all([
554    $.env.get('HOME'),
555    $.session.usage(),
556    $.session.model(),
557    $.tool.list(),
558    $.session.version(),
559    readGit($, cwd),
560    $.clock.now(),
561  ])
562  const limits = await limitsOf($, usage.rateLimits, now)
563
564  return {
565    cwd,
566    home: home ?? '',
567    isRepo: repo.isRepo,
568    branch: repo.branch,
569    files: repo.files,
570    model,
571    tokens: usage.context.tokens ?? null,
572    window: usage.context.window,
573    percent: usage.context.percent ?? null,
574    costUsd: usage.cost?.usd ?? null,
575    mcp: mcpServersOf(tools.map(tool => tool.name)),
576    version: version.base ?? version.version,
577    limits,
578    minute: Math.floor(now / MINUTE),
579  }
580}
581
582type GitState = { isRepo: boolean; branch: string | null; files: ChangedFile[] }
583
584async function readGit($: $, cwd: string): Promise<GitState> {
585  const root = await git($, cwd, ['rev-parse', '--show-toplevel'])
586  if (!root.ok) return { isRepo: false, branch: null, files: [] }
587  const top = root.out.trim()
588
589  const [symbolic, status, staged] = await Promise.all([
590    git($, top, ['symbolic-ref', '--short', '-q', 'HEAD']),
591    git($, top, ['status', '--porcelain=v1', '-z', '--no-renames', '--untracked-files=all']),
592    git($, top, ['diff', '--numstat', '--no-renames', 'HEAD']),
593  ])
594  const branch = symbolic.ok
595    ? symbolic.out.trim()
596    : (await git($, top, ['rev-parse', '--short', 'HEAD'])).out.trim() || null
597
598  const counts = new Map<string, { added: number; removed: number }>()
599  const numstat = staged.ok ? staged : await git($, top, ['diff', '--numstat', '--no-renames'])
600  for (const line of numstat.out.split('\n')) {
601    const [added, removed, ...rest] = line.split('\t')
602    if (rest.length === 0) continue
603    counts.set(rest.join('\t'), { added: toCount(added), removed: toCount(removed) })
604  }
605
606  const files: ChangedFile[] = []
607  let untrackedCounted = 0
608  for (const entry of status.out.split('\0')) {
609    if (entry.length < 4) continue
610    const code = entry.slice(0, 2)
611    const path = entry.slice(3)
612    const flag = code === '??' ? '?' : (code.trim()[0] ?? 'M')
613    let count = counts.get(path) ?? { added: 0, removed: 0 }
614    if (flag === '?' && untrackedCounted < MAX_UNTRACKED_COUNTED) {
615      untrackedCounted++
616      const fresh = await git($, top, ['diff', '--no-index', '--numstat', '--', '/dev/null', path])
617      const [added] = fresh.out.split('\t')
618      count = { added: toCount(added), removed: 0 }
619    }
620    files.push({ path, status: flag, ...count })
621    if (files.length >= MAX_FILES) break
622  }
623  files.sort((a, b) => a.path.localeCompare(b.path))
624
625  return { isRepo: true, branch, files }
626}
627
628async function git($: $, cwd: string, args: string[]): Promise<{ ok: boolean; out: string }> {
629  try {
630    const run = await $.process.run(['git', '-c', 'core.quotePath=false', ...args], {
631      cwd,
632      timeoutMs: 5000,
633    })
634
635    return { ok: run.exitCode === 0, out: run.stdout }
636  } catch {
637    return { ok: false, out: '' }
638  }
639}
640
641async function restoreTitle($: $): Promise<void> {
642  if ((await read($, title)) !== '') return
643  const messages = await $.session.messages()
644  if ('deny' in messages) return
645  const first = messages.find(m => m.role === 'user' && m.text.trim() !== '' && !m.text.startsWith('<'))
646  if (first !== undefined) await update($, title, () => toTitle(first.text))
647}
648
649type LiveWindow = { kind: string; percentUsed: number; resetsAt?: string }
650
651// The headers on each model response only carry the session and weekly windows, so the per-model
652// weekly limits (Fable's) come from the account's usage endpoint, the one /usage reads.
653async function limitsOf($: $, live: readonly LiveWindow[], now: number): Promise<RateWindow[]> {
654  const account = await accountLimits($, now)
655  const fresh = live.map(fromLive)
656  let windows: RateWindow[]
657  if (account !== null || fresh.length > 0) {
658    windows = mergeLimits(account ?? [], fresh)
659    if (JSON.stringify(windows) !== JSON.stringify(await $.store.get(LIMITS_KEY))) {
660      await $.store.set(LIMITS_KEY, windows)
661    }
662  } else {
663    const kept = await $.store.get(LIMITS_KEY)
664    windows = Array.isArray(kept) ? (kept as Partial<RateWindow>[]).flatMap(toWindow) : []
665  }
666
667  return windows.map(w =>
668    w.resetsAt !== null && Date.parse(w.resetsAt) <= now ? { ...w, percentUsed: 0, resetsAt: null } : w,
669  )
670}
671
672async function accountLimits($: $, now: number): Promise<RateWindow[] | null> {
673  const wait = isAccountUsageStale ? USAGE_AFTER_TURN_MS : USAGE_REFRESH_MS
674  if (now - accountUsageFetchedAt < wait) return accountUsage
675  accountUsageFetchedAt = now
676  isAccountUsageStale = false
677  try {
678    const auth = await $.session.authorize()
679    if (auth === null) {
680      accountUsage = null
681    } else {
682      const response = await $.http.fetch(USAGE_URL, {
683        auth: auth.handle,
684        headers: { 'anthropic-beta': 'oauth-2025-04-20' },
685      })
686      if (response.ok) accountUsage = parseAccountLimits(JSON.parse(response.text))
687    }
688  } catch (error) {
689    $.ui.log(`opencode-sidebar: usage fetch failed: ${String(error)}`, { to: 'debug' })
690  }
691
692  return accountUsage
693}
694
695export function parseAccountLimits(body: unknown): RateWindow[] | null {
696  const limits = (body as { limits?: unknown } | null)?.limits
697  if (!Array.isArray(limits)) return null
698
699  return limits.flatMap((item: unknown): RateWindow[] => {
700    const limit = item as { kind?: unknown; percent?: unknown; resets_at?: unknown; scope?: unknown }
701    if (typeof limit.kind !== 'string' || typeof limit.percent !== 'number') return []
702    const scope = scopeName(limit.scope)
703    const kind = scope === null ? limit.kind : `${limit.kind}:${scope}`
704
705    return [
706      {
707        kind,
708        label: scope ?? limitLabel(limit.kind),
709        percentUsed: limit.percent,
710        resetsAt: typeof limit.resets_at === 'string' ? limit.resets_at : null,
711      },
712    ]
713  })
714}
715
716function scopeName(scope: unknown): string | null {
717  const named = scope as { model?: { display_name?: unknown }; surface?: { display_name?: unknown } } | null
718  const name = named?.model?.display_name ?? named?.surface?.display_name
719
720  return typeof name === 'string' && name !== '' ? name : null
721}
722
723// Usage only climbs within a window, so of two readings of the same window the higher is the newer;
724// a later reset means the window rolled over and that reading wins.
725export function mergeLimits(account: readonly RateWindow[], live: readonly RateWindow[]): RateWindow[] {
726  const merged = account.map(window => {
727    const other = live.find(w => w.kind === window.kind)
728    if (other === undefined) return window
729    const resets = (w: RateWindow) => (w.resetsAt === null ? 0 : Date.parse(w.resetsAt))
730    if (Math.abs(resets(other) - resets(window)) > MINUTE) return resets(other) > resets(window) ? other : window
731
732    return other.percentUsed > window.percentUsed ? { ...window, percentUsed: other.percentUsed } : window
733  })
734
735  return [...merged, ...live.filter(w => !account.some(a => a.kind === w.kind))]
736}
737
738function fromLive(window: LiveWindow): RateWindow {
739  const kind = LIVE_KINDS[window.kind] ?? window.kind
740
741  return { kind, label: limitLabel(kind), percentUsed: window.percentUsed, resetsAt: window.resetsAt ?? null }
742}
743
744function toWindow(kept: Partial<RateWindow>): RateWindow[] {
745  if (typeof kept.kind !== 'string' || typeof kept.percentUsed !== 'number') return []
746  const kind = LIVE_KINDS[kept.kind] ?? kept.kind
747
748  return [{ kind, label: kept.label ?? limitLabel(kind), percentUsed: kept.percentUsed, resetsAt: kept.resetsAt ?? null }]
749}
750
751function limitLabel(kind: string): string {
752  if (kind === 'session') return '5h'
753  if (kind === 'weekly_all') return 'Week'
754  if (kind === 'spend_limit') return 'Spend'
755
756  return kind.replace(/_/g, ' ')
757}
758
759function limitTone(percent: number): string {
760  if (percent >= 90) return 'error'
761  if (percent >= 75) return 'warning'
762
763  return 'text'
764}
765
766function gaugeFill(percent: number): number {
767  return Math.max(0, Math.min(5, Math.round(percent / 20)))
768}
769
770function gauge(percent: number): string {
771  return '▮'.repeat(gaugeFill(percent))
772}
773
774function untilReset(resetsAt: string, minute: number): string {
775  const left = Date.parse(resetsAt) - minute * MINUTE
776  if (!Number.isFinite(left) || left <= 0) return 'now'
777  const days = Math.floor(left / DAY)
778  const hours = Math.floor((left % DAY) / HOUR)
779  const minutes = Math.max(1, Math.floor((left % HOUR) / MINUTE))
780  if (days > 0) return `${days}d ${hours}h`
781
782  return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`
783}
784
785function prettyModel(model: string): string {
786  const match = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(\[1m\])?$/i.exec(model)
787  if (match === null) return model
788  const [, family = '', major, minor, wide] = match
789  const name = family.charAt(0).toUpperCase() + family.slice(1)
790
791  return `${name} ${major}${minor !== undefined ? `.${minor}` : ''}${wide !== undefined ? ' (1M)' : ''}`
792}
793
794const CLOUD_PREFIX = 'claude_ai_'
795
796// A server that is not signed in offers only these tools.
797const AUTH_TOOLS = ['authenticate', 'complete_authentication']
798
799export function mcpServersOf(names: string[]): McpServer[] {
800  const servers = new Map<string, string[]>()
801  for (const name of names) {
802    const [prefix, server, tool] = name.split('__')
803    if (prefix !== 'mcp' || !server || tool === undefined) continue
804    servers.set(server, [...(servers.get(server) ?? []), tool])
805  }
806
807  return [...servers]
808    .map(([raw, tools]) => {
809      const isCloud = raw.startsWith(CLOUD_PREFIX)
810
811      return {
812        name: (isCloud ? raw.slice(CLOUD_PREFIX.length) : raw).replace(/_/g, ' '),
813        isCloud,
814        needsAuth: tools.every(tool => AUTH_TOOLS.includes(tool)),
815        tools: tools.length,
816      }
817    })
818    .sort((a, b) => a.name.localeCompare(b.name))
819}
820
821function taskIdOf(result: unknown): string | null {
822  if (typeof result !== 'object' || result === null || !('task' in result)) return null
823  const task = (result as { task?: { id?: unknown } }).task
824
825  return typeof task?.id === 'string' ? task.id : null
826}
827
828function isMutating(tool: string): boolean {
829  return ['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash'].includes(tool)
830}
831
832function toTitle(text: string): string {
833  const line = text.trim().split('\n')[0]?.trim() ?? ''
834
835  return line.length > 80 ? `${line.slice(0, 79)}…` : line
836}
837
838function toCount(value: string | undefined): number {
839  const n = Number(value)
840
841  return Number.isFinite(n) ? n : 0
842}
843
844function formatTokens(tokens: number | null): string {
845  if (tokens === null) return '0'
846
847  return String(tokens).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
848}
849
850function formatPercent(percent: number | null): string {
851  return `${percent ?? 0}%`
852}
853
854function formatCost(usd: number | null): string {
855  return `$${(usd ?? 0).toFixed(2)}`
856}
857
858function bar(percent: number | null, width: number): string {
859  const size = Math.max(5, width)
860  const filled = Math.round(((percent ?? 0) / 100) * size)
861
862  return '▰'.repeat(filled) + '▱'.repeat(size - filled)
863}
864
865function contextColor(percent: number | null): string {
866  if ((percent ?? 0) >= 90) return 'error'
867  if ((percent ?? 0) >= 70) return 'warning'
868
869  return 'success'
870}
871
872function fileColor(status: string): string | undefined {
873  if (status === '?' || status === 'A') return 'success'
874  if (status === 'D') return 'error'
875
876  return undefined
877}
878
879function statusLabel(status: string): string {
880  if (status === '?') return 'new'
881  if (status === 'D') return 'deleted'
882
883  return status
884}
885
886function todoMark(status: TodoStatus): string {
887  if (status === 'completed') return '[✓]'
888  if (status === 'in_progress') return '[•]'
889
890  return '[ ]'
891}
892
893function todoColor(status: TodoStatus): string | undefined {
894  if (status === 'completed') return 'success'
895  if (status === 'in_progress') return 'warning'
896
897  return undefined
898}
899
900function shortPath(path: string, home: string): string {
901  if (home !== '' && (path === home || path.startsWith(`${home}/`))) return `~${path.slice(home.length)}`
902
903  return path
904}
905
906function locationOf(snap: Snapshot): string {
907  const where = shortPath(snap.cwd, snap.home)
908
909  return snap.branch === null ? where : `${where}:${snap.branch}`
910}
911
types/index.d.ts 71 lines
1export type TodoStatus = 'pending' | 'in_progress' | 'completed'
2
3export type Todo = {
4  id: string
5  content: string
6  status: TodoStatus
7  source: 'todo' | 'task'
8}
9
10export type ChangedFile = {
11  path: string
12  added: number
13  removed: number
14  status: string
15}
16
17export type McpServer = {
18  name: string
19  isCloud: boolean
20  needsAuth: boolean
21  tools: number
22}
23
24export type RateWindow = {
25  kind: string
26  label: string
27  percentUsed: number
28  resetsAt: string | null
29}
30
31export type Snapshot = {
32  cwd: string
33  home: string
34  isRepo: boolean
35  branch: string | null
36  files: ChangedFile[]
37  model: string
38  tokens: number | null
39  window: number
40  percent: number | null
41  costUsd: number | null
42  mcp: McpServer[]
43  version: string
44  limits: RateWindow[]
45  minute: number
46}
47
48export type Section = 'context' | 'usage' | 'mcp' | 'files' | 'todo'
49
50export type EffortLevel = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
51
52export type Effort = {
53  model: string
54  engine: EffortLevel | 'none' | null
55  override: EffortLevel | null
56  inheritedEnv: string | null
57  isStale: boolean
58}
59
60declare module 'claude-code' {
61  interface PluginState {
62    'opencode-sidebar': {
63      snapshot: Snapshot
64      todos: Todo[]
65      title: string
66      collapsed: Partial<Record<Section, boolean>>
67      effort: Effort
68    }
69  }
70}
71