@rmrdeveloper/sideroom-pi 5.0.1 → 7.0.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 +115 -81
- package/{src/assets → assets}/artifacts/GUIDELINES_TEMPLATE.md +40 -11
- package/extensions/ask/execute.ts +56 -0
- package/extensions/ask/index.ts +78 -0
- package/extensions/ask/model.ts +196 -0
- package/extensions/ask/ui.ts +445 -0
- package/extensions/guidelines/index.ts +13 -0
- package/extensions/guidelines/prompt.ts +26 -0
- package/extensions/modified-files/index.ts +45 -0
- package/extensions/modified-files/model.ts +74 -0
- package/extensions/modified-files/session.ts +200 -0
- package/extensions/modified-files/ui.ts +207 -0
- package/extensions/todo/execute.ts +69 -0
- package/extensions/todo/guards.ts +107 -0
- package/extensions/todo/index.ts +91 -0
- package/extensions/todo/model.ts +327 -0
- package/extensions/todo/session.ts +166 -0
- package/extensions/todo/ui.ts +41 -0
- package/package.json +28 -24
- package/skills/sideroom-guidelines/SKILL.md +77 -0
- package/skills/sideroom-guidelines/references/languages/go.md +27 -0
- package/skills/sideroom-guidelines/references/languages/java.md +24 -0
- package/skills/sideroom-guidelines/references/languages/php-laravel.md +26 -0
- package/skills/sideroom-guidelines/references/languages/python.md +21 -0
- package/skills/sideroom-guidelines/references/languages/rust.md +19 -0
- package/skills/sideroom-guidelines/references/languages/typescript.md +30 -0
- package/dist/pi-extension.js +0 -1175
- package/skills/sideroom-critic/SKILL.md +0 -32
- package/skills/sideroom-grilling/SKILL.md +0 -81
- package/skills/sideroom-spec/SKILL.md +0 -38
- package/skills/sideroom-transcribe-audio/SKILL.md +0 -38
- package/skills/sideroom-transcribe-audio/scripts/transcribe.py +0 -40
- package/src/assets/agents/sideroom-code-reviewer.md +0 -30
- package/src/assets/agents/sideroom-fixer.md +0 -37
- package/src/assets/agents/sideroom-implementer.md +0 -39
- package/src/assets/agents/sideroom-planner.md +0 -31
- package/src/assets/agents/sideroom-verifier.md +0 -32
- package/src/assets/artifacts/guidelines/java.md +0 -211
- package/src/assets/artifacts/guidelines/javascript.md +0 -235
- package/src/assets/artifacts/guidelines/php-laravel.md +0 -210
- package/src/assets/artifacts/guidelines/python.md +0 -227
- package/src/assets/artifacts/guidelines/typescript.md +0 -231
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import type {
|
|
3
|
+
ExtensionAPI,
|
|
4
|
+
ExtensionContext,
|
|
5
|
+
} from '@earendil-works/pi-coding-agent';
|
|
6
|
+
import {
|
|
7
|
+
isEditToolResult,
|
|
8
|
+
isWriteToolResult,
|
|
9
|
+
} from '@earendil-works/pi-coding-agent';
|
|
10
|
+
import {
|
|
11
|
+
type FileToolName,
|
|
12
|
+
MODIFIED_FILES_SNAPSHOT_TYPE,
|
|
13
|
+
MODIFIED_FILES_WIDGET_KEY,
|
|
14
|
+
type ModifiedFile,
|
|
15
|
+
readModifiedFilesSnapshot,
|
|
16
|
+
recordModifiedFile,
|
|
17
|
+
resolveModifiedPath,
|
|
18
|
+
} from './model.ts';
|
|
19
|
+
import { renderModifiedFilesWidget } from './ui.ts';
|
|
20
|
+
|
|
21
|
+
export interface ModifiedFilesStore {
|
|
22
|
+
files: readonly ModifiedFile[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type SessionBranch = ReturnType<
|
|
26
|
+
ExtensionContext['sessionManager']['getBranch']
|
|
27
|
+
>;
|
|
28
|
+
|
|
29
|
+
export function createModifiedFilesStore(
|
|
30
|
+
files: readonly ModifiedFile[] = [],
|
|
31
|
+
): ModifiedFilesStore {
|
|
32
|
+
return { files };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function reconstructModifiedFiles(
|
|
36
|
+
branch: SessionBranch,
|
|
37
|
+
): readonly ModifiedFile[] {
|
|
38
|
+
for (let index = branch.length - 1; index >= 0; index -= 1) {
|
|
39
|
+
const entry = branch[index];
|
|
40
|
+
if (
|
|
41
|
+
entry?.type !== 'custom' ||
|
|
42
|
+
entry.customType !== MODIFIED_FILES_SNAPSHOT_TYPE
|
|
43
|
+
) {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const files = readModifiedFilesSnapshot(entry.data);
|
|
47
|
+
if (files !== undefined) {
|
|
48
|
+
return files;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return reconstructFromToolResults(branch);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function restoreModifiedFiles(
|
|
55
|
+
store: ModifiedFilesStore,
|
|
56
|
+
ctx: Pick<ExtensionContext, 'sessionManager' | 'cwd'>,
|
|
57
|
+
): void {
|
|
58
|
+
store.files = reconstructModifiedFiles(ctx.sessionManager.getBranch()).map(
|
|
59
|
+
(file) => ({
|
|
60
|
+
...file,
|
|
61
|
+
path: resolve(ctx.cwd, file.path),
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function refreshModifiedFiles(
|
|
67
|
+
store: ModifiedFilesStore,
|
|
68
|
+
ctx: Pick<ExtensionContext, 'ui' | 'cwd'>,
|
|
69
|
+
): void {
|
|
70
|
+
if (store.files.length === 0) {
|
|
71
|
+
ctx.ui.setWidget(MODIFIED_FILES_WIDGET_KEY, undefined);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
ctx.ui.setWidget(MODIFIED_FILES_WIDGET_KEY, (_tui, theme) => {
|
|
75
|
+
const lines = renderModifiedFilesWidget(store.files, ctx.cwd, theme);
|
|
76
|
+
return {
|
|
77
|
+
render: () => lines ?? [],
|
|
78
|
+
invalidate() {},
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function restoreAndRefreshModifiedFiles(
|
|
84
|
+
store: ModifiedFilesStore,
|
|
85
|
+
ctx: Pick<ExtensionContext, 'sessionManager' | 'ui' | 'cwd'>,
|
|
86
|
+
): void {
|
|
87
|
+
restoreModifiedFiles(store, ctx);
|
|
88
|
+
refreshModifiedFiles(store, ctx);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function commitModifiedFiles(
|
|
92
|
+
store: ModifiedFilesStore,
|
|
93
|
+
pi: Pick<ExtensionAPI, 'appendEntry'>,
|
|
94
|
+
ctx: Pick<ExtensionContext, 'ui' | 'cwd'>,
|
|
95
|
+
files: readonly ModifiedFile[],
|
|
96
|
+
): void {
|
|
97
|
+
store.files = files;
|
|
98
|
+
pi.appendEntry(MODIFIED_FILES_SNAPSHOT_TYPE, { files });
|
|
99
|
+
refreshModifiedFiles(store, ctx);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function registerModifiedFilesEvents(
|
|
103
|
+
pi: ExtensionAPI,
|
|
104
|
+
store: ModifiedFilesStore,
|
|
105
|
+
): void {
|
|
106
|
+
pi.on('session_start', (_event, ctx) =>
|
|
107
|
+
restoreAndRefreshModifiedFiles(store, ctx),
|
|
108
|
+
);
|
|
109
|
+
pi.on('session_tree', (_event, ctx) =>
|
|
110
|
+
restoreAndRefreshModifiedFiles(store, ctx),
|
|
111
|
+
);
|
|
112
|
+
pi.on('session_compact', (_event, ctx) =>
|
|
113
|
+
restoreAndRefreshModifiedFiles(store, ctx),
|
|
114
|
+
);
|
|
115
|
+
pi.on('tool_result', (event, ctx) => {
|
|
116
|
+
if (
|
|
117
|
+
event.isError ||
|
|
118
|
+
(!isEditToolResult(event) && !isWriteToolResult(event))
|
|
119
|
+
) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const path = readToolPath(event.input);
|
|
123
|
+
if (path === undefined) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const resolvedPath = resolveModifiedPath(ctx.cwd, path);
|
|
127
|
+
if (resolvedPath === undefined) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const files = recordModifiedFile(store.files, resolvedPath, event.toolName);
|
|
131
|
+
commitModifiedFiles(store, pi, ctx, files);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function reconstructFromToolResults(
|
|
136
|
+
branch: SessionBranch,
|
|
137
|
+
): readonly ModifiedFile[] {
|
|
138
|
+
const calls = collectFileToolCalls(branch);
|
|
139
|
+
let files: readonly ModifiedFile[] = [];
|
|
140
|
+
|
|
141
|
+
for (const entry of branch) {
|
|
142
|
+
const file = fileFromToolResult(entry, calls);
|
|
143
|
+
if (file !== undefined) {
|
|
144
|
+
files = recordModifiedFile(files, file.path, file.toolName);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return files;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function collectFileToolCalls(
|
|
151
|
+
branch: SessionBranch,
|
|
152
|
+
): Map<string, ModifiedFile> {
|
|
153
|
+
const calls = new Map<string, ModifiedFile>();
|
|
154
|
+
for (const entry of branch) {
|
|
155
|
+
if (entry?.type !== 'message' || entry.message.role !== 'assistant') {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
for (const content of entry.message.content) {
|
|
159
|
+
if (content.type !== 'toolCall') {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const toolName = fileToolName(content.name);
|
|
163
|
+
const path = readToolPath(content.arguments);
|
|
164
|
+
if (toolName !== undefined && path !== undefined) {
|
|
165
|
+
calls.set(content.id, { path, toolName });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return calls;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function fileFromToolResult(
|
|
173
|
+
entry: SessionBranch[number] | undefined,
|
|
174
|
+
calls: ReadonlyMap<string, ModifiedFile>,
|
|
175
|
+
): ModifiedFile | undefined {
|
|
176
|
+
if (entry?.type !== 'message' || entry.message.role !== 'toolResult') {
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
const message = entry.message;
|
|
180
|
+
if (message.isError) {
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
const toolName = fileToolName(message.toolName);
|
|
184
|
+
const call = calls.get(message.toolCallId);
|
|
185
|
+
if (toolName === undefined || call?.toolName !== toolName) {
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
return call;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function fileToolName(value: string): FileToolName | undefined {
|
|
192
|
+
if (value === 'edit' || value === 'write') {
|
|
193
|
+
return value;
|
|
194
|
+
}
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function readToolPath(input: Record<string, unknown>): string | undefined {
|
|
199
|
+
return typeof input.path === 'string' ? input.path : undefined;
|
|
200
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { pathToFileURL } from 'node:url';
|
|
2
|
+
import type { ExtensionContext, Theme } from '@earendil-works/pi-coding-agent';
|
|
3
|
+
import {
|
|
4
|
+
type Component,
|
|
5
|
+
Key,
|
|
6
|
+
matchesKey,
|
|
7
|
+
type TUI,
|
|
8
|
+
truncateToWidth,
|
|
9
|
+
} from '@earendil-works/pi-tui';
|
|
10
|
+
import { displayModifiedPath, type ModifiedFile } from './model.ts';
|
|
11
|
+
|
|
12
|
+
const COMPACT_FILE_LIMIT = 5;
|
|
13
|
+
const EXTENDED_FILE_LIMIT = 12;
|
|
14
|
+
|
|
15
|
+
export function renderModifiedFilesWidget(
|
|
16
|
+
files: readonly ModifiedFile[],
|
|
17
|
+
cwd: string,
|
|
18
|
+
theme: Theme,
|
|
19
|
+
): string[] | undefined {
|
|
20
|
+
if (files.length === 0) {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const visibleFiles = files.slice(0, COMPACT_FILE_LIMIT);
|
|
25
|
+
const lines = [
|
|
26
|
+
'',
|
|
27
|
+
theme.fg(
|
|
28
|
+
'dim',
|
|
29
|
+
`── Edited files this session (${String(files.length)}) ──`,
|
|
30
|
+
),
|
|
31
|
+
...visibleFiles.map((file) => renderFileLine(file, cwd, theme)),
|
|
32
|
+
];
|
|
33
|
+
lines.push(theme.fg('dim', 'F8: view all'));
|
|
34
|
+
return lines;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ModifiedFilesOverlayController {
|
|
38
|
+
toggle(ctx: ExtensionContext): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createModifiedFilesOverlayController(
|
|
42
|
+
files: () => readonly ModifiedFile[],
|
|
43
|
+
clearFiles: (ctx: ExtensionContext) => void,
|
|
44
|
+
): ModifiedFilesOverlayController {
|
|
45
|
+
let close: (() => void) | undefined;
|
|
46
|
+
let opening = false;
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
toggle(ctx) {
|
|
50
|
+
if (close !== undefined) {
|
|
51
|
+
close();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (opening || ctx.mode !== 'tui') {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
opening = true;
|
|
59
|
+
void ctx.ui
|
|
60
|
+
.custom<void>(
|
|
61
|
+
(tui, theme, _keybindings, done) => {
|
|
62
|
+
close = done;
|
|
63
|
+
return new ModifiedFilesOverlay(
|
|
64
|
+
tui,
|
|
65
|
+
files,
|
|
66
|
+
ctx.cwd,
|
|
67
|
+
theme,
|
|
68
|
+
done,
|
|
69
|
+
() => clearFiles(ctx),
|
|
70
|
+
);
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
overlay: true,
|
|
74
|
+
overlayOptions: {
|
|
75
|
+
anchor: 'center',
|
|
76
|
+
width: '70%',
|
|
77
|
+
minWidth: 40,
|
|
78
|
+
maxHeight: '80%',
|
|
79
|
+
visible: (terminalWidth) => terminalWidth >= 60,
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
)
|
|
83
|
+
.finally(() => {
|
|
84
|
+
close = undefined;
|
|
85
|
+
opening = false;
|
|
86
|
+
});
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export class ModifiedFilesOverlay implements Component {
|
|
92
|
+
private offset = 0;
|
|
93
|
+
private readonly tui: TUI;
|
|
94
|
+
private readonly files: () => readonly ModifiedFile[];
|
|
95
|
+
private readonly cwd: string;
|
|
96
|
+
private readonly theme: Theme;
|
|
97
|
+
private readonly close: () => void;
|
|
98
|
+
private readonly clear: () => void;
|
|
99
|
+
|
|
100
|
+
constructor(
|
|
101
|
+
tui: TUI,
|
|
102
|
+
files: () => readonly ModifiedFile[],
|
|
103
|
+
cwd: string,
|
|
104
|
+
theme: Theme,
|
|
105
|
+
close: () => void,
|
|
106
|
+
clear: () => void,
|
|
107
|
+
) {
|
|
108
|
+
this.tui = tui;
|
|
109
|
+
this.files = files;
|
|
110
|
+
this.cwd = cwd;
|
|
111
|
+
this.theme = theme;
|
|
112
|
+
this.close = close;
|
|
113
|
+
this.clear = clear;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
handleInput(data: string): void {
|
|
117
|
+
const maxOffset = Math.max(0, this.files().length - EXTENDED_FILE_LIMIT);
|
|
118
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.f8)) {
|
|
119
|
+
this.close();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (matchesKey(data, 'r')) {
|
|
123
|
+
this.clear();
|
|
124
|
+
this.close();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (matchesKey(data, Key.up)) {
|
|
128
|
+
this.offset = Math.max(0, this.offset - 1);
|
|
129
|
+
this.tui.requestRender();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (matchesKey(data, Key.down)) {
|
|
133
|
+
this.offset = Math.min(maxOffset, this.offset + 1);
|
|
134
|
+
this.tui.requestRender();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (matchesKey(data, Key.pageUp)) {
|
|
138
|
+
this.offset = Math.max(0, this.offset - EXTENDED_FILE_LIMIT);
|
|
139
|
+
this.tui.requestRender();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (matchesKey(data, Key.pageDown)) {
|
|
143
|
+
this.offset = Math.min(maxOffset, this.offset + EXTENDED_FILE_LIMIT);
|
|
144
|
+
this.tui.requestRender();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
render(width: number): string[] {
|
|
149
|
+
const innerWidth = Math.max(1, width - 2);
|
|
150
|
+
const files = this.files();
|
|
151
|
+
const visibleFiles = files.slice(
|
|
152
|
+
this.offset,
|
|
153
|
+
this.offset + EXTENDED_FILE_LIMIT,
|
|
154
|
+
);
|
|
155
|
+
const border = (text: string) => this.theme.fg('border', text);
|
|
156
|
+
const line = (text: string) => truncateToWidth(text, innerWidth, '…', true);
|
|
157
|
+
const title = line(
|
|
158
|
+
` Edited files during this session (${String(files.length)}) `,
|
|
159
|
+
);
|
|
160
|
+
const lines = [
|
|
161
|
+
border(`╭${'─'.repeat(innerWidth)}╮`),
|
|
162
|
+
`${border('│')}${line(this.theme.fg('accent', this.theme.bold(title)))}${border('│')}`,
|
|
163
|
+
`${border('├')}${border('─'.repeat(innerWidth))}${border('┤')}`,
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
for (const file of visibleFiles) {
|
|
167
|
+
lines.push(
|
|
168
|
+
`${border('│')}${line(renderFileLine(file, this.cwd, this.theme))}${border('│')}`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
if (visibleFiles.length === 0) {
|
|
172
|
+
lines.push(
|
|
173
|
+
`${border('│')}${line(this.theme.fg('muted', ' No files edited yet.'))}${border('│')}`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const scrollHint = files.length > EXTENDED_FILE_LIMIT ? ' ↑↓ scroll ·' : '';
|
|
178
|
+
lines.push(`${border('├')}${border('─'.repeat(innerWidth))}${border('┤')}`);
|
|
179
|
+
lines.push(
|
|
180
|
+
`${border('│')}${line(this.theme.fg('dim', `${scrollHint} R clear · F8 or Esc close`))}${border('│')}`,
|
|
181
|
+
);
|
|
182
|
+
lines.push(border(`╰${'─'.repeat(innerWidth)}╯`));
|
|
183
|
+
return lines;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
invalidate(): void {}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function renderFileLine(file: ModifiedFile, cwd: string, theme: Theme): string {
|
|
190
|
+
const operation = file.toolName === 'write' ? 'write' : 'edit';
|
|
191
|
+
const path = displayModifiedPath(cwd, file.path);
|
|
192
|
+
return ` ${theme.fg('muted', `${operation} `)}${fileHyperlink(file.path, path)}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function fileHyperlink(path: string, label: string): string {
|
|
196
|
+
const target = pathToFileURL(path).href;
|
|
197
|
+
return `\u001B]8;;${target}\u0007${safeLabel(label)}\u001B]8;;\u0007`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function safeLabel(value: string): string {
|
|
201
|
+
return [...value]
|
|
202
|
+
.map((character) => {
|
|
203
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
204
|
+
return codePoint < 32 || codePoint === 127 ? '?' : character;
|
|
205
|
+
})
|
|
206
|
+
.join('');
|
|
207
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import {
|
|
3
|
+
applyTodoParams,
|
|
4
|
+
parseTodoParams,
|
|
5
|
+
TODO_ACTION,
|
|
6
|
+
type TodoAction,
|
|
7
|
+
type TodoItem,
|
|
8
|
+
type TodoParams,
|
|
9
|
+
UI_UNAVAILABLE,
|
|
10
|
+
} from './model.ts';
|
|
11
|
+
|
|
12
|
+
export interface TodoToolDetails {
|
|
13
|
+
readonly action?: TodoAction;
|
|
14
|
+
readonly items: readonly TodoItem[];
|
|
15
|
+
readonly error?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface TodoToolResult {
|
|
19
|
+
readonly content: [{ readonly type: 'text'; readonly text: string }];
|
|
20
|
+
readonly details: TodoToolDetails;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function executeTodo(
|
|
24
|
+
params: TodoParams,
|
|
25
|
+
ctx: Pick<ExtensionContext, 'mode'>,
|
|
26
|
+
currentItems: readonly TodoItem[],
|
|
27
|
+
): TodoToolResult {
|
|
28
|
+
const parsed = parseTodoParams(params);
|
|
29
|
+
if (!parsed.ok) {
|
|
30
|
+
return errorResult(parsed.message, currentItems);
|
|
31
|
+
}
|
|
32
|
+
if (parsed.params.action === TODO_ACTION.propose && ctx.mode !== 'tui') {
|
|
33
|
+
return errorResult(UI_UNAVAILABLE, currentItems, parsed.params.action);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const applied = applyTodoParams(currentItems, parsed.params);
|
|
37
|
+
if (!applied.ok) {
|
|
38
|
+
return errorResult(applied.message, currentItems, parsed.params.action);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const itemCount = applied.items.length;
|
|
42
|
+
return {
|
|
43
|
+
content: [
|
|
44
|
+
{ type: 'text', text: successMessage(parsed.params.action, itemCount) },
|
|
45
|
+
],
|
|
46
|
+
details: { action: parsed.params.action, items: applied.items },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function successMessage(action: TodoAction, itemCount: number): string {
|
|
51
|
+
if (action === TODO_ACTION.propose) {
|
|
52
|
+
if (itemCount === 0) {
|
|
53
|
+
return 'Board cleared';
|
|
54
|
+
}
|
|
55
|
+
return `Board proposed with ${String(itemCount)} item${itemCount === 1 ? '' : 's'}`;
|
|
56
|
+
}
|
|
57
|
+
return `Board updated (${String(itemCount)} item${itemCount === 1 ? '' : 's'})`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function errorResult(
|
|
61
|
+
error: string,
|
|
62
|
+
items: readonly TodoItem[],
|
|
63
|
+
action?: TodoToolDetails['action'],
|
|
64
|
+
): TodoToolResult {
|
|
65
|
+
return {
|
|
66
|
+
content: [{ type: 'text', text: error }],
|
|
67
|
+
details: { action, items, error },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
} from '@earendil-works/pi-coding-agent';
|
|
5
|
+
import {
|
|
6
|
+
shouldNudgePropose,
|
|
7
|
+
shouldWatchdogUpdate,
|
|
8
|
+
TOOL_NAME,
|
|
9
|
+
} from './model.ts';
|
|
10
|
+
import type { TodoStore } from './session.ts';
|
|
11
|
+
|
|
12
|
+
const NUDGE_TYPE = 'sideroom-todo-nudge';
|
|
13
|
+
const WATCHDOG_TYPE = 'sideroom-todo-watchdog';
|
|
14
|
+
const MUTATING_TOOL_NAMES = new Set(['write', 'edit', 'bash']);
|
|
15
|
+
|
|
16
|
+
interface TodoRunState {
|
|
17
|
+
proposeNudgedThisRun: boolean;
|
|
18
|
+
updateWatchdogThisTurn: boolean;
|
|
19
|
+
turnMutated: boolean;
|
|
20
|
+
turnCalledTodo: boolean;
|
|
21
|
+
steerFromUs: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function registerGuardEvents(
|
|
25
|
+
pi: ExtensionAPI,
|
|
26
|
+
store: TodoStore,
|
|
27
|
+
restore: (ctx: ExtensionContext) => void,
|
|
28
|
+
): void {
|
|
29
|
+
const runState = createTodoRunState();
|
|
30
|
+
|
|
31
|
+
pi.on('input', (event) => {
|
|
32
|
+
if (event.source === 'interactive' || event.source === 'rpc') {
|
|
33
|
+
runState.proposeNudgedThisRun = false;
|
|
34
|
+
runState.steerFromUs = false;
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
pi.on('turn_start', () => resetTurnState(runState));
|
|
39
|
+
|
|
40
|
+
pi.on('tool_execution_start', (event, ctx) => {
|
|
41
|
+
if (event.toolName === TOOL_NAME) {
|
|
42
|
+
runState.turnCalledTodo = true;
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (!MUTATING_TOOL_NAMES.has(event.toolName)) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
runState.turnMutated = true;
|
|
50
|
+
restore(ctx);
|
|
51
|
+
if (!shouldNudgePropose({ items: store.items, ...runState })) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
runState.proposeNudgedThisRun = true;
|
|
56
|
+
sendSteer(
|
|
57
|
+
pi,
|
|
58
|
+
runState,
|
|
59
|
+
NUDGE_TYPE,
|
|
60
|
+
'Use sideroom_todo propose now to create the live board before continuing implementation.',
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
pi.on('turn_end', (_event, ctx) => {
|
|
65
|
+
restore(ctx);
|
|
66
|
+
if (!shouldWatchdogUpdate({ items: store.items, ...runState })) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
runState.updateWatchdogThisTurn = true;
|
|
71
|
+
sendSteer(
|
|
72
|
+
pi,
|
|
73
|
+
runState,
|
|
74
|
+
WATCHDOG_TYPE,
|
|
75
|
+
'Use sideroom_todo update to reflect this work before continuing to the next item.',
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function createTodoRunState(): TodoRunState {
|
|
81
|
+
return {
|
|
82
|
+
proposeNudgedThisRun: false,
|
|
83
|
+
updateWatchdogThisTurn: false,
|
|
84
|
+
turnMutated: false,
|
|
85
|
+
turnCalledTodo: false,
|
|
86
|
+
steerFromUs: false,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function resetTurnState(runState: TodoRunState): void {
|
|
91
|
+
runState.turnMutated = false;
|
|
92
|
+
runState.turnCalledTodo = false;
|
|
93
|
+
runState.updateWatchdogThisTurn = false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function sendSteer(
|
|
97
|
+
pi: ExtensionAPI,
|
|
98
|
+
runState: TodoRunState,
|
|
99
|
+
customType: string,
|
|
100
|
+
content: string,
|
|
101
|
+
): void {
|
|
102
|
+
runState.steerFromUs = true;
|
|
103
|
+
pi.sendMessage(
|
|
104
|
+
{ customType, content, display: false },
|
|
105
|
+
{ triggerTurn: true, deliverAs: 'steer' },
|
|
106
|
+
);
|
|
107
|
+
}
|