@f5xc-salesdemos/xcsh 15.6.2 → 15.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.
Files changed (116) hide show
  1. package/package.json +7 -7
  2. package/src/cli/update-cli.ts +118 -14
  3. package/src/config/settings-schema.ts +1 -1
  4. package/src/mcp/render.ts +24 -2
  5. package/src/modes/components/bash-execution.ts +36 -6
  6. package/src/modes/components/python-execution.ts +31 -3
  7. package/src/modes/components/status-line/presets.ts +3 -3
  8. package/src/modes/controllers/command-controller.ts +2 -2
  9. package/src/modes/theme/dark.json +1 -0
  10. package/src/modes/theme/defaults/alabaster.json +2 -1
  11. package/src/modes/theme/defaults/amethyst.json +2 -1
  12. package/src/modes/theme/defaults/anthracite.json +2 -1
  13. package/src/modes/theme/defaults/basalt.json +2 -1
  14. package/src/modes/theme/defaults/birch.json +2 -1
  15. package/src/modes/theme/defaults/dark-abyss.json +2 -1
  16. package/src/modes/theme/defaults/dark-arctic.json +2 -1
  17. package/src/modes/theme/defaults/dark-aurora.json +2 -1
  18. package/src/modes/theme/defaults/dark-catppuccin.json +2 -1
  19. package/src/modes/theme/defaults/dark-cavern.json +2 -1
  20. package/src/modes/theme/defaults/dark-copper.json +2 -1
  21. package/src/modes/theme/defaults/dark-cosmos.json +2 -1
  22. package/src/modes/theme/defaults/dark-cyberpunk.json +2 -1
  23. package/src/modes/theme/defaults/dark-dracula.json +2 -1
  24. package/src/modes/theme/defaults/dark-eclipse.json +2 -1
  25. package/src/modes/theme/defaults/dark-ember.json +2 -1
  26. package/src/modes/theme/defaults/dark-equinox.json +2 -1
  27. package/src/modes/theme/defaults/dark-forest.json +2 -1
  28. package/src/modes/theme/defaults/dark-github.json +2 -1
  29. package/src/modes/theme/defaults/dark-gruvbox.json +2 -1
  30. package/src/modes/theme/defaults/dark-lavender.json +2 -1
  31. package/src/modes/theme/defaults/dark-lunar.json +2 -1
  32. package/src/modes/theme/defaults/dark-midnight.json +2 -1
  33. package/src/modes/theme/defaults/dark-monochrome.json +2 -1
  34. package/src/modes/theme/defaults/dark-monokai.json +2 -1
  35. package/src/modes/theme/defaults/dark-nebula.json +2 -1
  36. package/src/modes/theme/defaults/dark-nord.json +2 -1
  37. package/src/modes/theme/defaults/dark-ocean.json +2 -1
  38. package/src/modes/theme/defaults/dark-one.json +2 -1
  39. package/src/modes/theme/defaults/dark-poimandres.json +137 -136
  40. package/src/modes/theme/defaults/dark-rainforest.json +2 -1
  41. package/src/modes/theme/defaults/dark-reef.json +2 -1
  42. package/src/modes/theme/defaults/dark-retro.json +2 -1
  43. package/src/modes/theme/defaults/dark-rose-pine.json +2 -1
  44. package/src/modes/theme/defaults/dark-sakura.json +2 -1
  45. package/src/modes/theme/defaults/dark-slate.json +2 -1
  46. package/src/modes/theme/defaults/dark-solarized.json +2 -1
  47. package/src/modes/theme/defaults/dark-solstice.json +2 -1
  48. package/src/modes/theme/defaults/dark-starfall.json +2 -1
  49. package/src/modes/theme/defaults/dark-sunset.json +2 -1
  50. package/src/modes/theme/defaults/dark-swamp.json +2 -1
  51. package/src/modes/theme/defaults/dark-synthwave.json +2 -1
  52. package/src/modes/theme/defaults/dark-taiga.json +2 -1
  53. package/src/modes/theme/defaults/dark-terminal.json +2 -1
  54. package/src/modes/theme/defaults/dark-tokyo-night.json +2 -1
  55. package/src/modes/theme/defaults/dark-tundra.json +2 -1
  56. package/src/modes/theme/defaults/dark-twilight.json +2 -1
  57. package/src/modes/theme/defaults/dark-volcanic.json +2 -1
  58. package/src/modes/theme/defaults/graphite.json +2 -1
  59. package/src/modes/theme/defaults/light-arctic.json +2 -1
  60. package/src/modes/theme/defaults/light-aurora-day.json +2 -1
  61. package/src/modes/theme/defaults/light-canyon.json +2 -1
  62. package/src/modes/theme/defaults/light-catppuccin.json +2 -1
  63. package/src/modes/theme/defaults/light-cirrus.json +2 -1
  64. package/src/modes/theme/defaults/light-coral.json +2 -1
  65. package/src/modes/theme/defaults/light-cyberpunk.json +2 -1
  66. package/src/modes/theme/defaults/light-dawn.json +2 -1
  67. package/src/modes/theme/defaults/light-dunes.json +2 -1
  68. package/src/modes/theme/defaults/light-eucalyptus.json +2 -1
  69. package/src/modes/theme/defaults/light-forest.json +2 -1
  70. package/src/modes/theme/defaults/light-frost.json +2 -1
  71. package/src/modes/theme/defaults/light-github.json +2 -1
  72. package/src/modes/theme/defaults/light-glacier.json +2 -1
  73. package/src/modes/theme/defaults/light-gruvbox.json +2 -1
  74. package/src/modes/theme/defaults/light-haze.json +2 -1
  75. package/src/modes/theme/defaults/light-honeycomb.json +2 -1
  76. package/src/modes/theme/defaults/light-lagoon.json +2 -1
  77. package/src/modes/theme/defaults/light-lavender.json +2 -1
  78. package/src/modes/theme/defaults/light-meadow.json +2 -1
  79. package/src/modes/theme/defaults/light-mint.json +2 -1
  80. package/src/modes/theme/defaults/light-monochrome.json +2 -1
  81. package/src/modes/theme/defaults/light-ocean.json +2 -1
  82. package/src/modes/theme/defaults/light-one.json +2 -1
  83. package/src/modes/theme/defaults/light-opal.json +2 -1
  84. package/src/modes/theme/defaults/light-orchard.json +2 -1
  85. package/src/modes/theme/defaults/light-paper.json +2 -1
  86. package/src/modes/theme/defaults/light-poimandres.json +137 -136
  87. package/src/modes/theme/defaults/light-prism.json +2 -1
  88. package/src/modes/theme/defaults/light-retro.json +2 -1
  89. package/src/modes/theme/defaults/light-sand.json +2 -1
  90. package/src/modes/theme/defaults/light-savanna.json +2 -1
  91. package/src/modes/theme/defaults/light-solarized.json +2 -1
  92. package/src/modes/theme/defaults/light-soleil.json +2 -1
  93. package/src/modes/theme/defaults/light-sunset.json +2 -1
  94. package/src/modes/theme/defaults/light-synthwave.json +2 -1
  95. package/src/modes/theme/defaults/light-tokyo-night.json +2 -1
  96. package/src/modes/theme/defaults/light-wetland.json +2 -1
  97. package/src/modes/theme/defaults/light-zenith.json +2 -1
  98. package/src/modes/theme/defaults/limestone.json +2 -1
  99. package/src/modes/theme/defaults/mahogany.json +2 -1
  100. package/src/modes/theme/defaults/marble.json +2 -1
  101. package/src/modes/theme/defaults/obsidian.json +2 -1
  102. package/src/modes/theme/defaults/onyx.json +2 -1
  103. package/src/modes/theme/defaults/pearl.json +2 -1
  104. package/src/modes/theme/defaults/porcelain.json +2 -1
  105. package/src/modes/theme/defaults/quartz.json +2 -1
  106. package/src/modes/theme/defaults/sandstone.json +2 -1
  107. package/src/modes/theme/defaults/titanium.json +2 -1
  108. package/src/modes/theme/defaults/xcsh-dark.json +2 -1
  109. package/src/modes/theme/defaults/xcsh-light.json +2 -1
  110. package/src/modes/theme/light.json +1 -0
  111. package/src/modes/theme/theme-schema.json +5 -0
  112. package/src/modes/theme/theme.ts +4 -0
  113. package/src/task/render.ts +21 -8
  114. package/src/tools/bash.ts +33 -2
  115. package/src/tools/grep.ts +22 -5
  116. package/src/tools/json-tree.ts +33 -10
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5xc-salesdemos/xcsh",
4
- "version": "15.6.2",
4
+ "version": "15.8.0",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/f5xc-salesdemos/xcsh",
7
7
  "author": "Can Boluk",
@@ -46,12 +46,12 @@
46
46
  "dependencies": {
47
47
  "@agentclientprotocol/sdk": "0.16.1",
48
48
  "@mozilla/readability": "^0.6",
49
- "@f5xc-salesdemos/xcsh-stats": "15.6.2",
50
- "@f5xc-salesdemos/pi-agent-core": "15.6.2",
51
- "@f5xc-salesdemos/pi-ai": "15.6.2",
52
- "@f5xc-salesdemos/pi-natives": "15.6.2",
53
- "@f5xc-salesdemos/pi-tui": "15.6.2",
54
- "@f5xc-salesdemos/pi-utils": "15.6.2",
49
+ "@f5xc-salesdemos/xcsh-stats": "15.8.0",
50
+ "@f5xc-salesdemos/pi-agent-core": "15.8.0",
51
+ "@f5xc-salesdemos/pi-ai": "15.8.0",
52
+ "@f5xc-salesdemos/pi-natives": "15.8.0",
53
+ "@f5xc-salesdemos/pi-tui": "15.8.0",
54
+ "@f5xc-salesdemos/pi-utils": "15.8.0",
55
55
  "@sinclair/typebox": "^0.34",
56
56
  "@xterm/headless": "^6.0",
57
57
  "ajv": "^8.18",
@@ -2,7 +2,8 @@
2
2
  * Update CLI command handler.
3
3
  *
4
4
  * Handles `xcsh update` to check for and install updates.
5
- * Uses bun if available, otherwise downloads binary from GitHub releases.
5
+ * Auto-detects the installation method (npm, brew, bun, or standalone binary)
6
+ * and updates through the appropriate channel.
6
7
  */
7
8
  import * as fs from "node:fs";
8
9
  import * as path from "node:path";
@@ -60,14 +61,74 @@ function isPathInDirectory(filePath: string, directoryPath: string): boolean {
60
61
  return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
61
62
  }
62
63
 
63
- type UpdateTarget = { method: "bun" } | { method: "binary"; path: string };
64
+ export type InstallMethod = "npm" | "brew" | "bun" | "binary";
64
65
 
65
- function resolveUpdateMethod(ompPath: string, bunBinDir: string | undefined): "bun" | "binary" {
66
- if (!bunBinDir) return "binary";
67
- return isPathInDirectory(ompPath, bunBinDir) ? "bun" : "binary";
66
+ type UpdateTarget =
67
+ | { method: "npm"; path: string }
68
+ | { method: "brew"; path: string }
69
+ | { method: "bun" }
70
+ | { method: "binary"; path: string };
71
+
72
+ /**
73
+ * Detect how xcsh was installed by examining the binary path.
74
+ *
75
+ * Detection order:
76
+ * 1. bun — binary is inside bun's global bin directory
77
+ * 2. npm — binary is a symlink whose resolution chain contains "node_modules"
78
+ * 3. brew — binary path or realpath contains "Cellar" or "homebrew"
79
+ * 4. binary — fallback for standalone installs
80
+ */
81
+ function detectInstallMethod(binPath: string, bunBinDir: string | undefined): InstallMethod {
82
+ // 1. Bun: binary lives inside bun's global bin dir
83
+ if (bunBinDir && isPathInDirectory(binPath, bunBinDir)) {
84
+ return "bun";
85
+ }
86
+
87
+ // 2. npm: binary is a symlink whose target chain contains node_modules
88
+ try {
89
+ const stats = fs.lstatSync(binPath);
90
+ if (stats.isSymbolicLink()) {
91
+ const linkTarget = fs.readlinkSync(binPath);
92
+ const resolvedTarget = path.resolve(path.dirname(binPath), linkTarget);
93
+ if (linkTarget.includes("node_modules") || resolvedTarget.includes("node_modules")) {
94
+ return "npm";
95
+ }
96
+ try {
97
+ const realPath = fs.realpathSync(binPath);
98
+ if (realPath.includes("node_modules")) {
99
+ return "npm";
100
+ }
101
+ } catch {
102
+ // realpath may fail if target doesn't exist
103
+ }
104
+ }
105
+ } catch {
106
+ // lstat/readlink may fail; fall through
107
+ }
108
+
109
+ // 3. brew: path or realpath contains Cellar or homebrew
110
+ const lowerBinPath = binPath.toLowerCase();
111
+ if (lowerBinPath.includes("/cellar/") || lowerBinPath.includes("/homebrew/")) {
112
+ return "brew";
113
+ }
114
+ try {
115
+ const realPath = fs.realpathSync(binPath).toLowerCase();
116
+ if (realPath.includes("/cellar/") || realPath.includes("/homebrew/")) {
117
+ return "brew";
118
+ }
119
+ } catch {
120
+ // realpath may fail; fall through
121
+ }
122
+
123
+ // 4. Standalone binary (fallback)
124
+ return "binary";
125
+ }
126
+
127
+ function resolveUpdateMethod(ompPath: string, bunBinDir: string | undefined): InstallMethod {
128
+ return detectInstallMethod(ompPath, bunBinDir);
68
129
  }
69
130
 
70
- export function _resolveUpdateMethodForTest(ompPath: string, bunBinDir: string | undefined): "bun" | "binary" {
131
+ export function _resolveUpdateMethodForTest(ompPath: string, bunBinDir: string | undefined): InstallMethod {
71
132
  return resolveUpdateMethod(ompPath, bunBinDir);
72
133
  }
73
134
  async function resolveUpdateTarget(): Promise<UpdateTarget> {
@@ -175,8 +236,9 @@ function resolveOmpPath(): string | undefined {
175
236
  */
176
237
  async function verifyInstalledVersion(
177
238
  expectedVersion: string,
239
+ explicitPath?: string,
178
240
  ): Promise<{ ok: boolean; actual?: string; path?: string }> {
179
- const ompPath = resolveOmpPath();
241
+ const ompPath = explicitPath ?? resolveOmpPath();
180
242
  if (!ompPath) return { ok: false };
181
243
  try {
182
244
  const result = await $`${ompPath} --version`.quiet().nothrow();
@@ -194,8 +256,8 @@ async function verifyInstalledVersion(
194
256
  /**
195
257
  * Print post-update verification result.
196
258
  */
197
- async function printVerification(expectedVersion: string): Promise<void> {
198
- const result = await verifyInstalledVersion(expectedVersion);
259
+ async function printVerification(expectedVersion: string, explicitPath?: string): Promise<void> {
260
+ const result = await verifyInstalledVersion(expectedVersion, explicitPath);
199
261
  if (result.ok) {
200
262
  console.log(chalk.green(`\n${theme.status.success} Updated to ${expectedVersion}`));
201
263
  return;
@@ -231,6 +293,30 @@ async function updateViaBun(expectedVersion: string): Promise<void> {
231
293
  await printVerification(expectedVersion);
232
294
  }
233
295
 
296
+ /**
297
+ * Update via npm package manager.
298
+ */
299
+ async function updateViaNpm(expectedVersion: string): Promise<void> {
300
+ console.log(chalk.dim("Updating via npm..."));
301
+ const result = await $`npm install -g ${PACKAGE}@${expectedVersion}`.nothrow();
302
+ if (result.exitCode !== 0) {
303
+ throw new Error(`npm install failed with exit code ${result.exitCode}`);
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Handle brew-installed xcsh.
309
+ *
310
+ * For corporate environments, brew-managed software must not be bypassed.
311
+ * Prints instructions instead of running brew upgrade automatically.
312
+ */
313
+ function updateViaBrew(targetPath: string, expectedVersion: string): void {
314
+ console.log(chalk.yellow(`\n${APP_NAME} at ${targetPath} was installed via Homebrew.`));
315
+ console.log(chalk.yellow(`To update to ${expectedVersion}, run:`));
316
+ console.log(chalk.cyan(` brew upgrade ${APP_NAME}`));
317
+ console.log(chalk.dim("\nThis ensures the update goes through your organization's Homebrew tap."));
318
+ }
319
+
234
320
  /**
235
321
  * Download a release binary to a target path, replacing an existing file.
236
322
  */
@@ -261,7 +347,7 @@ async function updateViaBinaryAt(targetPath: string, expectedVersion: string): P
261
347
  await fs.promises.rename(tempPath, targetPath);
262
348
  await fs.promises.unlink(backupPath);
263
349
 
264
- await printVerification(expectedVersion);
350
+ await printVerification(expectedVersion, targetPath);
265
351
  console.log(chalk.dim(`Restart ${APP_NAME} to use the new version`));
266
352
  } catch (err) {
267
353
  if (fs.existsSync(backupPath) && !fs.existsSync(targetPath)) {
@@ -310,10 +396,22 @@ export async function runUpdateCommand(opts: { force: boolean; check: boolean })
310
396
  // Choose update method based on the prioritized xcsh binary in PATH
311
397
  try {
312
398
  const target = await resolveUpdateTarget();
313
- if (target.method === "bun") {
314
- await updateViaBun(release.version);
315
- } else {
316
- await updateViaBinaryAt(target.path, release.version);
399
+ console.log(chalk.dim(`Install method: ${target.method}`));
400
+
401
+ switch (target.method) {
402
+ case "bun":
403
+ await updateViaBun(release.version);
404
+ break;
405
+ case "npm":
406
+ await updateViaNpm(release.version);
407
+ await printVerification(release.version);
408
+ break;
409
+ case "brew":
410
+ updateViaBrew(target.path, release.version);
411
+ return;
412
+ case "binary":
413
+ await updateViaBinaryAt(target.path, release.version);
414
+ break;
317
415
  }
318
416
  } catch (err) {
319
417
  console.error(chalk.red(`Update failed: ${err}`));
@@ -334,6 +432,12 @@ ${chalk.bold("Options:")}
334
432
  -c, --check Check for updates without installing
335
433
  -f, --force Force reinstall even if up to date
336
434
 
435
+ ${chalk.bold("Install methods (auto-detected):")}
436
+ npm Installed via npm install -g
437
+ brew Installed via Homebrew (prints upgrade instructions)
438
+ bun Installed via bun install -g
439
+ binary Standalone binary (direct download)
440
+
337
441
  ${chalk.bold("Examples:")}
338
442
  ${APP_NAME} update Update to latest version
339
443
  ${APP_NAME} update --check Check if updates are available
@@ -791,7 +791,7 @@ export const SETTINGS_SCHEMA = {
791
791
 
792
792
  "compaction.handoffSaveToDisk": {
793
793
  type: "boolean",
794
- default: false,
794
+ default: true,
795
795
  ui: {
796
796
  tab: "context",
797
797
  label: "Save Handoff Docs",
package/src/mcp/render.ts CHANGED
@@ -8,6 +8,7 @@ import type { Component } from "@f5xc-salesdemos/pi-tui";
8
8
  import { Text } from "@f5xc-salesdemos/pi-tui";
9
9
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
10
10
  import type { Theme } from "../modes/theme/theme";
11
+ import { highlightCode } from "../modes/theme/theme";
11
12
  import {
12
13
  formatArgsInline,
13
14
  JSON_TREE_MAX_DEPTH_COLLAPSED,
@@ -114,8 +115,29 @@ export function renderMCPResult(
114
115
  const maxOutputLines = expanded ? 12 : 4;
115
116
  const displayLines = outputLines.slice(0, maxOutputLines);
116
117
 
117
- for (const line of displayLines) {
118
- lines.push(theme.fg("toolOutput", truncateToWidth(line, 80)));
118
+ // Try to syntax-highlight structured output (e.g. JSON)
119
+ const firstNonEmpty = trimmedOutput.trim();
120
+ const isJson =
121
+ firstNonEmpty.length <= 32_768 &&
122
+ (firstNonEmpty[0] === "{" || firstNonEmpty[0] === "[") &&
123
+ (() => {
124
+ try {
125
+ JSON.parse(firstNonEmpty);
126
+ return true;
127
+ } catch {
128
+ return false;
129
+ }
130
+ })();
131
+
132
+ if (isJson) {
133
+ const highlighted = highlightCode(displayLines.join("\n"), "json");
134
+ for (const line of highlighted) {
135
+ lines.push(truncateToWidth(line, 80));
136
+ }
137
+ } else {
138
+ for (const line of displayLines) {
139
+ lines.push(theme.fg("toolOutput", truncateToWidth(line, 80)));
140
+ }
119
141
  }
120
142
 
121
143
  if (outputLines.length > maxOutputLines) {
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { sanitizeText } from "@f5xc-salesdemos/pi-natives";
6
6
  import { Container, ImageProtocol, Loader, Spacer, TERMINAL, Text, type TUI } from "@f5xc-salesdemos/pi-tui";
7
- import { getSymbolTheme, theme } from "../../modes/theme/theme";
7
+ import { getSymbolTheme, highlightCode, theme } from "../../modes/theme/theme";
8
8
  import { formatTruncationMetaNotice, type TruncationMeta } from "../../tools/output-meta";
9
9
  import { getSixelLineMask, isSixelPassthroughEnabled, sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
10
10
  import { DynamicBorder } from "./dynamic-border";
@@ -13,6 +13,29 @@ import { truncateToVisualLines } from "./visual-truncate";
13
13
  // Preview line limit when not expanded (matches tool execution behavior)
14
14
  const PREVIEW_LINES = 20;
15
15
  const STREAMING_LINE_CAP = PREVIEW_LINES * 5;
16
+
17
+ /** Max bytes to attempt JSON detection on (avoid parsing huge outputs) */
18
+ const JSON_DETECT_LIMIT = 32_768;
19
+
20
+ /**
21
+ * Detect if output looks like JSON and return syntax-highlighted lines.
22
+ * Returns undefined if not JSON or if detection should not be attempted.
23
+ */
24
+ function highlightIfStructured(lines: string[]): string[] | undefined {
25
+ if (lines.length === 0) return undefined;
26
+ const firstNonEmpty = lines.find(l => l.trim().length > 0)?.trim();
27
+ if (!firstNonEmpty) return undefined;
28
+ // Only detect JSON (starts with { or [)
29
+ if (firstNonEmpty[0] !== "{" && firstNonEmpty[0] !== "[") return undefined;
30
+ const fullText = lines.join("\n");
31
+ if (fullText.length > JSON_DETECT_LIMIT) return undefined;
32
+ try {
33
+ JSON.parse(fullText);
34
+ return highlightCode(fullText, "json");
35
+ } catch {
36
+ return undefined;
37
+ }
38
+ }
16
39
  const MAX_DISPLAY_LINE_CHARS = 4000;
17
40
  // Minimum interval between processing incoming chunks for display (ms).
18
41
  // Chunks arriving faster than this are accumulated and processed in one batch.
@@ -163,15 +186,22 @@ export class BashExecutionComponent extends Container {
163
186
 
164
187
  // Output
165
188
  if (availableLines.length > 0) {
189
+ // Try to syntax-highlight structured output (e.g. JSON)
190
+ const highlightedLines = hasSixelOutput ? undefined : highlightIfStructured(availableLines);
191
+
166
192
  if (this.#expanded || hasSixelOutput) {
167
- const displayText = availableLines
168
- .map((line, index) => (sixelLineMask?.[index] ? line : theme.fg("muted", line)))
169
- .join("\n");
193
+ const displayText = highlightedLines
194
+ ? highlightedLines.join("\n")
195
+ : availableLines
196
+ .map((line, index) => (sixelLineMask?.[index] ? line : theme.fg("muted", line)))
197
+ .join("\n");
170
198
  this.#contentContainer.addChild(new Text(`\n${displayText}`, 1, 0));
171
199
  } else {
172
200
  // Use shared visual truncation utility, recomputed per render width
173
- const styledOutput = previewLogicalLines.map(line => theme.fg("muted", line)).join("\n");
174
- const previewText = `\n${styledOutput}`;
201
+ const previewHighlighted = highlightedLines
202
+ ? highlightedLines.slice(-previewLogicalLines.length).join("\n")
203
+ : previewLogicalLines.map(line => theme.fg("muted", line)).join("\n");
204
+ const previewText = `\n${previewHighlighted}`;
175
205
  this.#contentContainer.addChild({
176
206
  render: (width: number) => {
177
207
  const { visualLines } = truncateToVisualLines(previewText, PREVIEW_LINES, width, 1);
@@ -13,6 +13,27 @@ import { truncateToVisualLines } from "./visual-truncate";
13
13
  const PREVIEW_LINES = 20;
14
14
  const MAX_DISPLAY_LINE_CHARS = 4000;
15
15
 
16
+ /** Max bytes to attempt JSON detection on */
17
+ const JSON_DETECT_LIMIT = 32_768;
18
+
19
+ /**
20
+ * Detect if output looks like JSON and return syntax-highlighted lines.
21
+ */
22
+ function highlightIfStructured(lines: string[]): string[] | undefined {
23
+ if (lines.length === 0) return undefined;
24
+ const firstNonEmpty = lines.find(l => l.trim().length > 0)?.trim();
25
+ if (!firstNonEmpty) return undefined;
26
+ if (firstNonEmpty[0] !== "{" && firstNonEmpty[0] !== "[") return undefined;
27
+ const fullText = lines.join("\n");
28
+ if (fullText.length > JSON_DETECT_LIMIT) return undefined;
29
+ try {
30
+ JSON.parse(fullText);
31
+ return highlightCode(fullText, "json");
32
+ } catch {
33
+ return undefined;
34
+ }
35
+ }
36
+
16
37
  export class PythonExecutionComponent extends Container {
17
38
  #outputLines: string[] = [];
18
39
  #status: "running" | "complete" | "cancelled" | "error" = "running";
@@ -117,12 +138,19 @@ export class PythonExecutionComponent extends Container {
117
138
  this.#contentContainer.addChild(this.#formatHeader(colorKey));
118
139
 
119
140
  if (availableLines.length > 0) {
141
+ // Try to syntax-highlight structured output (e.g. JSON)
142
+ const highlightedLines = highlightIfStructured(availableLines);
143
+
120
144
  if (this.#expanded) {
121
- const displayText = availableLines.map(line => theme.fg("muted", line)).join("\n");
145
+ const displayText = highlightedLines
146
+ ? highlightedLines.join("\n")
147
+ : availableLines.map(line => theme.fg("muted", line)).join("\n");
122
148
  this.#contentContainer.addChild(new Text(`\n${displayText}`, 1, 0));
123
149
  } else {
124
- const styledOutput = previewLogicalLines.map(line => theme.fg("muted", line)).join("\n");
125
- const previewText = `\n${styledOutput}`;
150
+ const previewHighlighted = highlightedLines
151
+ ? highlightedLines.slice(-previewLogicalLines.length).join("\n")
152
+ : previewLogicalLines.map(line => theme.fg("muted", line)).join("\n");
153
+ const previewText = `\n${previewHighlighted}`;
126
154
  this.#contentContainer.addChild({
127
155
  render: (width: number) => {
128
156
  const { visualLines } = truncateToVisualLines(previewText, PREVIEW_LINES, width, 1);
@@ -4,7 +4,7 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
4
4
  default: {
5
5
  leftSegments: ["pi", "model", "plan_mode", "path", "git", "pr", "context_pct", "token_total", "cost"],
6
6
  rightSegments: [],
7
- separator: "powerline-thin",
7
+ separator: "powerline",
8
8
  segmentOptions: {
9
9
  model: { showThinkingLevel: true },
10
10
  path: { abbreviate: true, maxLength: 40, stripWorkPrefix: true },
@@ -25,7 +25,7 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
25
25
  compact: {
26
26
  leftSegments: ["model", "plan_mode", "git", "pr"],
27
27
  rightSegments: ["cost", "context_pct"],
28
- separator: "powerline-thin",
28
+ separator: "powerline",
29
29
  segmentOptions: {
30
30
  model: { showThinkingLevel: false },
31
31
  git: { showBranch: true, showStaged: true, showUnstaged: true, showUntracked: false },
@@ -95,7 +95,7 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
95
95
  // User-defined - these are just defaults that get overridden
96
96
  leftSegments: ["model", "plan_mode", "path", "git", "pr"],
97
97
  rightSegments: ["token_total", "cost", "context_pct"],
98
- separator: "powerline-thin",
98
+ separator: "powerline",
99
99
  segmentOptions: {},
100
100
  },
101
101
  };
@@ -10,7 +10,7 @@ import {
10
10
  type UsageReport,
11
11
  } from "@f5xc-salesdemos/pi-ai";
12
12
  import { Loader, Markdown, padding, Spacer, Text, visibleWidth } from "@f5xc-salesdemos/pi-tui";
13
- import { formatDuration, Snowflake, setProjectDir } from "@f5xc-salesdemos/pi-utils";
13
+ import { formatDuration, Snowflake, setProjectDir, setShellPwd } from "@f5xc-salesdemos/pi-utils";
14
14
  import { $ } from "bun";
15
15
  import { reset as resetCapabilities } from "../../capability";
16
16
  import { clearClaudePluginRootsCache } from "../../discovery/helpers";
@@ -713,7 +713,7 @@ export class CommandController {
713
713
 
714
714
  // Update CWD if the shell changed directory (e.g. via cd)
715
715
  if (result.newCwd && result.newCwd !== this.ctx.sessionManager.getCwd()) {
716
- setProjectDir(result.newCwd);
716
+ setShellPwd(result.newCwd);
717
717
  this.ctx.statusLine.invalidate();
718
718
  this.ctx.ui.requestRender();
719
719
  }
@@ -64,6 +64,7 @@
64
64
  "syntaxType": "#4EC9B0",
65
65
  "syntaxOperator": "#D4D4D4",
66
66
  "syntaxPunctuation": "#D4D4D4",
67
+ "syntaxControl": "#C586C0",
67
68
  "thinkingOff": "darkGray",
68
69
  "thinkingMinimal": "dimGray",
69
70
  "thinkingLow": "#178fb9",
@@ -83,7 +83,8 @@
83
83
  "statusLineOutput": 133,
84
84
  "statusLineCost": 133,
85
85
  "statusLineSubagents": "shadowGray",
86
- "pythonMode": "#f0c040"
86
+ "pythonMode": "#f0c040",
87
+ "syntaxControl": "#303840"
87
88
  },
88
89
  "export": {
89
90
  "pageBg": "#fdfcfb",
@@ -86,7 +86,8 @@
86
86
  "statusLineOutput": 213,
87
87
  "statusLineCost": 213,
88
88
  "statusLineSubagents": "amethyst",
89
- "pythonMode": "gold"
89
+ "pythonMode": "gold",
90
+ "syntaxControl": "amethyst"
90
91
  },
91
92
  "export": {
92
93
  "pageBg": "caveDark",
@@ -83,7 +83,8 @@
83
83
  "statusLineOutput": 173,
84
84
  "statusLineCost": 173,
85
85
  "statusLineSubagents": "ember",
86
- "pythonMode": "#f0c040"
86
+ "pythonMode": "#f0c040",
87
+ "syntaxControl": "#7ba8d4"
87
88
  },
88
89
  "export": {
89
90
  "pageBg": "#121419",
@@ -81,7 +81,8 @@
81
81
  "statusLineOutput": "#cac5bd",
82
82
  "statusLineCost": "#d97651",
83
83
  "statusLineSubagents": "#c45a3a",
84
- "pythonMode": "#f0c040"
84
+ "pythonMode": "#f0c040",
85
+ "syntaxControl": "#c45a3a"
85
86
  },
86
87
  "export": {
87
88
  "pageBg": "#171613",
@@ -85,7 +85,8 @@
85
85
  "statusLineOutput": 133,
86
86
  "statusLineCost": 133,
87
87
  "statusLineSubagents": "mossSage",
88
- "pythonMode": "#f0c040"
88
+ "pythonMode": "#f0c040",
89
+ "syntaxControl": "#4a7060"
89
90
  },
90
91
  "export": {
91
92
  "pageBg": "#f9f7f1",
@@ -81,7 +81,8 @@
81
81
  "statusLineOutput": "crystalBlue",
82
82
  "statusLineCost": "warningAmber",
83
83
  "statusLineSubagents": "glowCyan",
84
- "pythonMode": "#f0c040"
84
+ "pythonMode": "#f0c040",
85
+ "syntaxControl": "abyssCyan"
85
86
  },
86
87
  "export": {
87
88
  "pageBg": "#05070B",
@@ -94,7 +94,8 @@
94
94
  "statusLineOutput": 139,
95
95
  "statusLineCost": 139,
96
96
  "statusLineSubagents": "iceBlue",
97
- "pythonMode": "#f0c040"
97
+ "pythonMode": "#f0c040",
98
+ "syntaxControl": "frost3"
98
99
  },
99
100
  "export": {
100
101
  "pageBg": "#1a1f2b",
@@ -85,7 +85,8 @@
85
85
  "statusLineOutput": 205,
86
86
  "statusLineCost": 205,
87
87
  "statusLineSubagents": "accent",
88
- "pythonMode": "yellow"
88
+ "pythonMode": "yellow",
89
+ "syntaxControl": "#00b4d8"
89
90
  },
90
91
  "export": {
91
92
  "pageBg": "#0f0f1a",
@@ -97,7 +97,8 @@
97
97
  "statusLineOutput": "maroon",
98
98
  "statusLineCost": "maroon",
99
99
  "statusLineSubagents": "peach",
100
- "pythonMode": "yellow"
100
+ "pythonMode": "yellow",
101
+ "syntaxControl": "#CBA6F7"
101
102
  },
102
103
  "export": {
103
104
  "pageBg": "base",
@@ -81,7 +81,8 @@
81
81
  "statusLineOutput": "crystalBlue",
82
82
  "statusLineCost": "amber",
83
83
  "statusLineSubagents": "crystalBlue",
84
- "pythonMode": "amber"
84
+ "pythonMode": "amber",
85
+ "syntaxControl": "crystalBlue"
85
86
  },
86
87
  "export": {
87
88
  "pageBg": "#0B0D10",
@@ -85,7 +85,8 @@
85
85
  "statusLineOutput": 205,
86
86
  "statusLineCost": 205,
87
87
  "statusLineSubagents": "accent",
88
- "pythonMode": "yellow"
88
+ "pythonMode": "yellow",
89
+ "syntaxControl": "#5e81ac"
89
90
  },
90
91
  "export": {
91
92
  "pageBg": "#242933",
@@ -80,7 +80,8 @@
80
80
  "statusLineOutput": "starlight",
81
81
  "statusLineCost": "nebula",
82
82
  "statusLineSubagents": "nebula",
83
- "pythonMode": "#f0c040"
83
+ "pythonMode": "#f0c040",
84
+ "syntaxControl": "nebula"
84
85
  },
85
86
  "export": {
86
87
  "pageBg": "#07070d",
@@ -92,7 +92,8 @@
92
92
  "statusLineOutput": "#EA00D9",
93
93
  "statusLineCost": "#FF6F61",
94
94
  "statusLineSubagents": "accent",
95
- "pythonMode": "#f0c040"
95
+ "pythonMode": "#f0c040",
96
+ "syntaxControl": "neonMagenta"
96
97
  },
97
98
  "export": {
98
99
  "pageBg": "#091833",
@@ -88,7 +88,8 @@
88
88
  "statusLineOutput": "pink",
89
89
  "statusLineCost": "pink",
90
90
  "statusLineSubagents": "purple",
91
- "pythonMode": "yellow"
91
+ "pythonMode": "yellow",
92
+ "syntaxControl": "#FF79C6"
92
93
  },
93
94
  "export": {
94
95
  "pageBg": "#1e1f29",
@@ -81,7 +81,8 @@
81
81
  "statusLineOutput": "silver",
82
82
  "statusLineCost": "corona",
83
83
  "statusLineSubagents": "corona",
84
- "pythonMode": "#f0c040"
84
+ "pythonMode": "#f0c040",
85
+ "syntaxControl": "violet"
85
86
  },
86
87
  "export": {
87
88
  "pageBg": "#08070d",
@@ -85,7 +85,8 @@
85
85
  "statusLineOutput": 205,
86
86
  "statusLineCost": 205,
87
87
  "statusLineSubagents": "accent",
88
- "pythonMode": "yellow"
88
+ "pythonMode": "yellow",
89
+ "syntaxControl": "#5f8dd3"
89
90
  },
90
91
  "export": {
91
92
  "pageBg": "#21252b",
@@ -80,7 +80,8 @@
80
80
  "statusLineOutput": "light",
81
81
  "statusLineCost": "warm",
82
82
  "statusLineSubagents": "warm",
83
- "pythonMode": "#f0c040"
83
+ "pythonMode": "#f0c040",
84
+ "syntaxControl": "violet"
84
85
  },
85
86
  "export": {
86
87
  "pageBg": "#0f1115",
@@ -86,7 +86,8 @@
86
86
  "statusLineOutput": "walnut",
87
87
  "statusLineCost": "earth",
88
88
  "statusLineSubagents": "moss",
89
- "pythonMode": "amber"
89
+ "pythonMode": "amber",
90
+ "syntaxControl": "pine"
90
91
  },
91
92
  "export": {
92
93
  "pageBg": "#0A120E",
@@ -95,7 +95,8 @@
95
95
  "statusLineOutput": "purple",
96
96
  "statusLineCost": "purple",
97
97
  "statusLineSubagents": "yellowLight",
98
- "pythonMode": "yellow"
98
+ "pythonMode": "yellow",
99
+ "syntaxControl": "#F47067"
99
100
  },
100
101
  "export": {
101
102
  "pageBg": "#010409",