@kal-elsam/kairo-runtime 0.9.0 → 0.11.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 (33) hide show
  1. package/README.md +14 -8
  2. package/package.json +1 -1
  3. package/scripts/cockpit-smoke.mjs +3 -3
  4. package/scripts/ux-prototype-tty.mjs +9 -0
  5. package/src/global/control-plane-proposals.js +2 -1
  6. package/src/global/control-plane-snapshot.js +1 -1
  7. package/src/global/ink/brand/wordmark.js +50 -0
  8. package/src/global/ink/cockpit/primitives.js +72 -50
  9. package/src/global/ink/cockpit-changes.js +2 -2
  10. package/src/global/ink/cockpit-control-center.js +12 -64
  11. package/src/global/ink/cockpit-controller.js +41 -5
  12. package/src/global/ink/cockpit-enter.js +1 -0
  13. package/src/global/ink/cockpit-models.js +14 -7
  14. package/src/global/ink/cockpit-palette.js +18 -7
  15. package/src/global/ink/cockpit-recovery.js +9 -6
  16. package/src/global/ink/cockpit-usage.js +111 -0
  17. package/src/global/ink/cockpit-views.js +70 -97
  18. package/src/global/ink/orchestrator-app.js +44 -4
  19. package/src/global/ink/setup-app.js +55 -72
  20. package/src/global/ink/setup-state.js +16 -0
  21. package/src/global/ink/theme.js +40 -8
  22. package/src/global/ink/ux/live-activity.js +191 -0
  23. package/src/global/ink/ux/live-alerts.js +156 -0
  24. package/src/global/ink/ux/live-governance.js +191 -0
  25. package/src/global/ink/ux/live-orchestration.js +185 -0
  26. package/src/global/ink/ux/live-overview.js +175 -0
  27. package/src/global/ink/ux/live-settings.js +186 -0
  28. package/src/global/ink/ux/live-setup.js +160 -0
  29. package/src/global/ink/ux/live-usage.js +49 -0
  30. package/src/global/ink/ux/semantic.js +101 -0
  31. package/src/global/ink/ux/task-flow-app.js +85 -0
  32. package/src/global/ink/ux/task-flow.js +173 -0
  33. package/src/global/orchestrator.js +37 -20
@@ -1,7 +1,7 @@
1
1
  import React, { useEffect, useState } from "react";
2
2
  import { Box, Text, useApp, useInput } from "ink";
3
3
  import { stdout as output } from "node:process";
4
- import { BRAND, WIZARD_COPY } from "../brand/index.js";
4
+ import { BRAND } from "../brand/index.js";
5
5
  import { DEFAULT_COMPONENT_IDS, describeComponentCatalog } from "../component-registry.js";
6
6
  import { GLOBAL_AGENT_IDS, detectInstalledAdapters, listAdapters } from "../registry.js";
7
7
  import { buildSetupPreview, resolveComponentSelection } from "../clack/setup-preview.js";
@@ -9,72 +9,57 @@ import {
9
9
  SETUP_STEPS,
10
10
  buildAgentOptions,
11
11
  buildComponentOptions,
12
- formatInkDetectPanel,
13
12
  formatInkHeaderLines,
14
- formatInkPreviewLines,
15
- formatInkSelectList,
16
13
  formatInkSplashLines,
17
14
  INITIAL_SETUP_STEP,
18
15
  shouldStartPreviewLoad,
19
16
  shouldUseCompactSplashLogo,
17
+ setupLineKey,
20
18
  toggleComponentSelection,
21
19
  toggleSelection,
22
20
  transitionFromSplash
23
21
  } from "./setup-state.js";
24
- import { COCKPIT_COLORS } from "./theme.js";
25
- import { CockpitPanel } from "./cockpit/primitives.js";
22
+ import { COCKPIT_COLORS, resolveInkColor } from "./theme.js";
23
+ import { useTerminalSize } from "./use-terminal-size.js";
24
+ import { resolveTerminalCapabilities } from "./terminal-capabilities.js";
25
+ import { SemanticSetupPanel } from "./ux/live-setup.js";
26
26
 
27
- const INK_COLORS = {
28
- accent: COCKPIT_COLORS.primary,
29
- success: COCKPIT_COLORS.success,
30
- warning: COCKPIT_COLORS.warning,
31
- danger: COCKPIT_COLORS.danger,
32
- muted: COCKPIT_COLORS.muted
33
- };
34
-
35
- function Header() {
27
+ export function SetupHeader({ colorEnabled = true }) {
36
28
  const lines = formatInkHeaderLines();
37
29
  return React.createElement(Box, { flexDirection: "column", marginBottom: 1 },
38
- React.createElement(Text, { bold: true, color: INK_COLORS.accent }, `╭─ ${lines[0]}`),
39
- React.createElement(Text, { color: COCKPIT_COLORS.secondary }, lines[1]),
30
+ React.createElement(Text, {
31
+ bold: true,
32
+ color: resolveInkColor(colorEnabled, COCKPIT_COLORS.primary)
33
+ }, `╭─ ${lines[0]}`),
34
+ React.createElement(Text, {
35
+ color: resolveInkColor(colorEnabled, COCKPIT_COLORS.secondary)
36
+ }, lines[1]),
40
37
  React.createElement(Text, { dimColor: true }, lines[2])
41
38
  );
42
39
  }
43
40
 
44
- function Panel({ title, children }) {
45
- return React.createElement(CockpitPanel, {
46
- title,
47
- focused: true,
48
- width: "100%"
49
- }, children);
50
- }
51
-
52
- function Footer({ children }) {
53
- return React.createElement(Text, { dimColor: true }, children);
54
- }
55
-
56
- function Splash({ compact, onboarding = false }) {
41
+ export function SetupSplash({ compact, onboarding = false, colorEnabled = true }) {
57
42
  const lines = formatInkSplashLines({ compact, onboarding });
58
43
  const logoLineCount = compact ? BRAND.compactLogo.length : BRAND.asciiLogo.length;
44
+ const accent = resolveInkColor(colorEnabled, COCKPIT_COLORS.primary);
45
+ const muted = resolveInkColor(colorEnabled, COCKPIT_COLORS.muted);
59
46
 
60
47
  return React.createElement(Box, { flexDirection: "column", marginBottom: 1 },
61
48
  lines.map((line, index) => {
49
+ const key = setupLineKey(index, line);
62
50
  if (index < logoLineCount) {
63
- return React.createElement(Text, { key: `logo-${index}`, bold: true, color: INK_COLORS.accent }, line);
51
+ return React.createElement(Text, { key, bold: true, color: accent }, line);
64
52
  }
65
53
  if (line === BRAND.name) {
66
- return React.createElement(Text, { key: `line-${index}`, bold: true, color: INK_COLORS.accent }, line);
54
+ return React.createElement(Text, { key, bold: true, color: accent }, line);
67
55
  }
68
56
  if (line === BRAND.tagline) {
69
- return React.createElement(Text, { key: `line-${index}`, color: INK_COLORS.muted }, line);
57
+ return React.createElement(Text, { key, color: muted }, line);
70
58
  }
71
59
  if (line === BRAND.splashHint || line.includes("Esc to exit") || line.includes("Press Enter")) {
72
- return React.createElement(Text, { key: `line-${index}`, dimColor: true }, line);
73
- }
74
- if (line === "") {
75
- return React.createElement(Text, { key: `line-${index}` }, "");
60
+ return React.createElement(Text, { key, dimColor: true }, line);
76
61
  }
77
- return React.createElement(Text, { key: `line-${index}` }, line);
62
+ return React.createElement(Text, { key }, line);
78
63
  })
79
64
  );
80
65
  }
@@ -90,6 +75,12 @@ export function SetupApp({
90
75
  onComplete
91
76
  }) {
92
77
  const { exit } = useApp();
78
+ const { columns, rows, layoutMode } = useTerminalSize({
79
+ initialColumns: output.columns ?? 80,
80
+ initialRows: output.rows ?? 24
81
+ });
82
+ const caps = resolveTerminalCapabilities({ columns, rows, isTTY: true });
83
+ const colorEnabled = caps.color;
93
84
  const adapters = listAdapters();
94
85
  const detected = detectInstalledAdapters({ homeDir });
95
86
  const componentCatalog = describeComponentCatalog({ workspaceRoot });
@@ -98,7 +89,7 @@ export function SetupApp({
98
89
  const defaultAgents = detected.length > 0 ? detected : [...GLOBAL_AGENT_IDS];
99
90
 
100
91
  const [step, setStep] = useState(INITIAL_SETUP_STEP);
101
- const useCompactSplash = shouldUseCompactSplashLogo(output.columns);
92
+ const useCompactSplash = shouldUseCompactSplashLogo(columns);
102
93
  const [activeIndex, setActiveIndex] = useState(0);
103
94
  const [selectedAgents, setSelectedAgents] = useState(defaultAgents);
104
95
  const [selectedComponents, setSelectedComponents] = useState([...DEFAULT_COMPONENT_IDS]);
@@ -238,42 +229,34 @@ export function SetupApp({
238
229
  }
239
230
  });
240
231
 
241
- const detectPanel = formatInkDetectPanel({ adapters, detected });
242
-
243
232
  return React.createElement(Box, { flexDirection: "column" },
244
- step === SETUP_STEPS.SPLASH && React.createElement(Splash, {
233
+ step === SETUP_STEPS.SPLASH && React.createElement(SetupSplash, {
245
234
  compact: useCompactSplash,
246
- onboarding
235
+ onboarding,
236
+ colorEnabled
247
237
  }),
248
- step !== SETUP_STEPS.SPLASH && React.createElement(Header),
249
- step === SETUP_STEPS.DETECT && React.createElement(Panel, { title: WIZARD_COPY.detectTitle },
250
- detectPanel.split("\n")
251
- .map((line) => React.createElement(Text, { key: line }, line))
252
- ),
253
- step === SETUP_STEPS.AGENTS && React.createElement(Panel, { title: WIZARD_COPY.agentsPrompt },
254
- formatInkSelectList({ options: agentOptions, selected: selectedAgents, activeIndex })
255
- .map((line) => React.createElement(Text, { key: line }, line))
256
- ),
257
- step === SETUP_STEPS.COMPONENTS && React.createElement(Panel, { title: WIZARD_COPY.componentsPrompt },
258
- formatInkSelectList({ options: componentOptions, selected: selectedComponents, activeIndex })
259
- .map((line) => React.createElement(Text, { key: line }, line))
238
+ step === SETUP_STEPS.SPLASH && React.createElement(Text, { dimColor: true },
239
+ `${BRAND.splashHint} · Esc cancel`
260
240
  ),
261
- step === SETUP_STEPS.PREVIEW && React.createElement(Panel, { title: WIZARD_COPY.previewTitle },
262
- previewLoading && React.createElement(Text, { color: INK_COLORS.warning }, "Building preview…"),
263
- previewError && React.createElement(Text, { color: INK_COLORS.danger }, previewError),
264
- preview && formatInkPreviewLines({ preview, componentCatalog })
265
- .map((line) => React.createElement(Text, { key: line }, line))
266
- ),
267
- step === SETUP_STEPS.CONFIRM && React.createElement(Panel, { title: "Confirm" },
268
- React.createElement(Text, null, dryRun ? WIZARD_COPY.confirmDryRun : WIZARD_COPY.confirmApply)
269
- ),
270
- React.createElement(Footer, null,
271
- step === SETUP_STEPS.SPLASH && `${BRAND.splashHint} · Esc cancel`,
272
- step === SETUP_STEPS.DETECT && "Enter continue · Esc cancel",
273
- step === SETUP_STEPS.AGENTS && "↑↓ move · Space toggle · Enter continue · Esc cancel",
274
- step === SETUP_STEPS.COMPONENTS && "↑↓ move · Space toggle · Enter continue · Esc cancel",
275
- step === SETUP_STEPS.PREVIEW && preview && !previewLoading && "Enter continue · Esc cancel",
276
- step === SETUP_STEPS.CONFIRM && "Y apply · N cancel · Esc cancel"
277
- )
241
+ step !== SETUP_STEPS.SPLASH && React.createElement(SetupHeader, { colorEnabled }),
242
+ step !== SETUP_STEPS.SPLASH && React.createElement(SemanticSetupPanel, {
243
+ step,
244
+ activeIndex,
245
+ agentOptions,
246
+ componentOptions,
247
+ componentCatalog,
248
+ selectedAgents,
249
+ selectedComponents,
250
+ adapters,
251
+ detected,
252
+ preview,
253
+ previewLoading,
254
+ previewError,
255
+ dryRun,
256
+ layoutMode,
257
+ colorEnabled,
258
+ unicode: caps.unicode,
259
+ columns
260
+ })
278
261
  );
279
262
  }
@@ -178,6 +178,22 @@ export function formatInkPreviewLines({ preview, componentCatalog }) {
178
178
  return lines;
179
179
  }
180
180
 
181
+ /** Cap preview lines for the active layout; keep remaining-count when truncated. */
182
+ export function setupPreviewLineLimit(layoutMode = "compact") {
183
+ if (layoutMode === "wide") return 12;
184
+ if (layoutMode === "minimal") return 5;
185
+ return 8;
186
+ }
187
+
188
+ export function windowSetupLines(lines = [], limit = 8) {
189
+ if (lines.length <= limit) return lines;
190
+ return [...lines.slice(0, limit), `… ${lines.length - limit} more`];
191
+ }
192
+
193
+ export function setupLineKey(index, line) {
194
+ return `${index}-${line}`;
195
+ }
196
+
181
197
  export function formatInkSuccessLines(result, { dryRun = false, cliName = PREFERRED_CLI } = {}) {
182
198
  const agentLine = result.agents.map((id) => getAgentLabel(id)).join(", ");
183
199
  const componentLine = result.components.length > 0
@@ -1,15 +1,20 @@
1
1
  /**
2
- * Deep-space cockpit theme. Status always has a text label — never color alone.
2
+ * Kairo Cockpit theme amber brand, ice interactive, semantic status.
3
+ * Status always has a text label — never color alone.
3
4
  */
4
5
 
5
6
  export const COCKPIT_COLORS = {
6
- primary: "cyan",
7
- secondary: "magenta",
8
- success: "green",
9
- warning: "yellow",
10
- danger: "red",
7
+ brand: "#E8A017",
8
+ interactive: "#7EC8E8",
9
+ success: "#3DDC97",
10
+ warning: "#F0C674",
11
+ danger: "#FF6B6B",
11
12
  muted: "gray",
12
- border: "cyan"
13
+ /** @deprecated alias — prefer interactive */
14
+ primary: "#7EC8E8",
15
+ /** @deprecated alias — prefer brand */
16
+ secondary: "#E8A017",
17
+ border: "#7EC8E8"
13
18
  };
14
19
 
15
20
  export const STATUS_LABELS = {
@@ -69,10 +74,37 @@ export function statusColor(kind, { colorEnabled = true } = {}) {
69
74
  case "muted":
70
75
  return COCKPIT_COLORS.muted;
71
76
  default:
72
- return COCKPIT_COLORS.primary;
77
+ return COCKPIT_COLORS.interactive;
73
78
  }
74
79
  }
75
80
 
81
+ /** Ink `color` / `borderColor` prop — undefined when color is disabled. */
82
+ export function resolveInkColor(colorEnabled, color) {
83
+ return colorEnabled ? color : undefined;
84
+ }
85
+
86
+ /**
87
+ * Detect ANSI color SGR in a string.
88
+ * Bold (1), dim (2), and reset (0) alone are not color.
89
+ */
90
+ export function hasColorSgr(text) {
91
+ const re = /\u001b\[([0-9;]*)m/g;
92
+ let match;
93
+ while ((match = re.exec(String(text ?? ""))) !== null) {
94
+ const params = match[1].length === 0
95
+ ? []
96
+ : match[1].split(";").map((part) => Number(part));
97
+ for (let i = 0; i < params.length; i += 1) {
98
+ const code = params[i];
99
+ if (!Number.isFinite(code)) continue;
100
+ if (code === 38 || code === 48) return true;
101
+ if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) return true;
102
+ if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) return true;
103
+ }
104
+ }
105
+ return false;
106
+ }
107
+
76
108
  export function formatStatusBadge(kind, label = STATUS_LABELS[kind] ?? String(kind)) {
77
109
  return label;
78
110
  }
@@ -0,0 +1,191 @@
1
+ /** Live semantic Activity/Recovery. Callout=status · Confirm=action · footer=keys · snapshots=focus. */
2
+ import React from "react";
3
+ import { Box, Text } from "ink";
4
+ import { formatConfirmPath } from "../cockpit-path-label.js";
5
+ import {
6
+ RECOVERY_PHASE, listRecoverySnapshots, formatWhen, formatResult, shortName
7
+ } from "../cockpit-recovery.js";
8
+ import { LAYOUT_MODES } from "../layout.js";
9
+ import { ActionList, Callout, Confirm, Details, SectionLabel, ViewTitle } from "./semantic.js";
10
+ import { detailsPathLimit } from "./live-governance.js";
11
+
12
+ export function activityContentLimits(layoutMode = LAYOUT_MODES.COMPACT) {
13
+ return layoutMode === LAYOUT_MODES.WIDE
14
+ ? { events: 4, snapshots: 8 }
15
+ : { events: 3, snapshots: 3 };
16
+ }
17
+
18
+ export function activitySnapshotLimit(layoutMode = LAYOUT_MODES.COMPACT) {
19
+ return activityContentLimits(layoutMode).snapshots;
20
+ }
21
+
22
+ /** Visible window over full list; keeps `index` focused without truncating navigation. */
23
+ export function windowSlice(items = [], index = 0, limit = 3) {
24
+ const total = items.length;
25
+ if (total === 0) return { items: [], selectedIndex: -1, start: 0 };
26
+ const size = Math.max(1, Math.min(limit, total));
27
+ const safeIndex = Math.min(Math.max(0, index), total - 1);
28
+ const start = Math.min(Math.max(0, safeIndex - Math.floor((size - 1) / 2)), total - size);
29
+ return { items: items.slice(start, start + size), selectedIndex: safeIndex - start, start };
30
+ }
31
+
32
+ function collectRecentItems(snapshot, dashboard, eventLimit) {
33
+ const items = [];
34
+ for (const event of (snapshot?.history?.events ?? []).slice(0, 3)) {
35
+ items.push({
36
+ id: `e-${items.length}`,
37
+ label: `${formatWhen(event.timestamp)} · ${event.command ?? event.type ?? "event"} · ${formatResult(event.action)}`
38
+ });
39
+ }
40
+ for (const run of (dashboard?.recentRuns ?? []).slice(0, 2)) {
41
+ items.push({
42
+ id: `r-${items.length}`,
43
+ label: `${formatWhen(run.updatedAt ?? run.endedAt ?? run.startedAt)} · ${run.agentId ?? "agent"} · ${formatResult(run.state)}`
44
+ });
45
+ }
46
+ return items.slice(0, eventLimit);
47
+ }
48
+
49
+ function phaseTone(phase) {
50
+ if (phase === RECOVERY_PHASE.FAILED) return "danger";
51
+ if (phase === RECOVERY_PHASE.COMPLETED) return "ready";
52
+ if (phase === RECOVERY_PHASE.CONFIRMING || phase === RECOVERY_PHASE.PREVIEWING || phase === RECOVERY_PHASE.APPLYING) {
53
+ return "warn";
54
+ }
55
+ return "info";
56
+ }
57
+
58
+ function phaseTitle(phase, snapCount, eventCount) {
59
+ if (phase === RECOVERY_PHASE.PREVIEWING) return "Previewing restore";
60
+ if (phase === RECOVERY_PHASE.CONFIRMING) return "Confirm restore";
61
+ if (phase === RECOVERY_PHASE.APPLYING) return "Restoring";
62
+ if (phase === RECOVERY_PHASE.COMPLETED) return "Restore complete";
63
+ if (phase === RECOVERY_PHASE.FAILED) return "Restore failed";
64
+ return `${snapCount} snapshot(s) · ${eventCount} recent`;
65
+ }
66
+
67
+ function calloutBody(phase, recoveryAction) {
68
+ const msg = recoveryAction?.message ?? "";
69
+ if (/Y restore|N\/Esc/i.test(msg)) return "";
70
+ if (phase === RECOVERY_PHASE.FAILED) {
71
+ return msg || (recoveryAction?.error ? `Error · ${recoveryAction.error}` : "");
72
+ }
73
+ return phase === RECOVERY_PHASE.IDLE ? msg : "";
74
+ }
75
+
76
+ function buildDetailsLines(preview, receipt, showPaths, homeDir, pathLimit) {
77
+ if (!showPaths) return [];
78
+ const files = preview?.files ?? [];
79
+ if (files.length > 0) {
80
+ const lines = files.slice(0, pathLimit).map((f) => formatConfirmPath(f.displayPath ?? f.path, homeDir));
81
+ if (files.length > pathLimit) lines.push(`… ${files.length - pathLimit} more`);
82
+ return lines;
83
+ }
84
+ if (receipt?.safetyBackup) return ["Safety backup retained"];
85
+ if (receipt) return [`Result · ${receipt.action ?? "rollback"} · ${receipt.restored?.length ?? 0} restored`];
86
+ return ["No path evidence in this preview."];
87
+ }
88
+
89
+ export function adaptActivityModel({
90
+ snapshot = null, recoveryAction = null, dashboard = null, listIndex = 0,
91
+ homeDir = null, detailsOpen = false, layoutMode = LAYOUT_MODES.COMPACT
92
+ } = {}) {
93
+ const phase = recoveryAction?.phase ?? RECOVERY_PHASE.IDLE;
94
+ const limits = activityContentLimits(layoutMode);
95
+ const allSnapshots = listRecoverySnapshots(snapshot);
96
+ const windowed = windowSlice(allSnapshots, listIndex, limits.snapshots);
97
+ const preview = recoveryAction?.preview ?? null;
98
+ const receipt = recoveryAction?.receipt ?? null;
99
+ const hasPreview = Boolean(preview);
100
+ const confirming = phase === RECOVERY_PHASE.CONFIRMING;
101
+ const working = phase === RECOVERY_PHASE.PREVIEWING || phase === RECOVERY_PHASE.APPLYING;
102
+ const showPaths = (detailsOpen && hasPreview) || confirming;
103
+ const recentItems = collectRecentItems(snapshot, dashboard, limits.events);
104
+ const recent = recentItems.length > 0
105
+ ? recentItems
106
+ : [{ id: "empty-recent", label: "No recent agent activity." }];
107
+ const snapshotItems = windowed.items.length === 0
108
+ ? [{ id: "empty-snap", label: "No global snapshots yet." }]
109
+ : windowed.items.map((entry, index) => ({
110
+ id: entry.name ?? `s-${windowed.start + index}`,
111
+ label: `${shortName(entry.name)} · ${entry.fileCount ?? "?"} files`
112
+ }));
113
+ const focused = allSnapshots[Math.min(Math.max(0, listIndex), Math.max(0, allSnapshots.length - 1))] ?? null;
114
+ const fileCount = preview?.files?.length ?? 0;
115
+
116
+ return {
117
+ title: "Activity",
118
+ phase,
119
+ hasPreview,
120
+ callout: {
121
+ tone: phaseTone(phase),
122
+ title: phaseTitle(phase, allSnapshots.length, recentItems.length),
123
+ body: calloutBody(phase, recoveryAction)
124
+ },
125
+ primary: confirming || working ? null : {
126
+ label: hasPreview
127
+ ? `Restore preview · ${fileCount} file(s)`
128
+ : (allSnapshots.length > 0 ? "Select a snapshot to preview" : "No snapshots to restore"),
129
+ detail: receipt
130
+ ? `Result · ${receipt.action ?? "rollback"} · ${receipt.restored?.length ?? 0} restored`
131
+ : null
132
+ },
133
+ confirm: confirming ? {
134
+ summary: fileCount > 0
135
+ ? `Restore ${fileCount} file(s) from ${shortName(preview?.snapshot)}.`
136
+ : "Restore confirmed snapshot preview.",
137
+ primaryLabel: "Restore"
138
+ } : null,
139
+ recent,
140
+ snapshots: snapshotItems,
141
+ selectedIndex: windowed.items.length === 0 ? -1 : windowed.selectedIndex,
142
+ focusedSnapshot: focused?.name ?? null,
143
+ snapshotTotal: allSnapshots.length,
144
+ details: buildDetailsLines(preview, receipt, showPaths, homeDir, detailsPathLimit(layoutMode)),
145
+ detailsOpen: showPaths,
146
+ showDetails: hasPreview || confirming || Boolean(receipt)
147
+ };
148
+ }
149
+
150
+ export function SemanticActivityPanel({
151
+ snapshot = null, recoveryAction = null, dashboard = null, listIndex = 0,
152
+ homeDir = null, detailsOpen = false, layoutMode = LAYOUT_MODES.COMPACT,
153
+ contentFocused = false, colorEnabled = true, unicode = true
154
+ }) {
155
+ const view = adaptActivityModel({
156
+ snapshot, recoveryAction, dashboard, listIndex, homeDir, detailsOpen, layoutMode
157
+ });
158
+ return React.createElement(Box, { flexDirection: "column" },
159
+ React.createElement(ViewTitle, { colorEnabled }, view.title),
160
+ React.createElement(Callout, {
161
+ tone: view.callout.tone, title: view.callout.title,
162
+ body: view.callout.body || undefined, colorEnabled, compact: true
163
+ }),
164
+ view.confirm
165
+ ? React.createElement(Confirm, {
166
+ summary: view.confirm.summary, primaryLabel: view.confirm.primaryLabel,
167
+ focused: false, colorEnabled, mark: " "
168
+ })
169
+ : view.primary
170
+ ? React.createElement(Text, { bold: true }, ` ${view.primary.label}`)
171
+ : null,
172
+ view.primary?.detail && !view.confirm
173
+ ? React.createElement(Text, null, view.primary.detail) : null,
174
+ React.createElement(SectionLabel, { colorEnabled }, "Recent"),
175
+ React.createElement(ActionList, {
176
+ items: view.recent, selectedIndex: -1, focused: false, colorEnabled, unicode
177
+ }),
178
+ React.createElement(SectionLabel, { colorEnabled }, "Snapshots"),
179
+ React.createElement(ActionList, {
180
+ items: view.snapshots, selectedIndex: view.selectedIndex,
181
+ focused: contentFocused, colorEnabled, unicode
182
+ }),
183
+ view.showDetails
184
+ ? React.createElement(Details, {
185
+ open: view.detailsOpen, summary: "Details",
186
+ lines: view.details.length > 0 ? view.details : ["No path evidence in this preview."],
187
+ colorEnabled, focused: false, mark: " "
188
+ })
189
+ : null
190
+ );
191
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Live semantic Alerts inbox.
3
+ * ActionList owns the only focus mark; windowSlice keeps full open domain navigable.
4
+ * Enter resolve / D dismiss unchanged — adapter exposes focusedId aligned with selectAlertFromList.
5
+ */
6
+ import React from "react";
7
+ import { Box, Text } from "ink";
8
+ import { LAYOUT_MODES } from "../layout.js";
9
+ import { ALERT_STATES } from "../../runtime/alerts/alert-types.js";
10
+ import { formatAlertsHeadline, selectAlertFromList } from "../cockpit-alerts.js";
11
+ import { ActionList, Callout, ViewTitle } from "./semantic.js";
12
+ import { windowSlice } from "./live-activity.js";
13
+
14
+ export function alertsListLimit(layoutMode = LAYOUT_MODES.COMPACT) {
15
+ return layoutMode === LAYOUT_MODES.WIDE ? 8 : 3;
16
+ }
17
+
18
+ function formatAlertWhen(alert) {
19
+ return String(alert.createdAt ?? "").slice(0, 16).replace("T", " ") || "unknown time";
20
+ }
21
+
22
+ function alertLabel(alert) {
23
+ return `${alert.severity} · ${alert.title} · ${formatAlertWhen(alert)}`;
24
+ }
25
+
26
+ function openAlerts(alerts) {
27
+ if (!Array.isArray(alerts)) return [];
28
+ return alerts.filter((alert) => alert.state === ALERT_STATES.OPEN);
29
+ }
30
+
31
+ function unavailablePack() {
32
+ return {
33
+ items: [{ id: "unavailable", label: "Could not read the alert store." }],
34
+ selectedIndex: -1,
35
+ focusedId: null,
36
+ total: 0,
37
+ start: 0,
38
+ isEmpty: true,
39
+ isUnavailable: true
40
+ };
41
+ }
42
+
43
+ function emptyPack() {
44
+ return {
45
+ items: [{ id: "empty", label: "No pending alerts." }],
46
+ selectedIndex: -1,
47
+ focusedId: null,
48
+ total: 0,
49
+ start: 0,
50
+ isEmpty: true,
51
+ isUnavailable: false
52
+ };
53
+ }
54
+
55
+ function populatedPack(open, listIndex, limit) {
56
+ const windowed = windowSlice(open, listIndex, limit);
57
+ const safe = Math.min(Math.max(0, listIndex), open.length - 1);
58
+ const focused = open[safe] ?? null;
59
+ return {
60
+ items: windowed.items.map((alert, i) => ({
61
+ id: alert.alertId ?? `alert-${windowed.start + i}`,
62
+ label: alertLabel(alert)
63
+ })),
64
+ selectedIndex: windowed.selectedIndex,
65
+ focusedId: focused?.alertId ?? null,
66
+ total: open.length,
67
+ start: windowed.start,
68
+ isEmpty: false,
69
+ isUnavailable: false
70
+ };
71
+ }
72
+
73
+ /** Pure adapter: unavailable · empty · pending inbox. */
74
+ export function adaptAlertsModel({
75
+ alerts = null,
76
+ listIndex = 0,
77
+ layoutMode = LAYOUT_MODES.COMPACT
78
+ } = {}) {
79
+ const limit = alertsListLimit(layoutMode);
80
+ const headline = formatAlertsHeadline(alerts);
81
+ let list;
82
+
83
+ if (alerts == null) {
84
+ list = unavailablePack();
85
+ } else {
86
+ const open = openAlerts(alerts);
87
+ list = open.length === 0
88
+ ? emptyPack()
89
+ : populatedPack(open, listIndex, limit);
90
+ }
91
+
92
+ const selected = selectAlertFromList(alerts, listIndex);
93
+ if (!list.isEmpty && selected?.alertId) {
94
+ list.focusedId = selected.alertId;
95
+ }
96
+
97
+ const callout = list.isUnavailable
98
+ ? {
99
+ tone: "danger",
100
+ title: headline.headline,
101
+ body: "Esc back · / Alerts"
102
+ }
103
+ : list.isEmpty
104
+ ? {
105
+ tone: "info",
106
+ title: headline.headline,
107
+ body: "Esc back · / Alerts"
108
+ }
109
+ : {
110
+ tone: "warn",
111
+ title: headline.headline,
112
+ body: "Enter resolves · D dismisses · Esc back"
113
+ };
114
+
115
+ return {
116
+ title: "Alerts",
117
+ callout,
118
+ items: list.items,
119
+ selectedIndex: list.selectedIndex,
120
+ focusedId: list.focusedId,
121
+ total: list.total,
122
+ start: list.start,
123
+ isEmpty: list.isEmpty,
124
+ isUnavailable: list.isUnavailable,
125
+ listLimit: limit
126
+ };
127
+ }
128
+
129
+ export function SemanticAlertsPanel({
130
+ alerts = null,
131
+ listIndex = 0,
132
+ layoutMode = LAYOUT_MODES.COMPACT,
133
+ contentFocused = false,
134
+ colorEnabled = true,
135
+ unicode = true
136
+ }) {
137
+ const model = adaptAlertsModel({ alerts, listIndex, layoutMode });
138
+ const listFocused = contentFocused && !model.isEmpty && !model.isUnavailable;
139
+ return React.createElement(Box, { flexDirection: "column" },
140
+ React.createElement(ViewTitle, { colorEnabled }, model.title),
141
+ React.createElement(Callout, {
142
+ tone: model.callout.tone,
143
+ title: model.callout.title,
144
+ body: model.callout.body || undefined,
145
+ colorEnabled,
146
+ compact: true
147
+ }),
148
+ React.createElement(ActionList, {
149
+ items: model.items,
150
+ selectedIndex: model.selectedIndex,
151
+ focused: listFocused,
152
+ colorEnabled,
153
+ unicode
154
+ })
155
+ );
156
+ }