@fcannizzaro/exocommand 1.0.10 → 1.1.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/dist/tui.js ADDED
@@ -0,0 +1,517 @@
1
+ import { createCliRenderer, Box, Text, TextAttributes, t, bold, dim, fg, ScrollBoxRenderable, BoxRenderable, TextRenderable, Renderable, } from "@opentui/core";
2
+ // Tokyo Night Dark palette
3
+ const TN_BG = "#1a1b26";
4
+ const TN_BG_DARK = "#16161e";
5
+ const TN_BG_HIGHLIGHT = "#292e42";
6
+ const TN_FG_DARK = "#565f89";
7
+ const TN_CYAN = "#7dcfff";
8
+ const TN_GREEN = "#9ece6a";
9
+ const TN_YELLOW = "#e0af68";
10
+ const TN_RED = "#f7768e";
11
+ const TN_BLUE = "#7aa2f7";
12
+ const TN_MAGENTA = "#bb9af7";
13
+ const TN_ORANGE = "#ff9e64";
14
+ const SPINNER_FRAMES = [
15
+ "\u28CB",
16
+ "\u28D9",
17
+ "\u28F9",
18
+ "\u28F8",
19
+ "\u28FC",
20
+ "\u28F4",
21
+ "\u28E6",
22
+ "\u28E7",
23
+ "\u28C7",
24
+ "\u28CF",
25
+ ];
26
+ function formatTime(date) {
27
+ const h = String(date.getHours()).padStart(2, "0");
28
+ const m = String(date.getMinutes()).padStart(2, "0");
29
+ const s = String(date.getSeconds()).padStart(2, "0");
30
+ return `${h}:${m}:${s}`;
31
+ }
32
+ function getStatusStyle(status, frame) {
33
+ switch (status) {
34
+ case "running":
35
+ return { color: TN_YELLOW, label: "running", symbol: frame };
36
+ case "success":
37
+ return { color: TN_GREEN, label: "success", symbol: "\u2713" };
38
+ case "error":
39
+ return { color: TN_RED, label: "error", symbol: "\u2717" };
40
+ case "cancelled":
41
+ return { color: TN_ORANGE, label: "cancelled", symbol: "\u25CB" };
42
+ case "timeout":
43
+ return { color: TN_ORANGE, label: "timeout", symbol: "\u25F4" };
44
+ }
45
+ }
46
+ class PillTabBar {
47
+ renderer;
48
+ container;
49
+ options = [];
50
+ selectedIndex = -1;
51
+ wrapSelection;
52
+ selectionCallback = null;
53
+ tabElements = [];
54
+ keyListener = null;
55
+ constructor(renderer, opts) {
56
+ this.renderer = renderer;
57
+ this.wrapSelection = opts.wrapSelection ?? true;
58
+ this.container = new BoxRenderable(renderer, {
59
+ id: "project-tabs",
60
+ flexDirection: "row",
61
+ width: "100%",
62
+ flexShrink: 0,
63
+ gap: 1,
64
+ alignItems: "center",
65
+ paddingLeft: 1,
66
+ paddingRight: 1,
67
+ });
68
+ this.container.visible = false;
69
+ // Global key listener — this is the only interactive element in the TUI
70
+ this.keyListener = (key) => this.handleKey(key);
71
+ renderer.keyInput.on("keypress", this.keyListener);
72
+ }
73
+ handleKey(key) {
74
+ if (!this.container.visible || this.options.length === 0)
75
+ return;
76
+ if (key.name === "left" || key.name === "[") {
77
+ this.moveSelection(-1);
78
+ }
79
+ else if (key.name === "right" || key.name === "]") {
80
+ this.moveSelection(1);
81
+ }
82
+ }
83
+ moveSelection(delta) {
84
+ if (this.options.length === 0)
85
+ return;
86
+ let newIndex = this.selectedIndex + delta;
87
+ if (this.wrapSelection) {
88
+ newIndex =
89
+ ((newIndex % this.options.length) + this.options.length) %
90
+ this.options.length;
91
+ }
92
+ else {
93
+ newIndex = Math.max(0, Math.min(this.options.length - 1, newIndex));
94
+ }
95
+ this.selectIndex(newIndex);
96
+ }
97
+ selectIndex(index) {
98
+ if (index === this.selectedIndex)
99
+ return;
100
+ this.selectedIndex = index;
101
+ this.rebuild();
102
+ this.selectionCallback?.(index, this.options[index]);
103
+ }
104
+ rebuild() {
105
+ for (const el of this.tabElements) {
106
+ this.container.remove(el.id);
107
+ el.destroy();
108
+ }
109
+ this.tabElements = [];
110
+ for (let i = 0; i < this.options.length; i++) {
111
+ const option = this.options[i];
112
+ const isSelected = i === this.selectedIndex;
113
+ if (isSelected) {
114
+ // Solid background block, no border
115
+ const text = new TextRenderable(this.renderer, {
116
+ id: `tab-text-${i}`,
117
+ content: t ` ${bold(fg(TN_BG)(option.name))} `,
118
+ selectable: false,
119
+ });
120
+ const pill = new BoxRenderable(this.renderer, {
121
+ id: `tab-pill-${i}`,
122
+ backgroundColor: TN_CYAN,
123
+ paddingLeft: 1,
124
+ paddingRight: 1,
125
+ onMouseDown: () => this.selectIndex(i),
126
+ });
127
+ pill.add(text);
128
+ this.container.add(pill);
129
+ this.tabElements.push(pill);
130
+ }
131
+ else {
132
+ // Plain text label with padding
133
+ const label = new TextRenderable(this.renderer, {
134
+ id: `tab-label-${i}`,
135
+ content: t ` ${fg(TN_FG_DARK)(option.name)} `,
136
+ selectable: false,
137
+ onMouseDown: () => this.selectIndex(i),
138
+ });
139
+ this.container.add(label);
140
+ this.tabElements.push(label);
141
+ }
142
+ }
143
+ }
144
+ get visible() {
145
+ return this.container.visible;
146
+ }
147
+ set visible(value) {
148
+ this.container.visible = value;
149
+ }
150
+ get renderable() {
151
+ return this.container;
152
+ }
153
+ setOptions(options) {
154
+ this.options = options;
155
+ if (this.selectedIndex >= options.length) {
156
+ this.selectedIndex = Math.max(0, options.length - 1);
157
+ }
158
+ this.rebuild();
159
+ }
160
+ getSelectedOption() {
161
+ if (this.selectedIndex < 0 || this.selectedIndex >= this.options.length) {
162
+ return null;
163
+ }
164
+ return this.options[this.selectedIndex];
165
+ }
166
+ setSelectedIndex(index) {
167
+ if (index < 0 || index >= this.options.length)
168
+ return;
169
+ if (index === this.selectedIndex)
170
+ return;
171
+ this.selectedIndex = index;
172
+ this.rebuild();
173
+ this.selectionCallback?.(index, this.options[index]);
174
+ }
175
+ onChanged(callback) {
176
+ this.selectionCallback = callback;
177
+ }
178
+ destroy() {
179
+ if (this.keyListener) {
180
+ this.renderer.keyInput.off("keypress", this.keyListener);
181
+ this.keyListener = null;
182
+ }
183
+ for (const el of this.tabElements) {
184
+ el.destroy();
185
+ }
186
+ this.tabElements = [];
187
+ this.container.destroy();
188
+ }
189
+ }
190
+ export async function createTui(version) {
191
+ const renderer = await createCliRenderer({
192
+ exitOnCtrlC: true,
193
+ useMouse: true,
194
+ targetFps: 30,
195
+ onDestroy: () => {
196
+ process.exit();
197
+ },
198
+ });
199
+ // Per-project panels and lookup maps
200
+ const panels = new Map();
201
+ const cardToProject = new Map();
202
+ let totalRunningCount = 0;
203
+ let spinnerIndex = 0;
204
+ let spinnerTimer = null;
205
+ // -- Toolbar --
206
+ const toolbar = Box({
207
+ flexDirection: "row",
208
+ width: "100%",
209
+ flexShrink: 0,
210
+ alignItems: "center",
211
+ justifyContent: "space-between",
212
+ backgroundColor: TN_BG_DARK,
213
+ borderStyle: "rounded",
214
+ borderColor: TN_BG_HIGHLIGHT,
215
+ paddingLeft: 2,
216
+ paddingRight: 2,
217
+ }, Box({ flexDirection: "row", gap: 0 }, Text({
218
+ content: "@fcannizzaro/",
219
+ fg: TN_FG_DARK,
220
+ }), Text({
221
+ content: "exocommand",
222
+ fg: TN_CYAN,
223
+ attributes: TextAttributes.BOLD,
224
+ })), Text({
225
+ content: `v${version}`,
226
+ fg: TN_FG_DARK,
227
+ attributes: TextAttributes.DIM,
228
+ }));
229
+ // -- PillTabBar for project switching --
230
+ const tabBar = new PillTabBar(renderer, { wrapSelection: true });
231
+ // Show the correct panel when the user navigates tabs
232
+ tabBar.onChanged((_index, option) => {
233
+ for (const [key, panel] of panels) {
234
+ panel.scrollBox.visible = option.value === key;
235
+ }
236
+ });
237
+ // -- Panel container (holds per-project ScrollBoxes) --
238
+ const panelContainer = new BoxRenderable(renderer, {
239
+ id: "panel-container",
240
+ flexGrow: 1,
241
+ width: "100%",
242
+ flexDirection: "column",
243
+ });
244
+ // -- Status bar --
245
+ const statusMessage = new TextRenderable(renderer, {
246
+ id: "status-message",
247
+ content: "",
248
+ fg: TN_FG_DARK,
249
+ });
250
+ const statusPort = new TextRenderable(renderer, {
251
+ id: "status-port",
252
+ content: "",
253
+ fg: TN_FG_DARK,
254
+ });
255
+ const statusBar = Box({
256
+ width: "100%",
257
+ height: 3,
258
+ backgroundColor: TN_BG_DARK,
259
+ borderStyle: "rounded",
260
+ borderColor: TN_BG_HIGHLIGHT,
261
+ flexDirection: "row",
262
+ alignItems: "center",
263
+ justifyContent: "space-between",
264
+ paddingLeft: 1,
265
+ paddingRight: 1,
266
+ }, statusMessage, statusPort);
267
+ // -- Assemble tree --
268
+ renderer.root.add(Box({
269
+ flexDirection: "column",
270
+ width: "100%",
271
+ height: "100%",
272
+ backgroundColor: TN_BG,
273
+ }, toolbar, tabBar.renderable, panelContainer, statusBar));
274
+ // -- Tab options helper --
275
+ function rebuildTabOptions() {
276
+ const options = [...panels.values()].map((p) => ({
277
+ name: p.label,
278
+ value: p.projectKey,
279
+ }));
280
+ tabBar.setOptions(options);
281
+ }
282
+ // -- Show the panel for the currently selected tab --
283
+ function syncPanelVisibility() {
284
+ const selected = tabBar.getSelectedOption();
285
+ for (const [key, panel] of panels) {
286
+ panel.scrollBox.visible = selected?.value === key;
287
+ }
288
+ }
289
+ // -- Spinner control --
290
+ function startSpinner() {
291
+ if (spinnerTimer)
292
+ return;
293
+ renderer.requestLive();
294
+ spinnerTimer = setInterval(() => {
295
+ spinnerIndex = (spinnerIndex + 1) % SPINNER_FRAMES.length;
296
+ const frame = SPINNER_FRAMES[spinnerIndex];
297
+ for (const panel of panels.values()) {
298
+ for (const card of panel.cards.values()) {
299
+ if (card.status === "running") {
300
+ card.statusText.content = t `${fg(TN_YELLOW)(`${frame} running`)}`;
301
+ }
302
+ }
303
+ }
304
+ }, 80);
305
+ }
306
+ function stopSpinner() {
307
+ if (!spinnerTimer)
308
+ return;
309
+ clearInterval(spinnerTimer);
310
+ spinnerTimer = null;
311
+ renderer.dropLive();
312
+ }
313
+ // -- Card factory --
314
+ function createCard(id, commandName, agentId, startedAt) {
315
+ const statusText = new TextRenderable(renderer, {
316
+ id: `status-text-${id}`,
317
+ content: t `${fg(TN_YELLOW)(`${SPINNER_FRAMES[0]} running`)}`,
318
+ });
319
+ const statusBadge = new BoxRenderable(renderer, {
320
+ id: `status-badge-${id}`,
321
+ borderStyle: "rounded",
322
+ borderColor: TN_YELLOW,
323
+ paddingLeft: 1,
324
+ paddingRight: 1,
325
+ height: 3,
326
+ });
327
+ statusBadge.add(statusText);
328
+ const cardBox = new BoxRenderable(renderer, {
329
+ id: `card-${id}`,
330
+ borderStyle: "rounded",
331
+ borderColor: TN_FG_DARK,
332
+ flexDirection: "row",
333
+ gap: 1,
334
+ width: "100%",
335
+ paddingLeft: 1,
336
+ paddingRight: 1,
337
+ title: ` ${formatTime(startedAt)} `,
338
+ titleAlignment: "left",
339
+ });
340
+ // cmd badge
341
+ const cmdBadge = Box({
342
+ borderStyle: "rounded",
343
+ borderColor: TN_FG_DARK,
344
+ paddingLeft: 1,
345
+ paddingRight: 1,
346
+ height: 3,
347
+ }, Text({ content: t `${dim("cmd")} ${fg(TN_MAGENTA)(commandName)}` }));
348
+ // agent badge
349
+ const agentBadge = Box({
350
+ borderStyle: "rounded",
351
+ borderColor: TN_FG_DARK,
352
+ paddingLeft: 1,
353
+ paddingRight: 1,
354
+ height: 3,
355
+ }, Text({ content: t `${dim("agent")} ${fg(TN_CYAN)(String(agentId))}` }));
356
+ cardBox.add(agentBadge);
357
+ cardBox.add(cmdBadge);
358
+ cardBox.add(Box({ flexGrow: 1 }));
359
+ cardBox.add(statusBadge);
360
+ return {
361
+ id,
362
+ commandName,
363
+ agentId,
364
+ startedAt,
365
+ status: "running",
366
+ cardBox,
367
+ statusText,
368
+ statusBadge,
369
+ };
370
+ }
371
+ // -- TuiManager implementation --
372
+ return {
373
+ addProject(projectKey, label) {
374
+ if (panels.has(projectKey))
375
+ return;
376
+ const scrollBox = new ScrollBoxRenderable(renderer, {
377
+ id: `executions-${projectKey}`,
378
+ flexGrow: 1,
379
+ width: "100%",
380
+ stickyScroll: true,
381
+ stickyStart: "bottom",
382
+ viewportCulling: true,
383
+ contentOptions: {
384
+ flexDirection: "column",
385
+ gap: 0,
386
+ padding: 1,
387
+ },
388
+ scrollbarOptions: {
389
+ trackOptions: {
390
+ foregroundColor: TN_BLUE,
391
+ backgroundColor: TN_BG_HIGHLIGHT,
392
+ },
393
+ },
394
+ });
395
+ const emptyText = new BoxRenderable(renderer, {
396
+ id: `empty-${projectKey}`,
397
+ flexGrow: 1,
398
+ width: "100%",
399
+ alignItems: "center",
400
+ justifyContent: "center",
401
+ });
402
+ emptyText.add(new TextRenderable(renderer, {
403
+ id: `empty-text-${projectKey}`,
404
+ content: t `${dim("No commands executed yet")}`,
405
+ }));
406
+ scrollBox.add(emptyText);
407
+ scrollBox.visible = false;
408
+ panelContainer.add(scrollBox);
409
+ panels.set(projectKey, {
410
+ projectKey,
411
+ label,
412
+ scrollBox,
413
+ cards: new Map(),
414
+ runningCount: 0,
415
+ emptyText,
416
+ });
417
+ rebuildTabOptions();
418
+ // First project: show tabs and select it
419
+ if (panels.size === 1) {
420
+ tabBar.visible = true;
421
+ tabBar.setSelectedIndex(0);
422
+ scrollBox.visible = true;
423
+ }
424
+ },
425
+ removeProject(projectKey) {
426
+ const panel = panels.get(projectKey);
427
+ if (!panel)
428
+ return;
429
+ const selected = tabBar.getSelectedOption();
430
+ const wasSelected = selected?.value === projectKey;
431
+ // Remove scroll box from container
432
+ panelContainer.remove(panel.scrollBox.id);
433
+ // Clean up card-to-project mappings
434
+ for (const cardId of panel.cards.keys()) {
435
+ cardToProject.delete(cardId);
436
+ }
437
+ totalRunningCount -= panel.runningCount;
438
+ panels.delete(projectKey);
439
+ if (panels.size === 0) {
440
+ tabBar.visible = false;
441
+ if (totalRunningCount <= 0) {
442
+ totalRunningCount = 0;
443
+ stopSpinner();
444
+ }
445
+ }
446
+ else {
447
+ rebuildTabOptions();
448
+ if (wasSelected) {
449
+ tabBar.setSelectedIndex(0);
450
+ syncPanelVisibility();
451
+ }
452
+ }
453
+ },
454
+ addExecution(id, commandName, agentId, projectKey) {
455
+ const panel = panels.get(projectKey);
456
+ if (!panel)
457
+ return;
458
+ // Hide empty state placeholder on first execution
459
+ if (panel.cards.size === 0) {
460
+ panel.emptyText.visible = false;
461
+ }
462
+ const startedAt = new Date();
463
+ const card = createCard(id, commandName, agentId, startedAt);
464
+ panel.cards.set(id, card);
465
+ cardToProject.set(id, projectKey);
466
+ panel.scrollBox.add(card.cardBox);
467
+ panel.runningCount++;
468
+ totalRunningCount++;
469
+ startSpinner();
470
+ },
471
+ updateExecution(id, status) {
472
+ const projectKey = cardToProject.get(id);
473
+ if (!projectKey)
474
+ return;
475
+ const panel = panels.get(projectKey);
476
+ if (!panel)
477
+ return;
478
+ const card = panel.cards.get(id);
479
+ if (!card)
480
+ return;
481
+ if (card.status !== "running")
482
+ return; // already in terminal state
483
+ card.status = status;
484
+ const style = getStatusStyle(status, SPINNER_FRAMES[spinnerIndex]);
485
+ card.statusText.content = t `${fg(style.color)(`${style.symbol} ${style.label}`)}`;
486
+ card.statusBadge.borderColor = style.color;
487
+ // update running count for terminal states
488
+ if (status !== "running") {
489
+ panel.runningCount--;
490
+ totalRunningCount--;
491
+ if (totalRunningCount <= 0) {
492
+ totalRunningCount = 0;
493
+ stopSpinner();
494
+ }
495
+ }
496
+ },
497
+ logMessage(level, event, message) {
498
+ const colorMap = {
499
+ info: TN_CYAN,
500
+ success: TN_GREEN,
501
+ warn: TN_YELLOW,
502
+ error: TN_RED,
503
+ };
504
+ const color = colorMap[level] ?? TN_CYAN;
505
+ const ts = formatTime(new Date());
506
+ statusMessage.content = t `${dim(ts)} ${bold(fg(color)(`[${event}]`))} ${message}`;
507
+ },
508
+ setPort(port) {
509
+ statusPort.content = t `${fg(TN_FG_DARK)(`:${port}`)}`;
510
+ },
511
+ destroy() {
512
+ stopSpinner();
513
+ tabBar.destroy();
514
+ renderer.destroy();
515
+ },
516
+ };
517
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@fcannizzaro/exocommand",
3
3
  "module": "index.ts",
4
- "description": "An MCP server that exposes user-defined shell commands as tools for AI coding assistants",
5
- "version": "1.0.10",
4
+ "description": "An MCP server (with TUI) that exposes user-defined shell commands as tools for AI coding assistants",
5
+ "version": "1.1.0",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
8
8
  "homepage": "https://github.com/fcannizzaro/exocommand",
@@ -45,11 +45,13 @@
45
45
  "dependencies": {
46
46
  "@hono/node-server": "^1.19.9",
47
47
  "@modelcontextprotocol/sdk": "^1.26.0",
48
+ "@opentui/core": "^0.1.81",
48
49
  "yaml": "^2.8.2"
49
50
  },
50
51
  "keywords": [
51
52
  "exocommand",
52
53
  "mcp",
54
+ "tui",
53
55
  "agentic-programming",
54
56
  "mcp",
55
57
  "model-context-protocol"