go-dev 0.6.0 → 0.8.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/README.md CHANGED
@@ -18,6 +18,7 @@ In complex monorepos, starting your development environment can be a chore. You
18
18
  * **`docker` services:** Manage Docker containers via `docker compose`. Automatically checks container status and performs health checks.
19
19
  * **Mode-Aware Dependencies:** Services can depend on other services running in specific modes (e.g., your `api` dev mode might depend on `frontend` in `serve` mode).
20
20
  * **Preset-Driven Startup:** Define different "presets" (e.g., `api`, `frontend`, `all`) to easily spin up specific combinations of services tailored to your current development focus.
21
+ * **Interactive Selection (no preset required):** Run `go-dev` with no preset to open a full-screen TUI where you can pick a preset *or* compose a custom selection — toggle services and choose a mode per service — and optionally save that selection as a new preset. Presets become a convenience, not a requirement.
21
22
  * **Automatic Dependency Resolution:** `go-dev` builds an intelligent execution graph, starting services in the correct topological order.
22
23
  * **Centralized Logging:** Prefixes logs from each service, making it easy to follow activity from multiple concurrent processes.
23
24
  * **Automatic Process Exit:** The `go-dev` process will automatically exit when all primary services (those directly listed in the chosen preset) exit cleanly (with a success code of `0`).
@@ -48,11 +49,27 @@ yarn add --dev go-dev
48
49
  Once installed, simply run `go-dev` with the name of the preset you want to start:
49
50
 
50
51
  ```bash
51
- npx go-dev <preset_name> [-c|--config <path>]
52
+ npx go-dev [preset_name] [-c|--config <path>] [-i|--interactive]
52
53
  ```
53
54
 
54
- * `<preset_name>`: The name of the preset defined in your `go-dev.yml` (e.g., `api`, `frontend`, `all`).
55
+ * `[preset_name]`: (Optional) The name of the preset defined in your `go-dev.yml` (e.g., `api`, `frontend`, `all`). When omitted, `go-dev` opens the interactive selector (see below).
55
56
  * `-c <path>` / `--config <path>` (also `-c=<path>` / `--config=<path>`): (Optional) Path to your `go-dev.yml` file. When omitted, `go-dev` auto-discovers a config file in the current directory (see the **Configuration** section below for the lookup order). The flag must appear before any `--args-for` block.
57
+ * `-i` / `--interactive`: (Optional) Force the interactive selector even when a preset is given, pre-populating it from that preset so you can tweak the selection before starting.
58
+
59
+ ### Interactive selection
60
+
61
+ Running `go-dev` **without a preset** (in an interactive terminal) opens a full-screen TUI with two tabs:
62
+
63
+ * **Services & Modes** — toggle services with <kbd>Space</kbd>, cycle the mode of a hybrid service with <kbd>m</kbd>, then press <kbd>Enter</kbd>. You'll be offered to save the selection as a new preset (written back to your config, preserving comments).
64
+ * **Presets** — pick an existing preset and press <kbd>Enter</kbd> to start it.
65
+
66
+ A panel at the bottom shows the **resolved selection** split into sections — *primary services* and *dependencies* (each with its mode) — so you can see exactly what will start. On the **Services & Modes** tab it reflects the services you've checked; on the **Presets** tab, the highlighted preset.
67
+
68
+ If the same service is pulled in under **two different modes** (e.g. `keplero:build` as a primary while another service depends on `keplero:dev`), the panel flags a *mode conflict*: go-dev runs one instance per service, so the losing mode is dropped and that dependency goes unmet. The same warning is printed at startup, so it's visible even when launching a preset by name without the TUI.
69
+
70
+ Navigate tabs with <kbd>←</kbd>/<kbd>→</kbd>, move with <kbd>↑</kbd>/<kbd>↓</kbd>, and quit with <kbd>q</kbd>. When stdin is not a TTY (e.g. CI) and no preset is given, `go-dev` exits with an error instead of opening the TUI.
71
+
72
+ The selector **remembers your last launched selection per config file** and restores it the next time you open it. This state is stored in your user state directory (`$XDG_STATE_HOME/go-dev/` on Linux/macOS, `%LOCALAPPDATA%\go-dev\` on Windows), keyed by the config file's canonical absolute path — **never written into your repo**.
56
73
 
57
74
  **Passing Arguments to Service Commands:**
58
75
 
package/bin/go-dev CHANGED
@@ -1,19 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const Orchestrator = require('../src/orchestrator');
4
- const path = require('path');
3
+ const { run } = require('../src/run');
5
4
 
6
- const { parseCliArgs } = require('../src/cli-args');
7
-
8
- const { presetName, configPath, logLevel, remaining } = parseCliArgs(process.argv.slice(2));
9
-
10
- if (!presetName) {
11
- console.error('Error: Please specify a preset to run. Usage: go-dev <preset_name> [-c|--config <path>] [-l|--log-level <level>] [--args-for ...]');
12
- process.exit(1);
13
- }
14
-
15
- process.argv = [process.argv[0], process.argv[1], presetName, ...remaining];
16
-
17
- const orchestrator = new Orchestrator(configPath, { logLevel });
18
-
19
- orchestrator.start(presetName);
5
+ run(process.argv.slice(2));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "go-dev",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "main": "src/index.js",
5
5
  "bin": {
6
6
  "go-dev": "bin/go-dev"
@@ -17,6 +17,7 @@
17
17
  "description": "",
18
18
  "dependencies": {
19
19
  "joi": "^17.13.3",
20
- "js-yaml": "^4.1.0"
20
+ "js-yaml": "^4.1.0",
21
+ "terminal-kit": "^3.1.2"
21
22
  }
22
23
  }
@@ -0,0 +1,77 @@
1
+ # Spike TUI — terminal-kit vs OpenTUI
2
+
3
+ Prototipo comparativo per la TUI full-screen unificata di go-dev (schermata di
4
+ selezione a tab → viewer log streaming). Obiettivo: scegliere la libreria di
5
+ base sui **fatti**, non sulle opinioni.
6
+
7
+ Entrambi gli spike implementano le **stesse due schermate**, alimentate dalla
8
+ **stessa** sorgente di log fasulla ad alto volume (`shared/fake-log-source.js`,
9
+ ~350 righe/s, mix servizi + libreria/`core`):
10
+
11
+ 1. **SELECT** — due tab (`Presets` / `Services & Modes`).
12
+ 2. **LOGS** — pannello scrollabile con filtro libreria-vs-servizi (`f`, `1-4`, `a`).
13
+
14
+ ## Come eseguire
15
+
16
+ ```bash
17
+ # interattivo (serve un vero terminale)
18
+ cd terminal-kit && npm install && npm start
19
+ cd opentui && npm install && npm start # ⚠️ richiede Bun, vedi sotto
20
+
21
+ # smoke test non interattivo (sotto pseudo-TTY, auto-exit dopo ~1.5s)
22
+ script -qec "stty rows 40 cols 120; node index.js --smoke" /dev/null # terminal-kit
23
+ script -qec "stty rows 40 cols 120; bun index.js --smoke" /dev/null # opentui
24
+ ```
25
+
26
+ ## Risultati misurati (Node 22.22, Linux x64)
27
+
28
+ | Criterio | terminal-kit | OpenTUI (`@opentui/core` 0.4) |
29
+ |---|---|---|
30
+ | **Gira su Node.js** | ✅ `received=480`, ok | ❌ **fallisce all'init** |
31
+ | **Runtime richiesto** | Node (JS puro) | **Bun** (`received=498`, ok solo con `bun`) |
32
+ | Tab nativi | ❌ fatti a mano (~15 righe) | ✅ `TabSelectRenderable` (con descrizioni/underline) |
33
+ | Log scrollabili nativi | ✅ `TextBox` + `appendLog` | ✅ `ScrollBoxRenderable` (stickyScroll bottom) |
34
+ | LOC dello shell | 161 | 137 (tab gratis dal widget) |
35
+ | Footprint installato | **4.2 MB**, pure JS, 0 binari | **18 MB**, include `libopentui.so` per-piattaforma |
36
+ | Cross-platform | ovunque giri Node | binario nativo per OS/arch (linux/darwin/win32 x64+arm64 presenti su npm) |
37
+
38
+ ## Il blocco decisivo: OpenTUI richiede Bun
39
+
40
+ OpenTUI renderizza tramite **FFI nativo**. Il backend FFI nel bundle fa così:
41
+
42
+ ```js
43
+ if (isBun) return createBunBackend(require("bun:ffi")); // ok
44
+ try { return createNodeBackend(require("node:ffi")); } // node:ffi NON ESISTE
45
+ catch { return createUnsupportedBackend(error); } // -> throw all'uso
46
+ ```
47
+
48
+ `node:ffi` **non è un modulo di Node** (`ERR_UNKNOWN_BUILTIN_MODULE`), quindi
49
+ sotto Node il render lib non si inizializza e `createCliRenderer()` lancia:
50
+
51
+ ```
52
+ Error: Failed to initialize OpenTUI render library:
53
+ OpenTUI native FFI is not available for this runtime yet
54
+ ```
55
+
56
+ Sotto **Bun** lo stesso identico codice funziona e renderizza benissimo (tab con
57
+ descrizioni, underline, viewer log pinnato in fondo). La pagina "getting started"
58
+ dice che puoi *importare* `@opentui/core` da Node — ed è vero — ma *renderizzare*
59
+ no: serve Bun.
60
+
61
+ ## Conclusione / raccomandazione
62
+
63
+ Per go-dev **così com'è (CLI Node.js)** la scelta è **terminal-kit**:
64
+
65
+ - è l'unico dei due che **gira sul runtime del progetto** senza migrazioni;
66
+ - JS puro, zero binari nativi → portabilità Windows inclusa, in linea con
67
+ l'attenzione cross-platform già presente in `src/process-manager.js`;
68
+ - copre il pezzo difficile (log streaming scrollabili) con `TextBox.appendLog`;
69
+ - costo extra reale e contenuto: i **tab non sono nativi** e vanno disegnati a
70
+ mano (qui ~15 righe). Tutto il resto è coperto.
71
+
72
+ OpenTUI resta tecnicamente superiore per ergonomia widget (tab nativi, layout
73
+ flex, frame sub-ms) **ma solo se go-dev adottasse Bun come runtime** — decisione
74
+ ben più grande e fuori dallo scope di "migliorare la fruibilità con una TUI".
75
+
76
+ > Da rivalutare se/quando OpenTUI abiliterà il backend FFI per Node
77
+ > ("...not available for this runtime **yet**").
@@ -0,0 +1,181 @@
1
+ // go-dev TUI spike — OpenTUI core imperative API (non-React, Zig-backed).
2
+ //
3
+ // Same two screens as the terminal-kit spike, so the two are comparable:
4
+ // 1. SELECT — a native TabSelectRenderable ("Presets" / "Services & Modes")
5
+ // + a body Text. This is OpenTUI's selling point: tabs are a
6
+ // first-class widget, not hand-rolled.
7
+ // 2. LOGS — a native ScrollBoxRenderable with stickyScroll pinned to the
8
+ // bottom, fed by the same high-volume fake source, with the same
9
+ // library-vs-service filter.
10
+ //
11
+ // Run interactively: npm start
12
+ // Smoke test (PTY): node index.js --smoke
13
+ //
14
+ // ESM + top-level await: @opentui/core is ESM-only (note "type":"module").
15
+
16
+ import { createRequire } from 'node:module';
17
+ import {
18
+ createCliRenderer,
19
+ BoxRenderable,
20
+ TextRenderable,
21
+ ScrollBoxRenderable,
22
+ TabSelectRenderable,
23
+ } from '@opentui/core';
24
+
25
+ const require = createRequire(import.meta.url);
26
+ const { FakeLogSource, SERVICES } = require('../shared/fake-log-source');
27
+
28
+ const SMOKE = process.argv.includes('--smoke');
29
+
30
+ // ---- shared log model (the "log bus") --------------------------------------
31
+ const RING_CAP = 2000;
32
+ const ring = [];
33
+ let showCore = true;
34
+ let serviceFilter = null;
35
+ let received = 0;
36
+
37
+ function visibleLines() {
38
+ const out = [];
39
+ for (const e of ring) {
40
+ if (e.kind === 'core' && !showCore) continue;
41
+ if (serviceFilter && e.kind === 'service' && e.source !== serviceFilter) continue;
42
+ const tag = e.kind === 'core' ? '[core]' : `[${e.source}]`;
43
+ out.push(`${tag} ${e.level} ${e.line}`);
44
+ }
45
+ return out;
46
+ }
47
+
48
+ // ---- renderer + UI ----------------------------------------------------------
49
+ const renderer = await createCliRenderer({ exitOnCtrlC: false, targetFps: 30 });
50
+ const root = renderer.root;
51
+
52
+ let screen = 'select';
53
+ const PRESETS = ['api', 'dedup', 'basic'];
54
+
55
+ const header = new TextRenderable(renderer, { content: '', height: 1 });
56
+ root.add(header);
57
+
58
+ // SELECT view ---------------------------------------------------------------
59
+ const selectView = new BoxRenderable(renderer, { flexGrow: 1, flexDirection: 'column', padding: 1 });
60
+ const tabs = new TabSelectRenderable(renderer, {
61
+ height: 3,
62
+ options: [
63
+ { name: 'Presets', description: 'Avvia da un preset esistente' },
64
+ { name: 'Services & Modes', description: 'Componi una selezione personalizzata' },
65
+ ],
66
+ showDescription: true,
67
+ });
68
+ const selectBody = new TextRenderable(renderer, { content: '', flexGrow: 1 });
69
+ selectView.add(tabs);
70
+ selectView.add(selectBody);
71
+ root.add(selectView);
72
+
73
+ // LOGS view -----------------------------------------------------------------
74
+ const logsView = new BoxRenderable(renderer, { flexGrow: 1, flexDirection: 'column', visible: false });
75
+ const scroll = new ScrollBoxRenderable(renderer, {
76
+ flexGrow: 1,
77
+ stickyScroll: true,
78
+ stickyStart: 'bottom',
79
+ border: true,
80
+ });
81
+ const logText = new TextRenderable(renderer, { content: '' });
82
+ scroll.content.add(logText);
83
+ logsView.add(scroll);
84
+ root.add(logsView);
85
+
86
+ const footer = new TextRenderable(renderer, { content: '', height: 1 });
87
+ root.add(footer);
88
+
89
+ // ---- rendering --------------------------------------------------------------
90
+ function renderSelect() {
91
+ const tab = tabs.getSelectedIndex();
92
+ header.content = 'go-dev — selezione servizi';
93
+ if (tab === 0) {
94
+ selectBody.content =
95
+ 'Preset disponibili (invio per avviare):\n\n' +
96
+ PRESETS.map((p) => ` • ${p}`).join('\n');
97
+ } else {
98
+ selectBody.content =
99
+ 'Servizi disponibili (spazio = on/off, m = modalità):\n\n' +
100
+ SERVICES.map((s) => ` [x] ${s} mode: dev`).join('\n');
101
+ }
102
+ footer.content = ' ←/→ cambia tab invio: vai ai log q: esci';
103
+ }
104
+
105
+ function renderLogs() {
106
+ header.content =
107
+ `go-dev · logs core:${showCore ? 'ON' : 'OFF'}` +
108
+ ` filtro:${serviceFilter ?? 'tutti'} (${received} righe)`;
109
+ logText.content = visibleLines().join('\n');
110
+ footer.content =
111
+ ' f: libreria on/off 1-4: filtra servizio a: tutti ↑↓/pgup: scorri b: indietro q: esci';
112
+ }
113
+
114
+ function showSelect() {
115
+ screen = 'select';
116
+ logsView.visible = false;
117
+ selectView.visible = true;
118
+ renderSelect();
119
+ }
120
+
121
+ function showLogs() {
122
+ screen = 'logs';
123
+ selectView.visible = false;
124
+ logsView.visible = true;
125
+ renderLogs();
126
+ }
127
+
128
+ // ---- input ------------------------------------------------------------------
129
+ renderer.keyInput.on('keypress', (key) => {
130
+ const n = key.name;
131
+ if (n === 'q' || (key.ctrl && n === 'c')) return shutdown(0);
132
+
133
+ if (screen === 'select') {
134
+ if (n === 'left') { tabs.moveLeft(); renderSelect(); }
135
+ else if (n === 'right') { tabs.moveRight(); renderSelect(); }
136
+ else if (n === 'return' || n === 'enter') showLogs();
137
+ return;
138
+ }
139
+
140
+ switch (n) {
141
+ case 'f': showCore = !showCore; renderLogs(); break;
142
+ case 'a': case '0': serviceFilter = null; renderLogs(); break;
143
+ case '1': case '2': case '3': case '4':
144
+ serviceFilter = SERVICES[Number(n) - 1] || null; renderLogs(); break;
145
+ case 'up': scroll.scrollBy(-1); break;
146
+ case 'down': scroll.scrollBy(1); break;
147
+ case 'pageup': scroll.scrollBy(-scroll.height); break;
148
+ case 'pagedown': scroll.scrollBy(scroll.height); break;
149
+ case 'b': showSelect(); break;
150
+ }
151
+ });
152
+
153
+ // ---- lifecycle --------------------------------------------------------------
154
+ const source = new FakeLogSource({ intervalMs: 8, burst: 3 });
155
+ source.on('log', (e) => {
156
+ received++;
157
+ ring.push(e);
158
+ if (ring.length > RING_CAP) ring.shift();
159
+ });
160
+
161
+ let renderTimer = setInterval(() => { if (screen === 'logs') renderLogs(); }, 33);
162
+
163
+ function shutdown(code) {
164
+ source.stop();
165
+ clearInterval(renderTimer);
166
+ try { renderer.stop(); } catch {}
167
+ try { renderer.destroy?.(); } catch {}
168
+ if (SMOKE) console.log(`[smoke] opentui ok — received=${received} ring=${ring.length}`);
169
+ setTimeout(() => process.exit(code), 50);
170
+ }
171
+
172
+ showSelect();
173
+ renderer.start();
174
+ source.start();
175
+
176
+ if (SMOKE) {
177
+ setTimeout(showLogs, 300);
178
+ setTimeout(() => { showCore = false; }, 700);
179
+ setTimeout(() => { serviceFilter = SERVICES[0]; }, 1000);
180
+ setTimeout(() => shutdown(0), 1500);
181
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "spike-opentui",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "description": "go-dev TUI spike — OpenTUI core (non-React, Zig-backed)",
6
+ "type": "module",
7
+ "main": "index.js",
8
+ "scripts": {
9
+ "start": "node index.js"
10
+ },
11
+ "dependencies": {
12
+ "@opentui/core": "latest"
13
+ }
14
+ }
@@ -0,0 +1,89 @@
1
+ 'use strict';
2
+
3
+ // Shared, dependency-free high-volume log generator used by both spikes.
4
+ // Mirrors what go-dev's future "log bus" would emit: tagged events
5
+ // { source, kind, level, line } where kind is 'service' or 'core' (library).
6
+ //
7
+ // The point of the spike is to stress the TUI's log pane: many lines per
8
+ // second, interleaved sources, so we can judge streaming smoothness.
9
+
10
+ const { EventEmitter } = require('node:events');
11
+
12
+ const SERVICES = ['api', 'worker', 'main', 'db'];
13
+ const LEVELS = ['info', 'info', 'info', 'warn', 'debug', 'error'];
14
+
15
+ const SERVICE_LINES = [
16
+ 'GET /health 200 1ms',
17
+ 'processing job %d',
18
+ 'cache miss for key user:%d',
19
+ 'flush batch size=%d',
20
+ 'connection pool: %d active',
21
+ 'emitted event order.created id=%d',
22
+ 'slow query took %dms',
23
+ 'retrying upstream (attempt %d)',
24
+ ];
25
+
26
+ const CORE_LINES = [
27
+ 'resolved dependency graph (%d nodes)',
28
+ 'service ready gate satisfied',
29
+ 'spawned managed process pid=%d',
30
+ 'readyWhen: port check ok',
31
+ 'preCommand finished in %dms',
32
+ ];
33
+
34
+ // Deterministic-ish pseudo counter so we don't need Math.random (and so output
35
+ // is reproducible run-to-run, which makes comparing the two spikes fairer).
36
+ let seq = 0;
37
+ function nextInt(mod) {
38
+ seq = (seq * 1103515245 + 12345) & 0x7fffffff;
39
+ return seq % mod;
40
+ }
41
+
42
+ function fmt(template) {
43
+ return template.replace('%d', String(nextInt(9000) + 100));
44
+ }
45
+
46
+ class FakeLogSource extends EventEmitter {
47
+ constructor({ intervalMs = 8, burst = 3 } = {}) {
48
+ super();
49
+ this.intervalMs = intervalMs; // how often we emit a burst
50
+ this.burst = burst; // lines per burst -> ~burst/intervalMs * 1000 lines/s
51
+ this._timer = null;
52
+ }
53
+
54
+ start() {
55
+ if (this._timer) return;
56
+ this._timer = setInterval(() => {
57
+ for (let i = 0; i < this.burst; i++) this._emitOne();
58
+ }, this.intervalMs);
59
+ // ~375 lines/s at defaults — enough to expose flicker/lag.
60
+ }
61
+
62
+ stop() {
63
+ if (this._timer) clearInterval(this._timer);
64
+ this._timer = null;
65
+ }
66
+
67
+ _emitOne() {
68
+ const isCore = nextInt(6) === 0; // ~1 in 6 lines are library/core logs
69
+ const level = LEVELS[nextInt(LEVELS.length)];
70
+ if (isCore) {
71
+ this.emit('log', {
72
+ source: 'core',
73
+ kind: 'core',
74
+ level,
75
+ line: fmt(CORE_LINES[nextInt(CORE_LINES.length)]),
76
+ });
77
+ } else {
78
+ const service = SERVICES[nextInt(SERVICES.length)];
79
+ this.emit('log', {
80
+ source: service,
81
+ kind: 'service',
82
+ level,
83
+ line: fmt(SERVICE_LINES[nextInt(SERVICE_LINES.length)]),
84
+ });
85
+ }
86
+ }
87
+ }
88
+
89
+ module.exports = { FakeLogSource, SERVICES };
@@ -0,0 +1,206 @@
1
+ 'use strict';
2
+
3
+ // go-dev TUI spike — terminal-kit (pure JS, non-React).
4
+ //
5
+ // Two screens, mirroring the real design:
6
+ // 1. SELECT — two hand-rolled tabs ("Presets" / "Services"). terminal-kit has
7
+ // no native tab widget, so we draw the tab bar ourselves. This is
8
+ // an honest data point on the per-feature cost.
9
+ // 2. LOGS — a scrollable TextBox fed by a high-volume fake source, with a
10
+ // library-vs-service filter. TextBox + appendLog/setContent does
11
+ // the heavy lifting here, which is terminal-kit's strong suit.
12
+ //
13
+ // Run interactively: npm start
14
+ // Smoke test (CI/PTY): node index.js --smoke (build UI, pump logs, auto-exit)
15
+
16
+ const termkit = require('terminal-kit');
17
+ const term = termkit.terminal;
18
+ const { FakeLogSource, SERVICES } = require('../shared/fake-log-source');
19
+
20
+ const SMOKE = process.argv.includes('--smoke');
21
+
22
+ // ---- shared log model (this is the "log bus" go-dev would grow) -------------
23
+ const RING_CAP = 2000;
24
+ const ring = []; // { source, kind, level, line }
25
+ let showCore = true; // 'f' toggles library/core logs
26
+ let serviceFilter = null; // 1-4 -> a single service; null = all services
27
+ let pinnedToBottom = true; // false once the user scrolls up
28
+ let received = 0;
29
+
30
+ function levelColor(level) {
31
+ switch (level) {
32
+ case 'error': return '^r';
33
+ case 'warn': return '^y';
34
+ case 'debug': return '^K';
35
+ default: return '^g';
36
+ }
37
+ }
38
+
39
+ function visibleLines() {
40
+ const out = [];
41
+ for (const e of ring) {
42
+ if (e.kind === 'core' && !showCore) continue;
43
+ if (serviceFilter && e.kind === 'service' && e.source !== serviceFilter) continue;
44
+ const tag = e.kind === 'core' ? '^c[core]^:' : `^b[${e.source}]^:`;
45
+ out.push(`${tag} ${levelColor(e.level)}${e.level}^: ${e.line}`);
46
+ }
47
+ return out;
48
+ }
49
+
50
+ // ---- UI ---------------------------------------------------------------------
51
+ let screen = 'select'; // 'select' | 'logs'
52
+ let activeTab = 0; // 0 = Presets, 1 = Services
53
+ const TABS = ['Presets', 'Services & Modes'];
54
+ const PRESETS = ['api', 'dedup', 'basic'];
55
+
56
+ let document, logBox, headerText, bodyText, footerText;
57
+
58
+ function buildUI() {
59
+ term.fullscreen(true);
60
+ document = term.createDocument();
61
+
62
+ headerText = new termkit.Text({ parent: document, x: 0, y: 0, content: '', contentHasMarkup: true });
63
+ bodyText = new termkit.Text({ parent: document, x: 0, y: 2, content: '', contentHasMarkup: true });
64
+
65
+ logBox = new termkit.TextBox({
66
+ parent: document,
67
+ x: 0, y: 2,
68
+ width: term.width,
69
+ height: term.height - 4,
70
+ scrollable: true,
71
+ vScrollBar: true,
72
+ contentHasMarkup: true,
73
+ hidden: true,
74
+ });
75
+
76
+ footerText = new termkit.Text({
77
+ parent: document,
78
+ x: 0, y: term.height - 1,
79
+ content: '', contentHasMarkup: true,
80
+ });
81
+
82
+ renderSelect();
83
+ }
84
+
85
+ function renderSelect() {
86
+ const tabBar = TABS.map((t, i) =>
87
+ i === activeTab ? `^#^k ${t} ^:` : `^K ${t} ^:`
88
+ ).join(' ');
89
+ headerText.setContent(`^+go-dev^: ${tabBar}`, true);
90
+
91
+ let body;
92
+ if (activeTab === 0) {
93
+ body = PRESETS.length
94
+ ? ' Preset disponibili (↑↓ + invio per avviare):\n\n' +
95
+ PRESETS.map(p => ` ^b•^: ${p}`).join('\n')
96
+ : ' Nessun preset definito — usa la tab Services.';
97
+ } else {
98
+ body = ' Servizi disponibili (spazio per (de)selezionare, m per modalità):\n\n' +
99
+ SERVICES.map(s => ` ^g[x]^: ${s} ^Kmode: dev^:`).join('\n');
100
+ }
101
+ bodyText.setContent(body, true);
102
+ footerText.setContent('^K tab^: cambia tab ^K invio^: vai ai log ^K q^: esci', true);
103
+ }
104
+
105
+ function renderLogs() {
106
+ headerText.setContent(
107
+ `^+go-dev · logs^: core:${showCore ? '^gON^:' : '^rOFF^:'}` +
108
+ ` filtro:${serviceFilter ? '^b' + serviceFilter + '^:' : 'tutti'}` +
109
+ ` ^K(${received} righe)^:`,
110
+ true
111
+ );
112
+ const lines = visibleLines();
113
+ logBox.setContent(lines.join('\n'), true, true);
114
+ if (pinnedToBottom) logBox.scrollToBottom(true);
115
+ logBox.draw();
116
+ footerText.setContent(
117
+ '^K f^: libreria on/off ^K 1-4^: filtra servizio ^K a^: tutti ' +
118
+ '^K ↑↓/pgup^: scorri ^K g/G^: cima/fondo ^K b^: indietro ^K q^: esci',
119
+ true
120
+ );
121
+ }
122
+
123
+ function goToLogs() {
124
+ screen = 'logs';
125
+ bodyText.hide();
126
+ logBox.show();
127
+ logBox.y = 2;
128
+ logBox.outerHeight = term.height - 4;
129
+ renderLogs();
130
+ }
131
+
132
+ function goToSelect() {
133
+ screen = 'select';
134
+ logBox.hide();
135
+ bodyText.show();
136
+ renderSelect();
137
+ }
138
+
139
+ // ---- input ------------------------------------------------------------------
140
+ function onKey(name) {
141
+ if (name === 'CTRL_C' || name === 'q') return shutdown(0);
142
+
143
+ if (screen === 'select') {
144
+ if (name === 'TAB' || name === 'RIGHT') { activeTab = (activeTab + 1) % TABS.length; renderSelect(); }
145
+ else if (name === 'LEFT') { activeTab = (activeTab + TABS.length - 1) % TABS.length; renderSelect(); }
146
+ else if (name === 'ENTER') goToLogs();
147
+ return;
148
+ }
149
+
150
+ // logs screen
151
+ switch (name) {
152
+ case 'f': showCore = !showCore; renderLogs(); break;
153
+ case 'a': case '0': serviceFilter = null; renderLogs(); break;
154
+ case '1': case '2': case '3': case '4':
155
+ serviceFilter = SERVICES[Number(name) - 1] || null; renderLogs(); break;
156
+ case 'UP': pinnedToBottom = false; logBox.scroll(0, 1); break;
157
+ case 'DOWN': logBox.scroll(0, -1); break;
158
+ case 'PAGE_UP': pinnedToBottom = false; logBox.scroll(0, logBox.outerHeight); break;
159
+ case 'PAGE_DOWN': logBox.scroll(0, -logBox.outerHeight); break;
160
+ case 'g': pinnedToBottom = false; logBox.scrollTo(0, 0); break;
161
+ case 'G': pinnedToBottom = true; logBox.scrollToBottom(); break;
162
+ case 'b': goToSelect(); break;
163
+ }
164
+ }
165
+
166
+ // ---- lifecycle --------------------------------------------------------------
167
+ const source = new FakeLogSource({ intervalMs: 8, burst: 3 });
168
+ source.on('log', (e) => {
169
+ received++;
170
+ ring.push(e);
171
+ if (ring.length > RING_CAP) ring.shift();
172
+ });
173
+
174
+ let renderTimer = null;
175
+ function startRenderLoop() {
176
+ renderTimer = setInterval(() => { if (screen === 'logs') renderLogs(); }, 33); // ~30fps
177
+ }
178
+
179
+ function shutdown(code) {
180
+ source.stop();
181
+ if (renderTimer) clearInterval(renderTimer);
182
+ term.grabInput(false);
183
+ term.fullscreen(false);
184
+ term.hideCursor(false);
185
+ if (SMOKE) console.log(`[smoke] terminal-kit ok — received=${received} ring=${ring.length}`);
186
+ setTimeout(() => process.exit(code), 50);
187
+ }
188
+
189
+ function main() {
190
+ buildUI();
191
+ term.grabInput(true);
192
+ term.hideCursor(true);
193
+ term.on('key', onKey);
194
+ source.start();
195
+ startRenderLoop();
196
+
197
+ if (SMOKE) {
198
+ // Drive the real code path without a human: jump to logs, toggle a filter, exit.
199
+ setTimeout(goToLogs, 300);
200
+ setTimeout(() => { showCore = false; }, 700);
201
+ setTimeout(() => { serviceFilter = SERVICES[0]; }, 1000);
202
+ setTimeout(() => shutdown(0), 1500);
203
+ }
204
+ }
205
+
206
+ main();
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "spike-terminal-kit",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "description": "go-dev TUI spike — terminal-kit (pure JS, non-React)",
6
+ "main": "index.js",
7
+ "scripts": {
8
+ "start": "node index.js"
9
+ },
10
+ "dependencies": {
11
+ "terminal-kit": "^3.1.2"
12
+ }
13
+ }