@oh-my-pi/pi-tui 18.4.0 → 18.4.1

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 (42) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/types/chat/image-loading.d.ts +24 -0
  3. package/dist/types/chat/transcript-entry.d.ts +9 -0
  4. package/dist/types/chrome/transcript-container.d.ts +9 -2
  5. package/dist/types/overlays/agents-hub.d.ts +7 -4
  6. package/dist/types/overlays/rewind-selector.d.ts +3 -3
  7. package/dist/types/overlays/usage-dashboard.d.ts +9 -2
  8. package/dist/types/prompt/composer-cache.d.ts +34 -16
  9. package/dist/types/prompt/composer.d.ts +15 -18
  10. package/dist/types/prompt/welcome.d.ts +5 -2
  11. package/dist/types/status-line/component.d.ts +13 -4
  12. package/dist/types/status-line/metrics.d.ts +0 -1
  13. package/dist/types/status-line/startup.d.ts +38 -0
  14. package/dist/types/status-line/types.d.ts +0 -2
  15. package/dist/types/terminal-capabilities.d.ts +10 -6
  16. package/dist/types/tools/output-meta.d.ts +2 -1
  17. package/dist/types/tools/vibe.d.ts +4 -0
  18. package/package.json +9 -9
  19. package/src/chat/assistant-message.ts +27 -21
  20. package/src/chat/image-loading.ts +81 -0
  21. package/src/chat/tool-execution.ts +29 -13
  22. package/src/chat/transcript-entry.ts +16 -0
  23. package/src/chrome/transcript-container.ts +208 -74
  24. package/src/components/image.ts +10 -2
  25. package/src/overlays/agents-hub.ts +7 -18
  26. package/src/overlays/copy-selector.ts +3 -31
  27. package/src/overlays/model-hub.ts +49 -16
  28. package/src/overlays/rewind-selector.ts +51 -12
  29. package/src/overlays/usage-dashboard.ts +47 -6
  30. package/src/prompt/composer-cache.ts +217 -196
  31. package/src/prompt/composer.ts +40 -70
  32. package/src/prompt/welcome.ts +31 -28
  33. package/src/status-line/component.ts +73 -50
  34. package/src/status-line/metrics.ts +3 -21
  35. package/src/status-line/segments.ts +34 -50
  36. package/src/status-line/startup.ts +181 -0
  37. package/src/status-line/types.ts +0 -2
  38. package/src/terminal-capabilities.ts +12 -6
  39. package/src/terminal.ts +38 -9
  40. package/src/tools/output-meta.ts +11 -3
  41. package/src/tools/vibe.ts +15 -2
  42. package/src/tui.ts +66 -42
@@ -1,5 +1,8 @@
1
- import type { EditorTopBorder } from "../components/composer/types";
1
+ import { getComposerStyle } from "../components/composer/registry";
2
2
  import { Spacer } from "../components/spacer";
3
+ import type { StatusLineComponent } from "../status-line/component";
4
+ import type { StatusLineSession } from "../status-line/host";
5
+ import { createStartupStatusLine, type StatusLineStartupData } from "../status-line/startup";
3
6
  import { isInsideTerminalMultiplexer } from "../terminal-multiplexer";
4
7
  import { ProcessTerminal, type Terminal } from "../terminal";
5
8
  import {
@@ -12,7 +15,7 @@ import {
12
15
  type TUIOptions,
13
16
  type ViewportSize,
14
17
  } from "../tui";
15
- import { sliceWithWidth, truncateToWidth, visibleWidth } from "../utils";
18
+ import { sliceWithWidth, visibleWidth } from "../utils";
16
19
  import { postmortem } from "@oh-my-pi/pi-utils";
17
20
  import { CustomEditor } from "./custom-editor";
18
21
  import type { WordCompletionMethod } from "./word-completion";
@@ -56,28 +59,22 @@ export interface ComposerWelcomeUpdate {
56
59
  readonly modelName?: string;
57
60
  readonly providerName?: string;
58
61
  readonly recentSessions?: readonly RecentSession[];
59
- readonly lspServers?: readonly LspServerInfo[];
62
+ /** Detected project servers; `null` means LSP is disabled and hides the welcome section. */
63
+ readonly lspServers?: readonly LspServerInfo[] | null;
60
64
  }
61
65
 
62
66
  /**
63
- * Placeholder-only status chrome replayed on the next first frame so the
64
- * status band/border exists before the session-aware status line attaches.
65
- * Bound to the composer shape it was rendered for; a different shape drops it.
67
+ * Status-bar inputs persisted by the last session. The first frame renders
68
+ * them through a startup {@link StatusLineComponent} at the live width until
69
+ * the session-aware status line attaches.
66
70
  */
67
- export interface ComposerStatusSnapshot {
68
- readonly shape: string;
69
- /** ANSI wrapper of the editor border at snapshot time (session accent or thinking color). */
71
+ export interface ComposerStatusCache {
72
+ /** ANSI wrapper of the editor border when persisted (session accent or thinking color). */
70
73
  readonly borderColor?: {
71
74
  readonly prefix: string;
72
75
  readonly suffix: string;
73
76
  };
74
- /** Status content embedded in the editor's top chrome (`top-border`, `top-band`, `top-rule-chip`). */
75
- readonly topBorder?: {
76
- readonly content: string;
77
- readonly width: number;
78
- };
79
- /** Standalone bottom-bar rows (`pi`/`claude` shapes), gap row included. */
80
- readonly bottomLines: readonly string[];
77
+ readonly statusLine: StatusLineStartupData;
81
78
  }
82
79
 
83
80
  /** Optional dependencies and initial state for a standalone composer. */
@@ -87,7 +84,7 @@ export interface ComposerOptions {
87
84
  readonly tuiOptions?: TUIOptions;
88
85
  readonly preferences?: Partial<ComposerPreferences>;
89
86
  readonly welcome?: ComposerWelcomeUpdate;
90
- readonly status?: ComposerStatusSnapshot;
87
+ readonly status?: ComposerStatusCache;
91
88
  readonly exit?: (code: number) => void;
92
89
  readonly now?: () => number;
93
90
  }
@@ -117,30 +114,16 @@ export interface ComposerStartOptions {
117
114
  readonly deferInput?: boolean;
118
115
  }
119
116
 
120
- /**
121
- * Mount slot for the session-aware status component below the editor. Shows
122
- * placeholder rows during startup until the real component mounts.
123
- */
117
+ /** Mount slot below the editor: the startup status line, then the session-aware one. */
124
118
  class StatusHost implements Component {
125
- #lines: readonly string[] = [];
126
119
  #component: Component | undefined;
127
120
 
128
- get mounted(): boolean {
129
- return this.#component !== undefined;
130
- }
131
-
132
- setLines(lines: readonly string[]): void {
133
- this.#lines = lines;
134
- }
135
-
136
121
  setComponent(component: Component): void {
137
122
  this.#component = component;
138
- this.#lines = [];
139
123
  }
140
124
 
141
125
  render(width: number): readonly string[] {
142
- if (this.#component) return this.#component.render(width);
143
- return this.#lines.map(line => truncateToWidth(line, width));
126
+ return this.#component?.render(width) ?? [];
144
127
  }
145
128
  }
146
129
 
@@ -220,11 +203,12 @@ export class Composer implements TerminalFrameProvider {
220
203
  #modelName = "";
221
204
  #providerName = "";
222
205
  #recentSessions: RecentSession[] = [];
223
- #lspServers: LspServerInfo[] = [];
206
+ #lspServers: LspServerInfo[] | null = [];
224
207
  #headerBefore: readonly Component[] = [];
225
208
  #headerAfter: readonly Component[] = [];
226
209
  #runtimeChildren: readonly Component[] = [];
227
- #statusSnapshot: ComposerStatusSnapshot | undefined;
210
+ /** Cache-driven status line shown until {@link setStatusComponent} mounts the session's. */
211
+ #startupStatus: StatusLineComponent | undefined;
228
212
  #runtimeMounted = false;
229
213
  // Composer-owned history id space. Transcript batch ids restart across
230
214
  // container clears/swaps; the composer translates them into one monotonic
@@ -291,7 +275,6 @@ export class Composer implements TerminalFrameProvider {
291
275
  this.#exit = options.exit ?? (code => postmortem.exitProcess(code));
292
276
  this.#now = options.now ?? Date.now;
293
277
  this.#preferences = { ...COMPOSER_DEFAULTS, ...options.preferences };
294
- this.#statusSnapshot = options.status;
295
278
  this.#applyWelcomeUpdate(options.welcome ?? {});
296
279
 
297
280
  this.ui = new TUI(
@@ -318,7 +301,13 @@ export class Composer implements TerminalFrameProvider {
318
301
  } catch {
319
302
  // Extension-defined styles arrive with the session; InteractiveMode reapplies them.
320
303
  }
321
- this.#applyStatusSnapshot();
304
+ if (options.status) {
305
+ const { borderColor, statusLine } = options.status;
306
+ if (borderColor) this.editor.borderColor = text => `${borderColor.prefix}${text}${borderColor.suffix}`;
307
+ this.#startupStatus = createStartupStatusLine(statusLine);
308
+ this.#statusHost.setComponent(this.#startupStatus);
309
+ this.#startupStatus.attachToEditor(this.editor, getComposerStyle(this.#preferences.composerShape));
310
+ }
322
311
  // Emergency controls stay active until InteractiveMode installs configured bindings.
323
312
  // They deliberately mirror the interactive editor's contract so a stalled startup
324
313
  // never behaves differently from a healthy one: Ctrl+C clears the draft and a second
@@ -635,7 +624,9 @@ export class Composer implements TerminalFrameProvider {
635
624
  // retires first so transcript prefixes can follow in order.
636
625
  const renderedHeader = this.#header.render(width);
637
626
  if (renderedHeader.length > 0) {
638
- const liveRows = transcript.liveRowCount(width);
627
+ // Only the comparison below reads the height, so the walk stops at
628
+ // the budget instead of rendering every replayed block (#12933).
629
+ const liveRows = transcript.liveRowCount(width, Math.max(0, rows - renderedHeader.length - chromeRows));
639
630
  if (!this.#historyFlush && renderedHeader.length + chromeRows + liveRows <= rows) return undefined;
640
631
  this.#offeredHistory = {
641
632
  id: this.#nextHistoryId++,
@@ -789,7 +780,7 @@ export class Composer implements TerminalFrameProvider {
789
780
  autocomplete: this.#preferences.spellingAutocomplete,
790
781
  autocorrect: this.#preferences.spellingAutocorrect,
791
782
  });
792
- this.#applyStatusSnapshot();
783
+ this.#startupStatus?.attachToEditor(this.editor, getComposerStyle(this.#preferences.composerShape));
793
784
  if (this.#preferences.quiet) {
794
785
  this.#welcome?.stopIntro();
795
786
  this.#welcome = undefined;
@@ -834,42 +825,20 @@ export class Composer implements TerminalFrameProvider {
834
825
  }
835
826
 
836
827
  /**
837
- * Mount the session-aware status component into the slot below the editor.
838
- * Drops the speculative snapshot; the caller installs the real top-border
828
+ * Mount the session-aware status component into the slot below the editor,
829
+ * retiring the startup status line; the caller installs the real top-border
839
830
  * provider through its composer-shape sync.
840
831
  */
841
- setStatusComponent(component: Component): void {
832
+ setStatusComponent<TSession extends StatusLineSession>(component: StatusLineComponent<TSession>): void {
833
+ if (this.#startupStatus) component.adoptGitStatus(this.#startupStatus);
834
+ this.#disposeStartupStatus();
842
835
  this.#statusHost.setComponent(component);
843
- this.#statusSnapshot = undefined;
844
836
  this.editor.setTopBorderProvider(undefined);
845
837
  }
846
838
 
847
- /** Cached placeholder top-border content fitted to the current editor width. */
848
- #speculativeTopBorder(availableWidth: number): EditorTopBorder | undefined {
849
- const border = this.#statusSnapshot?.topBorder;
850
- if (!border) return undefined;
851
- if (border.width <= availableWidth) return { content: border.content, width: border.width };
852
- const content = truncateToWidth(border.content, availableWidth);
853
- return { content, width: visibleWidth(content) };
854
- }
855
-
856
- /** Install the cached chrome for the current shape; a shape mismatch clears it. */
857
- #applyStatusSnapshot(): void {
858
- if (this.#statusHost.mounted) return;
859
- const snapshot = this.#statusSnapshot;
860
- if (!snapshot || snapshot.shape !== this.#preferences.composerShape) {
861
- this.editor.setTopBorderProvider(undefined);
862
- this.#statusHost.setLines([]);
863
- return;
864
- }
865
- if (snapshot.borderColor) {
866
- const { prefix, suffix } = snapshot.borderColor;
867
- this.editor.borderColor = text => `${prefix}${text}${suffix}`;
868
- }
869
- this.editor.setTopBorderProvider(
870
- snapshot.topBorder ? availableWidth => this.#speculativeTopBorder(availableWidth) : undefined,
871
- );
872
- this.#statusHost.setLines(snapshot.bottomLines);
839
+ #disposeStartupStatus(): void {
840
+ this.#startupStatus?.dispose();
841
+ this.#startupStatus = undefined;
873
842
  }
874
843
 
875
844
  /** Mount or replace session-aware root children while preserving the header and status hosts. */
@@ -908,6 +877,7 @@ export class Composer implements TerminalFrameProvider {
908
877
  stop(): void {
909
878
  if (!this.#started || this.#stopped || this.#transferred) return;
910
879
  this.#welcome?.stopIntro();
880
+ this.#disposeStartupStatus();
911
881
  this.ui.stop();
912
882
  this.#stopped = true;
913
883
  }
@@ -917,7 +887,7 @@ export class Composer implements TerminalFrameProvider {
917
887
  if (update.modelName !== undefined) this.#modelName = update.modelName;
918
888
  if (update.providerName !== undefined) this.#providerName = update.providerName;
919
889
  if (update.recentSessions !== undefined) this.#recentSessions = [...update.recentSessions];
920
- if (update.lspServers !== undefined) this.#lspServers = [...update.lspServers];
890
+ if (update.lspServers !== undefined) this.#lspServers = update.lspServers && [...update.lspServers];
921
891
  }
922
892
 
923
893
  #ensureWelcome(): void {
@@ -186,7 +186,8 @@ export class WelcomeComponent implements Component {
186
186
  private modelName: string,
187
187
  private providerName: string,
188
188
  private recentSessions: RecentSession[] = [],
189
- private lspServers: LspServerInfo[] = [],
189
+ /** Detected project servers; `null` means LSP is disabled and hides the section. */
190
+ private lspServers: LspServerInfo[] | null = [],
190
191
  ) {}
191
192
  get tip(): string | undefined {
192
193
  this.#nagRoll ??= Math.random();
@@ -272,7 +273,7 @@ export class WelcomeComponent implements Component {
272
273
  this.invalidate();
273
274
  }
274
275
 
275
- setLspServers(servers: LspServerInfo[]): void {
276
+ setLspServers(servers: LspServerInfo[] | null): void {
276
277
  this.lspServers = servers;
277
278
  this.invalidate();
278
279
  }
@@ -365,29 +366,6 @@ export class WelcomeComponent implements Component {
365
366
  sessionLines.push("");
366
367
  }
367
368
 
368
- // LSP servers content
369
- const lspLines: string[] = [];
370
- if (this.lspServers.length === 0) {
371
- lspLines.push(` ${theme.fg("dim", "No LSP servers")}`);
372
- } else {
373
- for (const server of this.lspServers.slice(0, WELCOME_LSP_SLOTS)) {
374
- const icon =
375
- server.status === "ready"
376
- ? theme.styledSymbol("status.enabled", "success")
377
- : server.status === "available"
378
- ? theme.styledSymbol("status.enabled", "dim")
379
- : server.status === "connecting"
380
- ? theme.styledSymbol("status.pending", "muted")
381
- : theme.styledSymbol("status.error", "error");
382
- const exts = server.fileTypes.slice(0, 3).join(" ");
383
- lspLines.push(` ${icon} ${theme.fg("muted", server.name)} ${theme.fg("dim", exts)}`);
384
- }
385
- }
386
- // Pad to the fixed slot count so the box height doesn't depend on server count.
387
- while (lspLines.length < WELCOME_LSP_SLOTS) {
388
- lspLines.push("");
389
- }
390
-
391
369
  // Right column
392
370
  const rightLines = [
393
371
  ` ${theme.bold(theme.fg("accent", "Tips"))}`,
@@ -395,9 +373,7 @@ export class WelcomeComponent implements Component {
395
373
  ` ${theme.fg("dim", "/")}${theme.fg("muted", " for commands")}`,
396
374
  ` ${theme.fg("dim", "!")}${theme.fg("muted", " to run bash")}`,
397
375
  ` ${theme.fg("dim", "$")}${theme.fg("muted", " to run python")}`,
398
- separator,
399
- ` ${theme.bold(theme.fg("accent", "LSP Servers"))}`,
400
- ...lspLines,
376
+ ...this.#renderLspSection(separator),
401
377
  separator,
402
378
  ` ${theme.bold(theme.fg("accent", "Recent sessions"))}`,
403
379
  ...sessionLines,
@@ -452,6 +428,33 @@ export class WelcomeComponent implements Component {
452
428
  return lines;
453
429
  }
454
430
 
431
+ /** Right-column LSP rows padded to a fixed height; empty when LSP is disabled. */
432
+ #renderLspSection(separator: string): string[] {
433
+ if (this.lspServers === null) return [];
434
+ const lspLines: string[] = [];
435
+ if (this.lspServers.length === 0) {
436
+ lspLines.push(` ${theme.fg("dim", "No LSP servers")}`);
437
+ } else {
438
+ for (const server of this.lspServers.slice(0, WELCOME_LSP_SLOTS)) {
439
+ const icon =
440
+ server.status === "ready"
441
+ ? theme.styledSymbol("status.enabled", "success")
442
+ : server.status === "available"
443
+ ? theme.styledSymbol("status.enabled", "dim")
444
+ : server.status === "connecting"
445
+ ? theme.styledSymbol("status.pending", "muted")
446
+ : theme.styledSymbol("status.error", "error");
447
+ const exts = server.fileTypes.slice(0, 3).join(" ");
448
+ lspLines.push(` ${icon} ${theme.fg("muted", server.name)} ${theme.fg("dim", exts)}`);
449
+ }
450
+ }
451
+ // Pad to the fixed slot count so the box height doesn't depend on server count.
452
+ while (lspLines.length < WELCOME_LSP_SLOTS) {
453
+ lspLines.push("");
454
+ }
455
+ return [separator, ` ${theme.bold(theme.fg("accent", "LSP Servers"))}`, ...lspLines];
456
+ }
457
+
455
458
  /**
456
459
  * Render the per-instance tip line: the `customMessageLabel`-themed `Tip:`
457
460
  * label followed by a `muted` body, the whole line italicized. Returns `[]`
@@ -25,6 +25,7 @@ import type {
25
25
  StatusLineHost,
26
26
  StatusLineSession,
27
27
  } from "./host";
28
+ import type { Editor } from "../components/editor";
28
29
  import { getSessionAccentAnsi, getSessionAccentHex } from "../theme/session-color";
29
30
  import { sanitizeStatusText } from "../chrome/shared";
30
31
  import { getThemeEpoch, theme } from "../theme";
@@ -247,6 +248,8 @@ interface ContextUsageMemo {
247
248
  tokenizerRef: unknown;
248
249
  usedTokens: number;
249
250
  contextWindow: number;
251
+ /** The session reported `percent: null`: usage is unknown, so no percent is shown. */
252
+ percentUnknown: boolean;
250
253
  systemPromptRef: readonly string[] | undefined;
251
254
  toolsRef: readonly any[] | undefined;
252
255
  skillsRef: readonly any[] | undefined;
@@ -312,7 +315,6 @@ interface CachedStatusLine {
312
315
  availableWidth: number;
313
316
  renderRevision: number;
314
317
  inputRevision: number;
315
- placeholders: boolean;
316
318
  previewTitle: string | undefined;
317
319
  externalInputs: StatusLineExternalInputs;
318
320
  }
@@ -420,8 +422,10 @@ function formatEmbeddedContextPercent(percent: number): string {
420
422
  return `${percent > 0 && percent < 1 ? percent.toFixed(1) : Math.round(percent)}%`;
421
423
  }
422
424
 
423
- function embeddedContextGaugeMinWidth(percent: number, contextWindow: number): number {
424
- return formatEmbeddedContextPercent(percent).length + formatNumber(contextWindow).length + 4;
425
+ /** Gap width the embedded gauge needs for its labels; an unknown percent (`null`) shows the window label alone. */
426
+ function embeddedContextGaugeMinWidth(percent: number | null, contextWindow: number): number {
427
+ const percentWidth = percent === null ? 0 : formatEmbeddedContextPercent(percent).length + 2;
428
+ return percentWidth + formatNumber(contextWindow).length + 2;
425
429
  }
426
430
 
427
431
  function hasGitSegment(segments: readonly StatusLineSegmentId[]): boolean {
@@ -1483,6 +1487,24 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
1483
1487
  return this.#cachedGitStatusCwd === gitCwd ? this.#cachedGitStatus : null;
1484
1488
  }
1485
1489
 
1490
+ /**
1491
+ * Seed working-tree status from the status line this one replaces (the
1492
+ * startup bar), so the dirty counts stay on screen instead of blanking until
1493
+ * this instance's first fetch lands. Normal TTL refreshes follow.
1494
+ */
1495
+ adoptGitStatus(source: StatusLineComponent): void {
1496
+ if (this.#cachedGitStatusCwd === undefined && source.#cachedGitStatusCwd !== undefined) {
1497
+ this.#cachedGitStatus = source.#cachedGitStatus;
1498
+ this.#cachedGitStatusCwd = source.#cachedGitStatusCwd;
1499
+ this.#gitStatusLastFetch = source.#gitStatusLastFetch;
1500
+ }
1501
+ if (this.#jjStatusLastFetch === 0 && source.#jjStatusLastFetch !== 0) {
1502
+ this.#cachedJjStatus = source.#cachedJjStatus;
1503
+ this.#jjStatusLastFetch = source.#jjStatusLastFetch;
1504
+ }
1505
+ this.#invalidateStatusLineRenderCache();
1506
+ }
1507
+
1486
1508
  #lookupPr(activeRepoCache: ActiveRepoCache = this.#resolveActiveRepoCache()): {
1487
1509
  number: number;
1488
1510
  url: string;
@@ -2088,6 +2110,7 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2088
2110
  const usedTokens = usage?.tokens ?? 0;
2089
2111
  const contextWindow = usage?.contextWindow ?? modelContextWindow;
2090
2112
  this.#contextUsageCache = {
2113
+ percentUnknown: usage?.percent === null,
2091
2114
  messagesRef: messages,
2092
2115
  length,
2093
2116
  lastFingerprint,
@@ -2141,7 +2164,10 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2141
2164
  const breakdown = this.getCachedContextBreakdown();
2142
2165
  let contextTokens = breakdown.usedTokens;
2143
2166
  contextWindow = breakdown.contextWindow || contextWindow;
2144
- let contextPercent: number | null = contextWindow > 0 ? (breakdown.usedTokens / contextWindow) * 100 : null;
2167
+ let contextPercent: number | null =
2168
+ contextWindow > 0 && !this.#contextUsageCache?.percentUnknown
2169
+ ? (breakdown.usedTokens / contextWindow) * 100
2170
+ : null;
2145
2171
  // Collab guest: context comes from the host's state frames — the local
2146
2172
  // replica does no accounting of its own.
2147
2173
  const collabState = this.#collabStatus?.stateOverride;
@@ -2439,8 +2465,7 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2439
2465
  * clocks, countdowns, and VCS fallback polling advance only at their own
2440
2466
  * display/probe cadence.
2441
2467
  */
2442
- #statusLineClock(nowMs: number, effectiveSettings: EffectiveStatusLineSettings, placeholders: boolean): number {
2443
- if (placeholders) return 0;
2468
+ #statusLineClock(nowMs: number, effectiveSettings: EffectiveStatusLineSettings): number {
2444
2469
  const leftSegments = effectiveSettings.leftSegments;
2445
2470
  const rightSegments = effectiveSettings.rightSegments;
2446
2471
  const meter = this.#meter();
@@ -2469,17 +2494,11 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2469
2494
  return 0;
2470
2495
  }
2471
2496
 
2472
- #buildStatusLine(
2473
- width: number,
2474
- layout: StatusLineLayout = "box",
2475
- previewTitle?: string,
2476
- options?: { readonly placeholders?: boolean },
2477
- ): CachedStatusLine {
2497
+ #buildStatusLine(width: number, layout: StatusLineLayout = "box", previewTitle?: string): CachedStatusLine {
2478
2498
  const effectiveSettings = this.#resolveSettings();
2479
- const placeholders = options?.placeholders === true;
2480
2499
  const externalInputs = this.#readStatusLineExternalInputs();
2481
2500
  const nowMs = Date.now();
2482
- const clockTick = this.#statusLineClock(nowMs, effectiveSettings, placeholders);
2501
+ const clockTick = this.#statusLineClock(nowMs, effectiveSettings);
2483
2502
  if (clockTick !== this.#statusLineClockTick) {
2484
2503
  this.#statusLineClockTick = clockTick;
2485
2504
  this.#invalidateStatusLineRenderCache();
@@ -2491,14 +2510,13 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2491
2510
  cached.availableWidth === width &&
2492
2511
  cached.renderRevision === this.#renderRevision &&
2493
2512
  cached.inputRevision === this.#statusLineInputRevision &&
2494
- cached.placeholders === placeholders &&
2495
2513
  cached.previewTitle === previewTitle &&
2496
2514
  this.#sameStatusLineExternalInputs(cached.externalInputs, externalInputs)
2497
2515
  ) {
2498
2516
  return cached;
2499
2517
  }
2500
2518
 
2501
- const content = this.#renderStatusLine(width, layout, previewTitle, options, nowMs);
2519
+ const content = this.#renderStatusLine(width, layout, previewTitle, nowMs);
2502
2520
  const result = {
2503
2521
  content,
2504
2522
  dimmedContent: this.#dimWhileFocusProxied(content),
@@ -2506,7 +2524,6 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2506
2524
  availableWidth: width,
2507
2525
  renderRevision: this.#renderRevision,
2508
2526
  inputRevision: this.#statusLineInputRevision,
2509
- placeholders,
2510
2527
  previewTitle,
2511
2528
  externalInputs,
2512
2529
  };
@@ -2529,16 +2546,9 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2529
2546
  * `previewTitle` is a stand-in session title for composer previews; the
2530
2547
  * `session_name` segment renders it when the session is unnamed.
2531
2548
  */
2532
- #renderStatusLine(
2533
- width: number,
2534
- layout: StatusLineLayout,
2535
- previewTitle: string | undefined,
2536
- options: { readonly placeholders?: boolean } | undefined,
2537
- nowMs: number,
2538
- ): string {
2549
+ #renderStatusLine(width: number, layout: StatusLineLayout, previewTitle: string | undefined, nowMs: number): string {
2539
2550
  const effectiveSettings = this.#resolveSettings();
2540
2551
  this.#syncPricingTimer();
2541
- const placeholders = options?.placeholders === true;
2542
2552
  const plain = layout !== "box" && layout !== "band";
2543
2553
  const includePath =
2544
2554
  hasPathSegment(effectiveSettings.leftSegments) || hasPathSegment(effectiveSettings.rightSegments);
@@ -2548,7 +2558,7 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2548
2558
  (hasGitSegment(effectiveSettings.leftSegments) || hasGitSegment(effectiveSettings.rightSegments));
2549
2559
  const includePr =
2550
2560
  gitEnabled && (hasPrSegment(effectiveSettings.leftSegments) || hasPrSegment(effectiveSettings.rightSegments));
2551
- const liveCtx = this.#buildSegmentContext(
2561
+ const ctx = this.#buildSegmentContext(
2552
2562
  width,
2553
2563
  effectiveSettings.segmentOptions,
2554
2564
  includePath,
@@ -2557,7 +2567,6 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2557
2567
  nowMs,
2558
2568
  previewTitle,
2559
2569
  );
2560
- const ctx: SegmentContext = placeholders ? { ...liveCtx, startupPlaceholder: true } : liveCtx;
2561
2570
  const separatorDef = plain
2562
2571
  ? { left: "·", right: "·" }
2563
2572
  : getSeparator(effectiveSettings.separator ?? "powerline-thin", theme);
@@ -2609,8 +2618,6 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2609
2618
  const embedContext =
2610
2619
  !plain &&
2611
2620
  effectiveSettings.contextLine === "embedded" &&
2612
- ctx.contextPercent !== null &&
2613
- ctx.contextPercent !== undefined &&
2614
2621
  ctx.contextWindow > 0 &&
2615
2622
  (hasContextSegment(leftSegIds) || hasContextSegment(rightSegIds)) &&
2616
2623
  (hasNonContextSegment(leftSegIds) || hasNonContextSegment(rightSegIds));
@@ -2630,13 +2637,9 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2630
2637
  job => job.type !== "task" || job.agentId === undefined || !this.#runningSubagentIds.has(job.agentId),
2631
2638
  ).length ?? 0;
2632
2639
  if (runningBackgroundJobs > 0) {
2633
- const count = placeholders ? "…" : `${runningBackgroundJobs}`;
2634
- rightParts.unshift(theme.fg("statusLineSubagents", `${theme.icon.job} ${count}`));
2635
- }
2636
- if (subagentBadge) {
2637
- const content = placeholders ? [theme.icon.agents, "…"].filter(Boolean).join(" ") : subagentBadge;
2638
- rightParts.unshift(placeholders ? theme.fg("statusLineSubagents", content) : content);
2640
+ rightParts.unshift(theme.fg("statusLineSubagents", `${theme.icon.job} ${runningBackgroundJobs}`));
2639
2641
  }
2642
+ if (subagentBadge) rightParts.unshift(subagentBadge);
2640
2643
  }
2641
2644
  const topFillWidth = Math.max(0, width);
2642
2645
  const left = [...leftParts];
@@ -2668,9 +2671,7 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2668
2671
  // this budget a long path/session title can leave a one-cell gap: the
2669
2672
  // context segment is gone, and the gauge silently omits its labels too.
2670
2673
  const embeddedContextWidth = embedContext
2671
- ? ctx.startupPlaceholder
2672
- ? "…%".length + "…".length + 4
2673
- : embeddedContextGaugeMinWidth(ctx.contextPercent ?? 0, ctx.contextWindow)
2674
+ ? embeddedContextGaugeMinWidth(ctx.contextPercent, ctx.contextWindow)
2674
2675
  : 0;
2675
2676
  const minimumGapWidth = (): number => {
2676
2677
  if (!embeddedContextWidth) return left.length > 0 && right.length > 0 ? 1 : 0;
@@ -2824,11 +2825,13 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2824
2825
  const horizontal = theme.boxRound.horizontal;
2825
2826
  const mode = effectiveSettings.contextLine ?? "embedded";
2826
2827
  const pct = ctx.contextPercent;
2827
- if (mode === "off" || pct === null || pct === undefined) {
2828
+ if (mode === "off" || (pct === null && ctx.contextWindow <= 0)) {
2828
2829
  return `\x1b[49m${usedColor}${horizontal.repeat(gapWidth)}\x1b[39m`;
2829
2830
  }
2830
2831
 
2831
- const clampedPct = Math.min(100, Math.max(0, pct));
2832
+ // Unknown usage against a known window (startup prepaint) draws the fresh-session
2833
+ // gauge: one lit cell and the window label, no percent.
2834
+ const clampedPct = pct === null ? 0 : Math.min(100, Math.max(0, pct));
2832
2835
  let percentLabel = "";
2833
2836
  let windowLabel = "";
2834
2837
  let percentStart = -1;
@@ -2837,14 +2840,11 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2837
2840
  // >100%: usage anchored past the active window (e.g. model switch to a
2838
2841
  // smaller window). The bar clamps full, but the embedded label breaks
2839
2842
  // past the window label — `──200K─120%` with the percent in error color.
2840
- const percentOverflow = pct > 100;
2843
+ const percentOverflow = pct !== null && pct > 100;
2841
2844
  if (embedContext) {
2842
- const candidatePercent = ctx.startupPlaceholder
2843
- ? "…%"
2844
- : formatEmbeddedContextPercent(percentOverflow ? pct : clampedPct);
2845
- const candidateWindow = ctx.startupPlaceholder ? "…" : formatNumber(ctx.contextWindow);
2846
- const minimumLabelWidth = candidatePercent.length + candidateWindow.length + 4;
2847
- if (gapWidth >= minimumLabelWidth) {
2845
+ const candidatePercent = pct === null ? "" : formatEmbeddedContextPercent(percentOverflow ? pct : clampedPct);
2846
+ const candidateWindow = formatNumber(ctx.contextWindow);
2847
+ if (gapWidth >= embeddedContextGaugeMinWidth(pct, ctx.contextWindow)) {
2848
2848
  percentLabel = candidatePercent;
2849
2849
  windowLabel = candidateWindow;
2850
2850
  if (percentOverflow) {
@@ -2946,9 +2946,32 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2946
2946
  }
2947
2947
  }
2948
2948
 
2949
- /** Render startup ellipses inside each segment's normal icon, color, and static chrome. */
2950
- renderStartupPlaceholder(width: number, layout: StatusLineLayout): string {
2951
- return this.#buildStatusLine(width, layout, undefined, { placeholders: true }).content;
2949
+ /**
2950
+ * Wire this bar into `editor` for a composer layout: the matching top-border
2951
+ * provider, the autocomplete probe, and the standalone bottom-bar placement.
2952
+ * Callers re-run it whenever the composer shape changes.
2953
+ */
2954
+ attachToEditor(
2955
+ editor: Pick<Editor, "isAutocompleteActive" | "setTopBorderProvider" | "setTopBorder">,
2956
+ style: Pick<ComposerStyle, "statusAttachment" | "bottomBar" | "bottomBarGap">,
2957
+ ): void {
2958
+ this.setAutocompleteActiveProbe(() => editor.isAutocompleteActive());
2959
+ switch (style.statusAttachment) {
2960
+ case "top-border":
2961
+ editor.setTopBorderProvider(availableWidth => this.getTopBorder(availableWidth));
2962
+ break;
2963
+ case "top-band":
2964
+ editor.setTopBorderProvider(availableWidth => this.getBandTopBorder(availableWidth));
2965
+ break;
2966
+ case "top-rule-chip":
2967
+ editor.setTopBorderProvider(availableWidth => this.getStandaloneTopBorder(availableWidth));
2968
+ break;
2969
+ case "none":
2970
+ editor.setTopBorderProvider(undefined);
2971
+ editor.setTopBorder(undefined);
2972
+ break;
2973
+ }
2974
+ this.setComposerStyle(style);
2952
2975
  }
2953
2976
 
2954
2977
  getTopBorder(width: number, previewTitle?: string): { content: string; width: number; revision: number } {
@@ -7,7 +7,6 @@ export interface BillingSummaryOptions {
7
7
  readonly usingSubscription: boolean;
8
8
  readonly premiumRequests: number;
9
9
  readonly fractionDigits: number;
10
- readonly startupPlaceholder?: boolean;
11
10
  readonly pricingPeriod?: "peak" | "off-peak";
12
11
  readonly advisor?: {
13
12
  readonly cost: number;
@@ -30,24 +29,13 @@ function formatSpend(amount: number, usingSubscription: boolean, fractionDigits:
30
29
  return `S${formatted}`;
31
30
  }
32
31
 
33
- function formatSpendPlaceholder(usingSubscription: boolean, uiTheme: Theme): string {
34
- if (!usingSubscription) return "$…";
35
- if (uiTheme.getSymbolPreset() === "nerd" && uiTheme.icon.subscription) {
36
- return `${uiTheme.icon.subscription} …`;
37
- }
38
- return "S…";
39
- }
40
-
41
32
  function formatAdvisorSpend(
42
33
  amount: number,
43
34
  usingSubscription: boolean,
44
35
  fractionDigits: number,
45
- placeholder: boolean,
46
36
  uiTheme: Theme,
47
37
  ): string {
48
- const spend = placeholder
49
- ? formatSpendPlaceholder(usingSubscription, uiTheme)
50
- : formatSpend(amount, usingSubscription, fractionDigits, uiTheme);
38
+ const spend = formatSpend(amount, usingSubscription, fractionDigits, uiTheme);
51
39
  const icon = uiTheme.icon.advisor;
52
40
  return icon && icon !== "(adv)" ? `${icon} ${spend}` : `${spend} (adv)`;
53
41
  }
@@ -63,21 +51,16 @@ export function formatBillingSummary(options: BillingSummaryOptions, uiTheme: Th
63
51
  return undefined;
64
52
  }
65
53
 
66
- const placeholder = options.startupPlaceholder === true;
67
54
  const parts: string[] = [];
68
55
  if (options.cost || options.pricingPeriod) {
69
- parts.push(
70
- placeholder
71
- ? formatSpendPlaceholder(options.usingSubscription, uiTheme)
72
- : formatSpend(options.cost, options.usingSubscription, options.fractionDigits, uiTheme),
73
- );
56
+ parts.push(formatSpend(options.cost, options.usingSubscription, options.fractionDigits, uiTheme));
74
57
  } else if (options.usingSubscription) {
75
58
  parts.push(
76
59
  uiTheme.getSymbolPreset() === "nerd" && uiTheme.icon.subscription ? uiTheme.icon.subscription : "(sub)",
77
60
  );
78
61
  }
79
62
  if (options.pricingPeriod) parts.push(options.pricingPeriod === "peak" ? "↑" : "↓");
80
- if (premiumRequests) parts.push(`★ ${placeholder ? "…" : formatNumber(premiumRequests)}`);
63
+ if (premiumRequests) parts.push(`★ ${formatNumber(premiumRequests)}`);
81
64
  if (advisorCost && options.advisor) {
82
65
  const prefix = parts.length > 0 ? "+ " : "";
83
66
  parts.push(
@@ -85,7 +68,6 @@ export function formatBillingSummary(options: BillingSummaryOptions, uiTheme: Th
85
68
  advisorCost,
86
69
  options.advisor.usingSubscription,
87
70
  options.fractionDigits,
88
- placeholder,
89
71
  uiTheme,
90
72
  )}`,
91
73
  );