pi-cc-loop 0.7.8 → 0.8.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/tasks.ts ADDED
@@ -0,0 +1,1294 @@
1
+ /**
2
+ * /tasks (or /cc-tasks on conflict): one native Monitor/Loop task overlay.
3
+ *
4
+ * Both packages carry this file verbatim (tests/shared.test.mjs fails when the
5
+ * copies drift apart). They are separate npm packages but may run in the same
6
+ * Pi session. Pi gives every extension its own `pi.events` facade over one
7
+ * common event bus, so a WeakMap keyed by `pi.events` cannot join them. The
8
+ * registry is therefore discovered synchronously over that bus:
9
+ *
10
+ * - the first package selects one entry after startup: /tasks if no loaded
11
+ * command or prompt owns that name, otherwise /cc-tasks;
12
+ * - a later package emits a probe, the existing registry answers with itself
13
+ * and the new provider joins it (only one panel command exists);
14
+ * - the bus belongs to one Pi runtime (one session), so two SDK sessions or
15
+ * subagents in one process do not see each other's providers.
16
+ *
17
+ * The overlay is read-only except for an explicit, locally confirmed stop. It
18
+ * never sends a message, starts a turn, changes the tool set, or calls the
19
+ * model. Esc closes the detail, then the overlay; closing never cancels a task
20
+ * and never reaches the application's interrupt handling.
21
+ *
22
+ * Provider numbers (times, counts) are computed in the owning package from its
23
+ * live records: Monitor's records and LoopScheduler's LoopJob objects. The
24
+ * overlay only renders what a provider returns. Log reads are bounded tails,
25
+ * sanitized for display, and never part of an event stream.
26
+ */
27
+
28
+ import fs from "node:fs";
29
+ import type { ExtensionAPI, ExtensionCommandContext, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
30
+ import {
31
+ backgroundAnsi,
32
+ colorToRgb,
33
+ foregroundAnsi,
34
+ mixColors,
35
+ rgbColor,
36
+ matchesKey,
37
+ stripTerminalSequences,
38
+ truncateToWidth,
39
+ visibleWidth,
40
+ wrapTextWithAnsi,
41
+ type Color,
42
+ type KeybindingsManager,
43
+ type TUI,
44
+ } from "@earendil-works/pi-tui";
45
+
46
+ /** Preferred entry when no loaded command or prompt already owns the name. */
47
+ export const TASKS_COMMAND = "tasks";
48
+ /** Used instead of /tasks on conflicts or when command ownership cannot be queried. */
49
+ export const TASKS_FALLBACK_COMMAND = "cc-tasks";
50
+
51
+ const PROBE_CHANNEL = "pi-cc:tasks:probe";
52
+ const CHANGED_CHANNEL = "pi-cc:tasks:changed";
53
+
54
+ /** A refresh coalesces bursts of provider changes. One second keeps elapsed times honest. */
55
+ const REFRESH_MS = 250;
56
+ const TICK_MS = 1000;
57
+ /** A log tail is small enough to read synchronously and wide enough to be useful. */
58
+ const LOG_MAX_BYTES = 8192;
59
+ const LOG_MAX_LINES = 60;
60
+ const LOG_LINE_MAX = 500;
61
+ const PANEL_MAX_WIDTH = 96;
62
+ const PANEL_MAX_BODY_ROWS = 12;
63
+
64
+ /** Keep wide terminals from turning an empty task list into a full-screen sheet. */
65
+ function panelWidth(columns: number): number {
66
+ const available = Number.isFinite(columns) ? Math.floor(columns) - 2 : 78;
67
+ return Math.max(1, Math.min(PANEL_MAX_WIDTH, available));
68
+ }
69
+
70
+ interface PanelPalette {
71
+ surface: Color;
72
+ chrome: Color;
73
+ selected: Color;
74
+ tab: Color;
75
+ border: Color;
76
+ text: Color;
77
+ muted: Color;
78
+ }
79
+
80
+ /** Concrete colors also work when the system theme has transparent/default panels. */
81
+ function panelPalette(theme: Theme): PanelPalette {
82
+ const dark = theme.appearance !== "light";
83
+ const colors = theme.colors;
84
+ const text = colors?.text ?? rgbColor(dark ? 232 : 32, dark ? 236 : 36, dark ? 242 : 42);
85
+ const accent = colors?.accent ?? rgbColor(74, 153, 230);
86
+ const original = colors?.userMessageBg ?? rgbColor(dark ? 46 : 248, dark ? 50 : 248, dark ? 58 : 250);
87
+ const channels = colorToRgb(original);
88
+ const base = dark
89
+ ? rgbColor(Math.max(46, channels.r), Math.max(50, channels.g), Math.max(58, channels.b))
90
+ : original;
91
+ const neutral = dark ? rgbColor(255, 255, 255) : rgbColor(0, 0, 0);
92
+ const tinted = mixColors(base, accent, 0.035, "srgb");
93
+ const surface = mixColors(tinted, neutral, dark ? 0.06 : 0.03, "srgb");
94
+ const chrome = mixColors(surface, accent, dark ? 0.09 : 0.055, "srgb");
95
+ const mode = typeof theme.getColorMode === "function" ? theme.getColorMode() : "truecolor";
96
+ // Nearby RGB shades can collapse into the same 256-color cell. Keep
97
+ // selection/pills visibly distinct after terminal color conversion too.
98
+ const distinct = (candidate: Color, against: Color): Color => {
99
+ const reference = backgroundAnsi(against, mode);
100
+ for (const amount of [0, 0.12, 0.24]) {
101
+ const adjusted = amount ? mixColors(candidate, neutral, amount, "srgb") : candidate;
102
+ if (backgroundAnsi(adjusted, mode) !== reference) return adjusted;
103
+ }
104
+ return mixColors(against, neutral, 0.4, "srgb");
105
+ };
106
+ return {
107
+ surface,
108
+ chrome,
109
+ selected: distinct(mixColors(surface, accent, dark ? 0.17 : 0.11, "srgb"), surface),
110
+ tab: distinct(mixColors(surface, accent, dark ? 0.27 : 0.20, "srgb"), chrome),
111
+ border: mixColors(accent, text, 0.22, "srgb"),
112
+ text,
113
+ muted: mixColors(text, surface, 0.30, "srgb"),
114
+ };
115
+ }
116
+
117
+ const TABS = [
118
+ { id: "all", label: "All" },
119
+ { id: "monitor", label: "Monitor" },
120
+ { id: "loop", label: "Loop" },
121
+ ] as const;
122
+ type TaskTab = (typeof TABS)[number]["id"];
123
+
124
+ /** Provider order in the All tab and in the textual summary. */
125
+ const PROVIDER_ORDER = ["monitor", "loop"] as const;
126
+
127
+ /** One row in a provider's list. `id` is unique within that provider, not across providers. */
128
+ export interface TaskItem {
129
+ id: string;
130
+ /** The one-line name a reader sees (a description, a prompt preview). */
131
+ title: string;
132
+ /** A short state word: running, pending, scheduled, foreign, ... */
133
+ state: string;
134
+ /** False when the task has ended or cannot be stopped from here. */
135
+ active: boolean;
136
+ /** Optional short right-hand detail, e.g. "3 events · 1m" or "next 2m". */
137
+ meta?: string;
138
+ /** Stable within a provider; lower comes first. */
139
+ order?: number;
140
+ }
141
+
142
+ /** The overlay's list entry: the provider identity is part of the key. */
143
+ export interface TaskEntry extends TaskItem {
144
+ provider: string;
145
+ providerLabel: string;
146
+ }
147
+
148
+ export interface TaskDetailRow {
149
+ label: string;
150
+ value: string;
151
+ }
152
+
153
+ /** A log file the shared reader may tail. The provider names it, the overlay reads it. */
154
+ export interface TaskLogRef {
155
+ label: string;
156
+ path: string;
157
+ }
158
+
159
+ export interface TaskDetail {
160
+ rows: TaskDetailRow[];
161
+ logs?: TaskLogRef[];
162
+ /** A short note under the rows (ownership, disabled actions, ...). */
163
+ note?: string;
164
+ /** Optional extra title line; the provider label and task id are always shown. */
165
+ heading?: string;
166
+ }
167
+
168
+ export interface TaskStopResult {
169
+ stopped: boolean;
170
+ message?: string;
171
+ }
172
+
173
+ /**
174
+ * What a package contributes. All methods are called on the interactive path;
175
+ * they must be quick and must not throw (the overlay catches anyway).
176
+ */
177
+ export interface TaskProvider {
178
+ /** "monitor" or "loop": the namespace half of a task's identity. */
179
+ id: string;
180
+ label: string;
181
+ list(): TaskItem[];
182
+ detail?(item: TaskItem): TaskDetail;
183
+ /** Stop one task. The owning package reuses its own lifecycle logic. */
184
+ stop?(id: string): TaskStopResult | Promise<TaskStopResult>;
185
+ }
186
+
187
+ export interface TaskProviderRegistration {
188
+ /** Ask an open overlay to refresh; coalesced and ignored when no overlay is open. */
189
+ changed(): void;
190
+ /** Remove this provider and, for the registry owner, close the overlay. */
191
+ close(): void;
192
+ }
193
+
194
+ interface TaskRegistry {
195
+ providers: Map<string, TaskProvider>;
196
+ /** Close callbacks for overlays created by this registry. */
197
+ overlays: Set<() => void>;
198
+ disposed: boolean;
199
+ probeListener?: () => void;
200
+ }
201
+
202
+ interface ProbeRequest {
203
+ reply(registry: TaskRegistry): void;
204
+ }
205
+
206
+ /**
207
+ * Find the registry that already exists in this Pi runtime, if any. The bus is
208
+ * synchronous, so the answer arrives before `emit` returns; every extension
209
+ * has its own facade, but they all share the one underlying bus.
210
+ */
211
+ function findRegistry(pi: ExtensionAPI): TaskRegistry | undefined {
212
+ let found: TaskRegistry | undefined;
213
+ try {
214
+ pi.events.emit(PROBE_CHANNEL, {
215
+ reply: (registry: TaskRegistry) => {
216
+ if (!found && registry && !registry.disposed) found = registry;
217
+ },
218
+ } satisfies ProbeRequest);
219
+ } catch {
220
+ // A missing bus only means this package stands alone.
221
+ }
222
+ return found;
223
+ }
224
+
225
+ function disposeRegistry(registry: TaskRegistry): void {
226
+ if (registry.disposed) return;
227
+ registry.disposed = true;
228
+ const closes = [...registry.overlays];
229
+ registry.overlays.clear();
230
+ for (const close of closes) {
231
+ try {
232
+ close();
233
+ } catch {
234
+ // an overlay that is already gone must not break shutdown
235
+ }
236
+ }
237
+ registry.providers.clear();
238
+ try {
239
+ registry.probeListener?.();
240
+ } catch {
241
+ // the runtime may already have unsubscribed it
242
+ }
243
+ registry.probeListener = undefined;
244
+ }
245
+
246
+ /**
247
+ * Join (or create) this runtime's task registry. Only the first package owns
248
+ * command registration. After startup command loading, it selects /tasks when
249
+ * that name is free, otherwise /cc-tasks. Never register both or an extra alias.
250
+ */
251
+ export function registerTaskProvider(pi: ExtensionAPI, provider: TaskProvider): TaskProviderRegistration {
252
+ let registry = findRegistry(pi);
253
+ let owner = false;
254
+
255
+ if (!registry) {
256
+ owner = true;
257
+ const created: TaskRegistry = {
258
+ providers: new Map(),
259
+ overlays: new Set(),
260
+ disposed: false,
261
+ probeListener: undefined,
262
+ };
263
+ registry = created;
264
+ created.probeListener = pi.events.on(PROBE_CHANNEL, (data) => {
265
+ if (created.disposed) return;
266
+ try {
267
+ (data as ProbeRequest | undefined)?.reply?.(created);
268
+ } catch {
269
+ // a broken prober must not affect the registry
270
+ }
271
+ });
272
+ const command = {
273
+ description: "Open the shared Monitor and Loop task overlay (read-only; x stops a selected task)",
274
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
275
+ await openTasks(pi, created, ctx);
276
+ },
277
+ };
278
+ let commandRegistered = false;
279
+ const registerPanelCommand = () => {
280
+ if (created.disposed || commandRegistered) return;
281
+ let name = TASKS_FALLBACK_COMMAND;
282
+ try {
283
+ if (typeof pi.getCommands === "function" &&
284
+ !pi.getCommands().some(({ name }) => name === TASKS_COMMAND || /^tasks:\d+$/.test(name))) {
285
+ name = TASKS_COMMAND;
286
+ }
287
+ } catch {
288
+ // An unbound host cannot safely claim /tasks; use the fallback alone.
289
+ }
290
+ pi.registerCommand(name, command);
291
+ commandRegistered = true;
292
+ };
293
+ if (typeof pi.getCommands !== "function") {
294
+ // Legacy hosts may also lack resources_discover, so keep one safe entry.
295
+ registerPanelCommand();
296
+ } else {
297
+ // getCommands() is not bound while factories load. This boundary runs
298
+ // after all session_start handlers and before TUI autocomplete is built.
299
+ pi.on("resources_discover", registerPanelCommand);
300
+ }
301
+ }
302
+
303
+ registry.providers.set(provider.id, provider);
304
+ let closed = false;
305
+ return {
306
+ changed() {
307
+ if (closed || registry.disposed || registry.overlays.size === 0) return;
308
+ try {
309
+ pi.events.emit(CHANGED_CHANNEL, { provider: provider.id });
310
+ } catch {
311
+ // the session is going away; there is nobody left to repaint
312
+ }
313
+ },
314
+ close() {
315
+ if (closed) return;
316
+ closed = true;
317
+ if (registry.disposed) return;
318
+ registry.providers.delete(provider.id);
319
+ if (owner) disposeRegistry(registry);
320
+ },
321
+ };
322
+ }
323
+
324
+ // --------------------------------------------------------------- text mode
325
+
326
+ function safeItems(provider: TaskProvider): TaskItem[] {
327
+ try {
328
+ const items = provider.list();
329
+ return Array.isArray(items) ? items.filter((item) => item && typeof item.id === "string") : [];
330
+ } catch {
331
+ return [];
332
+ }
333
+ }
334
+
335
+ function providerFor(registry: TaskRegistry, id: string): TaskProvider | undefined {
336
+ const provider = registry.providers.get(id);
337
+ return provider && !registry.disposed ? provider : undefined;
338
+ }
339
+
340
+ /** A plain-text listing for print, JSON, and RPC modes: no TUI methods are called. */
341
+ export function taskSummary(registry: TaskRegistry): string {
342
+ const lines = ["Tasks · Monitor & Loop"];
343
+ let total = 0;
344
+ for (const id of PROVIDER_ORDER) {
345
+ const provider = providerFor(registry, id);
346
+ if (!provider) {
347
+ lines.push(`${id === "monitor" ? "Monitor" : "Loop"}: not loaded`);
348
+ continue;
349
+ }
350
+ const items = safeItems(provider);
351
+ total += items.length;
352
+ lines.push(`${provider.label} (${items.length})`);
353
+ for (const item of items) {
354
+ lines.push(` ${oneLine(item.state)} ${oneLine(item.id)} ${oneLine(item.title)}${item.meta ? ` ${oneLine(item.meta)}` : ""}`);
355
+ }
356
+ }
357
+ if (total === 0) lines.push("No tasks.");
358
+ return lines.join("\n");
359
+ }
360
+
361
+ async function openTasks(pi: ExtensionAPI, registry: TaskRegistry, ctx: ExtensionCommandContext): Promise<void> {
362
+ if (ctx.mode !== "tui" || typeof ctx.ui.custom !== "function") {
363
+ if (ctx.hasUI) {
364
+ try {
365
+ ctx.ui.notify(taskSummary(registry), "info");
366
+ } catch {
367
+ // a stale context must never break the command
368
+ }
369
+ }
370
+ return;
371
+ }
372
+
373
+ // Both command names share one overlay; a second invocation replaces it.
374
+ for (const close of [...registry.overlays]) {
375
+ try {
376
+ close();
377
+ } catch {
378
+ // already closed
379
+ }
380
+ }
381
+
382
+ let doneRef: ((result: void) => void) | undefined;
383
+ let overlayTerminal: TUI["terminal"] | undefined;
384
+ const close = () => {
385
+ const done = doneRef;
386
+ doneRef = undefined;
387
+ try {
388
+ done?.(undefined);
389
+ } catch {
390
+ // the interaction already ended
391
+ }
392
+ };
393
+ registry.overlays.add(close);
394
+ try {
395
+ await ctx.ui.custom<void>(
396
+ (tui, theme, keybindings, done) => {
397
+ doneRef = done;
398
+ overlayTerminal = tui.terminal;
399
+ return new TasksOverlay({
400
+ tui,
401
+ theme,
402
+ keybindings,
403
+ registry,
404
+ pi,
405
+ onClose: close,
406
+ });
407
+ },
408
+ {
409
+ overlay: true,
410
+ overlayOptions: () => ({
411
+ // custom() resolves the factory once; the retained getter keeps
412
+ // the native renderer responsive even when a narrow window grows.
413
+ get width() { return panelWidth(overlayTerminal?.columns ?? 80); },
414
+ maxHeight: "90%",
415
+ margin: 1,
416
+ }),
417
+ },
418
+ );
419
+ } finally {
420
+ registry.overlays.delete(close);
421
+ doneRef = undefined;
422
+ overlayTerminal = undefined;
423
+ }
424
+ }
425
+
426
+ // ------------------------------------------------------------- log display
427
+
428
+ function errorCode(error: unknown): string | undefined {
429
+ return (error as NodeJS.ErrnoException | undefined)?.code;
430
+ }
431
+
432
+ /** Strip ANSI, C0/C1 controls, and bidi overrides from anything shown. */
433
+ export function sanitizeDisplay(value: unknown): string {
434
+ let text = String(value ?? "");
435
+ try {
436
+ text = stripTerminalSequences(text);
437
+ } catch {
438
+ // fall through to the control-character pass
439
+ }
440
+ return text
441
+ .replace(/\x1b/g, "")
442
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, "")
443
+ .replace(/[\u202a-\u202e\u2066-\u2069]/g, "");
444
+ }
445
+
446
+ function oneLine(value: unknown): string {
447
+ return sanitizeDisplay(value).replace(/\s+/g, " ").trim();
448
+ }
449
+
450
+ export interface LogTail {
451
+ text: string;
452
+ truncated: boolean;
453
+ /** Why there is nothing to show, when the file is missing or unreadable. */
454
+ note?: string;
455
+ }
456
+
457
+ /**
458
+ * Read a bounded tail of a log. Missing files and permission errors are notes,
459
+ * not failures: the task viewer must work even after a log is cleaned up.
460
+ */
461
+ export function readLogTail(filePath: string, maxBytes = LOG_MAX_BYTES, maxLines = LOG_MAX_LINES): LogTail {
462
+ if (!filePath) return { text: "", truncated: false, note: "no log path" };
463
+ let stat: fs.Stats;
464
+ try {
465
+ stat = fs.statSync(filePath);
466
+ } catch (error) {
467
+ return {
468
+ text: "",
469
+ truncated: false,
470
+ note: errorCode(error) === "ENOENT" ? "log not created yet" : "log unreadable",
471
+ };
472
+ }
473
+ if (!stat.isFile()) return { text: "", truncated: false, note: "log is not a file" };
474
+ const size = stat.size;
475
+ const start = Math.max(0, size - Math.max(0, maxBytes));
476
+ let buffer: Buffer;
477
+ try {
478
+ const fd = fs.openSync(filePath, "r");
479
+ try {
480
+ buffer = Buffer.alloc(size - start);
481
+ if (buffer.length > 0) fs.readSync(fd, buffer, 0, buffer.length, start);
482
+ } finally {
483
+ fs.closeSync(fd);
484
+ }
485
+ } catch {
486
+ return { text: "", truncated: false, note: "log unreadable" };
487
+ }
488
+
489
+ let text = buffer.toString("utf8");
490
+ if (start > 0) {
491
+ // Drop the partial first line of a byte-bounded tail.
492
+ const newline = text.indexOf("\n");
493
+ if (newline >= 0) text = text.slice(newline + 1);
494
+ }
495
+ let lines = text.split(/\r\n|\r|\n/);
496
+ const overLines = lines.length > maxLines;
497
+ if (overLines) lines = lines.slice(-maxLines);
498
+ while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
499
+ const sanitized = lines.map((line) =>
500
+ truncateToWidth(sanitizeDisplay(line).replace(/\t/g, " "), LOG_LINE_MAX, "\u2026"),
501
+ );
502
+ return {
503
+ text: sanitized.join("\n"),
504
+ truncated: start > 0 || overLines,
505
+ note:
506
+ start > 0 || overLines
507
+ ? `last ${Math.min(maxLines, lines.length)} lines \u00b7 ${formatBytes(Math.max(0, size - start))} tail`
508
+ : undefined,
509
+ };
510
+ }
511
+
512
+ function formatBytes(bytes: number): string {
513
+ if (bytes < 1024) return `${bytes}B`;
514
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1).replace(/\.0$/, "")}KB`;
515
+ return `${(bytes / (1024 * 1024)).toFixed(1).replace(/\.0$/, "")}MB`;
516
+ }
517
+
518
+ // ---------------------------------------------------------------- overlay
519
+
520
+ interface OverlayOptions {
521
+ tui: TUI;
522
+ theme: Theme;
523
+ keybindings: KeybindingsManager;
524
+ registry: TaskRegistry;
525
+ pi: ExtensionAPI;
526
+ onClose: () => void;
527
+ }
528
+
529
+ interface DetailLogView {
530
+ label: string;
531
+ path: string;
532
+ note?: string;
533
+ lines: string[];
534
+ }
535
+
536
+ function taskKey(entry: Pick<TaskEntry, "provider" | "id">): string {
537
+ return `${entry.provider}:${entry.id}`;
538
+ }
539
+
540
+ function shortDuration(ms: number): string {
541
+ if (!Number.isFinite(ms) || ms < 0) return "0s";
542
+ if (ms < 1000) return `${Math.round(ms)}ms`;
543
+ const seconds = Math.round(ms / 1000);
544
+ if (seconds < 60) return `${seconds}s`;
545
+ const minutes = Math.floor(seconds / 60);
546
+ if (minutes < 60) return `${minutes}m${seconds % 60 ? ` ${seconds % 60}s` : ""}`;
547
+ const hours = Math.floor(minutes / 60);
548
+ return `${hours}h${minutes % 60 ? ` ${minutes % 60}m` : ""}`;
549
+ }
550
+
551
+ function clock(ms: number): string {
552
+ const date = new Date(ms);
553
+ if (Number.isNaN(date.getTime())) return "?";
554
+ const hour = date.getHours();
555
+ const twelve = hour % 12 === 0 ? 12 : hour % 12;
556
+ return `${twelve}:${String(date.getMinutes()).padStart(2, "0")}${hour < 12 ? "am" : "pm"}`;
557
+ }
558
+
559
+ function dateTime(ms: number | undefined): string {
560
+ if (ms === undefined || !Number.isFinite(ms)) return "\u2014";
561
+ const date = new Date(ms);
562
+ if (Number.isNaN(date.getTime())) return "?";
563
+ const today = new Date();
564
+ if (date.toDateString() === today.toDateString()) return clock(ms);
565
+ return `${date.toLocaleDateString(undefined, { month: "short", day: "numeric" })} ${clock(ms)}`;
566
+ }
567
+
568
+ function eta(ms: number): string {
569
+ if (!Number.isFinite(ms)) return "?";
570
+ if (ms <= 0) return "now";
571
+ return shortDuration(ms);
572
+ }
573
+
574
+ function stateColor(state: string): ThemeColor {
575
+ if (/fail|error/i.test(state)) return "error";
576
+ if (/pending|waiting|keepalive|awaiting|foreign/i.test(state)) return "warning";
577
+ if (/running|scheduled|active|armed|due/i.test(state)) return "success";
578
+ return "dim";
579
+ }
580
+
581
+ class TasksOverlay {
582
+ private tab: TaskTab = "all";
583
+ private entries: TaskEntry[] = [];
584
+ private selectedKey: string | undefined;
585
+ private selectedIndexHint = 0;
586
+ private listScroll = 0;
587
+ private detailKey: string | undefined;
588
+ private detailScroll = 0;
589
+ private detailLineCount = 0;
590
+ private detailViewport = 0;
591
+ private lastRenderWidth = 0;
592
+ private lastBodyWidth = 0;
593
+ private lastColumns = 0;
594
+ private logs: DetailLogView[] = [];
595
+ private knownKeys: Set<string> | undefined;
596
+ private recentTask: string | undefined;
597
+ private confirming: TaskEntry | undefined;
598
+ private stoppingKey: string | undefined;
599
+ private status = "";
600
+ private disposed = false;
601
+ private renderQueued = false;
602
+ private renderPalette: PanelPalette | undefined;
603
+ private refreshTimer: ReturnType<typeof setTimeout> | undefined;
604
+ private tickTimer: ReturnType<typeof setInterval> | undefined;
605
+ private unsubscribe: (() => void) | undefined;
606
+
607
+ constructor(private readonly options: OverlayOptions) {
608
+ this.refreshNow(false);
609
+ try {
610
+ this.unsubscribe = options.pi.events.on(CHANGED_CHANNEL, () => this.markDirty());
611
+ } catch {
612
+ this.unsubscribe = undefined;
613
+ }
614
+ this.tickTimer = setInterval(() => this.markDirty(), TICK_MS);
615
+ this.tickTimer.unref?.();
616
+ }
617
+
618
+ dispose(): void {
619
+ if (this.disposed) return;
620
+ this.disposed = true;
621
+ if (this.refreshTimer) {
622
+ clearTimeout(this.refreshTimer);
623
+ this.refreshTimer = undefined;
624
+ }
625
+ if (this.tickTimer) {
626
+ clearInterval(this.tickTimer);
627
+ this.tickTimer = undefined;
628
+ }
629
+ try {
630
+ this.unsubscribe?.();
631
+ } catch {
632
+ // the runtime may already have unsubscribed it
633
+ }
634
+ this.unsubscribe = undefined;
635
+ }
636
+
637
+ invalidate(): void {
638
+ this.renderQueued = false;
639
+ this.renderPalette = undefined;
640
+ }
641
+
642
+ private palette(): PanelPalette {
643
+ return this.renderPalette ??= panelPalette(this.options.theme);
644
+ }
645
+
646
+ private styled(text: string, style: { fg?: Color; bg?: Color; bold?: boolean }): string {
647
+ const theme = this.options.theme;
648
+ // Older/minimal hosts without rich theme styling retain a readable plain view.
649
+ return typeof theme.style === "function" ? theme.style(text, style) : text;
650
+ }
651
+
652
+ private panelTheme(): Pick<Theme, "fg" | "bold"> {
653
+ const theme = this.options.theme;
654
+ return {
655
+ fg: (role, text) => {
656
+ if (role === "dim" || role === "muted") return this.styled(text, { fg: this.palette().muted });
657
+ if (role === "border") return this.styled(text, { fg: this.palette().border });
658
+ return theme.fg(role, text);
659
+ },
660
+ bold: (text) => theme.bold(text),
661
+ };
662
+ }
663
+
664
+ /** Paint padding too, and restore the card after nested tab/selection resets. */
665
+ private paint(text: string, width: number, background: Color = this.palette().surface): string {
666
+ const fitted = fitWidth(text, width);
667
+ const theme = this.options.theme;
668
+ if (typeof theme.style !== "function") return fitted;
669
+ const mode = theme.getColorMode();
670
+ const bg = backgroundAnsi(background, mode);
671
+ const fg = foregroundAnsi(this.palette().text, mode);
672
+ const restored = fitted
673
+ .replace(/\x1b\[(?:0)?m/g, (reset) => reset + fg + bg)
674
+ .replace(/\x1b\[49m/g, () => bg)
675
+ .replace(/\x1b\[39m/g, () => fg);
676
+ return theme.style(restored, { fg: this.palette().text, bg: background });
677
+ }
678
+
679
+ // ------------------------------------------------------------- data
680
+
681
+ private markDirty(): void {
682
+ if (this.disposed) return;
683
+ if (this.refreshTimer) return;
684
+ this.refreshTimer = setTimeout(() => {
685
+ this.refreshTimer = undefined;
686
+ if (this.disposed) return;
687
+ this.refreshNow(true);
688
+ }, REFRESH_MS);
689
+ this.refreshTimer.unref?.();
690
+ }
691
+
692
+ private collect(): TaskEntry[] {
693
+ const registry = this.options.registry;
694
+ const entries: TaskEntry[] = [];
695
+ const seen = new Set<string>();
696
+ const add = (id: string, provider: TaskProvider) => {
697
+ if (seen.has(id)) return;
698
+ seen.add(id);
699
+ for (const item of safeItems(provider)) {
700
+ entries.push({ ...item, provider: id, providerLabel: provider.label || id });
701
+ }
702
+ };
703
+ for (const id of PROVIDER_ORDER) {
704
+ const provider = providerFor(registry, id);
705
+ if (provider) add(id, provider);
706
+ }
707
+ for (const [id, provider] of registry.providers) {
708
+ if (!registry.disposed) add(id, provider);
709
+ }
710
+ return entries;
711
+ }
712
+
713
+ private visible(): TaskEntry[] {
714
+ return this.tab === "all" ? this.entries : this.entries.filter((entry) => entry.provider === this.tab);
715
+ }
716
+
717
+ private entryFor(key: string): TaskEntry | undefined {
718
+ return this.entries.find((entry) => taskKey(entry) === key);
719
+ }
720
+
721
+ private selected(): TaskEntry | undefined {
722
+ const list = this.visible();
723
+ if (!list.length) return undefined;
724
+ const found = list.find((entry) => taskKey(entry) === this.selectedKey);
725
+ return found ?? list[Math.min(this.selectedIndexHint, list.length - 1)];
726
+ }
727
+
728
+ private refreshNow(render: boolean): void {
729
+ if (this.disposed) return;
730
+ this.entries = this.collect();
731
+ const keys = new Set(this.entries.map(taskKey));
732
+ if (this.knownKeys) {
733
+ const added = this.entries.filter((entry) => !this.knownKeys!.has(taskKey(entry)));
734
+ if (added.length) this.recentTask = added[added.length - 1]!.title;
735
+ }
736
+ this.knownKeys = keys;
737
+ this.ensureSelection();
738
+ if (this.detailKey && !this.entryFor(this.detailKey)) this.detailKey = undefined;
739
+ if (this.detailKey) this.reloadLogs();
740
+ else {
741
+ this.logs = [];
742
+ }
743
+ if (render) this.requestRender();
744
+ }
745
+
746
+ private ensureSelection(): void {
747
+ const list = this.visible();
748
+ if (!list.length) {
749
+ this.selectedKey = undefined;
750
+ this.selectedIndexHint = 0;
751
+ return;
752
+ }
753
+ const index = list.findIndex((entry) => taskKey(entry) === this.selectedKey);
754
+ if (index >= 0) {
755
+ this.selectedIndexHint = index;
756
+ return;
757
+ }
758
+ const next = Math.min(Math.max(0, this.selectedIndexHint), list.length - 1);
759
+ this.selectedKey = taskKey(list[next]!);
760
+ this.selectedIndexHint = next;
761
+ }
762
+
763
+ private move(delta: number, page = false): void {
764
+ const list = this.visible();
765
+ if (!list.length) return;
766
+ const current = Math.max(
767
+ 0,
768
+ list.findIndex((entry) => taskKey(entry) === this.selectedKey),
769
+ );
770
+ const step = page ? (delta < 0 ? -this.pageSize() : this.pageSize()) : delta;
771
+ const next = Math.max(0, Math.min(list.length - 1, current + step));
772
+ this.selectedKey = taskKey(list[next]!);
773
+ this.selectedIndexHint = next;
774
+ this.requestRender();
775
+ }
776
+
777
+ private switchTab(delta: number): void {
778
+ const index = TABS.findIndex((tab) => tab.id === this.tab);
779
+ this.tab = TABS[(index + delta + TABS.length) % TABS.length]!.id;
780
+ this.ensureSelection();
781
+ this.listScroll = 0;
782
+ this.requestRender();
783
+ }
784
+
785
+ private reloadLogs(): void {
786
+ if (!this.detailKey) return;
787
+ const entry = this.entryFor(this.detailKey);
788
+ if (!entry) return;
789
+ const provider = providerFor(this.options.registry, entry.provider);
790
+ if (!provider?.detail) {
791
+ this.logs = [];
792
+ return;
793
+ }
794
+ let detail: TaskDetail;
795
+ try {
796
+ detail = provider.detail(entry);
797
+ } catch {
798
+ return;
799
+ }
800
+ const refs = detail.logs ?? [];
801
+ // Re-read the bounded tails on every refresh: a live monitor's log grows
802
+ // while the detail is open. The refresh is already throttled and each read
803
+ // is capped by bytes and lines.
804
+ this.logs = refs.map((log) => {
805
+ const tail = readLogTail(log.path);
806
+ return {
807
+ label: log.label,
808
+ path: log.path,
809
+ note: tail.note,
810
+ lines: tail.text ? tail.text.split("\n") : [],
811
+ };
812
+ });
813
+ }
814
+
815
+ private requestRender(): void {
816
+ if (this.disposed) return;
817
+ this.renderQueued = true;
818
+ try {
819
+ this.options.tui.requestRender();
820
+ } catch {
821
+ // the TUI may be shutting down
822
+ }
823
+ }
824
+
825
+ // ------------------------------------------------------------ input
826
+
827
+ handleInput(data: string): void {
828
+ if (this.disposed) return;
829
+ this.recentTask = undefined;
830
+
831
+ if (this.confirming) {
832
+ if (data === "y" || data === "Y") {
833
+ const target = this.confirming;
834
+ this.confirming = undefined;
835
+ void this.stop(target);
836
+ return;
837
+ }
838
+ if (data === "n" || data === "N" || this.isCancel(data)) {
839
+ this.confirming = undefined;
840
+ this.status = "";
841
+ this.requestRender();
842
+ return;
843
+ }
844
+ return;
845
+ }
846
+
847
+ if (this.detailKey) {
848
+ if (this.isCancel(data)) {
849
+ this.detailKey = undefined;
850
+ this.detailScroll = 0;
851
+ this.status = "";
852
+ this.requestRender();
853
+ return;
854
+ }
855
+ if (data === "x" || data === "X") {
856
+ this.askStop();
857
+ return;
858
+ }
859
+ const page = this.pageSize();
860
+ if (this.isUp(data)) this.scrollDetail(-1);
861
+ else if (this.isDown(data)) this.scrollDetail(1);
862
+ else if (this.isPageUp(data)) this.scrollDetail(-page);
863
+ else if (this.isPageDown(data)) this.scrollDetail(page);
864
+ return;
865
+ }
866
+
867
+ if (this.isNextTab(data)) {
868
+ this.switchTab(1);
869
+ return;
870
+ }
871
+ if (this.isPreviousTab(data)) {
872
+ this.switchTab(-1);
873
+ return;
874
+ }
875
+ if (this.isCancel(data)) {
876
+ this.options.onClose();
877
+ return;
878
+ }
879
+ if (this.isUp(data)) {
880
+ this.move(-1);
881
+ return;
882
+ }
883
+ if (this.isDown(data)) {
884
+ this.move(1);
885
+ return;
886
+ }
887
+ if (this.isPageUp(data)) {
888
+ this.move(-1, true);
889
+ return;
890
+ }
891
+ if (this.isPageDown(data)) {
892
+ this.move(1, true);
893
+ return;
894
+ }
895
+ if (this.isConfirm(data)) {
896
+ const entry = this.selected();
897
+ if (entry) {
898
+ this.detailKey = taskKey(entry);
899
+ this.detailScroll = 0;
900
+ this.reloadLogs();
901
+ this.requestRender();
902
+ }
903
+ return;
904
+ }
905
+ if (data === "x" || data === "X") {
906
+ this.askStop();
907
+ }
908
+ }
909
+
910
+ private askStop(): void {
911
+ if (this.stoppingKey) return;
912
+ const entry = this.detailKey ? this.entryFor(this.detailKey) : this.selected();
913
+ if (!entry) return;
914
+ if (!entry.active) {
915
+ this.status = "This task has ended; there is nothing to stop.";
916
+ this.requestRender();
917
+ return;
918
+ }
919
+ const provider = providerFor(this.options.registry, entry.provider);
920
+ if (!provider?.stop) {
921
+ this.status = "This task cannot be stopped from here.";
922
+ this.requestRender();
923
+ return;
924
+ }
925
+ this.confirming = entry;
926
+ this.status = `Stop ${entry.providerLabel} ${entry.id}? (y/N)`;
927
+ this.requestRender();
928
+ }
929
+
930
+ private async stop(entry: TaskEntry): Promise<void> {
931
+ if (this.disposed || this.stoppingKey) return;
932
+ const provider = providerFor(this.options.registry, entry.provider);
933
+ if (!provider?.stop) return;
934
+ const key = taskKey(entry);
935
+ this.stoppingKey = key;
936
+ this.status = `Stopping ${entry.providerLabel} ${entry.id}\u2026`;
937
+ this.requestRender();
938
+ try {
939
+ const result = await provider.stop(entry.id);
940
+ if (this.disposed) return;
941
+ this.stoppingKey = undefined;
942
+ this.status = oneLine(
943
+ result?.message ??
944
+ (result?.stopped ? `Stopped ${entry.providerLabel} ${entry.id}.` : `${entry.providerLabel} ${entry.id} was already gone.`),
945
+ );
946
+ } catch (error) {
947
+ if (this.disposed) return;
948
+ this.stoppingKey = undefined;
949
+ this.status = `Stop failed: ${oneLine(error instanceof Error ? error.message : String(error))}`;
950
+ }
951
+ this.refreshNow(true);
952
+ }
953
+
954
+ private scrollDetail(delta: number): void {
955
+ // Recompute the line count from the live width and height: a resize
956
+ // between renders must not leave PageUp/PageDown on stale bounds.
957
+ this.syncDetailMetrics();
958
+ const viewport = Math.max(1, this.pageSize());
959
+ this.detailViewport = viewport;
960
+ const max = Math.max(0, this.detailLineCount - viewport);
961
+ this.detailScroll = Math.max(0, Math.min(max, this.detailScroll + delta));
962
+ this.requestRender();
963
+ }
964
+
965
+ private syncDetailMetrics(): void {
966
+ if (!this.detailKey) return;
967
+ const lines = this.detailLines(this.metricsWidth());
968
+ this.detailLineCount = lines.length;
969
+ this.detailViewport = Math.max(1, this.pageSize());
970
+ this.detailScroll = Math.max(0, Math.min(Math.max(0, lines.length - this.detailViewport), this.detailScroll));
971
+ }
972
+
973
+ private isCancel(data: string): boolean {
974
+ return matchesKey(data, "escape") || this.options.keybindings.matches(data, "tui.select.cancel");
975
+ }
976
+ private isConfirm(data: string): boolean {
977
+ return this.options.keybindings.matches(data, "tui.select.confirm");
978
+ }
979
+ private isUp(data: string): boolean {
980
+ return this.options.keybindings.matches(data, "tui.select.up");
981
+ }
982
+ private isDown(data: string): boolean {
983
+ return this.options.keybindings.matches(data, "tui.select.down");
984
+ }
985
+ private isPageUp(data: string): boolean {
986
+ return this.options.keybindings.matches(data, "tui.select.pageUp");
987
+ }
988
+ private isPageDown(data: string): boolean {
989
+ return this.options.keybindings.matches(data, "tui.select.pageDown");
990
+ }
991
+ private isNextTab(data: string): boolean {
992
+ return data === "\t" || matchesKey(data, "tab");
993
+ }
994
+ private isPreviousTab(data: string): boolean {
995
+ return data === "\x1b[Z" || matchesKey(data, "shift+tab");
996
+ }
997
+
998
+ // ---------------------------------------------------------- rendering
999
+
1000
+ private rows(): number {
1001
+ const rows = this.options.tui.terminal?.rows;
1002
+ return Number.isFinite(rows) && (rows ?? 0) > 0 ? rows! : 24;
1003
+ }
1004
+
1005
+ private columns(): number {
1006
+ const columns = this.options.tui.terminal?.columns;
1007
+ return Number.isFinite(columns) && (columns ?? 0) > 0 ? columns! : 80;
1008
+ }
1009
+
1010
+ /** The native overlay's height budget: 90% of rows, also inside margin 1 top and bottom. */
1011
+ private overlayRows(): number {
1012
+ const rows = this.rows();
1013
+ const cap = Math.max(1, Math.floor(rows * 0.9));
1014
+ const insideMargins = Math.max(1, rows - 2);
1015
+ return Math.max(1, Math.min(cap, insideMargins));
1016
+ }
1017
+
1018
+ private estimatedOverlayWidth(): number {
1019
+ return panelWidth(this.columns());
1020
+ }
1021
+
1022
+ /**
1023
+ * The body height the current terminal can show. Framed modes keep the
1024
+ * title/tabs/rules/hint; very short terminals switch to a borderless layout
1025
+ * with just tabs, body and hint, and finally to a single line.
1026
+ */
1027
+ private layoutFor(width: number = this.lastRenderWidth || this.estimatedOverlayWidth()): {
1028
+ mode: "framed" | "compact" | "borderless" | "twoLine" | "single";
1029
+ body: number;
1030
+ max: number;
1031
+ } {
1032
+ const max = this.overlayRows();
1033
+ const mode = width >= 6 && max >= 9 ? "framed"
1034
+ : width >= 6 && max >= 7 ? "compact"
1035
+ : max >= 3 ? "borderless" : max === 2 ? "twoLine" : "single";
1036
+ const chrome = mode === "framed" ? 7 : mode === "compact" ? 6 : mode === "borderless" ? 2 : 1;
1037
+ const bodyWidth = mode === "framed" || mode === "compact" ? Math.max(1, width - 4) : width;
1038
+ const wanted = this.detailKey ? this.detailLines(bodyWidth).length : Math.max(2, this.visible().length);
1039
+ const body = Math.max(1, Math.min(PANEL_MAX_BODY_ROWS, wanted, max - chrome));
1040
+ return { mode, body, max };
1041
+ }
1042
+
1043
+ private pageSize(): number {
1044
+ return Math.max(1, this.layoutFor().body);
1045
+ }
1046
+
1047
+ private metricsWidth(): number {
1048
+ if (this.lastColumns === this.columns() && this.lastBodyWidth > 0) return this.lastBodyWidth;
1049
+ const overlay = this.estimatedOverlayWidth();
1050
+ const layout = this.layoutFor(overlay);
1051
+ return layout.mode === "framed" || layout.mode === "compact"
1052
+ ? Math.max(1, overlay - 4)
1053
+ : Math.max(1, overlay);
1054
+ }
1055
+
1056
+ /** Slice the detail or list body to the current height and record scroll metrics. */
1057
+ private bodyLines(width: number, height: number): string[] {
1058
+ if (this.detailKey) {
1059
+ const full = this.detailLines(width);
1060
+ this.detailLineCount = full.length;
1061
+ this.detailViewport = height;
1062
+ const maxScroll = Math.max(0, full.length - height);
1063
+ this.detailScroll = Math.max(0, Math.min(maxScroll, this.detailScroll));
1064
+ return full.slice(this.detailScroll, this.detailScroll + height);
1065
+ }
1066
+ this.detailLineCount = 0;
1067
+ this.detailViewport = 0;
1068
+ return this.listLines(width, height);
1069
+ }
1070
+
1071
+ private listLines(width: number, height: number): string[] {
1072
+ const list = this.visible();
1073
+ if (!list.length) {
1074
+ const message =
1075
+ this.tab === "all"
1076
+ ? "No tasks. Arm a Monitor or start a /loop."
1077
+ : this.tab === "monitor"
1078
+ ? providerFor(this.options.registry, "monitor")
1079
+ ? "No monitors in this session."
1080
+ : "pi-cc-monitor is not loaded."
1081
+ : providerFor(this.options.registry, "loop")
1082
+ ? "No loops in this session."
1083
+ : "pi-cc-loop is not loaded.";
1084
+ return this.tab === "all"
1085
+ ? [this.options.theme.fg("text", "No tasks."), this.panelTheme().fg("muted", "Start a Monitor or /loop to add one.")]
1086
+ : [this.options.theme.fg("text", message)];
1087
+ }
1088
+ const index = Math.max(
1089
+ 0,
1090
+ list.findIndex((entry) => taskKey(entry) === this.selectedKey),
1091
+ );
1092
+ if (index < this.listScroll) this.listScroll = index;
1093
+ if (index >= this.listScroll + height) this.listScroll = index - height + 1;
1094
+ this.listScroll = Math.max(0, Math.min(this.listScroll, Math.max(0, list.length - height)));
1095
+ const lines: string[] = [];
1096
+ const theme = this.panelTheme();
1097
+ for (let i = this.listScroll; i < Math.min(list.length, this.listScroll + height); i++) {
1098
+ const entry = list[i]!;
1099
+ const selected = entry === this.selected();
1100
+ const marker = selected ? theme.fg("accent", "\u25b8 ") : " ";
1101
+ const tag = this.tab === "all" ? `${theme.fg("dim", entry.provider === "monitor" ? "M" : "L")} ` : "";
1102
+ const state = theme.fg(stateColor(entry.state), oneLine(entry.state));
1103
+ const title = selected ? theme.fg("text", theme.bold(oneLine(entry.title))) : oneLine(entry.title);
1104
+ const meta = entry.meta ? theme.fg("dim", ` \u00b7 ${oneLine(entry.meta)}`) : "";
1105
+ const line = fitWidth(`${marker}${tag}${state} ${title}${meta}`, width);
1106
+ lines.push(selected ? this.styled(line, { bg: this.palette().selected }) : line);
1107
+ }
1108
+ return lines;
1109
+ }
1110
+
1111
+ private detailLines(width: number): string[] {
1112
+ const entry = this.detailKey ? this.entryFor(this.detailKey) : undefined;
1113
+ if (!entry) return [];
1114
+ const theme = this.panelTheme();
1115
+ const lines: string[] = [];
1116
+ lines.push(fitWidth(theme.fg("accent", theme.bold(`${entry.providerLabel} \u00b7 ${oneLine(entry.id)}`)), width));
1117
+ lines.push(fitWidth(theme.fg("text", oneLine(entry.title)), width));
1118
+
1119
+ let detail: TaskDetail | undefined;
1120
+ const provider = providerFor(this.options.registry, entry.provider);
1121
+ if (provider?.detail) {
1122
+ try {
1123
+ detail = provider.detail(entry);
1124
+ } catch (error) {
1125
+ lines.push(fitWidth(theme.fg("error", `Detail unavailable: ${oneLine(error instanceof Error ? error.message : String(error))}`), width));
1126
+ }
1127
+ }
1128
+ if (detail?.heading) lines.push(fitWidth(theme.fg("muted", oneLine(detail.heading)), width));
1129
+ if (detail) {
1130
+ const labels = detail.rows.map((row) => oneLine(row.label));
1131
+ const labelWidth = Math.min(12, Math.max(1, ...labels.map((label) => visibleWidth(label))));
1132
+ const inline = width >= labelWidth + 16;
1133
+ for (let i = 0; i < detail.rows.length; i++) {
1134
+ const label = labels[i] ?? "";
1135
+ const value = oneLine(detail.rows[i]!.value);
1136
+ if (!inline) {
1137
+ lines.push(fitWidth(theme.fg("dim", label), width));
1138
+ for (const part of wrapTextWithAnsi(value, Math.max(8, width - 2))) {
1139
+ lines.push(fitWidth(` ${part}`, width));
1140
+ }
1141
+ continue;
1142
+ }
1143
+ const prefix = theme.fg("dim", `${label.padEnd(labelWidth)} `);
1144
+ const available = Math.max(8, width - labelWidth - 1);
1145
+ const wrapped = wrapTextWithAnsi(value, available);
1146
+ lines.push(fitWidth(`${prefix}${wrapped[0] ?? ""}`, width));
1147
+ for (const part of wrapped.slice(1)) {
1148
+ lines.push(fitWidth(`${" ".repeat(labelWidth + 1)}${part}`, width));
1149
+ }
1150
+ }
1151
+ if (detail.note) lines.push(fitWidth(theme.fg("dim", oneLine(detail.note)), width));
1152
+ } else if (!provider) {
1153
+ lines.push(fitWidth(theme.fg("dim", "The owning package is no longer loaded."), width));
1154
+ }
1155
+
1156
+ if (this.logs.length) {
1157
+ lines.push("");
1158
+ for (const log of this.logs) {
1159
+ lines.push(
1160
+ fitWidth(
1161
+ theme.fg("accent", theme.bold(`${oneLine(log.label)}`)) +
1162
+ theme.fg("dim", log.note ? ` \u00b7 ${oneLine(log.note)}` : " \u00b7 recent output"),
1163
+ width,
1164
+ ),
1165
+ );
1166
+ if (!log.lines.length) {
1167
+ lines.push(fitWidth(theme.fg("dim", " (empty)"), width));
1168
+ }
1169
+ for (const line of log.lines) {
1170
+ lines.push(fitWidth(theme.fg("text", ` ${line}`), width));
1171
+ }
1172
+ }
1173
+ }
1174
+ return lines;
1175
+ }
1176
+
1177
+ private tabsLine(width: number): string {
1178
+ const theme = this.panelTheme();
1179
+ const counts: Record<TaskTab, number> = {
1180
+ all: this.entries.length,
1181
+ monitor: this.entries.filter((entry) => entry.provider === "monitor").length,
1182
+ loop: this.entries.filter((entry) => entry.provider === "loop").length,
1183
+ };
1184
+ const parts = TABS.map((tab) => {
1185
+ const label = ` ${tab.label} ${counts[tab.id]} `;
1186
+ return tab.id === this.tab
1187
+ ? this.styled(label, { fg: this.palette().text, bg: this.palette().tab, bold: true })
1188
+ : theme.fg("muted", label);
1189
+ });
1190
+ return fitWidth(parts.join(" "), width);
1191
+ }
1192
+
1193
+ private titleLine(width: number): string {
1194
+ const theme = this.panelTheme();
1195
+ const title = this.detailKey ? "Task detail" : "Tasks";
1196
+ const right = this.detailKey ? "Esc back" : "Esc close";
1197
+ const left = theme.fg("accent", theme.bold(title));
1198
+ const gap = width - visibleWidth(left) - visibleWidth(right);
1199
+ if (gap < 1) return fitWidth(left, width);
1200
+ return fitWidth(`${left}${" ".repeat(gap)}${theme.fg("dim", right)}`, width);
1201
+ }
1202
+
1203
+ private hintLine(width: number): string {
1204
+ const theme = this.panelTheme();
1205
+ if (this.status) return fitWidth(theme.fg("warning", this.status), width);
1206
+ if (this.confirming) return fitWidth(theme.fg("warning", "y stop \u00b7 n/Esc cancel"), width);
1207
+ if (this.detailKey) {
1208
+ const total = this.detailLineCount;
1209
+ const visible = this.detailViewport;
1210
+ const scroll = total > visible ? ` ${this.detailScroll + 1}-${Math.min(total, this.detailScroll + visible)}/${total}` : "";
1211
+ return fitWidth(theme.fg("dim", `\u2191\u2193 scroll \u00b7 pgUp/pgDn page \u00b7 x stop \u00b7 Esc back${scroll}`), width);
1212
+ }
1213
+ const newTask = this.recentTask ? theme.fg("warning", `new ${oneLine(this.recentTask)} \u00b7 `) : "";
1214
+ return fitWidth(
1215
+ newTask + theme.fg("dim", "Tab switch \u00b7 \u2191\u2193 select \u00b7 Enter detail \u00b7 x stop \u00b7 Esc close"),
1216
+ width,
1217
+ );
1218
+ }
1219
+
1220
+ private singleLine(width: number): string {
1221
+ const entry = this.detailKey ? this.entryFor(this.detailKey) : this.selected();
1222
+ const hint = this.hintLine(width);
1223
+ if (!entry || this.status) return hint;
1224
+ const controls = this.detailKey ? "Esc back" : "Esc close";
1225
+ return fitWidth(`${oneLine(entry.state)} ${oneLine(entry.title)} \u00b7 ${controls}`, width);
1226
+ }
1227
+
1228
+ private renderBorderless(width: number, layout: ReturnType<TasksOverlay["layoutFor"]>): string[] {
1229
+ if (layout.mode === "twoLine") {
1230
+ const body = this.bodyLines(width, 1);
1231
+ return [fitWidth(body[0] ?? this.hintLine(width), width), this.hintLine(width)];
1232
+ }
1233
+ const lines: string[] = [this.tabsLine(width)];
1234
+ const body = this.bodyLines(width, layout.body);
1235
+ while (body.length < layout.body) body.push("");
1236
+ for (const line of body) lines.push(fitWidth(line, width));
1237
+ lines.push(this.hintLine(width));
1238
+ return lines;
1239
+ }
1240
+
1241
+ render(width: number): string[] {
1242
+ // Rebuild colors on every render: the injected theme can change in place.
1243
+ this.renderPalette = panelPalette(this.options.theme);
1244
+ const safeWidth = Math.max(1, Math.floor(Number(width) || 0));
1245
+ this.lastRenderWidth = safeWidth;
1246
+ this.lastColumns = this.columns();
1247
+ const layout = this.layoutFor(safeWidth);
1248
+ if (layout.mode === "single") {
1249
+ this.lastBodyWidth = safeWidth;
1250
+ if (this.detailKey) {
1251
+ this.detailLineCount = this.detailLines(safeWidth).length;
1252
+ this.detailViewport = 1;
1253
+ } else {
1254
+ this.detailLineCount = 0;
1255
+ this.detailViewport = 0;
1256
+ }
1257
+ return [this.paint(this.singleLine(safeWidth), safeWidth)];
1258
+ }
1259
+ if (layout.mode === "borderless" || layout.mode === "twoLine") {
1260
+ this.lastBodyWidth = safeWidth;
1261
+ const lines = this.renderBorderless(safeWidth, layout);
1262
+ return lines.map((line, index) => this.paint(line, safeWidth,
1263
+ index === 0 || index === lines.length - 1 ? this.palette().chrome : this.palette().surface));
1264
+ }
1265
+
1266
+ const inner = safeWidth - 2;
1267
+ const bodyWidth = Math.max(1, inner - 2);
1268
+ this.lastBodyWidth = bodyWidth;
1269
+ const theme = this.panelTheme();
1270
+ const border = (text: string) => theme.fg("border", text);
1271
+ const row = (text: string, background: Color = this.palette().surface) =>
1272
+ this.paint(border("│") + fitWidth(text, inner) + border("│"), safeWidth, background);
1273
+ const rule = (left: string, right: string) => this.paint(border(left + "─".repeat(inner) + right), safeWidth);
1274
+ const lines: string[] = [rule("╭", "╮")];
1275
+ if (layout.mode === "framed") lines.push(row(` ${this.titleLine(bodyWidth)} `, this.palette().chrome));
1276
+ lines.push(row(` ${this.tabsLine(bodyWidth)} `, this.palette().chrome));
1277
+ lines.push(rule("├", "┤"));
1278
+
1279
+ const body = this.bodyLines(bodyWidth, layout.body);
1280
+ while (body.length < layout.body) body.push("");
1281
+ for (const line of body) lines.push(row(` ${line} `));
1282
+ lines.push(rule("├", "┤"));
1283
+ lines.push(row(` ${this.hintLine(bodyWidth)} `, this.palette().chrome));
1284
+ lines.push(rule("╰", "╯"));
1285
+ return lines;
1286
+ }
1287
+
1288
+ }
1289
+
1290
+ /** Truncate/pad a line to exactly `width` visible columns. */
1291
+ function fitWidth(text: string, width: number): string {
1292
+ if (width <= 0) return "";
1293
+ return truncateToWidth(text, width, "\u2026", true);
1294
+ }