@oh-my-pi/pi-tui 18.2.8 → 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.
- package/CHANGELOG.md +15 -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/types.d.ts +7 -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/find.d.ts +1 -1
- package/dist/types/tools/hub.d.ts +2 -0
- 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 +48 -5
- package/src/status-line/segments.ts +18 -1
- package/src/status-line/types.ts +7 -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/find.ts +18 -5
- package/src/tools/hub.ts +4 -2
- package/src/utils.ts +64 -0
|
@@ -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);
|
|
@@ -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 (!
|
|
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
|
-
|
|
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
|
},
|
package/src/status-line/types.ts
CHANGED
|
@@ -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
|
|
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 {
|
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
|
+
}
|
package/src/tools/find.ts
CHANGED
|
@@ -7,7 +7,15 @@ import * as path from "node:path";
|
|
|
7
7
|
import { formatDuration, formatNumber } from "@oh-my-pi/pi-utils";
|
|
8
8
|
import { renderProgressBar } from "../components/progress-bar";
|
|
9
9
|
import { Text } from "../components/text";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
Ellipsis,
|
|
12
|
+
fileHyperlink,
|
|
13
|
+
getTreeBranch,
|
|
14
|
+
renderStatusLine,
|
|
15
|
+
renderTreeList,
|
|
16
|
+
truncateToWidth,
|
|
17
|
+
uriHyperlink,
|
|
18
|
+
} from "../render";
|
|
11
19
|
import {
|
|
12
20
|
createCachedComponent,
|
|
13
21
|
formatCount,
|
|
@@ -31,7 +39,7 @@ export interface FindRange {
|
|
|
31
39
|
|
|
32
40
|
/** A file whose verified passages cleared the threshold; `ranges` are merged positive spans, strongest first. */
|
|
33
41
|
export interface FindHit {
|
|
34
|
-
/** Display path relative to {@link FindToolDetails.cwd}. */
|
|
42
|
+
/** Display path relative to {@link FindToolDetails.cwd}, or an `omp://` doc URL for docs scopes. */
|
|
35
43
|
rel: string;
|
|
36
44
|
/** Filename judgment, when the name batch answered. */
|
|
37
45
|
nameScore?: number;
|
|
@@ -116,9 +124,14 @@ function gauge(p: number, theme: Theme): string {
|
|
|
116
124
|
}
|
|
117
125
|
|
|
118
126
|
function renderHit(hit: FindHit, rangeLimit: number, cwd: string | undefined, theme: Theme): string[] {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
127
|
+
// `omp://` hits are virtual docs, not files under `cwd`: link the URL
|
|
128
|
+
// itself instead of joining it onto a filesystem base.
|
|
129
|
+
const isOmpHit = /^omp:\/\//i.test(hit.rel);
|
|
130
|
+
const link = (text: string, line?: number): string => {
|
|
131
|
+
if (isOmpHit) return uriHyperlink(line === undefined ? hit.rel : `${hit.rel}:${line}`, text);
|
|
132
|
+
const absPath = cwd === undefined ? undefined : path.join(cwd, hit.rel);
|
|
133
|
+
return absPath === undefined ? text : fileHyperlink(absPath, text, { line });
|
|
134
|
+
};
|
|
122
135
|
const coverage = hit.truncated ? `${hit.linesSeen} lines judged, partial` : `${hit.linesSeen} lines judged`;
|
|
123
136
|
const lines = [
|
|
124
137
|
`${gauge(hit.contentScore, theme)} ${theme.fg(scoreColor(hit.contentScore), hit.contentScore.toFixed(2))} ${link(theme.fg("accent", hit.rel))} ${theme.fg("dim", coverage)}`,
|
package/src/tools/hub.ts
CHANGED
|
@@ -100,6 +100,8 @@ export interface JobSnapshot {
|
|
|
100
100
|
status: "running" | "completed" | "failed" | "cancelled";
|
|
101
101
|
label: string;
|
|
102
102
|
durationMs: number;
|
|
103
|
+
/** Process exit status when the job reports one. */
|
|
104
|
+
exitCode?: number;
|
|
103
105
|
/** Effective task model selector, including an explicit reasoning suffix when configured. */
|
|
104
106
|
resolvedModel?: string;
|
|
105
107
|
/** Provider/id including routing, with no added thinking suffix. */
|
|
@@ -570,11 +572,11 @@ export function jobsRenderResult(
|
|
|
570
572
|
renderItem: (job, context) => {
|
|
571
573
|
const rowWidth = Math.max(0, width - (context.prefixWidth ?? 0));
|
|
572
574
|
const lines: string[] = [];
|
|
573
|
-
const icon = formatStatusIcon(
|
|
575
|
+
const icon = `${formatStatusIcon(
|
|
574
576
|
statusToIcon(job.status),
|
|
575
577
|
uiTheme,
|
|
576
578
|
job.status === "running" ? options.spinnerFrame : undefined,
|
|
577
|
-
)
|
|
579
|
+
)}${job.exitCode === undefined ? "" : `${uiTheme.sep.dot}${uiTheme.fg(job.exitCode === 0 ? "muted" : "error", `exit ${job.exitCode}`)}`}`;
|
|
578
580
|
const typeBadge = formatBadge(job.type, statusToColor(job.status), uiTheme);
|
|
579
581
|
const durationSuffix = `${uiTheme.sep.dot}${uiTheme.fg("dim", formatDuration(job.durationMs))}`;
|
|
580
582
|
const displayId = truncateToWidth(
|