@outputai/cli 0.4.1-next.c0b98d8.0 → 0.4.1-next.d085dde.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 (67) hide show
  1. package/dist/api/generated/api.d.ts +21 -23
  2. package/dist/api/generated/api.js +0 -4
  3. package/dist/assets/docker/docker-compose-dev.yml +1 -4
  4. package/dist/generated/framework_version.json +1 -1
  5. package/dist/utils/format_workflow_result.spec.js +4 -0
  6. package/dist/views/dev/chrome/footer.d.ts +5 -4
  7. package/dist/views/dev/chrome/footer.js +12 -2
  8. package/dist/views/dev/chrome/header.d.ts +2 -1
  9. package/dist/views/dev/chrome/header.js +18 -47
  10. package/dist/views/dev/chrome/header.spec.js +6 -46
  11. package/dist/views/dev/chrome/layout_heights.spec.d.ts +1 -0
  12. package/dist/views/dev/chrome/layout_heights.spec.js +19 -0
  13. package/dist/views/dev/chrome/search_bar.d.ts +1 -1
  14. package/dist/views/dev/chrome/search_bar.js +10 -11
  15. package/dist/views/dev/chrome/tab_bar.d.ts +8 -1
  16. package/dist/views/dev/chrome/tab_bar.js +16 -2
  17. package/dist/views/dev/chrome/toasts.d.ts +1 -0
  18. package/dist/views/dev/chrome/toasts.js +8 -4
  19. package/dist/views/dev/components/content_title.d.ts +6 -0
  20. package/dist/views/dev/components/content_title.js +7 -0
  21. package/dist/views/dev/components/docker_service_status.d.ts +12 -0
  22. package/dist/views/dev/components/docker_service_status.js +19 -0
  23. package/dist/views/dev/components/inline_snippet.d.ts +4 -0
  24. package/dist/views/dev/components/inline_snippet.js +3 -0
  25. package/dist/views/dev/components/master_detail_panel.d.ts +9 -8
  26. package/dist/views/dev/components/master_detail_panel.js +8 -5
  27. package/dist/views/dev/components/run_info_sidebar.d.ts +7 -0
  28. package/dist/views/dev/components/run_info_sidebar.js +19 -0
  29. package/dist/views/dev/components/workflow_status.d.ts +12 -0
  30. package/dist/views/dev/components/workflow_status.js +19 -0
  31. package/dist/views/dev/dev_app.js +107 -31
  32. package/dist/views/dev/hooks/use_run_detail.js +6 -9
  33. package/dist/views/dev/hooks/use_run_detail.spec.js +7 -0
  34. package/dist/views/dev/modals/expanded_json_modal.js +5 -6
  35. package/dist/views/dev/modals/modal_frame.d.ts +13 -0
  36. package/dist/views/dev/modals/modal_frame.js +13 -0
  37. package/dist/views/dev/modals/run_modal.js +23 -13
  38. package/dist/views/dev/{panels/run_detail_view.d.ts → modals/steps_modal.d.ts} +2 -1
  39. package/dist/views/dev/modals/steps_modal.js +102 -0
  40. package/dist/views/dev/panels/help_panel.d.ts +14 -0
  41. package/dist/views/dev/panels/help_panel.js +19 -21
  42. package/dist/views/dev/panels/runs_panel.d.ts +6 -2
  43. package/dist/views/dev/panels/runs_panel.js +82 -83
  44. package/dist/views/dev/panels/runs_panel.spec.js +1 -28
  45. package/dist/views/dev/panels/services_panel.d.ts +6 -0
  46. package/dist/views/dev/panels/services_panel.js +53 -62
  47. package/dist/views/dev/panels/workflows_panel.d.ts +6 -0
  48. package/dist/views/dev/panels/workflows_panel.js +21 -29
  49. package/dist/views/dev/panels/workflows_panel.spec.d.ts +1 -0
  50. package/dist/views/dev/panels/workflows_panel.spec.js +39 -0
  51. package/dist/views/dev/state/ui_state.d.ts +7 -3
  52. package/dist/views/dev/state/ui_state.js +23 -6
  53. package/dist/views/dev/utils/constants.d.ts +2 -2
  54. package/dist/views/dev/utils/constants.js +2 -2
  55. package/dist/views/dev/utils/json_editor.js +3 -3
  56. package/dist/views/dev/utils/json_render.d.ts +2 -0
  57. package/dist/views/dev/utils/json_render.js +48 -6
  58. package/dist/views/dev/utils/json_render.spec.js +9 -1
  59. package/dist/views/dev/utils/panel_helpers.d.ts +15 -0
  60. package/dist/views/dev/utils/panel_helpers.js +30 -0
  61. package/dist/views/dev/utils/panel_helpers.spec.js +46 -1
  62. package/package.json +4 -4
  63. package/dist/components/status_icon.d.ts +0 -11
  64. package/dist/components/status_icon.js +0 -25
  65. package/dist/views/dev/chrome/divider.d.ts +0 -8
  66. package/dist/views/dev/chrome/divider.js +0 -16
  67. package/dist/views/dev/panels/run_detail_view.js +0 -112
@@ -0,0 +1,12 @@
1
+ import React from 'react';
2
+ interface StatusDisplay {
3
+ icon: string;
4
+ color: string;
5
+ }
6
+ export declare const resolveWorkflowStatus: (status: string) => StatusDisplay;
7
+ export declare const workflowStatusColor: (status: string) => string;
8
+ /** Renders workflow, run, and step status without Docker status semantics. */
9
+ export declare const WorkflowStatusIcon: React.FC<{
10
+ status: string;
11
+ }>;
12
+ export {};
@@ -0,0 +1,19 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Text } from 'ink';
3
+ const WORKFLOW_STATUS_MAP = {
4
+ running: { icon: '●', color: 'yellow' },
5
+ completed: { icon: '●', color: 'green' },
6
+ failed: { icon: '✗', color: 'red' },
7
+ canceled: { icon: '○', color: 'gray' },
8
+ terminated: { icon: '✗', color: 'gray' },
9
+ timed_out: { icon: '✗', color: 'red' },
10
+ continued: { icon: '↻', color: 'blue' }
11
+ };
12
+ const DEFAULT_DISPLAY = { icon: '?', color: 'white' };
13
+ export const resolveWorkflowStatus = (status) => WORKFLOW_STATUS_MAP[status] ?? DEFAULT_DISPLAY;
14
+ export const workflowStatusColor = (status) => resolveWorkflowStatus(status).color;
15
+ /** Renders workflow, run, and step status without Docker status semantics. */
16
+ export const WorkflowStatusIcon = ({ status }) => {
17
+ const { icon, color } = resolveWorkflowStatus(status);
18
+ return _jsx(Text, { color: color, children: icon });
19
+ };
@@ -1,28 +1,56 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useMemo, useRef, useState } from 'react';
3
- import { Box, useApp, useInput, useStdout } from 'ink';
3
+ import { Box, Text, useApp, useInput, useStdout } from 'ink';
4
4
  import { isServiceFailed, isServiceHealthy } from '#services/docker.js';
5
- import { openUrl } from '#utils/open_url.js';
6
5
  import { useHealthPolling, useStatusRefresh, useWorkflowRunsPolling } from '#views/dev/hooks/use_poll.js';
7
6
  import { useWorkflowCatalog } from '#views/dev/hooks/use_workflow_catalog.js';
8
- import { Header, buildSummaryCounters } from '#views/dev/chrome/header.js';
9
- import { TabBar } from '#views/dev/chrome/tab_bar.js';
10
- import { SearchBar } from '#views/dev/chrome/search_bar.js';
11
- import { Toasts } from '#views/dev/chrome/toasts.js';
12
- import { HorizontalRule } from '#views/dev/chrome/divider.js';
7
+ import { Header, buildSummaryCounters, getHeight as getHeaderHeight } from '#views/dev/chrome/header.js';
8
+ import { TabBar, getHeight as getTabBarHeight } from '#views/dev/chrome/tab_bar.js';
9
+ import { SearchBar, useHeight as useSearchBarHeight } from '#views/dev/chrome/search_bar.js';
10
+ import { Toasts, useHeight as useToastsHeight } from '#views/dev/chrome/toasts.js';
11
+ import { Footer, getHeight as getFooterHeight } from '#views/dev/chrome/footer.js';
12
+ import { RULE_PURPLE } from '#views/dev/chrome/palette.js';
13
13
  import { UiStateProvider, useUiState } from '#views/dev/state/ui_state.js';
14
- import { WorkflowsPanel } from '#views/dev/panels/workflows_panel.js';
15
- import { RunsPanel } from '#views/dev/panels/runs_panel.js';
16
- import { ServicesPanel } from '#views/dev/panels/services_panel.js';
17
- import { HelpPanel } from '#views/dev/panels/help_panel.js';
14
+ import { WorkflowsPanel, WORKFLOWS_HINTS, WORKFLOWS_LOADING_HINTS, buildVisibleWorkflows } from '#views/dev/panels/workflows_panel.js';
15
+ import { RunsPanel, RUNS_EMPTY_HINTS, RUNS_HINTS, buildVisibleRuns } from '#views/dev/panels/runs_panel.js';
16
+ import { ServicesPanel, SERVICES_BOOT_HINTS, SERVICES_HINTS } from '#views/dev/panels/services_panel.js';
17
+ import { HELP_HINTS, HELP_SECTION_COUNT, HelpPanel } from '#views/dev/panels/help_panel.js';
18
18
  import { RunModal } from '#views/dev/modals/run_modal.js';
19
19
  import { ExpandedJsonModal } from '#views/dev/modals/expanded_json_modal.js';
20
+ import { StepsModal } from '#views/dev/modals/steps_modal.js';
21
+ import { MIN_TERMINAL_COLUMNS, MIN_TERMINAL_ROWS } from '#views/dev/utils/constants.js';
20
22
  const TAB_NUMBER_KEYS = {
21
23
  1: 'workflows',
22
24
  2: 'runs',
23
25
  3: 'services',
24
26
  4: 'help'
25
27
  };
28
+ const getContentRows = (opts) => {
29
+ return Math.max(1, opts.rows -
30
+ getHeaderHeight(opts.rows) -
31
+ getTabBarHeight() -
32
+ opts.searchHeight -
33
+ opts.toastsHeight -
34
+ getFooterHeight());
35
+ };
36
+ const useTerminalSize = () => {
37
+ const { stdout } = useStdout();
38
+ const readSize = () => ({
39
+ rows: stdout?.rows ?? 60,
40
+ cols: stdout?.columns ?? 80
41
+ });
42
+ const [size, setSize] = useState(readSize);
43
+ useEffect(() => {
44
+ const update = () => setSize(readSize());
45
+ update();
46
+ stdout?.on('resize', update);
47
+ return () => {
48
+ stdout?.off('resize', update);
49
+ };
50
+ }, [stdout]);
51
+ return size;
52
+ };
53
+ const TerminalTooSmall = ({ rows, cols }) => (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingTop: 1, children: [_jsx(Text, { bold: true, children: "Terminal too small for Output dev UI." }), _jsxs(Text, { dimColor: true, children: ["Resize to at least ", MIN_TERMINAL_COLUMNS, "x", MIN_TERMINAL_ROWS, " characters. Current size: ", cols, "x", rows, "."] })] }));
26
54
  const useGlobalInput = (opts) => {
27
55
  const ui = useUiState();
28
56
  const { exit } = useApp();
@@ -37,7 +65,7 @@ const useGlobalInput = (opts) => {
37
65
  .catch(err => exit(err instanceof Error ? err : new Error(String(err))));
38
66
  return;
39
67
  }
40
- if (ui.search.open || ui.runModal.open || ui.expandedJson.open) {
68
+ if (ui.search.open || ui.runModal.open || ui.expandedJson.open || opts.runDetailOpen) {
41
69
  return;
42
70
  }
43
71
  // Esc on a list view drops an active filter. Skip when we're on
@@ -45,19 +73,10 @@ const useGlobalInput = (opts) => {
45
73
  // to the list and the filter should still apply when we land
46
74
  // there. The search bar's esc (close + clear) returns above, so
47
75
  // it never reaches this branch.
48
- if (key.escape && ui.search.query && ui.runsView === 'list') {
76
+ if (key.escape && ui.search.query && !opts.runDetailOpen) {
49
77
  ui.clearSearch();
50
78
  return;
51
79
  }
52
- // Switching tabs is treated as leaving the current view, so any
53
- // active filter goes with it. App-driven setTab calls (e.g. the
54
- // run modal pre-filtering Recent Runs to a workflow) bypass this
55
- // path on purpose.
56
- if (key.tab || (input && TAB_NUMBER_KEYS[input])) {
57
- if (ui.search.query) {
58
- ui.clearSearch();
59
- }
60
- }
61
80
  if (key.tab && key.shift) {
62
81
  ui.prevTab();
63
82
  return;
@@ -78,9 +97,6 @@ const useGlobalInput = (opts) => {
78
97
  ui.setTab(TAB_NUMBER_KEYS[input]);
79
98
  return;
80
99
  }
81
- if (input === 'o' && ui.tab === 'services') {
82
- openUrl('http://localhost:8080');
83
- }
84
100
  });
85
101
  };
86
102
  const computeWorkflowSummary = (runs) => {
@@ -94,6 +110,40 @@ const computeWorkflowSummary = (runs) => {
94
110
  total: runs.length
95
111
  };
96
112
  };
113
+ const footerFor = (opts) => {
114
+ if (opts.ui.tab === 'workflows') {
115
+ if (opts.workflowCount === 0) {
116
+ return { hints: WORKFLOWS_LOADING_HINTS };
117
+ }
118
+ return { hints: WORKFLOWS_HINTS, itemCount: opts.visibleWorkflowCount, itemLabel: 'workflows' };
119
+ }
120
+ if (opts.ui.tab === 'runs') {
121
+ if (opts.runCount === 0) {
122
+ return { hints: RUNS_EMPTY_HINTS };
123
+ }
124
+ return { hints: RUNS_HINTS, itemCount: opts.visibleRunCount, itemLabel: 'runs' };
125
+ }
126
+ if (opts.ui.tab === 'services') {
127
+ return {
128
+ hints: opts.phase === 'waiting' && opts.serviceCount === 0 ? SERVICES_BOOT_HINTS : SERVICES_HINTS,
129
+ itemCount: opts.serviceCount,
130
+ itemLabel: 'services'
131
+ };
132
+ }
133
+ return { hints: HELP_HINTS, itemCount: HELP_SECTION_COUNT, itemLabel: 'sections' };
134
+ };
135
+ const overlayFor = (opts) => {
136
+ if (opts.ui.expandedJson.open) {
137
+ return _jsx(ExpandedJsonModal, {});
138
+ }
139
+ if (opts.ui.runModal.open) {
140
+ return _jsx(RunModal, { workflowName: opts.ui.runModal.workflowName, workflowPath: opts.ui.runModal.workflowPath });
141
+ }
142
+ if (opts.ui.tab === 'runs' && opts.runDetailOpen && opts.detailRun) {
143
+ return _jsx(StepsModal, { run: opts.detailRun, height: opts.rows });
144
+ }
145
+ return null;
146
+ };
97
147
  const Shell = ({ dockerComposePath, onCleanup }) => {
98
148
  const { exit } = useApp();
99
149
  const ui = useUiState();
@@ -120,8 +170,14 @@ const Shell = ({ dockerComposePath, onCleanup }) => {
120
170
  setTab('workflows');
121
171
  }
122
172
  }, [phase, workflows.length, ui.tab, setTab]);
123
- useGlobalInput({ onCleanup });
124
173
  const summary = useMemo(() => computeWorkflowSummary(runs), [runs]);
174
+ const visibleWorkflows = useMemo(() => buildVisibleWorkflows(workflows, ui.search.query), [workflows, ui.search.query]);
175
+ const visibleRuns = useMemo(() => buildVisibleRuns(runs, ui.search.query), [runs, ui.search.query]);
176
+ const detailRun = ui.runsView === 'detail' ?
177
+ runs.find(r => r.runId === ui.selection.runId && r.workflowId === ui.selection.workflowId) :
178
+ undefined;
179
+ const runDetailOpen = ui.runsView === 'detail' && detailRun !== undefined;
180
+ useGlobalInput({ onCleanup, runDetailOpen });
125
181
  const failingServices = useMemo(() => services.filter(isServiceFailed).length, [services]);
126
182
  const serviceBadge = useMemo(() => {
127
183
  if (failingServices > 0) {
@@ -136,11 +192,31 @@ const Shell = ({ dockerComposePath, onCleanup }) => {
136
192
  // `stdout.rows` is undefined on a small set of TTYs (mostly piped envs).
137
193
  // 60 is a generous default — chrome alone is ~10 rows, and run-detail
138
194
  // wants ~25 of content, so anything below 40 starts to clip step rows.
139
- const { stdout } = useStdout();
140
- const rows = stdout?.rows ?? 60;
141
- if (ui.expandedJson.open) {
142
- return (_jsx(Box, { flexDirection: "column", height: rows, paddingX: 2, paddingTop: 1, children: _jsx(ExpandedJsonModal, {}) }));
195
+ const { rows, cols } = useTerminalSize();
196
+ const searchHeight = useSearchBarHeight();
197
+ const toastsHeight = useToastsHeight();
198
+ const terminalTooSmall = cols < MIN_TERMINAL_COLUMNS || rows < MIN_TERMINAL_ROWS;
199
+ const contentRows = getContentRows({
200
+ rows,
201
+ searchHeight,
202
+ toastsHeight
203
+ });
204
+ const footer = footerFor({
205
+ ui,
206
+ workflowCount: workflows.length,
207
+ visibleWorkflowCount: visibleWorkflows.length,
208
+ runCount: runs.length,
209
+ visibleRunCount: visibleRuns.length,
210
+ serviceCount: services.length,
211
+ phase
212
+ });
213
+ const overlay = overlayFor({ ui, detailRun, runDetailOpen, rows });
214
+ if (terminalTooSmall) {
215
+ return _jsx(TerminalTooSmall, { rows: rows, cols: cols });
216
+ }
217
+ if (overlay) {
218
+ return (_jsx(Box, { flexDirection: "column", height: rows, paddingX: 1, children: overlay }));
143
219
  }
144
- return (_jsxs(Box, { flexDirection: "column", height: rows, paddingX: 2, paddingTop: 1, children: [_jsx(Header, { counters: counters }), _jsx(TabBar, { active: ui.tab }), _jsx(HorizontalRule, {}), _jsx(SearchBar, { active: ui.search.open }), _jsx(Toasts, {}), _jsxs(Box, { flexDirection: "column", flexGrow: 1, overflow: "hidden", children: [ui.tab === 'workflows' && !ui.runModal.open && _jsx(WorkflowsPanel, { workflows: workflows, runs: runs }), ui.tab === 'runs' && !ui.runModal.open && _jsx(RunsPanel, { runs: runs }), ui.tab === 'services' && !ui.runModal.open && (_jsx(ServicesPanel, { phase: phase, services: services, dockerComposePath: dockerComposePath })), ui.tab === 'help' && !ui.runModal.open && _jsx(HelpPanel, {}), ui.runModal.open && _jsx(RunModal, { workflowName: ui.runModal.workflowName, workflowPath: ui.runModal.workflowPath })] })] }));
220
+ return (_jsxs(Box, { flexDirection: "column", height: rows, paddingX: 1, children: [_jsx(Header, { counters: counters }), _jsx(TabBar, { active: ui.tab, borderColor: RULE_PURPLE }), _jsx(SearchBar, { active: ui.search.open }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, height: contentRows, overflow: "hidden", children: [ui.tab === 'workflows' && _jsx(WorkflowsPanel, { workflows: workflows, runs: runs }), ui.tab === 'runs' && _jsx(RunsPanel, { runs: runs, height: contentRows }), ui.tab === 'services' && (_jsx(ServicesPanel, { height: contentRows, phase: phase, services: services, dockerComposePath: dockerComposePath })), ui.tab === 'help' && _jsx(HelpPanel, {})] }), _jsx(Toasts, {}), _jsx(Footer, { hints: footer.hints, itemCount: footer.itemCount, itemLabel: footer.itemLabel })] }));
145
221
  };
146
222
  export const DevApp = ({ dockerComposePath, onCleanup }) => (_jsx(UiStateProvider, { children: _jsx(Shell, { dockerComposePath: dockerComposePath, onCleanup: onCleanup }) }));
@@ -7,6 +7,7 @@ const EMPTY_DETAIL = {
7
7
  steps: [],
8
8
  loading: false
9
9
  };
10
+ const runDetailCache = new Map();
10
11
  const stepNameOf = (node) => {
11
12
  if (node.name) {
12
13
  return node.name;
@@ -16,16 +17,13 @@ const stepNameOf = (node) => {
16
17
  return `${kind}#${leaf}`;
17
18
  };
18
19
  const stepStatusOf = (node) => {
19
- if (node.status) {
20
- return node.status;
21
- }
22
- if (node.phase === 'error' || node.error) {
20
+ if (node.phase === 'error' || node.error || node.status === 'failed') {
23
21
  return 'failed';
24
22
  }
25
- if (node.phase === 'end') {
23
+ if (node.phase === 'end' || node.status === 'completed' || node.endedAt !== undefined) {
26
24
  return 'completed';
27
25
  }
28
- return 'running';
26
+ return node.status ?? 'running';
29
27
  };
30
28
  const numericTimestamp = (...candidates) => {
31
29
  for (const candidate of candidates) {
@@ -106,7 +104,6 @@ const TERMINAL_STATUSES = new Set(['completed', 'failed', 'canceled', 'terminate
106
104
  export const isTerminalRunStatus = (status) => Boolean(status && TERMINAL_STATUSES.has(status));
107
105
  export const useRunDetail = (workflowId, runId, status) => {
108
106
  const [detail, setDetail] = useState(EMPTY_DETAIL);
109
- const cacheRef = useRef(new Map());
110
107
  const fetchIdRef = useRef(0);
111
108
  useEffect(() => {
112
109
  if (!workflowId) {
@@ -116,7 +113,7 @@ export const useRunDetail = (workflowId, runId, status) => {
116
113
  // The cache only ever holds terminal-status entries (see below), so
117
114
  // a hit here is always safe to reuse without a network roundtrip.
118
115
  const key = `${workflowId}:${runId ?? 'latest'}`;
119
- const cached = cacheRef.current.get(key);
116
+ const cached = runDetailCache.get(key);
120
117
  if (cached) {
121
118
  setDetail(cached);
122
119
  return;
@@ -140,7 +137,7 @@ export const useRunDetail = (workflowId, runId, status) => {
140
137
  // still running the API returns partial data; a follow-up status
141
138
  // change re-fires this effect and we re-fetch fresh.
142
139
  if (isTerminalRunStatus(result?.status)) {
143
- cacheRef.current.set(key, next);
140
+ runDetailCache.set(key, next);
144
141
  }
145
142
  setDetail(next);
146
143
  });
@@ -45,6 +45,13 @@ describe('extractSteps', () => {
45
45
  expect(steps[0].status).toBe('failed');
46
46
  expect(steps[1].status).toBe('failed');
47
47
  });
48
+ it('maps buildTraceTree-style ended nodes to status=completed', () => {
49
+ const t = trace([
50
+ { name: 'done', kind: 'step', startedAt: 1000, endedAt: 1500, output: { ok: true } }
51
+ ]);
52
+ const steps = extractSteps(t);
53
+ expect(steps[0].status).toBe('completed');
54
+ });
48
55
  it('falls back to startTime/endTime for duration', () => {
49
56
  const t = trace([
50
57
  { phase: 'end', name: 'with-times', startTime: 1000, endTime: 1500 }
@@ -1,10 +1,10 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState } from 'react';
3
- import { Box, Text, useInput, useStdout } from 'ink';
3
+ import { Text, useInput, useStdout } from 'ink';
4
4
  import { useUiState } from '#views/dev/state/ui_state.js';
5
5
  import { JsonView, countJsonLines } from '#views/dev/utils/json_render.js';
6
- import { RULE_PURPLE } from '#views/dev/chrome/palette.js';
7
- const CHROME_HEIGHT = 4;
6
+ import { ModalFrame } from '#views/dev/modals/modal_frame.js';
7
+ const CHROME_HEIGHT = 6;
8
8
  const FALLBACK_ROWS = 24;
9
9
  const PAGE_SIZE = 10;
10
10
  export const ExpandedJsonModal = () => {
@@ -13,7 +13,6 @@ export const ExpandedJsonModal = () => {
13
13
  const [offset, setOffset] = useState(0);
14
14
  const { value, title } = ui.expandedJson;
15
15
  const totalLines = countJsonLines(value);
16
- const cols = stdout?.columns ?? 80;
17
16
  const rows = stdout?.rows ?? FALLBACK_ROWS;
18
17
  const visibleLines = Math.max(5, rows - CHROME_HEIGHT);
19
18
  const maxOffset = Math.max(0, totalLines - visibleLines);
@@ -40,5 +39,5 @@ export const ExpandedJsonModal = () => {
40
39
  }
41
40
  });
42
41
  const progress = totalLines === 0 ? 100 : Math.round(((clampedOffset + visibleLines) / totalLines) * 100);
43
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { bold: true, children: ["\u2922 ", title] }), _jsxs(Text, { dimColor: true, children: [Math.min(100, progress), "% line ", clampedOffset + 1, "-", Math.min(totalLines, clampedOffset + visibleLines), "/", totalLines] })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: RULE_PURPLE, children: ''.repeat(Math.max(1, cols)) }) }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(JsonView, { value: value, maxLines: visibleLines, offset: clampedOffset, truncateLine: true, showOverflowFooter: false }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: RULE_PURPLE, children: '─'.repeat(Math.max(1, cols)) }) }), _jsxs(Box, { columnGap: 2, children: [_jsxs(Box, { columnGap: 1, children: [_jsx(Text, { bold: true, children: "\u2191/\u2193" }), _jsx(Text, { dimColor: true, children: "scroll" })] }), _jsxs(Box, { columnGap: 1, children: [_jsx(Text, { bold: true, children: "pgup/pgdn" }), _jsx(Text, { dimColor: true, children: "page" })] }), _jsxs(Box, { columnGap: 1, children: [_jsx(Text, { bold: true, children: "esc" }), _jsx(Text, { dimColor: true, children: "close" })] })] })] }));
42
+ return (_jsx(ModalFrame, { title: title, titleRight: (_jsxs(Text, { dimColor: true, children: [Math.min(100, progress), "% line ", clampedOffset + 1, "-", Math.min(totalLines, clampedOffset + visibleLines), "/", totalLines] })), shortcuts: [['↑/↓', 'scroll'], ['pgup/pgdn', 'page'], ['esc', 'close']], children: _jsx(JsonView, { value: value, maxLines: visibleLines, offset: clampedOffset, truncateLine: true, showOverflowFooter: false }) }));
44
43
  };
@@ -0,0 +1,13 @@
1
+ import React from 'react';
2
+ export declare const getHeight: () => number;
3
+ export type ModalShortcut = readonly [key: string, label: string] | {
4
+ key: string;
5
+ label: string;
6
+ };
7
+ export declare const ModalFrame: React.FC<{
8
+ title: string;
9
+ titleRight?: React.ReactNode;
10
+ footer?: React.ReactNode;
11
+ shortcuts?: readonly ModalShortcut[];
12
+ children: React.ReactNode;
13
+ }>;
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { RULE_PURPLE } from '#views/dev/chrome/palette.js';
4
+ const FRAME_BORDER_ROWS = 2;
5
+ const FRAME_TITLE_ROWS = 1;
6
+ const FRAME_FOOTER_ROWS = 1;
7
+ const FRAME_GAP_ROWS = 2;
8
+ export const getHeight = () => FRAME_BORDER_ROWS + FRAME_TITLE_ROWS + FRAME_FOOTER_ROWS + FRAME_GAP_ROWS;
9
+ const isShortcutTuple = (shortcut) => Array.isArray(shortcut);
10
+ const shortcutKey = (shortcut) => isShortcutTuple(shortcut) ? shortcut[0] : shortcut.key;
11
+ const shortcutLabel = (shortcut) => isShortcutTuple(shortcut) ? shortcut[1] : shortcut.label;
12
+ const ModalShortcutList = ({ shortcuts }) => (_jsx(Box, { columnGap: 2, children: shortcuts.map(shortcut => (_jsxs(Box, { columnGap: 1, children: [_jsx(Text, { bold: true, children: shortcutKey(shortcut) }), _jsx(Text, { dimColor: true, children: shortcutLabel(shortcut) })] }, shortcutKey(shortcut)))) }));
13
+ export const ModalFrame = ({ title, titleRight, footer, shortcuts, children }) => (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: RULE_PURPLE, paddingX: 1, gap: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { bold: true, children: title }), titleRight] }), _jsx(Box, { flexDirection: "column", children: children }), shortcuts && shortcuts.length > 0 ? _jsx(ModalShortcutList, { shortcuts: shortcuts }) : footer] }));
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import React, { useMemo, useState } from 'react';
2
+ import { useMemo, useState } from 'react';
3
3
  import { Box, Text, useInput } from 'ink';
4
4
  import Spinner from 'ink-spinner';
5
5
  import { listScenariosForWorkflow } from '#utils/scenario_resolver.js';
@@ -8,6 +8,7 @@ import { useUiState } from '#views/dev/state/ui_state.js';
8
8
  import { startWorkflow } from '#views/dev/services/run_workflow.js';
9
9
  import { readScenario, writeScenario } from '#views/dev/services/scenario_io.js';
10
10
  import { JsonEditor } from '#views/dev/utils/json_editor.js';
11
+ import { ModalFrame } from '#views/dev/modals/modal_frame.js';
11
12
  const CUSTOM_SEED = { '': '' };
12
13
  const SCENARIO_NAME_RE = /^[a-zA-Z0-9_-]+$/;
13
14
  const buildEntries = (scenarios) => {
@@ -16,11 +17,24 @@ const buildEntries = (scenarios) => {
16
17
  label: s,
17
18
  scenarioName: s
18
19
  }));
19
- list.push({ kind: 'custom', label: 'Custom input' });
20
+ list.push({ kind: 'custom', label: '[Create new scenario]' });
20
21
  return list;
21
22
  };
22
- const Frame = ({ title, children }) => (_jsxs(Box, { flexDirection: "column", marginTop: 1, borderStyle: "round", paddingX: 1, paddingY: 0, children: [_jsx(Text, { bold: true, children: title }), children] }));
23
- const TextPrompt = ({ label, value }) => (_jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { children: [label, " "] }), _jsx(Text, { children: value }), _jsx(Text, { inverse: true, children: ' ' })] }));
23
+ const SELECT_SHORTCUTS = [
24
+ ['↑/↓', 'navigate'],
25
+ ['enter', 'run'],
26
+ ['d', 'duplicate'],
27
+ ['esc', 'cancel']
28
+ ];
29
+ const NAME_SHORTCUTS = [
30
+ ['enter', 'next'],
31
+ ['esc', 'back']
32
+ ];
33
+ const ERROR_SHORTCUTS = [
34
+ { key: 'enter', label: 'return' },
35
+ { key: 'esc', label: 'return' }
36
+ ];
37
+ const TextPrompt = ({ label, value }) => (_jsxs(Box, { children: [_jsx(Text, { children: label }), _jsx(Text, { children: value }), _jsx(Text, { inverse: true, children: ' ' })] }));
24
38
  export const RunModal = ({ workflowName, workflowPath }) => {
25
39
  const ui = useUiState();
26
40
  const scenarios = useMemo(() => listScenariosForWorkflow(workflowName, workflowPath), [workflowName, workflowPath]);
@@ -192,22 +206,18 @@ export const RunModal = ({ workflowName, workflowPath }) => {
192
206
  }
193
207
  });
194
208
  if (mode === 'edit_content') {
195
- return (_jsx(Frame, { title: `${editFrameTitle} → ${editName}.json`, children: _jsx(JsonEditor, { seed: editSeed, title: `${editName}.json`, isActive: true, onSubmit: value => {
209
+ return (_jsx(ModalFrame, { title: editFrameTitle, children: _jsx(JsonEditor, { seed: editSeed, title: `${editName}.json`, isActive: true, onSubmit: value => {
196
210
  void handleEditorSubmit(value);
197
211
  }, onCancel: handleEditorCancel }) }));
198
212
  }
199
213
  if (mode === 'edit_name') {
200
- return (_jsxs(Frame, { title: editFrameTitle, children: [_jsx(TextPrompt, { label: "Scenario name:", value: editName }), nameError ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "red", children: nameError }) })) : null, _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "enter" }), _jsx(Text, { children: " next " }), _jsx(Text, { dimColor: true, children: "esc" }), _jsx(Text, { children: " back" })] })] }));
214
+ return (_jsxs(ModalFrame, { title: editFrameTitle, shortcuts: NAME_SHORTCUTS, children: [_jsx(TextPrompt, { label: "Scenario name:", value: editName }), nameError ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "red", children: nameError }) })) : null] }));
201
215
  }
202
216
  if (mode === 'submitting') {
203
- return (_jsx(Frame, { title: `Run ${workflowName}`, children: _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Starting workflow\u2026" })] }) }));
217
+ return (_jsxs(ModalFrame, { title: `Run ${workflowName}`, children: [_jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: "\u00A0Starting workflow\u2026" })] }));
204
218
  }
205
219
  if (mode === 'error') {
206
- return (_jsx(Frame, { title: `Run ${workflowName}`, children: _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", errorMessage ?? 'Something went wrong.'] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press enter or esc to return." }) })] }) }));
220
+ return (_jsx(ModalFrame, { title: `Run workflow "${workflowName}"`, shortcuts: ERROR_SHORTCUTS, children: _jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", errorMessage ?? 'Something went wrong.'] }) }));
207
221
  }
208
- return (_jsxs(Frame, { title: `Run ${workflowName}`, children: [entries.length === 0 ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "No scenarios on disk. Choose Custom input." }) })) : (_jsx(Box, { flexDirection: "column", marginTop: 1, children: entries.map((entry, i) => {
209
- const prev = i > 0 ? entries[i - 1] : undefined;
210
- const showSeparator = prev?.kind === 'scenario' && entry.kind !== 'scenario';
211
- return (_jsxs(React.Fragment, { children: [showSeparator && (_jsx(Box, { marginY: 0, children: _jsx(Text, { dimColor: true, children: '─'.repeat(40) }) })), _jsxs(Box, { children: [_jsx(SelectionIndicator, { selected: i === index }), _jsxs(Text, { bold: i === index, children: [' ', entry.label] })] })] }, `${entry.kind}-${entry.scenarioName ?? i}`));
212
- }) })), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "\u2191/\u2193" }), _jsx(Text, { children: " navigate " }), _jsx(Text, { dimColor: true, children: "enter" }), _jsx(Text, { children: " run " }), _jsx(Text, { dimColor: true, children: "d" }), _jsx(Text, { children: " duplicate " }), _jsx(Text, { dimColor: true, children: "esc" }), _jsx(Text, { children: " cancel" })] })] }));
222
+ return (_jsx(ModalFrame, { title: `Run ${workflowName}`, shortcuts: SELECT_SHORTCUTS, children: _jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { dimColor: true, children: scenarios.length === 0 ? 'No scenarios found. Create a new one:' : 'Select scenarios:' }), _jsx(Box, { flexDirection: "column", children: entries.map((entry, i) => (_jsxs(Box, { children: [_jsx(SelectionIndicator, { selected: i === index }), _jsxs(Text, { bold: i === index, children: ["\u00A0", entry.label] })] }, `${entry.kind}-${entry.scenarioName ?? i}`))) })] }) }));
213
223
  };
@@ -1,5 +1,6 @@
1
1
  import React from 'react';
2
2
  import type { WorkflowRun } from '#services/workflow_runs.js';
3
- export declare const RunDetailView: React.FC<{
3
+ export declare const StepsModal: React.FC<{
4
4
  run: WorkflowRun;
5
+ height: number;
5
6
  }>;
@@ -0,0 +1,102 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text, useInput } from 'ink';
3
+ import { WorkflowStatusIcon } from '#views/dev/components/workflow_status.js';
4
+ import { formatDurationCompact } from '#utils/date_formatter.js';
5
+ import { TabBar, getHeight as getTabBarHeight } from '#views/dev/chrome/tab_bar.js';
6
+ import { LoadingSpinner } from '#views/dev/chrome/loading_spinner.js';
7
+ import { SelectionIndicator } from '#views/dev/chrome/selection_indicator.js';
8
+ import { useUiState } from '#views/dev/state/ui_state.js';
9
+ import { useRunDetail } from '#views/dev/hooks/use_run_detail.js';
10
+ import { JsonView } from '#views/dev/utils/json_render.js';
11
+ import { cycleValue, formatContentTitle, truncate, useListSelection } from '#views/dev/utils/panel_helpers.js';
12
+ import { RUN_DETAIL_VISIBLE_STEPS } from '#views/dev/utils/constants.js';
13
+ import { ContentTitle, getHeight as getContentTitleHeight } from '#views/dev/components/content_title.js';
14
+ import { MasterDetailPanel } from '#views/dev/components/master_detail_panel.js';
15
+ import { ModalFrame, getHeight as getModalFrameHeight } from '#views/dev/modals/modal_frame.js';
16
+ const RIGHT_PANE_ORDER = ['input', 'output', 'meta'];
17
+ const RIGHT_PANE_TABS = [
18
+ { id: 'input', label: 'Input' },
19
+ { id: 'output', label: 'Output' },
20
+ { id: 'meta', label: 'Meta' }
21
+ ];
22
+ const STEPS_MODAL_SHORTCUTS = [
23
+ ['↑/↓', 'navigate'],
24
+ ['←/→', 'switch pane'],
25
+ ['e', 'expand'],
26
+ ['esc', 'back']
27
+ ];
28
+ const COL = {
29
+ indicator: 3,
30
+ icon: 3,
31
+ num: 3,
32
+ name: 50,
33
+ duration: 8
34
+ };
35
+ const HeaderRow = () => (_jsxs(Box, { children: [_jsx(Box, { width: COL.indicator, children: _jsx(Text, { children: "\u00A0" }) }), _jsx(Box, { width: COL.icon, children: _jsx(Text, { children: "\u00A0" }) }), _jsx(Box, { width: COL.num, children: _jsx(Text, { dimColor: true, bold: true, children: "#" }) }), _jsx(Box, { width: COL.name, children: _jsx(Text, { dimColor: true, bold: true, children: "NAME" }) }), _jsx(Box, { width: COL.duration, justifyContent: "flex-end", children: _jsx(Text, { dimColor: true, bold: true, children: "DURATION" }) })] }));
36
+ const StepRow = ({ step, selected }) => (_jsxs(Box, { children: [_jsx(Box, { width: COL.indicator, children: _jsx(SelectionIndicator, { selected: selected }) }), _jsx(Box, { width: COL.icon, children: _jsx(WorkflowStatusIcon, { status: step.status }) }), _jsx(Box, { width: COL.num, children: _jsx(Text, { bold: selected, children: step.index }) }), _jsx(Box, { width: COL.name, children: _jsx(Text, { bold: selected, children: truncate(step.name, COL.name - 1) }) }), _jsx(Box, { width: COL.duration, justifyContent: "flex-end", children: _jsx(Text, { dimColor: !selected, children: formatDurationCompact(step.durationMs) }) })] }));
37
+ const stepPaneValue = (step, activeTab) => {
38
+ if (activeTab === 'input') {
39
+ return step.input;
40
+ }
41
+ if (activeTab === 'output') {
42
+ return step.error ?? step.output;
43
+ }
44
+ return {
45
+ kind: step.kind,
46
+ status: step.status,
47
+ durationMs: step.durationMs,
48
+ hasError: Boolean(step.error)
49
+ };
50
+ };
51
+ const StepDetail = ({ step, activeTab, rows }) => {
52
+ if (!step) {
53
+ return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: "Select a step to see input/output." }) }));
54
+ }
55
+ const tabContentRows = Math.max(1, rows - getContentTitleHeight() - getTabBarHeight());
56
+ return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsx(ContentTitle, { title: formatContentTitle([`Step "${step.name}"`, 'Result']) }), _jsx(TabBar, { active: activeTab, items: RIGHT_PANE_TABS }), _jsx(Box, { flexDirection: "column", children: _jsx(JsonView, { value: stepPaneValue(step, activeTab), maxLines: tabContentRows }) })] }));
57
+ };
58
+ export const StepsModal = ({ run, height }) => {
59
+ const ui = useUiState();
60
+ const { steps, loading } = useRunDetail(run.workflowId, run.runId, run.status);
61
+ const { selectedIndex: clamped, selectPrevious, selectNext } = useListSelection(steps.length);
62
+ const selectedStep = steps[clamped];
63
+ const activeTab = ui.runStepPaneTab;
64
+ useInput((input, key) => {
65
+ if (key.escape) {
66
+ ui.setRunsView('list');
67
+ return;
68
+ }
69
+ if (key.upArrow) {
70
+ selectPrevious();
71
+ return;
72
+ }
73
+ if (key.downArrow) {
74
+ selectNext();
75
+ return;
76
+ }
77
+ if (key.leftArrow) {
78
+ ui.setRunStepPaneTab(cycleValue(RIGHT_PANE_ORDER, activeTab, -1));
79
+ return;
80
+ }
81
+ if (key.rightArrow) {
82
+ ui.setRunStepPaneTab(cycleValue(RIGHT_PANE_ORDER, activeTab, 1));
83
+ return;
84
+ }
85
+ if (input === 'e' && selectedStep) {
86
+ const content = stepPaneValue(selectedStep, activeTab);
87
+ const label = `step ${selectedStep.index}: ${selectedStep.name} -> ${activeTab}`;
88
+ ui.openExpandedJson(content, label);
89
+ }
90
+ }, { isActive: ui.tab === 'runs' && ui.runsView === 'detail' && !ui.expandedJson.open });
91
+ const contentRows = Math.max(1, height - getModalFrameHeight());
92
+ const renderDetail = (rows) => {
93
+ if (loading && steps.length === 0) {
94
+ return (_jsx(LoadingSpinner, { label: "Loading steps..." }));
95
+ }
96
+ if (steps.length === 0) {
97
+ return (_jsx(Text, { dimColor: true, children: "No steps recorded for this run." }));
98
+ }
99
+ return _jsx(StepDetail, { step: selectedStep, activeTab: activeTab, rows: rows });
100
+ };
101
+ return (_jsx(ModalFrame, { title: formatContentTitle([`Workflow "${run.workflowType}"`, 'Steps']), shortcuts: STEPS_MODAL_SHORTCUTS, children: _jsx(MasterDetailPanel, { items: steps, selectedIndex: clamped, height: contentRows, visibleRows: RUN_DETAIL_VISIBLE_STEPS, renderHeader: () => _jsx(HeaderRow, {}), renderRow: (step, selected) => _jsx(StepRow, { step: step, selected: selected }), rowKey: (step, i) => `${step.index}-${step.name}-${i}`, detail: ({ detailRows }) => renderDetail(detailRows) }) }));
102
+ };
@@ -1,2 +1,16 @@
1
1
  import React from 'react';
2
+ export declare const Section: React.FC<{
3
+ children: React.ReactNode;
4
+ title: string;
5
+ direction?: string;
6
+ }>;
7
+ export declare const SubSection: React.FC<{
8
+ children: React.ReactNode;
9
+ title: string;
10
+ }>;
11
+ export declare const HELP_HINTS: {
12
+ key: string;
13
+ label: string;
14
+ }[];
15
+ export declare const HELP_SECTION_COUNT: number;
2
16
  export declare const HelpPanel: React.FC;