@f5xc-salesdemos/xcsh 18.5.4 → 18.6.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/package.json +7 -7
- package/src/config/settings-schema.ts +12 -0
- package/src/config/settings.ts +12 -1
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/main.ts +1 -0
- package/src/modes/components/user-message.ts +21 -2
- package/src/modes/components/welcome-checks.ts +37 -2
- package/src/modes/components/welcome.ts +12 -7
- package/src/modes/theme/theme.ts +42 -2
- package/src/services/f5xc-profile-indicators.ts +16 -0
- package/src/services/f5xc-table.ts +5 -6
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5xc-salesdemos/xcsh",
|
|
4
|
-
"version": "18.
|
|
4
|
+
"version": "18.6.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",
|
|
@@ -47,12 +47,12 @@
|
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"@agentclientprotocol/sdk": "0.16.1",
|
|
49
49
|
"@mozilla/readability": "^0.6",
|
|
50
|
-
"@f5xc-salesdemos/xcsh-stats": "18.
|
|
51
|
-
"@f5xc-salesdemos/pi-agent-core": "18.
|
|
52
|
-
"@f5xc-salesdemos/pi-ai": "18.
|
|
53
|
-
"@f5xc-salesdemos/pi-natives": "18.
|
|
54
|
-
"@f5xc-salesdemos/pi-tui": "18.
|
|
55
|
-
"@f5xc-salesdemos/pi-utils": "18.
|
|
50
|
+
"@f5xc-salesdemos/xcsh-stats": "18.6.0",
|
|
51
|
+
"@f5xc-salesdemos/pi-agent-core": "18.6.0",
|
|
52
|
+
"@f5xc-salesdemos/pi-ai": "18.6.0",
|
|
53
|
+
"@f5xc-salesdemos/pi-natives": "18.6.0",
|
|
54
|
+
"@f5xc-salesdemos/pi-tui": "18.6.0",
|
|
55
|
+
"@f5xc-salesdemos/pi-utils": "18.6.0",
|
|
56
56
|
"@sinclair/typebox": "^0.34",
|
|
57
57
|
"@xterm/headless": "^6.0",
|
|
58
58
|
"ajv": "^8.18",
|
|
@@ -263,6 +263,18 @@ export const SETTINGS_SCHEMA = {
|
|
|
263
263
|
},
|
|
264
264
|
},
|
|
265
265
|
|
|
266
|
+
"theme.forceSlot": {
|
|
267
|
+
type: "enum",
|
|
268
|
+
values: ["auto", "dark", "light"] as const,
|
|
269
|
+
default: "auto",
|
|
270
|
+
ui: {
|
|
271
|
+
tab: "appearance",
|
|
272
|
+
label: "Force Theme Slot",
|
|
273
|
+
description: "Override auto dark/light detection (useful inside tmux where OSC 11 is unreliable)",
|
|
274
|
+
submenu: true,
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
|
|
266
278
|
symbolPreset: {
|
|
267
279
|
type: "enum",
|
|
268
280
|
values: ["unicode", "nerd", "ascii"] as const,
|
package/src/config/settings.ts
CHANGED
|
@@ -26,7 +26,13 @@ import { YAML } from "bun";
|
|
|
26
26
|
import { type Settings as SettingsCapabilityItem, settingsCapability } from "../capability/settings";
|
|
27
27
|
import type { ModelRole } from "../config/model-registry";
|
|
28
28
|
import { loadCapability } from "../discovery";
|
|
29
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
isLightTheme,
|
|
31
|
+
setAutoThemeMapping,
|
|
32
|
+
setColorBlindMode,
|
|
33
|
+
setForceSlot,
|
|
34
|
+
setSymbolPreset,
|
|
35
|
+
} from "../modes/theme/theme";
|
|
30
36
|
import { AgentStorage } from "../session/agent-storage";
|
|
31
37
|
import { type EditMode, normalizeEditMode } from "../utils/edit-mode";
|
|
32
38
|
import { withFileLock } from "./file-lock";
|
|
@@ -649,6 +655,11 @@ const SETTING_HOOKS: Partial<Record<SettingPath, SettingHook<any>>> = {
|
|
|
649
655
|
setAutoThemeMapping("light", value);
|
|
650
656
|
}
|
|
651
657
|
},
|
|
658
|
+
"theme.forceSlot": value => {
|
|
659
|
+
if (value === "auto" || value === "dark" || value === "light") {
|
|
660
|
+
setForceSlot(value);
|
|
661
|
+
}
|
|
662
|
+
},
|
|
652
663
|
symbolPreset: value => {
|
|
653
664
|
if (typeof value === "string" && (value === "unicode" || value === "nerd" || value === "ascii")) {
|
|
654
665
|
setSymbolPreset(value).catch(err => {
|
|
@@ -17,17 +17,17 @@ export interface BuildInfo {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export const BUILD_INFO: BuildInfo = {
|
|
20
|
-
"version": "18.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "18.6.0",
|
|
21
|
+
"commit": "486319ec8f5de5272ed0e256808d6cfc64592fb5",
|
|
22
|
+
"shortCommit": "486319e",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v18.
|
|
25
|
-
"commitDate": "2026-04-
|
|
26
|
-
"buildDate": "2026-04-
|
|
24
|
+
"tag": "v18.6.0",
|
|
25
|
+
"commitDate": "2026-04-22T06:10:11Z",
|
|
26
|
+
"buildDate": "2026-04-22T06:29:23.525Z",
|
|
27
27
|
"dirty": false,
|
|
28
28
|
"prNumber": "",
|
|
29
29
|
"repoUrl": "https://github.com/f5xc-salesdemos/xcsh",
|
|
30
30
|
"repoSlug": "f5xc-salesdemos/xcsh",
|
|
31
|
-
"commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/
|
|
32
|
-
"releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.
|
|
31
|
+
"commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/486319ec8f5de5272ed0e256808d6cfc64592fb5",
|
|
32
|
+
"releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.6.0"
|
|
33
33
|
};
|
package/src/main.ts
CHANGED
|
@@ -704,6 +704,7 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
|
|
|
704
704
|
settings.get("colorBlindMode"),
|
|
705
705
|
settings.get("theme.dark"),
|
|
706
706
|
settings.get("theme.light"),
|
|
707
|
+
settings.get("theme.forceSlot"),
|
|
707
708
|
);
|
|
708
709
|
|
|
709
710
|
let scopedModels: ScopedModel[] = [];
|
|
@@ -25,13 +25,32 @@ const GUTTER_PAD = " ";
|
|
|
25
25
|
* leading blank spacer separates the prompt from the preceding block.
|
|
26
26
|
*/
|
|
27
27
|
export class UserMessageComponent extends Container {
|
|
28
|
+
#text: string;
|
|
29
|
+
#synthetic: boolean;
|
|
30
|
+
|
|
28
31
|
constructor(text: string, synthetic = false) {
|
|
29
32
|
super();
|
|
30
|
-
|
|
33
|
+
this.#text = text;
|
|
34
|
+
this.#synthetic = synthetic;
|
|
35
|
+
this.#rebuild();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Mirror AssistantMessageComponent: on invalidate, drop the Markdown child
|
|
39
|
+
// and rebuild it so getMarkdownTheme() is re-captured. Without this, a
|
|
40
|
+
// theme change leaves the Markdown child rendering with the original
|
|
41
|
+
// construction-time theme.
|
|
42
|
+
override invalidate(): void {
|
|
43
|
+
super.invalidate();
|
|
44
|
+
this.#rebuild();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
#rebuild(): void {
|
|
48
|
+
this.children = [];
|
|
49
|
+
const color = this.#synthetic
|
|
31
50
|
? (value: string) => theme.fg("dim", value)
|
|
32
51
|
: (value: string) => `\x1b[3m${theme.fg("userMessageText", value)}\x1b[23m`;
|
|
33
52
|
this.addChild(new Spacer(1));
|
|
34
|
-
this.addChild(new Markdown(text, 0, 0, getMarkdownTheme(), { color }));
|
|
53
|
+
this.addChild(new Markdown(this.#text, 0, 0, getMarkdownTheme(), { color }));
|
|
35
54
|
}
|
|
36
55
|
|
|
37
56
|
override render(width: number): string[] {
|
|
@@ -1,9 +1,44 @@
|
|
|
1
1
|
import type { Model } from "@f5xc-salesdemos/pi-ai";
|
|
2
2
|
import { validateApiKeyAgainstModelsEndpoint } from "@f5xc-salesdemos/pi-ai/utils/oauth/api-key-validation";
|
|
3
3
|
import { logger } from "@f5xc-salesdemos/pi-utils";
|
|
4
|
-
import { ProfileService } from "../../services/f5xc-profile";
|
|
4
|
+
import { type AuthStatus, ProfileService } from "../../services/f5xc-profile";
|
|
5
5
|
import type { AuthStorage } from "../../session/auth-storage";
|
|
6
6
|
|
|
7
|
+
// Startup validation budget. These are longer than validateToken's 3000ms default because
|
|
8
|
+
// the welcome path runs during TLS/DNS cold-start — a single 3s shot races against warm-up
|
|
9
|
+
// and falsely reports offline for profiles that reconnect cleanly moments later.
|
|
10
|
+
const STARTUP_FIRST_TIMEOUT_MS = 4000;
|
|
11
|
+
const STARTUP_RETRY_TIMEOUT_MS = 5000;
|
|
12
|
+
const STARTUP_RETRY_DELAY_MS = 500;
|
|
13
|
+
|
|
14
|
+
type ProfileValidator = (opts: { timeoutMs: number }) => Promise<{ status: AuthStatus; latencyMs?: number }>;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Runs the profile validator once with a startup-sized timeout; if the result is `offline`
|
|
18
|
+
* (the only transient class — auth_error/connected/unknown are definitive), waits briefly
|
|
19
|
+
* to let DNS/TLS warm up, then tries once more with a longer timeout.
|
|
20
|
+
*/
|
|
21
|
+
export async function validateProfileWithStartupRetry(
|
|
22
|
+
validate: ProfileValidator,
|
|
23
|
+
options?: {
|
|
24
|
+
firstTimeoutMs?: number;
|
|
25
|
+
retryTimeoutMs?: number;
|
|
26
|
+
retryDelayMs?: number;
|
|
27
|
+
},
|
|
28
|
+
): Promise<{ status: AuthStatus; latencyMs?: number }> {
|
|
29
|
+
const firstTimeoutMs = options?.firstTimeoutMs ?? STARTUP_FIRST_TIMEOUT_MS;
|
|
30
|
+
const retryTimeoutMs = options?.retryTimeoutMs ?? STARTUP_RETRY_TIMEOUT_MS;
|
|
31
|
+
const retryDelayMs = options?.retryDelayMs ?? STARTUP_RETRY_DELAY_MS;
|
|
32
|
+
|
|
33
|
+
const first = await validate({ timeoutMs: firstTimeoutMs });
|
|
34
|
+
if (first.status !== "offline") return first;
|
|
35
|
+
|
|
36
|
+
if (retryDelayMs > 0) {
|
|
37
|
+
await new Promise(resolve => setTimeout(resolve, retryDelayMs));
|
|
38
|
+
}
|
|
39
|
+
return await validate({ timeoutMs: retryTimeoutMs });
|
|
40
|
+
}
|
|
41
|
+
|
|
7
42
|
export type ModelCheckState = "no_provider" | "connected" | "auth_error";
|
|
8
43
|
|
|
9
44
|
export interface ModelStatus {
|
|
@@ -117,7 +152,7 @@ async function checkProfileStatus(): Promise<WelcomeProfileStatus> {
|
|
|
117
152
|
}
|
|
118
153
|
|
|
119
154
|
const name = status.activeProfileName ?? "default";
|
|
120
|
-
const result = await profileService.validateToken();
|
|
155
|
+
const result = await validateProfileWithStartupRetry(opts => profileService.validateToken(opts));
|
|
121
156
|
|
|
122
157
|
switch (result.status) {
|
|
123
158
|
case "connected":
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type Component, padding, truncateToWidth, visibleWidth } from "@f5xc-salesdemos/pi-tui";
|
|
2
2
|
import { APP_NAME } from "@f5xc-salesdemos/pi-utils";
|
|
3
3
|
import { theme } from "../../modes/theme/theme";
|
|
4
|
+
import { formatStatusIcon } from "../../services/f5xc-profile-indicators";
|
|
4
5
|
import type { ModelStatus, WelcomeProfileStatus } from "./welcome-checks";
|
|
5
6
|
|
|
6
7
|
export interface UpdateStatus {
|
|
@@ -200,15 +201,17 @@ export class WelcomeComponent implements Component {
|
|
|
200
201
|
const p = provider ?? "unknown";
|
|
201
202
|
switch (state) {
|
|
202
203
|
case "connected":
|
|
203
|
-
return [
|
|
204
|
+
return [
|
|
205
|
+
` ${formatStatusIcon("connected")} ${theme.fg("muted", p)} ${theme.fg("dim", `\u2014 connected (${latencyMs ?? "?"}ms)`)}`,
|
|
206
|
+
];
|
|
204
207
|
case "auth_error":
|
|
205
208
|
return [
|
|
206
|
-
`
|
|
209
|
+
` ${formatStatusIcon("error")} ${theme.fg("muted", p)} ${theme.fg("error", "\u2014 connection failed")}`,
|
|
207
210
|
` ${theme.fg("dim", "Run /login to reconnect")}`,
|
|
208
211
|
];
|
|
209
212
|
case "no_provider":
|
|
210
213
|
return [
|
|
211
|
-
`
|
|
214
|
+
` ${formatStatusIcon("error")} ${theme.fg("error", "No model provider configured")}`,
|
|
212
215
|
` ${theme.fg("dim", "Run /login to connect")}`,
|
|
213
216
|
];
|
|
214
217
|
}
|
|
@@ -220,20 +223,22 @@ export class WelcomeComponent implements Component {
|
|
|
220
223
|
const n = name ?? "default";
|
|
221
224
|
switch (state) {
|
|
222
225
|
case "connected":
|
|
223
|
-
return [
|
|
226
|
+
return [
|
|
227
|
+
` ${formatStatusIcon("connected")} ${theme.fg("muted", n)} ${theme.fg("dim", `\u2014 connected (${latencyMs ?? "?"}ms)`)}`,
|
|
228
|
+
];
|
|
224
229
|
case "auth_error":
|
|
225
230
|
return [
|
|
226
|
-
`
|
|
231
|
+
` ${formatStatusIcon("error")} ${theme.fg("muted", n)} ${theme.fg("error", "\u2014 token invalid")}`,
|
|
227
232
|
` ${theme.fg("dim", "Run /profile to update")}`,
|
|
228
233
|
];
|
|
229
234
|
case "offline":
|
|
230
235
|
return [
|
|
231
|
-
`
|
|
236
|
+
` ${formatStatusIcon("warning")} ${theme.fg("muted", n)} ${theme.fg("warning", "\u2014 unreachable")}`,
|
|
232
237
|
` ${theme.fg("dim", "Check network, /profile")}`,
|
|
233
238
|
];
|
|
234
239
|
case "no_profile":
|
|
235
240
|
return [
|
|
236
|
-
`
|
|
241
|
+
` ${formatStatusIcon("warning")} ${theme.fg("warning", "No profile configured")}`,
|
|
237
242
|
` ${theme.fg("dim", "Run /profile create <name> <url> <token>")}`,
|
|
238
243
|
];
|
|
239
244
|
}
|
package/src/modes/theme/theme.ts
CHANGED
|
@@ -1894,9 +1894,33 @@ function detectTerminalBackground(): "dark" | "light" {
|
|
|
1894
1894
|
return "dark";
|
|
1895
1895
|
}
|
|
1896
1896
|
|
|
1897
|
+
/**
|
|
1898
|
+
* Theme-slot override mode. `"auto"` uses terminal-appearance detection to
|
|
1899
|
+
* pick between the dark and light slots. `"dark"` / `"light"` skip detection
|
|
1900
|
+
* and always load that slot — useful when running inside tmux where OSC 11
|
|
1901
|
+
* queries and `COLORFGBG` don't reliably surface (see issue #228).
|
|
1902
|
+
*/
|
|
1903
|
+
export type ThemeForceSlot = "auto" | "dark" | "light";
|
|
1904
|
+
|
|
1905
|
+
/**
|
|
1906
|
+
* Pure slot-resolution helper. Exported so it can be unit-tested without a
|
|
1907
|
+
* terminal. When `forceSlot` is `"dark"` or `"light"`, returns that slot's
|
|
1908
|
+
* theme name unconditionally. Otherwise maps `detected` bg to the matching
|
|
1909
|
+
* slot.
|
|
1910
|
+
*/
|
|
1911
|
+
export function resolveThemeSlot(
|
|
1912
|
+
forceSlot: ThemeForceSlot,
|
|
1913
|
+
detected: "dark" | "light",
|
|
1914
|
+
darkTheme: string,
|
|
1915
|
+
lightTheme: string,
|
|
1916
|
+
): string {
|
|
1917
|
+
if (forceSlot === "dark") return darkTheme;
|
|
1918
|
+
if (forceSlot === "light") return lightTheme;
|
|
1919
|
+
return detected === "light" ? lightTheme : darkTheme;
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1897
1922
|
function getDefaultTheme(): string {
|
|
1898
|
-
|
|
1899
|
-
return bg === "light" ? autoLightTheme : autoDarkTheme;
|
|
1923
|
+
return resolveThemeSlot(currentForceSlot, detectTerminalBackground(), autoDarkTheme, autoLightTheme);
|
|
1900
1924
|
}
|
|
1901
1925
|
|
|
1902
1926
|
// ============================================================================
|
|
@@ -1918,6 +1942,7 @@ var sigwinchHandler: (() => void) | undefined;
|
|
|
1918
1942
|
var autoDetectedTheme: boolean = false;
|
|
1919
1943
|
var autoDarkTheme: string = "xcsh-dark";
|
|
1920
1944
|
var autoLightTheme: string = "xcsh-light";
|
|
1945
|
+
var currentForceSlot: ThemeForceSlot = "auto";
|
|
1921
1946
|
var onThemeChangeCallback: (() => void) | undefined;
|
|
1922
1947
|
var themeLoadRequestId: number = 0;
|
|
1923
1948
|
|
|
@@ -1934,10 +1959,12 @@ export async function initTheme(
|
|
|
1934
1959
|
colorBlindMode?: boolean,
|
|
1935
1960
|
darkTheme?: string,
|
|
1936
1961
|
lightTheme?: string,
|
|
1962
|
+
forceSlot?: ThemeForceSlot,
|
|
1937
1963
|
): Promise<void> {
|
|
1938
1964
|
autoDetectedTheme = true;
|
|
1939
1965
|
autoDarkTheme = darkTheme ?? "xcsh-dark";
|
|
1940
1966
|
autoLightTheme = lightTheme ?? "xcsh-light";
|
|
1967
|
+
currentForceSlot = forceSlot ?? "auto";
|
|
1941
1968
|
const name = getDefaultTheme();
|
|
1942
1969
|
currentThemeName = name;
|
|
1943
1970
|
currentSymbolPresetOverride = symbolPreset;
|
|
@@ -2032,6 +2059,19 @@ export function setAutoThemeMapping(mode: "dark" | "light", themeName: string):
|
|
|
2032
2059
|
reevaluateAutoTheme("setAutoThemeMapping");
|
|
2033
2060
|
}
|
|
2034
2061
|
|
|
2062
|
+
/**
|
|
2063
|
+
* Update the forced-slot override. Callers pass `"auto"` to restore the
|
|
2064
|
+
* detection chain, or `"dark"` / `"light"` to skip detection entirely and
|
|
2065
|
+
* always load that slot. Re-evaluates the active theme immediately.
|
|
2066
|
+
*
|
|
2067
|
+
* Primary use case: tmux sessions where OSC 11 and COLORFGBG don't surface
|
|
2068
|
+
* a reliable bg signal (issue #228).
|
|
2069
|
+
*/
|
|
2070
|
+
export function setForceSlot(slot: ThemeForceSlot): void {
|
|
2071
|
+
currentForceSlot = slot;
|
|
2072
|
+
reevaluateAutoTheme("setForceSlot");
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2035
2075
|
/**
|
|
2036
2076
|
* Called when the terminal detects a dark/light appearance change.
|
|
2037
2077
|
* The terminal layer queries OSC 11 (background color) and computes luminance;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { theme } from "../modes/theme/theme";
|
|
2
|
+
|
|
3
|
+
export type StatusCategory = "connected" | "error" | "warning" | "unknown";
|
|
4
|
+
|
|
5
|
+
export function formatStatusIcon(status: StatusCategory): string {
|
|
6
|
+
switch (status) {
|
|
7
|
+
case "connected":
|
|
8
|
+
return theme.fg("success", "●");
|
|
9
|
+
case "error":
|
|
10
|
+
return theme.fg("error", "○");
|
|
11
|
+
case "warning":
|
|
12
|
+
return theme.fg("warning", "⚠");
|
|
13
|
+
case "unknown":
|
|
14
|
+
return theme.fg("dim", "○");
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import type { AuthStatus } from "./f5xc-profile";
|
|
2
|
+
import { formatStatusIcon } from "./f5xc-profile-indicators";
|
|
2
3
|
|
|
3
4
|
// F5 Brand Red — same as welcome.ts line 203
|
|
4
5
|
const F5_RED = "\x1b[38;5;160m";
|
|
5
|
-
const GREEN = "\x1b[38;5;34m";
|
|
6
|
-
const RED_TEXT = "\x1b[38;5;196m";
|
|
7
6
|
const RESET = "\x1b[0m";
|
|
8
7
|
const BOLD = "\x1b[1m";
|
|
9
8
|
|
|
@@ -25,13 +24,13 @@ export function formatAuthIndicator(status: AuthStatus, latencyMs?: number): str
|
|
|
25
24
|
const ms = latencyMs !== undefined ? ` (${latencyMs}ms)` : "";
|
|
26
25
|
switch (status) {
|
|
27
26
|
case "connected":
|
|
28
|
-
return `${
|
|
27
|
+
return `${formatStatusIcon("connected")} Connected${ms}`;
|
|
29
28
|
case "auth_error":
|
|
30
|
-
return `${
|
|
29
|
+
return `${formatStatusIcon("error")} Auth Error${ms}`;
|
|
31
30
|
case "offline":
|
|
32
|
-
return `${
|
|
31
|
+
return `${formatStatusIcon("warning")} Offline`;
|
|
33
32
|
default:
|
|
34
|
-
return
|
|
33
|
+
return `${formatStatusIcon("unknown")} Unknown`;
|
|
35
34
|
}
|
|
36
35
|
}
|
|
37
36
|
|