@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.
- package/README.md +17 -3
- package/package.json +3 -1
- package/scripts/check-published-release.mjs +4 -1
- package/scripts/cockpit-smoke-test.sh +21 -0
- package/scripts/cockpit-smoke.mjs +103 -0
- package/src/cli.js +7 -5
- package/src/global/ink/cockpit/primitives.js +131 -0
- package/src/global/ink/cockpit-controller.js +168 -0
- package/src/global/ink/cockpit-focus.js +92 -0
- package/src/global/ink/cockpit-models.js +180 -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 +109 -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 +203 -457
- 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 +162 -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
package/README.md
CHANGED
|
@@ -33,10 +33,24 @@ kairo
|
|
|
33
33
|
```
|
|
34
34
|
|
|
35
35
|
**First run** (no `~/.harness/state.json`): interactive onboarding → safe diagnosis →
|
|
36
|
-
setup with confirmation → operations
|
|
36
|
+
setup with confirmation → full-screen operations cockpit.
|
|
37
37
|
|
|
38
|
-
**Later runs** (state present):
|
|
39
|
-
|
|
38
|
+
**Later runs** (state present): full-screen cockpit with mission control (recommended
|
|
39
|
+
action), navigation, and a system strip. Layout adapts to terminal size:
|
|
40
|
+
|
|
41
|
+
| Mode | Size | Layout |
|
|
42
|
+
|------|------|--------|
|
|
43
|
+
| Wide | ≥100 cols × ≥28 rows | Nav + content + system |
|
|
44
|
+
| Compact | ≥72×20 | Nav + content |
|
|
45
|
+
| Minimal | 60–71 cols or short height | Single panel + nav header |
|
|
46
|
+
| Below gate | <60 cols | Explicit TTY fallback (Ink disabled) |
|
|
47
|
+
|
|
48
|
+
Keys: `↑↓` navigate · `Enter` open · `Esc` back (exit only from Overview) ·
|
|
49
|
+
`R` refresh · `C` cancel run · `?` help. `Tab` switches region only when content
|
|
50
|
+
is interactive (run lists / launch).
|
|
51
|
+
|
|
52
|
+
Respects `NO_COLOR`, `HARNESS_ASCII=1`, and `HARNESS_INK=0`. Status is always labeled
|
|
53
|
+
in text, never color alone.
|
|
40
54
|
|
|
41
55
|
Explicit commands and setup flags keep their current behavior (`kairo setup`,
|
|
42
56
|
`kairo --dry-run`, `kairo shell`, non-TTY scripts, etc.).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kal-elsam/kairo-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Kairo Runtime — local agent operating system for Codex, Cursor, Claude, Gemini, Copilot, Engram, and Graphify.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"homepage": "https://github.com/Kal-elSam/harness#readme",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"doctor": "node ./bin/kairo.js doctor",
|
|
34
34
|
"smoke": "bash scripts/smoke-test.sh",
|
|
35
35
|
"ux:smoke": "bash scripts/ux-smoke-test.sh",
|
|
36
|
+
"smoke:cockpit": "bash scripts/cockpit-smoke-test.sh",
|
|
36
37
|
"smoke:registry": "bash scripts/registry-smoke-test.sh",
|
|
37
38
|
"smoke:installer": "bash scripts/installer-smoke-test.sh",
|
|
38
39
|
"smoke:bridge": "bash scripts/bridge-smoke-test.sh",
|
|
@@ -59,6 +60,7 @@
|
|
|
59
60
|
},
|
|
60
61
|
"dependencies": {
|
|
61
62
|
"@clack/prompts": "^1.7.0",
|
|
63
|
+
"ansi-escapes": "^7.3.0",
|
|
62
64
|
"ink": "^5.2.1",
|
|
63
65
|
"react": "^18.3.1"
|
|
64
66
|
}
|
|
@@ -113,8 +113,11 @@ export async function verifyPublishedRelease({
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
const remoteTagLine = runGit(`git ls-remote --tags origin refs/tags/${releaseTag}`).trim();
|
|
116
|
+
const remotePeeledLine = runGit(`git ls-remote origin refs/tags/${releaseTag}^{}`).trim();
|
|
117
|
+
const remotePointsAtGitHead = remoteTagLine.startsWith(gitHead)
|
|
118
|
+
|| remotePeeledLine.startsWith(gitHead);
|
|
116
119
|
|
|
117
|
-
if (!
|
|
120
|
+
if (!remotePointsAtGitHead) {
|
|
118
121
|
throw new Error(
|
|
119
122
|
`Remote tag ${releaseTag} on origin does not point to npm gitHead (${gitHead})`
|
|
120
123
|
);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Cockpit smoke — layout/capabilities + package wiring (no PTY required).
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
|
|
5
|
+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
6
|
+
cd "$ROOT"
|
|
7
|
+
|
|
8
|
+
echo "Kairo cockpit smoke"
|
|
9
|
+
node "$ROOT/scripts/cockpit-smoke.mjs"
|
|
10
|
+
|
|
11
|
+
node --test \
|
|
12
|
+
test/fullscreen-session.test.js \
|
|
13
|
+
test/layout.test.js \
|
|
14
|
+
test/terminal-capabilities.test.js \
|
|
15
|
+
test/cockpit-models.test.js \
|
|
16
|
+
test/cockpit-controller.test.js \
|
|
17
|
+
test/cockpit-frame.test.js \
|
|
18
|
+
test/cockpit-navigation.test.js \
|
|
19
|
+
test/cockpit-input-integration.test.js
|
|
20
|
+
|
|
21
|
+
echo "Kairo cockpit smoke passed"
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { resolveLayoutMode, LAYOUT_MODES } from "../src/global/ink/layout.js";
|
|
7
|
+
import { resolveTerminalCapabilities } from "../src/global/ink/terminal-capabilities.js";
|
|
8
|
+
import { createFullscreenSession } from "../src/global/ink/fullscreen-session.js";
|
|
9
|
+
import {
|
|
10
|
+
buildFooterModel,
|
|
11
|
+
buildHomeMissionModel,
|
|
12
|
+
buildTopBarModel,
|
|
13
|
+
COCKPIT_NAV,
|
|
14
|
+
COCKPIT_REGIONS
|
|
15
|
+
} from "../src/global/ink/cockpit-models.js";
|
|
16
|
+
import {
|
|
17
|
+
createCockpitUiState,
|
|
18
|
+
reduceCockpitUi,
|
|
19
|
+
routeCockpitKey
|
|
20
|
+
} from "../src/global/ink/cockpit-controller.js";
|
|
21
|
+
import { ORCHESTRATOR_VIEWS } from "../src/global/ink/orchestrator-state.js";
|
|
22
|
+
|
|
23
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const pkg = require(join(root, "package.json"));
|
|
26
|
+
|
|
27
|
+
assert.equal(pkg.version, "0.3.1");
|
|
28
|
+
assert.ok(pkg.dependencies["ansi-escapes"]);
|
|
29
|
+
|
|
30
|
+
assert.equal(resolveLayoutMode({ columns: 120, rows: 40 }), LAYOUT_MODES.WIDE);
|
|
31
|
+
assert.equal(resolveLayoutMode({ columns: 80, rows: 24 }), LAYOUT_MODES.COMPACT);
|
|
32
|
+
assert.equal(resolveLayoutMode({ columns: 65, rows: 24 }), LAYOUT_MODES.MINIMAL);
|
|
33
|
+
assert.equal(resolveLayoutMode({ columns: 50, rows: 24 }), null);
|
|
34
|
+
|
|
35
|
+
const caps = resolveTerminalCapabilities({
|
|
36
|
+
columns: 80,
|
|
37
|
+
rows: 24,
|
|
38
|
+
isTTY: true,
|
|
39
|
+
term: "xterm-256color",
|
|
40
|
+
env: { NO_COLOR: "1" }
|
|
41
|
+
});
|
|
42
|
+
assert.equal(caps.color, false);
|
|
43
|
+
assert.equal(caps.canUseInk, true);
|
|
44
|
+
|
|
45
|
+
const session = createFullscreenSession({
|
|
46
|
+
stdout: { isTTY: true, write: () => true },
|
|
47
|
+
processRef: { on() {}, removeListener() {}, exit() {} },
|
|
48
|
+
onSignal: () => {}
|
|
49
|
+
});
|
|
50
|
+
assert.equal(session.enter(), true);
|
|
51
|
+
assert.equal(session.leave(), true);
|
|
52
|
+
assert.equal(session.leave(), false);
|
|
53
|
+
|
|
54
|
+
const top = buildTopBarModel({ projectName: "smoke" });
|
|
55
|
+
assert.match(top.status, /ONLINE|Offline/);
|
|
56
|
+
const mission = buildHomeMissionModel({
|
|
57
|
+
hasGlobalState: false,
|
|
58
|
+
diagnostics: { diagnostics: { detected: 0 } },
|
|
59
|
+
dashboard: { providers: [], recentRuns: [] }
|
|
60
|
+
});
|
|
61
|
+
assert.match(mission.title, /MISSION CONTROL/);
|
|
62
|
+
|
|
63
|
+
function applyKey(state, keyAction) {
|
|
64
|
+
const routed = routeCockpitKey(state, keyAction);
|
|
65
|
+
if (!routed) return state;
|
|
66
|
+
return reduceCockpitUi(state, routed);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function smokeNavigation(layoutMode) {
|
|
70
|
+
let state = createCockpitUiState({
|
|
71
|
+
layoutMode,
|
|
72
|
+
region: layoutMode === LAYOUT_MODES.MINIMAL ? COCKPIT_REGIONS.CONTENT : COCKPIT_REGIONS.NAV
|
|
73
|
+
});
|
|
74
|
+
const diagnosticsIndex = COCKPIT_NAV.findIndex((item) => item.id === "diagnostics");
|
|
75
|
+
while (state.navIndex < diagnosticsIndex) {
|
|
76
|
+
state = applyKey(state, { type: "arrow", direction: "down" });
|
|
77
|
+
}
|
|
78
|
+
state = applyKey(state, { type: "enter" });
|
|
79
|
+
assert.equal(state.view, ORCHESTRATOR_VIEWS.DIAGNOSTICS);
|
|
80
|
+
assert.equal(state.region, COCKPIT_REGIONS.NAV);
|
|
81
|
+
|
|
82
|
+
state = applyKey(state, { type: "arrow", direction: "up" });
|
|
83
|
+
state = applyKey(state, { type: "enter" });
|
|
84
|
+
assert.equal(state.view, ORCHESTRATOR_VIEWS.LAUNCH);
|
|
85
|
+
assert.equal(state.region, COCKPIT_REGIONS.CONTENT);
|
|
86
|
+
|
|
87
|
+
const footer = buildFooterModel({
|
|
88
|
+
view: ORCHESTRATOR_VIEWS.DIAGNOSTICS,
|
|
89
|
+
region: COCKPIT_REGIONS.NAV,
|
|
90
|
+
unicode: false
|
|
91
|
+
});
|
|
92
|
+
assert.doesNotMatch(footer.text, /Tab/);
|
|
93
|
+
|
|
94
|
+
state = applyKey(state, { type: "escape" });
|
|
95
|
+
assert.equal(state.view, ORCHESTRATOR_VIEWS.HOME);
|
|
96
|
+
state = applyKey(state, { type: "escape" });
|
|
97
|
+
assert.equal(state.shouldExit, true);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
smokeNavigation(LAYOUT_MODES.WIDE);
|
|
101
|
+
smokeNavigation(LAYOUT_MODES.COMPACT);
|
|
102
|
+
|
|
103
|
+
console.log("cockpit smoke OK");
|
package/src/cli.js
CHANGED
|
@@ -696,11 +696,11 @@ sections, components, backups, and drift repair under ~/.harness.
|
|
|
696
696
|
Bootstrap: see README.md (curl install.sh or npx ${PACKAGE_NAME}).
|
|
697
697
|
|
|
698
698
|
Usage:
|
|
699
|
-
${cli} First run: onboarding → setup →
|
|
700
|
-
Later:
|
|
699
|
+
${cli} First run: onboarding → setup → cockpit (TTY).
|
|
700
|
+
Later: full-screen cockpit (wide/compact/minimal).
|
|
701
701
|
${cli} --dry-run Setup dry-run (scriptable)
|
|
702
702
|
${cli} --version
|
|
703
|
-
${cli} shell Operations
|
|
703
|
+
${cli} shell Operations cockpit (TTY)
|
|
704
704
|
${cli} run --agent <id> --task "..." [--model <name>] [--cwd <dir>] [--permissions force] [--capture-transcript] [--follow] [--no-wait] [--json]
|
|
705
705
|
${cli} runs list [--json] [--limit <n>] [--active-only]
|
|
706
706
|
${cli} runs show <runId> [--json] [--limit <n>] [--follow]
|
|
@@ -741,8 +741,10 @@ Scopes:
|
|
|
741
741
|
Explicit --scope=workspace only.
|
|
742
742
|
|
|
743
743
|
Commands:
|
|
744
|
-
shell Operations
|
|
745
|
-
is missing, otherwise the
|
|
744
|
+
shell Operations cockpit (TTY). Bare ${cli} opens onboarding when ~/.harness/state.json
|
|
745
|
+
is missing, otherwise the cockpit. Explicit ${cli} shell always opens the cockpit.
|
|
746
|
+
Keys: ↑↓ · Enter · Esc back/exit · R refresh · C cancel · ? help.
|
|
747
|
+
Tab switches region only when content is interactive (runs/launch).
|
|
746
748
|
run Launch a managed agent run with local audit trail.
|
|
747
749
|
runs List, inspect, or cancel agent runs under ~/.harness/runs/.
|
|
748
750
|
orchestrator Read-only capability registry diagnostics (--json supported).
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { COCKPIT_COLORS, statusColor } from "../theme.js";
|
|
4
|
+
|
|
5
|
+
export function CockpitBadge({ label, kind = "ready", colorEnabled = true }) {
|
|
6
|
+
return React.createElement(Text, {
|
|
7
|
+
color: statusColor(kind, { colorEnabled })
|
|
8
|
+
}, label);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function CockpitEmptyState({ title, message, hint }) {
|
|
12
|
+
return React.createElement(Box, { flexDirection: "column", marginY: 1 },
|
|
13
|
+
title && React.createElement(Text, { bold: true, color: COCKPIT_COLORS.secondary }, title),
|
|
14
|
+
message && React.createElement(Text, null, message),
|
|
15
|
+
hint && React.createElement(Text, { color: COCKPIT_COLORS.muted }, hint)
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function CockpitPanel({ title, focused = false, width, children }) {
|
|
20
|
+
return React.createElement(Box, {
|
|
21
|
+
flexDirection: "column",
|
|
22
|
+
width,
|
|
23
|
+
borderStyle: "single",
|
|
24
|
+
borderColor: focused ? COCKPIT_COLORS.primary : COCKPIT_COLORS.muted,
|
|
25
|
+
paddingX: 1,
|
|
26
|
+
flexGrow: 1
|
|
27
|
+
},
|
|
28
|
+
title && React.createElement(Text, {
|
|
29
|
+
bold: true,
|
|
30
|
+
color: focused ? COCKPIT_COLORS.primary : COCKPIT_COLORS.secondary
|
|
31
|
+
}, title),
|
|
32
|
+
children
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function CockpitTopBar({ model, colorEnabled = true }) {
|
|
37
|
+
return React.createElement(Box, { justifyContent: "space-between", width: "100%" },
|
|
38
|
+
React.createElement(Text, {
|
|
39
|
+
bold: true,
|
|
40
|
+
color: colorEnabled ? COCKPIT_COLORS.primary : undefined
|
|
41
|
+
}, `╭─ ${model.brand} ─ ${model.status}`),
|
|
42
|
+
React.createElement(Text, {
|
|
43
|
+
color: colorEnabled ? COCKPIT_COLORS.muted : undefined
|
|
44
|
+
}, `${model.projectLabel} ─╮`)
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function CockpitNav({ model, colorEnabled = true }) {
|
|
49
|
+
return React.createElement(Box, { flexDirection: "column" },
|
|
50
|
+
model.items.map((item) =>
|
|
51
|
+
React.createElement(Text, {
|
|
52
|
+
key: item.id,
|
|
53
|
+
bold: item.focused || item.selected,
|
|
54
|
+
color: item.focused
|
|
55
|
+
? (colorEnabled ? COCKPIT_COLORS.primary : undefined)
|
|
56
|
+
: item.selected
|
|
57
|
+
? (colorEnabled ? COCKPIT_COLORS.secondary : undefined)
|
|
58
|
+
: undefined
|
|
59
|
+
}, `${item.marker} ${item.label}`)
|
|
60
|
+
)
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function CockpitSystemStrip({ model, colorEnabled = true }) {
|
|
65
|
+
return React.createElement(Box, { flexDirection: "column" },
|
|
66
|
+
model.rows.map((row) =>
|
|
67
|
+
React.createElement(Text, { key: row.key },
|
|
68
|
+
React.createElement(Text, { color: COCKPIT_COLORS.muted }, `${row.key.padEnd(7)}`),
|
|
69
|
+
React.createElement(CockpitBadge, {
|
|
70
|
+
label: row.value,
|
|
71
|
+
kind: row.kind,
|
|
72
|
+
colorEnabled
|
|
73
|
+
})
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function CockpitFooter({ model }) {
|
|
80
|
+
return React.createElement(Box, { flexDirection: "column" },
|
|
81
|
+
React.createElement(Text, { color: COCKPIT_COLORS.muted },
|
|
82
|
+
`├${"─".repeat(62)}┤`
|
|
83
|
+
),
|
|
84
|
+
React.createElement(Text, { color: COCKPIT_COLORS.muted }, `│ ${model.text}`),
|
|
85
|
+
React.createElement(Text, { color: COCKPIT_COLORS.muted },
|
|
86
|
+
`╰${"─".repeat(62)}╯`
|
|
87
|
+
)
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function CockpitShell({
|
|
92
|
+
topBar,
|
|
93
|
+
footer,
|
|
94
|
+
layoutMode,
|
|
95
|
+
nav,
|
|
96
|
+
system,
|
|
97
|
+
navFocused,
|
|
98
|
+
contentFocused,
|
|
99
|
+
systemFocused,
|
|
100
|
+
colorEnabled = true,
|
|
101
|
+
children
|
|
102
|
+
}) {
|
|
103
|
+
const showNav = layoutMode === "wide" || layoutMode === "compact";
|
|
104
|
+
const showSystem = layoutMode === "wide";
|
|
105
|
+
|
|
106
|
+
return React.createElement(Box, { flexDirection: "column", width: "100%" },
|
|
107
|
+
React.createElement(CockpitTopBar, { model: topBar, colorEnabled }),
|
|
108
|
+
layoutMode === "minimal" && nav && React.createElement(Box, { marginY: 0 },
|
|
109
|
+
React.createElement(Text, { color: COCKPIT_COLORS.muted }, "Nav: "),
|
|
110
|
+
React.createElement(CockpitNav, { model: nav, colorEnabled })
|
|
111
|
+
),
|
|
112
|
+
React.createElement(Box, { flexDirection: "row", width: "100%" },
|
|
113
|
+
showNav && React.createElement(CockpitPanel, {
|
|
114
|
+
title: nav?.title ?? "NAVIGATION",
|
|
115
|
+
focused: navFocused,
|
|
116
|
+
width: 22
|
|
117
|
+
}, React.createElement(CockpitNav, { model: nav, colorEnabled })),
|
|
118
|
+
React.createElement(CockpitPanel, {
|
|
119
|
+
title: undefined,
|
|
120
|
+
focused: contentFocused,
|
|
121
|
+
width: showSystem ? 48 : showNav ? 56 : "100%"
|
|
122
|
+
}, children),
|
|
123
|
+
showSystem && React.createElement(CockpitPanel, {
|
|
124
|
+
title: system?.title ?? "SYSTEM",
|
|
125
|
+
focused: systemFocused,
|
|
126
|
+
width: 20
|
|
127
|
+
}, React.createElement(CockpitSystemStrip, { model: system, colorEnabled }))
|
|
128
|
+
),
|
|
129
|
+
React.createElement(CockpitFooter, { model: footer })
|
|
130
|
+
);
|
|
131
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import {
|
|
2
|
+
COCKPIT_NAV,
|
|
3
|
+
COCKPIT_REGIONS,
|
|
4
|
+
LAYOUT_MODES,
|
|
5
|
+
ORCHESTRATOR_VIEWS,
|
|
6
|
+
canTabBetweenRegions,
|
|
7
|
+
clamp,
|
|
8
|
+
defaultRegionForView,
|
|
9
|
+
interactiveRegionsFor,
|
|
10
|
+
isContentInteractiveView,
|
|
11
|
+
isNavFocusedView,
|
|
12
|
+
regionsForLayout,
|
|
13
|
+
routeCockpitKey
|
|
14
|
+
} from "./cockpit-focus.js";
|
|
15
|
+
|
|
16
|
+
export {
|
|
17
|
+
canTabBetweenRegions,
|
|
18
|
+
defaultRegionForView,
|
|
19
|
+
isContentInteractiveView,
|
|
20
|
+
isNavFocusedView,
|
|
21
|
+
routeCockpitKey
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Pure cockpit focus / navigation reducer — no I/O, no React.
|
|
26
|
+
*/
|
|
27
|
+
export function createCockpitUiState({
|
|
28
|
+
layoutMode = LAYOUT_MODES.COMPACT,
|
|
29
|
+
view = ORCHESTRATOR_VIEWS.HOME,
|
|
30
|
+
region = COCKPIT_REGIONS.NAV,
|
|
31
|
+
navIndex = 0,
|
|
32
|
+
listIndex = 0,
|
|
33
|
+
helpOpen = false,
|
|
34
|
+
returnView = null
|
|
35
|
+
} = {}) {
|
|
36
|
+
const regions = regionsForLayout(layoutMode);
|
|
37
|
+
const preferred = region ?? defaultRegionForView(view, layoutMode);
|
|
38
|
+
const safeRegion = regions.includes(preferred)
|
|
39
|
+
? preferred
|
|
40
|
+
: defaultRegionForView(view, layoutMode);
|
|
41
|
+
return {
|
|
42
|
+
layoutMode,
|
|
43
|
+
view,
|
|
44
|
+
region: safeRegion,
|
|
45
|
+
navIndex: clamp(navIndex, 0, COCKPIT_NAV.length - 1),
|
|
46
|
+
listIndex: Math.max(0, listIndex),
|
|
47
|
+
helpOpen,
|
|
48
|
+
returnView,
|
|
49
|
+
shouldExit: false
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function reduceCockpitUi(state, action) {
|
|
54
|
+
switch (action.type) {
|
|
55
|
+
case "resize": {
|
|
56
|
+
const regions = regionsForLayout(action.layoutMode);
|
|
57
|
+
const preferred = regions.includes(state.region)
|
|
58
|
+
? state.region
|
|
59
|
+
: defaultRegionForView(state.view, action.layoutMode);
|
|
60
|
+
return { ...state, layoutMode: action.layoutMode, region: preferred };
|
|
61
|
+
}
|
|
62
|
+
case "tab": {
|
|
63
|
+
if (!canTabBetweenRegions(state)) return state;
|
|
64
|
+
const regions = interactiveRegionsFor(state);
|
|
65
|
+
const index = regions.indexOf(state.region);
|
|
66
|
+
const next = regions[(Math.max(index, 0) + 1) % regions.length];
|
|
67
|
+
return { ...state, region: next };
|
|
68
|
+
}
|
|
69
|
+
case "arrow": {
|
|
70
|
+
if (state.region === COCKPIT_REGIONS.SYSTEM) return state;
|
|
71
|
+
|
|
72
|
+
const navigatesNav = state.region === COCKPIT_REGIONS.NAV
|
|
73
|
+
|| isNavFocusedView(state.view)
|
|
74
|
+
|| (state.view === ORCHESTRATOR_VIEWS.HOME
|
|
75
|
+
&& state.layoutMode === LAYOUT_MODES.MINIMAL);
|
|
76
|
+
|
|
77
|
+
if (navigatesNav) {
|
|
78
|
+
const delta = action.direction === "up" ? -1 : 1;
|
|
79
|
+
return {
|
|
80
|
+
...state,
|
|
81
|
+
navIndex: clamp(state.navIndex + delta, 0, COCKPIT_NAV.length - 1)
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!isContentInteractiveView(state.view) || state.view === ORCHESTRATOR_VIEWS.LAUNCH) {
|
|
86
|
+
return state;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const delta = action.direction === "up" ? -1 : 1;
|
|
90
|
+
const max = Math.max(0, (action.listLength ?? 1) - 1);
|
|
91
|
+
return { ...state, listIndex: clamp(state.listIndex + delta, 0, max) };
|
|
92
|
+
}
|
|
93
|
+
case "enter-nav": {
|
|
94
|
+
const item = COCKPIT_NAV[state.navIndex];
|
|
95
|
+
if (!item) return state;
|
|
96
|
+
return {
|
|
97
|
+
...state,
|
|
98
|
+
view: item.view,
|
|
99
|
+
listIndex: 0,
|
|
100
|
+
region: defaultRegionForView(item.view, state.layoutMode),
|
|
101
|
+
helpOpen: false,
|
|
102
|
+
returnView: null
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
case "set-view": {
|
|
106
|
+
const nextView = action.view;
|
|
107
|
+
return {
|
|
108
|
+
...state,
|
|
109
|
+
view: nextView,
|
|
110
|
+
listIndex: 0,
|
|
111
|
+
region: action.region ?? defaultRegionForView(nextView, state.layoutMode),
|
|
112
|
+
returnView: action.returnView ?? state.returnView,
|
|
113
|
+
helpOpen: nextView === ORCHESTRATOR_VIEWS.HELP
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
case "toggle-help":
|
|
117
|
+
if (state.helpOpen || state.view === ORCHESTRATOR_VIEWS.HELP) {
|
|
118
|
+
return goOverview(state);
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
...state,
|
|
122
|
+
helpOpen: true,
|
|
123
|
+
view: ORCHESTRATOR_VIEWS.HELP,
|
|
124
|
+
region: defaultRegionForView(ORCHESTRATOR_VIEWS.HELP, state.layoutMode)
|
|
125
|
+
};
|
|
126
|
+
case "escape": {
|
|
127
|
+
if (state.helpOpen || state.view === ORCHESTRATOR_VIEWS.HELP) {
|
|
128
|
+
return goOverview(state);
|
|
129
|
+
}
|
|
130
|
+
if (state.view === ORCHESTRATOR_VIEWS.RUN_DETAIL) {
|
|
131
|
+
const back = state.returnView && isContentInteractiveView(state.returnView)
|
|
132
|
+
? state.returnView
|
|
133
|
+
: ORCHESTRATOR_VIEWS.ACTIVE_RUNS;
|
|
134
|
+
return {
|
|
135
|
+
...state,
|
|
136
|
+
view: back,
|
|
137
|
+
listIndex: 0,
|
|
138
|
+
region: defaultRegionForView(back, state.layoutMode),
|
|
139
|
+
returnView: null
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
if (state.view !== ORCHESTRATOR_VIEWS.HOME) {
|
|
143
|
+
return goOverview(state);
|
|
144
|
+
}
|
|
145
|
+
return { ...state, shouldExit: true };
|
|
146
|
+
}
|
|
147
|
+
case "clear-exit":
|
|
148
|
+
return { ...state, shouldExit: false };
|
|
149
|
+
default:
|
|
150
|
+
return state;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function resolveNavAction(navIndex) {
|
|
155
|
+
return COCKPIT_NAV[navIndex] ?? null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function goOverview(state) {
|
|
159
|
+
return {
|
|
160
|
+
...state,
|
|
161
|
+
helpOpen: false,
|
|
162
|
+
view: ORCHESTRATOR_VIEWS.HOME,
|
|
163
|
+
navIndex: 0,
|
|
164
|
+
listIndex: 0,
|
|
165
|
+
region: defaultRegionForView(ORCHESTRATOR_VIEWS.HOME, state.layoutMode),
|
|
166
|
+
returnView: null
|
|
167
|
+
};
|
|
168
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { COCKPIT_NAV, COCKPIT_REGIONS, regionsForLayout } from "./cockpit-models.js";
|
|
2
|
+
import { ORCHESTRATOR_VIEWS } from "./orchestrator-state.js";
|
|
3
|
+
import { LAYOUT_MODES } from "./layout.js";
|
|
4
|
+
|
|
5
|
+
const NAV_FOCUSED_VIEWS = new Set([
|
|
6
|
+
ORCHESTRATOR_VIEWS.HOME,
|
|
7
|
+
ORCHESTRATOR_VIEWS.PROVIDERS,
|
|
8
|
+
ORCHESTRATOR_VIEWS.DIAGNOSTICS,
|
|
9
|
+
ORCHESTRATOR_VIEWS.HELP
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
const CONTENT_INTERACTIVE_VIEWS = new Set([
|
|
13
|
+
ORCHESTRATOR_VIEWS.ACTIVE_RUNS,
|
|
14
|
+
ORCHESTRATOR_VIEWS.RECENT_RUNS,
|
|
15
|
+
ORCHESTRATOR_VIEWS.RUN_DETAIL,
|
|
16
|
+
ORCHESTRATOR_VIEWS.LAUNCH
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
export function isNavFocusedView(view) {
|
|
20
|
+
return NAV_FOCUSED_VIEWS.has(view);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isContentInteractiveView(view) {
|
|
24
|
+
return CONTENT_INTERACTIVE_VIEWS.has(view);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function defaultRegionForView(view, layoutMode = LAYOUT_MODES.COMPACT) {
|
|
28
|
+
const regions = regionsForLayout(layoutMode);
|
|
29
|
+
if (isContentInteractiveView(view) && regions.includes(COCKPIT_REGIONS.CONTENT)) {
|
|
30
|
+
return COCKPIT_REGIONS.CONTENT;
|
|
31
|
+
}
|
|
32
|
+
if (regions.includes(COCKPIT_REGIONS.NAV)) return COCKPIT_REGIONS.NAV;
|
|
33
|
+
return regions[0] ?? COCKPIT_REGIONS.CONTENT;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function interactiveRegionsFor(state) {
|
|
37
|
+
const regions = regionsForLayout(state.layoutMode);
|
|
38
|
+
if (!isContentInteractiveView(state.view) || state.view === ORCHESTRATOR_VIEWS.RUN_DETAIL) {
|
|
39
|
+
return regions.filter((region) => region === COCKPIT_REGIONS.NAV);
|
|
40
|
+
}
|
|
41
|
+
return regions.filter(
|
|
42
|
+
(region) => region === COCKPIT_REGIONS.NAV || region === COCKPIT_REGIONS.CONTENT
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function canTabBetweenRegions(state) {
|
|
47
|
+
if (!isContentInteractiveView(state.view)) return false;
|
|
48
|
+
if (state.view === ORCHESTRATOR_VIEWS.RUN_DETAIL) return false;
|
|
49
|
+
return interactiveRegionsFor(state).length >= 2;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Map raw key intent to a reducer action (or null when inactive / view-owned).
|
|
54
|
+
*/
|
|
55
|
+
export function routeCockpitKey(state, keyAction) {
|
|
56
|
+
switch (keyAction.type) {
|
|
57
|
+
case "escape":
|
|
58
|
+
return { type: "escape" };
|
|
59
|
+
case "tab":
|
|
60
|
+
return canTabBetweenRegions(state) ? { type: "tab" } : null;
|
|
61
|
+
case "arrow": {
|
|
62
|
+
if (state.region === COCKPIT_REGIONS.SYSTEM) return null;
|
|
63
|
+
if (state.region === COCKPIT_REGIONS.NAV || isNavFocusedView(state.view)) {
|
|
64
|
+
return { type: "arrow", direction: keyAction.direction };
|
|
65
|
+
}
|
|
66
|
+
if (state.region === COCKPIT_REGIONS.CONTENT && isContentInteractiveView(state.view)) {
|
|
67
|
+
if (state.view === ORCHESTRATOR_VIEWS.RUN_DETAIL || state.view === ORCHESTRATOR_VIEWS.LAUNCH) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
type: "arrow",
|
|
72
|
+
direction: keyAction.direction,
|
|
73
|
+
listLength: keyAction.listLength
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
case "enter":
|
|
79
|
+
if (state.region === COCKPIT_REGIONS.NAV || isNavFocusedView(state.view)) {
|
|
80
|
+
return { type: "enter-nav" };
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
default:
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function clamp(value, min, max) {
|
|
89
|
+
return Math.min(max, Math.max(min, value));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export { COCKPIT_NAV, COCKPIT_REGIONS, LAYOUT_MODES, ORCHESTRATOR_VIEWS, regionsForLayout };
|