@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
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Single source of truth for the TUI's purple gradient. Tailwind violet
3
+ * stops, picked so the OUTPUT logo reads top-bright to bottom-dark and
4
+ * the chrome rules sit somewhere in the middle.
5
+ */
6
+ export declare const PURPLE_50 = "#c4b5fd";
7
+ export declare const PURPLE_100 = "#a78bfa";
8
+ export declare const PURPLE_200 = "#8b5cf6";
9
+ export declare const PURPLE_300 = "#7c3aed";
10
+ export declare const PURPLE_400 = "#6d28d9";
11
+ export declare const RULE_PURPLE = "#a78bfa";
12
+ /**
13
+ * 3-stop gradient applied across the OUTPUT logo's three rows after
14
+ * 2x2 quadrant compression.
15
+ */
16
+ export declare const LOGO_GRADIENT: readonly ["#c4b5fd", "#8b5cf6", "#6d28d9"];
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Single source of truth for the TUI's purple gradient. Tailwind violet
3
+ * stops, picked so the OUTPUT logo reads top-bright to bottom-dark and
4
+ * the chrome rules sit somewhere in the middle.
5
+ */
6
+ export const PURPLE_50 = '#c4b5fd';
7
+ export const PURPLE_100 = '#a78bfa';
8
+ export const PURPLE_200 = '#8b5cf6';
9
+ export const PURPLE_300 = '#7c3aed';
10
+ export const PURPLE_400 = '#6d28d9';
11
+ export const RULE_PURPLE = PURPLE_100;
12
+ /**
13
+ * 3-stop gradient applied across the OUTPUT logo's three rows after
14
+ * 2x2 quadrant compression.
15
+ */
16
+ export const LOGO_GRADIENT = [PURPLE_50, PURPLE_200, PURPLE_400];
@@ -0,0 +1,5 @@
1
+ import React from 'react';
2
+ export declare const SearchBar: React.FC<{
3
+ active: boolean;
4
+ onSubmit?: (query: string) => void;
5
+ }>;
@@ -0,0 +1,35 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect } from 'react';
3
+ import { Box, Text, useInput } from 'ink';
4
+ import { useUiState } from '#views/dev/state/ui_state.js';
5
+ export const SearchBar = ({ active, onSubmit }) => {
6
+ const ui = useUiState();
7
+ useInput((input, key) => {
8
+ if (key.escape) {
9
+ ui.clearSearch();
10
+ return;
11
+ }
12
+ if (key.return) {
13
+ onSubmit?.(ui.search.query);
14
+ ui.closeSearch();
15
+ return;
16
+ }
17
+ if (key.backspace || key.delete) {
18
+ ui.setSearchQuery(ui.search.query.slice(0, -1));
19
+ return;
20
+ }
21
+ if (input && !key.ctrl && !key.meta) {
22
+ ui.setSearchQuery(ui.search.query + input);
23
+ }
24
+ }, { isActive: active });
25
+ useEffect(() => {
26
+ if (!active) {
27
+ return;
28
+ }
29
+ onSubmit?.(ui.search.query);
30
+ }, [active, ui.search.query, onSubmit]);
31
+ if (!active) {
32
+ return null;
33
+ }
34
+ return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { bold: true, children: "/ " }), _jsx(Text, { children: ui.search.query }), _jsx(Text, { inverse: true, children: ' ' })] }));
35
+ };
@@ -0,0 +1,14 @@
1
+ import React from 'react';
2
+ /**
3
+ * Theme-agnostic 2-cell selection bar for list rows. When selected, renders
4
+ * inverse-video (SGR 7) — a solid block of the terminal's actual default
5
+ * foreground colour with the arrow visible inside it. When unselected,
6
+ * renders two plain spaces. Inverse is theme-agnostic by design: it never
7
+ * picks a palette slot, just swaps the user's configured fg/bg.
8
+ *
9
+ * Each row using this still owns its own 1-cell separator after the
10
+ * indicator, so the total indicator column is 3 cells wide.
11
+ */
12
+ export declare const SelectionIndicator: React.FC<{
13
+ selected: boolean;
14
+ }>;
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Text } from 'ink';
3
+ /**
4
+ * Theme-agnostic 2-cell selection bar for list rows. When selected, renders
5
+ * inverse-video (SGR 7) — a solid block of the terminal's actual default
6
+ * foreground colour with the arrow visible inside it. When unselected,
7
+ * renders two plain spaces. Inverse is theme-agnostic by design: it never
8
+ * picks a palette slot, just swaps the user's configured fg/bg.
9
+ *
10
+ * Each row using this still owns its own 1-cell separator after the
11
+ * indicator, so the total indicator column is 3 cells wide.
12
+ */
13
+ export const SelectionIndicator = ({ selected }) => (selected ? _jsx(Text, { inverse: true, children: ' ▸' }) : _jsx(Text, { children: ' ' }));
@@ -0,0 +1,5 @@
1
+ import React from 'react';
2
+ import { type Tab } from '#views/dev/state/ui_state.js';
3
+ export declare const TabBar: React.FC<{
4
+ active: Tab;
5
+ }>;
@@ -0,0 +1,4 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { TAB_LABELS, TAB_ORDER } from '#views/dev/state/ui_state.js';
4
+ export const TabBar = ({ active }) => (_jsx(Box, { flexDirection: "row", marginTop: 1, children: TAB_ORDER.map(tab => (_jsx(Box, { marginRight: 3, children: tab === active ? (_jsx(Text, { inverse: true, bold: true, children: ` ${TAB_LABELS[tab]} ` })) : (_jsx(Text, { dimColor: true, children: TAB_LABELS[tab] })) }, tab))) }));
@@ -0,0 +1,2 @@
1
+ import React from 'react';
2
+ export declare const Toasts: React.FC;
@@ -0,0 +1,40 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect } from 'react';
3
+ import { Box, Text } from 'ink';
4
+ import { useUiState } from '#views/dev/state/ui_state.js';
5
+ const TOAST_TTL_MS = 4000;
6
+ const toneColor = (tone) => {
7
+ if (tone === 'success') {
8
+ return 'green';
9
+ }
10
+ if (tone === 'error') {
11
+ return 'red';
12
+ }
13
+ return 'cyan';
14
+ };
15
+ const tonePrefix = (tone) => {
16
+ if (tone === 'success') {
17
+ return '✓';
18
+ }
19
+ if (tone === 'error') {
20
+ return '✗';
21
+ }
22
+ return 'ℹ';
23
+ };
24
+ export const Toasts = () => {
25
+ const ui = useUiState();
26
+ const dismissToast = ui.dismissToast;
27
+ const toasts = ui.toasts;
28
+ useEffect(() => {
29
+ const timers = toasts.map(toast => setTimeout(() => dismissToast(toast.id), TOAST_TTL_MS));
30
+ return () => {
31
+ for (const t of timers) {
32
+ clearTimeout(t);
33
+ }
34
+ };
35
+ }, [toasts, dismissToast]);
36
+ if (toasts.length === 0) {
37
+ return null;
38
+ }
39
+ return (_jsx(Box, { flexDirection: "column", marginTop: 1, children: toasts.map(toast => (_jsxs(Box, { children: [_jsxs(Text, { color: toneColor(toast.tone), bold: true, children: [tonePrefix(toast.tone), " "] }), _jsx(Text, { children: toast.message })] }, toast.id))) }));
40
+ };
@@ -0,0 +1,21 @@
1
+ import React from 'react';
2
+ import { type CommandHint } from '#views/dev/chrome/footer.js';
3
+ /**
4
+ * Generic two-pane shell shared by every panel that has a windowed list
5
+ * on top, a horizontal rule, a detail pane below, and a footer at the
6
+ * bottom. Panels keep their own selection state and detail rendering;
7
+ * the shell owns the layout invariant (windowing, overflow indicators,
8
+ * separator, footer) so it lives in one place.
9
+ */
10
+ export interface MasterDetailPanelProps<T> {
11
+ items: T[];
12
+ selectedIndex: number;
13
+ visibleRows: number;
14
+ renderHeader: () => React.ReactNode;
15
+ renderRow: (item: T, selected: boolean, absoluteIndex: number) => React.ReactNode;
16
+ rowKey: (item: T, absoluteIndex: number) => string;
17
+ detail: React.ReactNode;
18
+ hints: CommandHint[];
19
+ itemLabel: string;
20
+ }
21
+ export declare const MasterDetailPanel: <T extends object>(props: MasterDetailPanelProps<T>) => React.ReactElement;
@@ -0,0 +1,18 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import React from 'react';
3
+ import { Box, Text } from 'ink';
4
+ import { Footer } from '#views/dev/chrome/footer.js';
5
+ import { HorizontalRule } from '#views/dev/chrome/divider.js';
6
+ import { computeWindowStart } from '#views/dev/utils/panel_helpers.js';
7
+ const OverflowIndicator = ({ direction, count }) => (_jsxs(Text, { dimColor: true, children: [" ", direction === 'up' ? '↑' : '↓', " ", count, " more ", direction === 'up' ? 'above' : 'below'] }));
8
+ export const MasterDetailPanel = (props) => {
9
+ const { items, selectedIndex, visibleRows, renderHeader, renderRow, rowKey, detail, hints, itemLabel } = props;
10
+ const windowStart = computeWindowStart(selectedIndex, items.length, visibleRows);
11
+ const visible = items.slice(windowStart, windowStart + visibleRows);
12
+ const overflowAbove = windowStart;
13
+ const overflowBelow = items.length - (windowStart + visible.length);
14
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { flexDirection: "column", children: [renderHeader(), overflowAbove > 0 && _jsx(OverflowIndicator, { direction: "up", count: overflowAbove }), visible.map((item, i) => {
15
+ const absoluteIndex = windowStart + i;
16
+ return (_jsx(React.Fragment, { children: renderRow(item, absoluteIndex === selectedIndex, absoluteIndex) }, rowKey(item, absoluteIndex)));
17
+ }), overflowBelow > 0 && _jsx(OverflowIndicator, { direction: "down", count: overflowBelow })] }), _jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(HorizontalRule, {}) }), detail, _jsx(Footer, { hints: hints, itemCount: items.length, itemLabel: itemLabel })] }));
18
+ };
@@ -1,4 +1,5 @@
1
1
  import React from 'react';
2
+ export type Phase = 'waiting' | 'running' | 'failed';
2
3
  export declare const DevApp: React.FC<{
3
4
  dockerComposePath: string;
4
5
  onCleanup: () => Promise<void>;
@@ -0,0 +1,146 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useRef, useState } from 'react';
3
+ import { Box, useApp, useInput, useStdout } from 'ink';
4
+ import { isServiceFailed, isServiceHealthy } from '#services/docker.js';
5
+ import { openUrl } from '#utils/open_url.js';
6
+ import { useHealthPolling, useStatusRefresh, useWorkflowRunsPolling } from '#views/dev/hooks/use_poll.js';
7
+ 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';
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';
18
+ import { RunModal } from '#views/dev/modals/run_modal.js';
19
+ import { ExpandedJsonModal } from '#views/dev/modals/expanded_json_modal.js';
20
+ const TAB_NUMBER_KEYS = {
21
+ 1: 'workflows',
22
+ 2: 'runs',
23
+ 3: 'services',
24
+ 4: 'help'
25
+ };
26
+ const useGlobalInput = (opts) => {
27
+ const ui = useUiState();
28
+ const { exit } = useApp();
29
+ const isExitingRef = useRef(false);
30
+ useInput((input, key) => {
31
+ // Ctrl+C is the universal escape hatch — handle it regardless of which
32
+ // overlay is open so the user can always quit.
33
+ if (key.ctrl && input === 'c' && !isExitingRef.current) {
34
+ isExitingRef.current = true;
35
+ void opts.onCleanup()
36
+ .then(() => exit())
37
+ .catch(err => exit(err instanceof Error ? err : new Error(String(err))));
38
+ return;
39
+ }
40
+ if (ui.search.open || ui.runModal.open || ui.expandedJson.open) {
41
+ return;
42
+ }
43
+ // Esc on a list view drops an active filter. Skip when we're on
44
+ // the run detail sub-view — the panel's own esc handler pops back
45
+ // to the list and the filter should still apply when we land
46
+ // there. The search bar's esc (close + clear) returns above, so
47
+ // it never reaches this branch.
48
+ if (key.escape && ui.search.query && ui.runsView === 'list') {
49
+ ui.clearSearch();
50
+ return;
51
+ }
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
+ if (key.tab && key.shift) {
62
+ ui.prevTab();
63
+ return;
64
+ }
65
+ if (key.tab) {
66
+ ui.nextTab();
67
+ return;
68
+ }
69
+ if (input === '/') {
70
+ ui.openSearch();
71
+ return;
72
+ }
73
+ if (input === '?') {
74
+ ui.setTab('help');
75
+ return;
76
+ }
77
+ if (input && TAB_NUMBER_KEYS[input]) {
78
+ ui.setTab(TAB_NUMBER_KEYS[input]);
79
+ return;
80
+ }
81
+ if (input === 'o' && ui.tab === 'services') {
82
+ openUrl('http://localhost:8080');
83
+ }
84
+ });
85
+ };
86
+ const computeWorkflowSummary = (runs) => {
87
+ if (runs.length === 0) {
88
+ return null;
89
+ }
90
+ return {
91
+ running: runs.filter(r => r.status === 'running').length,
92
+ completed: runs.filter(r => r.status === 'completed').length,
93
+ failed: runs.filter(r => r.status === 'failed').length,
94
+ total: runs.length
95
+ };
96
+ };
97
+ const Shell = ({ dockerComposePath, onCleanup }) => {
98
+ const { exit } = useApp();
99
+ const ui = useUiState();
100
+ const [phase, setPhase] = useState('waiting');
101
+ const [services, setServices] = useState([]);
102
+ const [runs, setRuns] = useState([]);
103
+ useHealthPolling(dockerComposePath, phase === 'waiting', {
104
+ onServices: setServices,
105
+ onAllHealthy: () => setPhase('running'),
106
+ onFailure: () => setPhase('failed'),
107
+ onTimeout: () => exit(new Error('Timeout waiting for services to become healthy'))
108
+ });
109
+ useStatusRefresh(dockerComposePath, phase !== 'waiting', setServices);
110
+ useWorkflowRunsPolling(phase !== 'waiting', setRuns);
111
+ const workflows = useWorkflowCatalog(phase !== 'waiting');
112
+ const autoSwitchedRef = useRef(false);
113
+ const setTab = ui.setTab;
114
+ useEffect(() => {
115
+ if (phase === 'running' &&
116
+ workflows.length > 0 &&
117
+ !autoSwitchedRef.current &&
118
+ ui.tab === 'services') {
119
+ autoSwitchedRef.current = true;
120
+ setTab('workflows');
121
+ }
122
+ }, [phase, workflows.length, ui.tab, setTab]);
123
+ useGlobalInput({ onCleanup });
124
+ const summary = useMemo(() => computeWorkflowSummary(runs), [runs]);
125
+ const failingServices = useMemo(() => services.filter(isServiceFailed).length, [services]);
126
+ const serviceBadge = useMemo(() => {
127
+ if (failingServices > 0) {
128
+ return 'failed';
129
+ }
130
+ if (phase === 'waiting' || services.length === 0 || !services.every(isServiceHealthy)) {
131
+ return 'starting';
132
+ }
133
+ return 'healthy';
134
+ }, [phase, services, failingServices]);
135
+ const counters = buildSummaryCounters(summary, workflows.length, serviceBadge, failingServices);
136
+ // `stdout.rows` is undefined on a small set of TTYs (mostly piped envs).
137
+ // 60 is a generous default — chrome alone is ~10 rows, and run-detail
138
+ // 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, {}) }));
143
+ }
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 })] })] }));
145
+ };
146
+ export const DevApp = ({ dockerComposePath, onCleanup }) => (_jsx(UiStateProvider, { children: _jsx(Shell, { dockerComposePath: dockerComposePath, onCleanup: onCleanup }) }));
@@ -0,0 +1,7 @@
1
+ export interface DockerLogsState {
2
+ lines: string[];
3
+ paused: boolean;
4
+ setPaused: (paused: boolean) => void;
5
+ clear: () => void;
6
+ }
7
+ export declare const useDockerLogs: (dockerComposePath: string, serviceName: string | null, enabled: boolean) => DockerLogsState;
@@ -0,0 +1,69 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { tailLogs } from '#views/dev/services/docker_control.js';
3
+ const MAX_BUFFER = 2000;
4
+ export const useDockerLogs = (dockerComposePath, serviceName, enabled) => {
5
+ const [lines, setLines] = useState([]);
6
+ const [paused, setPaused] = useState(false);
7
+ const pausedRef = useRef(paused);
8
+ pausedRef.current = paused;
9
+ const bufferRef = useRef([]);
10
+ useEffect(() => {
11
+ bufferRef.current = [];
12
+ setLines([]);
13
+ if (!enabled || !serviceName) {
14
+ return () => { };
15
+ }
16
+ const child = tailLogs(dockerComposePath, serviceName);
17
+ const pending = [];
18
+ const timer = { id: null };
19
+ const flush = () => {
20
+ if (pending.length === 0) {
21
+ return;
22
+ }
23
+ bufferRef.current = [...bufferRef.current, ...pending].slice(-MAX_BUFFER);
24
+ pending.length = 0;
25
+ if (!pausedRef.current) {
26
+ setLines(bufferRef.current);
27
+ }
28
+ };
29
+ const onChunk = (chunk) => {
30
+ const text = chunk.toString();
31
+ const split = text.split('\n').filter((l, i, arr) => i < arr.length - 1 || l.length > 0);
32
+ pending.push(...split);
33
+ if (timer.id === null) {
34
+ timer.id = setTimeout(() => {
35
+ timer.id = null;
36
+ flush();
37
+ }, 100);
38
+ }
39
+ };
40
+ child.stdout.on('data', onChunk);
41
+ child.stderr.on('data', onChunk);
42
+ // Swallow spawn errors. Docker isn't reachable in only two cases the
43
+ // user can't act on inside this panel: the daemon stopped (the
44
+ // services list will already show this) and the binary is missing
45
+ // (ruled out by `validateDockerEnvironment` at startup). The user
46
+ // can always tail logs from a host shell as a fallback.
47
+ child.on('error', () => { });
48
+ return () => {
49
+ child.kill('SIGTERM');
50
+ if (timer.id !== null) {
51
+ clearTimeout(timer.id);
52
+ }
53
+ };
54
+ }, [dockerComposePath, serviceName, enabled]);
55
+ useEffect(() => {
56
+ if (!paused) {
57
+ setLines(bufferRef.current);
58
+ }
59
+ }, [paused]);
60
+ return {
61
+ lines,
62
+ paused,
63
+ setPaused,
64
+ clear: () => {
65
+ bufferRef.current = [];
66
+ setLines([]);
67
+ }
68
+ };
69
+ };
@@ -0,0 +1,16 @@
1
+ import { type ServiceStatus } from '#services/docker.js';
2
+ import { type WorkflowRun } from '#services/workflow_runs.js';
3
+ export declare const POLL_INTERVAL_MS = 2000;
4
+ export declare const HEALTH_TIMEOUT_MS = 120000;
5
+ type TickResult = 'done' | 'continue';
6
+ export declare const usePoll: (enabled: boolean, intervalMs: number, onTick: () => Promise<TickResult>) => void;
7
+ export interface HealthPollingCallbacks {
8
+ onServices: (svcs: ServiceStatus[]) => void;
9
+ onAllHealthy: (svcs: ServiceStatus[]) => void;
10
+ onFailure: (svcs: ServiceStatus[]) => void;
11
+ onTimeout: () => void;
12
+ }
13
+ export declare const useHealthPolling: (dockerComposePath: string, enabled: boolean, callbacks: HealthPollingCallbacks) => void;
14
+ export declare const useStatusRefresh: (dockerComposePath: string, enabled: boolean, onServices: (svcs: ServiceStatus[]) => void) => void;
15
+ export declare const useWorkflowRunsPolling: (enabled: boolean, onRuns: (runs: WorkflowRun[]) => void) => void;
16
+ export {};
@@ -0,0 +1,95 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import { getServiceStatus, isServiceHealthy, isServiceFailed } from '#services/docker.js';
3
+ import { fetchWorkflowRuns } from '#services/workflow_runs.js';
4
+ export const POLL_INTERVAL_MS = 2000;
5
+ export const HEALTH_TIMEOUT_MS = 120_000;
6
+ export const usePoll = (enabled, intervalMs, onTick) => {
7
+ // Ref-shadowed callback. The polling effect intentionally only depends
8
+ // on `enabled` and `intervalMs` so it doesn't tear down and re-subscribe
9
+ // on every render — but we still want each tick to invoke the latest
10
+ // callback closure. The ref reassignment on every render keeps that
11
+ // closure fresh without nudging the effect's dependency array.
12
+ const onTickRef = useRef(onTick);
13
+ onTickRef.current = onTick;
14
+ useEffect(() => {
15
+ const state = {
16
+ active: true,
17
+ timeout: undefined
18
+ };
19
+ const run = async () => {
20
+ if (!state.active) {
21
+ return;
22
+ }
23
+ const result = await onTickRef.current();
24
+ if (!state.active || result === 'done') {
25
+ return;
26
+ }
27
+ state.timeout = setTimeout(run, intervalMs);
28
+ };
29
+ if (enabled) {
30
+ void run();
31
+ }
32
+ return () => {
33
+ state.active = false;
34
+ clearTimeout(state.timeout);
35
+ };
36
+ }, [enabled, intervalMs]);
37
+ };
38
+ const fetchServices = async (dockerComposePath) => {
39
+ try {
40
+ return await getServiceStatus(dockerComposePath);
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ };
46
+ export const useHealthPolling = (dockerComposePath, enabled, callbacks) => {
47
+ const callbacksRef = useRef(callbacks);
48
+ callbacksRef.current = callbacks;
49
+ const startTimeRef = useRef(Date.now());
50
+ usePoll(enabled, POLL_INTERVAL_MS, async () => {
51
+ if (Date.now() - startTimeRef.current > HEALTH_TIMEOUT_MS) {
52
+ callbacksRef.current.onTimeout();
53
+ return 'done';
54
+ }
55
+ const svcs = await fetchServices(dockerComposePath);
56
+ if (svcs === null) {
57
+ return 'continue';
58
+ }
59
+ callbacksRef.current.onServices(svcs);
60
+ if (svcs.length > 0 && svcs.every(isServiceHealthy)) {
61
+ callbacksRef.current.onAllHealthy(svcs);
62
+ return 'done';
63
+ }
64
+ if (svcs.length > 0 && svcs.find(isServiceFailed)) {
65
+ callbacksRef.current.onFailure(svcs);
66
+ return 'done';
67
+ }
68
+ return 'continue';
69
+ });
70
+ };
71
+ export const useStatusRefresh = (dockerComposePath, enabled, onServices) => {
72
+ const onServicesRef = useRef(onServices);
73
+ onServicesRef.current = onServices;
74
+ usePoll(enabled, POLL_INTERVAL_MS, async () => {
75
+ const svcs = await fetchServices(dockerComposePath);
76
+ if (svcs !== null) {
77
+ onServicesRef.current(svcs);
78
+ }
79
+ return 'continue';
80
+ });
81
+ };
82
+ export const useWorkflowRunsPolling = (enabled, onRuns) => {
83
+ const onRunsRef = useRef(onRuns);
84
+ onRunsRef.current = onRuns;
85
+ usePoll(enabled, POLL_INTERVAL_MS, async () => {
86
+ try {
87
+ const { runs } = await fetchWorkflowRuns({ limit: 100 });
88
+ onRunsRef.current(runs);
89
+ }
90
+ catch {
91
+ // API may not be ready yet
92
+ }
93
+ return 'continue';
94
+ });
95
+ };
@@ -0,0 +1,21 @@
1
+ import { type WorkflowResultResponse } from '#api/generated/api.js';
2
+ import type { TraceData } from '#types/trace.js';
3
+ export interface RunStep {
4
+ index: number;
5
+ name: string;
6
+ kind: string;
7
+ status: string;
8
+ durationMs: number;
9
+ input: unknown;
10
+ output: unknown;
11
+ error: unknown;
12
+ }
13
+ export interface RunDetail {
14
+ result: WorkflowResultResponse | null;
15
+ trace: TraceData | null;
16
+ steps: RunStep[];
17
+ loading: boolean;
18
+ }
19
+ export declare const extractSteps: (trace: TraceData | null) => RunStep[];
20
+ export declare const isTerminalRunStatus: (status: string | null | undefined) => boolean;
21
+ export declare const useRunDetail: (workflowId: string | undefined, runId: string | undefined, status?: string) => RunDetail;