@kal-elsam/kairo-runtime 0.2.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -3
- package/package.json +3 -1
- package/scripts/cockpit-smoke-test.sh +19 -0
- package/scripts/cockpit-smoke.mjs +51 -0
- package/src/cli.js +6 -5
- package/src/global/ink/cockpit/primitives.js +131 -0
- package/src/global/ink/cockpit-controller.js +131 -0
- package/src/global/ink/cockpit-models.js +153 -0
- package/src/global/ink/cockpit-views.js +141 -0
- package/src/global/ink/fullscreen-session.js +128 -0
- package/src/global/ink/launch-input.js +99 -0
- package/src/global/ink/layout.js +34 -0
- package/src/global/ink/list-window.js +38 -0
- package/src/global/ink/orchestrator-app.js +172 -454
- package/src/global/ink/run-orchestrator-ink.js +36 -16
- package/src/global/ink/run-setup-ink.js +38 -17
- package/src/global/ink/setup-app.js +14 -17
- package/src/global/ink/terminal-capabilities.js +50 -0
- package/src/global/ink/terminal.js +27 -5
- package/src/global/ink/theme.js +72 -0
- package/src/global/ink/use-orchestrator-data.js +158 -0
- package/src/global/ink/use-terminal-size.js +39 -0
- package/src/global/orchestrator.js +56 -35
- package/src/global/setup.js +4 -2
|
@@ -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,99 @@
|
|
|
1
|
+
import {
|
|
2
|
+
LAUNCH_PERMISSION_OPTIONS,
|
|
3
|
+
LAUNCH_WIZARD_STEPS,
|
|
4
|
+
retreatLaunchWizardStep
|
|
5
|
+
} from "./orchestrator-state.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Launch-wizard key handler. Returns true when the key was consumed.
|
|
9
|
+
*/
|
|
10
|
+
export function handleLaunchInput(ctx) {
|
|
11
|
+
const {
|
|
12
|
+
key,
|
|
13
|
+
inputKey,
|
|
14
|
+
launchStep,
|
|
15
|
+
launchDraft,
|
|
16
|
+
launchableAgents,
|
|
17
|
+
launchAgentIndex,
|
|
18
|
+
launchPermissionIndex,
|
|
19
|
+
setLaunchAgentIndex,
|
|
20
|
+
setLaunchDraft,
|
|
21
|
+
setLaunchStep,
|
|
22
|
+
setLaunchPermissionIndex,
|
|
23
|
+
setError,
|
|
24
|
+
handleLaunch,
|
|
25
|
+
reload
|
|
26
|
+
} = ctx;
|
|
27
|
+
|
|
28
|
+
if (launchStep === LAUNCH_WIZARD_STEPS.AGENT) {
|
|
29
|
+
if (key.upArrow) {
|
|
30
|
+
setLaunchAgentIndex((index) => Math.max(0, index - 1));
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
if (key.downArrow) {
|
|
34
|
+
setLaunchAgentIndex((index) => Math.min(launchableAgents.length - 1, index + 1));
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
if (key.return) {
|
|
38
|
+
const agentId = launchableAgents[launchAgentIndex];
|
|
39
|
+
setLaunchDraft((draft) => ({ ...draft, agentId }));
|
|
40
|
+
setLaunchStep(LAUNCH_WIZARD_STEPS.TASK);
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (launchStep === LAUNCH_WIZARD_STEPS.TASK || launchStep === LAUNCH_WIZARD_STEPS.MODEL) {
|
|
47
|
+
const field = launchStep === LAUNCH_WIZARD_STEPS.TASK ? "task" : "model";
|
|
48
|
+
if (key.return) {
|
|
49
|
+
if (field === "task" && !launchDraft.task.trim()) {
|
|
50
|
+
setError("Task cannot be empty.");
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
setLaunchStep(field === "task" ? LAUNCH_WIZARD_STEPS.MODEL : LAUNCH_WIZARD_STEPS.PERMISSIONS);
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
if (key.backspace || key.delete) {
|
|
57
|
+
setLaunchDraft((draft) => ({ ...draft, [field]: draft[field].slice(0, -1) }));
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
if (inputKey && inputKey.length === 1 && !key.ctrl && !key.meta) {
|
|
61
|
+
setLaunchDraft((draft) => ({ ...draft, [field]: `${draft[field]}${inputKey}` }));
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (launchStep === LAUNCH_WIZARD_STEPS.PERMISSIONS) {
|
|
68
|
+
if (key.upArrow) {
|
|
69
|
+
setLaunchPermissionIndex((index) => Math.max(0, index - 1));
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
if (key.downArrow) {
|
|
73
|
+
setLaunchPermissionIndex((index) => Math.min(LAUNCH_PERMISSION_OPTIONS.length - 1, index + 1));
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
if (key.return) {
|
|
77
|
+
setLaunchStep(LAUNCH_WIZARD_STEPS.CONFIRM);
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (launchStep === LAUNCH_WIZARD_STEPS.CONFIRM) {
|
|
84
|
+
if (key.return) {
|
|
85
|
+
handleLaunch({ ...launchDraft, permissionIndex: launchPermissionIndex });
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
if (key.escape) {
|
|
89
|
+
setLaunchStep(retreatLaunchWizardStep(launchStep));
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (inputKey.toLowerCase() === "r") {
|
|
95
|
+
reload().catch(() => {});
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
@@ -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
|
+
}
|