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 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 active tasks in the terminal UI sidebar.
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
- - 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.
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 with one active task:
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
- opencode-mjf ▾
28
- Current task title
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
- Example with multiple active tasks:
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: 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
- ```
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
- 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.
47
+ [◐] T60 — Fix retry loop
48
+ ```
46
49
 
47
50
  ## How it works
48
51
 
49
- The server part of the plugin runs three commands in the current project directory:
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 epic status --eligible-only --json
59
+ br list --status closed --json
55
60
  ```
56
61
 
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.
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 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.
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
- When there is exactly one active task, left-clicking its ID or title shows or hides its description:
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
- - `▾`: description collapsed;
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 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.
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` 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.
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
- 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.
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 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.
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, updates, and description expansion.
135
- - `display.ts`: bars, numbers, and percentages.
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
- - ID и название задачи со статусом `in_progress`.
11
- - Строка `2 epics ready to close` (для русской локали `2 эпика готовы к закрытию`), если есть эпики, у которых все дочерние задачи закрыты. Строка появляется только при положительном количестве таких эпиков и располагается после статистики и перед активными задачами.
12
- - Описание по запросу, если активная задача только одна.
13
- - Список `ID - название`, если активных задач несколько.
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
- opencode-mjf ▾
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
- Всего задач: 4
34
- Открыто ■■■□□□□□□□ 1 (25%)
35
- В работе ■■■■■□□□□□ 2 (50%)
36
- Блокировано ■■■□□□□□□□ 1 (25%)
37
- Закрыто ■■■□□□□□□□ 1 (25%)
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 epic status --eligible-only --json
57
+ br list --status closed --json
53
58
  ```
54
59
 
55
- Из `stats` берутся общее количество и статистика статусов. Из `list` берутся задачи `in_progress`, отсортированные по `updated_at` от более новых к более старым. Из `epic status --eligible-only` берётся количество эпиков, у которых все дочерние задачи закрыты. Все три команды выполняются параллельно при загрузке плагина, затем через 5 секунд после завершения каждого запроса.
60
+ Из `stats` берутся общее количество и статистика статусов. Количество эпиков, готовых к закрытию, вычисляется по флагам `eligible_for_close` из `epic status --json`, поэтому список эпиков и счётчик используют один источник. Все пять команд выполняются параллельно при загрузке плагина, затем через 5 секунд после завершения каждого запроса.
56
61
 
57
- TUI-часть передаёт в RPC location текущего проекта, поэтому статистика не смешивается с другими открытыми проектами. До первого успешного ответа блок скрыт. Во время последующих обновлений отображаются последние полученные данные.
62
+ Затем плагин выбирает одну текущую задачу: самую свежую задачу из `br list --status in_progress --json` или, если этот список пуст, самую свежую задачу из `br list --status closed --json`. Задача в работе всегда имеет приоритет. Файл `.beads/last-touched` больше не читается.
58
63
 
59
- Если активная задача одна, клик левой кнопкой мыши по ID или названию раскрывает и скрывает описание:
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`/`list` завершилось ошибкой, плагин скрывает весь блок. Если команда `br epic status --eligible-only --json` недоступна или вернула некорректный ответ, статистика и активные задачи сохраняются, а строка про эпики просто не показывается.
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
- Названия и описания задач не переводятся. Заголовок `Beads` выделен жирным основным цветом темы, остальные строки используют приглушённый цвет темы.
115
+ Заголовок `Beads` выделен жирным основным цветом темы. Все остальные строки, включая сводку эпика и строки задач, используют приглушённый цвет темы. Названия задач не переводятся.
114
116
 
115
117
  ## Ограничения
116
118
 
117
- - Описание доступно только при одной активной задаче; список из нескольких задач его не поддерживает.
118
- - Поддерживается выбор текущей задачи через её статус; привязки к конкретной сессии OpenCode или исполнителю нет.
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 CurrentTask = {
6
+ export type PlanTaskStatus = "closed" | "in_progress" | "ready" | "blocked" | "deferred"
7
+
8
+ export type PlanTask = {
7
9
  id: string
8
10
  title: string
9
- description: string | null
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
- tasks: CurrentTask[]
21
- current: CurrentTask | null
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, tasks: [], current: null }
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 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
- }
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
- const epics = JSON.parse(epicStatusJson)
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
- export function parseSnapshot(statsJson: string, listJson: string, epicStatusJson?: string): Snapshot {
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
- 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
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(directory: string, signal?: AbortSignal, run: BrRunner = runBr): Promise<Snapshot> {
258
+ export async function readSnapshot(
259
+ directory: string,
260
+ signal?: AbortSignal,
261
+ run: BrRunner = runBr,
262
+ ): Promise<Snapshot> {
107
263
  try {
108
- const [stats, list, epics] = await Promise.all([
264
+ const [stats, epics, ready, inProgress, closed] = await Promise.all([
109
265
  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),
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
- return parseSnapshot(stats, list, epics ?? undefined)
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.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.",
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
- 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: {
21
+ plan: {
28
22
  type: "object",
29
23
  properties: {
30
- id: { type: "string" },
31
- title: { type: "string" },
32
- description: { anyOf: [{ type: "string" }, { type: "null" }] },
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: ["id", "title", "description"],
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().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>}
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
- </Show>
88
- </box>
57
+ </box>
58
+ )}
89
59
  </Show>
90
60
  </box>
91
61
  </Show>