pi-famulus 0.1.7 → 0.1.8

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.
@@ -0,0 +1,417 @@
1
+ /**
2
+ * /tasks detail for a shell or monitor, matching `pi-famulus top`:
3
+ *
4
+ * ◆ sh_ab12 · pytest -n 4 ● following
5
+ * ● running shell 12s exit - pinned info
6
+ * $ pytest -n 4
7
+ * cwd ~/app started 14:45:32 run run_1 …/sh_ab12.output
8
+ * ─┐1 output┌┐2 stderr┌──────────────────────── 24 lines · 12 blank hidden ─
9
+ * …output tail, blank lines hidden, matches highlighted…
10
+ * / search & filter b show blank 1 2 tab f follow esc close
11
+ *
12
+ * `/` searches as you type: matches highlight and `n` / `N` step through
13
+ * them, or `&` (Tab while typing) keeps only matching lines. Lowercase is
14
+ * case-insensitive, an uppercase letter makes it case-sensitive, `a|b`
15
+ * matches either.
16
+ */
17
+ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
18
+ import { realClock, type Clock, type ClockTimer } from "../clock";
19
+ import { formatAge, type WorkItem } from "../work-index";
20
+ import { scrubTty } from "./conversation-view";
21
+ import { fitLines, loadPiTui, truncateToWidth, visibleWidth } from "./pi-tui-load";
22
+ import { fitKeys, positionPill, spread, tabRow, withThumb, type Key, type PaneTheme } from "./scroll-detail-view";
23
+ import { readTaskFileTailCached } from "./task-output-paths";
24
+ import { statusGlyph } from "./tool-component";
25
+
26
+ export type TaskStream = "output" | "stderr";
27
+
28
+ export interface TaskTheme extends PaneTheme {
29
+ bg?(color: string, text: string): string;
30
+ inverse?(text: string): string;
31
+ }
32
+
33
+ export interface LogView {
34
+ query: string;
35
+ editing: boolean;
36
+ filter: boolean;
37
+ showBlank: boolean;
38
+ }
39
+
40
+ export interface Query {
41
+ terms: string[];
42
+ fold: boolean;
43
+ }
44
+
45
+ /**
46
+ * Lower-case that keeps every index in place (`İ` would grow under
47
+ * toLowerCase). Final sigma folds to σ so `ος` matches `ΟΣ`, which a
48
+ * per-character fold lowers to `οσ`.
49
+ */
50
+ function foldCase(text: string): string {
51
+ let out = "";
52
+ for (const ch of text) {
53
+ const lower = ch.toLowerCase();
54
+ out += lower === "ς" ? "σ" : lower.length === ch.length ? lower : ch;
55
+ }
56
+ return out;
57
+ }
58
+
59
+ export function parseQuery(query: string): Query | null {
60
+ const fold = !/[A-Z]/.test(query);
61
+ const terms = query
62
+ .split("|")
63
+ .map((term) => term.trim())
64
+ .filter((term) => term.length > 0)
65
+ .map((term) => (fold ? foldCase(term) : term));
66
+ return terms.length > 0 ? { terms, fold } : null;
67
+ }
68
+
69
+ /** Merged [start, end) ranges of every match, left to right. */
70
+ export function matchRanges(line: string, query: Query): Array<[number, number]> {
71
+ const hay = query.fold ? foldCase(line) : line;
72
+ const found: Array<[number, number]> = [];
73
+ for (const term of query.terms) {
74
+ let from = 0;
75
+ for (;;) {
76
+ const at = hay.indexOf(term, from);
77
+ if (at < 0) break;
78
+ found.push([at, at + term.length]);
79
+ from = at + term.length;
80
+ }
81
+ }
82
+ found.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
83
+ const merged: Array<[number, number]> = [];
84
+ for (const [start, end] of found) {
85
+ const last = merged.at(-1);
86
+ if (last && start <= last[1]) last[1] = Math.max(last[1], end);
87
+ else merged.push([start, end]);
88
+ }
89
+ return merged;
90
+ }
91
+
92
+ function hit(theme: TaskTheme, text: string, current: boolean): string {
93
+ if (current) return theme.inverse ? theme.inverse(theme.bold(text)) : theme.bold(theme.fg("warning", text));
94
+ try {
95
+ if (theme.bg) return theme.bg("searchMatchBg", theme.fg("searchMatchText", text));
96
+ } catch {
97
+ // Themes without the optional search colors fall through.
98
+ }
99
+ return theme.bold(theme.fg("warning", text));
100
+ }
101
+
102
+ function highlight(segment: string, query: Query | null, theme: TaskTheme, current: boolean): { text: string; hit: boolean } {
103
+ const ranges = query ? matchRanges(segment, query) : [];
104
+ if (ranges.length === 0) return { text: segment, hit: false };
105
+ let out = "";
106
+ let at = 0;
107
+ for (const [start, end] of ranges) {
108
+ out += segment.slice(at, start) + hit(theme, segment.slice(start, end), current);
109
+ at = end;
110
+ }
111
+ return { text: out + segment.slice(at), hit: true };
112
+ }
113
+
114
+ export interface LogLayout {
115
+ lines: string[];
116
+ /** Body rows holding a match. */
117
+ matches: number[];
118
+ /** Source lines shown / in the tail. */
119
+ shown: number;
120
+ total: number;
121
+ }
122
+
123
+ /** Bodies that stand in for output; real output such as `(done)` is searched. */
124
+ const PLACEHOLDERS = new Set([
125
+ "(no output path)",
126
+ "(no output yet)",
127
+ "(no output; the command printed nothing)",
128
+ "(nothing on stderr yet)",
129
+ "(nothing on stderr)",
130
+ ]);
131
+
132
+ function isPlaceholder(text: string): boolean {
133
+ return PLACEHOLDERS.has(text.trim());
134
+ }
135
+
136
+ export function layoutLog(text: string, width: number, view: LogView, theme: TaskTheme, currentRow = -1): LogLayout {
137
+ const inner = Math.max(1, width - 1);
138
+ const source = text.replace(/\n$/, "").split("\n");
139
+ const out: LogLayout = { lines: [], matches: [], shown: 0, total: source.length };
140
+ if (isPlaceholder(text)) {
141
+ return { lines: [truncateToWidth(` ${theme.fg("dim", text.trim())}`, Math.max(1, width), "…")], matches: [], shown: 1, total: 1 };
142
+ }
143
+ const query = parseQuery(view.query);
144
+ for (const raw of source) {
145
+ const clean = scrubTty(raw.replace(/\r+$/, ""));
146
+ if (!view.showBlank && clean.trim() === "") continue;
147
+ if (view.filter && query && matchRanges(clean, query).length === 0) continue;
148
+ out.shown++;
149
+ for (const segment of fitLines(clean, inner)) {
150
+ const row = out.lines.length;
151
+ const painted = highlight(segment, query, theme, row === currentRow);
152
+ if (painted.hit) out.matches.push(row);
153
+ out.lines.push(` ${painted.text}`);
154
+ }
155
+ }
156
+ if (out.lines.length === 0) {
157
+ out.lines.push(` ${theme.fg("dim", query && view.filter ? "(no line matches)" : "(only blank lines)")}`);
158
+ }
159
+ return out;
160
+ }
161
+
162
+ function clockOf(ms: number): string {
163
+ const d = new Date(ms);
164
+ const pad = (n: number) => String(n).padStart(2, "0");
165
+ return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
166
+ }
167
+
168
+ function tilde(path: string): string {
169
+ const home = process.env.HOME;
170
+ if (!home || !path.startsWith(home)) return path;
171
+ const rest = path.slice(home.length);
172
+ // `/home/alice2` is not under `/home/alice`.
173
+ return rest === "" || rest[0] === "/" || rest[0] === "\\" ? `~${rest}` : path;
174
+ }
175
+
176
+ /** Pinned panel: state and outcome, the command, and where it runs. */
177
+ export function infoCard(item: WorkItem, now: number, theme: TaskTheme, width: number): string[] {
178
+ const { color, glyph } = statusGlyph(item.status);
179
+ const live = item.status === "running" || item.status === "pending";
180
+ const pair = (label: string, value: string) => `${theme.fg("dim", ` ${label} `)}${value}`;
181
+ let line1 = `${theme.fg(color, glyph)} ${theme.bold(theme.fg(live ? "success" : color, item.status))}${theme.fg("muted", ` ${item.kind}`)}`;
182
+ line1 += pair("wall", formatAge(item.startedAt, item.endedAt, now));
183
+ if (item.exitCode !== undefined && item.exitCode !== null) {
184
+ line1 += pair("exit", theme.bold(theme.fg(item.exitCode === 0 ? "success" : "error", String(item.exitCode))));
185
+ } else if (item.signal) {
186
+ line1 += pair("signal", theme.fg("error", item.signal));
187
+ }
188
+ if (item.endReason && item.endReason !== "exited") line1 += pair("ended", item.endReason);
189
+ if (item.error) line1 += pair("error", theme.fg("error", item.error));
190
+ const command = (item.command ?? item.title).trimEnd();
191
+ const first = command.split("\n")[0] ?? "";
192
+ const line2 = `${theme.bold(theme.fg("accent", "$ "))}${theme.bold(first)}${command.includes("\n") ? theme.fg("dim", " …") : ""}`;
193
+ const parts: string[] = [];
194
+ if (item.cwd) parts.push(`${theme.fg("dim", "cwd ")}${theme.fg("muted", tilde(item.cwd))}`);
195
+ parts.push(`${theme.fg("dim", "started ")}${theme.fg("muted", clockOf(item.startedAt))}`);
196
+ if (item.runId) parts.push(`${theme.fg("dim", "run ")}${theme.fg("muted", item.runId)}`);
197
+ let line3 = parts.join(" ");
198
+ if (item.outputPath) {
199
+ const room = width - visibleWidth(line3) - 3;
200
+ if (room > 12) line3 += ` ${theme.fg("dim", truncateLeft(item.outputPath, room))}`;
201
+ }
202
+ return [line1, line2, line3].map((line) => truncateToWidth(line, Math.max(1, width), "…"));
203
+ }
204
+
205
+ function truncateLeft(text: string, width: number): string {
206
+ return text.length <= width ? text : `…${text.slice(text.length - width + 1)}`;
207
+ }
208
+
209
+ /** File tail, or why it is empty (live vs finished). */
210
+ export function streamBody(item: WorkItem, path: string, stream: TaskStream): string {
211
+ const text = readTaskFileTailCached(path);
212
+ if (text !== "(empty)") return text;
213
+ const live = item.status === "running" || item.status === "pending";
214
+ if (stream === "stderr") return live ? "(nothing on stderr yet)" : "(nothing on stderr)";
215
+ return live ? "(no output yet)" : "(no output; the command printed nothing)";
216
+ }
217
+
218
+ export interface TaskDetailOptions {
219
+ title: string;
220
+ /** Read on every render so the panel follows the task's status. */
221
+ getItem: () => WorkItem;
222
+ paths: { outputPath: string; stderrPath: string };
223
+ pollMs?: number;
224
+ clock?: Clock;
225
+ }
226
+
227
+ const INFO_ROWS = 3;
228
+
229
+ export async function showTaskDetail(ui: ExtensionUIContext, options: TaskDetailOptions): Promise<void> {
230
+ const piTui = loadPiTui();
231
+ if (!piTui) throw new Error("pi-tui is not available");
232
+ const { matchesKey } = piTui;
233
+ const tabs: TaskStream[] = ["output", "stderr"];
234
+ const clock = options.clock ?? realClock;
235
+ await ui.custom<void>(
236
+ (tui, rawTheme, _kb, done) => {
237
+ const theme = rawTheme as unknown as TaskTheme;
238
+ let stream: TaskStream = "output";
239
+ const view: LogView = { query: "", editing: false, filter: false, showBlank: false };
240
+ let follow = true;
241
+ // `f` at the end must not re-follow on the next render; scrolling clears it.
242
+ let held = false;
243
+ let scroll = 0;
244
+ let current = 0;
245
+ let matches: number[] = [];
246
+ let seek = false;
247
+ let tabHits: Array<[number, number, TaskStream]> = [];
248
+ let tabY = INFO_ROWS + 1;
249
+ let lastHeight = 1;
250
+ let disposed = false;
251
+ let timer: ClockTimer | null = null;
252
+ if (options.pollMs && options.pollMs > 0) {
253
+ timer = clock.setInterval(() => {
254
+ if (!disposed) tui.requestRender();
255
+ }, options.pollMs);
256
+ clock.unref?.(timer);
257
+ }
258
+ const bodyHeight = () => Math.max(1, Math.max(10, tui.terminal?.rows ?? 24) - (1 + INFO_ROWS + 1 + 1));
259
+ const setStream = (next: TaskStream) => {
260
+ if (next === stream) return;
261
+ stream = next;
262
+ scroll = 0;
263
+ follow = true;
264
+ current = 0;
265
+ matches = [];
266
+ };
267
+ const jump = (forward: boolean) => {
268
+ if (matches.length === 0) return;
269
+ current = forward ? (current + 1) % matches.length : (current + matches.length - 1) % matches.length;
270
+ follow = false;
271
+ scroll = Math.max(0, matches[current] - Math.floor(lastHeight / 3));
272
+ };
273
+ const render = (width: number): string[] => {
274
+ const item = options.getItem();
275
+ const now = clock.now();
276
+ const height = bodyHeight();
277
+ lastHeight = height;
278
+ const path = stream === "stderr" ? options.paths.stderrPath : options.paths.outputPath;
279
+ const laid = layoutLog(streamBody(item, path, stream), width - 1, view, theme, matches[current] ?? -1);
280
+ matches = laid.matches;
281
+ const max = Math.max(0, laid.lines.length - height);
282
+ if (seek && matches.length > 0) {
283
+ const next = matches.findIndex((row) => row >= scroll);
284
+ current = next >= 0 ? next : matches.length - 1;
285
+ follow = false;
286
+ scroll = Math.max(0, matches[current] - Math.floor(height / 3));
287
+ }
288
+ seek = false;
289
+ current = Math.min(current, Math.max(0, matches.length - 1));
290
+ if (follow || (!held && scroll >= max)) {
291
+ scroll = max;
292
+ follow = true;
293
+ } else scroll = Math.min(scroll, max);
294
+ const dim = (s: string) => theme.fg("dim", s);
295
+ const live = item.status === "running" || item.status === "pending";
296
+ const pill = positionPill(theme, follow, live, laid.lines.length, height, scroll);
297
+ const head = spread(`${theme.fg("accent", "◆")} ${theme.bold(options.title)}`, pill, width);
298
+ const q = view.query.length > 0;
299
+ let right: string;
300
+ const lines = (n: number) => (n === 1 ? " line" : " lines");
301
+ if (q && view.filter) right = `${laid.shown}${dim(` of ${laid.total}${lines(laid.total)}`)}`;
302
+ else if (q) {
303
+ const at = matches.length === 0 ? 0 : current + 1;
304
+ right = `${theme.fg(matches.length === 0 ? "error" : "text", `${at}/${matches.length}`)}${dim(" matching rows")}`;
305
+ } else {
306
+ const hidden = laid.total - laid.shown;
307
+ right = `${laid.total}${dim(lines(laid.total))}${hidden > 0 && !view.showBlank ? dim(` · ${hidden} blank hidden`) : ""}`;
308
+ }
309
+ const row = tabRow(tabs, stream, right, theme, width);
310
+ tabHits = row.hits;
311
+ tabY = 1 + INFO_ROWS;
312
+ const visible = laid.lines.slice(scroll, scroll + height);
313
+ while (visible.length < height) visible.push("");
314
+ const body = withThumb(visible, width, laid.lines.length, height, scroll, theme);
315
+ // Keys name the mode they switch to; the least important drop first.
316
+ const mode = view.filter ? "highlight" : "filter";
317
+ let foot: string;
318
+ if (view.editing) {
319
+ const prompt = `${theme.bold(theme.fg("accent", "/ "))}${view.query}${theme.fg("accent", "▏")}`;
320
+ foot = fitKeys(theme, prompt, [["enter", "apply", 0], ["tab", mode, 1], ["esc", "clear", 0]], width - 1);
321
+ } else {
322
+ const keys: Key[] = q ? [["n N", "next", 1]] : [["/", "search", 1]];
323
+ keys.push(["&", mode, 2], ["b", view.showBlank ? "hide blank" : "show blank", 4], ["1 2", "stream", 5]);
324
+ if (live) keys.push(["f", follow ? "unfollow" : "follow", 3]);
325
+ keys.push(["esc", q ? "clear" : "close", 0]);
326
+ foot = fitKeys(theme, q ? `${theme.fg("accent", "/ ")}${view.query}` : "", keys, width - 1);
327
+ }
328
+ return [head, ...infoCard(item, now, theme, width), row.line, ...body, ` ${foot}`];
329
+ };
330
+ const searchInput = (data: string) => {
331
+ if (matchesKey(data, "escape")) {
332
+ view.query = "";
333
+ view.editing = false;
334
+ } else if (matchesKey(data, "enter") || data === "\r" || data === "\n") view.editing = false;
335
+ else if (matchesKey(data, "tab") || data === "\t") view.filter = !view.filter;
336
+ else if (data === "\u007f" || data === "\b") {
337
+ view.query = view.query.slice(0, -1);
338
+ seek = true;
339
+ } else if (!data.startsWith("\u001b") && [...data].every((ch) => ch >= " ")) {
340
+ view.query += data;
341
+ seek = true;
342
+ }
343
+ tui.requestRender();
344
+ };
345
+ return {
346
+ render,
347
+ invalidate() {},
348
+ handleInput(data: string) {
349
+ if (view.editing) {
350
+ searchInput(data);
351
+ return;
352
+ }
353
+ const move = (next: number) => {
354
+ scroll = Math.max(0, next);
355
+ follow = false;
356
+ held = false;
357
+ tui.requestRender();
358
+ };
359
+ if (matchesKey(data, "escape")) {
360
+ if (view.query) {
361
+ view.query = "";
362
+ matches = [];
363
+ tui.requestRender();
364
+ return;
365
+ }
366
+ done();
367
+ return;
368
+ }
369
+ if (data === "q") return done();
370
+ if (data === "1") setStream("output");
371
+ else if (data === "2") setStream("stderr");
372
+ else if (matchesKey(data, "tab") || data === "\t") setStream(stream === "output" ? "stderr" : "output");
373
+ else if (data === "/") {
374
+ view.editing = true;
375
+ seek = true;
376
+ } else if (data === "&") view.filter = !view.filter;
377
+ else if (data === "n") jump(true);
378
+ else if (data === "N") jump(false);
379
+ else if (data === "b") view.showBlank = !view.showBlank;
380
+ else if (data === "f" || data === "F") {
381
+ follow = !follow;
382
+ held = !follow;
383
+ }
384
+ else if (matchesKey(data, "up") || data === "k") return move(scroll - 1);
385
+ else if (matchesKey(data, "down") || data === "j") return move(scroll + 1);
386
+ else if (matchesKey(data, "pageUp")) return move(scroll - Math.max(1, lastHeight - 1));
387
+ else if (matchesKey(data, "pageDown")) return move(scroll + Math.max(1, lastHeight - 1));
388
+ else if (matchesKey(data, "home") || data === "g") return move(0);
389
+ else if (matchesKey(data, "end") || data === "G") follow = true;
390
+ else return;
391
+ tui.requestRender();
392
+ },
393
+ handleMouse(event: { type: string; x?: number; y?: number; wheelDelta?: number }) {
394
+ if (event.type === "wheel" && event.wheelDelta) {
395
+ scroll = Math.max(0, scroll + event.wheelDelta);
396
+ follow = false;
397
+ held = false;
398
+ return { handled: true };
399
+ }
400
+ if (event.type === "click" && event.y === tabY && event.x !== undefined) {
401
+ const tab = tabHits.find(([x0, x1]) => event.x! >= x0 && event.x! < x1);
402
+ if (tab) {
403
+ setStream(tab[2]);
404
+ return { handled: true };
405
+ }
406
+ }
407
+ return undefined;
408
+ },
409
+ dispose() {
410
+ disposed = true;
411
+ if (timer !== null) clock.clearInterval(timer);
412
+ },
413
+ };
414
+ },
415
+ { overlay: true, overlayOptions: { anchor: "bottom-left", width: "100%", maxHeight: "100%", margin: 0 } },
416
+ );
417
+ }