@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.
Files changed (91) hide show
  1. package/dist/assets/docker/docker-compose-dev.yml +1 -1
  2. package/dist/commands/dev/index.js +48 -13
  3. package/dist/commands/dev/index.spec.js +1 -2
  4. package/dist/components/status_icon.js +1 -1
  5. package/dist/generated/framework_version.json +1 -1
  6. package/dist/services/docker.js +0 -1
  7. package/dist/utils/paths.d.ts +11 -0
  8. package/dist/utils/paths.js +14 -0
  9. package/dist/utils/scenario_resolver.js +3 -2
  10. package/dist/views/dev/chrome/divider.d.ts +8 -0
  11. package/dist/views/dev/chrome/divider.js +16 -0
  12. package/dist/views/dev/chrome/footer.d.ts +11 -0
  13. package/dist/views/dev/chrome/footer.js +10 -0
  14. package/dist/views/dev/chrome/header.d.ts +21 -0
  15. package/dist/views/dev/chrome/header.js +74 -0
  16. package/dist/views/dev/chrome/header.spec.d.ts +1 -0
  17. package/dist/views/dev/chrome/header.spec.js +50 -0
  18. package/dist/views/dev/chrome/loading_spinner.d.ts +9 -0
  19. package/dist/views/dev/chrome/loading_spinner.js +9 -0
  20. package/dist/views/dev/chrome/palette.d.ts +16 -0
  21. package/dist/views/dev/chrome/palette.js +16 -0
  22. package/dist/views/dev/chrome/search_bar.d.ts +5 -0
  23. package/dist/views/dev/chrome/search_bar.js +35 -0
  24. package/dist/views/dev/chrome/selection_indicator.d.ts +14 -0
  25. package/dist/views/dev/chrome/selection_indicator.js +13 -0
  26. package/dist/views/dev/chrome/tab_bar.d.ts +5 -0
  27. package/dist/views/dev/chrome/tab_bar.js +4 -0
  28. package/dist/views/dev/chrome/toasts.d.ts +2 -0
  29. package/dist/views/dev/chrome/toasts.js +40 -0
  30. package/dist/views/dev/components/master_detail_panel.d.ts +21 -0
  31. package/dist/views/dev/components/master_detail_panel.js +18 -0
  32. package/dist/views/{dev.d.ts → dev/dev_app.d.ts} +1 -0
  33. package/dist/views/dev/dev_app.js +146 -0
  34. package/dist/views/dev/hooks/use_docker_logs.d.ts +7 -0
  35. package/dist/views/dev/hooks/use_docker_logs.js +69 -0
  36. package/dist/views/dev/hooks/use_poll.d.ts +16 -0
  37. package/dist/views/dev/hooks/use_poll.js +95 -0
  38. package/dist/views/dev/hooks/use_run_detail.d.ts +21 -0
  39. package/dist/views/dev/hooks/use_run_detail.js +153 -0
  40. package/dist/views/dev/hooks/use_run_detail.spec.d.ts +1 -0
  41. package/dist/views/dev/hooks/use_run_detail.spec.js +86 -0
  42. package/dist/views/dev/hooks/use_workflow_catalog.d.ts +2 -0
  43. package/dist/views/dev/hooks/use_workflow_catalog.js +21 -0
  44. package/dist/views/dev/modals/expanded_json_modal.d.ts +2 -0
  45. package/dist/views/dev/modals/expanded_json_modal.js +44 -0
  46. package/dist/views/dev/modals/run_modal.d.ts +4 -0
  47. package/dist/views/dev/modals/run_modal.js +213 -0
  48. package/dist/views/dev/panels/help_panel.d.ts +2 -0
  49. package/dist/views/dev/panels/help_panel.js +53 -0
  50. package/dist/views/dev/panels/run_detail_view.d.ts +5 -0
  51. package/dist/views/dev/panels/run_detail_view.js +112 -0
  52. package/dist/views/dev/panels/runs_panel.d.ts +8 -0
  53. package/dist/views/dev/panels/runs_panel.js +204 -0
  54. package/dist/views/dev/panels/runs_panel.spec.d.ts +1 -0
  55. package/dist/views/dev/panels/runs_panel.spec.js +82 -0
  56. package/dist/views/dev/panels/services_panel.d.ts +14 -0
  57. package/dist/views/dev/panels/services_panel.js +155 -0
  58. package/dist/views/dev/panels/services_panel.spec.d.ts +1 -0
  59. package/dist/views/dev/panels/services_panel.spec.js +28 -0
  60. package/dist/views/dev/panels/workflows_panel.d.ts +7 -0
  61. package/dist/views/dev/panels/workflows_panel.js +111 -0
  62. package/dist/views/dev/services/docker_control.d.ts +5 -0
  63. package/dist/views/dev/services/docker_control.js +25 -0
  64. package/dist/views/dev/services/run_workflow.d.ts +10 -0
  65. package/dist/views/dev/services/run_workflow.js +14 -0
  66. package/dist/views/dev/services/scenario_io.d.ts +2 -0
  67. package/dist/views/dev/services/scenario_io.js +37 -0
  68. package/dist/views/dev/state/ui_state.d.ts +60 -0
  69. package/dist/views/dev/state/ui_state.js +64 -0
  70. package/dist/views/dev/utils/constants.d.ts +17 -0
  71. package/dist/views/dev/utils/constants.js +17 -0
  72. package/dist/views/dev/utils/json_editor.d.ts +21 -0
  73. package/dist/views/dev/utils/json_editor.js +117 -0
  74. package/dist/views/dev/utils/json_editor.spec.d.ts +1 -0
  75. package/dist/views/dev/utils/json_editor.spec.js +57 -0
  76. package/dist/views/dev/utils/json_render.d.ts +15 -0
  77. package/dist/views/dev/utils/json_render.js +77 -0
  78. package/dist/views/dev/utils/json_render.spec.d.ts +1 -0
  79. package/dist/views/dev/utils/json_render.spec.js +65 -0
  80. package/dist/views/dev/utils/panel_helpers.d.ts +16 -0
  81. package/dist/views/dev/utils/panel_helpers.js +32 -0
  82. package/dist/views/dev/utils/panel_helpers.spec.d.ts +1 -0
  83. package/dist/views/dev/utils/panel_helpers.spec.js +47 -0
  84. package/package.json +5 -5
  85. package/dist/components/command_footer.d.ts +0 -8
  86. package/dist/components/command_footer.js +0 -4
  87. package/dist/components/workflow_summary.d.ts +0 -10
  88. package/dist/components/workflow_summary.js +0 -4
  89. package/dist/views/dev.js +0 -187
  90. package/dist/views/workflow/list.d.ts +0 -6
  91. package/dist/views/workflow/list.js +0 -129
@@ -77,7 +77,7 @@ services:
77
77
  condition: service_healthy
78
78
  worker:
79
79
  condition: service_healthy
80
- image: outputai/api:${OUTPUT_API_VERSION:-0.3.2-next.5e221e8.0}
80
+ image: outputai/api:${OUTPUT_API_VERSION:-0.3.3-next.b23002f.0}
81
81
  init: true
82
82
  networks:
83
83
  - main
@@ -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: 'blue' },
11
+ running: { icon: '●', color: 'green' },
12
12
  completed: { icon: '●', color: 'green' },
13
13
  failed: { icon: '✗', color: 'red' },
14
14
  canceled: { icon: '○', color: 'gray' },
@@ -1,3 +1,3 @@
1
1
  {
2
- "framework": "0.3.2-next.5e221e8.0"
2
+ "framework": "0.3.3-next.b23002f.0"
3
3
  }
@@ -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']
@@ -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
  */
@@ -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 = process.cwd()) {
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 = process.cwd()) {
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,8 @@
1
+ import React from 'react';
2
+ export declare const HorizontalRule: React.FC<{
3
+ color?: string;
4
+ widthOffset?: number;
5
+ }>;
6
+ export declare const VerticalRule: React.FC<{
7
+ color?: string;
8
+ }>;
@@ -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,5 @@
1
+ import React from 'react';
2
+ export declare const SearchBar: React.FC<{
3
+ active: boolean;
4
+ onSubmit?: (query: string) => void;
5
+ }>;
@@ -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,5 @@
1
+ import React from 'react';
2
+ import { type Tab } from '#views/dev/state/ui_state.js';
3
+ export declare const TabBar: React.FC<{
4
+ active: Tab;
5
+ }>;
@@ -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,2 @@
1
+ import React from 'react';
2
+ export declare const Toasts: React.FC;
@@ -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;