@oh-my-pi/pi-coding-agent 16.1.16 → 16.1.17
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 +30 -0
- package/dist/cli.js +2749 -2726
- package/dist/types/auto-thinking/classifier.d.ts +4 -2
- package/dist/types/config/model-discovery.d.ts +1 -0
- package/dist/types/config/model-registry.d.ts +4 -0
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +22 -7
- package/dist/types/extensibility/plugins/loader.d.ts +16 -9
- package/dist/types/extensibility/tool-event-input.d.ts +2 -0
- package/dist/types/hindsight/content.d.ts +2 -10
- package/dist/types/modes/components/chat-transcript-builder.d.ts +2 -0
- package/dist/types/modes/components/custom-editor.d.ts +3 -0
- package/dist/types/modes/components/status-line/types.d.ts +1 -0
- package/dist/types/modes/controllers/command-controller.d.ts +2 -0
- package/dist/types/modes/controllers/input-controller.d.ts +14 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +11 -0
- package/dist/types/session/agent-session.d.ts +6 -6
- package/dist/types/session/provider-image-budget.d.ts +3 -0
- package/dist/types/session/session-context.d.ts +6 -5
- package/dist/types/task/parallel.d.ts +4 -0
- package/dist/types/thinking.d.ts +8 -1
- package/dist/types/tiny/title-client.d.ts +2 -2
- package/dist/types/utils/tools-manager.d.ts +2 -0
- package/package.json +12 -12
- package/src/auto-thinking/classifier.ts +7 -2
- package/src/cli/profile-alias.ts +38 -7
- package/src/cli/usage-cli.ts +5 -1
- package/src/config/model-discovery.ts +59 -8
- package/src/config/model-registry.ts +74 -3
- package/src/discovery/omp-extension-roots.ts +1 -3
- package/src/extensibility/extensions/wrapper.ts +3 -2
- package/src/extensibility/hooks/tool-wrapper.ts +4 -3
- package/src/extensibility/plugins/legacy-pi-compat.ts +40 -8
- package/src/extensibility/plugins/loader.ts +71 -23
- package/src/extensibility/plugins/marketplace/manager.ts +134 -0
- package/src/extensibility/tool-event-input.ts +57 -0
- package/src/hindsight/content.ts +21 -12
- package/src/mcp/tool-bridge.ts +27 -2
- package/src/mnemopi/state.ts +5 -2
- package/src/modes/components/agent-transcript-viewer.ts +193 -41
- package/src/modes/components/chat-transcript-builder.ts +6 -0
- package/src/modes/components/custom-editor.test.ts +18 -1
- package/src/modes/components/custom-editor.ts +77 -45
- package/src/modes/components/hook-editor.ts +15 -2
- package/src/modes/components/settings-selector.ts +2 -2
- package/src/modes/components/status-line/component.ts +52 -8
- package/src/modes/components/status-line/segments.ts +5 -1
- package/src/modes/components/status-line/types.ts +1 -0
- package/src/modes/components/welcome.ts +12 -14
- package/src/modes/controllers/command-controller.ts +16 -5
- package/src/modes/controllers/input-controller.ts +115 -3
- package/src/modes/controllers/selector-controller.ts +19 -1
- package/src/modes/interactive-mode.ts +3 -3
- package/src/modes/utils/ui-helpers.ts +3 -3
- package/src/sdk.ts +8 -10
- package/src/session/agent-session.ts +193 -49
- package/src/session/provider-image-budget.ts +86 -0
- package/src/session/session-context.ts +14 -7
- package/src/session/session-storage.ts +24 -2
- package/src/session/snapcompact-inline.ts +19 -3
- package/src/slash-commands/builtin-registry.ts +0 -22
- package/src/slash-commands/helpers/usage-report.ts +9 -1
- package/src/task/parallel.ts +6 -1
- package/src/thinking.ts +9 -2
- package/src/tiny/title-client.ts +75 -21
- package/src/utils/tools-manager.ts +67 -10
|
@@ -67,7 +67,7 @@ export class HookEditorComponent extends Container {
|
|
|
67
67
|
|
|
68
68
|
// Hint
|
|
69
69
|
const hint = this.#promptStyle
|
|
70
|
-
? "enter submit esc cancel ctrl+g external editor"
|
|
70
|
+
? "enter or ctrl+q submit esc cancel ctrl+g external editor"
|
|
71
71
|
: "ctrl+q/ctrl+enter submit esc cancel ctrl+g external editor";
|
|
72
72
|
this.addChild(new Text(theme.fg("dim", hint), 1, 0));
|
|
73
73
|
|
|
@@ -95,8 +95,21 @@ export class HookEditorComponent extends Container {
|
|
|
95
95
|
this.#editor.pasteText(text);
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
/**
|
|
98
|
+
/**
|
|
99
|
+
* Prompt-style: raw Enter submits; Editor owns newline-producing sequences.
|
|
100
|
+
* The follow-up chord (`app.message.followUp` → Ctrl+Q / Ctrl+Enter) also
|
|
101
|
+
* submits, so muscle memory from the main editor / hook-style surface works
|
|
102
|
+
* here and Windows Terminal — which can't deliver a distinct Ctrl+Enter
|
|
103
|
+
* event (#1903) — still has a working chord via Ctrl+Q (#3353).
|
|
104
|
+
*/
|
|
99
105
|
#handlePromptStyleInput(keyData: string): void {
|
|
106
|
+
// Submit on the follow-up chord first so it wins over Editor's own
|
|
107
|
+
// Ctrl+Enter newline handling. Mirrors #handleHookStyleInput.
|
|
108
|
+
if (matchesAppFollowUp(keyData)) {
|
|
109
|
+
this.#submitCurrentText();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
100
113
|
// Prompt-style keeps Escape as an explicit cancel key and also honors app.interrupt remaps.
|
|
101
114
|
if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc") || matchesAppInterrupt(keyData)) {
|
|
102
115
|
this.#onCancelCallback();
|
|
@@ -703,7 +703,7 @@ export class SettingsSelectorComponent implements Component {
|
|
|
703
703
|
id: def.path,
|
|
704
704
|
label: def.label,
|
|
705
705
|
description: def.description,
|
|
706
|
-
currentValue: currentValue
|
|
706
|
+
currentValue: String(currentValue ?? ""),
|
|
707
707
|
values: [...def.values],
|
|
708
708
|
changed,
|
|
709
709
|
};
|
|
@@ -723,7 +723,7 @@ export class SettingsSelectorComponent implements Component {
|
|
|
723
723
|
id: def.path,
|
|
724
724
|
label: def.label,
|
|
725
725
|
description: def.description,
|
|
726
|
-
currentValue: (currentValue
|
|
726
|
+
currentValue: String(currentValue ?? ""),
|
|
727
727
|
submenu: (cv, done) => this.#createTextInput(def, cv, done),
|
|
728
728
|
changed,
|
|
729
729
|
};
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
|
4
|
-
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
|
|
4
|
+
import type { AssistantMessage, UsageLimit, UsageReport } from "@oh-my-pi/pi-ai";
|
|
5
5
|
import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
|
|
6
6
|
import { getProjectDir } from "@oh-my-pi/pi-utils";
|
|
7
7
|
import { $ } from "bun";
|
|
8
8
|
import { settings } from "../../../config/settings";
|
|
9
9
|
import type { AgentSession } from "../../../session/agent-session";
|
|
10
|
+
import type { OAuthAccountIdentity } from "../../../session/auth-storage";
|
|
11
|
+
import { limitMatchesActiveAccount } from "../../../slash-commands/helpers/active-oauth-account";
|
|
10
12
|
import * as git from "../../../utils/git";
|
|
11
13
|
import { getSessionAccentAnsi, getSessionAccentHex } from "../../../utils/session-color";
|
|
12
14
|
import { sanitizeStatusText } from "../../shared";
|
|
@@ -207,11 +209,13 @@ export class StatusLineComponent implements Component {
|
|
|
207
209
|
#lastTokensPerSecond: number | null = null;
|
|
208
210
|
#lastTokensPerSecondTimestamp: number | null = null;
|
|
209
211
|
|
|
210
|
-
//
|
|
212
|
+
// Provider usage caching (5-min TTL, OAuth/sub only)
|
|
211
213
|
#cachedUsage: {
|
|
214
|
+
tier?: string;
|
|
212
215
|
fiveHour?: { percent: number; resetMinutes?: number };
|
|
213
216
|
sevenDay?: { percent: number; resetHours?: number };
|
|
214
217
|
} | null = null;
|
|
218
|
+
#cachedUsageContextKey: string | null = null;
|
|
215
219
|
#usageFetchedAt = 0;
|
|
216
220
|
#usageInFlight = false;
|
|
217
221
|
#usageStartTimer: Timer | null = null;
|
|
@@ -531,15 +535,28 @@ export class StatusLineComponent implements Component {
|
|
|
531
535
|
return null;
|
|
532
536
|
}
|
|
533
537
|
|
|
538
|
+
#getUsageContextKey(session: AgentSession): string {
|
|
539
|
+
const activeProvider = session.state.model?.provider ?? session.model?.provider ?? "";
|
|
540
|
+
if (!activeProvider) return "";
|
|
541
|
+
const identity = session.modelRegistry?.authStorage?.getOAuthAccountIdentity(activeProvider, session.sessionId);
|
|
542
|
+
return [activeProvider, identity?.accountId ?? "", identity?.email ?? "", identity?.projectId ?? ""].join("\0");
|
|
543
|
+
}
|
|
544
|
+
|
|
534
545
|
/**
|
|
535
546
|
* Startup redraws only arm a short-delayed task; timeout releases the render
|
|
536
547
|
* cadence while a late successful fetch can still refresh the cached segment.
|
|
537
548
|
*/
|
|
538
549
|
refreshUsageInBackground(): void {
|
|
539
550
|
const now = Date.now();
|
|
551
|
+
const session = this.session;
|
|
552
|
+
const usageContextKey = this.#getUsageContextKey(session);
|
|
553
|
+
if (this.#cachedUsageContextKey !== usageContextKey) {
|
|
554
|
+
this.#cachedUsage = null;
|
|
555
|
+
this.#usageFetchedAt = 0;
|
|
556
|
+
this.#cachedUsageContextKey = usageContextKey;
|
|
557
|
+
}
|
|
540
558
|
if (this.#usageInFlight || this.#usageStartTimer) return;
|
|
541
559
|
if (this.#usageFetchedAt > 0 && now - this.#usageFetchedAt < 5 * 60_000) return;
|
|
542
|
-
const session = this.session;
|
|
543
560
|
const fetcher = (session as { fetchUsageReports?: (signal?: AbortSignal) => Promise<unknown> }).fetchUsageReports;
|
|
544
561
|
if (typeof fetcher !== "function") return;
|
|
545
562
|
this.#usageInFlight = true;
|
|
@@ -572,7 +589,12 @@ export class StatusLineComponent implements Component {
|
|
|
572
589
|
|
|
573
590
|
#applyUsageRefreshReports(session: AgentSession, reports: unknown): void {
|
|
574
591
|
if (this.#disposed || this.session !== session) return;
|
|
575
|
-
|
|
592
|
+
const activeProvider = session.state.model?.provider ?? session.model?.provider;
|
|
593
|
+
const activeIdentity =
|
|
594
|
+
activeProvider && session.modelRegistry?.authStorage
|
|
595
|
+
? session.modelRegistry.authStorage.getOAuthAccountIdentity(activeProvider, session.sessionId)
|
|
596
|
+
: undefined;
|
|
597
|
+
this.#cachedUsage = this.#normalizeUsageReports(reports, activeProvider, activeIdentity);
|
|
576
598
|
this.#usageFetchedAt = Date.now();
|
|
577
599
|
}
|
|
578
600
|
|
|
@@ -599,20 +621,35 @@ export class StatusLineComponent implements Component {
|
|
|
599
621
|
}
|
|
600
622
|
}
|
|
601
623
|
|
|
602
|
-
#normalizeUsageReports(
|
|
624
|
+
#normalizeUsageReports(
|
|
625
|
+
reports: unknown,
|
|
626
|
+
activeProvider?: string,
|
|
627
|
+
activeIdentity?: OAuthAccountIdentity,
|
|
628
|
+
): {
|
|
629
|
+
tier?: string;
|
|
603
630
|
fiveHour?: { percent: number; resetMinutes?: number };
|
|
604
631
|
sevenDay?: { percent: number; resetHours?: number };
|
|
605
632
|
} | null {
|
|
606
633
|
if (!Array.isArray(reports)) return null;
|
|
607
634
|
let fiveHour: { percent: number; resetMinutes?: number } | undefined;
|
|
608
635
|
let sevenDay: { percent: number; resetHours?: number } | undefined;
|
|
636
|
+
let fiveHourTier: string | undefined;
|
|
637
|
+
let sevenDayTier: string | undefined;
|
|
609
638
|
const now = Date.now();
|
|
610
639
|
for (const report of reports) {
|
|
611
640
|
if (!report || typeof report !== "object") continue;
|
|
641
|
+
const provider = (report as { provider?: unknown }).provider;
|
|
642
|
+
if (activeProvider && provider !== activeProvider) continue;
|
|
612
643
|
const limits = (report as { limits?: unknown }).limits;
|
|
613
644
|
if (!Array.isArray(limits)) continue;
|
|
614
645
|
for (const limit of limits) {
|
|
615
646
|
if (!limit || typeof limit !== "object") continue;
|
|
647
|
+
if (
|
|
648
|
+
activeIdentity &&
|
|
649
|
+
!limitMatchesActiveAccount(report as UsageReport, limit as UsageLimit, activeIdentity)
|
|
650
|
+
) {
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
616
653
|
const l = limit as {
|
|
617
654
|
scope?: { windowId?: string; tier?: string };
|
|
618
655
|
window?: { resetsAt?: number };
|
|
@@ -623,23 +660,30 @@ export class StatusLineComponent implements Component {
|
|
|
623
660
|
const windowId = l.scope?.windowId;
|
|
624
661
|
const tier = l.scope?.tier;
|
|
625
662
|
const resetsAt = l.window?.resetsAt;
|
|
626
|
-
|
|
663
|
+
// Accept tiered limits, but prefer untiered (backward compat with Anthropic).
|
|
664
|
+
// An untiered limit always replaces a tiered one; among same-tieredness, first wins.
|
|
665
|
+
if (windowId === "5h" && (!fiveHour || (fiveHourTier !== undefined && !tier))) {
|
|
627
666
|
fiveHour = {
|
|
628
667
|
percent: fraction * 100,
|
|
629
668
|
resetMinutes:
|
|
630
669
|
typeof resetsAt === "number" ? Math.max(0, Math.round((resetsAt - now) / 60_000)) : undefined,
|
|
631
670
|
};
|
|
632
|
-
|
|
671
|
+
fiveHourTier = tier || undefined;
|
|
672
|
+
}
|
|
673
|
+
if (windowId === "7d" && (!sevenDay || (sevenDayTier !== undefined && !tier))) {
|
|
633
674
|
sevenDay = {
|
|
634
675
|
percent: fraction * 100,
|
|
635
676
|
resetHours:
|
|
636
677
|
typeof resetsAt === "number" ? Math.max(0, Math.round((resetsAt - now) / 3_600_000)) : undefined,
|
|
637
678
|
};
|
|
679
|
+
sevenDayTier = tier || undefined;
|
|
638
680
|
}
|
|
639
681
|
}
|
|
640
682
|
}
|
|
641
683
|
if (!fiveHour && !sevenDay) return null;
|
|
642
|
-
|
|
684
|
+
// Single compact label; prefer the five-hour tier if displayed windows ever disagree.
|
|
685
|
+
const effectiveTier = fiveHourTier ?? sevenDayTier;
|
|
686
|
+
return { tier: effectiveTier, fiveHour, sevenDay };
|
|
643
687
|
}
|
|
644
688
|
|
|
645
689
|
/**
|
|
@@ -4,7 +4,7 @@ import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
|
|
4
4
|
import { TERMINAL } from "@oh-my-pi/pi-tui";
|
|
5
5
|
import { formatDuration, formatNumber, getProjectDir, pathIsWithin, relativePathWithinRoot } from "@oh-my-pi/pi-utils";
|
|
6
6
|
import { type ThemeColor, theme } from "../../../modes/theme/theme";
|
|
7
|
-
import { shortenPath } from "../../../tools/render-utils";
|
|
7
|
+
import { shortenPath, TRUNCATE_LENGTHS, truncateToWidth } from "../../../tools/render-utils";
|
|
8
8
|
import { getSessionAccentAnsi, getSessionAccentHex } from "../../../utils/session-color";
|
|
9
9
|
import { sanitizeStatusText } from "../../shared";
|
|
10
10
|
import { formatContextUsage, getContextUsageLevel, getContextUsageThemeColor } from "./context-thresholds";
|
|
@@ -551,6 +551,10 @@ const usageSegment: StatusLineSegment = {
|
|
|
551
551
|
return { content: "", visible: false };
|
|
552
552
|
}
|
|
553
553
|
const parts: string[] = [];
|
|
554
|
+
if (u.tier) {
|
|
555
|
+
const tier = truncateToWidth(sanitizeStatusText(u.tier), TRUNCATE_LENGTHS.SHORT);
|
|
556
|
+
if (tier) parts.push(theme.fg("accent", tier));
|
|
557
|
+
}
|
|
554
558
|
if (u.fiveHour) {
|
|
555
559
|
const pct = u.fiveHour.percent;
|
|
556
560
|
const pctText = theme.fg(pickUsageColor(pct), `${Math.round(pct)}%`);
|
|
@@ -95,24 +95,23 @@ export function renderWelcomeTip(tip: string, boxWidth: number, phase = 0): stri
|
|
|
95
95
|
const wrappedBody = wrapTextWithAnsi(replaceTabs(body), bodyBudget);
|
|
96
96
|
if (wrappedBody.length === 0) return [];
|
|
97
97
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const italic = "\x1b[3m";
|
|
102
|
-
const dim = "\x1b[2m";
|
|
103
|
-
const reset = "\x1b[0m";
|
|
98
|
+
// Pull both colors from the active theme so the line stays readable on light
|
|
99
|
+
// themes; the previous hardcoded `#b48cff` / `#9ccfff` pastels (plus a manual
|
|
100
|
+
// `\x1b[2m` dim on the body) dropped to ~1.5:1 contrast on a white background.
|
|
104
101
|
const continuationIndent = padding(labelWidth);
|
|
102
|
+
const styledLabel = theme.fg("customMessageLabel", label);
|
|
105
103
|
|
|
106
|
-
const lines = wrappedBody.map((line, index) =>
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
);
|
|
104
|
+
const lines = wrappedBody.map((line, index) => {
|
|
105
|
+
const styledBody = theme.fg("muted", line);
|
|
106
|
+
const content = index === 0 ? `${styledLabel}${styledBody}` : `${continuationIndent}${styledBody}`;
|
|
107
|
+
return ` ${theme.italic(content)}`;
|
|
108
|
+
});
|
|
111
109
|
|
|
112
110
|
if (isNew) {
|
|
113
111
|
// Append the rainbow tag to the final body line when it fits within the
|
|
114
112
|
// box; otherwise drop it onto its own indented continuation line so the
|
|
115
113
|
// styled glyphs never overflow or reflow the wrapped body.
|
|
114
|
+
const encoding: ColorEncoding = TERMINAL.trueColor ? "ansi-16m" : "ansi-256";
|
|
116
115
|
const tag = renderNewTag(phase, encoding);
|
|
117
116
|
const tagWidth = 1 + visibleWidth(NEW_TAG_TEXT); // 1 = space separator
|
|
118
117
|
const lastLine = lines[lines.length - 1];
|
|
@@ -330,7 +329,6 @@ export class WelcomeComponent implements Component {
|
|
|
330
329
|
// Right column
|
|
331
330
|
const rightLines = [
|
|
332
331
|
` ${theme.bold(theme.fg("accent", "Tips"))}`,
|
|
333
|
-
` ${theme.fg("dim", "?")}${theme.fg("muted", " for keyboard shortcuts")}`,
|
|
334
332
|
` ${theme.fg("dim", "#")}${theme.fg("muted", " for prompt actions")}`,
|
|
335
333
|
` ${theme.fg("dim", "/")}${theme.fg("muted", " for commands")}`,
|
|
336
334
|
` ${theme.fg("dim", "!")}${theme.fg("muted", " to run bash")}`,
|
|
@@ -393,8 +391,8 @@ export class WelcomeComponent implements Component {
|
|
|
393
391
|
}
|
|
394
392
|
|
|
395
393
|
/**
|
|
396
|
-
* Render the per-instance tip line:
|
|
397
|
-
*
|
|
394
|
+
* Render the per-instance tip line: the `customMessageLabel`-themed `Tip:`
|
|
395
|
+
* label followed by a `muted` body, the whole line italicized. Returns `[]`
|
|
398
396
|
* when no tip is available or the box is too narrow to be useful.
|
|
399
397
|
*/
|
|
400
398
|
#renderTip(boxWidth: number): string[] {
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
type UsageReport,
|
|
11
11
|
} from "@oh-my-pi/pi-ai";
|
|
12
12
|
import { Loader, Markdown, padding, Spacer, Text, visibleWidth } from "@oh-my-pi/pi-tui";
|
|
13
|
-
import { formatDuration, Snowflake } from "@oh-my-pi/pi-utils";
|
|
13
|
+
import { formatDuration, Snowflake, sanitizeText } from "@oh-my-pi/pi-utils";
|
|
14
14
|
import { shouldEnableAppendOnlyContext } from "../../config/append-only-context-mode";
|
|
15
15
|
import { type LoadedCustomShare, loadCustomShare } from "../../export/custom-share";
|
|
16
16
|
import { shareSession } from "../../export/share";
|
|
@@ -44,7 +44,7 @@ import { formatShakeSummary, type ShakeMode, type ShakeResult } from "../../sess
|
|
|
44
44
|
import { limitMatchesActiveAccount } from "../../slash-commands/helpers/active-oauth-account";
|
|
45
45
|
import { outputMeta } from "../../tools/output-meta";
|
|
46
46
|
import { resolveToCwd, stripOuterDoubleQuotes } from "../../tools/path-utils";
|
|
47
|
-
import { replaceTabs } from "../../tools/render-utils";
|
|
47
|
+
import { replaceTabs, truncateToWidth } from "../../tools/render-utils";
|
|
48
48
|
import { getChangelogPath, parseChangelog } from "../../utils/changelog";
|
|
49
49
|
import { copyToClipboard } from "../../utils/clipboard";
|
|
50
50
|
import { openPath } from "../../utils/open";
|
|
@@ -1523,7 +1523,7 @@ function resolveColumnWidth(count: number, available: number, trailing: number):
|
|
|
1523
1523
|
return ideal;
|
|
1524
1524
|
}
|
|
1525
1525
|
|
|
1526
|
-
function renderUsageReports(
|
|
1526
|
+
export function renderUsageReports(
|
|
1527
1527
|
reports: UsageReport[],
|
|
1528
1528
|
uiTheme: typeof theme,
|
|
1529
1529
|
nowMs: number,
|
|
@@ -1583,6 +1583,15 @@ function renderUsageReports(
|
|
|
1583
1583
|
lines.push(` ${uiTheme.fg("accent", "in use by this session:")} ${activeAccountLabel}`);
|
|
1584
1584
|
}
|
|
1585
1585
|
|
|
1586
|
+
// Provider-wide disclaimers (e.g. "OMP-observed spend only") render once
|
|
1587
|
+
// above the per-account sections instead of duplicating onto every limit.
|
|
1588
|
+
const providerNotes = [...new Set(providerReports.flatMap(report => report.notes ?? []))];
|
|
1589
|
+
if (providerNotes.length > 0) {
|
|
1590
|
+
lines.push(
|
|
1591
|
+
` ${uiTheme.fg("dim", replaceTabs(truncateToWidth(sanitizeText(providerNotes.map(n => n.replace(/[\r\n]+/g, " ")).join(" • ")), 110)))}`.trimEnd(),
|
|
1592
|
+
);
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1586
1595
|
const resetAccountLines: string[] = [];
|
|
1587
1596
|
for (const report of providerReports) {
|
|
1588
1597
|
const count = report.resetCredits?.availableCount ?? 0;
|
|
@@ -1651,9 +1660,11 @@ function renderUsageReports(
|
|
|
1651
1660
|
if (resetText) {
|
|
1652
1661
|
lines.push(` ${uiTheme.fg("dim", resetText)}`.trimEnd());
|
|
1653
1662
|
}
|
|
1654
|
-
const notes = sortedLimits.flatMap(limit => limit.notes ?? []);
|
|
1663
|
+
const notes = [...new Set(sortedLimits.flatMap(limit => limit.notes ?? []))];
|
|
1655
1664
|
if (notes.length > 0) {
|
|
1656
|
-
lines.push(
|
|
1665
|
+
lines.push(
|
|
1666
|
+
` ${uiTheme.fg("dim", replaceTabs(truncateToWidth(sanitizeText(notes.map(n => n.replace(/[\r\n]+/g, " ")).join(" • ")), 110)))}`.trimEnd(),
|
|
1667
|
+
);
|
|
1657
1668
|
}
|
|
1658
1669
|
}
|
|
1659
1670
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
3
4
|
import type { ImageContent } from "@oh-my-pi/pi-ai";
|
|
4
5
|
import { type AutocompleteProvider, matchesKey, type SlashCommand } from "@oh-my-pi/pi-tui";
|
|
5
6
|
import { $env, isEnoent, logger, sanitizeText } from "@oh-my-pi/pi-utils";
|
|
@@ -19,6 +20,7 @@ import { isTinyTitleLocalModelKey } from "../../tiny/models";
|
|
|
19
20
|
import { isLowSignalTitleInput } from "../../tiny/text";
|
|
20
21
|
import { tinyTitleClient } from "../../tiny/title-client";
|
|
21
22
|
import type { TinyTitleProgressEvent } from "../../tiny/title-protocol";
|
|
23
|
+
import { resolveReadPath } from "../../tools/path-utils";
|
|
22
24
|
import { shortenPath, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils";
|
|
23
25
|
import { copyToClipboard, readImageFromClipboard, readTextFromClipboard } from "../../utils/clipboard";
|
|
24
26
|
import { EnhancedPasteController } from "../../utils/enhanced-paste";
|
|
@@ -27,6 +29,39 @@ import { ensureSupportedImageInput, ImageInputTooLargeError, loadImageInput } fr
|
|
|
27
29
|
import { resizeImage } from "../../utils/image-resize";
|
|
28
30
|
import { generateSessionTitle, setSessionTerminalTitle } from "../../utils/title-generator";
|
|
29
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Slash commands that may carry secrets in their arguments should never be
|
|
34
|
+
* persisted to history.
|
|
35
|
+
*
|
|
36
|
+
* - /login accepts three callback forms (redirect URL, query string, raw auth
|
|
37
|
+
* code) — all can contain OAuth code=/state= params.
|
|
38
|
+
* - /join <link> carries a 32-byte room key and optional write token.
|
|
39
|
+
* - /mcp add --token <token> carries a bearer token.
|
|
40
|
+
*
|
|
41
|
+
* The command name is extracted the same way as parseSlashCommand() — splitting
|
|
42
|
+
* on the earliest whitespace or colon — so /login:?code=... is correctly matched.
|
|
43
|
+
*/
|
|
44
|
+
export function shouldSkipHistory(slashText: string): boolean {
|
|
45
|
+
if (!slashText.startsWith("/")) return false;
|
|
46
|
+
const body = slashText.slice(1);
|
|
47
|
+
// Match parseSlashCommand: split on earliest whitespace or colon.
|
|
48
|
+
const firstWs = body.search(/\s/);
|
|
49
|
+
const firstColon = body.indexOf(":");
|
|
50
|
+
const sep = firstWs === -1 ? firstColon : firstColon === -1 ? firstWs : Math.min(firstWs, firstColon);
|
|
51
|
+
const name = sep === -1 ? body : body.slice(0, sep);
|
|
52
|
+
const hasArgs = sep !== -1;
|
|
53
|
+
// /login <anything> — parseCallbackInput() accepts redirect URLs, query
|
|
54
|
+
// strings (?code=...), and raw auth codes, all of which carry secrets.
|
|
55
|
+
if (name === "login" && hasArgs) return true;
|
|
56
|
+
// /join <link> — the link carries the 32-byte room key and write token.
|
|
57
|
+
if (name === "join" && hasArgs) return true;
|
|
58
|
+
if (name === "mcp") {
|
|
59
|
+
const args = body.slice(sep + 1).trim();
|
|
60
|
+
return args.startsWith("add") && /--token\s/.test(args);
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
30
65
|
interface Expandable {
|
|
31
66
|
setExpanded(expanded: boolean): void;
|
|
32
67
|
}
|
|
@@ -69,6 +104,20 @@ function wrapPasteInAttachmentBlock(content: string): string {
|
|
|
69
104
|
return `<attachment>\n${content}\n</attachment>`;
|
|
70
105
|
}
|
|
71
106
|
|
|
107
|
+
const FILE_URI_REGEX = /^file:\/\//i;
|
|
108
|
+
|
|
109
|
+
function pastedFileAttachmentExtension(sourcePath: string): string {
|
|
110
|
+
const ext = path.extname(sourcePath);
|
|
111
|
+
const bareExt = ext.slice(1);
|
|
112
|
+
if (!bareExt || bareExt.length > 32 || !/^[a-z0-9][a-z0-9._-]*$/i.test(bareExt)) return "";
|
|
113
|
+
return ext;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function resolvePastedFilePath(filePath: string, cwd: string): string {
|
|
117
|
+
if (FILE_URI_REGEX.test(filePath)) return fileURLToPath(filePath);
|
|
118
|
+
return resolveReadPath(filePath, cwd);
|
|
119
|
+
}
|
|
120
|
+
|
|
72
121
|
const TINY_TITLE_PROGRESS_DONE_TTL_MS = 3_000;
|
|
73
122
|
// A cached model fires its file-load events in a short burst and then goes silent
|
|
74
123
|
// while onnxruntime builds the session; a genuine download keeps streaming progress
|
|
@@ -104,8 +153,8 @@ export class InputController {
|
|
|
104
153
|
// (>= LEFT_DOUBLE_TAP_MAX_GAP_MS) starts a fresh sequence. See
|
|
105
154
|
// #detectLeftDoubleTap.
|
|
106
155
|
#leftTapCount = 0;
|
|
107
|
-
// Sequential index for `local://attachment-N` references created by
|
|
108
|
-
//
|
|
156
|
+
// Sequential index for `local://attachment-N` references created by large-paste and
|
|
157
|
+
// pasted-file attachments. Seeded from 0 and bumped past existing attachment files.
|
|
109
158
|
#attachmentCounter = 0;
|
|
110
159
|
|
|
111
160
|
#showTinyTitleDownloadProgress(modelKey: string): void {
|
|
@@ -343,6 +392,7 @@ export class InputController {
|
|
|
343
392
|
);
|
|
344
393
|
this.ctx.editor.onPasteImage = () => this.handleImagePaste();
|
|
345
394
|
this.ctx.editor.onPasteImagePath = path => this.handleImagePathPaste(path);
|
|
395
|
+
this.ctx.editor.onPasteFilePath = path => this.handleFilePathPaste(path);
|
|
346
396
|
this.ctx.editor.setActionKeys(
|
|
347
397
|
"app.clipboard.pasteTextRaw",
|
|
348
398
|
this.ctx.keybindings.getKeys("app.clipboard.pasteTextRaw"),
|
|
@@ -572,10 +622,14 @@ export class InputController {
|
|
|
572
622
|
ctx: this.ctx,
|
|
573
623
|
});
|
|
574
624
|
if (slashResult === true) {
|
|
625
|
+
if (!shouldSkipHistory(text)) this.ctx.editor.addToHistory(text);
|
|
575
626
|
return;
|
|
576
627
|
}
|
|
577
628
|
if (typeof slashResult === "string") {
|
|
578
|
-
// Command handled but returned remaining text to use as prompt
|
|
629
|
+
// Command handled but returned remaining text to use as prompt.
|
|
630
|
+
// Record the original slash command text so Up Arrow recalls
|
|
631
|
+
// "/loop 10 fix bug" rather than just "fix bug".
|
|
632
|
+
if (!shouldSkipHistory(text)) this.ctx.editor.addToHistory(text);
|
|
579
633
|
text = slashResult;
|
|
580
634
|
}
|
|
581
635
|
|
|
@@ -1011,9 +1065,13 @@ export class InputController {
|
|
|
1011
1065
|
ctx: this.ctx,
|
|
1012
1066
|
});
|
|
1013
1067
|
if (slashResult === true) {
|
|
1068
|
+
if (!shouldSkipHistory(text)) this.ctx.editor.addToHistory(text);
|
|
1014
1069
|
return;
|
|
1015
1070
|
}
|
|
1016
1071
|
if (typeof slashResult === "string") {
|
|
1072
|
+
// Command handled but returned remaining text to use as prompt.
|
|
1073
|
+
// Record the original slash command text so Up Arrow recalls it.
|
|
1074
|
+
if (!shouldSkipHistory(text)) this.ctx.editor.addToHistory(text);
|
|
1017
1075
|
text = slashResult;
|
|
1018
1076
|
}
|
|
1019
1077
|
|
|
@@ -1203,6 +1261,37 @@ export class InputController {
|
|
|
1203
1261
|
}
|
|
1204
1262
|
}
|
|
1205
1263
|
|
|
1264
|
+
async handleFilePathPaste(filePath: string): Promise<void> {
|
|
1265
|
+
try {
|
|
1266
|
+
const resolvedPath = resolvePastedFilePath(filePath, this.ctx.sessionManager.getCwd());
|
|
1267
|
+
const stat = await Bun.file(resolvedPath).stat();
|
|
1268
|
+
if (!stat.isFile()) {
|
|
1269
|
+
this.ctx.editor.pasteText(filePath);
|
|
1270
|
+
this.ctx.ui.requestRender();
|
|
1271
|
+
this.ctx.showStatus("Pasted path is not a file");
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
const reference = await this.#attachExistingFileAsLocal(resolvedPath);
|
|
1276
|
+
this.ctx.editor.insertText(`${reference} `);
|
|
1277
|
+
this.ctx.ui.requestRender();
|
|
1278
|
+
this.ctx.showStatus(`Attached file as ${reference}`);
|
|
1279
|
+
} catch (error) {
|
|
1280
|
+
if (isEnoent(error)) {
|
|
1281
|
+
this.ctx.editor.pasteText(filePath);
|
|
1282
|
+
this.ctx.ui.requestRender();
|
|
1283
|
+
this.ctx.showStatus("Pasted file path was not found");
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
logger.warn("failed to attach pasted file path", {
|
|
1287
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1288
|
+
});
|
|
1289
|
+
this.ctx.editor.pasteText(filePath);
|
|
1290
|
+
this.ctx.ui.requestRender();
|
|
1291
|
+
this.ctx.showError("Failed to attach pasted file path — pasted path inline instead");
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1206
1295
|
async handleImagePathPaste(path: string): Promise<void> {
|
|
1207
1296
|
try {
|
|
1208
1297
|
const image = await loadImageInput({
|
|
@@ -1373,6 +1462,29 @@ export class InputController {
|
|
|
1373
1462
|
this.ctx.ui.requestRender();
|
|
1374
1463
|
}
|
|
1375
1464
|
|
|
1465
|
+
async #attachExistingFileAsLocal(sourcePath: string): Promise<string> {
|
|
1466
|
+
const localRoot = resolveLocalRoot({
|
|
1467
|
+
getArtifactsDir: () => this.ctx.sessionManager.getArtifactsDir(),
|
|
1468
|
+
getSessionId: () => this.ctx.sessionManager.getSessionId(),
|
|
1469
|
+
});
|
|
1470
|
+
await fs.mkdir(localRoot, { recursive: true });
|
|
1471
|
+
const ext = pastedFileAttachmentExtension(sourcePath);
|
|
1472
|
+
let name: string;
|
|
1473
|
+
let filePath: string;
|
|
1474
|
+
do {
|
|
1475
|
+
this.#attachmentCounter++;
|
|
1476
|
+
name = `attachment-${this.#attachmentCounter}${ext}`;
|
|
1477
|
+
filePath = path.join(localRoot, name);
|
|
1478
|
+
} while (await Bun.file(filePath).exists());
|
|
1479
|
+
|
|
1480
|
+
try {
|
|
1481
|
+
await fs.link(sourcePath, filePath);
|
|
1482
|
+
} catch {
|
|
1483
|
+
await fs.copyFile(sourcePath, filePath);
|
|
1484
|
+
}
|
|
1485
|
+
return `local://${name}`;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1376
1488
|
/**
|
|
1377
1489
|
* Save a large paste to the session's `local://` store and insert a clean `local://attachment-N`
|
|
1378
1490
|
* reference into the editor so the agent can `read` it on demand — instead of inlining the text or
|
|
@@ -84,6 +84,22 @@ export class SelectorController {
|
|
|
84
84
|
),
|
|
85
85
|
);
|
|
86
86
|
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Restore keyboard focus to whatever currently owns the editor slot. The
|
|
90
|
+
* slot can hold the editor itself or a hook selector/input/editor pushed
|
|
91
|
+
* in by `ExtensionUiController` — e.g. an approval prompt that fired while
|
|
92
|
+
* a fullscreen overlay was up. `overlayHandle.hide()` restores focus to
|
|
93
|
+
* the component focused when the overlay opened, which is stale in that
|
|
94
|
+
* case (the editor was swapped out): keys land on a hidden editor and the
|
|
95
|
+
* visible prompt receives nothing (issue #3349). Call this after the
|
|
96
|
+
* overlay hides to re-target focus at the visible slot owner.
|
|
97
|
+
*/
|
|
98
|
+
focusActiveEditorArea(): void {
|
|
99
|
+
const visible = this.ctx.editorContainer.children[0] ?? this.ctx.editor;
|
|
100
|
+
this.ctx.ui.setFocus(visible);
|
|
101
|
+
}
|
|
102
|
+
|
|
87
103
|
/**
|
|
88
104
|
* Shows a selector component in place of the editor.
|
|
89
105
|
* @param create Factory that receives a `done` callback and returns the component and focus target
|
|
@@ -109,7 +125,7 @@ export class SelectorController {
|
|
|
109
125
|
let overlayHandle: OverlayHandle | undefined;
|
|
110
126
|
const done = () => {
|
|
111
127
|
overlayHandle?.hide();
|
|
112
|
-
this.
|
|
128
|
+
this.focusActiveEditorArea();
|
|
113
129
|
this.ctx.ui.requestRender();
|
|
114
130
|
};
|
|
115
131
|
const selector = new SettingsSelectorComponent(
|
|
@@ -224,6 +240,7 @@ export class SelectorController {
|
|
|
224
240
|
});
|
|
225
241
|
dashboard.onClose = () => {
|
|
226
242
|
overlay.hide();
|
|
243
|
+
this.focusActiveEditorArea();
|
|
227
244
|
this.ctx.ui.requestRender();
|
|
228
245
|
};
|
|
229
246
|
dashboard.onRequestRender = () => {
|
|
@@ -251,6 +268,7 @@ export class SelectorController {
|
|
|
251
268
|
});
|
|
252
269
|
dashboard.onClose = () => {
|
|
253
270
|
overlay.hide();
|
|
271
|
+
this.focusActiveEditorArea();
|
|
254
272
|
this.ctx.ui.requestRender();
|
|
255
273
|
};
|
|
256
274
|
dashboard.onRequestRender = () => {
|
|
@@ -1441,9 +1441,9 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
1441
1441
|
|
|
1442
1442
|
rebuildChatFromMessages(): void {
|
|
1443
1443
|
this.chatContainer.clear();
|
|
1444
|
-
//
|
|
1445
|
-
//
|
|
1446
|
-
const context = this.viewSession.buildTranscriptSessionContext();
|
|
1444
|
+
// Live display uses the compacted transcript tail; export/resume callers
|
|
1445
|
+
// can still request the full inline compaction history.
|
|
1446
|
+
const context = this.viewSession.buildTranscriptSessionContext({ collapseCompactedHistory: true });
|
|
1447
1447
|
this.renderSessionContext(context);
|
|
1448
1448
|
// During the pre-streaming window — after `startPendingSubmission` has
|
|
1449
1449
|
// optimistically rendered the user's message but before the user
|
|
@@ -517,9 +517,9 @@ export class UiHelpers {
|
|
|
517
517
|
this.ctx.pendingBashComponents = [];
|
|
518
518
|
this.ctx.pendingPythonComponents = [];
|
|
519
519
|
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
const context = this.ctx.viewSession.buildTranscriptSessionContext();
|
|
520
|
+
// Live display uses the compacted transcript tail; export/resume callers
|
|
521
|
+
// can still request the full inline compaction history.
|
|
522
|
+
const context = this.ctx.viewSession.buildTranscriptSessionContext({ collapseCompactedHistory: true });
|
|
523
523
|
this.ctx.renderSessionContext(context, {
|
|
524
524
|
updateFooter: true,
|
|
525
525
|
populateHistory: !this.ctx.focusedAgentId,
|
package/src/sdk.ts
CHANGED
|
@@ -115,6 +115,7 @@ import {
|
|
|
115
115
|
USER_INTERRUPT_LABEL,
|
|
116
116
|
wrapSteeringForModel,
|
|
117
117
|
} from "./session/messages";
|
|
118
|
+
import { clampProviderContextImages } from "./session/provider-image-budget";
|
|
118
119
|
import { getRestorableSessionModels } from "./session/session-context";
|
|
119
120
|
import { SessionManager } from "./session/session-manager";
|
|
120
121
|
import { SnapcompactInlineTransformer } from "./session/snapcompact-inline";
|
|
@@ -2420,8 +2421,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
2420
2421
|
return wrapSteeringForModel(withContext);
|
|
2421
2422
|
};
|
|
2422
2423
|
// Per-request provider-context transforms. Obfuscate FIRST so secrets are
|
|
2423
|
-
// redacted from text before snapcompact rasterizes it into PNG frames
|
|
2424
|
-
//
|
|
2424
|
+
// redacted from text before snapcompact rasterizes it into PNG frames, then
|
|
2425
|
+
// clamp images to the active provider budget before the request is sent.
|
|
2425
2426
|
const snapcompactSystemPromptMode = settings.get("snapcompact.systemPrompt");
|
|
2426
2427
|
const snapcompactInline =
|
|
2427
2428
|
snapcompactSystemPromptMode !== "none" || settings.get("snapcompact.toolResults")
|
|
@@ -2436,14 +2437,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
2436
2437
|
createSnapcompactSavingsRecorder(() => sessionManager.getSessionFile() ?? null),
|
|
2437
2438
|
)
|
|
2438
2439
|
: undefined;
|
|
2439
|
-
const transformProviderContext =
|
|
2440
|
-
obfuscator
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
return transformed;
|
|
2445
|
-
}
|
|
2446
|
-
: undefined;
|
|
2440
|
+
const transformProviderContext = async (context: Context, transformModel: Model): Promise<Context> => {
|
|
2441
|
+
let transformed = obfuscator ? obfuscateProviderContext(obfuscator, context) : context;
|
|
2442
|
+
if (snapcompactInline) transformed = await snapcompactInline.transform(transformed, transformModel);
|
|
2443
|
+
return clampProviderContextImages(transformed, transformModel);
|
|
2444
|
+
};
|
|
2447
2445
|
const onPayload = async (payload: unknown, _model?: Model) => {
|
|
2448
2446
|
return await extensionRunner.emitBeforeProviderRequest(payload);
|
|
2449
2447
|
};
|