pi-squad 0.1.0 → 0.4.14

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.
@@ -1,11 +1,15 @@
1
1
  /**
2
2
  * squad-panel.ts — Main overlay component for the squad TUI panel.
3
3
  *
4
- * Manages view switching between task list, message view, and agent list.
5
- * Adapts layout: wide screen (>=160 cols) = right panel, narrow = centered overlay.
4
+ * Uses ctx.ui.custom() with a proper done() callback for lifecycle management.
5
+ * Inspired by pi-interactive-shell's overlay pattern:
6
+ * - Component implements Component + Focusable
7
+ * - handleInput for key dispatch
8
+ * - render(width) returns string[]
9
+ * - done() closes the overlay cleanly
6
10
  */
7
11
 
8
- import type { Component, Focusable, TUI, OverlayHandle, OverlayOptions } from "@mariozechner/pi-tui";
12
+ import type { Component, Focusable, TUI } from "@mariozechner/pi-tui";
9
13
  import { matchesKey, visibleWidth, truncateToWidth } from "@mariozechner/pi-tui";
10
14
  import type { Theme } from "@mariozechner/pi-coding-agent";
11
15
  import type { PanelState, PanelView } from "../types.js";
@@ -15,10 +19,15 @@ import { MessageView } from "./message-view.js";
15
19
  import * as store from "../store.js";
16
20
 
17
21
  // ============================================================================
18
- // Constants
22
+ // Result type — what the panel returns when closed
19
23
  // ============================================================================
20
24
 
21
- const WIDE_THRESHOLD = 160;
25
+ export interface SquadPanelResult {
26
+ /** How the panel was closed */
27
+ action: "close" | "send-message";
28
+ taskId?: string;
29
+ message?: string;
30
+ }
22
31
 
23
32
  // ============================================================================
24
33
  // Squad Panel
@@ -29,11 +38,14 @@ export class SquadPanel implements Component, Focusable {
29
38
 
30
39
  private tui: TUI;
31
40
  private theme: Theme;
41
+ private done: (result: SquadPanelResult) => void;
32
42
  private scheduler: Scheduler;
33
43
  private squadId: string;
34
- private handle: OverlayHandle | null = null;
35
- /** Callback to send a human message — set by the extension */
36
- onSendMessage?: (taskId: string, message: string) => void;
44
+
45
+ /** Callback for sending messages — set by the extension */
46
+ onSendMessage?: (taskId: string, message: string) => Promise<void>;
47
+ /** Callback when panel closes */
48
+ onClose?: () => void;
37
49
 
38
50
  private state: PanelState = {
39
51
  view: "tasks",
@@ -45,116 +57,54 @@ export class SquadPanel implements Component, Focusable {
45
57
 
46
58
  private taskListView: TaskListView;
47
59
  private messageView: MessageView;
48
-
49
- private refreshInterval: ReturnType<typeof setInterval> | null = null;
50
-
51
- constructor(tui: TUI, theme: Theme, scheduler: Scheduler, squadId: string) {
60
+ /** Debounced render timer */
61
+ private renderTimeout: ReturnType<typeof setTimeout> | null = null;
62
+ /** Auto-refresh timer for live activity */
63
+ private refreshTimer: ReturnType<typeof setInterval> | null = null;
64
+ private finished = false;
65
+
66
+ constructor(
67
+ tui: TUI,
68
+ theme: Theme,
69
+ scheduler: Scheduler,
70
+ squadId: string,
71
+ done: (result: SquadPanelResult) => void,
72
+ ) {
52
73
  this.tui = tui;
53
74
  this.theme = theme;
54
75
  this.scheduler = scheduler;
55
76
  this.squadId = squadId;
77
+ this.done = done;
56
78
 
57
79
  this.taskListView = new TaskListView(theme, squadId);
58
80
  this.messageView = new MessageView(theme, squadId);
59
- }
60
-
61
- // =========================================================================
62
- // Overlay Lifecycle
63
- // =========================================================================
64
-
65
- /** Show the panel as an overlay */
66
- show(): void {
67
- if (this.handle) return;
68
81
 
69
- this.handle = this.tui.showOverlay(this, this.getOverlayOptions());
70
-
71
- // Refresh panel periodically
72
- this.refreshInterval = setInterval(() => {
82
+ // Auto-refresh for live activity. 5s is enough — faster causes
83
+ // flicker and races with agents writing to JSONL files.
84
+ this.refreshTimer = setInterval(() => {
73
85
  this.tui.requestRender();
74
- }, 2000);
86
+ }, 5000);
75
87
  }
76
88
 
77
- /** Hide the panel */
78
- hide(): void {
79
- if (this.handle) {
80
- this.handle.hide();
81
- this.handle = null;
82
- }
83
- if (this.refreshInterval) {
84
- clearInterval(this.refreshInterval);
85
- this.refreshInterval = null;
86
- }
89
+ /** Trigger a render update (called from outside, e.g., on scheduler events) */
90
+ requestUpdate(): void {
91
+ this.debouncedRender();
87
92
  }
88
93
 
89
- /** Toggle panel visibility */
90
- toggle(): void {
91
- if (this.handle) {
92
- if (this.handle.isHidden()) {
93
- this.handle.setHidden(false);
94
- } else {
95
- this.handle.setHidden(true);
96
- }
97
- } else {
98
- this.show();
99
- }
100
- }
101
-
102
- /** Toggle focus between panel and main editor.
103
- * Wide screen: panel stays visible, just switch focus.
104
- * Narrow screen: show/hide since overlay covers content.
105
- */
106
- toggleFocus(): void {
107
- if (!this.handle) {
108
- this.show();
109
- return;
110
- }
111
- const wide = this.tui.columns >= WIDE_THRESHOLD;
112
-
113
- if (this.handle.isHidden()) {
114
- this.handle.setHidden(false);
115
- this.handle.focus();
116
- } else if (this.handle.isFocused()) {
117
- if (wide) {
118
- // Wide: release focus back to editor, panel stays visible
119
- this.handle.unfocus();
120
- } else {
121
- // Narrow: hide overlay entirely
122
- this.handle.setHidden(true);
123
- }
124
- } else {
125
- // Visible but not focused — take focus
126
- this.handle.focus();
127
- }
128
- }
129
-
130
- /** Check if panel is visible */
131
- isVisible(): boolean {
132
- return this.handle !== null && !this.handle.isHidden();
133
- }
134
-
135
- dispose(): void {
136
- this.hide();
94
+ private debouncedRender(): void {
95
+ if (this.renderTimeout) clearTimeout(this.renderTimeout);
96
+ this.renderTimeout = setTimeout(() => {
97
+ this.renderTimeout = null;
98
+ this.tui.requestRender();
99
+ }, 16);
137
100
  }
138
101
 
139
- // =========================================================================
140
- // Overlay Options (adaptive layout)
141
- // =========================================================================
142
-
143
- private getOverlayOptions(): OverlayOptions {
144
- const wide = this.tui.columns >= WIDE_THRESHOLD;
145
- if (wide) {
146
- return {
147
- anchor: "top-right",
148
- width: "35%",
149
- maxHeight: "100%",
150
- margin: { top: 0, right: 0, bottom: 1, left: 0 },
151
- };
152
- }
153
- return {
154
- anchor: "center",
155
- width: "90%",
156
- maxHeight: "85%",
157
- };
102
+ private finish(result: SquadPanelResult): void {
103
+ if (this.finished) return;
104
+ this.finished = true;
105
+ this.dispose();
106
+ this.onClose?.();
107
+ this.done(result);
158
108
  }
159
109
 
160
110
  // =========================================================================
@@ -162,43 +112,27 @@ export class SquadPanel implements Component, Focusable {
162
112
  // =========================================================================
163
113
 
164
114
  handleInput(data: string): void {
165
- const wide = this.tui.columns >= WIDE_THRESHOLD;
166
-
167
- // Ctrl+Q: switch focus back to editor (wide) or hide (narrow)
115
+ // Ctrl+Q or q: close panel
168
116
  if (data === "\x11") {
169
- if (wide) {
170
- this.handle?.unfocus();
171
- } else {
172
- this.handle?.setHidden(true);
173
- }
117
+ this.finish({ action: "close" });
174
118
  return;
175
119
  }
176
120
 
177
- // Escape: back from messages to tasks, or release focus
121
+ // Escape: back from messages, or close panel
178
122
  if (matchesKey(data, "escape")) {
179
123
  if (this.state.view === "messages") {
180
124
  this.state.view = "tasks";
181
125
  this.tui.requestRender();
182
126
  return;
183
127
  }
184
- if (wide) {
185
- this.handle?.unfocus();
186
- } else {
187
- this.handle?.setHidden(true);
188
- }
128
+ this.finish({ action: "close" });
189
129
  return;
190
130
  }
191
131
 
192
- // q: release focus (task list only, not from message view)
193
- if (matchesKey(data, "q")) {
194
- if (this.state.view === "tasks") {
195
- if (wide) {
196
- this.handle?.unfocus();
197
- } else {
198
- this.handle?.setHidden(true);
199
- }
200
- return;
201
- }
132
+ // q: close from task list
133
+ if (matchesKey(data, "q") && this.state.view === "tasks") {
134
+ this.finish({ action: "close" });
135
+ return;
202
136
  }
203
137
 
204
138
  // Delegate to current view
@@ -228,7 +162,6 @@ export class SquadPanel implements Component, Focusable {
228
162
  }
229
163
  this.tui.requestRender();
230
164
  } else if (matchesKey(data, "return")) {
231
- // Open message view for selected task
232
165
  if (this.state.selectedTaskId) {
233
166
  this.state.view = "messages";
234
167
  this.state.scrollOffset = 0;
@@ -236,7 +169,6 @@ export class SquadPanel implements Component, Focusable {
236
169
  this.tui.requestRender();
237
170
  }
238
171
  } else if (matchesKey(data, "p")) {
239
- // Pause/resume selected task
240
172
  if (this.state.selectedTaskId) {
241
173
  const task = store.loadTask(this.squadId, this.state.selectedTaskId);
242
174
  if (task?.status === "in_progress") {
@@ -247,7 +179,6 @@ export class SquadPanel implements Component, Focusable {
247
179
  this.tui.requestRender();
248
180
  }
249
181
  } else if (matchesKey(data, "x")) {
250
- // Cancel selected task
251
182
  if (this.state.selectedTaskId) {
252
183
  this.scheduler.cancelTask(this.state.selectedTaskId);
253
184
  this.tui.requestRender();
@@ -270,7 +201,6 @@ export class SquadPanel implements Component, Focusable {
270
201
  this.state.view = "tasks";
271
202
  this.tui.requestRender();
272
203
  } else if (matchesKey(data, "m")) {
273
- // Send message to this task's agent
274
204
  const taskId = this.messageView.getTaskId();
275
205
  if (taskId && this.onSendMessage) {
276
206
  this.onSendMessage(taskId, "");
@@ -296,10 +226,14 @@ export class SquadPanel implements Component, Focusable {
296
226
  const title = squad ? `squad: ${squad.goal.slice(0, width - 20)}` : "squad";
297
227
  lines.push(...this.renderHeader(title, width));
298
228
 
299
- // Content area: total height minus header (1 line) and footer (3 lines)
300
- const contentWidth = width - 2; // Account for border chars
301
- const totalAvailable = this.tui.rows || 24;
302
- const availHeight = Math.max(5, totalAvailable - 4); // 1 header + 3 footer
229
+ // Content area calculate available height for task list.
230
+ // The overlay has maxHeight "80%", so we must fit within that.
231
+ // Layout: 1 header + content + 3 footer = content gets the rest.
232
+ const contentWidth = width - 2;
233
+ const termRows = this.tui.terminal.rows || 24;
234
+ const overlayMaxRows = Math.floor(termRows * 0.8);
235
+ const chromeLines = 4; // 1 header + 3 footer
236
+ const availHeight = Math.max(5, overlayMaxRows - chromeLines);
303
237
 
304
238
  switch (this.state.view) {
305
239
  case "tasks": {
@@ -323,10 +257,13 @@ export class SquadPanel implements Component, Focusable {
323
257
  }
324
258
  }
325
259
 
326
- // Footer with keybindings
260
+ // Footer
327
261
  lines.push(...this.renderFooter(width));
328
262
 
329
- return lines;
263
+ // Strict height: cap to overlayMaxRows so the overlay never overflows.
264
+ // Also truncate all lines to width to prevent pi-tui crash.
265
+ const maxTotalLines = overlayMaxRows;
266
+ return lines.slice(0, maxTotalLines).map((line) => truncateToWidth(line, width, ""));
330
267
  }
331
268
 
332
269
  private renderHeader(title: string, width: number): string[] {
@@ -334,33 +271,29 @@ export class SquadPanel implements Component, Focusable {
334
271
  const innerW = width - 2;
335
272
  const titleStr = ` ${title} `;
336
273
  const titleVW = visibleWidth(titleStr);
337
- const leftBar = "─";
338
274
  const rightBarLen = Math.max(0, innerW - titleVW - 1);
339
- const rightBar = "─".repeat(rightBarLen);
340
275
 
341
276
  return [
342
- th.fg("border", "" + leftBar) +
277
+ th.fg("border", "╭─") +
343
278
  th.fg("accent", titleStr) +
344
- th.fg("border", rightBar + "╮"),
279
+ th.fg("border", "─".repeat(rightBarLen) + "╮"),
345
280
  ];
346
281
  }
347
282
 
348
283
  private renderFooter(width: number): string[] {
349
284
  const th = this.theme;
350
285
  const innerW = width - 2;
351
- const wide = this.tui.columns >= WIDE_THRESHOLD;
352
- const switchHint = wide ? "^q switch" : "^q close";
353
286
 
354
287
  let keys: string;
355
288
  switch (this.state.view) {
356
289
  case "tasks":
357
- keys = ` ↑↓ nav ⏎ msgs m send p pause x cancel ${switchHint}`;
290
+ keys = ` ↑↓ nav ⏎ msgs m send p pause x cancel ^q close`;
358
291
  break;
359
292
  case "messages":
360
- keys = ` ↑↓ scroll m send esc back ${switchHint}`;
293
+ keys = ` ↑↓ scroll m send esc back ^q close`;
361
294
  break;
362
295
  default:
363
- keys = ` ${switchHint}`;
296
+ keys = ` ^q close`;
364
297
  }
365
298
 
366
299
  return [
@@ -370,14 +303,27 @@ export class SquadPanel implements Component, Focusable {
370
303
  ];
371
304
  }
372
305
 
373
- /** Wrap a content line with border chars, padded to exact width */
374
306
  private borderLine(content: string, width: number): string {
375
307
  const th = this.theme;
376
308
  const innerW = width - 2;
377
- // Truncate content to fit, preserving ANSI codes
378
309
  const truncated = truncateToWidth(content, innerW);
379
310
  const contentVW = visibleWidth(truncated);
380
311
  const pad = " ".repeat(Math.max(0, innerW - contentVW));
381
312
  return th.fg("border", "│") + truncated + pad + th.fg("border", "│");
382
313
  }
314
+
315
+ // =========================================================================
316
+ // Lifecycle
317
+ // =========================================================================
318
+
319
+ dispose(): void {
320
+ if (this.renderTimeout) {
321
+ clearTimeout(this.renderTimeout);
322
+ this.renderTimeout = null;
323
+ }
324
+ if (this.refreshTimer) {
325
+ clearInterval(this.refreshTimer);
326
+ this.refreshTimer = null;
327
+ }
328
+ }
383
329
  }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * squad-widget.ts — Simple string[] widget for squad status.
3
+ *
4
+ * Uses the simple setWidget(key, string[]) API which pi-tui handles
5
+ * reliably. Component-based widgets with variable height cause TUI
6
+ * layout corruption.
7
+ *
8
+ * Updates are pushed by calling requestUpdate() which rebuilds the
9
+ * string[] and calls setWidget() again.
10
+ */
11
+
12
+ import { truncateToWidth } from "@mariozechner/pi-tui";
13
+ import type { Theme } from "@mariozechner/pi-coding-agent";
14
+ import type { TaskStatus } from "../types.js";
15
+ import * as store from "../store.js";
16
+
17
+ function statusIcon(status: TaskStatus, th: Theme): string {
18
+ switch (status) {
19
+ case "done": return th.fg("success", "✓");
20
+ case "in_progress": return th.fg("warning", "⏳");
21
+ case "blocked": return th.fg("muted", "◻");
22
+ case "failed": return th.fg("error", "✗");
23
+ case "suspended": return th.fg("muted", "⏸");
24
+ default: return th.fg("dim", "·");
25
+ }
26
+ }
27
+
28
+ function formatElapsed(ms: number): string {
29
+ const s = Math.floor(ms / 1000);
30
+ const m = Math.floor(s / 60);
31
+ const h = Math.floor(m / 60);
32
+ if (h > 0) return `${h}h${m % 60}m`;
33
+ if (m > 0) return `${m}m${s % 60}s`;
34
+ return `${s}s`;
35
+ }
36
+
37
+ export interface SquadWidgetState {
38
+ squadId: string | null;
39
+ enabled: boolean;
40
+ }
41
+
42
+ /**
43
+ * Set up the squad widget. Returns control functions.
44
+ * Uses simple string[] setWidget — no component factory.
45
+ */
46
+ export function setupSquadWidget(
47
+ ctx: { ui: { setWidget: Function; setStatus: Function; [key: string]: any }; hasUI?: boolean },
48
+ state: SquadWidgetState,
49
+ ): {
50
+ requestUpdate: () => void;
51
+ dispose: () => void;
52
+ } {
53
+ if (!ctx.hasUI) return { requestUpdate: () => {}, dispose: () => {} };
54
+
55
+ let durationTimer: ReturnType<typeof setInterval> | null = null;
56
+ let renderTimer: ReturnType<typeof setTimeout> | null = null;
57
+ /** Cache key to skip redundant setWidget calls */
58
+ let lastCacheKey = "";
59
+
60
+ function render(): void {
61
+ if (!state.enabled || !state.squadId) {
62
+ ctx.ui.setWidget("squad-tasks", undefined);
63
+ ctx.ui.setStatus("squad", undefined);
64
+ return;
65
+ }
66
+
67
+ const th = ctx.ui.theme;
68
+ const tasks = store.loadAllTasks(state.squadId);
69
+ const squad = store.loadSquad(state.squadId);
70
+ if (!squad || tasks.length === 0) {
71
+ ctx.ui.setWidget("squad-tasks", undefined);
72
+ ctx.ui.setStatus("squad", undefined);
73
+ return;
74
+ }
75
+
76
+ const lines: string[] = [];
77
+ const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
78
+ const doneCount = tasks.filter((t) => t.status === "done").length;
79
+ const elapsed = Date.now() - new Date(squad.created).getTime();
80
+
81
+ const sIcon = squad.status === "done" ? th.fg("success", "✓")
82
+ : squad.status === "failed" ? th.fg("error", "✗")
83
+ : th.fg("warning", "⏳");
84
+
85
+ lines.push(
86
+ `${sIcon} ${th.fg("accent", "squad")} ${th.fg("dim", squad.goal.slice(0, 35))} ` +
87
+ `${th.fg("muted", `${doneCount}/${tasks.length}`)} ` +
88
+ `${th.fg("dim", `$${totalCost.toFixed(2)}`)} ` +
89
+ `${th.fg("dim", formatElapsed(elapsed))} ` +
90
+ `${th.fg("dim", "^q detail · /squad msg")}`
91
+ );
92
+
93
+ // Cap visible tasks based on total count
94
+ const maxVisible = tasks.length > 6 ? 4 : tasks.length;
95
+ const visibleTasks = tasks.slice(0, maxVisible);
96
+
97
+ for (const task of visibleTasks) {
98
+ const icon = statusIcon(task.status, th);
99
+ let line = ` ${icon} ${th.fg("muted", task.id)} ${th.fg("dim", `(${task.agent})`)}`;
100
+
101
+ if (task.status === "done" && task.output) {
102
+ let timeStr = "";
103
+ if (task.started && task.completed) {
104
+ const dur = new Date(task.completed).getTime() - new Date(task.started).getTime();
105
+ timeStr = ` ${formatElapsed(dur)}`;
106
+ }
107
+ line += th.fg("dim", `${timeStr} ${task.output.split("\n")[0].slice(0, 40)}`);
108
+ } else if (task.status === "failed" && task.error) {
109
+ line += ` ${th.fg("error", task.error.slice(0, 40))}`;
110
+ } else if (task.status === "in_progress") {
111
+ const runningFor = task.started ? Date.now() - new Date(task.started).getTime() : 0;
112
+ const timeColor = runningFor > 180_000 ? "warning" : "dim";
113
+ line += ` ${th.fg(timeColor as any, formatElapsed(runningFor))}`;
114
+ const messages = store.loadMessages(state.squadId!, task.id);
115
+ const lastTool = [...messages].reverse().find(m => m.type === "tool");
116
+ if (lastTool) {
117
+ const rawDetail = (lastTool.args?.path || lastTool.args?.command || "").toString();
118
+ const detail = rawDetail.split("\n")[0]; // first line only
119
+ const toolStr = `→ ${lastTool.name || lastTool.text}`;
120
+ line += ` ${th.fg("dim", (detail ? `${toolStr} ${detail}` : toolStr).slice(0, 30))}`;
121
+ }
122
+ } else if (task.status === "blocked") {
123
+ const blockers = task.depends.filter((d) => {
124
+ const dep = tasks.find((t) => t.id === d);
125
+ return dep && dep.status !== "done";
126
+ });
127
+ if (blockers.length > 0) {
128
+ line += ` ${th.fg("dim", "← " + blockers.join(", "))}`;
129
+ }
130
+ }
131
+
132
+ lines.push(line);
133
+ }
134
+
135
+ if (tasks.length > maxVisible) {
136
+ lines.push(` ${th.fg("dim", ` +${tasks.length - maxVisible} more · ^q detail`)}`);
137
+ }
138
+
139
+ // Skip if nothing changed (avoid redundant setWidget calls that cause flicker)
140
+ const cacheKey = `${squad.status}:${tasks.map(t => `${t.id}=${t.status}:${t.usage.turns}`).join(",")}`;
141
+ if (cacheKey === lastCacheKey) return;
142
+ lastCacheKey = cacheKey;
143
+
144
+ ctx.ui.setWidget("squad-tasks", lines);
145
+
146
+ // Update status bar
147
+ const statusText = squad.status === "done"
148
+ ? th.fg("success", `✓ squad ${doneCount}/${tasks.length}`)
149
+ : squad.status === "failed"
150
+ ? th.fg("error", `✗ squad ${doneCount}/${tasks.length}`)
151
+ : th.fg("accent", `⏳ squad ${doneCount}/${tasks.length} $${totalCost.toFixed(2)}`);
152
+ ctx.ui.setStatus("squad", statusText);
153
+ }
154
+
155
+ function manageDurationTimer(): void {
156
+ if (!state.squadId || !state.enabled) {
157
+ if (durationTimer) { clearInterval(durationTimer); durationTimer = null; }
158
+ return;
159
+ }
160
+ const squad = store.loadSquad(state.squadId);
161
+ const isActive = squad && (squad.status === "running" || squad.status === "paused");
162
+ if (isActive && !durationTimer) {
163
+ durationTimer = setInterval(() => render(), 5000);
164
+ } else if (!isActive && durationTimer) {
165
+ clearInterval(durationTimer);
166
+ durationTimer = null;
167
+ }
168
+ }
169
+
170
+ // Initial render
171
+ render();
172
+ manageDurationTimer();
173
+
174
+ return {
175
+ requestUpdate(): void {
176
+ // Debounce: multiple rapid events (scheduler) coalesce into one render
177
+ if (renderTimer) return;
178
+ renderTimer = setTimeout(() => {
179
+ renderTimer = null;
180
+ render();
181
+ manageDurationTimer();
182
+ }, 50);
183
+ },
184
+ dispose(): void {
185
+ if (durationTimer) { clearInterval(durationTimer); durationTimer = null; }
186
+ if (renderTimer) { clearTimeout(renderTimer); renderTimer = null; }
187
+ lastCacheKey = "";
188
+ ctx.ui.setWidget("squad-tasks", undefined);
189
+ ctx.ui.setStatus("squad", undefined);
190
+ },
191
+ };
192
+ }