opencode-beads_rust-stats-sidebar 0.0.0-stage → 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 melik
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,144 @@
1
- # Temporary Holding Version
1
+ # opencode-beads_rust-stats-sidebar
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [Русская версия](https://github.com/melik/opencode-beads_rust-stats-sidebar/blob/main/README.ru.md)
4
+
5
+ An OpenCode V2 plugin that displays [beads_rust](https://github.com/Dicklesworthstone/beads_rust) statistics and active tasks in the terminal UI sidebar.
6
+
7
+ ## What it displays
8
+
9
+ - The total number of tasks.
10
+ - Counts of open, in-progress, blocked, and closed tasks.
11
+ - A 10-character bar and percentage of the total for each category.
12
+ - The ID and title of tasks with the `in_progress` status.
13
+ - A `2 epics ready to close` line (or `2 эпика готовы к закрытию` for the Russian locale) when epics have all their child tasks closed. This line appears only when the count is positive, after the statistics and before the active tasks.
14
+ - An expandable description when there is exactly one active task.
15
+ - An `ID - title` list when there are multiple active tasks.
16
+
17
+ Example with one active task:
18
+
19
+ ```text
20
+ Beads
21
+ Total tasks: 18
22
+ Open ■■■■■■□□□□ 11 (61%)
23
+ In progress ■□□□□□□□□□ 1 (6%)
24
+ Blocked ■■■■■■□□□□ 10 (56%)
25
+ Closed ■■■□□□□□□□ 6 (33%)
26
+
27
+ opencode-mjf ▾
28
+ Current task title
29
+ ```
30
+
31
+ Example with multiple active tasks:
32
+
33
+ ```text
34
+ Beads
35
+ Total tasks: 4
36
+ Open ■■■□□□□□□□ 1 (25%)
37
+ In progress ■■■■■□□□□□ 2 (50%)
38
+ Blocked ■■■□□□□□□□ 1 (25%)
39
+ Closed ■■■□□□□□□□ 1 (25%)
40
+
41
+ opencode-bb7 - Temporary task 4
42
+ opencode-mjf - Temporary task 2
43
+ ```
44
+
45
+ If there are no active tasks, the task list is not displayed. The line for epics ready to close is displayed independently of whether there are active tasks.
46
+
47
+ ## How it works
48
+
49
+ The server part of the plugin runs three commands in the current project directory:
50
+
51
+ ```sh
52
+ br stats --json --no-activity
53
+ br list --status in_progress --json
54
+ br epic status --eligible-only --json
55
+ ```
56
+
57
+ The total count and status statistics come from `stats`. The `in_progress` tasks come from `list`, sorted by `updated_at` from newest to oldest. The number of epics with all child tasks closed comes from `epic status --eligible-only`. All three commands run in parallel when the plugin loads, and again 5 seconds after each request completes.
58
+
59
+ The TUI part passes the current project's location to RPC, so statistics are not mixed with those of other open projects. The block is hidden until the first successful response. During subsequent updates, the last received data remains visible.
60
+
61
+ When there is exactly one active task, left-clicking its ID or title shows or hides its description:
62
+
63
+ - `▾`: description collapsed;
64
+ - `▴`: description expanded.
65
+
66
+ The expanded state is preserved for the last expanded task while the plugin is loaded.
67
+
68
+ When there are multiple active tasks, they are displayed as an `ID - title` list without arrows, descriptions, or click handlers.
69
+
70
+ If Beads is not initialized, `br` is unavailable, or reading `stats` or `list` fails, the plugin hides the entire block. If `br epic status --eligible-only --json` is unavailable or returns an invalid response, the statistics and active tasks remain visible, but the epics line is hidden.
71
+
72
+ ## Installation and updates
73
+
74
+ Requirements:
75
+
76
+ - OpenCode V2 with support for `sidebar.content`;
77
+ - the `br` command available in the OpenCode server process's `PATH`;
78
+ - an initialized `.beads` directory in the project.
79
+
80
+ Install the plugin globally using the OpenCode V2 CLI, either from npm or directly from GitHub:
81
+
82
+ ```sh
83
+ opencode plugin add opencode-beads_rust-stats-sidebar
84
+ ```
85
+
86
+ ```sh
87
+ opencode plugin add github:melik/opencode-beads_rust-stats-sidebar
88
+ ```
89
+
90
+ Alternatively, configure it declaratively in `opencode.jsonc`: globally in `~/.config/opencode/opencode.jsonc`, or in the project in `opencode.jsonc` or `.opencode/opencode.jsonc`:
91
+
92
+ ```jsonc
93
+ {
94
+ "$schema": "https://opencode.ai/config.json",
95
+ "plugins": ["opencode-beads_rust-stats-sidebar"]
96
+ }
97
+ ```
98
+
99
+ The plugin has server, TUI, and RPC parts, so one entry in `opencode.jsonc` automatically loads all of them. A separate `cli.json` configuration is not needed. Its sidebar heading is always the short `Beads` label.
100
+
101
+ For a local installation, place the plugin directory in `~/.config/opencode/plugins/opencode-beads_rust-stats-sidebar` or `.opencode/plugins/opencode-beads_rust-stats-sidebar` for automatic loading, or specify the local directory path in the `plugins` array.
102
+
103
+ Update the installed plugin:
104
+
105
+ ```sh
106
+ opencode plugin update opencode-beads_rust-stats-sidebar
107
+ ```
108
+
109
+ OpenCode reloads configuration and plugin file changes. A full restart is usually not required.
110
+
111
+ ## Localization and appearance
112
+
113
+ A Russian system locale enables Russian labels. English is used for all other locales. The selection considers `LC_ALL`, `LC_MESSAGES`, `LANG`, and the `Intl` system locale.
114
+
115
+ Task titles and descriptions are not translated. The `Beads` heading is bold and uses the theme's primary text color; the remaining lines use the muted text color.
116
+
117
+ ## Limitations
118
+
119
+ - The description is available only when there is one active task; the list of multiple tasks does not support it.
120
+ - The current task is selected by its status, not by a specific OpenCode session or assignee.
121
+ - The plugin is read-only: creating, closing, and modifying tasks through the sidebar is not supported.
122
+ - A mouse is required to expand the description of a single task; there is no dedicated keyboard command. Mouse support depends on the terminal.
123
+ - Updates use polling 5 seconds after the previous request completes, rather than a subscription to Beads events.
124
+ - Git activity, average completion time, and additional `br stats` breakdowns are not displayed.
125
+ - Bars and percentages are rounded to whole numbers. With a small number of tasks, one task can fill multiple characters.
126
+ - Blocked tasks can also be counted under other statuses. Therefore, the rows and percentages are not mutually exclusive and can add up to more than 100%.
127
+ - When the total task count is zero, the bars are empty and percentages are zero.
128
+
129
+ ## Structure
130
+
131
+ - `index.ts`: server RPC registration.
132
+ - `beads.ts`: `br` execution, data validation, and transformation.
133
+ - `rpc.ts`: the contract between the server and TUI.
134
+ - `tui.tsx`: the sidebar block, updates, and description expansion.
135
+ - `display.ts`: bars, numbers, and percentages.
136
+ - `i18n.ts`: system locale and labels.
137
+
138
+ ## Verification
139
+
140
+ ```sh
141
+ npm ci
142
+ npm test
143
+ npm run typecheck
144
+ ```
package/README.ru.md ADDED
@@ -0,0 +1,142 @@
1
+ # opencode-beads_rust-stats-sidebar
2
+
3
+ Плагин для OpenCode V2, который показывает статистику [beads_rust](https://github.com/Dicklesworthstone/beads_rust) и активные задачи в боковой панели терминального интерфейса.
4
+
5
+ ## Что отображается
6
+
7
+ - Общее количество задач.
8
+ - Количество открытых задач, задач в работе, заблокированных и закрытых задач.
9
+ - Полоска из 10 символов и процент от общего количества для каждой категории.
10
+ - ID и название задачи со статусом `in_progress`.
11
+ - Строка `2 epics ready to close` (для русской локали `2 эпика готовы к закрытию`), если есть эпики, у которых все дочерние задачи закрыты. Строка появляется только при положительном количестве таких эпиков и располагается после статистики и перед активными задачами.
12
+ - Описание по запросу, если активная задача только одна.
13
+ - Список `ID - название`, если активных задач несколько.
14
+
15
+ Пример с одной активной задачей:
16
+
17
+ ```text
18
+ Beads
19
+ Всего задач: 18
20
+ Открыто ■■■■■■□□□□ 11 (61%)
21
+ В работе ■□□□□□□□□□ 1 (6%)
22
+ Блокировано ■■■■■□□□□□ 10 (56%)
23
+ Закрыто ■■■□□□□□□□ 6 (33%)
24
+
25
+ opencode-mjf ▾
26
+ Название текущей задачи
27
+ ```
28
+
29
+ Пример с несколькими активными задачами:
30
+
31
+ ```text
32
+ Beads
33
+ Всего задач: 4
34
+ Открыто ■■■□□□□□□□ 1 (25%)
35
+ В работе ■■■■■□□□□□ 2 (50%)
36
+ Блокировано ■■■□□□□□□□ 1 (25%)
37
+ Закрыто ■■■□□□□□□□ 1 (25%)
38
+
39
+ opencode-bb7 - Временная задача 4
40
+ opencode-mjf - Временная задача 2
41
+ ```
42
+
43
+ Если активных задач нет, список задач не отображается. Строка готовых к закрытию эпиков показывается независимо от наличия активных задач.
44
+
45
+ ## Как это работает
46
+
47
+ Серверная часть плагина выполняет три команды в каталоге текущего проекта:
48
+
49
+ ```sh
50
+ br stats --json --no-activity
51
+ br list --status in_progress --json
52
+ br epic status --eligible-only --json
53
+ ```
54
+
55
+ Из `stats` берутся общее количество и статистика статусов. Из `list` берутся задачи `in_progress`, отсортированные по `updated_at` от более новых к более старым. Из `epic status --eligible-only` берётся количество эпиков, у которых все дочерние задачи закрыты. Все три команды выполняются параллельно при загрузке плагина, затем через 5 секунд после завершения каждого запроса.
56
+
57
+ TUI-часть передаёт в RPC location текущего проекта, поэтому статистика не смешивается с другими открытыми проектами. До первого успешного ответа блок скрыт. Во время последующих обновлений отображаются последние полученные данные.
58
+
59
+ Если активная задача одна, клик левой кнопкой мыши по ID или названию раскрывает и скрывает описание:
60
+
61
+ - `▾`: описание закрыто;
62
+ - `▴`: описание открыто.
63
+
64
+ Состояние раскрытия сохраняется для последней раскрытой задачи, пока плагин загружен.
65
+
66
+ Если активных задач несколько, они выводятся списком `ID - название`. В этом случае стрелки, описания и обработчики клика отсутствуют.
67
+
68
+ Если Beads не инициализирован, `br` недоступен или чтение данных `stats`/`list` завершилось ошибкой, плагин скрывает весь блок. Если команда `br epic status --eligible-only --json` недоступна или вернула некорректный ответ, статистика и активные задачи сохраняются, а строка про эпики просто не показывается.
69
+
70
+ ## Установка и обновление
71
+
72
+ Для работы нужны:
73
+
74
+ - OpenCode V2 с поддержкой `sidebar.content`;
75
+ - команда `br`, доступная в `PATH` серверного процесса OpenCode;
76
+ - инициализированный каталог `.beads` в проекте.
77
+
78
+ Установить плагин глобально через CLI OpenCode V2 можно из npm или напрямую из GitHub:
79
+
80
+ ```sh
81
+ opencode plugin add opencode-beads_rust-stats-sidebar
82
+ ```
83
+
84
+ ```sh
85
+ opencode plugin add github:melik/opencode-beads_rust-stats-sidebar
86
+ ```
87
+
88
+ Либо подключить его декларативно в `opencode.jsonc` (глобально в `~/.config/opencode/opencode.jsonc`, либо в проекте в `opencode.jsonc` или `.opencode/opencode.jsonc`):
89
+
90
+ ```jsonc
91
+ {
92
+ "$schema": "https://opencode.ai/config.json",
93
+ "plugins": ["opencode-beads_rust-stats-sidebar"]
94
+ }
95
+ ```
96
+
97
+ Плагин состоит из серверной, TUI и RPC частей, поэтому одной записи в `opencode.jsonc` достаточно для автоматической загрузки всех частей (отдельная настройка `cli.json` не нужна). В sidebar он всегда сохраняет короткий заголовок `Beads`.
98
+
99
+ Для локальной установки поместите каталог плагина в `~/.config/opencode/plugins/opencode-beads_rust-stats-sidebar` или `.opencode/plugins/opencode-beads_rust-stats-sidebar` (он загрузится автоматически), либо укажите локальный путь к каталогу в массиве `plugins`.
100
+
101
+ Обновление установленного плагина:
102
+
103
+ ```sh
104
+ opencode plugin update opencode-beads_rust-stats-sidebar
105
+ ```
106
+
107
+ Изменения конфигурации и файлов плагина подхватываются OpenCode через механизм перезагрузки. Полного перезапуска обычно не требуется.
108
+
109
+ ## Локализация и оформление
110
+
111
+ Русская системная локаль включает русские подписи. Для всех остальных локалей используется английский. Выбор учитывает `LC_ALL`, `LC_MESSAGES`, `LANG` и системную локаль `Intl`.
112
+
113
+ Названия и описания задач не переводятся. Заголовок `Beads` выделен жирным основным цветом темы, остальные строки используют приглушённый цвет темы.
114
+
115
+ ## Ограничения
116
+
117
+ - Описание доступно только при одной активной задаче; список из нескольких задач его не поддерживает.
118
+ - Поддерживается выбор текущей задачи через её статус; привязки к конкретной сессии OpenCode или исполнителю нет.
119
+ - Плагин только читает данные: создание, закрытие и изменение задач через sidebar не поддерживаются.
120
+ - Для описания одиночной задачи используется мышь; отдельной клавиатурной команды для его раскрытия нет. Поддержка мыши зависит от терминала.
121
+ - Обновление выполняется опросом через 5 секунд после завершения предыдущего запроса, а не подпиской на события Beads.
122
+ - Активность из Git, среднее время выполнения и дополнительные разбивки `br stats` не отображаются.
123
+ - Полоски и проценты округляются до целых. При небольшом количестве задач одна задача может заполнить несколько символов.
124
+ - Заблокированные задачи могут учитываться и в других статусах. Поэтому строки и проценты не являются взаимоисключающими и могут суммарно превышать 100%.
125
+ - При нулевом общем количестве задач полоски пустые, проценты равны нулю.
126
+
127
+ ## Структура
128
+
129
+ - `index.ts`: регистрация серверного RPC.
130
+ - `beads.ts`: запуск `br`, проверка и преобразование данных.
131
+ - `rpc.ts`: схема обмена между сервером и TUI.
132
+ - `tui.tsx`: блок sidebar, обновления и раскрытие описания.
133
+ - `display.ts`: полоски, числа и проценты.
134
+ - `i18n.ts`: системная локаль и подписи.
135
+
136
+ ## Проверка
137
+
138
+ ```sh
139
+ npm ci
140
+ npm test
141
+ npm run typecheck
142
+ ```
package/beads.ts ADDED
@@ -0,0 +1,117 @@
1
+ import { execFile } from "node:child_process"
2
+ import { promisify } from "node:util"
3
+
4
+ const execFileAsync = promisify(execFile)
5
+
6
+ export type CurrentTask = {
7
+ id: string
8
+ title: string
9
+ description: string | null
10
+ }
11
+
12
+ export type Snapshot = {
13
+ available: boolean
14
+ total: number
15
+ open: number
16
+ inProgress: number
17
+ blocked: number
18
+ closed: number
19
+ epicsReady: number
20
+ tasks: CurrentTask[]
21
+ current: CurrentTask | null
22
+ }
23
+
24
+ export type BrRunner = (directory: string, args: readonly string[], signal?: AbortSignal) => Promise<string>
25
+
26
+ export function hiddenSnapshot(): Snapshot {
27
+ return { available: false, total: 0, open: 0, inProgress: 0, blocked: 0, closed: 0, epicsReady: 0, tasks: [], current: null }
28
+ }
29
+
30
+ const runBr: BrRunner = async (directory, args, signal) => {
31
+ const result = await execFileAsync("br", [...args], {
32
+ cwd: directory,
33
+ encoding: "utf8",
34
+ signal,
35
+ timeout: 5000,
36
+ maxBuffer: 4 * 1024 * 1024,
37
+ })
38
+ return result.stdout
39
+ }
40
+
41
+ function record(value: unknown): Record<string, unknown> {
42
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid br response")
43
+ return value as Record<string, unknown>
44
+ }
45
+
46
+ function count(value: unknown): number {
47
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) throw new Error("Invalid br count")
48
+ return value
49
+ }
50
+
51
+ function text(value: unknown): string {
52
+ if (typeof value !== "string") throw new Error("Invalid br text")
53
+ return value
54
+ }
55
+
56
+ function parseTask(value: unknown) {
57
+ const issue = record(value)
58
+ const description = issue.description
59
+ if (description !== null && description !== undefined && typeof description !== "string") {
60
+ throw new Error("Invalid br description")
61
+ }
62
+ const updatedAt = Date.parse(text(issue.updated_at))
63
+ if (!Number.isFinite(updatedAt)) throw new Error("Invalid br update time")
64
+ return {
65
+ id: text(issue.id),
66
+ title: text(issue.title),
67
+ description: description ?? null,
68
+ updatedAt,
69
+ }
70
+ }
71
+
72
+ function parseEpicCount(epicStatusJson: string | undefined): number {
73
+ if (epicStatusJson === undefined) return 0
74
+ try {
75
+ const epics = JSON.parse(epicStatusJson)
76
+ if (!Array.isArray(epics)) throw new Error("Invalid br epic status")
77
+ return epics.length
78
+ } catch {
79
+ return 0
80
+ }
81
+ }
82
+
83
+ export function parseSnapshot(statsJson: string, listJson: string, epicStatusJson?: string): Snapshot {
84
+ const summary = record(record(JSON.parse(statsJson)).summary)
85
+ const issues = record(JSON.parse(listJson)).issues
86
+ if (!Array.isArray(issues)) throw new Error("Invalid br issue list")
87
+
88
+ const tasks = issues.map(parseTask)
89
+ .sort((left, right) => right.updatedAt - left.updatedAt)
90
+ .map(({ id, title, description }) => ({ id, title, description }))
91
+ const current = tasks.length === 1 ? tasks[0] : null
92
+
93
+ return {
94
+ available: true,
95
+ total: count(summary.total_issues),
96
+ open: count(summary.open_issues),
97
+ inProgress: count(summary.in_progress_issues),
98
+ blocked: count(summary.blocked_issues),
99
+ closed: count(summary.closed_issues),
100
+ epicsReady: parseEpicCount(epicStatusJson),
101
+ tasks,
102
+ current,
103
+ }
104
+ }
105
+
106
+ export async function readSnapshot(directory: string, signal?: AbortSignal, run: BrRunner = runBr): Promise<Snapshot> {
107
+ try {
108
+ const [stats, list, epics] = await Promise.all([
109
+ run(directory, ["stats", "--json", "--no-activity"], signal),
110
+ run(directory, ["list", "--status", "in_progress", "--json"], signal),
111
+ run(directory, ["epic", "status", "--eligible-only", "--json"], signal).catch(() => null),
112
+ ])
113
+ return parseSnapshot(stats, list, epics ?? undefined)
114
+ } catch {
115
+ return hiddenSnapshot()
116
+ }
117
+ }
package/display.ts ADDED
@@ -0,0 +1,10 @@
1
+ export function progressBar(value: number, total: number): string {
2
+ if (total === 0) return "□".repeat(10)
3
+ const filled = Math.max(0, Math.min(10, Math.round(value / total * 10)))
4
+ return "■".repeat(filled) + "□".repeat(10 - filled)
5
+ }
6
+
7
+ export function statisticLine(label: string, value: number, total: number, labelWidth: number): string {
8
+ const percent = total === 0 ? 0 : Math.round(value / total * 100)
9
+ return `${label.padEnd(labelWidth)} ${progressBar(value, total)} ${value} (${percent}%)`
10
+ }
package/i18n.ts ADDED
@@ -0,0 +1,38 @@
1
+ type Language = "ru" | "en"
2
+
3
+ const ruEpicsReady = (count: number) => {
4
+ const mod100 = count % 100
5
+ const mod10 = count % 10
6
+ const form = mod10 === 1 && mod100 !== 11
7
+ ? "эпик"
8
+ : mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)
9
+ ? "эпика"
10
+ : "эпиков"
11
+ return `${count} ${form} ${form === "эпик" ? "готов" : "готовы"} к закрытию`
12
+ }
13
+
14
+ const translations = {
15
+ ru: {
16
+ total: (count: number) => `Всего задач: ${count}`,
17
+ statuses: ["Открыто", "В работе", "Блокировано", "Закрыто"],
18
+ epicsReady: ruEpicsReady,
19
+ },
20
+ en: {
21
+ total: (count: number) => `Total tasks: ${count}`,
22
+ statuses: ["Open", "In progress", "Blocked", "Closed"],
23
+ epicsReady: (count: number) => `${count} ${count === 1 ? "epic" : "epics"} ready to close`,
24
+ },
25
+ }
26
+
27
+ export function resolveLanguage(locale: string): Language {
28
+ return /^ru(?:[-_.@]|$)/i.test(locale) ? "ru" : "en"
29
+ }
30
+
31
+ export function systemLocale(env: { LC_ALL?: string; LC_MESSAGES?: string; LANG?: string } = process.env) {
32
+ return env.LC_ALL || env.LC_MESSAGES || env.LANG || Intl.DateTimeFormat().resolvedOptions().locale
33
+ }
34
+
35
+ export function createI18n(locale = systemLocale()) {
36
+ const language = resolveLanguage(locale)
37
+ return { language, ...translations[language] }
38
+ }
package/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ import { Plugin } from "@opencode/plugin"
2
+ import { readSnapshot } from "./beads.js"
3
+ import { BeadsCurrent } from "./rpc.js"
4
+
5
+ export default Plugin.define({
6
+ id: "beads_rust-sidebar",
7
+ async setup(ctx) {
8
+ const registration = await ctx.rpc.register(BeadsCurrent, {
9
+ get: async (_input, context) => readSnapshot(ctx.location.directory, context.signal),
10
+ })
11
+ return () => registration.dispose()
12
+ },
13
+ })
package/package.json CHANGED
@@ -1,6 +1,60 @@
1
1
  {
2
2
  "name": "opencode-beads_rust-stats-sidebar",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "OpenCode V2 plugin that displays beads_rust (br) issue statistics, ready-to-close epics, and active in_progress tasks in the TUI sidebar.",
5
+ "keywords": [
6
+ "opencode",
7
+ "opencode-plugin",
8
+ "beads",
9
+ "beads-rust",
10
+ "tui",
11
+ "solidjs",
12
+ "typescript"
13
+ ],
14
+ "homepage": "https://github.com/melik/opencode-beads_rust-stats-sidebar#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/melik/opencode-beads_rust-stats-sidebar/issues"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/melik/opencode-beads_rust-stats-sidebar.git"
21
+ },
22
+ "license": "MIT",
23
+ "author": "Alexey M (https://github.com/melik)",
24
+ "type": "module",
25
+ "exports": {
26
+ ".": "./index.ts",
27
+ "./tui": "./tui.tsx",
28
+ "./rpc": "./rpc.ts"
29
+ },
30
+ "main": "index.js",
31
+ "files": [
32
+ "index.ts",
33
+ "tui.tsx",
34
+ "rpc.ts",
35
+ "beads.ts",
36
+ "display.ts",
37
+ "i18n.ts",
38
+ "README.md"
39
+ ],
40
+ "scripts": {
41
+ "test": "node --test beads.test.ts display.test.ts i18n.test.ts",
42
+ "typecheck": "tsc --noEmit",
43
+ "prepublishOnly": "npm test && npm run typecheck"
44
+ },
45
+ "dependencies": {
46
+ "@opencode/plugin": "latest"
47
+ },
48
+ "devDependencies": {
49
+ "typescript": "^5.6.0",
50
+ "@types/node": "^22.0.0"
51
+ },
52
+ "peerDependencies": {
53
+ "@opentui/core": ">=0.6.6",
54
+ "@opentui/solid": ">=0.6.6",
55
+ "solid-js": ">=1.9.0"
56
+ },
57
+ "engines": {
58
+ "node": ">=22.6.0"
59
+ }
60
+ }
package/rpc.ts ADDED
@@ -0,0 +1,43 @@
1
+ import { Rpc } from "@opencode/plugin/rpc"
2
+
3
+ export const BeadsCurrent = Rpc.define({
4
+ id: "beads_rust-sidebar",
5
+ methods: {
6
+ get: {
7
+ input: {
8
+ type: "object",
9
+ additionalProperties: false,
10
+ },
11
+ output: {
12
+ type: "object",
13
+ properties: {
14
+ available: { type: "boolean" },
15
+ total: { type: "integer", minimum: 0 },
16
+ open: { type: "integer", minimum: 0 },
17
+ inProgress: { type: "integer", minimum: 0 },
18
+ blocked: { type: "integer", minimum: 0 },
19
+ closed: { type: "integer", minimum: 0 },
20
+ epicsReady: { type: "integer", minimum: 0 },
21
+ tasks: { type: "array", items: { $ref: "#/$defs/task" } },
22
+ current: { anyOf: [{ type: "null" }, { $ref: "#/$defs/task" }] },
23
+ },
24
+ required: ["available", "total", "open", "inProgress", "blocked", "closed", "epicsReady", "tasks", "current"],
25
+ additionalProperties: false,
26
+ $defs: {
27
+ task: {
28
+ type: "object",
29
+ properties: {
30
+ id: { type: "string" },
31
+ title: { type: "string" },
32
+ description: { anyOf: [{ type: "string" }, { type: "null" }] },
33
+ },
34
+ required: ["id", "title", "description"],
35
+ additionalProperties: false,
36
+ },
37
+ },
38
+ },
39
+ errors: {},
40
+ },
41
+ },
42
+ events: {},
43
+ })
package/tui.tsx ADDED
@@ -0,0 +1,108 @@
1
+ import { Plugin } from "@opencode/plugin/tui"
2
+ import { createSignal, For, Show } from "solid-js"
3
+ import { hiddenSnapshot, type Snapshot } from "./beads.js"
4
+ import { statisticLine } from "./display.js"
5
+ import { createI18n } from "./i18n.js"
6
+ import { BeadsCurrent } from "./rpc.js"
7
+
8
+ const hasDescription = (description: string | null | undefined) => Boolean(description?.trim())
9
+
10
+ export default Plugin.define({
11
+ id: "beads_rust-sidebar-tui",
12
+ setup(ctx) {
13
+ const i18n = createI18n()
14
+ const labelWidth = Math.max(...i18n.statuses.map((label) => label.length))
15
+ const location = ctx.location ?? ctx.data.location.default()
16
+ const [snapshot, setSnapshot] = createSignal<Snapshot>(hiddenSnapshot())
17
+ const [expandedTaskID, setExpandedTaskID] = createSignal<string | null>(null)
18
+ let disposed = false
19
+ let sequence = 0
20
+
21
+ async function refresh() {
22
+ const request = ++sequence
23
+ try {
24
+ const next = await ctx.client.rpc(BeadsCurrent).get({}, { location }) as Snapshot
25
+ if (!disposed && request === sequence) setSnapshot(next)
26
+ } catch {
27
+ if (!disposed && request === sequence) setSnapshot(hiddenSnapshot())
28
+ }
29
+ }
30
+
31
+ function toggleDescription(id: string) {
32
+ setExpandedTaskID(expandedTaskID() === id ? null : id)
33
+ }
34
+
35
+ const remove = ctx.ui.slot({
36
+ append: "sidebar.content",
37
+ render: () => (
38
+ <Show when={snapshot().available}>
39
+ <box flexDirection="column" paddingTop={1}>
40
+ <text fg={ctx.theme.text.base}><b>Beads</b></text>
41
+ <text fg={ctx.theme.text.muted}>{i18n.total(snapshot().total)}</text>
42
+ {i18n.statuses.map((label, index) => (
43
+ <text fg={ctx.theme.text.muted}>
44
+ {statisticLine(label, [snapshot().open, snapshot().inProgress, snapshot().blocked, snapshot().closed][index], snapshot().total, labelWidth)}
45
+ </text>
46
+ ))}
47
+ <Show when={snapshot().epicsReady > 0}>
48
+ <text fg={ctx.theme.text.muted}>{i18n.epicsReady(snapshot().epicsReady)}</text>
49
+ </Show>
50
+ <Show when={snapshot().tasks.length > 0}>
51
+ <box flexDirection="column" paddingTop={1}>
52
+ <Show when={snapshot().current}>
53
+ {(task) => (
54
+ <>
55
+ <text
56
+ fg={ctx.theme.text.muted}
57
+ selectable={false}
58
+ onMouseDown={(event) => {
59
+ if (event.button !== 0 || !hasDescription(task().description)) return
60
+ event.stopPropagation()
61
+ toggleDescription(task().id)
62
+ }}
63
+ >
64
+ {hasDescription(task().description) ? `${task().id} ${expandedTaskID() === task().id ? "▴" : "▾"}` : task().id}
65
+ </text>
66
+ <text
67
+ fg={ctx.theme.text.muted}
68
+ selectable={false}
69
+ onMouseDown={(event) => {
70
+ if (event.button !== 0 || !hasDescription(task().description)) return
71
+ event.stopPropagation()
72
+ toggleDescription(task().id)
73
+ }}
74
+ >
75
+ {task().title}
76
+ </text>
77
+ <Show when={expandedTaskID() === task().id && hasDescription(task().description) ? task().description : null}>
78
+ {(description) => <text fg={ctx.theme.text.muted}>{description()}</text>}
79
+ </Show>
80
+ </>
81
+ )}
82
+ </Show>
83
+ <Show when={snapshot().current === null}>
84
+ <For each={snapshot().tasks}>
85
+ {(task) => <text fg={ctx.theme.text.muted}>{task.id} - {task.title}</text>}
86
+ </For>
87
+ </Show>
88
+ </box>
89
+ </Show>
90
+ </box>
91
+ </Show>
92
+ ),
93
+ })
94
+
95
+ let timer: ReturnType<typeof setTimeout> | undefined
96
+ async function poll() {
97
+ await refresh()
98
+ if (!disposed) timer = setTimeout(() => void poll(), 5000)
99
+ }
100
+ void poll()
101
+ return () => {
102
+ disposed = true
103
+ sequence++
104
+ if (timer) clearTimeout(timer)
105
+ remove()
106
+ }
107
+ },
108
+ })