pilotswarm-cli 0.1.13 → 0.1.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pilotswarm-cli",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "description": "Terminal UI for PilotSwarm.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/app.js CHANGED
@@ -241,6 +241,7 @@ export function PilotSwarmTuiApp({ controller, platform, onRequestExit }) {
241
241
  const isCtrlA = matchesCtrlKey("a", "\u0001");
242
242
  const isShiftN = input === "N" || (key.shift && key.name === "n");
243
243
  const isShiftD = input === "D" || (key.shift && key.name === "d");
244
+ const isShiftT = !key.ctrl && !key.meta && !key.alt && (input === "T" || (key.shift && key.name === "t"));
244
245
  const isAltBackspace = key.meta && (key.backspace || key.delete || key.name === "backspace" || key.name === "delete");
245
246
  const isAltLeftWord = key.meta && (key.leftArrow || key.name === "left" || input === "b" || input === "B");
246
247
  const isAltRightWord = key.meta && (key.rightArrow || key.name === "right" || input === "f" || input === "F");
@@ -275,6 +276,10 @@ export function PilotSwarmTuiApp({ controller, platform, onRequestExit }) {
275
276
  }
276
277
 
277
278
  if (modal) {
279
+ if (isShiftT && modal.type === "themePicker") {
280
+ controller.handleCommand(UI_COMMANDS.CLOSE_MODAL).catch(() => {});
281
+ return;
282
+ }
278
283
  if (modal.type === "renameSession" || modal.type === "artifactUpload") {
279
284
  if (key.escape) {
280
285
  controller.handleCommand(UI_COMMANDS.CLOSE_MODAL).catch(() => {});
@@ -342,6 +347,11 @@ export function PilotSwarmTuiApp({ controller, platform, onRequestExit }) {
342
347
  return;
343
348
  }
344
349
 
350
+ if (focus !== "prompt" && isShiftT) {
351
+ controller.handleCommand(UI_COMMANDS.OPEN_THEME_PICKER).catch(() => {});
352
+ return;
353
+ }
354
+
345
355
  if (key.tab && key.shift) {
346
356
  controller.handleCommand(UI_COMMANDS.FOCUS_PREV).catch(() => {});
347
357
  return;
@@ -387,6 +397,18 @@ export function PilotSwarmTuiApp({ controller, platform, onRequestExit }) {
387
397
  controller.handleCommand(UI_COMMANDS.OPEN_FILES_FILTER).catch(() => {});
388
398
  return;
389
399
  }
400
+ if (focus === "inspector" && inspectorTab === "history" && input === "f") {
401
+ controller.handleCommand(UI_COMMANDS.OPEN_HISTORY_FORMAT).catch(() => {});
402
+ return;
403
+ }
404
+ if (focus === "inspector" && inspectorTab === "history" && input === "r") {
405
+ controller.handleCommand(UI_COMMANDS.REFRESH_EXECUTION_HISTORY).catch(() => {});
406
+ return;
407
+ }
408
+ if (focus === "inspector" && inspectorTab === "history" && input === "a") {
409
+ controller.handleCommand(UI_COMMANDS.EXPORT_EXECUTION_HISTORY).catch(() => {});
410
+ return;
411
+ }
390
412
  if (focus === "inspector" && inspectorTab === "files" && input === "v") {
391
413
  controller.handleCommand(UI_COMMANDS.TOGGLE_FILE_PREVIEW_FULLSCREEN).catch(() => {});
392
414
  return;
package/src/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import React from "react";
2
2
  import fs from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
3
5
  import { createRequire } from "node:module";
4
6
  import { render } from "ink";
5
7
  import {
@@ -14,6 +16,26 @@ import { NodeSdkTransport } from "./node-sdk-transport.js";
14
16
 
15
17
  const require = createRequire(import.meta.url);
16
18
 
19
+ const CONFIG_DIR = path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "pilotswarm");
20
+ const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
21
+
22
+ function readConfig() {
23
+ try {
24
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
25
+ } catch {
26
+ return {};
27
+ }
28
+ }
29
+
30
+ function writeConfig(patch) {
31
+ try {
32
+ const existing = readConfig();
33
+ const merged = { ...existing, ...patch };
34
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
35
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2) + "\n", "utf8");
36
+ } catch {}
37
+ }
38
+
17
39
  function setupTuiHostRuntime() {
18
40
  const logFile = "/tmp/duroxide-tui.log";
19
41
  const originalConsole = {
@@ -90,9 +112,11 @@ export async function startTuiApp(config) {
90
112
  store: config.store,
91
113
  mode: config.mode,
92
114
  });
115
+ const userConfig = readConfig();
93
116
  const store = createStore(appReducer, createInitialState({
94
117
  mode: config.mode,
95
118
  branding: config.branding,
119
+ themeId: userConfig.themeId,
96
120
  }));
97
121
  const controller = new PilotSwarmUiController({ store, transport });
98
122
  let tuiApp;
@@ -144,6 +168,49 @@ export async function startTuiApp(config) {
144
168
  exitOnCtrlC: false,
145
169
  });
146
170
 
171
+ // Listen for portal theme-change OSC sequences on stdin:
172
+ // \x1b]777;theme;<themeId>\x07
173
+ let oscBuffer = "";
174
+ const OSC_PREFIX = "\x1b]777;theme;";
175
+ const OSC_SUFFIX = "\x07";
176
+ const stdinThemeHandler = (data) => {
177
+ const str = typeof data === "string" ? data : data.toString("utf8");
178
+ oscBuffer += str;
179
+ while (oscBuffer.includes(OSC_PREFIX) && oscBuffer.includes(OSC_SUFFIX)) {
180
+ const start = oscBuffer.indexOf(OSC_PREFIX);
181
+ const end = oscBuffer.indexOf(OSC_SUFFIX, start);
182
+ if (end < 0) break;
183
+ const themeId = oscBuffer.slice(start + OSC_PREFIX.length, end);
184
+ oscBuffer = oscBuffer.slice(end + OSC_SUFFIX.length);
185
+ if (themeId) {
186
+ store.dispatch({ type: "ui/theme", themeId });
187
+ }
188
+ }
189
+ // Prevent buffer from growing unbounded
190
+ if (oscBuffer.length > 1024) oscBuffer = oscBuffer.slice(-256);
191
+ };
192
+ process.stdin.on("data", stdinThemeHandler);
193
+
194
+ // Sync viewport on terminal resize (SIGWINCH)
195
+ const syncViewport = () => {
196
+ controller.setViewport({
197
+ width: process.stdout.columns || 120,
198
+ height: process.stdout.rows || 40,
199
+ });
200
+ };
201
+ syncViewport();
202
+ process.stdout.on("resize", syncViewport);
203
+
204
+ // Persist theme changes to config file
205
+ let lastPersistedThemeId = store.getState().ui.themeId;
206
+ store.subscribe(() => {
207
+ const currentThemeId = store.getState().ui.themeId;
208
+ if (currentThemeId && currentThemeId !== lastPersistedThemeId) {
209
+ lastPersistedThemeId = currentThemeId;
210
+ writeConfig({ themeId: currentThemeId });
211
+ }
212
+ });
213
+
147
214
  try {
148
215
  await exitPromise;
149
216
  } finally {
@@ -335,6 +335,10 @@ export class NodeSdkTransport {
335
335
  return this.mgmt.getOrchestrationStats(sessionId);
336
336
  }
337
337
 
338
+ async getExecutionHistory(sessionId, executionId) {
339
+ return this.mgmt.getExecutionHistory(sessionId, executionId);
340
+ }
341
+
338
342
  async createSession({ model } = {}) {
339
343
  const effectiveModel = model || this.mgmt.getDefaultModel();
340
344
  const session = await this.client.createSession(effectiveModel ? { model: effectiveModel } : undefined);
@@ -495,6 +499,43 @@ export class NodeSdkTransport {
495
499
  };
496
500
  }
497
501
 
502
+ async exportExecutionHistory(sessionId) {
503
+ if (!this.artifactStore) {
504
+ throw new Error("Artifact store is not available for this transport.");
505
+ }
506
+ const shortId = String(sessionId || "").slice(0, 8);
507
+ const [history, stats] = await Promise.all([
508
+ this.mgmt.getExecutionHistory(sessionId),
509
+ this.mgmt.getOrchestrationStats(sessionId),
510
+ ]);
511
+ const sessionInfo = await this.mgmt.getSession(sessionId).catch(() => null);
512
+ const exportData = {
513
+ exportedAt: new Date().toISOString(),
514
+ sessionId,
515
+ title: sessionInfo?.title || null,
516
+ agentId: sessionInfo?.agentId || null,
517
+ model: sessionInfo?.model || null,
518
+ orchestrationStats: stats || null,
519
+ eventCount: history?.length || 0,
520
+ events: (history || []).map((e) => {
521
+ const evt = { ...e };
522
+ if (evt.data) {
523
+ try { evt.data = JSON.parse(evt.data); } catch { /* keep raw */ }
524
+ }
525
+ return evt;
526
+ }),
527
+ };
528
+ const filename = `execution-history-${shortId}-${Date.now()}.json`;
529
+ const content = JSON.stringify(exportData, null, 2);
530
+ await this.artifactStore.uploadArtifact(sessionId, filename, content, guessArtifactContentType(filename));
531
+ return {
532
+ sessionId,
533
+ filename,
534
+ artifactLink: `artifact://${sessionId}/${filename}`,
535
+ sizeBytes: Buffer.byteLength(content, "utf8"),
536
+ };
537
+ }
538
+
498
539
  async openPathInDefaultApp(targetPath) {
499
540
  const resolvedPath = path.resolve(expandUserPath(targetPath));
500
541
  if (!resolvedPath) {
package/src/platform.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import React from "react";
2
2
  import { spawnSync } from "node:child_process";
3
3
  import { Box, Text } from "ink";
4
- import { parseTerminalMarkupRuns } from "pilotswarm-ui-core";
4
+ import { DEFAULT_THEME_ID, getTheme, parseTerminalMarkupRuns } from "pilotswarm-ui-core";
5
5
 
6
6
  const MAX_PROMPT_INPUT_ROWS = 3;
7
- const SELECTION_BACKGROUND = "white";
8
- const SELECTION_FOREGROUND = "black";
7
+ const SELECTION_BACKGROUND = "selectionBackground";
8
+ const SELECTION_FOREGROUND = "selectionForeground";
9
9
 
10
10
  function clampValue(value, min, max) {
11
11
  return Math.max(min, Math.min(max, value));
@@ -25,8 +25,23 @@ const tuiPlatformRuntime = {
25
25
  paneRegistry: new Map(),
26
26
  selection: createEmptySelection(),
27
27
  renderInvalidator: null,
28
+ themeId: DEFAULT_THEME_ID,
28
29
  };
29
30
 
31
+ function getCurrentTheme() {
32
+ return getTheme(tuiPlatformRuntime.themeId) || getTheme(DEFAULT_THEME_ID);
33
+ }
34
+
35
+ function resolveColorToken(color) {
36
+ if (!color) return undefined;
37
+ const theme = getCurrentTheme();
38
+ return theme?.tui?.[color] || color;
39
+ }
40
+
41
+ function isDimColorToken(color) {
42
+ return color === "gray";
43
+ }
44
+
30
45
  function trimText(value, width) {
31
46
  if (width <= 0) return "";
32
47
  const text = String(value || "");
@@ -230,11 +245,11 @@ function flattenTitleText(title) {
230
245
  function renderInlineRuns(runs, keyPrefix = "run") {
231
246
  return runs.map((run, index) => React.createElement(Text, {
232
247
  key: `${keyPrefix}:${index}`,
233
- color: run.color || undefined,
234
- backgroundColor: run.backgroundColor || undefined,
248
+ color: resolveColorToken(run.color),
249
+ backgroundColor: resolveColorToken(run.backgroundColor),
235
250
  bold: Boolean(run.bold),
236
251
  underline: Boolean(run.underline),
237
- dimColor: run.color === "gray",
252
+ dimColor: isDimColorToken(run.color),
238
253
  }, run.text || ""));
239
254
  }
240
255
 
@@ -339,15 +354,15 @@ function renderPanelRow(line, rowKey, contentWidth, borderColor, scrollIndicator
339
354
  const selectedRuns = applySelectionToRuns(lineToRuns(line, contentWidth), normalizedSelection);
340
355
 
341
356
  return React.createElement(Box, { key: `row:${rowKey}`, flexDirection: "row" },
342
- React.createElement(Text, { color: borderColor }, "│ "),
343
- React.createElement(Box, { width: contentWidth, backgroundColor: fillColor || undefined },
357
+ React.createElement(Text, { color: resolveColorToken(borderColor) }, "│ "),
358
+ React.createElement(Box, { width: contentWidth, backgroundColor: resolveColorToken(fillColor) },
344
359
  !line
345
360
  ? React.createElement(Text, null, " ".repeat(contentWidth))
346
361
  : selectedRuns.length > 0
347
362
  ? renderInlineRuns(selectedRuns, `inline:${rowKey}`)
348
363
  : React.createElement(Text, null, "")),
349
- React.createElement(Text, { color: scrollIndicator ? "gray" : undefined, dimColor: Boolean(scrollIndicator) }, scrollChar),
350
- React.createElement(Text, { color: borderColor }, "│"));
364
+ React.createElement(Text, { color: scrollIndicator ? resolveColorToken("gray") : undefined, dimColor: Boolean(scrollIndicator) }, scrollChar),
365
+ React.createElement(Text, { color: resolveColorToken(borderColor) }, "│"));
351
366
  }
352
367
 
353
368
  function renderBorderTop(title, color, width) {
@@ -356,14 +371,14 @@ function renderBorderTop(title, color, width) {
356
371
  const fill = Math.max(0, safeWidth - titleRunLength(safeTitleRuns) - 5);
357
372
 
358
373
  return React.createElement(Box, null,
359
- React.createElement(Text, { color }, "╭─ "),
374
+ React.createElement(Text, { color: resolveColorToken(color) }, "╭─ "),
360
375
  renderInlineRuns(safeTitleRuns, "title"),
361
- React.createElement(Text, { color }, ` ${"─".repeat(fill)}╮`));
376
+ React.createElement(Text, { color: resolveColorToken(color) }, ` ${"─".repeat(fill)}╮`));
362
377
  }
363
378
 
364
379
  function renderBorderBottom(color, width) {
365
380
  const safeWidth = Math.max(8, Number(width) || 40);
366
- return React.createElement(Text, { color }, `╰${"─".repeat(Math.max(0, safeWidth - 2))}╯`);
381
+ return React.createElement(Text, { color: resolveColorToken(color) }, `╰${"─".repeat(Math.max(0, safeWidth - 2))}╯`);
367
382
  }
368
383
 
369
384
  function compareSelectionPoints(left, right) {
@@ -527,10 +542,10 @@ function linesToElements(lines) {
527
542
  }
528
543
  return React.createElement(Text, {
529
544
  key: `text:${index}`,
530
- color: line.color || undefined,
531
- backgroundColor: line.backgroundColor || undefined,
545
+ color: resolveColorToken(line.color),
546
+ backgroundColor: resolveColorToken(line.backgroundColor),
532
547
  bold: Boolean(line.bold),
533
- dimColor: line.color === "gray",
548
+ dimColor: isDimColorToken(line.color),
534
549
  }, line.text || "");
535
550
  });
536
551
  }
@@ -540,6 +555,7 @@ function Root({ children }) {
540
555
  flexDirection: "column",
541
556
  height: process.stdout.rows || 40,
542
557
  width: process.stdout.columns || 120,
558
+ backgroundColor: resolveColorToken("background"),
543
559
  }, children);
544
560
  }
545
561
 
@@ -554,13 +570,13 @@ function Column({ children, ...props }) {
554
570
  function Header({ title, subtitle }) {
555
571
  return React.createElement(Box, {
556
572
  borderStyle: "round",
557
- borderColor: "cyan",
573
+ borderColor: resolveColorToken("cyan"),
558
574
  paddingX: 1,
559
575
  marginBottom: 1,
560
576
  justifyContent: "space-between",
561
577
  },
562
- React.createElement(Text, { bold: true, color: "cyan" }, title),
563
- React.createElement(Text, { color: "gray" }, subtitle || ""));
578
+ React.createElement(Text, { bold: true, color: resolveColorToken("cyan") }, title),
579
+ React.createElement(Text, { color: resolveColorToken("gray"), dimColor: true }, subtitle || ""));
564
580
  }
565
581
 
566
582
  function Panel({
@@ -660,10 +676,10 @@ function Panel({
660
676
  : React.createElement(Box, {
661
677
  flexDirection: "column",
662
678
  borderStyle: "round",
663
- borderColor: borderColor,
679
+ borderColor: resolveColorToken(borderColor),
664
680
  paddingX: 1,
665
681
  flexGrow: 1,
666
- backgroundColor: fillColor || undefined,
682
+ backgroundColor: resolveColorToken(fillColor),
667
683
  }, children),
668
684
  renderBorderBottom(borderColor, safeWidth));
669
685
  }
@@ -717,11 +733,11 @@ function renderPromptRow(lineText, cursorColumn, { color, showCursor, keyPrefix,
717
733
  showCursor
718
734
  ? cursorChar
719
735
  ? React.createElement(Text, {
720
- color: "black",
721
- backgroundColor: "green",
736
+ color: resolveColorToken("promptCursorForeground"),
737
+ backgroundColor: resolveColorToken("promptCursorBackground"),
722
738
  dimColor,
723
739
  }, cursorChar)
724
- : React.createElement(Text, { color: "green" }, "█")
740
+ : React.createElement(Text, { color: resolveColorToken("promptCursorBackground") }, "█")
725
741
  : null,
726
742
  after ? React.createElement(Text, { color, dimColor }, after) : null,
727
743
  );
@@ -732,7 +748,7 @@ function Input({ label, value, focused, placeholder, rows = 1, cursorIndex = 0 }
732
748
  const isEmpty = safeValue.length === 0;
733
749
  const safeRows = clampValue(Number(rows) || 1, 1, MAX_PROMPT_INPUT_ROWS);
734
750
  const labelPrefix = React.createElement(Text, {
735
- color: focused ? "red" : "green",
751
+ color: resolveColorToken(focused ? "red" : "green"),
736
752
  bold: true,
737
753
  }, `${label}: `);
738
754
  const cursorPosition = getPromptCursorPosition(safeValue, cursorIndex);
@@ -756,7 +772,7 @@ function Input({ label, value, focused, placeholder, rows = 1, cursorIndex = 0 }
756
772
  isEmpty
757
773
  ? [
758
774
  renderPromptRow(placeholder || "Type a message and press Enter", focused ? 0 : null, {
759
- color: "gray",
775
+ color: resolveColorToken("gray"),
760
776
  dimColor: true,
761
777
  showCursor: Boolean(focused),
762
778
  keyPrefix: "prompt-line:0",
@@ -768,7 +784,7 @@ function Input({ label, value, focused, placeholder, rows = 1, cursorIndex = 0 }
768
784
  }, React.createElement(Text, null, ""))),
769
785
  ]
770
786
  : displayLines.map((line, index) => renderPromptRow(line, focused && visibleCursorLine === index ? cursorPosition.column : null, {
771
- color: "white",
787
+ color: resolveColorToken("white"),
772
788
  showCursor: Boolean(focused && visibleCursorLine === index),
773
789
  keyPrefix: `prompt-line:${index}`,
774
790
  prefix: index === 0 ? labelPrefix : null,
@@ -779,12 +795,12 @@ function Input({ label, value, focused, placeholder, rows = 1, cursorIndex = 0 }
779
795
  function StatusLine({ left, right }) {
780
796
  return React.createElement(Box, {
781
797
  borderStyle: "round",
782
- borderColor: "gray",
798
+ borderColor: resolveColorToken("gray"),
783
799
  paddingX: 1,
784
800
  justifyContent: "space-between",
785
801
  },
786
- React.createElement(Text, { color: "white" }, left || ""),
787
- React.createElement(Text, { color: "gray", dimColor: true }, right || ""));
802
+ React.createElement(Text, { color: resolveColorToken("white") }, left || ""),
803
+ React.createElement(Text, { color: resolveColorToken("gray"), dimColor: true }, right || ""));
788
804
  }
789
805
 
790
806
  function Overlay({ children }) {
@@ -807,6 +823,7 @@ export function createTuiPlatform() {
807
823
  tuiPlatformRuntime.paneRegistry.clear();
808
824
  tuiPlatformRuntime.selection = createEmptySelection();
809
825
  tuiPlatformRuntime.renderInvalidator = null;
826
+ tuiPlatformRuntime.themeId = DEFAULT_THEME_ID;
810
827
 
811
828
  return {
812
829
  Root,
@@ -818,6 +835,12 @@ export function createTuiPlatform() {
818
835
  Lines,
819
836
  Input,
820
837
  StatusLine,
838
+ setTheme(themeId) {
839
+ const nextTheme = getTheme(themeId) || getTheme(DEFAULT_THEME_ID);
840
+ if (!nextTheme || nextTheme.id === tuiPlatformRuntime.themeId) return;
841
+ tuiPlatformRuntime.themeId = nextTheme.id;
842
+ requestTuiRender();
843
+ },
821
844
  setRenderInvalidator(fn) {
822
845
  tuiPlatformRuntime.renderInvalidator = typeof fn === "function" ? fn : null;
823
846
  },