@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,6 @@ export interface BillingSummaryOptions {
7
7
  readonly usingSubscription: boolean;
8
8
  readonly premiumRequests: number;
9
9
  readonly fractionDigits: number;
10
- readonly startupPlaceholder?: boolean;
11
10
  readonly pricingPeriod?: "peak" | "off-peak";
12
11
  readonly advisor?: {
13
12
  readonly cost: number;
@@ -30,24 +29,13 @@ function formatSpend(amount: number, usingSubscription: boolean, fractionDigits:
30
29
  return `S${formatted}`;
31
30
  }
32
31
 
33
- function formatSpendPlaceholder(usingSubscription: boolean, uiTheme: Theme): string {
34
- if (!usingSubscription) return "$…";
35
- if (uiTheme.getSymbolPreset() === "nerd" && uiTheme.icon.subscription) {
36
- return `${uiTheme.icon.subscription} …`;
37
- }
38
- return "S…";
39
- }
40
-
41
32
  function formatAdvisorSpend(
42
33
  amount: number,
43
34
  usingSubscription: boolean,
44
35
  fractionDigits: number,
45
- placeholder: boolean,
46
36
  uiTheme: Theme,
47
37
  ): string {
48
- const spend = placeholder
49
- ? formatSpendPlaceholder(usingSubscription, uiTheme)
50
- : formatSpend(amount, usingSubscription, fractionDigits, uiTheme);
38
+ const spend = formatSpend(amount, usingSubscription, fractionDigits, uiTheme);
51
39
  const icon = uiTheme.icon.advisor;
52
40
  return icon && icon !== "(adv)" ? `${icon} ${spend}` : `${spend} (adv)`;
53
41
  }
@@ -63,21 +51,16 @@ export function formatBillingSummary(options: BillingSummaryOptions, uiTheme: Th
63
51
  return undefined;
64
52
  }
65
53
 
66
- const placeholder = options.startupPlaceholder === true;
67
54
  const parts: string[] = [];
68
55
  if (options.cost || options.pricingPeriod) {
69
- parts.push(
70
- placeholder
71
- ? formatSpendPlaceholder(options.usingSubscription, uiTheme)
72
- : formatSpend(options.cost, options.usingSubscription, options.fractionDigits, uiTheme),
73
- );
56
+ parts.push(formatSpend(options.cost, options.usingSubscription, options.fractionDigits, uiTheme));
74
57
  } else if (options.usingSubscription) {
75
58
  parts.push(
76
59
  uiTheme.getSymbolPreset() === "nerd" && uiTheme.icon.subscription ? uiTheme.icon.subscription : "(sub)",
77
60
  );
78
61
  }
79
62
  if (options.pricingPeriod) parts.push(options.pricingPeriod === "peak" ? "↑" : "↓");
80
- if (premiumRequests) parts.push(`★ ${placeholder ? "…" : formatNumber(premiumRequests)}`);
63
+ if (premiumRequests) parts.push(`★ ${formatNumber(premiumRequests)}`);
81
64
  if (advisorCost && options.advisor) {
82
65
  const prefix = parts.length > 0 ? "+ " : "";
83
66
  parts.push(
@@ -85,7 +68,6 @@ export function formatBillingSummary(options: BillingSummaryOptions, uiTheme: Th
85
68
  advisorCost,
86
69
  options.advisor.usingSubscription,
87
70
  options.fractionDigits,
88
- placeholder,
89
71
  uiTheme,
90
72
  )}`,
91
73
  );
@@ -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";
@@ -28,15 +27,10 @@ export type { SegmentContext } from "./types";
28
27
  // Helpers
29
28
  // ═══════════════════════════════════════════════════════════════════════════
30
29
 
31
- const STARTUP_PLACEHOLDER = "…";
32
-
33
30
  function withIcon(icon: string, text: string): string {
34
31
  return icon ? `${icon} ${text}` : text;
35
32
  }
36
33
 
37
- function statusValue(ctx: SegmentContext, value: string): string {
38
- return ctx.startupPlaceholder ? STARTUP_PLACEHOLDER : value;
39
- }
40
34
  /**
41
35
  * Hash-derived accent ANSI for the session title (or preview stand-in title).
42
36
  * Undefined when `statusLine.sessionAccent` is off or the session is unnamed,
@@ -75,14 +69,6 @@ function leadingGlyph(display: string): string {
75
69
  return space === -1 ? display : display.slice(0, space);
76
70
  }
77
71
 
78
- function stripDisplayRoot(pwd: string): string {
79
- for (const root of [path.join(os.homedir(), "Projects"), "/work"]) {
80
- const relative = relativePathWithinRoot(root, pwd);
81
- if (relative) return relative;
82
- }
83
- return pwd;
84
- }
85
-
86
72
  /**
87
73
  * Single-field usage counter (`token_in`, `token_out`, `cache_read`,
88
74
  * `cache_write`). Hidden on zero, startup-placeholder aware, icon omitted when
@@ -101,7 +87,7 @@ function singleStatSegment(
101
87
  if (!value) return { content: "", visible: false };
102
88
  const content = formatMetric({
103
89
  leading: theme.icon[iconKey] || undefined,
104
- value: statusValue(ctx, formatNumber(value)),
90
+ value: formatNumber(value),
105
91
  });
106
92
  return { content: theme.fg(color, content ?? ""), visible: true };
107
93
  },
@@ -126,28 +112,50 @@ const NORMALIZED_SCRATCH_ROOTS: readonly string[] = (() => {
126
112
  return [...new Set(Array.from(roots, normalizePathForComparison))];
127
113
  })();
128
114
 
129
- interface ProjectDirClassification {
115
+ interface ProjectDirDisplay {
116
+ projectDir: string;
117
+ homeDir: string;
118
+ displayRoots: readonly string[] | undefined;
130
119
  scratch: boolean;
131
- relative: string | null;
120
+ displayPath: string;
132
121
  }
133
122
 
134
- const PROJECT_DIR_CLASSIFICATIONS = new Map<string, ProjectDirClassification>();
123
+ let projectDirDisplay: ProjectDirDisplay | undefined;
135
124
 
136
- function classifyProjectDir(projectDir: string): ProjectDirClassification {
137
- const cached = PROJECT_DIR_CLASSIFICATIONS.get(projectDir);
138
- 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;
139
133
 
134
+ const homeDir = os.homedir();
135
+ let displayRoots = cached?.homeDir === homeDir ? cached.displayRoots : undefined;
140
136
  const normalizedProjectDir = normalizePathForComparison(projectDir);
141
- let classification: ProjectDirClassification = { scratch: false, relative: null };
137
+ let scratch = false;
138
+ let displayPath = projectDir;
142
139
  for (const normalizedRoot of NORMALIZED_SCRATCH_ROOTS) {
143
140
  const relative = relativePathWithinNormalizedRoot(normalizedRoot, normalizedProjectDir);
144
141
  if (relative !== null) {
145
- classification = { scratch: true, relative: relative || null };
142
+ scratch = true;
143
+ displayPath = relative || projectDir;
146
144
  break;
147
145
  }
148
146
  }
149
- PROJECT_DIR_CLASSIFICATIONS.set(projectDir, classification);
150
- 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;
151
159
  }
152
160
 
153
161
  // ═══════════════════════════════════════════════════════════════════════════
@@ -160,7 +168,7 @@ const piSegment: StatusLineSegment = {
160
168
  if (ctx.focusedAgentId) {
161
169
  const icon = theme.icon.ghost ? `${theme.icon.ghost} ` : "";
162
170
  return {
163
- content: theme.fg("warning", `${icon}${statusValue(ctx, ctx.focusedAgentId)}`),
171
+ content: theme.fg("warning", `${icon}${ctx.focusedAgentId}`),
164
172
  visible: true,
165
173
  };
166
174
  }
@@ -173,7 +181,7 @@ const piSegment: StatusLineSegment = {
173
181
  // trailing space here would double the gap at the first separator (#11103).
174
182
  const content =
175
183
  ctx.turnElapsedMs != null
176
- ? `${brandSpinnerFrame(ctx.now?.getTime())} ${statusValue(ctx, brandTimer(ctx.turnElapsedMs))}`
184
+ ? `${brandSpinnerFrame(ctx.now?.getTime())} ${brandTimer(ctx.turnElapsedMs)}`
177
185
  : theme.icon.omp
178
186
  ? theme.icon.omp
179
187
  : "";
@@ -220,7 +228,6 @@ const modelSegment: StatusLineSegment = {
220
228
  if (modelName.startsWith("Claude ")) {
221
229
  modelName = modelName.slice(7);
222
230
  }
223
- modelName = statusValue(ctx, modelName);
224
231
 
225
232
  // Resolve the current thinking-level display ("◉ xhigh", "⟳ auto", …)
226
233
  // when the model supports thinking and the segment isn't hiding it.
@@ -242,10 +249,6 @@ const modelSegment: StatusLineSegment = {
242
249
  }
243
250
  }
244
251
 
245
- if (ctx.startupPlaceholder && thinkingDisplay) {
246
- thinkingDisplay = withIcon(leadingGlyph(thinkingDisplay), STARTUP_PLACEHOLDER);
247
- }
248
-
249
252
  // Compact mode swaps the model icon for the thinking-level glyph and drops
250
253
  // the " · <level>" tail, keeping the level visible as a single icon.
251
254
  const compact = ctx.compactThinkingLevel && thinkingDisplay !== "";
@@ -337,7 +340,7 @@ function renderGoalMode(ctx: SegmentContext, mode: { enabled: boolean; paused: b
337
340
  const parts: string[] = [withIcon(icon, "Goal")];
338
341
  const showBudget = ctx.goalStatusInFooter === true;
339
342
  if (showBudget && goal) {
340
- parts.push(statusValue(ctx, formatGoalBudget(goal.tokensUsed, goal.tokenBudget)));
343
+ parts.push(formatGoalBudget(goal.tokensUsed, goal.tokenBudget));
341
344
  }
342
345
  return {
343
346
  content: color === "accent" ? accentFg(ctx, color, parts.join(" ")) : theme.fg(color, parts.join(" ")),
@@ -397,11 +400,13 @@ const modeSegment: StatusLineSegment = {
397
400
  if (loop) {
398
401
  const icon = loop.state === "paused" ? theme.icon.pause || theme.icon.loop : theme.icon.loop;
399
402
  const color: ThemeColor = loop.state === "paused" ? "warning" : "customMessageLabel";
400
- const parts = [withIcon(icon, `Loop ${statusValue(ctx, loop.state)}`)];
403
+ const stateLabel = loop.state === "waiting" ? "next prompt repeats" : loop.state;
404
+ const label = `Loop${loop.state === "waiting" ? ":" : ""} ${stateLabel}`;
405
+ const parts = [withIcon(icon, label)];
401
406
  const limit = formatLoopLimit(loop.limit, ctx.now?.getTime());
402
- if (limit) parts.push(statusValue(ctx, limit));
407
+ if (limit) parts.push(limit);
403
408
  if (loop.condition) {
404
- parts.push(statusValue(ctx, summarizeLoopCondition(loop.condition, TRUNCATE_LENGTHS.SHORT)));
409
+ parts.push(summarizeLoopCondition(loop.condition, TRUNCATE_LENGTHS.SHORT));
405
410
  }
406
411
  return { content: theme.fg(color, parts.join(" ")), visible: true };
407
412
  }
@@ -423,24 +428,14 @@ const pathSegment: StatusLineSegment = {
423
428
  if (stripPrefix && ctx.worktree) {
424
429
  const { projectName, worktreeName } = ctx.worktree;
425
430
  const label = ctx.git.branch === worktreeName ? projectName : `${projectName}/${worktreeName}`;
426
- const text = ctx.startupPlaceholder
427
- ? STARTUP_PLACEHOLDER
428
- : fileHyperlink(getProjectDir(), clampPathLength(label, opts.maxLength ?? 40));
431
+ const text = fileHyperlink(getProjectDir(), clampPathLength(label, opts.maxLength ?? 40));
429
432
  const content = withIcon(theme.icon.worktree, text);
430
433
  return { content: theme.fg("statusLinePath", content), visible: true };
431
434
  }
432
435
 
433
436
  const projectDir = ctx.activeRepo?.cwd ?? getProjectDir();
434
- const { scratch, relative } = classifyProjectDir(projectDir);
435
- let pwd = projectDir;
436
-
437
- if (stripPrefix) {
438
- if (scratch) {
439
- if (relative) pwd = relative;
440
- } else {
441
- pwd = stripDisplayRoot(pwd);
442
- }
443
- }
437
+ const { scratch, displayPath } = getProjectDirDisplay(projectDir);
438
+ let pwd = stripPrefix ? displayPath : projectDir;
444
439
  const repoSuffix = ctx.activeRepo ? ` ↳ ${ctx.activeRepo.relativeRepoRoot}` : "";
445
440
  if (opts.abbreviate !== false) {
446
441
  pwd = shortenPath(pwd);
@@ -450,7 +445,7 @@ const pathSegment: StatusLineSegment = {
450
445
 
451
446
  const showScratchIcon = scratch && stripPrefix;
452
447
  const icon = showScratchIcon ? theme.icon.scratchFolder : theme.icon.folder;
453
- const text = ctx.startupPlaceholder ? STARTUP_PLACEHOLDER : `${fileHyperlink(projectDir, pwd)}${repoSuffix}`;
448
+ const text = `${fileHyperlink(projectDir, pwd)}${repoSuffix}`;
454
449
  const content = withIcon(icon, text);
455
450
  return { content: theme.fg("statusLinePath", content), visible: true };
456
451
  },
@@ -469,20 +464,20 @@ const gitSegment: StatusLineSegment = {
469
464
  const showBranch = opts.showBranch !== false;
470
465
  let content = "";
471
466
  if (showBranch && branch) {
472
- content = withIcon(theme.icon.branch, statusValue(ctx, branch));
467
+ content = withIcon(theme.icon.branch, branch);
473
468
  }
474
469
 
475
470
  // Add status indicators
476
471
  if (gitStatus) {
477
472
  const indicators: string[] = [];
478
473
  if (opts.showUnstaged !== false && gitStatus.unstaged > 0) {
479
- indicators.push(theme.fg("statusLineDirty", `*${statusValue(ctx, `${gitStatus.unstaged}`)}`));
474
+ indicators.push(theme.fg("statusLineDirty", `*${gitStatus.unstaged}`));
480
475
  }
481
476
  if (opts.showStaged !== false && gitStatus.staged > 0) {
482
- indicators.push(theme.fg("statusLineStaged", `+${statusValue(ctx, `${gitStatus.staged}`)}`));
477
+ indicators.push(theme.fg("statusLineStaged", `+${gitStatus.staged}`));
483
478
  }
484
479
  if (opts.showUntracked !== false && gitStatus.untracked > 0) {
485
- indicators.push(theme.fg("statusLineUntracked", `?${statusValue(ctx, `${gitStatus.untracked}`)}`));
480
+ indicators.push(theme.fg("statusLineUntracked", `?${gitStatus.untracked}`));
486
481
  }
487
482
  if (indicators.length > 0) {
488
483
  const indicatorText = indicators.join(" ");
@@ -507,9 +502,8 @@ const prSegment: StatusLineSegment = {
507
502
  const { pr } = ctx.git;
508
503
  if (!pr) return { content: "", visible: false };
509
504
 
510
- const label = withIcon(theme.icon.pr, `#${statusValue(ctx, `${pr.number}`)}`);
511
- const content =
512
- !ctx.startupPlaceholder && TERMINAL.hyperlinks ? `\x1b]8;;${pr.url}\x07${label}\x1b]8;;\x07` : label;
505
+ const label = withIcon(theme.icon.pr, `#${pr.number}`);
506
+ const content = TERMINAL.hyperlinks ? `\x1b]8;;${pr.url}\x07${label}\x1b]8;;\x07` : label;
513
507
  return { content: accentFg(ctx, "accent", content), visible: true };
514
508
  },
515
509
  };
@@ -520,7 +514,7 @@ const subagentsSegment: StatusLineSegment = {
520
514
  if (ctx.subagentCount === 0) {
521
515
  return { content: "", visible: false };
522
516
  }
523
- const content = withIcon(theme.icon.agents, statusValue(ctx, `${ctx.subagentCount}`));
517
+ const content = withIcon(theme.icon.agents, `${ctx.subagentCount}`);
524
518
  return { content: theme.fg("statusLineSubagents", content), visible: true };
525
519
  },
526
520
  };
@@ -542,7 +536,7 @@ const tokenTotalSegment: StatusLineSegment = {
542
536
 
543
537
  const content = formatMetric({
544
538
  leading: theme.icon.tokens || undefined,
545
- value: statusValue(ctx, formatNumber(total)),
539
+ value: formatNumber(total),
546
540
  });
547
541
  return { content: theme.fg("statusLineSpend", content ?? ""), visible: true };
548
542
  },
@@ -556,7 +550,7 @@ const tokenRateSegment: StatusLineSegment = {
556
550
 
557
551
  const content = formatMetric({
558
552
  leading: theme.icon.throughput || undefined,
559
- value: `${statusValue(ctx, tokensPerSecond.toFixed(1))} tok/s`,
553
+ value: `${tokensPerSecond.toFixed(1)} tok/s`,
560
554
  });
561
555
  return { content: theme.fg("statusLineOutput", content ?? ""), visible: true };
562
556
  },
@@ -583,7 +577,6 @@ const costSegment: StatusLineSegment = {
583
577
  usingSubscription,
584
578
  premiumRequests,
585
579
  fractionDigits: 2,
586
- startupPlaceholder: ctx.startupPlaceholder,
587
580
  pricingPeriod,
588
581
  advisor: advisorCost
589
582
  ? {
@@ -623,9 +616,10 @@ const contextPctSegment: StatusLineSegment = {
623
616
  : theme.fg(color, theme.icon.auto)
624
617
  }`;
625
618
  }
619
+ // A known window with unknown usage (startup prepaint) shows the window alone.
626
620
  const text = theme.fg(
627
621
  color,
628
- ctx.startupPlaceholder ? STARTUP_PLACEHOLDER : formatContextUsage(pct, window, ctx.contextTokens),
622
+ pct === null && window > 0 ? formatNumber(window) : formatContextUsage(pct, window, ctx.contextTokens),
629
623
  );
630
624
  const content = withIcon(theme.icon.context, `${text}${autoIcon}`);
631
625
 
@@ -639,7 +633,7 @@ const contextTotalSegment: StatusLineSegment = {
639
633
  const window = ctx.contextWindow;
640
634
  if (!window) return { content: "", visible: false };
641
635
  return {
642
- content: theme.fg("statusLineContext", withIcon(theme.icon.context, statusValue(ctx, formatNumber(window)))),
636
+ content: theme.fg("statusLineContext", withIcon(theme.icon.context, formatNumber(window))),
643
637
  visible: true,
644
638
  };
645
639
  },
@@ -657,7 +651,7 @@ const timeSpentSegment: StatusLineSegment = {
657
651
  id: "time_spent",
658
652
  render(ctx) {
659
653
  if (ctx.activeMs < 1000) return { content: "", visible: false };
660
- return { content: withIcon(theme.icon.time, statusValue(ctx, formatDuration(ctx.activeMs))), visible: true };
654
+ return { content: withIcon(theme.icon.time, formatDuration(ctx.activeMs)), visible: true };
661
655
  },
662
656
  };
663
657
 
@@ -681,7 +675,7 @@ const timeSegment: StatusLineSegment = {
681
675
  }
682
676
  timeStr += suffix;
683
677
 
684
- return { content: withIcon(theme.icon.time, statusValue(ctx, timeStr)), visible: true };
678
+ return { content: withIcon(theme.icon.time, timeStr), visible: true };
685
679
  },
686
680
  };
687
681
 
@@ -690,7 +684,7 @@ const sessionSegment: StatusLineSegment = {
690
684
  render(ctx) {
691
685
  const sessionManager = ctx.session.sessionManager;
692
686
  const sessionId = sessionManager?.getSessionId?.();
693
- const display = statusValue(ctx, sessionId?.slice(0, 8) || "new");
687
+ const display = sessionId?.slice(0, 8) || "new";
694
688
 
695
689
  return { content: withIcon(theme.icon.session, display), visible: true };
696
690
  },
@@ -699,7 +693,7 @@ const sessionSegment: StatusLineSegment = {
699
693
  const hostnameSegment: StatusLineSegment = {
700
694
  id: "hostname",
701
695
  render(ctx) {
702
- const name = statusValue(ctx, ctx.hostname ?? os.hostname().split(".")[0]);
696
+ const name = ctx.hostname ?? os.hostname().split(".")[0];
703
697
  const content = withIcon(theme.icon.host, name);
704
698
  const ansi = sessionAccentAnsi(ctx);
705
699
  return { content: ansi ? `${ansi}${content}\x1b[39m` : content, visible: true };
@@ -729,7 +723,7 @@ const cacheHitSegment: StatusLineSegment = {
729
723
  const total = cacheRead + cacheWrite + input;
730
724
 
731
725
  const rate = (cacheRead / total) * 100;
732
- const rateStr = statusValue(ctx, rate.toFixed(2));
726
+ const rateStr = rate.toFixed(2);
733
727
 
734
728
  const parts: string[] = [theme.icon.cache];
735
729
  parts.push(theme.fg("statusLineSpend", `${rateStr}%`));
@@ -744,7 +738,7 @@ const sessionNameSegment: StatusLineSegment = {
744
738
  const name = sessionManager?.getSessionName() || ctx.previewTitle;
745
739
  if (!name) return { content: "", visible: false };
746
740
 
747
- const content = ctx.startupPlaceholder ? STARTUP_PLACEHOLDER : sanitizeStatusText(name);
741
+ const content = sanitizeStatusText(name);
748
742
  return { content: accentFg(ctx, "accent", content), visible: true };
749
743
  },
750
744
  };
@@ -753,7 +747,7 @@ const collabSegment: StatusLineSegment = {
753
747
  id: "collab",
754
748
  render(ctx) {
755
749
  if (!ctx.collab) return { content: "", visible: false };
756
- const participants = statusValue(ctx, `${ctx.collab.participantCount}`);
750
+ const participants = `${ctx.collab.participantCount}`;
757
751
  const label = ctx.collab.role === "host" ? `⇄ collab:${participants}` : `⇄ collab guest:${participants}`;
758
752
  return { content: accentFg(ctx, "accent", label), visible: true };
759
753
  },
@@ -763,7 +757,7 @@ const streamSegment: StatusLineSegment = {
763
757
  id: "stream",
764
758
  render(ctx) {
765
759
  const badges: string[] = [];
766
- if (ctx.stream) badges.push(`● LIVE ${statusValue(ctx, `${ctx.stream.viewers}`)}`);
760
+ if (ctx.stream) badges.push(`● LIVE ${ctx.stream.viewers}`);
767
761
  if (ctx.recording) badges.push("● REC");
768
762
  if (badges.length === 0) return { content: "", visible: false };
769
763
  return { content: theme.fg("thinkingHigh", badges.join(" ")), visible: true };
@@ -838,11 +832,8 @@ function formatQuotaWindow(
838
832
  integer: "round" | "floor",
839
833
  ): string {
840
834
  const whole = integer === "floor" ? Math.floor(percent) : Math.round(percent);
841
- const pctText = theme.fg(pickUsageColor(percent), `${statusValue(ctx, `${whole}`)}%`);
842
- const resetText =
843
- reset !== undefined
844
- ? theme.fg("muted", ctx.startupPlaceholder ? " (…)" : ` (${formatUsageReset(reset, resetUnit)})`)
845
- : "";
835
+ const pctText = theme.fg(pickUsageColor(percent), `${whole}%`);
836
+ const resetText = reset !== undefined ? theme.fg("muted", ` (${formatUsageReset(reset, resetUnit)})`) : "";
846
837
  return `${label} ${pctText}${resetText}`;
847
838
  }
848
839
 
@@ -870,9 +861,7 @@ const usageSegment: StatusLineSegment = {
870
861
  }
871
862
  const parts: string[] = [];
872
863
  if (u.tier) {
873
- const tier = ctx.startupPlaceholder
874
- ? STARTUP_PLACEHOLDER
875
- : truncateToWidth(sanitizeStatusText(u.tier), TRUNCATE_LENGTHS.SHORT);
864
+ const tier = truncateToWidth(sanitizeStatusText(u.tier), TRUNCATE_LENGTHS.SHORT);
876
865
  if (tier) parts.push(accentFg(ctx, "accent", tier));
877
866
  }
878
867
  if (u.fiveHour) {
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Status line for the speculative first frame, before any session exists.
3
+ *
4
+ * InteractiveMode persists {@link StatusLineStartupData} (settings plus the last
5
+ * session's model and thinking state); the next launch renders it through the
6
+ * real {@link StatusLineComponent} as a fresh, unnamed session at the live
7
+ * terminal width. Path and git branch are read live; context usage is reported
8
+ * unknown so the gauge shows the window without a percent.
9
+ *
10
+ * @example
11
+ * ```ts ignore
12
+ * const statusLine = createStartupStatusLine(cached.statusLine);
13
+ * statusLine.attachToEditor(editor, getComposerStyle(shape));
14
+ * ```
15
+ */
16
+ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
17
+ import type { Model } from "@oh-my-pi/pi-catalog/types";
18
+ import { isRecord } from "@oh-my-pi/pi-utils/type-guards";
19
+ import { parseThinkingLevel } from "../thinking";
20
+ import { StatusLineComponent } from "./component";
21
+ import type { CompactionBoundaries } from "./context-usage";
22
+ import type { StatusLineHost, StatusLineSession } from "./host";
23
+ import {
24
+ CONTEXT_LINE_MODE_VALUES,
25
+ STATUS_LINE_PRESET_VALUES,
26
+ STATUS_LINE_SEGMENT_IDS,
27
+ STATUS_LINE_SEPARATOR_VALUES,
28
+ } from "./schema";
29
+ import type { StatusLineSettings } from "./types";
30
+
31
+ /** Session-independent inputs that reproduce a fresh session's status bar on the next launch. */
32
+ export interface StatusLineStartupData {
33
+ readonly settings: StatusLineSettings;
34
+ readonly gitEnabled: boolean;
35
+ /** Model of the last session; the next fresh session usually starts on it too. */
36
+ readonly model?: Model;
37
+ readonly thinkingLevel?: ThinkingLevel;
38
+ readonly autoThinking: boolean;
39
+ readonly fastMode: boolean;
40
+ /** Whether `model` bills through a subscription (drives the cost segment's prefix). */
41
+ readonly usingSubscription: boolean;
42
+ readonly autoCompactEnabled: boolean;
43
+ readonly compactionBoundaries: CompactionBoundaries | null;
44
+ }
45
+
46
+ const NO_MESSAGES: readonly never[] = [];
47
+ const NO_USAGE = {
48
+ input: 0,
49
+ output: 0,
50
+ cacheRead: 0,
51
+ cacheWrite: 0,
52
+ totalTokens: 0,
53
+ orchestrationInput: 0,
54
+ orchestrationOutput: 0,
55
+ orchestrationCacheRead: 0,
56
+ premiumRequests: 0,
57
+ cost: 0,
58
+ };
59
+
60
+ /** Build the prepaint status line; the caller disposes it once the session-bound bar mounts. */
61
+ export function createStartupStatusLine(data: StatusLineStartupData): StatusLineComponent {
62
+ const session: StatusLineSession = {
63
+ state: { model: data.model, thinkingLevel: data.thinkingLevel, messages: NO_MESSAGES },
64
+ model: data.model,
65
+ messages: NO_MESSAGES,
66
+ isStreaming: false,
67
+ isAutoThinking: data.autoThinking,
68
+ sessionManager: {
69
+ getSessionName: () => undefined,
70
+ getSessionId: () => "",
71
+ getUsageStatistics: () => NO_USAGE,
72
+ },
73
+ modelRegistry: { isUsingOAuth: () => data.usingSubscription },
74
+ getContextUsage: () =>
75
+ data.model?.contextWindow ? { tokens: 0, contextWindow: data.model.contextWindow, percent: null } : undefined,
76
+ autoResolvedThinkingLevel: () => undefined,
77
+ isFastModeActive: () => data.fastMode,
78
+ getAsyncJobSnapshot: () => null,
79
+ getGoalModeState: () => undefined,
80
+ };
81
+ const host: StatusLineHost = {
82
+ getSettings: () => data.settings,
83
+ gitEnabled: () => data.gitEnabled,
84
+ codexResetFireworksEnabled: () => false,
85
+ getSettingsRevision: () => 0,
86
+ getSessionSettingsIdentity: () => undefined,
87
+ getSessionSettingsRevision: () => 0,
88
+ goalStatusInFooter: () => false,
89
+ activeAccount: () => undefined,
90
+ canFetchUsageReports: () => false,
91
+ fetchUsageReports: async () => null,
92
+ resolveActiveRepo: () => null,
93
+ lookupPullRequest: async () => ({ stdout: "", exitCode: 1 }),
94
+ calculateTokensPerSecond: () => null,
95
+ limitMatchesActiveAccount: () => false,
96
+ computeCompactionBoundaries: () => data.compactionBoundaries,
97
+ };
98
+ const statusLine = new StatusLineComponent(session, host);
99
+ statusLine.setAutoCompactEnabled(data.autoCompactEnabled);
100
+ return statusLine;
101
+ }
102
+
103
+ function isOneOf<T extends string>(values: readonly T[], value: unknown): value is T {
104
+ return typeof value === "string" && values.some(candidate => candidate === value);
105
+ }
106
+
107
+ function isOptionalBoolean(value: unknown): boolean {
108
+ return value === undefined || typeof value === "boolean";
109
+ }
110
+
111
+ function isSegmentList(value: unknown): boolean {
112
+ return value === undefined || (Array.isArray(value) && value.every(id => isOneOf(STATUS_LINE_SEGMENT_IDS, id)));
113
+ }
114
+
115
+ /** Shallow check of persisted settings; nested segment options are trusted as written by this version. */
116
+ function isStatusLineSettings(value: unknown): value is StatusLineSettings {
117
+ return (
118
+ isRecord(value) &&
119
+ (value.preset === undefined || isOneOf(STATUS_LINE_PRESET_VALUES, value.preset)) &&
120
+ isSegmentList(value.leftSegments) &&
121
+ isSegmentList(value.rightSegments) &&
122
+ (value.separator === undefined || isOneOf(STATUS_LINE_SEPARATOR_VALUES, value.separator)) &&
123
+ (value.segmentOptions === undefined || isRecord(value.segmentOptions)) &&
124
+ (value.contextLine === undefined || isOneOf(CONTEXT_LINE_MODE_VALUES, value.contextLine)) &&
125
+ isOptionalBoolean(value.showHookStatus) &&
126
+ isOptionalBoolean(value.sessionAccent) &&
127
+ isOptionalBoolean(value.transparent) &&
128
+ isOptionalBoolean(value.compactThinkingLevel)
129
+ );
130
+ }
131
+
132
+ /** Check the model fields the status line reads; the rest is trusted as serialized by this version. */
133
+ function isPersistedModel(value: unknown): value is Model {
134
+ return (
135
+ isRecord(value) &&
136
+ typeof value.id === "string" &&
137
+ typeof value.name === "string" &&
138
+ typeof value.provider === "string" &&
139
+ typeof value.api === "string" &&
140
+ typeof value.contextWindow === "number"
141
+ );
142
+ }
143
+
144
+ function readCompactionBoundaries(value: unknown): CompactionBoundaries | null | undefined {
145
+ if (value === null) return null;
146
+ if (!isRecord(value) || typeof value.thresholdPercent !== "number") return undefined;
147
+ const speculationPercent = value.speculationPercent;
148
+ if (speculationPercent !== null && typeof speculationPercent !== "number") return undefined;
149
+ return { thresholdPercent: value.thresholdPercent, speculationPercent };
150
+ }
151
+
152
+ /** Validate persisted {@link StatusLineStartupData}; `undefined` for anything malformed. */
153
+ export function readStatusLineStartupData(value: unknown): StatusLineStartupData | undefined {
154
+ if (!isRecord(value) || !isStatusLineSettings(value.settings)) return undefined;
155
+ const model = value.model;
156
+ if (model !== undefined && !isPersistedModel(model)) return undefined;
157
+ const thinkingLevel = typeof value.thinkingLevel === "string" ? parseThinkingLevel(value.thinkingLevel) : undefined;
158
+ if (value.thinkingLevel !== undefined && thinkingLevel === undefined) return undefined;
159
+ const compactionBoundaries = readCompactionBoundaries(value.compactionBoundaries);
160
+ if (
161
+ typeof value.gitEnabled !== "boolean" ||
162
+ typeof value.autoThinking !== "boolean" ||
163
+ typeof value.fastMode !== "boolean" ||
164
+ typeof value.usingSubscription !== "boolean" ||
165
+ typeof value.autoCompactEnabled !== "boolean" ||
166
+ compactionBoundaries === undefined
167
+ ) {
168
+ return undefined;
169
+ }
170
+ return {
171
+ settings: value.settings,
172
+ gitEnabled: value.gitEnabled,
173
+ model,
174
+ thinkingLevel,
175
+ autoThinking: value.autoThinking,
176
+ fastMode: value.fastMode,
177
+ usingSubscription: value.usingSubscription,
178
+ autoCompactEnabled: value.autoCompactEnabled,
179
+ compactionBoundaries,
180
+ };
181
+ }
@@ -84,8 +84,6 @@ export interface SegmentContext {
84
84
  sessionAccent?: boolean;
85
85
  /** Stand-in session title for previews; `session_name` renders it when the session is unnamed. */
86
86
  previewTitle?: string;
87
- /** Replace dynamic values with ellipses while preserving each segment's icon, color, and static text. */
88
- startupPlaceholder?: boolean;
89
87
  activeRepo: ActiveRepoContext | null;
90
88
  width: number;
91
89
  options: StatusLineSegmentOptions;