tuidoscope 0.1.4

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.
Files changed (3) hide show
  1. package/README.md +110 -0
  2. package/dist/index.js +1717 -0
  3. package/package.json +50 -0
package/dist/index.js ADDED
@@ -0,0 +1,1717 @@
1
+ // @bun
2
+ // src/index.tsx
3
+ import { render, extend } from "@opentui/solid";
4
+ import { GhosttyTerminalRenderable } from "ghostty-opentui/terminal-buffer";
5
+
6
+ // src/app.tsx
7
+ import { Show as Show3, createSignal as createSignal4, createEffect as createEffect2, onCleanup, createMemo as createMemo3 } from "solid-js";
8
+ import { useKeyboard as useKeyboard4, useTerminalDimensions, useRenderer } from "@opentui/solid";
9
+
10
+ // src/components/TabList.tsx
11
+ import { For, createMemo } from "solid-js";
12
+
13
+ // src/components/TabItem.tsx
14
+ import { jsxDEV } from "@opentui/solid/jsx-dev-runtime";
15
+ function getStatusIndicator(status) {
16
+ switch (status) {
17
+ case "running":
18
+ return "\u25CF";
19
+ case "stopped":
20
+ return "\u25CB";
21
+ case "error":
22
+ return "\u2716";
23
+ }
24
+ }
25
+ function getStatusColor(status, theme) {
26
+ switch (status) {
27
+ case "running":
28
+ return "#9ece6a";
29
+ case "stopped":
30
+ return theme.muted;
31
+ case "error":
32
+ return "#f7768e";
33
+ }
34
+ }
35
+ var TabItem = (props) => {
36
+ const truncatedName = () => {
37
+ const maxLen = props.width - 4;
38
+ if (props.entry.name.length > maxLen) {
39
+ return props.entry.name.slice(0, maxLen - 1) + "\u2026";
40
+ }
41
+ return props.entry.name;
42
+ };
43
+ const bgColor = () => {
44
+ if (props.isActive) {
45
+ return props.theme.primary;
46
+ }
47
+ if (props.isFocused) {
48
+ return props.theme.muted;
49
+ }
50
+ return props.theme.background;
51
+ };
52
+ const fgColor = () => {
53
+ if (props.isActive) {
54
+ return props.theme.background;
55
+ }
56
+ return props.theme.foreground;
57
+ };
58
+ return /* @__PURE__ */ jsxDEV("box", {
59
+ height: 1,
60
+ width: props.width,
61
+ flexDirection: "row",
62
+ onMouseDown: props.onSelect,
63
+ children: [
64
+ /* @__PURE__ */ jsxDEV("text", {
65
+ fg: getStatusColor(props.status, props.theme),
66
+ children: getStatusIndicator(props.status)
67
+ }, undefined, false, undefined, this),
68
+ /* @__PURE__ */ jsxDEV("text", {
69
+ children: " "
70
+ }, undefined, false, undefined, this),
71
+ /* @__PURE__ */ jsxDEV("text", {
72
+ fg: fgColor(),
73
+ bg: bgColor(),
74
+ children: props.isActive ? /* @__PURE__ */ jsxDEV("b", {
75
+ children: truncatedName()
76
+ }, undefined, false, undefined, this) : truncatedName()
77
+ }, undefined, false, undefined, this)
78
+ ]
79
+ }, undefined, true, undefined, this);
80
+ };
81
+
82
+ // src/components/TabList.tsx
83
+ import { jsxDEV as jsxDEV2 } from "@opentui/solid/jsx-dev-runtime";
84
+ var TabList = (props) => {
85
+ const visibleHeight = () => props.height - 2;
86
+ const visibleEntries = createMemo(() => {
87
+ const start = props.scrollOffset;
88
+ const end = start + visibleHeight();
89
+ return props.entries.slice(start, end);
90
+ });
91
+ const hasScrollUp = () => props.scrollOffset > 0;
92
+ const hasScrollDown = () => props.scrollOffset + visibleHeight() < props.entries.length;
93
+ return /* @__PURE__ */ jsxDEV2("box", {
94
+ flexDirection: "column",
95
+ width: props.width,
96
+ height: props.height,
97
+ borderStyle: "single",
98
+ borderColor: props.isFocused ? props.theme.primary : props.theme.muted,
99
+ children: [
100
+ /* @__PURE__ */ jsxDEV2("box", {
101
+ height: 1,
102
+ width: props.width - 2,
103
+ children: /* @__PURE__ */ jsxDEV2("text", {
104
+ fg: props.theme.accent,
105
+ children: /* @__PURE__ */ jsxDEV2("b", {
106
+ children: [
107
+ "Apps ",
108
+ hasScrollUp() ? "\u25B2" : " ",
109
+ hasScrollDown() ? "\u25BC" : " "
110
+ ]
111
+ }, undefined, true, undefined, this)
112
+ }, undefined, false, undefined, this)
113
+ }, undefined, false, undefined, this),
114
+ /* @__PURE__ */ jsxDEV2("box", {
115
+ flexDirection: "column",
116
+ flexGrow: 1,
117
+ children: /* @__PURE__ */ jsxDEV2(For, {
118
+ each: visibleEntries(),
119
+ children: (entry, index) => {
120
+ const actualIndex = () => props.scrollOffset + index();
121
+ return /* @__PURE__ */ jsxDEV2(TabItem, {
122
+ entry,
123
+ status: props.getStatus(entry.id),
124
+ isActive: entry.id === props.activeTabId,
125
+ isFocused: props.isFocused && actualIndex() === props.selectedIndex,
126
+ width: props.width - 2,
127
+ theme: props.theme,
128
+ onSelect: () => props.onSelect(entry.id)
129
+ }, undefined, false, undefined, this);
130
+ }
131
+ }, undefined, false, undefined, this)
132
+ }, undefined, false, undefined, this),
133
+ /* @__PURE__ */ jsxDEV2("box", {
134
+ height: 1,
135
+ width: props.width - 2,
136
+ onMouseDown: props.onAddClick,
137
+ children: /* @__PURE__ */ jsxDEV2("text", {
138
+ fg: props.theme.muted,
139
+ children: "[+ Add]"
140
+ }, undefined, false, undefined, this)
141
+ }, undefined, false, undefined, this)
142
+ ]
143
+ }, undefined, true, undefined, this);
144
+ };
145
+
146
+ // src/components/TerminalPane.tsx
147
+ import { Show } from "solid-js";
148
+ import { jsxDEV as jsxDEV3 } from "@opentui/solid/jsx-dev-runtime";
149
+ var TerminalPane = (props) => {
150
+ const contentWidth = () => Math.max(1, props.width - 2);
151
+ const contentHeight = () => Math.max(1, props.height - 3);
152
+ return /* @__PURE__ */ jsxDEV3("box", {
153
+ flexDirection: "column",
154
+ flexGrow: 1,
155
+ height: props.height,
156
+ borderStyle: "single",
157
+ borderColor: props.isFocused ? props.theme.primary : props.theme.muted,
158
+ children: /* @__PURE__ */ jsxDEV3(Show, {
159
+ when: props.runningApp,
160
+ fallback: /* @__PURE__ */ jsxDEV3("box", {
161
+ flexGrow: 1,
162
+ justifyContent: "center",
163
+ alignItems: "center",
164
+ children: /* @__PURE__ */ jsxDEV3("text", {
165
+ fg: props.theme.muted,
166
+ children: "No app selected. Press Ctrl+T to add one."
167
+ }, undefined, false, undefined, this)
168
+ }, undefined, false, undefined, this),
169
+ children: (app) => /* @__PURE__ */ jsxDEV3("box", {
170
+ flexDirection: "column",
171
+ flexGrow: 1,
172
+ children: [
173
+ /* @__PURE__ */ jsxDEV3("box", {
174
+ height: 1,
175
+ flexDirection: "row",
176
+ children: [
177
+ /* @__PURE__ */ jsxDEV3("text", {
178
+ fg: props.theme.accent,
179
+ children: /* @__PURE__ */ jsxDEV3("b", {
180
+ children: app().entry.name
181
+ }, undefined, false, undefined, this)
182
+ }, undefined, false, undefined, this),
183
+ /* @__PURE__ */ jsxDEV3("text", {
184
+ fg: props.theme.muted,
185
+ children: [
186
+ " ",
187
+ "(",
188
+ app().status,
189
+ ")"
190
+ ]
191
+ }, undefined, true, undefined, this)
192
+ ]
193
+ }, undefined, true, undefined, this),
194
+ /* @__PURE__ */ jsxDEV3("box", {
195
+ width: contentWidth(),
196
+ height: contentHeight(),
197
+ overflow: "hidden",
198
+ children: /* @__PURE__ */ jsxDEV3("ghostty-terminal", {
199
+ ansi: app().buffer,
200
+ cols: contentWidth(),
201
+ rows: contentHeight(),
202
+ showCursor: true,
203
+ style: { width: contentWidth(), height: contentHeight() }
204
+ }, undefined, false, undefined, this)
205
+ }, undefined, false, undefined, this)
206
+ ]
207
+ }, undefined, true, undefined, this)
208
+ }, undefined, false, undefined, this)
209
+ }, undefined, false, undefined, this);
210
+ };
211
+
212
+ // src/components/StatusBar.tsx
213
+ import { Show as Show2 } from "solid-js";
214
+
215
+ // src/lib/keybinds.ts
216
+ function parseKeybind(keybind) {
217
+ const parts = keybind.toLowerCase().split("+");
218
+ const key = parts[parts.length - 1];
219
+ return {
220
+ key,
221
+ ctrl: parts.includes("ctrl"),
222
+ alt: parts.includes("alt"),
223
+ shift: parts.includes("shift"),
224
+ meta: parts.includes("meta") || parts.includes("cmd")
225
+ };
226
+ }
227
+ function matchesKeybind(event, keybind) {
228
+ const parsed = parseKeybind(keybind);
229
+ const eventAlt = event.option ?? false;
230
+ const eventName = event.name.toLowerCase();
231
+ const isSpaceKey = parsed.key === "space";
232
+ const eventIsSpace = eventName === "space" || eventName === " " || event.sequence === " " || event.sequence === "\x00";
233
+ const ctrlSequence = parsed.ctrl && parsed.key.length === 1 && parsed.key >= "a" && parsed.key <= "z" ? String.fromCharCode(parsed.key.charCodeAt(0) - 96) : null;
234
+ const ctrlSequenceMatch = !!ctrlSequence && event.sequence === ctrlSequence;
235
+ const keyMatches = isSpaceKey ? eventIsSpace : eventName === parsed.key || ctrlSequenceMatch;
236
+ const ctrlMatches = parsed.ctrl ? event.ctrl || ctrlSequenceMatch : !event.ctrl;
237
+ return keyMatches && ctrlMatches && eventAlt === parsed.alt && event.shift === parsed.shift && event.meta === parsed.meta;
238
+ }
239
+ function createKeybindHandler(config, handlers) {
240
+ const keybindMap = {
241
+ [config.next_tab]: "next_tab",
242
+ [config.prev_tab]: "prev_tab",
243
+ [config.close_tab]: "close_tab",
244
+ [config.new_tab]: "new_tab",
245
+ [config.toggle_focus]: "toggle_focus",
246
+ [config.edit_app]: "edit_app",
247
+ [config.restart_app]: "restart_app",
248
+ [config.command_palette]: "command_palette",
249
+ [config.stop_app]: "stop_app",
250
+ [config.kill_all]: "kill_all",
251
+ [config.quit]: "quit"
252
+ };
253
+ return (event) => {
254
+ for (const [keybind, action] of Object.entries(keybindMap)) {
255
+ if (matchesKeybind(event, keybind)) {
256
+ const handler = handlers[action];
257
+ if (handler) {
258
+ handler();
259
+ return true;
260
+ }
261
+ }
262
+ }
263
+ return false;
264
+ };
265
+ }
266
+ function formatKeybind(keybind) {
267
+ return keybind.split("+").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("+");
268
+ }
269
+
270
+ // src/components/StatusBar.tsx
271
+ import { jsxDEV as jsxDEV4 } from "@opentui/solid/jsx-dev-runtime";
272
+ var StatusBar = (props) => {
273
+ return /* @__PURE__ */ jsxDEV4("box", {
274
+ height: 1,
275
+ flexDirection: "row",
276
+ backgroundColor: props.theme.muted,
277
+ children: [
278
+ /* @__PURE__ */ jsxDEV4("box", {
279
+ flexGrow: 1,
280
+ children: /* @__PURE__ */ jsxDEV4("text", {
281
+ fg: props.theme.foreground,
282
+ children: [
283
+ " ",
284
+ formatKeybind(props.keybinds.toggle_focus),
285
+ ":Focus",
286
+ " | ",
287
+ formatKeybind(props.keybinds.command_palette),
288
+ ":Palette",
289
+ " | ",
290
+ formatKeybind(props.keybinds.edit_app),
291
+ ":Edit",
292
+ " | ",
293
+ formatKeybind(props.keybinds.stop_app),
294
+ ":Stop",
295
+ " | ",
296
+ formatKeybind(props.keybinds.kill_all),
297
+ ":KillAll",
298
+ " | ",
299
+ formatKeybind(props.keybinds.quit),
300
+ ":Quit"
301
+ ]
302
+ }, undefined, true, undefined, this)
303
+ }, undefined, false, undefined, this),
304
+ /* @__PURE__ */ jsxDEV4("box", {
305
+ children: /* @__PURE__ */ jsxDEV4(Show2, {
306
+ when: props.message,
307
+ fallback: /* @__PURE__ */ jsxDEV4(Show2, {
308
+ when: props.appName,
309
+ children: /* @__PURE__ */ jsxDEV4("text", {
310
+ fg: props.theme.accent,
311
+ children: [
312
+ props.appName,
313
+ props.appStatus ? ` (${props.appStatus})` : ""
314
+ ]
315
+ }, undefined, true, undefined, this)
316
+ }, undefined, false, undefined, this),
317
+ children: /* @__PURE__ */ jsxDEV4("text", {
318
+ fg: props.theme.accent,
319
+ children: /* @__PURE__ */ jsxDEV4("b", {
320
+ children: props.message
321
+ }, undefined, false, undefined, this)
322
+ }, undefined, false, undefined, this)
323
+ }, undefined, false, undefined, this)
324
+ }, undefined, false, undefined, this),
325
+ /* @__PURE__ */ jsxDEV4("box", {
326
+ children: /* @__PURE__ */ jsxDEV4("text", {
327
+ fg: props.theme.foreground,
328
+ children: [
329
+ props.focusMode === "terminal" ? "[TERMINAL]" : "[TABS]",
330
+ " "
331
+ ]
332
+ }, undefined, true, undefined, this)
333
+ }, undefined, false, undefined, this)
334
+ ]
335
+ }, undefined, true, undefined, this);
336
+ };
337
+
338
+ // src/components/CommandPalette.tsx
339
+ import { For as For2, createSignal, createMemo as createMemo2, createEffect } from "solid-js";
340
+ import { useKeyboard } from "@opentui/solid";
341
+
342
+ // src/lib/fuzzy.ts
343
+ import Fuse from "fuse.js";
344
+ function createAppSearch(entries) {
345
+ const fuse = new Fuse(entries, {
346
+ keys: ["name", "command", "args"],
347
+ threshold: 0.4,
348
+ includeScore: true
349
+ });
350
+ return {
351
+ search: (query) => {
352
+ if (!query.trim()) {
353
+ return entries.map((item) => ({ item, score: 0 }));
354
+ }
355
+ const results = fuse.search(query);
356
+ return results.map((r) => ({
357
+ item: r.item,
358
+ score: r.score ?? 0
359
+ }));
360
+ },
361
+ update: (newEntries) => {
362
+ fuse.setCollection(newEntries);
363
+ }
364
+ };
365
+ }
366
+
367
+ // src/lib/command.ts
368
+ function buildCommand(command, args) {
369
+ const trimmedArgs = args?.trim();
370
+ return trimmedArgs ? `${command} ${trimmedArgs}` : command;
371
+ }
372
+ function buildEntryCommand(entry) {
373
+ return buildCommand(entry.command, entry.args);
374
+ }
375
+
376
+ // src/components/CommandPalette.tsx
377
+ import { jsxDEV as jsxDEV5 } from "@opentui/solid/jsx-dev-runtime";
378
+ var CommandPalette = (props) => {
379
+ const [query, setQuery] = createSignal("");
380
+ const [selectedIndex, setSelectedIndex] = createSignal(0);
381
+ const search = createMemo2(() => createAppSearch(props.entries));
382
+ const results = createMemo2(() => {
383
+ return search().search(query());
384
+ });
385
+ useKeyboard((event) => {
386
+ if (event.name === "escape") {
387
+ props.onClose();
388
+ event.preventDefault();
389
+ return;
390
+ }
391
+ if (event.name === "return" || event.name === "enter") {
392
+ const selected = results()[selectedIndex()];
393
+ if (selected) {
394
+ props.onSelect(selected.item, "switch");
395
+ }
396
+ event.preventDefault();
397
+ return;
398
+ }
399
+ if (event.name === "x") {
400
+ const selected = results()[selectedIndex()];
401
+ if (selected) {
402
+ props.onSelect(selected.item, "stop");
403
+ }
404
+ event.preventDefault();
405
+ return;
406
+ }
407
+ if (event.ctrl && event.name === "e" || event.sequence === "\x05") {
408
+ const selected = results()[selectedIndex()];
409
+ if (selected) {
410
+ props.onSelect(selected.item, "edit");
411
+ }
412
+ event.preventDefault();
413
+ return;
414
+ }
415
+ if (event.name === "up" || event.name === "k") {
416
+ setSelectedIndex((current) => Math.max(0, current - 1));
417
+ event.preventDefault();
418
+ return;
419
+ }
420
+ if (event.name === "down" || event.name === "j") {
421
+ setSelectedIndex((current) => Math.min(results().length - 1, current + 1));
422
+ event.preventDefault();
423
+ return;
424
+ }
425
+ if (event.name === "backspace") {
426
+ setQuery((current) => current.slice(0, -1));
427
+ event.preventDefault();
428
+ return;
429
+ }
430
+ if (!event.ctrl && !event.meta && !event.option && event.sequence && event.sequence.length === 1) {
431
+ setQuery((current) => current + event.sequence);
432
+ event.preventDefault();
433
+ }
434
+ });
435
+ createEffect(() => {
436
+ results();
437
+ setSelectedIndex(0);
438
+ });
439
+ return /* @__PURE__ */ jsxDEV5("box", {
440
+ position: "absolute",
441
+ top: "20%",
442
+ left: "20%",
443
+ width: "60%",
444
+ height: "60%",
445
+ flexDirection: "column",
446
+ borderStyle: "double",
447
+ borderColor: props.theme.primary,
448
+ backgroundColor: props.theme.background,
449
+ children: [
450
+ /* @__PURE__ */ jsxDEV5("box", {
451
+ height: 1,
452
+ flexDirection: "row",
453
+ borderStyle: "single",
454
+ borderColor: props.theme.muted,
455
+ children: /* @__PURE__ */ jsxDEV5("text", {
456
+ fg: props.theme.foreground,
457
+ children: `> ${query()}\u2588`
458
+ }, undefined, false, undefined, this)
459
+ }, undefined, false, undefined, this),
460
+ /* @__PURE__ */ jsxDEV5("box", {
461
+ flexDirection: "column",
462
+ flexGrow: 1,
463
+ overflow: "hidden",
464
+ children: /* @__PURE__ */ jsxDEV5(For2, {
465
+ each: results().slice(0, 10),
466
+ children: (result, index) => /* @__PURE__ */ jsxDEV5("box", {
467
+ height: 1,
468
+ width: "100%",
469
+ flexDirection: "row",
470
+ backgroundColor: index() === selectedIndex() ? props.theme.primary : props.theme.background,
471
+ onMouseDown: () => props.onSelect(result.item, "switch"),
472
+ children: /* @__PURE__ */ jsxDEV5("text", {
473
+ width: "100%",
474
+ fg: index() === selectedIndex() ? props.theme.background : props.theme.foreground,
475
+ bg: index() === selectedIndex() ? props.theme.primary : props.theme.background,
476
+ children: [
477
+ " ",
478
+ `${result.item.name} - ${buildEntryCommand(result.item)}`
479
+ ]
480
+ }, undefined, true, undefined, this)
481
+ }, undefined, false, undefined, this)
482
+ }, undefined, false, undefined, this)
483
+ }, undefined, false, undefined, this),
484
+ /* @__PURE__ */ jsxDEV5("box", {
485
+ height: 1,
486
+ borderStyle: "single",
487
+ borderColor: props.theme.muted,
488
+ children: /* @__PURE__ */ jsxDEV5("text", {
489
+ fg: props.theme.muted,
490
+ children: "Enter:Select | x:Stop | Ctrl+E:Edit | Esc:Close | \u2191\u2193:Navigate"
491
+ }, undefined, false, undefined, this)
492
+ }, undefined, false, undefined, this)
493
+ ]
494
+ }, undefined, true, undefined, this);
495
+ };
496
+
497
+ // src/components/AddTabModal.tsx
498
+ import { createSignal as createSignal2 } from "solid-js";
499
+ import { useKeyboard as useKeyboard2 } from "@opentui/solid";
500
+ import { jsxDEV as jsxDEV6 } from "@opentui/solid/jsx-dev-runtime";
501
+ var AddTabModal = (props) => {
502
+ const [name, setName] = createSignal2("");
503
+ const [command, setCommand] = createSignal2("");
504
+ const [args, setArgs] = createSignal2("");
505
+ const [cwd, setCwd] = createSignal2("~");
506
+ const [focusedField, setFocusedField] = createSignal2("name");
507
+ const fields = [
508
+ { key: "name", label: "Name", value: name, setValue: setName },
509
+ { key: "command", label: "Command", value: command, setValue: setCommand },
510
+ { key: "args", label: "Arguments", value: args, setValue: setArgs },
511
+ { key: "cwd", label: "Directory", value: cwd, setValue: setCwd }
512
+ ];
513
+ const focusIndex = () => fields.findIndex((field) => field.key === focusedField());
514
+ const setFocusByIndex = (index) => {
515
+ const clamped = Math.max(0, Math.min(fields.length - 1, index));
516
+ setFocusedField(fields[clamped].key);
517
+ };
518
+ const handleSubmit = () => {
519
+ if (name() && command()) {
520
+ props.onAdd({
521
+ name: name(),
522
+ command: command(),
523
+ args: args().trim() || undefined,
524
+ cwd: cwd() || "~",
525
+ autostart: false
526
+ });
527
+ }
528
+ };
529
+ useKeyboard2((event) => {
530
+ if (event.name === "escape") {
531
+ props.onClose();
532
+ event.preventDefault();
533
+ return;
534
+ }
535
+ if (event.name === "tab") {
536
+ const direction = event.shift ? -1 : 1;
537
+ setFocusByIndex(focusIndex() + direction);
538
+ event.preventDefault();
539
+ return;
540
+ }
541
+ if (event.name === "return" || event.name === "enter") {
542
+ handleSubmit();
543
+ event.preventDefault();
544
+ return;
545
+ }
546
+ const focused = fields[focusIndex()];
547
+ if (!focused) {
548
+ return;
549
+ }
550
+ if (event.name === "backspace") {
551
+ focused.setValue(focused.value().slice(0, -1));
552
+ event.preventDefault();
553
+ return;
554
+ }
555
+ if (!event.ctrl && !event.meta && !event.option && event.sequence && event.sequence.length === 1) {
556
+ focused.setValue(focused.value() + event.sequence);
557
+ event.preventDefault();
558
+ }
559
+ });
560
+ return /* @__PURE__ */ jsxDEV6("box", {
561
+ position: "absolute",
562
+ top: "30%",
563
+ left: "25%",
564
+ width: "50%",
565
+ height: 11,
566
+ flexDirection: "column",
567
+ borderStyle: "double",
568
+ borderColor: props.theme.primary,
569
+ backgroundColor: props.theme.background,
570
+ children: [
571
+ /* @__PURE__ */ jsxDEV6("box", {
572
+ height: 1,
573
+ children: /* @__PURE__ */ jsxDEV6("text", {
574
+ fg: props.theme.accent,
575
+ children: /* @__PURE__ */ jsxDEV6("b", {
576
+ children: " Add New App"
577
+ }, undefined, false, undefined, this)
578
+ }, undefined, false, undefined, this)
579
+ }, undefined, false, undefined, this),
580
+ fields.map((field) => {
581
+ const isFocused = () => focusedField() === field.key;
582
+ return /* @__PURE__ */ jsxDEV6("box", {
583
+ height: 1,
584
+ flexDirection: "row",
585
+ children: [
586
+ /* @__PURE__ */ jsxDEV6("box", {
587
+ width: 12,
588
+ children: /* @__PURE__ */ jsxDEV6("text", {
589
+ fg: isFocused() ? props.theme.accent : props.theme.muted,
590
+ children: [
591
+ field.label,
592
+ ":"
593
+ ]
594
+ }, undefined, true, undefined, this)
595
+ }, undefined, false, undefined, this),
596
+ /* @__PURE__ */ jsxDEV6("text", {
597
+ fg: isFocused() ? props.theme.foreground : props.theme.muted,
598
+ bg: isFocused() ? props.theme.primary : undefined,
599
+ children: [
600
+ " ",
601
+ field.value(),
602
+ isFocused() ? "\u2588" : " "
603
+ ]
604
+ }, undefined, true, undefined, this)
605
+ ]
606
+ }, undefined, true, undefined, this);
607
+ }),
608
+ /* @__PURE__ */ jsxDEV6("box", {
609
+ height: 1,
610
+ children: /* @__PURE__ */ jsxDEV6("text", {
611
+ fg: props.theme.muted,
612
+ children: "Enter:Add | Esc:Cancel | Tab:Next field"
613
+ }, undefined, false, undefined, this)
614
+ }, undefined, false, undefined, this)
615
+ ]
616
+ }, undefined, true, undefined, this);
617
+ };
618
+
619
+ // src/components/EditAppModal.tsx
620
+ import { createSignal as createSignal3 } from "solid-js";
621
+ import { useKeyboard as useKeyboard3 } from "@opentui/solid";
622
+ import { jsxDEV as jsxDEV7 } from "@opentui/solid/jsx-dev-runtime";
623
+ var EditAppModal = (props) => {
624
+ const [name, setName] = createSignal3(props.entry.name ?? "");
625
+ const [command, setCommand] = createSignal3(props.entry.command ?? "");
626
+ const [args, setArgs] = createSignal3(props.entry.args ?? "");
627
+ const [cwd, setCwd] = createSignal3(props.entry.cwd ?? "");
628
+ const [focusedField, setFocusedField] = createSignal3("name");
629
+ const fields = [
630
+ { key: "name", label: "Name", value: name, setValue: setName },
631
+ { key: "command", label: "Command", value: command, setValue: setCommand },
632
+ { key: "args", label: "Arguments", value: args, setValue: setArgs },
633
+ { key: "cwd", label: "Directory", value: cwd, setValue: setCwd }
634
+ ];
635
+ const focusIndex = () => fields.findIndex((field) => field.key === focusedField());
636
+ const setFocusByIndex = (index) => {
637
+ const clamped = Math.max(0, Math.min(fields.length - 1, index));
638
+ setFocusedField(fields[clamped].key);
639
+ };
640
+ const handleSubmit = () => {
641
+ if (name() && command()) {
642
+ props.onSave({
643
+ name: name(),
644
+ command: command(),
645
+ args: args().trim() || undefined,
646
+ cwd: cwd() || "~"
647
+ });
648
+ }
649
+ };
650
+ useKeyboard3((event) => {
651
+ if (event.name === "escape") {
652
+ props.onClose();
653
+ event.preventDefault();
654
+ return;
655
+ }
656
+ if (event.name === "tab") {
657
+ const direction = event.shift ? -1 : 1;
658
+ setFocusByIndex(focusIndex() + direction);
659
+ event.preventDefault();
660
+ return;
661
+ }
662
+ if (event.name === "return" || event.name === "enter") {
663
+ handleSubmit();
664
+ event.preventDefault();
665
+ return;
666
+ }
667
+ const focused = fields[focusIndex()];
668
+ if (!focused) {
669
+ return;
670
+ }
671
+ if (event.name === "backspace") {
672
+ focused.setValue(focused.value().slice(0, -1));
673
+ event.preventDefault();
674
+ return;
675
+ }
676
+ if (!event.ctrl && !event.meta && !event.option && event.sequence && event.sequence.length === 1) {
677
+ focused.setValue(focused.value() + event.sequence);
678
+ event.preventDefault();
679
+ }
680
+ });
681
+ return /* @__PURE__ */ jsxDEV7("box", {
682
+ position: "absolute",
683
+ top: "30%",
684
+ left: "25%",
685
+ width: "50%",
686
+ height: 12,
687
+ flexDirection: "column",
688
+ borderStyle: "double",
689
+ borderColor: props.theme.primary,
690
+ backgroundColor: props.theme.background,
691
+ children: [
692
+ /* @__PURE__ */ jsxDEV7("box", {
693
+ height: 1,
694
+ children: /* @__PURE__ */ jsxDEV7("text", {
695
+ fg: props.theme.accent,
696
+ children: /* @__PURE__ */ jsxDEV7("b", {
697
+ children: " Edit App"
698
+ }, undefined, false, undefined, this)
699
+ }, undefined, false, undefined, this)
700
+ }, undefined, false, undefined, this),
701
+ fields.map((field) => {
702
+ const isFocused = () => focusedField() === field.key;
703
+ return /* @__PURE__ */ jsxDEV7("box", {
704
+ height: 1,
705
+ flexDirection: "row",
706
+ children: [
707
+ /* @__PURE__ */ jsxDEV7("box", {
708
+ width: 12,
709
+ children: /* @__PURE__ */ jsxDEV7("text", {
710
+ fg: isFocused() ? props.theme.accent : props.theme.muted,
711
+ children: [
712
+ field.label,
713
+ ":"
714
+ ]
715
+ }, undefined, true, undefined, this)
716
+ }, undefined, false, undefined, this),
717
+ /* @__PURE__ */ jsxDEV7("text", {
718
+ fg: isFocused() ? props.theme.foreground : props.theme.muted,
719
+ bg: isFocused() ? props.theme.primary : undefined,
720
+ children: [
721
+ " ",
722
+ field.value(),
723
+ isFocused() ? "\u2588" : " "
724
+ ]
725
+ }, undefined, true, undefined, this)
726
+ ]
727
+ }, undefined, true, undefined, this);
728
+ }),
729
+ /* @__PURE__ */ jsxDEV7("box", {
730
+ height: 1,
731
+ children: /* @__PURE__ */ jsxDEV7("text", {
732
+ fg: props.theme.muted,
733
+ children: "Enter:Save | Esc:Cancel | Tab:Next field"
734
+ }, undefined, false, undefined, this)
735
+ }, undefined, false, undefined, this)
736
+ ]
737
+ }, undefined, true, undefined, this);
738
+ };
739
+
740
+ // src/stores/apps.ts
741
+ import { createStore } from "solid-js/store";
742
+ function generateId() {
743
+ return Math.random().toString(36).substring(2, 11);
744
+ }
745
+ function configToEntry(config) {
746
+ return {
747
+ id: generateId(),
748
+ name: config.name,
749
+ command: config.command,
750
+ args: config.args,
751
+ cwd: config.cwd,
752
+ env: config.env,
753
+ autostart: config.autostart ?? false,
754
+ restartOnExit: config.restart_on_exit ?? false
755
+ };
756
+ }
757
+ function createAppsStore(initialApps = []) {
758
+ const [store, setStore] = createStore({
759
+ entries: initialApps.map(configToEntry)
760
+ });
761
+ const addEntry = (config) => {
762
+ const entry = configToEntry(config);
763
+ setStore("entries", (entries) => [...entries, entry]);
764
+ return entry;
765
+ };
766
+ const removeEntry = (id) => {
767
+ setStore("entries", (entries) => entries.filter((e) => e.id !== id));
768
+ };
769
+ const updateEntry = (id, updates) => {
770
+ setStore("entries", (entry) => entry.id === id, updates);
771
+ };
772
+ const getEntry = (id) => {
773
+ return store.entries.find((e) => e.id === id);
774
+ };
775
+ return {
776
+ store,
777
+ addEntry,
778
+ removeEntry,
779
+ updateEntry,
780
+ getEntry
781
+ };
782
+ }
783
+
784
+ // src/stores/tabs.ts
785
+ import { createStore as createStore2 } from "solid-js/store";
786
+ function createTabsStore() {
787
+ const [store, setStore] = createStore2({
788
+ activeTabId: null,
789
+ runningApps: new Map,
790
+ focusMode: "tabs",
791
+ scrollOffset: 0
792
+ });
793
+ const setActiveTab = (id) => {
794
+ setStore("activeTabId", id);
795
+ };
796
+ const setFocusMode = (mode) => {
797
+ setStore("focusMode", mode);
798
+ };
799
+ const toggleFocus = () => {
800
+ setStore("focusMode", (current) => current === "tabs" ? "terminal" : "tabs");
801
+ };
802
+ const addRunningApp = (app) => {
803
+ setStore("runningApps", (apps) => {
804
+ const newApps = new Map(apps);
805
+ newApps.set(app.entry.id, app);
806
+ return newApps;
807
+ });
808
+ };
809
+ const removeRunningApp = (id) => {
810
+ setStore("runningApps", (apps) => {
811
+ const newApps = new Map(apps);
812
+ newApps.delete(id);
813
+ return newApps;
814
+ });
815
+ };
816
+ const updateAppStatus = (id, status) => {
817
+ setStore("runningApps", (apps) => {
818
+ const app = apps.get(id);
819
+ if (app) {
820
+ const newApps = new Map(apps);
821
+ newApps.set(id, { ...app, status });
822
+ return newApps;
823
+ }
824
+ return apps;
825
+ });
826
+ };
827
+ const updateRunningEntry = (id, updates) => {
828
+ setStore("runningApps", (apps) => {
829
+ const app = apps.get(id);
830
+ if (app) {
831
+ const newApps = new Map(apps);
832
+ newApps.set(id, { ...app, entry: { ...app.entry, ...updates } });
833
+ return newApps;
834
+ }
835
+ return apps;
836
+ });
837
+ };
838
+ const appendToBuffer = (id, data) => {
839
+ const MAX_BUFFER_CHARS = 200000;
840
+ setStore("runningApps", (apps) => {
841
+ const app = apps.get(id);
842
+ if (app) {
843
+ const nextBuffer = (app.buffer + data).slice(-MAX_BUFFER_CHARS);
844
+ const newApps = new Map(apps);
845
+ newApps.set(id, { ...app, buffer: nextBuffer });
846
+ return newApps;
847
+ }
848
+ return apps;
849
+ });
850
+ };
851
+ const getRunningApp = (id) => {
852
+ return store.runningApps.get(id);
853
+ };
854
+ const setScrollOffset = (offset) => {
855
+ setStore("scrollOffset", offset);
856
+ };
857
+ return {
858
+ store,
859
+ setActiveTab,
860
+ setFocusMode,
861
+ toggleFocus,
862
+ addRunningApp,
863
+ removeRunningApp,
864
+ updateAppStatus,
865
+ updateRunningEntry,
866
+ appendToBuffer,
867
+ getRunningApp,
868
+ setScrollOffset
869
+ };
870
+ }
871
+
872
+ // src/stores/ui.ts
873
+ import { createStore as createStore3 } from "solid-js/store";
874
+ function createUIStore() {
875
+ const [store, setStore] = createStore3({
876
+ activeModal: null,
877
+ statusMessage: null
878
+ });
879
+ const openModal = (modal) => {
880
+ setStore("activeModal", modal);
881
+ };
882
+ const closeModal = () => {
883
+ setStore("activeModal", null);
884
+ };
885
+ const setStatusMessage = (message) => {
886
+ setStore("statusMessage", message);
887
+ };
888
+ const showTemporaryMessage = (message, durationMs = 3000) => {
889
+ setStore("statusMessage", message);
890
+ setTimeout(() => {
891
+ setStore("statusMessage", (current) => current === message ? null : current);
892
+ }, durationMs);
893
+ };
894
+ return {
895
+ store,
896
+ openModal,
897
+ closeModal,
898
+ setStatusMessage,
899
+ showTemporaryMessage
900
+ };
901
+ }
902
+
903
+ // src/lib/config.ts
904
+ import { parse, stringify } from "yaml";
905
+ import { z } from "zod";
906
+ import { readFile, writeFile, mkdir } from "fs/promises";
907
+ import { existsSync } from "fs";
908
+ import { homedir } from "os";
909
+ import { dirname, join, resolve } from "path";
910
+ var CONFIG_DIR = join(homedir(), ".config", "tuidoscope");
911
+ var CONFIG_PATH = join(CONFIG_DIR, "config.yaml");
912
+ var LOCAL_CONFIG_PATH = "./tuidoscope.yaml";
913
+ var ThemeSchema = z.object({
914
+ primary: z.string().default("#7aa2f7"),
915
+ background: z.string().default("#1a1b26"),
916
+ foreground: z.string().default("#c0caf5"),
917
+ accent: z.string().default("#bb9af7"),
918
+ muted: z.string().default("#565f89")
919
+ });
920
+ var KeybindSchema = z.object({
921
+ next_tab: z.string().default("ctrl+n"),
922
+ prev_tab: z.string().default("ctrl+p"),
923
+ close_tab: z.string().default("ctrl+w"),
924
+ new_tab: z.string().default("ctrl+t"),
925
+ toggle_focus: z.string().default("ctrl+a"),
926
+ edit_app: z.string().optional(),
927
+ rename_tab: z.string().optional(),
928
+ restart_app: z.string().default("ctrl+shift+r"),
929
+ command_palette: z.string().default("ctrl+space"),
930
+ stop_app: z.string().default("ctrl+x"),
931
+ kill_all: z.string().default("ctrl+shift+k"),
932
+ quit: z.string().default("ctrl+q")
933
+ }).transform(({ rename_tab, edit_app, ...rest }) => ({
934
+ ...rest,
935
+ edit_app: edit_app ?? rename_tab ?? "ctrl+e"
936
+ }));
937
+ var AppEntrySchema = z.object({
938
+ name: z.string(),
939
+ command: z.string(),
940
+ args: z.string().optional(),
941
+ cwd: z.string().default("~"),
942
+ autostart: z.boolean().default(false),
943
+ restart_on_exit: z.boolean().default(false),
944
+ env: z.record(z.string()).optional()
945
+ });
946
+ var SessionSchema = z.object({
947
+ persist: z.boolean().default(true),
948
+ file: z.string().default("~/.local/state/tuidoscope/session.yaml")
949
+ });
950
+ var ConfigSchema = z.object({
951
+ version: z.number().default(1),
952
+ theme: ThemeSchema.default({}),
953
+ keybinds: KeybindSchema.default({}),
954
+ tab_width: z.number().default(20),
955
+ apps: z.array(AppEntrySchema).default([]),
956
+ session: SessionSchema.default({})
957
+ });
958
+ var configPath = CONFIG_PATH;
959
+ var configDir = CONFIG_DIR;
960
+ function expandPath(path) {
961
+ let expanded = path;
962
+ if (expanded.startsWith("~")) {
963
+ expanded = expanded.replace("~", homedir());
964
+ }
965
+ if (expanded.includes("<CONFIG_DIR>")) {
966
+ expanded = expanded.replace("<CONFIG_DIR>", configDir);
967
+ }
968
+ return resolve(expanded);
969
+ }
970
+ async function loadConfig() {
971
+ if (existsSync(LOCAL_CONFIG_PATH)) {
972
+ configPath = resolve(LOCAL_CONFIG_PATH);
973
+ configDir = dirname(configPath);
974
+ } else if (existsSync(CONFIG_PATH)) {
975
+ configPath = CONFIG_PATH;
976
+ configDir = CONFIG_DIR;
977
+ } else {
978
+ return ConfigSchema.parse({});
979
+ }
980
+ try {
981
+ const content = await readFile(configPath, "utf-8");
982
+ const parsed = parse(content);
983
+ const validated = ConfigSchema.parse(parsed);
984
+ return validated;
985
+ } catch (error) {
986
+ console.error(`Error loading config from ${configPath}:`, error);
987
+ return ConfigSchema.parse({});
988
+ }
989
+ }
990
+ async function saveConfig(config) {
991
+ await mkdir(dirname(configPath), { recursive: true });
992
+ const yamlContent = stringify(config, {
993
+ indent: 2,
994
+ lineWidth: 0
995
+ });
996
+ await writeFile(configPath, yamlContent, "utf-8");
997
+ }
998
+
999
+ // src/lib/pty.ts
1000
+ var INTERACTIVE_SHELLS = ["bash", "zsh", "fish", "sh", "dash", "ksh", "tcsh", "csh"];
1001
+ var DEFAULT_FG = "#c0caf5";
1002
+ var DEFAULT_BG = "#1a1b26";
1003
+ function normalizeHexColor(value, fallback) {
1004
+ if (!value)
1005
+ return fallback;
1006
+ const trimmed = value.trim();
1007
+ if (/^#[0-9a-fA-F]{6}$/.test(trimmed))
1008
+ return trimmed.toLowerCase();
1009
+ if (/^#[0-9a-fA-F]{3}$/.test(trimmed)) {
1010
+ const chars = trimmed.slice(1).split("");
1011
+ return `#${chars.map((c) => c + c).join("")}`.toLowerCase();
1012
+ }
1013
+ return fallback;
1014
+ }
1015
+ function hexToOscRgb(hex) {
1016
+ const normalized = normalizeHexColor(hex, DEFAULT_BG).slice(1);
1017
+ const r = normalized.slice(0, 2);
1018
+ const g = normalized.slice(2, 4);
1019
+ const b = normalized.slice(4, 6);
1020
+ return `rgb:${r}/${g}/${b}`;
1021
+ }
1022
+ function buildColorResponse(code, hex) {
1023
+ return `\x1B]${code};${hexToOscRgb(hex)}\x1B\\`;
1024
+ }
1025
+ function buildCursorPositionReport() {
1026
+ return "\x1B[1;1R";
1027
+ }
1028
+ function stripTerminalQueries(input, colors) {
1029
+ let output = "";
1030
+ const responses = [];
1031
+ let i = 0;
1032
+ const prefixes = ["\x1B[6n", "\x1B[?6n", "\x1B]10;?", "\x1B]11;?"];
1033
+ while (i < input.length) {
1034
+ const char = input[i];
1035
+ if (char !== "\x1B") {
1036
+ output += char;
1037
+ i += 1;
1038
+ continue;
1039
+ }
1040
+ const remaining = input.slice(i);
1041
+ const hasPartialPrefix = prefixes.some((prefix) => prefix.startsWith(remaining) && remaining.length < prefix.length);
1042
+ if (hasPartialPrefix) {
1043
+ return { output, responses, pending: remaining };
1044
+ }
1045
+ const next = input[i + 1];
1046
+ if (next === "[") {
1047
+ if (input.startsWith("\x1B[6n", i)) {
1048
+ responses.push(buildCursorPositionReport());
1049
+ i += 4;
1050
+ continue;
1051
+ }
1052
+ if (input.startsWith("\x1B[?6n", i)) {
1053
+ responses.push(buildCursorPositionReport());
1054
+ i += 5;
1055
+ continue;
1056
+ }
1057
+ }
1058
+ if (next === "]") {
1059
+ if (input.startsWith("\x1B]10;?", i) || input.startsWith("\x1B]11;?", i)) {
1060
+ const isForeground = input.startsWith("\x1B]10;?", i);
1061
+ const terminatorIndex = input.indexOf("\x07", i + 5);
1062
+ const stIndex = input.indexOf("\x1B\\", i + 5);
1063
+ let endIndex = -1;
1064
+ let terminatorLength = 0;
1065
+ if (terminatorIndex !== -1) {
1066
+ endIndex = terminatorIndex;
1067
+ terminatorLength = 1;
1068
+ } else if (stIndex !== -1) {
1069
+ endIndex = stIndex;
1070
+ terminatorLength = 2;
1071
+ } else {
1072
+ return { output, responses, pending: input.slice(i) };
1073
+ }
1074
+ responses.push(buildColorResponse(isForeground ? "10" : "11", isForeground ? colors.fg : colors.bg));
1075
+ i = endIndex + terminatorLength;
1076
+ continue;
1077
+ }
1078
+ }
1079
+ output += char;
1080
+ i += 1;
1081
+ }
1082
+ return { output, responses, pending: "" };
1083
+ }
1084
+ function buildCommandString(command) {
1085
+ const trimmed = command.trim();
1086
+ if (!trimmed.includes(" ") && INTERACTIVE_SHELLS.includes(trimmed)) {
1087
+ return `${trimmed} -i`;
1088
+ }
1089
+ return trimmed;
1090
+ }
1091
+ function parseCommand(command) {
1092
+ const trimmed = command.trim();
1093
+ if (!trimmed.includes(" ")) {
1094
+ if (INTERACTIVE_SHELLS.includes(trimmed)) {
1095
+ return { program: trimmed, args: ["-i"] };
1096
+ }
1097
+ return { program: trimmed, args: [] };
1098
+ }
1099
+ const shell = process.env.SHELL || "/bin/sh";
1100
+ return { program: shell, args: ["-c", trimmed] };
1101
+ }
1102
+ function spawnPty(entry, options = {}) {
1103
+ const { cols = 80, rows = 24 } = options;
1104
+ const cwd = expandPath(entry.cwd);
1105
+ const colorOverrides = {
1106
+ fg: normalizeHexColor(entry.env?.TUIDISCOPE_FG ?? process.env.TUIDISCOPE_FG, DEFAULT_FG),
1107
+ bg: normalizeHexColor(entry.env?.TUIDISCOPE_BG ?? process.env.TUIDISCOPE_BG, DEFAULT_BG)
1108
+ };
1109
+ const env = {
1110
+ ...process.env,
1111
+ TERM: "xterm-256color",
1112
+ COLUMNS: String(cols),
1113
+ LINES: String(rows),
1114
+ ...entry.env
1115
+ };
1116
+ const useScript = process.platform !== "win32" && !process.env.TUIDISCOPE_NO_SCRIPT;
1117
+ const entryCommand = buildEntryCommand(entry);
1118
+ const commandString = buildCommandString(entryCommand);
1119
+ const { program, args } = parseCommand(entryCommand);
1120
+ let spawnProgram;
1121
+ let spawnArgs;
1122
+ if (useScript) {
1123
+ spawnProgram = "script";
1124
+ if (process.platform === "darwin") {
1125
+ spawnArgs = ["-q", "/dev/null", program, ...args];
1126
+ } else {
1127
+ spawnArgs = ["-q", "-c", commandString, "/dev/null"];
1128
+ }
1129
+ } else {
1130
+ spawnProgram = program;
1131
+ spawnArgs = args;
1132
+ }
1133
+ let dataCallback = null;
1134
+ let exitCallback = null;
1135
+ let pendingControl = "";
1136
+ const terminal = new Bun.Terminal({
1137
+ cols,
1138
+ rows,
1139
+ data(_term, data) {
1140
+ const str = typeof data === "string" ? data : new TextDecoder().decode(data);
1141
+ const parsed = stripTerminalQueries(pendingControl + str, colorOverrides);
1142
+ pendingControl = parsed.pending;
1143
+ if (parsed.responses.length > 0) {
1144
+ terminal.write(parsed.responses.join(""));
1145
+ }
1146
+ if (dataCallback && parsed.output.length > 0) {
1147
+ dataCallback(parsed.output);
1148
+ }
1149
+ },
1150
+ exit(_term) {}
1151
+ });
1152
+ const proc = Bun.spawn([spawnProgram, ...spawnArgs], {
1153
+ terminal,
1154
+ cwd,
1155
+ env
1156
+ });
1157
+ proc.exited.then((exitCode) => {
1158
+ if (exitCallback) {
1159
+ exitCallback({ exitCode, signal: 0 });
1160
+ }
1161
+ }).catch(() => {
1162
+ if (exitCallback) {
1163
+ exitCallback({ exitCode: 1, signal: 0 });
1164
+ }
1165
+ });
1166
+ const ptyProcess = {
1167
+ terminal,
1168
+ proc,
1169
+ pid: proc.pid,
1170
+ write(data) {
1171
+ terminal.write(data);
1172
+ },
1173
+ resize(cols2, rows2) {
1174
+ terminal.resize(cols2, rows2);
1175
+ },
1176
+ kill() {
1177
+ proc.kill();
1178
+ terminal.close();
1179
+ },
1180
+ onData(callback) {
1181
+ dataCallback = callback;
1182
+ },
1183
+ onExit(callback) {
1184
+ exitCallback = callback;
1185
+ }
1186
+ };
1187
+ return ptyProcess;
1188
+ }
1189
+ function resizePty(ptyProcess, cols, rows) {
1190
+ ptyProcess.resize(cols, rows);
1191
+ }
1192
+ function killPty(ptyProcess) {
1193
+ ptyProcess.kill();
1194
+ }
1195
+
1196
+ // src/lib/session.ts
1197
+ import { parse as parse2, stringify as stringify2 } from "yaml";
1198
+ import { readFile as readFile2, writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
1199
+ import { existsSync as existsSync2 } from "fs";
1200
+ import { dirname as dirname2 } from "path";
1201
+ var sessionPath = null;
1202
+ function initSessionPath(config) {
1203
+ sessionPath = expandPath(config.session.file);
1204
+ }
1205
+ async function saveSession(data) {
1206
+ if (!sessionPath) {
1207
+ throw new Error("Session path not initialized");
1208
+ }
1209
+ await mkdir2(dirname2(sessionPath), { recursive: true });
1210
+ const yamlContent = stringify2(data, {
1211
+ indent: 2,
1212
+ lineWidth: 0
1213
+ });
1214
+ await writeFile2(sessionPath, yamlContent, "utf-8");
1215
+ }
1216
+ async function restoreSession() {
1217
+ if (!sessionPath || !existsSync2(sessionPath)) {
1218
+ return null;
1219
+ }
1220
+ try {
1221
+ const content = await readFile2(sessionPath, "utf-8");
1222
+ const parsed = parse2(content);
1223
+ if (typeof parsed === "object" && parsed !== null && Array.isArray(parsed.runningApps) && typeof parsed.timestamp === "number") {
1224
+ return parsed;
1225
+ }
1226
+ return null;
1227
+ } catch {
1228
+ return null;
1229
+ }
1230
+ }
1231
+
1232
+ // src/lib/debug.ts
1233
+ import { appendFileSync } from "fs";
1234
+ var DEBUG_LOG_PATH = process.env.TUIDOSCOPE_DEBUG_LOG || "tuidoscope-debug.log";
1235
+ function debugLog(message) {
1236
+ if (!process.env.TUIDOSCOPE_DEBUG) {
1237
+ return;
1238
+ }
1239
+ try {
1240
+ const line = message.endsWith(`
1241
+ `) ? message : `${message}
1242
+ `;
1243
+ appendFileSync(DEBUG_LOG_PATH, line);
1244
+ } catch {}
1245
+ }
1246
+
1247
+ // src/app.tsx
1248
+ import { jsxDEV as jsxDEV8 } from "@opentui/solid/jsx-dev-runtime";
1249
+ var App = (props) => {
1250
+ const renderer = useRenderer();
1251
+ const appsStore = createAppsStore(props.config.apps);
1252
+ const tabsStore = createTabsStore();
1253
+ const uiStore = createUIStore();
1254
+ const terminalDims = useTerminalDimensions();
1255
+ const [selectedIndex, setSelectedIndex] = createSignal4(0);
1256
+ initSessionPath(props.config);
1257
+ const [hasAutostarted, setHasAutostarted] = createSignal4(false);
1258
+ const [isQuitting, setIsQuitting] = createSignal4(false);
1259
+ const [editingEntryId, setEditingEntryId] = createSignal4(null);
1260
+ const getPtyDimensions = () => {
1261
+ const dims = terminalDims();
1262
+ const cols = dims.width - props.config.tab_width - 2;
1263
+ const rows = dims.height - 4;
1264
+ return { cols, rows };
1265
+ };
1266
+ const startApp = (entry) => {
1267
+ if (tabsStore.store.runningApps.has(entry.id)) {
1268
+ return;
1269
+ }
1270
+ const dims = terminalDims();
1271
+ const { cols, rows } = getPtyDimensions();
1272
+ if (cols < 10 || rows < 3) {
1273
+ console.warn(`Skipping start for ${entry.name}: invalid dimensions ${cols}x${rows}`);
1274
+ return;
1275
+ }
1276
+ const ptyProcess = spawnPty(entry, { cols, rows });
1277
+ const runningApp = {
1278
+ entry,
1279
+ pty: ptyProcess,
1280
+ status: "running",
1281
+ buffer: ""
1282
+ };
1283
+ let pendingOutput = "";
1284
+ let flushTimer = null;
1285
+ const flushOutput = () => {
1286
+ if (pendingOutput.length === 0) {
1287
+ flushTimer = null;
1288
+ return;
1289
+ }
1290
+ tabsStore.appendToBuffer(entry.id, pendingOutput);
1291
+ pendingOutput = "";
1292
+ flushTimer = null;
1293
+ };
1294
+ ptyProcess.onData((data) => {
1295
+ pendingOutput += data;
1296
+ if (!flushTimer) {
1297
+ flushTimer = setTimeout(flushOutput, 50);
1298
+ }
1299
+ });
1300
+ ptyProcess.onExit(({ exitCode }) => {
1301
+ flushOutput();
1302
+ if (exitCode === 0) {
1303
+ tabsStore.updateAppStatus(entry.id, "stopped");
1304
+ } else {
1305
+ tabsStore.updateAppStatus(entry.id, "error");
1306
+ }
1307
+ if (entry.restartOnExit && exitCode !== 0) {
1308
+ setTimeout(() => startApp(entry), 1000);
1309
+ }
1310
+ });
1311
+ tabsStore.addRunningApp(runningApp);
1312
+ tabsStore.setActiveTab(entry.id);
1313
+ uiStore.showTemporaryMessage(`Started: ${entry.name}`);
1314
+ };
1315
+ const stopApp = (id, options = {}) => {
1316
+ const app = tabsStore.getRunningApp(id);
1317
+ if (app) {
1318
+ killPty(app.pty);
1319
+ tabsStore.removeRunningApp(id);
1320
+ if (!options.silent) {
1321
+ uiStore.showTemporaryMessage(`Stopped: ${app.entry.name}`);
1322
+ }
1323
+ }
1324
+ };
1325
+ const stopAllApps = (options = {}) => {
1326
+ const runningIds = Array.from(tabsStore.store.runningApps.keys());
1327
+ if (runningIds.length === 0) {
1328
+ if (options.showMessage) {
1329
+ uiStore.showTemporaryMessage("No running apps");
1330
+ }
1331
+ return;
1332
+ }
1333
+ for (const id of runningIds) {
1334
+ stopApp(id, { silent: true });
1335
+ }
1336
+ tabsStore.setActiveTab(null);
1337
+ if (options.showMessage) {
1338
+ uiStore.showTemporaryMessage(`Stopped ${runningIds.length} app${runningIds.length === 1 ? "" : "s"}`);
1339
+ }
1340
+ };
1341
+ const restartApp = (id) => {
1342
+ const app = tabsStore.getRunningApp(id);
1343
+ if (app) {
1344
+ stopApp(id);
1345
+ setTimeout(() => {
1346
+ const entry = appsStore.getEntry(id);
1347
+ if (entry)
1348
+ startApp(entry);
1349
+ }, 500);
1350
+ }
1351
+ };
1352
+ const getAppStatus = (id) => {
1353
+ const app = tabsStore.getRunningApp(id);
1354
+ return app?.status ?? "stopped";
1355
+ };
1356
+ const handleTabNavigation = (direction) => {
1357
+ const entries = appsStore.store.entries;
1358
+ if (entries.length === 0)
1359
+ return;
1360
+ setSelectedIndex((current) => {
1361
+ if (direction === "up") {
1362
+ return Math.max(0, current - 1);
1363
+ } else {
1364
+ return Math.min(entries.length - 1, current + 1);
1365
+ }
1366
+ });
1367
+ const visibleHeight = 20;
1368
+ const currentOffset = tabsStore.store.scrollOffset;
1369
+ const newIndex = selectedIndex();
1370
+ if (newIndex < currentOffset) {
1371
+ tabsStore.setScrollOffset(newIndex);
1372
+ } else if (newIndex >= currentOffset + visibleHeight) {
1373
+ tabsStore.setScrollOffset(newIndex - visibleHeight + 1);
1374
+ }
1375
+ };
1376
+ const handleSelectApp = (id) => {
1377
+ const entry = appsStore.getEntry(id);
1378
+ if (!entry)
1379
+ return;
1380
+ if (!tabsStore.store.runningApps.has(id)) {
1381
+ startApp(entry);
1382
+ } else {
1383
+ tabsStore.setActiveTab(id);
1384
+ }
1385
+ };
1386
+ const persistAppsConfig = async () => {
1387
+ const nextApps = appsStore.store.entries.map((entry) => ({
1388
+ name: entry.name,
1389
+ command: entry.command,
1390
+ args: entry.args?.trim() || undefined,
1391
+ cwd: entry.cwd,
1392
+ autostart: entry.autostart,
1393
+ restart_on_exit: entry.restartOnExit,
1394
+ env: entry.env
1395
+ }));
1396
+ const nextConfig = { ...props.config, apps: nextApps };
1397
+ props.config.apps = nextApps;
1398
+ try {
1399
+ await saveConfig(nextConfig);
1400
+ } catch (error) {
1401
+ console.error("Failed to save config:", error);
1402
+ uiStore.showTemporaryMessage("Failed to save config");
1403
+ }
1404
+ };
1405
+ const openEditModal = (id) => {
1406
+ setEditingEntryId(id);
1407
+ uiStore.openModal("edit-app");
1408
+ };
1409
+ const handleAddApp = (config) => {
1410
+ const entry = appsStore.addEntry(config);
1411
+ uiStore.closeModal();
1412
+ uiStore.showTemporaryMessage(`Added: ${entry.name}`);
1413
+ persistAppsConfig();
1414
+ };
1415
+ const handleEditApp = (id, updates) => {
1416
+ appsStore.updateEntry(id, updates);
1417
+ tabsStore.updateRunningEntry(id, updates);
1418
+ uiStore.closeModal();
1419
+ setEditingEntryId(null);
1420
+ const updatedName = appsStore.getEntry(id)?.name ?? updates.name;
1421
+ if (tabsStore.store.runningApps.has(id)) {
1422
+ uiStore.showTemporaryMessage(`Updated: ${updatedName} (restart to apply)`);
1423
+ } else {
1424
+ uiStore.showTemporaryMessage(`Updated: ${updatedName}`);
1425
+ }
1426
+ persistAppsConfig();
1427
+ };
1428
+ const handleKeybind = createKeybindHandler(props.config.keybinds, {
1429
+ next_tab: () => handleTabNavigation("down"),
1430
+ prev_tab: () => handleTabNavigation("up"),
1431
+ toggle_focus: () => tabsStore.toggleFocus(),
1432
+ new_tab: () => uiStore.openModal("add-tab"),
1433
+ edit_app: () => {
1434
+ if (tabsStore.store.focusMode !== "tabs") {
1435
+ uiStore.showTemporaryMessage("Switch to tabs to edit");
1436
+ return;
1437
+ }
1438
+ const entry = appsStore.store.entries[selectedIndex()];
1439
+ if (!entry) {
1440
+ uiStore.showTemporaryMessage("No app selected");
1441
+ return;
1442
+ }
1443
+ openEditModal(entry.id);
1444
+ },
1445
+ command_palette: () => uiStore.openModal("command-palette"),
1446
+ close_tab: () => {
1447
+ const activeId = tabsStore.store.activeTabId;
1448
+ if (activeId)
1449
+ stopApp(activeId);
1450
+ },
1451
+ stop_app: () => {
1452
+ if (tabsStore.store.focusMode === "tabs") {
1453
+ const entries = appsStore.store.entries;
1454
+ const entry = entries[selectedIndex()];
1455
+ if (!entry) {
1456
+ uiStore.showTemporaryMessage("No app selected");
1457
+ return;
1458
+ }
1459
+ if (tabsStore.store.runningApps.has(entry.id)) {
1460
+ stopApp(entry.id);
1461
+ } else {
1462
+ uiStore.showTemporaryMessage(`Not running: ${entry.name}`);
1463
+ }
1464
+ return;
1465
+ }
1466
+ const activeId = tabsStore.store.activeTabId;
1467
+ if (activeId) {
1468
+ stopApp(activeId);
1469
+ } else {
1470
+ uiStore.showTemporaryMessage("No active app");
1471
+ }
1472
+ },
1473
+ kill_all: () => stopAllApps({ showMessage: true }),
1474
+ restart_app: () => {
1475
+ const activeId = tabsStore.store.activeTabId;
1476
+ if (activeId)
1477
+ restartApp(activeId);
1478
+ },
1479
+ quit: () => {
1480
+ if (isQuitting()) {
1481
+ return;
1482
+ }
1483
+ setIsQuitting(true);
1484
+ if (props.config.session.persist) {
1485
+ const runningIds = Array.from(tabsStore.store.runningApps.keys());
1486
+ saveSession({
1487
+ runningApps: runningIds,
1488
+ activeTab: tabsStore.store.activeTabId,
1489
+ timestamp: Date.now()
1490
+ });
1491
+ }
1492
+ stopAllApps({ showMessage: false });
1493
+ renderer.destroy();
1494
+ setTimeout(() => process.exit(0), 50);
1495
+ }
1496
+ });
1497
+ useKeyboard4((event) => {
1498
+ debugLog(`[App] key: ${event.name} modal: ${uiStore.store.activeModal} prevented: ${event.defaultPrevented}`);
1499
+ if (uiStore.store.activeModal) {
1500
+ if (event.name === "escape") {
1501
+ if (uiStore.store.activeModal === "edit-app") {
1502
+ setEditingEntryId(null);
1503
+ }
1504
+ uiStore.closeModal();
1505
+ event.preventDefault();
1506
+ }
1507
+ return;
1508
+ }
1509
+ if (tabsStore.store.focusMode === "terminal") {
1510
+ if (handleKeybind(event)) {
1511
+ event.preventDefault();
1512
+ return;
1513
+ }
1514
+ const activeApp = tabsStore.store.activeTabId ? tabsStore.getRunningApp(tabsStore.store.activeTabId) : undefined;
1515
+ if (activeApp && event.sequence) {
1516
+ activeApp.pty.write(event.sequence);
1517
+ event.preventDefault();
1518
+ }
1519
+ return;
1520
+ }
1521
+ if (handleKeybind(event)) {
1522
+ event.preventDefault();
1523
+ return;
1524
+ }
1525
+ if (event.name === "j" || event.name === "down") {
1526
+ handleTabNavigation("down");
1527
+ event.preventDefault();
1528
+ return;
1529
+ }
1530
+ if (event.name === "k" || event.name === "up") {
1531
+ handleTabNavigation("up");
1532
+ event.preventDefault();
1533
+ return;
1534
+ }
1535
+ if (event.name === "return" || event.name === "enter") {
1536
+ const entries = appsStore.store.entries;
1537
+ if (entries.length > 0 && selectedIndex() < entries.length) {
1538
+ handleSelectApp(entries[selectedIndex()].id);
1539
+ event.preventDefault();
1540
+ }
1541
+ return;
1542
+ }
1543
+ });
1544
+ createEffect2(() => {
1545
+ const { cols, rows } = getPtyDimensions();
1546
+ if (cols < 10 || rows < 3 || hasAutostarted()) {
1547
+ return;
1548
+ }
1549
+ setHasAutostarted(true);
1550
+ for (const entry of appsStore.store.entries) {
1551
+ if (entry.autostart) {
1552
+ startApp(entry);
1553
+ }
1554
+ }
1555
+ if (props.session) {
1556
+ for (const id of props.session.runningApps) {
1557
+ const entry = appsStore.getEntry(id);
1558
+ if (entry && !entry.autostart) {
1559
+ startApp(entry);
1560
+ }
1561
+ }
1562
+ if (props.session.activeTab) {
1563
+ tabsStore.setActiveTab(props.session.activeTab);
1564
+ }
1565
+ }
1566
+ });
1567
+ createEffect2(() => {
1568
+ const { cols: termWidth, rows: termHeight } = getPtyDimensions();
1569
+ if (termWidth < 10 || termHeight < 3) {
1570
+ return;
1571
+ }
1572
+ for (const [, app] of tabsStore.store.runningApps) {
1573
+ if (app.status === "running") {
1574
+ resizePty(app.pty, termWidth, termHeight);
1575
+ }
1576
+ }
1577
+ });
1578
+ const handleTerminalInput = (data) => {
1579
+ const activeApp = tabsStore.store.activeTabId ? tabsStore.getRunningApp(tabsStore.store.activeTabId) : undefined;
1580
+ if (activeApp) {
1581
+ activeApp.pty.write(data);
1582
+ }
1583
+ };
1584
+ onCleanup(() => {
1585
+ for (const [id] of tabsStore.store.runningApps) {
1586
+ stopApp(id, { silent: true });
1587
+ }
1588
+ });
1589
+ const activeRunningApp = createMemo3(() => {
1590
+ const activeId = tabsStore.store.activeTabId;
1591
+ return activeId ? tabsStore.getRunningApp(activeId) : undefined;
1592
+ });
1593
+ const editingEntry = createMemo3(() => {
1594
+ const id = editingEntryId();
1595
+ const entry = id ? appsStore.getEntry(id) : undefined;
1596
+ return entry;
1597
+ });
1598
+ return /* @__PURE__ */ jsxDEV8("box", {
1599
+ flexDirection: "column",
1600
+ width: "100%",
1601
+ height: "100%",
1602
+ children: [
1603
+ /* @__PURE__ */ jsxDEV8("box", {
1604
+ flexDirection: "row",
1605
+ flexGrow: 1,
1606
+ children: [
1607
+ /* @__PURE__ */ jsxDEV8(TabList, {
1608
+ entries: appsStore.store.entries,
1609
+ activeTabId: tabsStore.store.activeTabId,
1610
+ selectedIndex: selectedIndex(),
1611
+ getStatus: getAppStatus,
1612
+ isFocused: tabsStore.store.focusMode === "tabs",
1613
+ width: props.config.tab_width,
1614
+ height: terminalDims().height - 1,
1615
+ scrollOffset: tabsStore.store.scrollOffset,
1616
+ theme: props.config.theme,
1617
+ onSelect: handleSelectApp,
1618
+ onAddClick: () => uiStore.openModal("add-tab")
1619
+ }, undefined, false, undefined, this),
1620
+ /* @__PURE__ */ jsxDEV8(TerminalPane, {
1621
+ runningApp: activeRunningApp(),
1622
+ isFocused: tabsStore.store.focusMode === "terminal",
1623
+ width: terminalDims().width - props.config.tab_width,
1624
+ height: terminalDims().height - 1,
1625
+ theme: props.config.theme,
1626
+ onInput: handleTerminalInput
1627
+ }, undefined, false, undefined, this)
1628
+ ]
1629
+ }, undefined, true, undefined, this),
1630
+ /* @__PURE__ */ jsxDEV8(StatusBar, {
1631
+ appName: activeRunningApp()?.entry.name ?? null,
1632
+ appStatus: activeRunningApp()?.status ?? null,
1633
+ focusMode: tabsStore.store.focusMode,
1634
+ message: uiStore.store.statusMessage,
1635
+ theme: props.config.theme,
1636
+ keybinds: {
1637
+ toggle_focus: props.config.keybinds.toggle_focus,
1638
+ command_palette: props.config.keybinds.command_palette,
1639
+ edit_app: props.config.keybinds.edit_app,
1640
+ stop_app: props.config.keybinds.stop_app,
1641
+ kill_all: props.config.keybinds.kill_all,
1642
+ quit: props.config.keybinds.quit
1643
+ }
1644
+ }, undefined, false, undefined, this),
1645
+ /* @__PURE__ */ jsxDEV8(Show3, {
1646
+ when: uiStore.store.activeModal === "command-palette",
1647
+ children: /* @__PURE__ */ jsxDEV8(CommandPalette, {
1648
+ entries: appsStore.store.entries,
1649
+ theme: props.config.theme,
1650
+ onSelect: (entry, action) => {
1651
+ if (action === "edit") {
1652
+ openEditModal(entry.id);
1653
+ return;
1654
+ }
1655
+ uiStore.closeModal();
1656
+ if (action === "switch") {
1657
+ handleSelectApp(entry.id);
1658
+ } else if (action === "stop") {
1659
+ if (tabsStore.store.runningApps.has(entry.id)) {
1660
+ stopApp(entry.id);
1661
+ } else {
1662
+ uiStore.showTemporaryMessage(`Not running: ${entry.name}`);
1663
+ }
1664
+ }
1665
+ },
1666
+ onClose: () => uiStore.closeModal()
1667
+ }, undefined, false, undefined, this)
1668
+ }, undefined, false, undefined, this),
1669
+ /* @__PURE__ */ jsxDEV8(Show3, {
1670
+ when: uiStore.store.activeModal === "add-tab",
1671
+ children: /* @__PURE__ */ jsxDEV8(AddTabModal, {
1672
+ theme: props.config.theme,
1673
+ onAdd: handleAddApp,
1674
+ onClose: () => uiStore.closeModal()
1675
+ }, undefined, false, undefined, this)
1676
+ }, undefined, false, undefined, this),
1677
+ /* @__PURE__ */ jsxDEV8(Show3, {
1678
+ when: uiStore.store.activeModal === "edit-app" && editingEntry(),
1679
+ children: /* @__PURE__ */ jsxDEV8(EditAppModal, {
1680
+ theme: props.config.theme,
1681
+ entry: editingEntry(),
1682
+ onSave: (updates) => handleEditApp(editingEntry().id, updates),
1683
+ onClose: () => {
1684
+ uiStore.closeModal();
1685
+ setEditingEntryId(null);
1686
+ }
1687
+ }, undefined, false, undefined, this)
1688
+ }, undefined, false, undefined, this)
1689
+ ]
1690
+ }, undefined, true, undefined, this);
1691
+ };
1692
+
1693
+ // src/index.tsx
1694
+ import { jsxDEV as jsxDEV9 } from "@opentui/solid/jsx-dev-runtime";
1695
+ extend({ "ghostty-terminal": GhosttyTerminalRenderable });
1696
+ async function main() {
1697
+ try {
1698
+ const config = await loadConfig();
1699
+ initSessionPath(config);
1700
+ const session = config.session.persist ? await restoreSession() : null;
1701
+ await render(() => /* @__PURE__ */ jsxDEV9(App, {
1702
+ config,
1703
+ session
1704
+ }, undefined, false, undefined, this));
1705
+ const handleShutdown = () => {
1706
+ console.log(`
1707
+ Shutting down tuidoscope...`);
1708
+ process.exit(0);
1709
+ };
1710
+ process.on("SIGINT", handleShutdown);
1711
+ process.on("SIGTERM", handleShutdown);
1712
+ } catch (error) {
1713
+ console.error("Failed to start tuidoscope:", error);
1714
+ process.exit(1);
1715
+ }
1716
+ }
1717
+ main();