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,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 } 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,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 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
+ // 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 > 10) {
156
- lines.push(` ${th.fg("dim", `... +${textLines.length - 10} lines`)}`);
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
- case "done": {
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
- // 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
- }
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
- 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
- }
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
- private padToHeight(lines: string[], maxLines: number): string[] {
227
- while (lines.length < maxLines) {
228
- lines.push("");
229
- }
230
- return lines.slice(0, maxLines);
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
  }