@kal-elsam/kairo-runtime 0.2.3 → 0.3.1

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.
@@ -0,0 +1,180 @@
1
+ import { LAYOUT_MODES } from "./layout.js";
2
+ import { ORCHESTRATOR_VIEWS } from "./orchestrator-state.js";
3
+ import { resolveDashboardRecommendation } from "../dashboard-guidance.js";
4
+ import { STATUS_LABELS, resolveGlyphs } from "./theme.js";
5
+ import { windowList } from "./list-window.js";
6
+ import { resolveListLimit } from "./layout.js";
7
+
8
+ export const COCKPIT_REGIONS = {
9
+ NAV: "nav",
10
+ CONTENT: "content",
11
+ SYSTEM: "system"
12
+ };
13
+
14
+ export const COCKPIT_NAV = [
15
+ { id: "overview", label: "Overview", view: ORCHESTRATOR_VIEWS.HOME },
16
+ { id: "active", label: "Active runs", view: ORCHESTRATOR_VIEWS.ACTIVE_RUNS },
17
+ { id: "recent", label: "Recent runs", view: ORCHESTRATOR_VIEWS.RECENT_RUNS },
18
+ { id: "providers", label: "Providers", view: ORCHESTRATOR_VIEWS.PROVIDERS },
19
+ { id: "launch", label: "Launch run", view: ORCHESTRATOR_VIEWS.LAUNCH, action: "launch" },
20
+ { id: "diagnostics", label: "Diagnostics", view: ORCHESTRATOR_VIEWS.DIAGNOSTICS }
21
+ ];
22
+
23
+ export function regionsForLayout(layoutMode) {
24
+ if (layoutMode === LAYOUT_MODES.WIDE) {
25
+ return [COCKPIT_REGIONS.NAV, COCKPIT_REGIONS.CONTENT, COCKPIT_REGIONS.SYSTEM];
26
+ }
27
+ if (layoutMode === LAYOUT_MODES.COMPACT) {
28
+ return [COCKPIT_REGIONS.NAV, COCKPIT_REGIONS.CONTENT];
29
+ }
30
+ return [COCKPIT_REGIONS.CONTENT];
31
+ }
32
+
33
+ export function buildTopBarModel({
34
+ projectName = "project",
35
+ systemOnline = true,
36
+ unicode = true
37
+ } = {}) {
38
+ const glyphs = resolveGlyphs(unicode);
39
+ const status = systemOnline ? STATUS_LABELS.online : STATUS_LABELS.offline;
40
+ return {
41
+ brand: "KAIRO",
42
+ status,
43
+ statusKind: systemOnline ? "online" : "offline",
44
+ projectLabel: `Project: ${projectName}`,
45
+ separator: glyphs.bullet
46
+ };
47
+ }
48
+
49
+ export function buildNavModel({
50
+ navIndex = 0,
51
+ focused = false,
52
+ unicode = true,
53
+ items = COCKPIT_NAV
54
+ } = {}) {
55
+ const glyphs = resolveGlyphs(unicode);
56
+ return {
57
+ title: "NAVIGATION",
58
+ items: items.map((item, index) => ({
59
+ ...item,
60
+ marker: index === navIndex ? glyphs.focus : " ",
61
+ selected: index === navIndex,
62
+ focused: focused && index === navIndex
63
+ }))
64
+ };
65
+ }
66
+
67
+ export function buildSystemStripModel({
68
+ dashboard = null,
69
+ diagnostics = null,
70
+ healthKind = "ready"
71
+ } = {}) {
72
+ const agentsDetected = diagnostics?.diagnostics?.detected
73
+ ?? (dashboard?.providers ?? []).filter((p) => p.available).length;
74
+ const agentsTotal = diagnostics?.capabilities?.length
75
+ ?? dashboard?.providers?.length
76
+ ?? 0;
77
+ const activeRuns = dashboard?.activeRuns?.length ?? 0;
78
+ const intelligence = diagnostics?.intelligence?.summary;
79
+ const intelLabel = intelligence?.localAvailable
80
+ ? STATUS_LABELS.local
81
+ : intelligence?.cloudAuthenticated
82
+ ? "Cloud"
83
+ : "None";
84
+
85
+ return {
86
+ title: "SYSTEM",
87
+ rows: [
88
+ { key: "Agents", value: `${agentsDetected}/${agentsTotal}`, kind: agentsDetected > 0 ? "ready" : "warn" },
89
+ { key: "Runs", value: String(activeRuns), kind: activeRuns > 0 ? "ready" : "muted" },
90
+ { key: "Intel", value: intelLabel, kind: intelLabel === "None" ? "warn" : "ready" },
91
+ { key: "Health", value: STATUS_LABELS[healthKind] ?? healthKind, kind: healthKind }
92
+ ]
93
+ };
94
+ }
95
+
96
+ export function buildHomeMissionModel({
97
+ hasGlobalState = false,
98
+ diagnostics = null,
99
+ dashboard = null,
100
+ layoutMode = LAYOUT_MODES.COMPACT,
101
+ activityLines = []
102
+ } = {}) {
103
+ const recommendation = resolveDashboardRecommendation({
104
+ hasGlobalState,
105
+ diagnostics,
106
+ dashboard
107
+ });
108
+ const limit = resolveListLimit(layoutMode, { contentRows: 10 });
109
+ const windowed = windowList(activityLines, limit);
110
+
111
+ return {
112
+ title: "MISSION CONTROL",
113
+ recommendedTitle: "Recommended action",
114
+ recommendedAction: recommendation.message,
115
+ recommendedKind: recommendation.kind,
116
+ activityTitle: activityLines.length ? "Activity" : "Activity / empty state",
117
+ activityLines: windowed.items,
118
+ moreLine: windowed.moreLine,
119
+ emptyHint: activityLines.length === 0
120
+ ? "No recent activity. Launch a run when agents are ready."
121
+ : null
122
+ };
123
+ }
124
+
125
+ export function buildFooterModel({
126
+ view = ORCHESTRATOR_VIEWS.HOME,
127
+ region = COCKPIT_REGIONS.NAV,
128
+ helpOpen = false,
129
+ canCancel = false,
130
+ unicode = true
131
+ } = {}) {
132
+ const glyphs = resolveGlyphs(unicode);
133
+ const parts = [];
134
+
135
+ if (helpOpen || view === ORCHESTRATOR_VIEWS.HELP) {
136
+ parts.push("Esc close help");
137
+ parts.push("? Help");
138
+ return { text: parts.join(` ${glyphs.bullet} `) };
139
+ }
140
+
141
+ if (view === ORCHESTRATOR_VIEWS.RUN_DETAIL) {
142
+ parts.push("R refresh");
143
+ if (canCancel) parts.push("C cancel");
144
+ parts.push("Esc Back");
145
+ return { text: parts.join(` ${glyphs.bullet} `) };
146
+ }
147
+
148
+ parts.push("↑↓ Navigate");
149
+
150
+ const showTab = view === ORCHESTRATOR_VIEWS.ACTIVE_RUNS
151
+ || view === ORCHESTRATOR_VIEWS.RECENT_RUNS
152
+ || view === ORCHESTRATOR_VIEWS.LAUNCH;
153
+ if (showTab) {
154
+ parts.push("Tab Region");
155
+ }
156
+
157
+ if (view === ORCHESTRATOR_VIEWS.HOME
158
+ || view === ORCHESTRATOR_VIEWS.PROVIDERS
159
+ || view === ORCHESTRATOR_VIEWS.DIAGNOSTICS
160
+ || region === COCKPIT_REGIONS.NAV
161
+ || view === ORCHESTRATOR_VIEWS.ACTIVE_RUNS
162
+ || view === ORCHESTRATOR_VIEWS.RECENT_RUNS) {
163
+ parts.push("Enter Open");
164
+ }
165
+
166
+ if (view !== ORCHESTRATOR_VIEWS.LAUNCH) {
167
+ parts.push("R refresh");
168
+ }
169
+
170
+ parts.push("? Help");
171
+ parts.push(view === ORCHESTRATOR_VIEWS.HOME ? "Esc Exit" : "Esc Back");
172
+
173
+ return { text: parts.join(` ${glyphs.bullet} `) };
174
+ }
175
+
176
+ export function resolveProjectName(workspaceRoot = "") {
177
+ if (!workspaceRoot) return "project";
178
+ const parts = String(workspaceRoot).split(/[/\\]/).filter(Boolean);
179
+ return parts[parts.length - 1] || "project";
180
+ }
@@ -0,0 +1,141 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ import { COCKPIT_COLORS } from "./theme.js";
4
+ import { CockpitEmptyState } from "./cockpit/primitives.js";
5
+ import {
6
+ formatDiagnosticsLines,
7
+ formatLaunchWizardLines,
8
+ formatProviderLines,
9
+ formatRunDetailLines,
10
+ formatRunLines,
11
+ ORCHESTRATOR_VIEWS,
12
+ LAUNCH_WIZARD_STEPS
13
+ } from "./orchestrator-state.js";
14
+
15
+ export function HomeMissionPanel({ model, colorEnabled = true }) {
16
+ return React.createElement(Box, { flexDirection: "column" },
17
+ React.createElement(Text, {
18
+ bold: true,
19
+ color: colorEnabled ? COCKPIT_COLORS.secondary : undefined
20
+ }, model.title),
21
+ React.createElement(Text, { bold: true }, model.recommendedTitle),
22
+ React.createElement(Text, {
23
+ color: colorEnabled ? COCKPIT_COLORS.primary : undefined
24
+ }, model.recommendedAction),
25
+ React.createElement(Text, null, ""),
26
+ React.createElement(Text, { bold: true }, model.activityTitle),
27
+ model.emptyHint
28
+ ? React.createElement(CockpitEmptyState, {
29
+ message: model.emptyHint,
30
+ hint: "Enter on Launch run when ready."
31
+ })
32
+ : model.activityLines.map((line) =>
33
+ React.createElement(Text, { key: line }, line)
34
+ ),
35
+ model.moreLine && React.createElement(Text, {
36
+ color: COCKPIT_COLORS.muted
37
+ }, model.moreLine)
38
+ );
39
+ }
40
+
41
+ export function renderCockpitView({
42
+ view,
43
+ dashboard,
44
+ diagnostics,
45
+ listIndex,
46
+ launchStep,
47
+ launchDraft,
48
+ launchAgentIndex,
49
+ launchPermissionIndex,
50
+ launchableAgents,
51
+ selectedRun,
52
+ selectedEvents,
53
+ homeMission,
54
+ colorEnabled = true
55
+ }) {
56
+ switch (view) {
57
+ case ORCHESTRATOR_VIEWS.HOME:
58
+ return React.createElement(HomeMissionPanel, { model: homeMission, colorEnabled });
59
+ case ORCHESTRATOR_VIEWS.ACTIVE_RUNS:
60
+ return listBlock(
61
+ "Active runs",
62
+ formatRunLines(dashboard?.activeRuns ?? [], { emptyMessage: "No active runs." }),
63
+ listIndex,
64
+ colorEnabled
65
+ );
66
+ case ORCHESTRATOR_VIEWS.RECENT_RUNS:
67
+ return listBlock(
68
+ "Recent runs",
69
+ formatRunLines(dashboard?.recentRuns ?? [], { emptyMessage: "No completed runs yet." }),
70
+ listIndex,
71
+ colorEnabled
72
+ );
73
+ case ORCHESTRATOR_VIEWS.PROVIDERS:
74
+ return React.createElement(Box, { flexDirection: "column" },
75
+ React.createElement(Text, { bold: true }, "Providers"),
76
+ formatProviderLines(dashboard?.providers ?? [])
77
+ .map((line) => React.createElement(Text, { key: line }, line))
78
+ );
79
+ case ORCHESTRATOR_VIEWS.LAUNCH:
80
+ if (launchableAgents.length === 0) {
81
+ return React.createElement(CockpitEmptyState, {
82
+ title: "Launch run",
83
+ message: "No launchable agents detected.",
84
+ hint: "Esc to return · check Diagnostics"
85
+ });
86
+ }
87
+ return React.createElement(Box, { flexDirection: "column" },
88
+ React.createElement(Text, { bold: true }, "Launch run"),
89
+ formatLaunchWizardLines({
90
+ step: launchStep,
91
+ draft: launchDraft,
92
+ launchableAgents,
93
+ agentIndex: launchAgentIndex,
94
+ permissionIndex: launchPermissionIndex
95
+ }).map((line) => React.createElement(Text, {
96
+ key: line,
97
+ color: line.startsWith("›") || line.startsWith(">")
98
+ ? (colorEnabled ? COCKPIT_COLORS.primary : undefined)
99
+ : undefined
100
+ }, line))
101
+ );
102
+ case ORCHESTRATOR_VIEWS.RUN_DETAIL:
103
+ return React.createElement(Box, { flexDirection: "column" },
104
+ React.createElement(Text, { bold: true }, "Run detail"),
105
+ formatRunDetailLines(selectedRun, selectedEvents)
106
+ .map((line) => React.createElement(Text, { key: line }, line))
107
+ );
108
+ case ORCHESTRATOR_VIEWS.DIAGNOSTICS:
109
+ return React.createElement(Box, { flexDirection: "column" },
110
+ React.createElement(Text, { bold: true }, "Diagnostics"),
111
+ formatDiagnosticsLines(diagnostics)
112
+ .map((line) => React.createElement(Text, { key: line }, line))
113
+ );
114
+ case ORCHESTRATOR_VIEWS.HELP:
115
+ return React.createElement(Box, { flexDirection: "column" },
116
+ React.createElement(Text, { bold: true }, "Help"),
117
+ React.createElement(Text, null, "Kairo runtime launches and audits agent CLIs you manage."),
118
+ React.createElement(Text, null, "↑↓ navigate · Tab region · Enter open · Esc back/exit"),
119
+ React.createElement(Text, null, "R refresh · C cancel active run · ? toggle help"),
120
+ React.createElement(Text, null, "CLI: kairo run --agent <id> --task \"...\""),
121
+ React.createElement(Text, null, "Audit trail: ~/.harness/runs/<runId>/")
122
+ );
123
+ default: {
124
+ const _exhaustive = view;
125
+ return React.createElement(Text, null, `Unknown view: ${_exhaustive}`);
126
+ }
127
+ }
128
+ }
129
+
130
+ function listBlock(title, lines, listIndex, colorEnabled) {
131
+ return React.createElement(Box, { flexDirection: "column" },
132
+ React.createElement(Text, { bold: true }, title),
133
+ lines.map((line, index) => React.createElement(Text, {
134
+ key: `${index}-${line}`,
135
+ bold: index === listIndex,
136
+ color: index === listIndex && colorEnabled ? COCKPIT_COLORS.primary : undefined
137
+ }, `${index === listIndex ? "› " : " "}${line}`))
138
+ );
139
+ }
140
+
141
+ export { LAUNCH_WIZARD_STEPS };
@@ -0,0 +1,128 @@
1
+ import ansiEscapes from "ansi-escapes";
2
+ import { stdout as defaultStdout } from "node:process";
3
+
4
+ const SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
5
+
6
+ /**
7
+ * Idempotent alternate-screen session for Ink TTY flows.
8
+ * Enter once for onboarding → setup → dashboard; leave exactly once on any exit.
9
+ *
10
+ * @param {{
11
+ * stdout?: { isTTY?: boolean, write?: Function },
12
+ * ansi?: { enterAlternativeScreen: string, exitAlternativeScreen: string, cursorHide: string, cursorShow: string },
13
+ * processRef?: NodeJS.Process,
14
+ * enabled?: boolean,
15
+ * onSignal?: (signal: string) => void
16
+ * }} [options]
17
+ */
18
+ export function createFullscreenSession({
19
+ stdout = defaultStdout,
20
+ ansi = ansiEscapes,
21
+ processRef = process,
22
+ enabled = Boolean(stdout?.isTTY),
23
+ onSignal = null
24
+ } = {}) {
25
+ let active = false;
26
+ let left = false;
27
+ const signalHandlers = new Map();
28
+
29
+ function write(sequence) {
30
+ if (!stdout || typeof stdout.write !== "function") return;
31
+ try {
32
+ stdout.write(sequence);
33
+ } catch {
34
+ // Best-effort restore; never throw from leave path.
35
+ }
36
+ }
37
+
38
+ function detachSignals() {
39
+ for (const [signal, handler] of signalHandlers) {
40
+ try {
41
+ processRef.removeListener(signal, handler);
42
+ } catch {
43
+ // ignore
44
+ }
45
+ }
46
+ signalHandlers.clear();
47
+ }
48
+
49
+ function leave() {
50
+ if (!active || left) return false;
51
+ left = true;
52
+ active = false;
53
+ detachSignals();
54
+ write(ansi.cursorShow);
55
+ write(ansi.exitAlternativeScreen);
56
+ return true;
57
+ }
58
+
59
+ function defaultSignalExit(signal) {
60
+ const code = signal === "SIGINT" ? 130 : signal === "SIGTERM" ? 143 : 129;
61
+ try {
62
+ processRef.exit(code);
63
+ } catch {
64
+ // ignore in constrained environments
65
+ }
66
+ }
67
+
68
+ function attachSignals() {
69
+ for (const signal of SIGNALS) {
70
+ const handler = () => {
71
+ leave();
72
+ if (typeof onSignal === "function") {
73
+ onSignal(signal);
74
+ return;
75
+ }
76
+ defaultSignalExit(signal);
77
+ };
78
+ signalHandlers.set(signal, handler);
79
+ processRef.on(signal, handler);
80
+ }
81
+ }
82
+
83
+ function enter() {
84
+ if (!enabled) return false;
85
+ if (active) return false;
86
+ left = false;
87
+ active = true;
88
+ write(ansi.enterAlternativeScreen);
89
+ write(ansi.cursorHide);
90
+ attachSignals();
91
+ return true;
92
+ }
93
+
94
+ function isActive() {
95
+ return active && !left;
96
+ }
97
+
98
+ return {
99
+ enter,
100
+ leave,
101
+ isActive,
102
+ get enabled() {
103
+ return enabled;
104
+ }
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Run an async body inside a fullscreen session (enter → finally leave).
110
+ * If `sessionOrOptions` is an existing session that is already active, does not leave it.
111
+ */
112
+ export async function withFullscreenSession(sessionOrOptions, body) {
113
+ const isExisting = typeof sessionOrOptions?.enter === "function";
114
+ const session = isExisting
115
+ ? sessionOrOptions
116
+ : createFullscreenSession(sessionOrOptions);
117
+
118
+ const wasActive = session.isActive();
119
+ const didEnter = wasActive ? false : session.enter();
120
+
121
+ try {
122
+ return await body(session);
123
+ } finally {
124
+ if (didEnter) {
125
+ session.leave();
126
+ }
127
+ }
128
+ }
@@ -0,0 +1,109 @@
1
+ import {
2
+ LAUNCH_PERMISSION_OPTIONS,
3
+ LAUNCH_WIZARD_STEPS,
4
+ retreatLaunchWizardStep
5
+ } from "./orchestrator-state.js";
6
+
7
+ /**
8
+ * Launch-wizard key handler.
9
+ * Returns true when consumed, "retreated" when Esc stepped back, false otherwise.
10
+ */
11
+ export function handleLaunchInput(ctx) {
12
+ const {
13
+ key,
14
+ inputKey,
15
+ launchStep,
16
+ launchDraft,
17
+ launchableAgents,
18
+ launchAgentIndex,
19
+ launchPermissionIndex,
20
+ setLaunchAgentIndex,
21
+ setLaunchDraft,
22
+ setLaunchStep,
23
+ setLaunchPermissionIndex,
24
+ setError,
25
+ handleLaunch,
26
+ reload,
27
+ allowEscapeRetreat = false
28
+ } = ctx;
29
+
30
+ if (allowEscapeRetreat && key.escape) {
31
+ if (launchStep === LAUNCH_WIZARD_STEPS.AGENT) {
32
+ return false;
33
+ }
34
+ setLaunchStep(retreatLaunchWizardStep(launchStep));
35
+ return "retreated";
36
+ }
37
+
38
+ if (launchStep === LAUNCH_WIZARD_STEPS.AGENT) {
39
+ if (key.upArrow) {
40
+ setLaunchAgentIndex((index) => Math.max(0, index - 1));
41
+ return true;
42
+ }
43
+ if (key.downArrow) {
44
+ setLaunchAgentIndex((index) => Math.min(launchableAgents.length - 1, index + 1));
45
+ return true;
46
+ }
47
+ if (key.return) {
48
+ const agentId = launchableAgents[launchAgentIndex];
49
+ setLaunchDraft((draft) => ({ ...draft, agentId }));
50
+ setLaunchStep(LAUNCH_WIZARD_STEPS.TASK);
51
+ return true;
52
+ }
53
+ return true;
54
+ }
55
+
56
+ if (launchStep === LAUNCH_WIZARD_STEPS.TASK || launchStep === LAUNCH_WIZARD_STEPS.MODEL) {
57
+ const field = launchStep === LAUNCH_WIZARD_STEPS.TASK ? "task" : "model";
58
+ if (key.return) {
59
+ if (field === "task" && !launchDraft.task.trim()) {
60
+ setError("Task cannot be empty.");
61
+ return true;
62
+ }
63
+ setLaunchStep(field === "task" ? LAUNCH_WIZARD_STEPS.MODEL : LAUNCH_WIZARD_STEPS.PERMISSIONS);
64
+ return true;
65
+ }
66
+ if (key.backspace || key.delete) {
67
+ setLaunchDraft((draft) => ({ ...draft, [field]: draft[field].slice(0, -1) }));
68
+ return true;
69
+ }
70
+ if (inputKey && inputKey.length === 1 && !key.ctrl && !key.meta) {
71
+ setLaunchDraft((draft) => ({ ...draft, [field]: `${draft[field]}${inputKey}` }));
72
+ return true;
73
+ }
74
+ return true;
75
+ }
76
+
77
+ if (launchStep === LAUNCH_WIZARD_STEPS.PERMISSIONS) {
78
+ if (key.upArrow) {
79
+ setLaunchPermissionIndex((index) => Math.max(0, index - 1));
80
+ return true;
81
+ }
82
+ if (key.downArrow) {
83
+ setLaunchPermissionIndex((index) => Math.min(LAUNCH_PERMISSION_OPTIONS.length - 1, index + 1));
84
+ return true;
85
+ }
86
+ if (key.return) {
87
+ setLaunchStep(LAUNCH_WIZARD_STEPS.CONFIRM);
88
+ return true;
89
+ }
90
+ return true;
91
+ }
92
+
93
+ if (launchStep === LAUNCH_WIZARD_STEPS.CONFIRM) {
94
+ if (key.return) {
95
+ handleLaunch({ ...launchDraft, permissionIndex: launchPermissionIndex });
96
+ return true;
97
+ }
98
+ if (key.escape) {
99
+ setLaunchStep(retreatLaunchWizardStep(launchStep));
100
+ return allowEscapeRetreat ? "retreated" : true;
101
+ }
102
+ }
103
+
104
+ if (inputKey.toLowerCase() === "r") {
105
+ reload().catch(() => {});
106
+ return true;
107
+ }
108
+ return false;
109
+ }
@@ -0,0 +1,34 @@
1
+ import { LAYOUT_MODES } from "./terminal-capabilities.js";
2
+
3
+ /**
4
+ * Pure layout mode from terminal size.
5
+ * @returns {"wide"|"compact"|"minimal"|null} null when below Ink gate (<60 cols)
6
+ */
7
+ export function resolveLayoutMode({ columns = 80, rows = 24 } = {}) {
8
+ const cols = Number.isFinite(columns) && columns > 0 ? columns : 0;
9
+ const r = Number.isFinite(rows) && rows > 0 ? rows : 0;
10
+
11
+ if (cols < 60) return null;
12
+
13
+ if (cols >= 100 && r >= 28) return LAYOUT_MODES.WIDE;
14
+ if (cols >= 72 && r >= 20) return LAYOUT_MODES.COMPACT;
15
+ return LAYOUT_MODES.MINIMAL;
16
+ }
17
+
18
+ /**
19
+ * Suggested visible list limit for the active layout.
20
+ */
21
+ export function resolveListLimit(layoutMode, { contentRows = 12 } = {}) {
22
+ switch (layoutMode) {
23
+ case LAYOUT_MODES.WIDE:
24
+ return Math.max(6, Math.min(16, contentRows - 4));
25
+ case LAYOUT_MODES.COMPACT:
26
+ return Math.max(4, Math.min(10, contentRows - 4));
27
+ case LAYOUT_MODES.MINIMAL:
28
+ return Math.max(3, Math.min(6, contentRows - 2));
29
+ default:
30
+ return 4;
31
+ }
32
+ }
33
+
34
+ export { LAYOUT_MODES };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Truncate a list for a fixed viewport with an overflow indicator.
3
+ *
4
+ * @template T
5
+ * @param {T[]} items
6
+ * @param {number} limit
7
+ * @param {{ moreLabel?: string }} [options]
8
+ * @returns {{ items: T[], hiddenCount: number, hasMore: boolean, moreLine: string | null }}
9
+ */
10
+ export function windowList(items = [], limit = 8, { moreLabel = "… more" } = {}) {
11
+ const safeLimit = Math.max(0, Number(limit) || 0);
12
+ if (safeLimit === 0) {
13
+ return {
14
+ items: [],
15
+ hiddenCount: items.length,
16
+ hasMore: items.length > 0,
17
+ moreLine: items.length > 0 ? `${moreLabel} (${items.length})` : null
18
+ };
19
+ }
20
+
21
+ if (items.length <= safeLimit) {
22
+ return {
23
+ items: [...items],
24
+ hiddenCount: 0,
25
+ hasMore: false,
26
+ moreLine: null
27
+ };
28
+ }
29
+
30
+ const visible = items.slice(0, safeLimit);
31
+ const hiddenCount = items.length - safeLimit;
32
+ return {
33
+ items: visible,
34
+ hiddenCount,
35
+ hasMore: true,
36
+ moreLine: `${moreLabel} (${hiddenCount})`
37
+ };
38
+ }