@oh-my-pi/pi-tui 16.3.6 → 16.3.8

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,13 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.3.7] - 2026-07-05
6
+
7
+ ### Fixed
8
+
9
+ - Fixed an issue where `@` file-reference tokens in slash-command arguments incorrectly triggered prompt-composer autocompletion when the command did not define argument completions.
10
+ - Fixed a memory leak caused by unbounded map growth in the image budget cache.
11
+
5
12
  ## [16.3.6] - 2026-07-04
6
13
 
7
14
  ### Added
@@ -19,6 +19,8 @@ export interface SlashCommand {
19
19
  aliases?: string[];
20
20
  description?: string;
21
21
  argumentHint?: string;
22
+ /** Whether the command consumes argument text after the command name. False means the full input stays normal prompt text once args are present. */
23
+ allowArgs?: boolean;
22
24
  /** Dynamic display-only description for slash-command autocomplete. Must be synchronous and side-effect free. */
23
25
  getAutocompleteDescription?: () => string | undefined;
24
26
  getArgumentCompletions?(argumentPrefix: string): Awaitable<AutocompleteItem[] | null>;
@@ -1,4 +1,4 @@
1
- import type { Component } from "../tui";
1
+ import type { Component } from "../tui.js";
2
2
  /** Box-drawing glyphs plus an optional colorizer for an outline drawn around a {@link Box}. */
3
3
  export interface BoxBorder {
4
4
  chars: {
@@ -1,4 +1,4 @@
1
- import { Loader } from "./loader";
1
+ import { Loader } from "./loader.js";
2
2
  /**
3
3
  * Loader that can be cancelled with Escape.
4
4
  * Extends Loader with an AbortSignal for cancelling async operations.
@@ -1,7 +1,7 @@
1
- import { type AutocompleteProvider } from "../autocomplete";
2
- import type { SymbolTheme } from "../symbols";
3
- import { type Component, type Focusable } from "../tui";
4
- import { type SelectListTheme } from "./select-list";
1
+ import { type AutocompleteProvider } from "../autocomplete.js";
2
+ import type { SymbolTheme } from "../symbols.js";
3
+ import { type Component, type Focusable } from "../tui.js";
4
+ import { type SelectListTheme } from "./select-list.js";
5
5
  export interface EditorTheme {
6
6
  borderColor: (str: string) => string;
7
7
  selectList: SelectListTheme;
@@ -1,5 +1,5 @@
1
- import { type ImageDimensions } from "../terminal-capabilities";
2
- import type { Component } from "../tui";
1
+ import { type ImageDimensions } from "../terminal-capabilities.js";
2
+ import type { Component } from "../tui.js";
3
3
  export interface ImageTheme {
4
4
  fallbackColor: (str: string) => string;
5
5
  }
@@ -1,4 +1,4 @@
1
- import { type Component, type Focusable } from "../tui";
1
+ import { type Component, type Focusable } from "../tui.js";
2
2
  /**
3
3
  * Input component - single-line text input with horizontal scrolling
4
4
  */
@@ -1,5 +1,5 @@
1
- import type { TUI } from "../tui";
2
- import { Text } from "./text";
1
+ import type { TUI } from "../tui.js";
2
+ import { Text } from "./text.js";
3
3
  type ColorFn = (str: string) => string;
4
4
  export type LoaderMessageColorFn = ColorFn & {
5
5
  readonly animated?: true;
@@ -1,5 +1,5 @@
1
- import type { SymbolTheme } from "../symbols";
2
- import type { Component } from "../tui";
1
+ import type { SymbolTheme } from "../symbols.js";
2
+ import type { Component } from "../tui.js";
3
3
  /** Drop all L2 cache entries. Call on theme change to prevent stale styled output. */
4
4
  export declare function clearRenderCache(): void;
5
5
  /**
@@ -1,5 +1,5 @@
1
- import type { Component } from "../tui";
2
- import { Ellipsis } from "../utils";
1
+ import type { Component } from "../tui.js";
2
+ import { Ellipsis } from "../utils.js";
3
3
  type ScrollbarMode = "auto" | "always" | "never";
4
4
  export interface ScrollViewTheme {
5
5
  track?: (text: string) => string;
@@ -1,6 +1,6 @@
1
- import { type MouseRoutable, type SgrMouseEvent } from "../mouse";
2
- import type { SymbolTheme } from "../symbols";
3
- import type { Component } from "../tui";
1
+ import { type MouseRoutable, type SgrMouseEvent } from "../mouse.js";
2
+ import type { SymbolTheme } from "../symbols.js";
3
+ import type { Component } from "../tui.js";
4
4
  export interface SelectItem {
5
5
  value: string;
6
6
  label: string;
@@ -1,5 +1,5 @@
1
- import type { SgrMouseEvent } from "../mouse";
2
- import type { Component } from "../tui";
1
+ import type { SgrMouseEvent } from "../mouse.js";
2
+ import type { Component } from "../tui.js";
3
3
  export interface SettingItem {
4
4
  /** Unique identifier for this setting */
5
5
  id: string;
@@ -1,4 +1,4 @@
1
- import type { Component } from "../tui";
1
+ import type { Component } from "../tui.js";
2
2
  /**
3
3
  * Spacer component that renders empty lines
4
4
  */
@@ -1,4 +1,4 @@
1
- import type { Component } from "../tui";
1
+ import type { Component } from "../tui.js";
2
2
  /** Tab definition */
3
3
  export interface Tab {
4
4
  /** Unique identifier for the tab */
@@ -1,4 +1,4 @@
1
- import type { Component } from "../tui";
1
+ import type { Component } from "../tui.js";
2
2
  /**
3
3
  * Text component - displays multi-line text with word wrapping
4
4
  */
@@ -1,4 +1,4 @@
1
- import type { Component } from "../tui";
1
+ import type { Component } from "../tui.js";
2
2
  /**
3
3
  * Text component that truncates to fit viewport width
4
4
  */
@@ -1,4 +1,4 @@
1
- import type { TerminalId, TerminalNotification } from "./terminal-capabilities";
1
+ import type { TerminalId, TerminalNotification } from "./terminal-capabilities.js";
2
2
  /** Resolved notifier binary used to fan a notification out to D-Bus. */
3
3
  export type DesktopNotifierKind = "notify-send" | "gdbus";
4
4
  export interface DesktopNotifier {
@@ -1,5 +1,5 @@
1
- import type { AutocompleteProvider } from "./autocomplete";
2
- import type { Component } from "./tui";
1
+ import type { AutocompleteProvider } from "./autocomplete.js";
2
+ import type { Component } from "./tui.js";
3
3
  /**
4
4
  * Interface for custom editor components.
5
5
  *
@@ -1,32 +1,32 @@
1
- export * from "./autocomplete";
2
- export * from "./components/box";
3
- export * from "./components/cancellable-loader";
4
- export * from "./components/editor";
5
- export * from "./components/image";
6
- export * from "./components/input";
7
- export * from "./components/loader";
8
- export * from "./components/markdown";
9
- export * from "./components/scroll-view";
10
- export * from "./components/select-list";
11
- export * from "./components/settings-list";
12
- export * from "./components/spacer";
13
- export * from "./components/tab-bar";
14
- export * from "./components/text";
15
- export * from "./components/truncated-text";
16
- export * from "./deccara";
17
- export * from "./desktop-notify";
18
- export type * from "./editor-component";
19
- export * from "./fuzzy";
20
- export * from "./keybindings";
21
- export * from "./keys";
22
- export * from "./kitty-graphics";
23
- export * from "./latex-block";
24
- export * from "./latex-to-unicode";
25
- export * from "./mouse";
26
- export * from "./stdin-buffer";
27
- export type * from "./symbols";
28
- export * from "./terminal";
29
- export * from "./terminal-capabilities";
30
- export * from "./ttyid";
31
- export * from "./tui";
32
- export * from "./utils";
1
+ export * from "./autocomplete.js";
2
+ export * from "./components/box.js";
3
+ export * from "./components/cancellable-loader.js";
4
+ export * from "./components/editor.js";
5
+ export * from "./components/image.js";
6
+ export * from "./components/input.js";
7
+ export * from "./components/loader.js";
8
+ export * from "./components/markdown.js";
9
+ export * from "./components/scroll-view.js";
10
+ export * from "./components/select-list.js";
11
+ export * from "./components/settings-list.js";
12
+ export * from "./components/spacer.js";
13
+ export * from "./components/tab-bar.js";
14
+ export * from "./components/text.js";
15
+ export * from "./components/truncated-text.js";
16
+ export * from "./deccara.js";
17
+ export * from "./desktop-notify.js";
18
+ export type * from "./editor-component.js";
19
+ export * from "./fuzzy.js";
20
+ export * from "./keybindings.js";
21
+ export * from "./keys.js";
22
+ export * from "./kitty-graphics.js";
23
+ export * from "./latex-block.js";
24
+ export * from "./latex-to-unicode.js";
25
+ export * from "./mouse.js";
26
+ export * from "./stdin-buffer.js";
27
+ export type * from "./symbols.js";
28
+ export * from "./terminal.js";
29
+ export * from "./terminal-capabilities.js";
30
+ export * from "./ttyid.js";
31
+ export * from "./tui.js";
32
+ export * from "./utils.js";
@@ -1,4 +1,4 @@
1
- import { type KeyId } from "./keys";
1
+ import { type KeyId } from "./keys.js";
2
2
  /**
3
3
  * Global keybinding registry.
4
4
  * Downstream packages can add keybindings via declaration merging.
@@ -42,6 +42,12 @@ export declare class TerminalInfo {
42
42
  export declare function isInsideTmux(env?: NodeJS.ProcessEnv): boolean;
43
43
  /** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */
44
44
  export declare function isInsideTerminalMultiplexer(env?: NodeJS.ProcessEnv): boolean;
45
+ /**
46
+ * Whether the agent process is running inside a Zellij session. Read fresh on
47
+ * each call (like {@link isInsideTmux}) so a session attached/detached mid-run
48
+ * is observed and tests can toggle `Bun.env.ZELLIJ` per case.
49
+ */
50
+ export declare function isInsideZellij(env?: NodeJS.ProcessEnv): boolean;
45
51
  /**
46
52
  * Wrap a control-sequence payload in tmux's DCS passthrough envelope. Each
47
53
  * ESC byte inside `payload` is doubled per tmux's escape rules. tmux strips
@@ -1,4 +1,4 @@
1
- import { type HangulCompatibilityJamoWidth } from "./utils";
1
+ import { type HangulCompatibilityJamoWidth } from "./utils.js";
2
2
  export declare function resolveHangulCompatibilityJamoWidthFromTerminalIdentity(env?: NodeJS.ProcessEnv): HangulCompatibilityJamoWidth;
3
3
  /**
4
4
  * Split `data` into chunks whose encoded UTF-8 byte length is no greater than
@@ -1,6 +1,6 @@
1
- import { ImageBudget } from "./components/image";
2
- import { type Terminal } from "./terminal";
3
- import { visibleWidth } from "./utils";
1
+ import { ImageBudget } from "./components/image.js";
2
+ import { type Terminal } from "./terminal.js";
3
+ import { visibleWidth } from "./utils.js";
4
4
  type InputListenerResult = {
5
5
  consume?: boolean;
6
6
  data?: 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": "16.3.6",
4
+ "version": "16.3.8",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -37,8 +37,8 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/pi-natives": "16.3.6",
41
- "@oh-my-pi/pi-utils": "16.3.6",
40
+ "@oh-my-pi/pi-natives": "16.3.8",
41
+ "@oh-my-pi/pi-utils": "16.3.8",
42
42
  "lru-cache": "11.5.1",
43
43
  "marked": "^18.0.5"
44
44
  },
@@ -182,6 +182,8 @@ export interface SlashCommand {
182
182
  aliases?: string[];
183
183
  description?: string;
184
184
  argumentHint?: string;
185
+ /** Whether the command consumes argument text after the command name. False means the full input stays normal prompt text once args are present. */
186
+ allowArgs?: boolean;
185
187
  /** Dynamic display-only description for slash-command autocomplete. Must be synchronous and side-effect free. */
186
188
  getAutocompleteDescription?: () => string | undefined;
187
189
  // Function to get argument completions for this command
@@ -387,44 +389,18 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
387
389
  const currentLine = lines[cursorLine] || "";
388
390
  const textBeforeCursor = currentLine.slice(0, cursorCol);
389
391
 
390
- // Check for @ file reference (fuzzy search) - must be after a delimiter or at start
391
- const atPrefix = this.#extractAtPrefix(textBeforeCursor);
392
- if (atPrefix) {
393
- const { rawPrefix, isQuotedPrefix } = parsePathPrefix(atPrefix);
394
- // Recursive fuzzy walks rooted outside the project (e.g. `@../`,
395
- // `@~/`, `@/abs`) can be huge — a parent dir full of sibling
396
- // projects blows past several seconds of latency. Outside cwd,
397
- // fall back to plain prefix listing of the immediate directory
398
- // (matches Claude Code's behavior). Inside cwd we keep the
399
- // fuzzy-then-prefix flow.
400
- if (rawPrefix.length > 0 && this.#isOutsideCwd(rawPrefix)) {
401
- const items = await this.#getFileSuggestions(atPrefix);
402
- if (items.length === 0) return null;
403
- return { items, prefix: atPrefix };
404
- }
405
- const suggestions =
406
- rawPrefix.length > 0
407
- ? await this.#getFuzzyFileSuggestions(rawPrefix, { isQuotedPrefix })
408
- : await this.#getFileSuggestions("@");
409
- if (suggestions.length === 0 && rawPrefix.length > 0) {
410
- const fallback = await this.#getFileSuggestions(atPrefix);
411
- if (fallback.length === 0) return null;
412
- return { items: fallback, prefix: atPrefix };
413
- }
414
- if (suggestions.length === 0) return null;
415
-
416
- return {
417
- items: suggestions,
418
- prefix: atPrefix,
419
- };
420
- }
421
-
422
392
  const leadingSlashStart = findLeadingSlashCommandStart(textBeforeCursor);
423
393
  const trailingSlashStart = findTrailingSlashCommandStart(textBeforeCursor);
424
394
  const hasPromptTextBeforeTrailingSlash =
425
395
  trailingSlashStart !== null &&
426
396
  hasPromptTextBeforeSlash(lines, cursorLine, textBeforeCursor, trailingSlashStart);
427
- const slashStart = hasPromptTextBeforeTrailingSlash ? trailingSlashStart : leadingSlashStart;
397
+ const hasPromptTextBeforeLeadingSlash =
398
+ leadingSlashStart !== null && hasPromptTextBeforeSlash(lines, cursorLine, textBeforeCursor, leadingSlashStart);
399
+ const slashStart = hasPromptTextBeforeTrailingSlash
400
+ ? trailingSlashStart
401
+ : hasPromptTextBeforeLeadingSlash
402
+ ? null
403
+ : leadingSlashStart;
428
404
  if (slashStart !== null) {
429
405
  const commandText = textBeforeCursor.slice(slashStart);
430
406
  const spaceIndex = commandText.indexOf(" ");
@@ -453,26 +429,62 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
453
429
  // A mid-prompt slash token with no matching skill may still be an
454
430
  // absolute path (`see /tmp`); fall through to file-path completion.
455
431
  } else if (!isMidPromptSkillLookup) {
456
- // Space found - complete command arguments
432
+ // Submitted slash commands own their argument text only when the
433
+ // matched command accepts args. No-arg slash-looking prompts such
434
+ // as `/settings @file` still fall through to prompt-composer
435
+ // completions because submit treats them as normal prompt text.
457
436
  const commandName = commandText.slice(1, spaceIndex); // Command without "/"
458
437
  const argumentText = commandText.slice(spaceIndex + 1); // Text after space
459
438
 
460
439
  const command = this.#commands.find(cmd => commandMatchesNameOrAlias(cmd, commandName));
461
- if (!command || !("getArgumentCompletions" in command) || !command.getArgumentCompletions) {
462
- return null; // No argument completion for this command
463
- }
440
+ if (command && (!("allowArgs" in command) || command.allowArgs !== false)) {
441
+ if (!("getArgumentCompletions" in command) || !command.getArgumentCompletions) {
442
+ return null; // No argument completion for this command
443
+ }
444
+
445
+ const argumentSuggestions = await command.getArgumentCompletions(argumentText);
446
+ if (!Array.isArray(argumentSuggestions) || argumentSuggestions.length === 0) {
447
+ return null;
448
+ }
464
449
 
465
- const argumentSuggestions = await command.getArgumentCompletions(argumentText);
466
- if (!Array.isArray(argumentSuggestions) || argumentSuggestions.length === 0) {
467
- return null;
450
+ return {
451
+ items: argumentSuggestions,
452
+ prefix: argumentText,
453
+ };
468
454
  }
455
+ }
456
+ }
469
457
 
470
- return {
471
- items: argumentSuggestions,
472
- prefix: argumentText,
473
- };
458
+ // Check for @ file reference (fuzzy search) - must be after a delimiter or at start
459
+ const atPrefix = this.#extractAtPrefix(textBeforeCursor);
460
+ if (atPrefix) {
461
+ const { rawPrefix, isQuotedPrefix } = parsePathPrefix(atPrefix);
462
+ // Recursive fuzzy walks rooted outside the project (e.g. `@../`,
463
+ // `@~/`, `@/abs`) can be huge — a parent dir full of sibling
464
+ // projects blows past several seconds of latency. Outside cwd,
465
+ // fall back to plain prefix listing of the immediate directory
466
+ // (matches Claude Code's behavior). Inside cwd we keep the
467
+ // fuzzy-then-prefix flow.
468
+ if (rawPrefix.length > 0 && this.#isOutsideCwd(rawPrefix)) {
469
+ const items = await this.#getFileSuggestions(atPrefix);
470
+ if (items.length === 0) return null;
471
+ return { items, prefix: atPrefix };
474
472
  }
475
- if (!isMidPromptSkillLookup) return null;
473
+ const suggestions =
474
+ rawPrefix.length > 0
475
+ ? await this.#getFuzzyFileSuggestions(rawPrefix, { isQuotedPrefix })
476
+ : await this.#getFileSuggestions("@");
477
+ if (suggestions.length === 0 && rawPrefix.length > 0) {
478
+ const fallback = await this.#getFileSuggestions(atPrefix);
479
+ if (fallback.length === 0) return null;
480
+ return { items: fallback, prefix: atPrefix };
481
+ }
482
+ if (suggestions.length === 0) return null;
483
+
484
+ return {
485
+ items: suggestions,
486
+ prefix: atPrefix,
487
+ };
476
488
  }
477
489
 
478
490
  // Check for file paths - triggered by Tab or if we detect a path pattern
@@ -65,6 +65,7 @@ export class ImageBudget {
65
65
  #requestRender: () => void;
66
66
  #nextId = nextImageIdSeed();
67
67
  #keyToId = new Map<string, number>();
68
+ #idToKey = new Map<number, string>();
68
69
  /** Display-order image ids observed during the in-flight pass. */
69
70
  #passIds: number[] = [];
70
71
  /**
@@ -132,6 +133,7 @@ export class ImageBudget {
132
133
  const id = this.#nextId;
133
134
  this.#nextId = (this.#nextId + 1) & 0xffffff || 1;
134
135
  this.#keyToId.set(key, id);
136
+ this.#idToKey.set(id, key);
135
137
  return id;
136
138
  }
137
139
  const id = this.#nextId;
@@ -163,11 +165,15 @@ export class ImageBudget {
163
165
  */
164
166
  observe(imageId: number): boolean {
165
167
  if (this.#stablePass) {
166
- return this.#cap > 0 && this.#suppressedIds.has(imageId);
168
+ const suppressed = this.#cap > 0 && this.#suppressedIds.has(imageId);
169
+ if (suppressed) this.#forgetKeyForId(imageId);
170
+ return suppressed;
167
171
  }
168
172
  const index = this.#passIds.length;
169
173
  this.#passIds.push(imageId);
170
- return this.#cap > 0 && index < this.#planned;
174
+ const suppressed = this.#cap > 0 && index < this.#planned;
175
+ if (suppressed) this.#forgetKeyForId(imageId);
176
+ return suppressed;
171
177
  }
172
178
 
173
179
  /**
@@ -185,6 +191,7 @@ export class ImageBudget {
185
191
  this.#purgeIds.push(id);
186
192
  // d=I frees the data too, so the image must re-transmit if it returns.
187
193
  this.#transmitted.delete(id);
194
+ this.#forgetKeyForId(id);
188
195
  }
189
196
  this.#onTerminal = this.#planned;
190
197
  this.#applyingReset = false;
@@ -214,6 +221,8 @@ export class ImageBudget {
214
221
  this.#transmitted.clear();
215
222
  this.#purgeIds = [];
216
223
  this.#pendingTransmits = [];
224
+ this.#keyToId.clear();
225
+ this.#idToKey.clear();
217
226
  return ids;
218
227
  }
219
228
 
@@ -274,6 +283,13 @@ export class ImageBudget {
274
283
  this.#pendingTransmits = [];
275
284
  }
276
285
 
286
+ #forgetKeyForId(id: number): void {
287
+ const key = this.#idToKey.get(id);
288
+ if (key === undefined) return;
289
+ this.#idToKey.delete(id);
290
+ if (this.#keyToId.get(key) === id) this.#keyToId.delete(key);
291
+ }
292
+
277
293
  #reconcile(total: number): void {
278
294
  const desired = this.#cap > 0 ? Math.max(0, total - this.#cap) : 0;
279
295
  if (desired === this.#planned) {
@@ -122,6 +122,13 @@ export class TerminalInfo {
122
122
  process.stdout.write(`${wrapTmuxPassthrough(formatted)}\x07`);
123
123
  return;
124
124
  }
125
+ // Zellij drops OSC 9/99 and has no DCS passthrough envelope, but raises its
126
+ // `[!]` bell flag on a bare BEL — the same backgrounded-pane signal tmux
127
+ // users get. So follow the (Zellij-swallowed) OSC with a plain BEL.
128
+ if (this.notifyProtocol !== NotifyProtocol.Bell && isInsideZellij()) {
129
+ process.stdout.write(`${formatted}\x07`);
130
+ return;
131
+ }
125
132
  process.stdout.write(formatted);
126
133
  // VTE-family terminals (Ptyxis, GNOME Terminal, Tilix, …) plus Alacritty
127
134
  // and bare xterm-on-Wayland have no in-band escape that surfaces an
@@ -156,6 +163,15 @@ export function isInsideTerminalMultiplexer(env: NodeJS.ProcessEnv = Bun.env): b
156
163
  return term.startsWith("tmux") || term.startsWith("screen");
157
164
  }
158
165
 
166
+ /**
167
+ * Whether the agent process is running inside a Zellij session. Read fresh on
168
+ * each call (like {@link isInsideTmux}) so a session attached/detached mid-run
169
+ * is observed and tests can toggle `Bun.env.ZELLIJ` per case.
170
+ */
171
+ export function isInsideZellij(env: NodeJS.ProcessEnv = Bun.env): boolean {
172
+ return Boolean(env.ZELLIJ);
173
+ }
174
+
159
175
  /**
160
176
  * Wrap a control-sequence payload in tmux's DCS passthrough envelope. Each
161
177
  * ESC byte inside `payload` is doubled per tmux's escape rules. tmux strips