@nmzpy/pi-ember-stack 0.2.1 → 0.2.2

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 (30) hide show
  1. package/README.md +83 -83
  2. package/package.json +63 -59
  3. package/plugins/devin-auth/extensions/index.ts +23 -0
  4. package/plugins/devin-auth/src/cloud-direct/auth.ts +246 -246
  5. package/plugins/devin-auth/src/cloud-direct/catalog.ts +246 -246
  6. package/plugins/devin-auth/src/cloud-direct/chat.ts +1096 -1091
  7. package/plugins/devin-auth/src/cloud-direct/index.ts +41 -41
  8. package/plugins/devin-auth/src/cloud-direct/metadata.ts +78 -78
  9. package/plugins/devin-auth/src/cloud-direct/wire.ts +202 -202
  10. package/plugins/devin-auth/src/oauth/register-user.ts +174 -174
  11. package/plugins/devin-auth/src/oauth/types.ts +71 -71
  12. package/plugins/devin-auth/src/stream.ts +1 -1
  13. package/plugins/pi-compact-tools/index.ts +19 -1
  14. package/plugins/pi-compact-tools/renderer.ts +231 -61
  15. package/plugins/pi-custom-agents/index.ts +310 -102
  16. package/plugins/pi-custom-agents/questionnaire-tool.ts +14 -5
  17. package/plugins/pi-custom-agents/subagent/extensions/agents.ts +18 -1
  18. package/plugins/pi-custom-agents/subagent/extensions/index.ts +116 -224
  19. package/plugins/pi-custom-agents/subagent/extensions/model.ts +96 -96
  20. package/plugins/pi-custom-agents/subagent/extensions/render.ts +205 -1
  21. package/plugins/pi-custom-agents/subagent/extensions/runner.ts +241 -177
  22. package/plugins/pi-custom-agents/subagent/extensions/test/render.test.ts +145 -0
  23. package/plugins/pi-ember-fff/index.ts +275 -17
  24. package/plugins/pi-ember-fff/query.ts +170 -10
  25. package/plugins/pi-ember-fff/test/query.test.ts +157 -1
  26. package/plugins/pi-ember-fff/test/renderer.test.ts +367 -16
  27. package/plugins/pi-ember-tps/index.ts +27 -5
  28. package/plugins/pi-ember-ui/ember.json +3 -0
  29. package/plugins/pi-ember-ui/index.ts +223 -36
  30. package/plugins/pi-ember-ui/mode-colors.ts +36 -8
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Ember TPS Meter — minimal tokens-per-second tracker
3
3
  *
4
- * Tracks output token rate during streaming and exposes the live value
5
- * via getLiveTps() for the custom footer to render.
4
+ * Tracks output plus thinking token rate during streaming and exposes the live
5
+ * value via getLiveTps() for the custom footer to render.
6
6
  */
7
7
 
8
8
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
@@ -12,6 +12,7 @@ const STREAM_INTERVAL_MS = 500;
12
12
  let streamStartMs = 0;
13
13
  let firstTokenMs = 0;
14
14
  let streamChars = 0;
15
+ let streamThinkingChars = 0;
15
16
  let streamTokens = 0;
16
17
  let tickTimer: ReturnType<typeof setInterval> | null = null;
17
18
  let streaming = false;
@@ -61,6 +62,7 @@ export default function piEmberTps(pi: ExtensionAPI): void {
61
62
  streamStartMs = now();
62
63
  firstTokenMs = 0;
63
64
  streamChars = 0;
65
+ streamThinkingChars = 0;
64
66
  streamTokens = 0;
65
67
  liveTps = 0;
66
68
  streaming = true;
@@ -79,6 +81,7 @@ export default function piEmberTps(pi: ExtensionAPI): void {
79
81
  if (!d) return;
80
82
  if (firstTokenMs === 0) firstTokenMs = now();
81
83
  streamChars += d.length;
84
+ if (evt.type === "thinking_delta") streamThinkingChars += d.length;
82
85
  streamTokens = tokEst(streamChars);
83
86
  }
84
87
  });
@@ -88,9 +91,16 @@ export default function piEmberTps(pi: ExtensionAPI): void {
88
91
  streaming = false;
89
92
  stopTick();
90
93
 
91
- const realOut = event.message?.usage?.output;
92
- const tokens =
93
- typeof realOut === "number" && realOut > 0 ? realOut : streamTokens;
94
+ const usage = event.message?.usage;
95
+ const realOut = usage?.output;
96
+ // Some providers report output tokens without their thinking tokens.
97
+ // Keep the streamed combined estimate in that case; providers that
98
+ // expose `reasoning` already include it in `usage.output`.
99
+ const tokens = typeof realOut === "number" && realOut > 0
100
+ ? streamThinkingChars > 0 && usage?.reasoning === undefined
101
+ ? Math.max(realOut, streamTokens)
102
+ : realOut
103
+ : streamTokens;
94
104
 
95
105
  const ref = firstTokenMs > 0 ? firstTokenMs : streamStartMs;
96
106
  const elapsed = (now() - ref) / 1000;
@@ -114,9 +124,21 @@ export default function piEmberTps(pi: ExtensionAPI): void {
114
124
  streamStartMs = 0;
115
125
  firstTokenMs = 0;
116
126
  streamChars = 0;
127
+ streamThinkingChars = 0;
117
128
  streamTokens = 0;
118
129
  liveTps = 0;
119
130
  renderTrigger = undefined;
120
131
  ctx.ui.setStatus("tps", undefined);
121
132
  });
133
+
134
+ // Clear the tick interval and stale render trigger on shutdown so a
135
+ // subsequent /resume does not keep a setInterval alive against the dead
136
+ // session's ctx.ui. Without this, the 500ms tick keeps firing and
137
+ // calls the old renderTrigger long after the session is gone.
138
+ pi.on("session_shutdown", async () => {
139
+ streaming = false;
140
+ stopTick();
141
+ renderTrigger = undefined;
142
+ liveTps = 0;
143
+ });
122
144
  }
@@ -9,6 +9,7 @@
9
9
  "emberAccent45": "#A8501F",
10
10
  "emberAccent30": "#8F4627",
11
11
  "emberAccent15": "#783C2F",
12
+ "emberAccent25": "#4D2E17",
12
13
  "emberGray": "#808080",
13
14
  "emberDarkGray": "#505050",
14
15
  "cyan": "#00d7ff",
@@ -23,6 +24,7 @@
23
24
  "accent": "#EB6E00",
24
25
  "selectedBg": "#3a3a4a",
25
26
  "userMsgBg": "#343541",
27
+ "subagentBg": "#32333f",
26
28
  "toolPendingBg": "#282832",
27
29
  "toolSuccessBg": "#283228",
28
30
  "toolErrorBg": "#3c2828",
@@ -43,6 +45,7 @@
43
45
 
44
46
  "selectedBg": "selectedBg",
45
47
  "userMessageBg": "userMsgBg",
48
+ "subagentBg": "subagentBg",
46
49
  "userMessageText": "text",
47
50
  "customMessageBg": "customMsgBg",
48
51
  "customMessageText": "text",
@@ -2,22 +2,27 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import {
5
+ AssistantMessageComponent,
5
6
  Theme,
6
7
  type ExtensionAPI,
7
8
  } from "@earendil-works/pi-coding-agent";
8
- import { Editor, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
9
+ import { Editor, Markdown, Spacer, Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
9
10
  import {
10
11
  buildCodeBgHex,
11
12
  buildThemeBgColors,
12
13
  buildThemeFgColors,
13
14
  getActiveModeColor,
15
+ isShellMode,
16
+ MUTED_COLOR,
17
+ setLatestSubagentRunning,
18
+ setShellMode,
14
19
  } from "./mode-colors.ts";
15
20
 
16
21
  const SOURCE_ROOT = path.dirname(fileURLToPath(import.meta.url));
17
22
  const THEME_JSON = path.join(SOURCE_ROOT, "ember.json");
18
23
  const THEME_NAME = "ember";
19
24
 
20
- const THINKING_FRAME_INTERVAL_MS = 60;
25
+ const THINKING_FRAME_INTERVAL_MS = 33;
21
26
  const LOGO = [
22
27
  " ██████ ██",
23
28
  " ██ ██ ██",
@@ -40,6 +45,7 @@ let logoAnimating = false;
40
45
  let logoFrame = 0;
41
46
  let logoTimer: ReturnType<typeof setInterval> | undefined;
42
47
  let editorRenderPatched = false;
48
+ let assistantMessagePatched = false;
43
49
  let requestRender: (() => void) | undefined;
44
50
  let sessionCtx: any;
45
51
 
@@ -48,6 +54,57 @@ type EditorWithBorder = Editor & {
48
54
  getText: () => string;
49
55
  };
50
56
 
57
+ /**
58
+ * Cached result of the subagent-running scan. The scan itself is O(n)
59
+ * over the session branch (getBranch + two passes), so it MUST NOT run
60
+ * inside the per-frame Editor.prototype.render path. It is recomputed
61
+ * only on tool_execution_start / tool_execution_end (which fire once
62
+ * per tool call) and reset on session replacement. The render path
63
+ * reads this cached flag via subagentRunningCached.
64
+ */
65
+ let subagentRunningCached = false;
66
+
67
+ /**
68
+ * Recompute whether the latest tool call in the session is a `subagent`
69
+ * that has not yet produced a toolResult. Scans session entries
70
+ * (available via module-level sessionCtx) and writes the result to both
71
+ * the shared mode-colors flag and the local cache. Called only from
72
+ * tool-execution event handlers — never from the render path.
73
+ */
74
+ function recompute_latest_subagent_running(): boolean {
75
+ const entries = sessionCtx?.sessionManager?.getBranch?.() ?? [];
76
+ let latestSubagentCallId: string | undefined;
77
+ for (let i = entries.length - 1; i >= 0; i--) {
78
+ const entry = entries[i];
79
+ if (entry?.type !== "message") continue;
80
+ const msg = entry.message;
81
+ if (msg?.role !== "assistant") continue;
82
+ for (const part of msg.content ?? []) {
83
+ if (part?.type === "toolCall" && part?.name === "subagent") {
84
+ latestSubagentCallId = part.id;
85
+ break;
86
+ }
87
+ }
88
+ if (latestSubagentCallId) break;
89
+ }
90
+ let running = false;
91
+ if (latestSubagentCallId) {
92
+ running = true;
93
+ for (let i = entries.length - 1; i >= 0; i--) {
94
+ const entry = entries[i];
95
+ if (entry?.type !== "message") continue;
96
+ const msg = entry.message;
97
+ if (msg?.role === "toolResult" && msg?.toolCallId === latestSubagentCallId) {
98
+ running = false;
99
+ break;
100
+ }
101
+ }
102
+ }
103
+ subagentRunningCached = running;
104
+ setLatestSubagentRunning(running);
105
+ return running;
106
+ }
107
+
51
108
  function currentLabelText(): string {
52
109
  return thinkingActive ? "Thinking" : "Working";
53
110
  }
@@ -66,7 +123,7 @@ function startThinkingAnimation(): void {
66
123
  if (!thinkingInterval) {
67
124
  thinkingFrame = 0;
68
125
  thinkingInterval = setInterval(() => {
69
- thinkingFrame += 0.06;
126
+ thinkingFrame += 0.09;
70
127
  if (thinkingFrame > 1) thinkingFrame -= 1;
71
128
  requestRender?.();
72
129
  }, THINKING_FRAME_INTERVAL_MS);
@@ -78,7 +135,7 @@ function startWorkingAnimation(): void {
78
135
  if (!thinkingInterval) {
79
136
  thinkingFrame = 0;
80
137
  thinkingInterval = setInterval(() => {
81
- thinkingFrame += 0.06;
138
+ thinkingFrame += 0.09;
82
139
  if (thinkingFrame > 1) thinkingFrame -= 1;
83
140
  requestRender?.();
84
141
  }, THINKING_FRAME_INTERVAL_MS);
@@ -146,7 +203,7 @@ function installProxiedTheme(fgColors: Record<string, string>, bgColors: Record<
146
203
  (globalThis as any)[THEME_KEY_OLD] = wrapped;
147
204
  }
148
205
 
149
- function applyDynamicTheme(): void {
206
+ function applyDynamicTheme(options: { invalidate?: boolean; render?: boolean } = {}): void {
150
207
  const accent = getActiveModeColor();
151
208
  const fgColors = buildThemeFgColors(accent);
152
209
  const bgColors = buildThemeBgColors(accent);
@@ -155,12 +212,12 @@ function applyDynamicTheme(): void {
155
212
  if (liveTheme) {
156
213
  liveCodeBgAnsi = bgAnsi(codeBg);
157
214
  updateLiveThemeColors(fgColors, bgColors);
158
- (tuiRef as any)?.invalidate();
159
- requestRender?.();
215
+ if (options.invalidate !== false) (tuiRef as any)?.invalidate();
216
+ if (options.render !== false) requestRender?.();
160
217
  return;
161
218
  }
162
219
  installProxiedTheme(fgColors, bgColors, codeBg);
163
- requestRender?.();
220
+ if (options.render !== false) requestRender?.();
164
221
  }
165
222
 
166
223
  function updateLiveThemeColors(fgColors: Record<string, string>, bgColors: Record<string, string>): void {
@@ -175,23 +232,32 @@ function updateLiveThemeColors(fgColors: Record<string, string>, bgColors: Recor
175
232
  }
176
233
  }
177
234
 
178
- function renderGradientLabel(text: string, accent: string): string {
235
+ function renderGradientLabel(text: string, accent: string, phaseOffset = 0): string {
179
236
  const chars = [...text];
180
237
  const len = chars.length;
181
238
  if (len === 0) return "";
182
- const textHex = "#808080";
239
+ const dimHex = blendToHex(accent, "#18181e", 0.4);
183
240
  const result: string[] = [];
241
+ const phase = (thinkingFrame + phaseOffset) % 1;
184
242
  for (let i = 0; i < len; i++) {
185
243
  const charPos = i / Math.max(1, len - 1);
186
- const dist = charPos - thinkingFrame;
244
+ const dist = charPos - phase;
187
245
  const wrapped = dist < -0.5 ? dist + 1 : dist > 0.5 ? dist - 1 : dist;
188
246
  const intensity = Math.exp(-(wrapped * wrapped) * 8);
189
- const hex = blendToHex(textHex, accent, intensity);
247
+ const hex = blendToHex(dimHex, accent, intensity);
190
248
  result.push(colorize(chars[i], hex));
191
249
  }
192
250
  return result.join("");
193
251
  }
194
252
 
253
+ /** Render the same animated gradient used by the live Thinking label. */
254
+ export function renderLiveThinkingGradient(text: string): string {
255
+ return renderGradientLabel(text, getActiveModeColor());
256
+ }
257
+
258
+ /** Render an animated gradient with a stable per-row phase offset. */
259
+ export { renderGradientLabel };
260
+
195
261
  function installThinkingBorderOverride(): void {
196
262
  if (editorRenderPatched) return;
197
263
  editorRenderPatched = true;
@@ -199,7 +265,10 @@ function installThinkingBorderOverride(): void {
199
265
  Editor.prototype.render = function renderThinkingBorder(this: EditorWithBorder, width: number): string[] {
200
266
  const accent = getActiveModeColor();
201
267
  const accentLight = lightenHex(accent, 0.5);
202
- const accentBorder = (text: string): string => colorize(text, accentLight);
268
+ const borderColor = isShellMode() ? MUTED_COLOR : accentLight;
269
+ const accentBorder = (text: string): string => colorize(text, borderColor);
270
+ const dimInsetBorder = (): string =>
271
+ ` ${colorWithOpacity("\u2500".repeat(Math.max(0, width - 2)), borderColor, 0.1875)} `;
203
272
  const originalBorderColor = this.borderColor;
204
273
  this.borderColor = accentBorder;
205
274
  const innerWidth = Math.max(1, width - 2);
@@ -222,11 +291,16 @@ function installThinkingBorderOverride(): void {
222
291
  if (i === bottomBorderIdx) continue;
223
292
  lines[i] = ` ${lines[i]} `;
224
293
  }
225
- lines[topIdx] = accentBorder("\u2500".repeat(width));
294
+ const subagentActive = subagentRunningCached;
295
+ if (subagentActive) {
296
+ lines[topIdx] = dimInsetBorder();
297
+ } else {
298
+ lines[topIdx] = accentBorder("\u2500".repeat(width));
299
+ }
226
300
  if (bottomBorderIdx >= 0) {
227
301
  const inputText = this.getText?.() ?? "";
228
302
  if (inputText.trimStart().startsWith("/")) {
229
- lines[bottomBorderIdx] = ` ${colorWithOpacity("\u2500".repeat(width - 2), accentLight, 0.1875)} `;
303
+ lines[bottomBorderIdx] = dimInsetBorder();
230
304
  } else {
231
305
  lines[bottomBorderIdx] = accentBorder("\u2500".repeat(width));
232
306
  }
@@ -236,6 +310,7 @@ function installThinkingBorderOverride(): void {
236
310
  lines[lastLineIdx] = accentBorder("\u2500".repeat(width));
237
311
  }
238
312
  if (lines.length === 0) return lines;
313
+ if (subagentActive) return lines;
239
314
  if (!thinkingActive && !workingActive) return lines;
240
315
 
241
316
  const labelText = thinkingActive ? "Thinking" : "Working";
@@ -248,6 +323,105 @@ function installThinkingBorderOverride(): void {
248
323
  };
249
324
  }
250
325
 
326
+ function installAssistantMessagePatch(): void {
327
+ if (assistantMessagePatched) return;
328
+ assistantMessagePatched = true;
329
+
330
+ (AssistantMessageComponent as any).prototype.updateContent = function (this: any, message: any): void {
331
+ const hide = this.hideThinkingBlock;
332
+ const outputPad = this.outputPad;
333
+ // Skip the full rebuild when nothing that affects the rendered output
334
+ // has changed. invalidate() (from theme change, thinking toggle,
335
+ // output-pad change) calls updateContent with the SAME message —
336
+ // recreating every Markdown child and clearing contentContainer is
337
+ // O(blocks) per assistant message per rebuild, which freezes long
338
+ // transcripts on thinking-toggle. The child Markdown caches were
339
+ // already cleared by the invalidate() propagation, so the next
340
+ // render() will re-parse regardless — but we avoid the object
341
+ // churn and container rebuild.
342
+ const sameMessage = this._emberContentMessage === message;
343
+ const cacheKey = `${sameMessage ? "same" : "diff"}|${hide}|${outputPad}`;
344
+ if (sameMessage && this._emberContentKey === cacheKey) {
345
+ this.lastMessage = message;
346
+ return;
347
+ }
348
+ this._emberContentKey = cacheKey;
349
+ this._emberContentMessage = message;
350
+ this.lastMessage = message;
351
+
352
+ this.contentContainer.clear();
353
+
354
+ const isVisibleBlock = (c: any): boolean => {
355
+ if (c.type === "text" && c.text?.trim()) return true;
356
+ if (c.type === "thinking" && c.thinking?.trim() && !hide) return true;
357
+ return false;
358
+ };
359
+
360
+ const hasVisibleContent = message.content.some(isVisibleBlock);
361
+ if (hasVisibleContent) {
362
+ this.contentContainer.addChild(new Spacer(1));
363
+ }
364
+
365
+ const theme = liveTheme ?? (globalThis as any)[THEME_KEY];
366
+
367
+ for (let i = 0; i < message.content.length; i++) {
368
+ const content = message.content[i];
369
+ if (content.type === "text" && content.text?.trim()) {
370
+ this.contentContainer.addChild(
371
+ new Markdown(content.text.trim(), this.outputPad, 0, this.markdownTheme),
372
+ );
373
+ } else if (content.type === "thinking" && content.thinking?.trim()) {
374
+ if (hide) continue;
375
+ const hasVisibleContentAfter = message.content.slice(i + 1).some(isVisibleBlock);
376
+ this.contentContainer.addChild(
377
+ new Markdown(
378
+ content.thinking.trim(),
379
+ this.outputPad,
380
+ 0,
381
+ this.markdownTheme,
382
+ {
383
+ color: (text: string) => theme.fg("thinkingText", text),
384
+ italic: true,
385
+ },
386
+ ),
387
+ );
388
+ if (hasVisibleContentAfter) {
389
+ this.contentContainer.addChild(new Spacer(1));
390
+ }
391
+ }
392
+ }
393
+
394
+ const hasToolCalls = message.content.some((c: any) => c.type === "toolCall");
395
+ this.hasToolCalls = hasToolCalls;
396
+ if (message.stopReason === "length") {
397
+ this.contentContainer.addChild(new Spacer(1));
398
+ this.contentContainer.addChild(
399
+ new Text(
400
+ theme.fg(
401
+ "error",
402
+ "Error: Model stopped because it reached the maximum output token limit. The response may be incomplete.",
403
+ ),
404
+ this.outputPad,
405
+ 0,
406
+ ),
407
+ );
408
+ } else if (!hasToolCalls) {
409
+ if (message.stopReason === "aborted") {
410
+ const abortMessage =
411
+ message.errorMessage && message.errorMessage !== "Request was aborted"
412
+ ? message.errorMessage
413
+ : "Operation aborted";
414
+ this.contentContainer.addChild(new Spacer(1));
415
+ this.contentContainer.addChild(new Text(theme.fg("error", abortMessage), this.outputPad, 0));
416
+ } else if (message.stopReason === "error") {
417
+ const errorMsg = message.errorMessage || "Unknown error";
418
+ this.contentContainer.addChild(new Spacer(1));
419
+ this.contentContainer.addChild(new Text(theme.fg("error", `Error: ${errorMsg}`), this.outputPad, 0));
420
+ }
421
+ }
422
+ };
423
+ }
424
+
251
425
  function colorWithOpacity(text: string, hex: string, opacity: number): string {
252
426
  const source = hex.slice(1);
253
427
  const base = [24, 24, 30];
@@ -498,11 +672,13 @@ function ensureThemeInstalled(): void {
498
672
  export default function piEmberUiPlugin(pi: ExtensionAPI): void {
499
673
  ensureThemeInstalled();
500
674
  installThinkingBorderOverride();
675
+ installAssistantMessagePatch();
501
676
  applyDynamicTheme();
502
677
 
503
- pi.on("session_start", (_event, ctx) => {
678
+ pi.on("session_start", (event, ctx) => {
504
679
  sessionCtx = ctx;
505
680
  liveTheme = undefined;
681
+ subagentRunningCached = false;
506
682
  if (ctx.mode === "tui") {
507
683
  requestRender = () => ctx.ui.setStatus("pi-ember-ui-thinking-tick", undefined);
508
684
  ctx.ui.setWorkingVisible(false);
@@ -521,11 +697,15 @@ export default function piEmberUiPlugin(pi: ExtensionAPI): void {
521
697
  }
522
698
  }
523
699
  }
524
- startLogoAnimation();
525
- ctx.ui.onTerminalInput((data: string) => {
526
- if (logoAnimating && data !== "\t" && !data.startsWith("/")) stopLogoAnimation();
527
- return undefined;
528
- });
700
+ // Only animate the logo on genuinely fresh sessions where the
701
+ // header is visible. On /resume, /fork, and /reload the header
702
+ // is scrolled off-screen (or it's a hot reload) — the 60ms
703
+ // setInterval from startLogoAnimation would call requestRender()
704
+ // every tick, fighting the scrollarea's lazy-load and causing
705
+ // an infinite render glitch on long resumed sessions.
706
+ if (event.reason === "startup" || event.reason === "new") {
707
+ startLogoAnimation();
708
+ }
529
709
  }
530
710
  });
531
711
 
@@ -536,7 +716,14 @@ export default function piEmberUiPlugin(pi: ExtensionAPI): void {
536
716
 
537
717
  // Re-render header/footer when the active agent mode changes. Emitted by
538
718
  // pi-custom-agents (and any other extension) via the shared event bus.
539
- pi.events.on("pi-ember-ui:mode-change", () => {
719
+ pi.events.on("pi-ember-ui:mode-change", (event: any) => {
720
+ if (event?.liveOnly === true) {
721
+ // Mode switches update the live editor/footer only. Invalidating the
722
+ // whole TUI makes a large resumed transcript re-render synchronously.
723
+ // The custom-agents extension supplies the single live render tick.
724
+ applyDynamicTheme({ invalidate: false, render: false });
725
+ return;
726
+ }
540
727
  applyDynamicTheme();
541
728
  });
542
729
 
@@ -568,11 +755,13 @@ export default function piEmberUiPlugin(pi: ExtensionAPI): void {
568
755
 
569
756
  pi.on("tool_execution_start", (_event, ctx) => {
570
757
  if (ctx.mode !== "tui") return;
758
+ recompute_latest_subagent_running();
571
759
  if (!thinkingActive) startWorkingAnimation();
572
760
  });
573
761
 
574
762
  pi.on("tool_execution_end", (_event, ctx) => {
575
763
  if (ctx.mode !== "tui") return;
764
+ recompute_latest_subagent_running();
576
765
  if (workingActive && !thinkingActive) requestRender?.();
577
766
  });
578
767
 
@@ -594,25 +783,23 @@ export default function piEmberUiPlugin(pi: ExtensionAPI): void {
594
783
  },
595
784
  });
596
785
 
597
- pi.registerEntryRenderer("ember-turn-separator", (_entry, _opts, theme) => {
598
- return {
599
- render(width: number): string[] {
600
- return [theme.fg("accent", "\u2500".repeat(Math.max(0, width)))];
601
- },
602
- invalidate() {},
603
- } as any;
604
- });
605
-
606
- pi.on("turn_end", (_event, ctx) => {
607
- if (ctx.mode !== "tui") return;
608
- pi.appendEntry("ember-turn-separator");
609
- });
610
-
786
+ // Reset ALL session-bound module state on shutdown so a subsequent
787
+ // /resume (which re-runs the factory against a fresh runtime but keeps
788
+ // the cached module) does not call into the dead session's TUI/ctx via
789
+ // stale closures. The factory body calls applyDynamicTheme() on load,
790
+ // which would otherwise invoke the old requestRender/tuiRef.
611
791
  pi.on("session_shutdown", (_event, ctx) => {
612
792
  sessionCtx = undefined;
793
+ requestRender = undefined;
794
+ tuiRef = undefined;
795
+ liveTheme = undefined;
796
+ liveCodeBgAnsi = "";
797
+ subagentRunningCached = false;
613
798
  stopLogoAnimation();
614
799
  stopThinkingAnimation();
615
800
  stopWorkingAnimation();
801
+ setShellMode(false);
802
+ setLatestSubagentRunning(false);
616
803
  if (ctx.hasUI) ctx.ui.setHeader(undefined);
617
804
  });
618
805
  }
@@ -6,6 +6,7 @@ export const MODE_COLORS: Record<string, string> = {
6
6
  };
7
7
 
8
8
  export const MUTED_BULLET_COLOR = "#666666";
9
+ export const MUTED_COLOR = "#808080";
9
10
 
10
11
  export const PAGE_BG = "#18181e";
11
12
 
@@ -23,6 +24,32 @@ export function setActiveMode(modeId: string): void {
23
24
  activeModeId = modeId in MODE_COLORS ? modeId : "code";
24
25
  }
25
26
 
27
+ let shellModeActive = false;
28
+
29
+ export function isShellMode(): boolean {
30
+ return shellModeActive;
31
+ }
32
+
33
+ export function setShellMode(active: boolean): void {
34
+ shellModeActive = active;
35
+ }
36
+
37
+ let latestSubagentRunningFlag = false;
38
+
39
+ /**
40
+ * Whether the latest tool call in the session is a running subagent.
41
+ * Set by pi-ember-ui's editor border patch (which has session access)
42
+ * and read by both the border patch and the subagent renderer (via
43
+ * this shared module) to draw the integrated border + cap line.
44
+ */
45
+ export function isLatestSubagentRunning(): boolean {
46
+ return latestSubagentRunningFlag;
47
+ }
48
+
49
+ export function setLatestSubagentRunning(active: boolean): void {
50
+ latestSubagentRunningFlag = active;
51
+ }
52
+
26
53
  export function hexToRgb(hex: string): string {
27
54
  const r = parseInt(hex.slice(1, 3), 16);
28
55
  const g = parseInt(hex.slice(3, 5), 16);
@@ -126,22 +153,22 @@ export function buildThemeFgColors(accentHex: string): Record<string, string> {
126
153
  success: "#b5bd68",
127
154
  error: "#cc6666",
128
155
  warning: "#ffff00",
129
- muted: "#808080",
156
+ muted: MUTED_COLOR,
130
157
  dim: "#666666",
131
158
  text: "#d4d4d4",
132
- thinkingText: "#808080",
159
+ thinkingText: MUTED_COLOR,
133
160
  userMessageText: "#d4d4d4",
134
161
  customMessageText: "#d4d4d4",
135
- toolOutput: "#808080",
162
+ toolOutput: MUTED_COLOR,
136
163
  mdLinkUrl: "#666666",
137
164
  mdCodeBlock: "#b5bd68",
138
- mdCodeBlockBorder: "#808080",
139
- mdQuote: "#808080",
140
- mdQuoteBorder: "#808080",
141
- mdHr: "#808080",
165
+ mdCodeBlockBorder: MUTED_COLOR,
166
+ mdQuote: MUTED_COLOR,
167
+ mdQuoteBorder: MUTED_COLOR,
168
+ mdHr: MUTED_COLOR,
142
169
  toolDiffAdded: "#b5bd68",
143
170
  toolDiffRemoved: "#cc6666",
144
- toolDiffContext: "#808080",
171
+ toolDiffContext: MUTED_COLOR,
145
172
  syntaxComment: "#6A9955",
146
173
  syntaxKeyword: "#569CD6",
147
174
  syntaxFunction: "#DCDCAA",
@@ -160,6 +187,7 @@ export function buildThemeBgColors(accentHex: string): Record<string, string> {
160
187
  return {
161
188
  selectedBg: "#3a3a4a",
162
189
  userMessageBg: userMsgBg,
190
+ subagentBg: blendToHex(accentHex, PAGE_BG, 0.09),
163
191
  customMessageBg: "#2d2838",
164
192
  toolPendingBg: "#282832",
165
193
  toolSuccessBg: "#283228",