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.
- package/README.md +248 -70
- package/package.json +7 -3
- package/src/index.ts +559 -320
- package/src/panel/message-view.ts +100 -125
- 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 +188 -3
- 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 } 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,169 @@ 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 textLines =
|
|
152
|
-
|
|
153
|
-
|
|
162
|
+
// Split text, strip any \r, ensure no embedded newlines survive
|
|
163
|
+
const textLines = msg.text.replace(/\r/g, "").split("\n");
|
|
164
|
+
const show = textLines.slice(0, MAX_TEXT_LINES);
|
|
165
|
+
for (const tl of show) {
|
|
166
|
+
lines.push(fit(` ${tl}`, width));
|
|
154
167
|
}
|
|
155
|
-
if (textLines.length >
|
|
156
|
-
lines.push(` ${th.fg("dim", `... +${textLines.length -
|
|
168
|
+
if (textLines.length > MAX_TEXT_LINES) {
|
|
169
|
+
lines.push(fit(` ${th.fg("dim", `... +${textLines.length - MAX_TEXT_LINES} lines`)}`, width));
|
|
157
170
|
}
|
|
158
171
|
break;
|
|
159
172
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
lines.push(` ${th.fg("success", "✓ " + msg.text.slice(0, width - 6))}`);
|
|
173
|
+
case "done":
|
|
174
|
+
lines.push(fit(` ${th.fg("success", "✓ " + msg.text)}`, width));
|
|
163
175
|
break;
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
case "error": {
|
|
167
|
-
lines.push(` ${th.fg("error", "✗ " + msg.text.slice(0, width - 6))}`);
|
|
176
|
+
case "error":
|
|
177
|
+
lines.push(fit(` ${th.fg("error", "✗ " + msg.text)}`, width));
|
|
168
178
|
break;
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
case "status": {
|
|
172
|
-
lines.push(` ${th.fg("dim", msg.text.slice(0, width - 4))}`);
|
|
179
|
+
case "status":
|
|
180
|
+
lines.push(fit(` ${th.fg("dim", msg.text)}`, width));
|
|
173
181
|
break;
|
|
174
|
-
}
|
|
175
182
|
}
|
|
176
183
|
}
|
|
177
184
|
|
|
178
185
|
return lines;
|
|
179
186
|
}
|
|
187
|
+
}
|
|
180
188
|
|
|
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
|
-
}
|
|
194
|
-
|
|
195
|
-
private formatTime(ts: string): string {
|
|
196
|
-
try {
|
|
197
|
-
const d = new Date(ts);
|
|
198
|
-
const h = d.getHours().toString().padStart(2, "0");
|
|
199
|
-
const m = d.getMinutes().toString().padStart(2, "0");
|
|
200
|
-
return `${h}:${m}`;
|
|
201
|
-
} catch {
|
|
202
|
-
return "??:??";
|
|
203
|
-
}
|
|
204
|
-
}
|
|
189
|
+
function fit(line: string, width: number): string {
|
|
190
|
+
// Strip any newlines that would create extra terminal lines and break layout math
|
|
191
|
+
const clean = line.replace(/[\n\r]/g, " ");
|
|
192
|
+
return truncateToWidth(clean, width, "…");
|
|
193
|
+
}
|
|
205
194
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
}
|
|
195
|
+
function pad(lines: string[], max: number): string[] {
|
|
196
|
+
while (lines.length < max) lines.push("");
|
|
197
|
+
return lines.slice(0, max);
|
|
198
|
+
}
|
|
225
199
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
}
|
|
230
|
-
|
|
200
|
+
function fmtTime(ts: string): string {
|
|
201
|
+
try {
|
|
202
|
+
const d = new Date(ts);
|
|
203
|
+
return `${d.getHours().toString().padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}`;
|
|
204
|
+
} catch {
|
|
205
|
+
return "??:??";
|
|
231
206
|
}
|
|
232
207
|
}
|