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.
- package/CHANGELOG.md +38 -0
- package/package.json +1 -1
- package/src/extension/crew-shortcuts.ts +29 -2
- package/src/extension/registration/commands.ts +61 -34
- package/src/runtime/process-status.ts +7 -2
- package/src/ui/crew-footer.ts +3 -3
- package/src/ui/crew-select-list.ts +1 -1
- package/src/ui/dashboard-panes/agents-pane.ts +26 -4
- package/src/ui/dashboard-panes/cancellation-pane.ts +23 -0
- package/src/ui/keybinding-map.ts +7 -3
- package/src/ui/live-conversation-overlay.ts +2 -2
- package/src/ui/live-run-sidebar.ts +18 -10
- package/src/ui/overlays/help-overlay.ts +166 -0
- package/src/ui/run-dashboard.ts +210 -70
- package/src/ui/status-colors.ts +45 -0
- package/src/ui/widget/index.ts +46 -3
- package/src/ui/widget/widget-formatters.ts +22 -7
- package/src/ui/widget/widget-renderer.ts +31 -27
- package/src/utils/visual.ts +3 -1
package/src/ui/widget/index.ts
CHANGED
|
@@ -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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const
|
|
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
|
-
|
|
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
|
}
|
package/src/utils/visual.ts
CHANGED
|
@@ -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) {
|