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.
@@ -1,21 +1,26 @@
1
1
  /**
2
2
  * message-view.ts — Scrollable message log for a task.
3
- * Shows tool calls, agent text, @mentions, human messages.
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
- // Message View
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 this.padToHeight(["", th.fg("muted", " No task selected")], maxLines);
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 this.padToHeight(["", th.fg("error", " Task not found")], maxLines);
62
+ return pad(["", th.fg("error", " Task not found")], maxLines);
56
63
  }
57
64
 
58
- const messages = store.loadMessages(this.squadId, this.taskId);
59
- const lines: string[] = [];
65
+ const allMessages = store.loadMessages(this.squadId, this.taskId);
60
66
 
61
- // Header: task info
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
- lines.push(` ${th.fg("accent", th.bold(task.id))} · ${th.fg("dim", task.agent)} ${th.fg(statusColor as any, task.status)}`);
67
- lines.push(` ${th.fg("dim", task.title.slice(0, width - 2))}`);
68
- lines.push("");
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 (messages.length === 0) {
71
- lines.push(th.fg("muted", " No messages yet"));
72
- return this.padToHeight(lines, maxLines);
77
+ if (allMessages.length === 0) {
78
+ header.push(th.fg("muted", " No messages yet"));
79
+ return pad(header, maxLines);
73
80
  }
74
81
 
75
- // Render messages
76
- const msgLines = this.renderMessages(messages, width);
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
- // Apply scroll
79
- const contentHeight = maxLines - lines.length - 1;
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
- this.scrollOffset = Math.min(this.scrollOffset, maxScroll);
82
- // Auto-scroll to bottom if near the end
83
- if (this.scrollOffset >= maxScroll - 2) {
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
- lines.push(...visible);
113
+ while (visible.length < contentHeight) visible.push("");
114
+ lines.push(...visible.slice(0, contentHeight));
89
115
 
90
- // Scroll indicator
91
- if (msgLines.length > contentHeight) {
92
- const pos = maxScroll > 0 ? Math.round((this.scrollOffset / maxScroll) * 100) : 100;
93
- lines.push(th.fg("dim", ` ─── ${pos}% ───`));
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 this.padToHeight(lines, maxLines);
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
- // Skip system status messages that are noise
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
- // Sender header with timestamp
123
- const time = this.formatTime(msg.ts);
124
- const senderColor = this.getSenderColor(msg.from);
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 toolName = msg.name || msg.text;
133
- const argsStr = msg.args?.path || msg.args?.command || "";
134
- const preview = argsStr
135
- ? `→ ${toolName} ${argsStr}`
136
- : `→ ${toolName}`;
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
- const target = msg.to ? `→ ${msg.to}` : "";
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
- // Wrap long text
151
- const textLines = this.wrapText(msg.text, width - 4);
152
- for (const textLine of textLines.slice(0, 10)) {
153
- lines.push(` ${textLine}`);
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 > 10) {
156
- lines.push(` ${th.fg("dim", `... +${textLines.length - 10} lines`)}`);
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
- case "done": {
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
- // Helpers
183
- // =========================================================================
184
-
185
- private getSenderColor(from: string): string {
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
- 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 "??:??";
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
- private wrapText(text: string, maxWidth: number): string[] {
207
- const lines: string[] = [];
208
- for (const rawLine of text.split("\n")) {
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
- private padToHeight(lines: string[], maxLines: number): string[] {
227
- while (lines.length < maxLines) {
228
- lines.push("");
229
- }
230
- return lines.slice(0, maxLines);
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
  }