pi-crew 0.9.11 → 0.9.12

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.
@@ -13,7 +13,7 @@ import type { RunSnapshotCache, RunUiSnapshot } from "../snapshot-types.ts";
13
13
  import type { CrewTheme } from "../theme-adapter.ts";
14
14
  import { asCrewTheme, subscribeThemeChange } from "../theme-adapter.ts";
15
15
  import { truncate } from "../../utils/visual.ts";
16
- import { requestRender, setExtensionWidget } from "../pi-ui-compat.ts";
16
+ import { requestRender, requestRenderTarget, setExtensionWidget } from "../pi-ui-compat.ts";
17
17
  import { spinnerBucket, spinnerFrame } from "../spinner.ts";
18
18
  import { runEventBus } from "../run-event-bus.ts";
19
19
  import { DEFAULT_UI } from "../../config/defaults.ts";
@@ -51,6 +51,35 @@ const LEGACY_WIDGET_KEY = "pi-crew";
51
51
  const WIDGET_KEY = "pi-crew-active";
52
52
  const STATUS_KEY = "pi-crew";
53
53
 
54
+ // ── Terminal resize handling (T-2) ────────────────────────────────────
55
+ // On a terminal resize the widget's cached render width goes stale until the
56
+ // next invalidate; a mid-run resize could briefly paint a frame at the old
57
+ // width. We register ONE debounced process-level listener (guarded so it never
58
+ // accumulates across widget reinstalls) that busts the active widget's cache
59
+ // and pokes Pi to repaint at the new width. The listener references a
60
+ // module-level `activeResizeTarget` (the most-recently-mounted widget) rather
61
+ // than a specific instance, so replaced widgets do not leak listeners.
62
+ let resizeListenerInstalled = false;
63
+ let activeResizeTarget: { invalidate(): void; requestRepaint(): void } | undefined;
64
+
65
+ function installResizeListener(): void {
66
+ if (resizeListenerInstalled) return;
67
+ resizeListenerInstalled = true;
68
+ let timer: ReturnType<typeof setTimeout> | undefined;
69
+ const onResize = (): void => {
70
+ if (timer) clearTimeout(timer);
71
+ // Debounce (~120ms) so a drag-resize doesn't thrash renders.
72
+ timer = setTimeout(() => {
73
+ timer = undefined;
74
+ activeResizeTarget?.invalidate();
75
+ activeResizeTarget?.requestRepaint();
76
+ }, 120);
77
+ };
78
+ process.on("SIGWINCH", onResize);
79
+ // Windows has no SIGWINCH; Node emits "resize" on stdout instead.
80
+ if (typeof process.stdout?.on === "function") process.stdout.on("resize", onResize);
81
+ }
82
+
54
83
  // ── Widget Component ──────────────────────────────────────────────────
55
84
 
56
85
  interface WidgetComponent {
@@ -66,13 +95,21 @@ class CrewWidgetComponent implements WidgetComponent {
66
95
  private cachedLines: string[] = [];
67
96
  private cachedBaseLines: string[] = [];
68
97
  private cachedTheme: CrewTheme;
98
+ private readonly tui: unknown;
69
99
  private readonly unsubscribeTheme: () => void;
70
100
  private readonly unsubscribeEventBus: () => void;
71
101
 
72
- constructor(model: CrewWidgetModel, themeLike: unknown) {
102
+ constructor(model: CrewWidgetModel, themeLike: unknown, tui?: unknown) {
73
103
  this.model = model;
74
104
  this.theme = asCrewTheme(themeLike);
75
105
  this.cachedTheme = this.theme;
106
+ this.tui = tui;
107
+ // Register as the active resize target and ensure the single, guarded
108
+ // terminal-resize listener is installed. On a resize the cached width
109
+ // goes stale; busting the cache + requesting a repaint refreshes the
110
+ // widget at the new width without waiting for the next event tick (T-2).
111
+ activeResizeTarget = this;
112
+ installResizeListener();
76
113
  this.unsubscribeTheme = subscribeThemeChange(themeLike, () => this.invalidate());
77
114
  this.unsubscribeEventBus = (() => {
78
115
  const unsub1 = runEventBus.onChannel("run:state", () => this.invalidate());
@@ -114,9 +151,15 @@ class CrewWidgetComponent implements WidgetComponent {
114
151
  this.cachedLines = [];
115
152
  }
116
153
 
154
+ /** Poke the host TUI to repaint immediately (defensive: no-op if unavailable). */
155
+ requestRepaint(): void {
156
+ requestRenderTarget(this.tui);
157
+ }
158
+
117
159
  dispose(): void {
118
160
  this.unsubscribeTheme();
119
161
  this.unsubscribeEventBus();
162
+ if (activeResizeTarget === this) activeResizeTarget = undefined;
120
163
  }
121
164
 
122
165
  render(width: number): string[] {
@@ -215,7 +258,7 @@ export function updateCrewWidget(
215
258
  setExtensionWidget(
216
259
  ctx,
217
260
  WIDGET_KEY,
218
- ((_tui: unknown, theme: unknown) => new CrewWidgetComponent(model, theme)) as never,
261
+ ((_tui: unknown, theme: unknown) => new CrewWidgetComponent(model, theme, _tui)) as never,
219
262
  { placement, persist: true },
220
263
  );
221
264
  state.lastVisibility = "visible";
@@ -8,9 +8,24 @@ import type { CrewAgentRecord } from "../../runtime/crew-agent-runtime.ts";
8
8
  import type { LiveAgentHandle } from "../../runtime/live-agent-manager.ts";
9
9
  import { getTaskUsage } from "../../runtime/usage-tracker.ts";
10
10
  import { computeLiveDurationMs } from "../live-duration.ts";
11
+ import { visibleWidth } from "../../utils/visual.ts";
11
12
 
12
13
  // ── Token formatting ──────────────────────────────────────────────────
13
14
 
15
+ // V-1: fixed visible widths for per-agent numeric metrics so columns don't
16
+ // jitter every tick as values change width (e.g. 9.9s→10.0s, 950→1.0k).
17
+ // alignMetric right-aligns to the width; values wider than the width overflow
18
+ // verbatim (no truncation/crash) — these are rare one-off states, not jitter.
19
+ const TOOLS_METRIC_WIDTH = 8; // "127 tools"
20
+ const TOKENS_METRIC_WIDTH = 10; // "1.2k tok", "12.3M tok"
21
+ const CTX_METRIC_WIDTH = 7; // "100% ctx"
22
+ const DURATION_METRIC_WIDTH = 6; // "120.0s"
23
+
24
+ function alignMetric(value: string, width: number): string {
25
+ const pad = Math.max(0, width - visibleWidth(value));
26
+ return " ".repeat(pad) + value;
27
+ }
28
+
14
29
  export function formatTokensCompact(count: number): string {
15
30
  if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M tok`;
16
31
  if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k tok`;
@@ -107,22 +122,22 @@ export function agentStats(agent: CrewAgentRecord, liveHandle?: LiveAgentHandle)
107
122
  const parts: string[] = [];
108
123
  if (liveHandle) {
109
124
  const act = liveHandle.activity;
110
- if (act.toolUses > 0) parts.push(`${act.toolUses} tools`);
125
+ if (act.toolUses > 0) parts.push(alignMetric(`${act.toolUses} tools`, TOOLS_METRIC_WIDTH));
111
126
  const usage = getTaskUsage(liveHandle.taskId);
112
127
  const total = (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheWrite ?? 0);
113
- if (total > 0) parts.push(formatTokensCompact(total));
128
+ if (total > 0) parts.push(alignMetric(formatTokensCompact(total), TOKENS_METRIC_WIDTH));
114
129
  try {
115
130
  const stats = liveHandle.session.getSessionStats?.();
116
131
  const ctxPct = stats?.contextUsage?.percent;
117
- if (ctxPct != null) parts.push(`${Math.round(ctxPct)}% ctx`);
132
+ if (ctxPct != null) parts.push(alignMetric(`${Math.round(ctxPct)}% ctx`, CTX_METRIC_WIDTH));
118
133
  } catch { /* ignore */ }
119
134
  const ms = computeLiveDurationMs(act);
120
- parts.push(`${(ms / 1000).toFixed(1)}s`);
135
+ parts.push(alignMetric(`${(ms / 1000).toFixed(1)}s`, DURATION_METRIC_WIDTH));
121
136
  } else {
122
- if (agent.toolUses) parts.push(`${agent.toolUses} tools`);
123
- if (agent.progress?.tokens) parts.push(formatTokensCompact(agent.progress.tokens));
137
+ if (agent.toolUses) parts.push(alignMetric(`${agent.toolUses} tools`, TOOLS_METRIC_WIDTH));
138
+ if (agent.progress?.tokens) parts.push(alignMetric(formatTokensCompact(agent.progress.tokens), TOKENS_METRIC_WIDTH));
124
139
  const age = elapsed(agent.completedAt ?? agent.startedAt);
125
- if (age) parts.push(age);
140
+ if (age) parts.push(alignMetric(age, DURATION_METRIC_WIDTH));
126
141
  }
127
142
  return parts.join(" · ");
128
143
  }
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { CrewTheme } from "../theme-adapter.ts";
8
- import { iconForStatus } from "../status-colors.ts";
8
+ import { iconForStatus, colorizeStatusGlyphs } from "../status-colors.ts";
9
9
  import { truncate } from "../../utils/visual.ts";
10
10
  import { Box, Text } from "../layout-primitives.ts";
11
11
  import { listLiveAgents } from "../../runtime/live-agent-manager.ts";
@@ -89,19 +89,21 @@ export function buildWidgetLines(cwd: string, frame = 0, maxLines = 8, providedR
89
89
 
90
90
  const liveForRun = listLiveAgents().filter((a) => a.runId === run.runId);
91
91
 
92
- for (const agent of finishedAgents.slice(0, 2)) {
93
- const liveHandle = liveForRun.find((h) => h.taskId === agent.taskId);
94
- const name = liveHandle?.agent ?? agent.agent;
95
- const icon = agent.status === "completed" ? "✓" : agent.status === "failed" ? "✗" : agent.status === "needs_attention" ? "⚠" : "▪";
96
- const stats = agentStats(agent, liveHandle);
97
- const desc = truncate(liveHandle?.description ?? agent.role ?? "", TASK_DESC_MAX);
98
- const _finished = truncate(`│ ├─ ${icon} ${name} · ${desc}${stats ? ` · ${stats}` : ""}`, width);
99
- lines.push(_finished);
100
- }
101
-
102
- const visibleAgents = activeAgents.slice(0, MAX_AGENTS_DISPLAY);
92
+ // L-4: prioritize RUNNING > QUEUED > WAITING within the visible window so the
93
+ // most relevant live workers are always shown. Finished rows fill only the
94
+ // leftover budget and never steal slots from running agents.
95
+ const ACTIVE_PRIORITY: Record<string, number> = { running: 0, queued: 1, waiting: 2 };
96
+ const prioritizedActive = [...activeAgents].sort(
97
+ (a, b) => (ACTIVE_PRIORITY[a.status] ?? 9) - (ACTIVE_PRIORITY[b.status] ?? 9),
98
+ );
99
+ // Finished rows only appear in slots not used by active agents (max 2). When
100
+ // there are >= MAX_AGENTS_DISPLAY live workers, finished rows are suppressed
101
+ // entirely so they cannot push a live agent's activity line off-screen.
102
+ const finishedSlots = Math.max(0, Math.min(2, MAX_AGENTS_DISPLAY - activeAgents.length));
103
+
104
+ const visibleAgents = prioritizedActive.slice(0, MAX_AGENTS_DISPLAY);
103
105
  for (const [index, agent] of visibleAgents.entries()) {
104
- const last = index === visibleAgents.length - 1 && activeAgents.length <= MAX_AGENTS_DISPLAY;
106
+ const last = index === visibleAgents.length - 1 && activeAgents.length <= MAX_AGENTS_DISPLAY && finishedSlots === 0;
105
107
  const branch = last ? "└─" : "├─";
106
108
  const agentGlyph = iconForStatus(agent.status, { runningGlyph });
107
109
  const liveHandle = liveForRun.find((h) => h.taskId === agent.taskId);
@@ -118,6 +120,18 @@ export function buildWidgetLines(cwd: string, frame = 0, maxLines = 8, providedR
118
120
  lines.push(truncate(`│ └─ … +${activeAgents.length - MAX_AGENTS_DISPLAY} more agents`, width));
119
121
  }
120
122
 
123
+ for (const [index, agent] of finishedAgents.slice(0, finishedSlots).entries()) {
124
+ const liveHandle = liveForRun.find((h) => h.taskId === agent.taskId);
125
+ const name = liveHandle?.agent ?? agent.agent;
126
+ const icon = agent.status === "completed" ? "✓" : agent.status === "failed" ? "✗" : agent.status === "needs_attention" ? "⚠" : "▪";
127
+ const stats = agentStats(agent, liveHandle);
128
+ const desc = truncate(liveHandle?.description ?? agent.role ?? "", TASK_DESC_MAX);
129
+ const isLastFinished = index === Math.min(finishedAgents.length, finishedSlots) - 1;
130
+ const branch = isLastFinished ? "└─" : "├─";
131
+ const _finished = truncate(`│ ${branch} ${icon} ${name} · ${desc}${stats ? ` · ${stats}` : ""}`, width);
132
+ lines.push(_finished);
133
+ }
134
+
121
135
  if (lines.length >= maxLines) break;
122
136
  }
123
137
 
@@ -126,25 +140,15 @@ export function buildWidgetLines(cwd: string, frame = 0, maxLines = 8, providedR
126
140
 
127
141
  // ── Colorization ──────────────────────────────────────────────────────
128
142
 
129
- function statusGlyphColor(icon: string): Parameters<CrewTheme["fg"]>[0] {
130
- const mapping: Record<string, Parameters<CrewTheme["fg"]>[0]> = {
131
- "✓": "success",
132
- "✗": "error",
133
- "■": "warning",
134
- "⏸": "warning",
135
- "◦": "dim",
136
- "·": "dim",
137
- "▶": "accent",
138
- };
139
- return mapping[icon] ?? "accent";
140
- }
141
-
142
143
  export function colorWidgetLine(line: string, index: number, theme: CrewTheme): string {
143
144
  let result = line;
144
145
  if (index === 0) {
145
146
  result = result.replace("Crew agents", theme.bold(theme.fg("accent", "Crew agents")));
146
147
  }
147
- result = result.replace(/[✓✗■⏸◦·▶]/g, (icon) => theme.fg(statusGlyphColor(icon), icon));
148
+ // Shared glyph colorizer covers ALL status glyphs — including ⏳ (waiting),
149
+ // ⚠ (needs_attention), and the braille spinner range ⠁-⣿ (running) — which the
150
+ // previous local statusGlyphColor map + regex omitted (F-1, V-3).
151
+ result = colorizeStatusGlyphs(result, theme);
148
152
  if (index === 0) {
149
153
  result = theme.fg("accent", result);
150
154
  }
@@ -39,8 +39,10 @@ const WIDE_RANGES: Array<[number, number]> = [
39
39
  [0x1FA00, 0x1FAFF], // Symbols Extended-A
40
40
  [0x1F000, 0x1F02F], // Mahjong, Dominos
41
41
  [0xFE00, 0xFE0F], // Variation Selectors (emoji presentation)
42
- [0x200D, 0x200D], // Zero Width Joiner (creates compound emoji)
43
42
  ];
43
+ // NOTE: U+200D (Zero Width Joiner, ZWJ) is intentionally NOT listed as wide.
44
+ // ZWJ has zero advance width by Unicode definition; miscounting it as 2 caused
45
+ // slight over-truncation/over-padding of compound-emoji goals (T-1).
44
46
 
45
47
  function isWideCodePoint(code: number): boolean {
46
48
  for (const [lo, hi] of WIDE_RANGES) {