@oh-my-pi/pi-coding-agent 16.1.3 → 16.1.5
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 +20 -0
- package/dist/cli.js +3105 -3105
- package/dist/types/modes/components/cache-invalidation-marker.d.ts +23 -10
- package/dist/types/modes/components/status-line/component.d.ts +2 -3
- package/dist/types/sdk.d.ts +12 -0
- package/dist/types/session/agent-session.d.ts +3 -3
- package/dist/types/session/tool-choice-queue.d.ts +2 -0
- package/dist/types/tools/index.d.ts +2 -0
- package/dist/types/tui/hyperlink.d.ts +3 -2
- package/package.json +12 -12
- package/src/cli/bench-cli.ts +33 -2
- package/src/cli/dry-balance-cli.ts +4 -2
- package/src/extensibility/plugins/manager.ts +82 -22
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/modes/components/cache-invalidation-marker.ts +31 -15
- package/src/modes/components/custom-editor.test.ts +4 -3
- package/src/modes/components/status-line/component.ts +64 -18
- package/src/sdk.ts +33 -0
- package/src/session/agent-session.ts +10 -4
- package/src/session/tool-choice-queue.ts +6 -0
- package/src/tools/index.ts +2 -0
- package/src/tools/resolve.ts +1 -0
- package/src/tui/hyperlink.ts +6 -3
|
@@ -4,9 +4,9 @@ import { formatNumber } from "@oh-my-pi/pi-utils";
|
|
|
4
4
|
import { theme } from "../../modes/theme/theme";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* Minimum
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* Minimum prefix the previous turn must have READ back from cache before a
|
|
8
|
+
* collapse on the current turn counts as an invalidation. Filters out tiny
|
|
9
|
+
* contexts and providers below the cacheable-prefix floor, where a zero
|
|
10
10
|
* `cacheRead` is expected rather than a reset.
|
|
11
11
|
*/
|
|
12
12
|
const MIN_CACHE_FOOTPRINT = 2048;
|
|
@@ -18,25 +18,41 @@ export interface CacheInvalidation {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* Decide whether `current` turn lost
|
|
21
|
+
* Decide whether `current` turn lost a *working* prompt cache that `prev` was
|
|
22
|
+
* reusing.
|
|
22
23
|
*
|
|
23
24
|
* The provider reports a warm prefix as `cacheRead`; a model/thinking/tool/
|
|
24
25
|
* system-prompt change (or a history rewrite) breaks the prefix, so the next
|
|
25
|
-
* request reads nothing from cache and re-pays for the whole prompt. We
|
|
26
|
-
*
|
|
26
|
+
* request reads nothing from cache and re-pays for the whole prompt. We flag
|
|
27
|
+
* only the transition where a demonstrably warm cache goes cold: the previous
|
|
28
|
+
* turn must have actually READ a meaningful prefix back, and this turn's
|
|
27
29
|
* `cacheRead` collapsed to zero while it still reprocessed a non-trivial prompt.
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
30
|
+
*
|
|
31
|
+
* Requiring a prior warm read is deliberate. A turn that merely WROTE the prefix
|
|
32
|
+
* (`cacheRead` 0) has not proven the cache is live — that is the session's first
|
|
33
|
+
* request, or a re-write after expiry — so a following cold turn there is
|
|
34
|
+
* expected, not an invalidation the user caused (e.g. a long-running first tool
|
|
35
|
+
* call outliving the provider's 5-minute cache TTL surfaced a spurious "cache
|
|
36
|
+
* miss" right under the opening message). It also collapses a run of consecutive
|
|
37
|
+
* cold turns to the single marker at the moment the cache actually broke, instead
|
|
38
|
+
* of repeating the banner on every turn while it re-warms.
|
|
39
|
+
*
|
|
40
|
+
* Returns `undefined` (no marker) for the first turn, turns whose predecessor
|
|
41
|
+
* never read a warm prefix, tiny contexts, turns that reused any cache, and —
|
|
42
|
+
* crucially — turns on providers with *implicit* best-effort caching. Only an
|
|
43
|
+
* explicit, prefix-controlled cache (Anthropic / Bedrock `cache_control`)
|
|
44
|
+
* re-creates the prefix on a cold turn (`cacheWrite > 0`); implicit caches
|
|
45
|
+
* (Google / OpenAI / Fireworks) report `cacheWrite: 0` and drop `cacheRead` to
|
|
46
|
+
* zero intermittently as routine propagation noise that self-heals the next
|
|
47
|
+
* turn, so flagging it would be a false positive.
|
|
35
48
|
*/
|
|
36
49
|
export function detectCacheInvalidation(prev: Usage | undefined, current: Usage): CacheInvalidation | undefined {
|
|
37
50
|
if (!prev) return undefined;
|
|
38
|
-
|
|
39
|
-
|
|
51
|
+
// Only flag a warm→cold transition: the previous turn must have actually read
|
|
52
|
+
// a meaningful prefix from cache. A write-only predecessor (first request, or
|
|
53
|
+
// a re-write after expiry) has not proven the cache is live, so a cold turn
|
|
54
|
+
// behind it is expected — not an invalidation worth surfacing.
|
|
55
|
+
if (prev.cacheRead < MIN_CACHE_FOOTPRINT) return undefined;
|
|
40
56
|
// Any cache reuse this turn means the prefix survived (at least partly).
|
|
41
57
|
if (current.cacheRead > 0) return undefined;
|
|
42
58
|
// Only an explicit, prefix-controlled cache re-creates the prefix on a cold
|
|
@@ -39,11 +39,12 @@ function feedGaps(editor: CustomEditor, gaps: number[]): void {
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
async function decorateInFreshProcess(text: string): Promise<string> {
|
|
42
|
+
async function decorateInFreshProcess(text: string, imageLinks?: readonly string[]): Promise<string> {
|
|
43
43
|
const customEditorUrl = new URL("./custom-editor.ts", import.meta.url).href;
|
|
44
44
|
const script = `
|
|
45
45
|
import { CustomEditor } from ${JSON.stringify(customEditorUrl)};
|
|
46
46
|
const editor = new CustomEditor({});
|
|
47
|
+
editor.imageLinks = ${JSON.stringify(imageLinks)};
|
|
47
48
|
process.stdout.write(editor.decorateText(${JSON.stringify(text)}));
|
|
48
49
|
`;
|
|
49
50
|
const child = await $`bun -e ${script}`.quiet().nothrow();
|
|
@@ -59,8 +60,8 @@ describe("CustomEditor placeholder decoration", () => {
|
|
|
59
60
|
expect(output).toBe("[Paste #1, +30 lines]");
|
|
60
61
|
});
|
|
61
62
|
|
|
62
|
-
it("renders image placeholders before theme initialization", async () => {
|
|
63
|
-
const output = await decorateInFreshProcess("[Image #1]");
|
|
63
|
+
it("renders linked image placeholders before theme and settings initialization", async () => {
|
|
64
|
+
const output = await decorateInFreshProcess("[Image #1]", ["/tmp/example.png"]);
|
|
64
65
|
expect(output).toBe("[Image #1]");
|
|
65
66
|
});
|
|
66
67
|
});
|
|
@@ -154,6 +154,8 @@ interface ContextUsageMemo {
|
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
const EMPTY_MESSAGES: readonly AgentMessage[] = [];
|
|
157
|
+
const STATUS_USAGE_START_DELAY_MS = 0;
|
|
158
|
+
const STATUS_USAGE_REFRESH_TIMEOUT_MS = 2_000;
|
|
157
159
|
|
|
158
160
|
function hasContextSegment(segments: readonly StatusLineSegmentId[]): boolean {
|
|
159
161
|
return segments.includes("context_pct") || segments.includes("context_total");
|
|
@@ -212,6 +214,7 @@ export class StatusLineComponent implements Component {
|
|
|
212
214
|
} | null = null;
|
|
213
215
|
#usageFetchedAt = 0;
|
|
214
216
|
#usageInFlight = false;
|
|
217
|
+
#usageStartTimer: Timer | null = null;
|
|
215
218
|
// Context-usage memo. The status line redraws on every agent event, so the
|
|
216
219
|
// hot path must not recompute context tokens unless an input changed.
|
|
217
220
|
// `getContextUsage()` anchors on the last assistant's real prompt-token
|
|
@@ -344,16 +347,24 @@ export class StatusLineComponent implements Component {
|
|
|
344
347
|
dispose(): void {
|
|
345
348
|
this.#disposed = true;
|
|
346
349
|
this.#onBranchChange = null;
|
|
350
|
+
this.#clearUsageStartTimer();
|
|
347
351
|
if (this.#gitWatcher) {
|
|
348
352
|
this.#gitWatcher.close();
|
|
349
353
|
this.#gitWatcher = null;
|
|
350
354
|
}
|
|
351
355
|
}
|
|
352
356
|
|
|
357
|
+
#clearUsageStartTimer(): void {
|
|
358
|
+
if (!this.#usageStartTimer) return;
|
|
359
|
+
clearTimeout(this.#usageStartTimer);
|
|
360
|
+
this.#usageStartTimer = null;
|
|
361
|
+
}
|
|
362
|
+
|
|
353
363
|
invalidate(): void {
|
|
354
364
|
this.#invalidateGitCaches();
|
|
355
365
|
}
|
|
356
366
|
#invalidateSessionCaches(): void {
|
|
367
|
+
this.#clearUsageStartTimer();
|
|
357
368
|
this.#cachedUsage = null;
|
|
358
369
|
this.#usageFetchedAt = 0;
|
|
359
370
|
this.#usageInFlight = false;
|
|
@@ -521,38 +532,73 @@ export class StatusLineComponent implements Component {
|
|
|
521
532
|
}
|
|
522
533
|
|
|
523
534
|
/**
|
|
524
|
-
*
|
|
525
|
-
*
|
|
526
|
-
* (non-private) so unit tests can verify the backoff invariant.
|
|
535
|
+
* Startup redraws only arm a short-delayed task; timeout releases the render
|
|
536
|
+
* cadence while a late successful fetch can still refresh the cached segment.
|
|
527
537
|
*/
|
|
528
538
|
refreshUsageInBackground(): void {
|
|
529
539
|
const now = Date.now();
|
|
530
|
-
if (this.#usageInFlight) return;
|
|
540
|
+
if (this.#usageInFlight || this.#usageStartTimer) return;
|
|
531
541
|
if (this.#usageFetchedAt > 0 && now - this.#usageFetchedAt < 5 * 60_000) return;
|
|
532
542
|
const session = this.session;
|
|
533
|
-
const fetcher = (session as { fetchUsageReports?: () => Promise<unknown> }).fetchUsageReports;
|
|
543
|
+
const fetcher = (session as { fetchUsageReports?: (signal?: AbortSignal) => Promise<unknown> }).fetchUsageReports;
|
|
534
544
|
if (typeof fetcher !== "function") return;
|
|
535
545
|
this.#usageInFlight = true;
|
|
536
|
-
|
|
537
|
-
|
|
546
|
+
this.#usageStartTimer = setTimeout(() => {
|
|
547
|
+
this.#usageStartTimer = null;
|
|
548
|
+
void this.#runUsageRefresh(session, fetcher);
|
|
549
|
+
}, STATUS_USAGE_START_DELAY_MS);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async #runUsageRefresh(session: AgentSession, fetcher: (signal?: AbortSignal) => Promise<unknown>): Promise<void> {
|
|
553
|
+
if (this.#disposed || this.session !== session) {
|
|
554
|
+
this.#usageInFlight = false;
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
const signal = AbortSignal.timeout(STATUS_USAGE_REFRESH_TIMEOUT_MS);
|
|
558
|
+
let reportsPromise: Promise<unknown> | undefined;
|
|
559
|
+
try {
|
|
560
|
+
reportsPromise = fetcher.call(session, signal);
|
|
561
|
+
this.#applyUsageRefreshReports(session, await this.#raceUsageRefreshWithSignal(reportsPromise, signal));
|
|
562
|
+
} catch {
|
|
563
|
+
if (this.session !== session) return;
|
|
564
|
+
this.#usageFetchedAt = Date.now();
|
|
565
|
+
if (signal.aborted && reportsPromise) {
|
|
566
|
+
this.#observeLateUsageRefresh(session, reportsPromise);
|
|
567
|
+
}
|
|
568
|
+
} finally {
|
|
569
|
+
if (this.session === session) this.#usageInFlight = false;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
#applyUsageRefreshReports(session: AgentSession, reports: unknown): void {
|
|
574
|
+
if (this.#disposed || this.session !== session) return;
|
|
575
|
+
this.#cachedUsage = this.#normalizeUsageReports(reports);
|
|
576
|
+
this.#usageFetchedAt = Date.now();
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
#observeLateUsageRefresh(session: AgentSession, reportsPromise: Promise<unknown>): void {
|
|
580
|
+
void reportsPromise
|
|
538
581
|
.then(reports => {
|
|
539
|
-
|
|
540
|
-
this.#cachedUsage = this.#normalizeUsageReports(reports);
|
|
541
|
-
this.#usageFetchedAt = Date.now();
|
|
582
|
+
this.#applyUsageRefreshReports(session, reports);
|
|
542
583
|
})
|
|
543
584
|
.catch(() => {
|
|
544
|
-
if (this.session !== session) return;
|
|
545
|
-
// Backoff on error: stamp the fetch time so the 5-min TTL guard
|
|
546
|
-
// also acts as an error budget. Without this, every render
|
|
547
|
-
// kicks off another fetch (gated only by #usageInFlight),
|
|
548
|
-
// which hammers the endpoint during a network outage / 5xx.
|
|
585
|
+
if (this.#disposed || this.session !== session) return;
|
|
549
586
|
this.#usageFetchedAt = Date.now();
|
|
550
|
-
})
|
|
551
|
-
.finally(() => {
|
|
552
|
-
if (this.session === session) this.#usageInFlight = false;
|
|
553
587
|
});
|
|
554
588
|
}
|
|
555
589
|
|
|
590
|
+
async #raceUsageRefreshWithSignal(promise: Promise<unknown>, signal: AbortSignal): Promise<unknown> {
|
|
591
|
+
if (signal.aborted) throw signal.reason;
|
|
592
|
+
const aborted = Promise.withResolvers<never>();
|
|
593
|
+
const onAbort = () => aborted.reject(signal.reason);
|
|
594
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
595
|
+
try {
|
|
596
|
+
return await Promise.race([promise, aborted.promise]);
|
|
597
|
+
} finally {
|
|
598
|
+
signal.removeEventListener("abort", onAbort);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
556
602
|
#normalizeUsageReports(reports: unknown): {
|
|
557
603
|
fiveHour?: { percent: number; resetMinutes?: number };
|
|
558
604
|
sevenDay?: { percent: number; resetHours?: number };
|
package/src/sdk.ts
CHANGED
|
@@ -687,6 +687,37 @@ export async function loadSessionExtensions(
|
|
|
687
687
|
return result;
|
|
688
688
|
}
|
|
689
689
|
|
|
690
|
+
/**
|
|
691
|
+
* Load discovered/configured extensions and register their providers into
|
|
692
|
+
* `modelRegistry`, then discover the dynamic provider catalogs. One-shot CLIs
|
|
693
|
+
* (`omp bench`, dry-balance) build a bare {@link ModelRegistry} that only knows
|
|
694
|
+
* built-in catalog providers; without this, providers contributed by an
|
|
695
|
+
* extension (e.g. a custom OpenAI-compatible provider under
|
|
696
|
+
* `~/.omp/agent/extensions/`) never reach model resolution. Mirrors the
|
|
697
|
+
* session / `omp models` path: drain the queued provider registrations, then
|
|
698
|
+
* `refreshRuntimeProviders` so dynamically-discovered models exist before
|
|
699
|
+
* selectors are resolved.
|
|
700
|
+
*/
|
|
701
|
+
export async function loadCliExtensionProviders(
|
|
702
|
+
modelRegistry: ModelRegistry,
|
|
703
|
+
settings: Settings,
|
|
704
|
+
cwd: string,
|
|
705
|
+
options: Pick<CreateAgentSessionOptions, "disableExtensionDiscovery" | "additionalExtensionPaths"> = {},
|
|
706
|
+
): Promise<void> {
|
|
707
|
+
const eventBus = new EventBus();
|
|
708
|
+
const extensionsResult = await loadSessionExtensions(options, cwd, settings, eventBus);
|
|
709
|
+
const activeSources = extensionsResult.extensions.map(extension => extension.path);
|
|
710
|
+
modelRegistry.syncExtensionSources(activeSources);
|
|
711
|
+
for (const sourceId of new Set(activeSources)) {
|
|
712
|
+
modelRegistry.clearSourceRegistrations(sourceId);
|
|
713
|
+
}
|
|
714
|
+
for (const { name, config, sourceId } of extensionsResult.runtime.pendingProviderRegistrations) {
|
|
715
|
+
modelRegistry.registerProvider(name, config, sourceId);
|
|
716
|
+
}
|
|
717
|
+
extensionsResult.runtime.pendingProviderRegistrations = [];
|
|
718
|
+
await modelRegistry.refreshRuntimeProviders();
|
|
719
|
+
}
|
|
720
|
+
|
|
690
721
|
/**
|
|
691
722
|
* Discover skills from cwd and agentDir.
|
|
692
723
|
*/
|
|
@@ -1518,6 +1549,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1518
1549
|
timestamp: Date.now(),
|
|
1519
1550
|
}),
|
|
1520
1551
|
peekQueueInvoker: () => session.peekQueueInvoker(),
|
|
1552
|
+
peekPendingInvoker: () => session.peekPendingInvoker(),
|
|
1553
|
+
clearPendingInvokers: () => session.clearPendingInvokers(),
|
|
1521
1554
|
peekStandingResolveHandler: () => session.peekStandingResolveHandler(),
|
|
1522
1555
|
setStandingResolveHandler: handler => session.setStandingResolveHandler(handler),
|
|
1523
1556
|
allocateOutputArtifact: async toolType => {
|
|
@@ -2141,8 +2141,9 @@ export class AgentSession {
|
|
|
2141
2141
|
return this.#agentId;
|
|
2142
2142
|
}
|
|
2143
2143
|
|
|
2144
|
-
/**
|
|
2145
|
-
|
|
2144
|
+
/** Dequeue the next HARD forced tool choice for the upcoming LLM call, dropping
|
|
2145
|
+
* (and rejecting) one whose named tool is no longer active. */
|
|
2146
|
+
#nextHardToolChoice(): ToolChoice | undefined {
|
|
2146
2147
|
const choice = this.#toolChoiceQueue.nextToolChoice();
|
|
2147
2148
|
if (isToolChoiceActive(choice, this.agent.state.tools)) {
|
|
2148
2149
|
return choice;
|
|
@@ -2154,7 +2155,7 @@ export class AgentSession {
|
|
|
2154
2155
|
/**
|
|
2155
2156
|
* The per-turn tool-choice directive for the agent loop's `getToolChoice`. Priority:
|
|
2156
2157
|
* 1. a HARD forced choice from the queue (genuine forces: user-force, eager-todo, …) —
|
|
2157
|
-
* consuming
|
|
2158
|
+
* consuming (advances the queue generator);
|
|
2158
2159
|
* 2. else, when a non-forcing preview is pending, a {@link SoftToolRequirement} — a
|
|
2159
2160
|
* PEEK (advances/pops nothing), so the agent-loop injects the reminder once per head
|
|
2160
2161
|
* and escalates to a forced `resolve` only if the model declines. A compliant turn
|
|
@@ -2162,7 +2163,7 @@ export class AgentSession {
|
|
|
2162
2163
|
* 3. else undefined.
|
|
2163
2164
|
*/
|
|
2164
2165
|
nextToolChoiceDirective(): ToolChoiceDirective | undefined {
|
|
2165
|
-
const hard = this
|
|
2166
|
+
const hard = this.#nextHardToolChoice();
|
|
2166
2167
|
if (hard !== undefined) return hard;
|
|
2167
2168
|
const head = this.#toolChoiceQueue.peekPendingHead();
|
|
2168
2169
|
if (head !== undefined) {
|
|
@@ -2181,6 +2182,11 @@ export class AgentSession {
|
|
|
2181
2182
|
return this.#toolChoiceQueue.peekPendingInvoker();
|
|
2182
2183
|
}
|
|
2183
2184
|
|
|
2185
|
+
/** Clear stale non-forcing pending preview invokers after `resolve` proves none can run. */
|
|
2186
|
+
clearPendingInvokers(): void {
|
|
2187
|
+
this.#toolChoiceQueue.clearPendingInvokers();
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2184
2190
|
/**
|
|
2185
2191
|
* Force the next model call to target a specific active tool, then terminate
|
|
2186
2192
|
* the agent loop. Pushes a two-step sequence [forced, "none"] so the model
|
|
@@ -231,6 +231,12 @@ export class ToolChoiceQueue {
|
|
|
231
231
|
this.#pendingInvokers = this.#pendingInvokers.filter(p => p.id !== id);
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
+
/** Drop every pending preview invoker without touching hard tool-choice directives. */
|
|
235
|
+
clearPendingInvokers(): void {
|
|
236
|
+
if (this.#pendingInvokers.length === 0) return;
|
|
237
|
+
this.#pendingInvokers = [];
|
|
238
|
+
}
|
|
239
|
+
|
|
234
240
|
/** True when at least one non-forcing pending preview is registered. */
|
|
235
241
|
get hasPendingInvoker(): boolean {
|
|
236
242
|
return this.#pendingInvokers.length > 0;
|
package/src/tools/index.ts
CHANGED
|
@@ -316,6 +316,8 @@ export interface ToolSession {
|
|
|
316
316
|
* tool dispatches to it so a staged preview resolves WITHOUT forcing tool_choice — the
|
|
317
317
|
* agent-loop's SoftToolRequirement lifecycle owns reminder injection and escalation. */
|
|
318
318
|
peekPendingInvoker?(): ((input: unknown) => Promise<unknown> | unknown) | undefined;
|
|
319
|
+
/** Clear stale pending preview markers when `resolve` cannot dispatch them. */
|
|
320
|
+
clearPendingInvokers?(): void;
|
|
319
321
|
/** Peek the long-lived "standing" resolve handler registered by a mode (e.g. plan mode).
|
|
320
322
|
* Consulted by the `resolve` tool as a fallback when no queue invoker is in flight,
|
|
321
323
|
* letting modes accept `resolve` invocations without forcing the tool choice every turn. */
|
package/src/tools/resolve.ts
CHANGED
|
@@ -212,6 +212,7 @@ export class ResolveTool implements AgentTool<typeof resolveSchema, ResolveToolD
|
|
|
212
212
|
this.session.peekPendingInvoker?.() ??
|
|
213
213
|
this.session.peekStandingResolveHandler?.();
|
|
214
214
|
if (!invoker) {
|
|
215
|
+
this.session.clearPendingInvokers?.();
|
|
215
216
|
// `discard` is a request to cancel/abort a staged action. When nothing is
|
|
216
217
|
// pending, the desired end-state (no staged change) already holds, so honor
|
|
217
218
|
// it as a successful cancellation instead of surfacing a hard error to the
|
package/src/tui/hyperlink.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import * as url from "node:url";
|
|
9
9
|
import { TERMINAL } from "@oh-my-pi/pi-tui";
|
|
10
|
-
import { settings } from "../config/settings";
|
|
10
|
+
import { isSettingsInitialized, settings } from "../config/settings";
|
|
11
11
|
import {
|
|
12
12
|
LocalProtocolHandler,
|
|
13
13
|
memoryRootsFromRegistry,
|
|
@@ -45,8 +45,10 @@ function buildFileUri(filePath: string, opts?: { line?: number; col?: number }):
|
|
|
45
45
|
* - `"off"`: never
|
|
46
46
|
* - `"auto"`: when `process.stdout.isTTY`, `NO_COLOR` is unset, and the detected terminal reports hyperlink support
|
|
47
47
|
* - `"always"`: unconditionally (useful for viewers that support OSC 8 without advertising it)
|
|
48
|
+
* Before settings initialization, returns false so early render paths stay plain text.
|
|
48
49
|
*/
|
|
49
50
|
export function isHyperlinkEnabled(): boolean {
|
|
51
|
+
if (!isSettingsInitialized()) return false;
|
|
50
52
|
const mode = settings.get("tui.hyperlinks");
|
|
51
53
|
if (mode === "off") return false;
|
|
52
54
|
if (mode === "always") return true;
|
|
@@ -104,10 +106,11 @@ export function urlHyperlink(url: string, displayText: string): string {
|
|
|
104
106
|
* Wrap `displayText` in an OSC 8 hyperlink pointing at an HTTP(S) URL,
|
|
105
107
|
* bypassing terminal capability auto-detection. Used for auth prompts where
|
|
106
108
|
* an inert "click" label blocks login on terminals whose capabilities are
|
|
107
|
-
* not advertised. Still returns plain text
|
|
108
|
-
* opted out via `tui.hyperlinks=off`.
|
|
109
|
+
* not advertised. Still returns plain text before settings initialization or
|
|
110
|
+
* when the user has explicitly opted out via `tui.hyperlinks=off`.
|
|
109
111
|
*/
|
|
110
112
|
export function urlHyperlinkAlways(url: string, displayText: string): string {
|
|
113
|
+
if (!isSettingsInitialized()) return displayText;
|
|
111
114
|
if (settings.get("tui.hyperlinks") === "off") return displayText;
|
|
112
115
|
const normalized = url.match(/^www\./i) ? `https://${url}` : url;
|
|
113
116
|
try {
|