@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.
- package/CHANGELOG.md +25 -1
- package/dist/types/components/gajae-pet.d.ts +121 -22
- package/dist/types/components/ouroboros-pet.d.ts +23 -0
- package/dist/types/components/select-list.d.ts +3 -2
- package/dist/types/terminal-capabilities.d.ts +22 -0
- package/dist/types/terminal.d.ts +9 -0
- package/dist/types/tui.d.ts +122 -4
- package/package.json +3 -3
- package/src/components/gajae-pet.ts +645 -49
- package/src/components/ouroboros-pet-frames.json +488 -0
- package/src/components/ouroboros-pet.ts +150 -0
- package/src/components/select-list.ts +17 -11
- package/src/terminal-capabilities.ts +137 -0
- package/src/terminal.ts +33 -0
- package/src/tui.ts +1026 -135
|
@@ -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
|
|
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(
|
|
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(
|
|
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
|
|
197
|
-
|
|
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
|
|
219
|
+
return theme.description(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`);
|
|
214
220
|
}
|
|
215
221
|
if (isSelected) {
|
|
216
|
-
return
|
|
222
|
+
return theme.selectedText(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`);
|
|
217
223
|
}
|
|
218
224
|
|
|
219
|
-
const descText =
|
|
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
|
|
233
|
+
return theme.description(`${prefix}${truncatedValue}`);
|
|
228
234
|
}
|
|
229
235
|
if (isSelected) {
|
|
230
|
-
return
|
|
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
|