opencode-beans 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.opencode/plugins/beans-stats/beans.ts +133 -0
- package/.opencode/plugins/beans-stats/cache.ts +104 -0
- package/.opencode/plugins/beans-stats/index.ts +6 -0
- package/.opencode/plugins/beans-stats/layout.ts +136 -0
- package/.opencode/plugins/beans-stats/refresh.ts +49 -0
- package/.opencode/plugins/beans-stats/setup.ts +65 -0
- package/.opencode/plugins/beans-stats/tui.tsx +135 -0
- package/LICENSE +21 -0
- package/README.md +90 -2
- package/package.json +44 -4
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { execFile } from "node:child_process"
|
|
2
|
+
import { promisify } from "node:util"
|
|
3
|
+
import type { Stats } from "./layout.ts"
|
|
4
|
+
|
|
5
|
+
const run = promisify(execFile)
|
|
6
|
+
const MAX_BUFFER = 8 * 1024 * 1024
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_TIMEOUT_MS = 5_000
|
|
9
|
+
|
|
10
|
+
export interface LoadOptions {
|
|
11
|
+
readonly signal?: AbortSignal
|
|
12
|
+
readonly timeoutMs?: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ExecOptions {
|
|
16
|
+
readonly cwd: string
|
|
17
|
+
readonly maxBuffer: number
|
|
18
|
+
readonly timeout: number
|
|
19
|
+
readonly signal?: AbortSignal
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ExecResult {
|
|
23
|
+
readonly stdout: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type ExecFile = (
|
|
27
|
+
command: string,
|
|
28
|
+
args: readonly string[],
|
|
29
|
+
options: ExecOptions,
|
|
30
|
+
) => Promise<ExecResult>
|
|
31
|
+
|
|
32
|
+
export interface BeanRecord {
|
|
33
|
+
readonly status: string
|
|
34
|
+
readonly type?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class BeansUnavailableError extends Error {}
|
|
38
|
+
|
|
39
|
+
export function parseBeans(raw: string): BeanRecord[] {
|
|
40
|
+
let parsed: unknown
|
|
41
|
+
try {
|
|
42
|
+
parsed = JSON.parse(raw)
|
|
43
|
+
} catch {
|
|
44
|
+
throw new BeansUnavailableError("beans list --json did not return JSON")
|
|
45
|
+
}
|
|
46
|
+
if (!Array.isArray(parsed)) {
|
|
47
|
+
throw new BeansUnavailableError("beans list --json did not return an array")
|
|
48
|
+
}
|
|
49
|
+
return parsed.map((entry) => {
|
|
50
|
+
const record = entry as { status?: unknown; type?: unknown } | null
|
|
51
|
+
const status = record?.status
|
|
52
|
+
if (typeof status !== "string") {
|
|
53
|
+
throw new BeansUnavailableError("a bean is missing its status")
|
|
54
|
+
}
|
|
55
|
+
const type = record?.type
|
|
56
|
+
return typeof type === "string" ? { status, type } : { status }
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function aggregate(
|
|
61
|
+
beans: readonly BeanRecord[],
|
|
62
|
+
ready?: number,
|
|
63
|
+
blocked?: number,
|
|
64
|
+
): Stats {
|
|
65
|
+
const counts = new Map<string, number>()
|
|
66
|
+
const types = new Map<string, number>()
|
|
67
|
+
for (const bean of beans) {
|
|
68
|
+
counts.set(bean.status, (counts.get(bean.status) ?? 0) + 1)
|
|
69
|
+
if (bean.type !== undefined) {
|
|
70
|
+
types.set(bean.type, (types.get(bean.type) ?? 0) + 1)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
total: beans.length,
|
|
75
|
+
byStatus: [...counts].map(([status, count]) => ({ status, count })),
|
|
76
|
+
byType: [...types].map(([type, count]) => ({ type, count })),
|
|
77
|
+
...(ready === undefined ? {} : { ready }),
|
|
78
|
+
...(blocked === undefined ? {} : { blocked }),
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface BeanReader {
|
|
83
|
+
loadStats(dir: string, options?: LoadOptions): Promise<Stats>
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function createBeanReader(exec: ExecFile): BeanReader {
|
|
87
|
+
const list = async (
|
|
88
|
+
dir: string,
|
|
89
|
+
args: string[],
|
|
90
|
+
options: LoadOptions,
|
|
91
|
+
): Promise<BeanRecord[]> => {
|
|
92
|
+
const { stdout } = await exec("beans", args, {
|
|
93
|
+
cwd: dir,
|
|
94
|
+
maxBuffer: MAX_BUFFER,
|
|
95
|
+
timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
96
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
97
|
+
})
|
|
98
|
+
return parseBeans(stdout)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const count = async (
|
|
102
|
+
dir: string,
|
|
103
|
+
args: string[],
|
|
104
|
+
options: LoadOptions,
|
|
105
|
+
): Promise<number | undefined> => {
|
|
106
|
+
try {
|
|
107
|
+
return (await list(dir, args, options)).length
|
|
108
|
+
} catch {
|
|
109
|
+
return undefined
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
async loadStats(dir: string, options: LoadOptions = {}): Promise<Stats> {
|
|
115
|
+
let beans: BeanRecord[]
|
|
116
|
+
try {
|
|
117
|
+
beans = await list(dir, ["list", "--json"], options)
|
|
118
|
+
} catch {
|
|
119
|
+
throw new BeansUnavailableError("beans list --json failed")
|
|
120
|
+
}
|
|
121
|
+
const ready = await count(dir, ["list", "--ready", "--json"], options)
|
|
122
|
+
const blocked = await count(dir, ["list", "--is-blocked", "--json"], options)
|
|
123
|
+
return aggregate(beans, ready, blocked)
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const defaultExec: ExecFile = async (command, args, options) => {
|
|
129
|
+
const { stdout } = await run(command, [...args], { ...options })
|
|
130
|
+
return { stdout }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const { loadStats } = createBeanReader(defaultExec)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { Stats, StatusCount, TypeCount } from "./layout.ts"
|
|
2
|
+
|
|
3
|
+
export const CACHE_KEY = "beans.stats.cache"
|
|
4
|
+
|
|
5
|
+
export interface CacheState {
|
|
6
|
+
[directory: string]: Stats
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function decodeCount(entry: unknown): StatusCount | null {
|
|
10
|
+
if (typeof entry !== "object" || entry === null) {
|
|
11
|
+
return null
|
|
12
|
+
}
|
|
13
|
+
const record = entry as { status?: unknown; count?: unknown }
|
|
14
|
+
if (typeof record.status !== "string" || typeof record.count !== "number") {
|
|
15
|
+
return null
|
|
16
|
+
}
|
|
17
|
+
return { status: record.status, count: record.count }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function decodeType(entry: unknown): TypeCount | null {
|
|
21
|
+
if (typeof entry !== "object" || entry === null) {
|
|
22
|
+
return null
|
|
23
|
+
}
|
|
24
|
+
const record = entry as { type?: unknown; count?: unknown }
|
|
25
|
+
if (typeof record.type !== "string" || typeof record.count !== "number") {
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
return { type: record.type, count: record.count }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function decodeStats(raw: unknown): Stats | null {
|
|
32
|
+
if (typeof raw !== "object" || raw === null) {
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
const record = raw as {
|
|
36
|
+
total?: unknown
|
|
37
|
+
byStatus?: unknown
|
|
38
|
+
byType?: unknown
|
|
39
|
+
ready?: unknown
|
|
40
|
+
blocked?: unknown
|
|
41
|
+
}
|
|
42
|
+
if (typeof record.total !== "number" || !Array.isArray(record.byStatus)) {
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
const byStatus: StatusCount[] = []
|
|
46
|
+
for (const entry of record.byStatus) {
|
|
47
|
+
const decoded = decodeCount(entry)
|
|
48
|
+
if (decoded === null) {
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
byStatus.push(decoded)
|
|
52
|
+
}
|
|
53
|
+
const byType: TypeCount[] = []
|
|
54
|
+
if (record.byType !== undefined) {
|
|
55
|
+
if (!Array.isArray(record.byType)) {
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
for (const entry of record.byType) {
|
|
59
|
+
const decoded = decodeType(entry)
|
|
60
|
+
if (decoded === null) {
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
byType.push(decoded)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
total: record.total,
|
|
68
|
+
byStatus,
|
|
69
|
+
byType,
|
|
70
|
+
...(typeof record.ready === "number" ? { ready: record.ready } : {}),
|
|
71
|
+
...(typeof record.blocked === "number" ? { blocked: record.blocked } : {}),
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function toSnapshot(stats: Stats): Stats {
|
|
76
|
+
return {
|
|
77
|
+
total: stats.total,
|
|
78
|
+
byStatus: stats.byStatus.map((entry) => ({ status: entry.status, count: entry.count })),
|
|
79
|
+
byType: (stats.byType ?? []).map((entry) => ({ type: entry.type, count: entry.count })),
|
|
80
|
+
...(stats.ready === undefined ? {} : { ready: stats.ready }),
|
|
81
|
+
...(stats.blocked === undefined ? {} : { blocked: stats.blocked }),
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface CachePort {
|
|
86
|
+
read(directory: string): unknown
|
|
87
|
+
write(directory: string, stats: Stats): void
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface Cache {
|
|
91
|
+
seed(): Stats | null
|
|
92
|
+
save(stats: Stats): void
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function createCache(port: CachePort, directory: string): Cache {
|
|
96
|
+
return {
|
|
97
|
+
seed() {
|
|
98
|
+
return decodeStats(port.read(directory))
|
|
99
|
+
},
|
|
100
|
+
save(stats) {
|
|
101
|
+
port.write(directory, toSnapshot(stats))
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
export interface StatusCount {
|
|
2
|
+
readonly status: string
|
|
3
|
+
readonly count: number
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface TypeCount {
|
|
7
|
+
readonly type: string
|
|
8
|
+
readonly count: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface Stats {
|
|
12
|
+
readonly total: number
|
|
13
|
+
readonly byStatus: readonly StatusCount[]
|
|
14
|
+
readonly byType?: readonly TypeCount[]
|
|
15
|
+
readonly ready?: number
|
|
16
|
+
readonly blocked?: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SummaryLine {
|
|
20
|
+
readonly label: string
|
|
21
|
+
readonly count: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SectionLayout {
|
|
25
|
+
readonly total: string
|
|
26
|
+
readonly body: readonly string[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface Cell {
|
|
30
|
+
readonly label: string
|
|
31
|
+
readonly count: number
|
|
32
|
+
readonly bar?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface ColumnWidths {
|
|
36
|
+
readonly label: number
|
|
37
|
+
readonly count: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const BAR_WIDTH = 4
|
|
41
|
+
const FULL_BLOCK = "█"
|
|
42
|
+
const HALF_BLOCK = "▌"
|
|
43
|
+
const LABEL_GUTTER = 2
|
|
44
|
+
const COUNT_GUTTER = 2
|
|
45
|
+
const BAR_GAP = " "
|
|
46
|
+
|
|
47
|
+
export function statusRows(stats: Stats): StatusCount[] {
|
|
48
|
+
return [...stats.byStatus].sort(
|
|
49
|
+
(left, right) => right.count - left.count || left.status.localeCompare(right.status),
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function typeRows(stats: Stats): TypeCount[] {
|
|
54
|
+
return [...(stats.byType ?? [])].sort(
|
|
55
|
+
(left, right) => right.count - left.count || left.type.localeCompare(right.type),
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function bars<T extends { readonly count: number }>(
|
|
60
|
+
entries: readonly T[],
|
|
61
|
+
width: number = BAR_WIDTH,
|
|
62
|
+
): Array<T & { readonly bar: string }> {
|
|
63
|
+
const max = entries.reduce((largest, entry) => Math.max(largest, entry.count), 0)
|
|
64
|
+
return entries.map((entry) => ({ ...entry, bar: bar(entry.count, max, width) }))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function bar(count: number, max: number, width: number = BAR_WIDTH): string {
|
|
68
|
+
if (max <= 0 || count <= 0) {
|
|
69
|
+
return ""
|
|
70
|
+
}
|
|
71
|
+
const steps = Math.min(width * 2, Math.round((count / max) * width * 2))
|
|
72
|
+
const full = Math.floor(steps / 2)
|
|
73
|
+
const half = steps % 2 === 1
|
|
74
|
+
return FULL_BLOCK.repeat(full) + (half ? HALF_BLOCK : "")
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function totalLabel(stats: Stats): string {
|
|
78
|
+
return `${stats.total} total`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function layoutSection(stats: Stats): SectionLayout {
|
|
82
|
+
return layout(stats, false)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function layoutBreakdown(stats: Stats): SectionLayout {
|
|
86
|
+
return layout(stats, true)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function layout(stats: Stats, withTypes: boolean): SectionLayout {
|
|
90
|
+
const statuses = bars(statusRows(stats)).map((row) => ({
|
|
91
|
+
label: row.status,
|
|
92
|
+
count: row.count,
|
|
93
|
+
bar: row.bar,
|
|
94
|
+
}))
|
|
95
|
+
const summary = summaryLines(stats).map((line) => ({ label: line.label, count: line.count }))
|
|
96
|
+
const types = bars(typeRows(stats)).map((row) => ({ label: row.type, count: row.count, bar: row.bar }))
|
|
97
|
+
const widths = columnWidths(withTypes ? [...statuses, ...summary, ...types] : [...statuses, ...summary])
|
|
98
|
+
|
|
99
|
+
const body: string[] = []
|
|
100
|
+
appendBlock(body, statuses, widths)
|
|
101
|
+
appendBlock(body, summary, widths)
|
|
102
|
+
if (withTypes) {
|
|
103
|
+
appendBlock(body, types, widths)
|
|
104
|
+
}
|
|
105
|
+
return { total: totalLabel(stats), body }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function appendBlock(body: string[], cells: readonly Cell[], widths: ColumnWidths): void {
|
|
109
|
+
if (cells.length === 0) {
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
body.push("", ...cells.map((cell) => renderCell(cell, widths)))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function columnWidths(cells: readonly Cell[]): ColumnWidths {
|
|
116
|
+
const label = cells.reduce((width, cell) => Math.max(width, cell.label.length), 0) + LABEL_GUTTER
|
|
117
|
+
const count = cells.reduce((width, cell) => Math.max(width, String(cell.count).length), 0) + COUNT_GUTTER
|
|
118
|
+
return { label, count }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function renderCell(cell: Cell, widths: ColumnWidths): string {
|
|
122
|
+
const label = cell.label.padEnd(widths.label)
|
|
123
|
+
const count = String(cell.count).padStart(widths.count)
|
|
124
|
+
return cell.bar ? label + count + BAR_GAP + cell.bar : label + count
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function summaryLines(stats: Stats): SummaryLine[] {
|
|
128
|
+
const lines: SummaryLine[] = []
|
|
129
|
+
if (stats.ready !== undefined) {
|
|
130
|
+
lines.push({ label: "ready", count: stats.ready })
|
|
131
|
+
}
|
|
132
|
+
if (stats.blocked !== undefined) {
|
|
133
|
+
lines.push({ label: "blocked", count: stats.blocked })
|
|
134
|
+
}
|
|
135
|
+
return lines
|
|
136
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export interface RefresherOptions<Value> {
|
|
2
|
+
readonly load: (signal: AbortSignal) => Promise<Value>
|
|
3
|
+
readonly onValue: (value: Value) => void
|
|
4
|
+
readonly onError: () => void
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface Refresher {
|
|
8
|
+
refresh(): Promise<void>
|
|
9
|
+
dispose(): void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createRefresher<Value>(options: RefresherOptions<Value>): Refresher {
|
|
13
|
+
let current: AbortController | null = null
|
|
14
|
+
let disposed = false
|
|
15
|
+
|
|
16
|
+
const settle = (controller: AbortController) => {
|
|
17
|
+
if (current === controller) {
|
|
18
|
+
current = null
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
async refresh() {
|
|
24
|
+
if (disposed) {
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
current?.abort()
|
|
28
|
+
const controller = new AbortController()
|
|
29
|
+
current = controller
|
|
30
|
+
try {
|
|
31
|
+
const value = await options.load(controller.signal)
|
|
32
|
+
if (!controller.signal.aborted) {
|
|
33
|
+
options.onValue(value)
|
|
34
|
+
}
|
|
35
|
+
} catch {
|
|
36
|
+
if (!controller.signal.aborted) {
|
|
37
|
+
options.onError()
|
|
38
|
+
}
|
|
39
|
+
} finally {
|
|
40
|
+
settle(controller)
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
dispose() {
|
|
44
|
+
disposed = true
|
|
45
|
+
current?.abort()
|
|
46
|
+
current = null
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export interface DisposableBag {
|
|
2
|
+
add(dispose: () => void): void
|
|
3
|
+
dispose(): void
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function createDisposables(): DisposableBag {
|
|
7
|
+
const pending: Array<() => void> = []
|
|
8
|
+
let disposed = false
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
add(dispose) {
|
|
12
|
+
if (disposed) {
|
|
13
|
+
dispose()
|
|
14
|
+
return
|
|
15
|
+
}
|
|
16
|
+
pending.push(dispose)
|
|
17
|
+
},
|
|
18
|
+
dispose() {
|
|
19
|
+
if (disposed) {
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
disposed = true
|
|
23
|
+
while (pending.length > 0) {
|
|
24
|
+
pending.pop()?.()
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SlotHost<Claim> {
|
|
31
|
+
readonly ui: {
|
|
32
|
+
readonly slot: (claim: Claim) => () => void
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function registerSidebar<Claim>(host: SlotHost<Claim>, claim: Claim): () => void {
|
|
37
|
+
const unregister = host.ui.slot(claim)
|
|
38
|
+
return () => unregister()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const PANEL_NAME = "beans-stats"
|
|
42
|
+
export const PANEL_COMMAND_ID = "beans.stats.panel"
|
|
43
|
+
export const PANEL_BIND = "<leader>d"
|
|
44
|
+
|
|
45
|
+
export interface PanelCommand {
|
|
46
|
+
readonly id: string
|
|
47
|
+
readonly title: string
|
|
48
|
+
readonly description: string
|
|
49
|
+
readonly group: string
|
|
50
|
+
readonly bind: string
|
|
51
|
+
readonly run: () => void
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function panelCommand(open: (name: string) => unknown): PanelCommand {
|
|
55
|
+
return {
|
|
56
|
+
id: PANEL_COMMAND_ID,
|
|
57
|
+
title: "Beans breakdown",
|
|
58
|
+
description: "Show the full beans breakdown panel",
|
|
59
|
+
group: "Beans",
|
|
60
|
+
bind: PANEL_BIND,
|
|
61
|
+
run: () => {
|
|
62
|
+
open(PANEL_NAME)
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { For, Index, Show, createSignal, type Accessor } from "solid-js"
|
|
2
|
+
import { Plugin, usePlugin } from "@opencode/plugin/tui"
|
|
3
|
+
import {
|
|
4
|
+
layoutBreakdown,
|
|
5
|
+
layoutSection,
|
|
6
|
+
type SectionLayout,
|
|
7
|
+
type Stats,
|
|
8
|
+
} from "./layout.ts"
|
|
9
|
+
import { PANEL_NAME, createDisposables, panelCommand, registerSidebar } from "./setup.ts"
|
|
10
|
+
import { loadStats } from "./beans.ts"
|
|
11
|
+
import { createRefresher } from "./refresh.ts"
|
|
12
|
+
import { CACHE_KEY, createCache, type CacheState } from "./cache.ts"
|
|
13
|
+
|
|
14
|
+
function BeansLines(props: {
|
|
15
|
+
readonly stats: Accessor<Stats | null>
|
|
16
|
+
readonly error: Accessor<string | null>
|
|
17
|
+
readonly layout: (stats: Stats) => SectionLayout
|
|
18
|
+
}) {
|
|
19
|
+
const context = usePlugin()
|
|
20
|
+
return (
|
|
21
|
+
<Show
|
|
22
|
+
when={props.stats()}
|
|
23
|
+
fallback={<text fg={context.theme.text.muted}>{props.error() ?? "loading"}</text>}
|
|
24
|
+
>
|
|
25
|
+
{(stats: Accessor<Stats>) => (
|
|
26
|
+
<>
|
|
27
|
+
<text fg={context.theme.text.base}>{props.layout(stats()).total}</text>
|
|
28
|
+
<Index each={props.layout(stats()).body}>
|
|
29
|
+
{(line) => <text fg={context.theme.text.muted}>{line()}</text>}
|
|
30
|
+
</Index>
|
|
31
|
+
</>
|
|
32
|
+
)}
|
|
33
|
+
</Show>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function BeansStats(props: {
|
|
38
|
+
readonly stats: Accessor<Stats | null>
|
|
39
|
+
readonly error: Accessor<string | null>
|
|
40
|
+
}) {
|
|
41
|
+
const context = usePlugin()
|
|
42
|
+
return (
|
|
43
|
+
<box flexDirection="column" gap={0}>
|
|
44
|
+
<text fg={context.theme.text.muted}>BEANS</text>
|
|
45
|
+
<BeansLines stats={props.stats} error={props.error} layout={layoutSection} />
|
|
46
|
+
</box>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function BeansBreakdown(props: {
|
|
51
|
+
readonly stats: Accessor<Stats | null>
|
|
52
|
+
readonly error: Accessor<string | null>
|
|
53
|
+
}) {
|
|
54
|
+
const context = usePlugin()
|
|
55
|
+
return (
|
|
56
|
+
<box flexDirection="column" gap={0}>
|
|
57
|
+
<text fg={context.theme.text.muted}>BEANS</text>
|
|
58
|
+
<BeansLines stats={props.stats} error={props.error} layout={layoutBreakdown} />
|
|
59
|
+
</box>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export default Plugin.define({
|
|
64
|
+
id: "beans.stats",
|
|
65
|
+
setup(context) {
|
|
66
|
+
const [stats, setStats] = createSignal<Stats | null>(null)
|
|
67
|
+
const [error, setError] = createSignal<string | null>(null)
|
|
68
|
+
const bag = createDisposables()
|
|
69
|
+
const dir = () => (context.location ?? context.data.location.default()).directory
|
|
70
|
+
|
|
71
|
+
const [cacheState, updateCache] = context.storage.store<CacheState>(CACHE_KEY, { initial: {} })
|
|
72
|
+
const cache = createCache(
|
|
73
|
+
{
|
|
74
|
+
read: (directory) => cacheState[directory],
|
|
75
|
+
write: (directory, value) => {
|
|
76
|
+
void updateCache((draft) => {
|
|
77
|
+
draft[directory] = value
|
|
78
|
+
})
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
dir(),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
const cached = cache.seed()
|
|
85
|
+
if (cached !== null) {
|
|
86
|
+
setStats(cached)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const refresher = createRefresher<Stats>({
|
|
90
|
+
load: (signal) => loadStats(dir(), { signal }),
|
|
91
|
+
onValue: (value) => {
|
|
92
|
+
setStats(value)
|
|
93
|
+
setError(null)
|
|
94
|
+
cache.save(value)
|
|
95
|
+
},
|
|
96
|
+
onError: () => {
|
|
97
|
+
setStats(null)
|
|
98
|
+
setError("unavailable")
|
|
99
|
+
},
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
void refresher.refresh()
|
|
103
|
+
|
|
104
|
+
bag.add(context.data.on("session.execution.succeeded", () => void refresher.refresh()))
|
|
105
|
+
|
|
106
|
+
const timer = setInterval(() => void refresher.refresh(), 30_000)
|
|
107
|
+
bag.add(() => clearInterval(timer))
|
|
108
|
+
|
|
109
|
+
bag.add(refresher.dispose)
|
|
110
|
+
|
|
111
|
+
bag.add(
|
|
112
|
+
registerSidebar(context, {
|
|
113
|
+
append: "sidebar.content",
|
|
114
|
+
render: () => <BeansStats stats={stats} error={error} />,
|
|
115
|
+
}),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
bag.add(
|
|
119
|
+
registerSidebar(context, {
|
|
120
|
+
append: "session.panel",
|
|
121
|
+
render: (input) => (
|
|
122
|
+
<Show when={input.name === PANEL_NAME}>
|
|
123
|
+
<BeansBreakdown stats={stats} error={error} />
|
|
124
|
+
</Show>
|
|
125
|
+
),
|
|
126
|
+
}),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
context.keymap.layer(() => ({
|
|
130
|
+
commands: [panelCommand((name) => context.ui.panel.open(name))],
|
|
131
|
+
}))
|
|
132
|
+
|
|
133
|
+
return () => bag.dispose()
|
|
134
|
+
},
|
|
135
|
+
})
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pim Snel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,91 @@
|
|
|
1
|
-
#
|
|
1
|
+
# opencode-beans
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
OpenCode v2 plugin that shows [Beans](https://github.com/hmans/beans) issue
|
|
4
|
+
statistics in the sidebar of the OpenCode TUI.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
BEANS
|
|
8
|
+
42 total
|
|
9
|
+
|
|
10
|
+
todo 18 ████
|
|
11
|
+
in-progress 6 █▌
|
|
12
|
+
completed 2 ▌
|
|
13
|
+
|
|
14
|
+
ready 11
|
|
15
|
+
blocked 3
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The section refreshes after every assistant turn and on a fallback timer. When
|
|
19
|
+
`.beans/` is missing or `beans` fails it shows a muted `unavailable` line
|
|
20
|
+
instead of crashing.
|
|
21
|
+
|
|
22
|
+
The last counts are cached per project, so a project that has been read before
|
|
23
|
+
paints its counts immediately on startup. The full breakdown, including the
|
|
24
|
+
per-type split, opens in a session panel from a command bound to `<leader>d`.
|
|
25
|
+
|
|
26
|
+
## Prerequisites
|
|
27
|
+
|
|
28
|
+
`beans`, `openspec` and `jj` must be on `PATH`. Everything else comes from the
|
|
29
|
+
nix flake.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
The plugin is discovered automatically from a project's
|
|
34
|
+
`.opencode/plugins/<name>/` directory. To use it in a project:
|
|
35
|
+
|
|
36
|
+
1. Make sure that project has been initialized with `beans init`, so `.beans/`
|
|
37
|
+
and `.beans.yml` exist at its root.
|
|
38
|
+
2. Copy the plugin directory into the project:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
mkdir -p .opencode/plugins
|
|
42
|
+
cp -r /path/to/opencode-beans/.opencode/plugins/beans-stats .opencode/plugins/
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
3. Start OpenCode in that project. Confirm the plugin loaded:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
opencode plugin list
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The assembled plugin is also available from `nix build`, see [Build](#build).
|
|
52
|
+
|
|
53
|
+
## Development
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
nix develop # working environment: Node 24, python3, jujutsu, nixfmt
|
|
57
|
+
npm install # dependencies for the tests and the type check
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Testing
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
npm test # unit and integration tests
|
|
64
|
+
npm run test:e2e # end to end tests against the real OpenCode terminal
|
|
65
|
+
npm run typecheck # TypeScript
|
|
66
|
+
nix flake check # unit and integration tests in a pure sandbox
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The unit tests are pure logic. The integration tests run the real `beans` CLI
|
|
70
|
+
against a temporary fixture project and skip when `beans` is not available. The
|
|
71
|
+
end to end tests boot OpenCode against a fixture project in a pseudo terminal
|
|
72
|
+
and assert the rendered sidebar; they skip when their prerequisites are absent.
|
|
73
|
+
See `e2e/README.md` for the harness.
|
|
74
|
+
|
|
75
|
+
## Build
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
nix build
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The plugin files are then at `result/plugins/beans-stats/` (`index.ts`,
|
|
82
|
+
`tui.tsx`, `beans.ts`, `cache.ts`, `layout.ts`, `refresh.ts`, `setup.ts`).
|
|
83
|
+
|
|
84
|
+
## How work is organised
|
|
85
|
+
|
|
86
|
+
- Milestones and epics: beans. Milestone titles start with `01`, `02`, ...
|
|
87
|
+
- Epic internals: OpenSpec. One change per epic.
|
|
88
|
+
- Version control: jj, committed as Pim Snel.
|
|
89
|
+
|
|
90
|
+
See `AGENTS.md` for the full operating manual and `BRIEFING.md` for the product
|
|
91
|
+
goal.
|
package/package.json
CHANGED
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-beans",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Beans stats sidebar plugin for OpenCode v2",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/mipmip/opencode-beans.git"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"opencode",
|
|
13
|
+
"opencode-plugin",
|
|
14
|
+
"beans"
|
|
15
|
+
],
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./.opencode/plugins/beans-stats/index.ts",
|
|
18
|
+
"./tui": "./.opencode/plugins/beans-stats/tui.tsx"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
".opencode/plugins/beans-stats"
|
|
22
|
+
],
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "node --test \"tests/**/*.test.ts\"",
|
|
28
|
+
"test:e2e": "node --test \"e2e/**/*.test.ts\"",
|
|
29
|
+
"typecheck": "tsc --noEmit"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@opencode/plugin": "^2.0.31"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@opentui/core": ">=0.6.6",
|
|
36
|
+
"@opentui/solid": ">=0.6.6",
|
|
37
|
+
"solid-js": ">=1.9.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@opentui/core": ">=0.6.6",
|
|
41
|
+
"@opentui/solid": ">=0.6.6",
|
|
42
|
+
"@types/node": "^24",
|
|
43
|
+
"solid-js": "^1.9.0",
|
|
44
|
+
"typescript": "^5"
|
|
45
|
+
}
|
|
46
|
+
}
|