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

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 (67) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/types/chat/thinking-display.d.ts +2 -0
  3. package/dist/types/chrome/transcript-container.d.ts +15 -1
  4. package/dist/types/components/editor.d.ts +5 -0
  5. package/dist/types/loop-watchdog.d.ts +9 -0
  6. package/dist/types/overlays/model-browser.d.ts +15 -0
  7. package/dist/types/overlays/model-picker.d.ts +2 -1
  8. package/dist/types/overlays/rewind-selector.d.ts +2 -1
  9. package/dist/types/prompt/composer-attachments.d.ts +7 -0
  10. package/dist/types/prompt/model-mention-autocomplete.d.ts +4 -1
  11. package/dist/types/render/width-aware-text.d.ts +6 -0
  12. package/dist/types/setup/scenes/sign-in.d.ts +9 -9
  13. package/dist/types/setup/scenes/types.d.ts +0 -26
  14. package/dist/types/stdin-buffer.d.ts +10 -7
  15. package/dist/types/terminal.d.ts +16 -0
  16. package/dist/types/theme/color.d.ts +1 -0
  17. package/dist/types/theme/session-color.d.ts +3 -3
  18. package/dist/types/theme/shimmer.d.ts +1 -1
  19. package/dist/types/theme/theme-class.d.ts +1 -1
  20. package/dist/types/tools/web-search-types.d.ts +90 -0
  21. package/dist/types/tools/web-search.d.ts +1 -168
  22. package/dist/types/tui.d.ts +3 -2
  23. package/package.json +9 -9
  24. package/src/chat/assistant-message.ts +192 -40
  25. package/src/chat/thinking-display.ts +73 -11
  26. package/src/chat/tool-execution.ts +24 -4
  27. package/src/chrome/transcript-container.ts +178 -31
  28. package/src/components/editor.ts +51 -15
  29. package/src/components/image.ts +15 -7
  30. package/src/components/loader.ts +37 -17
  31. package/src/components/markdown.ts +110 -56
  32. package/src/loop-watchdog.ts +38 -10
  33. package/src/overlays/agent-hub.ts +16 -6
  34. package/src/overlays/copy-selector.ts +5 -0
  35. package/src/overlays/model-browser.ts +106 -25
  36. package/src/overlays/model-picker.ts +9 -6
  37. package/src/overlays/rewind-selector.ts +10 -2
  38. package/src/overlays/settings-selector.ts +2 -10
  39. package/src/prompt/composer-attachments.ts +54 -7
  40. package/src/prompt/composer.ts +5 -2
  41. package/src/prompt/custom-editor.ts +24 -6
  42. package/src/prompt/model-mention-autocomplete.ts +22 -10
  43. package/src/render/width-aware-text.ts +9 -0
  44. package/src/setup/scenes/sign-in.ts +17 -12
  45. package/src/setup/scenes/types.ts +0 -27
  46. package/src/setup/wizard.ts +1 -1
  47. package/src/status-line/component.ts +229 -134
  48. package/src/status-line/segments.ts +34 -29
  49. package/src/stdin-buffer.ts +27 -21
  50. package/src/terminal.ts +60 -20
  51. package/src/theme/color.ts +15 -0
  52. package/src/theme/mermaid-cache.ts +10 -1
  53. package/src/theme/session-color.ts +26 -4
  54. package/src/theme/shimmer.ts +55 -76
  55. package/src/theme/theme-class.ts +29 -12
  56. package/src/theme/tui-adapters.ts +14 -1
  57. package/src/tools/bash.ts +53 -13
  58. package/src/tools/edit.ts +110 -29
  59. package/src/tools/streaming-output.ts +79 -16
  60. package/src/tools/web-search-types.ts +99 -0
  61. package/src/tools/web-search.ts +1 -172
  62. package/src/tui.ts +143 -113
  63. package/src/utils.ts +15 -0
  64. package/dist/types/setup/scenes/providers.d.ts +0 -3
  65. package/dist/types/setup/scenes/web-search.d.ts +0 -22
  66. package/src/setup/scenes/providers.ts +0 -110
  67. package/src/setup/scenes/web-search.ts +0 -165
@@ -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,
@@ -48,6 +48,13 @@ import type {
48
48
  StatusLineSettings,
49
49
  } from "./types";
50
50
 
51
+ /**
52
+ * Freshness window for the git segment's working-tree counts. A whole-worktree
53
+ * `git status` costs ~1 CPU-second on large repos and every open session polls
54
+ * it, so edits surface within this window; HEAD moves refetch immediately via
55
+ * {@link StatusLineComponent.invalidateGitCaches}.
56
+ */
57
+ const GIT_STATUS_TTL_MS = 10_000;
51
58
  const JJ_REFRESH_TTL_MS = 5000;
52
59
  const JJ_COMMAND_TIMEOUT_MS = 5_000;
53
60
  const WATCHER_FAILURE_POLL_TTL_MS = 5000;
@@ -255,57 +262,62 @@ interface ContextUsageMemo {
255
262
  skillsRef: readonly any[] | undefined;
256
263
  }
257
264
 
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;
265
+ /**
266
+ * Snapshot of the cheap external inputs a cached bar depends on. Mutable and
267
+ * refilled in place: the per-frame cache probe writes into a recycled
268
+ * instance instead of allocating a fresh ~50-field object every frame.
269
+ */
270
+ class StatusLineExternalInputs {
271
+ themeRef: unknown = undefined;
272
+ themeEpoch = 0;
273
+ projectDir = "";
274
+ sessionRef: StatusLineSession | undefined = undefined;
275
+ sessionFile: string | undefined = undefined;
276
+ sessionSettingsRef: unknown = undefined;
277
+ sessionSettingsRevision = 0;
278
+ globalSettingsRevision = 0;
279
+ stateRef: unknown = undefined;
280
+ stateMessagesRef: readonly AgentMessage[] = EMPTY_MESSAGES;
281
+ stateMessagesLength = 0;
282
+ stateLastMessageRef: AgentMessage | undefined = undefined;
283
+ stateLastMessageContentRef: unknown = undefined;
284
+ messagesRef: readonly AgentMessage[] = EMPTY_MESSAGES;
285
+ messagesLength = 0;
286
+ lastMessageRef: AgentMessage | undefined = undefined;
287
+ lastMessageRole: string | undefined = undefined;
288
+ lastMessageTimestamp = 0;
289
+ lastMessageContentSize = 0;
290
+ lastMessageBlockCount = 0;
291
+ lastMessageUsageTotal = 0;
292
+ lastMessageStopReason: string | undefined = undefined;
293
+ modelRef: unknown = undefined;
294
+ sessionModelRef: unknown = undefined;
295
+ sessionModelId: string | undefined = undefined;
296
+ sessionModelContextWindow: Model["contextWindow"] | undefined = undefined;
297
+ modelId: string | undefined = undefined;
298
+ modelName: string | undefined = undefined;
299
+ modelProvider: string | undefined = undefined;
300
+ modelContextWindow: Model["contextWindow"] | undefined = undefined;
301
+ modelThinking: Model["thinking"] = undefined;
302
+ thinkingLevel: unknown = undefined;
303
+ modelCostRef: unknown = undefined;
304
+ contextUsageRevision = 0;
305
+ systemPromptRef: unknown = undefined;
306
+ systemPromptLength = 0;
307
+ systemPromptContentSize = 0;
308
+ toolsRef: readonly unknown[] | undefined = undefined;
309
+ toolsLength = 0;
310
+ toolSchemaMetadataRevision = 0;
311
+ tokenizerRef: unknown = undefined;
312
+ skillsRef: unknown = undefined;
313
+ skillsLength = 0;
314
+ sessionName: string | undefined = undefined;
315
+ sessionId: string | undefined = undefined;
316
+ isStreaming: boolean | undefined = undefined;
317
+ isAutoThinking: boolean | undefined = undefined;
318
+ isFastModeActive = false;
319
+ anthropicSlowModeLabel: string | undefined = undefined;
320
+ compactionSpeculation: unknown = undefined;
309
321
  }
310
322
 
311
323
  interface CachedStatusLine {
@@ -445,6 +457,17 @@ function hasGitBackedSegment(segments: readonly StatusLineSegmentId[]): boolean
445
457
 
446
458
  type StatusLineLayout = "box" | "band" | "plain-full" | "plain-left" | "plain-right";
447
459
 
460
+ /**
461
+ * Git views of immutable `VcsRepo` handles. `asGit()` mints a fresh napi
462
+ * wrapper around the same backend handle on every call, which the per-frame
463
+ * branch lookup would otherwise pay each render.
464
+ */
465
+ const GIT_VIEW = Symbol("status-line.git-view");
466
+
467
+ interface GitViewTaggedRepo extends VcsRepo {
468
+ [GIT_VIEW]?: VcsGitRepo | null;
469
+ }
470
+
448
471
  // ═══════════════════════════════════════════════════════════════════════════
449
472
  // StatusLineComponent
450
473
  // ═══════════════════════════════════════════════════════════════════════════
@@ -465,6 +488,22 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
465
488
  };
466
489
  #statusLineInputRevision = 0;
467
490
  #statusLineClockTick = 0;
491
+ /** Reused probe buffer for {@link #readStatusLineExternalInputs}; handed to the new cache entry on a miss. */
492
+ #externalInputsProbe = new StatusLineExternalInputs();
493
+ /** `adjustHsv` result for the gauge's threshold tint; pure in its source hex. */
494
+ #dimmedAccentMemo: { sourceHex: string; hex: string } | undefined;
495
+ /** Gauge boundary markers; a pure function of the session's compaction settings, model, and window. */
496
+ #compactionBoundariesMemo:
497
+ | {
498
+ sessionRef: StatusLineSession;
499
+ contextWindow: number;
500
+ modelRef: unknown;
501
+ settingsRef: unknown;
502
+ settingsRevision: number;
503
+ globalSettingsRevision: number;
504
+ boundaries: CompactionBoundaries | null;
505
+ }
506
+ | undefined;
468
507
  #settings: StatusLineSettings = {};
469
508
  #effectiveSettings: EffectiveStatusLineSettings | undefined;
470
509
  #cachedBranch: string | null | undefined = undefined;
@@ -554,6 +593,7 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
554
593
  #cachedGitStatus: { staged: number; unstaged: number; untracked: number } | null = null;
555
594
  #cachedGitStatusCwd: string | undefined = undefined;
556
595
  #gitStatusLastFetch = 0;
596
+ #gitStatusGeneration = 0;
557
597
  #gitStatusInFlightCwd: string | undefined = undefined;
558
598
  #cachedJjBranch: string | null = null;
559
599
  #jjBranchLastFetch = 0;
@@ -1070,8 +1110,7 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
1070
1110
  const sessionName = sessionAccentEnabled ? this.session.sessionManager?.getSessionName() : undefined;
1071
1111
  const idleHex = theme.getColorHex("dim");
1072
1112
  const workingHex =
1073
- (sessionName ? getSessionAccentHex(sessionName, theme.sessionAccentInputs) : undefined) ??
1074
- theme.getColorHex("accent");
1113
+ (sessionName && getSessionAccentHex(sessionName, theme.sessionAccentInputs)) || theme.getColorHex("accent");
1075
1114
  const now = Date.now();
1076
1115
  if (working !== this.#brandWorking) {
1077
1116
  const previousTargetHex = this.#brandWorking ? workingHex : idleHex;
@@ -1223,6 +1262,11 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
1223
1262
  this.#branchLastFetch = undefined;
1224
1263
  this.#branchCacheGeneration++;
1225
1264
  this.#cachedPrContext = undefined;
1265
+ // A HEAD move (commit, checkout, reset) changes the dirty counts; keep the
1266
+ // stale counts on screen but refetch on the next render. The generation
1267
+ // bump stops an in-flight pre-move result from re-stamping freshness.
1268
+ this.#gitStatusLastFetch = 0;
1269
+ this.#gitStatusGeneration++;
1226
1270
  // jj label/status share the git segment's lifecycle: a HEAD move (e.g. a
1227
1271
  // colocated `jj new`/bookmark move) must drop the throttled jj caches too,
1228
1272
  // so the next render refetches.
@@ -1267,7 +1311,12 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
1267
1311
 
1268
1312
  const gitCwd = activeRepoCache.effectiveGitCwd;
1269
1313
  if (!repository) return null;
1270
- const gitRepository = repository.asGit();
1314
+ const taggedRepository: GitViewTaggedRepo = repository;
1315
+ let gitRepository = taggedRepository[GIT_VIEW];
1316
+ if (gitRepository === undefined) {
1317
+ gitRepository = repository.asGit();
1318
+ taggedRepository[GIT_VIEW] = gitRepository;
1319
+ }
1271
1320
  if (!gitRepository) {
1272
1321
  if (this.#jjBranchActive || Date.now() - this.#jjBranchLastFetch < JJ_REFRESH_TTL_MS) {
1273
1322
  return this.#cachedJjBranch;
@@ -1457,11 +1506,12 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
1457
1506
  if (this.#gitStatusInFlightCwd !== undefined) {
1458
1507
  return this.#cachedGitStatusCwd === gitCwd ? this.#cachedGitStatus : null;
1459
1508
  }
1460
- if (this.#cachedGitStatusCwd === gitCwd && Date.now() - this.#gitStatusLastFetch < 1000) {
1509
+ if (this.#cachedGitStatusCwd === gitCwd && Date.now() - this.#gitStatusLastFetch < GIT_STATUS_TTL_MS) {
1461
1510
  return this.#cachedGitStatus;
1462
1511
  }
1463
1512
 
1464
1513
  this.#gitStatusInFlightCwd = gitCwd;
1514
+ const generation = this.#gitStatusGeneration;
1465
1515
 
1466
1516
  (async () => {
1467
1517
  let nextStatus: { staged: number; unstaged: number; untracked: number } | null = null;
@@ -1474,7 +1524,7 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
1474
1524
  const prev = this.#cachedGitStatusCwd === gitCwd ? this.#cachedGitStatus : null;
1475
1525
  this.#cachedGitStatus = nextStatus;
1476
1526
  this.#cachedGitStatusCwd = gitCwd;
1477
- this.#gitStatusLastFetch = Date.now();
1527
+ this.#gitStatusLastFetch = this.#gitStatusGeneration === generation ? Date.now() : 0;
1478
1528
  this.#gitStatusInFlightCwd = undefined;
1479
1529
  if (!this.#disposed && JSON.stringify(prev) !== JSON.stringify(nextStatus)) {
1480
1530
  this.#invalidateStatusLineRenderCache();
@@ -2294,8 +2344,9 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2294
2344
  * going through this component's setters. This deliberately uses refs,
2295
2345
  * scalars, and string lengths only: no schema estimation, serialization,
2296
2346
  * usage aggregation, VCS probing, or segment getters run on a cache hit.
2347
+ * Fills `target` in place so the per-frame probe allocates nothing.
2297
2348
  */
2298
- #readStatusLineExternalInputs(): StatusLineExternalInputs {
2349
+ #readStatusLineExternalInputs(target: StatusLineExternalInputs): void {
2299
2350
  const state = this.session.state;
2300
2351
  const stateMessages = state.messages ?? EMPTY_MESSAGES;
2301
2352
  const stateLastMessage = stateMessages[stateMessages.length - 1];
@@ -2346,62 +2397,61 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2346
2397
  if (typeof part === "string") systemPromptContentSize += part.length;
2347
2398
  }
2348
2399
  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
- };
2400
+ const skills = this.session.skills;
2401
+ target.themeRef = theme;
2402
+ target.themeEpoch = getThemeEpoch();
2403
+ target.projectDir = getProjectDir();
2404
+ target.sessionRef = this.session;
2405
+ target.sessionFile = this.session.sessionFile;
2406
+ target.sessionSettingsRef = this.host.getSessionSettingsIdentity(this.session);
2407
+ target.sessionSettingsRevision = this.host.getSessionSettingsRevision(this.session);
2408
+ target.globalSettingsRevision = this.host.getSettingsRevision();
2409
+ target.stateRef = state;
2410
+ target.stateMessagesRef = stateMessages;
2411
+ target.stateMessagesLength = stateMessages.length;
2412
+ target.stateLastMessageRef = stateLastMessage;
2413
+ target.stateLastMessageContentRef = stateLastMessageContent;
2414
+ target.messagesRef = messages;
2415
+ target.messagesLength = messages.length;
2416
+ target.lastMessageRef = lastMessage;
2417
+ target.lastMessageRole = lastMessageView?.role;
2418
+ target.lastMessageTimestamp = lastMessageView?.timestamp ?? 0;
2419
+ target.lastMessageContentSize = lastMessageContentSize;
2420
+ target.lastMessageBlockCount = lastMessageBlockCount;
2421
+ target.lastMessageUsageTotal = lastMessageView?.usage?.totalTokens ?? 0;
2422
+ target.lastMessageStopReason = lastMessageView?.stopReason;
2423
+ target.modelRef = model;
2424
+ target.sessionModelRef = sessionModel;
2425
+ target.sessionModelId = sessionModel?.id;
2426
+ target.sessionModelContextWindow = sessionModel?.contextWindow;
2427
+ target.modelId = model?.id;
2428
+ target.modelName = model?.name;
2429
+ target.modelProvider = model?.provider;
2430
+ target.modelContextWindow = model?.contextWindow;
2431
+ target.modelThinking = model?.thinking;
2432
+ target.thinkingLevel = state.thinkingLevel;
2433
+ target.modelCostRef = model?.cost;
2434
+ target.contextUsageRevision = this.session.contextUsageRevision ?? 0;
2435
+ target.systemPromptRef = systemPrompt;
2436
+ target.systemPromptLength = systemPrompt?.length ?? 0;
2437
+ target.systemPromptContentSize = systemPromptContentSize;
2438
+ target.toolsRef = tools;
2439
+ target.toolsLength = tools?.length ?? 0;
2440
+ target.toolSchemaMetadataRevision = tools ? getToolSchemaMetadataRevision(tools) : 0;
2441
+ target.tokenizerRef = this.session.agent?.tokenizer;
2442
+ target.skillsRef = skills;
2443
+ target.skillsLength = skills?.length ?? 0;
2444
+ target.sessionName = this.session.sessionManager?.getSessionName?.();
2445
+ target.sessionId = this.session.sessionManager?.getSessionId?.();
2446
+ target.isStreaming = this.session.isStreaming;
2447
+ target.isAutoThinking = this.session.isAutoThinking;
2448
+ target.isFastModeActive =
2449
+ typeof this.session.isFastModeActive === "function" ? this.session.isFastModeActive() : false;
2450
+ target.anthropicSlowModeLabel =
2451
+ typeof this.session.getAnthropicSlowModeLabel === "function"
2452
+ ? this.session.getAnthropicSlowModeLabel()
2453
+ : undefined;
2454
+ target.compactionSpeculation = this.session.compactionSpeculation;
2405
2455
  }
2406
2456
 
2407
2457
  #sameStatusLineExternalInputs(left: StatusLineExternalInputs, right: StatusLineExternalInputs): boolean {
@@ -2496,7 +2546,8 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2496
2546
 
2497
2547
  #buildStatusLine(width: number, layout: StatusLineLayout = "box", previewTitle?: string): CachedStatusLine {
2498
2548
  const effectiveSettings = this.#resolveSettings();
2499
- const externalInputs = this.#readStatusLineExternalInputs();
2549
+ const externalInputs = this.#externalInputsProbe;
2550
+ this.#readStatusLineExternalInputs(externalInputs);
2500
2551
  const nowMs = Date.now();
2501
2552
  const clockTick = this.#statusLineClock(nowMs, effectiveSettings);
2502
2553
  if (clockTick !== this.#statusLineClockTick) {
@@ -2516,6 +2567,8 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2516
2567
  return cached;
2517
2568
  }
2518
2569
 
2570
+ // The probe now belongs to the new cache entry; later probes need their own buffer.
2571
+ this.#externalInputsProbe = new StatusLineExternalInputs();
2519
2572
  const content = this.#renderStatusLine(width, layout, previewTitle, nowMs);
2520
2573
  const result = {
2521
2574
  content,
@@ -2642,8 +2695,11 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2642
2695
  if (subagentBadge) rightParts.unshift(subagentBadge);
2643
2696
  }
2644
2697
  const topFillWidth = Math.max(0, width);
2645
- const left = [...leftParts];
2646
- const right = [...rightParts];
2698
+ // These arrays are local to this render; overflow handling can mutate them.
2699
+ const left = leftParts;
2700
+ const right = rightParts;
2701
+ const leftWidths = left.map(part => visibleWidth(part));
2702
+ const rightWidths = right.map(part => visibleWidth(part));
2647
2703
 
2648
2704
  const leftSepWidth = visibleWidth(separatorDef.left);
2649
2705
  const rightSepWidth = visibleWidth(separatorDef.right);
@@ -2657,15 +2713,15 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2657
2713
  const bandCap = layout === "band" && separatorDef.endCaps && !transparentBg ? theme.sep.powerlineCapLeft : "";
2658
2714
  const bandCapWidth = visibleWidth(bandCap);
2659
2715
 
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);
2716
+ const groupWidth = (widths: number[], capWidth: number, sepWidth: number): number => {
2717
+ if (widths.length === 0) return 0;
2718
+ const partsWidth = widths.reduce((sum, partWidth) => sum + partWidth, 0);
2719
+ const sepTotal = (widths.length - 1) * (sepWidth + 2);
2664
2720
  return partsWidth + sepTotal + 2 + capWidth;
2665
2721
  };
2666
2722
 
2667
- let leftWidth = groupWidth(left, leftCapWidth + bandCapWidth, leftSepWidth);
2668
- let rightWidth = groupWidth(right, rightCapWidth, rightSepWidth);
2723
+ let leftWidth = groupWidth(leftWidths, leftCapWidth + bandCapWidth, leftSepWidth);
2724
+ let rightWidth = groupWidth(rightWidths, rightCapWidth, rightSepWidth);
2669
2725
  // Embedded mode removes the standalone context segment before overflow
2670
2726
  // handling, so the gauge must reserve enough room for both labels. Without
2671
2727
  // this budget a long path/session title can leave a one-cell gap: the
@@ -2694,23 +2750,27 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2694
2750
  if (nameSegIdx >= 0 && totalWidth() > topFillWidth) {
2695
2751
  // Badge/job parts were unshifted ahead of the tracked segment ids.
2696
2752
  const nameIdx = nameSegIdx + (right.length - rightSegIds.length);
2697
- const currentNameVW = visibleWidth(right[nameIdx]);
2753
+ const currentNameVW = rightWidths[nameIdx];
2698
2754
  const minNameVW = 8;
2699
2755
  const shrinkBy = Math.min(Math.max(0, currentNameVW - minNameVW), totalWidth() - topFillWidth);
2700
2756
  if (shrinkBy > 0) {
2701
2757
  right[nameIdx] = truncateToWidth(right[nameIdx], currentNameVW - shrinkBy);
2702
- rightWidth = groupWidth(right, rightCapWidth, rightSepWidth);
2758
+ const nameWidth = visibleWidth(right[nameIdx]);
2759
+ rightWidth += nameWidth - currentNameVW;
2760
+ rightWidths[nameIdx] = nameWidth;
2703
2761
  }
2704
2762
  }
2705
2763
  while (totalWidth() > topFillWidth && right.length > 0) {
2764
+ const removedWidth = rightWidths[right.length - 1];
2706
2765
  right.pop();
2707
- rightWidth = groupWidth(right, rightCapWidth, rightSepWidth);
2766
+ rightWidths.pop();
2767
+ rightWidth = right.length > 0 ? rightWidth - removedWidth - rightSepWidth - 2 : 0;
2708
2768
  }
2709
2769
  // Shrink path before dropping left segments — path is the only elastic segment
2710
2770
  const pathIdx = leftSegIds.indexOf("path");
2711
2771
  if (pathIdx >= 0 && totalWidth() > topFillWidth) {
2712
2772
  const overflow = totalWidth() - topFillWidth;
2713
- const currentPathVW = visibleWidth(left[pathIdx]);
2773
+ const currentPathVW = leftWidths[pathIdx];
2714
2774
  const minPathVW = 8; // icon + ellipsis + a few chars
2715
2775
  const shrinkable = currentPathVW - minPathVW;
2716
2776
  if (shrinkable > 0) {
@@ -2723,9 +2783,10 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2723
2783
  });
2724
2784
  let reRendered = renderSegment("path", pathCtx(newMaxLen));
2725
2785
  if (reRendered.visible && reRendered.content) {
2786
+ let pathWidth = visibleWidth(reRendered.content);
2726
2787
  // maxLength governs path text, not icon prefix; iterate to compensate
2727
2788
  for (let i = 0; i < 8; i++) {
2728
- const saved = currentPathVW - visibleWidth(reRendered.content);
2789
+ const saved = currentPathVW - pathWidth;
2729
2790
  if (saved >= shrinkBy) break;
2730
2791
  const nextMaxLen = Math.max(4, newMaxLen - (shrinkBy - saved));
2731
2792
  if (nextMaxLen >= newMaxLen) break; // no progress or hit floor
@@ -2733,9 +2794,11 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2733
2794
  const adjusted = renderSegment("path", pathCtx(newMaxLen));
2734
2795
  if (!adjusted.visible || !adjusted.content) break;
2735
2796
  reRendered = adjusted;
2797
+ pathWidth = visibleWidth(adjusted.content);
2736
2798
  }
2737
2799
  left[pathIdx] = reRendered.content;
2738
- leftWidth = groupWidth(left, leftCapWidth + bandCapWidth, leftSepWidth);
2800
+ leftWidth += pathWidth - currentPathVW;
2801
+ leftWidths[pathIdx] = pathWidth;
2739
2802
  }
2740
2803
  }
2741
2804
  }
@@ -2752,9 +2815,11 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2752
2815
 
2753
2816
  while (totalWidth() > topFillWidth && left.length > 0) {
2754
2817
  const dropIdx = leftOverflowDropIndex();
2818
+ const removedWidth = leftWidths[dropIdx];
2755
2819
  left.splice(dropIdx, 1);
2820
+ leftWidths.splice(dropIdx, 1);
2756
2821
  leftSegIds.splice(dropIdx, 1);
2757
- leftWidth = groupWidth(left, leftCapWidth + bandCapWidth, leftSepWidth);
2822
+ leftWidth = left.length > 0 ? leftWidth - removedWidth - leftSepWidth - 2 : 0;
2758
2823
  }
2759
2824
  }
2760
2825
 
@@ -2906,8 +2971,12 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2906
2971
  const speculationColor = theme.getFgAnsi("muted");
2907
2972
  const overflowColor = theme.getFgAnsi("error");
2908
2973
  const rawAccentHex = accentHex ?? theme.getColorHex("borderAccent");
2909
- const dimmedAccentHex = adjustHsv(rawAccentHex, { s: 0.7, v: 0.75 });
2910
- const thresholdColor = getSessionAccentAnsi(dimmedAccentHex) ?? usedColor;
2974
+ let dimmedAccent = this.#dimmedAccentMemo;
2975
+ if (dimmedAccent?.sourceHex !== rawAccentHex) {
2976
+ dimmedAccent = { sourceHex: rawAccentHex, hex: adjustHsv(rawAccentHex, { s: 0.7, v: 0.75 }) };
2977
+ this.#dimmedAccentMemo = dimmedAccent;
2978
+ }
2979
+ const thresholdColor = getSessionAccentAnsi(dimmedAccent.hex) ?? usedColor;
2911
2980
 
2912
2981
  let out = "\x1b[49m";
2913
2982
  let activeColor = "";
@@ -2938,9 +3007,35 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2938
3007
 
2939
3008
  /** Auto-compaction boundary percents, or null when unavailable (disabled, no window). */
2940
3009
  #compactionBoundaries(contextWindow: number): CompactionBoundaries | null {
2941
- const model = this.session.state?.model ?? this.session.model;
3010
+ const session = this.session;
3011
+ const model = session.state?.model ?? session.model;
3012
+ const settingsRef = this.host.getSessionSettingsIdentity(session);
3013
+ const settingsRevision = this.host.getSessionSettingsRevision(session);
3014
+ const globalSettingsRevision = this.host.getSettingsRevision();
3015
+ const memo = this.#compactionBoundariesMemo;
3016
+ if (
3017
+ memo &&
3018
+ memo.sessionRef === session &&
3019
+ memo.contextWindow === contextWindow &&
3020
+ memo.modelRef === model &&
3021
+ memo.settingsRef === settingsRef &&
3022
+ memo.settingsRevision === settingsRevision &&
3023
+ memo.globalSettingsRevision === globalSettingsRevision
3024
+ ) {
3025
+ return memo.boundaries;
3026
+ }
2942
3027
  try {
2943
- return this.host.computeCompactionBoundaries(this.session, contextWindow, model);
3028
+ const boundaries = this.host.computeCompactionBoundaries(session, contextWindow, model);
3029
+ this.#compactionBoundariesMemo = {
3030
+ sessionRef: session,
3031
+ contextWindow,
3032
+ modelRef: model,
3033
+ settingsRef,
3034
+ settingsRevision,
3035
+ globalSettingsRevision,
3036
+ boundaries,
3037
+ };
3038
+ return boundaries;
2944
3039
  } catch {
2945
3040
  return null;
2946
3041
  }
@@ -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);