opencode-beads_rust-stats-sidebar 1.0.0 → 1.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/README.md +39 -38
- package/README.ru.md +39 -38
- package/beads.ts +200 -35
- package/display.ts +23 -0
- package/package.json +4 -3
- package/rpc.ts +23 -11
- package/tsconfig.json +13 -0
- package/tui.tsx +16 -46
package/README.md
CHANGED
|
@@ -2,19 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
[Русская версия](https://github.com/melik/opencode-beads_rust-stats-sidebar/blob/main/README.ru.md)
|
|
4
4
|
|
|
5
|
-
An OpenCode V2 plugin that displays [beads_rust](https://github.com/Dicklesworthstone/beads_rust) statistics and
|
|
5
|
+
An OpenCode V2 plugin that displays [beads_rust](https://github.com/Dicklesworthstone/beads_rust) statistics and the current task, or the epic it belongs to, in the terminal UI sidebar.
|
|
6
6
|
|
|
7
7
|
## What it displays
|
|
8
8
|
|
|
9
9
|
- The total number of tasks.
|
|
10
10
|
- Counts of open, in-progress, blocked, and closed tasks.
|
|
11
11
|
- A 10-character bar and percentage of the total for each category.
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
12
|
+
- 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.
|
|
13
|
+
- Exactly one current-task block below the statistics. The current task is the most recently updated in-progress task, or, when nothing is in progress, the most recently closed task.
|
|
14
|
+
- If the current task belongs to an open epic, the block shows that epic: a `▼ <epic-id> <percent>% (<done>/<total>)` summary line followed by its child tasks.
|
|
15
|
+
- Otherwise the block shows the current task as a single row: `[✓] Title` for a closed task or `[◐] Title` for an in-progress task.
|
|
16
|
+
- A status glyph before each task: `✓` closed, `◐` in progress, `○` ready, `●` blocked, `❄` deferred. Blocked is inferred for open epic children that are absent from `br ready --json`.
|
|
16
17
|
|
|
17
|
-
Example
|
|
18
|
+
Example:
|
|
18
19
|
|
|
19
20
|
```text
|
|
20
21
|
Beads
|
|
@@ -23,51 +24,52 @@ Open ■■■■■■□□□□ 11 (61%)
|
|
|
23
24
|
In progress ■□□□□□□□□□ 1 (6%)
|
|
24
25
|
Blocked ■■■■■■□□□□ 10 (56%)
|
|
25
26
|
Closed ■■■□□□□□□□ 6 (33%)
|
|
27
|
+
2 epics ready to close
|
|
26
28
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
▼ rtr-e6 45% (5/11)
|
|
30
|
+
[✓] T56 — End-to-end authentication flow
|
|
31
|
+
[○] T57 — Public access and revocation
|
|
32
|
+
[◐] T58 — Comment moderation
|
|
33
|
+
[●] T59 — Rate limits and concurrency
|
|
29
34
|
```
|
|
30
35
|
|
|
31
|
-
|
|
36
|
+
When the current task does not belong to an epic, the block is a single row:
|
|
32
37
|
|
|
33
38
|
```text
|
|
34
39
|
Beads
|
|
35
|
-
Total tasks:
|
|
36
|
-
Open
|
|
37
|
-
In progress
|
|
38
|
-
Blocked
|
|
39
|
-
Closed ■■■□□□□□□□
|
|
40
|
-
|
|
41
|
-
opencode-bb7 - Temporary task 4
|
|
42
|
-
opencode-mjf - Temporary task 2
|
|
43
|
-
```
|
|
40
|
+
Total tasks: 18
|
|
41
|
+
Open ■■■■■■□□□□ 11 (61%)
|
|
42
|
+
In progress ■□□□□□□□□□ 1 (6%)
|
|
43
|
+
Blocked ■■■■■■□□□□ 10 (56%)
|
|
44
|
+
Closed ■■■□□□□□□□ 6 (33%)
|
|
45
|
+
1 epic ready to close
|
|
44
46
|
|
|
45
|
-
|
|
47
|
+
[◐] T60 — Fix retry loop
|
|
48
|
+
```
|
|
46
49
|
|
|
47
50
|
## How it works
|
|
48
51
|
|
|
49
|
-
The server part of the plugin runs
|
|
52
|
+
The server part of the plugin runs five read-only commands in the current project directory:
|
|
50
53
|
|
|
51
54
|
```sh
|
|
52
55
|
br stats --json --no-activity
|
|
56
|
+
br epic status --json
|
|
57
|
+
br ready --json
|
|
53
58
|
br list --status in_progress --json
|
|
54
|
-
br
|
|
59
|
+
br list --status closed --json
|
|
55
60
|
```
|
|
56
61
|
|
|
57
|
-
The total count and status statistics come from `stats`. The
|
|
62
|
+
The total count and status statistics come from `stats`. The number of epics ready to close is derived from the `eligible_for_close` flags in `epic status --json`, so the epic list and the count share a single source. All five commands run in parallel when the plugin loads, and again 5 seconds after each request completes.
|
|
58
63
|
|
|
59
|
-
The
|
|
64
|
+
The plugin then picks one current task: the most recently updated task from `br list --status in_progress --json`, or, when that list is empty, the most recently updated task from `br list --status closed --json`. An in-progress task always has priority. `.beads/last-touched` is no longer read.
|
|
60
65
|
|
|
61
|
-
|
|
66
|
+
The plugin resolves the current task's parent with `br show <task-id> --json`, because `br list --json` does not include the `parent` field. If the resolved `parent` matches one of the open epics from `br epic status --json`, the plugin runs `br show <epic-id> --json` and renders that epic with its child tasks. For an epic, `done` counts closed children and `total` counts all parent-child children. Open children present in `br ready --json` are shown as ready, and open children absent from it are shown as blocked.
|
|
62
67
|
|
|
63
|
-
-
|
|
64
|
-
- `▴`: description expanded.
|
|
68
|
+
Otherwise the current task is rendered as a single row. When there is neither an in-progress nor a closed task, no task block is shown.
|
|
65
69
|
|
|
66
|
-
The
|
|
67
|
-
|
|
68
|
-
When there are multiple active tasks, they are displayed as an `ID - title` list without arrows, descriptions, or click handlers.
|
|
70
|
+
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.
|
|
69
71
|
|
|
70
|
-
If Beads is not initialized, `br` is unavailable, or reading `stats`
|
|
72
|
+
If Beads is not initialized, `br` is unavailable, or reading `stats` fails, the plugin hides the entire block. If any command or response used for the current task or its epic fails or returns malformed data, the statistics and the epics line remain visible, and only the task block is omitted.
|
|
71
73
|
|
|
72
74
|
## Installation and updates
|
|
73
75
|
|
|
@@ -112,27 +114,26 @@ OpenCode reloads configuration and plugin file changes. A full restart is usuall
|
|
|
112
114
|
|
|
113
115
|
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
116
|
|
|
115
|
-
|
|
117
|
+
The `Beads` heading is bold and uses the theme's primary text color. All other lines, including the epic summary and task rows, use the muted text color. Task titles are not translated.
|
|
116
118
|
|
|
117
119
|
## Limitations
|
|
118
120
|
|
|
119
|
-
- The
|
|
120
|
-
- The current task
|
|
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.
|
|
121
|
+
- The plugin is read-only: creating, closing, and modifying tasks through the sidebar is not supported, and there are no click handlers, dialogs, or slash commands.
|
|
122
|
+
- The current task follows the most recently updated in-progress task, or the most recently closed task; it is not tied to a specific OpenCode session or assignee.
|
|
123
123
|
- Updates use polling 5 seconds after the previous request completes, rather than a subscription to Beads events.
|
|
124
124
|
- Git activity, average completion time, and additional `br stats` breakdowns are not displayed.
|
|
125
125
|
- Bars and percentages are rounded to whole numbers. With a small number of tasks, one task can fill multiple characters.
|
|
126
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
|
+
- Blocked children in the epic block are inferred: Beads stores them as open, so any open child that is not in `br ready --json` is shown as blocked.
|
|
127
128
|
- When the total task count is zero, the bars are empty and percentages are zero.
|
|
128
129
|
|
|
129
130
|
## Structure
|
|
130
131
|
|
|
131
132
|
- `index.ts`: server RPC registration.
|
|
132
|
-
- `beads.ts`: `br` execution, data validation, and transformation.
|
|
133
|
+
- `beads.ts`: `br` execution, current-task selection, data validation, and transformation.
|
|
133
134
|
- `rpc.ts`: the contract between the server and TUI.
|
|
134
|
-
- `tui.tsx`: the sidebar block
|
|
135
|
-
- `display.ts`: bars, numbers, and
|
|
135
|
+
- `tui.tsx`: the sidebar block and updates.
|
|
136
|
+
- `display.ts`: bars, numbers, percentages, and task glyphs.
|
|
136
137
|
- `i18n.ts`: system locale and labels.
|
|
137
138
|
|
|
138
139
|
## Verification
|
package/README.ru.md
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
# opencode-beads_rust-stats-sidebar
|
|
2
2
|
|
|
3
|
-
Плагин для OpenCode V2, который показывает статистику [beads_rust](https://github.com/Dicklesworthstone/beads_rust) и
|
|
3
|
+
Плагин для OpenCode V2, который показывает статистику [beads_rust](https://github.com/Dicklesworthstone/beads_rust) и текущую задачу, либо эпик, к которому она относится, в боковой панели терминального интерфейса.
|
|
4
4
|
|
|
5
5
|
## Что отображается
|
|
6
6
|
|
|
7
7
|
- Общее количество задач.
|
|
8
8
|
- Количество открытых задач, задач в работе, заблокированных и закрытых задач.
|
|
9
9
|
- Полоска из 10 символов и процент от общего количества для каждой категории.
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
10
|
+
- Строка `2 epics ready to close` (для русской локали `2 эпика готовы к закрытию`), если есть эпики, у которых все дочерние задачи закрыты. Строка появляется только при положительном количестве таких эпиков и располагается после статистики.
|
|
11
|
+
- Ровно один блок текущей задачи ниже статистики. Текущая задача — самая свежая задача в работе, а если задач в работе нет — самая свежая закрытая задача.
|
|
12
|
+
- Если текущая задача относится к открытому эпику, блок показывает этот эпик: строку сводки `▼ <id-эпика> <процент>% (<готово>/<всего>)` и его дочерние задачи.
|
|
13
|
+
- Иначе текущая задача показывается одной строкой: `[✓] Название` для закрытой задачи или `[◐] Название` для задачи в работе.
|
|
14
|
+
- Глиф статуса перед каждой задачей: `✓` закрыта, `◐` в работе, `○` готова, `●` заблокирована, `❄` отложена. Блокировка выводится косвенно: открытая дочерняя задача эпика, отсутствующая в `br ready --json`, считается заблокированной.
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
Пример:
|
|
16
17
|
|
|
17
18
|
```text
|
|
18
19
|
Beads
|
|
@@ -21,51 +22,52 @@ Beads
|
|
|
21
22
|
В работе ■□□□□□□□□□ 1 (6%)
|
|
22
23
|
Блокировано ■■■■■□□□□□ 10 (56%)
|
|
23
24
|
Закрыто ■■■□□□□□□□ 6 (33%)
|
|
25
|
+
2 эпика готовы к закрытию
|
|
24
26
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
+
▼ rtr-e6 45% (5/11)
|
|
28
|
+
[✓] T56 — Сквозной сценарий аутентификации
|
|
29
|
+
[○] T57 — Публичный доступ и отзыв
|
|
30
|
+
[◐] T58 — Модерация комментариев
|
|
31
|
+
[●] T59 — Лимиты и конкурентные операции
|
|
27
32
|
```
|
|
28
33
|
|
|
29
|
-
|
|
34
|
+
Если текущая задача не относится к эпику, блок состоит из одной строки:
|
|
30
35
|
|
|
31
36
|
```text
|
|
32
37
|
Beads
|
|
33
|
-
Всего задач:
|
|
34
|
-
Открыто
|
|
35
|
-
В работе
|
|
36
|
-
Блокировано
|
|
37
|
-
Закрыто ■■■□□□□□□□
|
|
38
|
-
|
|
39
|
-
opencode-bb7 - Временная задача 4
|
|
40
|
-
opencode-mjf - Временная задача 2
|
|
41
|
-
```
|
|
38
|
+
Всего задач: 18
|
|
39
|
+
Открыто ■■■■■■□□□□ 11 (61%)
|
|
40
|
+
В работе ■□□□□□□□□□ 1 (6%)
|
|
41
|
+
Блокировано ■■■■■□□□□□ 10 (56%)
|
|
42
|
+
Закрыто ■■■□□□□□□□ 6 (33%)
|
|
43
|
+
1 эпик готов к закрытию
|
|
42
44
|
|
|
43
|
-
|
|
45
|
+
[◐] T60 — Исправить цикл повторов
|
|
46
|
+
```
|
|
44
47
|
|
|
45
48
|
## Как это работает
|
|
46
49
|
|
|
47
|
-
Серверная часть плагина выполняет
|
|
50
|
+
Серверная часть плагина выполняет пять команд только для чтения в каталоге текущего проекта:
|
|
48
51
|
|
|
49
52
|
```sh
|
|
50
53
|
br stats --json --no-activity
|
|
54
|
+
br epic status --json
|
|
55
|
+
br ready --json
|
|
51
56
|
br list --status in_progress --json
|
|
52
|
-
br
|
|
57
|
+
br list --status closed --json
|
|
53
58
|
```
|
|
54
59
|
|
|
55
|
-
Из `stats` берутся общее количество и статистика статусов.
|
|
60
|
+
Из `stats` берутся общее количество и статистика статусов. Количество эпиков, готовых к закрытию, вычисляется по флагам `eligible_for_close` из `epic status --json`, поэтому список эпиков и счётчик используют один источник. Все пять команд выполняются параллельно при загрузке плагина, затем через 5 секунд после завершения каждого запроса.
|
|
56
61
|
|
|
57
|
-
|
|
62
|
+
Затем плагин выбирает одну текущую задачу: самую свежую задачу из `br list --status in_progress --json` или, если этот список пуст, самую свежую задачу из `br list --status closed --json`. Задача в работе всегда имеет приоритет. Файл `.beads/last-touched` больше не читается.
|
|
58
63
|
|
|
59
|
-
Если
|
|
64
|
+
Родителя текущей задачи плагин определяет запросом `br show <id-задачи> --json`, поскольку `br list --json` не возвращает поле `parent`. Если полученный `parent` совпадает с одним из открытых эпиков из `br epic status --json`, плагин выполняет `br show <id-эпика> --json` и показывает этот эпик с дочерними задачами. Для эпика `done` считает закрытых детей, а `total` — всех детей с типом связи parent-child. Открытые дети, присутствующие в `br ready --json`, показываются как готовые, а отсутствующие в нём — как заблокированные.
|
|
60
65
|
|
|
61
|
-
|
|
62
|
-
- `▴`: описание открыто.
|
|
66
|
+
Иначе текущая задача показывается одной строкой. Если нет ни задачи в работе, ни закрытой задачи, блок задач не отображается.
|
|
63
67
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
Если активных задач несколько, они выводятся списком `ID - название`. В этом случае стрелки, описания и обработчики клика отсутствуют.
|
|
68
|
+
TUI-часть передаёт в RPC location текущего проекта, поэтому статистика не смешивается с другими открытыми проектами. До первого успешного ответа блок скрыт. Во время последующих обновлений отображаются последние полученные данные.
|
|
67
69
|
|
|
68
|
-
Если Beads не инициализирован, `br` недоступен или чтение данных `stats
|
|
70
|
+
Если Beads не инициализирован, `br` недоступен или чтение данных `stats` завершилось ошибкой, плагин скрывает весь блок. Если любая команда или ответ для текущей задачи или её эпика завершилась ошибкой или вернула некорректные данные, статистика и строка про эпики сохраняются, а блок задач просто не показывается.
|
|
69
71
|
|
|
70
72
|
## Установка и обновление
|
|
71
73
|
|
|
@@ -110,27 +112,26 @@ opencode plugin update opencode-beads_rust-stats-sidebar
|
|
|
110
112
|
|
|
111
113
|
Русская системная локаль включает русские подписи. Для всех остальных локалей используется английский. Выбор учитывает `LC_ALL`, `LC_MESSAGES`, `LANG` и системную локаль `Intl`.
|
|
112
114
|
|
|
113
|
-
|
|
115
|
+
Заголовок `Beads` выделен жирным основным цветом темы. Все остальные строки, включая сводку эпика и строки задач, используют приглушённый цвет темы. Названия задач не переводятся.
|
|
114
116
|
|
|
115
117
|
## Ограничения
|
|
116
118
|
|
|
117
|
-
-
|
|
118
|
-
-
|
|
119
|
-
- Плагин только читает данные: создание, закрытие и изменение задач через sidebar не поддерживаются.
|
|
120
|
-
- Для описания одиночной задачи используется мышь; отдельной клавиатурной команды для его раскрытия нет. Поддержка мыши зависит от терминала.
|
|
119
|
+
- Плагин только читает данные: создание, закрытие и изменение задач через sidebar не поддерживаются, обработчиков клика, диалогов и slash-команд нет.
|
|
120
|
+
- Текущая задача следует за самой свежей задачей в работе или самой свежей закрытой задачей; привязки к конкретной сессии OpenCode или исполнителю нет.
|
|
121
121
|
- Обновление выполняется опросом через 5 секунд после завершения предыдущего запроса, а не подпиской на события Beads.
|
|
122
122
|
- Активность из Git, среднее время выполнения и дополнительные разбивки `br stats` не отображаются.
|
|
123
123
|
- Полоски и проценты округляются до целых. При небольшом количестве задач одна задача может заполнить несколько символов.
|
|
124
124
|
- Заблокированные задачи могут учитываться и в других статусах. Поэтому строки и проценты не являются взаимоисключающими и могут суммарно превышать 100%.
|
|
125
|
+
- Блокировка дочерних задач в блоке эпика выводится косвенно: Beads хранит такие задачи как открытые, поэтому открытый ребёнок, отсутствующий в `br ready --json`, показывается как заблокированный.
|
|
125
126
|
- При нулевом общем количестве задач полоски пустые, проценты равны нулю.
|
|
126
127
|
|
|
127
128
|
## Структура
|
|
128
129
|
|
|
129
130
|
- `index.ts`: регистрация серверного RPC.
|
|
130
|
-
- `beads.ts`: запуск `br`, проверка и преобразование данных.
|
|
131
|
+
- `beads.ts`: запуск `br`, выбор текущей задачи, проверка и преобразование данных.
|
|
131
132
|
- `rpc.ts`: схема обмена между сервером и TUI.
|
|
132
|
-
- `tui.tsx`: блок sidebar
|
|
133
|
-
- `display.ts`: полоски,
|
|
133
|
+
- `tui.tsx`: блок sidebar и обновления.
|
|
134
|
+
- `display.ts`: полоски, числа, проценты и глифы задач.
|
|
134
135
|
- `i18n.ts`: системная локаль и подписи.
|
|
135
136
|
|
|
136
137
|
## Проверка
|
package/beads.ts
CHANGED
|
@@ -3,10 +3,22 @@ import { promisify } from "node:util"
|
|
|
3
3
|
|
|
4
4
|
const execFileAsync = promisify(execFile)
|
|
5
5
|
|
|
6
|
-
export type
|
|
6
|
+
export type PlanTaskStatus = "closed" | "in_progress" | "ready" | "blocked" | "deferred"
|
|
7
|
+
|
|
8
|
+
export type PlanTask = {
|
|
7
9
|
id: string
|
|
8
10
|
title: string
|
|
9
|
-
|
|
11
|
+
status: PlanTaskStatus
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type SnapshotPlan = {
|
|
15
|
+
kind: "epic" | "task"
|
|
16
|
+
epicId?: string
|
|
17
|
+
epicTitle?: string
|
|
18
|
+
done?: number
|
|
19
|
+
total?: number
|
|
20
|
+
percent?: number
|
|
21
|
+
tasks: PlanTask[]
|
|
10
22
|
}
|
|
11
23
|
|
|
12
24
|
export type Snapshot = {
|
|
@@ -17,14 +29,26 @@ export type Snapshot = {
|
|
|
17
29
|
blocked: number
|
|
18
30
|
closed: number
|
|
19
31
|
epicsReady: number
|
|
20
|
-
|
|
21
|
-
|
|
32
|
+
plan?: SnapshotPlan
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type EpicStatus = {
|
|
36
|
+
id: string
|
|
37
|
+
title: string
|
|
38
|
+
status: string
|
|
39
|
+
eligibleForClose: boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type InProgressTask = {
|
|
43
|
+
id: string
|
|
44
|
+
title: string
|
|
45
|
+
updatedAt: number
|
|
22
46
|
}
|
|
23
47
|
|
|
24
48
|
export type BrRunner = (directory: string, args: readonly string[], signal?: AbortSignal) => Promise<string>
|
|
25
49
|
|
|
26
50
|
export function hiddenSnapshot(): Snapshot {
|
|
27
|
-
return { available: false, total: 0, open: 0, inProgress: 0, blocked: 0, closed: 0, epicsReady: 0
|
|
51
|
+
return { available: false, total: 0, open: 0, inProgress: 0, blocked: 0, closed: 0, epicsReady: 0 }
|
|
28
52
|
}
|
|
29
53
|
|
|
30
54
|
const runBr: BrRunner = async (directory, args, signal) => {
|
|
@@ -53,42 +77,172 @@ function text(value: unknown): string {
|
|
|
53
77
|
return value
|
|
54
78
|
}
|
|
55
79
|
|
|
56
|
-
function
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
if (!
|
|
64
|
-
return
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
80
|
+
function optionalText(value: unknown): string | undefined {
|
|
81
|
+
if (value === undefined || value === null || value === "") return undefined
|
|
82
|
+
return text(value)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function parseParent(issueJson: string): string | undefined {
|
|
86
|
+
const shown = JSON.parse(issueJson)
|
|
87
|
+
if (!Array.isArray(shown) || shown.length === 0) throw new Error("Invalid br show")
|
|
88
|
+
return optionalText(record(shown[0]).parent)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function parseEpicStatus(epicStatusJson: string): EpicStatus[] {
|
|
92
|
+
const epics = JSON.parse(epicStatusJson)
|
|
93
|
+
if (!Array.isArray(epics)) throw new Error("Invalid br epic status")
|
|
94
|
+
return epics.map((value) => {
|
|
95
|
+
const entry = record(value)
|
|
96
|
+
const epic = record(entry.epic)
|
|
97
|
+
return {
|
|
98
|
+
id: text(epic.id),
|
|
99
|
+
title: text(epic.title),
|
|
100
|
+
status: text(epic.status),
|
|
101
|
+
eligibleForClose: entry.eligible_for_close === true,
|
|
102
|
+
}
|
|
103
|
+
})
|
|
70
104
|
}
|
|
71
105
|
|
|
72
106
|
function parseEpicCount(epicStatusJson: string | undefined): number {
|
|
73
107
|
if (epicStatusJson === undefined) return 0
|
|
74
108
|
try {
|
|
75
|
-
|
|
76
|
-
if (!Array.isArray(epics)) throw new Error("Invalid br epic status")
|
|
77
|
-
return epics.length
|
|
109
|
+
return parseEpicStatus(epicStatusJson).filter((epic) => epic.eligibleForClose).length
|
|
78
110
|
} catch {
|
|
79
111
|
return 0
|
|
80
112
|
}
|
|
81
113
|
}
|
|
82
114
|
|
|
83
|
-
|
|
84
|
-
const summary = record(record(JSON.parse(statsJson)).summary)
|
|
115
|
+
function parseIssueList(listJson: string): InProgressTask[] {
|
|
85
116
|
const issues = record(JSON.parse(listJson)).issues
|
|
86
117
|
if (!Array.isArray(issues)) throw new Error("Invalid br issue list")
|
|
118
|
+
return issues.map((value) => {
|
|
119
|
+
const issue = record(value)
|
|
120
|
+
const updatedAt = typeof issue.updated_at === "string" ? Date.parse(issue.updated_at) : Number.NaN
|
|
121
|
+
return {
|
|
122
|
+
id: text(issue.id),
|
|
123
|
+
title: text(issue.title),
|
|
124
|
+
updatedAt: Number.isFinite(updatedAt) ? updatedAt : 0,
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function parseReadyTasks(readyJson: string): Array<{ id: string; title: string }> {
|
|
130
|
+
const issues = JSON.parse(readyJson)
|
|
131
|
+
if (!Array.isArray(issues)) throw new Error("Invalid br ready list")
|
|
132
|
+
return issues.map((value) => {
|
|
133
|
+
const issue = record(value)
|
|
134
|
+
return { id: text(issue.id), title: text(issue.title) }
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function byUpdatedAtDescending(left: InProgressTask, right: InProgressTask): number {
|
|
139
|
+
return right.updatedAt - left.updatedAt
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function parseRecentClosed(closedJson: string | null): InProgressTask | undefined {
|
|
143
|
+
if (closedJson === null) return undefined
|
|
144
|
+
try {
|
|
145
|
+
return parseIssueList(closedJson).sort(byUpdatedAtDescending)[0]
|
|
146
|
+
} catch {
|
|
147
|
+
return undefined
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function classifyTaskStatus(status: string, id: string, readyIds: ReadonlySet<string>): PlanTaskStatus {
|
|
152
|
+
switch (status) {
|
|
153
|
+
case "closed":
|
|
154
|
+
return "closed"
|
|
155
|
+
case "in_progress":
|
|
156
|
+
return "in_progress"
|
|
157
|
+
case "deferred":
|
|
158
|
+
return "deferred"
|
|
159
|
+
case "blocked":
|
|
160
|
+
return "blocked"
|
|
161
|
+
case "open":
|
|
162
|
+
return readyIds.has(id) ? "ready" : "blocked"
|
|
163
|
+
default:
|
|
164
|
+
return "blocked"
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function parseEpicTasks(epicJson: string, readyIds: ReadonlySet<string>): PlanTask[] {
|
|
169
|
+
const shown = JSON.parse(epicJson)
|
|
170
|
+
if (!Array.isArray(shown) || shown.length === 0) throw new Error("Invalid br epic show")
|
|
171
|
+
const dependents = record(shown[0]).dependents
|
|
172
|
+
if (!Array.isArray(dependents)) throw new Error("Invalid br epic dependents")
|
|
173
|
+
|
|
174
|
+
return dependents.flatMap((value) => {
|
|
175
|
+
const dependent = record(value)
|
|
176
|
+
if (dependent.dependency_type !== "parent-child") return []
|
|
177
|
+
const id = text(dependent.id)
|
|
178
|
+
return [{
|
|
179
|
+
id,
|
|
180
|
+
title: text(dependent.title),
|
|
181
|
+
status: classifyTaskStatus(text(dependent.status), id, readyIds),
|
|
182
|
+
}]
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function required(value: string | null): string {
|
|
187
|
+
if (value === null) throw new Error("Missing br data")
|
|
188
|
+
return value
|
|
189
|
+
}
|
|
87
190
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
191
|
+
async function readPlan(
|
|
192
|
+
directory: string,
|
|
193
|
+
signal: AbortSignal | undefined,
|
|
194
|
+
run: BrRunner,
|
|
195
|
+
data: {
|
|
196
|
+
epicStatusJson: string | null
|
|
197
|
+
readyJson: string | null
|
|
198
|
+
inProgressJson: string | null
|
|
199
|
+
closedJson: string | null
|
|
200
|
+
},
|
|
201
|
+
): Promise<SnapshotPlan | undefined> {
|
|
202
|
+
const epics = parseEpicStatus(required(data.epicStatusJson))
|
|
203
|
+
const ready = parseReadyTasks(required(data.readyJson))
|
|
204
|
+
const readyIds = new Set(ready.map((task) => task.id))
|
|
205
|
+
const inProgress = parseIssueList(required(data.inProgressJson))
|
|
206
|
+
const recentClosed = parseRecentClosed(data.closedJson)
|
|
207
|
+
const recentInProgress = [...inProgress].sort(byUpdatedAtDescending)[0]
|
|
208
|
+
const currentTask = recentInProgress ?? recentClosed
|
|
209
|
+
if (!currentTask) return undefined
|
|
210
|
+
|
|
211
|
+
let parentId: string | undefined
|
|
212
|
+
try {
|
|
213
|
+
parentId = parseParent(await run(directory, ["show", currentTask.id, "--json"], signal))
|
|
214
|
+
} catch {
|
|
215
|
+
parentId = undefined
|
|
216
|
+
}
|
|
217
|
+
const focus = parentId ? epics.find((epic) => epic.id === parentId) : undefined
|
|
218
|
+
if (!focus) {
|
|
219
|
+
return {
|
|
220
|
+
kind: "task",
|
|
221
|
+
tasks: [{
|
|
222
|
+
id: currentTask.id,
|
|
223
|
+
title: currentTask.title,
|
|
224
|
+
status: currentTask === recentClosed ? "closed" : "in_progress",
|
|
225
|
+
}],
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const tasks = parseEpicTasks(await run(directory, ["show", focus.id, "--json"], signal), readyIds)
|
|
230
|
+
const done = tasks.filter((task) => task.status === "closed").length
|
|
231
|
+
const total = tasks.length
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
kind: "epic",
|
|
235
|
+
epicId: focus.id,
|
|
236
|
+
epicTitle: focus.title,
|
|
237
|
+
done,
|
|
238
|
+
total,
|
|
239
|
+
percent: total === 0 ? 0 : Math.round(done / total * 100),
|
|
240
|
+
tasks,
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function parseSnapshot(statsJson: string, epicStatusJson?: string): Snapshot {
|
|
245
|
+
const summary = record(record(JSON.parse(statsJson)).summary)
|
|
92
246
|
|
|
93
247
|
return {
|
|
94
248
|
available: true,
|
|
@@ -98,19 +252,30 @@ export function parseSnapshot(statsJson: string, listJson: string, epicStatusJso
|
|
|
98
252
|
blocked: count(summary.blocked_issues),
|
|
99
253
|
closed: count(summary.closed_issues),
|
|
100
254
|
epicsReady: parseEpicCount(epicStatusJson),
|
|
101
|
-
tasks,
|
|
102
|
-
current,
|
|
103
255
|
}
|
|
104
256
|
}
|
|
105
257
|
|
|
106
|
-
export async function readSnapshot(
|
|
258
|
+
export async function readSnapshot(
|
|
259
|
+
directory: string,
|
|
260
|
+
signal?: AbortSignal,
|
|
261
|
+
run: BrRunner = runBr,
|
|
262
|
+
): Promise<Snapshot> {
|
|
107
263
|
try {
|
|
108
|
-
const [stats,
|
|
264
|
+
const [stats, epics, ready, inProgress, closed] = await Promise.all([
|
|
109
265
|
run(directory, ["stats", "--json", "--no-activity"], signal),
|
|
110
|
-
run(directory, ["
|
|
111
|
-
run(directory, ["
|
|
266
|
+
run(directory, ["epic", "status", "--json"], signal).catch(() => null),
|
|
267
|
+
run(directory, ["ready", "--json"], signal).catch(() => null),
|
|
268
|
+
run(directory, ["list", "--status", "in_progress", "--json"], signal).catch(() => null),
|
|
269
|
+
run(directory, ["list", "--status", "closed", "--json"], signal).catch(() => null),
|
|
112
270
|
])
|
|
113
|
-
|
|
271
|
+
const snapshot = parseSnapshot(stats, epics ?? undefined)
|
|
272
|
+
try {
|
|
273
|
+
const plan = await readPlan(directory, signal, run, { epicStatusJson: epics, readyJson: ready, inProgressJson: inProgress, closedJson: closed })
|
|
274
|
+
if (plan) snapshot.plan = plan
|
|
275
|
+
} catch {
|
|
276
|
+
// Plan data is optional: keep the statistics visible when it cannot be built.
|
|
277
|
+
}
|
|
278
|
+
return snapshot
|
|
114
279
|
} catch {
|
|
115
280
|
return hiddenSnapshot()
|
|
116
281
|
}
|
package/display.ts
CHANGED
|
@@ -1,3 +1,26 @@
|
|
|
1
|
+
import type { PlanTaskStatus } from "./beads.js"
|
|
2
|
+
|
|
3
|
+
const statusGlyphs: Record<PlanTaskStatus, string> = {
|
|
4
|
+
closed: "✓",
|
|
5
|
+
in_progress: "◐",
|
|
6
|
+
ready: "○",
|
|
7
|
+
blocked: "●",
|
|
8
|
+
deferred: "❄",
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function statusGlyph(status: PlanTaskStatus): string {
|
|
12
|
+
return statusGlyphs[status]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function epicSummaryLine(epicId: string, done: number, total: number): string {
|
|
16
|
+
const percent = total === 0 ? 0 : Math.round(done / total * 100)
|
|
17
|
+
return `▼ ${epicId} ${percent}% (${done}/${total})`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function taskLine(status: PlanTaskStatus, title: string): string {
|
|
21
|
+
return `[${statusGlyph(status)}] ${title}`
|
|
22
|
+
}
|
|
23
|
+
|
|
1
24
|
export function progressBar(value: number, total: number): string {
|
|
2
25
|
if (total === 0) return "□".repeat(10)
|
|
3
26
|
const filled = Math.max(0, Math.min(10, Math.round(value / total * 10)))
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-beads_rust-stats-sidebar",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "OpenCode V2 plugin that displays beads_rust (br) issue statistics, ready-to-close epics
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "OpenCode V2 plugin that displays beads_rust (br) issue statistics, a focused epic with its tasks, and ready-to-close epics in the TUI sidebar.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"opencode",
|
|
7
7
|
"opencode-plugin",
|
|
@@ -35,10 +35,11 @@
|
|
|
35
35
|
"beads.ts",
|
|
36
36
|
"display.ts",
|
|
37
37
|
"i18n.ts",
|
|
38
|
+
"tsconfig.json",
|
|
38
39
|
"README.md"
|
|
39
40
|
],
|
|
40
41
|
"scripts": {
|
|
41
|
-
"test": "node --test beads.test.ts display.test.ts i18n.test.ts",
|
|
42
|
+
"test": "node --test beads.test.ts display.test.ts i18n.test.ts package.test.ts",
|
|
42
43
|
"typecheck": "tsc --noEmit",
|
|
43
44
|
"prepublishOnly": "npm test && npm run typecheck"
|
|
44
45
|
},
|
package/rpc.ts
CHANGED
|
@@ -18,23 +18,35 @@ export const BeadsCurrent = Rpc.define({
|
|
|
18
18
|
blocked: { type: "integer", minimum: 0 },
|
|
19
19
|
closed: { type: "integer", minimum: 0 },
|
|
20
20
|
epicsReady: { type: "integer", minimum: 0 },
|
|
21
|
-
|
|
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: {
|
|
21
|
+
plan: {
|
|
28
22
|
type: "object",
|
|
29
23
|
properties: {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
24
|
+
kind: { type: "string", enum: ["epic", "task"] },
|
|
25
|
+
epicId: { type: "string" },
|
|
26
|
+
epicTitle: { type: "string" },
|
|
27
|
+
done: { type: "integer", minimum: 0 },
|
|
28
|
+
total: { type: "integer", minimum: 0 },
|
|
29
|
+
percent: { type: "integer", minimum: 0, maximum: 100 },
|
|
30
|
+
tasks: {
|
|
31
|
+
type: "array",
|
|
32
|
+
items: {
|
|
33
|
+
type: "object",
|
|
34
|
+
properties: {
|
|
35
|
+
id: { type: "string" },
|
|
36
|
+
title: { type: "string" },
|
|
37
|
+
status: { type: "string", enum: ["closed", "in_progress", "ready", "blocked", "deferred"] },
|
|
38
|
+
},
|
|
39
|
+
required: ["id", "title", "status"],
|
|
40
|
+
additionalProperties: false,
|
|
41
|
+
},
|
|
42
|
+
},
|
|
33
43
|
},
|
|
34
|
-
required: ["
|
|
44
|
+
required: ["kind", "tasks"],
|
|
35
45
|
additionalProperties: false,
|
|
36
46
|
},
|
|
37
47
|
},
|
|
48
|
+
required: ["available", "total", "open", "inProgress", "blocked", "closed", "epicsReady"],
|
|
49
|
+
additionalProperties: false,
|
|
38
50
|
},
|
|
39
51
|
errors: {},
|
|
40
52
|
},
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"jsx": "preserve",
|
|
7
|
+
"jsxImportSource": "@opentui/solid",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"noEmit": true
|
|
11
|
+
},
|
|
12
|
+
"include": ["index.ts", "rpc.ts", "tui.tsx", "beads.ts", "display.ts", "i18n.ts"]
|
|
13
|
+
}
|
package/tui.tsx
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
1
2
|
import { Plugin } from "@opencode/plugin/tui"
|
|
2
3
|
import { createSignal, For, Show } from "solid-js"
|
|
3
4
|
import { hiddenSnapshot, type Snapshot } from "./beads.js"
|
|
4
|
-
import { statisticLine } from "./display.js"
|
|
5
|
+
import { epicSummaryLine, statisticLine, taskLine } from "./display.js"
|
|
5
6
|
import { createI18n } from "./i18n.js"
|
|
6
7
|
import { BeadsCurrent } from "./rpc.js"
|
|
7
8
|
|
|
8
|
-
const hasDescription = (description: string | null | undefined) => Boolean(description?.trim())
|
|
9
|
-
|
|
10
9
|
export default Plugin.define({
|
|
11
10
|
id: "beads_rust-sidebar-tui",
|
|
12
11
|
setup(ctx) {
|
|
@@ -14,7 +13,6 @@ export default Plugin.define({
|
|
|
14
13
|
const labelWidth = Math.max(...i18n.statuses.map((label) => label.length))
|
|
15
14
|
const location = ctx.location ?? ctx.data.location.default()
|
|
16
15
|
const [snapshot, setSnapshot] = createSignal<Snapshot>(hiddenSnapshot())
|
|
17
|
-
const [expandedTaskID, setExpandedTaskID] = createSignal<string | null>(null)
|
|
18
16
|
let disposed = false
|
|
19
17
|
let sequence = 0
|
|
20
18
|
|
|
@@ -28,10 +26,6 @@ export default Plugin.define({
|
|
|
28
26
|
}
|
|
29
27
|
}
|
|
30
28
|
|
|
31
|
-
function toggleDescription(id: string) {
|
|
32
|
-
setExpandedTaskID(expandedTaskID() === id ? null : id)
|
|
33
|
-
}
|
|
34
|
-
|
|
35
29
|
const remove = ctx.ui.slot({
|
|
36
30
|
append: "sidebar.content",
|
|
37
31
|
render: () => (
|
|
@@ -47,45 +41,21 @@ export default Plugin.define({
|
|
|
47
41
|
<Show when={snapshot().epicsReady > 0}>
|
|
48
42
|
<text fg={ctx.theme.text.muted}>{i18n.epicsReady(snapshot().epicsReady)}</text>
|
|
49
43
|
</Show>
|
|
50
|
-
<Show when={snapshot().
|
|
51
|
-
|
|
52
|
-
<
|
|
53
|
-
{(
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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>}
|
|
44
|
+
<Show when={snapshot().plan}>
|
|
45
|
+
{(plan) => (
|
|
46
|
+
<box flexDirection="column" paddingTop={1}>
|
|
47
|
+
<Show when={plan().kind === "epic"}>
|
|
48
|
+
<text fg={ctx.theme.text.muted}>{epicSummaryLine(plan().epicId ?? "", plan().done ?? 0, plan().total ?? 0)}</text>
|
|
49
|
+
</Show>
|
|
50
|
+
<For each={plan().tasks}>
|
|
51
|
+
{(task) => (
|
|
52
|
+
<text fg={ctx.theme.text.muted}>
|
|
53
|
+
{taskLine(task.status, task.title)}
|
|
54
|
+
</text>
|
|
55
|
+
)}
|
|
86
56
|
</For>
|
|
87
|
-
</
|
|
88
|
-
|
|
57
|
+
</box>
|
|
58
|
+
)}
|
|
89
59
|
</Show>
|
|
90
60
|
</box>
|
|
91
61
|
</Show>
|