@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 +11 -0
- package/dist/types/autocomplete.d.ts +12 -0
- package/dist/types/desktop-notify.d.ts +51 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/terminal.d.ts +4 -0
- package/package.json +3 -3
- package/src/autocomplete.ts +85 -18
- package/src/components/editor.ts +54 -28
- package/src/desktop-notify.ts +186 -0
- package/src/index.ts +2 -0
- package/src/terminal-capabilities.ts +14 -3
- package/src/terminal.ts +24 -0
- package/src/tui.ts +32 -13
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;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/types/terminal.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
41
|
-
"@oh-my-pi/pi-utils": "16.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
|
},
|
package/src/autocomplete.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
|
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 &&
|
|
471
|
-
const slashPrefix = textBeforeCursor.slice(
|
|
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,
|
|
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;
|
package/src/components/editor.ts
CHANGED
|
@@ -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 (
|
|
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
|
|
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
|
|
1781
|
-
if (this.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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
|
-
!
|
|
2843
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2903
|
-
const
|
|
2904
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
1749
|
-
this.terminal.write(`${MOUSE_TRACKING_OFF}${
|
|
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
|
-
//
|
|
2492
|
-
//
|
|
2493
|
-
//
|
|
2494
|
-
//
|
|
2495
|
-
this.terminal.write(`\x1b[?1049h${this
|
|
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
|
|
2506
|
-
this.terminal.write(`${MOUSE_TRACKING_OFF}${
|
|
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
|
-
/**
|
|
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
|
|
3368
|
+
return `${ALT_SCREEN_ENTER}${this.#keyboardEnhancementEnter()}`;
|
|
3350
3369
|
}
|
|
3351
3370
|
|
|
3352
3371
|
#leaveResizeAltSequence(): string {
|
|
3353
3372
|
if (!this.#resizeAltActive) return "";
|
|
3354
|
-
const
|
|
3373
|
+
const enhancementExit = this.#keyboardEnhancementExit();
|
|
3355
3374
|
this.#resizeAltActive = false;
|
|
3356
3375
|
setAltScreenActive(false);
|
|
3357
3376
|
this.#forgetHardwareCursorState();
|
|
3358
|
-
return `${
|
|
3377
|
+
return `${enhancementExit}${ALT_SCREEN_EXIT}`;
|
|
3359
3378
|
}
|
|
3360
3379
|
|
|
3361
3380
|
/**
|