@ogulcancelik/pi-spar 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,7 +13,7 @@ pi install npm:@ogulcancelik/pi-spar
13
13
  Configure which models are available for sparring:
14
14
 
15
15
  ```
16
- /spar-models
16
+ /spmodels
17
17
  ```
18
18
 
19
19
  This shows all models from your pi configuration and lets you assign short aliases (e.g., `opus`, `gpt`).
@@ -29,7 +29,7 @@ The extension provides a `spar` tool the agent can use, plus commands for viewin
29
29
  The agent uses this automatically when you ask it to consult another model:
30
30
 
31
31
  ```
32
- "spar with gpt5 about whether this architecture makes sense"
32
+ "spar with gpt about whether this architecture makes sense"
33
33
  "ask opus to review the error handling in src/auth.ts"
34
34
  ```
35
35
 
@@ -39,13 +39,15 @@ Sessions persist — follow up, push back, disagree. The peer can read files, gr
39
39
 
40
40
  | Command | Description |
41
41
  |---------|-------------|
42
- | `/spar-models` | Configure available sparring models |
43
- | `/peek [session]` | Watch a spar session in a floating overlay |
44
- | `/peek-all` | List all sessions, pick one to peek |
42
+ | `/spmodels` | Configure available sparring models |
43
+ | `/spar [session]` | Watch a spar session in a floating overlay |
44
+ | `/spview` | Browse all sessions — view, peek, or delete |
45
45
 
46
- ### Peek
46
+ ### Peek overlay
47
47
 
48
- The peek overlay renders the spar conversation using the same components as pi's main TUI — same message styling, same syntax-highlighted tool output, same everything. It's pi inside pi.
48
+ `/spar` opens a floating overlay that renders the spar conversation using the same components as pi's main TUI — same message styling, same syntax-highlighted tool output, same everything. It's pi inside pi.
49
+
50
+ ![peek overlay demo](./assets/peek-demo.jpg)
49
51
 
50
52
  - **j/k** or **↑/↓** — scroll
51
53
  - **g/G** — jump to top/bottom
@@ -53,6 +55,16 @@ The peek overlay renders the spar conversation using the same components as pi's
53
55
 
54
56
  Live sessions auto-scroll as the peer model responds.
55
57
 
58
+ ### Session browser
59
+
60
+ `/spview` opens an inline session browser:
61
+
62
+ - **j/k** or **↑/↓** — navigate
63
+ - **enter** — open peek overlay for selected session
64
+ - **d** — delete selected session
65
+ - **D** — delete all non-active sessions
66
+ - **q** or **Esc** — close
67
+
56
68
  ## License
57
69
 
58
70
  MIT
Binary file
package/core.ts CHANGED
@@ -222,7 +222,8 @@ class EventBroadcaster {
222
222
  // Track state for sync on connect
223
223
  private currentStatus: "thinking" | "streaming" | "tool" | "done" = "thinking";
224
224
  private currentToolName?: string;
225
- private currentPartialMessage: any = null; // The full partial AssistantMessage
225
+ private currentPartialMessage: any = null;
226
+ private currentUserMessage: any = null;
226
227
 
227
228
  constructor(sessionId: string) {
228
229
  this.socketPath = getSocketPath(sessionId);
@@ -244,6 +245,7 @@ class EventBroadcaster {
244
245
  status: this.currentStatus,
245
246
  toolName: this.currentToolName,
246
247
  partialMessage: this.currentPartialMessage,
248
+ userMessage: this.currentUserMessage,
247
249
  };
248
250
  try { conn.write(JSON.stringify(syncEvent) + "\n"); } catch {}
249
251
 
@@ -262,7 +264,9 @@ class EventBroadcaster {
262
264
 
263
265
  broadcast(event: any): void {
264
266
  // Track state for sync
265
- if (event.type === "message_start" && event.message?.role === "assistant") {
267
+ if (event.type === "message_start" && event.message?.role === "user") {
268
+ this.currentUserMessage = event.message;
269
+ } else if (event.type === "message_start" && event.message?.role === "assistant") {
266
270
  this.currentPartialMessage = event.message;
267
271
  this.currentStatus = "thinking";
268
272
  } else if (event.type === "message_update" && event.message?.role === "assistant") {
@@ -279,6 +283,7 @@ class EventBroadcaster {
279
283
  this.currentToolName = event.toolName;
280
284
  } else if (event.type === "message_end" || event.type === "agent_end") {
281
285
  this.currentPartialMessage = null;
286
+ this.currentUserMessage = null;
282
287
  this.currentStatus = "done";
283
288
  this.currentToolName = undefined;
284
289
  }
@@ -443,6 +448,24 @@ export function sessionExists(name: string): boolean {
443
448
  return fs.existsSync(getSessionInfoPath(name));
444
449
  }
445
450
 
451
+ /**
452
+ * Delete a session and all its files (.jsonl, .info.json, .log)
453
+ */
454
+ export function deleteSession(name: string): void {
455
+ validateSessionName(name);
456
+ const files = [
457
+ getSessionFilePath(name),
458
+ getSessionInfoPath(name),
459
+ getSessionLogPath(name),
460
+ ];
461
+ for (const f of files) {
462
+ try { if (fs.existsSync(f)) fs.unlinkSync(f); } catch {}
463
+ }
464
+ // Clean up socket if still around
465
+ const socketPath = getSocketPath(name);
466
+ try { if (fs.existsSync(socketPath)) fs.unlinkSync(socketPath); } catch {}
467
+ }
468
+
446
469
  /**
447
470
  * Get session info
448
471
  */
package/index.ts CHANGED
@@ -2,13 +2,13 @@
2
2
  * Spar Extension - Agent-to-agent sparring
3
3
  *
4
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.
5
+ * plus /spar and /spview commands for viewing spar sessions.
6
6
  */
7
7
 
8
8
  import type { ExtensionAPI, Theme } from "@mariozechner/pi-coding-agent";
9
9
  import { StringEnum } from "@mariozechner/pi-ai";
10
10
  import { Type } from "@sinclair/typebox";
11
- import { Text, Container, Spacer, SelectList, Input, matchesKey, type SelectItem, type SelectListTheme } from "@mariozechner/pi-tui";
11
+ import { Text, Container, Spacer, SelectList, Input, matchesKey, truncateToWidth, type SelectItem, type SelectListTheme } from "@mariozechner/pi-tui";
12
12
  import {
13
13
  sendMessage,
14
14
  listSessions,
@@ -17,6 +17,7 @@ import {
17
17
  getConfiguredModelsDescription,
18
18
  loadSparConfig,
19
19
  saveSparConfig,
20
+ deleteSession,
20
21
  type SparModelConfig,
21
22
  DEFAULT_TIMEOUT,
22
23
  } from "./core.js";
@@ -48,7 +49,7 @@ function suggestAlias(provider: string, modelId: string): string {
48
49
 
49
50
  export default function (pi: ExtensionAPI) {
50
51
  // ==========================================================================
51
- // Tool Registration — called on load and after /spar-models changes config
52
+ // Tool Registration — model aliases are configured via /spmodels
52
53
  // ==========================================================================
53
54
 
54
55
  pi.registerTool({
@@ -117,7 +118,7 @@ spar({
117
118
  description: "Message to send (required for send)",
118
119
  })),
119
120
  model: Type.Optional(Type.String({
120
- description: "Model alias (from /spar-models) or provider:model. Required for first message in a session.",
121
+ description: "Model alias (from /spmodels) or provider:model. Required for first message in a session.",
121
122
  })),
122
123
  thinking: Type.Optional(StringEnum(["off", "minimal", "low", "medium", "high"] as const, {
123
124
  description: "Thinking level (default: high)",
@@ -437,14 +438,14 @@ spar({
437
438
  });
438
439
 
439
440
  // ==========================================================================
440
- // Command: /spar-models — configure available sparring models
441
+ // Command: /spmodels — configure available sparring models
441
442
  // ==========================================================================
442
443
 
443
- pi.registerCommand("spar-models", {
444
+ pi.registerCommand("spmodels", {
444
445
  description: "Configure models available for sparring",
445
446
  handler: async (_args, ctx) => {
446
447
  if (!ctx.hasUI) {
447
- ctx.ui.notify("Interactive mode required for /spar-models", "warning");
448
+ ctx.ui.notify("Interactive mode required for /spmodels", "warning");
448
449
  return;
449
450
  }
450
451
 
@@ -603,11 +604,11 @@ spar({
603
604
  });
604
605
 
605
606
  // ==========================================================================
606
- // Commands: /peek and /peek-all
607
+ // Commands: /spar and /spview
607
608
  // ==========================================================================
608
609
 
609
- pi.registerCommand("peek", {
610
- description: "Peek at a spar session. Usage: /peek [session-name]",
610
+ pi.registerCommand("spar", {
611
+ description: "Peek at a spar session. Usage: /spar [session-name]",
611
612
  getArgumentCompletions: (prefix: string) => {
612
613
  const sessions = listPeekableSessions();
613
614
  const items = sessions.map((s) => ({
@@ -632,7 +633,7 @@ spar({
632
633
  if (!sessionId) {
633
634
  const available = listPeekableSessions();
634
635
  if (available.length > 0) {
635
- ctx.ui.notify(`No recent spar. Try: /peek ${available[0].name}`, "info");
636
+ ctx.ui.notify(`No recent spar. Try: /spar ${available[0].name}`, "info");
636
637
  } else {
637
638
  ctx.ui.notify("No spar sessions found", "info");
638
639
  }
@@ -660,100 +661,164 @@ spar({
660
661
  },
661
662
  });
662
663
 
663
- pi.registerCommand("peek-all", {
664
- description: "List all spar sessions and pick one to peek",
664
+ pi.registerCommand("spview", {
665
+ description: "Browse spar sessions view, peek, or delete",
665
666
  handler: async (_args, ctx) => {
666
- const sessions = listPeekableSessions();
667
+ const result = await ctx.ui.custom<{ action: "peek"; name: string } | undefined>(
668
+ (tui, theme, _kb, done) => {
669
+ let selectedIndex = 0;
670
+ let cachedLines: string[] | undefined;
667
671
 
668
- if (sessions.length === 0) {
669
- ctx.ui.notify("No spar sessions found", "info");
670
- return;
671
- }
672
+ const fg = theme.fg.bind(theme);
673
+
674
+ function refresh() {
675
+ cachedLines = undefined;
676
+ tui.requestRender();
677
+ }
672
678
 
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
679
  return {
683
- value: s.name,
684
- label: `${status} ${s.name}`,
685
- description: desc,
686
- };
687
- });
680
+ render(width: number): string[] {
681
+ if (cachedLines) return cachedLines;
688
682
 
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
- });
683
+ const sessions = listPeekableSessions();
684
+ if (selectedIndex >= sessions.length) selectedIndex = Math.max(0, sessions.length - 1);
696
685
 
697
- selectList.onSelect = (item) => done(item.value);
698
- selectList.onCancel = () => done(null);
686
+ const lines: string[] = [];
699
687
 
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
- };
688
+ lines.push(fg("accent", "─".repeat(width)));
689
+ lines.push(fg("accent", theme.bold(" Spar Sessions")) + fg("dim", ` (${sessions.length})`));
690
+ lines.push("");
738
691
 
739
- return container;
740
- });
692
+ if (sessions.length === 0) {
693
+ lines.push(fg("dim", " No spar sessions found"));
694
+ } else {
695
+ // Table header
696
+ const header = [
697
+ "name".padEnd(30),
698
+ "model".padEnd(8),
699
+ "msgs".padStart(5),
700
+ "age".padStart(6),
701
+ ].join(" ");
702
+ lines.push(fg("dim", ` ${header}`));
703
+
704
+ // Paginated viewport — show PAGE_SIZE rows around selection
705
+ const PAGE_SIZE = 15;
706
+ let viewStart = 0;
707
+ if (sessions.length > PAGE_SIZE) {
708
+ // Keep selection centered in viewport
709
+ viewStart = Math.max(0, Math.min(
710
+ selectedIndex - Math.floor(PAGE_SIZE / 2),
711
+ sessions.length - PAGE_SIZE,
712
+ ));
713
+ }
714
+ const viewEnd = Math.min(viewStart + PAGE_SIZE, sessions.length);
741
715
 
742
- if (!selected) return;
716
+ if (viewStart > 0) {
717
+ lines.push(fg("dim", ` ↑ ${viewStart} more`));
718
+ }
743
719
 
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
- }
720
+ for (let i = viewStart; i < viewEnd; i++) {
721
+ const s = sessions[i];
722
+ const isSelected = i === selectedIndex;
723
+ const prefix = isSelected ? fg("accent", "→ ") : " ";
724
+ const icon = s.active ? fg("success", "●") : fg("dim", "○");
725
+
726
+ const row = [
727
+ s.name.slice(0, 30).padEnd(30),
728
+ (s.model || "—").padEnd(8),
729
+ String(s.messageCount || 0).padStart(5),
730
+ (s.lastActivity ? formatAge(s.lastActivity) : "—").padStart(6),
731
+ ].join(" ");
732
+
733
+ const color = isSelected ? "accent" : s.active ? "text" : "muted";
734
+ lines.push(truncateToWidth(`${prefix}${icon} ${fg(color, row)}`, width));
735
+ }
736
+
737
+ if (viewEnd < sessions.length) {
738
+ lines.push(fg("dim", ` ↓ ${sessions.length - viewEnd} more`));
739
+ }
740
+ }
741
+
742
+ lines.push("");
743
+
744
+ // Contextual help
745
+ const helpParts = ["↑↓ navigate"];
746
+ if (sessions.length > 0) {
747
+ helpParts.push("enter peek");
748
+ helpParts.push("d delete");
749
+ helpParts.push("D delete all");
750
+ }
751
+ helpParts.push("esc close");
752
+ lines.push(fg("dim", ` ${helpParts.join(" · ")}`));
753
+
754
+ lines.push(fg("accent", "─".repeat(width)));
755
+
756
+ cachedLines = lines;
757
+ return lines;
758
+ },
759
+
760
+ handleInput(data: string): void {
761
+ const sessions = listPeekableSessions();
762
+
763
+ if (matchesKey(data, "escape") || data === "q") {
764
+ done(undefined);
765
+ return;
766
+ }
767
+ if (matchesKey(data, "up") || data === "k") {
768
+ selectedIndex = selectedIndex <= 0 ? sessions.length - 1 : selectedIndex - 1;
769
+ refresh();
770
+ return;
771
+ }
772
+ if (matchesKey(data, "down") || data === "j") {
773
+ selectedIndex = selectedIndex >= sessions.length - 1 ? 0 : selectedIndex + 1;
774
+ refresh();
775
+ return;
776
+ }
777
+ if (matchesKey(data, "return")) {
778
+ if (sessions.length > 0 && sessions[selectedIndex]) {
779
+ done({ action: "peek", name: sessions[selectedIndex].name });
780
+ }
781
+ return;
782
+ }
783
+ if (data === "d") {
784
+ if (sessions.length > 0 && sessions[selectedIndex]) {
785
+ deleteSession(sessions[selectedIndex].name);
786
+ refresh();
787
+ }
788
+ return;
789
+ }
790
+ if (data === "D") {
791
+ for (const s of sessions) {
792
+ if (!s.active) deleteSession(s.name);
793
+ }
794
+ refresh();
795
+ return;
796
+ }
797
+ },
798
+
799
+ invalidate(): void {
800
+ cachedLines = undefined;
801
+ },
802
+ };
803
+ },
756
804
  );
805
+
806
+ // Open peek if selected
807
+ if (result?.action === "peek") {
808
+ await ctx.ui.custom<void>(
809
+ (tui, theme, _kb, done) => new SparPeekOverlay(tui, theme, result.name, done),
810
+ {
811
+ overlay: true,
812
+ overlayOptions: {
813
+ anchor: "right-center",
814
+ width: "45%",
815
+ minWidth: 50,
816
+ maxHeight: 60,
817
+ margin: { right: 2, top: 2, bottom: 2 },
818
+ },
819
+ },
820
+ );
821
+ }
757
822
  },
758
823
  });
759
824
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ogulcancelik/pi-spar",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Agent-to-agent sparring for pi. Back-and-forth conversations with peer AI models for debugging, design review, and challenging your thinking.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -35,6 +35,7 @@
35
35
  "index.ts",
36
36
  "core.ts",
37
37
  "peek.ts",
38
+ "assets/peek-demo.jpg",
38
39
  "README.md",
39
40
  "LICENSE"
40
41
  ]
package/peek.ts CHANGED
@@ -355,6 +355,16 @@ export class SparPeekOverlay {
355
355
  this.status = event.status || "thinking";
356
356
  this.toolName = event.toolName;
357
357
 
358
+ // If there's a user message that hasn't been persisted to file yet, show it
359
+ if (event.userMessage) {
360
+ const text = this.getUserText(event.userMessage);
361
+ if (text) {
362
+ this.chatContainer.addChild(
363
+ new UserMessageComponent(text, getMarkdownTheme()),
364
+ );
365
+ }
366
+ }
367
+
358
368
  // If there's a partial message, use it directly (faithful reconstruction)
359
369
  if (event.partialMessage) {
360
370
  this.streamingMessage = event.partialMessage;
@@ -383,8 +393,16 @@ export class SparPeekOverlay {
383
393
  }
384
394
  }
385
395
  } else if (event.type === "message_start") {
386
- // New assistant message create streaming component (same as interactive-mode)
387
- if (event.message?.role === "assistant") {
396
+ if (event.message?.role === "user") {
397
+ // User message render immediately (same as interactive-mode)
398
+ const text = this.getUserText(event.message);
399
+ if (text) {
400
+ this.chatContainer.addChild(
401
+ new UserMessageComponent(text, getMarkdownTheme()),
402
+ );
403
+ }
404
+ } else if (event.message?.role === "assistant") {
405
+ // New assistant message — create streaming component
388
406
  this.cleanupStreaming();
389
407
  this.streamingMessage = event.message;
390
408
  this.streamingComponent = new AssistantMessageComponent(
@@ -583,7 +601,7 @@ export class SparPeekOverlay {
583
601
  this.followMode = false;
584
602
  this.scrollOffset = 0;
585
603
  this.tui.requestRender();
586
- } else if (data === "G") {
604
+ } else if (data === "G" || matchesKey(data, "shift+g")) {
587
605
  this.followMode = true;
588
606
  this.scrollOffset = 999999;
589
607
  this.tui.requestRender();