shariq-pi-extensions 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.
Files changed (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +94 -0
  3. package/THIRD_PARTY_NOTICES.md +11 -0
  4. package/docs/ARCHITECTURE.md +54 -0
  5. package/docs/DEVELOPMENT.md +88 -0
  6. package/docs/EXTENSIONS.md +89 -0
  7. package/extensions/antigravity-provider/LICENSE +21 -0
  8. package/extensions/antigravity-provider/README.md +23 -0
  9. package/extensions/antigravity-provider/antigravity/cloud-code-assist.ts +519 -0
  10. package/extensions/antigravity-provider/antigravity/index.ts +67 -0
  11. package/extensions/antigravity-provider/antigravity/models.ts +184 -0
  12. package/extensions/antigravity-provider/antigravity/oauth.ts +499 -0
  13. package/extensions/antigravity-provider/index.ts +3 -0
  14. package/extensions/ask-user/README.md +21 -0
  15. package/extensions/ask-user/index.ts +417 -0
  16. package/extensions/background-terminals/README.md +43 -0
  17. package/extensions/background-terminals/index.ts +400 -0
  18. package/extensions/background-terminals/src/manager.ts +480 -0
  19. package/extensions/background-terminals/src/output-buffer.ts +110 -0
  20. package/extensions/background-terminals/src/presentation.ts +74 -0
  21. package/extensions/background-terminals/src/types.ts +47 -0
  22. package/extensions/background-terminals/src/ui.ts +478 -0
  23. package/extensions/context-usage/LICENSE +21 -0
  24. package/extensions/context-usage/README.md +20 -0
  25. package/extensions/context-usage/index.ts +2006 -0
  26. package/extensions/context-usage/lib/ansi.ts +60 -0
  27. package/extensions/context-usage/lib/boundary.ts +27 -0
  28. package/extensions/context-usage/lib/chat.ts +190 -0
  29. package/extensions/context-usage/lib/config.ts +30 -0
  30. package/extensions/context-usage/lib/fmt.ts +59 -0
  31. package/extensions/context-usage/lib/style.ts +260 -0
  32. package/extensions/copy-all/README.md +15 -0
  33. package/extensions/copy-all/index.ts +72 -0
  34. package/extensions/factory-provider/API_KEYS.md +17 -0
  35. package/extensions/factory-provider/AUTH.md +15 -0
  36. package/extensions/factory-provider/README.md +90 -0
  37. package/extensions/factory-provider/REFRESH_MODELS.md +185 -0
  38. package/extensions/factory-provider/UPDATING.md +97 -0
  39. package/extensions/factory-provider/factory/api-keys.ts +407 -0
  40. package/extensions/factory-provider/factory/auth.ts +132 -0
  41. package/extensions/factory-provider/factory/constants.ts +15 -0
  42. package/extensions/factory-provider/factory/dashboard.ts +363 -0
  43. package/extensions/factory-provider/factory/droid.ts +84 -0
  44. package/extensions/factory-provider/factory/limits.ts +342 -0
  45. package/extensions/factory-provider/factory/models.ts +283 -0
  46. package/extensions/factory-provider/factory/responses.ts +331 -0
  47. package/extensions/factory-provider/factory/warm-theme.ts +53 -0
  48. package/extensions/factory-provider/factory/websocket.ts +130 -0
  49. package/extensions/factory-provider/index.ts +204 -0
  50. package/extensions/factory-provider/scripts/audit-reasoning-efforts.py +84 -0
  51. package/extensions/firecrawl-web/README.md +31 -0
  52. package/extensions/firecrawl-web/auth.ts +92 -0
  53. package/extensions/firecrawl-web/client.ts +238 -0
  54. package/extensions/firecrawl-web/index.ts +96 -0
  55. package/extensions/firecrawl-web/output.ts +125 -0
  56. package/extensions/git-info/README.md +25 -0
  57. package/extensions/git-info/index.ts +162 -0
  58. package/extensions/git-info/src/git.ts +206 -0
  59. package/extensions/git-info/src/ui.ts +291 -0
  60. package/extensions/goal/README.md +44 -0
  61. package/extensions/goal/constants.ts +4 -0
  62. package/extensions/goal/extension.ts +1064 -0
  63. package/extensions/goal/index.ts +2 -0
  64. package/extensions/goal/types.ts +56 -0
  65. package/extensions/goal/ui.ts +334 -0
  66. package/extensions/orchestration/README.md +58 -0
  67. package/extensions/orchestration/dashboard.ts +192 -0
  68. package/extensions/orchestration/engine.ts +706 -0
  69. package/extensions/orchestration/index.ts +243 -0
  70. package/extensions/orchestration/prompts.ts +124 -0
  71. package/extensions/orchestration/protocol.ts +116 -0
  72. package/extensions/orchestration/settings-ui.ts +41 -0
  73. package/extensions/orchestration/settings.ts +59 -0
  74. package/extensions/orchestration/storage.ts +49 -0
  75. package/extensions/orchestration/types.ts +105 -0
  76. package/extensions/performance-status/README.md +5 -0
  77. package/extensions/performance-status/index.ts +171 -0
  78. package/extensions/pi-memory/README.md +53 -0
  79. package/extensions/pi-memory/index.ts +326 -0
  80. package/extensions/pi-memory/src/config.ts +75 -0
  81. package/extensions/pi-memory/src/database.ts +617 -0
  82. package/extensions/pi-memory/src/extraction.ts +178 -0
  83. package/extensions/pi-memory/src/project.ts +80 -0
  84. package/extensions/pi-memory/src/projection.ts +59 -0
  85. package/extensions/pi-memory/src/redaction.ts +60 -0
  86. package/extensions/pi-memory/src/retrieval.ts +30 -0
  87. package/extensions/pi-memory/src/service.ts +194 -0
  88. package/extensions/pi-memory/src/session.ts +146 -0
  89. package/extensions/pi-memory/src/types.ts +82 -0
  90. package/extensions/shared/README.md +18 -0
  91. package/extensions/shared/activity-status.ts +31 -0
  92. package/extensions/shared/child-session.ts +150 -0
  93. package/extensions/shared/context-utilization.ts +47 -0
  94. package/extensions/shared/dashboard-state.ts +99 -0
  95. package/extensions/shared/tool-call-timeout.ts +104 -0
  96. package/extensions/shared/tui-dashboard.ts +139 -0
  97. package/extensions/shell-shortcuts/README.md +17 -0
  98. package/extensions/shell-shortcuts/features/exit-alias.ts +10 -0
  99. package/extensions/shell-shortcuts/index.ts +6 -0
  100. package/extensions/subagents/README.md +89 -0
  101. package/extensions/subagents/index.ts +1386 -0
  102. package/extensions/subagents/src/backend.ts +69 -0
  103. package/extensions/subagents/src/backends/pi.ts +781 -0
  104. package/extensions/subagents/src/catalog.ts +85 -0
  105. package/extensions/subagents/src/config.ts +241 -0
  106. package/extensions/subagents/src/context.ts +57 -0
  107. package/extensions/subagents/src/coordinator.ts +64 -0
  108. package/extensions/subagents/src/domain.ts +294 -0
  109. package/extensions/subagents/src/format.ts +74 -0
  110. package/extensions/subagents/src/manager.ts +813 -0
  111. package/extensions/subagents/src/prompt.ts +94 -0
  112. package/extensions/subagents/src/result-delivery.ts +20 -0
  113. package/extensions/subagents/src/runtime.ts +48 -0
  114. package/extensions/subagents/src/ui/takeover.ts +695 -0
  115. package/extensions/subagents/src/ui/transcript.ts +182 -0
  116. package/extensions/subagents/src/worktree.ts +175 -0
  117. package/extensions/web-fetch/README.md +13 -0
  118. package/extensions/web-fetch/index.ts +441 -0
  119. package/package.json +117 -0
  120. package/skills/background-terminals/SKILL.md +42 -0
  121. package/skills/orchestration/SKILL.md +33 -0
  122. package/skills/subagents/SKILL.md +89 -0
  123. package/themes/ember-warm-dark.json +85 -0
@@ -0,0 +1,695 @@
1
+ /**
2
+ * Takeover UI for subagents (ported from v1, rendering from the synchronous
3
+ * SubagentReadModel instead of live pi sessions):
4
+ * - SubagentDashboard: full popup (overlay) listing all subagents.
5
+ * - TakeoverView: full interactive view of one subagent with an input line
6
+ * to steer/continue it.
7
+ */
8
+
9
+ import type {
10
+ ExtensionCommandContext,
11
+ KeybindingsManager,
12
+ Theme,
13
+ } from "@earendil-works/pi-coding-agent";
14
+ import type { Component, Focusable, TUI } from "@earendil-works/pi-tui";
15
+ import { Input, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
16
+ import { formatElapsed, latestText, type SubagentSnapshot } from "../domain.ts";
17
+ import { contextPercent, formatContextUtilization } from "../format.ts";
18
+ import {
19
+ joinSides,
20
+ meter,
21
+ oneLine,
22
+ padLine,
23
+ stateLabel,
24
+ viewportSlice,
25
+ } from "../../../shared/tui-dashboard.ts";
26
+ import type { SubagentReadModel } from "../manager.ts";
27
+ import { buildTranscriptLines } from "./transcript.ts";
28
+
29
+ function configuredKeys(
30
+ keybindings: KeybindingsManager,
31
+ binding: Parameters<KeybindingsManager["getKeys"]>[0],
32
+ ) {
33
+ return keybindings.getKeys(binding).join("/") || "unbound";
34
+ }
35
+
36
+ function statusGlyph(snap: SubagentSnapshot, theme: Theme): string {
37
+ switch (snap.status) {
38
+ case "running":
39
+ return theme.fg("warning", "■");
40
+ case "done":
41
+ return theme.fg("success", "■");
42
+ case "error":
43
+ return theme.fg("error", "■");
44
+ }
45
+ }
46
+
47
+ function statusWord(snap: SubagentSnapshot, theme: Theme): string {
48
+ switch (snap.status) {
49
+ case "running":
50
+ return theme.fg("warning", "running");
51
+ case "done":
52
+ return theme.fg("success", "done");
53
+ case "error":
54
+ return theme.fg("error", "failed");
55
+ }
56
+ }
57
+
58
+ // --- Entry point ---------------------------------------------------------------
59
+
60
+ export async function openSubagentPicker(
61
+ ctx: ExtensionCommandContext,
62
+ view: SubagentReadModel,
63
+ ) {
64
+ const selection: DashboardSelection = { index: 0 };
65
+
66
+ while (true) {
67
+ if (view.size() === 0) {
68
+ ctx.ui.notify("No subagents", "info");
69
+ return;
70
+ }
71
+
72
+ const picked = await ctx.ui.custom<string | null>(
73
+ (tui, theme, keybindings, done) =>
74
+ new SubagentDashboard(tui, theme, keybindings, view, selection, done),
75
+ {
76
+ overlay: true,
77
+ overlayOptions: { anchor: "center", width: "100%", maxHeight: "100%" },
78
+ },
79
+ );
80
+
81
+ if (!picked) return;
82
+ if (!view.get(picked)) continue;
83
+
84
+ await openSubagentTakeover(ctx, view, picked);
85
+ // After leaving the takeover view, fall back to the dashboard.
86
+ }
87
+ }
88
+
89
+ export async function openSubagentTakeover(
90
+ ctx: ExtensionCommandContext,
91
+ view: SubagentReadModel,
92
+ id: string,
93
+ ) {
94
+ if (!view.get(id)) {
95
+ ctx.ui.notify(`Subagent ${id} is no longer tracked`, "warning");
96
+ return;
97
+ }
98
+ await ctx.ui.custom<null>(
99
+ (tui, theme, keybindings, done) =>
100
+ new TakeoverView(tui, theme, keybindings, id, view, done),
101
+ {
102
+ overlay: true,
103
+ overlayOptions: { anchor: "center", width: "100%", maxHeight: "100%" },
104
+ },
105
+ );
106
+ }
107
+
108
+ export async function openPeerMessageViewer(
109
+ ctx: ExtensionCommandContext,
110
+ view: SubagentReadModel,
111
+ ) {
112
+ if (view.peerMessages().length === 0) {
113
+ ctx.ui.notify("No peer messages yet", "info");
114
+ return;
115
+ }
116
+ await ctx.ui.custom<null>(
117
+ (tui, theme, keybindings, done) => {
118
+ const unsubscribe = view.subscribe(() => tui.requestRender());
119
+ let closed = false;
120
+ const close = () => {
121
+ if (closed) return;
122
+ closed = true;
123
+ unsubscribe();
124
+ done(null);
125
+ };
126
+ return {
127
+ handleInput(data: string) {
128
+ if (
129
+ keybindings.matches(data, "tui.select.cancel") ||
130
+ keybindings.matches(data, "tui.select.confirm")
131
+ ) close();
132
+ },
133
+ render(width: number) {
134
+ const messages = view.peerMessages().slice(-Math.max(1, (tui.terminal.rows || 30) - 5));
135
+ const lines = [theme.fg("accent", theme.bold("Peer coordination")), theme.fg("border", "─".repeat(width))];
136
+ for (const message of messages) {
137
+ const time = new Date(message.sentAt).toLocaleTimeString();
138
+ const state = message.status === "delivered"
139
+ ? theme.fg("success", "✓")
140
+ : theme.fg("error", "✗");
141
+ const sender = message.senderId
142
+ ? `${message.sender} (${message.senderId})`
143
+ : message.sender;
144
+ lines.push(truncateToWidth(`${state} ${time} ${sender} → ${message.targetTitle} (${message.targetId})`, width));
145
+ lines.push(truncateToWidth(` ${message.message}`, width));
146
+ if (message.error) lines.push(truncateToWidth(theme.fg("error", ` ${message.error}`), width));
147
+ }
148
+ lines.push(theme.fg("border", "─".repeat(width)));
149
+ lines.push(truncateToWidth(theme.fg("dim", "Enter/Esc close"), width));
150
+ return lines;
151
+ },
152
+ invalidate() {},
153
+ dispose() {
154
+ if (!closed) unsubscribe();
155
+ closed = true;
156
+ },
157
+ };
158
+ },
159
+ { overlay: true, overlayOptions: { anchor: "center", width: "100%", maxHeight: "100%" } },
160
+ );
161
+ }
162
+
163
+ // --- Dashboard (fullscreen overlay) ----------------------------------------------
164
+
165
+ export interface DashboardSelection {
166
+ id?: string;
167
+ index: number;
168
+ }
169
+
170
+ export function reconcileDashboardSelection(
171
+ selection: DashboardSelection,
172
+ subs: ReadonlyArray<Pick<SubagentSnapshot, "id">>,
173
+ ) {
174
+ const stableIndex = selection.id
175
+ ? subs.findIndex((snap) => snap.id === selection.id)
176
+ : -1;
177
+ selection.index =
178
+ stableIndex >= 0
179
+ ? stableIndex
180
+ : Math.min(Math.max(0, selection.index), Math.max(0, subs.length - 1));
181
+ selection.id = subs[selection.index]?.id;
182
+ }
183
+
184
+ export class SubagentDashboard implements Component {
185
+ private tui: TUI;
186
+ private theme: Theme;
187
+ private keybindings: KeybindingsManager;
188
+ private view: SubagentReadModel;
189
+ private selection: DashboardSelection;
190
+ private done: (value: string | null) => void;
191
+
192
+ private closed = false;
193
+ private ticker: ReturnType<typeof setInterval>;
194
+ private unsubChange: () => void;
195
+
196
+ constructor(
197
+ tui: TUI,
198
+ theme: Theme,
199
+ keybindings: KeybindingsManager,
200
+ view: SubagentReadModel,
201
+ selection: DashboardSelection,
202
+ done: (value: string | null) => void,
203
+ ) {
204
+ this.tui = tui;
205
+ this.theme = theme;
206
+ this.keybindings = keybindings;
207
+ this.view = view;
208
+ this.selection = selection;
209
+ this.done = done;
210
+ // Elapsed times, token counts, and statuses tick along at 1Hz.
211
+ this.ticker = setInterval(() => this.tui.requestRender(), 1000);
212
+ this.ticker.unref?.();
213
+ this.unsubChange = view.subscribe(() => this.tui.requestRender());
214
+ }
215
+
216
+ private subs(): ReadonlyArray<SubagentSnapshot> {
217
+ return this.view.list();
218
+ }
219
+
220
+ private cleanup() {
221
+ if (this.closed) return false;
222
+ this.closed = true;
223
+ clearInterval(this.ticker);
224
+ this.unsubChange();
225
+ return true;
226
+ }
227
+
228
+ private close(result: string | null) {
229
+ if (this.cleanup()) this.done(result);
230
+ }
231
+
232
+ dispose(): void {
233
+ this.cleanup();
234
+ }
235
+
236
+ handleInput(data: string): void {
237
+ const subs = this.subs();
238
+ reconcileDashboardSelection(this.selection, subs);
239
+
240
+ if (this.keybindings.matches(data, "tui.select.cancel")) {
241
+ this.close(null);
242
+ return;
243
+ }
244
+ if (this.keybindings.matches(data, "tui.select.confirm")) {
245
+ const snap = subs[this.selection.index];
246
+ if (snap) this.close(snap.id);
247
+ return;
248
+ }
249
+ if (this.keybindings.matches(data, "tui.select.up") || data === "k") {
250
+ if (subs.length > 0) {
251
+ this.selection.index =
252
+ (this.selection.index - 1 + subs.length) % subs.length;
253
+ this.selection.id = subs[this.selection.index]?.id;
254
+ this.tui.requestRender();
255
+ }
256
+ return;
257
+ }
258
+ if (this.keybindings.matches(data, "tui.select.down") || data === "j") {
259
+ if (subs.length > 0) {
260
+ this.selection.index = (this.selection.index + 1) % subs.length;
261
+ this.selection.id = subs[this.selection.index]?.id;
262
+ this.tui.requestRender();
263
+ }
264
+ return;
265
+ }
266
+ if (data === "x") {
267
+ const snap = subs[this.selection.index];
268
+ if (snap && snap.status === "running") this.view.requestAbort(snap.id);
269
+ return;
270
+ }
271
+ }
272
+
273
+ private pad(text: string, width: number): string {
274
+ const truncated = truncateToWidth(text, width);
275
+ return truncated + " ".repeat(Math.max(0, width - visibleWidth(truncated)));
276
+ }
277
+
278
+ private borderSegment(width: number, title: string): string {
279
+ const theme = this.theme;
280
+ const label = title
281
+ ? ` ${truncateToWidth(title, Math.max(0, width - 3))} `
282
+ : "";
283
+ const labelWidth = visibleWidth(label);
284
+ return (
285
+ theme.fg("border", "─") +
286
+ (label ? theme.fg("text", label) : "") +
287
+ theme.fg("border", "─".repeat(Math.max(0, width - 1 - labelWidth)))
288
+ );
289
+ }
290
+
291
+ render(width: number): string[] {
292
+ const theme = this.theme;
293
+ const subs = this.subs();
294
+ reconcileDashboardSelection(this.selection, subs);
295
+
296
+ const rows = this.tui.terminal.rows || 30;
297
+ const bodyHeight = Math.max(7, rows - 6);
298
+ const innerWidth = Math.max(1, width - 2);
299
+ const running = subs.filter((snap) => snap.status === "running").length;
300
+ const done = subs.filter((snap) => snap.status === "done").length;
301
+ const failed = subs.filter((snap) => snap.status === "error").length;
302
+ const selected = subs[this.selection.index];
303
+
304
+ const headerLeft = theme.fg("accent", theme.bold("Subagent operations"));
305
+ const counts = [
306
+ running > 0 ? stateLabel(theme, "warning", `${running} running`) : "",
307
+ done > 0 ? stateLabel(theme, "success", `${done} done`) : "",
308
+ failed > 0 ? stateLabel(theme, "error", `${failed} failed`) : "",
309
+ ].filter(Boolean).join(theme.fg("dim", " · "));
310
+ const lines = [joinSides(` ${headerLeft}`, `${counts || theme.fg("muted", "idle")} `, width)];
311
+
312
+ lines.push(
313
+ theme.fg("border", "╭") +
314
+ this.borderSegment(innerWidth, `${subs.length} agent${subs.length === 1 ? "" : "s"} · live control`) +
315
+ theme.fg("border", "╮"),
316
+ );
317
+
318
+ const divider = theme.fg("border", "│");
319
+ if (width >= 100 && selected) {
320
+ const leftWidth = Math.max(38, Math.floor((innerWidth - 1) * 0.48));
321
+ const rightWidth = Math.max(20, innerWidth - leftWidth - 1);
322
+ const list = this.renderRows(subs, leftWidth, bodyHeight, true);
323
+ const detail = this.renderSelected(selected, rightWidth, bodyHeight);
324
+ for (let row = 0; row < bodyHeight; row++) {
325
+ lines.push(
326
+ divider +
327
+ padLine(list[row] ?? "", leftWidth) +
328
+ theme.fg("borderMuted", "│") +
329
+ padLine(detail[row] ?? "", rightWidth) +
330
+ divider,
331
+ );
332
+ }
333
+ } else {
334
+ const list = this.renderRows(subs, innerWidth, bodyHeight, false);
335
+ for (let row = 0; row < bodyHeight; row++) {
336
+ lines.push(divider + padLine(list[row] ?? "", innerWidth) + divider);
337
+ }
338
+ }
339
+
340
+ lines.push(
341
+ theme.fg("border", "╰") +
342
+ theme.fg("border", "─".repeat(innerWidth)) +
343
+ theme.fg("border", "╯"),
344
+ );
345
+ lines.push(
346
+ truncateToWidth(
347
+ theme.fg(
348
+ "dim",
349
+ ` ${configuredKeys(this.keybindings, "tui.select.up")}/${configuredKeys(this.keybindings, "tui.select.down")}/jk select · ${configuredKeys(this.keybindings, "tui.select.confirm")} take over · x abort running · ${configuredKeys(this.keybindings, "tui.select.cancel")} close`,
350
+ ),
351
+ width,
352
+ ),
353
+ );
354
+ return lines;
355
+ }
356
+
357
+ private renderRows(
358
+ subs: ReadonlyArray<SubagentSnapshot>,
359
+ width: number,
360
+ height: number,
361
+ compact: boolean,
362
+ ): string[] {
363
+ const theme = this.theme;
364
+ const out: string[] = [];
365
+ const viewport = viewportSlice(subs, this.selection.index, height);
366
+
367
+ for (let row = 0; row < viewport.items.length; row++) {
368
+ const snap = viewport.items[row];
369
+ const index = viewport.start + row;
370
+ const selected = index === this.selection.index;
371
+ const marker = selected ? theme.fg("accent", "❯") : " ";
372
+ const title = selected
373
+ ? theme.fg("accent", oneLine(snap.title))
374
+ : theme.fg("text", oneLine(snap.title));
375
+ const left = ` ${marker} ${statusGlyph(snap, theme)} ${title} ${theme.fg("dim", snap.id)}`;
376
+ const utilization = formatContextUtilization(snap.usage);
377
+ const rightParts = compact
378
+ ? [theme.fg("muted", formatElapsed(snap)), statusWord(snap, theme)]
379
+ : [
380
+ theme.fg("muted", snap.meta.modelLabel ?? "?"),
381
+ ...(utilization ? [theme.fg("muted", utilization)] : []),
382
+ theme.fg("muted", formatElapsed(snap)),
383
+ statusWord(snap, theme),
384
+ ];
385
+ const line = joinSides(left, `${rightParts.join(theme.fg("dim", " · "))} `, width);
386
+ out.push(selected ? theme.bg("selectedBg", padLine(line, width)) : line);
387
+ }
388
+ if (viewport.start > 0 && out.length > 0) {
389
+ out[0] = truncateToWidth(theme.fg("dim", ` ↑ ${viewport.start} more`), width);
390
+ }
391
+ const below = subs.length - viewport.start - viewport.items.length;
392
+ if (below > 0 && out.length > 0) {
393
+ out[out.length - 1] = truncateToWidth(theme.fg("dim", ` ↓ ${below} more`), width);
394
+ }
395
+ return out;
396
+ }
397
+
398
+ private renderSelected(
399
+ snap: SubagentSnapshot,
400
+ width: number,
401
+ height: number,
402
+ ): string[] {
403
+ const theme = this.theme;
404
+ const state = snap.status === "running" ? "warning" : snap.status === "done" ? "success" : "error";
405
+ const percent = contextPercent(snap.usage);
406
+ const activeTools = snap.liveTools.filter((tool) => !tool.done);
407
+ const lines: string[] = [
408
+ ` ${stateLabel(theme, state, snap.status === "error" ? "failed" : snap.status)} ${theme.fg("accent", theme.bold(oneLine(snap.title)))}`,
409
+ ` ${theme.fg("dim", snap.id)} ${theme.fg("muted", `· ${formatElapsed(snap)} · ${snap.turns} turn${snap.turns === 1 ? "" : "s"}`)}`,
410
+ "",
411
+ ` ${theme.fg("muted", "MODEL")} ${theme.fg("text", snap.meta.modelLabel ?? "inherit")}`,
412
+ ` ${theme.fg("muted", "PROFILE")} ${theme.fg("text", snap.meta.agentType ?? "general-purpose")}${snap.meta.persona ? theme.fg("dim", ` · ${snap.meta.persona}`) : ""}`,
413
+ ` ${theme.fg("muted", "ACCESS")} ${theme.fg("text", snap.meta.capability ?? "all")}${snap.meta.isolation === "worktree" ? theme.fg("warning", " · worktree") : ""}`,
414
+ ` ${theme.fg("muted", "SOURCE")} ${theme.fg("text", snap.meta.origin === "btw" ? "user aside" : "model delegation")}`,
415
+ ];
416
+ if (percent !== undefined) {
417
+ lines.push("", ` ${theme.fg("muted", "CONTEXT")} ${meter(theme, percent, 100, Math.max(12, width - 12), percent >= 85 ? "warning" : "active")}`);
418
+ }
419
+ lines.push("");
420
+ if (activeTools.length > 0) {
421
+ lines.push(` ${theme.fg("muted", "NOW")} ${theme.fg("toolTitle", oneLine(activeTools[0]!.name))}${activeTools.length > 1 ? theme.fg("dim", ` +${activeTools.length - 1}`) : ""}`);
422
+ } else if (snap.status === "running") {
423
+ lines.push(` ${theme.fg("muted", "NOW")} ${theme.fg("warning", "thinking / responding")}`);
424
+ } else {
425
+ lines.push(` ${theme.fg("muted", "NOW")} ${theme.fg("dim", "settled")}`);
426
+ }
427
+ if (snap.queued.length > 0) {
428
+ lines.push(` ${theme.fg("muted", "QUEUE")} ${theme.fg("warning", `${snap.queued.length} message${snap.queued.length === 1 ? "" : "s"}`)}`);
429
+ }
430
+ lines.push("", ` ${theme.fg("muted", "LATEST")}`);
431
+ const latest = oneLine(latestText(snap) || snap.errorText || "No output yet.");
432
+ const preview = wrapTextWithAnsi(latest, Math.max(10, width - 2)).slice(0, Math.max(1, height - lines.length - 3));
433
+ lines.push(...preview.map((line) => ` ${theme.fg(snap.errorText ? "error" : "toolOutput", line)}`));
434
+ lines.push("", truncateToWidth(` ${theme.fg("dim", snap.cwd)}`, width));
435
+ return lines.slice(0, height);
436
+ }
437
+
438
+ invalidate(): void {}
439
+ }
440
+
441
+ // --- Takeover view ------------------------------------------------------------
442
+
443
+ const TRANSCRIPT_SCROLL_STEP = 6;
444
+
445
+ export class TakeoverView implements Component, Focusable {
446
+ private tui: TUI;
447
+ private theme: Theme;
448
+ private keybindings: KeybindingsManager;
449
+ private id: string;
450
+ private view: SubagentReadModel;
451
+ private done: (value: null) => void;
452
+
453
+ private input = new Input();
454
+ /** Scroll offset in lines from the bottom of the transcript. 0 = pinned to bottom. */
455
+ private scrollOffset = 0;
456
+ private unsubscribe: () => void;
457
+ private renderTimer?: ReturnType<typeof setTimeout>;
458
+ private ticker: ReturnType<typeof setInterval>;
459
+ private transcriptCacheKey = "";
460
+ private transcriptCache: string[] = [];
461
+ private closed = false;
462
+
463
+ private _focused = false;
464
+ get focused(): boolean {
465
+ return this._focused;
466
+ }
467
+ set focused(value: boolean) {
468
+ this._focused = value;
469
+ this.input.focused = value;
470
+ }
471
+
472
+ constructor(
473
+ tui: TUI,
474
+ theme: Theme,
475
+ keybindings: KeybindingsManager,
476
+ id: string,
477
+ view: SubagentReadModel,
478
+ done: (value: null) => void,
479
+ ) {
480
+ this.tui = tui;
481
+ this.theme = theme;
482
+ this.keybindings = keybindings;
483
+ this.id = id;
484
+ this.view = view;
485
+ this.done = done;
486
+ this.unsubscribe = view.subscribeTo(id, () => this.scheduleRender());
487
+ // Elapsed time in the header ticks along at 1Hz.
488
+ this.ticker = setInterval(() => this.tui.requestRender(), 1000);
489
+ this.ticker.unref?.();
490
+ this.input.onSubmit = (value: string) => {
491
+ const text = value.trim();
492
+ if (!text) return;
493
+ this.input.setValue("");
494
+ this.view.requestSend(this.id, text);
495
+ this.scrollOffset = 0;
496
+ this.tui.requestRender();
497
+ };
498
+ }
499
+
500
+ private snap(): SubagentSnapshot | undefined {
501
+ return this.view.get(this.id);
502
+ }
503
+
504
+ private scheduleRender() {
505
+ if (this.renderTimer) return;
506
+ // Streaming can emit an event per token. Limit terminal repaints so this
507
+ // view cannot starve input handling or make the child look frozen.
508
+ this.renderTimer = setTimeout(() => {
509
+ this.renderTimer = undefined;
510
+ if (!this.closed) this.tui.requestRender();
511
+ }, 50);
512
+ }
513
+
514
+ private cleanup() {
515
+ if (this.closed) return false;
516
+ this.closed = true;
517
+ this.unsubscribe();
518
+ clearInterval(this.ticker);
519
+ if (this.renderTimer) clearTimeout(this.renderTimer);
520
+ this.renderTimer = undefined;
521
+ return true;
522
+ }
523
+
524
+ private close() {
525
+ if (this.cleanup()) this.done(null);
526
+ }
527
+
528
+ dispose(): void {
529
+ this.cleanup();
530
+ }
531
+
532
+ handleInput(data: string): void {
533
+ if (this.keybindings.matches(data, "app.clear")) {
534
+ const snap = this.snap();
535
+ if (snap?.status === "running") this.view.requestAbort(this.id);
536
+ return;
537
+ }
538
+ if (
539
+ this.keybindings.matches(data, "app.interrupt") ||
540
+ this.keybindings.matches(data, "tui.select.cancel")
541
+ ) {
542
+ this.close();
543
+ return;
544
+ }
545
+ if (this.keybindings.matches(data, "tui.editor.cursorUp")) {
546
+ this.scrollOffset += TRANSCRIPT_SCROLL_STEP;
547
+ this.tui.requestRender();
548
+ return;
549
+ }
550
+ if (this.keybindings.matches(data, "tui.editor.cursorDown")) {
551
+ this.scrollOffset = Math.max(
552
+ 0,
553
+ this.scrollOffset - TRANSCRIPT_SCROLL_STEP,
554
+ );
555
+ this.tui.requestRender();
556
+ return;
557
+ }
558
+ if (this.keybindings.matches(data, "tui.editor.pageUp")) {
559
+ this.scrollOffset += this.viewportHeight();
560
+ this.tui.requestRender();
561
+ return;
562
+ }
563
+ if (this.keybindings.matches(data, "tui.editor.pageDown")) {
564
+ this.scrollOffset = Math.max(
565
+ 0,
566
+ this.scrollOffset - this.viewportHeight(),
567
+ );
568
+ this.tui.requestRender();
569
+ return;
570
+ }
571
+ this.input.handleInput(data);
572
+ this.tui.requestRender();
573
+ }
574
+
575
+ private transcriptLines(snap: SubagentSnapshot, width: number): string[] {
576
+ const live = snap.liveAssistant;
577
+ const toolVersion = snap.liveTools
578
+ .map((tool) => `${tool.toolId}:${tool.done ? 1 : 0}:${tool.isError ? 1 : 0}:${tool.outputPreview ?? ""}`)
579
+ .join("|");
580
+ const key = [
581
+ width,
582
+ snap.transcript.length,
583
+ live?.text.length ?? 0,
584
+ live?.thinking.length ?? 0,
585
+ toolVersion,
586
+ live?.text.slice(-80) ?? "",
587
+ live?.thinking.slice(-80) ?? "",
588
+ snap.queued.map((message) => `${message.kind}:${message.text}`).join("|"),
589
+ snap.status,
590
+ ].join(":");
591
+ if (key !== this.transcriptCacheKey) {
592
+ this.transcriptCacheKey = key;
593
+ this.transcriptCache = buildTranscriptLines(snap, width, this.theme);
594
+ }
595
+ return this.transcriptCache;
596
+ }
597
+
598
+ private viewportHeight(): number {
599
+ const rows = this.tui.terminal.rows || 30;
600
+ // Header, metadata, input, borders, and hints consume eight rows.
601
+ return Math.max(6, rows - 9);
602
+ }
603
+
604
+ render(width: number): string[] {
605
+ const theme = this.theme;
606
+ const border = theme.fg("borderAccent", "─".repeat(Math.max(1, width)));
607
+ const lines: string[] = [];
608
+ const snap = this.snap();
609
+
610
+ if (!snap) {
611
+ lines.push(border);
612
+ lines.push(theme.fg("dim", `${this.id} is no longer tracked`));
613
+ lines.push(border);
614
+ return lines;
615
+ }
616
+
617
+ lines.push(border);
618
+ const utilization = formatContextUtilization(snap.usage);
619
+ const header =
620
+ `${statusGlyph(snap, theme)} ` +
621
+ theme.fg("accent", theme.bold(`${snap.id} · ${snap.title}`)) +
622
+ theme.fg("muted", ` · ${snap.status} · ${formatElapsed(snap)}`) +
623
+ theme.fg("dim", ` · ${snap.backend}: ${snap.meta.modelLabel ?? "?"}`) +
624
+ (utilization ? theme.fg("dim", ` · ${utilization}`) : "");
625
+ lines.push(truncateToWidth(header, width));
626
+ const percent = contextPercent(snap.usage);
627
+ const activeTools = snap.liveTools.filter((tool) => !tool.done);
628
+ const metaLeft =
629
+ ` ${theme.fg("muted", "MODEL")} ${theme.fg("text", snap.meta.modelLabel ?? "inherit")}` +
630
+ theme.fg("dim", ` · ${snap.meta.capability ?? "all"}${snap.meta.isolation === "worktree" ? " · worktree" : ""}`);
631
+ const metaRight = percent === undefined
632
+ ? activeTools.length > 0
633
+ ? theme.fg("toolTitle", oneLine(activeTools[0]!.name))
634
+ : ""
635
+ : `${meter(theme, percent, 100, Math.min(24, Math.max(10, Math.floor(width / 4))), percent >= 85 ? "warning" : "active")}`;
636
+ lines.push(joinSides(metaLeft, `${metaRight} `, width));
637
+ lines.push(border);
638
+
639
+ // Fixed-height transcript viewport. Error and scroll status consume rows
640
+ // inside the viewport so streaming/scrolling never changes overlay height.
641
+ const transcript = this.transcriptLines(snap, width);
642
+ const viewport = this.viewportHeight();
643
+ const errorRows = snap.errorText ? 1 : 0;
644
+ const scrollRows = this.scrollOffset > 0 ? 1 : 0;
645
+ const transcriptCapacity = Math.max(1, viewport - errorRows - scrollRows);
646
+ const maxOffset = Math.max(0, transcript.length - transcriptCapacity);
647
+ if (this.scrollOffset > maxOffset) this.scrollOffset = maxOffset;
648
+
649
+ const body: string[] = [];
650
+ if (snap.errorText) {
651
+ body.push(
652
+ truncateToWidth(theme.fg("error", `error: ${snap.errorText}`), width),
653
+ );
654
+ }
655
+
656
+ const capacity = Math.max(
657
+ 1,
658
+ viewport - body.length - (this.scrollOffset > 0 ? 1 : 0),
659
+ );
660
+ const end = transcript.length - this.scrollOffset;
661
+ const visible = transcript.slice(Math.max(0, end - capacity), end);
662
+ if (visible.length === 0) body.push(theme.fg("dim", "(no output yet)"));
663
+ else body.push(...visible);
664
+
665
+ if (this.scrollOffset > 0) {
666
+ body.push(
667
+ truncateToWidth(
668
+ theme.fg("dim", `... ${this.scrollOffset} lines below · ↓/pgdn`),
669
+ width,
670
+ ),
671
+ );
672
+ }
673
+ while (body.length < viewport) body.push("");
674
+ lines.push(...body.slice(0, viewport));
675
+
676
+ lines.push(border);
677
+ lines.push(...this.input.render(width));
678
+ lines.push(
679
+ truncateToWidth(
680
+ theme.fg(
681
+ "dim",
682
+ `${configuredKeys(this.keybindings, "tui.input.submit")} send · ${configuredKeys(this.keybindings, "app.interrupt")} back · ${configuredKeys(this.keybindings, "app.clear")} abort run · ${configuredKeys(this.keybindings, "tui.editor.cursorUp")}/${configuredKeys(this.keybindings, "tui.editor.cursorDown")} scroll · ${configuredKeys(this.keybindings, "tui.editor.pageUp")}/${configuredKeys(this.keybindings, "tui.editor.pageDown")} page`,
683
+ ),
684
+ width,
685
+ ),
686
+ );
687
+ lines.push(border);
688
+ return lines;
689
+ }
690
+
691
+ invalidate(): void {
692
+ this.transcriptCacheKey = "";
693
+ this.input.invalidate();
694
+ }
695
+ }