@oh-my-pi/pi-tui 18.4.4 → 18.4.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,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.4.5] - 2026-09-30
6
+
7
+ ### Added
8
+
9
+ - Added Factory Droid base-credit badges; models without a dollar-price reference no longer appear free ([#8577](https://github.com/can1357/oh-my-pi/pull/8577) by [@will-bogusz](https://github.com/will-bogusz), continued in [#13276](https://github.com/can1357/oh-my-pi/pull/13276) by [@DusKing1](https://github.com/DusKing1)).
10
+
11
+ ### Fixed
12
+
13
+ - Fixed home directories next to shell redirections, control operators or Markdown emphasis (`<`, `>`, `&`, `|`, `*`, `_`) leaking the full path in display-only text ([#11210](https://github.com/can1357/oh-my-pi/pull/11210) by [@DarkPhilosophy](https://github.com/DarkPhilosophy)).
14
+ - Fixed the subagent task card showing full home paths in tool intents and arguments, while keeping search patterns literal ([#11210](https://github.com/can1357/oh-my-pi/pull/11210) by [@DarkPhilosophy](https://github.com/DarkPhilosophy)).
15
+ - Fixed Enter doing nothing on a fully typed slash-command argument while its completion popup was open (e.g. `/mcp list`): it now submits instead of re-accepting the identical completion; subcommands that still need a required argument (e.g. `/mcp test`) keep inserting the subcommand so you can type it ([#13885](https://github.com/can1357/oh-my-pi/pull/13885) by [@H4vC](https://github.com/H4vC)).
16
+
5
17
  ## [18.4.4] - 2026-09-29
6
18
 
7
19
  ### Added
@@ -11,6 +23,10 @@
11
23
  - `HookEditorComponent` accepts pasted images when constructed with `acceptImages`; the ask dialog returns them as `customInputImages` / `noteImages` ([#13774](https://github.com/can1357/oh-my-pi/pull/13774) by [@DrFaustus-vic](https://github.com/DrFaustus-vic))
12
24
  - Added `formatFileMatches` and `FileMatchSection` to `tools/grouped-file-output` for rendering per-file grep/ast-grep matches in grouped or flat mode.
13
25
 
26
+ ### Fixed
27
+
28
+ - The model browser shows `varies`, `included`, or `pricing unknown` for models whose catalog declares that state, instead of labeling them `free` ([#11613](https://github.com/can1357/oh-my-pi/pull/11613) by [@will-bogusz](https://github.com/will-bogusz)).
29
+
14
30
  ## [18.4.3] - 2026-09-28
15
31
 
16
32
  ### Added
@@ -46,6 +46,8 @@ export interface ModelHubCallbacks {
46
46
  onFallbackChainChange?: (role: string, chain: string[]) => void;
47
47
  /** Locked provider activation: forward to the /login flow. */
48
48
  onLoginRequest?: (providerId: string) => void;
49
+ /** Save the current role assignments and default thinking level as a named model preset. */
50
+ onSavePreset?: (name: string) => void;
49
51
  /** Persist a new quick-switch cycle order (the ctrl+p role cycle). */
50
52
  onCycleOrderChange?: (order: string[]) => void;
51
53
  onCancel: () => void;
@@ -239,6 +239,11 @@ export declare function truncateDiffByHunk(diffText: string, maxHunks: number, m
239
239
  export declare function shortenPath(filePath: unknown, homeDir?: string): string;
240
240
  /** Shorten embedded home paths; normalize Windows separators unless the caller preserves native error text. */
241
241
  export declare function shortenEmbeddedPaths(text: string, homeDir?: string, preserveSeparators?: boolean): string;
242
+ /**
243
+ * Shorten filesystem and command arguments without rewriting literal search patterns. A producer that
244
+ * names no argument key gets the general embedded-path shortening.
245
+ */
246
+ export declare function shortenToolArgumentPaths(text: string, key: string | undefined, homeDir?: string): string;
242
247
  /** Sanitize warning text before showing it in TUI, including embedded home paths. */
243
248
  export declare function sanitizeDisplayWarning(text: string): string;
244
249
  /** Sanitize and bound warning text before showing it in TUI. */
@@ -136,6 +136,8 @@ export interface EditRenderContext {
136
136
  }
137
137
  /** Render replacement text until a computed diff preview is available. */
138
138
  export declare function renderStreamingFallback(editMode: EditMode, args: unknown, theme: Theme): string;
139
+ /** Extract display targets using the existing parsers for supported freeform edit modes. */
140
+ export declare function getEditInputPaths(input: string, resolvedMode?: EditMode): readonly string[];
139
141
  /** Render edit previews and completed file diffs. */
140
142
  export declare const editToolRenderer: {
141
143
  mergeCallAndResult: true;
@@ -209,10 +209,17 @@ export interface AgentProgress {
209
209
  lastIntent?: string;
210
210
  currentTool?: string;
211
211
  currentToolArgs?: string;
212
+ /** Argument key selected for the display preview, when known. */
213
+ currentToolArgsKey?: string;
214
+ /** Intent the model attached to the current call; undefined when that call carried none. */
215
+ currentToolIntent?: string;
212
216
  currentToolStartMs?: number;
213
217
  recentTools: Array<{
214
218
  tool: string;
215
219
  args: string;
220
+ argsKey?: string;
221
+ intent?: string;
222
+ isError?: boolean;
216
223
  endMs: number;
217
224
  }>;
218
225
  recentOutput: string[];
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.4.4",
4
+ "version": "18.4.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.4.4",
45
- "@oh-my-pi/pi-agent-core": "18.4.4",
46
- "@oh-my-pi/pi-ai": "18.4.4",
47
- "@oh-my-pi/pi-catalog": "18.4.4",
48
- "@oh-my-pi/pi-natives": "18.4.4",
49
- "@oh-my-pi/pi-utils": "18.4.4",
50
- "@oh-my-pi/pi-wire": "18.4.4",
51
- "@oh-my-pi/snapcompact": "18.4.4"
44
+ "@oh-my-pi/omptype": "18.4.5",
45
+ "@oh-my-pi/pi-agent-core": "18.4.5",
46
+ "@oh-my-pi/pi-ai": "18.4.5",
47
+ "@oh-my-pi/pi-catalog": "18.4.5",
48
+ "@oh-my-pi/pi-natives": "18.4.5",
49
+ "@oh-my-pi/pi-utils": "18.4.5",
50
+ "@oh-my-pi/pi-wire": "18.4.5",
51
+ "@oh-my-pi/snapcompact": "18.4.5"
52
52
  },
53
53
  "devDependencies": {
54
54
  "kitty-vt-wasm": "^0.2.0"
@@ -1949,8 +1949,14 @@ export class Editor implements Component, Focusable {
1949
1949
  const currentTextBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1950
1950
  // A narrowed `@` list can be empty while its refresh is pending; Enter
1951
1951
  // then submits instead of waiting on the search.
1952
- if (!selected || !this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor, selected)) {
1953
- // Autocomplete is stale - cancel and fall through to normal submission
1952
+ if (
1953
+ !selected ||
1954
+ !this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor, selected) ||
1955
+ this.#selectedSlashArgumentIsAlreadyTyped(selected)
1956
+ ) {
1957
+ // Stale, or accepting would only append whitespace to an already fully
1958
+ // typed slash-command argument (`/mcp list` + Enter): cancel and fall
1959
+ // through to normal submission instead of swallowing the keypress.
1954
1960
  this.#cancelAutocomplete();
1955
1961
  } else {
1956
1962
  if (selected && this.#autocompleteProvider) {
@@ -4212,6 +4218,25 @@ export class Editor implements Component, Focusable {
4212
4218
  return this.#autocompleteList?.getSelectedItem()?.value === SKILL_NAMESPACE;
4213
4219
  }
4214
4220
 
4221
+ /**
4222
+ * Whether the selected completion for a submitted slash command's argument
4223
+ * only restates what the user already typed (e.g. `list ` for `/mcp list`).
4224
+ * Accepting it would change nothing visible, so Enter should submit.
4225
+ *
4226
+ * A selection whose usage hint still names a required `<arg>` outside any
4227
+ * optional `[...]` group (e.g. `test` with `<name>`) keeps Enter's accept
4228
+ * role, so the user continues into the argument instead of submitting a
4229
+ * command the handler can only reject.
4230
+ */
4231
+ #selectedSlashArgumentIsAlreadyTyped(selected: AutocompleteItem): boolean {
4232
+ if (!this.#isInSubmittedSlashCommandContext()) return false;
4233
+ const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
4234
+ if (this.#state.cursorCol !== currentLine.length) return false;
4235
+ if (selected.hint?.replace(/\[[^\]]*\]/g, "").includes("<")) return false;
4236
+ const typed = this.#autocompletePrefix.trimEnd();
4237
+ return typed.length > 0 && selected.value.trimEnd() === typed;
4238
+ }
4239
+
4215
4240
  #isSlashCommandNameAutocompleteSelection(): boolean {
4216
4241
  if (this.#autocompleteState !== "regular") {
4217
4242
  return false;
@@ -10,8 +10,8 @@
10
10
  import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
11
11
  import type { Model } from "@oh-my-pi/pi-ai";
12
12
  import { buildModel } from "@oh-my-pi/pi-catalog/build";
13
- import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
14
- import type { ModelKind } from "@oh-my-pi/pi-catalog/types";
13
+ import { getModelPricingStatus, modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
14
+ import type { ModelKind, ModelPricingStatus } from "@oh-my-pi/pi-catalog/types";
15
15
  import type { Component } from "../tui";
16
16
  import { fuzzyRank } from "../fuzzy";
17
17
  import { Input } from "../components/input";
@@ -619,10 +619,23 @@ function roleChipSpans(role: string, assignment: RoleAssignment, settings: Model
619
619
  return spans;
620
620
  }
621
621
 
622
- /** Both token legs at zero cost — the condition {@link formatCostPair} renders as `free`. */
622
+ /**
623
+ * The non-rate pricing state a catalog rule declared for a zero-rate row
624
+ * (`pricing-status`), or `undefined` when the row publishes rates or declares
625
+ * nothing. Undeclared zero-rate rows keep the `free` convention below.
626
+ */
627
+ function declaredPricingStatus(model: Model): Exclude<ModelPricingStatus, "fixed"> | undefined {
628
+ if (model.pricingStatus === undefined) return undefined;
629
+ const status = getModelPricingStatus(model);
630
+ return status === "fixed" ? undefined : status;
631
+ }
632
+ /** No token price and no subscription-credit charge (unless a pricing state is declared). */
623
633
  function isFreeModel(model: Model): boolean {
634
+ const declared = declaredPricingStatus(model);
635
+ if (declared !== undefined) return declared === "free";
624
636
  const cost = model.cost;
625
- return !cost || (cost.input === 0 && cost.output === 0);
637
+ const credits = model.factoryDroidCredits;
638
+ return (!cost || (cost.input === 0 && cost.output === 0)) && (credits === undefined || credits === 0);
626
639
  }
627
640
 
628
641
  /** One per-million price leg: `3`, `0.25`, `12.5`; `?` when unknown. */
@@ -635,10 +648,37 @@ function formatCostLeg(n: number): string {
635
648
  return s.includes(".") ? s.replace(/\.?0+$/, "") : s;
636
649
  }
637
650
 
638
- /** `$in/out` per-million cost pair; `free` when both legs are zero. */
651
+ /**
652
+ * Adds Factory Droid's `N×` base Standard Credits rate to a dollar price,
653
+ * replacing the price when the model has no dollar reference. Neither the
654
+ * reference price nor the base credit rate includes live promotions.
655
+ */
656
+ function withCreditBadge(model: Model, price: string): string {
657
+ const credits = model.factoryDroidCredits;
658
+ if (credits === undefined) return price;
659
+ const badge = `${formatCostLeg(credits)}×`;
660
+ return model.cost.input !== 0 || model.cost.output !== 0 ? `${price} ${badge}` : badge;
661
+ }
662
+
663
+ const PRICING_STATUS_LABELS: Record<Exclude<ModelPricingStatus, "fixed">, { short: string; detail: string }> = {
664
+ free: { short: "free", detail: "free" },
665
+ included: { short: "included", detail: "included" },
666
+ variable: { short: "varies", detail: "price varies" },
667
+ unknown: { short: "unknown", detail: "pricing unknown" },
668
+ };
669
+
670
+ /** `$in/out` per-million cost pair with any credit badge; `free` when nothing is charged; a declared pricing state otherwise. */
639
671
  function formatCostPair(model: Model): string {
672
+ const declared = declaredPricingStatus(model);
673
+ if (declared !== undefined) return PRICING_STATUS_LABELS[declared].short;
640
674
  if (isFreeModel(model)) return "free";
641
- return `$${formatCostLeg(model.cost.input)}/${formatCostLeg(model.cost.output)}`;
675
+ return withCreditBadge(model, `$${formatCostLeg(model.cost.input)}/${formatCostLeg(model.cost.output)}`);
676
+ }
677
+
678
+ /** Detail-pane price fact: `$3/15 per M`, or the declared pricing state in words. */
679
+ function formatCostDetail(model: Model): string {
680
+ const declared = declaredPricingStatus(model);
681
+ return declared !== undefined ? PRICING_STATUS_LABELS[declared].detail : `${formatCostPair(model)} per M`;
642
682
  }
643
683
 
644
684
  /** Fact columns of a model picker (Stencil `NATIVE_REDESIGN.md` §4.7); the lowest priority hides first. */
@@ -649,18 +689,22 @@ export const MODEL_PICKER_COLUMNS: readonly TspPickerColumn[] = [
649
689
  { id: "price", head: "$/M", format: "price", priority: 3 },
650
690
  ];
651
691
 
652
- /** `$3·15` price fact of a picker row (`free` at zero cost). */
692
+ /** `$3·15` price fact of a picker row; `free` at zero cost; a declared pricing state otherwise. */
653
693
  function pickerPrice(model: Model): string {
694
+ const declared = declaredPricingStatus(model);
695
+ if (declared !== undefined) return PRICING_STATUS_LABELS[declared].short;
654
696
  if (isFreeModel(model)) return "free";
655
- return `$${formatCostLeg(model.cost.input)}·${formatCostLeg(model.cost.output)}`;
697
+ return withCreditBadge(model, `$${formatCostLeg(model.cost.input)}·${formatCostLeg(model.cost.output)}`);
656
698
  }
657
699
 
658
700
  /** `$2 in · $10 out · $0.2 cache` for a model preview. */
659
701
  function previewPrice(model: Model): string {
702
+ const declared = declaredPricingStatus(model);
703
+ if (declared !== undefined) return PRICING_STATUS_LABELS[declared].detail;
660
704
  const cost = model.cost;
661
705
  const parts = [`$${formatCostLeg(cost.input)} in`, `$${formatCostLeg(cost.output)} out`];
662
706
  if (cost.cacheRead > 0) parts.push(`$${formatCostLeg(cost.cacheRead)} cache`);
663
- return parts.join(" · ");
707
+ return withCreditBadge(model, parts.join(" · "));
664
708
  }
665
709
 
666
710
  /** The omp theme token of a thinking level's dot (`thinkingHigh`); none for inherit and auto. */
@@ -1301,7 +1345,7 @@ export class ModelBrowser implements Component {
1301
1345
  if (model.isRecommended) facts.push("recommended");
1302
1346
  if (model.contextWindow) facts.push(`${formatNumber(model.contextWindow).toLowerCase()} ctx`);
1303
1347
  if (model.maxTokens) facts.push(`${formatNumber(model.maxTokens).toLowerCase()} out`);
1304
- facts.push(`${formatCostPair(model)} per M`);
1348
+ facts.push(formatCostDetail(model));
1305
1349
  if (model.reasoning) facts.push("reasoning");
1306
1350
  if (model.input.includes("image")) facts.push("vision");
1307
1351
  const intelligence = formatIntelligence(model);
@@ -1564,7 +1608,7 @@ export class ModelBrowser implements Component {
1564
1608
  const facts: string[] = [];
1565
1609
  if (model.contextWindow) facts.push(`${formatNumber(model.contextWindow).toLowerCase()} ctx`);
1566
1610
  if (model.maxTokens) facts.push(`${formatNumber(model.maxTokens).toLowerCase()} out`);
1567
- facts.push(`${formatCostPair(model)} per M`);
1611
+ facts.push(formatCostDetail(model));
1568
1612
  if (model.reasoning) facts.push("reasoning");
1569
1613
  if (model.input.includes("image")) facts.push("vision");
1570
1614
  const intelligence = formatIntelligence(model);
@@ -1881,7 +1925,7 @@ export class ModelBrowser implements Component {
1881
1925
  const facts: { k: TspText; v: TspText }[] = [];
1882
1926
  if (ctx > 0) facts.push({ k: "ctx", v: formatNumber(ctx).toLowerCase() });
1883
1927
  if (out > 0) facts.push({ k: "out", v: formatNumber(out).toLowerCase() });
1884
- facts.push({ k: "price", v: isFreeModel(model) ? "free" : `${formatCostPair(model)} per M` });
1928
+ facts.push({ k: "price", v: isFreeModel(model) ? "free" : formatCostDetail(model) });
1885
1929
  facts.push({ k: "reasoning", v: model.reasoning ? "yes" : "no" });
1886
1930
  const children: NativeChild[] = [node("kv", { items: facts, layout: "inline" })];
1887
1931
  const chips: NativeChild[] = [];
@@ -152,6 +152,8 @@ export interface ModelHubCallbacks {
152
152
  onFallbackChainChange?: (role: string, chain: string[]) => void;
153
153
  /** Locked provider activation: forward to the /login flow. */
154
154
  onLoginRequest?: (providerId: string) => void;
155
+ /** Save the current role assignments and default thinking level as a named model preset. */
156
+ onSavePreset?: (name: string) => void;
155
157
  /** Persist a new quick-switch cycle order (the ctrl+p role cycle). */
156
158
  onCycleOrderChange?: (order: string[]) => void;
157
159
  onCancel: () => void;
@@ -204,13 +206,14 @@ type StripState =
204
206
  initialThinkingLevel?: ConfiguredThinkingLevel;
205
207
  })
206
208
  | {
207
- /** Footer text input naming a new custom role. */
208
- kind: "roleName";
209
+ /** Footer text input naming a new role, or saving a model preset. */
210
+ kind: "name";
211
+ purpose: "role" | "preset";
209
212
  input: Input;
210
213
  };
211
214
 
212
215
  /** A Roles-view command; keys and the picker's action bar both run {@link ModelHubComponent}'s `#runRolesAction`. */
213
- type RolesAction = "pick" | "clear" | "fallback" | "cycle" | "earlier" | "later" | "new" | "thinking";
216
+ type RolesAction = "pick" | "clear" | "fallback" | "cycle" | "earlier" | "later" | "new" | "thinking" | "save";
214
217
 
215
218
  /** Printable keys of the Roles view and the command each runs. */
216
219
  const ROLES_ACTION_KEYS: Record<string, RolesAction> = {
@@ -221,6 +224,7 @@ const ROLES_ACTION_KEYS: Record<string, RolesAction> = {
221
224
  "]": "later",
222
225
  n: "new",
223
226
  t: "thinking",
227
+ s: "save",
224
228
  };
225
229
 
226
230
  /** Picker fact columns of the Roles view. */
@@ -1429,7 +1433,7 @@ export class ModelHubComponent implements Component {
1429
1433
 
1430
1434
  #activateStripChip(): void {
1431
1435
  const strip = this.#strip;
1432
- if (!strip || strip.kind === "roleName") return;
1436
+ if (!strip || strip.kind === "name") return;
1433
1437
  const chip = strip.chips[strip.index];
1434
1438
  if (!chip) return;
1435
1439
  switch (chip.action) {
@@ -1654,15 +1658,26 @@ export class ModelHubComponent implements Component {
1654
1658
  this.#refreshAfterMutation();
1655
1659
  }
1656
1660
 
1657
- /** Open the footer name input that creates a new custom role. */
1658
- #openRoleNameStrip(): void {
1659
- this.#strip = { kind: "roleName", input: new Input() };
1661
+ /** Open the footer name input: a new custom role, or saving the current setup as a model preset. */
1662
+ #openNameStrip(purpose: "role" | "preset"): void {
1663
+ this.#strip = { kind: "name", purpose, input: new Input() };
1664
+ }
1665
+
1666
+ /** Validate and commit the name strip: a new role jumps into assigning it, a preset name saves it. */
1667
+ #submitNameStrip(): void {
1668
+ const strip = this.#strip;
1669
+ if (strip?.kind !== "name") return;
1670
+ if (strip.purpose === "preset") {
1671
+ this.#submitPresetName();
1672
+ return;
1673
+ }
1674
+ this.#submitRoleName();
1660
1675
  }
1661
1676
 
1662
1677
  /** Validate and commit the new-role name: jump straight into assigning it. */
1663
1678
  #submitRoleName(): void {
1664
1679
  const strip = this.#strip;
1665
- if (strip?.kind !== "roleName") return;
1680
+ if (strip?.kind !== "name" || strip.purpose !== "role") return;
1666
1681
  const name = strip.input.getValue().trim();
1667
1682
  if (!/^[a-zA-Z][\w-]*$/.test(name)) return;
1668
1683
  if (this.#visibleRoleIds().includes(name)) return;
@@ -1671,6 +1686,18 @@ export class ModelHubComponent implements Component {
1671
1686
  this.#startAssign(name);
1672
1687
  }
1673
1688
 
1689
+ /** Validate and commit the preset name: save the current setup under it (overwrites). */
1690
+ #submitPresetName(): void {
1691
+ const strip = this.#strip;
1692
+ if (strip?.kind !== "name" || strip.purpose !== "preset") return;
1693
+ const name = strip.input.getValue().trim();
1694
+ if (!/^[a-zA-Z][\w-]*$/.test(name)) return;
1695
+ this.#strip = null;
1696
+ this.#frame.chipRanges = [];
1697
+ this.#callbacks.onSavePreset?.(name);
1698
+ this.#refreshAfterMutation();
1699
+ }
1700
+
1674
1701
  // ═══════════════════════════════════════════════════════════════════════
1675
1702
  // Input
1676
1703
  // ═══════════════════════════════════════════════════════════════════════
@@ -1841,9 +1868,9 @@ export class ModelHubComponent implements Component {
1841
1868
  this.#closeStrip();
1842
1869
  return;
1843
1870
  }
1844
- if (strip.kind === "roleName") {
1871
+ if (strip.kind === "name") {
1845
1872
  if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
1846
- this.#submitRoleName();
1873
+ this.#submitNameStrip();
1847
1874
  return;
1848
1875
  }
1849
1876
  strip.input.handleInput(data);
@@ -1895,7 +1922,7 @@ export class ModelHubComponent implements Component {
1895
1922
  this.#startAssignFallbackKey();
1896
1923
  return;
1897
1924
  case "newRole":
1898
- this.#openRoleNameStrip();
1925
+ this.#openNameStrip("role");
1899
1926
  return;
1900
1927
  case "separator":
1901
1928
  return;
@@ -2001,7 +2028,10 @@ export class ModelHubComponent implements Component {
2001
2028
  return;
2002
2029
  }
2003
2030
  case "new":
2004
- this.#openRoleNameStrip();
2031
+ this.#openNameStrip("role");
2032
+ return;
2033
+ case "save":
2034
+ if (this.#callbacks.onSavePreset) this.#openNameStrip("preset");
2005
2035
  return;
2006
2036
  case "thinking":
2007
2037
  if (role) {
@@ -2036,7 +2066,7 @@ export class ModelHubComponent implements Component {
2036
2066
  // Footer strip chips (columns stay in frame coordinates).
2037
2067
  if (footerColumn !== undefined && this.#strip) {
2038
2068
  const strip = this.#strip;
2039
- if (event.leftClick && strip.kind !== "roleName" && this.#frame.selectChipAt(strip, footerColumn)) {
2069
+ if (event.leftClick && strip.kind !== "name" && this.#frame.selectChipAt(strip, footerColumn)) {
2040
2070
  this.#activateStripChip();
2041
2071
  }
2042
2072
  return true;
@@ -2472,7 +2502,10 @@ export class ModelHubComponent implements Component {
2472
2502
  const altLeftRight = formatKeyHints(["alt+left", "alt+right"]);
2473
2503
  const strip = this.#strip;
2474
2504
  if (strip) {
2475
- if (strip.kind === "roleName") {
2505
+ if (strip.kind === "name") {
2506
+ if (strip.purpose === "preset") {
2507
+ return `${enter} save preset · ${cancel} cancel`;
2508
+ }
2476
2509
  return `${enter} create + pick model · ${cancel} cancel`;
2477
2510
  }
2478
2511
  if (strip.kind === "role") return `${leftRight} choose · ${enter} assign/clear · ${cancel} cancel`;
@@ -2517,7 +2550,8 @@ export class ModelHubComponent implements Component {
2517
2550
  // open — an assigned role whose model has thinking levels to offer.
2518
2551
  const editable = row?.kind === "role" && this.#roleThinkingTarget(row.role) !== undefined;
2519
2552
  const thinking = editable ? ` · ${formatKeyHint("t")} thinking` : "";
2520
- return `${upDown} rows · ${enter} pick · ${formatKeyHint("f")} fallback · ${formatKeyHint("x")} clear${thinking} · ${formatKeyHint("c")} cycle · [/] reorder · ${formatKeyHint("n")} new`;
2553
+ const savePreset = this.#callbacks.onSavePreset ? ` · ${formatKeyHint("s")} save preset` : "";
2554
+ return `${upDown} rows · ${enter} pick · ${formatKeyHint("f")} fallback · ${formatKeyHint("x")} clear${thinking} · ${formatKeyHint("c")} cycle · [/] reorder · ${formatKeyHint("n")} new${savePreset}`;
2521
2555
  }
2522
2556
  if (entry.kind === "provider" && entry.locked) {
2523
2557
  return entry.oauth
@@ -2541,9 +2575,11 @@ export class ModelHubComponent implements Component {
2541
2575
  }
2542
2576
 
2543
2577
  #renderStrip(width: number, strip: StripState): string {
2544
- if (strip.kind === "roleName") {
2545
- const label = theme.fg("accent", "New role name:");
2546
- const inputWidth = Math.max(8, Math.min(32, width - visibleWidth("New role name:") - 24));
2578
+ if (strip.kind === "name") {
2579
+ const preset = strip.purpose === "preset";
2580
+ const labelText = preset ? "Preset name:" : "New role name:";
2581
+ const label = theme.fg("accent", labelText);
2582
+ const inputWidth = Math.max(8, Math.min(32, width - visibleWidth(labelText) - 24));
2547
2583
  const inputLine = strip.input.render(inputWidth)[0] ?? "";
2548
2584
  return truncateToWidth(`${label} ${inputLine} ${theme.fg("dim", "(letters, digits, - and _)")}`, width);
2549
2585
  }
@@ -2668,7 +2704,7 @@ export class ModelHubComponent implements Component {
2668
2704
  // A chip click picks and applies it, like the footer mouse path.
2669
2705
  const strip = this.#strip;
2670
2706
  const index = Number(event.item);
2671
- if (!strip || strip.kind === "roleName" || !Number.isInteger(index) || !strip.chips[index]) return;
2707
+ if (!strip || strip.kind === "name" || !Number.isInteger(index) || !strip.chips[index]) return;
2672
2708
  strip.index = index;
2673
2709
  this.#activateStripChip();
2674
2710
  break;
@@ -3184,8 +3220,13 @@ export class ModelHubComponent implements Component {
3184
3220
  if (this.#assignmentPending) return [CLOSE_ACTION];
3185
3221
  if (strip) {
3186
3222
  const apply =
3187
- strip.kind === "roleName"
3188
- ? pickerAction("roleName", "Create role", "enter", { primary: true })
3223
+ strip.kind === "name"
3224
+ ? pickerAction(
3225
+ strip.purpose === "preset" ? "presetName" : "roleName",
3226
+ strip.purpose === "preset" ? "Save preset" : "Create role",
3227
+ "enter",
3228
+ { primary: true },
3229
+ )
3189
3230
  : pickerAction(
3190
3231
  "stripApply",
3191
3232
  strip.kind === "thinking" ? "Apply" : strip.kind === "scope" ? "Save to scope" : "Assign / clear",
@@ -3220,6 +3261,7 @@ export class ModelHubComponent implements Component {
3220
3261
  this.#roleThinkingTarget(row.role) ? roleAction("thinking", "Thinking", "t") : undefined,
3221
3262
  roleAction("cycle", this.#cycleOrder().includes(row.role) ? "Leave cycle" : "Add to cycle", "c"),
3222
3263
  roleAction("new", "New role", "n"),
3264
+ this.#callbacks.onSavePreset ? roleAction("save", "Save preset", "s") : undefined,
3223
3265
  );
3224
3266
  break;
3225
3267
  }
@@ -3266,9 +3308,13 @@ export class ModelHubComponent implements Component {
3266
3308
 
3267
3309
  /** The open strip as the picker's chip strip (role assignment, save scope, thinking level, new role name). */
3268
3310
  #pickerStrip(strip: StripState): NonNullable<TspPickerProps["strip"]> {
3269
- if (strip.kind === "roleName") {
3311
+ if (strip.kind === "name") {
3270
3312
  return {
3271
- label: [span("New role name ", "muted"), span(strip.input.getValue(), "mono"), span("▏", "accent")],
3313
+ label: [
3314
+ span(strip.purpose === "preset" ? "Preset name " : "New role name ", "muted"),
3315
+ span(strip.input.getValue(), "mono"),
3316
+ span("▏", "accent"),
3317
+ ],
3272
3318
  items: [],
3273
3319
  };
3274
3320
  }
@@ -3562,7 +3608,7 @@ export class ModelHubComponent implements Component {
3562
3608
  case "strip": {
3563
3609
  const strip = this.#strip;
3564
3610
  const index = Number(value);
3565
- if (!strip || strip.kind === "roleName" || !Number.isInteger(index) || !strip.chips[index]) return;
3611
+ if (!strip || strip.kind === "name" || !Number.isInteger(index) || !strip.chips[index]) return;
3566
3612
  strip.index = index;
3567
3613
  this.#activateStripChip();
3568
3614
  return;
@@ -3573,6 +3619,9 @@ export class ModelHubComponent implements Component {
3573
3619
  case "roleName":
3574
3620
  this.#submitRoleName();
3575
3621
  return;
3622
+ case "presetName":
3623
+ this.#submitPresetName();
3624
+ return;
3576
3625
  case "cancel":
3577
3626
  if (this.#strip) this.#closeStrip();
3578
3627
  return;
@@ -3622,16 +3671,17 @@ export class ModelHubComponent implements Component {
3622
3671
  #describeStrip(): NativeNode | undefined {
3623
3672
  const strip = this.#strip;
3624
3673
  if (!strip) return undefined;
3625
- if (strip.kind === "roleName") {
3674
+ if (strip.kind === "name") {
3675
+ const preset = strip.purpose === "preset";
3626
3676
  return node(
3627
3677
  "row",
3628
3678
  { gap: "sm", align: "center" },
3629
3679
  [
3630
- text([span("New role name:", "accent")]),
3680
+ text([span(preset ? "Preset name:" : "New role name:", "accent")]),
3631
3681
  col([strip.input], { grow: 1 }),
3632
3682
  text([span("(letters, digits, - and _)", "dim")]),
3633
3683
  ],
3634
- "roleName",
3684
+ preset ? "presetName" : "roleName",
3635
3685
  );
3636
3686
  }
3637
3687
  let prefix: TspSpan[];
@@ -3674,8 +3724,10 @@ export class ModelHubComponent implements Component {
3674
3724
  const strip = this.#strip;
3675
3725
  if (strip) {
3676
3726
  switch (strip.kind) {
3677
- case "roleName":
3678
- return [keys("create + pick model", "enter"), cancel("cancel")];
3727
+ case "name":
3728
+ return strip.purpose === "preset"
3729
+ ? [keys("save preset", "enter"), cancel("cancel")]
3730
+ : [keys("create + pick model", "enter"), cancel("cancel")];
3679
3731
  case "role":
3680
3732
  return [keys("choose", "left", "right"), keys("assign/clear", "enter"), cancel("cancel")];
3681
3733
  case "scope":
@@ -3741,6 +3793,7 @@ export class ModelHubComponent implements Component {
3741
3793
  keys("cycle", "c"),
3742
3794
  reorder,
3743
3795
  keys("new", "n"),
3796
+ this.#callbacks.onSavePreset ? keys("save preset", "s") : undefined,
3744
3797
  ];
3745
3798
  }
3746
3799
  if (entry.kind === "provider" && entry.locked) {
@@ -909,10 +909,16 @@ function homePatternFor(homeDir: string, windowsStyle: boolean): HomePattern {
909
909
  })
910
910
  .join("[\\\\/]");
911
911
  }
912
+ // `*` and `_` are also identifier and glob characters (`/home/me_old`, `src/**/home/me/**`), so they
913
+ // bound the home directory only as Markdown emphasis: opened at start or after whitespace, and
914
+ // closed before end, whitespace, or punctuation.
915
+ const end = `[\\\\/\\s"'\\x60)\\]},;:<>&|]`;
912
916
  pattern = {
913
917
  leading: new RegExp(`^${escapedHome}(?=$|[\\\\/])`, windowsStyle ? "i" : ""),
914
918
  embedded: new RegExp(
915
- `[a-zA-Z][a-zA-Z0-9+.-]*://[^\\s"'<>]+|(^|[\\s"'\\x60([{=,:])(${escapedHome})(?=$|[\\\\/\\s"'\\x60)\\]},;:])`,
919
+ `[a-zA-Z][a-zA-Z0-9+.-]*://[^\\s"'<>]+` +
920
+ `|(^|[\\s"'\\x60([{=,:<>&|])(${escapedHome})(?=$|${end})` +
921
+ `|((?:^|\\s)[*_]{1,3})(${escapedHome})(?=$|${end}|[*_]{1,3}(?=$|[\\s.,;:!?)\\]}"'\\x60]))`,
916
922
  windowsStyle ? "gi" : "g",
917
923
  ),
918
924
  };
@@ -946,8 +952,13 @@ export function shortenEmbeddedPaths(text: string, homeDir?: string, preserveSep
946
952
  const homePattern = homePatternFor(resolvedHome, windowsStyle);
947
953
  const textWithShortenedHome = text.replace(
948
954
  homePattern.embedded,
949
- (match, boundary: string | undefined, candidate: string | undefined) =>
950
- candidate === undefined ? match : `${boundary}~`,
955
+ (
956
+ match,
957
+ boundary: string | undefined,
958
+ candidate: string | undefined,
959
+ emphasis: string | undefined,
960
+ emphasized: string | undefined,
961
+ ) => (candidate !== undefined ? `${boundary}~` : emphasized !== undefined ? `${emphasis}~` : match),
951
962
  );
952
963
  if (preserveSeparators) return textWithShortenedHome;
953
964
  return textWithShortenedHome
@@ -966,6 +977,43 @@ export function shortenEmbeddedPaths(text: string, homeDir?: string, preserveSep
966
977
  .join(" ");
967
978
  }
968
979
 
980
+ /**
981
+ * Shorten filesystem and command arguments without rewriting literal search patterns. A producer that
982
+ * names no argument key gets the general embedded-path shortening.
983
+ */
984
+ export function shortenToolArgumentPaths(text: string, key: string | undefined, homeDir?: string): string {
985
+ if (key === "url") {
986
+ try {
987
+ const url = new URL(text);
988
+ if (url.protocol === "file:") {
989
+ let decodedPath = url.pathname;
990
+ try {
991
+ decodedPath = decodeURIComponent(decodedPath);
992
+ } catch {
993
+ /* Retain malformed percent escapes as literal path bytes. */
994
+ }
995
+ const filePath = url.hostname
996
+ ? `//${url.hostname}${decodedPath}`
997
+ : /^[A-Za-z]:$/.test(decodedPath.slice(1, 3))
998
+ ? decodedPath.slice(1)
999
+ : decodedPath;
1000
+ return shortenEmbeddedPaths(filePath, homeDir);
1001
+ }
1002
+ } catch {
1003
+ // Preserve malformed and non-file URL arguments verbatim.
1004
+ }
1005
+ return text;
1006
+ }
1007
+ return key === undefined ||
1008
+ key === "path" ||
1009
+ key === "file_path" ||
1010
+ key === "command" ||
1011
+ key === "task" ||
1012
+ key === "prompt"
1013
+ ? shortenEmbeddedPaths(text, homeDir)
1014
+ : text;
1015
+ }
1016
+
969
1017
  /** Sanitize warning text before showing it in TUI, including embedded home paths. */
970
1018
  export function sanitizeDisplayWarning(text: string): string {
971
1019
  return shortenEmbeddedPaths(
package/src/tools/edit.ts CHANGED
@@ -693,6 +693,29 @@ function getHashlineInputSections(input: string): HashlineInputEntry[] {
693
693
  return entries;
694
694
  }
695
695
 
696
+ /** Extract display targets using the existing parsers for supported freeform edit modes. */
697
+ export function getEditInputPaths(input: string, resolvedMode?: EditMode): readonly string[] {
698
+ const mode =
699
+ resolvedMode ??
700
+ (/^\*\*\* (?:Add|Update|Delete) File:/m.test(input)
701
+ ? "apply_patch"
702
+ : /^[ \t]*\*{3}[ \t]+Edit[ \t]+File:/im.test(input)
703
+ ? "sloppy"
704
+ : undefined);
705
+ try {
706
+ const entries =
707
+ mode && mode !== "hashline" ? inspectInputEntries({}, mode, input) : getHashlineInputSections(input);
708
+ const paths: string[] = [];
709
+ for (const entry of entries) {
710
+ if (entry.path) paths.push(entry.path);
711
+ if (entry.rename && entry.rename !== entry.path) paths.push(entry.rename);
712
+ }
713
+ return paths;
714
+ } catch {
715
+ return [];
716
+ }
717
+ }
718
+
696
719
  function getHashlineInputRenderSummary(
697
720
  args: EditRenderArgs,
698
721
  editMode: EditMode | undefined,
package/src/tools/task.ts CHANGED
@@ -33,7 +33,9 @@ import {
33
33
  previewLine,
34
34
  previewWindowRows,
35
35
  replaceTabs,
36
+ shortenEmbeddedPaths,
36
37
  shortenPath,
38
+ shortenToolArgumentPaths,
37
39
  type ToolUIStatus,
38
40
  TRUNCATE_LENGTHS,
39
41
  truncateToWidth,
@@ -663,6 +665,11 @@ function renderRouteLine(route: string | undefined, continuePrefix: string, maxW
663
665
  ];
664
666
  }
665
667
 
668
+ /** A tool call's own intent, else its argument, with home paths shortened as the subagent HUD does. */
669
+ function toolCallDetail(intent: string | undefined, args: string | undefined, argsKey: string | undefined): string {
670
+ return intent ? shortenEmbeddedPaths(intent) : shortenToolArgumentPaths(args ?? "", argsKey);
671
+ }
672
+
666
673
  /**
667
674
  * Render streaming progress for a single agent.
668
675
  */
@@ -723,7 +730,11 @@ function renderAgentProgress(
723
730
  if (progress.status === "running") {
724
731
  if (progress.currentTool) {
725
732
  let toolLine = `${continuePrefix}${theme.tree.hook} ${theme.fg("muted", sanitizeText(progress.currentTool))}`;
726
- const toolDetail = progress.lastIntent ?? progress.currentToolArgs;
733
+ const toolDetail = toolCallDetail(
734
+ progress.currentToolIntent,
735
+ progress.currentToolArgs,
736
+ progress.currentToolArgsKey,
737
+ );
727
738
  if (toolDetail) {
728
739
  toolLine += `: ${theme.fg("dim", previewLine(sanitizeText(toolDetail), 40))}`;
729
740
  }
@@ -738,7 +749,7 @@ function renderAgentProgress(
738
749
  // Show most recent completed tool when idle between tools
739
750
  const recent = progress.recentTools[0];
740
751
  let toolLine = `${continuePrefix}${theme.tree.hook} ${theme.fg("dim", sanitizeText(recent.tool))}`;
741
- const toolDetail = progress.lastIntent ?? recent.args;
752
+ const toolDetail = toolCallDetail(recent.intent, recent.args, recent.argsKey);
742
753
  if (toolDetail) {
743
754
  toolLine += `: ${theme.fg("dim", previewLine(sanitizeText(toolDetail), 40))}`;
744
755
  }
@@ -1795,7 +1806,10 @@ function describeProgressAgent(progress: AgentProgress, state: AgentDescribeStat
1795
1806
  running && progress.currentTool
1796
1807
  ? {
1797
1808
  name: plainText(progress.currentTool),
1798
- intent: plainText(progress.lastIntent ?? progress.currentToolArgs ?? "") || undefined,
1809
+ intent:
1810
+ plainText(
1811
+ toolCallDetail(progress.currentToolIntent, progress.currentToolArgs, progress.currentToolArgsKey),
1812
+ ) || undefined,
1799
1813
  age: progress.currentToolStartMs ? Math.max(0, nowMs - progress.currentToolStartMs) : undefined,
1800
1814
  }
1801
1815
  : null;
@@ -2252,8 +2266,19 @@ export interface AgentProgress {
2252
2266
  lastIntent?: string;
2253
2267
  currentTool?: string;
2254
2268
  currentToolArgs?: string;
2269
+ /** Argument key selected for the display preview, when known. */
2270
+ currentToolArgsKey?: string;
2271
+ /** Intent the model attached to the current call; undefined when that call carried none. */
2272
+ currentToolIntent?: string;
2255
2273
  currentToolStartMs?: number;
2256
- recentTools: Array<{ tool: string; args: string; endMs: number }>;
2274
+ recentTools: Array<{
2275
+ tool: string;
2276
+ args: string;
2277
+ argsKey?: string;
2278
+ intent?: string;
2279
+ isError?: boolean;
2280
+ endMs: number;
2281
+ }>;
2257
2282
  recentOutput: string[];
2258
2283
  toolCount: number;
2259
2284
  /** Count of assistant requests (assistant message_end events) across the run. Drives the soft request budget guard. */