@rmrdeveloper/sideroom-pi 8.2.0 → 8.3.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
@@ -70,8 +70,10 @@ wall of tool calls, it:
70
70
  - **Works in the open.** A compact board sits above the editor:
71
71
  `pending → in_progress → completed`. Exactly one step is active at a
72
72
  time; the agent completes the current step and starts the next one in the
73
- same move, so skipped steps become structurally hard. The board survives
74
- reload, tree navigation, and compaction.
73
+ same move, so skipped steps become structurally hard. The widget shows at
74
+ most five rows, keeps the active step visible, and `F9` opens the full
75
+ read-only board. The board survives reload, tree navigation, and
76
+ compaction.
75
77
  - **Shows its traces.** Below the board, the last edited files appear as
76
78
  clickable `file://` links. `F8` opens the full session history; `R` clears
77
79
  it. Only successful Pi `write`/`edit` calls are tracked — no guesses from
@@ -114,6 +116,10 @@ chat. It never creates a repository task file or a `/todos` command.
114
116
  - `update` patches `{ id, status?, content? }` items and works everywhere,
115
117
  including headless modes.
116
118
  - Invariant: while anything is pending, exactly one item is `in_progress`.
119
+ - Widget: at most five rows — open items before resolved ones, the
120
+ `in_progress` row always visible — plus a `…+N more · F9: view all` hint.
121
+ `F9` opens the full board as a read-only overlay, so the widget stays small
122
+ without hiding work from you; the agent still gets every item in its prompt.
117
123
 
118
124
  ### Edited files — proof, not promises
119
125
 
@@ -213,6 +219,13 @@ Items have stable string ids, short content, and one of `pending`,
213
219
  start the next one in the same `update` call. Snapshots live in the active
214
220
  session branch and are rebuilt after navigation and compaction.
215
221
 
222
+ - Widget: max five rows. Open items win the cap over resolved ones, the
223
+ `in_progress` row is always shown, and a `…+N more · F9: view all` line
224
+ appears when rows are hidden.
225
+ - Extended view: `F9` toggles a read-only overlay. `↑`/`↓` and `PgUp`/`PgDn`
226
+ scroll; `Esc` or `F9` closes. It never mutates the board.
227
+ - The cap is display-only: every item still reaches the system prompt.
228
+
216
229
  A forked session inherits the board visible at its fork point. Later board
217
230
  updates are reconstructed from each session's active branch, so the fork and
218
231
  the original session evolve independently.
@@ -18,6 +18,7 @@ import {
18
18
  TODO_WIDGET_REFRESH_EVENT,
19
19
  type TodoStore,
20
20
  } from './session.ts';
21
+ import { createTodoBoardOverlayController } from './ui.ts';
21
22
 
22
23
  export const TODO_DESCRIPTION =
23
24
  'Maintain a live, user-visible work board. propose replaces the full board; update patches existing items by id.';
@@ -32,9 +33,12 @@ export const TODO_PROMPT_GUIDELINES = [
32
33
  'propose is interactive-TUI-only; update also works in print, JSON, and RPC modes.',
33
34
  ];
34
35
 
36
+ const BOARD_SHORTCUT = 'f9';
37
+
35
38
  // Pi loads extensions/*/index.ts through export default.
36
39
  export default function registerTodo(pi: ExtensionAPI): void {
37
40
  const store = createTodoStore();
41
+ const overlay = createTodoBoardOverlayController(() => store.items);
38
42
  const restore: BoardLifecycle = (ctx) => {
39
43
  restoreBoard(store, ctx);
40
44
  };
@@ -50,6 +54,10 @@ export default function registerTodo(pi: ExtensionAPI): void {
50
54
  registerGuardEvents(pi, store, restore);
51
55
  registerBoardContext(pi, store, restoreAndRefresh);
52
56
  registerTodoTool(pi, store, notifyWidgetRefresh);
57
+ pi.registerShortcut(BOARD_SHORTCUT, {
58
+ description: 'Toggle the work board',
59
+ handler: (ctx) => overlay.toggle(ctx),
60
+ });
53
61
  }
54
62
 
55
63
  function registerTodoTool(
@@ -1,5 +1,48 @@
1
- import type { Theme } from '@earendil-works/pi-coding-agent';
2
- import { formatBoardBlock, TODO_STATUS, type TodoItem } from './model.ts';
1
+ import type { ExtensionContext, Theme } from '@earendil-works/pi-coding-agent';
2
+ import {
3
+ type Component,
4
+ Key,
5
+ matchesKey,
6
+ type TUI,
7
+ truncateToWidth,
8
+ } from '@earendil-works/pi-tui';
9
+ import {
10
+ formatBoardBlock,
11
+ TODO_STATUS,
12
+ type TodoItem,
13
+ type TodoStatus,
14
+ } from './model.ts';
15
+
16
+ const COMPACT_ROW_LIMIT = 5;
17
+ const EXTENDED_ROW_LIMIT = 12;
18
+
19
+ const SELECTION_RANK = {
20
+ inProgress: 0,
21
+ pending: 1,
22
+ resolved: 2,
23
+ } as const;
24
+
25
+ export interface VisibleBoardRows {
26
+ readonly indexes: readonly number[];
27
+ readonly hiddenCount: number;
28
+ }
29
+
30
+ export function selectVisibleBoardRows(
31
+ items: readonly TodoItem[],
32
+ limit: number,
33
+ ): VisibleBoardRows {
34
+ if (items.length <= limit) {
35
+ return { indexes: items.map((_item, index) => index), hiddenCount: 0 };
36
+ }
37
+
38
+ const indexes = items
39
+ .map((item, index) => ({ index, rank: selectionRank(item) }))
40
+ .sort((left, right) => left.rank - right.rank || left.index - right.index)
41
+ .slice(0, limit)
42
+ .map((row) => row.index)
43
+ .sort((left, right) => left - right);
44
+ return { indexes, hiddenCount: items.length - indexes.length };
45
+ }
3
46
 
4
47
  export function renderTodoWidget(
5
48
  items: readonly TodoItem[],
@@ -9,33 +52,199 @@ export function renderTodoWidget(
9
52
  return undefined;
10
53
  }
11
54
 
55
+ const rows = boardRowLines(items);
56
+ const visible = selectVisibleBoardRows(items, COMPACT_ROW_LIMIT);
57
+ const renderedRows = visible.indexes.flatMap((index) => {
58
+ const item = items[index];
59
+ const row = rows[index];
60
+ if (item === undefined || row === undefined) {
61
+ return [];
62
+ }
63
+ return [colorizeBoardRow(row, item.status, theme)];
64
+ });
65
+ const lines = [
66
+ theme.fg('accent', theme.bold(boardHeader(items))),
67
+ ...renderedRows,
68
+ ];
69
+ if (visible.hiddenCount > 0) {
70
+ lines.push(theme.fg('dim', hiddenRowsHint(visible.hiddenCount)));
71
+ }
72
+ return lines;
73
+ }
74
+
75
+ export interface TodoBoardOverlayController {
76
+ toggle(ctx: ExtensionContext): void;
77
+ }
78
+
79
+ export function createTodoBoardOverlayController(
80
+ items: () => readonly TodoItem[],
81
+ ): TodoBoardOverlayController {
82
+ let close: (() => void) | undefined;
83
+ let opening = false;
84
+
85
+ return {
86
+ toggle(ctx) {
87
+ if (close !== undefined) {
88
+ close();
89
+ return;
90
+ }
91
+ if (opening || ctx.mode !== 'tui') {
92
+ return;
93
+ }
94
+
95
+ opening = true;
96
+ void ctx.ui
97
+ .custom<void>(
98
+ (tui, theme, _keybindings, done) => {
99
+ close = done;
100
+ return new TodoBoardOverlay(tui, items, theme, done);
101
+ },
102
+ {
103
+ overlay: true,
104
+ overlayOptions: {
105
+ anchor: 'center',
106
+ width: '70%',
107
+ minWidth: 40,
108
+ maxHeight: '80%',
109
+ visible: (terminalWidth) => terminalWidth >= 60,
110
+ },
111
+ },
112
+ )
113
+ .finally(() => {
114
+ close = undefined;
115
+ opening = false;
116
+ });
117
+ },
118
+ };
119
+ }
120
+
121
+ export class TodoBoardOverlay implements Component {
122
+ private offset = 0;
123
+ private readonly tui: TUI;
124
+ private readonly items: () => readonly TodoItem[];
125
+ private readonly theme: Theme;
126
+ private readonly close: () => void;
127
+
128
+ constructor(
129
+ tui: TUI,
130
+ items: () => readonly TodoItem[],
131
+ theme: Theme,
132
+ close: () => void,
133
+ ) {
134
+ this.tui = tui;
135
+ this.items = items;
136
+ this.theme = theme;
137
+ this.close = close;
138
+ }
139
+
140
+ handleInput(data: string): void {
141
+ const maxOffset = Math.max(0, this.items().length - EXTENDED_ROW_LIMIT);
142
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.f9)) {
143
+ this.close();
144
+ return;
145
+ }
146
+ if (matchesKey(data, Key.up)) {
147
+ this.offset = Math.max(0, this.offset - 1);
148
+ this.tui.requestRender();
149
+ return;
150
+ }
151
+ if (matchesKey(data, Key.down)) {
152
+ this.offset = Math.min(maxOffset, this.offset + 1);
153
+ this.tui.requestRender();
154
+ return;
155
+ }
156
+ if (matchesKey(data, Key.pageUp)) {
157
+ this.offset = Math.max(0, this.offset - EXTENDED_ROW_LIMIT);
158
+ this.tui.requestRender();
159
+ return;
160
+ }
161
+ if (matchesKey(data, Key.pageDown)) {
162
+ this.offset = Math.min(maxOffset, this.offset + EXTENDED_ROW_LIMIT);
163
+ this.tui.requestRender();
164
+ }
165
+ }
166
+
167
+ render(width: number): string[] {
168
+ const innerWidth = Math.max(1, width - 2);
169
+ const items = this.items();
170
+ const rows = boardRowLines(items);
171
+ const visibleRows = items
172
+ .map((item, index) => ({ item, row: rows[index] }))
173
+ .slice(this.offset, this.offset + EXTENDED_ROW_LIMIT);
174
+ const border = (text: string) => this.theme.fg('border', text);
175
+ const line = (text: string) => truncateToWidth(text, innerWidth, '…', true);
176
+ const title = line(` Work board (${String(items.length)}) `);
177
+ const lines = [
178
+ border(`╭${'─'.repeat(innerWidth)}╮`),
179
+ `${border('│')}${line(this.theme.fg('accent', this.theme.bold(title)))}${border('│')}`,
180
+ `${border('├')}${border('─'.repeat(innerWidth))}${border('┤')}`,
181
+ ];
182
+
183
+ for (const entry of visibleRows) {
184
+ const row =
185
+ entry.row === undefined
186
+ ? ''
187
+ : colorizeBoardRow(entry.row, entry.item.status, this.theme);
188
+ lines.push(`${border('│')}${line(row)}${border('│')}`);
189
+ }
190
+ if (items.length === 0) {
191
+ lines.push(
192
+ `${border('│')}${line(this.theme.fg('muted', ' No items on the board.'))}${border('│')}`,
193
+ );
194
+ }
195
+
196
+ const scrollHint = items.length > EXTENDED_ROW_LIMIT ? ' ↑↓ scroll ·' : '';
197
+ lines.push(`${border('├')}${border('─'.repeat(innerWidth))}${border('┤')}`);
198
+ lines.push(
199
+ `${border('│')}${line(this.theme.fg('dim', `${scrollHint} F9 or Esc close`))}${border('│')}`,
200
+ );
201
+ lines.push(border(`╰${'─'.repeat(innerWidth)}╯`));
202
+ return lines;
203
+ }
204
+
205
+ invalidate(): void {}
206
+ }
207
+
208
+ function selectionRank(item: TodoItem): number {
209
+ if (item.status === TODO_STATUS.inProgress) {
210
+ return SELECTION_RANK.inProgress;
211
+ }
212
+ if (item.status === TODO_STATUS.pending) {
213
+ return SELECTION_RANK.pending;
214
+ }
215
+ return SELECTION_RANK.resolved;
216
+ }
217
+
218
+ function boardHeader(items: readonly TodoItem[]): string {
12
219
  const active = items.filter(
13
220
  (item) => item.status === TODO_STATUS.inProgress,
14
221
  ).length;
15
222
  const queued = items.filter(
16
223
  (item) => item.status === TODO_STATUS.pending,
17
224
  ).length;
18
- const blockLines = formatBoardBlock(items).split('\n');
19
- const rows = blockLines.slice(1, 1 + items.length);
20
- const header = theme.fg(
21
- 'accent',
22
- theme.bold(
23
- `Sideroom board (${String(active)} active, ${String(queued)} queued)`,
24
- ),
25
- );
26
- const renderedRows = rows.map((row, index) => {
27
- const item = items[index];
28
- if (item === undefined) {
29
- return row;
30
- }
31
- if (item.status === TODO_STATUS.inProgress) {
32
- return theme.fg('success', row);
33
- }
34
- if (item.status === TODO_STATUS.pending) {
35
- return theme.fg('text', row);
36
- }
37
- return theme.fg('muted', row);
38
- });
225
+ return `Sideroom board (${String(active)} active, ${String(queued)} queued)`;
226
+ }
227
+
228
+ function boardRowLines(items: readonly TodoItem[]): string[] {
229
+ return formatBoardBlock(items)
230
+ .split('\n')
231
+ .slice(1, 1 + items.length);
232
+ }
233
+
234
+ function colorizeBoardRow(
235
+ row: string,
236
+ status: TodoStatus,
237
+ theme: Theme,
238
+ ): string {
239
+ if (status === TODO_STATUS.inProgress) {
240
+ return theme.fg('success', row);
241
+ }
242
+ if (status === TODO_STATUS.pending) {
243
+ return theme.fg('text', row);
244
+ }
245
+ return theme.fg('muted', row);
246
+ }
39
247
 
40
- return [header, ...renderedRows];
248
+ function hiddenRowsHint(hiddenCount: number): string {
249
+ return `…+${String(hiddenCount)} more · F9: view all`;
41
250
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmrdeveloper/sideroom-pi",
3
- "version": "8.2.0",
3
+ "version": "8.3.0",
4
4
  "description": "Pi coding-agent package that asks clarifying questions, shows a live todo board, enforces coding guidelines, and will not call it done while your checks are red.",
5
5
  "type": "module",
6
6
  "files": [
@@ -16,7 +16,7 @@
16
16
  "version-packages": "changeset version",
17
17
  "commitlint": "commitlint",
18
18
  "types": "tsc --noEmit",
19
- "test": "node --test 'extensions/**/*.test.ts'",
19
+ "test": "node --test 'extensions/**/*.test.ts' 'scripts/**/*.test.mjs'",
20
20
  "lint": "biome lint extensions scripts commitlint.config.mjs",
21
21
  "lint:fix": "biome lint --write extensions scripts commitlint.config.mjs",
22
22
  "format": "biome format --write extensions scripts package.json biome.json commitlint.config.mjs .changeset/config.json",