@ogulcancelik/pi-spar 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +58 -0
- package/core.ts +879 -0
- package/index.ts +760 -0
- package/package.json +41 -0
- package/peek.ts +683 -0
package/index.ts
ADDED
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spar Extension - Agent-to-agent sparring
|
|
3
|
+
*
|
|
4
|
+
* Provides a `spar` tool for back-and-forth dialogue with peer AI models,
|
|
5
|
+
* plus /peek and /peek-all commands for viewing spar sessions.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI, Theme } from "@mariozechner/pi-coding-agent";
|
|
9
|
+
import { StringEnum } from "@mariozechner/pi-ai";
|
|
10
|
+
import { Type } from "@sinclair/typebox";
|
|
11
|
+
import { Text, Container, Spacer, SelectList, Input, matchesKey, type SelectItem, type SelectListTheme } from "@mariozechner/pi-tui";
|
|
12
|
+
import {
|
|
13
|
+
sendMessage,
|
|
14
|
+
listSessions,
|
|
15
|
+
getSession,
|
|
16
|
+
getSessionHistory,
|
|
17
|
+
getConfiguredModelsDescription,
|
|
18
|
+
loadSparConfig,
|
|
19
|
+
saveSparConfig,
|
|
20
|
+
type SparModelConfig,
|
|
21
|
+
DEFAULT_TIMEOUT,
|
|
22
|
+
} from "./core.js";
|
|
23
|
+
import {
|
|
24
|
+
SparPeekOverlay,
|
|
25
|
+
listPeekableSessions,
|
|
26
|
+
sessionExists,
|
|
27
|
+
isSessionActive,
|
|
28
|
+
findRecentSession,
|
|
29
|
+
findActiveSession,
|
|
30
|
+
formatAge,
|
|
31
|
+
} from "./peek.js";
|
|
32
|
+
|
|
33
|
+
/** Suggest a short alias for a provider/model combo */
|
|
34
|
+
function suggestAlias(provider: string, modelId: string): string {
|
|
35
|
+
const id = modelId.toLowerCase();
|
|
36
|
+
if (id.includes("opus")) return "opus";
|
|
37
|
+
if (id.includes("sonnet")) return "sonnet";
|
|
38
|
+
if (id.includes("haiku")) return "haiku";
|
|
39
|
+
if (id.includes("gpt-5")) return "gpt5";
|
|
40
|
+
if (id.includes("gpt-4")) return "gpt4";
|
|
41
|
+
if (id.includes("o3")) return "o3";
|
|
42
|
+
if (id.includes("o4")) return "o4";
|
|
43
|
+
if (id.includes("gemini")) return "gemini";
|
|
44
|
+
if (id.includes("deepseek")) return "deepseek";
|
|
45
|
+
// Fallback: first meaningful chunk of model id
|
|
46
|
+
return id.replace(/[^a-z0-9]/g, "-").replace(/-+/g, "-").slice(0, 12);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export default function (pi: ExtensionAPI) {
|
|
50
|
+
// ==========================================================================
|
|
51
|
+
// Tool Registration — called on load and after /spar-models changes config
|
|
52
|
+
// ==========================================================================
|
|
53
|
+
|
|
54
|
+
pi.registerTool({
|
|
55
|
+
name: "spar",
|
|
56
|
+
label: "Spar",
|
|
57
|
+
get description() {
|
|
58
|
+
return `Spar with another AI model — this is a **conversation**, not a lookup.
|
|
59
|
+
|
|
60
|
+
Use for debugging, design, architecture review, or challenging your own thinking.
|
|
61
|
+
Sessions persist, so follow up, push back, disagree. If they raise a point you hadn't
|
|
62
|
+
considered, dig into it. If you disagree with something, counter it. Don't just take the
|
|
63
|
+
first response and run — that's querying, not sparring.
|
|
64
|
+
|
|
65
|
+
**Peer limitations:** The peer can ONLY explore the codebase: read files, grep, find, ls.
|
|
66
|
+
No bash, no web access, no network, no file writes. Don't ask them to look things up online
|
|
67
|
+
or run commands — they can't. Give them file paths and let them dig through code.
|
|
68
|
+
|
|
69
|
+
**Model selection:** Prefer sparring with a different model family than yourself.
|
|
70
|
+
Different architectures have different biases and blindspots — that's the value.
|
|
71
|
+
|
|
72
|
+
**Configured models:**
|
|
73
|
+
${getConfiguredModelsDescription()}
|
|
74
|
+
|
|
75
|
+
**Actions:**
|
|
76
|
+
- \`send\` - Send a message to a spar session (creates session if needed)
|
|
77
|
+
- \`list\` - List existing spar sessions
|
|
78
|
+
- \`history\` - View past exchanges from a session (default: last 5)
|
|
79
|
+
|
|
80
|
+
**Tips:**
|
|
81
|
+
- Give file paths and pointers, not full content — let them explore
|
|
82
|
+
- Ask for ranked hypotheses, not just "what do you think"
|
|
83
|
+
- Request critique: "What's the strongest case against my approach?"
|
|
84
|
+
- State your current position so they have something to push against
|
|
85
|
+
|
|
86
|
+
**Multi-party facilitation:** For big design questions, create multiple specialized sessions
|
|
87
|
+
with different models/roles. Name them \`{topic}-{role}\` (e.g., \`auth-design\`, \`auth-security\`).
|
|
88
|
+
Give each a focused persona in the first message. Then facilitate: forward interesting points
|
|
89
|
+
between them, let them argue through you, decide who to ask next based on the conversation.
|
|
90
|
+
You're the switchboard operator — each expert is in their own room, you relay selectively.
|
|
91
|
+
|
|
92
|
+
**Example:**
|
|
93
|
+
\`\`\`
|
|
94
|
+
spar({
|
|
95
|
+
action: "send",
|
|
96
|
+
session: "flow-field-debug",
|
|
97
|
+
model: "opus",
|
|
98
|
+
message: "I'm debugging flow field pathfinding. Enemies walk away from player instead of toward. Check scripts/HordeManagerCS.cs line 358-430 for the BFS implementation. I think the gradient is inverted in the BFS neighbor loop — what do you see?"
|
|
99
|
+
})
|
|
100
|
+
// ... read their response, then follow up:
|
|
101
|
+
spar({
|
|
102
|
+
action: "send",
|
|
103
|
+
session: "flow-field-debug",
|
|
104
|
+
message: "Interesting point about the cost function, but I don't think that's it because the distances look correct in the debug output. What about the direction vector calculation at line 415?"
|
|
105
|
+
})
|
|
106
|
+
\`\`\``;
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
parameters: Type.Object({
|
|
110
|
+
action: StringEnum(["send", "list", "history"] as const, {
|
|
111
|
+
description: "Action to perform",
|
|
112
|
+
}),
|
|
113
|
+
session: Type.Optional(Type.String({
|
|
114
|
+
description: "Session name (required for send/history). Use descriptive names like 'flow-field-debug'.",
|
|
115
|
+
})),
|
|
116
|
+
message: Type.Optional(Type.String({
|
|
117
|
+
description: "Message to send (required for send)",
|
|
118
|
+
})),
|
|
119
|
+
model: Type.Optional(Type.String({
|
|
120
|
+
description: "Model alias (from /spar-models) or provider:model. Required for first message in a session.",
|
|
121
|
+
})),
|
|
122
|
+
thinking: Type.Optional(StringEnum(["off", "minimal", "low", "medium", "high"] as const, {
|
|
123
|
+
description: "Thinking level (default: high)",
|
|
124
|
+
})),
|
|
125
|
+
timeout: Type.Optional(Type.Number({
|
|
126
|
+
description: `Timeout in ms (default: ${DEFAULT_TIMEOUT / 60000} min). Resets on activity.`,
|
|
127
|
+
})),
|
|
128
|
+
count: Type.Optional(Type.Number({
|
|
129
|
+
description: "Number of exchanges to show (for history action, default: 5)",
|
|
130
|
+
})),
|
|
131
|
+
}),
|
|
132
|
+
|
|
133
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
134
|
+
const { action, session, message, model, thinking, timeout, count } = params as {
|
|
135
|
+
action: "send" | "list" | "history";
|
|
136
|
+
session?: string;
|
|
137
|
+
message?: string;
|
|
138
|
+
model?: string;
|
|
139
|
+
thinking?: string;
|
|
140
|
+
timeout?: number;
|
|
141
|
+
count?: number;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// Handle list action
|
|
145
|
+
if (action === "list") {
|
|
146
|
+
const sessions = listSessions();
|
|
147
|
+
|
|
148
|
+
if (sessions.length === 0) {
|
|
149
|
+
return {
|
|
150
|
+
content: [{ type: "text", text: "No spar sessions found." }],
|
|
151
|
+
details: { sessions: [] },
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const lines = ["Sessions:", ""];
|
|
156
|
+
for (const s of sessions) {
|
|
157
|
+
const age = formatAge(s.lastActivity);
|
|
158
|
+
const modelDisplay = s.modelAlias || s.model.split(":").pop() || s.model;
|
|
159
|
+
const status = s.status === "failed" ? " ❌" : "";
|
|
160
|
+
lines.push(` ${s.name.padEnd(24)} ${modelDisplay.padEnd(8)} ${String(s.messageCount).padStart(3)} msgs ${age}${status}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
165
|
+
details: { sessions },
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Handle history action
|
|
170
|
+
if (action === "history") {
|
|
171
|
+
if (!session) {
|
|
172
|
+
return {
|
|
173
|
+
content: [{ type: "text", text: "Error: session name is required for history action." }],
|
|
174
|
+
details: { error: "missing_session" },
|
|
175
|
+
isError: true,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const info = getSession(session);
|
|
180
|
+
if (!info) {
|
|
181
|
+
return {
|
|
182
|
+
content: [{ type: "text", text: `Error: session "${session}" not found.` }],
|
|
183
|
+
details: { error: "session_not_found" },
|
|
184
|
+
isError: true,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const exchanges = getSessionHistory(session, count ?? 5);
|
|
189
|
+
|
|
190
|
+
if (exchanges.length === 0) {
|
|
191
|
+
return {
|
|
192
|
+
content: [{ type: "text", text: `No exchanges in session "${session}" yet.` }],
|
|
193
|
+
details: { session, exchanges: [] },
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const lines: string[] = [];
|
|
198
|
+
lines.push(`Session: ${session} (${info.modelId})`);
|
|
199
|
+
lines.push(`Showing last ${exchanges.length} exchange(s):\n`);
|
|
200
|
+
|
|
201
|
+
for (let i = 0; i < exchanges.length; i++) {
|
|
202
|
+
const ex = exchanges[i];
|
|
203
|
+
lines.push(`--- Exchange ${i + 1} ---`);
|
|
204
|
+
lines.push(`You: ${ex.user}`);
|
|
205
|
+
lines.push(`${info.modelId}: ${ex.assistant}`);
|
|
206
|
+
lines.push("");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
211
|
+
details: { session, exchanges, modelId: info.modelId },
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Handle send action
|
|
216
|
+
if (action === "send") {
|
|
217
|
+
// Validate required params
|
|
218
|
+
if (!session) {
|
|
219
|
+
return {
|
|
220
|
+
content: [{ type: "text", text: "Error: session name is required for send action." }],
|
|
221
|
+
details: { error: "missing_session" },
|
|
222
|
+
isError: true,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (!message) {
|
|
227
|
+
return {
|
|
228
|
+
content: [{ type: "text", text: "Error: message is required for send action." }],
|
|
229
|
+
details: { error: "missing_message" },
|
|
230
|
+
isError: true,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Check if session exists and if model is required
|
|
235
|
+
const existingSession = getSession(session);
|
|
236
|
+
if (!existingSession && !model) {
|
|
237
|
+
return {
|
|
238
|
+
content: [{ type: "text", text: `Error: session "${session}" doesn't exist. Provide a model to create it.` }],
|
|
239
|
+
details: { error: "session_not_found" },
|
|
240
|
+
isError: true,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Setup progress tracking
|
|
245
|
+
const modelName = model || existingSession?.modelId || "agent";
|
|
246
|
+
const startTime = Date.now();
|
|
247
|
+
|
|
248
|
+
// Stream initial progress
|
|
249
|
+
onUpdate?.({
|
|
250
|
+
content: [{ type: "text", text: `Consulting ${modelName}...` }],
|
|
251
|
+
details: { status: "starting", progress: { status: "thinking", elapsed: 0, model: modelName } },
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
const result = await sendMessage(session, message, {
|
|
256
|
+
model,
|
|
257
|
+
thinking: thinking ?? "high",
|
|
258
|
+
timeout: timeout ?? DEFAULT_TIMEOUT,
|
|
259
|
+
signal,
|
|
260
|
+
onProgress: (progress) => {
|
|
261
|
+
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
|
262
|
+
onUpdate?.({
|
|
263
|
+
content: [{ type: "text", text: `${progress.status}...` }],
|
|
264
|
+
details: {
|
|
265
|
+
progress: {
|
|
266
|
+
status: progress.status,
|
|
267
|
+
elapsed,
|
|
268
|
+
toolName: progress.toolName,
|
|
269
|
+
model: modelName,
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// Format usage info
|
|
277
|
+
let usageText = "";
|
|
278
|
+
if (result.usage) {
|
|
279
|
+
usageText = `\n\n---\n_${result.usage.input} in / ${result.usage.output} out, $${result.usage.cost.toFixed(4)}_`;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
content: [{ type: "text", text: result.response + usageText }],
|
|
284
|
+
details: {
|
|
285
|
+
session,
|
|
286
|
+
message, // Store original message for expanded view
|
|
287
|
+
model: model || existingSession?.model,
|
|
288
|
+
usage: result.usage,
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
} catch (err: any) {
|
|
292
|
+
return {
|
|
293
|
+
content: [{ type: "text", text: `Spar failed: ${err.message}` }],
|
|
294
|
+
details: { error: err.message, session },
|
|
295
|
+
isError: true,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
content: [{ type: "text", text: `Unknown action: ${action}` }],
|
|
302
|
+
details: { error: "unknown_action" },
|
|
303
|
+
isError: true,
|
|
304
|
+
};
|
|
305
|
+
},
|
|
306
|
+
|
|
307
|
+
// Custom rendering for cleaner display
|
|
308
|
+
renderCall(args: any, theme: Theme) {
|
|
309
|
+
const { action, session, model, count } = args;
|
|
310
|
+
|
|
311
|
+
if (action === "list") {
|
|
312
|
+
return new Text(theme.fg("toolTitle", theme.bold("spar ")) + theme.fg("muted", "list"), 0, 0);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (action === "history") {
|
|
316
|
+
let text = theme.fg("toolTitle", theme.bold("spar "));
|
|
317
|
+
text += theme.fg("accent", session || "?");
|
|
318
|
+
text += theme.fg("dim", ` (history${count ? `, last ${count}` : ""})`);
|
|
319
|
+
return new Text(text, 0, 0);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// For send action, show session + model (question shown in expanded result)
|
|
323
|
+
let text = theme.fg("toolTitle", theme.bold("spar "));
|
|
324
|
+
text += theme.fg("accent", session || "?");
|
|
325
|
+
if (model) {
|
|
326
|
+
text += theme.fg("dim", ` (${model})`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return new Text(text, 0, 0);
|
|
330
|
+
},
|
|
331
|
+
|
|
332
|
+
renderResult(result: any, options: { expanded: boolean; isPartial: boolean }, theme: Theme) {
|
|
333
|
+
const { expanded, isPartial } = options;
|
|
334
|
+
const details = result.details || {};
|
|
335
|
+
|
|
336
|
+
// Handle streaming/partial state
|
|
337
|
+
if (isPartial) {
|
|
338
|
+
const progress = details.progress || {};
|
|
339
|
+
const status = progress.status || details.status || "working";
|
|
340
|
+
const elapsed = progress.elapsed || 0;
|
|
341
|
+
const toolName = progress.toolName;
|
|
342
|
+
|
|
343
|
+
let statusText = status;
|
|
344
|
+
if (status === "tool" && toolName) {
|
|
345
|
+
statusText = `→ ${toolName}`;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return new Text(theme.fg("warning", `● ${statusText}`) + theme.fg("dim", ` (${elapsed}s)`), 0, 0);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Handle errors
|
|
352
|
+
if (result.isError || details.error) {
|
|
353
|
+
return new Text(theme.fg("error", `✗ ${details.error || "Failed"}`), 0, 0);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Handle list action
|
|
357
|
+
if (details.sessions !== undefined) {
|
|
358
|
+
const count = details.sessions.length;
|
|
359
|
+
if (count === 0) {
|
|
360
|
+
return new Text(theme.fg("dim", "No sessions"), 0, 0);
|
|
361
|
+
}
|
|
362
|
+
if (!expanded) {
|
|
363
|
+
return new Text(theme.fg("success", `✓ ${count} session${count > 1 ? "s" : ""}`), 0, 0);
|
|
364
|
+
}
|
|
365
|
+
// Expanded: show full list
|
|
366
|
+
const text = result.content?.[0]?.text || "";
|
|
367
|
+
return new Text(text, 0, 0);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Handle history action
|
|
371
|
+
if (details.exchanges !== undefined) {
|
|
372
|
+
const exchanges = details.exchanges as Array<{ user: string; assistant: string }>;
|
|
373
|
+
const count = exchanges.length;
|
|
374
|
+
const modelId = details.modelId || "assistant";
|
|
375
|
+
|
|
376
|
+
if (count === 0) {
|
|
377
|
+
return new Text(theme.fg("dim", "No exchanges yet"), 0, 0);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (!expanded) {
|
|
381
|
+
// Collapsed: show summary like read tool
|
|
382
|
+
return new Text(
|
|
383
|
+
theme.fg("success", `✓ ${count} exchange${count > 1 ? "s" : ""}`) +
|
|
384
|
+
theme.fg("dim", " (ctrl+o to expand)"),
|
|
385
|
+
0, 0
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Expanded: show full history from details (not truncated content)
|
|
390
|
+
const lines: string[] = [];
|
|
391
|
+
lines.push(theme.fg("accent", `Session: ${details.session}`) + theme.fg("dim", ` (${modelId})`));
|
|
392
|
+
lines.push("");
|
|
393
|
+
|
|
394
|
+
for (let i = 0; i < exchanges.length; i++) {
|
|
395
|
+
const ex = exchanges[i];
|
|
396
|
+
lines.push(theme.fg("muted", `--- Exchange ${i + 1} ---`));
|
|
397
|
+
lines.push(theme.fg("dim", "You: ") + ex.user);
|
|
398
|
+
lines.push(theme.fg("dim", `${modelId}: `) + ex.assistant);
|
|
399
|
+
lines.push("");
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Handle send action - show response
|
|
406
|
+
const responseText = result.content?.[0]?.text || "";
|
|
407
|
+
const usage = details.usage;
|
|
408
|
+
|
|
409
|
+
if (!expanded) {
|
|
410
|
+
// Collapsed: just show success + cost (response hidden until expanded)
|
|
411
|
+
let text = theme.fg("success", "✓");
|
|
412
|
+
if (usage) {
|
|
413
|
+
text += theme.fg("dim", ` [${usage.input} in / ${usage.output} out, $${usage.cost.toFixed(4)}]`);
|
|
414
|
+
}
|
|
415
|
+
return new Text(text, 0, 0);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Expanded: show question + full response
|
|
419
|
+
let text = "";
|
|
420
|
+
|
|
421
|
+
// Show the original question
|
|
422
|
+
if (details.message) {
|
|
423
|
+
text += theme.fg("muted", "Q: ") + details.message + "\n\n";
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Show response
|
|
427
|
+
let response = responseText;
|
|
428
|
+
// Remove the usage line we added (it's in details now)
|
|
429
|
+
response = response.replace(/\n\n---\n_.*_$/, "");
|
|
430
|
+
text += theme.fg("muted", "A: ") + response;
|
|
431
|
+
|
|
432
|
+
if (usage) {
|
|
433
|
+
text += "\n\n" + theme.fg("dim", `[${usage.input} in / ${usage.output} out, $${usage.cost.toFixed(4)}]`);
|
|
434
|
+
}
|
|
435
|
+
return new Text(text, 0, 0);
|
|
436
|
+
},
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
// ==========================================================================
|
|
440
|
+
// Command: /spar-models — configure available sparring models
|
|
441
|
+
// ==========================================================================
|
|
442
|
+
|
|
443
|
+
pi.registerCommand("spar-models", {
|
|
444
|
+
description: "Configure models available for sparring",
|
|
445
|
+
handler: async (_args, ctx) => {
|
|
446
|
+
if (!ctx.hasUI) {
|
|
447
|
+
ctx.ui.notify("Interactive mode required for /spar-models", "warning");
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const available = ctx.modelRegistry.getAvailable();
|
|
452
|
+
if (available.length === 0) {
|
|
453
|
+
ctx.ui.notify("No models available. Configure API keys first.", "warning");
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const config = loadSparConfig();
|
|
458
|
+
const configuredAliases = new Map(config.models.map(m => [`${m.provider}/${m.id}`, m.alias]));
|
|
459
|
+
|
|
460
|
+
// Build items: configured models marked, unconfigured available
|
|
461
|
+
const items: SelectItem[] = available.map(m => {
|
|
462
|
+
const key = `${m.provider}/${m.id}`;
|
|
463
|
+
const alias = configuredAliases.get(key);
|
|
464
|
+
return {
|
|
465
|
+
value: key,
|
|
466
|
+
label: alias ? `${key} (${alias})` : key,
|
|
467
|
+
description: alias ? "configured" : undefined,
|
|
468
|
+
};
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
const result = await ctx.ui.custom<{ action: "add" | "remove"; model: string } | undefined>(
|
|
472
|
+
(tui, theme, _kb, done) => {
|
|
473
|
+
const selectTheme: SelectListTheme = {
|
|
474
|
+
selectedPrefix: (t: string) => theme.fg("accent", t),
|
|
475
|
+
selectedText: (t: string) => theme.fg("accent", t),
|
|
476
|
+
description: (t: string) => theme.fg("success", t),
|
|
477
|
+
scrollInfo: (t: string) => theme.fg("dim", t),
|
|
478
|
+
noMatch: (t: string) => theme.fg("warning", t),
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
const container = new Container();
|
|
482
|
+
container.addChild(new Text(
|
|
483
|
+
theme.bold(theme.fg("accent", "Spar Models")) +
|
|
484
|
+
theme.fg("muted", " (enter to add/edit alias, backspace to remove)"),
|
|
485
|
+
0, 0,
|
|
486
|
+
));
|
|
487
|
+
container.addChild(new Spacer(1));
|
|
488
|
+
|
|
489
|
+
// Show current config
|
|
490
|
+
if (config.models.length > 0) {
|
|
491
|
+
const configText = config.models
|
|
492
|
+
.map(m => theme.fg("dim", " ") + theme.fg("accent", m.alias) + theme.fg("dim", ` → ${m.provider}/${m.id}`))
|
|
493
|
+
.join("\n");
|
|
494
|
+
container.addChild(new Text(theme.fg("muted", " Current:") + "\n" + configText, 0, 0));
|
|
495
|
+
container.addChild(new Spacer(1));
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const input = new Input();
|
|
499
|
+
container.addChild(input);
|
|
500
|
+
container.addChild(new Spacer(1));
|
|
501
|
+
|
|
502
|
+
let list = buildList(items);
|
|
503
|
+
container.addChild(list);
|
|
504
|
+
container.addChild(new Spacer(1));
|
|
505
|
+
container.addChild(new Text(
|
|
506
|
+
theme.fg("dim", " ↑/↓ navigate · type to filter · enter add/edit · backspace remove · esc close"),
|
|
507
|
+
0, 0,
|
|
508
|
+
));
|
|
509
|
+
|
|
510
|
+
function buildList(filtered: SelectItem[]): SelectList {
|
|
511
|
+
const sl = new SelectList(filtered, Math.min(filtered.length, 12), selectTheme);
|
|
512
|
+
sl.onSelect = (item) => done({ action: "add", model: item.value });
|
|
513
|
+
sl.onCancel = () => done(undefined);
|
|
514
|
+
return sl;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function rebuildList() {
|
|
518
|
+
const query = input.getValue();
|
|
519
|
+
const filtered = query
|
|
520
|
+
? items.filter(i => i.label.toLowerCase().includes(query.toLowerCase()))
|
|
521
|
+
: items;
|
|
522
|
+
container.removeChild(list);
|
|
523
|
+
list = buildList(filtered);
|
|
524
|
+
// Insert after the spacer that follows input
|
|
525
|
+
const children = container.children;
|
|
526
|
+
const inputIdx = children.indexOf(input);
|
|
527
|
+
children.splice(inputIdx + 2, 0, list);
|
|
528
|
+
tui.requestRender();
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
input.onSubmit = () => {
|
|
532
|
+
const selected = list.getSelectedItem();
|
|
533
|
+
if (selected) done({ action: "add", model: selected.value });
|
|
534
|
+
};
|
|
535
|
+
input.onEscape = () => done(undefined);
|
|
536
|
+
|
|
537
|
+
container.handleInput = (data: string) => {
|
|
538
|
+
if (data === "\x1b[A" || data === "\x1b[B" || data === "\r" || data === "\n") {
|
|
539
|
+
list.handleInput(data);
|
|
540
|
+
} else if (data === "\x1b" || data === "\x03") {
|
|
541
|
+
done(undefined);
|
|
542
|
+
} else if (data === "\x7f" || data === "\b") {
|
|
543
|
+
// Backspace: if input empty, remove selected model's config
|
|
544
|
+
if (input.getValue() === "") {
|
|
545
|
+
const selected = list.getSelectedItem();
|
|
546
|
+
if (selected && configuredAliases.has(selected.value)) {
|
|
547
|
+
done({ action: "remove", model: selected.value });
|
|
548
|
+
}
|
|
549
|
+
} else {
|
|
550
|
+
input.handleInput(data);
|
|
551
|
+
rebuildList();
|
|
552
|
+
}
|
|
553
|
+
} else {
|
|
554
|
+
input.handleInput(data);
|
|
555
|
+
rebuildList();
|
|
556
|
+
}
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
return container;
|
|
560
|
+
},
|
|
561
|
+
);
|
|
562
|
+
|
|
563
|
+
if (!result) return;
|
|
564
|
+
|
|
565
|
+
if (result.action === "remove") {
|
|
566
|
+
const [provider, ...idParts] = result.model.split("/");
|
|
567
|
+
const id = idParts.join("/");
|
|
568
|
+
config.models = config.models.filter(m => !(m.provider === provider && m.id === id));
|
|
569
|
+
saveSparConfig(config);
|
|
570
|
+
ctx.ui.notify(`Removed ${result.model} from spar models. Restart pi to update.`, "info");
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// action === "add" — prompt for alias
|
|
575
|
+
const [provider, ...idParts] = result.model.split("/");
|
|
576
|
+
const id = idParts.join("/");
|
|
577
|
+
|
|
578
|
+
// Check if already configured
|
|
579
|
+
const existing = config.models.find(m => m.provider === provider && m.id === id);
|
|
580
|
+
const defaultAlias = existing?.alias || suggestAlias(provider, id);
|
|
581
|
+
|
|
582
|
+
const alias = await ctx.ui.input(
|
|
583
|
+
`Alias for ${result.model}:`,
|
|
584
|
+
defaultAlias,
|
|
585
|
+
);
|
|
586
|
+
|
|
587
|
+
if (!alias?.trim()) return;
|
|
588
|
+
|
|
589
|
+
const cleanAlias = alias.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "");
|
|
590
|
+
if (!cleanAlias) {
|
|
591
|
+
ctx.ui.notify("Invalid alias — use letters, numbers, hyphens, underscores", "error");
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Remove any existing entry for this model or alias
|
|
596
|
+
config.models = config.models.filter(m =>
|
|
597
|
+
!(m.provider === provider && m.id === id) && m.alias !== cleanAlias
|
|
598
|
+
);
|
|
599
|
+
config.models.push({ alias: cleanAlias, provider, id });
|
|
600
|
+
saveSparConfig(config);
|
|
601
|
+
ctx.ui.notify(`Configured ${cleanAlias} → ${result.model}. Restart pi to update.`, "info");
|
|
602
|
+
},
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
// ==========================================================================
|
|
606
|
+
// Commands: /peek and /peek-all
|
|
607
|
+
// ==========================================================================
|
|
608
|
+
|
|
609
|
+
pi.registerCommand("peek", {
|
|
610
|
+
description: "Peek at a spar session. Usage: /peek [session-name]",
|
|
611
|
+
getArgumentCompletions: (prefix: string) => {
|
|
612
|
+
const sessions = listPeekableSessions();
|
|
613
|
+
const items = sessions.map((s) => ({
|
|
614
|
+
value: s.name,
|
|
615
|
+
label: s.active ? `${s.name} (active)` : s.name,
|
|
616
|
+
}));
|
|
617
|
+
return prefix ? items.filter((i) => i.value.startsWith(prefix)) : items;
|
|
618
|
+
},
|
|
619
|
+
handler: async (args, ctx) => {
|
|
620
|
+
let sessionId = args?.trim();
|
|
621
|
+
|
|
622
|
+
// If no session specified, find the last spar tool call
|
|
623
|
+
if (!sessionId) {
|
|
624
|
+
sessionId = findRecentSession(ctx.sessionManager) ?? undefined;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Still no session? Check for active socket
|
|
628
|
+
if (!sessionId) {
|
|
629
|
+
sessionId = findActiveSession() ?? undefined;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
if (!sessionId) {
|
|
633
|
+
const available = listPeekableSessions();
|
|
634
|
+
if (available.length > 0) {
|
|
635
|
+
ctx.ui.notify(`No recent spar. Try: /peek ${available[0].name}`, "info");
|
|
636
|
+
} else {
|
|
637
|
+
ctx.ui.notify("No spar sessions found", "info");
|
|
638
|
+
}
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
if (!sessionExists(sessionId) && !isSessionActive(sessionId)) {
|
|
643
|
+
ctx.ui.notify(`Session "${sessionId}" not found`, "error");
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
await ctx.ui.custom<void>(
|
|
648
|
+
(tui, theme, _kb, done) => new SparPeekOverlay(tui, theme, sessionId!, done),
|
|
649
|
+
{
|
|
650
|
+
overlay: true,
|
|
651
|
+
overlayOptions: {
|
|
652
|
+
anchor: "right-center",
|
|
653
|
+
width: "45%",
|
|
654
|
+
minWidth: 50,
|
|
655
|
+
maxHeight: 60,
|
|
656
|
+
margin: { right: 2, top: 2, bottom: 2 },
|
|
657
|
+
},
|
|
658
|
+
}
|
|
659
|
+
);
|
|
660
|
+
},
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
pi.registerCommand("peek-all", {
|
|
664
|
+
description: "List all spar sessions and pick one to peek",
|
|
665
|
+
handler: async (_args, ctx) => {
|
|
666
|
+
const sessions = listPeekableSessions();
|
|
667
|
+
|
|
668
|
+
if (sessions.length === 0) {
|
|
669
|
+
ctx.ui.notify("No spar sessions found", "info");
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Use custom component with SelectList for proper filtering/pagination
|
|
674
|
+
const selected = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
|
|
675
|
+
const items: SelectItem[] = sessions.map((s) => {
|
|
676
|
+
const status = s.active ? "●" : "○";
|
|
677
|
+
const model = s.model ? `[${s.model}]` : "";
|
|
678
|
+
const age = s.lastActivity ? formatAge(s.lastActivity) : "";
|
|
679
|
+
const msgs = s.messageCount > 0 ? `${s.messageCount}msg` : "";
|
|
680
|
+
// Format: "● session-name [gpt5] 3msg 2h"
|
|
681
|
+
const desc = [model, msgs, age].filter(Boolean).join(" ");
|
|
682
|
+
return {
|
|
683
|
+
value: s.name,
|
|
684
|
+
label: `${status} ${s.name}`,
|
|
685
|
+
description: desc,
|
|
686
|
+
};
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
const selectList = new SelectList(items, 15, {
|
|
690
|
+
selectedPrefix: (t: string) => theme.bg("selectedBg", theme.fg("accent", t)),
|
|
691
|
+
selectedText: (t: string) => theme.bg("selectedBg", t),
|
|
692
|
+
description: (t: string) => theme.fg("muted", t),
|
|
693
|
+
scrollInfo: (t: string) => theme.fg("dim", t),
|
|
694
|
+
noMatch: (t: string) => theme.fg("warning", t),
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
selectList.onSelect = (item) => done(item.value);
|
|
698
|
+
selectList.onCancel = () => done(null);
|
|
699
|
+
|
|
700
|
+
// Wrapper with filter display
|
|
701
|
+
let filter = "";
|
|
702
|
+
const filterText = new Text("", 0, 0);
|
|
703
|
+
|
|
704
|
+
const updateFilterDisplay = () => {
|
|
705
|
+
if (filter) {
|
|
706
|
+
filterText.text = theme.fg("dim", "Filter: ") + theme.fg("accent", filter) + theme.fg("dim", "▏");
|
|
707
|
+
} else {
|
|
708
|
+
filterText.text = theme.fg("dim", "Type to filter...");
|
|
709
|
+
}
|
|
710
|
+
};
|
|
711
|
+
updateFilterDisplay();
|
|
712
|
+
|
|
713
|
+
const container = new Container();
|
|
714
|
+
container.addChild(new Text(theme.fg("accent", "Spar Sessions") + theme.fg("dim", " (↑↓ navigate, enter select, esc cancel)"), 0, 1));
|
|
715
|
+
container.addChild(filterText);
|
|
716
|
+
container.addChild(selectList);
|
|
717
|
+
|
|
718
|
+
(container as any).handleInput = (data: string) => {
|
|
719
|
+
if (matchesKey(data, "escape")) {
|
|
720
|
+
done(null);
|
|
721
|
+
} else if (matchesKey(data, "return")) {
|
|
722
|
+
selectList.handleInput(data);
|
|
723
|
+
} else if (matchesKey(data, "up") || matchesKey(data, "down")) {
|
|
724
|
+
selectList.handleInput(data);
|
|
725
|
+
tui.requestRender();
|
|
726
|
+
} else if (matchesKey(data, "backspace")) {
|
|
727
|
+
filter = filter.slice(0, -1);
|
|
728
|
+
selectList.setFilter(filter);
|
|
729
|
+
updateFilterDisplay();
|
|
730
|
+
tui.requestRender();
|
|
731
|
+
} else if (data.length === 1 && data >= " ") {
|
|
732
|
+
filter += data;
|
|
733
|
+
selectList.setFilter(filter);
|
|
734
|
+
updateFilterDisplay();
|
|
735
|
+
tui.requestRender();
|
|
736
|
+
}
|
|
737
|
+
};
|
|
738
|
+
|
|
739
|
+
return container;
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
if (!selected) return;
|
|
743
|
+
|
|
744
|
+
await ctx.ui.custom<void>(
|
|
745
|
+
(tui, theme, _kb, done) => new SparPeekOverlay(tui, theme, selected, done),
|
|
746
|
+
{
|
|
747
|
+
overlay: true,
|
|
748
|
+
overlayOptions: {
|
|
749
|
+
anchor: "right-center",
|
|
750
|
+
width: "45%",
|
|
751
|
+
minWidth: 50,
|
|
752
|
+
maxHeight: 60,
|
|
753
|
+
margin: { right: 2, top: 2, bottom: 2 },
|
|
754
|
+
},
|
|
755
|
+
}
|
|
756
|
+
);
|
|
757
|
+
},
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
|