@oh-my-pi/pi-tui 18.8.4 → 18.8.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 CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.8.5] - 2026-10-08
6
+
7
+ ### Added
8
+
9
+ - Added a **Compacts at** fact to the model hub preview and a Roles-view **Compaction limit** action (`k`) that edits the selected row's model limit through the new `ModelBrowserSource.compactionPointFor` and `ModelHubCallbacks.onCompactionPointChange` hooks ([#14952](https://github.com/can1357/oh-my-pi/pull/14952) by [@H4vC](https://github.com/H4vC))
10
+
11
+ ### Fixed
12
+
13
+ - Fixed `/usage` dashboard cards reordering their limit rows by usage; rows now keep the provider's window order (e.g. 5 hour → weekly → monthly) ([#14953](https://github.com/can1357/oh-my-pi/pull/14953) by [@H4vC](https://github.com/H4vC))
14
+ - Fixed Cmd+A, Cmd+C, Cmd+X and Shift+arrow selection doing nothing in the Tern prompt while Vim mode is in Insert mode ([#14954](https://github.com/can1357/oh-my-pi/pull/14954) by [@H4vC](https://github.com/H4vC))
15
+
5
16
  ## [18.8.4] - 2026-10-08
6
17
 
7
18
  ### Changed
@@ -4,7 +4,7 @@ import type { ReactionTarget } from "./reaction.js";
4
4
  import { type DescribeContext, type NativeNode, type NativeUiEvent } from "../native/node.js";
5
5
  /** How a user bubble styles its prose and chips (see {@link userBubbleColor}). */
6
6
  export interface UserBubbleOptions {
7
- /** Materialized `file://` targets per attached image, indexed by chip number. */
7
+ /** Filesystem paths for attached image chips, indexed by chip number. */
8
8
  imageLinks?: readonly (string | undefined)[];
9
9
  /** The message's attached images in chip order (`#1` first); a native bubble shows them. */
10
10
  images?: readonly ImageContent[];
@@ -37,6 +37,17 @@ export interface ResolvedModelRoleValue {
37
37
  explicitThinkingLevel: boolean;
38
38
  warning?: string;
39
39
  }
40
+ /** Where auto-compaction triggers for one model, as the model hub's preview shows it (edited from the Roles view). */
41
+ export interface ModelCompactionPoint {
42
+ /** Context tokens that trigger auto-compaction; undefined when auto-compaction is off or the window is unknown. */
43
+ tokens: number | undefined;
44
+ /** The configured percentage of the window, when the trigger is percent-based. */
45
+ percent?: number;
46
+ /** What sets it: the matching `compaction.modelThresholds` key, `global`, or `default`. */
47
+ source: string;
48
+ /** The model's own exact entry as editable text (`90k`, `80%`); absent when it has none. */
49
+ draft?: string;
50
+ }
40
51
  /** Host-provided preferences and model-role resolution for the browser. */
41
52
  export interface ModelBrowserSource extends ModelRoleLookup {
42
53
  /**
@@ -58,6 +69,8 @@ export interface ModelBrowserSource extends ModelRoleLookup {
58
69
  getRoleInfo(role: string): ModelBrowserRoleInfo;
59
70
  defaultRoleChain(role: string): string[];
60
71
  resolveRoleValue(value: string | undefined, models: Model[], roleLookup?: ModelRoleLookup): ResolvedModelRoleValue;
72
+ /** Auto-compaction point for `model`; absent hosts show no compaction row in the preview. */
73
+ compactionPointFor?(model: Model): ModelCompactionPoint | undefined;
61
74
  }
62
75
  /** Read-only catalog surface consumed by model browsers. */
63
76
  export interface ModelBrowserRegistry {
@@ -64,6 +64,12 @@ export interface ModelHubCallbacks {
64
64
  onSwitchPreset?: (name: string) => void | Promise<void>;
65
65
  /** Persist a new quick-switch cycle order (the ctrl+p role cycle). */
66
66
  onCycleOrderChange?: (order: string[]) => void;
67
+ /**
68
+ * Persist typed text as `model`'s own compaction point: a token count (`90000`,
69
+ * `90k`, `1M`), a percentage (`80%`), or empty to reset. Returns an error
70
+ * message for input it rejects, which keeps the field open.
71
+ */
72
+ onCompactionPointChange?: (model: Model, input: string) => string | undefined;
67
73
  onCancel: () => void;
68
74
  }
69
75
  export interface ModelHubOptions {
@@ -33,7 +33,7 @@ export interface ProviderCard {
33
33
  /** Number of represented accounts, including unavailable usage lookups. */
34
34
  accounts: number;
35
35
  unavailableAccounts: string[];
36
- /** Window rows sorted most-pressing first. */
36
+ /** Window rows in provider-declared order (e.g. 5h → weekly → monthly); the fullest CARD_MAX_WINDOWS lead. */
37
37
  windows: CardWindowRow[];
38
38
  /** True when every account reports no limits (e.g. enterprise plans). */
39
39
  unlimited: boolean;
@@ -52,8 +52,10 @@ export interface ProviderCard {
52
52
  * Collapse usage reports into one compact card per provider: limits grouped by
53
53
  * quota bucket (label + window), each bucket showing the mean used fraction
54
54
  * across accounts (matching the classic report's aggregate "% free") with the
55
- * most-used account's reset countdown. Cards sort most-pressing first so
56
- * what's burning is on top-left; fully idle providers collapse into a tick.
55
+ * most-used account's reset countdown. Rows keep the provider's declared window
56
+ * order so 5h → week → month reads the same every time; cards sort by their
57
+ * fullest window so what's burning is on top-left; fully idle providers
58
+ * collapse into a tick.
57
59
  */
58
60
  export declare function buildProviderCards(reports: UsageReport[], nowMs: number, unavailableAccounts?: readonly UnavailableUsageAccount[]): ProviderCard[];
59
61
  /** GitHub-style week-per-column heatmap grid derived from daily activity. */
@@ -118,8 +118,8 @@ export declare class CustomEditor extends Editor {
118
118
  * Numbered by a per-draft monotonic counter so a deleted chip never recycles its number
119
119
  * (labels key the atom table). */
120
120
  pendingTexts: TextAttachment[];
121
- /** Host-wired producer of per-image `file://` links (session blob store); drives clickable
122
- * chip tokens for restored drafts (esc-esc, `/tree`, branch). */
121
+ /** Host-wired producer of per-image chip targets (a file on disk, or a session blob copy);
122
+ * drives clickable chip tokens for restored drafts (esc-esc, `/tree`, branch). */
123
123
  draftImageLinkMaterializer?: (images: readonly ImageContent[]) => Promise<(string | undefined)[] | undefined>;
124
124
  /**
125
125
  * The host {@link TUI}, captured when a plugin constructs this editor through
@@ -4,7 +4,8 @@
4
4
  * to the session's `local://` root, and generated video contact-sheet previews.
5
5
  * Symbol metadata stays out of serialized/model-bound image data while traveling
6
6
  * with the draft object, until AgentSession creates the hidden companion message
7
- * that tells the model the path (and link materialization prefers it over a blob copy).
7
+ * that tells the model the path. The model keeps `local://` references across
8
+ * `/move`; chips for internal URLs instead use a stable blob copy.
8
9
  */
9
10
  import type { ImageContent } from "@oh-my-pi/pi-ai";
10
11
  /** How an image attachment's backing file entered the session. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "18.8.4",
4
+ "version": "18.8.5",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": {
@@ -41,14 +41,14 @@
41
41
  "gen:glyphs": "bun scripts/gen-glyphs.ts"
42
42
  },
43
43
  "dependencies": {
44
- "@oh-my-pi/omptype": "18.8.4",
45
- "@oh-my-pi/pi-agent-core": "18.8.4",
46
- "@oh-my-pi/pi-ai": "18.8.4",
47
- "@oh-my-pi/pi-catalog": "18.8.4",
48
- "@oh-my-pi/pi-natives": "18.8.4",
49
- "@oh-my-pi/pi-utils": "18.8.4",
50
- "@oh-my-pi/pi-wire": "18.8.4",
51
- "@oh-my-pi/snapcompact": "18.8.4"
44
+ "@oh-my-pi/omptype": "18.8.5",
45
+ "@oh-my-pi/pi-agent-core": "18.8.5",
46
+ "@oh-my-pi/pi-ai": "18.8.5",
47
+ "@oh-my-pi/pi-catalog": "18.8.5",
48
+ "@oh-my-pi/pi-natives": "18.8.5",
49
+ "@oh-my-pi/pi-utils": "18.8.5",
50
+ "@oh-my-pi/pi-wire": "18.8.5",
51
+ "@oh-my-pi/snapcompact": "18.8.5"
52
52
  },
53
53
  "devDependencies": {
54
54
  "kitty-vt-wasm": "^0.2.0"
@@ -57,7 +57,7 @@ const OSC133_ZONE_CLOSE = OSC133_ZONE_END + OSC133_COMMAND_START + OSC133_COMMAN
57
57
 
58
58
  /** How a user bubble styles its prose and chips (see {@link userBubbleColor}). */
59
59
  export interface UserBubbleOptions {
60
- /** Materialized `file://` targets per attached image, indexed by chip number. */
60
+ /** Filesystem paths for attached image chips, indexed by chip number. */
61
61
  imageLinks?: readonly (string | undefined)[];
62
62
  /** The message's attached images in chip order (`#1` first); a native bubble shows them. */
63
63
  images?: readonly ImageContent[];
@@ -74,9 +74,12 @@ export interface NativeEditorLayout {
74
74
  readonly caret: string;
75
75
  }
76
76
 
77
- /** Vim mode as the TSP `editor.mode` label. */
78
- const VIM_MODE_LABELS: Record<VimMode, string> = {
79
- insert: "INSERT",
77
+ /**
78
+ * Vim mode as the TSP `editor.mode` label. Insert carries none: Insert edits like a plain field,
79
+ * and a host such as Tern stops native editing (selection, ⌘A/⌘C/⌘X) for any labelled mode.
80
+ */
81
+ const VIM_MODE_LABELS: Record<VimMode, string | undefined> = {
82
+ insert: undefined,
80
83
  normal: "NORMAL",
81
84
  visual: "VISUAL",
82
85
  "visual-line": "VISUAL",
@@ -122,6 +122,18 @@ export interface ResolvedModelRoleValue {
122
122
  warning?: string;
123
123
  }
124
124
 
125
+ /** Where auto-compaction triggers for one model, as the model hub's preview shows it (edited from the Roles view). */
126
+ export interface ModelCompactionPoint {
127
+ /** Context tokens that trigger auto-compaction; undefined when auto-compaction is off or the window is unknown. */
128
+ tokens: number | undefined;
129
+ /** The configured percentage of the window, when the trigger is percent-based. */
130
+ percent?: number;
131
+ /** What sets it: the matching `compaction.modelThresholds` key, `global`, or `default`. */
132
+ source: string;
133
+ /** The model's own exact entry as editable text (`90k`, `80%`); absent when it has none. */
134
+ draft?: string;
135
+ }
136
+
125
137
  /** Host-provided preferences and model-role resolution for the browser. */
126
138
  export interface ModelBrowserSource extends ModelRoleLookup {
127
139
  /**
@@ -143,6 +155,8 @@ export interface ModelBrowserSource extends ModelRoleLookup {
143
155
  getRoleInfo(role: string): ModelBrowserRoleInfo;
144
156
  defaultRoleChain(role: string): string[];
145
157
  resolveRoleValue(value: string | undefined, models: Model[], roleLookup?: ModelRoleLookup): ResolvedModelRoleValue;
158
+ /** Auto-compaction point for `model`; absent hosts show no compaction row in the preview. */
159
+ compactionPointFor?(model: Model): ModelCompactionPoint | undefined;
146
160
  }
147
161
 
148
162
  /** Read-only catalog surface consumed by model browsers. */
@@ -2061,7 +2075,7 @@ export class ModelBrowser implements Component {
2061
2075
  pickerPreview(mode: "full" | "compact", current?: string): readonly NativeChild[] {
2062
2076
  const selected = this.getSelected();
2063
2077
  const item = selected && !this.#isDisabled(selected) ? selected : undefined;
2064
- const key = `${mode}\0${current ?? ""}`;
2078
+ const key = `${mode}\0${current ?? ""}\0${this.#settings.revision}`;
2065
2079
  const memo = this.#pickerPreview;
2066
2080
  if (
2067
2081
  memo !== undefined &&
@@ -2155,6 +2169,17 @@ export class ModelBrowser implements Component {
2155
2169
  if (v) facts.push({ k: [span(k, "muted")], v: [span(v, "mono")] });
2156
2170
  };
2157
2171
  fact("Context", ctx > 0 ? ctx.toLocaleString("en-US") : undefined);
2172
+ const compaction = this.#settings.compactionPointFor?.(model);
2173
+ if (compaction) {
2174
+ const value =
2175
+ compaction.tokens === undefined
2176
+ ? "off"
2177
+ : `${compaction.tokens.toLocaleString("en-US")}${compaction.percent !== undefined ? ` · ${compaction.percent}%` : ""}`;
2178
+ facts.push({
2179
+ k: [span("Compacts at", "muted")],
2180
+ v: [span(value, "mono"), span(` · ${compaction.source}`, "dim")],
2181
+ });
2182
+ }
2158
2183
  fact("Max output", out > 0 ? out.toLocaleString("en-US") : undefined);
2159
2184
  fact("Price", isFreeModel(model) ? "free" : `${previewPrice(model)} per M`);
2160
2185
  fact("Speed", speed.length > 0 ? speed.join(" · ") : undefined);
@@ -170,6 +170,12 @@ export interface ModelHubCallbacks {
170
170
  onSwitchPreset?: (name: string) => void | Promise<void>;
171
171
  /** Persist a new quick-switch cycle order (the ctrl+p role cycle). */
172
172
  onCycleOrderChange?: (order: string[]) => void;
173
+ /**
174
+ * Persist typed text as `model`'s own compaction point: a token count (`90000`,
175
+ * `90k`, `1M`), a percentage (`80%`), or empty to reset. Returns an error
176
+ * message for input it rejects, which keeps the field open.
177
+ */
178
+ onCompactionPointChange?: (model: Model, input: string) => string | undefined;
173
179
  onCancel: () => void;
174
180
  }
175
181
 
@@ -224,6 +230,14 @@ type StripState =
224
230
  kind: "name";
225
231
  purpose: "role" | "preset";
226
232
  input: Input;
233
+ }
234
+ | {
235
+ /** Footer text input setting `model`'s compaction point; `error` is the last rejection. */
236
+ kind: "name";
237
+ purpose: "compaction";
238
+ model: Model;
239
+ input: Input;
240
+ error?: string;
227
241
  };
228
242
 
229
243
  /** A Roles-view command; keys and the picker's action bar both run {@link ModelHubComponent}'s `#runRolesAction`. */
@@ -236,6 +250,7 @@ type RolesAction =
236
250
  | "later"
237
251
  | "new"
238
252
  | "thinking"
253
+ | "compaction"
239
254
  | "save"
240
255
  | "nextPreset"
241
256
  | "prevPreset";
@@ -249,6 +264,7 @@ const ROLES_ACTION_KEYS: Record<string, RolesAction> = {
249
264
  "]": "later",
250
265
  n: "new",
251
266
  t: "thinking",
267
+ k: "compaction",
252
268
  s: "save",
253
269
  // Letter twins of ctrl+←/→, which macOS reserves for switching Spaces.
254
270
  p: "nextPreset",
@@ -286,6 +302,8 @@ function providerInitials(providerId: string): string {
286
302
 
287
303
  const PROVIDER_REFRESH_DEBOUNCE_MS = 120;
288
304
  const RECENT_LIMIT = 15;
305
+ /** Accepted compaction point input, shown beside the field. */
306
+ const COMPACTION_INPUT_HINT = "90000 · 90k · 1m · 80% · empty resets";
289
307
  const MODEL_KIND_TABS: ReadonlyArray<"all" | ModelKind> = ["all", ...MODEL_KINDS];
290
308
  const ROLE_TABS = ["all", "chat", "kind"] as const;
291
309
  type RoleTab = (typeof ROLE_TABS)[number];
@@ -436,6 +454,7 @@ export class ModelHubComponent implements Component {
436
454
  row: RolesRow | undefined;
437
455
  roles: RoleAssignments;
438
456
  rows: readonly RolesRow[];
457
+ revision: number;
439
458
  children: readonly NativeChild[];
440
459
  }
441
460
  | undefined;
@@ -1758,10 +1777,39 @@ export class ModelHubComponent implements Component {
1758
1777
  this.#strip = { kind: "name", purpose, input: new Input() };
1759
1778
  }
1760
1779
 
1761
- /** Validate and commit the name strip: a new role jumps into assigning it, a preset name saves it. */
1780
+ /** The model a Roles row resolves to (assigned role or resolvable fallback), whose compaction point `k` edits. */
1781
+ #roleRowModel(): Model | undefined {
1782
+ const row = this.#rolesRows[this.#roleIndex];
1783
+ if (row?.kind === "role") return this.#roles[row.role]?.model;
1784
+ if (row?.kind === "fallback") return this.#resolveFallbackEntry(row.role, row.chainIndex)?.item.model;
1785
+ return undefined;
1786
+ }
1787
+
1788
+ /** Open the footer input for the Roles row's model compaction point, prefilled with its own entry. */
1789
+ #openCompactionStrip(): void {
1790
+ if (!this.#callbacks.onCompactionPointChange) return;
1791
+ const model = this.#roleRowModel();
1792
+ if (!model) return;
1793
+ const input = new Input();
1794
+ input.setValue(this.#settings.compactionPointFor?.(model)?.draft ?? "");
1795
+ this.#strip = { kind: "name", purpose: "compaction", model, input };
1796
+ }
1797
+
1798
+ /** Validate and commit the name strip: a new role, a preset name, or a compaction point. */
1762
1799
  #submitNameStrip(): void {
1763
1800
  const strip = this.#strip;
1764
1801
  if (strip?.kind !== "name") return;
1802
+ if (strip.purpose === "compaction") {
1803
+ const error = this.#callbacks.onCompactionPointChange?.(strip.model, strip.input.getValue());
1804
+ if (error !== undefined) {
1805
+ strip.error = error;
1806
+ return;
1807
+ }
1808
+ this.#strip = null;
1809
+ this.#frame.chipRanges = [];
1810
+ this.#refreshAfterMutation();
1811
+ return;
1812
+ }
1765
1813
  if (strip.purpose === "preset") {
1766
1814
  this.#submitPresetName();
1767
1815
  return;
@@ -2020,6 +2068,7 @@ export class ModelHubComponent implements Component {
2020
2068
  return;
2021
2069
  }
2022
2070
  strip.input.handleInput(data);
2071
+ if (strip.purpose === "compaction") strip.error = undefined;
2023
2072
  return;
2024
2073
  }
2025
2074
  if (moveStripSelection(strip, data)) return;
@@ -2183,6 +2232,9 @@ export class ModelHubComponent implements Component {
2183
2232
  case "prevPreset":
2184
2233
  this.#switchPreset(action === "nextPreset" ? 1 : -1);
2185
2234
  return;
2235
+ case "compaction":
2236
+ this.#openCompactionStrip();
2237
+ return;
2186
2238
  case "thinking":
2187
2239
  if (role) {
2188
2240
  const target = this.#roleThinkingTarget(role);
@@ -2658,6 +2710,7 @@ export class ModelHubComponent implements Component {
2658
2710
  const strip = this.#strip;
2659
2711
  if (strip) {
2660
2712
  if (strip.kind === "name") {
2713
+ if (strip.purpose === "compaction") return `${enter} set compaction point · ${cancel} cancel`;
2661
2714
  if (strip.purpose === "preset") {
2662
2715
  return `${enter} save preset · ${cancel} cancel`;
2663
2716
  }
@@ -2689,13 +2742,17 @@ export class ModelHubComponent implements Component {
2689
2742
  return `${upDown} providers · ${enterRight} roles · ${altLeftRight} tabs${presets} · ${cancel} close`;
2690
2743
  }
2691
2744
  const row = this.#rolesRows[this.#roleIndex];
2745
+ const compaction =
2746
+ this.#callbacks.onCompactionPointChange && this.#roleRowModel()
2747
+ ? ` · ${formatKeyHint("k")} compaction limit`
2748
+ : "";
2692
2749
  if (row?.kind === "fallback") {
2693
2750
  // Advertise `t` only when the entry resolves: wildcards always
2694
2751
  // inherit and unknown models have no ladder to offer, so the
2695
2752
  // action would be inert there.
2696
2753
  const editable = this.#resolveFallbackEntry(row.role, row.chainIndex) !== undefined;
2697
2754
  const thinking = editable ? ` · ${formatKeyHint("t")} thinking` : "";
2698
- return `${upDown} rows · ${enter} replace · ${formatKeyHint("f")} add another · ${formatKeyHint("x")} remove${thinking} · [/] reorder · ${left} providers`;
2755
+ return `${upDown} rows · ${enter} replace · ${formatKeyHint("f")} add another · ${formatKeyHint("x")} remove${thinking}${compaction} · [/] reorder · ${left} providers`;
2699
2756
  }
2700
2757
  if (row?.kind === "chainKey") {
2701
2758
  return `${upDown} rows · ${formatKeyHints(["enter", "f"])} add fallback · ${formatKeyHint("x")} clear chain · ${left} providers`;
@@ -2709,7 +2766,7 @@ export class ModelHubComponent implements Component {
2709
2766
  const thinking = editable ? ` · ${formatKeyHint("t")} thinking` : "";
2710
2767
  const savePreset = this.#callbacks.onSavePreset ? ` · ${formatKeyHint("s")} save preset` : "";
2711
2768
  const switchPreset = this.#presets().names.length > 0 ? ` · ${formatKeyHints(["p", "shift+p"])} preset` : "";
2712
- return `${upDown} rows · ${enter} pick · ${formatKeyHint("f")} fallback · ${formatKeyHint("x")} clear${thinking} · ${formatKeyHint("c")} cycle · [/] reorder · ${formatKeyHint("n")} new${savePreset}${switchPreset}`;
2769
+ return `${upDown} rows · ${enter} pick · ${formatKeyHint("f")} fallback · ${formatKeyHint("x")} clear${thinking}${compaction} · ${formatKeyHint("c")} cycle · [/] reorder · ${formatKeyHint("n")} new${savePreset}${switchPreset}`;
2713
2770
  }
2714
2771
  if (entry.kind === "provider" && entry.locked) {
2715
2772
  return entry.oauth
@@ -2734,12 +2791,22 @@ export class ModelHubComponent implements Component {
2734
2791
 
2735
2792
  #renderStrip(width: number, strip: StripState): string {
2736
2793
  if (strip.kind === "name") {
2737
- const preset = strip.purpose === "preset";
2738
- const labelText = preset ? "Preset name:" : "New role name:";
2794
+ const labelText =
2795
+ strip.purpose === "compaction"
2796
+ ? `Compact ${strip.model.id} at:`
2797
+ : strip.purpose === "preset"
2798
+ ? "Preset name:"
2799
+ : "New role name:";
2739
2800
  const label = theme.fg("accent", labelText);
2740
2801
  const inputWidth = Math.max(8, Math.min(32, width - visibleWidth(labelText) - 24));
2741
2802
  const inputLine = strip.input.render(inputWidth)[0] ?? "";
2742
- return truncateToWidth(`${label} ${inputLine} ${theme.fg("dim", "(letters, digits, - and _)")}`, width);
2803
+ const hint =
2804
+ strip.purpose !== "compaction"
2805
+ ? theme.fg("dim", "(letters, digits, - and _)")
2806
+ : strip.error
2807
+ ? theme.fg("error", strip.error)
2808
+ : theme.fg("dim", COMPACTION_INPUT_HINT);
2809
+ return truncateToWidth(`${label} ${inputLine} ${hint}`, width);
2743
2810
  }
2744
2811
 
2745
2812
  const prefix =
@@ -3390,12 +3457,14 @@ export class ModelHubComponent implements Component {
3390
3457
  if (strip) {
3391
3458
  const apply =
3392
3459
  strip.kind === "name"
3393
- ? pickerAction(
3394
- strip.purpose === "preset" ? "presetName" : "roleName",
3395
- strip.purpose === "preset" ? "Save preset" : "Create role",
3396
- "enter",
3397
- { primary: true },
3398
- )
3460
+ ? strip.purpose === "compaction"
3461
+ ? pickerAction("compactionPoint", "Set compaction point", "enter", { primary: true })
3462
+ : pickerAction(
3463
+ strip.purpose === "preset" ? "presetName" : "roleName",
3464
+ strip.purpose === "preset" ? "Save preset" : "Create role",
3465
+ "enter",
3466
+ { primary: true },
3467
+ )
3399
3468
  : pickerAction(
3400
3469
  "stripApply",
3401
3470
  strip.kind === "thinking" ? "Apply" : strip.kind === "scope" ? "Save to scope" : "Assign / clear",
@@ -3420,6 +3489,10 @@ export class ModelHubComponent implements Component {
3420
3489
  const roleAction = (action: RolesAction, label: string, key: string, primary = false) =>
3421
3490
  pickerAction(`roles:${action}`, label, key, primary ? { primary: true } : undefined);
3422
3491
  const actions: (TspPickerAction | undefined)[] = [];
3492
+ const compaction =
3493
+ this.#callbacks.onCompactionPointChange && this.#roleRowModel()
3494
+ ? roleAction("compaction", "Compaction limit", "k")
3495
+ : undefined;
3423
3496
  switch (row?.kind) {
3424
3497
  case "role": {
3425
3498
  const assigned = this.#roles[row.role];
@@ -3428,6 +3501,7 @@ export class ModelHubComponent implements Component {
3428
3501
  roleAction("fallback", "Add fallback", "f"),
3429
3502
  assigned && !assigned.autoSelected ? roleAction("clear", "Clear", "x") : undefined,
3430
3503
  this.#roleThinkingTarget(row.role) ? roleAction("thinking", "Thinking", "t") : undefined,
3504
+ compaction,
3431
3505
  roleAction("cycle", this.#cycleOrder().includes(row.role) ? "Leave cycle" : "Add to cycle", "c"),
3432
3506
  roleAction("new", "New role", "n"),
3433
3507
  this.#callbacks.onSavePreset ? roleAction("save", "Save preset", "s") : undefined,
@@ -3443,6 +3517,7 @@ export class ModelHubComponent implements Component {
3443
3517
  this.#resolveFallbackEntry(row.role, row.chainIndex)
3444
3518
  ? roleAction("thinking", "Thinking", "t")
3445
3519
  : undefined,
3520
+ compaction,
3446
3521
  roleAction("earlier", "Earlier", "["),
3447
3522
  roleAction("later", "Later", "]"),
3448
3523
  );
@@ -3479,6 +3554,19 @@ export class ModelHubComponent implements Component {
3479
3554
  /** The open strip as the picker's chip strip (role assignment, save scope, thinking level, new role name). */
3480
3555
  #pickerStrip(strip: StripState): NonNullable<TspPickerProps["strip"]> {
3481
3556
  if (strip.kind === "name") {
3557
+ if (strip.purpose === "compaction") {
3558
+ return {
3559
+ label: [
3560
+ span("Compact ", "muted"),
3561
+ span(strip.model.id, "mono"),
3562
+ span(" at ", "muted"),
3563
+ span(strip.input.getValue(), "mono"),
3564
+ span("▏", "accent"),
3565
+ span(` ${strip.error ?? COMPACTION_INPUT_HINT}`, strip.error ? "error" : "dim"),
3566
+ ],
3567
+ items: [],
3568
+ };
3569
+ }
3482
3570
  return {
3483
3571
  label: [
3484
3572
  span(strip.purpose === "preset" ? "Preset name " : "New role name ", "muted"),
@@ -3618,7 +3706,14 @@ export class ModelHubComponent implements Component {
3618
3706
  /** The selected Roles-view row's preview: its model's facts and its fallback chain. */
3619
3707
  #rolePreview(row: RolesRow | undefined): readonly NativeChild[] {
3620
3708
  const memo = this.#pickerRolePreview;
3621
- if (memo !== undefined && memo.row === row && memo.roles === this.#roles && memo.rows === this.#rolesRows) {
3709
+ const revision = this.#settings.revision;
3710
+ if (
3711
+ memo !== undefined &&
3712
+ memo.row === row &&
3713
+ memo.roles === this.#roles &&
3714
+ memo.rows === this.#rolesRows &&
3715
+ memo.revision === revision
3716
+ ) {
3622
3717
  return memo.children;
3623
3718
  }
3624
3719
  const children: NativeChild[] = [];
@@ -3692,7 +3787,7 @@ export class ModelHubComponent implements Component {
3692
3787
  );
3693
3788
  break;
3694
3789
  }
3695
- this.#pickerRolePreview = { row, roles: this.#roles, rows: this.#rolesRows, children };
3790
+ this.#pickerRolePreview = { row, roles: this.#roles, rows: this.#rolesRows, revision, children };
3696
3791
  return children;
3697
3792
  }
3698
3793
 
@@ -3792,6 +3887,9 @@ export class ModelHubComponent implements Component {
3792
3887
  case "presetName":
3793
3888
  this.#submitPresetName();
3794
3889
  return;
3890
+ case "compactionPoint":
3891
+ this.#submitNameStrip();
3892
+ return;
3795
3893
  case "cancel":
3796
3894
  if (this.#strip) this.#closeStrip();
3797
3895
  return;
@@ -3842,6 +3940,18 @@ export class ModelHubComponent implements Component {
3842
3940
  const strip = this.#strip;
3843
3941
  if (!strip) return undefined;
3844
3942
  if (strip.kind === "name") {
3943
+ if (strip.purpose === "compaction") {
3944
+ return node(
3945
+ "row",
3946
+ { gap: "sm", align: "center" },
3947
+ [
3948
+ text([span(`Compact ${strip.model.id} at:`, "accent")]),
3949
+ col([strip.input], { grow: 1 }),
3950
+ text([span(strip.error ?? COMPACTION_INPUT_HINT, strip.error ? "error" : "dim")]),
3951
+ ],
3952
+ "compactionPoint",
3953
+ );
3954
+ }
3845
3955
  const preset = strip.purpose === "preset";
3846
3956
  return node(
3847
3957
  "row",
@@ -3895,9 +4005,11 @@ export class ModelHubComponent implements Component {
3895
4005
  if (strip) {
3896
4006
  switch (strip.kind) {
3897
4007
  case "name":
3898
- return strip.purpose === "preset"
3899
- ? [keys("save preset", "enter"), cancel("cancel")]
3900
- : [keys("create + pick model", "enter"), cancel("cancel")];
4008
+ return strip.purpose === "compaction"
4009
+ ? [keys("set compaction point", "enter"), cancel("cancel")]
4010
+ : strip.purpose === "preset"
4011
+ ? [keys("save preset", "enter"), cancel("cancel")]
4012
+ : [keys("create + pick model", "enter"), cancel("cancel")];
3901
4013
  case "role":
3902
4014
  return [keys("choose", "left", "right"), keys("assign/clear", "enter"), cancel("cancel")];
3903
4015
  case "scope":
@@ -3931,6 +4043,8 @@ export class ModelHubComponent implements Component {
3931
4043
  ];
3932
4044
  }
3933
4045
  const row = this.#rolesRows[this.#roleIndex];
4046
+ const compaction =
4047
+ this.#callbacks.onCompactionPointChange && this.#roleRowModel() ? keys("compaction limit", "k") : undefined;
3934
4048
  if (row?.kind === "fallback") {
3935
4049
  // Advertise `t` only where a strip would open, as the ANSI footer does.
3936
4050
  const editable = this.#resolveFallbackEntry(row.role, row.chainIndex) !== undefined;
@@ -3940,6 +4054,7 @@ export class ModelHubComponent implements Component {
3940
4054
  keys("add another", "f"),
3941
4055
  keys("remove", "x"),
3942
4056
  editable ? keys("thinking", "t") : undefined,
4057
+ compaction,
3943
4058
  reorder,
3944
4059
  keys("providers", "left"),
3945
4060
  ];
@@ -3962,6 +4077,7 @@ export class ModelHubComponent implements Component {
3962
4077
  keys("fallback", "f"),
3963
4078
  keys("clear", "x"),
3964
4079
  editable ? keys("thinking", "t") : undefined,
4080
+ compaction,
3965
4081
  keys("cycle", "c"),
3966
4082
  reorder,
3967
4083
  keys("new", "n"),
@@ -84,7 +84,7 @@ export interface ProviderCard {
84
84
  /** Number of represented accounts, including unavailable usage lookups. */
85
85
  accounts: number;
86
86
  unavailableAccounts: string[];
87
- /** Window rows sorted most-pressing first. */
87
+ /** Window rows in provider-declared order (e.g. 5h → weekly → monthly); the fullest CARD_MAX_WINDOWS lead. */
88
88
  windows: CardWindowRow[];
89
89
  /** True when every account reports no limits (e.g. enterprise plans). */
90
90
  unlimited: boolean;
@@ -146,8 +146,10 @@ function compactWindowTag(window: NonNullable<UsageLimit["window"]>): string {
146
146
  * Collapse usage reports into one compact card per provider: limits grouped by
147
147
  * quota bucket (label + window), each bucket showing the mean used fraction
148
148
  * across accounts (matching the classic report's aggregate "% free") with the
149
- * most-used account's reset countdown. Cards sort most-pressing first so
150
- * what's burning is on top-left; fully idle providers collapse into a tick.
149
+ * most-used account's reset countdown. Rows keep the provider's declared window
150
+ * order so 5h → week → month reads the same every time; cards sort by their
151
+ * fullest window so what's burning is on top-left; fully idle providers
152
+ * collapse into a tick.
151
153
  */
152
154
  export function buildProviderCards(
153
155
  reports: UsageReport[],
@@ -181,7 +183,7 @@ export function buildProviderCards(
181
183
  }
182
184
  }
183
185
 
184
- const windows: CardWindowRow[] = [...buckets.values()].map(bucket => {
186
+ const declared: CardWindowRow[] = [...buckets.values()].map(bucket => {
185
187
  const fractions = bucket.limits
186
188
  .map(limit => resolveUsedFraction(limit))
187
189
  .filter((value): value is number => value !== undefined);
@@ -200,7 +202,13 @@ export function buildProviderCards(
200
202
  usedText: fraction === undefined ? formatAbsoluteOnlyAmount(bucket.limits) : undefined,
201
203
  };
202
204
  });
203
- windows.sort((a, b) => (b.fraction ?? -1) - (a.fraction ?? -1));
205
+ // Cards render at most CARD_MAX_WINDOWS rows; when a provider declares
206
+ // more, keep the fullest ones visible (still in declared order) so an
207
+ // exhausted bucket never hides behind "+N more".
208
+ const visible = new Set(
209
+ [...declared].sort((a, b) => (b.fraction ?? -1) - (a.fraction ?? -1)).slice(0, CARD_MAX_WINDOWS),
210
+ );
211
+ const windows = [...declared.filter(row => visible.has(row)), ...declared.filter(row => !visible.has(row))];
204
212
  // The window tag earns its columns only when sibling rows would otherwise
205
213
  // be indistinguishable (e.g. Antigravity's daily vs weekly "Usage (Google)").
206
214
  for (const window of windows) {
@@ -264,9 +272,12 @@ export function buildProviderCards(
264
272
  });
265
273
  }
266
274
 
275
+ const worstFraction = new Map(
276
+ cards.map(card => [card, card.windows.reduce((max, window) => Math.max(max, window.fraction ?? -1), -1)]),
277
+ );
267
278
  cards.sort((a, b) => {
268
- const aWorst = a.windows[0]?.fraction ?? -1;
269
- const bWorst = b.windows[0]?.fraction ?? -1;
279
+ const aWorst = worstFraction.get(a)!;
280
+ const bWorst = worstFraction.get(b)!;
270
281
  if (aWorst !== bWorst) return bWorst - aWorst;
271
282
  return a.name.localeCompare(b.name);
272
283
  });
@@ -20,7 +20,6 @@ import type { DescribeContext, NativeNode, NativeUiEvent } from "../native/node"
20
20
  import type { ComposerFacts, ComposerFactsSource } from "../status-line/types";
21
21
  import { allowsModelMentions, allowsSkillTokens, SKILL_TOKEN_RE } from "./skill-tokens";
22
22
  import { expandModelMentionTags, MODEL_MENTION_RE, modelMentionToken } from "./model-mention-syntax";
23
- import { imageAttachmentSource } from "./image-source";
24
23
  import { isVideoPath } from "./video";
25
24
  import {
26
25
  attachmentSgr,
@@ -480,8 +479,8 @@ export class CustomEditor extends Editor {
480
479
  chips: ComposerChipDescriptor[];
481
480
  }
482
481
  | undefined;
483
- /** Host-wired producer of per-image `file://` links (session blob store); drives clickable
484
- * chip tokens for restored drafts (esc-esc, `/tree`, branch). */
482
+ /** Host-wired producer of per-image chip targets (a file on disk, or a session blob copy);
483
+ * drives clickable chip tokens for restored drafts (esc-esc, `/tree`, branch). */
485
484
  draftImageLinkMaterializer?: (images: readonly ImageContent[]) => Promise<(string | undefined)[] | undefined>;
486
485
 
487
486
  /**
@@ -791,7 +790,7 @@ export class CustomEditor extends Editor {
791
790
  if (!materialize || images.length === 0) return;
792
791
  const links = await materialize(images);
793
792
  if (!links || this.pendingImages !== images) return;
794
- this.pendingImageLinks = images.map((image, index) => imageAttachmentSource(image)?.path ?? links[index]);
793
+ this.pendingImageLinks = links;
795
794
  this.imageLinks = this.pendingImageLinks;
796
795
  this.#requestShimmerRepaint?.();
797
796
  }
@@ -4,7 +4,8 @@
4
4
  * to the session's `local://` root, and generated video contact-sheet previews.
5
5
  * Symbol metadata stays out of serialized/model-bound image data while traveling
6
6
  * with the draft object, until AgentSession creates the hidden companion message
7
- * that tells the model the path (and link materialization prefers it over a blob copy).
7
+ * that tells the model the path. The model keeps `local://` references across
8
+ * `/move`; chips for internal URLs instead use a stable blob copy.
8
9
  */
9
10
  import type { ImageContent } from "@oh-my-pi/pi-ai";
10
11
  import { isRecord } from "@oh-my-pi/pi-utils";