@oh-my-pi/pi-tui 17.2.9 → 17.2.11

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,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.2.11] - 2026-08-07
6
+
7
+ ### Fixed
8
+
9
+ - Fixed an issue where Herdr panes lost native terminal scrollback during TUI transcript replacements or resize redraws.
10
+ - Fixed an issue inside tmux where explicit display resets retained stale light/dark palettes and leaked terminal capability bytes into the editor.
11
+
12
+ ## [17.2.10] - 2026-08-06
13
+
14
+ ### Fixed
15
+
16
+ - Fixed a startup crash (EIO error) in multiplexer or SSH sessions when a revoked pty leaves stdin.isTTY active.
17
+ - Fixed prompt autocomplete to support Windows drive-absolute paths (e.g., C:/ or C:\).
18
+ - Fixed desktop notifications in systemd, tmux, or SSH-attached Linux sessions when DBUS_SESSION_BUS_ADDRESS is unset.
19
+ - Fixed an issue where Shift+letter and shifted symbol inputs (such as capital letters, ?, and !) were silently dropped on Windows and WSL terminals using ConPTY (e.g., WezTerm).
20
+
5
21
  ## [17.2.9] - 2026-08-05
6
22
 
7
23
  ### Fixed
package/README.md CHANGED
@@ -640,7 +640,7 @@ class MyComponent implements Component {
640
640
  - `wrapTextWithAnsi()` preserves ANSI codes while word-wrapping and trimming line ends
641
641
 
642
642
  ```typescript
643
- import chalk from "chalk";
643
+ import chalk from "@oh-my-pi/pi-utils/chalk";
644
644
 
645
645
  const styled = chalk.red("Hello") + " " + chalk.blue("World");
646
646
  const width = visibleWidth(styled); // 11 (not counting ANSI codes)
@@ -7,10 +7,11 @@ export interface DesktopNotifier {
7
7
  }
8
8
  /**
9
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}.
10
+ * Linux platform plus either a session bus address in env or the
11
+ * systemd user-bus socket at `$XDG_RUNTIME_DIR/bus`. Caller is still responsible for
12
+ * resolving a delivery binary via {@link resolveDesktopNotifier}.
12
13
  */
13
- export declare function hasLinuxDesktopSession(platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv): boolean;
14
+ export declare function hasLinuxDesktopSession(platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv, fileExists?: (path: string) => boolean): boolean;
14
15
  /**
15
16
  * Whether `sendNotification` should also dispatch a D-Bus toast for this
16
17
  * terminal. Returns true only when (1) the chosen `notifyProtocol` is BEL,
@@ -101,12 +101,13 @@ export interface Terminal {
101
101
  */
102
102
  onAppearanceReport?(callback: (appearance: TerminalAppearance, requestToken?: TerminalAppearanceRequestToken) => void): (() => void) | void;
103
103
  /**
104
- * Issue a single OSC 11 background-color re-query, driving the appearance
104
+ * Start a bounded OSC 11 background-color refresh cycle, driving appearance
105
105
  * callbacks through the same parse/dedup pipeline used at startup and on Mode
106
- * 2031 notifications. Bounded: one probe per call, no timers. Invoked on the
107
- * user's explicit display-reset gesture so terminals that cannot
108
- * deliver end-to-end Mode 2031 notifications still pick up a light/dark switch
109
- * without a restart.
106
+ * 2031 notifications. Direct terminals need one query; tmux needs a
107
+ * passthrough query to update its cache followed by one delayed direct cache
108
+ * read. Invoked on the user's explicit display-reset gesture so terminals
109
+ * without end-to-end Mode 2031 notifications pick up a light/dark switch
110
+ * without a restart. No periodic probes are armed.
110
111
  *
111
112
  * A caller-provided token must be propagated unchanged to callbacks and
112
113
  * returned when the request is accepted. This lets callers establish ownership
@@ -146,13 +147,14 @@ export declare class ProcessTerminal implements Terminal {
146
147
  onAppearanceChange(callback: (appearance: TerminalAppearance, requestToken?: TerminalAppearanceRequestToken) => void): void;
147
148
  onAppearanceReport(callback: (appearance: TerminalAppearance, requestToken?: TerminalAppearanceRequestToken) => void): () => void;
148
149
  /**
149
- * Re-query the terminal background via a single OSC 11 probe. Reuses the
150
- * startup DA1-sentinel FIFO, pending/queued gating, parsing, dedup, and
151
- * appearance callbacks. Inside tmux, only this explicit path wraps the query
152
- * and sentinel together for passthrough to the outer terminal; startup and
153
- * Mode 2031 probes remain direct. Bounded to one probe per call; no timers are
154
- * armed. Suppressed while inactive, headless, or after the terminal is torn
155
- * down.
150
+ * Re-query the terminal background through the startup DA1-sentinel FIFO,
151
+ * pending/queued gating, parsing, dedup, and appearance callbacks. Inside
152
+ * tmux, only this explicit path first passes an OSC 11 query to the outer
153
+ * terminal, waits briefly for tmux to consume the response into its cache,
154
+ * then reads that cache with a direct query. The outer query deliberately has
155
+ * no DA1 sentinel: multiplexers can decode a fragmented DA1 response as a key
156
+ * sequence and leak the remaining bytes into the editor. Startup and Mode 2031
157
+ * probes remain direct. Suppressed while inactive, headless, or after teardown.
156
158
  */
157
159
  refreshAppearance(requestToken?: TerminalAppearanceRequestToken): TerminalAppearanceRequestToken | void;
158
160
  onPrivateModeReport(callback: (mode: number, supported: boolean, confirmed?: 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": "17.2.9",
4
+ "version": "17.2.11",
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,13 +37,10 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/pi-natives": "17.2.9",
41
- "@oh-my-pi/pi-utils": "17.2.9",
42
- "lru-cache": "11.5.2",
43
- "marked": "^18.0.6"
40
+ "@oh-my-pi/pi-natives": "17.2.11",
41
+ "@oh-my-pi/pi-utils": "17.2.11"
44
42
  },
45
43
  "devDependencies": {
46
- "chalk": "^5.6.2",
47
44
  "ghostty-web": "^0.4.0"
48
45
  },
49
46
  "engines": {
@@ -687,7 +687,9 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
687
687
  pathPrefix.startsWith("/") ||
688
688
  pathPrefix.startsWith("./") ||
689
689
  pathPrefix.startsWith("../") ||
690
- pathPrefix.startsWith("~/")
690
+ pathPrefix.startsWith("~/") ||
691
+ // Windows drive-absolute paths (C:/Users, C:\Users).
692
+ /^[A-Za-z]:[\\/]/.test(pathPrefix)
691
693
  ) {
692
694
  return pathPrefix;
693
695
  }
@@ -1,5 +1,12 @@
1
- import { LRUCache } from "lru-cache/raw";
2
- import { Lexer, Marked, type Token, Tokenizer, type TokenizerAndRendererExtension, type Tokens } from "marked";
1
+ import { LRUCache } from "@oh-my-pi/pi-utils/lru";
2
+ import {
3
+ Lexer,
4
+ Marked,
5
+ type Token,
6
+ Tokenizer,
7
+ type TokenizerAndRendererExtension,
8
+ type Tokens,
9
+ } from "@oh-my-pi/pi-utils/marked";
3
10
  import { latexToBlock } from "../latex-block";
4
11
  import { inlineMathSpanEnd, isBareMathEnvironment, latexToUnicode } from "../latex-to-unicode";
5
12
  import type { SymbolTheme } from "../symbols";
@@ -20,6 +20,8 @@
20
20
  // iTerm2, WezTerm, …) keep working unchanged and the BEL emission still fires
21
21
  // for tmux `monitor-bell`, X11 urgency hints, and audible-bell handlers.
22
22
 
23
+ import * as fs from "node:fs";
24
+ import * as path from "node:path";
23
25
  import { $which } from "@oh-my-pi/pi-utils";
24
26
  import type { TerminalId, TerminalNotification } from "./terminal-capabilities";
25
27
 
@@ -36,15 +38,19 @@ export interface DesktopNotifier {
36
38
 
37
39
  /**
38
40
  * 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
+ * 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}.
41
44
  */
42
45
  export function hasLinuxDesktopSession(
43
46
  platform: NodeJS.Platform = process.platform,
44
47
  env: NodeJS.ProcessEnv = Bun.env,
48
+ fileExists: (path: string) => boolean = fs.existsSync,
45
49
  ): boolean {
46
50
  if (platform !== "linux") return false;
47
- return Boolean(env.DBUS_SESSION_BUS_ADDRESS);
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")));
48
54
  }
49
55
 
50
56
  /**
@@ -187,12 +187,12 @@ export class TerminalInfo {
187
187
 
188
188
  /** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */
189
189
  export function isInsideTerminalMultiplexer(env: NodeJS.ProcessEnv = Bun.env): boolean {
190
- // TMUX/STY/ZELLIJ and CMUX workspace/surface/remote-transport markers are
191
- // authoritative session signals. TERM can also survive when those are
190
+ // TMUX/STY/ZELLIJ, Herdr, and CMUX workspace/surface/remote-transport
191
+ // markers are authoritative session signals. TERM can also survive when those are
192
192
  // stripped (`sudo` without -E, `su`, env-sanitizing launchers/ssh). Do not
193
193
  // use CMUX_SOCKET_PATH here: it is a CLI socket override and can be set
194
194
  // outside a CMUX terminal.
195
- if (env.TMUX || env.STY || env.ZELLIJ) return true;
195
+ if (env.TMUX || env.STY || env.ZELLIJ || env.HERDR_ENV === "1") return true;
196
196
  if (env.CMUX_WORKSPACE_ID || env.CMUX_SURFACE_ID || env.CMUX_REMOTE_TRANSPORT) return true;
197
197
  const term = env.TERM?.toLowerCase() ?? "";
198
198
  return term.startsWith("tmux") || term.startsWith("screen");
package/src/terminal.ts CHANGED
@@ -459,12 +459,13 @@ export interface Terminal {
459
459
  callback: (appearance: TerminalAppearance, requestToken?: TerminalAppearanceRequestToken) => void,
460
460
  ): (() => void) | void;
461
461
  /**
462
- * Issue a single OSC 11 background-color re-query, driving the appearance
462
+ * Start a bounded OSC 11 background-color refresh cycle, driving appearance
463
463
  * callbacks through the same parse/dedup pipeline used at startup and on Mode
464
- * 2031 notifications. Bounded: one probe per call, no timers. Invoked on the
465
- * user's explicit display-reset gesture so terminals that cannot
466
- * deliver end-to-end Mode 2031 notifications still pick up a light/dark switch
467
- * without a restart.
464
+ * 2031 notifications. Direct terminals need one query; tmux needs a
465
+ * passthrough query to update its cache followed by one delayed direct cache
466
+ * read. Invoked on the user's explicit display-reset gesture so terminals
467
+ * without end-to-end Mode 2031 notifications pick up a light/dark switch
468
+ * without a restart. No periodic probes are armed.
468
469
  *
469
470
  * A caller-provided token must be propagated unchanged to callbacks and
470
471
  * returned when the request is accepted. This lets callers establish ownership
@@ -517,6 +518,7 @@ function parseOsc99KeyValues(section: string): Map<string, string> {
517
518
  }
518
519
  const XTERM_SCROLL_TO_BOTTOM_MODES = [1010, 1011] as const;
519
520
  type Osc11QueryRoute = "direct" | "tmux";
521
+ const TMUX_OSC11_CACHE_REFRESH_DELAY_MS = 100;
520
522
 
521
523
  function isXtermScrollToBottomMode(mode: number): boolean {
522
524
  return mode === 1010 || mode === 1011;
@@ -596,6 +598,7 @@ export class ProcessTerminal implements Terminal {
596
598
  #osc11QueuedQuery?: { route: Osc11QueryRoute; token?: TerminalAppearanceRequestToken };
597
599
  #nextAppearanceRequestToken = 1;
598
600
  #osc11ResponseBuffer = "";
601
+ #osc11TmuxRefreshTimer?: Timer;
599
602
  #osc99PendingId: string | undefined;
600
603
  #osc99ResponseBuffer = "";
601
604
  #osc99Capabilities = new Map<string, string>();
@@ -671,13 +674,14 @@ export class ProcessTerminal implements Terminal {
671
674
  }
672
675
 
673
676
  /**
674
- * Re-query the terminal background via a single OSC 11 probe. Reuses the
675
- * startup DA1-sentinel FIFO, pending/queued gating, parsing, dedup, and
676
- * appearance callbacks. Inside tmux, only this explicit path wraps the query
677
- * and sentinel together for passthrough to the outer terminal; startup and
678
- * Mode 2031 probes remain direct. Bounded to one probe per call; no timers are
679
- * armed. Suppressed while inactive, headless, or after the terminal is torn
680
- * down.
677
+ * Re-query the terminal background through the startup DA1-sentinel FIFO,
678
+ * pending/queued gating, parsing, dedup, and appearance callbacks. Inside
679
+ * tmux, only this explicit path first passes an OSC 11 query to the outer
680
+ * terminal, waits briefly for tmux to consume the response into its cache,
681
+ * then reads that cache with a direct query. The outer query deliberately has
682
+ * no DA1 sentinel: multiplexers can decode a fragmented DA1 response as a key
683
+ * sequence and leak the remaining bytes into the editor. Startup and Mode 2031
684
+ * probes remain direct. Suppressed while inactive, headless, or after teardown.
681
685
  */
682
686
  refreshAppearance(requestToken?: TerminalAppearanceRequestToken): TerminalAppearanceRequestToken | void {
683
687
  if (!this.#active || this.#headless || this.#dead) return;
@@ -717,10 +721,17 @@ export class ProcessTerminal implements Terminal {
717
721
  // stderr-guard in pi-utils (mirrors openai/codex#24459).
718
722
  suppressTerminalStderr();
719
723
 
720
- // Save previous state and enable raw mode
724
+ // A multiplexer or SSH disconnect can leave isTTY true after its pty has
725
+ // been revoked. Raw mode is then impossible, so take the normal terminal
726
+ // disconnect path rather than letting Bun abort startup with EIO.
721
727
  this.#wasRaw = process.stdin.isRaw || false;
722
728
  if (process.stdin.setRawMode) {
723
- process.stdin.setRawMode(true);
729
+ try {
730
+ process.stdin.setRawMode(true);
731
+ } catch (err) {
732
+ this.#markTerminalDisconnected("stdin raw mode setup failed", err);
733
+ return;
734
+ }
724
735
  }
725
736
  process.stdin.setEncoding("utf8");
726
737
  process.stdin.on("end", this.#stdinEndHandler);
@@ -1034,7 +1045,7 @@ export class ProcessTerminal implements Terminal {
1034
1045
  switch (owner.kind) {
1035
1046
  case "osc11": {
1036
1047
  if (this.#osc11Pending) {
1037
- // DA1 arrived before the OSC 11 reply: terminal does not support OSC 11.
1048
+ // DA1 arrived before OSC 11 response: terminal doesn't support OSC 11.
1038
1049
  this.#osc11Pending = false;
1039
1050
  this.#osc11ActiveToken = undefined;
1040
1051
  this.#osc11ResponseBuffer = "";
@@ -1100,7 +1111,13 @@ export class ProcessTerminal implements Terminal {
1100
1111
  const reportedFlags = parseInt(match[1]!, 10);
1101
1112
  this.#kittyProtocolActive = true;
1102
1113
  setKittyProtocolActive(true);
1103
- if ((reportedFlags & 2) !== 0) {
1114
+ if (isConPTYHosted()) {
1115
+ // ConPTY (native Windows and WSL) drops Shift+letter keypresses
1116
+ // entirely when flag 4 (report alternate keys) is set. Use flag 1
1117
+ // (disambiguate only), preserving flag 2 if already active.
1118
+ this.#kittyEnableSeq = (reportedFlags & 2) !== 0 ? "\x1b[>3u" : "\x1b[>1u";
1119
+ this.#safeWrite(this.#kittyEnableSeq);
1120
+ } else if ((reportedFlags & 2) !== 0) {
1104
1121
  // Preserve event-type reporting already enabled by a parent app.
1105
1122
  // Push level-2 to keep its shortcuts reporting consistently.
1106
1123
  this.#kittyEnableSeq = "\x1b[>7u";
@@ -1208,11 +1225,20 @@ export class ProcessTerminal implements Terminal {
1208
1225
  this.#osc11Pending = true;
1209
1226
  this.#osc11ActiveToken = token;
1210
1227
  this.#osc11ResponseBuffer = "";
1211
- this.#da1SentinelOwners.push({ kind: "osc11" });
1212
1228
  if (route === "tmux") {
1213
- this.#safeWrite(wrapTmuxPassthrough("\x1b]11;?\x07\x1b[c"));
1229
+ this.#safeWrite(wrapTmuxPassthrough("\x1b]11;?\x07"));
1230
+ this.#osc11TmuxRefreshTimer = setTimeout(() => {
1231
+ this.#osc11TmuxRefreshTimer = undefined;
1232
+ if (this.#dead || !this.#osc11Pending) return;
1233
+ this.#startDirectOsc11Query();
1234
+ }, TMUX_OSC11_CACHE_REFRESH_DELAY_MS);
1214
1235
  return;
1215
1236
  }
1237
+ this.#startDirectOsc11Query();
1238
+ }
1239
+
1240
+ #startDirectOsc11Query(): void {
1241
+ this.#da1SentinelOwners.push({ kind: "osc11" });
1216
1242
  this.#safeWrite("\x1b]11;?\x07"); // OSC 11 query (BEL terminated)
1217
1243
  this.#safeWrite("\x1b[c"); // DA1 sentinel
1218
1244
  }
@@ -1549,6 +1575,10 @@ export class ProcessTerminal implements Terminal {
1549
1575
  clearTimeout(this.#mode2031DebounceTimer);
1550
1576
  this.#mode2031DebounceTimer = undefined;
1551
1577
  }
1578
+ if (this.#osc11TmuxRefreshTimer) {
1579
+ clearTimeout(this.#osc11TmuxRefreshTimer);
1580
+ this.#osc11TmuxRefreshTimer = undefined;
1581
+ }
1552
1582
  this.#appearanceCallbacks = [];
1553
1583
  this.#appearanceReportCallbacks = [];
1554
1584
  this.#osc11Pending = false;