@oh-my-pi/pi-tui 18.2.8 → 18.2.10
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.
- package/CHANGELOG.md +22 -0
- package/dist/types/kitty-graphics.d.ts +1 -1
- package/dist/types/overlays/reset-usage-selector.d.ts +14 -2
- package/dist/types/overlays/usage-dashboard.d.ts +6 -0
- package/dist/types/overlays/usage-display.d.ts +16 -1
- package/dist/types/prompt/image-source.d.ts +26 -0
- package/dist/types/prompt/video.d.ts +7 -11
- package/dist/types/status-line/component.d.ts +2 -0
- package/dist/types/status-line/types.d.ts +9 -0
- package/dist/types/stdin-buffer.d.ts +9 -0
- package/dist/types/terminal-capabilities.d.ts +1 -1
- package/dist/types/tmux.d.ts +8 -0
- package/dist/types/tools/eval.d.ts +15 -9
- package/dist/types/tools/find.d.ts +1 -1
- package/dist/types/tools/hub.d.ts +2 -0
- package/dist/types/tui.d.ts +8 -3
- package/dist/types/utils.d.ts +2 -0
- package/package.json +9 -9
- package/src/components/editor.ts +21 -1
- package/src/kitty-graphics.ts +2 -2
- package/src/overlays/composer-shape-preview.ts +3 -1
- package/src/overlays/reset-usage-selector.ts +77 -25
- package/src/overlays/usage-dashboard.ts +62 -2
- package/src/overlays/usage-display.ts +74 -1
- package/src/prompt/custom-editor.ts +3 -2
- package/src/prompt/image-source.ts +51 -0
- package/src/prompt/video.ts +10 -20
- package/src/status-line/component.ts +57 -5
- package/src/status-line/segments.ts +23 -4
- package/src/status-line/types.ts +9 -0
- package/src/stdin-buffer.ts +22 -0
- package/src/terminal-capabilities.ts +38 -12
- package/src/terminal.ts +10 -0
- package/src/tmux.ts +38 -0
- package/src/tools/eval-format/index.ts +1 -0
- package/src/tools/eval.ts +22 -14
- package/src/tools/find.ts +18 -5
- package/src/tools/hub.ts +4 -2
- package/src/tui.ts +21 -10
- package/src/utils.ts +64 -0
|
@@ -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 {
|
|
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:
|
|
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 {
|
|
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) =>
|
|
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
|
+
}
|
package/src/prompt/video.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
/**
|
|
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
|
-
|
|
45
|
-
|
|
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
|
|
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);
|
|
@@ -528,6 +529,7 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
|
|
|
528
529
|
#vibeWorkerTokenRate: (() => number | null) | null = null;
|
|
529
530
|
#collabStatus: CollabStatus | null = null;
|
|
530
531
|
#streamStatus: { viewers: number } | null = null;
|
|
532
|
+
#recording = false;
|
|
531
533
|
#focusedAgentId: string | undefined;
|
|
532
534
|
#activeRepoCache: ActiveRepoCache | undefined;
|
|
533
535
|
|
|
@@ -567,6 +569,13 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
|
|
|
567
569
|
daily?: { percent: number; resetMinutes?: number };
|
|
568
570
|
sevenDay?: { percent: number; resetHours?: number };
|
|
569
571
|
monthly?: { percent: number; resetHours?: number };
|
|
572
|
+
resetCredits?: {
|
|
573
|
+
bankedCount: number;
|
|
574
|
+
redeemableCount: number;
|
|
575
|
+
expiryHours?: number;
|
|
576
|
+
expired?: boolean;
|
|
577
|
+
unavailableReason?: string;
|
|
578
|
+
};
|
|
570
579
|
} | null = null;
|
|
571
580
|
#cachedUsageContextKey: string | null = null;
|
|
572
581
|
#usageFetchedAt = 0;
|
|
@@ -922,6 +931,13 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
|
|
|
922
931
|
this.#invalidateStatusLineRenderCache();
|
|
923
932
|
}
|
|
924
933
|
|
|
934
|
+
/** Toggle the `● REC` badge shown while `/record` captures the screen. */
|
|
935
|
+
setRecording(recording: boolean): void {
|
|
936
|
+
if (this.#recording === recording) return;
|
|
937
|
+
this.#recording = recording;
|
|
938
|
+
this.#invalidateStatusLineRenderCache();
|
|
939
|
+
}
|
|
940
|
+
|
|
925
941
|
/** Set the callback that presents detected Codex reset celebrations, or clear it with `undefined`. */
|
|
926
942
|
setCodexResetFireworksHandler(handler: ((event: CodexResetFireworksEvent) => void) | undefined): void {
|
|
927
943
|
this.#onCodexResetFireworks = handler;
|
|
@@ -1756,7 +1772,7 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
|
|
|
1756
1772
|
// The report boundary above validates the fields this extractor iterates;
|
|
1757
1773
|
// optional metadata and credit fields are narrowed again before use.
|
|
1758
1774
|
const usageReport = report as UsageReport;
|
|
1759
|
-
if (!
|
|
1775
|
+
if (!reportMatchesExactIdentity(usageReport, activeIdentity)) continue;
|
|
1760
1776
|
matchingReport = usageReport;
|
|
1761
1777
|
break;
|
|
1762
1778
|
}
|
|
@@ -1812,9 +1828,17 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
|
|
|
1812
1828
|
daily?: { percent: number; resetMinutes?: number };
|
|
1813
1829
|
sevenDay?: { percent: number; resetHours?: number };
|
|
1814
1830
|
monthly?: { percent: number; resetHours?: number };
|
|
1831
|
+
resetCredits?: {
|
|
1832
|
+
bankedCount: number;
|
|
1833
|
+
redeemableCount: number;
|
|
1834
|
+
expiryHours?: number;
|
|
1835
|
+
expired?: boolean;
|
|
1836
|
+
unavailableReason?: string;
|
|
1837
|
+
};
|
|
1815
1838
|
} | null {
|
|
1816
1839
|
if (!Array.isArray(reports)) return null;
|
|
1817
1840
|
const now = Date.now();
|
|
1841
|
+
const resetReports: UsageReport[] = [];
|
|
1818
1842
|
const activeModelId = normalizeUsageScopeValue(context.modelId);
|
|
1819
1843
|
const activeAntigravityCounter =
|
|
1820
1844
|
context.provider === "google-antigravity" ? getAntigravityCounterKeyForModel(context.modelId) : undefined;
|
|
@@ -1828,6 +1852,12 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
|
|
|
1828
1852
|
// fetchUsageReports supplies normalized rows; the guards above protect
|
|
1829
1853
|
// the unknown session boundary before the account matcher reads metadata.
|
|
1830
1854
|
const usageReport = report as UsageReport;
|
|
1855
|
+
if (
|
|
1856
|
+
usageReport.resetCredits &&
|
|
1857
|
+
(!context.identity || reportMatchesExactIdentity(usageReport, context.identity))
|
|
1858
|
+
) {
|
|
1859
|
+
resetReports.push(usageReport);
|
|
1860
|
+
}
|
|
1831
1861
|
const limits =
|
|
1832
1862
|
provider === "google-antigravity" && activeAntigravityCounter
|
|
1833
1863
|
? scopeAntigravityLimitsForModel(usageReport, context)
|
|
@@ -1915,7 +1945,28 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
|
|
|
1915
1945
|
for (const group of scopeGroups.values()) {
|
|
1916
1946
|
if (!selectedGroup || group.priority < selectedGroup.priority) selectedGroup = group;
|
|
1917
1947
|
}
|
|
1918
|
-
|
|
1948
|
+
const resetReport =
|
|
1949
|
+
resetReports.length === 1
|
|
1950
|
+
? resetReports[0]
|
|
1951
|
+
: context.identity
|
|
1952
|
+
? resetReports.find(report => reportMatchesExactIdentity(report, context.identity))
|
|
1953
|
+
: undefined;
|
|
1954
|
+
const resetSummary = summarizeUsageResetCredits(resetReport?.resetCredits, now);
|
|
1955
|
+
const resetExpiryMs = resetSummary?.soonestExpiry ? Date.parse(resetSummary.soonestExpiry) - now : undefined;
|
|
1956
|
+
const resetCredits =
|
|
1957
|
+
resetSummary && resetSummary.bankedCount > 0
|
|
1958
|
+
? {
|
|
1959
|
+
bankedCount: resetSummary.bankedCount,
|
|
1960
|
+
redeemableCount: resetSummary.redeemableCount,
|
|
1961
|
+
expiryHours:
|
|
1962
|
+
resetExpiryMs !== undefined && resetExpiryMs > 0
|
|
1963
|
+
? Math.max(1, Math.ceil(resetExpiryMs / 3_600_000))
|
|
1964
|
+
: undefined,
|
|
1965
|
+
expired: resetExpiryMs !== undefined && resetExpiryMs <= 0,
|
|
1966
|
+
unavailableReason: resetSummary.unavailableReason,
|
|
1967
|
+
}
|
|
1968
|
+
: undefined;
|
|
1969
|
+
if (!selectedGroup) return resetCredits ? { resetCredits } : null;
|
|
1919
1970
|
|
|
1920
1971
|
let fiveHour: { percent: number; resetMinutes?: number } | undefined;
|
|
1921
1972
|
let daily: { percent: number; resetMinutes?: number } | undefined;
|
|
@@ -1972,8 +2023,8 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
|
|
|
1972
2023
|
}
|
|
1973
2024
|
}
|
|
1974
2025
|
}
|
|
1975
|
-
if (!fiveHour && !daily && !sevenDay && !monthly) return null;
|
|
1976
|
-
return { tier: selectedGroup.tier, fiveHour, daily, sevenDay, monthly };
|
|
2026
|
+
if (!fiveHour && !daily && !sevenDay && !monthly && !resetCredits) return null;
|
|
2027
|
+
return { tier: selectedGroup.tier, fiveHour, daily, sevenDay, monthly, resetCredits };
|
|
1977
2028
|
}
|
|
1978
2029
|
|
|
1979
2030
|
/**
|
|
@@ -2131,6 +2182,7 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
|
|
|
2131
2182
|
vim: this.#vimStatus,
|
|
2132
2183
|
collab: this.#collabStatus,
|
|
2133
2184
|
stream: this.#streamStatus,
|
|
2185
|
+
recording: this.#recording,
|
|
2134
2186
|
usageStats,
|
|
2135
2187
|
contextPercent,
|
|
2136
2188
|
contextTokens,
|
|
@@ -758,9 +758,11 @@ const collabSegment: StatusLineSegment = {
|
|
|
758
758
|
const streamSegment: StatusLineSegment = {
|
|
759
759
|
id: "stream",
|
|
760
760
|
render(ctx) {
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
761
|
+
const badges: string[] = [];
|
|
762
|
+
if (ctx.stream) badges.push(`● LIVE ${statusValue(ctx, `${ctx.stream.viewers}`)}`);
|
|
763
|
+
if (ctx.recording) badges.push("● REC");
|
|
764
|
+
if (badges.length === 0) return { content: "", visible: false };
|
|
765
|
+
return { content: theme.fg("thinkingHigh", badges.join(" ")), visible: true };
|
|
764
766
|
},
|
|
765
767
|
};
|
|
766
768
|
|
|
@@ -859,7 +861,7 @@ const usageSegment: StatusLineSegment = {
|
|
|
859
861
|
id: "usage",
|
|
860
862
|
render(ctx) {
|
|
861
863
|
const u = ctx.usage;
|
|
862
|
-
if (!u || (!u.fiveHour && !u.daily && !u.sevenDay && !u.monthly)) {
|
|
864
|
+
if (!u || (!u.fiveHour && !u.daily && !u.sevenDay && !u.monthly && !u.resetCredits)) {
|
|
863
865
|
return { content: "", visible: false };
|
|
864
866
|
}
|
|
865
867
|
const parts: string[] = [];
|
|
@@ -884,6 +886,23 @@ const usageSegment: StatusLineSegment = {
|
|
|
884
886
|
// "1% used"; OpenCode's endpoint already emits floored integers).
|
|
885
887
|
parts.push(formatQuotaWindow(ctx, "mo", u.monthly.percent, u.monthly.resetHours, "h", "floor"));
|
|
886
888
|
}
|
|
889
|
+
if (u.resetCredits) {
|
|
890
|
+
const resets = u.resetCredits;
|
|
891
|
+
let resetText = `✦ ${resets.bankedCount}`;
|
|
892
|
+
if (resets.redeemableCount !== resets.bankedCount) {
|
|
893
|
+
resetText += ` (${resets.redeemableCount} usable)`;
|
|
894
|
+
}
|
|
895
|
+
if (resets.expiryHours !== undefined) {
|
|
896
|
+
resetText += ` exp ${formatUsageReset(resets.expiryHours, "h")}`;
|
|
897
|
+
} else if (resets.expired) {
|
|
898
|
+
resetText += " expired";
|
|
899
|
+
}
|
|
900
|
+
if (resets.redeemableCount === 0 && resets.unavailableReason) {
|
|
901
|
+
const reason = truncateToWidth(sanitizeStatusText(resets.unavailableReason), TRUNCATE_LENGTHS.SHORT);
|
|
902
|
+
if (reason) resetText += ` ${reason}`;
|
|
903
|
+
}
|
|
904
|
+
parts.push(theme.fg(resets.redeemableCount > 0 ? "success" : "warning", resetText));
|
|
905
|
+
}
|
|
887
906
|
const content = withIcon(theme.icon.time, parts.join(theme.sep.dot));
|
|
888
907
|
return { content, visible: true };
|
|
889
908
|
},
|
package/src/status-line/types.ts
CHANGED
|
@@ -125,6 +125,8 @@ export interface SegmentContext {
|
|
|
125
125
|
} | null;
|
|
126
126
|
collab: CollabStatus | null;
|
|
127
127
|
stream: { viewers: number } | null;
|
|
128
|
+
/** A `/record` capture of this screen is running. */
|
|
129
|
+
recording: boolean;
|
|
128
130
|
// Cached values for performance (computed once per render)
|
|
129
131
|
usageStats: {
|
|
130
132
|
input: number;
|
|
@@ -188,6 +190,13 @@ export interface SegmentContext {
|
|
|
188
190
|
daily?: { percent: number; resetMinutes?: number };
|
|
189
191
|
sevenDay?: { percent: number; resetHours?: number };
|
|
190
192
|
monthly?: { percent: number; resetHours?: number };
|
|
193
|
+
resetCredits?: {
|
|
194
|
+
bankedCount: number;
|
|
195
|
+
redeemableCount: number;
|
|
196
|
+
expiryHours?: number;
|
|
197
|
+
expired?: boolean;
|
|
198
|
+
unavailableReason?: string;
|
|
199
|
+
};
|
|
191
200
|
} | null;
|
|
192
201
|
}
|
|
193
202
|
|
package/src/stdin-buffer.ts
CHANGED
|
@@ -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
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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 {
|