@oh-my-pi/pi-tui 18.1.4 → 18.1.6

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,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.1.6] - 2026-09-03
6
+
7
+ ### Fixed
8
+
9
+ - Fixed the band composer layout so the status line remains visible and no longer causes the prompt to shift unexpectedly when the top border is empty.
10
+
11
+ ## [18.1.5] - 2026-09-03
12
+
13
+ ### Fixed
14
+
15
+ - Fixed terminal query support in supervised PTY processes, including cursor position reports.
16
+ - Fixed paste-and-submit handling so an Enter keypress received with a bracketed paste is delivered to the previously focused component; `Editor.onLargePaste` now receives `PasteOptions` describing the queued submit.
17
+
5
18
  ## [18.1.3] - 2026-09-02
6
19
 
7
20
  ### Fixed
@@ -57,6 +57,11 @@ export interface EditorTextAssistProvider {
57
57
  /** Return replacement candidates for the misspelled word at the cursor. */
58
58
  getWordReplacements?(lines: string[], cursorLine: number, cursorCol: number): EditorWordReplacements | null | Promise<EditorWordReplacements | null>;
59
59
  }
60
+ /** What the paste transport knew about the input burst before the editor inserts the payload. */
61
+ export interface PasteOptions {
62
+ /** A submit keypress arrived in the same input burst and will be dispatched right after the paste. */
63
+ submitAfterPaste?: boolean;
64
+ }
60
65
  export declare class Editor implements Component, Focusable {
61
66
  #private;
62
67
  /** Focusable interface - set by TUI when focus changes */
@@ -89,8 +94,9 @@ export declare class Editor implements Component, Focusable {
89
94
  * the editor inserts nothing and records no undo state, leaving insertion to the host (e.g. a
90
95
  * "wrap in a code block / XML / attach as file" menu for very large pastes), which re-inserts
91
96
  * via {@link insertPaste} or {@link insertText}. Return `false` (or leave unset) for the
92
- * default collapse-to-marker behavior. `lineCount` is the sanitized paste's line count. */
93
- onLargePaste?: (text: string, lineCount: number) => boolean;
97
+ * default collapse-to-marker behavior. `lineCount` is the sanitized paste's line count;
98
+ * `options` carries what the paste transport knew about the burst. */
99
+ onLargePaste?: (text: string, lineCount: number, options: PasteOptions) => boolean;
94
100
  onAutocompleteCancel?: () => void;
95
101
  disableSubmit: boolean;
96
102
  constructor(theme: EditorTheme);
@@ -210,7 +216,7 @@ export declare class Editor implements Component, Focusable {
210
216
  /** Drop any volatile preview, then insert `text` as a single undoable edit. */
211
217
  commitVolatileText(text: string): void;
212
218
  /** Apply terminal paste semantics to text from non-bracketed paste transports. */
213
- pasteText(text: string): void;
219
+ pasteText(text: string, options?: PasteOptions): void;
214
220
  /** Insert `content` as a collapsed `[Paste #N]` marker (stored for expansion on submit via
215
221
  * {@link getExpandedText}). Hosts that intercept large pastes through {@link onLargePaste} use
216
222
  * this to re-insert a (possibly transformed) paste without re-triggering the interception hook. */
@@ -12,6 +12,20 @@ export declare let fastTailSplices: number;
12
12
  export declare function resetFastTailSplices(): void;
13
13
  /** @internal exported for tests — the grown-line-start block-kind gate. */
14
14
  export declare function fastLineStartHazard(grownLine: string): boolean;
15
+ /** A hyperlink as the renderer sees it: inline `[text](href)`, `<autolink>`, bare GFM URL, or reference link. */
16
+ export interface MarkdownLink {
17
+ /** Visible link text (equals `href` for autolinks and bare URLs). */
18
+ text: string;
19
+ /** Destination exactly as marked resolved it (references resolved, no normalization). */
20
+ href: string;
21
+ }
22
+ /**
23
+ * Every link token in `text`, in document order, from the same configured
24
+ * lexer the renderer uses — so fenced code, code spans, escapes, reference
25
+ * definitions and the GFM autolink rules agree with what is drawn on screen.
26
+ * Duplicate hrefs are kept; callers decide how to fold them.
27
+ */
28
+ export declare function extractMarkdownLinks(text: string): MarkdownLink[];
15
29
  /** Drop all L2 cache entries. Call on theme change to prevent stale styled output. */
16
30
  export declare function clearRenderCache(): void;
17
31
  /**
@@ -43,7 +43,12 @@ export type StdinBufferOptions = {
43
43
  };
44
44
  export type StdinBufferEventMap = {
45
45
  data: [string];
46
- paste: [string];
46
+ /**
47
+ * A completed bracketed paste. `enter` carries an Enter keypress that shared
48
+ * the paste's stdin read, so the terminal can dispatch paste and submit to
49
+ * the same focused component before a paste-triggered overlay can take focus.
50
+ */
51
+ paste: [content: string, enter?: string];
47
52
  };
48
53
  /**
49
54
  * Buffers stdin input and emits complete sequences via the 'data' event.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "18.1.4",
4
+ "version": "18.1.6",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Stencil Labs, Inc.",
@@ -37,8 +37,8 @@
37
37
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/pi-natives": "18.1.4",
41
- "@oh-my-pi/pi-utils": "18.1.4"
40
+ "@oh-my-pi/pi-natives": "18.1.6",
41
+ "@oh-my-pi/pi-utils": "18.1.6"
42
42
  },
43
43
  "devDependencies": {
44
44
  "kitty-vt-wasm": "^0.2.0"
@@ -24,9 +24,13 @@ export const bandComposerStyle: ComposerStyle = {
24
24
  return 0;
25
25
  },
26
26
 
27
- renderTop(ctx: ComposerChromeContext): string | undefined {
27
+ renderTop(ctx: ComposerChromeContext): string {
28
28
  const { topBorder, width } = ctx;
29
- if (!topBorder?.content) return undefined;
29
+ // The band always owns one chrome row (`verticalChrome: 1`): keep it
30
+ // reserved while the status line has nothing to show yet — the startup
31
+ // prepaint mounts the editor before the session-aware status line
32
+ // attaches — so the band fills in later without shifting the layout.
33
+ if (!topBorder?.content) return "";
30
34
  // The band builder already sizes its groups + gauge to the full width;
31
35
  // truncation only guards against a stale provider during resize.
32
36
  return topBorder.width > width ? truncateToWidth(topBorder.content, width) : topBorder.content;
@@ -467,6 +467,12 @@ export interface EditorTextAssistProvider {
467
467
  type HistoryCursorAnchor = "start" | "end";
468
468
  type AutocompleteRequest = { kind: "regular"; explicitTab: boolean } | { kind: "force" };
469
469
 
470
+ /** What the paste transport knew about the input burst before the editor inserts the payload. */
471
+ export interface PasteOptions {
472
+ /** A submit keypress arrived in the same input burst and will be dispatched right after the paste. */
473
+ submitAfterPaste?: boolean;
474
+ }
475
+
470
476
  export class Editor implements Component, Focusable {
471
477
  #state: EditorState = {
472
478
  lines: [""],
@@ -581,8 +587,9 @@ export class Editor implements Component, Focusable {
581
587
  * the editor inserts nothing and records no undo state, leaving insertion to the host (e.g. a
582
588
  * "wrap in a code block / XML / attach as file" menu for very large pastes), which re-inserts
583
589
  * via {@link insertPaste} or {@link insertText}. Return `false` (or leave unset) for the
584
- * default collapse-to-marker behavior. `lineCount` is the sanitized paste's line count. */
585
- onLargePaste?: (text: string, lineCount: number) => boolean;
590
+ * default collapse-to-marker behavior. `lineCount` is the sanitized paste's line count;
591
+ * `options` carries what the paste transport knew about the burst. */
592
+ onLargePaste?: (text: string, lineCount: number, options: PasteOptions) => boolean;
586
593
  onAutocompleteCancel?: () => void;
587
594
  disableSubmit: boolean = false;
588
595
 
@@ -2148,8 +2155,8 @@ export class Editor implements Component, Focusable {
2148
2155
  }
2149
2156
 
2150
2157
  /** Apply terminal paste semantics to text from non-bracketed paste transports. */
2151
- pasteText(text: string): void {
2152
- this.#handlePaste(text);
2158
+ pasteText(text: string, options: PasteOptions = {}): void {
2159
+ this.#handlePaste(text, options);
2153
2160
  }
2154
2161
 
2155
2162
  /** Insert `content` as a collapsed `[Paste #N]` marker (stored for expansion on submit via
@@ -2289,7 +2296,7 @@ export class Editor implements Component, Focusable {
2289
2296
  }
2290
2297
  }
2291
2298
 
2292
- #handlePaste(pastedText: string): void {
2299
+ #handlePaste(pastedText: string, options: PasteOptions = {}): void {
2293
2300
  let filteredText = this.#sanitizePastedText(pastedText);
2294
2301
 
2295
2302
  // If pasting a file path (starts with /, ~, or .) and the character before
@@ -2311,7 +2318,7 @@ export class Editor implements Component, Focusable {
2311
2318
  // Let the host intercept marker-sized pastes (e.g. the large-paste menu). When it takes
2312
2319
  // over, the editor inserts nothing and records no undo state — the host re-inserts via
2313
2320
  // `insertPaste`/`insertText` once the user chooses.
2314
- if (isMarkerSized && this.onLargePaste?.(filteredText, pastedLines.length)) {
2321
+ if (isMarkerSized && this.onLargePaste?.(filteredText, pastedLines.length, options)) {
2315
2322
  return;
2316
2323
  }
2317
2324
 
@@ -1254,6 +1254,52 @@ function lexDocument(text: string): Token[] {
1254
1254
  return lexWindowed(text);
1255
1255
  }
1256
1256
 
1257
+ /** A hyperlink as the renderer sees it: inline `[text](href)`, `<autolink>`, bare GFM URL, or reference link. */
1258
+ export interface MarkdownLink {
1259
+ /** Visible link text (equals `href` for autolinks and bare URLs). */
1260
+ text: string;
1261
+ /** Destination exactly as marked resolved it (references resolved, no normalization). */
1262
+ href: string;
1263
+ }
1264
+
1265
+ /**
1266
+ * Every link token in `text`, in document order, from the same configured
1267
+ * lexer the renderer uses — so fenced code, code spans, escapes, reference
1268
+ * definitions and the GFM autolink rules agree with what is drawn on screen.
1269
+ * Duplicate hrefs are kept; callers decide how to fold them.
1270
+ */
1271
+ export function extractMarkdownLinks(text: string): MarkdownLink[] {
1272
+ const links: MarkdownLink[] = [];
1273
+ const walk = (tokens: readonly Token[] | undefined): void => {
1274
+ if (!tokens) return;
1275
+ for (const token of tokens) {
1276
+ if (token.type === "link") {
1277
+ const link = token as Tokens.Link;
1278
+ if (typeof link.href === "string" && link.href.length > 0) {
1279
+ links.push({
1280
+ text: typeof link.text === "string" && link.text.length > 0 ? link.text : link.href,
1281
+ href: link.href,
1282
+ });
1283
+ }
1284
+ continue;
1285
+ }
1286
+ // Containers: paragraphs, emphasis, lists, blockquotes, table cells.
1287
+ const any = token as {
1288
+ tokens?: Token[];
1289
+ items?: Token[];
1290
+ header?: Array<{ tokens?: Token[] }>;
1291
+ rows?: Array<Array<{ tokens?: Token[] }>>;
1292
+ };
1293
+ walk(any.tokens);
1294
+ walk(any.items);
1295
+ if (any.header) for (const cell of any.header) walk(cell.tokens);
1296
+ if (any.rows) for (const row of any.rows) for (const cell of row) walk(cell.tokens);
1297
+ }
1298
+ };
1299
+ walk(lexDocument(text));
1300
+ return links;
1301
+ }
1302
+
1257
1303
  /** Drop all L2 cache entries. Call on theme change to prevent stale styled output. */
1258
1304
  export function clearRenderCache(): void {
1259
1305
  renderCache.clear();
@@ -375,9 +375,27 @@ export type StdinBufferOptions = {
375
375
  pasteByteLimit?: number;
376
376
  };
377
377
 
378
+ const KITTY_ENTER = /^\x1b\[13(?:;1)?u/u;
379
+
380
+ /**
381
+ * The Enter keypress that immediately follows a bracketed-paste end marker in
382
+ * the same stdin read: legacy CR/LF, or the kitty CSI-u encoding with no
383
+ * modifier. Nothing else qualifies — any other trailing byte takes the normal
384
+ * data route.
385
+ */
386
+ function leadingEnter(input: string): string | undefined {
387
+ if (input.startsWith("\r") || input.startsWith("\n")) return input[0];
388
+ return KITTY_ENTER.exec(input)?.[0];
389
+ }
390
+
378
391
  export type StdinBufferEventMap = {
379
392
  data: [string];
380
- paste: [string];
393
+ /**
394
+ * A completed bracketed paste. `enter` carries an Enter keypress that shared
395
+ * the paste's stdin read, so the terminal can dispatch paste and submit to
396
+ * the same focused component before a paste-triggered overlay can take focus.
397
+ */
398
+ paste: [content: string, enter?: string];
381
399
  };
382
400
 
383
401
  /**
@@ -577,10 +595,15 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
577
595
  this.#pasteBytes = 0;
578
596
  this.#pendingKittyPrintableCodepoint = undefined;
579
597
 
580
- this.emit("paste", pastedContent);
598
+ // A paste-and-Enter burst (automation, terminals that batch reads) must
599
+ // not split into two dispatches: the paste may open an overlay that
600
+ // would then swallow the Enter meant to submit it.
601
+ const enter = leadingEnter(remaining);
602
+ this.emit("paste", pastedContent, enter);
581
603
 
582
- if (remaining.length > 0) {
583
- this.process(remaining);
604
+ const rest = enter === undefined ? remaining : remaining.slice(enter.length);
605
+ if (rest.length > 0) {
606
+ this.process(rest);
584
607
  }
585
608
  }
586
609
 
package/src/terminal.ts CHANGED
@@ -1359,10 +1359,13 @@ export class ProcessTerminal implements Terminal {
1359
1359
  }
1360
1360
  });
1361
1361
 
1362
- // Re-wrap paste content with bracketed paste markers for existing editor handling
1363
- this.#stdinBuffer.on("paste", (content: string) => {
1362
+ // Re-wrap paste content with bracketed paste markers for existing editor
1363
+ // handling. An Enter that shared the paste's stdin read rides along so
1364
+ // paste and submit reach the component focused right now, not one the
1365
+ // paste itself is about to open.
1366
+ this.#stdinBuffer.on("paste", (content: string, enter?: string) => {
1364
1367
  if (this.#inputHandler) {
1365
- this.#inputHandler(`\x1b[200~${content}\x1b[201~`);
1368
+ this.#inputHandler(`\x1b[200~${content}\x1b[201~${enter ?? ""}`);
1366
1369
  }
1367
1370
  });
1368
1371