@oh-my-pi/pi-tui 16.2.2 → 16.2.4

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
+ ## [16.2.3] - 2026-06-28
6
+
7
+ ### Added
8
+
9
+ - Added a desktop notification fallback for Linux terminals using D-Bus (via notify-send or gdbus), enabling completion and prompt notifications in VTE-family terminals (such as GNOME Terminal, Ptyxis, Tilix), Alacritty, and xterm. This is automatically skipped for terminals with native notification support (like VS Code and Warp) and can be disabled using the PI_NO_DESKTOP_NOTIFY=1 environment variable.
10
+
11
+ ### Fixed
12
+
13
+ - Fixed slash skill autocomplete not opening when there is existing prompt text, ensuring mid-prompt slash lookups correctly display and insert skill commands.
14
+ - Fixed modified Enter and keyboard shortcuts in fullscreen overlays for terminals using the xterm modifyOtherKeys fallback (such as iTerm2 when Kitty keyboard negotiation is unavailable).
15
+
5
16
  ## [16.2.0] - 2026-06-27
6
17
 
7
18
  ### Added
@@ -5,6 +5,7 @@
5
5
  * agree on which prefixes count.
6
6
  */
7
7
  export declare function findLeadingSlashCommandStart(text: string): number | null;
8
+ export declare function findTrailingSlashCommandStart(text: string): number | null;
8
9
  export interface AutocompleteItem {
9
10
  value: string;
10
11
  label: string;
@@ -57,6 +58,17 @@ export interface AutocompleteProvider {
57
58
  replaceLen: number;
58
59
  insert: string;
59
60
  } | null;
61
+ /**
62
+ * Force file-path completion (called on Tab). Returns matched items plus the
63
+ * full prefix, or null when no path token sits before the cursor. Present on
64
+ * file-aware providers; absent on slash-only ones.
65
+ */
66
+ getForceFileSuggestions?(lines: string[], cursorLine: number, cursorCol: number): Promise<{
67
+ items: AutocompleteItem[];
68
+ prefix: string;
69
+ } | null>;
70
+ /** Whether a Tab press should attempt file completion at the cursor. */
71
+ shouldTriggerFileCompletion?(lines: string[], cursorLine: number, cursorCol: number): boolean;
60
72
  }
61
73
  type CommandEntry = SlashCommand | AutocompleteItem;
62
74
  export declare class CombinedAutocompleteProvider implements AutocompleteProvider {
@@ -0,0 +1,51 @@
1
+ import type { TerminalId, TerminalNotification } from "./terminal-capabilities";
2
+ /** Resolved notifier binary used to fan a notification out to D-Bus. */
3
+ export type DesktopNotifierKind = "notify-send" | "gdbus";
4
+ export interface DesktopNotifier {
5
+ kind: DesktopNotifierKind;
6
+ path: string;
7
+ }
8
+ /**
9
+ * Whether the current process can reach a freedesktop notification daemon:
10
+ * Linux platform + a session bus address in env. Caller is still responsible
11
+ * for resolving a delivery binary via {@link resolveDesktopNotifier}.
12
+ */
13
+ export declare function hasLinuxDesktopSession(platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv): boolean;
14
+ /**
15
+ * Whether `sendNotification` should also dispatch a D-Bus toast for this
16
+ * terminal. Returns true only when (1) the chosen `notifyProtocol` is BEL,
17
+ * which cannot carry arbitrary toast text, (2) the host exposes a Linux desktop
18
+ * session, and (3) the user has not opted out via `PI_NO_DESKTOP_NOTIFY=1`.
19
+ * Terminals that genuinely speak OSC 9 / OSC 99 pass
20
+ * `notifyProtocolIsBell=false` and are filtered before the D-Bus fallback can
21
+ * run. Pure helper for tests and the singleton path.
22
+ */
23
+ export declare function shouldDeliverDesktopNotification(_terminalId: TerminalId, notifyProtocolIsBell: boolean, platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv): boolean;
24
+ /** Reset the cached notifier resolution. Tests only. */
25
+ export declare function resetDesktopNotifierCache(): void;
26
+ /**
27
+ * Locate a libnotify-compatible delivery binary on `PATH`, preferring
28
+ * `notify-send` (one-shot, no marshalling) and falling back to `gdbus call`
29
+ * for hosts where libnotify is not installed but GLib is. Result is cached so
30
+ * repeated notifications do not hit `$which` again.
31
+ */
32
+ export declare function resolveDesktopNotifier(): DesktopNotifier | null;
33
+ /**
34
+ * Build the argv that delivers `message` through the resolved notifier. Pure
35
+ * helper so tests assert exact wire shape without spawning a child. Notes:
36
+ * - `notify-send` accepts title + body positionally and a numeric expire
37
+ * timeout (`-t`); urgency is a flag.
38
+ * - `gdbus call ... Notify` takes the freedesktop signature
39
+ * `s u s s s as a{sv} i`: app_name, replaces_id, app_icon, summary, body,
40
+ * actions, hints, expire_timeout. We feed hints with the urgency byte so
41
+ * the daemon classifies the toast identically to `notify-send`.
42
+ */
43
+ export declare function buildDesktopNotifyCommand(notifier: DesktopNotifier, message: string | TerminalNotification): string[];
44
+ /**
45
+ * Fire-and-forget D-Bus desktop notification. Resolves a notifier, spawns it
46
+ * with stdio fully detached, and never throws — terminal notifications are
47
+ * best-effort and must not block the renderer or interleave bytes onto
48
+ * stdout. Caller is responsible for the gating check
49
+ * ({@link shouldDeliverDesktopNotification}).
50
+ */
51
+ export declare function sendDesktopNotification(message: string | TerminalNotification): void;
@@ -14,6 +14,7 @@ export * from "./components/tab-bar";
14
14
  export * from "./components/text";
15
15
  export * from "./components/truncated-text";
16
16
  export * from "./deccara";
17
+ export * from "./desktop-notify";
17
18
  export type * from "./editor-component";
18
19
  export * from "./fuzzy";
19
20
  export * from "./keybindings";
@@ -45,6 +45,8 @@ export interface Terminal {
45
45
  get rows(): number;
46
46
  get kittyProtocolActive(): boolean;
47
47
  get kittyEnableSequence(): string | null;
48
+ readonly keyboardEnhancementEnterSequence?: string | null;
49
+ readonly keyboardEnhancementExitSequence?: string | null;
48
50
  moveBy(lines: number): void;
49
51
  hideCursor(): void;
50
52
  showCursor(): void;
@@ -83,6 +85,8 @@ export declare class ProcessTerminal implements Terminal {
83
85
  #private;
84
86
  get kittyProtocolActive(): boolean;
85
87
  get kittyEnableSequence(): string | null;
88
+ get keyboardEnhancementEnterSequence(): string | null;
89
+ get keyboardEnhancementExitSequence(): string | null;
86
90
  get appearance(): TerminalAppearance | undefined;
87
91
  onAppearanceChange(callback: (appearance: TerminalAppearance) => void): void;
88
92
  onPrivateModeReport(callback: (mode: number, supported: boolean) => void): void;
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.2.2",
4
+ "version": "16.2.4",
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.2.2",
41
- "@oh-my-pi/pi-utils": "16.2.2",
40
+ "@oh-my-pi/pi-natives": "16.2.4",
41
+ "@oh-my-pi/pi-utils": "16.2.4",
42
42
  "lru-cache": "11.5.1",
43
43
  "marked": "^18.0.5"
44
44
  },
@@ -68,6 +68,13 @@ export function findLeadingSlashCommandStart(text: string): number | null {
68
68
  return text.length - trimmed.length;
69
69
  }
70
70
 
71
+ export function findTrailingSlashCommandStart(text: string): number | null {
72
+ const match = /(?:^|\s)\/([^\s/]*)$/.exec(text);
73
+ if (!match || match.index === undefined) return null;
74
+ const slashOffset = match[0].indexOf("/");
75
+ return match.index + slashOffset;
76
+ }
77
+
71
78
  function extractQuotedPrefix(text: string): string | null {
72
79
  const quoteStart = findUnclosedQuoteStart(text);
73
80
  if (quoteStart === null) {
@@ -223,6 +230,20 @@ export interface AutocompleteProvider {
223
230
  * buffer untouched.
224
231
  */
225
232
  trySyncInlineReplace?(textBeforeCursor: string): { replaceLen: number; insert: string } | null;
233
+
234
+ /**
235
+ * Force file-path completion (called on Tab). Returns matched items plus the
236
+ * full prefix, or null when no path token sits before the cursor. Present on
237
+ * file-aware providers; absent on slash-only ones.
238
+ */
239
+ getForceFileSuggestions?(
240
+ lines: string[],
241
+ cursorLine: number,
242
+ cursorCol: number,
243
+ ): Promise<{ items: AutocompleteItem[]; prefix: string } | null>;
244
+
245
+ /** Whether a Tab press should attempt file completion at the cursor. */
246
+ shouldTriggerFileCompletion?(lines: string[], cursorLine: number, cursorCol: number): boolean;
226
247
  }
227
248
 
228
249
  type CommandEntry = SlashCommand | AutocompleteItem;
@@ -324,6 +345,25 @@ function buildSlashCommandCompletions(commands: CommandEntry[], lowerPrefix: str
324
345
  .map(({ score: _, ...rest }) => rest);
325
346
  }
326
347
 
348
+ function hasPromptTextBeforeSlash(
349
+ lines: string[],
350
+ cursorLine: number,
351
+ textBeforeCursor: string,
352
+ slashStart: number,
353
+ ): boolean {
354
+ for (let i = 0; i < cursorLine; i += 1) {
355
+ if ((lines[i] || "").trim() !== "") return true;
356
+ }
357
+ return textBeforeCursor.slice(0, slashStart).trim() !== "";
358
+ }
359
+
360
+ function buildMidPromptSkillCompletions(commands: CommandEntry[], lowerPrefix: string): AutocompleteItem[] {
361
+ return buildSlashCommandCompletions(
362
+ commands.filter(cmd => getCommandName(cmd)?.startsWith("skill:")),
363
+ lowerPrefix,
364
+ );
365
+ }
366
+
327
367
  // Combined provider that handles both slash commands and file paths.
328
368
  export class CombinedAutocompleteProvider implements AutocompleteProvider {
329
369
  #commands: CommandEntry[];
@@ -379,29 +419,40 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
379
419
  };
380
420
  }
381
421
 
382
- const slashStart = findLeadingSlashCommandStart(textBeforeCursor);
422
+ const leadingSlashStart = findLeadingSlashCommandStart(textBeforeCursor);
423
+ const trailingSlashStart = findTrailingSlashCommandStart(textBeforeCursor);
424
+ const hasPromptTextBeforeTrailingSlash =
425
+ trailingSlashStart !== null &&
426
+ hasPromptTextBeforeSlash(lines, cursorLine, textBeforeCursor, trailingSlashStart);
427
+ const slashStart = hasPromptTextBeforeTrailingSlash ? trailingSlashStart : leadingSlashStart;
383
428
  if (slashStart !== null) {
384
429
  const commandText = textBeforeCursor.slice(slashStart);
385
430
  const spaceIndex = commandText.indexOf(" ");
431
+ const isMidPromptSkillLookup = hasPromptTextBeforeTrailingSlash;
386
432
 
387
433
  if (spaceIndex === -1) {
388
434
  // No space yet - complete command names
389
435
  const prefix = commandText.slice(1); // Remove the "/"
390
436
  const lowerPrefix = prefix.toLowerCase();
391
437
 
392
- const matches = buildSlashCommandCompletions(this.#commands, lowerPrefix);
393
-
394
- if (matches.length === 0) return null;
395
-
396
- return {
397
- items: matches,
398
- // Preserve the full text-before-cursor (incl. leading
399
- // whitespace) so the editor's Enter-staleness check
400
- // (`autocompletePrefix !== currentTextBeforeCursor`)
401
- // still applies the completion for ` /sk`.
402
- prefix: textBeforeCursor,
403
- };
404
- } else {
438
+ const matches = isMidPromptSkillLookup
439
+ ? buildMidPromptSkillCompletions(this.#commands, lowerPrefix)
440
+ : buildSlashCommandCompletions(this.#commands, lowerPrefix);
441
+
442
+ if (matches.length > 0) {
443
+ return {
444
+ items: matches,
445
+ // Preserve the full text-before-cursor for submitted slash
446
+ // commands so the editor's Enter-staleness check still applies
447
+ // completion for ` /sk`. Mid-prompt skill lookup keeps only
448
+ // the slash token because accepting it replaces the whole draft.
449
+ prefix: isMidPromptSkillLookup ? commandText : textBeforeCursor,
450
+ };
451
+ }
452
+ if (!isMidPromptSkillLookup) return null;
453
+ // A mid-prompt slash token with no matching skill may still be an
454
+ // absolute path (`see /tmp`); fall through to file-path completion.
455
+ } else if (!isMidPromptSkillLookup) {
405
456
  // Space found - complete command arguments
406
457
  const commandName = commandText.slice(1, spaceIndex); // Command without "/"
407
458
  const argumentText = commandText.slice(spaceIndex + 1); // Text after space
@@ -421,6 +472,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
421
472
  prefix: argumentText,
422
473
  };
423
474
  }
475
+ if (!isMidPromptSkillLookup) return null;
424
476
  }
425
477
 
426
478
  // Check for file paths - triggered by Tab or if we detect a path pattern
@@ -462,15 +514,30 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
462
514
  const textBeforeCursor = currentLine.slice(0, cursorCol);
463
515
  const afterCursor = currentLine.slice(cursorCol);
464
516
 
465
- const slashStart = findLeadingSlashCommandStart(textBeforeCursor);
517
+ const leadingSlashStart = findLeadingSlashCommandStart(textBeforeCursor);
518
+ const trailingSlashStart = findTrailingSlashCommandStart(textBeforeCursor);
519
+ const isMidPromptSkillLookup =
520
+ item.value.startsWith("skill:") &&
521
+ trailingSlashStart !== null &&
522
+ hasPromptTextBeforeSlash(lines, cursorLine, textBeforeCursor, trailingSlashStart) &&
523
+ findTrailingSlashCommandStart(prefix) !== null;
524
+
525
+ if (isMidPromptSkillLookup) {
526
+ const newLine = `/${item.value}`;
527
+ return {
528
+ lines: [newLine],
529
+ cursorLine: 0,
530
+ cursorCol: newLine.length,
531
+ };
532
+ }
466
533
 
467
534
  // Slash command suggestions can be accepted before the debounced refresh
468
535
  // catches up to newly typed characters. Replace the live command token,
469
536
  // not only the prefix captured when the suggestion list was rendered.
470
- if (findLeadingSlashCommandStart(prefix) !== null && slashStart !== null) {
471
- const slashPrefix = textBeforeCursor.slice(slashStart);
537
+ if (findLeadingSlashCommandStart(prefix) !== null && leadingSlashStart !== null) {
538
+ const slashPrefix = textBeforeCursor.slice(leadingSlashStart);
472
539
  if (!slashPrefix.includes(" ") && !slashPrefix.slice(1).includes("/")) {
473
- const beforeSlash = currentLine.slice(0, slashStart);
540
+ const beforeSlash = currentLine.slice(0, leadingSlashStart);
474
541
  const newLine = `${beforeSlash}/${item.value} ${afterCursor}`;
475
542
  const newLines = [...lines];
476
543
  newLines[cursorLine] = newLine;
@@ -1,8 +1,8 @@
1
1
  import { getProjectDir, logger } from "@oh-my-pi/pi-utils";
2
2
  import {
3
3
  type AutocompleteProvider,
4
- type CombinedAutocompleteProvider,
5
4
  findLeadingSlashCommandStart,
5
+ findTrailingSlashCommandStart,
6
6
  } from "../autocomplete";
7
7
  import { BracketedPasteHandler, decodeReencodedPasteControls } from "../bracketed-paste";
8
8
  import { getKeybindings, type KeybindingsManager } from "../keybindings";
@@ -1131,7 +1131,7 @@ export class Editor implements Component, Focusable {
1131
1131
  // Check for stale autocomplete state due to debounce
1132
1132
  const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
1133
1133
  const currentTextBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1134
- if (currentTextBeforeCursor !== this.#autocompletePrefix) {
1134
+ if (!this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor)) {
1135
1135
  // Autocomplete is stale - cancel and fall through to normal submission
1136
1136
  this.#cancelAutocomplete();
1137
1137
  } else {
@@ -1755,8 +1755,8 @@ export class Editor implements Component, Focusable {
1755
1755
 
1756
1756
  // Check if we should trigger or update autocomplete
1757
1757
  if (!this.#autocompleteState) {
1758
- // Auto-trigger for "/" at the start of a line (slash commands)
1759
- if (char === "/" && this.#isAtStartOfSubmittedMessage()) {
1758
+ // Auto-trigger for "/" at the start of a submitted command or a mid-prompt skill lookup.
1759
+ if (char === "/" && (this.#isAtStartOfSubmittedMessage() || this.#isInMidPromptSkillSlashContext())) {
1760
1760
  this.#tryTriggerAutocomplete();
1761
1761
  }
1762
1762
  // Auto-trigger for "@" file reference (fuzzy search)
@@ -1777,8 +1777,8 @@ export class Editor implements Component, Focusable {
1777
1777
  else if (/[a-zA-Z0-9.\-_/]/.test(char)) {
1778
1778
  const currentLine = this.#state.lines[this.#state.cursorLine] || "";
1779
1779
  const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1780
- // Check if we're in a slash command (with or without space for arguments)
1781
- if (this.#isInSubmittedSlashCommandContext()) {
1780
+ // Check if we're in a slash command or mid-prompt skill lookup.
1781
+ if (this.#isInSlashAutocompleteContext()) {
1782
1782
  this.#tryTriggerAutocomplete();
1783
1783
  }
1784
1784
  // Check if we're in an @ file reference context
@@ -1901,7 +1901,7 @@ export class Editor implements Component, Focusable {
1901
1901
  }
1902
1902
  const currentLine = this.#state.lines[this.#state.cursorLine] || "";
1903
1903
  const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1904
- if (this.#isInSubmittedSlashCommandContext()) {
1904
+ if (this.#isInSlashAutocompleteContext()) {
1905
1905
  this.#tryTriggerAutocomplete();
1906
1906
  } else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
1907
1907
  this.#tryTriggerAutocomplete();
@@ -2067,8 +2067,8 @@ export class Editor implements Component, Focusable {
2067
2067
  // If autocomplete was cancelled (no matches), re-trigger if we're in a completable context
2068
2068
  const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2069
2069
  const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
2070
- // Slash command context
2071
- if (this.#isInSubmittedSlashCommandContext()) {
2070
+ // Slash command or mid-prompt skill lookup context
2071
+ if (this.#isInSlashAutocompleteContext()) {
2072
2072
  this.#tryTriggerAutocomplete();
2073
2073
  }
2074
2074
  // @ file reference context
@@ -2236,7 +2236,7 @@ export class Editor implements Component, Focusable {
2236
2236
  } else {
2237
2237
  const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2238
2238
  const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
2239
- if (this.#isInSubmittedSlashCommandContext()) {
2239
+ if (this.#isInSlashAutocompleteContext()) {
2240
2240
  this.#tryTriggerAutocomplete();
2241
2241
  } else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
2242
2242
  this.#tryTriggerAutocomplete();
@@ -2568,8 +2568,8 @@ export class Editor implements Component, Focusable {
2568
2568
  } else {
2569
2569
  const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2570
2570
  const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
2571
- // Slash command context
2572
- if (this.#isInSubmittedSlashCommandContext()) {
2571
+ // Slash command or mid-prompt skill lookup context
2572
+ if (this.#isInSlashAutocompleteContext()) {
2573
2573
  this.#tryTriggerAutocomplete();
2574
2574
  }
2575
2575
  // @ file reference context
@@ -2799,6 +2799,27 @@ export class Editor implements Component, Focusable {
2799
2799
  return this.#hasOnlyWhitespaceBeforeCursorLine() && beforeCursor.trimStart().startsWith("/");
2800
2800
  }
2801
2801
 
2802
+ #isInMidPromptSkillSlashContext(): boolean {
2803
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2804
+ const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
2805
+ const slashStart = findTrailingSlashCommandStart(beforeCursor);
2806
+ if (slashStart === null) return false;
2807
+ if (this.#hasOnlyWhitespaceBeforeCursorLine() && findLeadingSlashCommandStart(beforeCursor) !== null)
2808
+ return false;
2809
+ return !this.#hasOnlyWhitespaceBeforeCursorLine() || beforeCursor.slice(0, slashStart).trim() !== "";
2810
+ }
2811
+
2812
+ #isInSlashAutocompleteContext(): boolean {
2813
+ return this.#isInSubmittedSlashCommandContext() || this.#isInMidPromptSkillSlashContext();
2814
+ }
2815
+
2816
+ #autocompletePrefixMatchesCursorText(currentTextBeforeCursor: string): boolean {
2817
+ if (currentTextBeforeCursor === this.#autocompletePrefix) return true;
2818
+ if (findTrailingSlashCommandStart(this.#autocompletePrefix) !== 0) return false;
2819
+ const slashStart = findTrailingSlashCommandStart(currentTextBeforeCursor);
2820
+ return slashStart !== null && currentTextBeforeCursor.slice(slashStart) === this.#autocompletePrefix;
2821
+ }
2822
+
2802
2823
  #isSlashCommandNameAutocompleteSelection(): boolean {
2803
2824
  if (this.#autocompleteState !== "regular") {
2804
2825
  return false;
@@ -2837,10 +2858,13 @@ export class Editor implements Component, Focusable {
2837
2858
  if (!this.#autocompleteProvider) return;
2838
2859
  // Check if we should trigger file completion on Tab
2839
2860
  if (explicitTab) {
2840
- const provider = this.#autocompleteProvider as CombinedAutocompleteProvider;
2841
2861
  const shouldTrigger =
2842
- !provider.shouldTriggerFileCompletion ||
2843
- provider.shouldTriggerFileCompletion(this.#state.lines, this.#state.cursorLine, this.#state.cursorCol);
2862
+ !this.#autocompleteProvider.shouldTriggerFileCompletion ||
2863
+ this.#autocompleteProvider.shouldTriggerFileCompletion(
2864
+ this.#state.lines,
2865
+ this.#state.cursorLine,
2866
+ this.#state.cursorCol,
2867
+ );
2844
2868
  if (!shouldTrigger) {
2845
2869
  return;
2846
2870
  }
@@ -2873,22 +2897,25 @@ export class Editor implements Component, Focusable {
2873
2897
  return new SelectList(items, this.#autocompleteMaxVisible, this.#theme.selectList, layout);
2874
2898
  }
2875
2899
 
2876
- #handleTabCompletion(): void {
2900
+ async #handleTabCompletion(): Promise<void> {
2877
2901
  if (!this.#autocompleteProvider) return;
2878
2902
 
2879
2903
  const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2880
2904
  const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
2881
2905
 
2882
- // Check if we're in a slash command context
2883
2906
  if (this.#isInSubmittedSlashCommandContext() && !beforeCursor.trimStart().includes(" ")) {
2884
- this.#handleSlashCommandCompletion();
2907
+ await this.#handleSlashCommandCompletion();
2908
+ } else if (this.#isInMidPromptSkillSlashContext()) {
2909
+ await this.#handleSlashCommandCompletion();
2910
+ if (!this.#autocompleteState) {
2911
+ await this.#forceFileAutocomplete(true);
2912
+ }
2885
2913
  } else {
2886
- this.#forceFileAutocomplete(true);
2914
+ await this.#forceFileAutocomplete(true);
2887
2915
  }
2888
2916
  }
2889
-
2890
- #handleSlashCommandCompletion(): void {
2891
- this.#tryTriggerAutocomplete(true);
2917
+ async #handleSlashCommandCompletion(): Promise<void> {
2918
+ await this.#tryTriggerAutocomplete();
2892
2919
  }
2893
2920
 
2894
2921
  /*
@@ -2899,17 +2926,16 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
2899
2926
  async #forceFileAutocomplete(explicitTab: boolean = false): Promise<void> {
2900
2927
  if (!this.#autocompleteProvider) return;
2901
2928
 
2902
- // Check if provider supports force file suggestions via runtime check
2903
- const provider = this.#autocompleteProvider as {
2904
- getForceFileSuggestions?: CombinedAutocompleteProvider["getForceFileSuggestions"];
2905
- };
2906
- if (typeof provider.getForceFileSuggestions !== "function") {
2929
+ // File-aware providers expose getForceFileSuggestions; slash-only ones fall back to regular completion.
2930
+ const getForceFileSuggestions = this.#autocompleteProvider.getForceFileSuggestions;
2931
+ if (typeof getForceFileSuggestions !== "function") {
2907
2932
  await this.#tryTriggerAutocomplete(true);
2908
2933
  return;
2909
2934
  }
2910
2935
 
2911
2936
  const requestId = ++this.#autocompleteRequestId;
2912
- const suggestions = await provider.getForceFileSuggestions(
2937
+ const suggestions = await getForceFileSuggestions.call(
2938
+ this.#autocompleteProvider,
2913
2939
  this.#state.lines,
2914
2940
  this.#state.cursorLine,
2915
2941
  this.#state.cursorCol,
@@ -0,0 +1,186 @@
1
+ // Linux desktop notification delivery via D-Bus.
2
+ //
3
+ // Several terminal families — most notably the VTE-based stack (Ptyxis,
4
+ // GNOME Terminal, Tilix, Terminator) but also Alacritty and bare xterm — have
5
+ // `notifyProtocol === Bell`, which means `formatNotification()` emits only a
6
+ // raw BEL. BEL alone never surfaces an arbitrary-text toast on those hosts
7
+ // (see #3685): Ptyxis hooks BEL to a CSS visual-bell flash, GNOME Terminal
8
+ // rings the audible bell. None of OSC 9 (ConEmu progress in VTE), OSC 99
9
+ // (unimplemented), or OSC 777 (only `notify;Command completed` → unused
10
+ // shell-postexec termprop in current VTE) produce a desktop notification.
11
+ //
12
+ // The freedesktop `org.freedesktop.Notifications` D-Bus service is the only
13
+ // path that consistently delivers toasts on those terminals across Wayland
14
+ // and X11. We invoke it out-of-process via `notify-send` (the canonical
15
+ // libnotify CLI present on every modern Linux desktop) and fall back to
16
+ // `gdbus call` when libnotify is absent but GLib is installed.
17
+ //
18
+ // Delivery is fire-and-forget: a failed spawn or missing binary is treated as
19
+ // a silent no-op so terminals that already deliver toasts in-band (Kitty,
20
+ // iTerm2, WezTerm, …) keep working unchanged and the BEL emission still fires
21
+ // for tmux `monitor-bell`, X11 urgency hints, and audible-bell handlers.
22
+
23
+ import { $which } from "@oh-my-pi/pi-utils";
24
+ import type { TerminalId, TerminalNotification } from "./terminal-capabilities";
25
+
26
+ /** Application name surfaced as the notification source. */
27
+ const APP_NAME = "Oh My Pi";
28
+
29
+ /** Resolved notifier binary used to fan a notification out to D-Bus. */
30
+ export type DesktopNotifierKind = "notify-send" | "gdbus";
31
+
32
+ export interface DesktopNotifier {
33
+ kind: DesktopNotifierKind;
34
+ path: string;
35
+ }
36
+
37
+ /**
38
+ * Whether the current process can reach a freedesktop notification daemon:
39
+ * Linux platform + a session bus address in env. Caller is still responsible
40
+ * for resolving a delivery binary via {@link resolveDesktopNotifier}.
41
+ */
42
+ export function hasLinuxDesktopSession(
43
+ platform: NodeJS.Platform = process.platform,
44
+ env: NodeJS.ProcessEnv = Bun.env,
45
+ ): boolean {
46
+ if (platform !== "linux") return false;
47
+ return Boolean(env.DBUS_SESSION_BUS_ADDRESS);
48
+ }
49
+
50
+ /**
51
+ * Whether `sendNotification` should also dispatch a D-Bus toast for this
52
+ * terminal. Returns true only when (1) the chosen `notifyProtocol` is BEL,
53
+ * which cannot carry arbitrary toast text, (2) the host exposes a Linux desktop
54
+ * session, and (3) the user has not opted out via `PI_NO_DESKTOP_NOTIFY=1`.
55
+ * Terminals that genuinely speak OSC 9 / OSC 99 pass
56
+ * `notifyProtocolIsBell=false` and are filtered before the D-Bus fallback can
57
+ * run. Pure helper for tests and the singleton path.
58
+ */
59
+ export function shouldDeliverDesktopNotification(
60
+ _terminalId: TerminalId,
61
+ notifyProtocolIsBell: boolean,
62
+ platform: NodeJS.Platform = process.platform,
63
+ env: NodeJS.ProcessEnv = Bun.env,
64
+ ): boolean {
65
+ if (!notifyProtocolIsBell) return false;
66
+ if (!hasLinuxDesktopSession(platform, env)) return false;
67
+ if (env.PI_NO_DESKTOP_NOTIFY === "1") return false;
68
+ return true;
69
+ }
70
+
71
+ let cachedNotifier: DesktopNotifier | null | undefined;
72
+
73
+ /** Reset the cached notifier resolution. Tests only. */
74
+ export function resetDesktopNotifierCache(): void {
75
+ cachedNotifier = undefined;
76
+ }
77
+
78
+ /**
79
+ * Locate a libnotify-compatible delivery binary on `PATH`, preferring
80
+ * `notify-send` (one-shot, no marshalling) and falling back to `gdbus call`
81
+ * for hosts where libnotify is not installed but GLib is. Result is cached so
82
+ * repeated notifications do not hit `$which` again.
83
+ */
84
+ export function resolveDesktopNotifier(): DesktopNotifier | null {
85
+ if (cachedNotifier !== undefined) return cachedNotifier;
86
+ const notifySend = $which("notify-send");
87
+ if (notifySend) {
88
+ cachedNotifier = { kind: "notify-send", path: notifySend };
89
+ return cachedNotifier;
90
+ }
91
+ const gdbus = $which("gdbus");
92
+ if (gdbus) {
93
+ cachedNotifier = { kind: "gdbus", path: gdbus };
94
+ return cachedNotifier;
95
+ }
96
+ cachedNotifier = null;
97
+ return null;
98
+ }
99
+
100
+ interface ResolvedNotificationFields {
101
+ title: string;
102
+ body: string;
103
+ urgency: "low" | "normal" | "critical";
104
+ }
105
+
106
+ function resolveFields(message: string | TerminalNotification): ResolvedNotificationFields {
107
+ if (typeof message === "string") {
108
+ return { title: APP_NAME, body: message, urgency: "normal" };
109
+ }
110
+ const title = message.title?.trim() || APP_NAME;
111
+ const body = message.body ?? "";
112
+ const urgency = message.urgency === "critical" || message.urgency === "low" ? message.urgency : "normal";
113
+ return { title, body, urgency };
114
+ }
115
+
116
+ const URGENCY_BYTE: Record<ResolvedNotificationFields["urgency"], number> = {
117
+ low: 0,
118
+ normal: 1,
119
+ critical: 2,
120
+ };
121
+
122
+ /**
123
+ * Build the argv that delivers `message` through the resolved notifier. Pure
124
+ * helper so tests assert exact wire shape without spawning a child. Notes:
125
+ * - `notify-send` accepts title + body positionally and a numeric expire
126
+ * timeout (`-t`); urgency is a flag.
127
+ * - `gdbus call ... Notify` takes the freedesktop signature
128
+ * `s u s s s as a{sv} i`: app_name, replaces_id, app_icon, summary, body,
129
+ * actions, hints, expire_timeout. We feed hints with the urgency byte so
130
+ * the daemon classifies the toast identically to `notify-send`.
131
+ */
132
+ export function buildDesktopNotifyCommand(notifier: DesktopNotifier, message: string | TerminalNotification): string[] {
133
+ const { title, body, urgency } = resolveFields(message);
134
+ if (notifier.kind === "notify-send") {
135
+ return [notifier.path, "--app-name", APP_NAME, `--urgency=${urgency}`, "--expire-time=5000", title, body];
136
+ }
137
+ const hints = `{"urgency": <byte ${URGENCY_BYTE[urgency]}>}`;
138
+ return [
139
+ notifier.path,
140
+ "call",
141
+ "--session",
142
+ "--dest",
143
+ "org.freedesktop.Notifications",
144
+ "--object-path",
145
+ "/org/freedesktop/Notifications",
146
+ "--method",
147
+ "org.freedesktop.Notifications.Notify",
148
+ APP_NAME,
149
+ "0",
150
+ "",
151
+ title,
152
+ body,
153
+ "[]",
154
+ hints,
155
+ "5000",
156
+ ];
157
+ }
158
+
159
+ /**
160
+ * Fire-and-forget D-Bus desktop notification. Resolves a notifier, spawns it
161
+ * with stdio fully detached, and never throws — terminal notifications are
162
+ * best-effort and must not block the renderer or interleave bytes onto
163
+ * stdout. Caller is responsible for the gating check
164
+ * ({@link shouldDeliverDesktopNotification}).
165
+ */
166
+ export function sendDesktopNotification(message: string | TerminalNotification): void {
167
+ const notifier = resolveDesktopNotifier();
168
+ if (!notifier) return;
169
+ try {
170
+ // `.unref()` lets the event loop exit while the notifier is still running.
171
+ // Without it, an unresponsive D-Bus activation (slow `notify-send`, hung
172
+ // `gdbus` waiting on a stalled session bus) would keep `omp` alive past
173
+ // the renderer's shutdown — a completion toast must never delay process
174
+ // exit. Ignored stdio alone does not detach the child from the parent's
175
+ // reference count.
176
+ const child = Bun.spawn({
177
+ cmd: buildDesktopNotifyCommand(notifier, message),
178
+ stdin: "ignore",
179
+ stdout: "ignore",
180
+ stderr: "ignore",
181
+ });
182
+ child.unref();
183
+ } catch {
184
+ // Best-effort: a failed spawn is silent.
185
+ }
186
+ }
package/src/index.ts CHANGED
@@ -19,6 +19,8 @@ export * from "./components/text";
19
19
  export * from "./components/truncated-text";
20
20
  // DECCARA rectangular-SGR background-fill optimizer
21
21
  export * from "./deccara";
22
+ // Desktop notifications via D-Bus (Linux freedesktop notifications)
23
+ export * from "./desktop-notify";
22
24
  // Editor component interface (for custom editors)
23
25
  export type * from "./editor-component";
24
26
  // Fuzzy matching
@@ -1,5 +1,6 @@
1
1
  import { encodeSixel } from "@oh-my-pi/pi-natives";
2
2
  import { $env, isBunTestRuntime, isTerminalHeadless } from "@oh-my-pi/pi-utils";
3
+ import { sendDesktopNotification, shouldDeliverDesktopNotification } from "./desktop-notify";
3
4
  import {
4
5
  detectKittyUnicodePlaceholdersSupport,
5
6
  getKittyGraphics,
@@ -122,6 +123,15 @@ export class TerminalInfo {
122
123
  return;
123
124
  }
124
125
  process.stdout.write(formatted);
126
+ // VTE-family terminals (Ptyxis, GNOME Terminal, Tilix, …) plus Alacritty
127
+ // and bare xterm-on-Wayland have no in-band escape that surfaces an
128
+ // arbitrary desktop toast (#3685). When the chosen `notifyProtocol` is
129
+ // BEL on a Linux session bus, also fan the notification out via
130
+ // libnotify so users see the toast and the BEL still fires for tmux
131
+ // `monitor-bell` / X11 urgency hints / audible bell.
132
+ if (this.notifyProtocol === NotifyProtocol.Bell && shouldDeliverDesktopNotification(this.id, true)) {
133
+ sendDesktopNotification(message);
134
+ }
125
135
  }
126
136
  }
127
137
 
@@ -399,7 +409,7 @@ export function resolveWarpImageProtocol(
399
409
  }
400
410
 
401
411
  function getWarpTerminalInfo(platform: NodeJS.Platform, env: NodeJS.ProcessEnv = Bun.env): TerminalInfo {
402
- return new TerminalInfo("warp", resolveWarpImageProtocol(platform, env), true, false, NotifyProtocol.Bell);
412
+ return new TerminalInfo("warp", resolveWarpImageProtocol(platform, env), true, false, NotifyProtocol.Osc9);
403
413
  }
404
414
  const KNOWN_TERMINALS = Object.freeze({
405
415
  // Fallback terminals
@@ -415,8 +425,9 @@ const KNOWN_TERMINALS = Object.freeze({
415
425
  // Warp identifies via TERM_PROGRAM=WarpTerminal and ships the Kitty graphics
416
426
  // protocol on macOS/Linux (direct placement only — no Unicode placeholders, so
417
427
  // detectKittyUnicodePlaceholdersSupport correctly excludes it). It does not
418
- // honor OSC 8 yet (the escape renders as visible text), so hyperlinks stay off.
419
- warp: new TerminalInfo("warp", ImageProtocol.Kitty, true, false, NotifyProtocol.Bell),
428
+ // honor OSC 8 yet (the escape renders as visible text), so hyperlinks stay off,
429
+ // but it does support OSC 9 notifications.
430
+ warp: new TerminalInfo("warp", ImageProtocol.Kitty, true, false, NotifyProtocol.Osc9),
420
431
  });
421
432
 
422
433
  /** Resolve terminal identity from environment markers used by common emulators. */
package/src/terminal.ts CHANGED
@@ -336,6 +336,16 @@ export interface Terminal {
336
336
  // so the TUI re-pushes this after entering the alternate screen.
337
337
  get kittyEnableSequence(): string | null;
338
338
 
339
+ // The active modified-key reporting sequence to reassert on alternate-screen
340
+ // entry, or null when no enhanced keyboard mode is active. Optional so custom
341
+ // Terminals built against older pi-tui versions keep working.
342
+ readonly keyboardEnhancementEnterSequence?: string | null;
343
+
344
+ // The sequence that cleanly disables the active enhanced keyboard mode on
345
+ // alternate-screen exit, or null when no exit handshake is required. Optional
346
+ // so custom Terminals built against older pi-tui versions keep working.
347
+ readonly keyboardEnhancementExitSequence?: string | null;
348
+
339
349
  // Cursor positioning (relative to current position)
340
350
  moveBy(lines: number): void; // Move cursor up (negative) or down (positive) by N lines
341
351
 
@@ -472,6 +482,20 @@ export class ProcessTerminal implements Terminal {
472
482
  return this.#kittyProtocolActive ? this.#kittyEnableSeq : null;
473
483
  }
474
484
 
485
+ get keyboardEnhancementEnterSequence(): string | null {
486
+ if (this.#kittyProtocolActive) return this.#kittyEnableSeq;
487
+ return this.#modifyOtherKeysActive ? "\x1b[>4;2m" : null;
488
+ }
489
+
490
+ get keyboardEnhancementExitSequence(): string | null {
491
+ // kitty is a stack push (per-screen), so the matching pop balances alt-screen
492
+ // entry. xterm modifyOtherKeys is a single global flag with no per-screen
493
+ // stack — emitting `>4;0m` here would clear it on the normal screen too,
494
+ // breaking the composer between overlays. terminal.stop() still disables it
495
+ // globally on graceful exit; the emergency-restore path mirrors that.
496
+ return this.#kittyProtocolActive ? "\x1b[<u" : null;
497
+ }
498
+
475
499
  get appearance(): TerminalAppearance | undefined {
476
500
  return this.#appearance;
477
501
  }
package/src/tui.ts CHANGED
@@ -1745,8 +1745,8 @@ export class TUI extends Container {
1745
1745
  this.terminal.write(this.#leaveResizeAltSequence());
1746
1746
  }
1747
1747
  if (this.#altActive) {
1748
- const kittyPop = this.terminal.kittyEnableSequence ? "\x1b[<u" : "";
1749
- this.terminal.write(`${MOUSE_TRACKING_OFF}${kittyPop}\x1b[?1049l`);
1748
+ const enhancementExit = this.#keyboardEnhancementExit();
1749
+ this.terminal.write(`${MOUSE_TRACKING_OFF}${enhancementExit}\x1b[?1049l`);
1750
1750
  setAltScreenActive(false);
1751
1751
  this.#altActive = false;
1752
1752
  this.#altPreviousLines = [];
@@ -2488,11 +2488,11 @@ export class TUI extends Container {
2488
2488
  // modal there; the normal screen and all accounting stay untouched.
2489
2489
  const wantAlt = this.#wantsAltScreen();
2490
2490
  if (wantAlt && !this.#altActive) {
2491
- // Kitty keyboard flags are per-screen: re-push our level on the freshly
2492
- // entered alternate screen, or Esc/modified keys revert to legacy
2493
- // encoding inside fullscreen overlays (Ghostty/kitty). See kitty
2494
- // keyboard-protocol docs: the mode stack is separate per screen.
2495
- this.terminal.write(`\x1b[?1049h${this.terminal.kittyEnableSequence ?? ""}${MOUSE_TRACKING_ON}`);
2491
+ // Enhanced keyboard modes can be buffer-local: re-push the active
2492
+ // modified-key reporting sequence on the freshly entered alternate
2493
+ // screen, or Esc/modified keys revert to legacy encoding inside
2494
+ // fullscreen overlays (Ghostty/kitty/iTerm2).
2495
+ this.terminal.write(`\x1b[?1049h${this.#keyboardEnhancementEnter()}${MOUSE_TRACKING_ON}`);
2496
2496
  setAltScreenActive(true);
2497
2497
  this.terminal.hideCursor();
2498
2498
  this.#forgetHardwareCursorState();
@@ -2502,8 +2502,8 @@ export class TUI extends Container {
2502
2502
  this.#altEnterWidth = width;
2503
2503
  this.#altEnterHeight = height;
2504
2504
  } else if (!wantAlt && this.#altActive) {
2505
- const kittyPop = this.terminal.kittyEnableSequence ? "\x1b[<u" : "";
2506
- this.terminal.write(`${MOUSE_TRACKING_OFF}${kittyPop}\x1b[?1049l`);
2505
+ const enhancementExit = this.#keyboardEnhancementExit();
2506
+ this.terminal.write(`${MOUSE_TRACKING_OFF}${enhancementExit}\x1b[?1049l`);
2507
2507
  setAltScreenActive(false);
2508
2508
  this.#forgetHardwareCursorState();
2509
2509
  this.#altActive = false;
@@ -3339,23 +3339,42 @@ export class TUI extends Container {
3339
3339
  return { window: this.#prepareLinesArray(window, width), contentRows: count };
3340
3340
  }
3341
3341
 
3342
- /** Enter or leave the alternate screen borrowed for transient resize frames. */
3342
+ /**
3343
+ * Resolve the active keyboard-enhancement enter sequence. Falls back to the
3344
+ * legacy `kittyEnableSequence` when a custom Terminal predates the
3345
+ * `keyboardEnhancementEnterSequence` property.
3346
+ */
3347
+ #keyboardEnhancementEnter(): string {
3348
+ return this.terminal.keyboardEnhancementEnterSequence ?? this.terminal.kittyEnableSequence ?? "";
3349
+ }
3350
+
3351
+ /**
3352
+ * Resolve the active keyboard-enhancement exit sequence. Falls back to popping
3353
+ * kitty whenever a custom Terminal exposes its push sequence but predates the
3354
+ * `keyboardEnhancementExitSequence` property.
3355
+ */
3356
+ #keyboardEnhancementExit(): string {
3357
+ const exit = this.terminal.keyboardEnhancementExitSequence;
3358
+ if (exit !== undefined) return exit ?? "";
3359
+ return this.terminal.kittyEnableSequence ? "\x1b[<u" : "";
3360
+ }
3361
+
3343
3362
  #enterResizeAltSequence(): string {
3344
3363
  if (this.#resizeAltActive || this.#altActive) return "";
3345
3364
  this.#resizeAltActive = true;
3346
3365
  setAltScreenActive(true);
3347
3366
  this.#forgetHardwareCursorState();
3348
3367
  this.#recordHardwareCursorHidden();
3349
- return `${ALT_SCREEN_ENTER}${this.terminal.kittyEnableSequence ?? ""}`;
3368
+ return `${ALT_SCREEN_ENTER}${this.#keyboardEnhancementEnter()}`;
3350
3369
  }
3351
3370
 
3352
3371
  #leaveResizeAltSequence(): string {
3353
3372
  if (!this.#resizeAltActive) return "";
3354
- const kittyPop = this.terminal.kittyEnableSequence ? "\x1b[<u" : "";
3373
+ const enhancementExit = this.#keyboardEnhancementExit();
3355
3374
  this.#resizeAltActive = false;
3356
3375
  setAltScreenActive(false);
3357
3376
  this.#forgetHardwareCursorState();
3358
- return `${kittyPop}${ALT_SCREEN_EXIT}`;
3377
+ return `${enhancementExit}${ALT_SCREEN_EXIT}`;
3359
3378
  }
3360
3379
 
3361
3380
  /**