pi-squad 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.
@@ -0,0 +1,383 @@
1
+ /**
2
+ * squad-panel.ts — Main overlay component for the squad TUI panel.
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.
6
+ */
7
+
8
+ import type { Component, Focusable, TUI, OverlayHandle, OverlayOptions } from "@mariozechner/pi-tui";
9
+ import { matchesKey, visibleWidth, truncateToWidth } from "@mariozechner/pi-tui";
10
+ import type { Theme } from "@mariozechner/pi-coding-agent";
11
+ import type { PanelState, PanelView } from "../types.js";
12
+ import type { Scheduler } from "../scheduler.js";
13
+ import { TaskListView } from "./task-list.js";
14
+ import { MessageView } from "./message-view.js";
15
+ import * as store from "../store.js";
16
+
17
+ // ============================================================================
18
+ // Constants
19
+ // ============================================================================
20
+
21
+ const WIDE_THRESHOLD = 160;
22
+
23
+ // ============================================================================
24
+ // Squad Panel
25
+ // ============================================================================
26
+
27
+ export class SquadPanel implements Component, Focusable {
28
+ focused = false;
29
+
30
+ private tui: TUI;
31
+ private theme: Theme;
32
+ private scheduler: Scheduler;
33
+ 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;
37
+
38
+ private state: PanelState = {
39
+ view: "tasks",
40
+ selectedTaskIndex: 0,
41
+ selectedTaskId: null,
42
+ scrollOffset: 0,
43
+ agentSelectedIndex: 0,
44
+ };
45
+
46
+ private taskListView: TaskListView;
47
+ private messageView: MessageView;
48
+
49
+ private refreshInterval: ReturnType<typeof setInterval> | null = null;
50
+
51
+ constructor(tui: TUI, theme: Theme, scheduler: Scheduler, squadId: string) {
52
+ this.tui = tui;
53
+ this.theme = theme;
54
+ this.scheduler = scheduler;
55
+ this.squadId = squadId;
56
+
57
+ this.taskListView = new TaskListView(theme, squadId);
58
+ 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
+
69
+ this.handle = this.tui.showOverlay(this, this.getOverlayOptions());
70
+
71
+ // Refresh panel periodically
72
+ this.refreshInterval = setInterval(() => {
73
+ this.tui.requestRender();
74
+ }, 2000);
75
+ }
76
+
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
+ }
87
+ }
88
+
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();
137
+ }
138
+
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
+ };
158
+ }
159
+
160
+ // =========================================================================
161
+ // Input Handling
162
+ // =========================================================================
163
+
164
+ 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)
168
+ if (data === "\x11") {
169
+ if (wide) {
170
+ this.handle?.unfocus();
171
+ } else {
172
+ this.handle?.setHidden(true);
173
+ }
174
+ return;
175
+ }
176
+
177
+ // Escape: back from messages to tasks, or release focus
178
+ if (matchesKey(data, "escape")) {
179
+ if (this.state.view === "messages") {
180
+ this.state.view = "tasks";
181
+ this.tui.requestRender();
182
+ return;
183
+ }
184
+ if (wide) {
185
+ this.handle?.unfocus();
186
+ } else {
187
+ this.handle?.setHidden(true);
188
+ }
189
+ return;
190
+ }
191
+
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
+ }
202
+ }
203
+
204
+ // Delegate to current view
205
+ switch (this.state.view) {
206
+ case "tasks":
207
+ this.handleTaskListInput(data);
208
+ break;
209
+ case "messages":
210
+ this.handleMessageViewInput(data);
211
+ break;
212
+ }
213
+ }
214
+
215
+ private handleTaskListInput(data: string): void {
216
+ const tasks = store.loadAllTasks(this.squadId);
217
+
218
+ if (matchesKey(data, "up") || matchesKey(data, "k")) {
219
+ this.state.selectedTaskIndex = Math.max(0, this.state.selectedTaskIndex - 1);
220
+ if (tasks[this.state.selectedTaskIndex]) {
221
+ this.state.selectedTaskId = tasks[this.state.selectedTaskIndex].id;
222
+ }
223
+ this.tui.requestRender();
224
+ } else if (matchesKey(data, "down") || matchesKey(data, "j")) {
225
+ this.state.selectedTaskIndex = Math.min(tasks.length - 1, this.state.selectedTaskIndex + 1);
226
+ if (tasks[this.state.selectedTaskIndex]) {
227
+ this.state.selectedTaskId = tasks[this.state.selectedTaskIndex].id;
228
+ }
229
+ this.tui.requestRender();
230
+ } else if (matchesKey(data, "return")) {
231
+ // Open message view for selected task
232
+ if (this.state.selectedTaskId) {
233
+ this.state.view = "messages";
234
+ this.state.scrollOffset = 0;
235
+ this.messageView.setTaskId(this.state.selectedTaskId);
236
+ this.tui.requestRender();
237
+ }
238
+ } else if (matchesKey(data, "p")) {
239
+ // Pause/resume selected task
240
+ if (this.state.selectedTaskId) {
241
+ const task = store.loadTask(this.squadId, this.state.selectedTaskId);
242
+ if (task?.status === "in_progress") {
243
+ this.scheduler.pauseTask(this.state.selectedTaskId);
244
+ } else if (task?.status === "suspended") {
245
+ this.scheduler.resumeTask(this.state.selectedTaskId);
246
+ }
247
+ this.tui.requestRender();
248
+ }
249
+ } else if (matchesKey(data, "x")) {
250
+ // Cancel selected task
251
+ if (this.state.selectedTaskId) {
252
+ this.scheduler.cancelTask(this.state.selectedTaskId);
253
+ this.tui.requestRender();
254
+ }
255
+ } else if (matchesKey(data, "m")) {
256
+ if (this.state.selectedTaskId && this.onSendMessage) {
257
+ this.onSendMessage(this.state.selectedTaskId, "");
258
+ }
259
+ }
260
+ }
261
+
262
+ private handleMessageViewInput(data: string): void {
263
+ if (matchesKey(data, "up") || matchesKey(data, "k")) {
264
+ this.messageView.scrollUp();
265
+ this.tui.requestRender();
266
+ } else if (matchesKey(data, "down") || matchesKey(data, "j")) {
267
+ this.messageView.scrollDown();
268
+ this.tui.requestRender();
269
+ } else if (matchesKey(data, "escape")) {
270
+ this.state.view = "tasks";
271
+ this.tui.requestRender();
272
+ } else if (matchesKey(data, "m")) {
273
+ // Send message to this task's agent
274
+ const taskId = this.messageView.getTaskId();
275
+ if (taskId && this.onSendMessage) {
276
+ this.onSendMessage(taskId, "");
277
+ }
278
+ }
279
+ }
280
+
281
+ // =========================================================================
282
+ // Rendering
283
+ // =========================================================================
284
+
285
+ invalidate(): void {
286
+ this.taskListView.invalidate();
287
+ this.messageView.invalidate();
288
+ }
289
+
290
+ render(width: number): string[] {
291
+ const th = this.theme;
292
+ const lines: string[] = [];
293
+
294
+ // Header
295
+ const squad = store.loadSquad(this.squadId);
296
+ const title = squad ? `squad: ${squad.goal.slice(0, width - 20)}` : "squad";
297
+ lines.push(...this.renderHeader(title, width));
298
+
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
303
+
304
+ switch (this.state.view) {
305
+ case "tasks": {
306
+ const taskLines = this.taskListView.render(
307
+ contentWidth,
308
+ this.state.selectedTaskIndex,
309
+ availHeight,
310
+ this.scheduler,
311
+ );
312
+ for (const line of taskLines) {
313
+ lines.push(this.borderLine(line, width));
314
+ }
315
+ break;
316
+ }
317
+ case "messages": {
318
+ const msgLines = this.messageView.render(contentWidth, availHeight);
319
+ for (const line of msgLines) {
320
+ lines.push(this.borderLine(line, width));
321
+ }
322
+ break;
323
+ }
324
+ }
325
+
326
+ // Footer with keybindings
327
+ lines.push(...this.renderFooter(width));
328
+
329
+ return lines;
330
+ }
331
+
332
+ private renderHeader(title: string, width: number): string[] {
333
+ const th = this.theme;
334
+ const innerW = width - 2;
335
+ const titleStr = ` ${title} `;
336
+ const titleVW = visibleWidth(titleStr);
337
+ const leftBar = "─";
338
+ const rightBarLen = Math.max(0, innerW - titleVW - 1);
339
+ const rightBar = "─".repeat(rightBarLen);
340
+
341
+ return [
342
+ th.fg("border", "╭" + leftBar) +
343
+ th.fg("accent", titleStr) +
344
+ th.fg("border", rightBar + "╮"),
345
+ ];
346
+ }
347
+
348
+ private renderFooter(width: number): string[] {
349
+ const th = this.theme;
350
+ const innerW = width - 2;
351
+ const wide = this.tui.columns >= WIDE_THRESHOLD;
352
+ const switchHint = wide ? "^q switch" : "^q close";
353
+
354
+ let keys: string;
355
+ switch (this.state.view) {
356
+ case "tasks":
357
+ keys = ` ↑↓ nav ⏎ msgs m send p pause x cancel ${switchHint}`;
358
+ break;
359
+ case "messages":
360
+ keys = ` ↑↓ scroll m send esc back ${switchHint}`;
361
+ break;
362
+ default:
363
+ keys = ` ${switchHint}`;
364
+ }
365
+
366
+ return [
367
+ th.fg("border", "├" + "─".repeat(innerW) + "┤"),
368
+ this.borderLine(th.fg("dim", keys), width),
369
+ th.fg("border", "╰" + "─".repeat(innerW) + "╯"),
370
+ ];
371
+ }
372
+
373
+ /** Wrap a content line with border chars, padded to exact width */
374
+ private borderLine(content: string, width: number): string {
375
+ const th = this.theme;
376
+ const innerW = width - 2;
377
+ // Truncate content to fit, preserving ANSI codes
378
+ const truncated = truncateToWidth(content, innerW);
379
+ const contentVW = visibleWidth(truncated);
380
+ const pad = " ".repeat(Math.max(0, innerW - contentVW));
381
+ return th.fg("border", "│") + truncated + pad + th.fg("border", "│");
382
+ }
383
+ }
@@ -0,0 +1,264 @@
1
+ /**
2
+ * task-list.ts — Task tree view with status icons, live activity preview.
3
+ */
4
+
5
+ import type { Theme } from "@mariozechner/pi-coding-agent";
6
+ import type { Task, TaskStatus } from "../types.js";
7
+ import type { Scheduler } from "../scheduler.js";
8
+ import * as store from "../store.js";
9
+
10
+ // ============================================================================
11
+ // Status Icons
12
+ // ============================================================================
13
+
14
+ function statusIcon(status: TaskStatus, th: Theme): string {
15
+ switch (status) {
16
+ case "done":
17
+ return th.fg("success", "✓");
18
+ case "in_progress":
19
+ return th.fg("warning", "⏳");
20
+ case "blocked":
21
+ return th.fg("muted", "◻");
22
+ case "failed":
23
+ return th.fg("error", "✗");
24
+ case "suspended":
25
+ return th.fg("muted", "⏸");
26
+ case "pending":
27
+ default:
28
+ return th.fg("dim", "·");
29
+ }
30
+ }
31
+
32
+ function statusLabel(status: TaskStatus): string {
33
+ switch (status) {
34
+ case "done":
35
+ return "done";
36
+ case "in_progress":
37
+ return "";
38
+ case "blocked":
39
+ return "blocked";
40
+ case "failed":
41
+ return "FAILED";
42
+ case "suspended":
43
+ return "paused";
44
+ case "pending":
45
+ return "pending";
46
+ }
47
+ }
48
+
49
+ // ============================================================================
50
+ // Task List View
51
+ // ============================================================================
52
+
53
+ export class TaskListView {
54
+ private theme: Theme;
55
+ private squadId: string;
56
+
57
+ constructor(theme: Theme, squadId: string) {
58
+ this.theme = theme;
59
+ this.squadId = squadId;
60
+ }
61
+
62
+ invalidate(): void {
63
+ /* stateless rendering */
64
+ }
65
+
66
+ render(
67
+ width: number,
68
+ selectedIndex: number,
69
+ maxLines: number,
70
+ scheduler: Scheduler,
71
+ ): string[] {
72
+ const th = this.theme;
73
+ const tasks = store.loadAllTasks(this.squadId);
74
+ const lines: string[] = [];
75
+
76
+ if (tasks.length === 0) {
77
+ lines.push("");
78
+ lines.push(th.fg("muted", " No tasks yet"));
79
+ lines.push("");
80
+ return this.padToHeight(lines, maxLines);
81
+ }
82
+
83
+ // Render task list
84
+ const taskLines = this.renderTaskTree(tasks, selectedIndex, width);
85
+
86
+ // Scroll window
87
+ const scrollStart = Math.max(0, Math.min(selectedIndex - Math.floor(maxLines / 2), taskLines.length - maxLines + 4));
88
+ const visibleTasks = taskLines.slice(scrollStart, scrollStart + maxLines - 4);
89
+ lines.push(...visibleTasks);
90
+
91
+ // Separator
92
+ lines.push("");
93
+ lines.push(th.fg("border", " " + "─".repeat(width - 2)));
94
+
95
+ // Live activity for selected or first running task
96
+ const runningTask = tasks.find((t) => t.status === "in_progress");
97
+ if (runningTask) {
98
+ lines.push(...this.renderLiveActivity(runningTask, width, scheduler));
99
+ }
100
+
101
+ // Summary line
102
+ lines.push("");
103
+ lines.push(this.renderSummary(tasks, width));
104
+
105
+ return this.padToHeight(lines, maxLines);
106
+ }
107
+
108
+ // =========================================================================
109
+ // Task Tree
110
+ // =========================================================================
111
+
112
+ private renderTaskTree(tasks: Task[], selectedIndex: number, width: number): string[] {
113
+ const th = this.theme;
114
+ const lines: string[] = [];
115
+
116
+ // Build parent → children map
117
+ const topLevel: Task[] = [];
118
+ const childMap = new Map<string, Task[]>();
119
+
120
+ for (const task of tasks) {
121
+ // Simple heuristic: tasks whose depends are not in the list are top-level
122
+ // For proper parent/child: check if task folder is nested
123
+ topLevel.push(task);
124
+ }
125
+
126
+ // TODO: proper parent/subtask tree from folder structure
127
+ // For now, render flat with dependency indicators
128
+
129
+ for (let i = 0; i < topLevel.length; i++) {
130
+ const task = topLevel[i];
131
+ const isSelected = i === selectedIndex;
132
+ const icon = statusIcon(task.status, th);
133
+ const label = statusLabel(task.status);
134
+
135
+ const cursor = isSelected ? th.fg("accent", "▸") : " ";
136
+ const taskName = isSelected
137
+ ? th.fg("accent", th.bold(task.id))
138
+ : th.fg("muted", task.id);
139
+ const agentTag = th.fg("dim", `(${task.agent})`);
140
+ const labelStr = label ? th.fg("dim", ` ${label}`) : "";
141
+
142
+ // Format elapsed time for done/in_progress tasks
143
+ let timeStr = "";
144
+ if (task.status === "done" && task.started && task.completed) {
145
+ const elapsed = new Date(task.completed).getTime() - new Date(task.started).getTime();
146
+ timeStr = th.fg("dim", ` ${formatMs(elapsed)}`);
147
+ } else if (task.status === "in_progress" && task.started) {
148
+ const elapsed = Date.now() - new Date(task.started).getTime();
149
+ timeStr = th.fg("warning", ` ${formatMs(elapsed)}`);
150
+ }
151
+
152
+ const line = ` ${cursor} ${icon} ${taskName} ${agentTag}${labelStr}${timeStr}`;
153
+ lines.push(line);
154
+
155
+ // Show blocked-by info
156
+ if (task.status === "blocked" && task.depends.length > 0) {
157
+ const blockers = task.depends
158
+ .filter((depId) => {
159
+ const dep = tasks.find((t) => t.id === depId);
160
+ return dep && dep.status !== "done";
161
+ });
162
+ if (blockers.length > 0) {
163
+ lines.push(
164
+ ` ${th.fg("dim", "└ waiting on: " + blockers.join(", "))}`,
165
+ );
166
+ }
167
+ }
168
+
169
+ // Show error for failed tasks
170
+ if (task.status === "failed" && task.error) {
171
+ const errorPreview = task.error.slice(0, width - 10);
172
+ lines.push(` ${th.fg("error", "└ " + errorPreview)}`);
173
+ }
174
+ }
175
+
176
+ return lines;
177
+ }
178
+
179
+ // =========================================================================
180
+ // Live Activity
181
+ // =========================================================================
182
+
183
+ private renderLiveActivity(task: Task, width: number, scheduler: Scheduler): string[] {
184
+ const th = this.theme;
185
+ const lines: string[] = [];
186
+
187
+ lines.push(th.fg("border", ` ── ${task.id} (live) `) + th.fg("border", "─".repeat(Math.max(0, width - task.id.length - 12))));
188
+
189
+ // Get recent messages for this task
190
+ const messages = store.loadMessages(this.squadId, task.id);
191
+ const recent = messages.slice(-3);
192
+
193
+ for (const msg of recent) {
194
+ if (msg.type === "tool") {
195
+ const toolStr = `→ ${msg.name || msg.text}`;
196
+ const argsStr = msg.args?.path || msg.args?.command || "";
197
+ const preview = argsStr ? `${toolStr} ${argsStr}` : toolStr;
198
+ lines.push(` ${th.fg("muted", preview.slice(0, width - 2))}`);
199
+ } else if (msg.type === "text" && msg.from !== "system") {
200
+ const preview = msg.text.split("\n")[0].slice(0, width - 4);
201
+ lines.push(` ${th.fg("dim", `"${preview}"`)}`);
202
+ }
203
+ }
204
+
205
+ // Health indicator
206
+ const activity = scheduler.getPool().getActivity(task.id);
207
+ if (activity) {
208
+ const idleMs = Date.now() - activity.lastOutputTs;
209
+ if (idleMs > 60000) {
210
+ lines.push(` ${th.fg("warning", `⚠ idle ${formatMs(idleMs)}`)}`);
211
+ }
212
+ }
213
+
214
+ return lines;
215
+ }
216
+
217
+ // =========================================================================
218
+ // Summary
219
+ // =========================================================================
220
+
221
+ private renderSummary(tasks: Task[], width: number): string {
222
+ const th = this.theme;
223
+ const done = tasks.filter((t) => t.status === "done").length;
224
+ const total = tasks.length;
225
+ const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
226
+
227
+ const parts: string[] = [];
228
+ parts.push(th.fg("accent", `${done}/${total}`));
229
+ if (totalCost > 0) parts.push(th.fg("dim", `$${totalCost.toFixed(4)}`));
230
+
231
+ // Find squad creation time for elapsed
232
+ const squad = store.loadSquad(this.squadId);
233
+ if (squad) {
234
+ const elapsed = Date.now() - new Date(squad.created).getTime();
235
+ parts.push(th.fg("dim", formatMs(elapsed)));
236
+ }
237
+
238
+ return ` ${parts.join(th.fg("dim", " · "))}`;
239
+ }
240
+
241
+ // =========================================================================
242
+ // Helpers
243
+ // =========================================================================
244
+
245
+ private padToHeight(lines: string[], maxLines: number): string[] {
246
+ while (lines.length < maxLines) {
247
+ lines.push("");
248
+ }
249
+ return lines.slice(0, maxLines);
250
+ }
251
+ }
252
+
253
+ // ============================================================================
254
+ // Formatting
255
+ // ============================================================================
256
+
257
+ function formatMs(ms: number): string {
258
+ const seconds = Math.floor(ms / 1000);
259
+ const minutes = Math.floor(seconds / 60);
260
+ const hours = Math.floor(minutes / 60);
261
+ if (hours > 0) return `${hours}h ${minutes % 60}m`;
262
+ if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
263
+ return `${seconds}s`;
264
+ }