pi-squad 0.1.0 → 0.5.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/README.md +288 -75
- package/package.json +7 -3
- package/src/agent-pool.ts +10 -1
- package/src/index.ts +563 -322
- package/src/panel/message-view.ts +140 -122
- package/src/panel/squad-panel.ts +94 -148
- package/src/panel/squad-widget.ts +192 -0
- package/src/panel/task-list.ts +38 -28
- package/src/planner.ts +1 -1
- package/src/protocol.ts +37 -0
- package/src/scheduler.ts +216 -5
- package/src/skills/supervisor/SKILL.md +111 -0
- package/src/skills/verification/SKILL.md +32 -0
- package/src/store.ts +29 -1
- package/src/types.ts +11 -0
|
@@ -1,21 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* message-view.ts — Scrollable message log for a task.
|
|
3
|
-
*
|
|
3
|
+
* All lines truncated to width. Caps rendered messages to prevent
|
|
4
|
+
* TUI corruption from large message histories.
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
8
|
+
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
7
9
|
import type { TaskMessage } from "../types.js";
|
|
8
10
|
import * as store from "../store.js";
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
/** Max messages to render (most recent). Older messages are skipped. */
|
|
13
|
+
const MAX_MESSAGES = 30;
|
|
14
|
+
/** Max lines per text message before truncation */
|
|
15
|
+
const MAX_TEXT_LINES = 5;
|
|
13
16
|
|
|
14
17
|
export class MessageView {
|
|
15
18
|
private theme: Theme;
|
|
16
19
|
private squadId: string;
|
|
17
20
|
private taskId: string | null = null;
|
|
18
21
|
private scrollOffset = 0;
|
|
22
|
+
/** Track if user has manually scrolled up */
|
|
23
|
+
private userScrolled = false;
|
|
19
24
|
|
|
20
25
|
constructor(theme: Theme, squadId: string) {
|
|
21
26
|
this.theme = theme;
|
|
@@ -25,6 +30,7 @@ export class MessageView {
|
|
|
25
30
|
setTaskId(taskId: string): void {
|
|
26
31
|
this.taskId = taskId;
|
|
27
32
|
this.scrollOffset = 0;
|
|
33
|
+
this.userScrolled = false;
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
getTaskId(): string | null {
|
|
@@ -33,200 +39,212 @@ export class MessageView {
|
|
|
33
39
|
|
|
34
40
|
scrollUp(): void {
|
|
35
41
|
this.scrollOffset = Math.max(0, this.scrollOffset - 1);
|
|
42
|
+
this.userScrolled = true;
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
scrollDown(): void {
|
|
39
46
|
this.scrollOffset++;
|
|
47
|
+
// Will be clamped in render
|
|
40
48
|
}
|
|
41
49
|
|
|
42
|
-
invalidate(): void {
|
|
43
|
-
/* stateless */
|
|
44
|
-
}
|
|
50
|
+
invalidate(): void {}
|
|
45
51
|
|
|
46
52
|
render(width: number, maxLines: number): string[] {
|
|
47
53
|
const th = this.theme;
|
|
54
|
+
const w = Math.max(10, width);
|
|
48
55
|
|
|
49
56
|
if (!this.taskId) {
|
|
50
|
-
return
|
|
57
|
+
return pad(["", th.fg("muted", " No task selected")], maxLines);
|
|
51
58
|
}
|
|
52
59
|
|
|
53
60
|
const task = store.loadTask(this.squadId, this.taskId);
|
|
54
61
|
if (!task) {
|
|
55
|
-
return
|
|
62
|
+
return pad(["", th.fg("error", " Task not found")], maxLines);
|
|
56
63
|
}
|
|
57
64
|
|
|
58
|
-
const
|
|
59
|
-
const lines: string[] = [];
|
|
65
|
+
const allMessages = store.loadMessages(this.squadId, this.taskId);
|
|
60
66
|
|
|
61
|
-
// Header
|
|
67
|
+
// Header (fixed, always visible)
|
|
68
|
+
const header: string[] = [];
|
|
62
69
|
const statusColor = task.status === "done" ? "success"
|
|
63
70
|
: task.status === "failed" ? "error"
|
|
64
71
|
: task.status === "in_progress" ? "warning"
|
|
65
72
|
: "muted";
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
73
|
+
header.push(fit(` ${th.fg("accent", th.bold(task.id))} · ${th.fg("dim", task.agent)} ${th.fg(statusColor as any, task.status)}`, w));
|
|
74
|
+
header.push(fit(` ${th.fg("dim", task.title)}`, w));
|
|
75
|
+
header.push("");
|
|
69
76
|
|
|
70
|
-
if (
|
|
71
|
-
|
|
72
|
-
return
|
|
77
|
+
if (allMessages.length === 0) {
|
|
78
|
+
header.push(th.fg("muted", " No messages yet"));
|
|
79
|
+
return pad(header, maxLines);
|
|
73
80
|
}
|
|
74
81
|
|
|
75
|
-
//
|
|
76
|
-
const
|
|
82
|
+
// Only render recent messages to prevent TUI overload
|
|
83
|
+
const messages = allMessages.slice(-MAX_MESSAGES);
|
|
84
|
+
const skipped = allMessages.length - messages.length;
|
|
77
85
|
|
|
78
|
-
|
|
79
|
-
|
|
86
|
+
const msgLines: string[] = [];
|
|
87
|
+
if (skipped > 0) {
|
|
88
|
+
msgLines.push(fit(th.fg("dim", ` ··· ${skipped} older messages ···`), w));
|
|
89
|
+
msgLines.push("");
|
|
90
|
+
}
|
|
91
|
+
msgLines.push(...this.renderMessages(messages, w));
|
|
92
|
+
|
|
93
|
+
// Fixed layout: header + scrollable content + status line = maxLines exactly
|
|
94
|
+
const statusLines = 1; // always show status/scroll bar
|
|
95
|
+
const contentHeight = Math.max(1, maxLines - header.length - statusLines);
|
|
80
96
|
const maxScroll = Math.max(0, msgLines.length - contentHeight);
|
|
81
|
-
|
|
82
|
-
// Auto-scroll to bottom
|
|
83
|
-
if (this.
|
|
97
|
+
|
|
98
|
+
// Auto-scroll to bottom unless user scrolled up
|
|
99
|
+
if (!this.userScrolled) {
|
|
84
100
|
this.scrollOffset = maxScroll;
|
|
101
|
+
} else {
|
|
102
|
+
this.scrollOffset = Math.min(this.scrollOffset, maxScroll);
|
|
103
|
+
if (this.scrollOffset >= maxScroll) {
|
|
104
|
+
this.userScrolled = false;
|
|
105
|
+
}
|
|
85
106
|
}
|
|
86
107
|
|
|
108
|
+
// Build output — exact height every time
|
|
109
|
+
const lines = [...header];
|
|
110
|
+
|
|
111
|
+
// Content area: pad to exact contentHeight
|
|
87
112
|
const visible = msgLines.slice(this.scrollOffset, this.scrollOffset + contentHeight);
|
|
88
|
-
|
|
113
|
+
while (visible.length < contentHeight) visible.push("");
|
|
114
|
+
lines.push(...visible.slice(0, contentHeight));
|
|
89
115
|
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
116
|
+
// Status bar (always present, keeps layout stable)
|
|
117
|
+
const pct = maxScroll > 0 ? Math.round((this.scrollOffset / maxScroll) * 100) : 100;
|
|
118
|
+
const scrollInfo = maxScroll > 0
|
|
119
|
+
? th.fg("dim", ` ─ ${pct}% ─ ${allMessages.length} msgs ─ ↑↓ scroll`)
|
|
120
|
+
: th.fg("dim", ` ─ ${allMessages.length} msgs`);
|
|
121
|
+
lines.push(fit(scrollInfo, w));
|
|
95
122
|
|
|
96
|
-
return
|
|
123
|
+
// Strict: return exactly maxLines
|
|
124
|
+
return lines.slice(0, maxLines);
|
|
97
125
|
}
|
|
98
126
|
|
|
99
|
-
// =========================================================================
|
|
100
|
-
// Message Rendering
|
|
101
|
-
// =========================================================================
|
|
102
|
-
|
|
103
127
|
private renderMessages(messages: TaskMessage[], width: number): string[] {
|
|
104
128
|
const th = this.theme;
|
|
105
129
|
const lines: string[] = [];
|
|
106
130
|
let lastFrom: string | null = null;
|
|
107
131
|
|
|
108
132
|
for (const msg of messages) {
|
|
109
|
-
|
|
110
|
-
if (msg.type === "status" && msg.from === "system" && msg.text === "Agent starting work") {
|
|
111
|
-
continue;
|
|
112
|
-
}
|
|
133
|
+
if (msg.type === "status" && msg.from === "system" && msg.text === "Agent starting work") continue;
|
|
113
134
|
|
|
114
|
-
// Group consecutive messages from the same sender
|
|
115
135
|
const showHeader = msg.from !== lastFrom;
|
|
116
136
|
lastFrom = msg.from;
|
|
117
137
|
|
|
118
138
|
if (showHeader) {
|
|
119
|
-
// Blank line between different senders
|
|
120
139
|
if (lines.length > 0) lines.push("");
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
const senderName = msg.from === "human" ? "YOU" : msg.from;
|
|
126
|
-
lines.push(` ${th.fg("dim", time)} ${th.fg(senderColor as any, senderName)}`);
|
|
140
|
+
const time = fmtTime(msg.ts);
|
|
141
|
+
const color = msg.from === "human" ? "accent" : msg.from === "system" ? "dim" : "success";
|
|
142
|
+
const name = msg.from === "human" ? "YOU" : msg.from;
|
|
143
|
+
lines.push(fit(` ${th.fg("dim", time)} ${th.fg(color as any, name)}`, width));
|
|
127
144
|
}
|
|
128
145
|
|
|
129
|
-
// Message content
|
|
130
146
|
switch (msg.type) {
|
|
131
147
|
case "tool": {
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
lines.push(` ${th.fg("muted", preview.slice(0, width - 4))}`);
|
|
148
|
+
const name = msg.name || msg.text;
|
|
149
|
+
// Tool args can contain multi-line bash commands — take first line only
|
|
150
|
+
const rawArg = (msg.args?.path || msg.args?.command || "").toString();
|
|
151
|
+
const arg = rawArg.split("\n")[0];
|
|
152
|
+
lines.push(fit(` ${th.fg("muted", `→ ${name}${arg ? " " + arg : ""}`)}`, width));
|
|
138
153
|
break;
|
|
139
154
|
}
|
|
140
|
-
|
|
141
155
|
case "mention": {
|
|
142
|
-
|
|
143
|
-
lines.push(` ${th.fg("accent", `@${msg.to || "?"}`)} ${th.fg("dim", msg.text.slice(0, width - 10))}`);
|
|
156
|
+
lines.push(fit(` ${th.fg("accent", `@${msg.to || "?"}`)} ${th.fg("dim", msg.text)}`, width));
|
|
144
157
|
break;
|
|
145
158
|
}
|
|
146
|
-
|
|
147
159
|
case "text":
|
|
148
160
|
case "message":
|
|
149
161
|
case "reply": {
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
for (const
|
|
153
|
-
lines
|
|
162
|
+
const textLines = msg.text.replace(/\r/g, "").split("\n");
|
|
163
|
+
const show = textLines.slice(0, MAX_TEXT_LINES);
|
|
164
|
+
for (const tl of show) {
|
|
165
|
+
// Wrap long lines instead of truncating
|
|
166
|
+
const wrapped = wrap(` ${tl}`, width, " ");
|
|
167
|
+
lines.push(...wrapped);
|
|
154
168
|
}
|
|
155
|
-
if (textLines.length >
|
|
156
|
-
lines.push(` ${th.fg("dim", `... +${textLines.length -
|
|
169
|
+
if (textLines.length > MAX_TEXT_LINES) {
|
|
170
|
+
lines.push(fit(` ${th.fg("dim", `... +${textLines.length - MAX_TEXT_LINES} lines`)}`, width));
|
|
157
171
|
}
|
|
158
172
|
break;
|
|
159
173
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
lines.push(` ${th.fg("success", "✓ " + msg.text.slice(0, width - 6))}`);
|
|
174
|
+
case "done":
|
|
175
|
+
lines.push(...wrap(` ✓ ${msg.text}`, width, " "));
|
|
163
176
|
break;
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
case "error": {
|
|
167
|
-
lines.push(` ${th.fg("error", "✗ " + msg.text.slice(0, width - 6))}`);
|
|
177
|
+
case "error":
|
|
178
|
+
lines.push(...wrap(` ✗ ${msg.text}`, width, " "));
|
|
168
179
|
break;
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
case "status": {
|
|
172
|
-
lines.push(` ${th.fg("dim", msg.text.slice(0, width - 4))}`);
|
|
180
|
+
case "status":
|
|
181
|
+
lines.push(fit(` ${th.fg("dim", msg.text)}`, width));
|
|
173
182
|
break;
|
|
174
|
-
}
|
|
175
183
|
}
|
|
176
184
|
}
|
|
177
185
|
|
|
178
186
|
return lines;
|
|
179
187
|
}
|
|
188
|
+
}
|
|
180
189
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
if (from === "human") return "accent";
|
|
187
|
-
if (from === "system") return "dim";
|
|
188
|
-
// Rotate through available colors for different agents
|
|
189
|
-
const colors = ["success", "warning", "accent", "muted"];
|
|
190
|
-
let hash = 0;
|
|
191
|
-
for (const c of from) hash = (hash * 31 + c.charCodeAt(0)) & 0x7fffffff;
|
|
192
|
-
return colors[hash % colors.length];
|
|
193
|
-
}
|
|
190
|
+
/** Truncate a single line (for headers, tool calls, status — not text content) */
|
|
191
|
+
function fit(line: string, width: number): string {
|
|
192
|
+
const clean = line.replace(/[\n\r]/g, " ");
|
|
193
|
+
return truncateToWidth(clean, width, "…");
|
|
194
|
+
}
|
|
194
195
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
196
|
+
/** Wrap a text line into multiple lines that fit within width.
|
|
197
|
+
* Uses ANSI-aware visibleWidth for correct wrapping with styled text.
|
|
198
|
+
* Returns array of lines, each guaranteed to fit within width. */
|
|
199
|
+
function wrap(line: string, width: number, indent: string = " "): string[] {
|
|
200
|
+
const clean = line.replace(/[\n\r]/g, " ");
|
|
201
|
+
// Fast path: already fits
|
|
202
|
+
if (visibleWidth(clean) <= width) return [clean];
|
|
203
|
+
|
|
204
|
+
// For styled text, we can't word-wrap by chars (ANSI codes break).
|
|
205
|
+
// Instead, strip to plain text, wrap that, then truncate styled lines.
|
|
206
|
+
const plain = stripAnsi(clean);
|
|
207
|
+
const indentW = visibleWidth(indent);
|
|
208
|
+
const firstW = width;
|
|
209
|
+
const contW = width - indentW;
|
|
210
|
+
|
|
211
|
+
const results: string[] = [];
|
|
212
|
+
let remaining = plain;
|
|
213
|
+
let isFirst = true;
|
|
214
|
+
|
|
215
|
+
while (remaining.length > 0) {
|
|
216
|
+
const maxW = isFirst ? firstW : contW;
|
|
217
|
+
if (remaining.length <= maxW) {
|
|
218
|
+
results.push(isFirst ? remaining : indent + remaining);
|
|
219
|
+
break;
|
|
203
220
|
}
|
|
221
|
+
// Find word break point
|
|
222
|
+
let breakAt = remaining.lastIndexOf(" ", maxW);
|
|
223
|
+
if (breakAt <= maxW * 0.3) breakAt = maxW; // No good break, hard cut
|
|
224
|
+
const chunk = remaining.slice(0, breakAt);
|
|
225
|
+
results.push(isFirst ? chunk : indent + chunk);
|
|
226
|
+
remaining = remaining.slice(breakAt).trimStart();
|
|
227
|
+
isFirst = false;
|
|
204
228
|
}
|
|
205
229
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
if (rawLine.length <= maxWidth) {
|
|
210
|
-
lines.push(rawLine);
|
|
211
|
-
} else {
|
|
212
|
-
// Simple word wrap
|
|
213
|
-
let remaining = rawLine;
|
|
214
|
-
while (remaining.length > maxWidth) {
|
|
215
|
-
const breakAt = remaining.lastIndexOf(" ", maxWidth);
|
|
216
|
-
const splitAt = breakAt > maxWidth * 0.3 ? breakAt : maxWidth;
|
|
217
|
-
lines.push(remaining.slice(0, splitAt));
|
|
218
|
-
remaining = remaining.slice(splitAt).trimStart();
|
|
219
|
-
}
|
|
220
|
-
if (remaining) lines.push(remaining);
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
return lines;
|
|
224
|
-
}
|
|
230
|
+
// Truncate each to be safe (handles edge cases)
|
|
231
|
+
return results.map(r => truncateToWidth(r, width, ""));
|
|
232
|
+
}
|
|
225
233
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
234
|
+
function stripAnsi(str: string): string {
|
|
235
|
+
return str.replace(/\x1b\[[0-9;]*m/g, "");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function pad(lines: string[], max: number): string[] {
|
|
239
|
+
while (lines.length < max) lines.push("");
|
|
240
|
+
return lines.slice(0, max);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function fmtTime(ts: string): string {
|
|
244
|
+
try {
|
|
245
|
+
const d = new Date(ts);
|
|
246
|
+
return `${d.getHours().toString().padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}`;
|
|
247
|
+
} catch {
|
|
248
|
+
return "??:??";
|
|
231
249
|
}
|
|
232
250
|
}
|