@oh-my-pi/pi-tui 18.2.7 → 18.2.9

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.
@@ -10,7 +10,12 @@ import { resolveUsedFraction, type UsageLimit, type UsageReport } from "@oh-my-p
10
10
  import { type Component, matchesKey, replaceTabs, routeSgrMouseInput, truncateToWidth, visibleWidth } from "../index";
11
11
  import { colorLuma, formatDuration, hexToRgb, rgbToHex, sanitizeText } from "@oh-my-pi/pi-utils";
12
12
  import { formatProviderName } from "../chrome/format";
13
- import { collapseSharedUsageReports, formatLimitTitle } from "./usage-display";
13
+ import {
14
+ collapseSharedUsageReports,
15
+ formatLimitTitle,
16
+ summarizeUsageResetCredits,
17
+ type UsageResetSummary,
18
+ } from "./usage-display";
14
19
  import { colorToAnsi } from "../theme/color";
15
20
  import { ensureThemeSync, theme } from "../theme/theme";
16
21
  import { formatAbsoluteOnlyAmount } from "../prompt/usage-amounts";
@@ -61,6 +66,12 @@ export interface ProviderCard {
61
66
  unlimited: boolean;
62
67
  /** True when nothing is used anywhere (or there are no limits): collapses to a tick. */
63
68
  idle: boolean;
69
+ resetCredits?: {
70
+ bankedCount: number;
71
+ redeemableCount: number;
72
+ soonestExpiryMs?: number;
73
+ unavailableReasons: string[];
74
+ };
64
75
  }
65
76
 
66
77
  /**
@@ -152,13 +163,43 @@ export function buildProviderCards(reports: UsageReport[], nowMs: number): Provi
152
163
  if (!duplicated) window.windowTag = undefined;
153
164
  }
154
165
 
166
+ const resetRows = providerReports
167
+ .map(report => summarizeUsageResetCredits(report.resetCredits, nowMs))
168
+ .filter((summary): summary is UsageResetSummary => summary !== undefined && summary.bankedCount > 0);
169
+ const bankedCount = resetRows.reduce((total, summary) => total + summary.bankedCount, 0);
170
+ const redeemableCount = resetRows.reduce((total, summary) => total + summary.redeemableCount, 0);
171
+ const resetExpiries = resetRows
172
+ .map(summary => summary.soonestExpiry)
173
+ .filter((expiry): expiry is string => expiry !== undefined)
174
+ .map(expiry => Date.parse(expiry))
175
+ .filter(Number.isFinite)
176
+ .sort((left, right) => left - right);
177
+ const soonestResetExpiry = resetExpiries.find(expiry => expiry > nowMs) ?? resetExpiries.at(-1);
178
+ const unavailableReasons = [
179
+ ...new Set(
180
+ resetRows
181
+ .map(summary => summary.unavailableReason)
182
+ .filter((reason): reason is string => reason !== undefined),
183
+ ),
184
+ ];
185
+ const resetCredits =
186
+ bankedCount > 0
187
+ ? {
188
+ bankedCount,
189
+ redeemableCount,
190
+ soonestExpiryMs: soonestResetExpiry === undefined ? undefined : soonestResetExpiry - nowMs,
191
+ unavailableReasons,
192
+ }
193
+ : undefined;
155
194
  cards.push({
156
195
  provider,
157
196
  name: formatProviderName(provider),
158
197
  accounts: providerReports.length,
159
198
  windows,
160
199
  unlimited: windows.length === 0,
161
- idle: windows.every(window => window.fraction !== undefined && window.fraction < IDLE_FRACTION),
200
+ idle:
201
+ !resetCredits && windows.every(window => window.fraction !== undefined && window.fraction < IDLE_FRACTION),
202
+ resetCredits,
162
203
  });
163
204
  }
164
205
 
@@ -391,6 +432,25 @@ export class UsageDashboardComponent implements Component {
391
432
  const titlePad = Math.max(0, width - 2 - visibleWidth(title) - visibleWidth(accountsText));
392
433
  lines.push(`${this.#statusIcon(cardStatus)} ${title}${" ".repeat(titlePad)}${accountsText}`);
393
434
 
435
+ if (card.resetCredits) {
436
+ const resets = card.resetCredits;
437
+ let resetText = `✦ ${resets.bankedCount} reset${resets.bankedCount === 1 ? "" : "s"}`;
438
+ if (resets.redeemableCount !== resets.bankedCount) {
439
+ resetText += ` · ${resets.redeemableCount} usable`;
440
+ }
441
+ if (resets.soonestExpiryMs !== undefined) {
442
+ resetText +=
443
+ resets.soonestExpiryMs > 0 ? ` · expires ${formatDuration(resets.soonestExpiryMs)}` : " · expired";
444
+ }
445
+ lines.push(
446
+ ` ${theme.fg(resets.redeemableCount > 0 ? "success" : "warning", truncateToWidth(resetText, width - 2))}`,
447
+ );
448
+ if (resets.redeemableCount === 0 && resets.unavailableReasons.length > 0) {
449
+ const reason = sanitizeText(resets.unavailableReasons.join(" • ").replace(/[\r\n\t]+/g, " "));
450
+ lines.push(` ${theme.fg("dim", truncateToWidth(`unavailable: ${reason}`, width - 2))}`);
451
+ }
452
+ }
453
+
394
454
  if (card.unlimited) {
395
455
  lines.push(` ${theme.fg("dim", "no limits")}`);
396
456
  return lines;
@@ -1,4 +1,4 @@
1
- import type { UsageLimit, UsageReport } from "@oh-my-pi/pi-ai";
1
+ import type { UsageLimit, UsageReport, UsageResetCredits } from "@oh-my-pi/pi-ai";
2
2
 
3
3
  /** Include the usage tier in a limit title unless its label already names it. */
4
4
  export function formatLimitTitle(limit: UsageLimit): string {
@@ -27,6 +27,79 @@ function collapseSharedLimits(limits: UsageLimit[]): UsageLimit[] {
27
27
  return collapsed ?? limits;
28
28
  }
29
29
 
30
+ /** Human-readable quota scope for a normalized saved-reset window ID. */
31
+ export function formatUsageResetWindow(windowId: string): string {
32
+ switch (windowId) {
33
+ case "anthropic:5h":
34
+ return "Claude 5h";
35
+ case "anthropic:7d":
36
+ return "Claude weekly";
37
+ case "anthropic:7d:opus":
38
+ return "Claude Opus weekly";
39
+ case "anthropic:7d:sonnet":
40
+ return "Claude Sonnet weekly";
41
+ default:
42
+ return windowId;
43
+ }
44
+ }
45
+
46
+ /** Saved inventory and current eligibility shared by every usage display. */
47
+ export interface UsageResetSummary {
48
+ bankedCount: number;
49
+ redeemableCount: number;
50
+ soonestExpiry?: string;
51
+ unavailableReason?: string;
52
+ }
53
+
54
+ /**
55
+ * Normalize old count-only and current provider reset metadata for display.
56
+ * `availableCount` is banked inventory; `redeemableCount` is the subset that
57
+ * may be spent now. Older providers used `availableCount` for both.
58
+ */
59
+ export function summarizeUsageResetCredits(
60
+ reset: UsageResetCredits | undefined,
61
+ nowMs = Date.now(),
62
+ ): UsageResetSummary | undefined {
63
+ if (!reset) return undefined;
64
+ const bankedCount = Math.max(0, Math.trunc(reset.availableCount));
65
+ const redeemableCount = Math.max(0, Math.trunc(reset.redeemableCount ?? reset.availableCount));
66
+ let soonestExpiry: string | undefined;
67
+ let soonestExpiryMs = Number.POSITIVE_INFINITY;
68
+ let latestExpired: string | undefined;
69
+ let latestExpiredMs = Number.NEGATIVE_INFINITY;
70
+ for (const credit of reset.credits ?? []) {
71
+ if (!credit.expiresAt || credit.remainingCount === 0 || credit.status === "redeemed") continue;
72
+ const expiryMs = Date.parse(credit.expiresAt);
73
+ if (!Number.isFinite(expiryMs)) continue;
74
+ if (expiryMs > nowMs && expiryMs < soonestExpiryMs) {
75
+ soonestExpiryMs = expiryMs;
76
+ soonestExpiry = credit.expiresAt;
77
+ } else if (expiryMs <= nowMs && expiryMs > latestExpiredMs) {
78
+ latestExpiredMs = expiryMs;
79
+ latestExpired = credit.expiresAt;
80
+ }
81
+ }
82
+ soonestExpiry ??= latestExpired;
83
+ const selectedCredit = reset.nextCreditId
84
+ ? reset.credits?.find(credit => credit.id === reset.nextCreditId)
85
+ : (reset.credits?.find(credit => credit.usable !== false) ?? reset.credits?.[0]);
86
+ const unavailableReason =
87
+ reset.reason ??
88
+ (reset.cooldownUntil ? `cooldown until ${reset.cooldownUntil}` : undefined) ??
89
+ (selectedCredit?.blocking?.length
90
+ ? `blocked by ${selectedCredit.blocking.map(formatUsageResetWindow).join(", ")}`
91
+ : undefined) ??
92
+ (selectedCredit?.status && selectedCredit.status !== "available" ? selectedCredit.status : undefined) ??
93
+ (reset.eligible === false ? "not eligible" : undefined) ??
94
+ (bankedCount > 0 && redeemableCount === 0 ? "not usable right now" : undefined);
95
+ return {
96
+ bankedCount,
97
+ redeemableCount,
98
+ soonestExpiry,
99
+ unavailableReason,
100
+ };
101
+ }
102
+
30
103
  /** Collapse routing-specific copies of a shared quota for user-facing usage views. */
31
104
  export function collapseSharedUsageReports(reports: UsageReport[]): UsageReport[] {
32
105
  let collapsed: UsageReport[] | undefined;
@@ -9,7 +9,8 @@ import { TUI } from "../tui";
9
9
  import type { AppKeybinding } from "../app-keybindings";
10
10
  import { allowsModelMentions, allowsSkillTokens, SKILL_TOKEN_RE } from "./skill-tokens";
11
11
  import { expandModelMentionTags, MODEL_MENTION_RE, modelMentionToken } from "./model-mention-syntax";
12
- import { isVideoPath, videoPreviewSource } from "./video";
12
+ import { imageAttachmentSource } from "./image-source";
13
+ import { isVideoPath } from "./video";
13
14
  import {
14
15
  attachmentSgr,
15
16
  COMPOSER_TOKEN_REGEX,
@@ -711,7 +712,7 @@ export class CustomEditor extends Editor {
711
712
  if (!materialize || images.length === 0) return;
712
713
  const links = await materialize(images);
713
714
  if (!links || this.pendingImages !== images) return;
714
- this.pendingImageLinks = images.map((image, index) => videoPreviewSource(image) ?? links[index]);
715
+ this.pendingImageLinks = images.map((image, index) => imageAttachmentSource(image)?.path ?? links[index]);
715
716
  this.imageLinks = this.pendingImageLinks;
716
717
  this.#requestShimmerRepaint?.();
717
718
  }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Original filesystem source carried on image attachments that entered from a
3
+ * file on disk: path-pasted/drag-and-dropped images and generated video
4
+ * contact-sheet previews. Symbol metadata stays out of serialized/model-bound
5
+ * image data while traveling with the draft object, until AgentSession creates
6
+ * the hidden companion message that tells the model the path (and link
7
+ * materialization prefers it over a blob copy).
8
+ */
9
+ import type { ImageContent } from "@oh-my-pi/pi-ai";
10
+ import { isRecord } from "@oh-my-pi/pi-utils";
11
+
12
+ /** How an image attachment's backing file entered the session. */
13
+ export type ImageAttachmentSourceKind = "image" | "video";
14
+
15
+ /** Original local file backing an image attachment. */
16
+ export interface ImageAttachmentSource {
17
+ readonly path: string;
18
+ readonly kind: ImageAttachmentSourceKind;
19
+ }
20
+
21
+ const kImageAttachmentSource = Symbol("image.attachmentSource");
22
+
23
+ /** An image attachment tagged with the original local file it came from. */
24
+ export type SourceTaggedImage = ImageContent & {
25
+ readonly [kImageAttachmentSource]: ImageAttachmentSource;
26
+ };
27
+
28
+ /** Create a model-ready image tagged with the local file it was loaded from. */
29
+ export function tagImageAttachmentSource(
30
+ image: ImageContent,
31
+ path: string,
32
+ kind: ImageAttachmentSourceKind,
33
+ ): SourceTaggedImage {
34
+ return {
35
+ type: "image",
36
+ data: image.data,
37
+ mimeType: image.mimeType,
38
+ [kImageAttachmentSource]: { path, kind },
39
+ };
40
+ }
41
+
42
+ function isImageAttachmentSource(value: unknown): value is ImageAttachmentSource {
43
+ return isRecord(value) && typeof value.path === "string" && (value.kind === "image" || value.kind === "video");
44
+ }
45
+
46
+ /** Return the original local source of an image attachment, or undefined for payloads with no source file (clipboard bitmaps). */
47
+ export function imageAttachmentSource(image: ImageContent): ImageAttachmentSource | undefined {
48
+ if (!(kImageAttachmentSource in image)) return undefined;
49
+ const source = image[kImageAttachmentSource];
50
+ return isImageAttachmentSource(source) ? source : undefined;
51
+ }
@@ -1,5 +1,6 @@
1
1
  import * as path from "node:path";
2
2
  import type { ImageContent } from "@oh-my-pi/pi-ai";
3
+ import { type SourceTaggedImage, imageAttachmentSource, tagImageAttachmentSource } from "./image-source";
3
4
 
4
5
  /** Container extensions treated as video. Mirrors the video subset of the local-protocol binary list. */
5
6
  const VIDEO_EXTENSION_LOOKUP: Record<string, true> = {
@@ -17,31 +18,20 @@ export function isVideoPath(filePath: string): boolean {
17
18
  return VIDEO_EXTENSION_LOOKUP[path.extname(filePath).toLowerCase()] === true;
18
19
  }
19
20
 
20
- /**
21
- * Original local source path stored on a generated contact-sheet image. Symbol
22
- * metadata stays out of serialized/model-bound image data while traveling with
23
- * the draft object until AgentSession creates its hidden companion message.
24
- */
25
- const kVideoPreviewSource = Symbol("video.previewSource");
26
-
27
21
  /** A contact-sheet image tagged with the original local video path. */
28
- export type VideoPreviewImage = ImageContent & {
29
- readonly [kVideoPreviewSource]: string;
30
- };
22
+ export type VideoPreviewImage = SourceTaggedImage;
31
23
 
32
24
  /** Create a model-ready contact-sheet image tagged with its original video path. */
33
25
  export function createVideoPreviewImage(preview: ImageContent, sourcePath: string): VideoPreviewImage {
34
- return {
35
- type: "image",
36
- data: preview.data,
37
- mimeType: preview.mimeType,
38
- [kVideoPreviewSource]: sourcePath,
39
- };
26
+ return tagImageAttachmentSource(preview, sourcePath, "video");
40
27
  }
41
28
 
42
- /** Return the original video path associated with a generated contact-sheet image. */
29
+ /**
30
+ * Return the original video path associated with a generated contact-sheet
31
+ * image, via the shared attachment-source tag (see {@link tagImageAttachmentSource}).
32
+ * Returns undefined for untagged images and for image-file (non-video) sources.
33
+ */
43
34
  export function videoPreviewSource(preview: ImageContent): string | undefined {
44
- if (!(kVideoPreviewSource in preview)) return undefined;
45
- const sourcePath = preview[kVideoPreviewSource];
46
- return typeof sourcePath === "string" ? sourcePath : undefined;
35
+ const source = imageAttachmentSource(preview);
36
+ return source?.kind === "video" ? source.path : undefined;
47
37
  }
@@ -35,6 +35,7 @@ import {
35
35
  detectCodexResetFireworks,
36
36
  } from "../overlays/codex-reset-fireworks";
37
37
  import { canReuseCachedPr, createPrCacheContext, isSamePrCacheContext, type PrCacheContext } from "./git-utils";
38
+ import { summarizeUsageResetCredits } from "../overlays/usage-display";
38
39
  import { getPreset } from "./presets";
39
40
  import { renderSegment, type SegmentContext } from "./segments";
40
41
  import { getSeparator } from "./separators";
@@ -79,7 +80,7 @@ function normalizeUsageScopeValue(value: unknown): string | undefined {
79
80
  * be present and equal or a workspace sibling can mutate this account's
80
81
  * baseline.
81
82
  */
82
- function codexReportMatchesExactIdentity(report: UsageReport, identity: OAuthAccountIdentity | undefined): boolean {
83
+ function reportMatchesExactIdentity(report: UsageReport, identity: OAuthAccountIdentity | undefined): boolean {
83
84
  if (!identity) return false;
84
85
  const accountId = normalizeUsageScopeValue(identity.accountId);
85
86
  const email = normalizeUsageScopeValue(identity.email);
@@ -567,6 +568,13 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
567
568
  daily?: { percent: number; resetMinutes?: number };
568
569
  sevenDay?: { percent: number; resetHours?: number };
569
570
  monthly?: { percent: number; resetHours?: number };
571
+ resetCredits?: {
572
+ bankedCount: number;
573
+ redeemableCount: number;
574
+ expiryHours?: number;
575
+ expired?: boolean;
576
+ unavailableReason?: string;
577
+ };
570
578
  } | null = null;
571
579
  #cachedUsageContextKey: string | null = null;
572
580
  #usageFetchedAt = 0;
@@ -1756,7 +1764,7 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
1756
1764
  // The report boundary above validates the fields this extractor iterates;
1757
1765
  // optional metadata and credit fields are narrowed again before use.
1758
1766
  const usageReport = report as UsageReport;
1759
- if (!codexReportMatchesExactIdentity(usageReport, activeIdentity)) continue;
1767
+ if (!reportMatchesExactIdentity(usageReport, activeIdentity)) continue;
1760
1768
  matchingReport = usageReport;
1761
1769
  break;
1762
1770
  }
@@ -1812,9 +1820,17 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
1812
1820
  daily?: { percent: number; resetMinutes?: number };
1813
1821
  sevenDay?: { percent: number; resetHours?: number };
1814
1822
  monthly?: { percent: number; resetHours?: number };
1823
+ resetCredits?: {
1824
+ bankedCount: number;
1825
+ redeemableCount: number;
1826
+ expiryHours?: number;
1827
+ expired?: boolean;
1828
+ unavailableReason?: string;
1829
+ };
1815
1830
  } | null {
1816
1831
  if (!Array.isArray(reports)) return null;
1817
1832
  const now = Date.now();
1833
+ const resetReports: UsageReport[] = [];
1818
1834
  const activeModelId = normalizeUsageScopeValue(context.modelId);
1819
1835
  const activeAntigravityCounter =
1820
1836
  context.provider === "google-antigravity" ? getAntigravityCounterKeyForModel(context.modelId) : undefined;
@@ -1828,6 +1844,12 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
1828
1844
  // fetchUsageReports supplies normalized rows; the guards above protect
1829
1845
  // the unknown session boundary before the account matcher reads metadata.
1830
1846
  const usageReport = report as UsageReport;
1847
+ if (
1848
+ usageReport.resetCredits &&
1849
+ (!context.identity || reportMatchesExactIdentity(usageReport, context.identity))
1850
+ ) {
1851
+ resetReports.push(usageReport);
1852
+ }
1831
1853
  const limits =
1832
1854
  provider === "google-antigravity" && activeAntigravityCounter
1833
1855
  ? scopeAntigravityLimitsForModel(usageReport, context)
@@ -1915,7 +1937,28 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
1915
1937
  for (const group of scopeGroups.values()) {
1916
1938
  if (!selectedGroup || group.priority < selectedGroup.priority) selectedGroup = group;
1917
1939
  }
1918
- if (!selectedGroup) return null;
1940
+ const resetReport =
1941
+ resetReports.length === 1
1942
+ ? resetReports[0]
1943
+ : context.identity
1944
+ ? resetReports.find(report => reportMatchesExactIdentity(report, context.identity))
1945
+ : undefined;
1946
+ const resetSummary = summarizeUsageResetCredits(resetReport?.resetCredits, now);
1947
+ const resetExpiryMs = resetSummary?.soonestExpiry ? Date.parse(resetSummary.soonestExpiry) - now : undefined;
1948
+ const resetCredits =
1949
+ resetSummary && resetSummary.bankedCount > 0
1950
+ ? {
1951
+ bankedCount: resetSummary.bankedCount,
1952
+ redeemableCount: resetSummary.redeemableCount,
1953
+ expiryHours:
1954
+ resetExpiryMs !== undefined && resetExpiryMs > 0
1955
+ ? Math.max(1, Math.ceil(resetExpiryMs / 3_600_000))
1956
+ : undefined,
1957
+ expired: resetExpiryMs !== undefined && resetExpiryMs <= 0,
1958
+ unavailableReason: resetSummary.unavailableReason,
1959
+ }
1960
+ : undefined;
1961
+ if (!selectedGroup) return resetCredits ? { resetCredits } : null;
1919
1962
 
1920
1963
  let fiveHour: { percent: number; resetMinutes?: number } | undefined;
1921
1964
  let daily: { percent: number; resetMinutes?: number } | undefined;
@@ -1972,8 +2015,8 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
1972
2015
  }
1973
2016
  }
1974
2017
  }
1975
- if (!fiveHour && !daily && !sevenDay && !monthly) return null;
1976
- return { tier: selectedGroup.tier, fiveHour, daily, sevenDay, monthly };
2018
+ if (!fiveHour && !daily && !sevenDay && !monthly && !resetCredits) return null;
2019
+ return { tier: selectedGroup.tier, fiveHour, daily, sevenDay, monthly, resetCredits };
1977
2020
  }
1978
2021
 
1979
2022
  /**
@@ -859,7 +859,7 @@ const usageSegment: StatusLineSegment = {
859
859
  id: "usage",
860
860
  render(ctx) {
861
861
  const u = ctx.usage;
862
- if (!u || (!u.fiveHour && !u.daily && !u.sevenDay && !u.monthly)) {
862
+ if (!u || (!u.fiveHour && !u.daily && !u.sevenDay && !u.monthly && !u.resetCredits)) {
863
863
  return { content: "", visible: false };
864
864
  }
865
865
  const parts: string[] = [];
@@ -884,6 +884,23 @@ const usageSegment: StatusLineSegment = {
884
884
  // "1% used"; OpenCode's endpoint already emits floored integers).
885
885
  parts.push(formatQuotaWindow(ctx, "mo", u.monthly.percent, u.monthly.resetHours, "h", "floor"));
886
886
  }
887
+ if (u.resetCredits) {
888
+ const resets = u.resetCredits;
889
+ let resetText = `✦ ${resets.bankedCount}`;
890
+ if (resets.redeemableCount !== resets.bankedCount) {
891
+ resetText += ` (${resets.redeemableCount} usable)`;
892
+ }
893
+ if (resets.expiryHours !== undefined) {
894
+ resetText += ` exp ${formatUsageReset(resets.expiryHours, "h")}`;
895
+ } else if (resets.expired) {
896
+ resetText += " expired";
897
+ }
898
+ if (resets.redeemableCount === 0 && resets.unavailableReason) {
899
+ const reason = truncateToWidth(sanitizeStatusText(resets.unavailableReason), TRUNCATE_LENGTHS.SHORT);
900
+ if (reason) resetText += ` ${reason}`;
901
+ }
902
+ parts.push(theme.fg(resets.redeemableCount > 0 ? "success" : "warning", resetText));
903
+ }
887
904
  const content = withIcon(theme.icon.time, parts.join(theme.sep.dot));
888
905
  return { content, visible: true };
889
906
  },
@@ -188,6 +188,13 @@ export interface SegmentContext {
188
188
  daily?: { percent: number; resetMinutes?: number };
189
189
  sevenDay?: { percent: number; resetHours?: number };
190
190
  monthly?: { percent: number; resetHours?: number };
191
+ resetCredits?: {
192
+ bankedCount: number;
193
+ redeemableCount: number;
194
+ expiryHours?: number;
195
+ expired?: boolean;
196
+ unavailableReason?: string;
197
+ };
191
198
  } | null;
192
199
  }
193
200
 
@@ -423,6 +423,12 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
423
423
  #escapeSearchOffset = 0;
424
424
  #rawPasteCandidate = "";
425
425
  #rawPasteTimer?: NodeJS.Timeout;
426
+ // Unbracketed raw-paste classification is a fallback for terminals that do
427
+ // not wrap pastes in DECSET 2004 markers. When the terminal confirms mode
428
+ // 2004 support, a genuine paste always arrives bracketed, so the heuristic
429
+ // can only misfire on keystrokes an event-loop stall batched into one read
430
+ // (issue #12540) — Terminal disables it via `setRawPasteClassification`.
431
+ #rawPasteClassificationEnabled = true;
426
432
  #stringDiscardActive = false;
427
433
  #stringDiscardBytes = 0;
428
434
  #stringDiscardEscHeld = false;
@@ -492,6 +498,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
492
498
  }
493
499
 
494
500
  if (
501
+ this.#rawPasteClassificationEnabled &&
495
502
  this.#buffer.length === 0 &&
496
503
  str.indexOf(ESC) === -1 &&
497
504
  (str.indexOf("\r") !== -1 || str.indexOf("\n") !== -1)
@@ -641,6 +648,21 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
641
648
  this.emit("paste", content);
642
649
  }
643
650
 
651
+ /**
652
+ * Enable or disable unbracketed raw-paste classification. Terminal disables
653
+ * it once DECRQM confirms bracketed-paste (mode 2004) support: a genuine
654
+ * paste then always arrives wrapped, so the heuristic can only misfire on
655
+ * keystrokes an event-loop stall batched into one read (issue #12540).
656
+ * Disabling flushes any candidate already held by the classification window
657
+ * as ordinary key events so no buffered input is lost.
658
+ */
659
+ setRawPasteClassification(enabled: boolean): void {
660
+ this.#rawPasteClassificationEnabled = enabled;
661
+ if (!enabled && this.#rawPasteCandidate.length > 0) {
662
+ this.#flushRawPasteCandidate();
663
+ }
664
+ }
665
+
644
666
  /** Start one fixed window from the first break-bearing raw read. */
645
667
  #armRawPasteTimer(): void {
646
668
  if (this.#rawPasteTimer) return;
@@ -10,7 +10,7 @@ import {
10
10
  setKittyGraphics,
11
11
  } from "./kitty-graphics";
12
12
  import { isInsideHerdr, isInsideTerminalMultiplexer } from "./terminal-multiplexer";
13
- import { isInsideTmux, wrapTmuxPassthrough, wrapTmuxPassthroughIfNeeded } from "./tmux";
13
+ import { isInsideTmux, resolveTmuxClientTerminalName, wrapTmuxPassthrough, wrapTmuxPassthroughIfNeeded } from "./tmux";
14
14
  import type { HangulCompatibilityJamoWidth } from "./utils";
15
15
 
16
16
  export * from "./terminal-multiplexer";
@@ -37,6 +37,8 @@ export type TerminalId =
37
37
  | "alacritty"
38
38
  | "warp"
39
39
  | "orca"
40
+ | "otty"
41
+ | "rio"
40
42
  | "base"
41
43
  | "trueColor";
42
44
 
@@ -669,6 +671,18 @@ const KNOWN_TERMINALS = Object.freeze({
669
671
  // honor OSC 8 yet (the escape renders as visible text), so hyperlinks stay off,
670
672
  // but it does support OSC 9 notifications.
671
673
  warp: new TerminalInfo("warp", ImageProtocol.Kitty, true, false, NotifyProtocol.Osc9, false, false, false, 1),
674
+ // Otty (appmakes, macOS) identifies via TERM_PROGRAM=otty. Its documented
675
+ // Kitty implementation covers direct and virtual (U+10EEEE placeholder)
676
+ // placement, and it honors OSC 8 hyperlinks and OSC 99 notifications
677
+ // (docs.otty.sh terminal comparison). Sixel is not implemented, DECCARA and
678
+ // OSC 66 text sizing are unverified, so those stay on conservative defaults;
679
+ // synchronized output is left to the runtime DECRQM probe.
680
+ otty: new TerminalInfo("otty", ImageProtocol.Kitty, true, true, NotifyProtocol.Osc99),
681
+ // rio ships the Kitty graphics protocol — direct placement plus U=1 Unicode
682
+ // placeholders verified by the reporter (#12205). Everything unproven stays
683
+ // conservative: hyperlinks, DECCARA, screen-to-scrollback, and notifications
684
+ // keep the base defaults until verified in that terminal.
685
+ rio: new TerminalInfo("rio", ImageProtocol.Kitty, true, false),
672
686
  });
673
687
 
674
688
  /** Resolve terminal identity from environment markers used by common emulators. */
@@ -677,6 +691,21 @@ export function detectTerminalId(env: NodeJS.ProcessEnv = Bun.env): TerminalId {
677
691
  return a.toLowerCase() === b.toLowerCase(); // For compiler to pattern match
678
692
  }
679
693
 
694
+ function fromProgram(program: string | undefined): TerminalId | null {
695
+ if (!program) return null;
696
+ if (caseEq(program, "kitty")) return "kitty";
697
+ if (caseEq(program, "ghostty")) return "ghostty";
698
+ if (caseEq(program, "wezterm")) return "wezterm";
699
+ if (caseEq(program, "iterm.app") || caseEq(program, "iterm2")) return "iterm2";
700
+ if (caseEq(program, "vscode")) return "vscode";
701
+ if (caseEq(program, "alacritty")) return "alacritty";
702
+ if (caseEq(program, "warpterminal")) return "warp";
703
+ if (caseEq(program, "orca")) return "orca";
704
+ if (caseEq(program, "otty")) return "otty";
705
+ if (caseEq(program, "rio")) return "rio";
706
+ return null;
707
+ }
708
+
680
709
  const {
681
710
  KITTY_WINDOW_ID,
682
711
  GHOSTTY_RESOURCES_DIR,
@@ -696,16 +725,13 @@ export function detectTerminalId(env: NodeJS.ProcessEnv = Bun.env): TerminalId {
696
725
  if (VSCODE_PID) return "vscode";
697
726
  if (ALACRITTY_WINDOW_ID) return "alacritty";
698
727
 
699
- if (TERM_PROGRAM) {
700
- if (caseEq(TERM_PROGRAM, "kitty")) return "kitty";
701
- if (caseEq(TERM_PROGRAM, "ghostty")) return "ghostty";
702
- if (caseEq(TERM_PROGRAM, "wezterm")) return "wezterm";
703
- if (caseEq(TERM_PROGRAM, "iterm.app")) return "iterm2";
704
- if (caseEq(TERM_PROGRAM, "vscode")) return "vscode";
705
- if (caseEq(TERM_PROGRAM, "alacritty")) return "alacritty";
706
- if (caseEq(TERM_PROGRAM, "warpterminal")) return "warp";
707
- if (caseEq(TERM_PROGRAM, "orca")) return "orca";
708
- }
728
+ const programId = fromProgram(TERM_PROGRAM);
729
+ if (programId) return programId;
730
+
731
+ // tmux >= 3.2 replaces the pane's identity with `TERM_PROGRAM=tmux`.
732
+ // Its server still holds the attached client's terminal-type reply.
733
+ const clientProgramId = fromProgram(resolveTmuxClientTerminalName(env) ?? undefined);
734
+ if (clientProgramId) return clientProgramId;
709
735
 
710
736
  if (TERM?.toLowerCase().includes("ghostty")) return "ghostty";
711
737
 
@@ -774,7 +800,7 @@ export const TERMINAL: RuntimeTerminal = (() => {
774
800
  })();
775
801
 
776
802
  // Seed Kitty Unicode placeholder support from the resolved terminal id. Only
777
- // kitty/ghostty are known to honor `U=1` placement; other Kitty-protocol paths
803
+ // kitty/ghostty/otty are known to honor `U=1` placement; other Kitty-protocol paths
778
804
  // (wezterm, tmux/screen fallback) treat the placeholder cells as literal PUA
779
805
  // glyphs, which is the "ASCII artifact + laggy scrolling" reported in #1877.
780
806
  setKittyGraphics({ unicodePlaceholders: detectKittyUnicodePlaceholdersSupport(TERMINAL.id, Bun.env) });
package/src/terminal.ts CHANGED
@@ -1066,6 +1066,11 @@ export class ProcessTerminal implements Terminal {
1066
1066
  this.#queryPrivateMode(2026);
1067
1067
  this.#queryPrivateMode(2048);
1068
1068
  this.#queryPrivateMode(2031);
1069
+ // 2004 (bracketed paste) is queried only to confirm the terminal brackets
1070
+ // pastes; once confirmed, the unbracketed raw-paste heuristic in
1071
+ // StdinBuffer is disabled so keystrokes an event-loop stall batches into
1072
+ // one read are never misclassified as a paste (#12540).
1073
+ this.#queryPrivateMode(2004);
1069
1074
  for (const mode of XTERM_SCROLL_TO_BOTTOM_MODES) {
1070
1075
  this.#queryPrivateMode(mode);
1071
1076
  }
@@ -1749,6 +1754,11 @@ export class ProcessTerminal implements Terminal {
1749
1754
  }
1750
1755
  if (mode === 2048 && supported) this.#enableInBandResize();
1751
1756
  if (mode === 2031) this.#syncWindowsTerminalAppearancePolling(supported);
1757
+ // Confirmed bracketed-paste support makes the unbracketed raw-paste
1758
+ // heuristic pure downside — turn it off so stall-batched keystrokes are
1759
+ // not misread as a paste (#12540). `supported` is only true here after an
1760
+ // explicit DECRPM reply (the DA1-sentinel fallback resolves unsupported).
1761
+ if (mode === 2004 && supported) this.#stdinBuffer?.setRawPasteClassification(false);
1752
1762
  }
1753
1763
 
1754
1764
  #syncWindowsTerminalAppearancePolling(mode2031Supported: boolean): void {
package/src/tmux.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { $which } from "@oh-my-pi/pi-utils";
2
+ import { isBunTestRuntime } from "@oh-my-pi/pi-utils/env";
3
+
1
4
  /** Whether the process is running inside a tmux session. */
2
5
  export function isInsideTmux(env: NodeJS.ProcessEnv = Bun.env): boolean {
3
6
  return Boolean(env.TMUX);
@@ -12,3 +15,38 @@ export function wrapTmuxPassthrough(payload: string): string {
12
15
  export function wrapTmuxPassthroughIfNeeded(payload: string, env: NodeJS.ProcessEnv = Bun.env): string {
13
16
  return isInsideTmux(env) ? wrapTmuxPassthrough(payload) : payload;
14
17
  }
18
+
19
+ const CLIENT_TERMTYPE_NAME = /^([A-Za-z][A-Za-z0-9._+-]*)(?=\s|\(|$)/u;
20
+ const CLIENT_TERMTYPE_TIMEOUT_MS = 500;
21
+ let cachedClientTerminalName: string | null | undefined;
22
+
23
+ function queryTmuxClientTerminalName(env: NodeJS.ProcessEnv): string | null {
24
+ const tmux = $which("tmux", { PATH: env.PATH });
25
+ if (!tmux) return null;
26
+ try {
27
+ const result = Bun.spawnSync([tmux, "display-message", "-p", "#{client_termtype}"], {
28
+ env,
29
+ stdout: "pipe",
30
+ stderr: "ignore",
31
+ timeout: CLIENT_TERMTYPE_TIMEOUT_MS,
32
+ killSignal: "SIGKILL",
33
+ });
34
+ if (result.exitCode !== 0) return null;
35
+ return CLIENT_TERMTYPE_NAME.exec(result.stdout.toString().trim())?.[1] ?? null;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Resolve the terminal emulator name recorded for this tmux client.
43
+ *
44
+ * tmux overwrites pane identity variables with its own values, but retains the
45
+ * attached client's terminal-type reply in `#{client_termtype}`. The local IPC
46
+ * query runs once per process and degrades to `null` when unavailable.
47
+ */
48
+ export function resolveTmuxClientTerminalName(env: NodeJS.ProcessEnv = Bun.env): string | null {
49
+ if (!isInsideTmux(env) || isBunTestRuntime()) return null;
50
+ if (cachedClientTerminalName === undefined) cachedClientTerminalName = queryTmuxClientTerminalName(env);
51
+ return cachedClientTerminalName;
52
+ }