@f5xc-salesdemos/xcsh 18.72.0 → 18.75.3
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/CHANGELOG.md +8 -2
- package/package.json +7 -7
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/modes/components/status-line/presets.ts +57 -0
- package/src/modes/components/status-line/segments.ts +19 -0
- package/src/modes/components/status-line/types.ts +2 -0
- package/src/modes/components/status-line.ts +77 -19
- package/src/pipeline-report/generator.ts +13 -10
- package/src/prompts/tools/sf-pipeline-report.md +25 -0
- package/src/prompts/tools/sf-query.md +5 -2
- package/src/sdk.ts +24 -0
- package/src/services/f5xc-context-display.ts +40 -0
- package/src/services/f5xc-context-segment.ts +17 -1
- package/src/tools/index.ts +2 -0
- package/src/tools/renderers.ts +1 -0
- package/src/tools/sf-pipeline-report.ts +175 -0
- package/src/tools/sf-renderer.ts +46 -0
- package/src/tools/sf.ts +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.75.0] - 2026-05-23
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Auto-validate credentials on context switch: SDK context change listener now fires a background `validateToken()` after emitting the switch notification, sending a follow-up `context_validation_result` custom message so the LLM knows whether the new context's credentials are valid
|
|
10
|
+
|
|
5
11
|
## [18.64.2] - 2026-05-16
|
|
6
12
|
|
|
7
13
|
### Fixed
|
|
@@ -20,11 +26,13 @@
|
|
|
20
26
|
### Changed
|
|
21
27
|
|
|
22
28
|
- Regenerated API spec index from catalog v2.1.82: http_loadbalancer CRUD verification corrections (6 new server defaults, corrected minimum configs, cross-field dependencies, default_pool inline pool discovery, 5 composable routing approaches) and tcp_loadbalancer minimum config corrections (listen_port, origin_pools_weights, do_not_advertise format, 9 server defaults, forced hash_policy default) ([#753](https://github.com/f5xc-salesdemos/xcsh/issues/753), [#757](https://github.com/f5xc-salesdemos/xcsh/issues/757))
|
|
29
|
+
|
|
23
30
|
## [18.53.0] - 2026-05-09
|
|
24
31
|
|
|
25
32
|
### Changed
|
|
26
33
|
|
|
27
34
|
- Autoresearch subsystem code quality: -513 lines (18.8% reduction), ~13% faster type checking. Un-exported internal symbols, relocated types, consolidated duplicate patterns, replaced manual deep copies with `structuredClone`, replaced `while(exec)` with `matchAll`, compressed control flow, extracted shared interfaces ([#734](https://github.com/f5xc-salesdemos/xcsh/pull/734))
|
|
35
|
+
|
|
28
36
|
## [18.53.0] - 2026-05-09
|
|
29
37
|
|
|
30
38
|
### Fixed
|
|
@@ -79,13 +87,11 @@
|
|
|
79
87
|
|
|
80
88
|
## [18.18.4] - 2026-04-26
|
|
81
89
|
|
|
82
|
-
|
|
83
90
|
### Fixed
|
|
84
91
|
|
|
85
92
|
- Fixed gutter width propagation in the fallback tool renderer: `#formatToolExecution()` now receives the actual available width at render-time and uses it for line truncation instead of a hardcoded 80-column limit. On narrow terminals (<82 cols) this prevents content wider than the gutter-adjusted viewport; on wide terminals it allows longer output lines. ([#117](https://github.com/f5xc-salesdemos/xcsh/issues/117))
|
|
86
93
|
- Fixed `resolveConfigValue` returning literal env var names (e.g. `"LITELLM_API_KEY"`) as API keys when the env var is unset, causing 401 errors on first launch. The resolver now rejects unresolved `ALL_CAPS_WITH_UNDERSCORES` patterns, matching the existing guard in `resolveYamlApiKeyConfig`. ([#241](https://github.com/f5xc-salesdemos/xcsh/issues/241))
|
|
87
94
|
|
|
88
|
-
|
|
89
95
|
### Changed
|
|
90
96
|
|
|
91
97
|
- Renamed F5 XC credential system from "profile" to "context" to align with kubectl conventions. The `/profile` command is now `/context`, all types/classes use `Context*` naming (`ContextService`, `ContextStatus`, `F5XCContext`, etc.), on-disk paths changed from `profiles/` to `contexts/` and `active_profile` to `active_context`, and the status-line segment ID is now `context_f5xc`. ([#302](https://github.com/f5xc-salesdemos/xcsh/issues/302))
|
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.75.3",
|
|
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",
|
|
@@ -48,12 +48,12 @@
|
|
|
48
48
|
"dependencies": {
|
|
49
49
|
"@agentclientprotocol/sdk": "0.16.1",
|
|
50
50
|
"@mozilla/readability": "^0.6",
|
|
51
|
-
"@f5xc-salesdemos/xcsh-stats": "
|
|
52
|
-
"@f5xc-salesdemos/pi-agent-core": "
|
|
53
|
-
"@f5xc-salesdemos/pi-ai": "
|
|
54
|
-
"@f5xc-salesdemos/pi-natives": "
|
|
55
|
-
"@f5xc-salesdemos/pi-tui": "
|
|
56
|
-
"@f5xc-salesdemos/pi-utils": "
|
|
51
|
+
"@f5xc-salesdemos/xcsh-stats": "workspace:*",
|
|
52
|
+
"@f5xc-salesdemos/pi-agent-core": "workspace:*",
|
|
53
|
+
"@f5xc-salesdemos/pi-ai": "workspace:*",
|
|
54
|
+
"@f5xc-salesdemos/pi-natives": "workspace:*",
|
|
55
|
+
"@f5xc-salesdemos/pi-tui": "workspace:*",
|
|
56
|
+
"@f5xc-salesdemos/pi-utils": "workspace:*",
|
|
57
57
|
"@sinclair/typebox": "^0.34",
|
|
58
58
|
"@xterm/headless": "^6.0",
|
|
59
59
|
"ajv": "^8.18",
|
|
@@ -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.75.3",
|
|
21
|
+
"commit": "6856457e251f8583b237e72702ac395bc7173f42",
|
|
22
|
+
"shortCommit": "6856457",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v18.
|
|
25
|
-
"commitDate": "2026-05-
|
|
26
|
-
"buildDate": "2026-05-
|
|
24
|
+
"tag": "v18.75.3",
|
|
25
|
+
"commitDate": "2026-05-23T08:23:41Z",
|
|
26
|
+
"buildDate": "2026-05-23T17:15:18.019Z",
|
|
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/6856457e251f8583b237e72702ac395bc7173f42",
|
|
32
|
+
"releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.75.3"
|
|
33
33
|
};
|
|
@@ -10,6 +10,18 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
|
|
|
10
10
|
path: { abbreviate: true, maxLength: 40, stripWorkPrefix: true },
|
|
11
11
|
git: { showBranch: true, showStaged: true, showUnstaged: true, showUntracked: true },
|
|
12
12
|
},
|
|
13
|
+
dropOrder: [
|
|
14
|
+
"pr",
|
|
15
|
+
"token_total",
|
|
16
|
+
"cost",
|
|
17
|
+
"git",
|
|
18
|
+
"path",
|
|
19
|
+
"context_pct",
|
|
20
|
+
"plan_mode",
|
|
21
|
+
"model",
|
|
22
|
+
"pi",
|
|
23
|
+
"context_f5xc",
|
|
24
|
+
],
|
|
13
25
|
},
|
|
14
26
|
|
|
15
27
|
minimal: {
|
|
@@ -20,6 +32,7 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
|
|
|
20
32
|
path: { abbreviate: true, maxLength: 30 },
|
|
21
33
|
git: { showBranch: true, showStaged: false, showUnstaged: false, showUntracked: false },
|
|
22
34
|
},
|
|
35
|
+
dropOrder: ["git", "plan_mode", "context_pct", "path", "context_f5xc"],
|
|
23
36
|
},
|
|
24
37
|
|
|
25
38
|
compact: {
|
|
@@ -30,6 +43,7 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
|
|
|
30
43
|
model: { showThinkingLevel: false },
|
|
31
44
|
git: { showBranch: true, showStaged: true, showUnstaged: true, showUntracked: false },
|
|
32
45
|
},
|
|
46
|
+
dropOrder: ["pr", "cost", "git", "context_pct", "plan_mode", "model", "context_f5xc"],
|
|
33
47
|
},
|
|
34
48
|
|
|
35
49
|
full: {
|
|
@@ -52,6 +66,25 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
|
|
|
52
66
|
git: { showBranch: true, showStaged: true, showUnstaged: true, showUntracked: true },
|
|
53
67
|
time: { format: "24h", showSeconds: false },
|
|
54
68
|
},
|
|
69
|
+
dropOrder: [
|
|
70
|
+
"time",
|
|
71
|
+
"time_spent",
|
|
72
|
+
"cache_read",
|
|
73
|
+
"token_rate",
|
|
74
|
+
"token_out",
|
|
75
|
+
"token_in",
|
|
76
|
+
"subagents",
|
|
77
|
+
"pr",
|
|
78
|
+
"git",
|
|
79
|
+
"path",
|
|
80
|
+
"cost",
|
|
81
|
+
"context_pct",
|
|
82
|
+
"plan_mode",
|
|
83
|
+
"hostname",
|
|
84
|
+
"model",
|
|
85
|
+
"pi",
|
|
86
|
+
"context_f5xc",
|
|
87
|
+
],
|
|
55
88
|
},
|
|
56
89
|
|
|
57
90
|
nerd: {
|
|
@@ -77,6 +110,28 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
|
|
|
77
110
|
git: { showBranch: true, showStaged: true, showUnstaged: true, showUntracked: true },
|
|
78
111
|
time: { format: "24h", showSeconds: true },
|
|
79
112
|
},
|
|
113
|
+
dropOrder: [
|
|
114
|
+
"context_total",
|
|
115
|
+
"cache_write",
|
|
116
|
+
"session",
|
|
117
|
+
"time",
|
|
118
|
+
"time_spent",
|
|
119
|
+
"cache_read",
|
|
120
|
+
"token_rate",
|
|
121
|
+
"token_out",
|
|
122
|
+
"token_in",
|
|
123
|
+
"subagents",
|
|
124
|
+
"pr",
|
|
125
|
+
"git",
|
|
126
|
+
"path",
|
|
127
|
+
"cost",
|
|
128
|
+
"context_pct",
|
|
129
|
+
"plan_mode",
|
|
130
|
+
"hostname",
|
|
131
|
+
"model",
|
|
132
|
+
"pi",
|
|
133
|
+
"context_f5xc",
|
|
134
|
+
],
|
|
80
135
|
},
|
|
81
136
|
|
|
82
137
|
ascii: {
|
|
@@ -89,6 +144,7 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
|
|
|
89
144
|
path: { abbreviate: true, maxLength: 40 },
|
|
90
145
|
git: { showBranch: true, showStaged: true, showUnstaged: true, showUntracked: true },
|
|
91
146
|
},
|
|
147
|
+
dropOrder: ["pr", "token_total", "cost", "git", "path", "context_pct", "model", "context_f5xc"],
|
|
92
148
|
},
|
|
93
149
|
|
|
94
150
|
xcsh: {
|
|
@@ -101,6 +157,7 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
|
|
|
101
157
|
git: { showBranch: true, showStaged: true, showUnstaged: true, showUntracked: true },
|
|
102
158
|
context_pct: { compact: true },
|
|
103
159
|
},
|
|
160
|
+
dropOrder: ["plan_mode", "git", "path", "context_pct", "context_f5xc"],
|
|
104
161
|
},
|
|
105
162
|
|
|
106
163
|
custom: {
|
|
@@ -461,6 +461,25 @@ export const SEGMENTS: Record<StatusLineSegmentId, StatusLineSegment> = {
|
|
|
461
461
|
return { content: "", visible: false };
|
|
462
462
|
}
|
|
463
463
|
},
|
|
464
|
+
truncate(maxWidth: number, _ctx: SegmentContext): RenderedSegment | null {
|
|
465
|
+
try {
|
|
466
|
+
const { truncateF5XCContextSegment } = require("../../../services/f5xc-context-segment");
|
|
467
|
+
const result = truncateF5XCContextSegment(maxWidth);
|
|
468
|
+
if (!result) return null;
|
|
469
|
+
let bg = theme.fgColorAsBg("statusLineContextF5xcBg");
|
|
470
|
+
let fg = theme.getFgAnsi("statusLineContextF5xcFg");
|
|
471
|
+
if (result.tokenHealth === "expiring") {
|
|
472
|
+
bg = theme.fgColorAsBg("statusLineGitDirtyBg");
|
|
473
|
+
fg = theme.getFgAnsi("statusLineGitDirtyFg");
|
|
474
|
+
} else if (result.tokenHealth === "expired") {
|
|
475
|
+
bg = theme.fgColorAsBg("statusLineGitConflictBg");
|
|
476
|
+
fg = theme.getFgAnsi("statusLineGitConflictFg");
|
|
477
|
+
}
|
|
478
|
+
return { ...result, bg, fg };
|
|
479
|
+
} catch {
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
},
|
|
464
483
|
},
|
|
465
484
|
};
|
|
466
485
|
|
|
@@ -66,6 +66,7 @@ export interface RenderedSegment {
|
|
|
66
66
|
export interface StatusLineSegment {
|
|
67
67
|
id: StatusLineSegmentId;
|
|
68
68
|
render(ctx: SegmentContext): RenderedSegment;
|
|
69
|
+
truncate?(maxWidth: number, ctx: SegmentContext): RenderedSegment | null;
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -91,4 +92,5 @@ export interface PresetDef {
|
|
|
91
92
|
rightSegments: StatusLineSegmentId[];
|
|
92
93
|
separator: StatusLineSeparatorStyle;
|
|
93
94
|
segmentOptions?: StatusLineSegmentOptions;
|
|
95
|
+
dropOrder?: StatusLineSegmentId[];
|
|
94
96
|
}
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
type PrCacheContext,
|
|
21
21
|
} from "./status-line/git-utils";
|
|
22
22
|
import { getPreset } from "./status-line/presets";
|
|
23
|
-
import { renderSegment, type SegmentContext } from "./status-line/segments";
|
|
23
|
+
import { renderSegment, SEGMENTS, type SegmentContext } from "./status-line/segments";
|
|
24
24
|
import { getSeparator } from "./status-line/separators";
|
|
25
25
|
import { calculateTokensPerSecond } from "./status-line/token-rate";
|
|
26
26
|
|
|
@@ -490,8 +490,9 @@ export class StatusLineComponent implements Component {
|
|
|
490
490
|
|
|
491
491
|
// Collect visible segments (preserving bg/fg metadata)
|
|
492
492
|
type SegPart = { content: string; bg: string; fg: string };
|
|
493
|
-
const collectParts = (segIds: readonly string[]): SegPart[] => {
|
|
493
|
+
const collectParts = (segIds: readonly string[]): { parts: SegPart[]; ids: string[] } => {
|
|
494
494
|
const parts: SegPart[] = [];
|
|
495
|
+
const ids: string[] = [];
|
|
495
496
|
for (const segId of segIds) {
|
|
496
497
|
const rendered = renderSegment(segId as any, ctx);
|
|
497
498
|
if (rendered.visible && rendered.content) {
|
|
@@ -500,13 +501,14 @@ export class StatusLineComponent implements Component {
|
|
|
500
501
|
bg: rendered.bg || defaultBg,
|
|
501
502
|
fg: rendered.fg || defaultFg,
|
|
502
503
|
});
|
|
504
|
+
ids.push(segId);
|
|
503
505
|
}
|
|
504
506
|
}
|
|
505
|
-
return parts;
|
|
507
|
+
return { parts, ids };
|
|
506
508
|
};
|
|
507
509
|
|
|
508
|
-
const leftParts = collectParts(effectiveSettings.leftSegments);
|
|
509
|
-
const rightParts = collectParts(effectiveSettings.rightSegments);
|
|
510
|
+
const { parts: leftParts, ids: leftIds } = collectParts(effectiveSettings.leftSegments);
|
|
511
|
+
const { parts: rightParts, ids: rightIds } = collectParts(effectiveSettings.rightSegments);
|
|
510
512
|
|
|
511
513
|
const runningBackgroundJobs = this.session.getAsyncJobSnapshot()?.running.length ?? 0;
|
|
512
514
|
if (runningBackgroundJobs > 0) {
|
|
@@ -534,6 +536,8 @@ export class StatusLineComponent implements Component {
|
|
|
534
536
|
const topFillWidth = Math.max(0, width);
|
|
535
537
|
const left = [...leftParts];
|
|
536
538
|
const right = [...rightParts];
|
|
539
|
+
const leftSegIds = [...leftIds];
|
|
540
|
+
const rightSegIds = [...rightIds];
|
|
537
541
|
|
|
538
542
|
const sepWidth = visibleWidth(separatorDef.left);
|
|
539
543
|
const capWidth = separatorDef.endCaps ? visibleWidth(separatorDef.endCaps.right) : 0;
|
|
@@ -541,23 +545,74 @@ export class StatusLineComponent implements Component {
|
|
|
541
545
|
const groupWidth = (parts: SegPart[]): number => {
|
|
542
546
|
if (parts.length === 0) return 0;
|
|
543
547
|
const partsWidth = parts.reduce((sum, p) => sum + visibleWidth(p.content), 0);
|
|
544
|
-
// Each segment gets 1 char padding on each side, separators between segments
|
|
545
548
|
const sepTotal = Math.max(0, parts.length - 1) * sepWidth;
|
|
546
549
|
return partsWidth + parts.length * 2 + sepTotal + capWidth;
|
|
547
550
|
};
|
|
548
551
|
|
|
549
552
|
let leftWidth = groupWidth(left);
|
|
550
553
|
let rightWidth = groupWidth(right);
|
|
551
|
-
const totalWidth = () => leftWidth + rightWidth
|
|
554
|
+
const totalWidth = () => leftWidth + rightWidth;
|
|
555
|
+
|
|
556
|
+
if (topFillWidth > 0 && totalWidth() > topFillWidth) {
|
|
557
|
+
const preset = getPreset(effectiveSettings.preset ?? "default");
|
|
558
|
+
const dropOrder = preset.dropOrder;
|
|
559
|
+
|
|
560
|
+
if (dropOrder) {
|
|
561
|
+
type SegRef = { group: "left" | "right"; part: SegPart; segId: string };
|
|
562
|
+
const segRefs: SegRef[] = [
|
|
563
|
+
...left.map((part, i) => ({ group: "left" as const, part, segId: leftSegIds[i] })),
|
|
564
|
+
...right.map((part, i) => ({ group: "right" as const, part, segId: rightSegIds[i] })),
|
|
565
|
+
];
|
|
566
|
+
|
|
567
|
+
const inOrder: SegRef[] = [];
|
|
568
|
+
const notInOrder: SegRef[] = [];
|
|
569
|
+
for (const ref of segRefs) {
|
|
570
|
+
const orderIdx = dropOrder.indexOf(ref.segId as any);
|
|
571
|
+
if (orderIdx === -1) notInOrder.push(ref);
|
|
572
|
+
else inOrder.push(ref);
|
|
573
|
+
}
|
|
574
|
+
inOrder.sort((a, b) => dropOrder.indexOf(a.segId as any) - dropOrder.indexOf(b.segId as any));
|
|
575
|
+
const dropQueue = [...notInOrder, ...inOrder];
|
|
576
|
+
|
|
577
|
+
for (const ref of dropQueue) {
|
|
578
|
+
if (totalWidth() <= topFillWidth) break;
|
|
579
|
+
|
|
580
|
+
const segDef = SEGMENTS[ref.segId as StatusLineSegmentId];
|
|
581
|
+
const group = ref.group === "left" ? left : right;
|
|
582
|
+
|
|
583
|
+
const currentIdx = group.indexOf(ref.part);
|
|
584
|
+
if (currentIdx === -1) continue;
|
|
585
|
+
|
|
586
|
+
if (segDef?.truncate) {
|
|
587
|
+
const currentContentWidth = visibleWidth(ref.part.content);
|
|
588
|
+
const deficit = totalWidth() - topFillWidth;
|
|
589
|
+
const targetWidth = Math.max(1, currentContentWidth - deficit);
|
|
590
|
+
const truncated = segDef.truncate(targetWidth, ctx);
|
|
591
|
+
if (truncated?.visible && truncated.content) {
|
|
592
|
+
group[currentIdx] = {
|
|
593
|
+
content: truncated.content,
|
|
594
|
+
bg: truncated.bg || ref.part.bg,
|
|
595
|
+
fg: truncated.fg || ref.part.fg,
|
|
596
|
+
};
|
|
597
|
+
if (ref.group === "left") leftWidth = groupWidth(left);
|
|
598
|
+
else rightWidth = groupWidth(right);
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
552
602
|
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
603
|
+
group.splice(currentIdx, 1);
|
|
604
|
+
if (ref.group === "left") leftWidth = groupWidth(left);
|
|
605
|
+
else rightWidth = groupWidth(right);
|
|
606
|
+
}
|
|
607
|
+
} else {
|
|
608
|
+
while (totalWidth() > topFillWidth && right.length > 0) {
|
|
609
|
+
right.pop();
|
|
610
|
+
rightWidth = groupWidth(right);
|
|
611
|
+
}
|
|
612
|
+
while (totalWidth() > topFillWidth && left.length > 0) {
|
|
613
|
+
left.pop();
|
|
614
|
+
leftWidth = groupWidth(left);
|
|
615
|
+
}
|
|
561
616
|
}
|
|
562
617
|
}
|
|
563
618
|
|
|
@@ -636,13 +691,16 @@ export class StatusLineComponent implements Component {
|
|
|
636
691
|
const rightGroup = renderGroup(right, "right");
|
|
637
692
|
if (!leftGroup && !rightGroup) return "";
|
|
638
693
|
|
|
639
|
-
if (topFillWidth === 0 || left.length === 0
|
|
694
|
+
if (topFillWidth === 0 || (left.length === 0 && right.length === 0)) {
|
|
640
695
|
return leftGroup + (leftGroup && rightGroup ? " " : "") + rightGroup;
|
|
641
696
|
}
|
|
642
697
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
const gapWidth = Math.max(
|
|
698
|
+
const actualLeftWidth = visibleWidth(leftGroup);
|
|
699
|
+
const actualRightWidth = visibleWidth(rightGroup);
|
|
700
|
+
const gapWidth = Math.max(0, topFillWidth - actualLeftWidth - actualRightWidth);
|
|
701
|
+
if (gapWidth === 0) {
|
|
702
|
+
return leftGroup + rightGroup;
|
|
703
|
+
}
|
|
646
704
|
const gapFill = theme.fg("border", theme.boxRound.horizontal.repeat(gapWidth));
|
|
647
705
|
return leftGroup + gapFill + rightGroup;
|
|
648
706
|
}
|
|
@@ -424,7 +424,13 @@ function detectAnomalies(
|
|
|
424
424
|
// Main generator
|
|
425
425
|
// ---------------------------------------------------------------------------
|
|
426
426
|
|
|
427
|
-
export
|
|
427
|
+
export type SfQueryFn = (soql: string, orgAlias?: string) => Promise<Record<string, unknown>[]>;
|
|
428
|
+
|
|
429
|
+
export async function generatePipelineReport(
|
|
430
|
+
options: PipelineReportOptions,
|
|
431
|
+
queryFn?: SfQueryFn,
|
|
432
|
+
): Promise<PipelineReportData> {
|
|
433
|
+
const query: SfQueryFn = queryFn ?? runSfQuery;
|
|
428
434
|
const {
|
|
429
435
|
userIds,
|
|
430
436
|
orgAlias,
|
|
@@ -501,15 +507,15 @@ export async function generatePipelineReport(options: PipelineReportOptions): Pr
|
|
|
501
507
|
|
|
502
508
|
// Three parallel queries first: combined OLI + renewals + FY booked
|
|
503
509
|
const [oliRecords, renewalRecords, fyBookedResult] = await Promise.all([
|
|
504
|
-
|
|
510
|
+
query(
|
|
505
511
|
`SELECT ${fields} FROM OpportunityLineItem WHERE ${oliTeamScope} AND ${combinedDateFilter} AND ${commonFilters}`,
|
|
506
512
|
orgAlias,
|
|
507
513
|
),
|
|
508
|
-
|
|
514
|
+
query(
|
|
509
515
|
`SELECT ${renewalFields} FROM Opportunity WHERE ${renewalWhere} ORDER BY True_ACV__c DESC NULLS LAST`,
|
|
510
516
|
orgAlias,
|
|
511
517
|
),
|
|
512
|
-
|
|
518
|
+
query(fyBookedQuery, orgAlias),
|
|
513
519
|
]);
|
|
514
520
|
const fyBookedTotal = (fyBookedResult[0]?.total as number) ?? 0;
|
|
515
521
|
|
|
@@ -548,11 +554,8 @@ export async function generatePipelineReport(options: PipelineReportOptions): Pr
|
|
|
548
554
|
const bookedWhere = `${oppTeamScope} AND ForecastCategoryName != 'Omitted' AND Amount > 0 AND IsWon = true AND CloseDate >= ${quarterStart} AND CloseDate <= ${quarterEnd}`;
|
|
549
555
|
|
|
550
556
|
const [fallbackNetNew, fallbackBooked] = await Promise.all([
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
`SELECT ${oppFields} FROM Opportunity WHERE ${bookedWhere} ORDER BY Amount DESC NULLS LAST`,
|
|
554
|
-
orgAlias,
|
|
555
|
-
),
|
|
557
|
+
query(`SELECT ${oppFields} FROM Opportunity WHERE ${oppWhere} ORDER BY Amount DESC NULLS LAST`, orgAlias),
|
|
558
|
+
query(`SELECT ${oppFields} FROM Opportunity WHERE ${bookedWhere} ORDER BY Amount DESC NULLS LAST`, orgAlias),
|
|
556
559
|
]);
|
|
557
560
|
|
|
558
561
|
function parseOppFallback(records: Record<string, unknown>[]): LineItemRecord[] {
|
|
@@ -602,7 +605,7 @@ export async function generatePipelineReport(options: PipelineReportOptions): Pr
|
|
|
602
605
|
const historyFields =
|
|
603
606
|
"OpportunityId, Opportunity.Name, Opportunity.Account.Name, Field, OldValue, NewValue, CreatedDate";
|
|
604
607
|
const historyWhere = `OpportunityId IN (${idList}) AND Field IN ('Amount','ForecastCategoryName','StageName') AND CreatedDate = LAST_N_DAYS:7`;
|
|
605
|
-
historyRecords = await
|
|
608
|
+
historyRecords = await query(
|
|
606
609
|
`SELECT ${historyFields} FROM OpportunityFieldHistory WHERE ${historyWhere} ORDER BY CreatedDate DESC LIMIT 50`,
|
|
607
610
|
orgAlias,
|
|
608
611
|
);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Generate a comprehensive F5 Distributed Cloud pipeline report for the current fiscal quarter.
|
|
2
|
+
|
|
3
|
+
<instruction>
|
|
4
|
+
Use this tool when the user asks for a "pipeline report", "forecast", "what's my pipeline", "how's the quarter", "show my deals", or any request for a structured view of the sales pipeline. It runs all the necessary queries automatically.
|
|
5
|
+
|
|
6
|
+
Do NOT use sf_query for pipeline reports — sf_pipeline_report handles the multi-query orchestration (net new, booked, renewals, anomaly detection, close distribution) in a single call.
|
|
7
|
+
|
|
8
|
+
Use sf_query for ad-hoc, one-off lookups: specific account checks, MEDDPICC qualification, case queries, or any query outside the pipeline reporting context.
|
|
9
|
+
|
|
10
|
+
Parameters:
|
|
11
|
+
**target_org**: optional — only needed when the default org is not the correct Salesforce instance.
|
|
12
|
+
|
|
13
|
+
What the report includes:
|
|
14
|
+
Booked (closed-won) this quarter by account, broken out by Platform (Distributed Cloud) and Point (Shape + DI).
|
|
15
|
+
Open net-new pipeline, grouped by territory and account, with forecast category (Commit / Best Case / Pipeline).
|
|
16
|
+
Open renewal pipeline (True ACV / Upsell ACV).
|
|
17
|
+
Forecast summary: Commit + Best Case + Pipeline totals for quota-eligible products.
|
|
18
|
+
Top deals by amount with owner, stage, close date, and next steps.
|
|
19
|
+
Close-date distribution: pipeline bucketed by month.
|
|
20
|
+
FY-to-date booked total vs quota (when quota is set in user profile).
|
|
21
|
+
Data quality anomalies: slipped close dates, stalled deals (no activity >30 days), missing territories, urgent renewals closing within 30 days, unclassified SKUs.
|
|
22
|
+
Recent pipeline changes (last 7 days) from OpportunityFieldHistory.
|
|
23
|
+
|
|
24
|
+
After the tool returns, present the report as-is. Do not re-query Salesforce to fill in gaps — the report already contains all available data. If the report shows zero pipeline, mention that the user may need to read `xcsh://salesforce?refresh=true` to refresh their team membership context.
|
|
25
|
+
</instruction>
|
|
@@ -3,7 +3,10 @@ Execute SOQL queries against Salesforce via sf CLI. Returns structured results a
|
|
|
3
3
|
<instruction>
|
|
4
4
|
Always provide a `description` parameter (2-4 words) summarizing the query's purpose — it appears in the output header. Examples: "forecast breakdown", "in-quarter pipeline", "closed-won deals", "open opportunities", "stalled deals", "renewal pipeline", "booked this quarter".
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
For structured pipeline reports, use **sf_pipeline_report** instead of sf_query. sf_pipeline_report runs multi-query orchestration (net new, booked, renewals, anomaly detection, close distribution) in one call.
|
|
7
|
+
Use sf_query for ad-hoc SOQL queries: specific account lookups, MEDDPICC data, case queries, or one-off investigations.
|
|
8
|
+
|
|
9
|
+
Use for ad-hoc data queries, account intelligence, and one-off investigations.
|
|
7
10
|
|
|
8
11
|
Common query templates (substitute {userId} from user profile — read `xcsh://user` to get identifiers.salesforceId):
|
|
9
12
|
|
|
@@ -106,7 +109,7 @@ For each deal, assess these 8 MEDDPICC elements from available SFDC data:
|
|
|
106
109
|
**D**ecision Criteria: Are evaluation criteria documented? Check Opportunity.NextStep, Description.
|
|
107
110
|
**D**ecision Process: Is the buying process mapped? Check stage progression timeline, paper process.
|
|
108
111
|
**P**aper Process: Are procurement steps known? Check Opportunity.Description for legal/procurement notes.
|
|
109
|
-
**
|
|
112
|
+
**I**dentify Pain: Is the business pain articulated? Check Opportunity.Description, discovery notes.
|
|
110
113
|
**C**hampion: Is there an internal advocate? Check Contact roles for 'Champion' or active engagement.
|
|
111
114
|
**C**ompetition: Are competitors identified? Check Opportunity.CompetitorName or description.
|
|
112
115
|
Score each element: Green (validated), Yellow (partially known), Red (unknown/missing).
|
package/src/sdk.ts
CHANGED
|
@@ -1906,6 +1906,30 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1906
1906
|
});
|
|
1907
1907
|
lastEmittedContext = { name: currentName, namespace: currentNamespace };
|
|
1908
1908
|
void session.refreshBaseSystemPrompt();
|
|
1909
|
+
// Role 3: background-validate credentials after context switch so the
|
|
1910
|
+
// LLM knows whether the new context is usable. Fire-and-forget — do
|
|
1911
|
+
// not block the context change notification.
|
|
1912
|
+
void (async () => {
|
|
1913
|
+
try {
|
|
1914
|
+
const { status: authStatus, latencyMs } = await service.instance.validateToken({
|
|
1915
|
+
timeoutMs: 5000,
|
|
1916
|
+
});
|
|
1917
|
+
const qualifier =
|
|
1918
|
+
authStatus === "connected"
|
|
1919
|
+
? `connected${latencyMs ? ` (${latencyMs}ms)` : ""}`
|
|
1920
|
+
: authStatus === "auth_error"
|
|
1921
|
+
? "credential error -- token may be invalid or expired"
|
|
1922
|
+
: "unreachable -- network error or tenant offline";
|
|
1923
|
+
void session.sendCustomMessage({
|
|
1924
|
+
customType: "context_validation_result",
|
|
1925
|
+
content: `[Auth status: ${authStatus}] Credentials for ${currentTenant}: ${qualifier}.`,
|
|
1926
|
+
display: true,
|
|
1927
|
+
attribution: "agent",
|
|
1928
|
+
});
|
|
1929
|
+
} catch {
|
|
1930
|
+
// Validation failed (e.g., no credentials configured) -- skip silently.
|
|
1931
|
+
}
|
|
1932
|
+
})();
|
|
1909
1933
|
} catch {
|
|
1910
1934
|
// ContextService.instance throws if not initialized; skip.
|
|
1911
1935
|
}
|
|
@@ -7,3 +7,43 @@ export function formatContextLabel(status: ContextStatus): string {
|
|
|
7
7
|
if (status.tokenHealth === "expiring" || status.tokenHealth === "expired") return `${base} ⚠`;
|
|
8
8
|
return base;
|
|
9
9
|
}
|
|
10
|
+
|
|
11
|
+
export function truncateContextLabel(status: ContextStatus, maxWidth: number): string | null {
|
|
12
|
+
if (!status.isConfigured) return null;
|
|
13
|
+
|
|
14
|
+
const left = status.activeContextTenant ?? status.activeContextName ?? "env";
|
|
15
|
+
const right = status.activeContextNamespace ?? "default";
|
|
16
|
+
const hasWarning = status.tokenHealth === "expiring" || status.tokenHealth === "expired";
|
|
17
|
+
const warningSuffix = hasWarning ? " ⚠" : "";
|
|
18
|
+
const warningLen = warningSuffix.length;
|
|
19
|
+
const available = maxWidth - warningLen;
|
|
20
|
+
|
|
21
|
+
if (available < 4) return null;
|
|
22
|
+
|
|
23
|
+
const full = `${left}:${right}`;
|
|
24
|
+
if (full.length <= available) return `${full}${warningSuffix}`;
|
|
25
|
+
|
|
26
|
+
// Tier 1: Truncate right side, keep full left
|
|
27
|
+
if (available >= left.length + 3) {
|
|
28
|
+
const rightMax = available - left.length - 1 - 1;
|
|
29
|
+
return `${left}:${right.slice(0, rightMax)}…${warningSuffix}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Tier 2: Abbreviate both sides
|
|
33
|
+
if (available >= 6) {
|
|
34
|
+
const contentBudget = available - 2;
|
|
35
|
+
const leftBudget = Math.max(2, Math.ceil(contentBudget / 2));
|
|
36
|
+
const rightBudget = contentBudget - leftBudget;
|
|
37
|
+
const abbrLeft = left.slice(0, leftBudget);
|
|
38
|
+
const abbrRight = rightBudget > 0 ? right.slice(0, rightBudget) : "";
|
|
39
|
+
return `${abbrLeft}:${abbrRight}…${warningSuffix}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Tier 3: Tenant abbreviation only
|
|
43
|
+
if (available >= 4) {
|
|
44
|
+
const leftBudget = available - 2;
|
|
45
|
+
return `${left.slice(0, leftBudget)}:…${warningSuffix}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ContextService, type TokenHealth } from "./f5xc-context";
|
|
2
|
-
import { formatContextLabel } from "./f5xc-context-display";
|
|
2
|
+
import { formatContextLabel, truncateContextLabel } from "./f5xc-context-display";
|
|
3
3
|
|
|
4
4
|
export interface RenderedSegment {
|
|
5
5
|
content: string;
|
|
@@ -21,3 +21,19 @@ export function renderF5XCContextSegment(): RenderedSegment {
|
|
|
21
21
|
return { content: "", visible: false };
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
|
+
|
|
25
|
+
export function truncateF5XCContextSegment(maxWidth: number): RenderedSegment | null {
|
|
26
|
+
try {
|
|
27
|
+
const service = ContextService.instance;
|
|
28
|
+
const status = service.getStatus();
|
|
29
|
+
|
|
30
|
+
if (!status.isConfigured) return null;
|
|
31
|
+
|
|
32
|
+
const truncated = truncateContextLabel(status, maxWidth);
|
|
33
|
+
if (truncated === null) return null;
|
|
34
|
+
|
|
35
|
+
return { content: truncated, visible: true, tokenHealth: status.tokenHealth };
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/tools/index.ts
CHANGED
|
@@ -54,6 +54,7 @@ import { ResolveTool } from "./resolve";
|
|
|
54
54
|
import { reportFindingTool } from "./review";
|
|
55
55
|
import { SearchToolBm25Tool } from "./search-tool-bm25";
|
|
56
56
|
import { SfOrgDisplayTool, SfQueryTool, SfSetupTool } from "./sf";
|
|
57
|
+
import { SfPipelineReportTool } from "./sf-pipeline-report";
|
|
57
58
|
import { loadSshTool } from "./ssh";
|
|
58
59
|
import { SubmitResultTool } from "./submit-result";
|
|
59
60
|
import { type TodoPhase, TodoWriteTool } from "./todo-write";
|
|
@@ -239,6 +240,7 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
|
|
|
239
240
|
sf_setup: SfSetupTool.createIf,
|
|
240
241
|
sf_query: SfQueryTool.createIf,
|
|
241
242
|
sf_org_display: SfOrgDisplayTool.createIf,
|
|
243
|
+
sf_pipeline_report: SfPipelineReportTool.createIf,
|
|
242
244
|
find: s => new FindTool(s),
|
|
243
245
|
grep: s => new GrepTool(s),
|
|
244
246
|
lsp: LspTool.createIf,
|
package/src/tools/renderers.ts
CHANGED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgentTool,
|
|
3
|
+
AgentToolContext,
|
|
4
|
+
AgentToolResult,
|
|
5
|
+
AgentToolUpdateCallback,
|
|
6
|
+
} from "@f5xc-salesdemos/pi-agent-core";
|
|
7
|
+
import { $which, prompt } from "@f5xc-salesdemos/pi-utils";
|
|
8
|
+
import { type Static, Type } from "@sinclair/typebox";
|
|
9
|
+
import { loadSalesforceContext } from "../internal-urls/salesforce-context";
|
|
10
|
+
import { loadProfile } from "../internal-urls/user-profile";
|
|
11
|
+
import { generatePipelineReport, type SfQueryFn } from "../pipeline-report/generator";
|
|
12
|
+
import { renderPipelineReport } from "../pipeline-report/renderer";
|
|
13
|
+
import type { PipelineReportData, PipelineReportOptions } from "../pipeline-report/types";
|
|
14
|
+
import sfPipelineReportDescription from "../prompts/tools/sf-pipeline-report.md" with { type: "text" };
|
|
15
|
+
import type { ToolSession } from ".";
|
|
16
|
+
import { makeExecApi, type SfErrorType, type SfToolDetails } from "./sf";
|
|
17
|
+
import { execSfJson, SfAuthError, SfNoDefaultOrgError, SfSessionExpiredError } from "./sf/exec";
|
|
18
|
+
import { ORG_ALIAS_PATTERN } from "./sf/types";
|
|
19
|
+
|
|
20
|
+
const sfPipelineReportSchema = Type.Object({
|
|
21
|
+
target_org: Type.Optional(Type.String({ description: "Org alias or username to run report against" })),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
type SfPipelineReportInput = Static<typeof sfPipelineReportSchema>;
|
|
25
|
+
|
|
26
|
+
type SfResult = AgentToolResult<SfToolDetails> & { isError?: boolean };
|
|
27
|
+
|
|
28
|
+
function fiscalQuarterDates(): { start: string; end: string } {
|
|
29
|
+
const now = new Date();
|
|
30
|
+
const m = now.getMonth();
|
|
31
|
+
const y = now.getFullYear();
|
|
32
|
+
|
|
33
|
+
let start: Date;
|
|
34
|
+
let end: Date;
|
|
35
|
+
|
|
36
|
+
if (m >= 10) {
|
|
37
|
+
start = new Date(y, 10, 1);
|
|
38
|
+
end = new Date(y + 1, 1, 0);
|
|
39
|
+
} else if (m === 0) {
|
|
40
|
+
start = new Date(y - 1, 10, 1);
|
|
41
|
+
end = new Date(y, 1, 0);
|
|
42
|
+
} else if (m <= 3) {
|
|
43
|
+
start = new Date(y, 1, 1);
|
|
44
|
+
end = new Date(y, 4, 0);
|
|
45
|
+
} else if (m <= 6) {
|
|
46
|
+
start = new Date(y, 4, 1);
|
|
47
|
+
end = new Date(y, 7, 0);
|
|
48
|
+
} else {
|
|
49
|
+
start = new Date(y, 7, 1);
|
|
50
|
+
end = new Date(y, 10, 0);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const fmt = (d: Date) => d.toISOString().split("T")[0]!;
|
|
54
|
+
return { start: fmt(start), end: fmt(end) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function buildQueryFn(cwd: string, orgAlias?: string): SfQueryFn {
|
|
58
|
+
const api = makeExecApi(cwd);
|
|
59
|
+
return async (soql: string, queryOrgAlias?: string): Promise<Record<string, unknown>[]> => {
|
|
60
|
+
const org = queryOrgAlias ?? orgAlias;
|
|
61
|
+
const args = ["data", "query", "--query", soql];
|
|
62
|
+
if (org) args.push("--target-org", org);
|
|
63
|
+
try {
|
|
64
|
+
const result = await execSfJson(api, args, undefined, soql);
|
|
65
|
+
const data = result.result as { records?: Record<string, unknown>[] };
|
|
66
|
+
return data.records ?? [];
|
|
67
|
+
} catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function detectErrorType(err: unknown): SfErrorType {
|
|
74
|
+
if (err instanceof SfAuthError) return "auth_required";
|
|
75
|
+
if (err instanceof SfSessionExpiredError) return "session_expired";
|
|
76
|
+
if (err instanceof SfNoDefaultOrgError) return "no_default_org";
|
|
77
|
+
return "exec_error";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class SfPipelineReportTool implements AgentTool<typeof sfPipelineReportSchema, SfToolDetails> {
|
|
81
|
+
readonly name = "sf_pipeline_report";
|
|
82
|
+
readonly label = "Salesforce Pipeline Report";
|
|
83
|
+
readonly description = prompt.render(sfPipelineReportDescription);
|
|
84
|
+
readonly parameters = sfPipelineReportSchema;
|
|
85
|
+
|
|
86
|
+
constructor(readonly session: ToolSession) {}
|
|
87
|
+
|
|
88
|
+
static createIf(session: ToolSession): SfPipelineReportTool | null {
|
|
89
|
+
if (!$which("sf")) return null;
|
|
90
|
+
return new SfPipelineReportTool(session);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async execute(
|
|
94
|
+
_toolCallId: string,
|
|
95
|
+
params: SfPipelineReportInput,
|
|
96
|
+
_signal?: AbortSignal,
|
|
97
|
+
_onUpdate?: AgentToolUpdateCallback<SfToolDetails>,
|
|
98
|
+
_context?: AgentToolContext,
|
|
99
|
+
): Promise<SfResult> {
|
|
100
|
+
const base: SfToolDetails = { tool: "sf_pipeline_report" };
|
|
101
|
+
|
|
102
|
+
if (params.target_org && !ORG_ALIAS_PATTERN.test(params.target_org)) {
|
|
103
|
+
return {
|
|
104
|
+
content: [
|
|
105
|
+
{
|
|
106
|
+
type: "text",
|
|
107
|
+
text: `Error: invalid org alias "${params.target_org}". Only alphanumeric characters, dots, underscores, hyphens, and @ are allowed.`,
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
isError: true,
|
|
111
|
+
details: { ...base, errorType: "exec_error" },
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const profile = await loadProfile();
|
|
116
|
+
const sfContext = await loadSalesforceContext();
|
|
117
|
+
|
|
118
|
+
const userId = profile.identifiers?.salesforceId;
|
|
119
|
+
if (!userId) {
|
|
120
|
+
return {
|
|
121
|
+
content: [
|
|
122
|
+
{
|
|
123
|
+
type: "text",
|
|
124
|
+
text: "No Salesforce user ID found. Run sf_setup with action 'status' first, then read `xcsh://user` to confirm your salesforceId is set.",
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
isError: true,
|
|
128
|
+
details: { ...base, errorType: "auth_required" },
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const partnerId = profile.partner?.id;
|
|
133
|
+
const userIds = partnerId ? [userId, partnerId] : [userId];
|
|
134
|
+
const { start, end } = fiscalQuarterDates();
|
|
135
|
+
|
|
136
|
+
const staleDate = new Date();
|
|
137
|
+
staleDate.setFullYear(staleDate.getFullYear() - 1);
|
|
138
|
+
const staleCutoff = staleDate.toISOString().split("T")[0]!;
|
|
139
|
+
|
|
140
|
+
const partnerName = profile.partner?.name;
|
|
141
|
+
const selfName = [profile.givenName, profile.familyName].filter(Boolean).join(" ").trim();
|
|
142
|
+
const teamMemberNames = partnerName && selfName ? [selfName, partnerName] : selfName ? [selfName] : undefined;
|
|
143
|
+
|
|
144
|
+
const orgAlias = params.target_org ?? sfContext?.orgAlias;
|
|
145
|
+
|
|
146
|
+
const options: PipelineReportOptions = {
|
|
147
|
+
userIds,
|
|
148
|
+
orgAlias,
|
|
149
|
+
quarterStart: start,
|
|
150
|
+
quarterEnd: end,
|
|
151
|
+
staleCutoff,
|
|
152
|
+
confirmedTerritories: profile.territories ?? sfContext?.confirmedTerritories ?? sfContext?.territories,
|
|
153
|
+
teamMemberNames,
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
const queryFn = buildQueryFn(this.session.cwd, orgAlias);
|
|
158
|
+
const data: PipelineReportData = await generatePipelineReport(options, queryFn);
|
|
159
|
+
const report = renderPipelineReport(data, sfContext?.instanceUrl ?? "");
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
content: [{ type: "text", text: report }],
|
|
163
|
+
details: { ...base, pipelineReport: data },
|
|
164
|
+
};
|
|
165
|
+
} catch (err) {
|
|
166
|
+
const errorType = detectErrorType(err);
|
|
167
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
168
|
+
return {
|
|
169
|
+
content: [{ type: "text", text: message }],
|
|
170
|
+
isError: true,
|
|
171
|
+
details: { ...base, errorType },
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
package/src/tools/sf-renderer.ts
CHANGED
|
@@ -124,6 +124,11 @@ export const sfToolRenderer = {
|
|
|
124
124
|
const text = renderStatusLine({ icon: "pending", title: TOOL_TITLE, description }, uiTheme);
|
|
125
125
|
return new Text(text, 0, 0);
|
|
126
126
|
}
|
|
127
|
+
if (args.action === undefined && args.query === undefined) {
|
|
128
|
+
// Pipeline report — no action or query args
|
|
129
|
+
const text = renderStatusLine({ icon: "pending", title: TOOL_TITLE, description: "pipeline report" }, uiTheme);
|
|
130
|
+
return new Text(text, 0, 0);
|
|
131
|
+
}
|
|
127
132
|
const action = args.action ?? "org";
|
|
128
133
|
const text = renderStatusLine(
|
|
129
134
|
{
|
|
@@ -244,6 +249,47 @@ export const sfToolRenderer = {
|
|
|
244
249
|
const text = result.content?.find(c => c.type === "text")?.text ?? "";
|
|
245
250
|
addSection(sections, "Result", [uiTheme.fg("toolOutput", text)], uiTheme);
|
|
246
251
|
}
|
|
252
|
+
} else if (tool === "sf_pipeline_report") {
|
|
253
|
+
description = "pipeline report";
|
|
254
|
+
const report = details?.pipelineReport;
|
|
255
|
+
if (report) {
|
|
256
|
+
const acctCount =
|
|
257
|
+
report.netNew.accounts.length + report.booked.accounts.length + report.renewals.accounts.length;
|
|
258
|
+
meta.push(uiTheme.fg("dim", `${report.lineItemCount} items`));
|
|
259
|
+
meta.push(uiTheme.fg("dim", `${acctCount} accounts`));
|
|
260
|
+
if (report.anomalies.length > 0) {
|
|
261
|
+
meta.push(uiTheme.fg("warning", `${report.anomalies.length} anomalies`));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const fc = report.forecast;
|
|
265
|
+
const fmtK = (v: number) =>
|
|
266
|
+
v >= 1_000_000
|
|
267
|
+
? `$${(v / 1_000_000).toFixed(1)}M`
|
|
268
|
+
: v >= 1_000
|
|
269
|
+
? `$${(v / 1_000).toFixed(0)}K`
|
|
270
|
+
: `$${v.toFixed(0)}`;
|
|
271
|
+
const summaryLines = [
|
|
272
|
+
` ${uiTheme.fg("toolTitle", "Commit".padEnd(12))}${uiTheme.fg("toolOutput", fmtK(fc.commit))}`,
|
|
273
|
+
` ${uiTheme.fg("toolTitle", "Best Case".padEnd(12))}${uiTheme.fg("toolOutput", fmtK(fc.bestCase))}`,
|
|
274
|
+
` ${uiTheme.fg("toolTitle", "Pipeline".padEnd(12))}${uiTheme.fg("toolOutput", fmtK(fc.pipeline))}`,
|
|
275
|
+
];
|
|
276
|
+
addSection(sections, "Forecast", summaryLines, uiTheme);
|
|
277
|
+
|
|
278
|
+
const text = result.content?.find(c => c.type === "text")?.text ?? "";
|
|
279
|
+
const reportLines = text.split("\n").map(line => replaceTabs(uiTheme.fg("toolOutput", line)));
|
|
280
|
+
addSection(sections, "Report", reportLines, uiTheme);
|
|
281
|
+
|
|
282
|
+
if (report.anomalies.length > 0) {
|
|
283
|
+
const anomalyLines = report.anomalies.map(a => {
|
|
284
|
+
const icon = a.severity === "warning" ? "[WARN]" : a.severity === "error" ? "[ERR]" : "[INFO]";
|
|
285
|
+
return uiTheme.fg(a.severity === "info" ? "muted" : "warning", ` ${icon} ${a.message}`);
|
|
286
|
+
});
|
|
287
|
+
addSection(sections, "Anomalies", anomalyLines, uiTheme);
|
|
288
|
+
}
|
|
289
|
+
} else {
|
|
290
|
+
const text = result.content?.find(c => c.type === "text")?.text ?? "";
|
|
291
|
+
addSection(sections, "Result", [uiTheme.fg("toolOutput", text)], uiTheme);
|
|
292
|
+
}
|
|
247
293
|
} else {
|
|
248
294
|
const text = result.content?.find(c => c.type === "text")?.text ?? "";
|
|
249
295
|
addSection(sections, "Result", [uiTheme.fg("toolOutput", text)], uiTheme);
|
package/src/tools/sf.ts
CHANGED
|
@@ -24,7 +24,7 @@ import { deriveQueryLabel, formatOrgDetail, formatOrgTable, formatQueryResults }
|
|
|
24
24
|
import type { SfOrg, SfQueryResult, SfRawResult } from "./sf/types";
|
|
25
25
|
import { ORG_ALIAS_PATTERN } from "./sf/types";
|
|
26
26
|
|
|
27
|
-
function makeExecApi(cwd: string): SfExecApi {
|
|
27
|
+
export function makeExecApi(cwd: string): SfExecApi {
|
|
28
28
|
return {
|
|
29
29
|
async exec(command: string, args: string[], _options?: { signal?: AbortSignal }): Promise<SfRawResult> {
|
|
30
30
|
// Never pass signal to Bun.spawn and never pre-check signal.aborted.
|
|
@@ -96,11 +96,12 @@ type SfOrgDisplayInput = Static<typeof sfOrgDisplaySchema>;
|
|
|
96
96
|
export type SfErrorType = "auth_required" | "session_expired" | "no_default_org" | "invalid_query" | "exec_error";
|
|
97
97
|
|
|
98
98
|
export interface SfToolDetails {
|
|
99
|
-
tool: "sf_setup" | "sf_query" | "sf_org_display";
|
|
99
|
+
tool: "sf_setup" | "sf_query" | "sf_org_display" | "sf_pipeline_report";
|
|
100
100
|
action?: string;
|
|
101
101
|
orgs?: SfOrg[];
|
|
102
102
|
queryResult?: SfQueryResult;
|
|
103
103
|
queryDescription?: string;
|
|
104
|
+
pipelineReport?: import("../pipeline-report/types").PipelineReportData;
|
|
104
105
|
errorType?: SfErrorType;
|
|
105
106
|
}
|
|
106
107
|
|