@rmrdeveloper/sideroom-pi 8.1.1 → 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
@@ -4,6 +4,15 @@
4
4
  [![npm monthly downloads](https://img.shields.io/npm/dm/@rmrdeveloper/sideroom-pi?label=downloads&logo=npm)](https://www.npmjs.com/package/@rmrdeveloper/sideroom-pi)
5
5
  [![npm license](https://img.shields.io/npm/l/@rmrdeveloper/sideroom-pi?label=license)](https://www.npmjs.com/package/@rmrdeveloper/sideroom-pi)
6
6
 
7
+ **Asks before it guesses. Shows the plan. Enforces the style. Refuses to
8
+ call it done in red.**
9
+
10
+ ```bash
11
+ pi install npm:@rmrdeveloper/sideroom-pi
12
+ ```
13
+
14
+ ![Sideroom asking a multi-question batch and keeping the live board below it](https://raw.githubusercontent.com/RMRdeveloper/sideroom-pi/main/media/preview.png)
15
+
7
16
  > Coding agents fail in predictable ways: they guess instead of asking, they
8
17
  > bury progress in chat scroll, they litter your repo with `TODO.md` files,
9
18
  > every session writes code in a slightly different style, and they declare
@@ -54,14 +63,17 @@ wall of tool calls, it:
54
63
 
55
64
  - **Asks like a senior would.** A clean TUI questionnaire appears — tabs for
56
65
  several questions, a simple list for one. Every question carries a
57
- recommendation, you can always answer *Out of scope* or write your own,
58
- and everything is written in your language. Escape cancels the whole
59
- batch. No guessing, no twenty follow-up clarifications in chat.
66
+ recommendation, and a plural one lets you tick several answers at once. You
67
+ can always answer *Out of scope* or write your own, and everything is written
68
+ in your language. Escape cancels the whole batch. No guessing, no twenty
69
+ follow-up clarifications in chat.
60
70
  - **Works in the open.** A compact board sits above the editor:
61
71
  `pending → in_progress → completed`. Exactly one step is active at a
62
72
  time; the agent completes the current step and starts the next one in the
63
- same move, so skipped steps become structurally hard. The board survives
64
- 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.
65
77
  - **Shows its traces.** Below the board, the last edited files appear as
66
78
  clickable `file://` links. `F8` opens the full session history; `R` clears
67
79
  it. Only successful Pi `write`/`edit` calls are tracked — no guesses from
@@ -86,9 +98,11 @@ wall of tool calls, it:
86
98
  ### `sideroom_ask` — decisions, not interrogations
87
99
 
88
100
  One call is one 1–N question batch. Each question needs an `id`, a `prompt`,
89
- at least two `{ value, label }` options, and a `recommendationIndex` pointing
90
- at the recommended option. The UI adds the rest: the recommended mark, the
91
- always-on *Out of scope* row, and a custom answer.
101
+ at least two `{ value, label }` options, and a recommendation:
102
+ `recommendationIndex` for the default single answer, or
103
+ `selectionMode: "multiple"` with `recommendedIndices` when the question accepts
104
+ several. The UI adds the rest: the recommended mark, the always-on *Out of
105
+ scope* row, and a custom answer.
92
106
 
93
107
  TUI-only by design. In print, JSON, or RPC modes it fails fast with
94
108
  `UI not available` instead of hanging.
@@ -102,6 +116,10 @@ chat. It never creates a repository task file or a `/todos` command.
102
116
  - `update` patches `{ id, status?, content? }` items and works everywhere,
103
117
  including headless modes.
104
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.
105
123
 
106
124
  ### Edited files — proof, not promises
107
125
 
@@ -185,9 +203,10 @@ pi update --extension npm:@rmrdeveloper/sideroom-pi
185
203
  ### Questionnaire contract
186
204
 
187
205
  Each batch accepts one to four questions, with two to four caller-provided
188
- options per question. Tab labels accept at most 16 characters and option
189
- labels at most 60. Inputs that exceed a limit are rejected rather than
190
- truncated.
206
+ options per question. A question takes one answer unless it declares
207
+ `selectionMode: "multiple"`, which requires `recommendedIndices` and at least
208
+ one selection. Tab labels accept at most 16 characters and option labels at
209
+ most 60. Inputs that exceed a limit are rejected rather than truncated.
191
210
 
192
211
  Write prompts, tab labels, and option copy in the language the user is
193
212
  speaking; keep ids, option values, and TUI chrome in English. Do not send
@@ -200,6 +219,13 @@ Items have stable string ids, short content, and one of `pending`,
200
219
  start the next one in the same `update` call. Snapshots live in the active
201
220
  session branch and are rebuilt after navigation and compaction.
202
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
+
203
229
  A forked session inherits the board visible at its fork point. Later board
204
230
  updates are reconstructed from each session's active branch, so the fork and
205
231
  the original session evolve independently.
@@ -250,6 +276,17 @@ Single-session scope only. Files are created lazily: nothing exists until
250
276
  the first term or decision crystallises. A session with a sharper glossary
251
277
  and zero ADRs is working as designed.
252
278
 
279
+ ### Coexisting with other grilling skills
280
+
281
+ Pi routes skills by description, not by name, so several grilling skills can
282
+ be installed at once. `sideroom-grill` claims non-trivial plans before
283
+ implementation and states precedence over other grilling, interview, and spec
284
+ skills. If a third-party skill still wins, remove it from routing by adding
285
+ `disable-model-invocation: true` to its frontmatter, or exclude its path with
286
+ `-path` in `settings.skills`. A literal name collision keeps the first skill
287
+ found: project `.pi/skills`, then project `.agents/skills`, then
288
+ `~/.pi/agent/skills`, then `~/.agents/skills`, then packages.
289
+
253
290
  ## Documentation
254
291
 
255
292
  Maintainer reference lives in [`docs/`](docs/README.md): the
@@ -9,16 +9,18 @@ import {
9
9
  } from './model.ts';
10
10
 
11
11
  export const ASK_DESCRIPTION =
12
- 'Ask the user one or more questions in the language they are speaking. Use for clarifying requirements, getting preferences, or confirming decisions. Each question must include a recommended option. For a single question, shows a simple option list. For multiple questions, shows a tab-based interface.';
12
+ 'Ask the user one or more questions in the language they are speaking. Use for clarifying requirements, getting preferences, or confirming decisions. Each question must include a recommended option, or several recommended options when it accepts multiple selections. For a single question, shows a simple option list. For multiple questions, shows a tab-based interface.';
13
13
 
14
14
  export const ASK_PROMPT_SNIPPET =
15
15
  'Ask the user one or more questions with a recommended option, in their language.';
16
16
 
17
17
  export const ASK_PROMPT_GUIDELINES = [
18
18
  'Use sideroom_ask to clarify requirements, preferences, or decisions with the user.',
19
+ 'When a non-trivial request is not yet a settled spec, follow the sideroom-grill skill before implementing; it owns the interview and drives sideroom_ask rounds.',
19
20
  'Each sideroom_ask question needs an id, a prompt, at least two options, and a recommendationIndex pointing at the recommended option.',
20
21
  'Write every sideroom_ask prompt, tab label, and option label or description in the language the user is speaking. Keep ids, option values, and tool code in English.',
21
- 'sideroom_ask always adds Out of scope and a custom answer; do not include those options yourself.',
22
+ 'sideroom_ask always adds Out of scope and a custom answer; do not include those options yourself, and in a multiple-selection question they replace the selections.',
23
+ 'For a genuinely plural sideroom_ask question, set selectionMode "multiple" with recommendedIndices (at least one) instead of recommendationIndex; never send both fields, and keep the single default otherwise.',
22
24
  'One sideroom_ask call is one batch; call it again if another round of questions is needed.',
23
25
  'sideroom_ask only works in the interactive TUI; it returns an error in print, JSON, or RPC modes.',
24
26
  ];
@@ -73,6 +75,14 @@ export default function registerAsk(pi: ExtensionAPI): void {
73
75
  if (answer.wasCustom) {
74
76
  return `${mark}${id}: ${theme.fg('muted', '(wrote) ')}${answer.label}`;
75
77
  }
78
+ if (answer.selections !== undefined && answer.selections.length > 0) {
79
+ const picked = answer.selections
80
+ .map(
81
+ (selection) => `${String(selection.index)}. ${selection.label}`,
82
+ )
83
+ .join(', ');
84
+ return `${mark}${id}: ${picked}`;
85
+ }
76
86
  if (answer.index !== undefined) {
77
87
  return `${mark}${id}: ${String(answer.index)}. ${answer.label}`;
78
88
  }
@@ -9,6 +9,13 @@ export const CUSTOM_LABEL = 'Write a custom answer';
9
9
  export const UI_UNAVAILABLE =
10
10
  'Error: UI not available (running in non-interactive mode)';
11
11
 
12
+ export const SINGLE_SELECTION_MODE = 'single';
13
+ export const MULTIPLE_SELECTION_MODE = 'multiple';
14
+
15
+ export type SelectionMode =
16
+ | typeof SINGLE_SELECTION_MODE
17
+ | typeof MULTIPLE_SELECTION_MODE;
18
+
12
19
  const MAX_QUESTIONS = 4;
13
20
  const MAX_OPTIONS = 4;
14
21
  const MAX_TAB_LABEL_LENGTH = 16;
@@ -39,10 +46,32 @@ const QuestionSchema = Type.Object({
39
46
  maxItems: MAX_OPTIONS,
40
47
  description: `Two to ${String(MAX_OPTIONS)} mutually exclusive options`,
41
48
  }),
42
- recommendationIndex: Type.Integer({
43
- minimum: 0,
44
- description: 'Index into options pointing at the recommended option',
45
- }),
49
+ selectionMode: Type.Optional(
50
+ Type.Union(
51
+ [
52
+ Type.Literal(SINGLE_SELECTION_MODE),
53
+ Type.Literal(MULTIPLE_SELECTION_MODE),
54
+ ],
55
+ {
56
+ description:
57
+ "'single' (default) answers with one option; 'multiple' answers with one or more",
58
+ },
59
+ ),
60
+ ),
61
+ recommendationIndex: Type.Optional(
62
+ Type.Integer({
63
+ minimum: 0,
64
+ description:
65
+ "Required for a 'single' question: index into options pointing at the recommended option",
66
+ }),
67
+ ),
68
+ recommendedIndices: Type.Optional(
69
+ Type.Array(Type.Integer({ minimum: 0 }), {
70
+ minItems: 1,
71
+ description:
72
+ "Required for a 'multiple' question: indexes of every recommended option, at least one",
73
+ }),
74
+ ),
46
75
  });
47
76
 
48
77
  export const AskParamsSchema = Type.Object({
@@ -61,7 +90,14 @@ export interface AskQuestion {
61
90
  readonly label: string;
62
91
  readonly prompt: string;
63
92
  readonly options: readonly QuestionOption[];
64
- readonly recommendationIndex: number;
93
+ readonly selectionMode: SelectionMode;
94
+ readonly recommendedIndices: readonly number[];
95
+ }
96
+
97
+ export interface AskSelection {
98
+ readonly value: string;
99
+ readonly label: string;
100
+ readonly index: number;
65
101
  }
66
102
 
67
103
  export interface AskAnswer {
@@ -71,6 +107,7 @@ export interface AskAnswer {
71
107
  readonly wasCustom: boolean;
72
108
  readonly outOfScope: boolean;
73
109
  readonly index?: number;
110
+ readonly selections?: readonly AskSelection[];
74
111
  }
75
112
 
76
113
  export interface AskResult {
@@ -208,11 +245,9 @@ export function parseAskParams(value: unknown): ParseAskResult {
208
245
  };
209
246
  }
210
247
  seen.add(question.id);
211
- if (question.recommendationIndex >= question.options.length) {
212
- return {
213
- ok: false,
214
- message: `Error: recommendationIndex ${String(question.recommendationIndex)} is out of range for question '${question.id}'`,
215
- };
248
+ const failure = recommendationError(question);
249
+ if (failure !== undefined) {
250
+ return { ok: false, message: failure };
216
251
  }
217
252
  }
218
253
 
@@ -224,6 +259,7 @@ export function normalizeQuestions(
224
259
  ): AskQuestion[] {
225
260
  return questions.map((question, index) => {
226
261
  const label = question.label;
262
+ const selectionMode = selectionModeOf(question);
227
263
  return {
228
264
  id: question.id,
229
265
  label:
@@ -232,15 +268,67 @@ export function normalizeQuestions(
232
268
  : `Q${String(index + 1)}`,
233
269
  prompt: question.prompt,
234
270
  options: question.options,
235
- recommendationIndex: question.recommendationIndex,
271
+ selectionMode,
272
+ recommendedIndices: recommendedIndicesFor(question, selectionMode),
236
273
  };
237
274
  });
238
275
  }
239
276
 
277
+ function selectionModeOf(
278
+ question: AskParams['questions'][number],
279
+ ): SelectionMode {
280
+ return question.selectionMode === MULTIPLE_SELECTION_MODE
281
+ ? MULTIPLE_SELECTION_MODE
282
+ : SINGLE_SELECTION_MODE;
283
+ }
284
+
285
+ function recommendedIndicesFor(
286
+ question: AskParams['questions'][number],
287
+ selectionMode: SelectionMode,
288
+ ): readonly number[] {
289
+ if (selectionMode === MULTIPLE_SELECTION_MODE) {
290
+ return question.recommendedIndices ?? [];
291
+ }
292
+ if (question.recommendationIndex === undefined) {
293
+ return [];
294
+ }
295
+ return [question.recommendationIndex];
296
+ }
297
+
298
+ function recommendationError(
299
+ question: AskParams['questions'][number],
300
+ ): string | undefined {
301
+ if (selectionModeOf(question) === SINGLE_SELECTION_MODE) {
302
+ if (question.recommendationIndex === undefined) {
303
+ return `Error: Question '${question.id}' requires recommendationIndex`;
304
+ }
305
+ if (question.recommendedIndices !== undefined) {
306
+ return `Error: Question '${question.id}' is a single-selection question; remove recommendedIndices`;
307
+ }
308
+ if (question.recommendationIndex >= question.options.length) {
309
+ return `Error: recommendationIndex ${String(question.recommendationIndex)} is out of range for question '${question.id}'`;
310
+ }
311
+ return undefined;
312
+ }
313
+ if (question.recommendedIndices === undefined) {
314
+ return `Error: Question '${question.id}' is a multiple-selection question and requires recommendedIndices`;
315
+ }
316
+ if (question.recommendationIndex !== undefined) {
317
+ return `Error: Question '${question.id}' is a multiple-selection question; remove recommendationIndex`;
318
+ }
319
+ const outOfRange = question.recommendedIndices.find(
320
+ (optionIndex) => optionIndex >= question.options.length,
321
+ );
322
+ if (outOfRange !== undefined) {
323
+ return `Error: recommendedIndices ${String(outOfRange)} is out of range for question '${question.id}'`;
324
+ }
325
+ return undefined;
326
+ }
327
+
240
328
  export function renderOptions(question: AskQuestion): RenderOption[] {
241
329
  const options: RenderOption[] = question.options.map((option, index) => ({
242
330
  ...option,
243
- isRecommended: index === question.recommendationIndex,
331
+ isRecommended: question.recommendedIndices.includes(index),
244
332
  }));
245
333
  options.push({
246
334
  value: OUT_OF_SCOPE_VALUE,
@@ -268,6 +356,13 @@ export function formatAnswerLines(
268
356
  if (answer.wasCustom) {
269
357
  return `${questionLabel}: user wrote: ${answer.label}`;
270
358
  }
359
+ const selections = answer.selections;
360
+ if (selections !== undefined && selections.length > 0) {
361
+ const picked = selections
362
+ .map((selection) => `${String(selection.index)}. ${selection.label}`)
363
+ .join(', ');
364
+ return `${questionLabel}: user selected: ${picked}`;
365
+ }
271
366
  if (answer.index !== undefined) {
272
367
  return `${questionLabel}: user selected: ${String(answer.index)}. ${answer.label}`;
273
368
  }
@@ -302,6 +397,15 @@ function schemaErrorMessage(value: unknown): string {
302
397
  }
303
398
  }
304
399
 
400
+ const recommendedMatch = /\/questions\/(\d+)\/recommendedIndices$/.exec(
401
+ error.instancePath,
402
+ );
403
+ if (recommendedMatch !== null && error.keyword === 'minItems') {
404
+ const questionIndex = Number(recommendedMatch[1]);
405
+ const id = questionId(questions, questionIndex);
406
+ return `Error: Question '${id}' must mark at least one recommended option`;
407
+ }
408
+
305
409
  const tabLabelMatch = /\/questions\/(\d+)\/label$/.exec(error.instancePath);
306
410
  if (error.keyword === 'maxLength' && tabLabelMatch !== null) {
307
411
  const questionIndex = Number(tabLabelMatch[1]);
@@ -0,0 +1,47 @@
1
+ import type { AskAnswer, AskQuestion, AskSelection } from './model.ts';
2
+
3
+ /**
4
+ * Sorted ascending so the answer and its rendering stay stable no matter the
5
+ * order the user toggled the options in.
6
+ */
7
+ export function toggleSelection(
8
+ selected: readonly number[],
9
+ optionIndex: number,
10
+ ): number[] {
11
+ if (selected.includes(optionIndex)) {
12
+ return selected.filter((index) => index !== optionIndex);
13
+ }
14
+ return [...selected, optionIndex].sort((left, right) => left - right);
15
+ }
16
+
17
+ export function answerFromSelections(
18
+ question: AskQuestion,
19
+ selected: readonly number[],
20
+ ): AskAnswer {
21
+ const selections = selected.map((optionIndex) =>
22
+ toSelection(question, optionIndex),
23
+ );
24
+ const [first] = selections;
25
+ if (first === undefined) {
26
+ throw new Error(`Question '${question.id}' needs at least one selection`);
27
+ }
28
+ return {
29
+ id: question.id,
30
+ value: first.value,
31
+ label: first.label,
32
+ index: first.index,
33
+ wasCustom: false,
34
+ outOfScope: false,
35
+ selections,
36
+ };
37
+ }
38
+
39
+ function toSelection(question: AskQuestion, optionIndex: number): AskSelection {
40
+ const option = question.options[optionIndex];
41
+ if (option === undefined) {
42
+ throw new Error(
43
+ `Unknown option index ${String(optionIndex)} for question '${question.id}'`,
44
+ );
45
+ }
46
+ return { value: option.value, label: option.label, index: optionIndex + 1 };
47
+ }
@@ -11,9 +11,11 @@ import {
11
11
  type AskAnswer,
12
12
  type AskQuestion,
13
13
  type AskResult,
14
+ MULTIPLE_SELECTION_MODE,
14
15
  type RenderOption,
15
16
  renderOptions,
16
17
  } from './model.ts';
18
+ import { answerFromSelections, toggleSelection } from './selection.ts';
17
19
 
18
20
  export async function runAskUi(
19
21
  ui: Pick<ExtensionUIContext, 'custom'>,
@@ -25,8 +27,7 @@ export async function runAskUi(
25
27
  return ui.custom<AskResult>((tui, theme, _keybindings, done) => {
26
28
  let currentTab = 0;
27
29
  const firstQuestion = questions[0];
28
- let optionIndex =
29
- firstQuestion === undefined ? 0 : firstQuestion.recommendationIndex;
30
+ let optionIndex = firstQuestion?.recommendedIndices[0] ?? 0;
30
31
  let inputMode = false;
31
32
  let inputQuestionId: string | null = null;
32
33
  let cachedLines: string[] | undefined;
@@ -82,11 +83,10 @@ export async function runAskUi(
82
83
  return;
83
84
  }
84
85
  const saved = answers.get(question.id);
85
- if (saved?.index !== undefined) {
86
- optionIndex = saved.index - 1;
87
- } else {
88
- optionIndex = question.recommendationIndex;
89
- }
86
+ optionIndex =
87
+ saved?.index !== undefined
88
+ ? saved.index - 1
89
+ : (question.recommendedIndices[0] ?? 0);
90
90
  refresh();
91
91
  }
92
92
 
@@ -106,6 +106,45 @@ export async function runAskUi(
106
106
  answers.set(answer.id, answer);
107
107
  }
108
108
 
109
+ function selectedIndices(question: AskQuestion): readonly number[] {
110
+ const saved = answers.get(question.id);
111
+ if (saved?.selections === undefined) {
112
+ return [];
113
+ }
114
+ return saved.selections.map((selection) => selection.index - 1);
115
+ }
116
+
117
+ function answerSummary(answer: AskAnswer): string {
118
+ if (answer.selections === undefined || answer.selections.length === 0) {
119
+ return answer.label;
120
+ }
121
+ return answer.selections.map((selection) => selection.label).join(', ');
122
+ }
123
+
124
+ function isReservedOption(opt: RenderOption): boolean {
125
+ return opt.isOther === true || opt.isOutOfScope === true;
126
+ }
127
+
128
+ function activateReservedOption(
129
+ question: AskQuestion,
130
+ opt: RenderOption,
131
+ index: number,
132
+ ): void {
133
+ if (opt.isOther === true) {
134
+ openCustomEditor(question);
135
+ return;
136
+ }
137
+ saveAnswer({
138
+ id: question.id,
139
+ value: opt.value,
140
+ label: opt.label,
141
+ wasCustom: false,
142
+ outOfScope: true,
143
+ index: index + 1,
144
+ });
145
+ advanceAfterAnswer();
146
+ }
147
+
109
148
  function openCustomEditor(question: AskQuestion): void {
110
149
  inputMode = true;
111
150
  inputQuestionId = question.id;
@@ -127,20 +166,8 @@ export async function runAskUi(
127
166
  if (opt === undefined) {
128
167
  return;
129
168
  }
130
- if (opt.isOther === true) {
131
- openCustomEditor(question);
132
- return;
133
- }
134
- if (opt.isOutOfScope === true) {
135
- saveAnswer({
136
- id: question.id,
137
- value: opt.value,
138
- label: opt.label,
139
- wasCustom: false,
140
- outOfScope: true,
141
- index: index + 1,
142
- });
143
- advanceAfterAnswer();
169
+ if (isReservedOption(opt)) {
170
+ activateReservedOption(question, opt, index);
144
171
  return;
145
172
  }
146
173
  const original = question.options[index];
@@ -155,6 +182,49 @@ export async function runAskUi(
155
182
  advanceAfterAnswer();
156
183
  }
157
184
 
185
+ function toggleMultipleOption(
186
+ question: AskQuestion,
187
+ opts: readonly RenderOption[],
188
+ index: number,
189
+ ): void {
190
+ const opt = opts[index];
191
+ if (opt === undefined) {
192
+ return;
193
+ }
194
+ if (isReservedOption(opt)) {
195
+ activateReservedOption(question, opt, index);
196
+ return;
197
+ }
198
+ const next = toggleSelection(selectedIndices(question), index);
199
+ if (next.length === 0) {
200
+ answers.delete(question.id);
201
+ refresh();
202
+ return;
203
+ }
204
+ answers.set(question.id, answerFromSelections(question, next));
205
+ refresh();
206
+ }
207
+
208
+ function confirmMultipleSelection(
209
+ question: AskQuestion,
210
+ opts: readonly RenderOption[],
211
+ index: number,
212
+ ): void {
213
+ const opt = opts[index];
214
+ if (opt === undefined) {
215
+ return;
216
+ }
217
+ if (isReservedOption(opt)) {
218
+ activateReservedOption(question, opt, index);
219
+ return;
220
+ }
221
+ if (selectedIndices(question).length === 0) {
222
+ refresh();
223
+ return;
224
+ }
225
+ advanceAfterAnswer();
226
+ }
227
+
158
228
  editor.onSubmit = (value) => {
159
229
  if (inputQuestionId === null) {
160
230
  return;
@@ -232,15 +302,31 @@ export async function runAskUi(
232
302
  return;
233
303
  }
234
304
 
305
+ if (
306
+ question?.selectionMode === MULTIPLE_SELECTION_MODE &&
307
+ matchesKey(data, Key.space)
308
+ ) {
309
+ toggleMultipleOption(question, opts, optionIndex);
310
+ return;
311
+ }
312
+
235
313
  const digit = optionIndexFromDigit(data, opts.length);
236
314
  if (digit !== undefined && question !== undefined) {
237
315
  optionIndex = digit;
238
- confirmOption(question, opts, digit);
316
+ if (question.selectionMode === MULTIPLE_SELECTION_MODE) {
317
+ toggleMultipleOption(question, opts, digit);
318
+ } else {
319
+ confirmOption(question, opts, digit);
320
+ }
239
321
  return;
240
322
  }
241
323
 
242
324
  if (matchesKey(data, Key.enter) && question !== undefined) {
243
- confirmOption(question, opts, optionIndex);
325
+ if (question.selectionMode === MULTIPLE_SELECTION_MODE) {
326
+ confirmMultipleSelection(question, opts, optionIndex);
327
+ } else {
328
+ confirmOption(question, opts, optionIndex);
329
+ }
244
330
  return;
245
331
  }
246
332
 
@@ -310,7 +396,10 @@ export async function runAskUi(
310
396
  lines.push('');
311
397
  }
312
398
 
313
- function renderOptionList(): void {
399
+ function renderOptionList(questionForList: AskQuestion): void {
400
+ const isMultipleAnswer =
401
+ questionForList.selectionMode === MULTIPLE_SELECTION_MODE;
402
+ const selected = selectedIndices(questionForList);
314
403
  for (let index = 0; index < opts.length; index += 1) {
315
404
  const opt = opts[index];
316
405
  if (opt === undefined) {
@@ -319,10 +408,16 @@ export async function runAskUi(
319
408
  if (opt.isOutOfScope === true) {
320
409
  lines.push('');
321
410
  }
322
- const selected = index === optionIndex;
323
- const prefix = selected ? theme.fg('accent', '> ') : ' ';
324
- const color = selected ? 'accent' : 'text';
325
- let line = theme.fg(color, `${String(index + 1)}. ${opt.label}`);
411
+ const focused = index === optionIndex;
412
+ const prefix = focused ? theme.fg('accent', '> ') : ' ';
413
+ const color = focused ? 'accent' : 'text';
414
+ let line = '';
415
+ if (isMultipleAnswer && !isReservedOption(opt)) {
416
+ line += selected.includes(index)
417
+ ? theme.fg('success', '[x] ')
418
+ : theme.fg('dim', '[ ] ');
419
+ }
420
+ line += theme.fg(color, `${String(index + 1)}. ${opt.label}`);
326
421
  if (opt.isRecommended === true) {
327
422
  line = `${line} ${theme.fg('dim', 'recommended')}`;
328
423
  }
@@ -367,7 +462,7 @@ export async function runAskUi(
367
462
  }
368
463
  addWrappedWithPrefix(
369
464
  ' ',
370
- `${name}${theme.fg('dim', kind)}${theme.fg('text', answer.label)}`,
465
+ `${name}${theme.fg('dim', kind)}${theme.fg('text', answerSummary(answer))}`,
371
466
  );
372
467
  }
373
468
  lines.push('');
@@ -395,19 +490,31 @@ export async function runAskUi(
395
490
  if (saved !== undefined) {
396
491
  addWrappedWithPrefix(
397
492
  ' ',
398
- theme.fg('muted', `Current: ${saved.label}`),
493
+ theme.fg('muted', `Current: ${answerSummary(saved)}`),
399
494
  );
400
495
  }
401
496
  lines.push('');
402
- renderOptionList();
497
+ renderOptionList(question);
403
498
  }
404
499
 
405
500
  if (!inputMode && currentTab !== questions.length) {
406
501
  lines.push('');
502
+ if (
503
+ question?.selectionMode === MULTIPLE_SELECTION_MODE &&
504
+ selectedIndices(question).length === 0
505
+ ) {
506
+ addWrappedWithPrefix(
507
+ ' ',
508
+ theme.fg('warning', 'Select at least one option'),
509
+ );
510
+ }
407
511
  const count = String(opts.length);
408
- const help = isMulti
409
- ? `↑↓ or 1–${count} · Enter · Tab next · Esc cancel`
410
- : `↑↓ or 1–${count} · Enter select · Esc cancel`;
512
+ let help = `↑↓ or 1–${count} · Enter select · Esc cancel`;
513
+ if (question?.selectionMode === MULTIPLE_SELECTION_MODE) {
514
+ help = `↑↓ or 1–${count} toggle · Space toggle · Enter confirm · Esc cancel`;
515
+ } else if (isMulti) {
516
+ help = `↑↓ or 1–${count} · Enter · Tab next · Esc cancel`;
517
+ }
411
518
  addWrappedWithPrefix(' ', theme.fg('dim', help));
412
519
  }
413
520
  lines.push(theme.fg('accent', '─'.repeat(renderWidth)));
@@ -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,7 +1,7 @@
1
1
  {
2
2
  "name": "@rmrdeveloper/sideroom-pi",
3
- "version": "8.1.1",
4
- "description": "A global Pi package with a questionnaire, work board, edited-file view, coding guidelines, rule enforcement, and a finish gate for parent agents.",
3
+ "version": "8.3.0",
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": [
7
7
  "extensions",
@@ -16,11 +16,12 @@
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",
23
23
  "format:check": "biome format extensions scripts package.json biome.json commitlint.config.mjs .changeset/config.json",
24
+ "preview": "node scripts/render-preview.mjs",
24
25
  "check:if-blocks": "node scripts/check-if-blocks.mjs",
25
26
  "check": "npm run types && npm run check:if-blocks && biome check extensions scripts package.json biome.json commitlint.config.mjs .changeset/config.json && npm test"
26
27
  },
@@ -31,13 +32,31 @@
31
32
  "pi",
32
33
  "pi-package",
33
34
  "pi-extension",
35
+ "pi-skill",
36
+ "coding-agent",
37
+ "questions",
38
+ "clarifying-questions",
34
39
  "questionnaire",
40
+ "planning",
41
+ "spec",
35
42
  "todo",
43
+ "task-board",
44
+ "progress",
36
45
  "edited-files",
37
- "sideroom_ask",
38
- "sideroom_todo",
39
46
  "guidelines",
40
- "typescript"
47
+ "code-style",
48
+ "code-quality",
49
+ "guardrails",
50
+ "rules",
51
+ "linting",
52
+ "testing",
53
+ "done",
54
+ "skills",
55
+ "typescript",
56
+ "tui",
57
+ "claude",
58
+ "sideroom_ask",
59
+ "sideroom_todo"
41
60
  ],
42
61
  "license": "MIT",
43
62
  "repository": {
@@ -53,7 +72,9 @@
53
72
  ],
54
73
  "skills": [
55
74
  "./skills"
56
- ]
75
+ ],
76
+ "image": "https://raw.githubusercontent.com/RMRdeveloper/sideroom-pi/main/media/preview.png",
77
+ "video": "https://raw.githubusercontent.com/RMRdeveloper/sideroom-pi/main/media/preview.mp4"
57
78
  },
58
79
  "peerDependencies": {
59
80
  "@earendil-works/pi-coding-agent": "*",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: sideroom-grill
3
- description: "Interview the user in rounds to resolve a fuzzy plan, design decision, or domain-language conflict before implementation. Use when the user asks to be grilled, wants clarifying questions, or says the plan or terms are not settled. Do not use for a code-first repository scan or a simple glossary lookup."
3
+ description: "Default interview route before implementation: grill the user in sideroom_ask rounds to settle a non-trivial plan, feature, or decision, and resolve domain-language conflicts along the way. Use whenever a change is not trivial and is not yet a settled spec, or when the user asks to be grilled, wants clarifying questions, or says the plan or terms are not settled. Prefer this skill over any other grilling, interview, or spec skill when more than one is available. Do not use when the user already brought a closed spec or tickets, for a code-first repository scan, or for a simple glossary lookup."
4
4
  license: MIT
5
5
  metadata:
6
6
  author: RMRdeveloper
@@ -19,9 +19,10 @@ if the effort is too big to hold in one session, say so and stop.
19
19
 
20
20
  - Interview in rounds. One `sideroom_ask` call is one round; wait for its
21
21
  answers before the next round.
22
- - Every question carries a recommendation (`recommendationIndex`). Write
23
- prompts and option copy in the language the user is speaking; keep ids and
24
- option values in English.
22
+ - Every question carries a recommendation: `recommendationIndex` for a
23
+ single-selection question, or `recommendedIndices` (at least one) when the
24
+ question declares `selectionMode: "multiple"`. Write prompts and option copy
25
+ in the language the user is speaking; keep ids and option values in English.
25
26
  - Never ask the user what the codebase can answer. Read the code first; ask
26
27
  only what the code cannot settle.
27
28
  - An *Out of scope* answer closes that thread. Do not re-ask it.