@oh-my-pi/pi-coding-agent 15.11.6 → 15.11.8

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 (102) hide show
  1. package/CHANGELOG.md +57 -1
  2. package/dist/cli.js +431 -381
  3. package/dist/types/cli/args.d.ts +2 -0
  4. package/dist/types/cli/bench-cli.d.ts +78 -0
  5. package/dist/types/collab/crypto.d.ts +12 -0
  6. package/dist/types/collab/guest.d.ts +21 -0
  7. package/dist/types/collab/host.d.ts +13 -0
  8. package/dist/types/collab/protocol.d.ts +100 -0
  9. package/dist/types/collab/relay-client.d.ts +22 -0
  10. package/dist/types/commands/bench.d.ts +29 -0
  11. package/dist/types/commands/join.d.ts +12 -0
  12. package/dist/types/config/model-resolver.d.ts +3 -2
  13. package/dist/types/config/settings-schema.d.ts +93 -1
  14. package/dist/types/edit/renderer.d.ts +1 -0
  15. package/dist/types/extensibility/slash-commands.d.ts +1 -11
  16. package/dist/types/modes/components/agent-hub.d.ts +13 -0
  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/oauth-selector.d.ts +10 -1
  20. package/dist/types/modes/components/segment-track.d.ts +11 -6
  21. package/dist/types/modes/components/settings-selector.d.ts +8 -1
  22. package/dist/types/modes/components/snapcompact-shape-preview.d.ts +31 -0
  23. package/dist/types/modes/components/status-line/component.d.ts +4 -1
  24. package/dist/types/modes/components/status-line/types.d.ts +9 -0
  25. package/dist/types/modes/components/tool-execution.d.ts +13 -9
  26. package/dist/types/modes/interactive-mode.d.ts +7 -0
  27. package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +3 -0
  28. package/dist/types/modes/setup-wizard/scenes/types.d.ts +10 -1
  29. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +3 -0
  30. package/dist/types/modes/types.d.ts +8 -0
  31. package/dist/types/session/agent-session.d.ts +11 -0
  32. package/dist/types/session/session-manager.d.ts +21 -0
  33. package/dist/types/session/snapcompact-inline.d.ts +8 -3
  34. package/dist/types/slash-commands/builtin-registry.d.ts +9 -0
  35. package/dist/types/tools/bash.d.ts +2 -0
  36. package/dist/types/tools/eval-render.d.ts +1 -0
  37. package/dist/types/tools/renderers.d.ts +13 -0
  38. package/dist/types/tools/ssh.d.ts +1 -0
  39. package/package.json +14 -12
  40. package/scripts/bench-guard.ts +71 -0
  41. package/src/cli/args.ts +2 -0
  42. package/src/cli/bench-cli.ts +437 -0
  43. package/src/cli-commands.ts +2 -0
  44. package/src/collab/crypto.ts +57 -0
  45. package/src/collab/guest.ts +421 -0
  46. package/src/collab/host.ts +494 -0
  47. package/src/collab/protocol.ts +191 -0
  48. package/src/collab/relay-client.ts +216 -0
  49. package/src/commands/bench.ts +42 -0
  50. package/src/commands/join.ts +39 -0
  51. package/src/config/model-registry.ts +74 -19
  52. package/src/config/model-resolver.ts +36 -5
  53. package/src/config/settings-schema.ts +119 -1
  54. package/src/edit/renderer.ts +5 -0
  55. package/src/extensibility/slash-commands.ts +1 -97
  56. package/src/hindsight/client.ts +26 -1
  57. package/src/hindsight/state.ts +6 -2
  58. package/src/internal-urls/docs-index.generated.ts +4 -3
  59. package/src/main.ts +11 -2
  60. package/src/mcp/transports/stdio.ts +81 -7
  61. package/src/modes/components/agent-hub.ts +119 -22
  62. package/src/modes/components/assistant-message.ts +126 -6
  63. package/src/modes/components/collab-prompt-message.ts +30 -0
  64. package/src/modes/components/hook-selector.ts +4 -5
  65. package/src/modes/components/oauth-selector.ts +67 -7
  66. package/src/modes/components/segment-track.ts +44 -7
  67. package/src/modes/components/settings-selector.ts +27 -0
  68. package/src/modes/components/snapcompact-shape-preview-doc.md +11 -0
  69. package/src/modes/components/snapcompact-shape-preview.ts +192 -0
  70. package/src/modes/components/status-line/component.ts +21 -1
  71. package/src/modes/components/status-line/presets.ts +1 -1
  72. package/src/modes/components/status-line/segments.ts +13 -0
  73. package/src/modes/components/status-line/types.ts +10 -0
  74. package/src/modes/components/tips.txt +2 -1
  75. package/src/modes/components/tool-execution.ts +18 -10
  76. package/src/modes/controllers/input-controller.ts +80 -12
  77. package/src/modes/controllers/selector-controller.ts +6 -2
  78. package/src/modes/controllers/streaming-reveal.ts +7 -0
  79. package/src/modes/interactive-mode.ts +36 -4
  80. package/src/modes/setup-wizard/index.ts +1 -0
  81. package/src/modes/setup-wizard/scenes/glyph.ts +24 -6
  82. package/src/modes/setup-wizard/scenes/providers.ts +36 -2
  83. package/src/modes/setup-wizard/scenes/sign-in.ts +10 -1
  84. package/src/modes/setup-wizard/scenes/theme.ts +28 -1
  85. package/src/modes/setup-wizard/scenes/types.ts +10 -1
  86. package/src/modes/setup-wizard/scenes/web-search.ts +22 -6
  87. package/src/modes/setup-wizard/wizard-overlay.ts +38 -1
  88. package/src/modes/types.ts +8 -0
  89. package/src/modes/utils/context-usage.ts +1 -1
  90. package/src/modes/utils/ui-helpers.ts +7 -0
  91. package/src/prompts/bench.md +7 -0
  92. package/src/sdk.ts +240 -36
  93. package/src/session/agent-session.ts +22 -0
  94. package/src/session/session-manager.ts +44 -0
  95. package/src/session/snapcompact-inline.ts +20 -22
  96. package/src/slash-commands/builtin-registry.ts +210 -0
  97. package/src/tools/bash.ts +3 -0
  98. package/src/tools/eval-render.ts +4 -0
  99. package/src/tools/read.ts +38 -5
  100. package/src/tools/renderers.ts +13 -0
  101. package/src/tools/ssh.ts +3 -0
  102. package/src/tools/write.ts +13 -42
@@ -6,6 +6,7 @@ import {
6
6
  fuzzyFilter,
7
7
  matchesKey,
8
8
  ScrollView,
9
+ type SgrMouseEvent,
9
10
  Spacer,
10
11
  TruncatedText,
11
12
  } from "@oh-my-pi/pi-tui";
@@ -16,6 +17,12 @@ import { DynamicBorder } from "./dynamic-border";
16
17
 
17
18
  const OAUTH_SELECTOR_MAX_VISIBLE = 10;
18
19
 
20
+ /**
21
+ * Rendered lines before the provider rows: top border, spacer, title, spacer
22
+ * (must mirror the constructor's addChild order).
23
+ */
24
+ const LIST_ROW_OFFSET = 4;
25
+
19
26
  /** Compact, human-readable tag for each credential-origin leg. */
20
27
  const ORIGIN_LABELS: Record<CredentialOriginKind, string> = {
21
28
  runtime: "--api-key",
@@ -34,6 +41,10 @@ export class OAuthSelectorComponent extends Container {
34
41
  #filteredProviders: OAuthProviderInfo[] = [];
35
42
  #searchQuery = "";
36
43
  #selectedIndex: number = 0;
44
+ #hoveredIndex: number | null = null;
45
+ /** First provider index of the visible ScrollView window (last #updateList). */
46
+ #scrollStart = 0;
47
+ #visibleCount = 0;
37
48
  #mode: "login" | "logout";
38
49
  #authStorage: AuthStorage;
39
50
  #onSelectCallback: (providerId: string) => void;
@@ -252,6 +263,8 @@ export class OAuthSelectorComponent extends Container {
252
263
  ? 0
253
264
  : Math.max(0, Math.min(this.#selectedIndex - Math.floor(maxVisible / 2), total - maxVisible));
254
265
  const endIndex = Math.min(startIndex + maxVisible, total);
266
+ this.#scrollStart = startIndex;
267
+ this.#visibleCount = endIndex - startIndex;
255
268
 
256
269
  const rows: string[] = [];
257
270
  for (let i = startIndex; i < endIndex; i++) {
@@ -270,6 +283,9 @@ export class OAuthSelectorComponent extends Container {
270
283
  const text = isAvailable ? ` ${provider.name}` : theme.fg("dim", ` ${provider.name}`);
271
284
  line = text + statusIndicator;
272
285
  }
286
+ if (!isSelected && i === this.#hoveredIndex) {
287
+ line = theme.bg("selectedBg", line);
288
+ }
273
289
  rows.push(line);
274
290
  }
275
291
 
@@ -354,15 +370,59 @@ export class OAuthSelectorComponent extends Container {
354
370
  }
355
371
  // Enter
356
372
  else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
357
- const selectedProvider = this.#filteredProviders[this.#selectedIndex];
358
- if (selectedProvider?.available) {
359
- this.#statusMessage = undefined;
360
- this.stopValidation();
361
- this.#onSelectCallback(selectedProvider.id);
362
- } else if (selectedProvider) {
363
- this.#statusMessage = "Provider unavailable in this environment.";
373
+ this.#confirmSelection();
374
+ }
375
+ }
376
+
377
+ /** Confirm the selected provider (Enter or mouse click). */
378
+ #confirmSelection(): void {
379
+ const selectedProvider = this.#filteredProviders[this.#selectedIndex];
380
+ if (selectedProvider?.available) {
381
+ this.#statusMessage = undefined;
382
+ this.stopValidation();
383
+ this.#onSelectCallback(selectedProvider.id);
384
+ } else if (selectedProvider) {
385
+ this.#statusMessage = "Provider unavailable in this environment.";
386
+ this.#updateList();
387
+ }
388
+ }
389
+
390
+ /** Move the selection one step for a wheel notch (clamped, no wrap). */
391
+ handleWheel(delta: -1 | 1): void {
392
+ if (this.#filteredProviders.length === 0) return;
393
+ const next = Math.max(0, Math.min(this.#selectedIndex + delta, this.#filteredProviders.length - 1));
394
+ if (next === this.#selectedIndex) return;
395
+ this.#selectedIndex = next;
396
+ this.#statusMessage = undefined;
397
+ this.#updateList();
398
+ }
399
+
400
+ /**
401
+ * Route an SGR mouse report at component-local coordinates. Provider rows
402
+ * start LIST_ROW_OFFSET lines into the render; the ScrollView window shows
403
+ * #visibleCount rows from #scrollStart. Wheel moves the selection, motion
404
+ * drives the hover band, and a left click selects and confirms like Enter.
405
+ */
406
+ routeMouse(event: SgrMouseEvent, line: number, _col: number): void {
407
+ if (event.wheel !== null) {
408
+ this.handleWheel(event.wheel);
409
+ return;
410
+ }
411
+ const localRow = line - LIST_ROW_OFFSET;
412
+ const index = localRow >= 0 && localRow < this.#visibleCount ? this.#scrollStart + localRow : undefined;
413
+ const target = index !== undefined && index < this.#filteredProviders.length ? index : null;
414
+ if (event.motion) {
415
+ if (target !== this.#hoveredIndex) {
416
+ this.#hoveredIndex = target;
364
417
  this.#updateList();
365
418
  }
419
+ return;
420
+ }
421
+ if (!event.leftClick || target === null) return;
422
+ if (target !== this.#selectedIndex) {
423
+ this.#selectedIndex = target;
424
+ this.#statusMessage = undefined;
366
425
  }
426
+ this.#confirmSelection();
367
427
  }
368
428
  }
@@ -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}`;
@@ -7,6 +7,7 @@ import {
7
7
  fuzzyRank,
8
8
  getKeybindings,
9
9
  getSettingItemFilterText,
10
+ type ImageBudget,
10
11
  Input,
11
12
  matchesKey,
12
13
  parseSgrMouse,
@@ -22,6 +23,7 @@ import {
22
23
  truncateToWidth,
23
24
  visibleWidth,
24
25
  } from "@oh-my-pi/pi-tui";
26
+ import type { ShapeTarget } from "@oh-my-pi/snapcompact";
25
27
  import { getDefault, type SettingPath, settings } from "../../config/settings";
26
28
  import type {
27
29
  SettingTab,
@@ -36,6 +38,7 @@ import { getTabBarTheme } from "../shared";
36
38
  import { bottomBorder, divider, row, topBorder } from "./overlay-box";
37
39
  import { handleInputOrEscape, PluginSettingsComponent } from "./plugin-settings";
38
40
  import { getSettingDef, getSettingsForTab, type SettingDef } from "./settings-defs";
41
+ import { SnapcompactShapePreview } from "./snapcompact-shape-preview";
39
42
  import { getPreset } from "./status-line/presets";
40
43
 
41
44
  /**
@@ -97,6 +100,7 @@ class SelectSubmenu extends Container {
97
100
  onCancel: () => void,
98
101
  onSelectionChange?: (value: string) => void | Promise<void>,
99
102
  private readonly getPreview?: () => string,
103
+ footer?: Component,
100
104
  ) {
101
105
  super();
102
106
 
@@ -158,6 +162,13 @@ class SelectSubmenu extends Container {
158
162
  // Hint
159
163
  this.addChild(new Spacer(1));
160
164
  this.addChild(new Text(theme.fg("dim", " Enter to select · Esc to go back"), 0, 0));
165
+
166
+ // Footer (e.g. the snapcompact shape preview) below the interactive rows,
167
+ // so the list never shifts while browsing.
168
+ if (footer) {
169
+ this.addChild(new Spacer(1));
170
+ this.addChild(footer);
171
+ }
161
172
  }
162
173
 
163
174
  #updatePreview(): void {
@@ -249,6 +260,12 @@ export interface SettingsRuntimeContext {
249
260
  availableThemes: string[];
250
261
  /** Working directory for plugins tab */
251
262
  cwd: string;
263
+ /** Active model (api + id); resolves what the snapcompact `auto` shape maps to. */
264
+ model?: ShapeTarget;
265
+ /** Shared TUI image budget (graphics ids + transmit-once) for image previews. */
266
+ imageBudget?: ImageBudget;
267
+ /** Schedules a re-render after async preview work completes. */
268
+ requestRender?: () => void;
252
269
  }
253
270
 
254
271
  /** Status line settings subset for preview */
@@ -758,6 +775,7 @@ export class SettingsSelectorComponent implements Component {
758
775
  // Preview handlers
759
776
  let onPreview: ((value: string) => void | Promise<void>) | undefined;
760
777
  let onPreviewCancel: (() => void) | undefined;
778
+ let footer: Component | undefined;
761
779
 
762
780
  const activeThemeBeforePreview = getCurrentThemeName() ?? currentValue;
763
781
  if (def.path === "theme.dark" || def.path === "theme.light") {
@@ -797,6 +815,14 @@ export class SettingsSelectorComponent implements Component {
797
815
  const separator = settings.get("statusLine.separator");
798
816
  this.callbacks.onStatusLinePreview?.({ separator });
799
817
  };
818
+ } else if (def.path === "snapcompact.shape") {
819
+ const shapePreview = new SnapcompactShapePreview(currentValue, {
820
+ model: this.context.model,
821
+ imageBudget: this.context.imageBudget,
822
+ requestRender: this.context.requestRender,
823
+ });
824
+ onPreview = value => shapePreview.setValue(value);
825
+ footer = shapePreview;
800
826
  }
801
827
 
802
828
  // Provide status line preview for theme selection
@@ -819,6 +845,7 @@ export class SettingsSelectorComponent implements Component {
819
845
  },
820
846
  onPreview,
821
847
  getPreview,
848
+ footer,
822
849
  );
823
850
  }
824
851
 
@@ -0,0 +1,11 @@
1
+ [User]: Fix the settings overlay crash. Wheeling past the last row throws.
2
+
3
+ [Assistant tool calls]: read(path="src/select-list.ts:140-180")
4
+
5
+ [Tool result]: 162: const index = Math.floor(line / rowHeight); index is never checked against bounds.
6
+
7
+ [Assistant]: Found it. The hit test indexes past the filtered list; clamping to the last row fixes the crash.
8
+
9
+ [User]: Does the fix survive filtering?
10
+
11
+ [Assistant]: Yes. The clamp applies after the filter pass, so a narrowed list keeps the hit map in sync. Added a regression test that wheels past the last row with a filter active and asserts no throw.
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Live preview for the `snapcompact.shape` setting: renders a sample session
3
+ * transcript through the real snapcompact rasterizer as a miniature page and
4
+ * shows it zoomed, so cell size, ink hues, highlight bands, and dim tool-result
5
+ * spans are legible at terminal scale.
6
+ *
7
+ * The mini-frame (a {@link SRC_FRAME_PX}px page) is upscaled with
8
+ * nearest-neighbor so the glyph pixels stay crisp when the terminal scales the
9
+ * placement box. Graphics display requires the Kitty unicode-placeholder path —
10
+ * `renderImage` returning `lines` is the gate — because the bordered settings
11
+ * frame re-fits every row, which direct cursor-positioned placements (iTerm2,
12
+ * Sixel, Kitty `a=p`) do not survive. Everything else falls back to the stats
13
+ * line plus a dim notice.
14
+ */
15
+ import { type Component, type ImageBudget, renderImage, TERMINAL } from "@oh-my-pi/pi-tui";
16
+ import {
17
+ DIM_OFF,
18
+ DIM_ON,
19
+ geometry,
20
+ isShapeVariantName,
21
+ normalize,
22
+ renderMany,
23
+ resolveShape,
24
+ SHAPE_VARIANT_NAMES,
25
+ SHAPE_VARIANTS,
26
+ type Shape,
27
+ type ShapeTarget,
28
+ type ShapeVariantName,
29
+ } from "@oh-my-pi/snapcompact";
30
+ import { theme } from "../theme/theme";
31
+ import sampleDoc from "./snapcompact-shape-preview-doc.md" with { type: "text" };
32
+
33
+ /** Mini-frame edge in px — a small page from the real rasterizer ≈ a zoomed crop. */
34
+ const SRC_FRAME_PX = 128;
35
+ /** Nearest-neighbor upscale factor; keeps glyph pixels crisp on HiDPI cell boxes. */
36
+ const ZOOM_SCALE = 4;
37
+ /** Display box in terminal cells (square-ish at the typical 1:2 cell aspect). */
38
+ const MAX_IMAGE_COLS = 28;
39
+ const MAX_IMAGE_ROWS = 14;
40
+
41
+ /** Sample transcript with `[Tool result]:` bodies wrapped in dim-ink toggles. */
42
+ const PREVIEW_TEXT = sampleDoc
43
+ .trim()
44
+ .replace(/\[Tool result\]: ([^[]*)/g, (_match, body: string) => `[Tool result]: ${DIM_ON}${body}${DIM_OFF}`);
45
+
46
+ type PreviewEntry =
47
+ | { state: "rendering" }
48
+ | { state: "failed" }
49
+ | { state: "ready"; data: string; edgePx: number; imageId: number; transmitted: boolean };
50
+
51
+ export interface SnapcompactShapePreviewOptions {
52
+ /** Active model (api + id); resolves what `auto` maps to for this reader. */
53
+ model?: ShapeTarget;
54
+ /** Shared TUI image budget: stable graphics ids, transmit-once, exit cleanup. */
55
+ imageBudget?: ImageBudget;
56
+ /** Schedules a re-render once an async sample render completes. */
57
+ requestRender?: () => void;
58
+ }
59
+
60
+ export class SnapcompactShapePreview implements Component {
61
+ #model: ShapeTarget | undefined;
62
+ #budget: ImageBudget | undefined;
63
+ #requestRender: () => void;
64
+ #variant: ShapeVariantName | "auto" = "auto";
65
+ #entries = new Map<ShapeVariantName, PreviewEntry>();
66
+
67
+ constructor(currentValue: string, options: SnapcompactShapePreviewOptions = {}) {
68
+ this.#model = options.model;
69
+ this.#budget = options.imageBudget;
70
+ this.#requestRender = options.requestRender ?? (() => {});
71
+ this.setValue(currentValue);
72
+ }
73
+
74
+ /** Track the highlighted option; the next render reflects it. */
75
+ setValue(value: string): void {
76
+ this.#variant = isShapeVariantName(value) ? value : "auto";
77
+ }
78
+
79
+ render(width: number): readonly string[] {
80
+ const shape = resolveShape(this.#model, this.#variant);
81
+ const name = resolvedVariantName(shape);
82
+ const geo = geometry(shape);
83
+ const label = this.#variant === "auto" ? `auto → ${name}` : name;
84
+ const chars = geo.capacity >= 1000 ? `${(geo.capacity / 1000).toFixed(1)}k` : String(geo.capacity);
85
+ const tokens =
86
+ shape.frameTokenEstimate >= 1000
87
+ ? `${(shape.frameTokenEstimate / 1000).toFixed(1)}k`
88
+ : String(shape.frameTokenEstimate);
89
+ const stats = `full frame ${geo.cols}×${geo.rows} cells ≈ ${chars} chars ≈ ${tokens} tokens`;
90
+ const lines: string[] = [theme.fg("muted", ` Sample (zoomed) · ${label} · ${stats}`), ""];
91
+
92
+ if (!this.#budget || !TERMINAL.imageProtocol) {
93
+ lines.push(theme.fg("dim", " (graphic sample needs a Kitty-graphics terminal)"));
94
+ return lines;
95
+ }
96
+
97
+ const entry = this.#ensureEntry(name, shape);
98
+ if (entry.state === "rendering") {
99
+ lines.push(theme.fg("dim", " rendering sample…"));
100
+ return lines;
101
+ }
102
+ if (entry.state === "failed") {
103
+ lines.push(theme.fg("dim", " (sample render failed)"));
104
+ return lines;
105
+ }
106
+
107
+ const result = renderImage(
108
+ entry.data,
109
+ { widthPx: entry.edgePx, heightPx: entry.edgePx },
110
+ {
111
+ maxWidthCells: Math.max(8, Math.min(MAX_IMAGE_COLS, width - 4)),
112
+ maxHeightCells: MAX_IMAGE_ROWS,
113
+ imageId: entry.imageId,
114
+ includeTransmit: !entry.transmitted,
115
+ },
116
+ );
117
+ // Only the unicode-placeholder path returns text-cell `lines`; cursor-moving
118
+ // placements would corrupt the bordered settings frame, so skip them.
119
+ if (!result?.lines) {
120
+ lines.push(theme.fg("dim", " (graphic sample needs Kitty unicode-placeholder graphics)"));
121
+ return lines;
122
+ }
123
+ if (result.transmit) {
124
+ this.#budget.enqueueTransmit(entry.imageId, result.transmit);
125
+ entry.transmitted = true;
126
+ }
127
+ for (const line of result.lines) {
128
+ lines.push(` ${line}`);
129
+ }
130
+ return lines;
131
+ }
132
+
133
+ #ensureEntry(name: ShapeVariantName, shape: Shape): PreviewEntry {
134
+ let entry = this.#entries.get(name);
135
+ if (!entry) {
136
+ entry = { state: "rendering" };
137
+ this.#entries.set(name, entry);
138
+ void this.#buildEntry(name, shape);
139
+ }
140
+ return entry;
141
+ }
142
+
143
+ async #buildEntry(name: ShapeVariantName, shape: Shape): Promise<void> {
144
+ try {
145
+ // Fill the mini-page so every variant shows a fully inked window.
146
+ const capacity = geometry(shape, SRC_FRAME_PX).capacity;
147
+ let text = PREVIEW_TEXT;
148
+ while (normalize(text).length < capacity) {
149
+ text += ` ${PREVIEW_TEXT}`;
150
+ }
151
+ const frame = renderMany(text, { shape, frameSize: SRC_FRAME_PX, maxFrames: 1 })[0];
152
+ if (!frame) throw new Error("empty sample frame");
153
+ const edgePx = SRC_FRAME_PX * ZOOM_SCALE;
154
+ const zoomed = await new Bun.Image(Buffer.from(frame.data, "base64"))
155
+ .resize(edgePx, edgePx, { filter: "nearest" })
156
+ .png()
157
+ .bytes();
158
+ this.#entries.set(name, {
159
+ state: "ready",
160
+ data: zoomed.toBase64(),
161
+ edgePx,
162
+ // Keyed id: reopening settings reuses the id, so data already in the
163
+ // terminal store is never re-transmitted (enqueueTransmit no-ops).
164
+ imageId: this.#budget?.acquireId(`snapshape:${name}:${edgePx}`) ?? 0,
165
+ transmitted: false,
166
+ });
167
+ } catch {
168
+ this.#entries.set(name, { state: "failed" });
169
+ }
170
+ this.#requestRender();
171
+ }
172
+ }
173
+
174
+ /** Research name of the concrete geometry `resolveShape` picked (for `auto`). */
175
+ function resolvedVariantName(shape: Shape): ShapeVariantName {
176
+ for (const name of SHAPE_VARIANT_NAMES) {
177
+ const candidate = SHAPE_VARIANTS[name];
178
+ if (
179
+ candidate.font === shape.font &&
180
+ candidate.cellWidth === shape.cellWidth &&
181
+ candidate.cellHeight === shape.cellHeight &&
182
+ candidate.variant === shape.variant &&
183
+ candidate.lineRepeat === shape.lineRepeat &&
184
+ candidate.frameSize === shape.frameSize
185
+ ) {
186
+ return name;
187
+ }
188
+ }
189
+ // resolveShape only hands out table geometries; the legacy shape is the
190
+ // conservative label if that invariant ever changes.
191
+ return "5x8-sent";
192
+ }
@@ -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,7 @@ 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;
155
157
 
156
158
  // Git status caching (1s TTL)
157
159
  #cachedGitStatus: { staged: number; unstaged: number; untracked: number } | null = null;
@@ -217,6 +219,11 @@ export class StatusLineComponent implements Component {
217
219
  this.#subagentCount = count;
218
220
  }
219
221
 
222
+ /** Active subagent count as currently displayed (collab state mirroring). */
223
+ get subagentCount(): number {
224
+ return this.#subagentCount;
225
+ }
226
+
220
227
  setSessionStartTime(time: number): void {
221
228
  this.#sessionStartTime = time;
222
229
  }
@@ -233,6 +240,10 @@ export class StatusLineComponent implements Component {
233
240
  this.#goalModeStatus = status ?? null;
234
241
  }
235
242
 
243
+ setCollabStatus(status: CollabStatus | null): void {
244
+ this.#collabStatus = status;
245
+ }
246
+
236
247
  setHookStatus(key: string, text: string | undefined): void {
237
248
  if (text === undefined) {
238
249
  this.#hookStatuses.delete(key);
@@ -642,7 +653,15 @@ export class StatusLineComponent implements Component {
642
653
  contextTokens = breakdown.usedTokens;
643
654
  contextWindow = breakdown.contextWindow || contextWindow;
644
655
  }
645
- const contextPercent = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
656
+ let contextPercent = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
657
+
658
+ // Collab guest: context comes from the host's state frames — the local
659
+ // replica does no accounting of its own.
660
+ const collabState = this.#collabStatus?.stateOverride;
661
+ if (collabState?.contextUsage) {
662
+ contextWindow = collabState.contextUsage.contextWindow || contextWindow;
663
+ contextPercent = collabState.contextUsage.percent ?? contextPercent;
664
+ }
646
665
 
647
666
  return {
648
667
  session: this.session,
@@ -651,6 +670,7 @@ export class StatusLineComponent implements Component {
651
670
  planMode: this.#planModeStatus,
652
671
  loopMode: this.#loopModeStatus,
653
672
  goalMode: this.#goalModeStatus,
673
+ collab: this.#collabStatus,
654
674
  usageStats,
655
675
  contextPercent,
656
676
  contextWindow,
@@ -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: {
@@ -493,6 +493,18 @@ const sessionNameSegment: StatusLineSegment = {
493
493
  },
494
494
  };
495
495
 
496
+ const collabSegment: StatusLineSegment = {
497
+ id: "collab",
498
+ render(ctx) {
499
+ if (!ctx.collab) return { content: "", visible: false };
500
+ const label =
501
+ ctx.collab.role === "host"
502
+ ? `⇄ collab:${ctx.collab.participantCount}`
503
+ : `⇄ collab guest:${ctx.collab.participantCount}`;
504
+ return { content: theme.fg("accent", label), visible: true };
505
+ },
506
+ };
507
+
496
508
  function pickUsageColor(percent: number): "muted" | "warning" | "error" {
497
509
  if (percent >= 80) return "error";
498
510
  if (percent >= 50) return "warning";
@@ -573,6 +585,7 @@ export const SEGMENTS: Record<StatusLineSegmentId, StatusLineSegment> = {
573
585
  cache_hit: cacheHitSegment,
574
586
  session_name: sessionNameSegment,
575
587
  usage: usageSegment,
588
+ collab: collabSegment,
576
589
  };
577
590
 
578
591
  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 };
@@ -49,6 +58,7 @@ export interface SegmentContext {
49
58
  enabled: boolean;
50
59
  paused: boolean;
51
60
  } | null;
61
+ collab: CollabStatus | null;
52
62
  // Cached values for performance (computed once per render)
53
63
  usageStats: {
54
64
  input: number;
@@ -16,4 +16,5 @@ 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
@@ -591,20 +591,28 @@ export class ToolExecutionComponent extends Container {
591
591
 
592
592
  /**
593
593
  * Whether this still-live block's settled rows may enter native scrollback
594
- * (see `FinalizableBlock.isTranscriptBlockCommitStable`). A pending
595
- * collapsed preview is provisional: the tail-window streaming views
596
- * (edit/bash/eval caps) are re-anchored top-first by the result render, so
597
- * promoting their visually static head e.g. an edit preview idling on
598
- * its last frame while the apply + LSP pass runs would strand a stale
599
- * copy of the call box above the final block the moment the result lands.
600
- * Expanded pending blocks stream top-anchored append-shaped content whose
601
- * rows the result render preserves byte-stable (the over-tall write/eval
602
- * scrollback contract), so they stay commit-eligible. Displaceable waiting
594
+ * (see `FinalizableBlock.isTranscriptBlockCommitStable`). Classification is
595
+ * per renderer (`ToolRenderer.provisionalPendingPreview`): tail-window
596
+ * streaming views (edit's streamed-diff tail, bash/ssh command caps, eval
597
+ * cells) are re-anchored top-first by the result render, so promoting
598
+ * their visually static head e.g. an edit preview idling on its last
599
+ * frame while the apply + LSP pass runs would strand a stale copy of
600
+ * the call box above the final block the moment the result lands. Every
601
+ * other pending preview streams top-anchored append-shaped rows the
602
+ * result render preserves (a task call's context/assignment markdown, a
603
+ * write's content), so it stays commit-eligible — a call taller than the
604
+ * viewport scrolls into native history mid-stream instead of reading as
605
+ * cut off until the result. Expanded blocks always stream top-anchored
606
+ * (the over-tall write/eval scrollback contract). Displaceable waiting
603
607
  * polls are removed wholesale by the next poll and must never commit.
604
608
  */
605
609
  isTranscriptBlockCommitStable(): boolean {
606
610
  if (this.#displaceable) return false;
607
- return this.#expanded || this.isTranscriptBlockFinalized();
611
+ if (this.#expanded || this.isTranscriptBlockFinalized()) return true;
612
+ if ((this.#tool as { provisionalPendingPreview?: boolean } | undefined)?.provisionalPendingPreview) {
613
+ return false;
614
+ }
615
+ return !toolRenderers[this.#toolName]?.provisionalPendingPreview;
608
616
  }
609
617
 
610
618
  /**