@oh-my-pi/pi-coding-agent 15.11.7 → 15.12.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 (107) hide show
  1. package/CHANGELOG.md +63 -1
  2. package/dist/cli.js +8106 -7708
  3. package/dist/types/cli/args.d.ts +2 -0
  4. package/dist/types/collab/crypto.d.ts +7 -0
  5. package/dist/types/collab/guest.d.ts +23 -0
  6. package/dist/types/collab/host.d.ts +29 -0
  7. package/dist/types/collab/protocol.d.ts +113 -0
  8. package/dist/types/collab/relay-client.d.ts +22 -0
  9. package/dist/types/commands/join.d.ts +12 -0
  10. package/dist/types/config/settings-schema.d.ts +60 -5
  11. package/dist/types/export/custom-share.d.ts +1 -2
  12. package/dist/types/export/html/index.d.ts +39 -1
  13. package/dist/types/export/share.d.ts +43 -0
  14. package/dist/types/extensibility/slash-commands.d.ts +1 -11
  15. package/dist/types/main.d.ts +2 -0
  16. package/dist/types/modes/components/agent-hub.d.ts +32 -1
  17. package/dist/types/modes/components/collab-prompt-message.d.ts +10 -0
  18. package/dist/types/modes/components/hook-selector.d.ts +4 -6
  19. package/dist/types/modes/components/segment-track.d.ts +11 -6
  20. package/dist/types/modes/components/status-line/component.d.ts +10 -2
  21. package/dist/types/modes/components/status-line/types.d.ts +11 -0
  22. package/dist/types/modes/controllers/event-controller.d.ts +7 -0
  23. package/dist/types/modes/controllers/input-controller.d.ts +1 -1
  24. package/dist/types/modes/controllers/session-focus-controller.d.ts +31 -0
  25. package/dist/types/modes/interactive-mode.d.ts +16 -0
  26. package/dist/types/modes/session-observer-registry.d.ts +7 -0
  27. package/dist/types/modes/theme/theme.d.ts +2 -1
  28. package/dist/types/modes/types.d.ts +20 -0
  29. package/dist/types/session/agent-session.d.ts +13 -0
  30. package/dist/types/session/codex-auto-reset.d.ts +8 -4
  31. package/dist/types/session/session-manager.d.ts +21 -0
  32. package/dist/types/session/snapcompact-inline.d.ts +6 -3
  33. package/dist/types/slash-commands/builtin-registry.d.ts +9 -0
  34. package/dist/types/task/executor.d.ts +7 -0
  35. package/dist/types/task/types.d.ts +9 -0
  36. package/package.json +14 -13
  37. package/scripts/bench-guard.ts +71 -0
  38. package/scripts/build-binary.ts +4 -0
  39. package/scripts/bundle-dist.ts +4 -0
  40. package/scripts/generate-share-viewer.ts +34 -0
  41. package/src/cli/args.ts +2 -0
  42. package/src/cli-commands.ts +1 -0
  43. package/src/collab/crypto.ts +63 -0
  44. package/src/collab/guest.ts +450 -0
  45. package/src/collab/host.ts +556 -0
  46. package/src/collab/protocol.ts +232 -0
  47. package/src/collab/relay-client.ts +216 -0
  48. package/src/commands/join.ts +39 -0
  49. package/src/config/model-registry.ts +22 -14
  50. package/src/config/settings-schema.ts +67 -5
  51. package/src/config/settings.ts +12 -0
  52. package/src/export/custom-share.ts +1 -1
  53. package/src/export/html/index.ts +122 -17
  54. package/src/export/html/share-loader.js +102 -0
  55. package/src/export/html/template.css +745 -459
  56. package/src/export/html/template.html +6 -3
  57. package/src/export/html/template.js +240 -915
  58. package/src/export/html/tool-views.generated.js +38 -0
  59. package/src/export/share.ts +268 -0
  60. package/src/extensibility/slash-commands.ts +1 -97
  61. package/src/internal-urls/docs-index.generated.ts +74 -73
  62. package/src/main.ts +33 -11
  63. package/src/modes/components/agent-hub.ts +659 -431
  64. package/src/modes/components/assistant-message.ts +126 -6
  65. package/src/modes/components/collab-prompt-message.ts +30 -0
  66. package/src/modes/components/hook-selector.ts +4 -5
  67. package/src/modes/components/segment-track.ts +44 -7
  68. package/src/modes/components/status-line/component.ts +59 -6
  69. package/src/modes/components/status-line/presets.ts +1 -1
  70. package/src/modes/components/status-line/segments.ts +18 -1
  71. package/src/modes/components/status-line/types.ts +12 -0
  72. package/src/modes/components/tips.txt +4 -1
  73. package/src/modes/controllers/command-controller.ts +55 -96
  74. package/src/modes/controllers/event-controller.ts +45 -16
  75. package/src/modes/controllers/input-controller.ts +175 -9
  76. package/src/modes/controllers/selector-controller.ts +13 -15
  77. package/src/modes/controllers/session-focus-controller.ts +112 -0
  78. package/src/modes/controllers/streaming-reveal.ts +7 -0
  79. package/src/modes/interactive-mode.ts +56 -6
  80. package/src/modes/session-observer-registry.ts +11 -0
  81. package/src/modes/theme/theme.ts +6 -0
  82. package/src/modes/types.ts +20 -0
  83. package/src/modes/utils/ui-helpers.ts +23 -13
  84. package/src/prompts/tools/job.md +1 -1
  85. package/src/sdk.ts +239 -36
  86. package/src/session/agent-session.ts +82 -7
  87. package/src/session/codex-auto-reset.ts +23 -11
  88. package/src/session/session-manager.ts +44 -0
  89. package/src/session/snapcompact-inline.ts +9 -3
  90. package/src/slash-commands/builtin-registry.ts +261 -24
  91. package/src/task/executor.ts +14 -0
  92. package/src/task/index.ts +5 -1
  93. package/src/task/render.ts +76 -5
  94. package/src/task/types.ts +9 -0
  95. package/src/tiny/worker.ts +17 -95
  96. package/src/tools/job.ts +6 -9
  97. package/src/tools/read.ts +38 -5
  98. package/src/tools/write.ts +13 -42
  99. package/dist/tokenizers.linux-x64-gnu-xcjh3jwk.node +0 -0
  100. package/dist/types/export/html/template.generated.d.ts +0 -1
  101. package/dist/types/export/html/template.macro.d.ts +0 -5
  102. package/dist/types/tiny/compiled-runtime.d.ts +0 -35
  103. package/scripts/generate-template.ts +0 -33
  104. package/src/bun-imports.d.ts +0 -28
  105. package/src/export/html/template.generated.ts +0 -2
  106. package/src/export/html/template.macro.ts +0 -25
  107. package/src/tiny/compiled-runtime.ts +0 -179
@@ -49,6 +49,11 @@ export class AssistantMessageComponent extends Container {
49
49
  /** Whether the last updateContent carried an in-flight streaming partial; such
50
50
  * renders bypass the markdown module LRU (see Markdown.transientRenderCache). */
51
51
  #lastUpdateTransient = false;
52
+ // Fast-path state: reuse Markdown children when message shape is stable during streaming.
53
+ #fastPathKey: string | undefined;
54
+ #fastPathItems:
55
+ | Array<{ md: Markdown; contentIndex: number; blockType: "text" | "thinking"; lastText: string }>
56
+ | undefined;
52
57
 
53
58
  constructor(
54
59
  message?: AssistantMessage,
@@ -71,6 +76,12 @@ export class AssistantMessageComponent extends Container {
71
76
 
72
77
  override invalidate(): void {
73
78
  super.invalidate();
79
+ // Theme/symbol changes arrive via invalidate(). Fast-path children captured
80
+ // getMarkdownTheme() at construction, so drop them and force the teardown
81
+ // path to rebuild with the current theme. Streaming updates call
82
+ // updateContent() directly and keep the fast path.
83
+ this.#fastPathKey = undefined;
84
+ this.#fastPathItems = undefined;
74
85
  if (this.#lastMessage) {
75
86
  this.updateContent(this.#lastMessage, { transient: this.#lastUpdateTransient });
76
87
  }
@@ -228,14 +239,111 @@ export class AssistantMessageComponent extends Container {
228
239
  }
229
240
  }
230
241
 
242
+ #computeShapeKey(message: AssistantMessage): string {
243
+ const parts: string[] = [`htb:${this.hideThinkingBlock ? 1 : 0}`];
244
+ for (const content of message.content) {
245
+ if (content.type === "text") {
246
+ parts.push(content.text.trim() ? "T1" : "T0");
247
+ } else if (content.type === "thinking") {
248
+ if (!content.thinking.trim()) parts.push("K0");
249
+ else if (this.hideThinkingBlock) parts.push("KH");
250
+ else parts.push("KV");
251
+ } else {
252
+ // Non-rendered blocks (toolCall, redactedThinking, …) still occupy a
253
+ // content index. Encode their position so an inserted/removed one shifts
254
+ // the key and forces the teardown path instead of mis-indexing children.
255
+ parts.push(`O:${content.type}`);
256
+ }
257
+ }
258
+ if (settings.get("display.showTokenUsage") && this.#usageInfo) {
259
+ const u = this.#usageInfo;
260
+ parts.push(`u:${u.input + u.cacheWrite}:${u.output}:${u.cacheRead}`);
261
+ } else {
262
+ parts.push("u:");
263
+ }
264
+ return parts.join("|");
265
+ }
266
+
267
+ #canFastPath(message: AssistantMessage): boolean {
268
+ for (const content of message.content) {
269
+ if (content.type === "toolCall") return false;
270
+ }
271
+ if (this.#toolImagesByCallId.size > 0) return false;
272
+ if (message.stopReason === "aborted" && shouldRenderAbortReason(message.errorMessage)) return false;
273
+ if (message.stopReason === "error" && !this.#errorPinned) return false;
274
+ if (
275
+ message.errorMessage &&
276
+ shouldRenderAbortReason(message.errorMessage) &&
277
+ message.stopReason !== "aborted" &&
278
+ message.stopReason !== "error"
279
+ )
280
+ return false;
281
+ // Extension stability: if thinking renderers exist and any tracked thinking
282
+ // block's text changed, extensions may produce a different child count.
283
+ if (this.thinkingRenderers.length > 0 && this.#fastPathItems) {
284
+ for (const item of this.#fastPathItems) {
285
+ if (item.blockType === "thinking") {
286
+ const content = message.content[item.contentIndex];
287
+ if (content?.type === "thinking" && content.thinking.trim() !== item.lastText) return false;
288
+ }
289
+ }
290
+ }
291
+ return true;
292
+ }
293
+
294
+ #tryFastPathUpdate(message: AssistantMessage, opts?: { transient?: boolean }): boolean {
295
+ if (!this.#fastPathKey || !this.#fastPathItems) return false;
296
+ if (!this.#canFastPath(message)) {
297
+ this.#fastPathKey = undefined;
298
+ this.#fastPathItems = undefined;
299
+ return false;
300
+ }
301
+ if (this.#computeShapeKey(message) !== this.#fastPathKey) {
302
+ this.#fastPathKey = undefined;
303
+ this.#fastPathItems = undefined;
304
+ return false;
305
+ }
306
+ const transient = opts?.transient === true;
307
+ // Shape is identical — setText only on Markdown children whose source changed.
308
+ for (const item of this.#fastPathItems) {
309
+ item.md.transientRenderCache = transient;
310
+ const content = message.content[item.contentIndex];
311
+ let newText: string;
312
+ if (item.blockType === "text" && content?.type === "text") {
313
+ newText = content.text.trim();
314
+ } else if (item.blockType === "thinking" && content?.type === "thinking") {
315
+ newText = content.thinking.trim();
316
+ } else {
317
+ // Block at this index is gone or changed type (index shift) — fail closed.
318
+ this.#fastPathKey = undefined;
319
+ this.#fastPathItems = undefined;
320
+ return false;
321
+ }
322
+ if (newText !== item.lastText) {
323
+ item.md.setText(newText);
324
+ item.lastText = newText;
325
+ }
326
+ }
327
+ return true;
328
+ }
329
+
231
330
  updateContent(message: AssistantMessage, opts?: { transient?: boolean }): void {
232
331
  this.#blockVersion++;
233
332
  this.#lastMessage = message;
234
333
  this.#lastUpdateTransient = opts?.transient === true;
235
334
 
335
+ // Fast path: reuse Markdown children when shape is stable during streaming
336
+ if (this.#tryFastPathUpdate(message)) return;
337
+
236
338
  // Clear content container
237
339
  this.#contentContainer.clear();
238
340
 
341
+ // Determine if we should capture Markdown instances for next fast path
342
+ const shouldCapture = this.#canFastPath(message);
343
+ const captureItems:
344
+ | Array<{ md: Markdown; contentIndex: number; blockType: "text" | "thinking"; lastText: string }>
345
+ | undefined = shouldCapture ? [] : undefined;
346
+
239
347
  const hasVisibleContent = message.content.some(
240
348
  c =>
241
349
  (c.type === "text" && c.text.trim()) ||
@@ -249,9 +357,11 @@ export class AssistantMessageComponent extends Container {
249
357
  if (content.type === "text" && content.text.trim()) {
250
358
  // Assistant text messages with no background - trim the text
251
359
  // Set paddingY=0 to avoid extra spacing before tool executions
252
- const markdown = new Markdown(content.text.trim(), 1, 0, getMarkdownTheme());
253
- markdown.transientRenderCache = this.#lastUpdateTransient;
254
- this.#contentContainer.addChild(markdown);
360
+ const trimmed = content.text.trim();
361
+ const md = new Markdown(trimmed, 1, 0, getMarkdownTheme());
362
+ md.transientRenderCache = this.#lastUpdateTransient;
363
+ this.#contentContainer.addChild(md);
364
+ captureItems?.push({ md, contentIndex: i, blockType: "text", lastText: trimmed });
255
365
  } else if (content.type === "thinking" && content.thinking.trim()) {
256
366
  if (this.hideThinkingBlock) {
257
367
  thinkingIndex += 1;
@@ -265,12 +375,13 @@ export class AssistantMessageComponent extends Container {
265
375
 
266
376
  const thinkingText = content.thinking.trim();
267
377
  // Thinking traces in thinkingText color, italic
268
- const thinkingMarkdown = new Markdown(thinkingText, 1, 0, getMarkdownTheme(), {
378
+ const md = new Markdown(thinkingText, 1, 0, getMarkdownTheme(), {
269
379
  color: (text: string) => theme.fg("thinkingText", text),
270
380
  italic: true,
271
381
  });
272
- thinkingMarkdown.transientRenderCache = this.#lastUpdateTransient;
273
- this.#contentContainer.addChild(thinkingMarkdown);
382
+ md.transientRenderCache = this.#lastUpdateTransient;
383
+ this.#contentContainer.addChild(md);
384
+ captureItems?.push({ md, contentIndex: i, blockType: "thinking", lastText: thinkingText });
274
385
  this.#appendThinkingExtensions(i, thinkingIndex, thinkingText);
275
386
  thinkingIndex += 1;
276
387
  if (hasVisibleContentAfter) {
@@ -318,5 +429,14 @@ export class AssistantMessageComponent extends Container {
318
429
  this.#contentContainer.addChild(new Spacer(1));
319
430
  this.#contentContainer.addChild(new Text(theme.fg("dim", parts.join(" ")), 1, 0));
320
431
  }
432
+
433
+ // Store fast-path state for next call
434
+ if (shouldCapture) {
435
+ this.#fastPathItems = captureItems;
436
+ this.#fastPathKey = this.#computeShapeKey(message);
437
+ } else {
438
+ this.#fastPathKey = undefined;
439
+ this.#fastPathItems = undefined;
440
+ }
321
441
  }
322
442
  }
@@ -0,0 +1,30 @@
1
+ import type { TextContent } from "@oh-my-pi/pi-ai";
2
+ import { Container, Markdown, Text } from "@oh-my-pi/pi-tui";
3
+ import type { CollabPromptDetails } from "../../collab/protocol";
4
+ import type { CustomMessage } from "../../session/messages";
5
+ import { getMarkdownTheme, theme } from "../theme/theme";
6
+
7
+ /**
8
+ * Renders a collab guest prompt on every participant's transcript: a
9
+ * user-message-styled bubble prefixed with the author's name.
10
+ */
11
+ export class CollabPromptMessageComponent extends Container {
12
+ constructor(message: CustomMessage<CollabPromptDetails>) {
13
+ super();
14
+ const from = message.details?.from?.trim() || "guest";
15
+ this.addChild(new Text(theme.fg("accent", `\x1b[1m«${from}»\x1b[22m ›`), 1, 0));
16
+ const text =
17
+ typeof message.content === "string"
18
+ ? message.content
19
+ : message.content
20
+ .filter((content): content is TextContent => content.type === "text")
21
+ .map(content => content.text)
22
+ .join("");
23
+ this.addChild(
24
+ new Markdown(text, 1, 1, getMarkdownTheme(), {
25
+ bgColor: (value: string) => theme.bg("userMessageBg", value),
26
+ color: (value: string) => theme.fg("userMessageText", value),
27
+ }),
28
+ );
29
+ }
30
+ }
@@ -31,13 +31,12 @@ import { CountdownTimer } from "./countdown-timer";
31
31
  import { DynamicBorder } from "./dynamic-border";
32
32
  import { renderSegmentTrack } from "./segment-track";
33
33
 
34
- /** One segment of a {@link HookSelectorSlider} — a label, its accent color, and
35
- * an optional detail line (e.g. the resolved model name) shown beneath the
36
- * track while the segment is active. */
34
+ /** One segment of a {@link HookSelectorSlider} — a label and an optional
35
+ * detail line (e.g. the resolved model name) shown beneath the track while
36
+ * the segment is active. Segment colors come from the track's theme palette,
37
+ * assigned by position. */
37
38
  export interface HookSelectorSliderSegment {
38
39
  label: string;
39
- /** Theme color for the segment label; defaults to `accent`. */
40
- color?: ThemeColor;
41
40
  /** Secondary line rendered under the track when this segment is selected. */
42
41
  detail?: string;
43
42
  }
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Shared renderer for a horizontal row of colored "segments" styled after the
3
- * status line: each segment shows in its own accent, the active one is filled
4
- * as a powerline chip (its accent as the background, a luminance-matched label,
5
- * flanked by triangle caps) and the rest are plain colored labels joined by a
6
- * thin separator.
3
+ * status line: each segment is colored by its track position from the theme's
4
+ * own palette, the active one is filled as a powerline chip (its color as the
5
+ * background, a luminance-matched label, flanked by triangle caps) and the
6
+ * rest are plain colored labels joined by a thin separator.
7
7
  *
8
8
  * Used by the plan-mode model-tier slider ({@link HookSelectorComponent}) and
9
9
  * the ctrl+p role-cycle status so both surfaces read identically.
@@ -12,13 +12,49 @@ import { type ThemeColor, theme } from "../theme/theme";
12
12
 
13
13
  export interface TrackSegment {
14
14
  label: string;
15
- /** Theme color for the segment; defaults to `accent`. */
16
- color?: ThemeColor;
17
15
  }
18
16
 
19
17
  const FG_RESET = "\x1b[39m";
20
18
  const BG_RESET = "\x1b[49m";
21
19
 
20
+ /** Vivid theme colors for position-based segment coloring, in preference
21
+ * order. Themes alias many of these to the same value (titanium maps most of
22
+ * the syntax set onto its accent), so {@link resolveSegmentPalette} dedupes
23
+ * by resolved escape and hands position i the i-th distinct color. */
24
+ const SEGMENT_COLOR_CANDIDATES: ThemeColor[] = [
25
+ "accent",
26
+ "success",
27
+ "warning",
28
+ "error",
29
+ "mdCode",
30
+ "mdLink",
31
+ "syntaxString",
32
+ "syntaxKeyword",
33
+ "syntaxFunction",
34
+ "syntaxNumber",
35
+ "syntaxOperator",
36
+ "syntaxVariable",
37
+ ];
38
+
39
+ /**
40
+ * Resolve up to `count` theme colors that render distinctly under the active
41
+ * theme, in candidate preference order. May return fewer than `count` when the
42
+ * theme has fewer distinct hues (e.g. monochrome themes) — callers wrap with
43
+ * modulo. Never returns an empty array: `accent` always resolves.
44
+ */
45
+ export function resolveSegmentPalette(count: number): ThemeColor[] {
46
+ const palette: ThemeColor[] = [];
47
+ const seen = new Set<string>();
48
+ for (const color of SEGMENT_COLOR_CANDIDATES) {
49
+ const ansi = theme.getFgAnsi(color);
50
+ if (seen.has(ansi)) continue;
51
+ seen.add(ansi);
52
+ palette.push(color);
53
+ if (palette.length >= count) break;
54
+ }
55
+ return palette;
56
+ }
57
+
22
58
  /**
23
59
  * Render `segments` as a colored chip track with `activeIndex` filled. Returns
24
60
  * a single line of styled text with no surrounding caption or arrows — callers
@@ -30,6 +66,7 @@ export function renderSegmentTrack(segments: TrackSegment[], activeIndex: number
30
66
  const capLeft = theme.sep.powerlineRight;
31
67
  const capRight = theme.sep.powerlineLeft;
32
68
  const thinSep = theme.fg("statusLineSep", theme.sep.powerlineThin);
69
+ const palette = resolveSegmentPalette(segments.length);
33
70
 
34
71
  let track = "";
35
72
  segments.forEach((segment, i) => {
@@ -38,7 +75,7 @@ export function renderSegmentTrack(segments: TrackSegment[], activeIndex: number
38
75
  // caps already delimit the active segment, so pad around it instead.
39
76
  track += i === activeIndex || i - 1 === activeIndex ? " " : ` ${thinSep} `;
40
77
  }
41
- const color = segment.color ?? "accent";
78
+ const color = palette[i % palette.length];
42
79
  const fg = theme.getFgAnsi(color);
43
80
  if (i !== activeIndex) {
44
81
  track += `${fg}${segment.label}${FG_RESET}`;
@@ -18,6 +18,7 @@ import { renderSegment, type SegmentContext } from "./segments";
18
18
  import { getSeparator } from "./separators";
19
19
  import { calculateTokensPerSecond } from "./token-rate";
20
20
  import type {
21
+ CollabStatus,
21
22
  EffectiveStatusLineSettings,
22
23
  StatusLineSegmentId,
23
24
  StatusLineSegmentOptions,
@@ -152,6 +153,8 @@ export class StatusLineComponent implements Component {
152
153
  #planModeStatus: { enabled: boolean; paused: boolean } | null = null;
153
154
  #loopModeStatus: { enabled: boolean } | null = null;
154
155
  #goalModeStatus: { enabled: boolean; paused: boolean } | null = null;
156
+ #collabStatus: CollabStatus | null = null;
157
+ #focusedAgentId: string | undefined;
155
158
 
156
159
  // Git status caching (1s TTL)
157
160
  #cachedGitStatus: { staged: number; unstaged: number; untracked: number } | null = null;
@@ -187,7 +190,7 @@ export class StatusLineComponent implements Component {
187
190
  #nonMessageInputsKey: string | undefined;
188
191
  #messageTokenTotalsCache: MessageTokenTotalsCache | undefined;
189
192
 
190
- constructor(private readonly session: AgentSession) {
193
+ constructor(private session: AgentSession) {
191
194
  this.#settings = {
192
195
  preset: settings.get("statusLine.preset"),
193
196
  leftSegments: settings.get("statusLine.leftSegments"),
@@ -200,6 +203,19 @@ export class StatusLineComponent implements Component {
200
203
  };
201
204
  }
202
205
 
206
+ /**
207
+ * Re-point the status line at another session (focus proxy). Invalidate: model/context/usage all derive
208
+ * from it. `focusedAgentId` is the focused subagent id while the view is proxied, undefined for main.
209
+ */
210
+ setSession(session: AgentSession, focusedAgentId?: string): void {
211
+ const sessionChanged = this.session !== session;
212
+ if (!sessionChanged && this.#focusedAgentId === focusedAgentId) return;
213
+ this.session = session;
214
+ this.#focusedAgentId = focusedAgentId;
215
+ if (sessionChanged) this.#invalidateSessionCaches();
216
+ this.invalidate();
217
+ }
218
+
203
219
  updateSettings(settings: StatusLineSettings): void {
204
220
  this.#settings = settings;
205
221
  this.#effectiveSettings = undefined;
@@ -217,6 +233,11 @@ export class StatusLineComponent implements Component {
217
233
  this.#subagentCount = count;
218
234
  }
219
235
 
236
+ /** Active subagent count as currently displayed (collab state mirroring). */
237
+ get subagentCount(): number {
238
+ return this.#subagentCount;
239
+ }
240
+
220
241
  setSessionStartTime(time: number): void {
221
242
  this.#sessionStartTime = time;
222
243
  }
@@ -233,6 +254,10 @@ export class StatusLineComponent implements Component {
233
254
  this.#goalModeStatus = status ?? null;
234
255
  }
235
256
 
257
+ setCollabStatus(status: CollabStatus | null): void {
258
+ this.#collabStatus = status;
259
+ }
260
+
236
261
  setHookStatus(key: string, text: string | undefined): void {
237
262
  if (text === undefined) {
238
263
  this.#hookStatuses.delete(key);
@@ -281,6 +306,16 @@ export class StatusLineComponent implements Component {
281
306
  invalidate(): void {
282
307
  this.#invalidateGitCaches();
283
308
  }
309
+ #invalidateSessionCaches(): void {
310
+ this.#cachedUsage = null;
311
+ this.#usageFetchedAt = 0;
312
+ this.#usageInFlight = false;
313
+ this.#nonMessageTokensCache = undefined;
314
+ this.#nonMessageInputsKey = undefined;
315
+ this.#messageTokenTotalsCache = undefined;
316
+ this.#lastTokensPerSecond = null;
317
+ this.#lastTokensPerSecondTimestamp = null;
318
+ }
284
319
 
285
320
  #invalidateGitCaches(): void {
286
321
  this.#cachedBranch = undefined;
@@ -441,16 +476,19 @@ export class StatusLineComponent implements Component {
441
476
  const now = Date.now();
442
477
  if (this.#usageInFlight) return;
443
478
  if (this.#usageFetchedAt > 0 && now - this.#usageFetchedAt < 5 * 60_000) return;
444
- const fetcher = (this.session as { fetchUsageReports?: () => Promise<unknown> }).fetchUsageReports;
479
+ const session = this.session;
480
+ const fetcher = (session as { fetchUsageReports?: () => Promise<unknown> }).fetchUsageReports;
445
481
  if (typeof fetcher !== "function") return;
446
482
  this.#usageInFlight = true;
447
483
  void fetcher
448
- .call(this.session)
484
+ .call(session)
449
485
  .then(reports => {
486
+ if (this.session !== session) return;
450
487
  this.#cachedUsage = this.#normalizeUsageReports(reports);
451
488
  this.#usageFetchedAt = Date.now();
452
489
  })
453
490
  .catch(() => {
491
+ if (this.session !== session) return;
454
492
  // Backoff on error: stamp the fetch time so the 5-min TTL guard
455
493
  // also acts as an error budget. Without this, every render
456
494
  // kicks off another fetch (gated only by #usageInFlight),
@@ -458,7 +496,7 @@ export class StatusLineComponent implements Component {
458
496
  this.#usageFetchedAt = Date.now();
459
497
  })
460
498
  .finally(() => {
461
- this.#usageInFlight = false;
499
+ if (this.session === session) this.#usageInFlight = false;
462
500
  });
463
501
  }
464
502
 
@@ -642,15 +680,25 @@ export class StatusLineComponent implements Component {
642
680
  contextTokens = breakdown.usedTokens;
643
681
  contextWindow = breakdown.contextWindow || contextWindow;
644
682
  }
645
- const contextPercent = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
683
+ let contextPercent = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
684
+
685
+ // Collab guest: context comes from the host's state frames — the local
686
+ // replica does no accounting of its own.
687
+ const collabState = this.#collabStatus?.stateOverride;
688
+ if (collabState?.contextUsage) {
689
+ contextWindow = collabState.contextUsage.contextWindow || contextWindow;
690
+ contextPercent = collabState.contextUsage.percent ?? contextPercent;
691
+ }
646
692
 
647
693
  return {
648
694
  session: this.session,
695
+ focusedAgentId: this.#focusedAgentId,
649
696
  width,
650
697
  options: segmentOptions ?? {},
651
698
  planMode: this.#planModeStatus,
652
699
  loopMode: this.#loopModeStatus,
653
700
  goalMode: this.#goalModeStatus,
701
+ collab: this.#collabStatus,
654
702
  usageStats,
655
703
  contextPercent,
656
704
  contextWindow,
@@ -858,7 +906,12 @@ export class StatusLineComponent implements Component {
858
906
  }
859
907
 
860
908
  getTopBorder(width: number): { content: string; width: number } {
861
- const content = this.#buildStatusLine(width);
909
+ let content = this.#buildStatusLine(width);
910
+ if (this.#focusedAgentId && content) {
911
+ // Dim the whole bar while focus-proxied. Group/cap terminators emit full
912
+ // `\x1b[0m` resets that would cancel faint mid-bar, so re-open it after each.
913
+ content = `\x1b[2m${content.replaceAll("\x1b[0m", "\x1b[0m\x1b[2m")}\x1b[22m`;
914
+ }
862
915
  return {
863
916
  content,
864
917
  width: visibleWidth(content),
@@ -2,7 +2,7 @@ import type { PresetDef, StatusLinePreset } from "./types";
2
2
 
3
3
  export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
4
4
  default: {
5
- leftSegments: ["pi", "model", "mode", "path", "git", "pr", "context_pct", "cost"],
5
+ leftSegments: ["pi", "model", "mode", "collab", "path", "git", "pr", "context_pct", "cost"],
6
6
  rightSegments: ["session_name"],
7
7
  separator: "powerline-thin",
8
8
  segmentOptions: {
@@ -65,7 +65,11 @@ function classifyProjectDir(pwd: string): { scratch: boolean; relative: string |
65
65
 
66
66
  const piSegment: StatusLineSegment = {
67
67
  id: "pi",
68
- render(_ctx) {
68
+ render(ctx) {
69
+ if (ctx.focusedAgentId) {
70
+ const icon = theme.icon.ghost ? `${theme.icon.ghost} ` : "";
71
+ return { content: theme.fg("warning", `${icon}${ctx.focusedAgentId} `), visible: true };
72
+ }
69
73
  const content = theme.icon.pi ? `${theme.icon.pi} ` : "";
70
74
  return { content: theme.fg("accent", content), visible: true };
71
75
  },
@@ -493,6 +497,18 @@ const sessionNameSegment: StatusLineSegment = {
493
497
  },
494
498
  };
495
499
 
500
+ const collabSegment: StatusLineSegment = {
501
+ id: "collab",
502
+ render(ctx) {
503
+ if (!ctx.collab) return { content: "", visible: false };
504
+ const label =
505
+ ctx.collab.role === "host"
506
+ ? `⇄ collab:${ctx.collab.participantCount}`
507
+ : `⇄ collab guest:${ctx.collab.participantCount}`;
508
+ return { content: theme.fg("accent", label), visible: true };
509
+ },
510
+ };
511
+
496
512
  function pickUsageColor(percent: number): "muted" | "warning" | "error" {
497
513
  if (percent >= 80) return "error";
498
514
  if (percent >= 50) return "warning";
@@ -573,6 +589,7 @@ export const SEGMENTS: Record<StatusLineSegmentId, StatusLineSegment> = {
573
589
  cache_hit: cacheHitSegment,
574
590
  session_name: sessionNameSegment,
575
591
  usage: usageSegment,
592
+ collab: collabSegment,
576
593
  };
577
594
 
578
595
  export function renderSegment(id: StatusLineSegmentId, ctx: SegmentContext): RenderedSegment {
@@ -1,8 +1,17 @@
1
+ import type { CollabSessionState } from "../../../collab/protocol";
1
2
  import type { StatusLinePreset, StatusLineSegmentId, StatusLineSeparatorStyle } from "../../../config/settings-schema";
2
3
  import type { AgentSession } from "../../../session/agent-session";
3
4
 
4
5
  export type { StatusLinePreset, StatusLineSegmentId, StatusLineSeparatorStyle };
5
6
 
7
+ /** Collab session indicator + (guest-only) host-state override for segments. */
8
+ export interface CollabStatus {
9
+ role: "host" | "guest";
10
+ participantCount: number;
11
+ /** Guest only: host footer snapshot that overrides locally computed values. */
12
+ stateOverride?: CollabSessionState | null;
13
+ }
14
+
6
15
  export interface StatusLineSegmentOptions {
7
16
  model?: { showThinkingLevel?: boolean };
8
17
  path?: { abbreviate?: boolean; maxLength?: number; stripWorkPrefix?: boolean };
@@ -36,6 +45,8 @@ export type RGB = readonly [number, number, number];
36
45
 
37
46
  export interface SegmentContext {
38
47
  session: AgentSession;
48
+ /** Focused subagent id while the view is proxied at its session, undefined otherwise. */
49
+ focusedAgentId?: string | undefined;
39
50
  width: number;
40
51
  options: StatusLineSegmentOptions;
41
52
  planMode: {
@@ -49,6 +60,7 @@ export interface SegmentContext {
49
60
  enabled: boolean;
50
61
  paused: boolean;
51
62
  } | null;
63
+ collab: CollabStatus | null;
52
64
  // Cached values for performance (computed once per render)
53
65
  usageStats: {
54
66
  input: number;
@@ -16,4 +16,7 @@ Press alt+p (or /switch) to switch provider, and ctrl+p to cycle role models smo
16
16
  Press ctrl+r to search your prompt history and reuse a past message
17
17
  `/force read` pins the next turn to one specific tool when the model keeps reaching for the wrong one
18
18
  `/copy code` grabs the last code block to your clipboard — `/copy cmd` grabs the last shell/python command
19
- `/shake` rips heavy tool results out of context to reclaim tokens without a full /compact — `/shake images` drops just images
19
+ `/shake` rips heavy tool results out of context to reclaim tokens without a full /compact — `/shake images` drops just images
20
+ Pair up live: `/collab` shares your session through an end-to-end encrypted relay link — a teammate runs `/join <link>` to watch tool calls stream and prompt the agent from their own omp
21
+ Press ← ← to drill into a running or finished agent and inspect its tool calls and transcript
22
+ Hit a Codex rate limit? `/usage reset` spends a saved reset credit to immediately restore your quota