@gajae-code/tui 0.13.2 → 0.14.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.
@@ -39,6 +39,8 @@ export interface SelectListTheme {
39
39
  symbols: SymbolTheme;
40
40
  }
41
41
 
42
+ export type SelectListThemeSource = SelectListTheme | (() => SelectListTheme);
43
+
42
44
  export interface SelectListTruncatePrimaryContext {
43
45
  text: string;
44
46
  maxWidth: number;
@@ -67,7 +69,7 @@ export class SelectList implements Component {
67
69
  constructor(
68
70
  private readonly items: ReadonlyArray<SelectItem>,
69
71
  private readonly maxVisible: number,
70
- private readonly theme: SelectListTheme,
72
+ private readonly themeSource: SelectListThemeSource,
71
73
  private readonly layout: SelectListLayoutOptions = {},
72
74
  ) {
73
75
  this.#filteredItems = items;
@@ -114,11 +116,12 @@ export class SelectList implements Component {
114
116
  }
115
117
 
116
118
  render(width: number): string[] {
119
+ const theme = this.#theme();
117
120
  const lines: string[] = [];
118
121
 
119
122
  // If no items match filter, show message
120
123
  if (this.#filteredItems.length === 0) {
121
- lines.push(truncateToWidth(this.theme.noMatch(" No matching commands"), Math.max(0, width), Ellipsis.Omit));
124
+ lines.push(truncateToWidth(theme.noMatch(" No matching commands"), Math.max(0, width), Ellipsis.Omit));
122
125
  return lines;
123
126
  }
124
127
 
@@ -146,12 +149,16 @@ export class SelectList implements Component {
146
149
  const position = this.#selectedIndex >= 0 ? `${this.#selectedIndex + 1}` : "-";
147
150
  const scrollText = ` (${position}/${this.#filteredItems.length})`;
148
151
  // Truncate if too long for terminal
149
- lines.push(this.theme.scrollInfo(truncateToWidth(scrollText, width - 2, Ellipsis.Omit)));
152
+ lines.push(theme.scrollInfo(truncateToWidth(scrollText, width - 2, Ellipsis.Omit)));
150
153
  }
151
154
 
152
155
  return lines;
153
156
  }
154
157
 
158
+ #theme(): SelectListTheme {
159
+ return typeof this.themeSource === "function" ? this.themeSource() : this.themeSource;
160
+ }
161
+
155
162
  handleInput(keyData: string): void {
156
163
  const kb = getKeybindings();
157
164
  if (this.#filteredItems.length === 0) {
@@ -193,9 +200,8 @@ export class SelectList implements Component {
193
200
  descriptionSingleLine: string | undefined,
194
201
  primaryColumnWidth: number,
195
202
  ): string {
196
- const prefix = isSelected
197
- ? `${this.theme.symbols.cursor} `
198
- : padding(visibleWidth(this.theme.symbols.cursor) + 1);
203
+ const theme = this.#theme();
204
+ const prefix = isSelected ? `${theme.symbols.cursor} ` : padding(visibleWidth(theme.symbols.cursor) + 1);
199
205
  const prefixWidth = visibleWidth(prefix);
200
206
 
201
207
  if (descriptionSingleLine && width > 40) {
@@ -210,13 +216,13 @@ export class SelectList implements Component {
210
216
  if (remainingWidth > MIN_DESCRIPTION_WIDTH) {
211
217
  const truncatedDesc = truncateToWidth(descriptionSingleLine, remainingWidth, Ellipsis.Omit);
212
218
  if (item.disabled) {
213
- return this.theme.description(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`);
219
+ return theme.description(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`);
214
220
  }
215
221
  if (isSelected) {
216
- return this.theme.selectedText(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`);
222
+ return theme.selectedText(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`);
217
223
  }
218
224
 
219
- const descText = this.theme.description(spacing + truncatedDesc);
225
+ const descText = theme.description(spacing + truncatedDesc);
220
226
  return prefix + truncatedValue + descText;
221
227
  }
222
228
  }
@@ -224,10 +230,10 @@ export class SelectList implements Component {
224
230
  const maxWidth = width - prefixWidth - 2;
225
231
  const truncatedValue = this.#truncatePrimary(item, isSelected, maxWidth, maxWidth);
226
232
  if (item.disabled) {
227
- return this.theme.description(`${prefix}${truncatedValue}`);
233
+ return theme.description(`${prefix}${truncatedValue}`);
228
234
  }
229
235
  if (isSelected) {
230
- return this.theme.selectedText(`${prefix}${truncatedValue}`);
236
+ return theme.selectedText(`${prefix}${truncatedValue}`);
231
237
  }
232
238
 
233
239
  return prefix + truncatedValue;
@@ -15,6 +15,8 @@ export enum ImageProtocol {
15
15
  Sixel = "\x1bPq",
16
16
  }
17
17
 
18
+ const ITERM2_MULTIPART_IMAGE_PREFIX = "\x1b]1337;MultipartFile=";
19
+
18
20
  export enum NotifyProtocol {
19
21
  Bell = "\x07",
20
22
  Osc99 = "\x1b]99;;",
@@ -39,6 +41,10 @@ export class TerminalInfo {
39
41
  if (this.imageProtocol === ImageProtocol.Sixel) {
40
42
  return SIXEL_DCS_START_REGEX.test(line.slice(0, 128));
41
43
  }
44
+ if (this.imageProtocol === ImageProtocol.Iterm2) {
45
+ const prefix = line.slice(0, 64);
46
+ return prefix.includes(ImageProtocol.Iterm2) || prefix.includes(ITERM2_MULTIPART_IMAGE_PREFIX);
47
+ }
42
48
  return line.slice(0, 64).includes(this.imageProtocol);
43
49
  }
44
50
 
@@ -867,3 +873,134 @@ export function imageFallback(mimeType: string, dimensions?: ImageDimensions, fi
867
873
  if (dimensions) parts.push(`${dimensions.widthPx}x${dimensions.heightPx}`);
868
874
  return `[Image: ${parts.join(" ")}]`;
869
875
  }
876
+ export type Iterm2Capability = { readonly key: string; readonly value: string };
877
+ export type Iterm2CapabilityReply = "complete-f" | "missing-f" | "invalid-f" | undefined;
878
+
879
+ const ITERM2_CAPABILITY_REPLY_REGEX = /\x1b\]1337;Capabilities(?:=|:)([^\x07\x1b]*)(?:\x07|\x1b\\)/gu;
880
+ const ITERM2_FEATURE_TOKEN_REGEX = /^[A-Z][a-z]*[0-9]*$/u;
881
+
882
+ /**
883
+ * Classifies complete iTerm2 capability replies. An absent result means the
884
+ * input does not yet contain a complete capability frame.
885
+ */
886
+ export function parseITerm2CapabilityReply(input: Uint8Array | string): Iterm2CapabilityReply {
887
+ const value = typeof input === "string" ? input : new TextDecoder().decode(input);
888
+ let complete = false;
889
+ for (const match of value.matchAll(ITERM2_CAPABILITY_REPLY_REGEX)) {
890
+ complete = true;
891
+ const featureString = match[1] ?? "";
892
+ const tokens: string[] = featureString.match(/[A-Z][a-z]*[0-9]*/gu) ?? [];
893
+ if (tokens.join("") !== featureString || !tokens.every(token => ITERM2_FEATURE_TOKEN_REGEX.test(token))) {
894
+ return "invalid-f";
895
+ }
896
+ if (tokens.includes("F")) return "complete-f";
897
+ }
898
+ return complete ? "missing-f" : undefined;
899
+ }
900
+
901
+ const ITERM2_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u;
902
+ const ITERM2_MAX_CAPABILITY_BYTES = 4096;
903
+
904
+ function assertIterm2Base64(value: string): void {
905
+ if (value.length % 4 !== 0 || !ITERM2_BASE64.test(value)) throw new Error("Invalid RFC 4648 base64");
906
+ }
907
+
908
+ export function encodeITerm2Multipart(
909
+ base64Data: string,
910
+ options: { width?: number | string; height?: number | string } = {},
911
+ ): string[] {
912
+ assertIterm2Base64(base64Data);
913
+ const validate = (value: number | string, label: string): number | string => {
914
+ if (
915
+ typeof value === "number" &&
916
+ (!Number.isFinite(value) || !Number.isInteger(value) || value <= 0 || value > 0xffff)
917
+ )
918
+ throw new Error(`Invalid iTerm2 ${label}`);
919
+ if (typeof value === "string" && !/^[A-Za-z0-9]+$/u.test(value)) throw new Error(`Invalid iTerm2 ${label}`);
920
+ return value;
921
+ };
922
+ const width = validate(options.width ?? "auto", "width");
923
+ const height = validate(options.height ?? "auto", "height");
924
+ const size = Buffer.from(base64Data, "base64").byteLength;
925
+ const name = Buffer.from("gajae-pet.gif").toString("base64");
926
+ const records = [
927
+ `\x1b]1337;MultipartFile=;name=${name};size=${size};width=${width};height=${height};inline=1;preserveAspectRatio=0:\x07`,
928
+ ];
929
+ for (let i = 0; i < base64Data.length; i += 200) {
930
+ records.push(`\x1b]1337;FilePart=${base64Data.slice(i, i + 200)}\x07`);
931
+ }
932
+ records.push("\x1b]1337;FileEnd\x07");
933
+ for (const record of records) {
934
+ if (Buffer.byteLength(`\x1bPtmux;${record.replaceAll("\x1b", "\x1b\x1b")}\x1b\\`, "utf8") > 256)
935
+ throw new Error("iTerm2 record exceeds tmux limit");
936
+ }
937
+ return records;
938
+ }
939
+
940
+ export function wrapITerm2RecordForTmux(record: string): string {
941
+ const wrapped = `\x1bPtmux;${record.replaceAll("\x1b", "\x1b\x1b")}\x1b\\`;
942
+ if (Buffer.byteLength(wrapped, "utf8") > 256) throw new Error("iTerm2 record exceeds tmux limit");
943
+ return wrapped;
944
+ }
945
+
946
+ export function wrapITerm2RecordsForTmux(records: readonly string[]): string[] {
947
+ return records.map(wrapITerm2RecordForTmux);
948
+ }
949
+
950
+ function parseIterm2CapabilityString(value: string): Iterm2Capability[] {
951
+ const out: Iterm2Capability[] = [];
952
+ for (const pair of value.split(";")) {
953
+ const i = pair.indexOf("=");
954
+ if (i > 0) out.push({ key: pair.slice(0, i), value: pair.slice(i + 1) });
955
+ else if (i < 0 && pair.length > 0) out.push({ key: pair, value: "" });
956
+ }
957
+ return out;
958
+ }
959
+
960
+ export function parseITerm2Capabilities(input: string): Iterm2Capability[] {
961
+ const parser = new Iterm2CapabilitiesParser();
962
+ return parser.push(input);
963
+ }
964
+
965
+ export class Iterm2CapabilitiesParser {
966
+ #buffer = "";
967
+ push(input: Uint8Array | string): Iterm2Capability[] {
968
+ this.#buffer += typeof input === "string" ? input : new TextDecoder().decode(input);
969
+ const out: Iterm2Capability[] = [];
970
+ while (true) {
971
+ const marker = this.#buffer.indexOf("\x1b]1337;Capabilities=");
972
+ if (marker < 0) {
973
+ this.#buffer = this.#buffer.slice(-32);
974
+ break;
975
+ }
976
+ const valueStart = marker + "\x1b]1337;Capabilities=".length;
977
+ let end = -1;
978
+ for (let i = valueStart; i < this.#buffer.length; i++) {
979
+ if (this.#buffer[i] === "\x07") {
980
+ end = i + 1;
981
+ break;
982
+ }
983
+ if (this.#buffer[i] === "\x1b" && this.#buffer[i + 1] === "\\") {
984
+ end = i + 2;
985
+ break;
986
+ }
987
+ if (i - valueStart > ITERM2_MAX_CAPABILITY_BYTES) {
988
+ end = -2;
989
+ break;
990
+ }
991
+ }
992
+ if (end === -1) break;
993
+ if (end === -2) {
994
+ this.#buffer = this.#buffer.slice(valueStart + 1);
995
+ continue;
996
+ }
997
+ const terminatorLength = this.#buffer[end - 2] === "\x1b" ? 2 : 1;
998
+ out.push(...parseIterm2CapabilityString(this.#buffer.slice(valueStart, end - terminatorLength)));
999
+ this.#buffer = this.#buffer.slice(end);
1000
+ }
1001
+ return out;
1002
+ }
1003
+ reset(): void {
1004
+ this.#buffer = "";
1005
+ }
1006
+ }
package/src/terminal.ts CHANGED
@@ -118,6 +118,15 @@ export interface Terminal {
118
118
  */
119
119
  drainInput(maxMs?: number, idleMs?: number): Promise<void>;
120
120
 
121
+ /**
122
+ * Wait for pending stdin to go quiet without changing terminal protocols or
123
+ * the active input handler. Capability probes use this non-destructive drain;
124
+ * shutdown paths must continue using drainInput().
125
+ * @param maxMs - Maximum time to wait (default: 1000ms)
126
+ * @param idleMs - Exit early if no input arrives within this time (default: 50ms)
127
+ */
128
+ drainPendingInput?(maxMs?: number, idleMs?: number): Promise<void>;
129
+
121
130
  // Write output to terminal
122
131
  write(data: string): void;
123
132
 
@@ -822,6 +831,30 @@ export class ProcessTerminal implements Terminal {
822
831
  }, 150);
823
832
  }
824
833
 
834
+ async drainPendingInput(maxMs = 1000, idleMs = 50): Promise<void> {
835
+ let lastDataTime = Date.now();
836
+ const onData = () => {
837
+ lastDataTime = Date.now();
838
+ };
839
+
840
+ process.stdin.on("data", onData);
841
+ const endTime = Date.now() + maxMs;
842
+
843
+ try {
844
+ while (true) {
845
+ const now = Date.now();
846
+ const timeLeft = endTime - now;
847
+ if (timeLeft <= 0) break;
848
+ if (now - lastDataTime >= idleMs) break;
849
+ const { promise, resolve } = Promise.withResolvers<void>();
850
+ setTimeout(resolve, Math.min(idleMs, timeLeft));
851
+ await promise;
852
+ }
853
+ } finally {
854
+ process.stdin.removeListener("data", onData);
855
+ }
856
+ }
857
+
825
858
  async drainInput(maxMs = 1000, idleMs = 50): Promise<void> {
826
859
  if (this.#kittyProtocolActive) {
827
860
  // Disable Kitty keyboard protocol first so any late key releases