@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.
Files changed (65) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/cli.js +2749 -2726
  3. package/dist/types/auto-thinking/classifier.d.ts +4 -2
  4. package/dist/types/config/model-discovery.d.ts +1 -0
  5. package/dist/types/config/model-registry.d.ts +4 -0
  6. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +22 -7
  7. package/dist/types/extensibility/plugins/loader.d.ts +16 -9
  8. package/dist/types/extensibility/tool-event-input.d.ts +2 -0
  9. package/dist/types/hindsight/content.d.ts +2 -10
  10. package/dist/types/modes/components/chat-transcript-builder.d.ts +2 -0
  11. package/dist/types/modes/components/custom-editor.d.ts +3 -0
  12. package/dist/types/modes/components/status-line/types.d.ts +1 -0
  13. package/dist/types/modes/controllers/command-controller.d.ts +2 -0
  14. package/dist/types/modes/controllers/input-controller.d.ts +14 -0
  15. package/dist/types/modes/controllers/selector-controller.d.ts +11 -0
  16. package/dist/types/session/agent-session.d.ts +6 -6
  17. package/dist/types/session/provider-image-budget.d.ts +3 -0
  18. package/dist/types/session/session-context.d.ts +6 -5
  19. package/dist/types/task/parallel.d.ts +4 -0
  20. package/dist/types/thinking.d.ts +8 -1
  21. package/dist/types/tiny/title-client.d.ts +2 -2
  22. package/dist/types/utils/tools-manager.d.ts +2 -0
  23. package/package.json +12 -12
  24. package/src/auto-thinking/classifier.ts +7 -2
  25. package/src/cli/profile-alias.ts +38 -7
  26. package/src/cli/usage-cli.ts +5 -1
  27. package/src/config/model-discovery.ts +59 -8
  28. package/src/config/model-registry.ts +74 -3
  29. package/src/discovery/omp-extension-roots.ts +1 -3
  30. package/src/extensibility/extensions/wrapper.ts +3 -2
  31. package/src/extensibility/hooks/tool-wrapper.ts +4 -3
  32. package/src/extensibility/plugins/legacy-pi-compat.ts +40 -8
  33. package/src/extensibility/plugins/loader.ts +71 -23
  34. package/src/extensibility/plugins/marketplace/manager.ts +134 -0
  35. package/src/extensibility/tool-event-input.ts +57 -0
  36. package/src/hindsight/content.ts +21 -12
  37. package/src/mcp/tool-bridge.ts +27 -2
  38. package/src/mnemopi/state.ts +5 -2
  39. package/src/modes/components/agent-transcript-viewer.ts +193 -41
  40. package/src/modes/components/chat-transcript-builder.ts +6 -0
  41. package/src/modes/components/custom-editor.test.ts +18 -1
  42. package/src/modes/components/custom-editor.ts +77 -45
  43. package/src/modes/components/hook-editor.ts +15 -2
  44. package/src/modes/components/settings-selector.ts +2 -2
  45. package/src/modes/components/status-line/component.ts +52 -8
  46. package/src/modes/components/status-line/segments.ts +5 -1
  47. package/src/modes/components/status-line/types.ts +1 -0
  48. package/src/modes/components/welcome.ts +12 -14
  49. package/src/modes/controllers/command-controller.ts +16 -5
  50. package/src/modes/controllers/input-controller.ts +115 -3
  51. package/src/modes/controllers/selector-controller.ts +19 -1
  52. package/src/modes/interactive-mode.ts +3 -3
  53. package/src/modes/utils/ui-helpers.ts +3 -3
  54. package/src/sdk.ts +8 -10
  55. package/src/session/agent-session.ts +193 -49
  56. package/src/session/provider-image-budget.ts +86 -0
  57. package/src/session/session-context.ts +14 -7
  58. package/src/session/session-storage.ts +24 -2
  59. package/src/session/snapcompact-inline.ts +19 -3
  60. package/src/slash-commands/builtin-registry.ts +0 -22
  61. package/src/slash-commands/helpers/usage-report.ts +9 -1
  62. package/src/task/parallel.ts +6 -1
  63. package/src/thinking.ts +9 -2
  64. package/src/tiny/title-client.ts +75 -21
  65. 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
- /** Prompt-style: raw Enter submits; Editor owns newline-producing sequences. */
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 as string,
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 as string) ?? "",
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
- // Anthropic usage caching (5-min TTL, OAuth/sub only)
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
- this.#cachedUsage = this.#normalizeUsageReports(reports);
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(reports: unknown): {
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
- if (windowId === "5h" && !tier && !fiveHour) {
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
- } else if (windowId === "7d" && !tier && !sevenDay) {
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
- return { fiveHour, sevenDay };
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)}%`);
@@ -84,6 +84,7 @@ export interface SegmentContext {
84
84
  pr: { number: number; url: string } | null;
85
85
  };
86
86
  usage: {
87
+ tier?: string;
87
88
  fiveHour?: { percent: number; resetMinutes?: number };
88
89
  sevenDay?: { percent: number; resetHours?: number };
89
90
  } | null;
@@ -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
- const encoding: ColorEncoding = TERMINAL.trueColor ? "ansi-16m" : "ansi-256";
99
- const purple = Bun.color("#b48cff", encoding) ?? "";
100
- const lightBlue = Bun.color("#9ccfff", encoding) ?? "";
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
- index === 0
108
- ? ` ${italic}${purple}${label}${dim}${lightBlue}${line}${reset}`
109
- : ` ${italic}${continuationIndent}${dim}${lightBlue}${line}${reset}`,
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: a purple "Tip:" label followed by the
397
- * tip body in dimmed light blue, the whole line italicized. Returns `[]`
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(` ${uiTheme.fg("dim", notes.join(" • "))}`.trimEnd());
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 the large-paste local-file
108
- // action. Seeded from 0 and bumped past any existing attachment files in #attachPasteAsFile.
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.ctx.ui.setFocus(this.ctx.editor);
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
- // Full-history transcript: compactions render as inline dividers instead
1445
- // of restarting the visible conversation (the LLM context still resets).
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
- // Display always uses the full-history transcript: compactions show as
521
- // inline dividers instead of restarting the visible conversation.
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
- // Both operate on the transient outgoing Context only never persisted.
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 || snapcompactInline
2441
- ? async (context: Context, transformModel: Model): Promise<Context> => {
2442
- let transformed = obfuscator ? obfuscateProviderContext(obfuscator, context) : context;
2443
- if (snapcompactInline) transformed = await snapcompactInline.transform(transformed, transformModel);
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
  };