@linxiraos/pi-tui 1.0.0

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.
Files changed (77) hide show
  1. package/CHANGELOG.md +2219 -0
  2. package/README.md +705 -0
  3. package/dist/types/autocomplete.d.ts +116 -0
  4. package/dist/types/bracketed-paste.d.ts +51 -0
  5. package/dist/types/components/box.d.ts +31 -0
  6. package/dist/types/components/cancellable-loader.d.ts +21 -0
  7. package/dist/types/components/editor.d.ts +162 -0
  8. package/dist/types/components/image.d.ts +112 -0
  9. package/dist/types/components/input.d.ts +25 -0
  10. package/dist/types/components/loader.d.ts +25 -0
  11. package/dist/types/components/markdown.d.ts +88 -0
  12. package/dist/types/components/scroll-view.d.ts +62 -0
  13. package/dist/types/components/select-list.d.ts +69 -0
  14. package/dist/types/components/settings-list.d.ts +123 -0
  15. package/dist/types/components/spacer.d.ts +11 -0
  16. package/dist/types/components/tab-bar.d.ts +89 -0
  17. package/dist/types/components/text.d.ts +27 -0
  18. package/dist/types/components/truncated-text.d.ts +10 -0
  19. package/dist/types/deccara.d.ts +49 -0
  20. package/dist/types/desktop-notify.d.ts +52 -0
  21. package/dist/types/editor-component.d.ts +38 -0
  22. package/dist/types/fuzzy.d.ts +48 -0
  23. package/dist/types/index.d.ts +32 -0
  24. package/dist/types/keybindings.d.ts +197 -0
  25. package/dist/types/keys.d.ts +210 -0
  26. package/dist/types/kill-ring.d.ts +20 -0
  27. package/dist/types/kitty-graphics.d.ts +76 -0
  28. package/dist/types/latex-block.d.ts +8 -0
  29. package/dist/types/latex-to-unicode.d.ts +50 -0
  30. package/dist/types/loop-watchdog.d.ts +44 -0
  31. package/dist/types/mouse.d.ts +67 -0
  32. package/dist/types/stdin-buffer.d.ts +60 -0
  33. package/dist/types/symbols.d.ts +25 -0
  34. package/dist/types/terminal-capabilities.d.ts +285 -0
  35. package/dist/types/terminal.d.ts +175 -0
  36. package/dist/types/tmux.d.ts +6 -0
  37. package/dist/types/ttyid.d.ts +9 -0
  38. package/dist/types/tui.d.ts +457 -0
  39. package/dist/types/utils.d.ts +100 -0
  40. package/package.json +70 -0
  41. package/src/autocomplete.ts +1079 -0
  42. package/src/bracketed-paste.ts +123 -0
  43. package/src/components/box.ts +236 -0
  44. package/src/components/cancellable-loader.ts +40 -0
  45. package/src/components/editor.ts +3301 -0
  46. package/src/components/image.ts +460 -0
  47. package/src/components/input.ts +482 -0
  48. package/src/components/loader.ts +174 -0
  49. package/src/components/markdown.ts +3119 -0
  50. package/src/components/scroll-view.ts +227 -0
  51. package/src/components/select-list.ts +539 -0
  52. package/src/components/settings-list.ts +793 -0
  53. package/src/components/spacer.ts +32 -0
  54. package/src/components/tab-bar.ts +300 -0
  55. package/src/components/text.ts +173 -0
  56. package/src/components/truncated-text.ts +69 -0
  57. package/src/deccara.ts +314 -0
  58. package/src/desktop-notify.ts +192 -0
  59. package/src/editor-component.ts +74 -0
  60. package/src/fuzzy.ts +384 -0
  61. package/src/index.ts +51 -0
  62. package/src/keybindings.ts +346 -0
  63. package/src/keys.ts +566 -0
  64. package/src/kill-ring.ts +51 -0
  65. package/src/kitty-graphics.ts +171 -0
  66. package/src/latex-block.ts +1338 -0
  67. package/src/latex-to-unicode.ts +2017 -0
  68. package/src/loop-watchdog.ts +115 -0
  69. package/src/mouse.ts +105 -0
  70. package/src/stdin-buffer.ts +781 -0
  71. package/src/symbols.ts +26 -0
  72. package/src/terminal-capabilities.ts +1211 -0
  73. package/src/terminal.ts +1854 -0
  74. package/src/tmux.ts +14 -0
  75. package/src/ttyid.ts +84 -0
  76. package/src/tui.ts +4275 -0
  77. package/src/utils.ts +619 -0
package/src/deccara.ts ADDED
@@ -0,0 +1,314 @@
1
+ /**
2
+ * DECCARA rectangular-SGR background-fill optimizer.
3
+ *
4
+ * Kitty extends VT510 DECCARA ("Change Attributes in Rectangular Area") to all
5
+ * SGR attributes, including background color, so a solid background panel can be
6
+ * painted as a single rectangle escape instead of a full-width run of
7
+ * background-styled spaces on every row (see kitty `docs/deccara.rst`):
8
+ *
9
+ * <ESC>[2*x DECSACE: select rectangle change extent
10
+ * <ESC>[Pt;Pl;Pb;Pr;<sgr>$r DECCARA: apply <sgr> to rows Pt..Pb, cols Pl..Pr
11
+ * <ESC>[*x DECSACE: restore default extent
12
+ *
13
+ * Coordinates are 1-based and inclusive. This module is a pure, renderer-level
14
+ * planner: it consumes the *final* ANSI strings the renderer would otherwise
15
+ * write, strips the trailing background-padded spaces it can prove are safe to
16
+ * drop, and returns the rectangles to emit in their place. It never mutates
17
+ * component output and never decides which rows are scrollback-bound — those
18
+ * concerns belong to the caller in `tui.ts`.
19
+ */
20
+ import { visibleWidth } from "./utils";
21
+
22
+ /** Reset every attribute (SGR 0). Mirrors `tui.ts`'s per-line terminator. */
23
+ const SEGMENT_RESET = "\x1b[0m";
24
+
25
+ /** DECSACE — select the rectangle change extent so DECCARA fills a rectangle. */
26
+ export const DECSACE_RECT = "\x1b[2*x";
27
+ /** DECSACE — restore the default (stream) change extent. */
28
+ export const DECSACE_DEFAULT = "\x1b[*x";
29
+
30
+ /**
31
+ * Byte cost of the per-frame DECSACE wrapper ({@link DECSACE_RECT} +
32
+ * {@link DECSACE_DEFAULT}) that brackets every rectangle batch. Charged once per
33
+ * frame: a plan is emitted only when the trailing-space bytes it removes exceed
34
+ * the rectangles' own bytes by more than this, so the optimizer never inflates.
35
+ */
36
+ const DECSACE_WRAPPER_BYTES = DECSACE_RECT.length + DECSACE_DEFAULT.length;
37
+
38
+ /**
39
+ * Encode a single DECCARA rectangle. `top`/`bottom` are 1-based inclusive screen
40
+ * rows, `left`/`right` 1-based inclusive columns, `sgr` the raw SGR parameter
41
+ * list to apply (e.g. `48;2;10;20;30`, `48;5;4`, `41`).
42
+ */
43
+ export function encodeDeccara(top: number, left: number, bottom: number, right: number, sgr: string): string {
44
+ return `\x1b[${top};${left};${bottom};${right};${sgr}$r`;
45
+ }
46
+
47
+ /** Sentinel for a background form this optimizer refuses to reason about. */
48
+ const BAIL = Symbol("deccara-bail");
49
+ type BgState = string | null;
50
+
51
+ /**
52
+ * Fold one SGR parameter list into the active background-color parameter string.
53
+ * Returns the new background (`null` = default/no background) or {@link BAIL}
54
+ * when the sequence contains a background form this optimizer will not reason
55
+ * about (colon-form extended color, malformed params). Foreground and style
56
+ * parameters are skipped; only background state is tracked.
57
+ */
58
+ function nextBackground(bg: BgState, params: string): BgState | typeof BAIL {
59
+ // CSI m with no parameters is SGR 0 (reset everything).
60
+ if (params.length === 0) return null;
61
+ const tokens = params.split(";");
62
+ let result: BgState = bg;
63
+ for (let i = 0; i < tokens.length; i++) {
64
+ const token = tokens[i];
65
+ // An empty parameter defaults to 0 (reset), matching terminal behavior.
66
+ const n = token.length === 0 ? 0 : Number(token);
67
+ if (!Number.isInteger(n)) return BAIL;
68
+ if (n === 0 || n === 49) {
69
+ result = null;
70
+ continue;
71
+ }
72
+ if ((n >= 40 && n <= 47) || (n >= 100 && n <= 107)) {
73
+ result = token;
74
+ continue;
75
+ }
76
+ if (n === 48) {
77
+ const mode = tokens[i + 1];
78
+ if (mode === "5") {
79
+ const idx = tokens[i + 2];
80
+ if (idx === undefined) return BAIL;
81
+ result = `48;5;${idx}`;
82
+ i += 2;
83
+ continue;
84
+ }
85
+ if (mode === "2") {
86
+ const r = tokens[i + 2];
87
+ const g = tokens[i + 3];
88
+ const b = tokens[i + 4];
89
+ if (r === undefined || g === undefined || b === undefined) return BAIL;
90
+ result = `48;2;${r};${g};${b}`;
91
+ i += 4;
92
+ continue;
93
+ }
94
+ // Colon-form (`48:2:...`) collapses to a single non-integer token and is
95
+ // rejected above; anything else following 48 is unexpected — bail.
96
+ return BAIL;
97
+ }
98
+ if (n === 38) {
99
+ // Foreground extended color: skip its sub-parameters, leave bg alone.
100
+ const mode = tokens[i + 1];
101
+ if (mode === "5") {
102
+ i += 2;
103
+ continue;
104
+ }
105
+ if (mode === "2") {
106
+ i += 4;
107
+ continue;
108
+ }
109
+ return BAIL;
110
+ }
111
+ // Every other parameter (foreground 30-39/90-97, styles) leaves bg alone.
112
+ }
113
+ return result;
114
+ }
115
+
116
+ /** Where to cut a fillable line and the background to paint over the remainder. */
117
+ export interface BgFillAnalysis {
118
+ /** Byte index where droppable trailing background padding begins (0 = whole line). */
119
+ cut: number;
120
+ /** 0-based column where the trailing padding begins (DECCARA left = leftCol + 1). */
121
+ leftCol: number;
122
+ /** SGR parameter list of the background covering the trailing region. */
123
+ bg: string;
124
+ }
125
+
126
+ /**
127
+ * Decide whether `line` (a final, width-fit, reset-terminated ANSI string) is a
128
+ * full-width background fill whose trailing padding can be replaced by a DECCARA
129
+ * rectangle. Returns `null` unless it can *prove* the dropped bytes are literal
130
+ * trailing spaces under a single, constant, non-default background span (or the
131
+ * entire row is background-styled spaces).
132
+ *
133
+ * Conservative by construction: any OSC sequence (hyperlinks/images), any
134
+ * non-SGR CSI, a partial row, an inconsistent or default trailing background, or
135
+ * a malformed escape all yield `null` so the caller keeps the exact original.
136
+ */
137
+ export function analyzeBgFillLine(line: string, width: number): BgFillAnalysis | null {
138
+ if (width <= 0 || line.length === 0) return null;
139
+ let i = 0;
140
+ let col = 0;
141
+ let bg: BgState = null;
142
+ // Byte index / column immediately after the last non-space printable glyph.
143
+ let nonSpaceEndByte = 0;
144
+ let nonSpaceEndCol = 0;
145
+ // Background covering the current trailing run of spaces, and whether that
146
+ // trailing run has started. `null` is a real "default background" value, so
147
+ // it cannot double as the uninitialized sentinel.
148
+ let trailBg: BgState = null;
149
+ let trailStarted = false;
150
+ let trailConsistent = true;
151
+
152
+ while (i < line.length) {
153
+ if (line.charCodeAt(i) === 0x1b) {
154
+ // Only CSI SGR (`\x1b[ ... m`) is tolerated. OSC, APC, and any other
155
+ // CSI mean styled hyperlinks/images/cursor markers — refuse to touch.
156
+ if (line.charCodeAt(i + 1) !== 0x5b) return null;
157
+ let j = i + 2;
158
+ while (j < line.length) {
159
+ const c = line.charCodeAt(j);
160
+ if (c >= 0x40 && c <= 0x7e) break;
161
+ j++;
162
+ }
163
+ if (j >= line.length) return null; // unterminated CSI
164
+ if (line.charCodeAt(j) !== 0x6d) return null; // non-SGR CSI (final byte != 'm')
165
+ const next = nextBackground(bg, line.slice(i + 2, j));
166
+ if (next === BAIL) return null;
167
+ bg = next;
168
+ i = j + 1;
169
+ continue;
170
+ }
171
+
172
+ // Printable run up to the next escape.
173
+ let j = i;
174
+ while (j < line.length && line.charCodeAt(j) !== 0x1b) j++;
175
+ const text = line.slice(i, j);
176
+ let nonSpaceLen = text.length;
177
+ while (nonSpaceLen > 0 && text.charCodeAt(nonSpaceLen - 1) === 0x20) nonSpaceLen--;
178
+
179
+ if (nonSpaceLen > 0) {
180
+ // Run carries a non-space glyph: the trailing region restarts after it.
181
+ const nonSpaceWidth = visibleWidth(text.slice(0, nonSpaceLen));
182
+ nonSpaceEndByte = i + nonSpaceLen;
183
+ nonSpaceEndCol = col + nonSpaceWidth;
184
+ // Spaces after the last non-space glyph in this same printable run sit
185
+ // under the current bg. If there are none, the trailing region has not
186
+ // started yet; a later SGR can still begin a uniform fill safely.
187
+ if (nonSpaceLen < text.length) {
188
+ trailBg = bg;
189
+ trailStarted = true;
190
+ } else {
191
+ trailBg = null;
192
+ trailStarted = false;
193
+ }
194
+ trailConsistent = true;
195
+ } else if (text.length > 0) {
196
+ // Whole run is spaces: it extends the trailing region. Track bg drift.
197
+ if (!trailStarted) {
198
+ trailBg = bg;
199
+ trailStarted = true;
200
+ } else if (bg !== trailBg) {
201
+ trailConsistent = false;
202
+ }
203
+ }
204
+ col += visibleWidth(text);
205
+ i = j;
206
+ }
207
+
208
+ if (col !== width) return null; // not a full-width fill
209
+ if (nonSpaceEndCol >= width) return null; // no trailing padding to drop
210
+ if (!trailStarted || trailBg === null || !trailConsistent) return null; // default/mixed bg — nothing safe to paint
211
+ return { cut: nonSpaceEndByte, leftCol: nonSpaceEndCol, bg: trailBg };
212
+ }
213
+
214
+ interface FillCandidate {
215
+ left: number;
216
+ right: number;
217
+ bg: string;
218
+ short: string;
219
+ origLen: number;
220
+ }
221
+
222
+ /** Per-frame plan: the (possibly shortened) row strings and the DECCARA batch. */
223
+ export interface DeccaraPlan {
224
+ /** Row strings to write, parallel to the input. Optimized rows are shortened. */
225
+ texts: string[];
226
+ /** DECSACE-wrapped rectangle batch to emit after the rows, or `""` if none. */
227
+ sequence: string;
228
+ }
229
+
230
+ /**
231
+ * Plan DECCARA rectangles for a contiguous block of visible rows.
232
+ *
233
+ * `lines[k]` is the final ANSI string for screen row `firstScreenRow + k`
234
+ * (0-based). For each fillable row the trailing background padding is removed
235
+ * (the row's cells are cleared/erased by the caller, then repainted by the
236
+ * rectangle), and vertically adjacent rows with an identical left/right/bg span
237
+ * coalesce into one rectangle. Rectangles are emitted only when they save more
238
+ * bytes than they cost, so the result never exceeds the original byte count.
239
+ */
240
+ export function planDeccaraFills(lines: string[], width: number, firstScreenRow = 0): DeccaraPlan {
241
+ const n = lines.length;
242
+ const texts: string[] = new Array(n);
243
+ const candidates: (FillCandidate | null)[] = new Array(n);
244
+
245
+ for (let k = 0; k < n; k++) {
246
+ const line = lines[k];
247
+ texts[k] = line;
248
+ const analysis = analyzeBgFillLine(line, width);
249
+ if (!analysis) {
250
+ candidates[k] = null;
251
+ continue;
252
+ }
253
+ // Cut at the last non-space glyph and re-close attributes. An all-space row
254
+ // (cut 0) needs no styled text at all — the caller's erase plus the
255
+ // rectangle paint it. A content row keeps its prefix and a fresh reset so
256
+ // the inline background never bleeds past the row.
257
+ const short = analysis.cut === 0 ? "" : line.slice(0, analysis.cut) + SEGMENT_RESET;
258
+ candidates[k] = { left: analysis.leftCol + 1, right: width, bg: analysis.bg, short, origLen: line.length };
259
+ }
260
+
261
+ // Collect coalesced groups whose rectangle at least pays for its own bytes.
262
+ // The DECSACE wrapper is a single per-frame cost, so it is charged once below
263
+ // rather than amortized into each group (which would over-reject lone rows).
264
+ interface Group {
265
+ start: number;
266
+ end: number;
267
+ rect: string;
268
+ }
269
+ const groups: Group[] = [];
270
+ let removedTotal = 0;
271
+ let rectBytesTotal = 0;
272
+ let k = 0;
273
+ while (k < n) {
274
+ const head = candidates[k];
275
+ if (!head) {
276
+ k++;
277
+ continue;
278
+ }
279
+ // Extend the group over adjacent rows sharing the same fill span.
280
+ let end = k;
281
+ while (end + 1 < n) {
282
+ const next = candidates[end + 1];
283
+ if (!next || next.left !== head.left || next.right !== head.right || next.bg !== head.bg) break;
284
+ end++;
285
+ }
286
+ const rect = encodeDeccara(firstScreenRow + k + 1, head.left, firstScreenRow + end + 1, head.right, head.bg);
287
+ let removed = 0;
288
+ for (let r = k; r <= end; r++) {
289
+ const c = candidates[r];
290
+ if (c) removed += c.origLen - c.short.length;
291
+ }
292
+ if (removed > rect.length) {
293
+ groups.push({ start: k, end, rect });
294
+ removedTotal += removed;
295
+ rectBytesTotal += rect.length;
296
+ }
297
+ k = end + 1;
298
+ }
299
+
300
+ // Emit nothing unless the batch beats the original by more than the wrapper.
301
+ if (groups.length === 0 || removedTotal - rectBytesTotal <= DECSACE_WRAPPER_BYTES) {
302
+ return { texts, sequence: "" };
303
+ }
304
+ let sequence = DECSACE_RECT;
305
+ for (const group of groups) {
306
+ for (let r = group.start; r <= group.end; r++) {
307
+ const c = candidates[r];
308
+ if (c) texts[r] = c.short;
309
+ }
310
+ sequence += group.rect;
311
+ }
312
+ sequence += DECSACE_DEFAULT;
313
+ return { texts, sequence };
314
+ }
@@ -0,0 +1,192 @@
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 * as fs from "node:fs";
24
+ import * as path from "node:path";
25
+ import { $which } from "@linxiraos/pi-utils";
26
+ import type { TerminalId, TerminalNotification } from "./terminal-capabilities";
27
+
28
+ /** Application name surfaced as the notification source. */
29
+ const APP_NAME = "Zeta";
30
+
31
+ /** Resolved notifier binary used to fan a notification out to D-Bus. */
32
+ export type DesktopNotifierKind = "notify-send" | "gdbus";
33
+
34
+ export interface DesktopNotifier {
35
+ kind: DesktopNotifierKind;
36
+ path: string;
37
+ }
38
+
39
+ /**
40
+ * Whether the current process can reach a freedesktop notification daemon:
41
+ * Linux platform plus either a session bus address in env or the
42
+ * systemd user-bus socket at `$XDG_RUNTIME_DIR/bus`. Caller is still responsible for
43
+ * resolving a delivery binary via {@link resolveDesktopNotifier}.
44
+ */
45
+ export function hasLinuxDesktopSession(
46
+ platform: NodeJS.Platform = process.platform,
47
+ env: NodeJS.ProcessEnv = Bun.env,
48
+ fileExists: (path: string) => boolean = fs.existsSync,
49
+ ): boolean {
50
+ if (platform !== "linux") return false;
51
+ if (env.DBUS_SESSION_BUS_ADDRESS) return true;
52
+ const runtimeDir = env.XDG_RUNTIME_DIR;
53
+ return Boolean(runtimeDir && fileExists(path.join(runtimeDir, "bus")));
54
+ }
55
+
56
+ /**
57
+ * Whether `sendNotification` should also dispatch a D-Bus toast for this
58
+ * terminal. Returns true only when (1) the chosen `notifyProtocol` is BEL,
59
+ * which cannot carry arbitrary toast text, (2) the host exposes a Linux desktop
60
+ * session, and (3) the user has not opted out via `PI_NO_DESKTOP_NOTIFY=1`.
61
+ * Terminals that genuinely speak OSC 9 / OSC 99 pass
62
+ * `notifyProtocolIsBell=false` and are filtered before the D-Bus fallback can
63
+ * run. Pure helper for tests and the singleton path.
64
+ */
65
+ export function shouldDeliverDesktopNotification(
66
+ _terminalId: TerminalId,
67
+ notifyProtocolIsBell: boolean,
68
+ platform: NodeJS.Platform = process.platform,
69
+ env: NodeJS.ProcessEnv = Bun.env,
70
+ ): boolean {
71
+ if (!notifyProtocolIsBell) return false;
72
+ if (!hasLinuxDesktopSession(platform, env)) return false;
73
+ if (env.PI_NO_DESKTOP_NOTIFY === "1") return false;
74
+ return true;
75
+ }
76
+
77
+ let cachedNotifier: DesktopNotifier | null | undefined;
78
+
79
+ /** Reset the cached notifier resolution. Tests only. */
80
+ export function resetDesktopNotifierCache(): void {
81
+ cachedNotifier = undefined;
82
+ }
83
+
84
+ /**
85
+ * Locate a libnotify-compatible delivery binary on `PATH`, preferring
86
+ * `notify-send` (one-shot, no marshalling) and falling back to `gdbus call`
87
+ * for hosts where libnotify is not installed but GLib is. Result is cached so
88
+ * repeated notifications do not hit `$which` again.
89
+ */
90
+ export function resolveDesktopNotifier(): DesktopNotifier | null {
91
+ if (cachedNotifier !== undefined) return cachedNotifier;
92
+ const notifySend = $which("notify-send");
93
+ if (notifySend) {
94
+ cachedNotifier = { kind: "notify-send", path: notifySend };
95
+ return cachedNotifier;
96
+ }
97
+ const gdbus = $which("gdbus");
98
+ if (gdbus) {
99
+ cachedNotifier = { kind: "gdbus", path: gdbus };
100
+ return cachedNotifier;
101
+ }
102
+ cachedNotifier = null;
103
+ return null;
104
+ }
105
+
106
+ interface ResolvedNotificationFields {
107
+ title: string;
108
+ body: string;
109
+ urgency: "low" | "normal" | "critical";
110
+ }
111
+
112
+ function resolveFields(message: string | TerminalNotification): ResolvedNotificationFields {
113
+ if (typeof message === "string") {
114
+ return { title: APP_NAME, body: message, urgency: "normal" };
115
+ }
116
+ const title = message.title?.trim() || APP_NAME;
117
+ const body = message.body ?? "";
118
+ const urgency = message.urgency === "critical" || message.urgency === "low" ? message.urgency : "normal";
119
+ return { title, body, urgency };
120
+ }
121
+
122
+ const URGENCY_BYTE: Record<ResolvedNotificationFields["urgency"], number> = {
123
+ low: 0,
124
+ normal: 1,
125
+ critical: 2,
126
+ };
127
+
128
+ /**
129
+ * Build the argv that delivers `message` through the resolved notifier. Pure
130
+ * helper so tests assert exact wire shape without spawning a child. Notes:
131
+ * - `notify-send` accepts title + body positionally and a numeric expire
132
+ * timeout (`-t`); urgency is a flag.
133
+ * - `gdbus call ... Notify` takes the freedesktop signature
134
+ * `s u s s s as a{sv} i`: app_name, replaces_id, app_icon, summary, body,
135
+ * actions, hints, expire_timeout. We feed hints with the urgency byte so
136
+ * the daemon classifies the toast identically to `notify-send`.
137
+ */
138
+ export function buildDesktopNotifyCommand(notifier: DesktopNotifier, message: string | TerminalNotification): string[] {
139
+ const { title, body, urgency } = resolveFields(message);
140
+ if (notifier.kind === "notify-send") {
141
+ return [notifier.path, "--app-name", APP_NAME, `--urgency=${urgency}`, "--expire-time=5000", title, body];
142
+ }
143
+ const hints = `{"urgency": <byte ${URGENCY_BYTE[urgency]}>}`;
144
+ return [
145
+ notifier.path,
146
+ "call",
147
+ "--session",
148
+ "--dest",
149
+ "org.freedesktop.Notifications",
150
+ "--object-path",
151
+ "/org/freedesktop/Notifications",
152
+ "--method",
153
+ "org.freedesktop.Notifications.Notify",
154
+ APP_NAME,
155
+ "0",
156
+ "",
157
+ title,
158
+ body,
159
+ "[]",
160
+ hints,
161
+ "5000",
162
+ ];
163
+ }
164
+
165
+ /**
166
+ * Fire-and-forget D-Bus desktop notification. Resolves a notifier, spawns it
167
+ * with stdio fully detached, and never throws — terminal notifications are
168
+ * best-effort and must not block the renderer or interleave bytes onto
169
+ * stdout. Caller is responsible for the gating check
170
+ * ({@link shouldDeliverDesktopNotification}).
171
+ */
172
+ export function sendDesktopNotification(message: string | TerminalNotification): void {
173
+ const notifier = resolveDesktopNotifier();
174
+ if (!notifier) return;
175
+ try {
176
+ // `.unref()` lets the event loop exit while the notifier is still running.
177
+ // Without it, an unresponsive D-Bus activation (slow `notify-send`, hung
178
+ // `gdbus` waiting on a stalled session bus) would keep `omp` alive past
179
+ // the renderer's shutdown — a completion toast must never delay process
180
+ // exit. Ignored stdio alone does not detach the child from the parent's
181
+ // reference count.
182
+ const child = Bun.spawn({
183
+ cmd: buildDesktopNotifyCommand(notifier, message),
184
+ stdin: "ignore",
185
+ stdout: "ignore",
186
+ stderr: "ignore",
187
+ });
188
+ child.unref();
189
+ } catch {
190
+ // Best-effort: a failed spawn is silent.
191
+ }
192
+ }
@@ -0,0 +1,74 @@
1
+ import type { AutocompleteProvider } from "./autocomplete";
2
+ import type { Component } from "./tui";
3
+
4
+ /**
5
+ * Interface for custom editor components.
6
+ *
7
+ * This allows extensions to provide their own editor implementation
8
+ * (e.g., vim mode, emacs mode, custom keybindings) while maintaining
9
+ * compatibility with the core application.
10
+ */
11
+ export interface EditorComponent extends Component {
12
+ // =========================================================================
13
+ // Core text access (required)
14
+ // =========================================================================
15
+
16
+ /** Get the current text content */
17
+ getText(): string;
18
+
19
+ /** Set the text content */
20
+ setText(text: string): void;
21
+
22
+ /** Handle raw terminal input (key presses, paste sequences, etc.) */
23
+ handleInput(data: string): void;
24
+
25
+ // =========================================================================
26
+ // Callbacks (required)
27
+ // =========================================================================
28
+
29
+ /** Called when user submits (e.g., Enter key) */
30
+ onSubmit?: (text: string) => void;
31
+
32
+ /** Programmatically trigger submission (optional, e.g. for voice submit). */
33
+ submit?(): void;
34
+
35
+ /** Called when text changes */
36
+ onChange?: (text: string) => void;
37
+
38
+ // =========================================================================
39
+ // History support (optional)
40
+ // =========================================================================
41
+
42
+ /** Add text to history for up/down navigation */
43
+ addToHistory?(text: string): void;
44
+
45
+ // =========================================================================
46
+ // Advanced text manipulation (optional)
47
+ // =========================================================================
48
+
49
+ /** Insert text at current cursor position */
50
+ insertTextAtCursor?(text: string): void;
51
+
52
+ /**
53
+ * Get text with any markers expanded (e.g., paste markers).
54
+ * Falls back to getText() if not implemented.
55
+ */
56
+ getExpandedText?(): string;
57
+
58
+ // =========================================================================
59
+ // Autocomplete support (optional)
60
+ // =========================================================================
61
+
62
+ /** Set the autocomplete provider */
63
+ setAutocompleteProvider?(provider: AutocompleteProvider): void;
64
+
65
+ // =========================================================================
66
+ // Appearance (optional)
67
+ // =========================================================================
68
+
69
+ /** Border color function */
70
+ borderColor?: (str: string) => string;
71
+
72
+ /** Set horizontal padding */
73
+ setPaddingX?(padding: number): void;
74
+ }