@rmrdeveloper/sideroom-pi 5.0.1 → 6.0.1

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.
Files changed (33) hide show
  1. package/README.md +101 -81
  2. package/{src/assets → assets}/artifacts/GUIDELINES_TEMPLATE.md +40 -11
  3. package/extensions/ask/execute.ts +56 -0
  4. package/extensions/ask/index.ts +77 -0
  5. package/extensions/ask/model.ts +196 -0
  6. package/extensions/ask/ui.ts +445 -0
  7. package/extensions/modified-files/index.ts +45 -0
  8. package/extensions/modified-files/model.ts +74 -0
  9. package/extensions/modified-files/session.ts +200 -0
  10. package/extensions/modified-files/ui.ts +207 -0
  11. package/extensions/todo/execute.ts +69 -0
  12. package/extensions/todo/guards.ts +107 -0
  13. package/extensions/todo/index.ts +91 -0
  14. package/extensions/todo/model.ts +327 -0
  15. package/extensions/todo/session.ts +166 -0
  16. package/extensions/todo/ui.ts +41 -0
  17. package/package.json +26 -27
  18. package/dist/pi-extension.js +0 -1175
  19. package/skills/sideroom-critic/SKILL.md +0 -32
  20. package/skills/sideroom-grilling/SKILL.md +0 -81
  21. package/skills/sideroom-spec/SKILL.md +0 -38
  22. package/skills/sideroom-transcribe-audio/SKILL.md +0 -38
  23. package/skills/sideroom-transcribe-audio/scripts/transcribe.py +0 -40
  24. package/src/assets/agents/sideroom-code-reviewer.md +0 -30
  25. package/src/assets/agents/sideroom-fixer.md +0 -37
  26. package/src/assets/agents/sideroom-implementer.md +0 -39
  27. package/src/assets/agents/sideroom-planner.md +0 -31
  28. package/src/assets/agents/sideroom-verifier.md +0 -32
  29. package/src/assets/artifacts/guidelines/java.md +0 -211
  30. package/src/assets/artifacts/guidelines/javascript.md +0 -235
  31. package/src/assets/artifacts/guidelines/php-laravel.md +0 -210
  32. package/src/assets/artifacts/guidelines/python.md +0 -227
  33. package/src/assets/artifacts/guidelines/typescript.md +0 -231
@@ -0,0 +1,445 @@
1
+ import type { ExtensionUIContext } from '@earendil-works/pi-coding-agent';
2
+ import {
3
+ Editor,
4
+ type EditorTheme,
5
+ Key,
6
+ matchesKey,
7
+ visibleWidth,
8
+ wrapTextWithAnsi,
9
+ } from '@earendil-works/pi-tui';
10
+ import {
11
+ type AskAnswer,
12
+ type AskQuestion,
13
+ type AskResult,
14
+ type RenderOption,
15
+ renderOptions,
16
+ } from './model.ts';
17
+
18
+ export async function runAskUi(
19
+ ui: Pick<ExtensionUIContext, 'custom'>,
20
+ questions: readonly AskQuestion[],
21
+ ): Promise<AskResult> {
22
+ const isMulti = questions.length > 1;
23
+ const totalTabs = questions.length + 1;
24
+
25
+ return ui.custom<AskResult>((tui, theme, _keybindings, done) => {
26
+ let currentTab = 0;
27
+ const firstQuestion = questions[0];
28
+ let optionIndex =
29
+ firstQuestion === undefined ? 0 : firstQuestion.recommendationIndex;
30
+ let inputMode = false;
31
+ let inputQuestionId: string | null = null;
32
+ let cachedLines: string[] | undefined;
33
+ const answers = new Map<string, AskAnswer>();
34
+
35
+ const editorTheme: EditorTheme = {
36
+ borderColor: (s) => theme.fg('accent', s),
37
+ selectList: {
38
+ selectedPrefix: (t) => theme.fg('accent', t),
39
+ selectedText: (t) => theme.fg('accent', t),
40
+ description: (t) => theme.fg('muted', t),
41
+ scrollInfo: (t) => theme.fg('dim', t),
42
+ noMatch: (t) => theme.fg('warning', t),
43
+ },
44
+ };
45
+ const editor = new Editor(tui, editorTheme);
46
+
47
+ function refresh(): void {
48
+ cachedLines = undefined;
49
+ tui.requestRender();
50
+ }
51
+
52
+ function submit(cancelled: boolean): void {
53
+ done({
54
+ questions,
55
+ answers: Array.from(answers.values()),
56
+ cancelled,
57
+ });
58
+ }
59
+
60
+ function currentQuestion(): AskQuestion | undefined {
61
+ return questions[currentTab];
62
+ }
63
+
64
+ function currentOptions(): RenderOption[] {
65
+ const question = currentQuestion();
66
+ if (question === undefined) {
67
+ return [];
68
+ }
69
+ return renderOptions(question);
70
+ }
71
+
72
+ function allAnswered(): boolean {
73
+ return questions.every((question) => answers.has(question.id));
74
+ }
75
+
76
+ function focusTab(tab: number): void {
77
+ currentTab = tab;
78
+ const question = questions[tab];
79
+ if (question === undefined) {
80
+ optionIndex = 0;
81
+ refresh();
82
+ return;
83
+ }
84
+ const saved = answers.get(question.id);
85
+ if (saved?.index !== undefined) {
86
+ optionIndex = saved.index - 1;
87
+ } else {
88
+ optionIndex = question.recommendationIndex;
89
+ }
90
+ refresh();
91
+ }
92
+
93
+ function advanceAfterAnswer(): void {
94
+ if (!isMulti) {
95
+ submit(false);
96
+ return;
97
+ }
98
+ if (currentTab < questions.length - 1) {
99
+ focusTab(currentTab + 1);
100
+ return;
101
+ }
102
+ focusTab(questions.length);
103
+ }
104
+
105
+ function saveAnswer(answer: AskAnswer): void {
106
+ answers.set(answer.id, answer);
107
+ }
108
+
109
+ function openCustomEditor(question: AskQuestion): void {
110
+ inputMode = true;
111
+ inputQuestionId = question.id;
112
+ const saved = answers.get(question.id);
113
+ if (saved?.wasCustom === true) {
114
+ editor.setText(saved.label);
115
+ } else {
116
+ editor.setText('');
117
+ }
118
+ refresh();
119
+ }
120
+
121
+ function confirmOption(
122
+ question: AskQuestion,
123
+ opts: readonly RenderOption[],
124
+ index: number,
125
+ ): void {
126
+ const opt = opts[index];
127
+ if (opt === undefined) {
128
+ return;
129
+ }
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();
144
+ return;
145
+ }
146
+ const original = question.options[index];
147
+ saveAnswer({
148
+ id: question.id,
149
+ value: original?.value ?? opt.value,
150
+ label: original?.label ?? opt.label,
151
+ wasCustom: false,
152
+ outOfScope: false,
153
+ index: index + 1,
154
+ });
155
+ advanceAfterAnswer();
156
+ }
157
+
158
+ editor.onSubmit = (value) => {
159
+ if (inputQuestionId === null) {
160
+ return;
161
+ }
162
+ const trimmed = value.trim();
163
+ if (trimmed.length === 0) {
164
+ inputMode = false;
165
+ inputQuestionId = null;
166
+ editor.setText('');
167
+ refresh();
168
+ return;
169
+ }
170
+ const question = questions.find((entry) => entry.id === inputQuestionId);
171
+ const displayIndex =
172
+ question === undefined ? 1 : renderOptions(question).length;
173
+ saveAnswer({
174
+ id: inputQuestionId,
175
+ value: trimmed,
176
+ label: trimmed,
177
+ wasCustom: true,
178
+ outOfScope: false,
179
+ index: displayIndex,
180
+ });
181
+ inputMode = false;
182
+ inputQuestionId = null;
183
+ editor.setText('');
184
+ advanceAfterAnswer();
185
+ };
186
+
187
+ function handleInput(data: string): void {
188
+ if (inputMode) {
189
+ if (matchesKey(data, Key.escape)) {
190
+ inputMode = false;
191
+ inputQuestionId = null;
192
+ editor.setText('');
193
+ refresh();
194
+ return;
195
+ }
196
+ editor.handleInput(data);
197
+ refresh();
198
+ return;
199
+ }
200
+
201
+ const question = currentQuestion();
202
+ const opts = currentOptions();
203
+
204
+ if (isMulti) {
205
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
206
+ focusTab((currentTab + 1) % totalTabs);
207
+ return;
208
+ }
209
+ if (matchesKey(data, Key.shift('tab')) || matchesKey(data, Key.left)) {
210
+ focusTab((currentTab - 1 + totalTabs) % totalTabs);
211
+ return;
212
+ }
213
+ }
214
+
215
+ if (currentTab === questions.length) {
216
+ if (matchesKey(data, Key.enter) && allAnswered()) {
217
+ submit(false);
218
+ } else if (matchesKey(data, Key.escape)) {
219
+ submit(true);
220
+ }
221
+ return;
222
+ }
223
+
224
+ if (matchesKey(data, Key.up)) {
225
+ optionIndex = Math.max(0, optionIndex - 1);
226
+ refresh();
227
+ return;
228
+ }
229
+ if (matchesKey(data, Key.down)) {
230
+ optionIndex = Math.min(opts.length - 1, optionIndex + 1);
231
+ refresh();
232
+ return;
233
+ }
234
+
235
+ const digit = optionIndexFromDigit(data, opts.length);
236
+ if (digit !== undefined && question !== undefined) {
237
+ optionIndex = digit;
238
+ confirmOption(question, opts, digit);
239
+ return;
240
+ }
241
+
242
+ if (matchesKey(data, Key.enter) && question !== undefined) {
243
+ confirmOption(question, opts, optionIndex);
244
+ return;
245
+ }
246
+
247
+ if (matchesKey(data, Key.escape)) {
248
+ submit(true);
249
+ }
250
+ }
251
+
252
+ function render(width: number): string[] {
253
+ if (cachedLines !== undefined) {
254
+ return cachedLines;
255
+ }
256
+
257
+ const lines: string[] = [];
258
+ const renderWidth = Math.max(1, width);
259
+ const question = currentQuestion();
260
+ const opts = currentOptions();
261
+
262
+ function addWrapped(text: string): void {
263
+ lines.push(...wrapTextWithAnsi(text, renderWidth));
264
+ }
265
+
266
+ function addWrappedWithPrefix(prefix: string, text: string): void {
267
+ const prefixWidth = visibleWidth(prefix);
268
+ if (prefixWidth >= renderWidth) {
269
+ addWrapped(prefix + text);
270
+ return;
271
+ }
272
+ const wrapped = wrapTextWithAnsi(text, renderWidth - prefixWidth);
273
+ const continuationPrefix = ' '.repeat(prefixWidth);
274
+ for (let index = 0; index < wrapped.length; index += 1) {
275
+ const part = wrapped[index];
276
+ if (part === undefined) {
277
+ continue;
278
+ }
279
+ lines.push(`${index === 0 ? prefix : continuationPrefix}${part}`);
280
+ }
281
+ }
282
+
283
+ lines.push(theme.fg('accent', '─'.repeat(renderWidth)));
284
+
285
+ if (isMulti) {
286
+ const tabs: string[] = [];
287
+ for (let index = 0; index < questions.length; index += 1) {
288
+ const entry = questions[index];
289
+ if (entry === undefined) {
290
+ continue;
291
+ }
292
+ const isActive = index === currentTab;
293
+ const isAnswered = answers.has(entry.id);
294
+ const mark = isAnswered ? '●' : '○';
295
+ const color = isAnswered ? 'success' : 'muted';
296
+ const text = ` ${mark} ${entry.label} `;
297
+ const styled = isActive
298
+ ? theme.bg('selectedBg', theme.fg('text', text))
299
+ : theme.fg(color, text);
300
+ tabs.push(styled);
301
+ }
302
+ const canSubmit = allAnswered();
303
+ const isSubmitTab = currentTab === questions.length;
304
+ const submitText = ' Submit ';
305
+ const submitStyled = isSubmitTab
306
+ ? theme.bg('selectedBg', theme.fg('text', submitText))
307
+ : theme.fg(canSubmit ? 'success' : 'dim', submitText);
308
+ tabs.push(submitStyled);
309
+ addWrappedWithPrefix(' ', tabs.join(' '));
310
+ lines.push('');
311
+ }
312
+
313
+ function renderOptionList(): void {
314
+ for (let index = 0; index < opts.length; index += 1) {
315
+ const opt = opts[index];
316
+ if (opt === undefined) {
317
+ continue;
318
+ }
319
+ if (opt.isOutOfScope === true) {
320
+ lines.push('');
321
+ }
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}`);
326
+ if (opt.isRecommended === true) {
327
+ line = `${line} ${theme.fg('dim', 'recommended')}`;
328
+ }
329
+ addWrappedWithPrefix(prefix, line);
330
+ if (opt.description !== undefined) {
331
+ addWrappedWithPrefix(' ', theme.fg('muted', opt.description));
332
+ }
333
+ }
334
+ }
335
+
336
+ if (inputMode && question !== undefined) {
337
+ addWrappedWithPrefix(
338
+ ' ',
339
+ theme.fg('accent', theme.bold(question.prompt)),
340
+ );
341
+ lines.push('');
342
+ addWrappedWithPrefix(' ', theme.fg('muted', 'Custom answer'));
343
+ for (const line of editor.render(Math.max(1, renderWidth - 2))) {
344
+ lines.push(` ${line}`);
345
+ }
346
+ lines.push('');
347
+ addWrappedWithPrefix(
348
+ ' ',
349
+ theme.fg('dim', 'Enter save · Esc back to options'),
350
+ );
351
+ } else if (currentTab === questions.length) {
352
+ for (const entry of questions) {
353
+ const answer = answers.get(entry.id);
354
+ const name = theme.fg('muted', `${entry.label}: `);
355
+ if (answer === undefined) {
356
+ addWrappedWithPrefix(
357
+ ' ',
358
+ `${name}${theme.fg('warning', 'not answered')}`,
359
+ );
360
+ continue;
361
+ }
362
+ let kind = '';
363
+ if (answer.outOfScope) {
364
+ kind = 'out of scope · ';
365
+ } else if (answer.wasCustom) {
366
+ kind = 'custom · ';
367
+ }
368
+ addWrappedWithPrefix(
369
+ ' ',
370
+ `${name}${theme.fg('dim', kind)}${theme.fg('text', answer.label)}`,
371
+ );
372
+ }
373
+ lines.push('');
374
+ if (allAnswered()) {
375
+ addWrappedWithPrefix(
376
+ ' ',
377
+ theme.fg('success', 'Enter send answers · Esc cancel'),
378
+ );
379
+ } else {
380
+ const missing = questions
381
+ .filter((entry) => !answers.has(entry.id))
382
+ .map((entry) => entry.label)
383
+ .join(', ');
384
+ addWrappedWithPrefix(
385
+ ' ',
386
+ theme.fg('warning', `Answer ${missing} to continue`),
387
+ );
388
+ }
389
+ } else if (question !== undefined) {
390
+ addWrappedWithPrefix(
391
+ ' ',
392
+ theme.fg('accent', theme.bold(question.prompt)),
393
+ );
394
+ const saved = answers.get(question.id);
395
+ if (saved !== undefined) {
396
+ addWrappedWithPrefix(
397
+ ' ',
398
+ theme.fg('muted', `Current: ${saved.label}`),
399
+ );
400
+ }
401
+ lines.push('');
402
+ renderOptionList();
403
+ }
404
+
405
+ if (!inputMode && currentTab !== questions.length) {
406
+ lines.push('');
407
+ 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`;
411
+ addWrappedWithPrefix(' ', theme.fg('dim', help));
412
+ }
413
+ lines.push(theme.fg('accent', '─'.repeat(renderWidth)));
414
+
415
+ cachedLines = lines;
416
+ return lines;
417
+ }
418
+
419
+ return {
420
+ render,
421
+ invalidate: () => {
422
+ cachedLines = undefined;
423
+ },
424
+ handleInput,
425
+ };
426
+ });
427
+ }
428
+
429
+ function optionIndexFromDigit(
430
+ data: string,
431
+ optionCount: number,
432
+ ): number | undefined {
433
+ if (data.length !== 1) {
434
+ return undefined;
435
+ }
436
+ const code = data.charCodeAt(0);
437
+ if (code < 49 || code > 57) {
438
+ return undefined;
439
+ }
440
+ const index = code - 49;
441
+ if (index >= optionCount) {
442
+ return undefined;
443
+ }
444
+ return index;
445
+ }
@@ -0,0 +1,45 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ } from '@earendil-works/pi-coding-agent';
5
+ import { TODO_WIDGET_REFRESH_EVENT } from '../todo/session.ts';
6
+ import {
7
+ commitModifiedFiles,
8
+ createModifiedFilesStore,
9
+ refreshModifiedFiles,
10
+ registerModifiedFilesEvents,
11
+ } from './session.ts';
12
+ import { createModifiedFilesOverlayController } from './ui.ts';
13
+
14
+ const SHORTCUT = 'f8';
15
+
16
+ // Pi loads extensions/*/index.ts through export default.
17
+ export default function registerModifiedFiles(pi: ExtensionAPI): void {
18
+ const store = createModifiedFilesStore();
19
+ const overlay = createModifiedFilesOverlayController(
20
+ () => store.files,
21
+ (ctx) => commitModifiedFiles(store, pi, ctx, []),
22
+ );
23
+
24
+ registerModifiedFilesEvents(pi, store);
25
+ pi.events.on(TODO_WIDGET_REFRESH_EVENT, (value) => {
26
+ if (isWidgetContext(value)) {
27
+ refreshModifiedFiles(store, value);
28
+ }
29
+ });
30
+ pi.registerShortcut(SHORTCUT, {
31
+ description: 'Toggle edited files',
32
+ handler: (ctx) => overlay.toggle(ctx),
33
+ });
34
+ }
35
+
36
+ function isWidgetContext(
37
+ value: unknown,
38
+ ): value is Pick<ExtensionContext, 'ui' | 'cwd'> {
39
+ return (
40
+ typeof value === 'object' &&
41
+ value !== null &&
42
+ 'ui' in value &&
43
+ 'cwd' in value
44
+ );
45
+ }
@@ -0,0 +1,74 @@
1
+ import { relative, resolve } from 'node:path';
2
+
3
+ export const MAX_TRACKED_FILES = 100;
4
+ export const MODIFIED_FILES_SNAPSHOT_TYPE = 'sideroom-modified-files';
5
+ export const MODIFIED_FILES_WIDGET_KEY = 'sideroom-modified-files';
6
+
7
+ export const FILE_TOOL_NAME = {
8
+ edit: 'edit',
9
+ write: 'write',
10
+ } as const;
11
+
12
+ export type FileToolName = (typeof FILE_TOOL_NAME)[keyof typeof FILE_TOOL_NAME];
13
+
14
+ export interface ModifiedFile {
15
+ readonly path: string;
16
+ readonly toolName: FileToolName;
17
+ }
18
+
19
+ export function recordModifiedFile(
20
+ files: readonly ModifiedFile[],
21
+ path: string,
22
+ toolName: FileToolName,
23
+ ): readonly ModifiedFile[] {
24
+ return [
25
+ { path, toolName },
26
+ ...files.filter((file) => file.path !== path),
27
+ ].slice(0, MAX_TRACKED_FILES);
28
+ }
29
+
30
+ export function resolveModifiedPath(
31
+ cwd: string,
32
+ path: string,
33
+ ): string | undefined {
34
+ const normalized = path.trim().replace(/^@/, '');
35
+ if (normalized.length === 0) {
36
+ return undefined;
37
+ }
38
+ return resolve(cwd, normalized);
39
+ }
40
+
41
+ export function displayModifiedPath(cwd: string, path: string): string {
42
+ const displayPath = relative(cwd, path);
43
+ if (displayPath === '' || displayPath.startsWith('..')) {
44
+ return `external: ${path}`;
45
+ }
46
+ return displayPath;
47
+ }
48
+
49
+ export function readModifiedFilesSnapshot(
50
+ value: unknown,
51
+ ): readonly ModifiedFile[] | undefined {
52
+ if (!isRecord(value) || !Array.isArray(value.files)) {
53
+ return undefined;
54
+ }
55
+
56
+ const files: ModifiedFile[] = [];
57
+ for (const file of value.files) {
58
+ if (!isRecord(file) || typeof file.path !== 'string') {
59
+ return undefined;
60
+ }
61
+ if (
62
+ file.toolName !== FILE_TOOL_NAME.edit &&
63
+ file.toolName !== FILE_TOOL_NAME.write
64
+ ) {
65
+ return undefined;
66
+ }
67
+ files.push({ path: file.path, toolName: file.toolName });
68
+ }
69
+ return files;
70
+ }
71
+
72
+ export function isRecord(value: unknown): value is Record<string, unknown> {
73
+ return typeof value === 'object' && value !== null;
74
+ }