@outputai/cli 0.3.2-next.5e221e8.0 → 0.3.3-next.b23002f.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/dist/assets/docker/docker-compose-dev.yml +1 -1
- package/dist/commands/dev/index.js +48 -13
- package/dist/commands/dev/index.spec.js +1 -2
- package/dist/components/status_icon.js +1 -1
- package/dist/generated/framework_version.json +1 -1
- package/dist/services/docker.js +0 -1
- package/dist/utils/paths.d.ts +11 -0
- package/dist/utils/paths.js +14 -0
- package/dist/utils/scenario_resolver.js +3 -2
- package/dist/views/dev/chrome/divider.d.ts +8 -0
- package/dist/views/dev/chrome/divider.js +16 -0
- package/dist/views/dev/chrome/footer.d.ts +11 -0
- package/dist/views/dev/chrome/footer.js +10 -0
- package/dist/views/dev/chrome/header.d.ts +21 -0
- package/dist/views/dev/chrome/header.js +74 -0
- package/dist/views/dev/chrome/header.spec.d.ts +1 -0
- package/dist/views/dev/chrome/header.spec.js +50 -0
- package/dist/views/dev/chrome/loading_spinner.d.ts +9 -0
- package/dist/views/dev/chrome/loading_spinner.js +9 -0
- package/dist/views/dev/chrome/palette.d.ts +16 -0
- package/dist/views/dev/chrome/palette.js +16 -0
- package/dist/views/dev/chrome/search_bar.d.ts +5 -0
- package/dist/views/dev/chrome/search_bar.js +35 -0
- package/dist/views/dev/chrome/selection_indicator.d.ts +14 -0
- package/dist/views/dev/chrome/selection_indicator.js +13 -0
- package/dist/views/dev/chrome/tab_bar.d.ts +5 -0
- package/dist/views/dev/chrome/tab_bar.js +4 -0
- package/dist/views/dev/chrome/toasts.d.ts +2 -0
- package/dist/views/dev/chrome/toasts.js +40 -0
- package/dist/views/dev/components/master_detail_panel.d.ts +21 -0
- package/dist/views/dev/components/master_detail_panel.js +18 -0
- package/dist/views/{dev.d.ts → dev/dev_app.d.ts} +1 -0
- package/dist/views/dev/dev_app.js +146 -0
- package/dist/views/dev/hooks/use_docker_logs.d.ts +7 -0
- package/dist/views/dev/hooks/use_docker_logs.js +69 -0
- package/dist/views/dev/hooks/use_poll.d.ts +16 -0
- package/dist/views/dev/hooks/use_poll.js +95 -0
- package/dist/views/dev/hooks/use_run_detail.d.ts +21 -0
- package/dist/views/dev/hooks/use_run_detail.js +153 -0
- package/dist/views/dev/hooks/use_run_detail.spec.d.ts +1 -0
- package/dist/views/dev/hooks/use_run_detail.spec.js +86 -0
- package/dist/views/dev/hooks/use_workflow_catalog.d.ts +2 -0
- package/dist/views/dev/hooks/use_workflow_catalog.js +21 -0
- package/dist/views/dev/modals/expanded_json_modal.d.ts +2 -0
- package/dist/views/dev/modals/expanded_json_modal.js +44 -0
- package/dist/views/dev/modals/run_modal.d.ts +4 -0
- package/dist/views/dev/modals/run_modal.js +213 -0
- package/dist/views/dev/panels/help_panel.d.ts +2 -0
- package/dist/views/dev/panels/help_panel.js +53 -0
- package/dist/views/dev/panels/run_detail_view.d.ts +5 -0
- package/dist/views/dev/panels/run_detail_view.js +112 -0
- package/dist/views/dev/panels/runs_panel.d.ts +8 -0
- package/dist/views/dev/panels/runs_panel.js +204 -0
- package/dist/views/dev/panels/runs_panel.spec.d.ts +1 -0
- package/dist/views/dev/panels/runs_panel.spec.js +82 -0
- package/dist/views/dev/panels/services_panel.d.ts +14 -0
- package/dist/views/dev/panels/services_panel.js +155 -0
- package/dist/views/dev/panels/services_panel.spec.d.ts +1 -0
- package/dist/views/dev/panels/services_panel.spec.js +28 -0
- package/dist/views/dev/panels/workflows_panel.d.ts +7 -0
- package/dist/views/dev/panels/workflows_panel.js +111 -0
- package/dist/views/dev/services/docker_control.d.ts +5 -0
- package/dist/views/dev/services/docker_control.js +25 -0
- package/dist/views/dev/services/run_workflow.d.ts +10 -0
- package/dist/views/dev/services/run_workflow.js +14 -0
- package/dist/views/dev/services/scenario_io.d.ts +2 -0
- package/dist/views/dev/services/scenario_io.js +37 -0
- package/dist/views/dev/state/ui_state.d.ts +60 -0
- package/dist/views/dev/state/ui_state.js +64 -0
- package/dist/views/dev/utils/constants.d.ts +17 -0
- package/dist/views/dev/utils/constants.js +17 -0
- package/dist/views/dev/utils/json_editor.d.ts +21 -0
- package/dist/views/dev/utils/json_editor.js +117 -0
- package/dist/views/dev/utils/json_editor.spec.d.ts +1 -0
- package/dist/views/dev/utils/json_editor.spec.js +57 -0
- package/dist/views/dev/utils/json_render.d.ts +15 -0
- package/dist/views/dev/utils/json_render.js +77 -0
- package/dist/views/dev/utils/json_render.spec.d.ts +1 -0
- package/dist/views/dev/utils/json_render.spec.js +65 -0
- package/dist/views/dev/utils/panel_helpers.d.ts +16 -0
- package/dist/views/dev/utils/panel_helpers.js +32 -0
- package/dist/views/dev/utils/panel_helpers.spec.d.ts +1 -0
- package/dist/views/dev/utils/panel_helpers.spec.js +47 -0
- package/package.json +5 -5
- package/dist/components/command_footer.d.ts +0 -8
- package/dist/components/command_footer.js +0 -4
- package/dist/components/workflow_summary.d.ts +0 -10
- package/dist/components/workflow_summary.js +0 -4
- package/dist/views/dev.js +0 -187
- package/dist/views/workflow/list.d.ts +0 -6
- package/dist/views/workflow/list.js +0 -129
|
@@ -6,7 +6,7 @@ import React from 'react';
|
|
|
6
6
|
import { validateDockerEnvironment, startDockerCompose, startDockerComposeDetached, stopDockerCompose, DockerComposeConfigNotFoundError, getDefaultDockerComposePath } from '#services/docker.js';
|
|
7
7
|
import { getErrorMessage } from '#utils/error_utils.js';
|
|
8
8
|
import { ensureClaudePlugin } from '#services/coding_agents.js';
|
|
9
|
-
import { DevApp } from '#views/dev.js';
|
|
9
|
+
import { DevApp } from '#views/dev/dev_app.js';
|
|
10
10
|
import { config } from '#config.js';
|
|
11
11
|
export default class Dev extends Command {
|
|
12
12
|
static description = [
|
|
@@ -57,11 +57,6 @@ export default class Dev extends Command {
|
|
|
57
57
|
catch {
|
|
58
58
|
throw new DockerComposeConfigNotFoundError(dockerComposePath);
|
|
59
59
|
}
|
|
60
|
-
this.log('\n🚀 Starting Output development services...\n');
|
|
61
|
-
this.log(`Docker project name: ${config.dockerServiceName}\n`);
|
|
62
|
-
if (flags['compose-file']) {
|
|
63
|
-
this.log(`Using custom docker-compose file: ${flags['compose-file']}\n`);
|
|
64
|
-
}
|
|
65
60
|
const pullPolicy = flags['image-pull-policy'];
|
|
66
61
|
if (flags.detached) {
|
|
67
62
|
this.log('🐳 Starting services in detached mode...\n');
|
|
@@ -69,7 +64,6 @@ export default class Dev extends Command {
|
|
|
69
64
|
this.log('✅ Services started. Run `output dev` without --detached to monitor status.\n');
|
|
70
65
|
return;
|
|
71
66
|
}
|
|
72
|
-
this.log('File watching enabled - worker will restart automatically on changes\n');
|
|
73
67
|
const cleanup = async () => {
|
|
74
68
|
this.log('\n');
|
|
75
69
|
if (this.dockerProcess) {
|
|
@@ -77,22 +71,63 @@ export default class Dev extends Command {
|
|
|
77
71
|
}
|
|
78
72
|
await stopDockerCompose(dockerComposePath);
|
|
79
73
|
};
|
|
74
|
+
// INK paints onto the alternate screen buffer so log-update has a
|
|
75
|
+
// fixed-height canvas and doesn't scroll old frames into the user's
|
|
76
|
+
// scrollback when the rendered tree exceeds the visible terminal rows.
|
|
77
|
+
const enterAltScreen = () => {
|
|
78
|
+
process.stdout.write('\x1b[?1049h\x1b[2J\x1b[H');
|
|
79
|
+
};
|
|
80
|
+
const exitAltScreen = () => {
|
|
81
|
+
process.stdout.write('\x1b[?1049l');
|
|
82
|
+
};
|
|
83
|
+
// Idempotent so repeated SIGINTs / process.exit don't re-emit the leave
|
|
84
|
+
// sequence (which produces visible garbage in some terminals).
|
|
85
|
+
const exitAltScreenOnce = (() => {
|
|
86
|
+
const state = { fired: false };
|
|
87
|
+
return () => {
|
|
88
|
+
if (state.fired) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
state.fired = true;
|
|
92
|
+
exitAltScreen();
|
|
93
|
+
};
|
|
94
|
+
})();
|
|
95
|
+
// Register cleanup before anything that can throw or get signaled. The
|
|
96
|
+
// `instance` ref is filled in once `render()` returns; until then,
|
|
97
|
+
// signal handlers just stop docker and exit.
|
|
98
|
+
const instanceRef = { current: null };
|
|
99
|
+
process.on('exit', exitAltScreenOnce);
|
|
100
|
+
// `process.on` doesn't await the handler, so the cleanup promise would
|
|
101
|
+
// float and any rejection would surface as an unhandled rejection.
|
|
102
|
+
// Wrap the async work in a sync registration that explicitly logs
|
|
103
|
+
// failures and always unmounts Ink afterwards. Exit the alt-screen
|
|
104
|
+
// first inside the catch — Ink still owns the alt-buffer until
|
|
105
|
+
// `unmount()` runs, so a bare `console.error` would paint into a
|
|
106
|
+
// buffer the user never sees.
|
|
107
|
+
const handleSignal = () => {
|
|
108
|
+
cleanup()
|
|
109
|
+
.catch(err => {
|
|
110
|
+
exitAltScreenOnce();
|
|
111
|
+
console.error('Cleanup failed:', getErrorMessage(err));
|
|
112
|
+
})
|
|
113
|
+
.finally(() => instanceRef.current?.unmount());
|
|
114
|
+
};
|
|
115
|
+
process.on('SIGINT', handleSignal);
|
|
116
|
+
process.on('SIGTERM', handleSignal);
|
|
80
117
|
try {
|
|
81
118
|
const { process: dockerProc } = await startDockerCompose(dockerComposePath, pullPolicy);
|
|
82
119
|
this.dockerProcess = dockerProc;
|
|
120
|
+
enterAltScreen();
|
|
83
121
|
const instance = render(React.createElement(DevApp, { dockerComposePath, onCleanup: cleanup }), { exitOnCtrlC: false });
|
|
122
|
+
instanceRef.current = instance;
|
|
84
123
|
dockerProc.on('error', error => {
|
|
85
124
|
instance.unmount(new Error(`Docker process error: ${getErrorMessage(error)}`));
|
|
86
125
|
});
|
|
87
|
-
const handleSignal = async () => {
|
|
88
|
-
await cleanup();
|
|
89
|
-
instance.unmount();
|
|
90
|
-
};
|
|
91
|
-
process.on('SIGINT', handleSignal);
|
|
92
|
-
process.on('SIGTERM', handleSignal);
|
|
93
126
|
await instance.waitUntilExit();
|
|
127
|
+
exitAltScreenOnce();
|
|
94
128
|
}
|
|
95
129
|
catch (error) {
|
|
130
|
+
exitAltScreenOnce();
|
|
96
131
|
this.error(getErrorMessage(error), { exit: 1 });
|
|
97
132
|
}
|
|
98
133
|
}
|
|
@@ -41,7 +41,7 @@ vi.mock('ink', () => ({
|
|
|
41
41
|
unmount: vi.fn()
|
|
42
42
|
})
|
|
43
43
|
}));
|
|
44
|
-
vi.mock('#views/dev.js', () => ({
|
|
44
|
+
vi.mock('#views/dev/dev_app.js', () => ({
|
|
45
45
|
DevApp: () => null
|
|
46
46
|
}));
|
|
47
47
|
const createMockDockerProcess = () => ({
|
|
@@ -184,7 +184,6 @@ describe('dev command', () => {
|
|
|
184
184
|
await new Promise(resolve => setImmediate(resolve));
|
|
185
185
|
expect(dockerService.startDockerCompose).toHaveBeenCalledWith('/path/to/docker-compose-dev.yml', 'always' // default pull policy
|
|
186
186
|
);
|
|
187
|
-
expect(cmd.log).toHaveBeenCalledWith(expect.stringContaining('File watching enabled'));
|
|
188
187
|
// Cancel the promise (it will be rejected but we don't care)
|
|
189
188
|
runPromise.catch(() => { });
|
|
190
189
|
});
|
|
@@ -8,7 +8,7 @@ const STATUS_MAP = {
|
|
|
8
8
|
none: { icon: '●', color: 'blue' },
|
|
9
9
|
exited: { icon: '✗', color: 'red' },
|
|
10
10
|
// Workflow run status
|
|
11
|
-
running: { icon: '●', color: '
|
|
11
|
+
running: { icon: '●', color: 'green' },
|
|
12
12
|
completed: { icon: '●', color: 'green' },
|
|
13
13
|
failed: { icon: '✗', color: 'red' },
|
|
14
14
|
canceled: { icon: '○', color: 'gray' },
|
package/dist/services/docker.js
CHANGED
|
@@ -133,7 +133,6 @@ export async function startDockerCompose(dockerComposePath, pullPolicy) {
|
|
|
133
133
|
if (pullPolicy) {
|
|
134
134
|
args.push('--pull', pullPolicy);
|
|
135
135
|
}
|
|
136
|
-
ux.stdout('🐳 Starting Docker services...\n');
|
|
137
136
|
const dockerProcess = spawn('docker', args, {
|
|
138
137
|
cwd: process.cwd(),
|
|
139
138
|
stdio: ['ignore', 'pipe', 'pipe']
|
package/dist/utils/paths.d.ts
CHANGED
|
@@ -15,6 +15,17 @@ export declare const DEFAULT_OUTPUT_DIRS: {
|
|
|
15
15
|
* Resolve the output directory path
|
|
16
16
|
*/
|
|
17
17
|
export declare function resolveOutputDir(outputDir: string): string;
|
|
18
|
+
/**
|
|
19
|
+
* Resolve the project root that holds `src/workflows` / `workflows`.
|
|
20
|
+
*
|
|
21
|
+
* When the CLI is invoked from inside the workflow project (the common
|
|
22
|
+
* case for `output workflow run` and friends) `process.cwd()` already
|
|
23
|
+
* points at the right place. When it's invoked from the repo root with
|
|
24
|
+
* `OUTPUT_WORKFLOWS_DIR=test_workflows` (how `npm run dev` drives
|
|
25
|
+
* `output dev` in this repo), we need to descend into that subdirectory
|
|
26
|
+
* before searching for scenarios.
|
|
27
|
+
*/
|
|
28
|
+
export declare function getWorkflowsBasePath(): string;
|
|
18
29
|
/**
|
|
19
30
|
* Create target directory path for a workflow
|
|
20
31
|
*/
|
package/dist/utils/paths.js
CHANGED
|
@@ -21,6 +21,20 @@ export const DEFAULT_OUTPUT_DIRS = {
|
|
|
21
21
|
export function resolveOutputDir(outputDir) {
|
|
22
22
|
return path.resolve(process.cwd(), outputDir);
|
|
23
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* Resolve the project root that holds `src/workflows` / `workflows`.
|
|
26
|
+
*
|
|
27
|
+
* When the CLI is invoked from inside the workflow project (the common
|
|
28
|
+
* case for `output workflow run` and friends) `process.cwd()` already
|
|
29
|
+
* points at the right place. When it's invoked from the repo root with
|
|
30
|
+
* `OUTPUT_WORKFLOWS_DIR=test_workflows` (how `npm run dev` drives
|
|
31
|
+
* `output dev` in this repo), we need to descend into that subdirectory
|
|
32
|
+
* before searching for scenarios.
|
|
33
|
+
*/
|
|
34
|
+
export function getWorkflowsBasePath() {
|
|
35
|
+
const subdir = process.env.OUTPUT_WORKFLOWS_DIR;
|
|
36
|
+
return subdir ? path.resolve(process.cwd(), subdir) : process.cwd();
|
|
37
|
+
}
|
|
24
38
|
/**
|
|
25
39
|
* Create target directory path for a workflow
|
|
26
40
|
*/
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readdirSync } from 'node:fs';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
3
|
import { getWorkflowCatalog } from '#api/generated/api.js';
|
|
4
|
+
import { getWorkflowsBasePath } from '#utils/paths.js';
|
|
4
5
|
const SCENARIOS_DIR = 'scenarios';
|
|
5
6
|
const WORKFLOWS_PATHS = ['src/workflows', 'workflows'];
|
|
6
7
|
export function extractWorkflowRelativePath(path) {
|
|
@@ -40,7 +41,7 @@ function resolveScenarioFromDirectory(relativeDir, scenarioFileName, basePath) {
|
|
|
40
41
|
}
|
|
41
42
|
return { found: false, searchedPaths };
|
|
42
43
|
}
|
|
43
|
-
export async function resolveScenarioPath(workflowName, scenarioName, basePath =
|
|
44
|
+
export async function resolveScenarioPath(workflowName, scenarioName, basePath = getWorkflowsBasePath()) {
|
|
44
45
|
const scenarioFileName = scenarioName.endsWith('.json') ?
|
|
45
46
|
scenarioName :
|
|
46
47
|
`${scenarioName}.json`;
|
|
@@ -65,7 +66,7 @@ export async function resolveScenarioPath(workflowName, scenarioName, basePath =
|
|
|
65
66
|
// API unavailable or workflow not in catalog — fall back to convention
|
|
66
67
|
return resolveScenarioFromDirectory(workflowName, scenarioFileName, basePath);
|
|
67
68
|
}
|
|
68
|
-
export function listScenariosForWorkflow(workflowName, workflowPath, basePath =
|
|
69
|
+
export function listScenariosForWorkflow(workflowName, workflowPath, basePath = getWorkflowsBasePath()) {
|
|
69
70
|
const relativeDir = (workflowPath && extractWorkflowRelativePath(workflowPath)) || workflowName;
|
|
70
71
|
for (const workflowsDir of WORKFLOWS_PATHS) {
|
|
71
72
|
const scenariosDir = resolve(basePath, workflowsDir, relativeDir, SCENARIOS_DIR);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useStdout } from 'ink';
|
|
3
|
+
import { RULE_PURPLE } from '#views/dev/chrome/palette.js';
|
|
4
|
+
// `RULE_PURPLE` is part of the OUTPUT brand chrome — kept as the default
|
|
5
|
+
// for horizontal rules. Vertical rules pass through whatever the caller
|
|
6
|
+
// supplies; default is undefined, which Ink renders in the terminal's
|
|
7
|
+
// default foreground colour (theme-agnostic).
|
|
8
|
+
const DEFAULT_RULE_COLOR = RULE_PURPLE;
|
|
9
|
+
// dev_app.tsx Shell uses paddingX={2}, so 4 cols are eaten by horizontal padding.
|
|
10
|
+
const SHELL_HORIZONTAL_PADDING = 4;
|
|
11
|
+
export const HorizontalRule = ({ color = DEFAULT_RULE_COLOR, widthOffset = SHELL_HORIZONTAL_PADDING }) => {
|
|
12
|
+
const { stdout } = useStdout();
|
|
13
|
+
const cols = stdout?.columns ?? 80;
|
|
14
|
+
return _jsx(Text, { color: color, children: '─'.repeat(Math.max(1, cols - widthOffset)) });
|
|
15
|
+
};
|
|
16
|
+
export const VerticalRule = ({ color }) => (_jsx(Box, { borderStyle: "single", borderColor: color, borderTop: false, borderBottom: false, borderRight: false, flexDirection: "column" }));
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
export interface CommandHint {
|
|
3
|
+
key: string;
|
|
4
|
+
label: string;
|
|
5
|
+
}
|
|
6
|
+
export declare const Footer: React.FC<{
|
|
7
|
+
hints: CommandHint[];
|
|
8
|
+
itemCount?: number;
|
|
9
|
+
itemLabel?: string;
|
|
10
|
+
}>;
|
|
11
|
+
export declare const GLOBAL_HINTS: CommandHint[];
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { Box, Text } from 'ink';
|
|
4
|
+
export const Footer = ({ hints, itemCount, itemLabel = 'items' }) => (_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", marginTop: 1, children: [_jsx(Box, { flexDirection: "row", children: hints.map((hint, i) => (_jsxs(React.Fragment, { children: [i > 0 && _jsx(Text, { dimColor: true, children: ' ' }), _jsx(Text, { bold: true, children: hint.key }), _jsx(Text, { dimColor: true, children: ` ${hint.label}` })] }, hint.key))) }), typeof itemCount === 'number' && (_jsx(Box, { children: _jsxs(Text, { dimColor: true, children: [itemCount, " ", itemLabel] }) }))] }));
|
|
5
|
+
export const GLOBAL_HINTS = [
|
|
6
|
+
{ key: 'tab', label: 'next tab' },
|
|
7
|
+
{ key: '/', label: 'search' },
|
|
8
|
+
{ key: '?', label: 'help' },
|
|
9
|
+
{ key: 'ctrl+c', label: 'quit' }
|
|
10
|
+
];
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
export interface WorkflowSummary {
|
|
3
|
+
running: number;
|
|
4
|
+
completed: number;
|
|
5
|
+
failed: number;
|
|
6
|
+
total: number;
|
|
7
|
+
}
|
|
8
|
+
export declare const compressPixels: (rows: string[]) => string[];
|
|
9
|
+
export type ServiceBadge = 'healthy' | 'starting' | 'failed';
|
|
10
|
+
export interface HeaderCounters {
|
|
11
|
+
running: number;
|
|
12
|
+
failed: number;
|
|
13
|
+
totalWorkflows: number;
|
|
14
|
+
totalRuns: number;
|
|
15
|
+
serviceBadge: ServiceBadge;
|
|
16
|
+
failingServices: number;
|
|
17
|
+
}
|
|
18
|
+
export declare const buildSummaryCounters: (summary: WorkflowSummary | null, totalWorkflows: number, serviceBadge?: ServiceBadge, failingServices?: number) => HeaderCounters;
|
|
19
|
+
export declare const Header: React.FC<{
|
|
20
|
+
counters: HeaderCounters;
|
|
21
|
+
}>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useStdout } from 'ink';
|
|
3
|
+
import Spinner from 'ink-spinner';
|
|
4
|
+
import { LOGO_GRADIENT, PURPLE_100 } from '#views/dev/chrome/palette.js';
|
|
5
|
+
const LOGO_PIXELS = [
|
|
6
|
+
' ██████ ██ ██ ████████ ██████ ██ ██ ████████',
|
|
7
|
+
'██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ',
|
|
8
|
+
'██ ██ ██ ██ ██ ██████ ██ ██ ██ ',
|
|
9
|
+
'██ ██ ██ ██ ██ ██ ██ ██ ██ ',
|
|
10
|
+
' ██████ ██████ ██ ██ ██████ ██ '
|
|
11
|
+
];
|
|
12
|
+
const QUADRANT_CHARS = [
|
|
13
|
+
' ', '▗', '▖', '▄',
|
|
14
|
+
'▝', '▐', '▞', '▟',
|
|
15
|
+
'▘', '▚', '▌', '▙',
|
|
16
|
+
'▀', '▜', '▛', '█'
|
|
17
|
+
];
|
|
18
|
+
export const compressPixels = (rows) => {
|
|
19
|
+
const maxCol = Math.max(...rows.map(r => r.length));
|
|
20
|
+
const evenCol = maxCol + (maxCol % 2);
|
|
21
|
+
const padded = rows.map(r => r.padEnd(evenCol, ' '));
|
|
22
|
+
const fullPadded = padded.length % 2 === 1 ?
|
|
23
|
+
[...padded, ' '.repeat(evenCol)] :
|
|
24
|
+
padded;
|
|
25
|
+
const rowPairs = fullPadded.reduce((acc, row, i) => {
|
|
26
|
+
if (i % 2 === 0) {
|
|
27
|
+
acc.push([row, fullPadded[i + 1]]);
|
|
28
|
+
}
|
|
29
|
+
return acc;
|
|
30
|
+
}, []);
|
|
31
|
+
const colCount = Math.floor(evenCol / 2);
|
|
32
|
+
return rowPairs.map(([top, bot]) => {
|
|
33
|
+
const chars = Array.from({ length: colCount }, (_, k) => {
|
|
34
|
+
const j = k * 2;
|
|
35
|
+
const tl = top[j] === '█' ? 8 : 0;
|
|
36
|
+
const tr = top[j + 1] === '█' ? 4 : 0;
|
|
37
|
+
const bl = bot[j] === '█' ? 2 : 0;
|
|
38
|
+
const br = bot[j + 1] === '█' ? 1 : 0;
|
|
39
|
+
return QUADRANT_CHARS[tl + tr + bl + br];
|
|
40
|
+
});
|
|
41
|
+
return chars.join('');
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
const LOGO_COMPRESSED = compressPixels(LOGO_PIXELS);
|
|
45
|
+
const FULL_WIDTH_THRESHOLD = 60;
|
|
46
|
+
const ServicesBadge = ({ badge, failingCount }) => {
|
|
47
|
+
if (badge === 'failed') {
|
|
48
|
+
return (_jsxs(Text, { color: "red", bold: true, children: ["\u26A0 ", failingCount, " service", failingCount === 1 ? '' : 's', " down"] }));
|
|
49
|
+
}
|
|
50
|
+
if (badge === 'starting') {
|
|
51
|
+
return (_jsxs(_Fragment, { children: [_jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " services" })] }));
|
|
52
|
+
}
|
|
53
|
+
return (_jsxs(_Fragment, { children: [_jsx(Text, { color: "green", children: "\u25CF " }), _jsx(Text, { children: "services" })] }));
|
|
54
|
+
};
|
|
55
|
+
const Counters = ({ counters }) => (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { marginRight: 3, children: _jsx(ServicesBadge, { badge: counters.serviceBadge, failingCount: counters.failingServices }) }), counters.running > 0 && (_jsxs(Box, { marginRight: 3, children: [_jsx(Text, { color: "blue", children: "\u25CF " }), _jsxs(Text, { bold: true, children: [counters.running, " "] }), _jsx(Text, { children: "running" })] })), counters.failed > 0 && (_jsxs(Box, { marginRight: 3, children: [_jsx(Text, { color: "red", children: "\u2717 " }), _jsxs(Text, { color: "red", bold: true, children: [counters.failed, " "] }), _jsx(Text, { color: "red", children: "failed" })] })), _jsx(Box, { children: _jsxs(Text, { dimColor: true, children: [counters.totalWorkflows, " workflows \u00B7 ", counters.totalRuns, " runs"] }) })] }));
|
|
56
|
+
const Logo = ({ cols }) => {
|
|
57
|
+
if (cols < FULL_WIDTH_THRESHOLD) {
|
|
58
|
+
return _jsx(Text, { color: PURPLE_100, bold: true, children: "OUTPUT" });
|
|
59
|
+
}
|
|
60
|
+
return (_jsx(Box, { flexDirection: "column", children: LOGO_COMPRESSED.map((line, i) => (_jsx(Text, { color: LOGO_GRADIENT[i] ?? PURPLE_100, bold: true, children: line }, i))) }));
|
|
61
|
+
};
|
|
62
|
+
export const buildSummaryCounters = (summary, totalWorkflows, serviceBadge = 'starting', failingServices = 0) => ({
|
|
63
|
+
running: summary?.running ?? 0,
|
|
64
|
+
failed: summary?.failed ?? 0,
|
|
65
|
+
totalWorkflows,
|
|
66
|
+
totalRuns: summary?.total ?? 0,
|
|
67
|
+
serviceBadge,
|
|
68
|
+
failingServices
|
|
69
|
+
});
|
|
70
|
+
export const Header = ({ counters }) => {
|
|
71
|
+
const { stdout } = useStdout();
|
|
72
|
+
const cols = stdout?.columns ?? 120;
|
|
73
|
+
return (_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start", children: [_jsx(Logo, { cols: cols }), _jsx(Box, { flexDirection: "column", paddingTop: 1, children: _jsx(Counters, { counters: counters }) })] }));
|
|
74
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { compressPixels } from './header.js';
|
|
3
|
+
describe('compressPixels', () => {
|
|
4
|
+
it('returns an empty array for empty input', () => {
|
|
5
|
+
expect(compressPixels([])).toEqual([]);
|
|
6
|
+
});
|
|
7
|
+
it('compresses a 2x2 full block to a single full block', () => {
|
|
8
|
+
expect(compressPixels(['██', '██'])).toEqual(['█']);
|
|
9
|
+
});
|
|
10
|
+
it('maps each quadrant character correctly', () => {
|
|
11
|
+
// Each row pair decodes to: top-left, top-right, bottom-left, bottom-right
|
|
12
|
+
const cases = [
|
|
13
|
+
[[' ', ' '], ' '], // 0000
|
|
14
|
+
[[' ', ' █'], '▗'], // 0001 bottom-right
|
|
15
|
+
[[' ', '█ '], '▖'], // 0010 bottom-left
|
|
16
|
+
[[' ', '██'], '▄'], // 0011 bottom half
|
|
17
|
+
[[' █', ' '], '▝'], // 0100 top-right
|
|
18
|
+
[[' █', ' █'], '▐'], // 0101 right half
|
|
19
|
+
[[' █', '█ '], '▞'], // 0110 anti-diagonal
|
|
20
|
+
[[' █', '██'], '▟'], // 0111
|
|
21
|
+
[['█ ', ' '], '▘'], // 1000 top-left
|
|
22
|
+
[['█ ', ' █'], '▚'], // 1001 diagonal
|
|
23
|
+
[['█ ', '█ '], '▌'], // 1010 left half
|
|
24
|
+
[['█ ', '██'], '▙'], // 1011
|
|
25
|
+
[['██', ' '], '▀'], // 1100 top half
|
|
26
|
+
[['██', ' █'], '▜'], // 1101
|
|
27
|
+
[['██', '█ '], '▛'], // 1110
|
|
28
|
+
[['██', '██'], '█'] // 1111 full
|
|
29
|
+
];
|
|
30
|
+
for (const [input, expected] of cases) {
|
|
31
|
+
expect(compressPixels(input)).toEqual([expected]);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
it('pads odd-length rows with an empty bottom row', () => {
|
|
35
|
+
// Single row of 4 cols compresses to 1 row of 2 cols
|
|
36
|
+
expect(compressPixels(['████'])).toEqual(['▀▀']);
|
|
37
|
+
});
|
|
38
|
+
it('pads odd-width columns with a trailing space', () => {
|
|
39
|
+
// 3 cols compresses to 2 cols (even-padded)
|
|
40
|
+
expect(compressPixels(['███', '███'])).toEqual(['█▌']);
|
|
41
|
+
});
|
|
42
|
+
it('compresses 5-row input to 3 rows', () => {
|
|
43
|
+
const five = ['██', '██', '██', '██', '██'];
|
|
44
|
+
const result = compressPixels(five);
|
|
45
|
+
expect(result.length).toBe(3);
|
|
46
|
+
expect(result[0]).toBe('█');
|
|
47
|
+
expect(result[1]).toBe('█');
|
|
48
|
+
expect(result[2]).toBe('▀');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* The dim "<spinner> loading…" row used by every panel and modal that
|
|
4
|
+
* waits on async data. Single source of truth so the visual stays
|
|
5
|
+
* uniform.
|
|
6
|
+
*/
|
|
7
|
+
export declare const LoadingSpinner: React.FC<{
|
|
8
|
+
label?: string;
|
|
9
|
+
}>;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import Spinner from 'ink-spinner';
|
|
4
|
+
/**
|
|
5
|
+
* The dim "<spinner> loading…" row used by every panel and modal that
|
|
6
|
+
* waits on async data. Single source of truth so the visual stays
|
|
7
|
+
* uniform.
|
|
8
|
+
*/
|
|
9
|
+
export const LoadingSpinner = ({ label = 'loading…' }) => (_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { dimColor: true, children: [" ", label] })] }));
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for the TUI's purple gradient. Tailwind violet
|
|
3
|
+
* stops, picked so the OUTPUT logo reads top-bright to bottom-dark and
|
|
4
|
+
* the chrome rules sit somewhere in the middle.
|
|
5
|
+
*/
|
|
6
|
+
export declare const PURPLE_50 = "#c4b5fd";
|
|
7
|
+
export declare const PURPLE_100 = "#a78bfa";
|
|
8
|
+
export declare const PURPLE_200 = "#8b5cf6";
|
|
9
|
+
export declare const PURPLE_300 = "#7c3aed";
|
|
10
|
+
export declare const PURPLE_400 = "#6d28d9";
|
|
11
|
+
export declare const RULE_PURPLE = "#a78bfa";
|
|
12
|
+
/**
|
|
13
|
+
* 3-stop gradient applied across the OUTPUT logo's three rows after
|
|
14
|
+
* 2x2 quadrant compression.
|
|
15
|
+
*/
|
|
16
|
+
export declare const LOGO_GRADIENT: readonly ["#c4b5fd", "#8b5cf6", "#6d28d9"];
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for the TUI's purple gradient. Tailwind violet
|
|
3
|
+
* stops, picked so the OUTPUT logo reads top-bright to bottom-dark and
|
|
4
|
+
* the chrome rules sit somewhere in the middle.
|
|
5
|
+
*/
|
|
6
|
+
export const PURPLE_50 = '#c4b5fd';
|
|
7
|
+
export const PURPLE_100 = '#a78bfa';
|
|
8
|
+
export const PURPLE_200 = '#8b5cf6';
|
|
9
|
+
export const PURPLE_300 = '#7c3aed';
|
|
10
|
+
export const PURPLE_400 = '#6d28d9';
|
|
11
|
+
export const RULE_PURPLE = PURPLE_100;
|
|
12
|
+
/**
|
|
13
|
+
* 3-stop gradient applied across the OUTPUT logo's three rows after
|
|
14
|
+
* 2x2 quadrant compression.
|
|
15
|
+
*/
|
|
16
|
+
export const LOGO_GRADIENT = [PURPLE_50, PURPLE_200, PURPLE_400];
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import { useUiState } from '#views/dev/state/ui_state.js';
|
|
5
|
+
export const SearchBar = ({ active, onSubmit }) => {
|
|
6
|
+
const ui = useUiState();
|
|
7
|
+
useInput((input, key) => {
|
|
8
|
+
if (key.escape) {
|
|
9
|
+
ui.clearSearch();
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
if (key.return) {
|
|
13
|
+
onSubmit?.(ui.search.query);
|
|
14
|
+
ui.closeSearch();
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (key.backspace || key.delete) {
|
|
18
|
+
ui.setSearchQuery(ui.search.query.slice(0, -1));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (input && !key.ctrl && !key.meta) {
|
|
22
|
+
ui.setSearchQuery(ui.search.query + input);
|
|
23
|
+
}
|
|
24
|
+
}, { isActive: active });
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (!active) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
onSubmit?.(ui.search.query);
|
|
30
|
+
}, [active, ui.search.query, onSubmit]);
|
|
31
|
+
if (!active) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { bold: true, children: "/ " }), _jsx(Text, { children: ui.search.query }), _jsx(Text, { inverse: true, children: ' ' })] }));
|
|
35
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Theme-agnostic 2-cell selection bar for list rows. When selected, renders
|
|
4
|
+
* inverse-video (SGR 7) — a solid block of the terminal's actual default
|
|
5
|
+
* foreground colour with the arrow visible inside it. When unselected,
|
|
6
|
+
* renders two plain spaces. Inverse is theme-agnostic by design: it never
|
|
7
|
+
* picks a palette slot, just swaps the user's configured fg/bg.
|
|
8
|
+
*
|
|
9
|
+
* Each row using this still owns its own 1-cell separator after the
|
|
10
|
+
* indicator, so the total indicator column is 3 cells wide.
|
|
11
|
+
*/
|
|
12
|
+
export declare const SelectionIndicator: React.FC<{
|
|
13
|
+
selected: boolean;
|
|
14
|
+
}>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Text } from 'ink';
|
|
3
|
+
/**
|
|
4
|
+
* Theme-agnostic 2-cell selection bar for list rows. When selected, renders
|
|
5
|
+
* inverse-video (SGR 7) — a solid block of the terminal's actual default
|
|
6
|
+
* foreground colour with the arrow visible inside it. When unselected,
|
|
7
|
+
* renders two plain spaces. Inverse is theme-agnostic by design: it never
|
|
8
|
+
* picks a palette slot, just swaps the user's configured fg/bg.
|
|
9
|
+
*
|
|
10
|
+
* Each row using this still owns its own 1-cell separator after the
|
|
11
|
+
* indicator, so the total indicator column is 3 cells wide.
|
|
12
|
+
*/
|
|
13
|
+
export const SelectionIndicator = ({ selected }) => (selected ? _jsx(Text, { inverse: true, children: ' ▸' }) : _jsx(Text, { children: ' ' }));
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { TAB_LABELS, TAB_ORDER } from '#views/dev/state/ui_state.js';
|
|
4
|
+
export const TabBar = ({ active }) => (_jsx(Box, { flexDirection: "row", marginTop: 1, children: TAB_ORDER.map(tab => (_jsx(Box, { marginRight: 3, children: tab === active ? (_jsx(Text, { inverse: true, bold: true, children: ` ${TAB_LABELS[tab]} ` })) : (_jsx(Text, { dimColor: true, children: TAB_LABELS[tab] })) }, tab))) }));
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect } from 'react';
|
|
3
|
+
import { Box, Text } from 'ink';
|
|
4
|
+
import { useUiState } from '#views/dev/state/ui_state.js';
|
|
5
|
+
const TOAST_TTL_MS = 4000;
|
|
6
|
+
const toneColor = (tone) => {
|
|
7
|
+
if (tone === 'success') {
|
|
8
|
+
return 'green';
|
|
9
|
+
}
|
|
10
|
+
if (tone === 'error') {
|
|
11
|
+
return 'red';
|
|
12
|
+
}
|
|
13
|
+
return 'cyan';
|
|
14
|
+
};
|
|
15
|
+
const tonePrefix = (tone) => {
|
|
16
|
+
if (tone === 'success') {
|
|
17
|
+
return '✓';
|
|
18
|
+
}
|
|
19
|
+
if (tone === 'error') {
|
|
20
|
+
return '✗';
|
|
21
|
+
}
|
|
22
|
+
return 'ℹ';
|
|
23
|
+
};
|
|
24
|
+
export const Toasts = () => {
|
|
25
|
+
const ui = useUiState();
|
|
26
|
+
const dismissToast = ui.dismissToast;
|
|
27
|
+
const toasts = ui.toasts;
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
const timers = toasts.map(toast => setTimeout(() => dismissToast(toast.id), TOAST_TTL_MS));
|
|
30
|
+
return () => {
|
|
31
|
+
for (const t of timers) {
|
|
32
|
+
clearTimeout(t);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
}, [toasts, dismissToast]);
|
|
36
|
+
if (toasts.length === 0) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
return (_jsx(Box, { flexDirection: "column", marginTop: 1, children: toasts.map(toast => (_jsxs(Box, { children: [_jsxs(Text, { color: toneColor(toast.tone), bold: true, children: [tonePrefix(toast.tone), " "] }), _jsx(Text, { children: toast.message })] }, toast.id))) }));
|
|
40
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { type CommandHint } from '#views/dev/chrome/footer.js';
|
|
3
|
+
/**
|
|
4
|
+
* Generic two-pane shell shared by every panel that has a windowed list
|
|
5
|
+
* on top, a horizontal rule, a detail pane below, and a footer at the
|
|
6
|
+
* bottom. Panels keep their own selection state and detail rendering;
|
|
7
|
+
* the shell owns the layout invariant (windowing, overflow indicators,
|
|
8
|
+
* separator, footer) so it lives in one place.
|
|
9
|
+
*/
|
|
10
|
+
export interface MasterDetailPanelProps<T> {
|
|
11
|
+
items: T[];
|
|
12
|
+
selectedIndex: number;
|
|
13
|
+
visibleRows: number;
|
|
14
|
+
renderHeader: () => React.ReactNode;
|
|
15
|
+
renderRow: (item: T, selected: boolean, absoluteIndex: number) => React.ReactNode;
|
|
16
|
+
rowKey: (item: T, absoluteIndex: number) => string;
|
|
17
|
+
detail: React.ReactNode;
|
|
18
|
+
hints: CommandHint[];
|
|
19
|
+
itemLabel: string;
|
|
20
|
+
}
|
|
21
|
+
export declare const MasterDetailPanel: <T extends object>(props: MasterDetailPanelProps<T>) => React.ReactElement;
|