pi-chalin 0.1.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/src/ui.ts ADDED
@@ -0,0 +1,875 @@
1
+ import {
2
+ AssistantMessageComponent,
3
+ getMarkdownTheme,
4
+ ToolExecutionComponent,
5
+ UserMessageComponent,
6
+ type ExtensionContext,
7
+ type Theme,
8
+ } from "@earendil-works/pi-coding-agent";
9
+ import { Container, Spacer, Text, type Component, type Focusable, type TUI } from "@earendil-works/pi-tui";
10
+ import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
11
+ import type { ArtifactStore, FeatureArtifactState } from "./artifacts.ts";
12
+ import { getLatestRun, getLiveStepSession } from "./runtime-state.ts";
13
+ import type { AgentDefinition, ApprovalDecision, ChalinRuntimeState, MemoryRecord, RouteDecision, RunState, RunStepState } from "./schemas.ts";
14
+ import { clearLegacyChalinControlWidget, setChalinStatus } from "./ui-status.ts";
15
+ import { formatWebFetchAudit, type WebFetchAuditEntry } from "./webfetch.ts";
16
+
17
+ function routeShortName(kind: RouteDecision["kind"]): string {
18
+ return kind.replace(/^multi-agent-/, "");
19
+ }
20
+
21
+ export async function openSafetyApproval(ctx: ExtensionContext, route: RouteDecision, approval: ApprovalDecision): Promise<boolean> {
22
+ const lines = [
23
+ "pi-chalin Safety Approval",
24
+ `risk: ${route.risk}`,
25
+ `route: ${route.kind}`,
26
+ `agents: ${route.agents.join(" → ") || "none"}`,
27
+ `reason: ${route.reason}`,
28
+ `approval: ${approval.reason}`,
29
+ route.needsArtifacts ? "scope: may inspect or change project artifacts" : "scope: read/context only",
30
+ ];
31
+ if (!ctx.hasUI) {
32
+ ctx.ui.notify(lines.join("\n"), approval.action === "block" ? "error" : "warning");
33
+ return false;
34
+ }
35
+ if (approval.action === "block") {
36
+ ctx.ui.notify(lines.join("\n"), "error");
37
+ return false;
38
+ }
39
+ return ctx.ui.confirm("pi-chalin Safety Approval", `${lines.slice(1).join("\n")}\n\nApprove this chalin route once?`);
40
+ }
41
+
42
+ export function summarizeChalinHome(state: ChalinRuntimeState, agentCount: number): string[] {
43
+ return [
44
+ "pi-chalin",
45
+ `routing: ${state.autoRoutingEnabled ? "on" : "off"}`,
46
+ `agents: ${agentCount}`,
47
+ `activity: ${summarizeActivity(state)}`,
48
+ `guards: ${summarizeGuardHealth(state.lastRun)}`,
49
+ `memory candidates: ${state.pendingMemoryCandidates}`,
50
+ `approvals: ${state.pendingApprovals}`,
51
+ ];
52
+ }
53
+
54
+ export async function openSmartPanel(
55
+ ctx: ExtensionContext,
56
+ options: {
57
+ state: ChalinRuntimeState;
58
+ agents: AgentDefinition[];
59
+ diagnostics: string[];
60
+ pendingMemories: MemoryRecord[];
61
+ onSelectAgents(): Promise<void>;
62
+ onSelectActivity(): Promise<void>;
63
+ onSelectMemory(): Promise<void>;
64
+ onSelectArtifacts?(): Promise<void>;
65
+ onSelectWebFetch?(): Promise<void>;
66
+ },
67
+ ): Promise<void> {
68
+ const lines = summarizeChalinHome(options.state, options.agents.length);
69
+ setChalinStatus(ctx, options.state.activeRuns > 0 ? { kind: "running", intent: "activity", agent: "chalin", completed: 0, total: 1 } : options.state.autoRoutingEnabled ? { kind: "idle" } : { kind: "off" });
70
+
71
+ if (!ctx.hasUI) {
72
+ ctx.ui.notify(lines.join(" | "), "info");
73
+ return;
74
+ }
75
+
76
+ if (options.state.pendingApprovals > 0) {
77
+ ctx.ui.notify("pi-chalin has pending approvals.", "warning");
78
+ return;
79
+ }
80
+ if (options.state.activeRuns > 0) return options.onSelectActivity();
81
+
82
+ const actions = [
83
+ "Agents",
84
+ ...(options.state.lastRun ? ["Activity"] : []),
85
+ options.pendingMemories.length > 0 ? `Memory · ${options.pendingMemories.length} pending` : "Memory",
86
+ ...(options.onSelectArtifacts ? ["Artifacts"] : []),
87
+ ...(options.onSelectWebFetch ? ["WebFetch"] : []),
88
+ "Status",
89
+ ...(options.diagnostics.length > 0 ? ["Diagnostics"] : []),
90
+ "Close",
91
+ ];
92
+ const selected = await ctx.ui.select("pi-chalin Smart Panel", actions);
93
+ if (selected === "Agents") return options.onSelectAgents();
94
+ if (selected === "Activity") return options.onSelectActivity();
95
+ if (selected?.startsWith("Memory")) return options.onSelectMemory();
96
+ if (selected === "Artifacts") return options.onSelectArtifacts?.();
97
+ if (selected === "WebFetch") return options.onSelectWebFetch?.();
98
+ if (selected === "Diagnostics") return void ctx.ui.notify(options.diagnostics.join("\n"), "warning");
99
+ if (selected === "Status") ctx.ui.notify(lines.join("\n"), "info");
100
+ }
101
+
102
+ export async function openWebFetchAuditPanel(ctx: ExtensionContext, entries: WebFetchAuditEntry[]): Promise<void> {
103
+ const summary = formatWebFetchAudit(entries);
104
+ if (!ctx.hasUI || entries.length === 0) {
105
+ ctx.ui.notify(summary, "info");
106
+ return;
107
+ }
108
+ const format = (entry: WebFetchAuditEntry) => `${entry.freshness} · ${entry.kind} · ${entry.sourceCount} sources · ${truncateUi(entry.label, 70)}`;
109
+ const selected = await ctx.ui.select("pi-chalin WebFetch Audit", [...entries.map(format), "Summary", "Close"]);
110
+ if (selected === "Summary") {
111
+ ctx.ui.notify(summary, "info");
112
+ return;
113
+ }
114
+ const entry = entries.find((candidate) => selected === format(candidate));
115
+ if (!entry) return;
116
+ ctx.ui.notify(formatWebFetchAudit([entry]), entry.freshness === "stale" ? "warning" : "info");
117
+ }
118
+
119
+
120
+ export async function openArtifactPanel(ctx: ExtensionContext, store: ArtifactStore): Promise<void> {
121
+ const features = await store.listFeatures();
122
+ if (features.length === 0) {
123
+ ctx.ui.notify("No pi-chalin artifacts yet.", "info");
124
+ return;
125
+ }
126
+
127
+ const format = (feature: FeatureArtifactState) => `${feature.status} · ${feature.featureId} · ${feature.currentStep ?? feature.goal}`;
128
+ if (!ctx.hasUI) {
129
+ ctx.ui.notify(features.map(format).join("\n"), "info");
130
+ return;
131
+ }
132
+
133
+ const selected = await ctx.ui.select("pi-chalin Artifacts", [...features.map(format), "Close"]);
134
+ const feature = features.find((candidate) => selected === format(candidate));
135
+ if (!feature) return;
136
+
137
+ const action = await ctx.ui.select(`Artifacts · ${feature.featureId}`, [
138
+ "Resume context",
139
+ "Checkpoints",
140
+ "Validation contracts",
141
+ "Interview decisions",
142
+ "Worker skills",
143
+ "Close",
144
+ ]);
145
+ if (action === "Resume context") {
146
+ ctx.ui.notify(await store.resumeContext(feature.featureId), "info");
147
+ return;
148
+ }
149
+ if (action === "Checkpoints") {
150
+ ctx.ui.notify(formatArtifactCheckpoints(feature), "info");
151
+ return;
152
+ }
153
+ if (action === "Validation contracts") {
154
+ ctx.ui.notify(formatArtifactValidations(feature), "info");
155
+ return;
156
+ }
157
+ if (action === "Interview decisions") {
158
+ ctx.ui.notify(formatArtifactInterviews(feature), "info");
159
+ return;
160
+ }
161
+ if (action === "Worker skills") {
162
+ ctx.ui.notify(formatArtifactSkills(feature), "info");
163
+ }
164
+ }
165
+
166
+ function formatArtifactCheckpoints(feature: FeatureArtifactState): string {
167
+ if (feature.checkpoints.length === 0) return "No checkpoints recorded for this artifact.";
168
+ return feature.checkpoints
169
+ .slice(-12)
170
+ .map((checkpoint) => `${artifactStatusIcon(checkpoint.status)} ${checkpoint.title} · ${checkpoint.agent} · ${truncateUi(checkpoint.summary, 160)}`)
171
+ .join("\n");
172
+ }
173
+
174
+ function artifactStatusIcon(status: FeatureArtifactState["status"]): string {
175
+ if (status === "complete") return "✓";
176
+ if (status === "active") return "◆";
177
+ if (status === "paused") return "■";
178
+ return "×";
179
+ }
180
+
181
+ function formatArtifactValidations(feature: FeatureArtifactState): string {
182
+ if (feature.validationContracts.length === 0) return "No validation contracts recorded for this artifact.";
183
+ return feature.validationContracts
184
+ .map((contract) => [
185
+ `✓ ${contract.id} · ${contract.title}`,
186
+ contract.commands.length ? ` commands: ${contract.commands.join(" ; ")}` : undefined,
187
+ contract.successCriteria.length ? ` success: ${contract.successCriteria.join(" ; ")}` : undefined,
188
+ ].filter(Boolean).join("\n"))
189
+ .join("\n");
190
+ }
191
+
192
+ function formatArtifactInterviews(feature: FeatureArtifactState): string {
193
+ if (feature.interviewDecisions.length === 0) return "No interview decisions recorded for this artifact.";
194
+ return feature.interviewDecisions.slice(-8).map((decision) => [
195
+ `◆ ${decision.status} · ${truncateUi(decision.reason, 120)}`,
196
+ ...decision.answers.map((answer) => ` - ${truncateUi(answer.question, 80)} → ${truncateUi(answer.answer, 120)}${answer.custom ? " (custom)" : answer.recommended ? " (recommended)" : ""}`),
197
+ ].join("\n")).join("\n");
198
+ }
199
+
200
+ function formatArtifactSkills(feature: FeatureArtifactState): string {
201
+ if (feature.workerSkills.length === 0) return "No worker skills recorded for this artifact.";
202
+ return feature.workerSkills.map((skill) => `◆ ${skill.name} · ${skill.summary}\n ${skill.path}`).join("\n");
203
+ }
204
+
205
+ export async function openActivityMonitor(ctx: ExtensionContext, run: RunState | undefined): Promise<void> {
206
+ clearLegacyChalinControlWidget(ctx);
207
+ if (!run) {
208
+ ctx.ui.notify("No pi-chalin activity yet.", "info");
209
+ return;
210
+ }
211
+ setChalinStatus(ctx, run.status === "running"
212
+ ? { kind: "running", intent: routeShortName(run.route.kind), agent: run.steps.find((step) => step.status === "running")?.agent ?? "chalin", completed: run.steps.filter((step) => isUsableActivityStatus(step.status)).length, total: Math.max(run.steps.length, 1) }
213
+ : run.status === "complete"
214
+ ? { kind: "complete", intent: routeShortName(run.route.kind) }
215
+ : run.status === "failed"
216
+ ? { kind: "failed" }
217
+ : { kind: "stopped" });
218
+ const lines = formatActivity(run);
219
+ if (!ctx.hasUI) {
220
+ ctx.ui.notify(lines.join("\n"), run.status === "failed" ? "error" : "info");
221
+ return;
222
+ }
223
+
224
+ if (run.status === "running") {
225
+ const selected = await ctx.ui.select("pi-chalin Control", ["Live status", "Current agent", "Guards", "Close"]);
226
+ if (selected === "Live status") {
227
+ await openLiveStatusOverlay(ctx, run);
228
+ return;
229
+ }
230
+ if (selected === "Current agent") {
231
+ const current = run.steps.find((step) => step.status === "running") ?? run.steps.find((step) => step.status === "pending");
232
+ if (current) ctx.ui.notify(formatActivityStep(current), current.status === "failed" ? "error" : "info");
233
+ return;
234
+ }
235
+ if (selected === "Guards") {
236
+ ctx.ui.notify(summarizeRuntimeGuards(run).join("\n"), "info");
237
+ return;
238
+ }
239
+ return;
240
+ }
241
+
242
+ const items = [
243
+ "Summary",
244
+ ...run.steps.map((step) => `${step.agent} · ${step.status}`),
245
+ ...(run.logsPath ? ["Log path"] : []),
246
+ "Close",
247
+ ];
248
+ const selected = await ctx.ui.select("pi-chalin Activity", items);
249
+ if (selected === "Summary") ctx.ui.notify(lines.join("\n"), run.status === "failed" ? "error" : "info");
250
+ else if (selected === "Log path") ctx.ui.notify(run.logsPath ?? "No log path recorded.", "info");
251
+ else if (selected && selected !== "Close") {
252
+ const step = run.steps.find((candidate) => selected === `${candidate.agent} · ${candidate.status}`);
253
+ ctx.ui.notify(formatActivityStep(step), step?.status === "failed" ? "error" : "info");
254
+ }
255
+ }
256
+
257
+ export async function openMemoryReview(
258
+ ctx: ExtensionContext,
259
+ memories: MemoryRecord[],
260
+ actions: { approve(id: string): void; reject(id: string): void; delete(id: string): void },
261
+ ): Promise<void> {
262
+ if (memories.length === 0) {
263
+ ctx.ui.notify("No memory records found.", "info");
264
+ return;
265
+ }
266
+ const sorted = [...memories].sort((a, b) => memoryStatusRank(a.status) - memoryStatusRank(b.status) || b.createdAt.localeCompare(a.createdAt));
267
+ const format = (record: MemoryRecord) => formatMemoryListItem(record);
268
+ if (!ctx.hasUI) {
269
+ ctx.ui.notify(sorted.map(format).join("\n"), "info");
270
+ return;
271
+ }
272
+ const selected = await ctx.ui.select("pi-chalin Memory", [...sorted.map(format), "Close"]);
273
+ if (!selected || selected === "Close") return;
274
+ const record = sorted.find((candidate) => selected === format(candidate));
275
+ if (!record) return;
276
+ const actionOptions = [
277
+ "Details",
278
+ ...(record.status === "pending" ? ["Approve", "Reject"] : []),
279
+ record.status !== "rejected" ? "Delete" : "Delete permanently",
280
+ "Close",
281
+ ];
282
+ const action = await ctx.ui.select(`Memory · ${memoryTitle(record)}`, actionOptions);
283
+ if (action === "Details") {
284
+ ctx.ui.notify(formatMemoryDetail(record), "info");
285
+ return;
286
+ }
287
+ if (action === "Approve") actions.approve(record.id);
288
+ if (action === "Reject") actions.reject(record.id);
289
+ if (action === "Delete" || action === "Delete permanently") actions.delete(record.id);
290
+ if (action && action !== "Close") ctx.ui.notify(`Memory ${action.toLowerCase().replace(/\s+.*/, "")}d: ${memoryTitle(record)}`, "info");
291
+ }
292
+
293
+ function formatMemoryListItem(record: MemoryRecord): string {
294
+ return `${memoryStatusIcon(record.status)} ${record.status} · ${record.category} · ${record.sourceAgent} · ${memoryTitle(record)}`;
295
+ }
296
+
297
+ function formatMemoryDetail(record: MemoryRecord): string {
298
+ return [
299
+ `${memoryStatusIcon(record.status)} ${memoryTitle(record)}`,
300
+ "",
301
+ record.content,
302
+ "",
303
+ `status: ${record.status}`,
304
+ `category: ${record.category}`,
305
+ `source: ${record.sourceAgent}`,
306
+ `confidence: ${Math.round(record.confidence * 100)}%`,
307
+ `importance: ${record.importance}`,
308
+ `trigger: ${record.trigger}`,
309
+ record.topicKey ? `topic: ${record.topicKey}` : undefined,
310
+ record.evidence ? `evidence: ${record.evidence}` : undefined,
311
+ `seen: ${record.duplicateCount} · used: ${record.useCount ?? 0} · revisions: ${record.revisionCount}`,
312
+ record.lastUsedAt ? `last used: ${record.lastUsedAt}` : undefined,
313
+ record.utilityScore !== undefined ? `utility: ${Math.round(record.utilityScore * 100)}%` : undefined,
314
+ ].filter((line): line is string => line !== undefined).join("\n");
315
+ }
316
+
317
+ function memoryTitle(record: MemoryRecord): string {
318
+ return truncateUi(stripMemoryPrefix(record.content), 74);
319
+ }
320
+
321
+ function stripMemoryPrefix(content: string): string {
322
+ return content
323
+ .replace(/^`?([a-z][a-z-]{2,30})`?\s*:\s*`?\s*/i, "")
324
+ .replace(/\s+/g, " ")
325
+ .trim();
326
+ }
327
+
328
+ function memoryStatusIcon(status: MemoryRecord["status"]): string {
329
+ if (status === "active") return "✓";
330
+ if (status === "rejected") return "×";
331
+ if (status === "quarantined") return "!";
332
+ if (status === "stale" || status === "superseded") return "-";
333
+ return "○";
334
+ }
335
+
336
+ function memoryStatusRank(status: MemoryRecord["status"]): number {
337
+ if (status === "pending") return 0;
338
+ if (status === "quarantined") return 1;
339
+ if (status === "active") return 2;
340
+ if (status === "stale") return 3;
341
+ if (status === "superseded") return 4;
342
+ return 5;
343
+ }
344
+
345
+ function summarizeActivity(state: ChalinRuntimeState): string {
346
+ if (state.activeRuns > 0) return "running";
347
+ if (state.lastRun) return `last ${state.lastRun.status}`;
348
+ return "none";
349
+ }
350
+
351
+ function isUsableActivityStatus(status: RunState["status"] | undefined): boolean {
352
+ return status === "complete" || status === "budget-capped";
353
+ }
354
+
355
+ function formatActivity(run: RunState): string[] {
356
+ const completed = run.steps.filter((step) => isUsableActivityStatus(step.status)).length;
357
+ const active = run.steps.find((step) => step.status === "running" || step.status === "pending");
358
+ const elapsed = formatElapsed(run.startedAt, run.endedAt);
359
+ return [
360
+ `pi-chalin Activity · ${statusIcon(run.status)} ${displayActivityStatus(run.status)} · ${completed}/${run.steps.length} · ${elapsed}`,
361
+ `route: ${run.route.kind} · risk: ${run.route.risk}`,
362
+ `agents: ${run.route.agents.join(" → ") || "none"}`,
363
+ active ? `current: ${active.agent} — ${truncateUi(active.task, 100)}` : undefined,
364
+ ...run.steps.map((step) => `${statusIcon(step.status)} ${step.agent.padEnd(15)} ${formatStepDuration(step)} ${truncateUi(step.output?.handoff || step.error || step.task, 120)}`),
365
+ ...summarizeRuntimeGuards(run),
366
+ run.warnings.length ? `warnings: ${run.warnings.join("; ")}` : undefined,
367
+ run.logsPath && run.status !== "running" ? `log: ${run.logsPath}` : undefined,
368
+ "keys: Enter details · Esc close",
369
+ ].filter((line): line is string => Boolean(line));
370
+ }
371
+
372
+ export function summarizeRuntimeGuards(run: RunState | undefined): string[] {
373
+ if (!run) return ["guards: no run yet"];
374
+ const policyViolations = run.metrics?.policyViolations?.length ?? sumStepMetric(run, (step) => step.metrics?.policyViolations?.length ?? 0);
375
+ const budgetStops = run.metrics?.budgetStopCount ?? sumStepMetric(run, (step) => step.metrics?.budgetStopCount ?? 0);
376
+ const duplicateReads = run.metrics?.duplicateReadCount ?? sumStepMetric(run, (step) => step.metrics?.duplicateReadCount ?? 0);
377
+ const toolCalls = run.metrics?.toolCalls ?? sumStepMetric(run, (step) => step.metrics?.toolCalls ?? 0);
378
+ const modelFallbacks = run.steps.reduce((count, step) => count + (step.modelResolution?.attempts.some((attempt) => ["invalid", "unavailable", "unauthenticated", "fallback"].includes(attempt.status)) ? 1 : 0), 0);
379
+ const worktreeState = summarizeWorktreeGuard(run);
380
+ const guardHealth = policyViolations > 0 || /conflict|unavailable|failed/i.test(worktreeState) || modelFallbacks > 0
381
+ ? "attention"
382
+ : "ok";
383
+ const budgetHealth = budgetStops > 0 ? `limit reached (${budgetStops} stops)` : "ok";
384
+ return [
385
+ `guards: ${guardHealth}`,
386
+ `budget: ${budgetHealth}`,
387
+ `approval: risk ${run.route.risk}`,
388
+ `tools: ${toolCalls} calls · policy violations: ${policyViolations} · duplicate reads: ${duplicateReads}`,
389
+ `worktrees: ${worktreeState}`,
390
+ `model fallback: ${modelFallbacks}`,
391
+ ];
392
+ }
393
+
394
+ async function openLiveStatusOverlay(ctx: ExtensionContext, run: RunState): Promise<void> {
395
+ if (typeof ctx.ui.custom !== "function") {
396
+ ctx.ui.notify(formatActivity(run).join("\n"), "info");
397
+ return;
398
+ }
399
+ await ctx.ui.custom<void>(
400
+ (tui, theme, _keybindings, done) => new ChalinLiveStatusOverlay(tui, theme, () => getLatestRun() ?? run, ctx.cwd, () => done(undefined)),
401
+ {
402
+ overlay: true,
403
+ overlayOptions: {
404
+ anchor: "center",
405
+ width: "94%",
406
+ maxHeight: "88%",
407
+ margin: 1,
408
+ },
409
+ },
410
+ );
411
+ }
412
+
413
+ type LiveStatusTab = {
414
+ id: string;
415
+ title: string;
416
+ step: RunStepState;
417
+ };
418
+
419
+ class ChalinLiveStatusOverlay implements Component, Focusable {
420
+ focused = false;
421
+ private selectedId: string | undefined;
422
+ private scroll = 0;
423
+ private followTail = true;
424
+ private toolsExpanded = false;
425
+ private timer: ReturnType<typeof setInterval>;
426
+
427
+ constructor(
428
+ private readonly tui: TUI,
429
+ private readonly theme: Theme,
430
+ private readonly runProvider: () => RunState,
431
+ private readonly cwd: string,
432
+ private readonly done: () => void,
433
+ ) {
434
+ this.timer = setInterval(() => this.tui.requestRender(), 650);
435
+ this.timer.unref?.();
436
+ }
437
+
438
+ handleInput(data: string): void {
439
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
440
+ this.done();
441
+ return;
442
+ }
443
+ if (matchesKey(data, "tab") || matchesKey(data, "right")) {
444
+ this.moveTab(1);
445
+ return;
446
+ }
447
+ if (matchesKey(data, "left")) {
448
+ this.moveTab(-1);
449
+ return;
450
+ }
451
+ if (matchesKey(data, "up")) {
452
+ this.followTail = false;
453
+ this.scroll = Math.max(0, this.scroll - 1);
454
+ this.tui.requestRender();
455
+ return;
456
+ }
457
+ if (matchesKey(data, "down")) {
458
+ this.followTail = false;
459
+ this.scroll += 1;
460
+ this.tui.requestRender();
461
+ return;
462
+ }
463
+ if (matchesKey(data, "pageUp")) {
464
+ this.followTail = false;
465
+ this.scroll = Math.max(0, this.scroll - 8);
466
+ this.tui.requestRender();
467
+ return;
468
+ }
469
+ if (matchesKey(data, "pageDown")) {
470
+ this.followTail = false;
471
+ this.scroll += 8;
472
+ this.tui.requestRender();
473
+ return;
474
+ }
475
+ if (matchesKey(data, "home")) {
476
+ this.followTail = false;
477
+ this.scroll = 0;
478
+ this.tui.requestRender();
479
+ return;
480
+ }
481
+ if (matchesKey(data, "end")) {
482
+ this.followTail = true;
483
+ this.tui.requestRender();
484
+ return;
485
+ }
486
+ if (matchesKey(data, "ctrl+o")) {
487
+ this.toolsExpanded = !this.toolsExpanded;
488
+ this.tui.requestRender();
489
+ return;
490
+ }
491
+ }
492
+
493
+ render(width: number): string[] {
494
+ const run = this.runProvider();
495
+ const tabs = liveStatusTabs(run);
496
+ this.ensureSelectedTab(tabs);
497
+ const selected = tabs.find((tab) => tab.id === this.selectedId) ?? tabs[0];
498
+ const overlayWidth = Math.max(1, width);
499
+ const innerWidth = Math.max(1, overlayWidth - 2);
500
+ const bodyHeight = 24;
501
+ const body = selected ? liveStatusBody(run, selected.step, this.tui, this.theme, this.cwd, Math.max(1, innerWidth - 2), this.toolsExpanded) : [this.theme.fg("dim", "No active subagent step.")];
502
+ const maxScroll = Math.max(0, body.length - bodyHeight);
503
+ if (this.followTail) this.scroll = maxScroll;
504
+ this.scroll = Math.min(this.scroll, maxScroll);
505
+ if (this.scroll >= maxScroll) this.followTail = true;
506
+ const visibleBody = body.slice(this.scroll, this.scroll + bodyHeight);
507
+ const border = (text: string) => this.theme.fg("border", text);
508
+ const row = (content = "") => `${border("│")}${padAnsi(content, innerWidth)}${border("│")}`;
509
+ const scrollInfo = maxScroll > 0 ? ` · ${this.scroll + 1}-${Math.min(body.length, this.scroll + bodyHeight)}/${body.length}` : "";
510
+ const lines = [
511
+ border(`╭${"─".repeat(innerWidth)}╮`),
512
+ row(` ${this.theme.fg("accent", this.theme.bold("pi-chalin Live Status"))} ${this.theme.fg("dim", `run ${run.id} · ${displayActivityStatus(run.status)}${scrollInfo}`)}`),
513
+ row(renderLiveTabs(tabs, this.selectedId, this.theme, innerWidth - 1)),
514
+ row(this.theme.fg("dim", " TAB/right next tab · ctrl+o tools · up/down scroll · end live tail · esc close")),
515
+ row(""),
516
+ ...visibleBody.map((line) => row(line)),
517
+ row(""),
518
+ border(`╰${"─".repeat(innerWidth)}╯`),
519
+ ];
520
+ return clampRenderedLines(lines, overlayWidth);
521
+ }
522
+
523
+ invalidate(): void {}
524
+
525
+ dispose(): void {
526
+ clearInterval(this.timer);
527
+ }
528
+
529
+ private ensureSelectedTab(tabs: LiveStatusTab[]): void {
530
+ if (tabs.length === 0) {
531
+ this.selectedId = undefined;
532
+ return;
533
+ }
534
+ if (this.selectedId && tabs.some((tab) => tab.id === this.selectedId)) return;
535
+ this.selectedId = tabs.find((tab) => tab.step.status === "running")?.id
536
+ ?? tabs.find((tab) => tab.step.status === "pending")?.id
537
+ ?? tabs.at(-1)?.id;
538
+ this.scroll = 0;
539
+ this.followTail = true;
540
+ }
541
+
542
+ private moveTab(delta: number): void {
543
+ const tabs = liveStatusTabs(this.runProvider());
544
+ if (tabs.length === 0) return;
545
+ this.ensureSelectedTab(tabs);
546
+ const current = Math.max(0, tabs.findIndex((tab) => tab.id === this.selectedId));
547
+ const next = (current + delta + tabs.length) % tabs.length;
548
+ this.selectedId = tabs[next]?.id;
549
+ this.scroll = 0;
550
+ this.followTail = true;
551
+ this.tui.requestRender();
552
+ }
553
+ }
554
+
555
+ function liveStatusTabs(run: RunState): LiveStatusTab[] {
556
+ return run.steps.map((step, index) => ({
557
+ id: step.id || `${step.agent}-${index}`,
558
+ title: `${statusIcon(step.status)} ${step.agent}`,
559
+ step,
560
+ }));
561
+ }
562
+
563
+ function renderLiveTabs(tabs: LiveStatusTab[], selectedId: string | undefined, theme: Theme, width: number): string {
564
+ if (tabs.length === 0) return theme.fg("dim", " no subagent tabs ");
565
+ const parts: string[] = [];
566
+ for (const tab of tabs) {
567
+ const active = tab.id === selectedId;
568
+ const label = ` ${tab.title} `;
569
+ parts.push(active ? theme.bg("selectedBg", theme.fg("text", label)) : theme.fg("dim", label));
570
+ }
571
+ return truncateToWidth(parts.join(" "), width, "…", true);
572
+ }
573
+
574
+ function liveStatusBody(run: RunState, step: RunStepState, tui: TUI, theme: Theme, cwd: string, width: number, toolsExpanded = false): string[] {
575
+ const liveSession = getLiveStepSession(run.id, step.id);
576
+ const messages = liveSession?.getMessages() ?? [];
577
+ const lines: string[] = [
578
+ theme.fg("dim", `route: ${run.route.kind} · progress: ${run.steps.filter((item) => isUsableActivityStatus(item.status)).length}/${run.steps.length} · elapsed: ${formatElapsed(run.startedAt, run.endedAt)}`),
579
+ theme.fg("dim", `step: ${step.id} · ${step.agent} · ${step.status} · ${formatStepDuration(step)}${step.currentTool ? ` · tool: ${step.currentTool}` : ""}`),
580
+ liveSession ? theme.fg("dim", `live session: in-memory · since ${formatElapsed(liveSession.startedAt, undefined)}`) : theme.fg("dim", "live session: not attached; showing persisted step summary"),
581
+ "",
582
+ ];
583
+ lines.push(...(messages.length > 0
584
+ ? renderPiLikeMessages([...messages], tui, cwd, theme, width, toolsExpanded)
585
+ : renderFallbackStepSummary(step, tui, cwd, theme, width, toolsExpanded)));
586
+ if (step.modelResolution || step.metrics || step.error) lines.push(...renderStepDiagnostics(step, theme, width));
587
+ return clampRenderedLines(lines.length > 4 ? lines : [...lines, theme.fg("dim", "No subagent history available yet.")], width);
588
+ }
589
+
590
+ function renderPiLikeMessages(messages: unknown[], tui: TUI, cwd: string, theme: Theme, width: number, toolsExpanded = false): string[] {
591
+ try {
592
+ return renderPiLikeMessagesWithComponents(messages, tui, cwd, width, toolsExpanded);
593
+ } catch {
594
+ return renderPlainSessionMessages(messages, theme, width, toolsExpanded);
595
+ }
596
+ }
597
+
598
+ function renderPiLikeMessagesWithComponents(messages: unknown[], tui: TUI, cwd: string, width: number, toolsExpanded: boolean): string[] {
599
+ const container = new Container();
600
+ const pendingTools = new Map<string, ToolExecutionComponent>();
601
+ const markdownTheme = getMarkdownTheme();
602
+ const toolOptions = { showImages: false, imageWidthCells: Math.max(20, Math.min(80, width - 4)) };
603
+
604
+ for (const message of messages) {
605
+ if (!isRecord(message)) continue;
606
+ if (message.role === "user") {
607
+ const text = messageTextContent(message.content);
608
+ if (!text) continue;
609
+ if (container.children.length > 0) container.addChild(new Spacer(1));
610
+ container.addChild(new UserMessageComponent(text, markdownTheme));
611
+ continue;
612
+ }
613
+
614
+ if (message.role === "assistant") {
615
+ container.addChild(new AssistantMessageComponent(message as never, false, markdownTheme));
616
+ for (const call of assistantToolCalls(message)) {
617
+ const component = new ToolExecutionComponent(call.name, call.id, call.arguments, toolOptions, undefined, tui, cwd);
618
+ component.setExpanded(toolsExpanded);
619
+ container.addChild(component);
620
+ if (message.stopReason === "aborted" || message.stopReason === "error") {
621
+ component.updateResult({ content: [{ type: "text", text: String(message.errorMessage ?? "Operation failed") }], isError: true });
622
+ } else {
623
+ pendingTools.set(call.id, component);
624
+ }
625
+ }
626
+ continue;
627
+ }
628
+
629
+ if (message.role === "toolResult") {
630
+ const toolCallId = typeof message.toolCallId === "string" ? message.toolCallId : "";
631
+ const component = pendingTools.get(toolCallId) ?? orphanToolComponent(message, tui, cwd, toolOptions, toolsExpanded);
632
+ component.updateResult({
633
+ content: toolResultContent(message.content),
634
+ details: message.details,
635
+ isError: Boolean(message.isError),
636
+ });
637
+ if (!pendingTools.has(toolCallId)) container.addChild(component);
638
+ pendingTools.delete(toolCallId);
639
+ continue;
640
+ }
641
+
642
+ const text = messageTextContent(message.content ?? message.text ?? message.display);
643
+ if (text) {
644
+ container.addChild(new Spacer(1));
645
+ container.addChild(new Text(text, 1, 0));
646
+ }
647
+ }
648
+
649
+ const rendered = container.render(Math.max(12, width));
650
+ return rendered.length > 0 ? clampRenderedLines(rendered, width) : [""];
651
+ }
652
+
653
+ function renderFallbackStepSummary(step: RunStepState, tui: TUI, cwd: string, theme: Theme, width: number, toolsExpanded: boolean): string[] {
654
+ return renderPiLikeMessages(fallbackStepMessages(step), tui, cwd, theme, width, toolsExpanded).concat(
655
+ step.output?.handoff ? renderInfoBlock("Handoff", step.output.handoff, theme, width) : [],
656
+ step.output?.memoryCandidates.length ? renderInfoBlock("Memory candidates", step.output.memoryCandidates.map((candidate) => `- ${candidate.category}: ${candidate.content}`).join("\n"), theme, width) : [],
657
+ step.output?.warnings.length ? renderInfoBlock("Warnings", step.output.warnings.join("\n"), theme, width) : [],
658
+ );
659
+ }
660
+
661
+ function renderPlainSessionMessages(messages: unknown[], theme: Theme, width: number, toolsExpanded: boolean): string[] {
662
+ const lines: string[] = [];
663
+ for (const message of messages) {
664
+ if (!isRecord(message)) continue;
665
+ if (message.role === "assistant") {
666
+ const textParts = Array.isArray(message.content)
667
+ ? message.content.flatMap((part) => isRecord(part) && part.type === "text" && typeof part.text === "string" ? [part.text] : [])
668
+ : [];
669
+ if (textParts.length) lines.push(...renderInfoBlock("Assistant", textParts.join("\n\n"), theme, width));
670
+ for (const call of assistantToolCalls(message)) lines.push(...renderInfoBlock(`$ ${call.name}`, JSON.stringify(call.arguments, null, 2), theme, width));
671
+ continue;
672
+ }
673
+ if (message.role === "toolResult") {
674
+ const toolName = typeof message.toolName === "string" ? message.toolName : "toolResult";
675
+ const text = toolResultContent(message.content).map((part) => part.text ?? "").filter(Boolean).join("\n");
676
+ lines.push(...renderInfoBlock(toolName, formatPlainToolResult(text || "(empty)", theme, toolsExpanded), theme, width));
677
+ continue;
678
+ }
679
+ const text = messageTextContent(message.content ?? message.text);
680
+ if (text) lines.push(...renderInfoBlock(message.role === "user" ? "Task" : String(message.role ?? "message"), text, theme, width));
681
+ }
682
+ return lines.length ? clampRenderedLines(lines, width) : [theme.fg("dim", "No renderable messages in live session.")];
683
+ }
684
+
685
+ function formatPlainToolResult(text: string, theme: Theme, expanded: boolean): string {
686
+ if (expanded) return text;
687
+ const lines = text.split("\n");
688
+ const visible = lines.slice(0, 10).join("\n");
689
+ const remaining = lines.length - 10;
690
+ return remaining > 0
691
+ ? `${visible}\n${theme.fg("dim", `... (${remaining} more lines, ctrl+o to expand)`)}`
692
+ : visible;
693
+ }
694
+
695
+ function fallbackStepMessages(step: RunStepState): unknown[] {
696
+ const messages: unknown[] = [{ role: "user", content: step.task, timestamp: Date.now() }];
697
+ const text = step.output?.raw || step.output?.text || (step.status === "running" ? "Working... waiting for the child session to publish messages." : "");
698
+ if (text) {
699
+ messages.push({
700
+ role: "assistant",
701
+ content: [{ type: "text", text }],
702
+ api: "openai-responses",
703
+ provider: "openai",
704
+ model: step.model ?? "unknown",
705
+ usage: emptyUsage(),
706
+ stopReason: step.error ? "error" : "stop",
707
+ errorMessage: step.error,
708
+ timestamp: Date.now(),
709
+ });
710
+ }
711
+ return messages;
712
+ }
713
+
714
+ function renderInfoBlock(title: string, text: string, theme: Theme, width: number): string[] {
715
+ const container = new Container();
716
+ container.addChild(new Spacer(1));
717
+ container.addChild(new Text(theme.fg("customMessageLabel", theme.bold(title)), 1, 0));
718
+ container.addChild(new Text(text, 1, 0));
719
+ return clampRenderedLines(container.render(width), width);
720
+ }
721
+
722
+ function renderStepDiagnostics(step: RunStepState, theme: Theme, width: number): string[] {
723
+ const diagnostics = formatStepDiagnostics(step);
724
+ return diagnostics ? renderInfoBlock("Step diagnostics", theme.fg("dim", diagnostics), theme, width) : [];
725
+ }
726
+
727
+ function assistantToolCalls(message: Record<string, unknown>): Array<{ id: string; name: string; arguments: Record<string, unknown> }> {
728
+ if (!Array.isArray(message.content)) return [];
729
+ return message.content.flatMap((part, index) => {
730
+ if (!isRecord(part) || part.type !== "toolCall") return [];
731
+ const id = typeof part.id === "string" ? part.id : `tool-${index + 1}`;
732
+ const name = typeof part.name === "string" ? part.name : "tool";
733
+ const args = isRecord(part.arguments) ? part.arguments : isRecord(part.input) ? part.input : {};
734
+ return [{ id, name, arguments: args }];
735
+ });
736
+ }
737
+
738
+ function orphanToolComponent(
739
+ message: Record<string, unknown>,
740
+ tui: TUI,
741
+ cwd: string,
742
+ toolOptions: { showImages: boolean; imageWidthCells: number },
743
+ toolsExpanded: boolean,
744
+ ): ToolExecutionComponent {
745
+ const name = typeof message.toolName === "string" ? message.toolName : "tool";
746
+ const id = typeof message.toolCallId === "string" ? message.toolCallId : `orphan-${Date.now()}`;
747
+ const component = new ToolExecutionComponent(name, id, {}, toolOptions, undefined, tui, cwd);
748
+ component.setExpanded(toolsExpanded);
749
+ return component;
750
+ }
751
+
752
+ function messageTextContent(content: unknown): string {
753
+ if (typeof content === "string") return content.trim();
754
+ if (!Array.isArray(content)) return "";
755
+ return content
756
+ .map((part) => {
757
+ if (!isRecord(part)) return "";
758
+ if (typeof part.text === "string") return part.text;
759
+ if (typeof part.content === "string") return part.content;
760
+ return "";
761
+ })
762
+ .filter(Boolean)
763
+ .join("\n\n")
764
+ .trim();
765
+ }
766
+
767
+ function toolResultContent(content: unknown): Array<{ type: string; text?: string; data?: string; mimeType?: string }> {
768
+ if (Array.isArray(content)) {
769
+ return content
770
+ .filter(isRecord)
771
+ .map((part) => ({
772
+ type: typeof part.type === "string" ? part.type : "text",
773
+ text: typeof part.text === "string" ? part.text : typeof part.content === "string" ? part.content : undefined,
774
+ data: typeof part.data === "string" ? part.data : undefined,
775
+ mimeType: typeof part.mimeType === "string" ? part.mimeType : undefined,
776
+ }));
777
+ }
778
+ const text = typeof content === "string" ? content : content === undefined ? "" : JSON.stringify(content, null, 2);
779
+ return text ? [{ type: "text", text }] : [];
780
+ }
781
+
782
+ function emptyUsage() {
783
+ return {
784
+ input: 0,
785
+ output: 0,
786
+ cacheRead: 0,
787
+ cacheWrite: 0,
788
+ totalTokens: 0,
789
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
790
+ };
791
+ }
792
+
793
+ function formatStepDiagnostics(step: RunStepState): string {
794
+ return [
795
+ step.modelResolution ? `model: ${step.modelResolution.selected}\nthinking: ${step.thinkingLevel ?? "inherit"}\n${step.modelResolution.attempts.map((attempt) => `- ${attempt.source}: ${attempt.ref ?? "inherit"} -> ${attempt.status}${attempt.reason ? ` (${attempt.reason})` : ""}`).join("\n")}` : undefined,
796
+ step.metrics ? `tools: ${step.metrics.toolCalls}/${step.maxToolCalls ?? "?"}\npolicy violations: ${step.metrics.policyViolations?.length ?? 0}\nbudget stops: ${step.metrics.budgetStopCount ?? 0}\nfiles read: ${step.metrics.filesRead?.join(", ") ?? "none"}` : undefined,
797
+ step.error ? `error: ${step.error}` : undefined,
798
+ ].filter(Boolean).join("\n\n");
799
+ }
800
+
801
+ function padAnsi(text: string, width: number): string {
802
+ const singleLine = text.replace(/\r/g, "").replace(/\n/g, " ").replace(/\t/g, " ");
803
+ const truncated = truncateToWidth(singleLine, width, "…", false);
804
+ const visible = visibleWidth(truncated);
805
+ const repaired = visible <= width ? truncated : truncateToWidth(truncated, width, "", false);
806
+ return `${repaired}${" ".repeat(Math.max(0, width - visibleWidth(repaired)))}`;
807
+ }
808
+
809
+ function clampRenderedLines(lines: string[], width: number): string[] {
810
+ return lines.map((line) => padAnsi(line, width));
811
+ }
812
+
813
+ function isRecord(value: unknown): value is Record<string, unknown> {
814
+ return typeof value === "object" && value !== null;
815
+ }
816
+
817
+ function summarizeGuardHealth(run: RunState | undefined): string {
818
+ const first = summarizeRuntimeGuards(run)[0] ?? "guards: no run yet";
819
+ return first.replace(/^guards:\s*/, "");
820
+ }
821
+
822
+ function sumStepMetric(run: RunState, pick: (step: RunState["steps"][number]) => number): number {
823
+ return run.steps.reduce((total, step) => total + pick(step), 0);
824
+ }
825
+
826
+ function summarizeWorktreeGuard(run: RunState): string {
827
+ const warnings = run.warnings.join("\n");
828
+ if (/merge conflict/i.test(warnings)) return "conflict needs resolver";
829
+ if (/worktree isolation unavailable/i.test(warnings)) return "unavailable";
830
+ if (/worktree isolation active|isolated writer/i.test(warnings)) return "isolated writers";
831
+ return "not needed";
832
+ }
833
+
834
+
835
+ function statusIcon(status: RunState["status"]): string {
836
+ if (status === "complete" || status === "budget-capped") return "✓";
837
+ if (status === "running") return "◆";
838
+ if (status === "pending") return "·";
839
+ if (status === "paused") return "■";
840
+ if (status === "failed") return "×";
841
+ return "◇";
842
+ }
843
+
844
+ function displayActivityStatus(status: RunState["status"]): string {
845
+ if (status === "budget-capped") return "done · budget limit reached";
846
+ return status;
847
+ }
848
+
849
+ function formatElapsed(startedAt: string | undefined, endedAt: string | undefined): string {
850
+ if (!startedAt) return "--";
851
+ const end = endedAt ? Date.parse(endedAt) : Date.now();
852
+ const ms = Math.max(0, end - Date.parse(startedAt));
853
+ return `${Math.round(ms / 1000)}s`;
854
+ }
855
+
856
+ function formatStepDuration(step: RunState["steps"][number]): string {
857
+ return formatElapsed(step.startedAt, step.endedAt).padStart(4);
858
+ }
859
+
860
+ function truncateUi(text: string | undefined, max: number): string {
861
+ const normalized = (text ?? "").replace(/\s+/g, " ").trim();
862
+ return normalized.length <= max ? normalized : `${normalized.slice(0, max - 1)}…`;
863
+ }
864
+
865
+ function formatActivityStep(step: RunState["steps"][number] | undefined): string {
866
+ if (!step) return "Step not found.";
867
+ return [
868
+ `${step.agent} · ${step.status}`,
869
+ step.task,
870
+ step.output?.handoff,
871
+ step.modelResolution ? `model: ${step.modelResolution.selected}\nthinking: ${step.thinkingLevel ?? "inherit"}\n${step.modelResolution.attempts.map((attempt) => `- ${attempt.source}: ${attempt.ref ?? "inherit"} → ${attempt.status}${attempt.reason ? ` (${attempt.reason})` : ""}`).join("\n")}` : undefined,
872
+ step.metrics ? `tools: ${step.metrics.toolCalls}/${step.maxToolCalls ?? "?"} · policy violations: ${step.metrics.policyViolations?.length ?? 0} · budget stops: ${step.metrics.budgetStopCount ?? 0}` : undefined,
873
+ step.error ? `error: ${step.error}` : undefined,
874
+ ].filter(Boolean).join("\n");
875
+ }