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.
@@ -4,16 +4,18 @@
4
4
  * Type to filter, ↑↓ selects, Enter opens, Ctrl+X asks before stopping, Tab
5
5
  * switches between active/recent and the manager's complete task history.
6
6
  */
7
- import { DynamicBorder, keyHint, rawKeyHint, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
7
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
8
8
  import { realClock, type Clock } from "../clock";
9
9
  import type { ManagerClient, TaskRecord } from "../manager-client";
10
10
  import { taskOutputPath } from "../config";
11
- import { formatConversation } from "../subagent/conversation";
12
11
  import type { SubagentRegistry } from "../subagent/registry";
13
12
  import { formatAge, nextAgeBoundary, type WorkIndex, type WorkItem } from "../work-index";
14
- import { notifyPlainFallback, showScrollDetail } from "./scroll-detail-view";
13
+ import { fitKeys, keyHints, notifyPlainFallback, showScrollDetail, spread, type PaneRenderer, type PaneTheme } from "./scroll-detail-view";
14
+ import { plainTheme, renderConversation } from "./conversation-view";
15
+ import { formatDuration } from "../format";
16
+ import { showTaskDetail } from "./task-detail-view";
15
17
  import { readTaskFileTailCached, stderrPathFor } from "./task-output-paths";
16
- import { fitLines, loadPiTui, truncateToWidth } from "./pi-tui-load";
18
+ import { fitLines, loadPiTui, truncateToWidth, visibleWidth } from "./pi-tui-load";
17
19
  import { statusGlyph } from "./tool-component";
18
20
 
19
21
 
@@ -150,20 +152,6 @@ export function formatWorkRows(
150
152
  });
151
153
  }
152
154
 
153
- function selectorHint(theme: { fg(color: string, text: string): string }): string {
154
- try {
155
- return [
156
- keyHint("tui.select.up", ""),
157
- keyHint("tui.select.down", ""),
158
- keyHint("tui.select.confirm", "view"),
159
- rawKeyHint("ctrl+x", "stop"),
160
- keyHint("tui.select.cancel", "close"),
161
- ].join(" ");
162
- } catch {
163
- return theme.fg("dim", "↑↓ select · Enter view · Ctrl+X stop · Esc close · type to filter");
164
- }
165
- }
166
-
167
155
  export interface TaskListChoice {
168
156
  action: "close" | "view" | "stop" | "already-finished";
169
157
  id?: string;
@@ -286,7 +274,6 @@ async function showTaskList(
286
274
  tui.requestRender();
287
275
  scheduleAgeTick();
288
276
  });
289
- const border = new DynamicBorder((text) => theme.fg("border", text));
290
277
  const pageSize = () => Math.max(1, (tui.terminal?.rows ?? 24) - 8);
291
278
  const matches = (data: string, id: "tui.select.up" | "tui.select.down" | "tui.select.confirm" | "tui.select.cancel") => {
292
279
  try { return kb.matches(data, id); } catch { return false; }
@@ -336,6 +323,8 @@ async function showTaskList(
336
323
  };
337
324
  return {
338
325
  render(width: number) {
326
+ const t = theme as unknown as PaneTheme;
327
+ const dim = (text: string) => t.fg("dim", text);
339
328
  const items = visibleItems();
340
329
  if (!items.some((item) => item.id === selectedId)) selectedId = items[0]?.id;
341
330
  const rows = groupTaskRows(items);
@@ -346,33 +335,54 @@ async function showTaskList(
346
335
  const window = rows.slice(start, start + height);
347
336
  const inner = Math.max(1, width);
348
337
  const active = items.filter(isLive).length;
349
- const scopeLabel = scope === "all" ? "All" : "Active + recent";
350
- const titleText = `Tasks ${selectedTask + (items.length ? 1 : 0)}/${items.length} · ${active} active · ${scopeLabel}`;
351
- const title = truncateToWidth(theme.fg("accent", titleText), inner, "…");
352
- const filterLine = truncateToWidth(theme.fg("dim", `Filter: ${filter || "(type to filter)"} · Tab scope`), inner, "…");
353
- const hintLine = hint ? truncateToWidth(theme.fg(hint.startsWith("Already") ? "dim" : "warning", hint), inner, "…") : "";
354
- const confirmLine = confirmId
355
- ? truncateToWidth(theme.fg("error", `Stop ${confirmId}? Enter confirm · Esc cancel`), inner, "…")
356
- : "";
338
+ const scopeLabel = scope === "all" ? "all history" : "active + recent";
339
+ const rule = t.fg("borderMuted", "─".repeat(inner));
340
+ const counts = `${active > 0 ? t.fg("success", `${active} active`) : dim("0 active")}${dim(` · ${items.length} shown · ${scopeLabel}`)}`;
341
+ const title = spread(`${t.fg("accent", "◆")} ${t.bold("Tasks")}`, counts, inner);
342
+ // A long filter keeps its end (what is being typed) in view.
343
+ const shownFilter = visibleWidth(filter) > inner - 4 ? `…${filter.slice(-(inner - 5))}` : filter;
344
+ const prompt = truncateToWidth(
345
+ filter ? `${t.fg("accent", "›")} ${shownFilter}${t.fg("accent", "▏")}` : `${t.fg("accent", "›")} ${dim("type to filter")}`,
346
+ inner,
347
+ "",
348
+ );
349
+ const notice = confirmId
350
+ ? ` ${t.bold(t.fg("error", `■ stop ${confirmId}?`))} ${keyHints(t, [["y ⏎", "confirm"], ["n esc", "cancel"]])}`
351
+ : hint
352
+ ? ` ${t.fg(hint.startsWith("Already") || hint.startsWith("Stop requested") ? "dim" : "warning", hint)}`
353
+ : "";
354
+ const kindWidth = width >= 60 ? 8 : 0;
357
355
  const body = window.map((row) => {
358
- if (row.type === "run") return truncateToWidth(theme.fg("muted", `Run ${row.runId} · ${row.count} child${row.count === 1 ? "" : "ren"}`), inner, "…");
359
- const selected = row.item.id === selectedId;
360
- const marker = selected ? "▸" : " ";
361
- const { color, glyph } = statusGlyph(row.item.status);
362
- const age = formatAge(row.item.startedAt, row.item.endedAt, clock.now());
363
- const terminal = row.item.exitCode !== undefined && row.item.exitCode !== null
364
- ? ` exit=${row.item.exitCode}`
365
- : row.item.signal ? ` ${row.item.signal}` : row.item.endReason ? ` ${row.item.endReason}` : "";
366
- const kind = width >= 60 ? `${row.item.kind.padEnd(7)} ` : "";
367
- const title = row.item.error ? `${row.item.title} · ${row.item.error}` : row.item.title;
368
- const prefix = row.indent ? " " : "";
369
- const text = `${prefix}${marker} ${theme.fg(color as never, glyph)} ${kind}${row.item.status.padEnd(10)} ${age.padEnd(6)}${terminal} ${title}`;
370
- return fitLines(truncateToWidth(text, inner, "…"), inner);
371
- }).flat();
372
- const page = theme.fg("dim", `(${items.length ? selectedTask + 1 : 0}/${items.length}) PgUp/PgDn Home/End · Ctrl+X stop · Enter view`);
373
- return [...border.render(inner), title, filterLine, ...(confirmLine ? [confirmLine] : []), ...(hintLine ? [hintLine] : []), ...body, page, ...border.render(inner)];
356
+ if (row.type === "run") {
357
+ return truncateToWidth(` ${dim("▾")} ${t.fg("muted", row.runId)} ${dim(`· ${row.count} child${row.count === 1 ? "" : "ren"}`)}`, inner, "…");
358
+ }
359
+ const item = row.item;
360
+ const selected = item.id === selectedId;
361
+ const { color, glyph } = statusGlyph(item.status);
362
+ const age = formatAge(item.startedAt, item.endedAt, clock.now());
363
+ const terminal = item.exitCode !== undefined && item.exitCode !== null
364
+ ? `exit=${item.exitCode}`
365
+ : item.signal ?? item.endReason ?? "";
366
+ const name = item.error ? `${item.title} ${t.fg("error", `· ${item.error}`)}` : item.title;
367
+ const lead = (selected ? t.fg("accent", "▌") : " ") + (row.indent ? " " : " ");
368
+ const kind = kindWidth ? t.fg("muted", item.kind.padEnd(kindWidth)) : "";
369
+ // Same rule as top: a finished row keeps a quiet status word.
370
+ const status = t.fg(isLive(item) || color !== "success" ? color : "dim", item.status.padEnd(10));
371
+ const left = `${lead}${t.fg(color, glyph)} ${kind}${status}${selected ? t.bold(name) : name}`;
372
+ const right = dim(`${terminal ? `${terminal} ` : ""}${age}`);
373
+ return spread(left, right, inner);
374
+ });
375
+ if (body.length === 0) body.push(` ${dim(filter ? "No task matches the filter." : "No background tasks.")}`);
376
+ const place = items.length ? `${selectedTask + 1}${dim(`/${items.length}`)}` : "";
377
+ const keys = fitKeys(
378
+ t,
379
+ "",
380
+ [["↑↓", "select", 2], ["⏎", "open", 1], ["ctrl+x", "stop", 3], ["tab", "scope", 4], ["esc", filter ? "clear" : "close", 0]],
381
+ inner - 2 - visibleWidth(place) - 2,
382
+ );
383
+ return [rule, title, prompt, ...(notice ? [truncateToWidth(notice, inner, "…")] : []), "", ...body, "", spread(` ${keys}`, place, inner), rule];
374
384
  },
375
- invalidate() { border.invalidate(); },
385
+ invalidate() {},
376
386
  handleInput(data: string) {
377
387
  const items = visibleItems();
378
388
  const current = items[selectedIndex(items, selectedId)];
@@ -383,7 +393,8 @@ async function showTaskList(
383
393
  return;
384
394
  }
385
395
  if (confirmId) {
386
- if (matchesKey(data, "tui.select.confirm") || data === "\r" || data === "\n") { void requestStop(); return; }
396
+ if (matchesKey(data, "tui.select.confirm") || data === "\r" || data === "\n" || data === "y" || data === "Y") { void requestStop(); return; }
397
+ if (data === "n" || data === "N") { confirmId = undefined; hint = "Stop cancelled."; tui.requestRender(); return; }
387
398
  return;
388
399
  }
389
400
  if (matches(data, "tui.select.up")) { choose(moveSelection(items, selectedId, -1) ?? selectedId); return; }
@@ -445,7 +456,40 @@ export function taskDetailHeader(item: WorkItem, now: number): string {
445
456
  const outcome = item.exitCode !== undefined && item.exitCode !== null
446
457
  ? `exit ${item.exitCode}`
447
458
  : item.signal ?? item.endReason ?? item.status;
448
- return `${item.id} · ${item.kind} · ${outcome} · ${formatAge(item.startedAt, item.endedAt, now)} · ${item.cwd ?? "cwd unavailable"}\n$ ${item.command ?? item.title}`;
459
+ const head = `${item.id} · ${item.kind} · ${outcome} · ${formatAge(item.startedAt, item.endedAt, now)}`;
460
+ // Agents have no cwd or command line of their own.
461
+ if (item.kind === "agent") return head;
462
+ return `${head} · ${item.cwd ?? "cwd unavailable"}\n$ ${item.command ?? item.title}`;
463
+ }
464
+
465
+ const SPIN = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧"];
466
+
467
+ /** `12.4k`, `1.3M`: same as top's compact numbers. */
468
+ export function compactNum(n: number): string {
469
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
470
+ if (n >= 10_000) return `${Math.floor(n / 1000)}k`;
471
+ if (n >= 1_000) return `${(n / 1000).toFixed(1)}k`;
472
+ return String(n);
473
+ }
474
+
475
+ /** `⠋ working 12.4k in · 1.8k out · 24.0 tok/s · llm 1m02s · tool 31s`, as in top's agent footer. */
476
+ export function agentStatusLine(
477
+ handle: { tokenUsage(): { input: number; output: number }; wallUsage(): { llmMs: number; toolMs: number } } | undefined,
478
+ live: boolean,
479
+ now: number,
480
+ theme: { fg(color: string, text: string): string; italic(text: string): string },
481
+ ): string {
482
+ const dim = (s: string) => theme.fg("dim", s);
483
+ const parts: string[] = [];
484
+ if (handle) {
485
+ const tokens = handle.tokenUsage();
486
+ const wall = handle.wallUsage();
487
+ parts.push(`${compactNum(tokens.input)}${dim(" in")}`, `${compactNum(tokens.output)}${dim(" out")}`);
488
+ if (wall.llmMs > 0) parts.push(`${((tokens.output * 1000) / wall.llmMs).toFixed(1)}${dim(" tok/s")}`);
489
+ parts.push(`${formatDuration(wall.llmMs)}${dim(" llm")}`, `${formatDuration(wall.toolMs)}${dim(" tool")}`);
490
+ }
491
+ const spin = live ? `${theme.fg("accent", SPIN[Math.floor(now / 80) % SPIN.length])} ${theme.italic(theme.fg("accent", "working"))} ` : "";
492
+ return spin + parts.join(dim(" · "));
449
493
  }
450
494
 
451
495
  export function resolveTaskOutputPath(item: WorkItem, home: string | undefined, sessionId: string | undefined): string {
@@ -467,75 +511,64 @@ export function taskDetailInfo(item: WorkItem, now: number): string {
467
511
  return lines.join("\n");
468
512
  }
469
513
 
470
- function isLiveStatus(status: string): boolean {
471
- return status === "running" || status === "pending";
472
- }
473
-
474
- /**
475
- * Tab bodies for a shell or monitor. `getItem` is read on every poll so the
476
- * header follows the task (a view opened while it ran must not keep saying
477
- * "running"), and an empty file says why it is empty.
478
- */
479
- export function taskDetailTabs(
480
- getItem: () => WorkItem,
481
- paths: { outputPath: string; stderrPath: string },
482
- now: () => number,
483
- ): { output: () => string; stderr: () => string; info: () => string } {
484
- const body = (path: string, emptyLive: string, emptyDone: string) => {
485
- const text = readTaskFileTailCached(path);
486
- if (text !== "(empty)") return text;
487
- return isLiveStatus(getItem().status) ? emptyLive : emptyDone;
488
- };
489
- return {
490
- output: () => `${taskDetailHeader(getItem(), now())}\n\n${body(paths.outputPath, "(no output yet)", "(no output; the command printed nothing)")}`,
491
- stderr: () => `${taskDetailHeader(getItem(), now())}\n\n${body(paths.stderrPath, "(nothing on stderr yet)", "(nothing on stderr)")}`,
492
- info: () => taskDetailInfo({ ...getItem(), ...paths }, now()),
493
- };
494
- }
495
-
496
514
  async function viewItem(ctx: ExtensionContext, item: WorkItem, deps: TasksCommandDeps): Promise<void> {
497
515
  if (!ctx.hasUI) return;
498
516
  const clock = deps.clock ?? realClock;
499
517
  if (item.kind === "agent") {
500
518
  const handle = () => deps.getRegistry()?.handle(item.id);
501
519
  const conversation = () => handle()?.conversation() ?? [];
502
- const conversationText = () => {
520
+ const name = item.name ?? item.title;
521
+ const turnsOrPrompt = () => {
503
522
  const turns = conversation();
504
- return turns.length > 0 ? formatConversation(turns) : item.prompt ? `Task prompt:\n${item.prompt}` : "(no conversation yet)";
523
+ return turns.length > 0 ? turns : item.prompt ? [{ role: "user", text: item.prompt }] : [];
505
524
  };
525
+ const thread: PaneRenderer = {
526
+ render(width, theme, state) {
527
+ return renderConversation(turnsOrPrompt(), width, theme, { ...state, agent: name });
528
+ },
529
+ };
530
+ const current = () => deps.getIndex()?.get(item.id) ?? item;
531
+ const live = () => ["running", "pending"].includes(current().status);
506
532
  const resultText = () => {
507
533
  const latest = [...conversation()].reverse().find((turn) => turn.role === "assistant")?.text;
508
534
  return latest ?? item.text ?? "(no result yet)";
509
535
  };
510
- const infoText = () => taskDetailInfo(deps.getIndex()?.get(item.id) ?? item, clock.now());
536
+ const infoText = () => taskDetailInfo(current(), clock.now());
511
537
  try {
512
538
  await showScrollDetail(ctx.ui, {
513
- title: `subagent ${item.title}`,
539
+ title: `${item.id} · ${name}`,
514
540
  tabs: {
515
- conversation: conversationText,
541
+ conversation: thread,
516
542
  result: resultText,
517
543
  info: infoText,
518
544
  },
545
+ live,
546
+ status: (theme) => agentStatusLine(handle(), live(), clock.now(), theme),
519
547
  pollMs: 500,
520
548
  clock: deps.clock,
521
549
  });
522
550
  } catch {
523
- notifyPlainFallback(ctx.ui.notify.bind(ctx.ui), item.title, `${infoText()}\n\n${conversationText()}`);
551
+ const plain = renderConversation(turnsOrPrompt(), 100, plainTheme, { expanded: true, agent: name }).join("\n");
552
+ notifyPlainFallback(ctx.ui.notify.bind(ctx.ui), item.title, `${infoText()}\n\n${plain}`);
524
553
  }
525
554
  return;
526
555
  }
527
556
  const outputPath = resolveTaskOutputPath(item, deps.home, deps.sessionId?.());
528
557
  const stderrPath = item.stderrPath || stderrPathFor(outputPath);
529
- const tabs = taskDetailTabs(() => deps.getIndex()?.get(item.id) ?? item, { outputPath, stderrPath }, () => clock.now());
530
- const infoText = tabs.info;
558
+ const getItem = () => deps.getIndex()?.get(item.id) ?? item;
531
559
  try {
532
- await showScrollDetail(ctx.ui, {
533
- title: `${item.kind} ${item.title}`,
534
- tabs,
560
+ await showTaskDetail(ctx.ui, {
561
+ title: `${item.id} · ${item.title}`,
562
+ getItem,
563
+ paths: { outputPath, stderrPath },
535
564
  pollMs: 500,
536
565
  clock: deps.clock,
537
566
  });
538
567
  } catch {
539
- notifyPlainFallback(ctx.ui.notify.bind(ctx.ui), item.title, `${infoText()}\n\n${readTaskFileTailCached(outputPath)}`);
568
+ notifyPlainFallback(
569
+ ctx.ui.notify.bind(ctx.ui),
570
+ item.title,
571
+ `${taskDetailInfo({ ...getItem(), outputPath, stderrPath }, clock.now())}\n\n${readTaskFileTailCached(outputPath)}`,
572
+ );
540
573
  }
541
574
  }
@@ -1,6 +1,6 @@
1
1
  import { fitLines } from "./pi-tui-load";
2
2
 
3
- export function renderToolLines(lines: string[], width: number): string[] {
3
+ function renderToolLines(lines: string[], width: number): string[] {
4
4
  const limit = Math.max(1, width);
5
5
  const out: string[] = [];
6
6
  for (const line of lines) out.push(...fitLines(line, limit));
@@ -16,11 +16,15 @@ export function toolComponent(lines: string[]): { render(width: number): string[
16
16
  };
17
17
  }
18
18
 
19
+ /**
20
+ * The one status mapping, shared with `pi-famulus top`: running ● green,
21
+ * pending ○ dim, done ✓ green, failed ✗ red, stopped / timeout ■ amber.
22
+ */
19
23
  export function statusGlyph(status: string | undefined, isError = false): { color: string; glyph: string } {
20
24
  if (isError || status === "failed" || status === "killed" || status === "orphaned" || status === "interrupted") {
21
25
  return { color: "error", glyph: "✗" };
22
26
  }
23
- if (status === "running") return { color: "accent", glyph: "●" };
27
+ if (status === "running") return { color: "success", glyph: "●" };
24
28
  if (status === "pending") return { color: "dim", glyph: "○" };
25
29
  if (status === "partial" || status === "timeout" || status === "stopped") {
26
30
  return { color: "warning", glyph: "■" };
package/src/wake.ts CHANGED
@@ -94,6 +94,17 @@ export interface WakeTiming {
94
94
  ageMs?: number;
95
95
  }
96
96
 
97
+ /** Original-output coordinates and observation times; independent of wake delivery time. */
98
+ export interface MonitorOutputInfo {
99
+ firstObservedAt?: number;
100
+ lastObservedAt?: number;
101
+ cursorStart?: number;
102
+ cursorEnd?: number;
103
+ truncated?: boolean;
104
+ suppressedCount?: number;
105
+ omittedChars?: number;
106
+ }
107
+
97
108
  export type FamulusWake = FamulusWakeBody & WakeTiming;
98
109
 
99
110
  type FamulusWakeBody =
@@ -106,6 +117,7 @@ type FamulusWakeBody =
106
117
  event: string;
107
118
  eventCount?: number;
108
119
  droppedLines?: number;
120
+ output?: MonitorOutputInfo;
109
121
  }
110
122
  | {
111
123
  kind: "subagent-handover";
@@ -316,7 +328,21 @@ function renderMonitor(details: Extract<FamulusWake, { kind: "monitor" }>): stri
316
328
  if (details.droppedLines !== undefined && details.droppedLines > 0) {
317
329
  attrs.push(`dropped-lines="${details.droppedLines}"`);
318
330
  }
319
- return [`<pi-famulus-wake ${attrs.join(" ")}>`, ` <event>${escapeXml(details.event)}</event>`, "</pi-famulus-wake>"].join("\n");
331
+ const output = details.output;
332
+ const parts = [`<pi-famulus-wake ${attrs.join(" ")}>`, ` <event>${escapeXml(details.event)}</event>`];
333
+ if (output?.cursorStart !== undefined) {
334
+ const fields: string[] = [];
335
+ if (output.firstObservedAt !== undefined) fields.push(`first-observed-at="${wakeTimestamp(output.firstObservedAt)}"`);
336
+ if (output.lastObservedAt !== undefined) fields.push(`last-observed-at="${wakeTimestamp(output.lastObservedAt)}"`);
337
+ fields.push(`cursor-start="${output.cursorStart}"`);
338
+ if (output.cursorEnd !== undefined) fields.push(`cursor-end="${output.cursorEnd}"`);
339
+ if (output.truncated) fields.push('truncated="true"');
340
+ if (output.suppressedCount) fields.push(`duplicates-suppressed="${output.suppressedCount}"`);
341
+ if (output.omittedChars) fields.push(`omitted-chars="${output.omittedChars}"`);
342
+ parts.push(` <output ${fields.join(" ")}>${escapeXml(PROMPTS["wakes.monitor.output-hint"])}</output>`);
343
+ }
344
+ parts.push("</pi-famulus-wake>");
345
+ return parts.join("\n");
320
346
  }
321
347
 
322
348
  function renderHandover(details: Extract<FamulusWake, { kind: "subagent-handover" }>): string {