@outputai/cli 0.3.2-next.5e221e8.0 → 0.3.3-dev.422151e.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 (94) 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.js +3 -2
  13. package/dist/views/dev/chrome/divider.d.ts +8 -0
  14. package/dist/views/dev/chrome/divider.js +16 -0
  15. package/dist/views/dev/chrome/footer.d.ts +11 -0
  16. package/dist/views/dev/chrome/footer.js +10 -0
  17. package/dist/views/dev/chrome/header.d.ts +21 -0
  18. package/dist/views/dev/chrome/header.js +74 -0
  19. package/dist/views/dev/chrome/header.spec.d.ts +1 -0
  20. package/dist/views/dev/chrome/header.spec.js +50 -0
  21. package/dist/views/dev/chrome/loading_spinner.d.ts +9 -0
  22. package/dist/views/dev/chrome/loading_spinner.js +9 -0
  23. package/dist/views/dev/chrome/palette.d.ts +16 -0
  24. package/dist/views/dev/chrome/palette.js +16 -0
  25. package/dist/views/dev/chrome/search_bar.d.ts +5 -0
  26. package/dist/views/dev/chrome/search_bar.js +35 -0
  27. package/dist/views/dev/chrome/selection_indicator.d.ts +14 -0
  28. package/dist/views/dev/chrome/selection_indicator.js +13 -0
  29. package/dist/views/dev/chrome/tab_bar.d.ts +5 -0
  30. package/dist/views/dev/chrome/tab_bar.js +4 -0
  31. package/dist/views/dev/chrome/toasts.d.ts +2 -0
  32. package/dist/views/dev/chrome/toasts.js +40 -0
  33. package/dist/views/dev/components/master_detail_panel.d.ts +21 -0
  34. package/dist/views/dev/components/master_detail_panel.js +18 -0
  35. package/dist/views/{dev.d.ts → dev/dev_app.d.ts} +1 -0
  36. package/dist/views/dev/dev_app.js +146 -0
  37. package/dist/views/dev/hooks/use_docker_logs.d.ts +7 -0
  38. package/dist/views/dev/hooks/use_docker_logs.js +69 -0
  39. package/dist/views/dev/hooks/use_poll.d.ts +16 -0
  40. package/dist/views/dev/hooks/use_poll.js +95 -0
  41. package/dist/views/dev/hooks/use_run_detail.d.ts +21 -0
  42. package/dist/views/dev/hooks/use_run_detail.js +153 -0
  43. package/dist/views/dev/hooks/use_run_detail.spec.d.ts +1 -0
  44. package/dist/views/dev/hooks/use_run_detail.spec.js +86 -0
  45. package/dist/views/dev/hooks/use_workflow_catalog.d.ts +2 -0
  46. package/dist/views/dev/hooks/use_workflow_catalog.js +21 -0
  47. package/dist/views/dev/modals/expanded_json_modal.d.ts +2 -0
  48. package/dist/views/dev/modals/expanded_json_modal.js +44 -0
  49. package/dist/views/dev/modals/run_modal.d.ts +4 -0
  50. package/dist/views/dev/modals/run_modal.js +213 -0
  51. package/dist/views/dev/panels/help_panel.d.ts +2 -0
  52. package/dist/views/dev/panels/help_panel.js +53 -0
  53. package/dist/views/dev/panels/run_detail_view.d.ts +5 -0
  54. package/dist/views/dev/panels/run_detail_view.js +112 -0
  55. package/dist/views/dev/panels/runs_panel.d.ts +8 -0
  56. package/dist/views/dev/panels/runs_panel.js +204 -0
  57. package/dist/views/dev/panels/runs_panel.spec.d.ts +1 -0
  58. package/dist/views/dev/panels/runs_panel.spec.js +82 -0
  59. package/dist/views/dev/panels/services_panel.d.ts +14 -0
  60. package/dist/views/dev/panels/services_panel.js +155 -0
  61. package/dist/views/dev/panels/services_panel.spec.d.ts +1 -0
  62. package/dist/views/dev/panels/services_panel.spec.js +28 -0
  63. package/dist/views/dev/panels/workflows_panel.d.ts +7 -0
  64. package/dist/views/dev/panels/workflows_panel.js +111 -0
  65. package/dist/views/dev/services/docker_control.d.ts +5 -0
  66. package/dist/views/dev/services/docker_control.js +25 -0
  67. package/dist/views/dev/services/run_workflow.d.ts +10 -0
  68. package/dist/views/dev/services/run_workflow.js +14 -0
  69. package/dist/views/dev/services/scenario_io.d.ts +2 -0
  70. package/dist/views/dev/services/scenario_io.js +37 -0
  71. package/dist/views/dev/state/ui_state.d.ts +60 -0
  72. package/dist/views/dev/state/ui_state.js +64 -0
  73. package/dist/views/dev/utils/constants.d.ts +17 -0
  74. package/dist/views/dev/utils/constants.js +17 -0
  75. package/dist/views/dev/utils/json_editor.d.ts +21 -0
  76. package/dist/views/dev/utils/json_editor.js +117 -0
  77. package/dist/views/dev/utils/json_editor.spec.d.ts +1 -0
  78. package/dist/views/dev/utils/json_editor.spec.js +57 -0
  79. package/dist/views/dev/utils/json_render.d.ts +15 -0
  80. package/dist/views/dev/utils/json_render.js +77 -0
  81. package/dist/views/dev/utils/json_render.spec.d.ts +1 -0
  82. package/dist/views/dev/utils/json_render.spec.js +65 -0
  83. package/dist/views/dev/utils/panel_helpers.d.ts +16 -0
  84. package/dist/views/dev/utils/panel_helpers.js +32 -0
  85. package/dist/views/dev/utils/panel_helpers.spec.d.ts +1 -0
  86. package/dist/views/dev/utils/panel_helpers.spec.js +47 -0
  87. package/package.json +5 -5
  88. package/dist/components/command_footer.d.ts +0 -8
  89. package/dist/components/command_footer.js +0 -4
  90. package/dist/components/workflow_summary.d.ts +0 -10
  91. package/dist/components/workflow_summary.js +0 -4
  92. package/dist/views/dev.js +0 -187
  93. package/dist/views/workflow/list.d.ts +0 -6
  94. package/dist/views/workflow/list.js +0 -129
@@ -0,0 +1,204 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useState } from 'react';
3
+ import { Box, Text, useInput } from 'ink';
4
+ import { StatusIcon, statusColor } from '#components/status_icon.js';
5
+ import { elapsedMs, formatDurationCompact, formatDate } from '#utils/date_formatter.js';
6
+ import { openUrl } from '#utils/open_url.js';
7
+ import { Footer } from '#views/dev/chrome/footer.js';
8
+ import { LoadingSpinner } from '#views/dev/chrome/loading_spinner.js';
9
+ import { SelectionIndicator } from '#views/dev/chrome/selection_indicator.js';
10
+ import { useUiState } from '#views/dev/state/ui_state.js';
11
+ import { RunDetailView } from '#views/dev/panels/run_detail_view.js';
12
+ import { useRunDetail } from '#views/dev/hooks/use_run_detail.js';
13
+ import { JsonView } from '#views/dev/utils/json_render.js';
14
+ import { MasterDetailPanel } from '#views/dev/components/master_detail_panel.js';
15
+ import { truncate, formatStartedShort } from '#views/dev/utils/panel_helpers.js';
16
+ import { CATALOG_WORKFLOW_NAME, RUNS_VISIBLE_ROWS, RUNS_PREVIEW_LINES } from '#views/dev/utils/constants.js';
17
+ const TEMPORAL_UI_BASE = 'http://localhost:8080';
18
+ const STATUS_ORDER = {
19
+ running: 0,
20
+ failed: 1,
21
+ timed_out: 2,
22
+ terminated: 3,
23
+ canceled: 4,
24
+ continued: 5,
25
+ completed: 6
26
+ };
27
+ const sortRuns = (runs) => [...runs].sort((a, b) => {
28
+ const statusDiff = (STATUS_ORDER[a.status ?? ''] ?? Infinity) - (STATUS_ORDER[b.status ?? ''] ?? Infinity);
29
+ if (statusDiff !== 0) {
30
+ return statusDiff;
31
+ }
32
+ const aTime = a.startedAt ? new Date(a.startedAt).getTime() : 0;
33
+ const bTime = b.startedAt ? new Date(b.startedAt).getTime() : 0;
34
+ return bTime - aTime;
35
+ });
36
+ const matchesFilter = (run, query) => {
37
+ if (!query) {
38
+ return true;
39
+ }
40
+ const q = query.toLowerCase();
41
+ return (run.workflowType ?? '').toLowerCase().includes(q) ||
42
+ (run.workflowId ?? '').toLowerCase().includes(q) ||
43
+ (run.status ?? '').toLowerCase().includes(q);
44
+ };
45
+ export const buildVisibleRuns = (runs, query) => {
46
+ const visible = runs.filter(r => !(r.workflowType === CATALOG_WORKFLOW_NAME && r.status === 'completed'));
47
+ const filtered = query ? visible.filter(r => matchesFilter(r, query)) : visible;
48
+ return sortRuns(filtered);
49
+ };
50
+ export const extractRunInput = (trace) => {
51
+ if (!trace?.children) {
52
+ return null;
53
+ }
54
+ const firstChild = trace.children[0];
55
+ if (!firstChild) {
56
+ return null;
57
+ }
58
+ if (firstChild.input !== undefined) {
59
+ return firstChild.input;
60
+ }
61
+ return firstChild.details?.input ?? null;
62
+ };
63
+ const COL = {
64
+ indicator: 3,
65
+ icon: 3,
66
+ status: 11,
67
+ type: 22,
68
+ id: 26,
69
+ duration: 9,
70
+ started: 14
71
+ };
72
+ const HeaderRow = () => (_jsxs(Box, { children: [_jsx(Box, { width: COL.indicator, children: _jsx(Text, { children: " " }) }), _jsx(Box, { width: COL.icon, children: _jsx(Text, { children: " " }) }), _jsx(Box, { width: COL.status, children: _jsx(Text, { dimColor: true, bold: true, children: "STATUS" }) }), _jsx(Box, { width: COL.type, children: _jsx(Text, { dimColor: true, bold: true, children: "TYPE" }) }), _jsx(Box, { width: COL.id, children: _jsx(Text, { dimColor: true, bold: true, children: "ID" }) }), _jsx(Box, { width: COL.duration, justifyContent: "flex-end", children: _jsx(Text, { dimColor: true, bold: true, children: "DURATION" }) }), _jsx(Box, { width: COL.started, marginLeft: 2, children: _jsx(Text, { dimColor: true, bold: true, children: "STARTED" }) })] }));
73
+ const RunRow = ({ run, selected }) => {
74
+ const status = run.status ?? 'running';
75
+ const color = statusColor(status);
76
+ const duration = run.startedAt ? formatDurationCompact(elapsedMs(run.startedAt, run.completedAt)) : '-';
77
+ return (_jsxs(Box, { children: [_jsx(Box, { width: COL.indicator, children: _jsx(SelectionIndicator, { selected: selected }) }), _jsx(Box, { width: COL.icon, children: _jsx(StatusIcon, { status: status }) }), _jsx(Box, { width: COL.status, children: _jsx(Text, { color: color, children: status }) }), _jsx(Box, { width: COL.type, children: _jsx(Text, { bold: selected, children: truncate(run.workflowType ?? '-', COL.type - 1) }) }), _jsx(Box, { width: COL.id, children: _jsx(Text, { dimColor: !selected, children: truncate(run.workflowId ?? '-', COL.id - 1) }) }), _jsx(Box, { width: COL.duration, justifyContent: "flex-end", children: _jsx(Text, { dimColor: !selected, children: duration }) }), _jsx(Box, { width: COL.started, marginLeft: 2, children: _jsx(Text, { dimColor: !selected, children: formatStartedShort(run.startedAt) }) })] }));
78
+ };
79
+ const PaneTabs = ({ active }) => (_jsx(Box, { children: ['input', 'output'].map((tab, i) => (_jsx(Box, { marginRight: i === 0 ? 1 : 0, children: tab === active ? (_jsx(Text, { inverse: true, bold: true, children: ` ${tab[0].toUpperCase()}${tab.slice(1)} ` })) : (_jsx(Text, { dimColor: true, children: ` ${tab[0].toUpperCase()}${tab.slice(1)} ` })) }, tab))) }));
80
+ const InlineKV = ({ label, value }) => (_jsxs(Box, { children: [_jsxs(Text, { dimColor: true, children: [label, ": "] }), _jsx(Text, { children: value })] }));
81
+ const DetailPane = ({ run, pane }) => {
82
+ const ui = useUiState();
83
+ if (!run || !pane) {
84
+ return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: "Select a run to see details." }) }));
85
+ }
86
+ const { input: runInput, output: runOutput, error: runError, status, loading } = pane;
87
+ const duration = run.startedAt ? formatDurationCompact(elapsedMs(run.startedAt, run.completedAt)) : '-';
88
+ const activePane = ui.rightPaneTab === 'input' ? 'input' : 'output';
89
+ const renderPane = () => {
90
+ if (activePane === 'input') {
91
+ if (loading && runInput === null) {
92
+ return _jsx(LoadingSpinner, {});
93
+ }
94
+ return _jsx(JsonView, { value: runInput, maxLines: RUNS_PREVIEW_LINES });
95
+ }
96
+ if (runError) {
97
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "red", bold: true, children: "ERROR" }), _jsx(Text, { color: "red", wrap: "wrap", children: truncate(String(runError), 400) })] }));
98
+ }
99
+ if (runOutput === undefined || runOutput === null) {
100
+ if (loading) {
101
+ return _jsx(LoadingSpinner, {});
102
+ }
103
+ return _jsx(Text, { dimColor: true, children: "No output yet." });
104
+ }
105
+ return _jsx(JsonView, { value: runOutput, maxLines: RUNS_PREVIEW_LINES });
106
+ };
107
+ const heading = `${run.workflowType ?? 'run'} : ${run.runId ?? run.workflowId ?? '-'}`;
108
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { children: _jsx(Text, { bold: true, children: heading }) }), _jsxs(Box, { marginTop: 1, children: [_jsx(StatusIcon, { status: status }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, color: statusColor(status), children: status.toUpperCase() }), _jsx(Text, { dimColor: true, children: " " }), _jsx(InlineKV, { label: "DURATION", value: duration }), _jsx(Text, { dimColor: true, children: " " }), _jsx(InlineKV, { label: "STARTED", value: formatDate(run.startedAt) }), _jsx(Text, { dimColor: true, children: " " }), _jsx(InlineKV, { label: "COMPLETED", value: run.completedAt ? formatDate(run.completedAt) : '—' })] }), _jsx(Box, { marginTop: 1, children: _jsx(PaneTabs, { active: activePane }) }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: renderPane() })] }));
109
+ };
110
+ const HINTS = [
111
+ { key: '↑/↓', label: 'navigate' },
112
+ { key: 'enter', label: 'open' },
113
+ { key: '←/→', label: 'switch pane' },
114
+ { key: 'e', label: 'expand' },
115
+ { key: 'o', label: 'temporal' },
116
+ { key: '/', label: 'filter' },
117
+ { key: 'tab', label: 'next tab' }
118
+ ];
119
+ export const RunsPanel = ({ runs }) => {
120
+ const ui = useUiState();
121
+ const filteredRuns = useMemo(() => buildVisibleRuns(runs, ui.search.query), [runs, ui.search.query]);
122
+ // Lazy initializer — runs once on mount. Restores the previously selected
123
+ // run after the expanded-JSON modal unmounts and remounts the panel.
124
+ const [selectedIndex, setSelectedIndex] = useState(() => {
125
+ const previousRunId = ui.selection.runId;
126
+ if (!previousRunId) {
127
+ return 0;
128
+ }
129
+ const initial = buildVisibleRuns(runs, ui.search.query);
130
+ const i = initial.findIndex(r => r.runId === previousRunId);
131
+ return i >= 0 ? i : 0;
132
+ });
133
+ const isActive = ui.tab === 'runs' && ui.runsView === 'list' && !ui.search.open && !ui.runModal.open && !ui.expandedJson.open;
134
+ const clampedIndex = Math.min(selectedIndex, Math.max(0, filteredRuns.length - 1));
135
+ const selectedRun = filteredRuns[clampedIndex];
136
+ const { result, trace, loading } = useRunDetail(selectedRun?.workflowId, selectedRun?.runId, selectedRun?.status);
137
+ const pane = selectedRun ? {
138
+ input: extractRunInput(trace),
139
+ output: result?.output,
140
+ error: result?.error,
141
+ status: result?.status ?? selectedRun.status ?? 'unknown',
142
+ loading
143
+ } : null;
144
+ useEffect(() => {
145
+ if (clampedIndex !== selectedIndex) {
146
+ setSelectedIndex(clampedIndex);
147
+ }
148
+ }, [clampedIndex, selectedIndex]);
149
+ const setSelection = ui.setSelection;
150
+ useEffect(() => {
151
+ setSelection({
152
+ runId: selectedRun?.runId,
153
+ workflowId: selectedRun?.workflowId,
154
+ workflowName: selectedRun?.workflowType
155
+ });
156
+ }, [selectedRun?.runId, selectedRun?.workflowId, selectedRun?.workflowType, setSelection]);
157
+ useInput((input, key) => {
158
+ if (key.upArrow) {
159
+ setSelectedIndex(i => Math.max(0, i - 1));
160
+ return;
161
+ }
162
+ if (key.downArrow) {
163
+ setSelectedIndex(i => Math.min(filteredRuns.length - 1, i + 1));
164
+ return;
165
+ }
166
+ if (input === 'o' && selectedRun?.workflowId) {
167
+ openUrl(`${TEMPORAL_UI_BASE}/namespaces/default/workflows/${selectedRun.workflowId}`);
168
+ return;
169
+ }
170
+ if (key.return && selectedRun?.workflowId) {
171
+ ui.setRunsView('detail');
172
+ return;
173
+ }
174
+ if (key.leftArrow || key.rightArrow) {
175
+ ui.setRightPaneTab(ui.rightPaneTab === 'input' ? 'output' : 'input');
176
+ return;
177
+ }
178
+ if (input === 'e' && pane) {
179
+ const content = ui.rightPaneTab === 'input' ?
180
+ pane.input :
181
+ (pane.error ?? pane.output);
182
+ const label = `${selectedRun?.workflowType ?? 'run'} → ${ui.rightPaneTab}`;
183
+ ui.openExpandedJson(content, label);
184
+ }
185
+ }, { isActive });
186
+ const detailRun = ui.runsView === 'detail' ?
187
+ (runs.find(r => r.runId === ui.selection.runId && r.workflowId === ui.selection.workflowId) ?? selectedRun) :
188
+ undefined;
189
+ useEffect(() => {
190
+ if (ui.runsView === 'detail' && !detailRun) {
191
+ ui.setRunsView('list');
192
+ }
193
+ }, [ui, detailRun]);
194
+ if (ui.runsView === 'detail' && detailRun) {
195
+ return _jsx(RunDetailView, { run: detailRun });
196
+ }
197
+ if (runs.length === 0) {
198
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, children: "Recent Runs" }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "No runs yet. Trigger one from the Workflows tab or with `output workflow run \u2026`." }) }), _jsx(Footer, { hints: [{ key: 'tab', label: 'next tab' }, { key: '?', label: 'help' }] })] }));
199
+ }
200
+ if (filteredRuns.length === 0) {
201
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, children: "Recent Runs" }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { dimColor: true, children: ["No runs match `", ui.search.query, "`. Press "] }), _jsx(Text, { bold: true, children: "esc" }), _jsx(Text, { dimColor: true, children: " to clear the filter." })] }), _jsx(Footer, { hints: HINTS, itemCount: 0, itemLabel: "runs" })] }));
202
+ }
203
+ return (_jsx(MasterDetailPanel, { items: filteredRuns, selectedIndex: clampedIndex, visibleRows: RUNS_VISIBLE_ROWS, renderHeader: () => _jsx(HeaderRow, {}), renderRow: (run, selected) => _jsx(RunRow, { run: run, selected: selected }), rowKey: (run, i) => `${run.workflowId}-${run.runId ?? run.startedAt}-${i}`, detail: _jsx(DetailPane, { run: selectedRun, pane: pane }), hints: HINTS, itemLabel: "runs" }));
204
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildVisibleRuns, extractRunInput } from './runs_panel.js';
3
+ const run = (overrides) => ({
4
+ workflowId: 'wf',
5
+ workflowType: 'demo',
6
+ status: 'completed',
7
+ startedAt: '2026-04-28T18:56:53Z',
8
+ completedAt: '2026-04-28T18:56:57Z',
9
+ ...overrides
10
+ });
11
+ describe('buildVisibleRuns', () => {
12
+ it('drops completed $catalog rows', () => {
13
+ const runs = [
14
+ run({ workflowType: '$catalog', status: 'completed' }),
15
+ run({ workflowType: 'demo', status: 'completed' })
16
+ ];
17
+ const visible = buildVisibleRuns(runs, '');
18
+ expect(visible.map(r => r.workflowType)).toEqual(['demo']);
19
+ });
20
+ it('keeps non-completed $catalog rows for diagnostics', () => {
21
+ const runs = [
22
+ run({ workflowType: '$catalog', status: 'running' }),
23
+ run({ workflowType: '$catalog', status: 'failed' })
24
+ ];
25
+ const visible = buildVisibleRuns(runs, '');
26
+ expect(visible).toHaveLength(2);
27
+ });
28
+ it('sorts running before failed before completed', () => {
29
+ const runs = [
30
+ run({ workflowType: 'a', status: 'completed' }),
31
+ run({ workflowType: 'b', status: 'running' }),
32
+ run({ workflowType: 'c', status: 'failed' })
33
+ ];
34
+ const visible = buildVisibleRuns(runs, '');
35
+ expect(visible.map(r => r.status)).toEqual(['running', 'failed', 'completed']);
36
+ });
37
+ it('sorts within the same status by startedAt descending', () => {
38
+ const runs = [
39
+ run({ workflowId: 'old', startedAt: '2026-04-01T00:00:00Z' }),
40
+ run({ workflowId: 'new', startedAt: '2026-04-28T00:00:00Z' })
41
+ ];
42
+ const visible = buildVisibleRuns(runs, '');
43
+ expect(visible.map(r => r.workflowId)).toEqual(['new', 'old']);
44
+ });
45
+ it('filters by query against workflowType, workflowId, and status', () => {
46
+ const runs = [
47
+ run({ workflowType: 'apple', workflowId: 'wf-1' }),
48
+ run({ workflowType: 'banana', workflowId: 'wf-2' })
49
+ ];
50
+ expect(buildVisibleRuns(runs, 'apple')).toHaveLength(1);
51
+ expect(buildVisibleRuns(runs, 'wf-2')).toHaveLength(1);
52
+ expect(buildVisibleRuns(runs, 'completed')).toHaveLength(2);
53
+ expect(buildVisibleRuns(runs, 'no-match')).toHaveLength(0);
54
+ });
55
+ });
56
+ describe('extractRunInput', () => {
57
+ it('returns null when the trace has no children', () => {
58
+ expect(extractRunInput(null)).toBeNull();
59
+ expect(extractRunInput({ root: { workflowName: 'x', workflowId: 'y', startTime: 0 }, children: [] })).toBeNull();
60
+ });
61
+ it('reads from the first child input field directly', () => {
62
+ const trace = {
63
+ root: { workflowName: 'x', workflowId: 'y', startTime: 0 },
64
+ children: [{ input: { foo: 1 } }]
65
+ };
66
+ expect(extractRunInput(trace)).toEqual({ foo: 1 });
67
+ });
68
+ it('falls back to details.input when the top-level input is missing', () => {
69
+ const trace = {
70
+ root: { workflowName: 'x', workflowId: 'y', startTime: 0 },
71
+ children: [{ details: { input: { bar: 2 } } }]
72
+ };
73
+ expect(extractRunInput(trace)).toEqual({ bar: 2 });
74
+ });
75
+ it('returns null when neither input source is set', () => {
76
+ const trace = {
77
+ root: { workflowName: 'x', workflowId: 'y', startTime: 0 },
78
+ children: [{ name: 'first-step' }]
79
+ };
80
+ expect(extractRunInput(trace)).toBeNull();
81
+ });
82
+ });
@@ -0,0 +1,14 @@
1
+ import React from 'react';
2
+ import { type ServiceStatus } from '#services/docker.js';
3
+ import type { Phase } from '#views/dev/dev_app.js';
4
+ /**
5
+ * Worker first (most-watched in development), then API, then everything
6
+ * else alphabetically. Promoting the priority list to a constant keeps
7
+ * the comparator three lines.
8
+ */
9
+ export declare const compareService: (a: ServiceStatus, b: ServiceStatus) => number;
10
+ export declare const ServicesPanel: React.FC<{
11
+ phase: Phase;
12
+ services: ServiceStatus[];
13
+ dockerComposePath: string;
14
+ }>;
@@ -0,0 +1,155 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useState } from 'react';
3
+ import { Box, Text, useInput } from 'ink';
4
+ import { isServiceFailed, SERVICE_HEALTH } from '#services/docker.js';
5
+ import { StatusIcon } from '#components/status_icon.js';
6
+ import { openUrl } from '#utils/open_url.js';
7
+ import { Footer } from '#views/dev/chrome/footer.js';
8
+ import { LoadingSpinner } from '#views/dev/chrome/loading_spinner.js';
9
+ import { SelectionIndicator } from '#views/dev/chrome/selection_indicator.js';
10
+ import { useUiState } from '#views/dev/state/ui_state.js';
11
+ import { useDockerLogs } from '#views/dev/hooks/use_docker_logs.js';
12
+ import { restartService, restartStack } from '#views/dev/services/docker_control.js';
13
+ import { MasterDetailPanel } from '#views/dev/components/master_detail_panel.js';
14
+ const VISIBLE_LOG_LINES = 18;
15
+ const resolveServiceStatus = (service) => service.health === SERVICE_HEALTH.NONE ? service.state : service.health;
16
+ const SERVICE_URLS = {
17
+ 'temporal-ui': 'http://localhost:8080',
18
+ api: 'http://localhost:3001',
19
+ temporal: 'http://localhost:7233',
20
+ redis: 'redis://localhost:6379'
21
+ };
22
+ const COL = {
23
+ indicator: 3,
24
+ icon: 3,
25
+ name: 16,
26
+ status: 11,
27
+ ports: 22
28
+ };
29
+ const HeaderRow = () => (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Services" }), _jsxs(Box, { marginTop: 1, children: [_jsx(Box, { width: COL.indicator, children: _jsx(Text, { children: " " }) }), _jsx(Box, { width: COL.icon, children: _jsx(Text, { children: " " }) }), _jsx(Box, { width: COL.name, children: _jsx(Text, { dimColor: true, bold: true, children: "SERVICE" }) }), _jsx(Box, { width: COL.status, children: _jsx(Text, { dimColor: true, bold: true, children: "STATUS" }) }), _jsx(Box, { width: COL.ports, children: _jsx(Text, { dimColor: true, bold: true, children: "PORTS" }) })] })] }));
30
+ const ServiceRow = ({ service, selected }) => {
31
+ const status = resolveServiceStatus(service);
32
+ const ports = service.ports.length ? service.ports.join(', ') : '-';
33
+ return (_jsxs(Box, { children: [_jsx(Box, { width: COL.indicator, children: _jsx(SelectionIndicator, { selected: selected }) }), _jsx(Box, { width: COL.icon, children: _jsx(StatusIcon, { status: status }) }), _jsx(Box, { width: COL.name, children: _jsx(Text, { bold: selected, wrap: "truncate-end", children: service.name }) }), _jsx(Box, { width: COL.status, children: _jsx(Text, { dimColor: !selected, wrap: "truncate-end", children: status }) }), _jsx(Box, { width: COL.ports, children: _jsx(Text, { dimColor: !selected, wrap: "truncate-end", children: ports }) })] }));
34
+ };
35
+ const FailureBanner = ({ services }) => {
36
+ const failed = services.filter(isServiceFailed);
37
+ if (failed.length === 0) {
38
+ return null;
39
+ }
40
+ return (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { backgroundColor: "red", color: "white", bold: true, children: [" \u26A0\uFE0F ", failed.length, " service(s) failing \u2014 see logs and press r to restart "] }) }));
41
+ };
42
+ const LogPane = ({ serviceName, lines, paused }) => {
43
+ if (!serviceName) {
44
+ return _jsx(Text, { dimColor: true, children: "Select a service to tail its logs." });
45
+ }
46
+ if (lines.length === 0) {
47
+ return _jsx(LoadingSpinner, { label: `Waiting for logs from ${serviceName}…` });
48
+ }
49
+ const visible = lines.slice(-VISIBLE_LOG_LINES);
50
+ return (_jsxs(Box, { flexDirection: "column", children: [paused && (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { backgroundColor: "yellow", color: "black", children: " PAUSED " }), _jsx(Text, { dimColor: true, children: " press p to resume" })] })), visible.map((line, i) => (_jsx(Text, { wrap: "truncate-end", children: line }, `${i}-${line.length}`)))] }));
51
+ };
52
+ const SERVICE_PRIORITY = ['worker', 'api'];
53
+ /**
54
+ * Worker first (most-watched in development), then API, then everything
55
+ * else alphabetically. Promoting the priority list to a constant keeps
56
+ * the comparator three lines.
57
+ */
58
+ export const compareService = (a, b) => {
59
+ const ai = SERVICE_PRIORITY.indexOf(a.name);
60
+ const bi = SERVICE_PRIORITY.indexOf(b.name);
61
+ if (ai !== -1 || bi !== -1) {
62
+ return (ai === -1 ? Infinity : ai) - (bi === -1 ? Infinity : bi);
63
+ }
64
+ return a.name.localeCompare(b.name);
65
+ };
66
+ const HINTS = [
67
+ { key: '↑/↓', label: 'navigate' },
68
+ { key: 'r/R', label: 'restart one/all' },
69
+ { key: 'p', label: 'pause logs' },
70
+ { key: 'c', label: 'clear' },
71
+ { key: 'o', label: 'open url' },
72
+ { key: 'tab', label: 'next tab' },
73
+ { key: 'ctrl+c', label: 'stop & quit' }
74
+ ];
75
+ const HINTS_BOOT = [
76
+ { key: 'tab', label: 'next tab' },
77
+ { key: '?', label: 'help' },
78
+ { key: 'ctrl+c', label: 'quit' }
79
+ ];
80
+ const Detail = ({ service, services, lines, paused, banner }) => (_jsxs(Box, { flexDirection: "column", children: [_jsx(FailureBanner, { services: services }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { bold: true, children: service?.name ?? 'Logs' }) }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(LogPane, { serviceName: service?.name ?? null, lines: lines, paused: paused }) }), banner && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { children: banner }) }))] }));
81
+ export const ServicesPanel = ({ phase, services, dockerComposePath }) => {
82
+ const ui = useUiState();
83
+ const [selectedIndex, setSelectedIndex] = useState(0);
84
+ const [banner, setBanner] = useState(null);
85
+ const sortedServices = useMemo(() => [...services].sort(compareService), [services]);
86
+ const isActive = ui.tab === 'services' && !ui.search.open && !ui.runModal.open;
87
+ const clamped = Math.min(selectedIndex, Math.max(0, sortedServices.length - 1));
88
+ const selectedService = sortedServices[clamped];
89
+ const enabledLogs = isActive && phase === 'running' && Boolean(selectedService);
90
+ useEffect(() => {
91
+ if (clamped !== selectedIndex) {
92
+ setSelectedIndex(clamped);
93
+ }
94
+ }, [clamped, selectedIndex]);
95
+ const logs = useDockerLogs(dockerComposePath, selectedService?.name ?? null, enabledLogs);
96
+ useEffect(() => {
97
+ if (!banner) {
98
+ return () => { };
99
+ }
100
+ const id = setTimeout(() => setBanner(null), 3000);
101
+ return () => clearTimeout(id);
102
+ }, [banner]);
103
+ useInput((input, key) => {
104
+ if (key.upArrow) {
105
+ setSelectedIndex(i => Math.max(0, i - 1));
106
+ return;
107
+ }
108
+ if (key.downArrow) {
109
+ setSelectedIndex(i => Math.min(sortedServices.length - 1, i + 1));
110
+ return;
111
+ }
112
+ if (!selectedService) {
113
+ return;
114
+ }
115
+ if (input === 'p') {
116
+ logs.setPaused(!logs.paused);
117
+ return;
118
+ }
119
+ if (input === 'c') {
120
+ logs.clear();
121
+ return;
122
+ }
123
+ if (input === 'o') {
124
+ const url = SERVICE_URLS[selectedService.name];
125
+ if (url) {
126
+ openUrl(url);
127
+ setBanner(`Opened ${url}`);
128
+ }
129
+ else {
130
+ setBanner(`${selectedService.name} has no known URL`);
131
+ }
132
+ return;
133
+ }
134
+ if (input === 'r') {
135
+ setBanner(`Restarting ${selectedService.name}…`);
136
+ restartService(dockerComposePath, selectedService.name)
137
+ .then(() => setBanner(`Restarted ${selectedService.name}`))
138
+ .catch(err => setBanner(`Restart failed: ${err instanceof Error ? err.message : String(err)}`));
139
+ return;
140
+ }
141
+ if (input === 'R') {
142
+ setBanner('Restarting all services…');
143
+ restartStack(dockerComposePath)
144
+ .then(() => setBanner('Restarted all services'))
145
+ .catch(err => setBanner(`Restart failed: ${err instanceof Error ? err.message : String(err)}`));
146
+ }
147
+ }, { isActive });
148
+ if (phase === 'waiting' && services.length === 0) {
149
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(LoadingSpinner, { label: "Starting Docker services\u2026" }), _jsx(Footer, { hints: HINTS_BOOT })] }));
150
+ }
151
+ if (services.length === 0) {
152
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "No services running." }), _jsx(Footer, { hints: HINTS_BOOT })] }));
153
+ }
154
+ return (_jsx(MasterDetailPanel, { items: sortedServices, selectedIndex: clamped, visibleRows: sortedServices.length, renderHeader: () => _jsx(HeaderRow, {}), renderRow: (service, selected) => _jsx(ServiceRow, { service: service, selected: selected }), rowKey: service => service.name, detail: _jsx(Detail, { service: selectedService, services: services, lines: logs.lines, paused: logs.paused, banner: banner }), hints: phase === 'running' ? HINTS : HINTS_BOOT, itemLabel: "services" }));
155
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { compareService } from './services_panel.js';
3
+ const svc = (name) => ({
4
+ name,
5
+ state: 'running',
6
+ health: 'healthy',
7
+ ports: []
8
+ });
9
+ describe('compareService', () => {
10
+ it('puts worker before api', () => {
11
+ expect(compareService(svc('worker'), svc('api'))).toBeLessThan(0);
12
+ });
13
+ it('puts api before alphabetically-earlier names', () => {
14
+ expect(compareService(svc('api'), svc('aardvark'))).toBeLessThan(0);
15
+ });
16
+ it('puts worker before alphabetically-earlier names', () => {
17
+ expect(compareService(svc('worker'), svc('aardvark'))).toBeLessThan(0);
18
+ });
19
+ it('sorts non-priority services alphabetically', () => {
20
+ expect(compareService(svc('postgres'), svc('redis'))).toBeLessThan(0);
21
+ expect(compareService(svc('temporal'), svc('redis'))).toBeGreaterThan(0);
22
+ });
23
+ it('produces the expected full ordering for a real stack', () => {
24
+ const stack = [svc('temporal-ui'), svc('redis'), svc('postgres'), svc('api'), svc('worker'), svc('temporal')];
25
+ const sorted = [...stack].sort(compareService).map(s => s.name);
26
+ expect(sorted).toEqual(['worker', 'api', 'postgres', 'redis', 'temporal', 'temporal-ui']);
27
+ });
28
+ });
@@ -0,0 +1,7 @@
1
+ import React from 'react';
2
+ import type { Workflow } from '#api/generated/api.js';
3
+ import type { WorkflowRun } from '#services/workflow_runs.js';
4
+ export declare const WorkflowsPanel: React.FC<{
5
+ workflows: Workflow[];
6
+ runs: WorkflowRun[];
7
+ }>;
@@ -0,0 +1,111 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useState } from 'react';
3
+ import { Box, Text, useInput } from 'ink';
4
+ import { StatusIcon, statusColor } from '#components/status_icon.js';
5
+ import { elapsedMs, formatDurationCompact } from '#utils/date_formatter.js';
6
+ import { Footer } from '#views/dev/chrome/footer.js';
7
+ import { SelectionIndicator } from '#views/dev/chrome/selection_indicator.js';
8
+ import { useUiState } from '#views/dev/state/ui_state.js';
9
+ import { MasterDetailPanel } from '#views/dev/components/master_detail_panel.js';
10
+ import { formatStartedShort } from '#views/dev/utils/panel_helpers.js';
11
+ import { WORKFLOWS_VISIBLE_ROWS, WORKFLOWS_RECENT_RUNS_LIMIT } from '#views/dev/utils/constants.js';
12
+ const COL = {
13
+ indicator: 3,
14
+ name: 30
15
+ };
16
+ const matchesQuery = (workflow, query) => {
17
+ if (!query) {
18
+ return true;
19
+ }
20
+ const q = query.toLowerCase();
21
+ if ((workflow.name ?? '').toLowerCase().includes(q)) {
22
+ return true;
23
+ }
24
+ if ((workflow.description ?? '').toLowerCase().includes(q)) {
25
+ return true;
26
+ }
27
+ return (workflow.aliases ?? []).some(a => a.toLowerCase().includes(q));
28
+ };
29
+ const sortByName = (workflows) => [...workflows].sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''));
30
+ const buildVisibleWorkflows = (workflows, query) => {
31
+ const list = query ? workflows.filter(w => matchesQuery(w, query)) : workflows;
32
+ return sortByName(list);
33
+ };
34
+ const HeaderRow = () => (_jsxs(Box, { children: [_jsx(Box, { width: COL.indicator, children: _jsx(Text, { children: " " }) }), _jsx(Box, { width: COL.name, children: _jsx(Text, { dimColor: true, bold: true, children: "WORKFLOW" }) }), _jsx(Box, { flexGrow: 1, children: _jsx(Text, { dimColor: true, bold: true, children: "DESCRIPTION" }) })] }));
35
+ const WorkflowRow = ({ workflow, selected }) => (_jsxs(Box, { children: [_jsx(Box, { width: COL.indicator, children: _jsx(SelectionIndicator, { selected: selected }) }), _jsx(Box, { width: COL.name, children: _jsx(Text, { bold: selected, wrap: "truncate-end", children: workflow.name ?? '-' }) }), _jsx(Box, { flexGrow: 1, children: _jsx(Text, { dimColor: !selected, wrap: "truncate-end", children: workflow.description ?? 'No description' }) })] }));
36
+ const SidebarRunRow = ({ run }) => {
37
+ const status = run.status ?? 'unknown';
38
+ const duration = run.startedAt ? formatDurationCompact(elapsedMs(run.startedAt, run.completedAt)) : '-';
39
+ return (_jsxs(Box, { children: [_jsx(StatusIcon, { status: status }), _jsx(Text, { children: " " }), _jsx(Box, { width: 10, children: _jsx(Text, { color: statusColor(status), children: status }) }), _jsx(Box, { width: 9, justifyContent: "flex-end", children: _jsx(Text, { dimColor: true, children: duration }) }), _jsx(Box, { marginLeft: 2, children: _jsx(Text, { dimColor: true, children: formatStartedShort(run.startedAt) }) })] }));
40
+ };
41
+ const DetailPane = ({ workflow, runs }) => {
42
+ if (!workflow) {
43
+ return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: "Select a workflow to see details." }) }));
44
+ }
45
+ const wfRuns = runs.filter(r => r.workflowType === workflow.name);
46
+ const stats = {
47
+ total: wfRuns.length,
48
+ running: wfRuns.filter(r => r.status === 'running').length,
49
+ failed: wfRuns.filter(r => r.status === 'failed').length,
50
+ completed: wfRuns.filter(r => r.status === 'completed').length
51
+ };
52
+ const recent = wfRuns.slice(0, WORKFLOWS_RECENT_RUNS_LIMIT);
53
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { children: _jsx(Text, { bold: true, color: "white", children: workflow.name }) }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { dimColor: true, children: [stats.total, " runs"] }), stats.running > 0 && _jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: " " }), _jsxs(Text, { color: "blue", children: ["\u25CF ", stats.running, " running"] })] }), stats.failed > 0 && _jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: " " }), _jsxs(Text, { color: "red", children: ["\u2717 ", stats.failed, " failed"] })] }), stats.completed > 0 && _jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: " " }), _jsxs(Text, { color: "green", children: ["\u25CF ", stats.completed, " ok"] })] })] }), _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(Box, { flexDirection: "column", flexGrow: 1, paddingRight: 2, children: _jsx(Text, { wrap: "wrap", children: workflow.description ?? 'No description' }) }), _jsxs(Box, { flexDirection: "column", width: 42, borderStyle: "single", borderTop: false, borderBottom: false, borderRight: false, paddingLeft: 2, children: [_jsx(Text, { dimColor: true, bold: true, children: "RECENT RUNS" }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: recent.length === 0 ? (_jsx(Text, { dimColor: true, children: "No runs yet" })) : (recent.map((run, i) => _jsx(SidebarRunRow, { run: run }, `${run.runId ?? i}`))) })] })] })] }));
54
+ };
55
+ const HINTS = [
56
+ { key: '↑/↓', label: 'navigate' },
57
+ { key: 'enter', label: 'show runs' },
58
+ { key: 'r', label: 'run' },
59
+ { key: '/', label: 'search' },
60
+ { key: 'tab', label: 'next tab' }
61
+ ];
62
+ export const WorkflowsPanel = ({ workflows, runs }) => {
63
+ const ui = useUiState();
64
+ const filtered = useMemo(() => buildVisibleWorkflows(workflows, ui.search.query), [workflows, ui.search.query]);
65
+ // Lazy initializer — runs once on mount. Restores the previously
66
+ // highlighted workflow after the run modal unmounts and remounts the
67
+ // panel.
68
+ const [selectedIndex, setSelectedIndex] = useState(() => {
69
+ const previousName = ui.selection.workflowName;
70
+ if (!previousName) {
71
+ return 0;
72
+ }
73
+ const initial = buildVisibleWorkflows(workflows, ui.search.query);
74
+ const i = initial.findIndex(w => w.name === previousName);
75
+ return i >= 0 ? i : 0;
76
+ });
77
+ const isActive = ui.tab === 'workflows' && !ui.search.open && !ui.runModal.open;
78
+ const clamped = Math.min(selectedIndex, Math.max(0, filtered.length - 1));
79
+ const selectedWorkflow = filtered[clamped];
80
+ useEffect(() => {
81
+ if (clamped !== selectedIndex) {
82
+ setSelectedIndex(clamped);
83
+ }
84
+ }, [clamped, selectedIndex]);
85
+ const setSelection = ui.setSelection;
86
+ useEffect(() => {
87
+ setSelection({ workflowName: selectedWorkflow?.name });
88
+ }, [selectedWorkflow?.name, setSelection]);
89
+ useInput((input, key) => {
90
+ if (key.upArrow) {
91
+ setSelectedIndex(i => Math.max(0, i - 1));
92
+ }
93
+ else if (key.downArrow) {
94
+ setSelectedIndex(i => Math.min(filtered.length - 1, i + 1));
95
+ }
96
+ else if (key.return && selectedWorkflow?.name) {
97
+ ui.setSearchQuery(selectedWorkflow.name);
98
+ ui.setTab('runs');
99
+ }
100
+ else if (input === 'r' && selectedWorkflow?.name) {
101
+ ui.openRunModal(selectedWorkflow.name);
102
+ }
103
+ }, { isActive });
104
+ if (workflows.length === 0) {
105
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, children: "Workflows" }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Loading catalog\u2026 (waiting for the API to come up)" }) }), _jsx(Footer, { hints: [{ key: 'tab', label: 'next tab' }, { key: '?', label: 'help' }] })] }));
106
+ }
107
+ if (filtered.length === 0) {
108
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, children: "Workflows" }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { dimColor: true, children: ["No workflows match `", ui.search.query, "`. Press "] }), _jsx(Text, { bold: true, children: "esc" }), _jsx(Text, { dimColor: true, children: " to clear." })] }), _jsx(Footer, { hints: HINTS, itemCount: 0, itemLabel: "workflows" })] }));
109
+ }
110
+ return (_jsx(MasterDetailPanel, { items: filtered, selectedIndex: clamped, visibleRows: WORKFLOWS_VISIBLE_ROWS, renderHeader: () => _jsx(HeaderRow, {}), renderRow: (wf, selected) => _jsx(WorkflowRow, { workflow: wf, selected: selected }), rowKey: (wf, i) => wf.name ?? `row-${i}`, detail: _jsx(DetailPane, { workflow: selectedWorkflow, runs: runs }), hints: HINTS, itemLabel: "workflows" }));
111
+ };
@@ -0,0 +1,5 @@
1
+ import { type ChildProcessWithoutNullStreams } from 'node:child_process';
2
+ export declare const restartService: (dockerComposePath: string, serviceName: string) => Promise<void>;
3
+ export declare const restartStack: (dockerComposePath: string) => Promise<void>;
4
+ export declare const stopService: (dockerComposePath: string, serviceName: string) => Promise<void>;
5
+ export declare const tailLogs: (dockerComposePath: string, serviceName: string, tailLines?: number) => ChildProcessWithoutNullStreams;
@@ -0,0 +1,25 @@
1
+ import { spawn } from 'node:child_process';
2
+ // stdio: 'pipe' so docker's stderr/stdout never reach the host terminal
3
+ // while INK owns the screen. Stderr is buffered and surfaced through the
4
+ // rejected Promise so the panel can present it as a banner instead of
5
+ // having garbled output collide with the rendered TUI.
6
+ const run = (args) => new Promise((resolve, reject) => {
7
+ const child = spawn('docker', args, { stdio: 'pipe' });
8
+ const stderrChunks = [];
9
+ child.stderr.on('data', chunk => {
10
+ stderrChunks.push(chunk.toString());
11
+ });
12
+ child.on('error', reject);
13
+ child.on('exit', code => {
14
+ if (code === 0) {
15
+ resolve();
16
+ return;
17
+ }
18
+ const stderr = stderrChunks.join('').trim();
19
+ reject(new Error(stderr || `docker ${args.join(' ')} exited with code ${code}`));
20
+ });
21
+ });
22
+ export const restartService = (dockerComposePath, serviceName) => run(['compose', '-f', dockerComposePath, 'restart', serviceName]);
23
+ export const restartStack = (dockerComposePath) => run(['compose', '-f', dockerComposePath, 'restart']);
24
+ export const stopService = (dockerComposePath, serviceName) => run(['compose', '-f', dockerComposePath, 'stop', serviceName]);
25
+ export const tailLogs = (dockerComposePath, serviceName, tailLines = 200) => spawn('docker', ['compose', '-f', dockerComposePath, 'logs', '-f', '--no-color', '--tail', String(tailLines), serviceName], { stdio: 'pipe' });