@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.
Files changed (6) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +58 -0
  3. package/core.ts +879 -0
  4. package/index.ts +760 -0
  5. package/package.json +41 -0
  6. 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
+