No description.

sh -c "$(curl -fsLS get.chezmoi.io)"
Add chezmoi to PATH:
export PATH="$HOME/.local/bin:$PATH"
You can use either SSH or HTTPS depending on whether you need to push 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:
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.
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.githooks/register.tsx 911 lines1import { 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}
911types/index.d.ts 71 lines1export 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