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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +42 -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 +24 -3
  5. package/dist/types/overlays/agents-hub.d.ts +7 -4
  6. package/dist/types/overlays/model-browser.d.ts +15 -0
  7. package/dist/types/overlays/rewind-selector.d.ts +4 -3
  8. package/dist/types/overlays/usage-dashboard.d.ts +9 -2
  9. package/dist/types/prompt/composer-attachments.d.ts +7 -0
  10. package/dist/types/prompt/composer-cache.d.ts +34 -16
  11. package/dist/types/prompt/composer.d.ts +15 -18
  12. package/dist/types/prompt/model-mention-autocomplete.d.ts +4 -1
  13. package/dist/types/prompt/welcome.d.ts +5 -2
  14. package/dist/types/render/width-aware-text.d.ts +6 -0
  15. package/dist/types/status-line/component.d.ts +13 -4
  16. package/dist/types/status-line/metrics.d.ts +0 -1
  17. package/dist/types/status-line/startup.d.ts +38 -0
  18. package/dist/types/status-line/types.d.ts +0 -2
  19. package/dist/types/terminal-capabilities.d.ts +10 -6
  20. package/dist/types/terminal.d.ts +7 -0
  21. package/dist/types/theme/color.d.ts +1 -0
  22. package/dist/types/theme/session-color.d.ts +3 -3
  23. package/dist/types/theme/shimmer.d.ts +1 -1
  24. package/dist/types/theme/theme-class.d.ts +1 -1
  25. package/dist/types/tools/output-meta.d.ts +2 -1
  26. package/dist/types/tools/vibe.d.ts +4 -0
  27. package/dist/types/tui.d.ts +3 -2
  28. package/package.json +9 -9
  29. package/src/chat/assistant-message.ts +27 -21
  30. package/src/chat/image-loading.ts +81 -0
  31. package/src/chat/tool-execution.ts +53 -17
  32. package/src/chat/transcript-entry.ts +16 -0
  33. package/src/chrome/transcript-container.ts +282 -86
  34. package/src/components/editor.ts +28 -6
  35. package/src/components/image.ts +25 -9
  36. package/src/components/loader.ts +37 -17
  37. package/src/overlays/agent-hub.ts +16 -6
  38. package/src/overlays/agents-hub.ts +7 -18
  39. package/src/overlays/copy-selector.ts +8 -31
  40. package/src/overlays/model-browser.ts +106 -25
  41. package/src/overlays/model-hub.ts +49 -16
  42. package/src/overlays/rewind-selector.ts +60 -13
  43. package/src/overlays/usage-dashboard.ts +47 -6
  44. package/src/prompt/composer-attachments.ts +54 -7
  45. package/src/prompt/composer-cache.ts +217 -196
  46. package/src/prompt/composer.ts +45 -72
  47. package/src/prompt/custom-editor.ts +24 -6
  48. package/src/prompt/model-mention-autocomplete.ts +22 -10
  49. package/src/prompt/welcome.ts +31 -28
  50. package/src/render/width-aware-text.ts +9 -0
  51. package/src/status-line/component.ts +286 -182
  52. package/src/status-line/metrics.ts +3 -21
  53. package/src/status-line/segments.ts +68 -79
  54. package/src/status-line/startup.ts +181 -0
  55. package/src/status-line/types.ts +0 -2
  56. package/src/terminal-capabilities.ts +12 -6
  57. package/src/terminal.ts +75 -20
  58. package/src/theme/color.ts +15 -0
  59. package/src/theme/session-color.ts +26 -4
  60. package/src/theme/shimmer.ts +55 -76
  61. package/src/theme/theme-class.ts +29 -12
  62. package/src/tools/output-meta.ts +11 -3
  63. package/src/tools/vibe.ts +15 -2
  64. package/src/tui.ts +208 -154
  65. package/src/utils.ts +15 -0
@@ -7,7 +7,7 @@ import {
7
7
  } from "@oh-my-pi/pi-ai/usage/google-antigravity";
8
8
  import { getNextTimeBasedPricingTransition } from "@oh-my-pi/pi-catalog/models";
9
9
  import type { Model, ModelCost } from "@oh-my-pi/pi-catalog/types";
10
- import type { VcsRepo } from "@oh-my-pi/pi-natives";
10
+ import type { VcsGitRepo, VcsRepo } from "@oh-my-pi/pi-natives";
11
11
  import * as vcs from "@oh-my-pi/pi-natives/vcs";
12
12
  import {
13
13
  type Component,
@@ -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,62 +248,69 @@ 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;
253
256
  }
254
257
 
255
- interface StatusLineExternalInputs {
256
- themeRef: unknown;
257
- themeEpoch: number;
258
- projectDir: string;
259
- sessionRef: StatusLineSession;
260
- sessionFile: string | undefined;
261
- sessionSettingsRef: unknown;
262
- sessionSettingsRevision: number;
263
- globalSettingsRevision: number;
264
- stateRef: unknown;
265
- stateMessagesRef: readonly AgentMessage[];
266
- stateMessagesLength: number;
267
- stateLastMessageRef: AgentMessage | undefined;
268
- stateLastMessageContentRef: unknown;
269
- messagesRef: readonly AgentMessage[];
270
- messagesLength: number;
271
- lastMessageRef: AgentMessage | undefined;
272
- lastMessageRole: string | undefined;
273
- lastMessageTimestamp: number;
274
- lastMessageContentSize: number;
275
- lastMessageBlockCount: number;
276
- lastMessageUsageTotal: number;
277
- lastMessageStopReason: string | undefined;
278
- modelRef: unknown;
279
- sessionModelRef: unknown;
280
- sessionModelId: string | undefined;
281
- sessionModelContextWindow: Model["contextWindow"] | undefined;
282
- modelId: string | undefined;
283
- modelName: string | undefined;
284
- modelProvider: string | undefined;
285
- modelContextWindow: Model["contextWindow"] | undefined;
286
- modelThinking: Model["thinking"];
287
- thinkingLevel: unknown;
288
- modelCostRef: unknown;
289
- contextUsageRevision: number;
290
- systemPromptRef: unknown;
291
- systemPromptLength: number;
292
- systemPromptContentSize: number;
293
- toolsRef: readonly unknown[] | undefined;
294
- toolsLength: number;
295
- toolSchemaMetadataRevision: number;
296
- tokenizerRef: unknown;
297
- skillsRef: unknown;
298
- skillsLength: number;
299
- sessionName: string | undefined;
300
- sessionId: string | undefined;
301
- isStreaming: boolean | undefined;
302
- isAutoThinking: boolean | undefined;
303
- isFastModeActive: boolean;
304
- anthropicSlowModeLabel: string | undefined;
305
- compactionSpeculation: unknown;
258
+ /**
259
+ * Snapshot of the cheap external inputs a cached bar depends on. Mutable and
260
+ * refilled in place: the per-frame cache probe writes into a recycled
261
+ * instance instead of allocating a fresh ~50-field object every frame.
262
+ */
263
+ class StatusLineExternalInputs {
264
+ themeRef: unknown = undefined;
265
+ themeEpoch = 0;
266
+ projectDir = "";
267
+ sessionRef: StatusLineSession | undefined = undefined;
268
+ sessionFile: string | undefined = undefined;
269
+ sessionSettingsRef: unknown = undefined;
270
+ sessionSettingsRevision = 0;
271
+ globalSettingsRevision = 0;
272
+ stateRef: unknown = undefined;
273
+ stateMessagesRef: readonly AgentMessage[] = EMPTY_MESSAGES;
274
+ stateMessagesLength = 0;
275
+ stateLastMessageRef: AgentMessage | undefined = undefined;
276
+ stateLastMessageContentRef: unknown = undefined;
277
+ messagesRef: readonly AgentMessage[] = EMPTY_MESSAGES;
278
+ messagesLength = 0;
279
+ lastMessageRef: AgentMessage | undefined = undefined;
280
+ lastMessageRole: string | undefined = undefined;
281
+ lastMessageTimestamp = 0;
282
+ lastMessageContentSize = 0;
283
+ lastMessageBlockCount = 0;
284
+ lastMessageUsageTotal = 0;
285
+ lastMessageStopReason: string | undefined = undefined;
286
+ modelRef: unknown = undefined;
287
+ sessionModelRef: unknown = undefined;
288
+ sessionModelId: string | undefined = undefined;
289
+ sessionModelContextWindow: Model["contextWindow"] | undefined = undefined;
290
+ modelId: string | undefined = undefined;
291
+ modelName: string | undefined = undefined;
292
+ modelProvider: string | undefined = undefined;
293
+ modelContextWindow: Model["contextWindow"] | undefined = undefined;
294
+ modelThinking: Model["thinking"] = undefined;
295
+ thinkingLevel: unknown = undefined;
296
+ modelCostRef: unknown = undefined;
297
+ contextUsageRevision = 0;
298
+ systemPromptRef: unknown = undefined;
299
+ systemPromptLength = 0;
300
+ systemPromptContentSize = 0;
301
+ toolsRef: readonly unknown[] | undefined = undefined;
302
+ toolsLength = 0;
303
+ toolSchemaMetadataRevision = 0;
304
+ tokenizerRef: unknown = undefined;
305
+ skillsRef: unknown = undefined;
306
+ skillsLength = 0;
307
+ sessionName: string | undefined = undefined;
308
+ sessionId: string | undefined = undefined;
309
+ isStreaming: boolean | undefined = undefined;
310
+ isAutoThinking: boolean | undefined = undefined;
311
+ isFastModeActive = false;
312
+ anthropicSlowModeLabel: string | undefined = undefined;
313
+ compactionSpeculation: unknown = undefined;
306
314
  }
307
315
 
308
316
  interface CachedStatusLine {
@@ -312,7 +320,6 @@ interface CachedStatusLine {
312
320
  availableWidth: number;
313
321
  renderRevision: number;
314
322
  inputRevision: number;
315
- placeholders: boolean;
316
323
  previewTitle: string | undefined;
317
324
  externalInputs: StatusLineExternalInputs;
318
325
  }
@@ -420,8 +427,10 @@ function formatEmbeddedContextPercent(percent: number): string {
420
427
  return `${percent > 0 && percent < 1 ? percent.toFixed(1) : Math.round(percent)}%`;
421
428
  }
422
429
 
423
- function embeddedContextGaugeMinWidth(percent: number, contextWindow: number): number {
424
- return formatEmbeddedContextPercent(percent).length + formatNumber(contextWindow).length + 4;
430
+ /** Gap width the embedded gauge needs for its labels; an unknown percent (`null`) shows the window label alone. */
431
+ function embeddedContextGaugeMinWidth(percent: number | null, contextWindow: number): number {
432
+ const percentWidth = percent === null ? 0 : formatEmbeddedContextPercent(percent).length + 2;
433
+ return percentWidth + formatNumber(contextWindow).length + 2;
425
434
  }
426
435
 
427
436
  function hasGitSegment(segments: readonly StatusLineSegmentId[]): boolean {
@@ -441,6 +450,17 @@ function hasGitBackedSegment(segments: readonly StatusLineSegmentId[]): boolean
441
450
 
442
451
  type StatusLineLayout = "box" | "band" | "plain-full" | "plain-left" | "plain-right";
443
452
 
453
+ /**
454
+ * Git views of immutable `VcsRepo` handles. `asGit()` mints a fresh napi
455
+ * wrapper around the same backend handle on every call, which the per-frame
456
+ * branch lookup would otherwise pay each render.
457
+ */
458
+ const GIT_VIEW = Symbol("status-line.git-view");
459
+
460
+ interface GitViewTaggedRepo extends VcsRepo {
461
+ [GIT_VIEW]?: VcsGitRepo | null;
462
+ }
463
+
444
464
  // ═══════════════════════════════════════════════════════════════════════════
445
465
  // StatusLineComponent
446
466
  // ═══════════════════════════════════════════════════════════════════════════
@@ -461,6 +481,22 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
461
481
  };
462
482
  #statusLineInputRevision = 0;
463
483
  #statusLineClockTick = 0;
484
+ /** Reused probe buffer for {@link #readStatusLineExternalInputs}; handed to the new cache entry on a miss. */
485
+ #externalInputsProbe = new StatusLineExternalInputs();
486
+ /** `adjustHsv` result for the gauge's threshold tint; pure in its source hex. */
487
+ #dimmedAccentMemo: { sourceHex: string; hex: string } | undefined;
488
+ /** Gauge boundary markers; a pure function of the session's compaction settings, model, and window. */
489
+ #compactionBoundariesMemo:
490
+ | {
491
+ sessionRef: StatusLineSession;
492
+ contextWindow: number;
493
+ modelRef: unknown;
494
+ settingsRef: unknown;
495
+ settingsRevision: number;
496
+ globalSettingsRevision: number;
497
+ boundaries: CompactionBoundaries | null;
498
+ }
499
+ | undefined;
464
500
  #settings: StatusLineSettings = {};
465
501
  #effectiveSettings: EffectiveStatusLineSettings | undefined;
466
502
  #cachedBranch: string | null | undefined = undefined;
@@ -1066,8 +1102,7 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
1066
1102
  const sessionName = sessionAccentEnabled ? this.session.sessionManager?.getSessionName() : undefined;
1067
1103
  const idleHex = theme.getColorHex("dim");
1068
1104
  const workingHex =
1069
- (sessionName ? getSessionAccentHex(sessionName, theme.sessionAccentInputs) : undefined) ??
1070
- theme.getColorHex("accent");
1105
+ (sessionName && getSessionAccentHex(sessionName, theme.sessionAccentInputs)) || theme.getColorHex("accent");
1071
1106
  const now = Date.now();
1072
1107
  if (working !== this.#brandWorking) {
1073
1108
  const previousTargetHex = this.#brandWorking ? workingHex : idleHex;
@@ -1263,7 +1298,12 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
1263
1298
 
1264
1299
  const gitCwd = activeRepoCache.effectiveGitCwd;
1265
1300
  if (!repository) return null;
1266
- const gitRepository = repository.asGit();
1301
+ const taggedRepository: GitViewTaggedRepo = repository;
1302
+ let gitRepository = taggedRepository[GIT_VIEW];
1303
+ if (gitRepository === undefined) {
1304
+ gitRepository = repository.asGit();
1305
+ taggedRepository[GIT_VIEW] = gitRepository;
1306
+ }
1267
1307
  if (!gitRepository) {
1268
1308
  if (this.#jjBranchActive || Date.now() - this.#jjBranchLastFetch < JJ_REFRESH_TTL_MS) {
1269
1309
  return this.#cachedJjBranch;
@@ -1483,6 +1523,24 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
1483
1523
  return this.#cachedGitStatusCwd === gitCwd ? this.#cachedGitStatus : null;
1484
1524
  }
1485
1525
 
1526
+ /**
1527
+ * Seed working-tree status from the status line this one replaces (the
1528
+ * startup bar), so the dirty counts stay on screen instead of blanking until
1529
+ * this instance's first fetch lands. Normal TTL refreshes follow.
1530
+ */
1531
+ adoptGitStatus(source: StatusLineComponent): void {
1532
+ if (this.#cachedGitStatusCwd === undefined && source.#cachedGitStatusCwd !== undefined) {
1533
+ this.#cachedGitStatus = source.#cachedGitStatus;
1534
+ this.#cachedGitStatusCwd = source.#cachedGitStatusCwd;
1535
+ this.#gitStatusLastFetch = source.#gitStatusLastFetch;
1536
+ }
1537
+ if (this.#jjStatusLastFetch === 0 && source.#jjStatusLastFetch !== 0) {
1538
+ this.#cachedJjStatus = source.#cachedJjStatus;
1539
+ this.#jjStatusLastFetch = source.#jjStatusLastFetch;
1540
+ }
1541
+ this.#invalidateStatusLineRenderCache();
1542
+ }
1543
+
1486
1544
  #lookupPr(activeRepoCache: ActiveRepoCache = this.#resolveActiveRepoCache()): {
1487
1545
  number: number;
1488
1546
  url: string;
@@ -2088,6 +2146,7 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2088
2146
  const usedTokens = usage?.tokens ?? 0;
2089
2147
  const contextWindow = usage?.contextWindow ?? modelContextWindow;
2090
2148
  this.#contextUsageCache = {
2149
+ percentUnknown: usage?.percent === null,
2091
2150
  messagesRef: messages,
2092
2151
  length,
2093
2152
  lastFingerprint,
@@ -2141,7 +2200,10 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2141
2200
  const breakdown = this.getCachedContextBreakdown();
2142
2201
  let contextTokens = breakdown.usedTokens;
2143
2202
  contextWindow = breakdown.contextWindow || contextWindow;
2144
- let contextPercent: number | null = contextWindow > 0 ? (breakdown.usedTokens / contextWindow) * 100 : null;
2203
+ let contextPercent: number | null =
2204
+ contextWindow > 0 && !this.#contextUsageCache?.percentUnknown
2205
+ ? (breakdown.usedTokens / contextWindow) * 100
2206
+ : null;
2145
2207
  // Collab guest: context comes from the host's state frames — the local
2146
2208
  // replica does no accounting of its own.
2147
2209
  const collabState = this.#collabStatus?.stateOverride;
@@ -2268,8 +2330,9 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2268
2330
  * going through this component's setters. This deliberately uses refs,
2269
2331
  * scalars, and string lengths only: no schema estimation, serialization,
2270
2332
  * usage aggregation, VCS probing, or segment getters run on a cache hit.
2333
+ * Fills `target` in place so the per-frame probe allocates nothing.
2271
2334
  */
2272
- #readStatusLineExternalInputs(): StatusLineExternalInputs {
2335
+ #readStatusLineExternalInputs(target: StatusLineExternalInputs): void {
2273
2336
  const state = this.session.state;
2274
2337
  const stateMessages = state.messages ?? EMPTY_MESSAGES;
2275
2338
  const stateLastMessage = stateMessages[stateMessages.length - 1];
@@ -2320,62 +2383,61 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2320
2383
  if (typeof part === "string") systemPromptContentSize += part.length;
2321
2384
  }
2322
2385
  const tools = this.session.agent?.state?.tools;
2323
- return {
2324
- themeRef: theme,
2325
- themeEpoch: getThemeEpoch(),
2326
- projectDir: getProjectDir(),
2327
- sessionRef: this.session,
2328
- sessionFile: this.session.sessionFile,
2329
- sessionSettingsRef: this.host.getSessionSettingsIdentity(this.session),
2330
- sessionSettingsRevision: this.host.getSessionSettingsRevision(this.session),
2331
- globalSettingsRevision: this.host.getSettingsRevision(),
2332
- stateRef: state,
2333
- stateMessagesRef: stateMessages,
2334
- stateMessagesLength: stateMessages.length,
2335
- stateLastMessageRef: stateLastMessage,
2336
- stateLastMessageContentRef: stateLastMessageContent,
2337
- messagesRef: messages,
2338
- messagesLength: messages.length,
2339
- lastMessageRef: lastMessage,
2340
- lastMessageRole: lastMessageView?.role,
2341
- lastMessageTimestamp: lastMessageView?.timestamp ?? 0,
2342
- lastMessageContentSize,
2343
- lastMessageBlockCount,
2344
- lastMessageUsageTotal: lastMessageView?.usage?.totalTokens ?? 0,
2345
- lastMessageStopReason: lastMessageView?.stopReason,
2346
- modelRef: model,
2347
- sessionModelRef: sessionModel,
2348
- sessionModelId: sessionModel?.id,
2349
- sessionModelContextWindow: sessionModel?.contextWindow,
2350
- modelId: model?.id,
2351
- modelName: model?.name,
2352
- modelProvider: model?.provider,
2353
- modelContextWindow: model?.contextWindow,
2354
- modelThinking: model?.thinking,
2355
- thinkingLevel: state.thinkingLevel,
2356
- modelCostRef: model?.cost,
2357
- contextUsageRevision: this.session.contextUsageRevision ?? 0,
2358
- systemPromptRef: systemPrompt,
2359
- systemPromptLength: systemPrompt?.length ?? 0,
2360
- systemPromptContentSize,
2361
- toolsRef: tools,
2362
- toolsLength: tools?.length ?? 0,
2363
- toolSchemaMetadataRevision: tools ? getToolSchemaMetadataRevision(tools) : 0,
2364
- tokenizerRef: this.session.agent?.tokenizer,
2365
- skillsRef: this.session.skills,
2366
- skillsLength: this.session.skills?.length ?? 0,
2367
- sessionName: this.session.sessionManager?.getSessionName?.(),
2368
- sessionId: this.session.sessionManager?.getSessionId?.(),
2369
- isStreaming: this.session.isStreaming,
2370
- isAutoThinking: this.session.isAutoThinking,
2371
- isFastModeActive:
2372
- typeof this.session.isFastModeActive === "function" ? this.session.isFastModeActive() : false,
2373
- anthropicSlowModeLabel:
2374
- typeof this.session.getAnthropicSlowModeLabel === "function"
2375
- ? this.session.getAnthropicSlowModeLabel()
2376
- : undefined,
2377
- compactionSpeculation: this.session.compactionSpeculation,
2378
- };
2386
+ const skills = this.session.skills;
2387
+ target.themeRef = theme;
2388
+ target.themeEpoch = getThemeEpoch();
2389
+ target.projectDir = getProjectDir();
2390
+ target.sessionRef = this.session;
2391
+ target.sessionFile = this.session.sessionFile;
2392
+ target.sessionSettingsRef = this.host.getSessionSettingsIdentity(this.session);
2393
+ target.sessionSettingsRevision = this.host.getSessionSettingsRevision(this.session);
2394
+ target.globalSettingsRevision = this.host.getSettingsRevision();
2395
+ target.stateRef = state;
2396
+ target.stateMessagesRef = stateMessages;
2397
+ target.stateMessagesLength = stateMessages.length;
2398
+ target.stateLastMessageRef = stateLastMessage;
2399
+ target.stateLastMessageContentRef = stateLastMessageContent;
2400
+ target.messagesRef = messages;
2401
+ target.messagesLength = messages.length;
2402
+ target.lastMessageRef = lastMessage;
2403
+ target.lastMessageRole = lastMessageView?.role;
2404
+ target.lastMessageTimestamp = lastMessageView?.timestamp ?? 0;
2405
+ target.lastMessageContentSize = lastMessageContentSize;
2406
+ target.lastMessageBlockCount = lastMessageBlockCount;
2407
+ target.lastMessageUsageTotal = lastMessageView?.usage?.totalTokens ?? 0;
2408
+ target.lastMessageStopReason = lastMessageView?.stopReason;
2409
+ target.modelRef = model;
2410
+ target.sessionModelRef = sessionModel;
2411
+ target.sessionModelId = sessionModel?.id;
2412
+ target.sessionModelContextWindow = sessionModel?.contextWindow;
2413
+ target.modelId = model?.id;
2414
+ target.modelName = model?.name;
2415
+ target.modelProvider = model?.provider;
2416
+ target.modelContextWindow = model?.contextWindow;
2417
+ target.modelThinking = model?.thinking;
2418
+ target.thinkingLevel = state.thinkingLevel;
2419
+ target.modelCostRef = model?.cost;
2420
+ target.contextUsageRevision = this.session.contextUsageRevision ?? 0;
2421
+ target.systemPromptRef = systemPrompt;
2422
+ target.systemPromptLength = systemPrompt?.length ?? 0;
2423
+ target.systemPromptContentSize = systemPromptContentSize;
2424
+ target.toolsRef = tools;
2425
+ target.toolsLength = tools?.length ?? 0;
2426
+ target.toolSchemaMetadataRevision = tools ? getToolSchemaMetadataRevision(tools) : 0;
2427
+ target.tokenizerRef = this.session.agent?.tokenizer;
2428
+ target.skillsRef = skills;
2429
+ target.skillsLength = skills?.length ?? 0;
2430
+ target.sessionName = this.session.sessionManager?.getSessionName?.();
2431
+ target.sessionId = this.session.sessionManager?.getSessionId?.();
2432
+ target.isStreaming = this.session.isStreaming;
2433
+ target.isAutoThinking = this.session.isAutoThinking;
2434
+ target.isFastModeActive =
2435
+ typeof this.session.isFastModeActive === "function" ? this.session.isFastModeActive() : false;
2436
+ target.anthropicSlowModeLabel =
2437
+ typeof this.session.getAnthropicSlowModeLabel === "function"
2438
+ ? this.session.getAnthropicSlowModeLabel()
2439
+ : undefined;
2440
+ target.compactionSpeculation = this.session.compactionSpeculation;
2379
2441
  }
2380
2442
 
2381
2443
  #sameStatusLineExternalInputs(left: StatusLineExternalInputs, right: StatusLineExternalInputs): boolean {
@@ -2439,8 +2501,7 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2439
2501
  * clocks, countdowns, and VCS fallback polling advance only at their own
2440
2502
  * display/probe cadence.
2441
2503
  */
2442
- #statusLineClock(nowMs: number, effectiveSettings: EffectiveStatusLineSettings, placeholders: boolean): number {
2443
- if (placeholders) return 0;
2504
+ #statusLineClock(nowMs: number, effectiveSettings: EffectiveStatusLineSettings): number {
2444
2505
  const leftSegments = effectiveSettings.leftSegments;
2445
2506
  const rightSegments = effectiveSettings.rightSegments;
2446
2507
  const meter = this.#meter();
@@ -2469,17 +2530,12 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2469
2530
  return 0;
2470
2531
  }
2471
2532
 
2472
- #buildStatusLine(
2473
- width: number,
2474
- layout: StatusLineLayout = "box",
2475
- previewTitle?: string,
2476
- options?: { readonly placeholders?: boolean },
2477
- ): CachedStatusLine {
2533
+ #buildStatusLine(width: number, layout: StatusLineLayout = "box", previewTitle?: string): CachedStatusLine {
2478
2534
  const effectiveSettings = this.#resolveSettings();
2479
- const placeholders = options?.placeholders === true;
2480
- const externalInputs = this.#readStatusLineExternalInputs();
2535
+ const externalInputs = this.#externalInputsProbe;
2536
+ this.#readStatusLineExternalInputs(externalInputs);
2481
2537
  const nowMs = Date.now();
2482
- const clockTick = this.#statusLineClock(nowMs, effectiveSettings, placeholders);
2538
+ const clockTick = this.#statusLineClock(nowMs, effectiveSettings);
2483
2539
  if (clockTick !== this.#statusLineClockTick) {
2484
2540
  this.#statusLineClockTick = clockTick;
2485
2541
  this.#invalidateStatusLineRenderCache();
@@ -2491,14 +2547,15 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2491
2547
  cached.availableWidth === width &&
2492
2548
  cached.renderRevision === this.#renderRevision &&
2493
2549
  cached.inputRevision === this.#statusLineInputRevision &&
2494
- cached.placeholders === placeholders &&
2495
2550
  cached.previewTitle === previewTitle &&
2496
2551
  this.#sameStatusLineExternalInputs(cached.externalInputs, externalInputs)
2497
2552
  ) {
2498
2553
  return cached;
2499
2554
  }
2500
2555
 
2501
- const content = this.#renderStatusLine(width, layout, previewTitle, options, nowMs);
2556
+ // The probe now belongs to the new cache entry; later probes need their own buffer.
2557
+ this.#externalInputsProbe = new StatusLineExternalInputs();
2558
+ const content = this.#renderStatusLine(width, layout, previewTitle, nowMs);
2502
2559
  const result = {
2503
2560
  content,
2504
2561
  dimmedContent: this.#dimWhileFocusProxied(content),
@@ -2506,7 +2563,6 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2506
2563
  availableWidth: width,
2507
2564
  renderRevision: this.#renderRevision,
2508
2565
  inputRevision: this.#statusLineInputRevision,
2509
- placeholders,
2510
2566
  previewTitle,
2511
2567
  externalInputs,
2512
2568
  };
@@ -2529,16 +2585,9 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2529
2585
  * `previewTitle` is a stand-in session title for composer previews; the
2530
2586
  * `session_name` segment renders it when the session is unnamed.
2531
2587
  */
2532
- #renderStatusLine(
2533
- width: number,
2534
- layout: StatusLineLayout,
2535
- previewTitle: string | undefined,
2536
- options: { readonly placeholders?: boolean } | undefined,
2537
- nowMs: number,
2538
- ): string {
2588
+ #renderStatusLine(width: number, layout: StatusLineLayout, previewTitle: string | undefined, nowMs: number): string {
2539
2589
  const effectiveSettings = this.#resolveSettings();
2540
2590
  this.#syncPricingTimer();
2541
- const placeholders = options?.placeholders === true;
2542
2591
  const plain = layout !== "box" && layout !== "band";
2543
2592
  const includePath =
2544
2593
  hasPathSegment(effectiveSettings.leftSegments) || hasPathSegment(effectiveSettings.rightSegments);
@@ -2548,7 +2597,7 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2548
2597
  (hasGitSegment(effectiveSettings.leftSegments) || hasGitSegment(effectiveSettings.rightSegments));
2549
2598
  const includePr =
2550
2599
  gitEnabled && (hasPrSegment(effectiveSettings.leftSegments) || hasPrSegment(effectiveSettings.rightSegments));
2551
- const liveCtx = this.#buildSegmentContext(
2600
+ const ctx = this.#buildSegmentContext(
2552
2601
  width,
2553
2602
  effectiveSettings.segmentOptions,
2554
2603
  includePath,
@@ -2557,7 +2606,6 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2557
2606
  nowMs,
2558
2607
  previewTitle,
2559
2608
  );
2560
- const ctx: SegmentContext = placeholders ? { ...liveCtx, startupPlaceholder: true } : liveCtx;
2561
2609
  const separatorDef = plain
2562
2610
  ? { left: "·", right: "·" }
2563
2611
  : getSeparator(effectiveSettings.separator ?? "powerline-thin", theme);
@@ -2609,8 +2657,6 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2609
2657
  const embedContext =
2610
2658
  !plain &&
2611
2659
  effectiveSettings.contextLine === "embedded" &&
2612
- ctx.contextPercent !== null &&
2613
- ctx.contextPercent !== undefined &&
2614
2660
  ctx.contextWindow > 0 &&
2615
2661
  (hasContextSegment(leftSegIds) || hasContextSegment(rightSegIds)) &&
2616
2662
  (hasNonContextSegment(leftSegIds) || hasNonContextSegment(rightSegIds));
@@ -2630,17 +2676,16 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2630
2676
  job => job.type !== "task" || job.agentId === undefined || !this.#runningSubagentIds.has(job.agentId),
2631
2677
  ).length ?? 0;
2632
2678
  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);
2679
+ rightParts.unshift(theme.fg("statusLineSubagents", `${theme.icon.job} ${runningBackgroundJobs}`));
2639
2680
  }
2681
+ if (subagentBadge) rightParts.unshift(subagentBadge);
2640
2682
  }
2641
2683
  const topFillWidth = Math.max(0, width);
2642
- const left = [...leftParts];
2643
- const right = [...rightParts];
2684
+ // These arrays are local to this render; overflow handling can mutate them.
2685
+ const left = leftParts;
2686
+ const right = rightParts;
2687
+ const leftWidths = left.map(part => visibleWidth(part));
2688
+ const rightWidths = right.map(part => visibleWidth(part));
2644
2689
 
2645
2690
  const leftSepWidth = visibleWidth(separatorDef.left);
2646
2691
  const rightSepWidth = visibleWidth(separatorDef.right);
@@ -2654,23 +2699,21 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2654
2699
  const bandCap = layout === "band" && separatorDef.endCaps && !transparentBg ? theme.sep.powerlineCapLeft : "";
2655
2700
  const bandCapWidth = visibleWidth(bandCap);
2656
2701
 
2657
- const groupWidth = (parts: string[], capWidth: number, sepWidth: number): number => {
2658
- if (parts.length === 0) return 0;
2659
- const partsWidth = parts.reduce((sum, part) => sum + visibleWidth(part), 0);
2660
- const sepTotal = Math.max(0, parts.length - 1) * (sepWidth + 2);
2702
+ const groupWidth = (widths: number[], capWidth: number, sepWidth: number): number => {
2703
+ if (widths.length === 0) return 0;
2704
+ const partsWidth = widths.reduce((sum, partWidth) => sum + partWidth, 0);
2705
+ const sepTotal = (widths.length - 1) * (sepWidth + 2);
2661
2706
  return partsWidth + sepTotal + 2 + capWidth;
2662
2707
  };
2663
2708
 
2664
- let leftWidth = groupWidth(left, leftCapWidth + bandCapWidth, leftSepWidth);
2665
- let rightWidth = groupWidth(right, rightCapWidth, rightSepWidth);
2709
+ let leftWidth = groupWidth(leftWidths, leftCapWidth + bandCapWidth, leftSepWidth);
2710
+ let rightWidth = groupWidth(rightWidths, rightCapWidth, rightSepWidth);
2666
2711
  // Embedded mode removes the standalone context segment before overflow
2667
2712
  // handling, so the gauge must reserve enough room for both labels. Without
2668
2713
  // this budget a long path/session title can leave a one-cell gap: the
2669
2714
  // context segment is gone, and the gauge silently omits its labels too.
2670
2715
  const embeddedContextWidth = embedContext
2671
- ? ctx.startupPlaceholder
2672
- ? "…%".length + "…".length + 4
2673
- : embeddedContextGaugeMinWidth(ctx.contextPercent ?? 0, ctx.contextWindow)
2716
+ ? embeddedContextGaugeMinWidth(ctx.contextPercent, ctx.contextWindow)
2674
2717
  : 0;
2675
2718
  const minimumGapWidth = (): number => {
2676
2719
  if (!embeddedContextWidth) return left.length > 0 && right.length > 0 ? 1 : 0;
@@ -2693,23 +2736,27 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2693
2736
  if (nameSegIdx >= 0 && totalWidth() > topFillWidth) {
2694
2737
  // Badge/job parts were unshifted ahead of the tracked segment ids.
2695
2738
  const nameIdx = nameSegIdx + (right.length - rightSegIds.length);
2696
- const currentNameVW = visibleWidth(right[nameIdx]);
2739
+ const currentNameVW = rightWidths[nameIdx];
2697
2740
  const minNameVW = 8;
2698
2741
  const shrinkBy = Math.min(Math.max(0, currentNameVW - minNameVW), totalWidth() - topFillWidth);
2699
2742
  if (shrinkBy > 0) {
2700
2743
  right[nameIdx] = truncateToWidth(right[nameIdx], currentNameVW - shrinkBy);
2701
- rightWidth = groupWidth(right, rightCapWidth, rightSepWidth);
2744
+ const nameWidth = visibleWidth(right[nameIdx]);
2745
+ rightWidth += nameWidth - currentNameVW;
2746
+ rightWidths[nameIdx] = nameWidth;
2702
2747
  }
2703
2748
  }
2704
2749
  while (totalWidth() > topFillWidth && right.length > 0) {
2750
+ const removedWidth = rightWidths[right.length - 1];
2705
2751
  right.pop();
2706
- rightWidth = groupWidth(right, rightCapWidth, rightSepWidth);
2752
+ rightWidths.pop();
2753
+ rightWidth = right.length > 0 ? rightWidth - removedWidth - rightSepWidth - 2 : 0;
2707
2754
  }
2708
2755
  // Shrink path before dropping left segments — path is the only elastic segment
2709
2756
  const pathIdx = leftSegIds.indexOf("path");
2710
2757
  if (pathIdx >= 0 && totalWidth() > topFillWidth) {
2711
2758
  const overflow = totalWidth() - topFillWidth;
2712
- const currentPathVW = visibleWidth(left[pathIdx]);
2759
+ const currentPathVW = leftWidths[pathIdx];
2713
2760
  const minPathVW = 8; // icon + ellipsis + a few chars
2714
2761
  const shrinkable = currentPathVW - minPathVW;
2715
2762
  if (shrinkable > 0) {
@@ -2722,9 +2769,10 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2722
2769
  });
2723
2770
  let reRendered = renderSegment("path", pathCtx(newMaxLen));
2724
2771
  if (reRendered.visible && reRendered.content) {
2772
+ let pathWidth = visibleWidth(reRendered.content);
2725
2773
  // maxLength governs path text, not icon prefix; iterate to compensate
2726
2774
  for (let i = 0; i < 8; i++) {
2727
- const saved = currentPathVW - visibleWidth(reRendered.content);
2775
+ const saved = currentPathVW - pathWidth;
2728
2776
  if (saved >= shrinkBy) break;
2729
2777
  const nextMaxLen = Math.max(4, newMaxLen - (shrinkBy - saved));
2730
2778
  if (nextMaxLen >= newMaxLen) break; // no progress or hit floor
@@ -2732,9 +2780,11 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2732
2780
  const adjusted = renderSegment("path", pathCtx(newMaxLen));
2733
2781
  if (!adjusted.visible || !adjusted.content) break;
2734
2782
  reRendered = adjusted;
2783
+ pathWidth = visibleWidth(adjusted.content);
2735
2784
  }
2736
2785
  left[pathIdx] = reRendered.content;
2737
- leftWidth = groupWidth(left, leftCapWidth + bandCapWidth, leftSepWidth);
2786
+ leftWidth += pathWidth - currentPathVW;
2787
+ leftWidths[pathIdx] = pathWidth;
2738
2788
  }
2739
2789
  }
2740
2790
  }
@@ -2751,9 +2801,11 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2751
2801
 
2752
2802
  while (totalWidth() > topFillWidth && left.length > 0) {
2753
2803
  const dropIdx = leftOverflowDropIndex();
2804
+ const removedWidth = leftWidths[dropIdx];
2754
2805
  left.splice(dropIdx, 1);
2806
+ leftWidths.splice(dropIdx, 1);
2755
2807
  leftSegIds.splice(dropIdx, 1);
2756
- leftWidth = groupWidth(left, leftCapWidth + bandCapWidth, leftSepWidth);
2808
+ leftWidth = left.length > 0 ? leftWidth - removedWidth - leftSepWidth - 2 : 0;
2757
2809
  }
2758
2810
  }
2759
2811
 
@@ -2824,11 +2876,13 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2824
2876
  const horizontal = theme.boxRound.horizontal;
2825
2877
  const mode = effectiveSettings.contextLine ?? "embedded";
2826
2878
  const pct = ctx.contextPercent;
2827
- if (mode === "off" || pct === null || pct === undefined) {
2879
+ if (mode === "off" || (pct === null && ctx.contextWindow <= 0)) {
2828
2880
  return `\x1b[49m${usedColor}${horizontal.repeat(gapWidth)}\x1b[39m`;
2829
2881
  }
2830
2882
 
2831
- const clampedPct = Math.min(100, Math.max(0, pct));
2883
+ // Unknown usage against a known window (startup prepaint) draws the fresh-session
2884
+ // gauge: one lit cell and the window label, no percent.
2885
+ const clampedPct = pct === null ? 0 : Math.min(100, Math.max(0, pct));
2832
2886
  let percentLabel = "";
2833
2887
  let windowLabel = "";
2834
2888
  let percentStart = -1;
@@ -2837,14 +2891,11 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2837
2891
  // >100%: usage anchored past the active window (e.g. model switch to a
2838
2892
  // smaller window). The bar clamps full, but the embedded label breaks
2839
2893
  // past the window label — `──200K─120%` with the percent in error color.
2840
- const percentOverflow = pct > 100;
2894
+ const percentOverflow = pct !== null && pct > 100;
2841
2895
  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) {
2896
+ const candidatePercent = pct === null ? "" : formatEmbeddedContextPercent(percentOverflow ? pct : clampedPct);
2897
+ const candidateWindow = formatNumber(ctx.contextWindow);
2898
+ if (gapWidth >= embeddedContextGaugeMinWidth(pct, ctx.contextWindow)) {
2848
2899
  percentLabel = candidatePercent;
2849
2900
  windowLabel = candidateWindow;
2850
2901
  if (percentOverflow) {
@@ -2906,8 +2957,12 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2906
2957
  const speculationColor = theme.getFgAnsi("muted");
2907
2958
  const overflowColor = theme.getFgAnsi("error");
2908
2959
  const rawAccentHex = accentHex ?? theme.getColorHex("borderAccent");
2909
- const dimmedAccentHex = adjustHsv(rawAccentHex, { s: 0.7, v: 0.75 });
2910
- const thresholdColor = getSessionAccentAnsi(dimmedAccentHex) ?? usedColor;
2960
+ let dimmedAccent = this.#dimmedAccentMemo;
2961
+ if (dimmedAccent?.sourceHex !== rawAccentHex) {
2962
+ dimmedAccent = { sourceHex: rawAccentHex, hex: adjustHsv(rawAccentHex, { s: 0.7, v: 0.75 }) };
2963
+ this.#dimmedAccentMemo = dimmedAccent;
2964
+ }
2965
+ const thresholdColor = getSessionAccentAnsi(dimmedAccent.hex) ?? usedColor;
2911
2966
 
2912
2967
  let out = "\x1b[49m";
2913
2968
  let activeColor = "";
@@ -2938,17 +2993,66 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2938
2993
 
2939
2994
  /** Auto-compaction boundary percents, or null when unavailable (disabled, no window). */
2940
2995
  #compactionBoundaries(contextWindow: number): CompactionBoundaries | null {
2941
- const model = this.session.state?.model ?? this.session.model;
2996
+ const session = this.session;
2997
+ const model = session.state?.model ?? session.model;
2998
+ const settingsRef = this.host.getSessionSettingsIdentity(session);
2999
+ const settingsRevision = this.host.getSessionSettingsRevision(session);
3000
+ const globalSettingsRevision = this.host.getSettingsRevision();
3001
+ const memo = this.#compactionBoundariesMemo;
3002
+ if (
3003
+ memo &&
3004
+ memo.sessionRef === session &&
3005
+ memo.contextWindow === contextWindow &&
3006
+ memo.modelRef === model &&
3007
+ memo.settingsRef === settingsRef &&
3008
+ memo.settingsRevision === settingsRevision &&
3009
+ memo.globalSettingsRevision === globalSettingsRevision
3010
+ ) {
3011
+ return memo.boundaries;
3012
+ }
2942
3013
  try {
2943
- return this.host.computeCompactionBoundaries(this.session, contextWindow, model);
3014
+ const boundaries = this.host.computeCompactionBoundaries(session, contextWindow, model);
3015
+ this.#compactionBoundariesMemo = {
3016
+ sessionRef: session,
3017
+ contextWindow,
3018
+ modelRef: model,
3019
+ settingsRef,
3020
+ settingsRevision,
3021
+ globalSettingsRevision,
3022
+ boundaries,
3023
+ };
3024
+ return boundaries;
2944
3025
  } catch {
2945
3026
  return null;
2946
3027
  }
2947
3028
  }
2948
3029
 
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;
3030
+ /**
3031
+ * Wire this bar into `editor` for a composer layout: the matching top-border
3032
+ * provider, the autocomplete probe, and the standalone bottom-bar placement.
3033
+ * Callers re-run it whenever the composer shape changes.
3034
+ */
3035
+ attachToEditor(
3036
+ editor: Pick<Editor, "isAutocompleteActive" | "setTopBorderProvider" | "setTopBorder">,
3037
+ style: Pick<ComposerStyle, "statusAttachment" | "bottomBar" | "bottomBarGap">,
3038
+ ): void {
3039
+ this.setAutocompleteActiveProbe(() => editor.isAutocompleteActive());
3040
+ switch (style.statusAttachment) {
3041
+ case "top-border":
3042
+ editor.setTopBorderProvider(availableWidth => this.getTopBorder(availableWidth));
3043
+ break;
3044
+ case "top-band":
3045
+ editor.setTopBorderProvider(availableWidth => this.getBandTopBorder(availableWidth));
3046
+ break;
3047
+ case "top-rule-chip":
3048
+ editor.setTopBorderProvider(availableWidth => this.getStandaloneTopBorder(availableWidth));
3049
+ break;
3050
+ case "none":
3051
+ editor.setTopBorderProvider(undefined);
3052
+ editor.setTopBorder(undefined);
3053
+ break;
3054
+ }
3055
+ this.setComposerStyle(style);
2952
3056
  }
2953
3057
 
2954
3058
  getTopBorder(width: number, previewTitle?: string): { content: string; width: number; revision: number } {