@aliou/pi-processes 0.4.7 → 0.6.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 +83 -13
- package/package.json +10 -4
- package/src/commands/clear/command.ts +14 -0
- package/src/commands/clear/index.ts +1 -0
- package/src/commands/completions.ts +38 -0
- package/src/commands/dock/command.ts +29 -0
- package/src/commands/dock/index.ts +1 -0
- package/src/commands/index.ts +26 -327
- package/src/commands/kill/command.ts +69 -0
- package/src/commands/kill/index.ts +1 -0
- package/src/commands/logs/command.ts +47 -0
- package/src/commands/logs/index.ts +1 -0
- package/src/commands/pick-process.ts +34 -0
- package/src/commands/pin/command.ts +33 -0
- package/src/commands/pin/index.ts +1 -0
- package/src/commands/processes/command.ts +39 -0
- package/src/commands/processes/index.ts +1 -0
- package/src/commands/settings/apply-setting-change.ts +72 -0
- package/src/commands/settings/build-sections.ts +160 -0
- package/src/commands/settings/command.ts +20 -0
- package/src/commands/settings/index.ts +1 -0
- package/src/components/log-dock-component.ts +238 -0
- package/src/components/log-file-viewer.ts +317 -0
- package/src/components/log-overlay-component.ts +555 -0
- package/src/components/process-picker-component.ts +14 -2
- package/src/components/processes-component.ts +41 -21
- package/src/config.ts +38 -1
- package/src/constants/index.ts +1 -0
- package/src/constants/types.ts +7 -1
- package/src/hooks/background-blocker.ts +66 -0
- package/src/hooks/index.ts +15 -3
- package/src/hooks/process-end.ts +3 -30
- package/src/hooks/widget/index.ts +2 -0
- package/src/hooks/widget/setup.ts +168 -0
- package/src/hooks/{widget.ts → widget/status-widget.ts} +27 -68
- package/src/hooks/widget/types.ts +21 -0
- package/src/index.ts +9 -3
- package/src/manager.ts +61 -9
- package/src/tools/actions/index.ts +5 -0
- package/src/tools/actions/write.ts +87 -0
- package/src/tools/index.ts +82 -14
- package/src/utils/command-executor.test.ts +2 -1
- package/src/utils/command-executor.ts +1 -1
- package/src/utils/keybindings.ts +76 -0
- package/src/utils/shell-utils.ts +133 -0
- package/src/commands/settings-command.ts +0 -157
- package/src/components/log-stream-component.ts +0 -149
- package/src/test/test-exit-crash.sh +0 -19
- package/src/test/test-exit-failure.sh +0 -17
- package/src/test/test-exit-success.sh +0 -16
- package/src/test/test-output.sh +0 -28
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LogFileViewer - reads a single log file and renders a scrollable,
|
|
3
|
+
* searchable window of lines.
|
|
4
|
+
*
|
|
5
|
+
* A plain helper class (not a Component). Consumed by LogDockComponent
|
|
6
|
+
* (open mode) and LogOverlayComponent (tabbed overlay). Callers are
|
|
7
|
+
* responsible for polling / invalidating when file content changes.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
12
|
+
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
13
|
+
import { stripAnsi } from "../utils";
|
|
14
|
+
|
|
15
|
+
export type StreamFilter = "combined" | "stdout" | "stderr";
|
|
16
|
+
export type LineFormat = "plain" | "combined";
|
|
17
|
+
|
|
18
|
+
interface ParsedLine {
|
|
19
|
+
type: "stdout" | "stderr";
|
|
20
|
+
text: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface LogFileViewerOptions {
|
|
24
|
+
filePath: string;
|
|
25
|
+
/** "plain" = raw lines (stdout/stderr files), "combined" = manager's 1:/2: tagged format */
|
|
26
|
+
format: LineFormat;
|
|
27
|
+
theme: Theme;
|
|
28
|
+
/** Start in follow mode (auto-scroll to tail). Default: false */
|
|
29
|
+
follow?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class LogFileViewer {
|
|
33
|
+
private filePath: string;
|
|
34
|
+
private format: LineFormat;
|
|
35
|
+
private theme: Theme;
|
|
36
|
+
|
|
37
|
+
private follow: boolean;
|
|
38
|
+
/** Absolute index of the last visible line (1-based).
|
|
39
|
+
* null = follow mode; always shows latest lines. */
|
|
40
|
+
private anchorEnd: number | null = null;
|
|
41
|
+
private streamFilter: StreamFilter = "combined";
|
|
42
|
+
|
|
43
|
+
private searchQuery = "";
|
|
44
|
+
private searchMatches: number[] = [];
|
|
45
|
+
private searchCurrentMatch = -1;
|
|
46
|
+
|
|
47
|
+
/** Line index (0-based) to center in the viewport. null = not centering. */
|
|
48
|
+
private centerTarget: number | null = null;
|
|
49
|
+
|
|
50
|
+
constructor(opts: LogFileViewerOptions) {
|
|
51
|
+
this.filePath = opts.filePath;
|
|
52
|
+
this.format = opts.format;
|
|
53
|
+
this.theme = opts.theme;
|
|
54
|
+
this.follow = opts.follow ?? false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// File reading
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
private readAllLines(): ParsedLine[] {
|
|
62
|
+
try {
|
|
63
|
+
const content = readFileSync(this.filePath, "utf-8");
|
|
64
|
+
const rawLines = content.split("\n");
|
|
65
|
+
// Remove trailing empty string produced by a trailing newline.
|
|
66
|
+
if (rawLines.length > 0 && rawLines[rawLines.length - 1] === "") {
|
|
67
|
+
rawLines.pop();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (this.format === "plain") {
|
|
71
|
+
return rawLines.map((line) => ({
|
|
72
|
+
type: "stdout" as const,
|
|
73
|
+
text: line,
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Combined format: "1:text" = stdout, "2:text" = stderr
|
|
78
|
+
return rawLines.map((line) => {
|
|
79
|
+
if (line.startsWith("2:")) {
|
|
80
|
+
return { type: "stderr" as const, text: line.slice(2) };
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
type: "stdout" as const,
|
|
84
|
+
text: line.startsWith("1:") ? line.slice(2) : line,
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
} catch {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private applyFilter(allLines: ParsedLine[]): ParsedLine[] {
|
|
93
|
+
if (this.streamFilter === "combined") return allLines;
|
|
94
|
+
const keep = this.streamFilter === "stdout" ? "stdout" : "stderr";
|
|
95
|
+
return allLines.filter((l) => l.type === keep);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private computeMatches(lines: ParsedLine[]): number[] {
|
|
99
|
+
if (!this.searchQuery) return [];
|
|
100
|
+
const q = this.searchQuery.toLowerCase();
|
|
101
|
+
return lines.reduce<number[]>((acc, line, i) => {
|
|
102
|
+
if (stripAnsi(line.text).toLowerCase().includes(q)) acc.push(i);
|
|
103
|
+
return acc;
|
|
104
|
+
}, []);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Navigation
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
scrollToTop(): void {
|
|
112
|
+
this.anchorEnd = 0;
|
|
113
|
+
this.follow = false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
scrollToBottom(): void {
|
|
117
|
+
const lines = this.applyFilter(this.readAllLines());
|
|
118
|
+
this.anchorEnd = lines.length;
|
|
119
|
+
this.follow = false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** delta > 0 = scroll toward older content, delta < 0 = toward newer. */
|
|
123
|
+
scrollBy(delta: number): void {
|
|
124
|
+
if (this.anchorEnd === null) {
|
|
125
|
+
const lines = this.applyFilter(this.readAllLines());
|
|
126
|
+
this.anchorEnd = lines.length;
|
|
127
|
+
}
|
|
128
|
+
this.anchorEnd = Math.max(0, this.anchorEnd + delta);
|
|
129
|
+
this.follow = false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
toggleFollow(): boolean {
|
|
133
|
+
this.follow = !this.follow;
|
|
134
|
+
if (this.follow) {
|
|
135
|
+
this.anchorEnd = null;
|
|
136
|
+
} else {
|
|
137
|
+
const lines = this.applyFilter(this.readAllLines());
|
|
138
|
+
this.anchorEnd = lines.length;
|
|
139
|
+
}
|
|
140
|
+
return this.follow;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
isFollowing(): boolean {
|
|
144
|
+
return this.follow;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
cycleStreamFilter(): StreamFilter {
|
|
148
|
+
const order: StreamFilter[] = ["combined", "stdout", "stderr"];
|
|
149
|
+
this.streamFilter =
|
|
150
|
+
order[(order.indexOf(this.streamFilter) + 1) % order.length];
|
|
151
|
+
// Invalidate search since the line set changed.
|
|
152
|
+
this.searchMatches = [];
|
|
153
|
+
this.searchCurrentMatch = -1;
|
|
154
|
+
return this.streamFilter;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
getStreamFilter(): StreamFilter {
|
|
158
|
+
return this.streamFilter;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// Search
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
setSearch(query: string): void {
|
|
166
|
+
this.searchQuery = query;
|
|
167
|
+
const lines = this.applyFilter(this.readAllLines());
|
|
168
|
+
this.searchMatches = this.computeMatches(lines);
|
|
169
|
+
if (this.searchMatches.length > 0) {
|
|
170
|
+
// Start at the last match so the user lands near the tail.
|
|
171
|
+
this.searchCurrentMatch = this.searchMatches.length - 1;
|
|
172
|
+
this.jumpToMatchLine(this.searchMatches[this.searchCurrentMatch]);
|
|
173
|
+
} else {
|
|
174
|
+
this.searchCurrentMatch = -1;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
clearSearch(): void {
|
|
179
|
+
this.searchQuery = "";
|
|
180
|
+
this.searchMatches = [];
|
|
181
|
+
this.searchCurrentMatch = -1;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private jumpToMatchLine(lineIdx: number): void {
|
|
185
|
+
this.centerTarget = lineIdx;
|
|
186
|
+
this.follow = false;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
nextMatch(): void {
|
|
190
|
+
if (this.searchMatches.length === 0) return;
|
|
191
|
+
this.searchCurrentMatch =
|
|
192
|
+
(this.searchCurrentMatch + 1) % this.searchMatches.length;
|
|
193
|
+
this.jumpToMatchLine(this.searchMatches[this.searchCurrentMatch]);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
prevMatch(): void {
|
|
197
|
+
if (this.searchMatches.length === 0) return;
|
|
198
|
+
this.searchCurrentMatch =
|
|
199
|
+
(this.searchCurrentMatch - 1 + this.searchMatches.length) %
|
|
200
|
+
this.searchMatches.length;
|
|
201
|
+
this.jumpToMatchLine(this.searchMatches[this.searchCurrentMatch]);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
getSearchInfo(): { query: string; current: number; total: number } | null {
|
|
205
|
+
if (!this.searchQuery) return null;
|
|
206
|
+
return {
|
|
207
|
+
query: this.searchQuery,
|
|
208
|
+
current: this.searchCurrentMatch + 1, // 1-based for display
|
|
209
|
+
total: this.searchMatches.length,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// Rendering
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
/** Returns up to `maxLines` rendered content lines. */
|
|
218
|
+
renderLines(width: number, maxLines: number): string[] {
|
|
219
|
+
const theme = this.theme;
|
|
220
|
+
const dim = (s: string) => theme.fg("dim", s);
|
|
221
|
+
const warning = (s: string) => theme.fg("warning", s);
|
|
222
|
+
|
|
223
|
+
const allLines = this.readAllLines();
|
|
224
|
+
const lines = this.applyFilter(allLines);
|
|
225
|
+
|
|
226
|
+
// Refresh matches against current (possibly grown) data.
|
|
227
|
+
if (this.searchQuery) {
|
|
228
|
+
this.searchMatches = this.computeMatches(lines);
|
|
229
|
+
if (this.searchCurrentMatch >= this.searchMatches.length) {
|
|
230
|
+
this.searchCurrentMatch = Math.max(0, this.searchMatches.length - 1);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const total = lines.length;
|
|
235
|
+
if (total === 0) return [dim("(no output yet)")];
|
|
236
|
+
|
|
237
|
+
// Resolve centerTarget into anchorEnd now that we know maxLines.
|
|
238
|
+
if (this.centerTarget !== null) {
|
|
239
|
+
const half = Math.floor(maxLines / 2);
|
|
240
|
+
this.anchorEnd = Math.min(total, this.centerTarget + half + 1);
|
|
241
|
+
this.centerTarget = null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Resolve anchor: null = follow (tail), number = absolute frozen end.
|
|
245
|
+
const rawEnd = this.anchorEnd ?? total;
|
|
246
|
+
// Clamp to valid range. Math.max with min(maxLines, total) ensures anchorEnd = 0
|
|
247
|
+
// (scrollToTop sentinel) still shows a full window from the top.
|
|
248
|
+
const endIdx = Math.min(total, Math.max(rawEnd, Math.min(maxLines, total)));
|
|
249
|
+
const startIdx = Math.max(0, endIdx - maxLines);
|
|
250
|
+
|
|
251
|
+
const currentMatchIdx =
|
|
252
|
+
this.searchCurrentMatch >= 0 &&
|
|
253
|
+
this.searchCurrentMatch < this.searchMatches.length
|
|
254
|
+
? this.searchMatches[this.searchCurrentMatch]
|
|
255
|
+
: -1;
|
|
256
|
+
const matchSet = new Set(this.searchMatches);
|
|
257
|
+
|
|
258
|
+
return lines.slice(startIdx, endIdx).map((line, i) => {
|
|
259
|
+
const absIdx = startIdx + i;
|
|
260
|
+
const text = truncateToWidth(stripAnsi(line.text), width);
|
|
261
|
+
|
|
262
|
+
if (absIdx === currentMatchIdx) return theme.bold(theme.inverse(text));
|
|
263
|
+
if (matchSet.has(absIdx)) return warning(text);
|
|
264
|
+
if (line.type === "stderr") return warning(text);
|
|
265
|
+
return text;
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Returns a single status-bar string exactly `width` characters wide
|
|
271
|
+
* (visible width). Shows position, stream filter, and search state.
|
|
272
|
+
*/
|
|
273
|
+
renderStatusBar(width: number): string {
|
|
274
|
+
const theme = this.theme;
|
|
275
|
+
const dim = (s: string) => theme.fg("dim", s);
|
|
276
|
+
const accent = (s: string) => theme.fg("accent", s);
|
|
277
|
+
|
|
278
|
+
const lines = this.applyFilter(this.readAllLines());
|
|
279
|
+
const total = lines.length;
|
|
280
|
+
|
|
281
|
+
// Right side: position + stream filter
|
|
282
|
+
const rightParts: string[] = [];
|
|
283
|
+
if (this.follow) {
|
|
284
|
+
rightParts.push(accent("following"));
|
|
285
|
+
} else if (total === 0) {
|
|
286
|
+
rightParts.push(dim("empty"));
|
|
287
|
+
} else {
|
|
288
|
+
const rawEnd = this.anchorEnd ?? total;
|
|
289
|
+
const endIdx = Math.min(total, Math.max(0, rawEnd));
|
|
290
|
+
const pct = Math.round((endIdx / total) * 100);
|
|
291
|
+
rightParts.push(dim(`${pct}% L${Math.min(endIdx, total)}/${total}`));
|
|
292
|
+
}
|
|
293
|
+
if (this.streamFilter !== "combined") {
|
|
294
|
+
rightParts.push(dim(`[${this.streamFilter}]`));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Left side: search state
|
|
298
|
+
const searchInfo = this.getSearchInfo();
|
|
299
|
+
let left = "";
|
|
300
|
+
if (searchInfo) {
|
|
301
|
+
left =
|
|
302
|
+
searchInfo.total === 0
|
|
303
|
+
? theme.fg("error", `no matches: "${searchInfo.query}"`)
|
|
304
|
+
: `${dim("/")}${searchInfo.query} ${dim(`${searchInfo.current}/${searchInfo.total}`)}`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const right = rightParts.join(" ");
|
|
308
|
+
const leftW = visibleWidth(left);
|
|
309
|
+
const rightW = visibleWidth(right);
|
|
310
|
+
const gap = Math.max(1, width - leftW - rightW);
|
|
311
|
+
const bar = left + " ".repeat(gap) + right;
|
|
312
|
+
const barW = visibleWidth(bar);
|
|
313
|
+
|
|
314
|
+
if (barW > width) return truncateToWidth(bar, width);
|
|
315
|
+
return bar + " ".repeat(width - barW);
|
|
316
|
+
}
|
|
317
|
+
}
|