@oh-my-pi/pi-tui 18.4.1 → 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 (37) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/types/chrome/transcript-container.d.ts +15 -1
  3. package/dist/types/overlays/model-browser.d.ts +15 -0
  4. package/dist/types/overlays/rewind-selector.d.ts +2 -1
  5. package/dist/types/prompt/composer-attachments.d.ts +7 -0
  6. package/dist/types/prompt/model-mention-autocomplete.d.ts +4 -1
  7. package/dist/types/render/width-aware-text.d.ts +6 -0
  8. package/dist/types/terminal.d.ts +7 -0
  9. package/dist/types/theme/color.d.ts +1 -0
  10. package/dist/types/theme/session-color.d.ts +3 -3
  11. package/dist/types/theme/shimmer.d.ts +1 -1
  12. package/dist/types/theme/theme-class.d.ts +1 -1
  13. package/dist/types/tui.d.ts +3 -2
  14. package/package.json +9 -9
  15. package/src/chat/tool-execution.ts +24 -4
  16. package/src/chrome/transcript-container.ts +76 -14
  17. package/src/components/editor.ts +28 -6
  18. package/src/components/image.ts +15 -7
  19. package/src/components/loader.ts +37 -17
  20. package/src/overlays/agent-hub.ts +16 -6
  21. package/src/overlays/copy-selector.ts +5 -0
  22. package/src/overlays/model-browser.ts +106 -25
  23. package/src/overlays/rewind-selector.ts +10 -2
  24. package/src/prompt/composer-attachments.ts +54 -7
  25. package/src/prompt/composer.ts +5 -2
  26. package/src/prompt/custom-editor.ts +24 -6
  27. package/src/prompt/model-mention-autocomplete.ts +22 -10
  28. package/src/render/width-aware-text.ts +9 -0
  29. package/src/status-line/component.ts +213 -132
  30. package/src/status-line/segments.ts +34 -29
  31. package/src/terminal.ts +37 -11
  32. package/src/theme/color.ts +15 -0
  33. package/src/theme/session-color.ts +26 -4
  34. package/src/theme/shimmer.ts +55 -76
  35. package/src/theme/theme-class.ts +29 -12
  36. package/src/tui.ts +142 -112
  37. 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,
@@ -255,57 +255,62 @@ interface ContextUsageMemo {
255
255
  skillsRef: readonly any[] | undefined;
256
256
  }
257
257
 
258
- interface StatusLineExternalInputs {
259
- themeRef: unknown;
260
- themeEpoch: number;
261
- projectDir: string;
262
- sessionRef: StatusLineSession;
263
- sessionFile: string | undefined;
264
- sessionSettingsRef: unknown;
265
- sessionSettingsRevision: number;
266
- globalSettingsRevision: number;
267
- stateRef: unknown;
268
- stateMessagesRef: readonly AgentMessage[];
269
- stateMessagesLength: number;
270
- stateLastMessageRef: AgentMessage | undefined;
271
- stateLastMessageContentRef: unknown;
272
- messagesRef: readonly AgentMessage[];
273
- messagesLength: number;
274
- lastMessageRef: AgentMessage | undefined;
275
- lastMessageRole: string | undefined;
276
- lastMessageTimestamp: number;
277
- lastMessageContentSize: number;
278
- lastMessageBlockCount: number;
279
- lastMessageUsageTotal: number;
280
- lastMessageStopReason: string | undefined;
281
- modelRef: unknown;
282
- sessionModelRef: unknown;
283
- sessionModelId: string | undefined;
284
- sessionModelContextWindow: Model["contextWindow"] | undefined;
285
- modelId: string | undefined;
286
- modelName: string | undefined;
287
- modelProvider: string | undefined;
288
- modelContextWindow: Model["contextWindow"] | undefined;
289
- modelThinking: Model["thinking"];
290
- thinkingLevel: unknown;
291
- modelCostRef: unknown;
292
- contextUsageRevision: number;
293
- systemPromptRef: unknown;
294
- systemPromptLength: number;
295
- systemPromptContentSize: number;
296
- toolsRef: readonly unknown[] | undefined;
297
- toolsLength: number;
298
- toolSchemaMetadataRevision: number;
299
- tokenizerRef: unknown;
300
- skillsRef: unknown;
301
- skillsLength: number;
302
- sessionName: string | undefined;
303
- sessionId: string | undefined;
304
- isStreaming: boolean | undefined;
305
- isAutoThinking: boolean | undefined;
306
- isFastModeActive: boolean;
307
- anthropicSlowModeLabel: string | undefined;
308
- 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;
309
314
  }
310
315
 
311
316
  interface CachedStatusLine {
@@ -445,6 +450,17 @@ function hasGitBackedSegment(segments: readonly StatusLineSegmentId[]): boolean
445
450
 
446
451
  type StatusLineLayout = "box" | "band" | "plain-full" | "plain-left" | "plain-right";
447
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
+
448
464
  // ═══════════════════════════════════════════════════════════════════════════
449
465
  // StatusLineComponent
450
466
  // ═══════════════════════════════════════════════════════════════════════════
@@ -465,6 +481,22 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
465
481
  };
466
482
  #statusLineInputRevision = 0;
467
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;
468
500
  #settings: StatusLineSettings = {};
469
501
  #effectiveSettings: EffectiveStatusLineSettings | undefined;
470
502
  #cachedBranch: string | null | undefined = undefined;
@@ -1070,8 +1102,7 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
1070
1102
  const sessionName = sessionAccentEnabled ? this.session.sessionManager?.getSessionName() : undefined;
1071
1103
  const idleHex = theme.getColorHex("dim");
1072
1104
  const workingHex =
1073
- (sessionName ? getSessionAccentHex(sessionName, theme.sessionAccentInputs) : undefined) ??
1074
- theme.getColorHex("accent");
1105
+ (sessionName && getSessionAccentHex(sessionName, theme.sessionAccentInputs)) || theme.getColorHex("accent");
1075
1106
  const now = Date.now();
1076
1107
  if (working !== this.#brandWorking) {
1077
1108
  const previousTargetHex = this.#brandWorking ? workingHex : idleHex;
@@ -1267,7 +1298,12 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
1267
1298
 
1268
1299
  const gitCwd = activeRepoCache.effectiveGitCwd;
1269
1300
  if (!repository) return null;
1270
- 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
+ }
1271
1307
  if (!gitRepository) {
1272
1308
  if (this.#jjBranchActive || Date.now() - this.#jjBranchLastFetch < JJ_REFRESH_TTL_MS) {
1273
1309
  return this.#cachedJjBranch;
@@ -2294,8 +2330,9 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2294
2330
  * going through this component's setters. This deliberately uses refs,
2295
2331
  * scalars, and string lengths only: no schema estimation, serialization,
2296
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.
2297
2334
  */
2298
- #readStatusLineExternalInputs(): StatusLineExternalInputs {
2335
+ #readStatusLineExternalInputs(target: StatusLineExternalInputs): void {
2299
2336
  const state = this.session.state;
2300
2337
  const stateMessages = state.messages ?? EMPTY_MESSAGES;
2301
2338
  const stateLastMessage = stateMessages[stateMessages.length - 1];
@@ -2346,62 +2383,61 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2346
2383
  if (typeof part === "string") systemPromptContentSize += part.length;
2347
2384
  }
2348
2385
  const tools = this.session.agent?.state?.tools;
2349
- return {
2350
- themeRef: theme,
2351
- themeEpoch: getThemeEpoch(),
2352
- projectDir: getProjectDir(),
2353
- sessionRef: this.session,
2354
- sessionFile: this.session.sessionFile,
2355
- sessionSettingsRef: this.host.getSessionSettingsIdentity(this.session),
2356
- sessionSettingsRevision: this.host.getSessionSettingsRevision(this.session),
2357
- globalSettingsRevision: this.host.getSettingsRevision(),
2358
- stateRef: state,
2359
- stateMessagesRef: stateMessages,
2360
- stateMessagesLength: stateMessages.length,
2361
- stateLastMessageRef: stateLastMessage,
2362
- stateLastMessageContentRef: stateLastMessageContent,
2363
- messagesRef: messages,
2364
- messagesLength: messages.length,
2365
- lastMessageRef: lastMessage,
2366
- lastMessageRole: lastMessageView?.role,
2367
- lastMessageTimestamp: lastMessageView?.timestamp ?? 0,
2368
- lastMessageContentSize,
2369
- lastMessageBlockCount,
2370
- lastMessageUsageTotal: lastMessageView?.usage?.totalTokens ?? 0,
2371
- lastMessageStopReason: lastMessageView?.stopReason,
2372
- modelRef: model,
2373
- sessionModelRef: sessionModel,
2374
- sessionModelId: sessionModel?.id,
2375
- sessionModelContextWindow: sessionModel?.contextWindow,
2376
- modelId: model?.id,
2377
- modelName: model?.name,
2378
- modelProvider: model?.provider,
2379
- modelContextWindow: model?.contextWindow,
2380
- modelThinking: model?.thinking,
2381
- thinkingLevel: state.thinkingLevel,
2382
- modelCostRef: model?.cost,
2383
- contextUsageRevision: this.session.contextUsageRevision ?? 0,
2384
- systemPromptRef: systemPrompt,
2385
- systemPromptLength: systemPrompt?.length ?? 0,
2386
- systemPromptContentSize,
2387
- toolsRef: tools,
2388
- toolsLength: tools?.length ?? 0,
2389
- toolSchemaMetadataRevision: tools ? getToolSchemaMetadataRevision(tools) : 0,
2390
- tokenizerRef: this.session.agent?.tokenizer,
2391
- skillsRef: this.session.skills,
2392
- skillsLength: this.session.skills?.length ?? 0,
2393
- sessionName: this.session.sessionManager?.getSessionName?.(),
2394
- sessionId: this.session.sessionManager?.getSessionId?.(),
2395
- isStreaming: this.session.isStreaming,
2396
- isAutoThinking: this.session.isAutoThinking,
2397
- isFastModeActive:
2398
- typeof this.session.isFastModeActive === "function" ? this.session.isFastModeActive() : false,
2399
- anthropicSlowModeLabel:
2400
- typeof this.session.getAnthropicSlowModeLabel === "function"
2401
- ? this.session.getAnthropicSlowModeLabel()
2402
- : undefined,
2403
- compactionSpeculation: this.session.compactionSpeculation,
2404
- };
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;
2405
2441
  }
2406
2442
 
2407
2443
  #sameStatusLineExternalInputs(left: StatusLineExternalInputs, right: StatusLineExternalInputs): boolean {
@@ -2496,7 +2532,8 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2496
2532
 
2497
2533
  #buildStatusLine(width: number, layout: StatusLineLayout = "box", previewTitle?: string): CachedStatusLine {
2498
2534
  const effectiveSettings = this.#resolveSettings();
2499
- const externalInputs = this.#readStatusLineExternalInputs();
2535
+ const externalInputs = this.#externalInputsProbe;
2536
+ this.#readStatusLineExternalInputs(externalInputs);
2500
2537
  const nowMs = Date.now();
2501
2538
  const clockTick = this.#statusLineClock(nowMs, effectiveSettings);
2502
2539
  if (clockTick !== this.#statusLineClockTick) {
@@ -2516,6 +2553,8 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2516
2553
  return cached;
2517
2554
  }
2518
2555
 
2556
+ // The probe now belongs to the new cache entry; later probes need their own buffer.
2557
+ this.#externalInputsProbe = new StatusLineExternalInputs();
2519
2558
  const content = this.#renderStatusLine(width, layout, previewTitle, nowMs);
2520
2559
  const result = {
2521
2560
  content,
@@ -2642,8 +2681,11 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2642
2681
  if (subagentBadge) rightParts.unshift(subagentBadge);
2643
2682
  }
2644
2683
  const topFillWidth = Math.max(0, width);
2645
- const left = [...leftParts];
2646
- 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));
2647
2689
 
2648
2690
  const leftSepWidth = visibleWidth(separatorDef.left);
2649
2691
  const rightSepWidth = visibleWidth(separatorDef.right);
@@ -2657,15 +2699,15 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2657
2699
  const bandCap = layout === "band" && separatorDef.endCaps && !transparentBg ? theme.sep.powerlineCapLeft : "";
2658
2700
  const bandCapWidth = visibleWidth(bandCap);
2659
2701
 
2660
- const groupWidth = (parts: string[], capWidth: number, sepWidth: number): number => {
2661
- if (parts.length === 0) return 0;
2662
- const partsWidth = parts.reduce((sum, part) => sum + visibleWidth(part), 0);
2663
- 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);
2664
2706
  return partsWidth + sepTotal + 2 + capWidth;
2665
2707
  };
2666
2708
 
2667
- let leftWidth = groupWidth(left, leftCapWidth + bandCapWidth, leftSepWidth);
2668
- let rightWidth = groupWidth(right, rightCapWidth, rightSepWidth);
2709
+ let leftWidth = groupWidth(leftWidths, leftCapWidth + bandCapWidth, leftSepWidth);
2710
+ let rightWidth = groupWidth(rightWidths, rightCapWidth, rightSepWidth);
2669
2711
  // Embedded mode removes the standalone context segment before overflow
2670
2712
  // handling, so the gauge must reserve enough room for both labels. Without
2671
2713
  // this budget a long path/session title can leave a one-cell gap: the
@@ -2694,23 +2736,27 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2694
2736
  if (nameSegIdx >= 0 && totalWidth() > topFillWidth) {
2695
2737
  // Badge/job parts were unshifted ahead of the tracked segment ids.
2696
2738
  const nameIdx = nameSegIdx + (right.length - rightSegIds.length);
2697
- const currentNameVW = visibleWidth(right[nameIdx]);
2739
+ const currentNameVW = rightWidths[nameIdx];
2698
2740
  const minNameVW = 8;
2699
2741
  const shrinkBy = Math.min(Math.max(0, currentNameVW - minNameVW), totalWidth() - topFillWidth);
2700
2742
  if (shrinkBy > 0) {
2701
2743
  right[nameIdx] = truncateToWidth(right[nameIdx], currentNameVW - shrinkBy);
2702
- rightWidth = groupWidth(right, rightCapWidth, rightSepWidth);
2744
+ const nameWidth = visibleWidth(right[nameIdx]);
2745
+ rightWidth += nameWidth - currentNameVW;
2746
+ rightWidths[nameIdx] = nameWidth;
2703
2747
  }
2704
2748
  }
2705
2749
  while (totalWidth() > topFillWidth && right.length > 0) {
2750
+ const removedWidth = rightWidths[right.length - 1];
2706
2751
  right.pop();
2707
- rightWidth = groupWidth(right, rightCapWidth, rightSepWidth);
2752
+ rightWidths.pop();
2753
+ rightWidth = right.length > 0 ? rightWidth - removedWidth - rightSepWidth - 2 : 0;
2708
2754
  }
2709
2755
  // Shrink path before dropping left segments — path is the only elastic segment
2710
2756
  const pathIdx = leftSegIds.indexOf("path");
2711
2757
  if (pathIdx >= 0 && totalWidth() > topFillWidth) {
2712
2758
  const overflow = totalWidth() - topFillWidth;
2713
- const currentPathVW = visibleWidth(left[pathIdx]);
2759
+ const currentPathVW = leftWidths[pathIdx];
2714
2760
  const minPathVW = 8; // icon + ellipsis + a few chars
2715
2761
  const shrinkable = currentPathVW - minPathVW;
2716
2762
  if (shrinkable > 0) {
@@ -2723,9 +2769,10 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2723
2769
  });
2724
2770
  let reRendered = renderSegment("path", pathCtx(newMaxLen));
2725
2771
  if (reRendered.visible && reRendered.content) {
2772
+ let pathWidth = visibleWidth(reRendered.content);
2726
2773
  // maxLength governs path text, not icon prefix; iterate to compensate
2727
2774
  for (let i = 0; i < 8; i++) {
2728
- const saved = currentPathVW - visibleWidth(reRendered.content);
2775
+ const saved = currentPathVW - pathWidth;
2729
2776
  if (saved >= shrinkBy) break;
2730
2777
  const nextMaxLen = Math.max(4, newMaxLen - (shrinkBy - saved));
2731
2778
  if (nextMaxLen >= newMaxLen) break; // no progress or hit floor
@@ -2733,9 +2780,11 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2733
2780
  const adjusted = renderSegment("path", pathCtx(newMaxLen));
2734
2781
  if (!adjusted.visible || !adjusted.content) break;
2735
2782
  reRendered = adjusted;
2783
+ pathWidth = visibleWidth(adjusted.content);
2736
2784
  }
2737
2785
  left[pathIdx] = reRendered.content;
2738
- leftWidth = groupWidth(left, leftCapWidth + bandCapWidth, leftSepWidth);
2786
+ leftWidth += pathWidth - currentPathVW;
2787
+ leftWidths[pathIdx] = pathWidth;
2739
2788
  }
2740
2789
  }
2741
2790
  }
@@ -2752,9 +2801,11 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2752
2801
 
2753
2802
  while (totalWidth() > topFillWidth && left.length > 0) {
2754
2803
  const dropIdx = leftOverflowDropIndex();
2804
+ const removedWidth = leftWidths[dropIdx];
2755
2805
  left.splice(dropIdx, 1);
2806
+ leftWidths.splice(dropIdx, 1);
2756
2807
  leftSegIds.splice(dropIdx, 1);
2757
- leftWidth = groupWidth(left, leftCapWidth + bandCapWidth, leftSepWidth);
2808
+ leftWidth = left.length > 0 ? leftWidth - removedWidth - leftSepWidth - 2 : 0;
2758
2809
  }
2759
2810
  }
2760
2811
 
@@ -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,9 +2993,35 @@ 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
  }
@@ -9,7 +9,6 @@ import {
9
9
  getProjectDir,
10
10
  normalizePathForComparison,
11
11
  relativePathWithinNormalizedRoot,
12
- relativePathWithinRoot,
13
12
  } from "@oh-my-pi/pi-utils";
14
13
  import { type SymbolKey, type Theme, type ThemeColor, theme } from "../theme";
15
14
  import { shortenPath, TRUNCATE_LENGTHS, truncateToWidth } from "../render/render-utils";
@@ -70,14 +69,6 @@ function leadingGlyph(display: string): string {
70
69
  return space === -1 ? display : display.slice(0, space);
71
70
  }
72
71
 
73
- function stripDisplayRoot(pwd: string): string {
74
- for (const root of [path.join(os.homedir(), "Projects"), "/work"]) {
75
- const relative = relativePathWithinRoot(root, pwd);
76
- if (relative) return relative;
77
- }
78
- return pwd;
79
- }
80
-
81
72
  /**
82
73
  * Single-field usage counter (`token_in`, `token_out`, `cache_read`,
83
74
  * `cache_write`). Hidden on zero, startup-placeholder aware, icon omitted when
@@ -121,28 +112,50 @@ const NORMALIZED_SCRATCH_ROOTS: readonly string[] = (() => {
121
112
  return [...new Set(Array.from(roots, normalizePathForComparison))];
122
113
  })();
123
114
 
124
- interface ProjectDirClassification {
115
+ interface ProjectDirDisplay {
116
+ projectDir: string;
117
+ homeDir: string;
118
+ displayRoots: readonly string[] | undefined;
125
119
  scratch: boolean;
126
- relative: string | null;
120
+ displayPath: string;
127
121
  }
128
122
 
129
- const PROJECT_DIR_CLASSIFICATIONS = new Map<string, ProjectDirClassification>();
123
+ let projectDirDisplay: ProjectDirDisplay | undefined;
130
124
 
131
- function classifyProjectDir(projectDir: string): ProjectDirClassification {
132
- const cached = PROJECT_DIR_CLASSIFICATIONS.get(projectDir);
133
- if (cached) return cached;
125
+ /**
126
+ * Retain only the active directory: switching cwd re-resolves its aliases,
127
+ * while repeated paints reuse both scratch classification and root stripping.
128
+ * Display roots remain normalized while the home directory is unchanged.
129
+ */
130
+ function getProjectDirDisplay(projectDir: string): ProjectDirDisplay {
131
+ const cached = projectDirDisplay;
132
+ if (cached?.projectDir === projectDir) return cached;
134
133
 
134
+ const homeDir = os.homedir();
135
+ let displayRoots = cached?.homeDir === homeDir ? cached.displayRoots : undefined;
135
136
  const normalizedProjectDir = normalizePathForComparison(projectDir);
136
- let classification: ProjectDirClassification = { scratch: false, relative: null };
137
+ let scratch = false;
138
+ let displayPath = projectDir;
137
139
  for (const normalizedRoot of NORMALIZED_SCRATCH_ROOTS) {
138
140
  const relative = relativePathWithinNormalizedRoot(normalizedRoot, normalizedProjectDir);
139
141
  if (relative !== null) {
140
- classification = { scratch: true, relative: relative || null };
142
+ scratch = true;
143
+ displayPath = relative || projectDir;
141
144
  break;
142
145
  }
143
146
  }
144
- PROJECT_DIR_CLASSIFICATIONS.set(projectDir, classification);
145
- return classification;
147
+ if (!scratch) {
148
+ displayRoots ??= [path.join(homeDir, "Projects"), "/work"].map(normalizePathForComparison);
149
+ for (const root of displayRoots) {
150
+ const relative = relativePathWithinNormalizedRoot(root, normalizedProjectDir);
151
+ if (relative) {
152
+ displayPath = relative;
153
+ break;
154
+ }
155
+ }
156
+ }
157
+ projectDirDisplay = { projectDir, homeDir, displayRoots, scratch, displayPath };
158
+ return projectDirDisplay;
146
159
  }
147
160
 
148
161
  // ═══════════════════════════════════════════════════════════════════════════
@@ -421,16 +434,8 @@ const pathSegment: StatusLineSegment = {
421
434
  }
422
435
 
423
436
  const projectDir = ctx.activeRepo?.cwd ?? getProjectDir();
424
- const { scratch, relative } = classifyProjectDir(projectDir);
425
- let pwd = projectDir;
426
-
427
- if (stripPrefix) {
428
- if (scratch) {
429
- if (relative) pwd = relative;
430
- } else {
431
- pwd = stripDisplayRoot(pwd);
432
- }
433
- }
437
+ const { scratch, displayPath } = getProjectDirDisplay(projectDir);
438
+ let pwd = stripPrefix ? displayPath : projectDir;
434
439
  const repoSuffix = ctx.activeRepo ? ` ↳ ${ctx.activeRepo.relativeRepoRoot}` : "";
435
440
  if (opts.abbreviate !== false) {
436
441
  pwd = shortenPath(pwd);