@outputai/cli 0.3.2-next.5e221e8.0 β†’ 0.3.3-dev.2650161.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 (96) 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/templates/project/src/workflows/blog_evaluator/prompts/signal_noise@v1.prompt.template +2 -1
  8. package/dist/templates/workflow/README.md.template +2 -1
  9. package/dist/templates/workflow/prompts/example@v1.prompt.template +2 -1
  10. package/dist/utils/paths.d.ts +11 -0
  11. package/dist/utils/paths.js +14 -0
  12. package/dist/utils/scenario_resolver.d.ts +2 -1
  13. package/dist/utils/scenario_resolver.js +57 -25
  14. package/dist/utils/scenario_resolver.spec.js +30 -1
  15. package/dist/views/dev/chrome/divider.d.ts +8 -0
  16. package/dist/views/dev/chrome/divider.js +16 -0
  17. package/dist/views/dev/chrome/footer.d.ts +11 -0
  18. package/dist/views/dev/chrome/footer.js +10 -0
  19. package/dist/views/dev/chrome/header.d.ts +21 -0
  20. package/dist/views/dev/chrome/header.js +74 -0
  21. package/dist/views/dev/chrome/header.spec.d.ts +1 -0
  22. package/dist/views/dev/chrome/header.spec.js +50 -0
  23. package/dist/views/dev/chrome/loading_spinner.d.ts +9 -0
  24. package/dist/views/dev/chrome/loading_spinner.js +9 -0
  25. package/dist/views/dev/chrome/palette.d.ts +16 -0
  26. package/dist/views/dev/chrome/palette.js +16 -0
  27. package/dist/views/dev/chrome/search_bar.d.ts +5 -0
  28. package/dist/views/dev/chrome/search_bar.js +35 -0
  29. package/dist/views/dev/chrome/selection_indicator.d.ts +14 -0
  30. package/dist/views/dev/chrome/selection_indicator.js +13 -0
  31. package/dist/views/dev/chrome/tab_bar.d.ts +5 -0
  32. package/dist/views/dev/chrome/tab_bar.js +4 -0
  33. package/dist/views/dev/chrome/toasts.d.ts +2 -0
  34. package/dist/views/dev/chrome/toasts.js +40 -0
  35. package/dist/views/dev/components/master_detail_panel.d.ts +21 -0
  36. package/dist/views/dev/components/master_detail_panel.js +18 -0
  37. package/dist/views/{dev.d.ts β†’ dev/dev_app.d.ts} +1 -0
  38. package/dist/views/dev/dev_app.js +146 -0
  39. package/dist/views/dev/hooks/use_docker_logs.d.ts +7 -0
  40. package/dist/views/dev/hooks/use_docker_logs.js +69 -0
  41. package/dist/views/dev/hooks/use_poll.d.ts +16 -0
  42. package/dist/views/dev/hooks/use_poll.js +95 -0
  43. package/dist/views/dev/hooks/use_run_detail.d.ts +21 -0
  44. package/dist/views/dev/hooks/use_run_detail.js +153 -0
  45. package/dist/views/dev/hooks/use_run_detail.spec.d.ts +1 -0
  46. package/dist/views/dev/hooks/use_run_detail.spec.js +86 -0
  47. package/dist/views/dev/hooks/use_workflow_catalog.d.ts +2 -0
  48. package/dist/views/dev/hooks/use_workflow_catalog.js +21 -0
  49. package/dist/views/dev/modals/expanded_json_modal.d.ts +2 -0
  50. package/dist/views/dev/modals/expanded_json_modal.js +44 -0
  51. package/dist/views/dev/modals/run_modal.d.ts +5 -0
  52. package/dist/views/dev/modals/run_modal.js +213 -0
  53. package/dist/views/dev/panels/help_panel.d.ts +2 -0
  54. package/dist/views/dev/panels/help_panel.js +53 -0
  55. package/dist/views/dev/panels/run_detail_view.d.ts +5 -0
  56. package/dist/views/dev/panels/run_detail_view.js +112 -0
  57. package/dist/views/dev/panels/runs_panel.d.ts +8 -0
  58. package/dist/views/dev/panels/runs_panel.js +204 -0
  59. package/dist/views/dev/panels/runs_panel.spec.d.ts +1 -0
  60. package/dist/views/dev/panels/runs_panel.spec.js +82 -0
  61. package/dist/views/dev/panels/services_panel.d.ts +14 -0
  62. package/dist/views/dev/panels/services_panel.js +155 -0
  63. package/dist/views/dev/panels/services_panel.spec.d.ts +1 -0
  64. package/dist/views/dev/panels/services_panel.spec.js +28 -0
  65. package/dist/views/dev/panels/workflows_panel.d.ts +7 -0
  66. package/dist/views/dev/panels/workflows_panel.js +111 -0
  67. package/dist/views/dev/services/docker_control.d.ts +5 -0
  68. package/dist/views/dev/services/docker_control.js +25 -0
  69. package/dist/views/dev/services/run_workflow.d.ts +10 -0
  70. package/dist/views/dev/services/run_workflow.js +14 -0
  71. package/dist/views/dev/services/scenario_io.d.ts +2 -0
  72. package/dist/views/dev/services/scenario_io.js +41 -0
  73. package/dist/views/dev/state/ui_state.d.ts +61 -0
  74. package/dist/views/dev/state/ui_state.js +64 -0
  75. package/dist/views/dev/utils/constants.d.ts +17 -0
  76. package/dist/views/dev/utils/constants.js +17 -0
  77. package/dist/views/dev/utils/json_editor.d.ts +21 -0
  78. package/dist/views/dev/utils/json_editor.js +117 -0
  79. package/dist/views/dev/utils/json_editor.spec.d.ts +1 -0
  80. package/dist/views/dev/utils/json_editor.spec.js +57 -0
  81. package/dist/views/dev/utils/json_render.d.ts +15 -0
  82. package/dist/views/dev/utils/json_render.js +77 -0
  83. package/dist/views/dev/utils/json_render.spec.d.ts +1 -0
  84. package/dist/views/dev/utils/json_render.spec.js +65 -0
  85. package/dist/views/dev/utils/panel_helpers.d.ts +16 -0
  86. package/dist/views/dev/utils/panel_helpers.js +32 -0
  87. package/dist/views/dev/utils/panel_helpers.spec.d.ts +1 -0
  88. package/dist/views/dev/utils/panel_helpers.spec.js +47 -0
  89. package/package.json +5 -5
  90. package/dist/components/command_footer.d.ts +0 -8
  91. package/dist/components/command_footer.js +0 -4
  92. package/dist/components/workflow_summary.d.ts +0 -10
  93. package/dist/components/workflow_summary.js +0 -4
  94. package/dist/views/dev.js +0 -187
  95. package/dist/views/workflow/list.d.ts +0 -6
  96. 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-dev.2650161.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-dev.2650161.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']
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  provider: anthropic
3
- model: claude-haiku-4-5
3
+ # current as of 2026-05-04 β€” run output-dev-model-selection for the latest
4
+ model: claude-sonnet-4-6
4
5
  temperature: 0.3
5
6
  maxTokens: 4096
6
7
  ---
@@ -185,7 +185,8 @@ Example prompt file:
185
185
  ```
186
186
  ---
187
187
  provider: anthropic
188
- model: claude-haiku-4-5
188
+ # current as of 2026-05-04 β€” run output-dev-model-selection for the latest
189
+ model: claude-sonnet-4-6
189
190
  temperature: 0.3
190
191
  maxTokens: 1024
191
192
  ---
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  provider: anthropic
3
- model: claude-haiku-4-5
3
+ # current as of 2026-05-04 β€” run output-dev-model-selection for the latest
4
+ model: claude-sonnet-4-6
4
5
  temperature: 0.3
5
6
  maxTokens: 4096
6
7
  ---
@@ -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
  */
@@ -4,6 +4,7 @@ export interface ScenarioResolutionResult {
4
4
  searchedPaths: string[];
5
5
  }
6
6
  export declare function extractWorkflowRelativePath(path: string): string | null;
7
- export declare function resolveScenarioPath(workflowName: string, scenarioName: string, basePath?: string): Promise<ScenarioResolutionResult>;
7
+ export declare function findWorkflowDirectoryFromPath(workflowPath: string | undefined, basePath?: string): string | null;
8
+ export declare function resolveScenarioPath(workflowName: string, scenarioName: string, basePath?: string, workflowPath?: string): Promise<ScenarioResolutionResult>;
8
9
  export declare function listScenariosForWorkflow(workflowName: string, workflowPath?: string, basePath?: string): string[];
9
10
  export declare function getScenarioNotFoundMessage(workflowName: string, scenarioName: string, searchedPaths: string[]): string;
@@ -1,13 +1,34 @@
1
1
  import { existsSync, readdirSync } from 'node:fs';
2
- import { resolve } from 'node:path';
2
+ import { dirname, 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) {
7
- const match = path.match(/workflows\/(.+)\/workflow\.[jt]s$/);
8
+ const match = path.match(/(?:^|\/)workflows\/(.+)\/workflow\.[jt]s$/);
8
9
  return match ? match[1] : null;
9
10
  }
10
- async function fetchWorkflowDirectory(workflowName) {
11
+ function unique(values) {
12
+ return [...new Set(values)];
13
+ }
14
+ function workflowPathSuffixes(workflowPath) {
15
+ const parts = dirname(workflowPath).split(/[/\\]+/).filter(Boolean);
16
+ return parts.map((_, index) => parts.slice(index));
17
+ }
18
+ function candidateWorkflowDirsFromPath(workflowPath, basePath) {
19
+ return unique(workflowPathSuffixes(workflowPath).flatMap(suffix => WORKFLOWS_PATHS.map(workflowsDir => resolve(basePath, workflowsDir, ...suffix))));
20
+ }
21
+ function candidateScenarioDirsFromPath(workflowPath, basePath) {
22
+ return candidateWorkflowDirsFromPath(workflowPath, basePath)
23
+ .map(workflowDir => resolve(workflowDir, SCENARIOS_DIR));
24
+ }
25
+ export function findWorkflowDirectoryFromPath(workflowPath, basePath = getWorkflowsBasePath()) {
26
+ if (!workflowPath) {
27
+ return null;
28
+ }
29
+ return candidateWorkflowDirsFromPath(workflowPath, basePath).find(existsSync) ?? null;
30
+ }
31
+ async function fetchWorkflowPath(workflowName) {
11
32
  try {
12
33
  const response = await getWorkflowCatalog();
13
34
  const data = response?.data;
@@ -19,11 +40,7 @@ async function fetchWorkflowDirectory(workflowName) {
19
40
  if (!workflow) {
20
41
  return null;
21
42
  }
22
- const workflowPath = workflow.path;
23
- if (!workflowPath) {
24
- return null;
25
- }
26
- return extractWorkflowRelativePath(workflowPath);
43
+ return workflow.path ?? null;
27
44
  }
28
45
  catch {
29
46
  return null;
@@ -40,33 +57,48 @@ function resolveScenarioFromDirectory(relativeDir, scenarioFileName, basePath) {
40
57
  }
41
58
  return { found: false, searchedPaths };
42
59
  }
43
- export async function resolveScenarioPath(workflowName, scenarioName, basePath = process.cwd()) {
60
+ function resolveScenarioFromScenarioDirs(scenariosDirs, scenarioFileName) {
61
+ const searchedPaths = scenariosDirs.map(dir => resolve(dir, scenarioFileName));
62
+ const path = searchedPaths.find(existsSync);
63
+ return path ?
64
+ { found: true, path, searchedPaths } :
65
+ { found: false, searchedPaths };
66
+ }
67
+ export async function resolveScenarioPath(workflowName, scenarioName, basePath = getWorkflowsBasePath(), workflowPath) {
44
68
  const scenarioFileName = scenarioName.endsWith('.json') ?
45
69
  scenarioName :
46
70
  `${scenarioName}.json`;
47
- const catalogDir = await fetchWorkflowDirectory(workflowName);
48
- if (catalogDir) {
49
- const result = resolveScenarioFromDirectory(catalogDir, scenarioFileName, basePath);
71
+ if (workflowPath) {
72
+ const pathResult = resolveScenarioFromScenarioDirs(candidateScenarioDirsFromPath(workflowPath, basePath), scenarioFileName);
73
+ if (pathResult.found) {
74
+ return pathResult;
75
+ }
76
+ }
77
+ const catalogPath = workflowPath ? null : await fetchWorkflowPath(workflowName);
78
+ if (catalogPath) {
79
+ const result = resolveScenarioFromScenarioDirs(candidateScenarioDirsFromPath(catalogPath, basePath), scenarioFileName);
50
80
  if (result.found) {
51
81
  return result;
52
82
  }
53
- // Catalog resolved but scenario not found at that path β€” still try convention fallback
54
- // in case the catalog path differs from local source layout
55
- if (catalogDir !== workflowName) {
56
- const fallback = resolveScenarioFromDirectory(workflowName, scenarioFileName, basePath);
57
- return {
58
- found: fallback.found,
59
- path: fallback.path,
60
- searchedPaths: [...result.searchedPaths, ...fallback.searchedPaths]
61
- };
62
- }
63
- return result;
83
+ const fallback = resolveScenarioFromDirectory(workflowName, scenarioFileName, basePath);
84
+ return {
85
+ found: fallback.found,
86
+ path: fallback.path,
87
+ searchedPaths: [...result.searchedPaths, ...fallback.searchedPaths]
88
+ };
64
89
  }
65
90
  // API unavailable or workflow not in catalog β€” fall back to convention
66
91
  return resolveScenarioFromDirectory(workflowName, scenarioFileName, basePath);
67
92
  }
68
- export function listScenariosForWorkflow(workflowName, workflowPath, basePath = process.cwd()) {
69
- const relativeDir = (workflowPath && extractWorkflowRelativePath(workflowPath)) || workflowName;
93
+ export function listScenariosForWorkflow(workflowName, workflowPath, basePath = getWorkflowsBasePath()) {
94
+ const scenariosDirs = workflowPath ? candidateScenarioDirsFromPath(workflowPath, basePath) : [];
95
+ const scenariosDir = scenariosDirs.find(existsSync);
96
+ if (scenariosDir) {
97
+ return readdirSync(scenariosDir)
98
+ .filter(f => f.endsWith('.json'))
99
+ .map(f => f.replace(/\.json$/, ''));
100
+ }
101
+ const relativeDir = workflowName;
70
102
  for (const workflowsDir of WORKFLOWS_PATHS) {
71
103
  const scenariosDir = resolve(basePath, workflowsDir, relativeDir, SCENARIOS_DIR);
72
104
  if (existsSync(scenariosDir)) {
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
2
- import { resolveScenarioPath, getScenarioNotFoundMessage, extractWorkflowRelativePath, listScenariosForWorkflow } from './scenario_resolver.js';
2
+ import { resolveScenarioPath, getScenarioNotFoundMessage, extractWorkflowRelativePath, findWorkflowDirectoryFromPath, listScenariosForWorkflow } from './scenario_resolver.js';
3
3
  import * as fs from 'node:fs';
4
4
  import * as api from '#api/generated/api.js';
5
5
  vi.mock('node:fs', () => ({
@@ -32,6 +32,10 @@ describe('extractWorkflowRelativePath', () => {
32
32
  expect(extractWorkflowRelativePath('/src/workflows/my_flow/workflow.ts'))
33
33
  .toBe('my_flow');
34
34
  });
35
+ it('should not match workflows inside a parent directory name', () => {
36
+ expect(extractWorkflowRelativePath('/app/test_workflows/dist/workflows/simple_sleep/workflow.js'))
37
+ .toBe('simple_sleep');
38
+ });
35
39
  it('should return null for non-matching paths', () => {
36
40
  expect(extractWorkflowRelativePath('/app/dist/other/workflow.js')).toBeNull();
37
41
  expect(extractWorkflowRelativePath('/app/dist/workflows/')).toBeNull();
@@ -100,6 +104,15 @@ describe('resolveScenarioPath', () => {
100
104
  expect(result.found).toBe(true);
101
105
  expect(result.path).toContain('src/workflows/my_workflow/scenarios/test_scenario.json');
102
106
  });
107
+ it('should resolve using a provided workflow path without requiring a workflows segment', async () => {
108
+ mockCatalogFailure();
109
+ vi.mocked(fs.existsSync).mockImplementation(path => {
110
+ return String(path) === '/project/src/workflows/writing/editor/scenarios/basic.json';
111
+ });
112
+ const result = await resolveScenarioPath('writing_editor', 'basic', '/project', '/app/build-output/writing/editor/workflow.js');
113
+ expect(result.found).toBe(true);
114
+ expect(result.path).toBe('/project/src/workflows/writing/editor/scenarios/basic.json');
115
+ });
103
116
  });
104
117
  describe('when workflow is not in catalog', () => {
105
118
  it('should fall back to convention-based lookup', async () => {
@@ -158,6 +171,12 @@ describe('listScenariosForWorkflow', () => {
158
171
  const result = listScenariosForWorkflow('my_workflow', undefined, '/project');
159
172
  expect(result).toEqual(['basic', 'advanced']);
160
173
  });
174
+ it('should list scenarios from a workflow path without requiring a workflows segment', () => {
175
+ vi.mocked(fs.existsSync).mockImplementation(path => String(path) === '/project/src/workflows/writing/editor/scenarios');
176
+ vi.mocked(fs.readdirSync).mockReturnValue(['basic.json', 'README.md']);
177
+ const result = listScenariosForWorkflow('writing_editor', '/app/build-output/writing/editor/workflow.js', '/project');
178
+ expect(result).toEqual(['basic']);
179
+ });
161
180
  it('should use workflowPath from catalog to derive directory', () => {
162
181
  vi.mocked(fs.existsSync).mockImplementation(path => String(path).includes('src/workflows/viz_examples/01_simple_linear/scenarios'));
163
182
  vi.mocked(fs.readdirSync).mockReturnValue(['test.json']);
@@ -197,6 +216,16 @@ describe('listScenariosForWorkflow', () => {
197
216
  expect(result).toEqual([]);
198
217
  });
199
218
  });
219
+ describe('findWorkflowDirectoryFromPath', () => {
220
+ beforeEach(() => {
221
+ vi.resetAllMocks();
222
+ });
223
+ it('should find the local workflow directory from a loaded workflow path suffix', () => {
224
+ vi.mocked(fs.existsSync).mockImplementation(path => String(path) === '/project/src/workflows/writing/editor');
225
+ const result = findWorkflowDirectoryFromPath('/app/build-output/writing/editor/workflow.js', '/project');
226
+ expect(result).toBe('/project/src/workflows/writing/editor');
227
+ });
228
+ });
200
229
  describe('getScenarioNotFoundMessage', () => {
201
230
  it('should return a helpful error message', () => {
202
231
  const searchedPaths = [
@@ -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] })] }));