@gajae-code/tui 0.13.3 → 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.
@@ -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