@bastani/atomic 0.5.0-1
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.
- package/LICENSE +24 -0
- package/README.md +956 -0
- package/assets/settings.schema.json +52 -0
- package/package.json +68 -0
- package/src/cli.ts +197 -0
- package/src/commands/cli/chat/client.ts +18 -0
- package/src/commands/cli/chat/index.ts +247 -0
- package/src/commands/cli/chat.ts +8 -0
- package/src/commands/cli/config.ts +55 -0
- package/src/commands/cli/init/index.ts +452 -0
- package/src/commands/cli/init/onboarding.ts +45 -0
- package/src/commands/cli/init/scm.ts +190 -0
- package/src/commands/cli/init.ts +8 -0
- package/src/commands/cli/update.ts +46 -0
- package/src/commands/cli/workflow.ts +164 -0
- package/src/lib/merge.ts +65 -0
- package/src/lib/path-root-guard.ts +38 -0
- package/src/lib/spawn.ts +467 -0
- package/src/scripts/bump-version.ts +94 -0
- package/src/scripts/constants-base.ts +14 -0
- package/src/scripts/constants.ts +34 -0
- package/src/sdk/components/color-utils.ts +20 -0
- package/src/sdk/components/connectors.test.ts +661 -0
- package/src/sdk/components/connectors.ts +156 -0
- package/src/sdk/components/edge.tsx +11 -0
- package/src/sdk/components/error-boundary.tsx +38 -0
- package/src/sdk/components/graph-theme.ts +36 -0
- package/src/sdk/components/header.tsx +60 -0
- package/src/sdk/components/layout.test.ts +924 -0
- package/src/sdk/components/layout.ts +186 -0
- package/src/sdk/components/node-card.tsx +68 -0
- package/src/sdk/components/orchestrator-panel-contexts.ts +26 -0
- package/src/sdk/components/orchestrator-panel-store.test.ts +561 -0
- package/src/sdk/components/orchestrator-panel-store.ts +118 -0
- package/src/sdk/components/orchestrator-panel-types.ts +21 -0
- package/src/sdk/components/orchestrator-panel.tsx +143 -0
- package/src/sdk/components/session-graph-panel.tsx +364 -0
- package/src/sdk/components/status-helpers.ts +32 -0
- package/src/sdk/components/statusline.tsx +63 -0
- package/src/sdk/define-workflow.ts +98 -0
- package/src/sdk/errors.ts +39 -0
- package/src/sdk/index.ts +38 -0
- package/src/sdk/providers/claude.ts +316 -0
- package/src/sdk/providers/copilot.ts +43 -0
- package/src/sdk/providers/opencode.ts +43 -0
- package/src/sdk/runtime/discovery.ts +172 -0
- package/src/sdk/runtime/executor.test.ts +415 -0
- package/src/sdk/runtime/executor.ts +695 -0
- package/src/sdk/runtime/loader.ts +372 -0
- package/src/sdk/runtime/panel.tsx +9 -0
- package/src/sdk/runtime/theme.ts +76 -0
- package/src/sdk/runtime/tmux.ts +542 -0
- package/src/sdk/types.ts +114 -0
- package/src/sdk/workflows.ts +85 -0
- package/src/services/config/atomic-config.ts +124 -0
- package/src/services/config/atomic-global-config.ts +361 -0
- package/src/services/config/config-path.ts +19 -0
- package/src/services/config/definitions.ts +176 -0
- package/src/services/config/index.ts +7 -0
- package/src/services/config/settings-schema.ts +2 -0
- package/src/services/config/settings.ts +149 -0
- package/src/services/system/copy.ts +381 -0
- package/src/services/system/detect.ts +161 -0
- package/src/services/system/download.ts +325 -0
- package/src/services/system/file-lock.ts +289 -0
- package/src/services/system/skills.ts +67 -0
- package/src/theme/colors.ts +25 -0
- package/src/version.ts +7 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/react */
|
|
2
|
+
/**
|
|
3
|
+
* OrchestratorPanel — public API class that bridges the imperative
|
|
4
|
+
* executor interface with the React-based session graph TUI.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createCliRenderer, type CliRenderer } from "@opentui/core";
|
|
8
|
+
import { createRoot } from "@opentui/react";
|
|
9
|
+
import { resolveTheme } from "../runtime/theme.ts";
|
|
10
|
+
import { deriveGraphTheme } from "./graph-theme.ts";
|
|
11
|
+
import type { GraphTheme } from "./graph-theme.ts";
|
|
12
|
+
import { PanelStore } from "./orchestrator-panel-store.ts";
|
|
13
|
+
import { StoreContext, ThemeContext, TmuxSessionContext } from "./orchestrator-panel-contexts.ts";
|
|
14
|
+
import type { PanelSession, PanelOptions } from "./orchestrator-panel-types.ts";
|
|
15
|
+
import { SessionGraphPanel } from "./session-graph-panel.tsx";
|
|
16
|
+
import { ErrorBoundary } from "./error-boundary.tsx";
|
|
17
|
+
|
|
18
|
+
export class OrchestratorPanel {
|
|
19
|
+
private store: PanelStore;
|
|
20
|
+
private renderer: CliRenderer;
|
|
21
|
+
private destroyed = false;
|
|
22
|
+
|
|
23
|
+
private constructor(
|
|
24
|
+
renderer: CliRenderer,
|
|
25
|
+
store: PanelStore,
|
|
26
|
+
graphTheme: GraphTheme,
|
|
27
|
+
tmuxSession: string,
|
|
28
|
+
) {
|
|
29
|
+
this.renderer = renderer;
|
|
30
|
+
this.store = store;
|
|
31
|
+
|
|
32
|
+
createRoot(renderer).render(
|
|
33
|
+
<StoreContext.Provider value={store}>
|
|
34
|
+
<ThemeContext.Provider value={graphTheme}>
|
|
35
|
+
<TmuxSessionContext.Provider value={tmuxSession}>
|
|
36
|
+
<ErrorBoundary
|
|
37
|
+
fallback={(err) => (
|
|
38
|
+
<box
|
|
39
|
+
width="100%"
|
|
40
|
+
height="100%"
|
|
41
|
+
justifyContent="center"
|
|
42
|
+
alignItems="center"
|
|
43
|
+
backgroundColor={graphTheme.background}
|
|
44
|
+
>
|
|
45
|
+
<text>
|
|
46
|
+
<span fg={graphTheme.error}>
|
|
47
|
+
{`Fatal render error: ${err.message}`}
|
|
48
|
+
</span>
|
|
49
|
+
</text>
|
|
50
|
+
</box>
|
|
51
|
+
)}
|
|
52
|
+
>
|
|
53
|
+
<SessionGraphPanel />
|
|
54
|
+
</ErrorBoundary>
|
|
55
|
+
</TmuxSessionContext.Provider>
|
|
56
|
+
</ThemeContext.Provider>
|
|
57
|
+
</StoreContext.Provider>,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Create a new OrchestratorPanel with the default CLI renderer.
|
|
63
|
+
*
|
|
64
|
+
* This is the primary entry point — it initialises the terminal renderer
|
|
65
|
+
* and mounts the React-based session graph TUI.
|
|
66
|
+
*/
|
|
67
|
+
static async create(options: PanelOptions): Promise<OrchestratorPanel> {
|
|
68
|
+
const renderer = await createCliRenderer({
|
|
69
|
+
exitOnCtrlC: false,
|
|
70
|
+
exitSignals: ["SIGTERM", "SIGQUIT", "SIGABRT", "SIGHUP", "SIGPIPE", "SIGBUS", "SIGFPE"],
|
|
71
|
+
});
|
|
72
|
+
return OrchestratorPanel.createWithRenderer(renderer, options);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Create with an externally-provided renderer (e.g. a test renderer). */
|
|
76
|
+
static createWithRenderer(
|
|
77
|
+
renderer: CliRenderer,
|
|
78
|
+
options: PanelOptions,
|
|
79
|
+
): OrchestratorPanel {
|
|
80
|
+
const termTheme = resolveTheme(renderer.themeMode);
|
|
81
|
+
const graphTheme = deriveGraphTheme(termTheme);
|
|
82
|
+
const store = new PanelStore();
|
|
83
|
+
return new OrchestratorPanel(renderer, store, graphTheme, options.tmuxSession);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Display the workflow overview in the TUI — name, agent, session graph,
|
|
88
|
+
* and the user prompt. Call once after construction before sessions start.
|
|
89
|
+
*/
|
|
90
|
+
showWorkflowInfo(
|
|
91
|
+
name: string,
|
|
92
|
+
agent: string,
|
|
93
|
+
sessions: PanelSession[],
|
|
94
|
+
prompt: string,
|
|
95
|
+
): void {
|
|
96
|
+
this.store.setWorkflowInfo(name, agent, sessions, prompt);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Mark a session as running in the graph UI. */
|
|
100
|
+
sessionStart(name: string): void {
|
|
101
|
+
this.store.startSession(name);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Mark a session as successfully completed in the graph UI. */
|
|
105
|
+
sessionSuccess(name: string): void {
|
|
106
|
+
this.store.completeSession(name);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Mark a session as failed in the graph UI and display the error message. */
|
|
110
|
+
sessionError(name: string, message: string): void {
|
|
111
|
+
this.store.failSession(name, message);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Show the workflow-complete banner with a link to saved transcripts. */
|
|
115
|
+
showCompletion(workflowName: string, transcriptsPath: string): void {
|
|
116
|
+
this.store.setCompletion(workflowName, transcriptsPath);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Display a fatal error banner in the TUI. */
|
|
120
|
+
showFatalError(message: string): void {
|
|
121
|
+
this.store.setFatalError(message);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Block until the user presses `q` or `Ctrl+C` in the TUI.
|
|
126
|
+
* Call after {@link showCompletion} or {@link showFatalError}.
|
|
127
|
+
*/
|
|
128
|
+
waitForExit(): Promise<void> {
|
|
129
|
+
this.store.markCompletionReached();
|
|
130
|
+
return new Promise<void>((resolve) => {
|
|
131
|
+
this.store.exitResolve = resolve;
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Tear down the terminal renderer and release resources. Idempotent. */
|
|
136
|
+
destroy(): void {
|
|
137
|
+
if (this.destroyed) return;
|
|
138
|
+
this.destroyed = true;
|
|
139
|
+
try {
|
|
140
|
+
this.renderer.destroy();
|
|
141
|
+
} catch {}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/react */
|
|
2
|
+
/**
|
|
3
|
+
* Main graph component — renders the navigable session tree with
|
|
4
|
+
* keyboard navigation, scroll management, and live animations.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ScrollBoxRenderable } from "@opentui/core";
|
|
8
|
+
import {
|
|
9
|
+
useKeyboard,
|
|
10
|
+
useTerminalDimensions,
|
|
11
|
+
useRenderer,
|
|
12
|
+
} from "@opentui/react";
|
|
13
|
+
import {
|
|
14
|
+
useState,
|
|
15
|
+
useEffect,
|
|
16
|
+
useMemo,
|
|
17
|
+
useCallback,
|
|
18
|
+
useRef,
|
|
19
|
+
useContext,
|
|
20
|
+
} from "react";
|
|
21
|
+
import { tmuxRun } from "../runtime/tmux.ts";
|
|
22
|
+
import {
|
|
23
|
+
useStore,
|
|
24
|
+
useGraphTheme,
|
|
25
|
+
useStoreSubscription,
|
|
26
|
+
TmuxSessionContext,
|
|
27
|
+
} from "./orchestrator-panel-contexts.ts";
|
|
28
|
+
import { computeLayout } from "./layout.ts";
|
|
29
|
+
import { NODE_W, NODE_H } from "./layout.ts";
|
|
30
|
+
import type { LayoutNode } from "./layout.ts";
|
|
31
|
+
import { buildConnector, buildMergeConnector } from "./connectors.ts";
|
|
32
|
+
import type { ConnectorResult } from "./connectors.ts";
|
|
33
|
+
import { NodeCard } from "./node-card.tsx";
|
|
34
|
+
import { Edge } from "./edge.tsx";
|
|
35
|
+
import { Header } from "./header.tsx";
|
|
36
|
+
import { Statusline } from "./statusline.tsx";
|
|
37
|
+
|
|
38
|
+
/** Interval (ms) between pulse animation frames — ~60fps feel. */
|
|
39
|
+
const PULSE_INTERVAL_MS = 60;
|
|
40
|
+
/** Total frames in one pulse cycle (~2s at 60ms/frame). */
|
|
41
|
+
const PULSE_FRAME_COUNT = 32;
|
|
42
|
+
/** Timeout (ms) for "gg" double-tap to jump to root node. */
|
|
43
|
+
const GG_DOUBLE_TAP_MS = 300;
|
|
44
|
+
/** Duration (ms) to display the attach flash message in the statusline. */
|
|
45
|
+
const ATTACH_MSG_DISPLAY_MS = 2400;
|
|
46
|
+
|
|
47
|
+
export function SessionGraphPanel() {
|
|
48
|
+
const store = useStore();
|
|
49
|
+
const theme = useGraphTheme();
|
|
50
|
+
const tmuxSession = useContext(TmuxSessionContext);
|
|
51
|
+
useRenderer();
|
|
52
|
+
const { width: termW, height: termH } = useTerminalDimensions();
|
|
53
|
+
|
|
54
|
+
useStoreSubscription(store);
|
|
55
|
+
|
|
56
|
+
// Compute layout from current session data
|
|
57
|
+
const layout = useMemo(() => computeLayout(store.sessions), [store.version]);
|
|
58
|
+
const nodeList = useMemo(() => Object.values(layout.map), [layout]);
|
|
59
|
+
|
|
60
|
+
const connectors = useMemo(() => {
|
|
61
|
+
const result: ConnectorResult[] = [];
|
|
62
|
+
for (const n of nodeList) {
|
|
63
|
+
// Fan-out: parent → children
|
|
64
|
+
const conn = buildConnector(n, layout.rowH, theme);
|
|
65
|
+
if (conn) result.push(conn);
|
|
66
|
+
// Fan-in: multiple parents → merge child
|
|
67
|
+
if (n.parents.length > 1) {
|
|
68
|
+
const mergeConn = buildMergeConnector(n, layout.rowH, layout.map, theme);
|
|
69
|
+
if (mergeConn) result.push(mergeConn);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return result;
|
|
73
|
+
}, [nodeList, layout.rowH, theme]);
|
|
74
|
+
|
|
75
|
+
// Focus tracking
|
|
76
|
+
const [focusedId, setFocusedId] = useState("");
|
|
77
|
+
const focusedIdRef = useRef(focusedId);
|
|
78
|
+
focusedIdRef.current = focusedId;
|
|
79
|
+
|
|
80
|
+
// Update focus when sessions first appear
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (store.sessions.length > 0 && !layout.map[focusedId]) {
|
|
83
|
+
setFocusedId(store.sessions[0]!.name);
|
|
84
|
+
}
|
|
85
|
+
}, [store.version]);
|
|
86
|
+
|
|
87
|
+
// Pulse animation for running nodes — paused when nothing is running
|
|
88
|
+
const hasRunning = store.sessions.some((s) => s.status === "running");
|
|
89
|
+
const [pulsePhase, setPulsePhase] = useState(0);
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (!hasRunning) return;
|
|
92
|
+
const id = setInterval(
|
|
93
|
+
() => setPulsePhase((p: number) => (p + 1) % PULSE_FRAME_COUNT),
|
|
94
|
+
PULSE_INTERVAL_MS,
|
|
95
|
+
);
|
|
96
|
+
return () => clearInterval(id);
|
|
97
|
+
}, [hasRunning]);
|
|
98
|
+
|
|
99
|
+
// Live timer refresh — re-render every second while any session is running
|
|
100
|
+
const [, setTick] = useState(0);
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
const hasRunning = store.sessions.some((s) => s.status === "running");
|
|
103
|
+
if (!hasRunning) return;
|
|
104
|
+
const id = setInterval(() => setTick((t) => t + 1), 1000);
|
|
105
|
+
return () => clearInterval(id);
|
|
106
|
+
}, [store.version]);
|
|
107
|
+
|
|
108
|
+
// Attach flash message
|
|
109
|
+
const [attachMsg, setAttachMsg] = useState("");
|
|
110
|
+
const attachTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
111
|
+
|
|
112
|
+
// Clear attach timer on unmount to prevent state updates after teardown
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
return () => {
|
|
115
|
+
if (attachTimerRef.current) clearTimeout(attachTimerRef.current);
|
|
116
|
+
};
|
|
117
|
+
}, []);
|
|
118
|
+
|
|
119
|
+
const doAttach = useCallback(
|
|
120
|
+
(id: string) => {
|
|
121
|
+
const n = layout.map[id];
|
|
122
|
+
if (!n) return;
|
|
123
|
+
// Only attach to started sessions (not pending)
|
|
124
|
+
const session = store.sessions.find((s) => s.name === id);
|
|
125
|
+
if (!session || session.status === "pending") return;
|
|
126
|
+
|
|
127
|
+
if (attachTimerRef.current) clearTimeout(attachTimerRef.current);
|
|
128
|
+
setAttachMsg(`\u2192 ${n.name}`);
|
|
129
|
+
attachTimerRef.current = setTimeout(() => setAttachMsg(""), ATTACH_MSG_DISPLAY_MS);
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
tmuxRun(["select-window", "-t", `${tmuxSession}:${n.name}`]);
|
|
133
|
+
} catch {}
|
|
134
|
+
},
|
|
135
|
+
[layout.map, tmuxSession, store.sessions],
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
// Spatial navigation
|
|
139
|
+
const navigate = useCallback(
|
|
140
|
+
(dir: "left" | "right" | "up" | "down") => {
|
|
141
|
+
const cur = layout.map[focusedId];
|
|
142
|
+
if (!cur) return;
|
|
143
|
+
const cx = cur.x + NODE_W / 2;
|
|
144
|
+
const cy = cur.y + NODE_H / 2;
|
|
145
|
+
let best: LayoutNode | null = null;
|
|
146
|
+
let bestDist = Infinity;
|
|
147
|
+
|
|
148
|
+
for (const n of nodeList) {
|
|
149
|
+
if (n.name === focusedId) continue;
|
|
150
|
+
const nx = n.x + NODE_W / 2;
|
|
151
|
+
const ny = n.y + NODE_H / 2;
|
|
152
|
+
const dx = nx - cx;
|
|
153
|
+
const dy = ny - cy;
|
|
154
|
+
|
|
155
|
+
let valid = false;
|
|
156
|
+
if (dir === "left" && dx < -1) valid = true;
|
|
157
|
+
if (dir === "right" && dx > 1) valid = true;
|
|
158
|
+
if (dir === "up" && dy < -1) valid = true;
|
|
159
|
+
if (dir === "down" && dy > 1) valid = true;
|
|
160
|
+
if (!valid) continue;
|
|
161
|
+
|
|
162
|
+
// Weight: prefer movement along the intended axis
|
|
163
|
+
const dist =
|
|
164
|
+
dir === "left" || dir === "right"
|
|
165
|
+
? Math.abs(dx) + Math.abs(dy) * 3
|
|
166
|
+
: Math.abs(dy) + Math.abs(dx) * 3;
|
|
167
|
+
if (dist < bestDist) {
|
|
168
|
+
bestDist = dist;
|
|
169
|
+
best = n;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (best) setFocusedId(best.name);
|
|
174
|
+
},
|
|
175
|
+
[focusedId, layout.map, nodeList],
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
// gg double-tap tracking
|
|
179
|
+
const lastKeyRef = useRef({ key: "", time: 0 });
|
|
180
|
+
|
|
181
|
+
// Keyboard handling
|
|
182
|
+
useKeyboard((key) => {
|
|
183
|
+
// Ctrl+C always exits
|
|
184
|
+
if (key.ctrl && key.name === "c") {
|
|
185
|
+
store.resolveExit();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// After completion: only q exits (Enter is for attach, Escape is too easy to hit)
|
|
190
|
+
if (store.completionReached && key.name === "q") {
|
|
191
|
+
store.resolveExit();
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Arrow keys + hjkl navigation
|
|
196
|
+
if (key.name === "left" || key.name === "h") {
|
|
197
|
+
navigate("left");
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (key.name === "right" || key.name === "l") {
|
|
201
|
+
navigate("right");
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (key.name === "up" || key.name === "k") {
|
|
205
|
+
navigate("up");
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (key.name === "down" || key.name === "j") {
|
|
209
|
+
navigate("down");
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (key.name === "tab") {
|
|
213
|
+
navigate(key.shift ? "left" : "right");
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Enter: attach to focused node's tmux window
|
|
218
|
+
if (key.name === "return") {
|
|
219
|
+
doAttach(focusedIdRef.current);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// G: focus deepest leaf (rightmost in DFS order)
|
|
224
|
+
if (key.name === "g" && key.shift) {
|
|
225
|
+
let deepest: LayoutNode | null = null;
|
|
226
|
+
for (const n of nodeList) {
|
|
227
|
+
if (
|
|
228
|
+
!deepest ||
|
|
229
|
+
n.depth > deepest.depth ||
|
|
230
|
+
(n.depth === deepest.depth && n.x > deepest.x)
|
|
231
|
+
) {
|
|
232
|
+
deepest = n;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (deepest) setFocusedId(deepest.name);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// gg: focus root (double-tap within 300ms)
|
|
240
|
+
if (key.name === "g" && !key.shift) {
|
|
241
|
+
const now = Date.now();
|
|
242
|
+
if (lastKeyRef.current.key === "g" && now - lastKeyRef.current.time < GG_DOUBLE_TAP_MS) {
|
|
243
|
+
setFocusedId(store.sessions[0]?.name ?? "");
|
|
244
|
+
lastKeyRef.current.key = "";
|
|
245
|
+
} else {
|
|
246
|
+
lastKeyRef.current.key = "g";
|
|
247
|
+
lastKeyRef.current.time = now;
|
|
248
|
+
}
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// Auto-scroll to keep focused node visible
|
|
254
|
+
const scrollboxRef = useRef<ScrollBoxRenderable | null>(null);
|
|
255
|
+
const focused = layout.map[focusedId];
|
|
256
|
+
|
|
257
|
+
// Center the graph when it's smaller than the viewport.
|
|
258
|
+
// viewportH = terminal height minus header (1) and statusline (1).
|
|
259
|
+
const viewportH = Math.max(0, termH - 2);
|
|
260
|
+
const padX = Math.max(0, Math.floor((termW - layout.width) / 2));
|
|
261
|
+
const padY = Math.max(0, Math.floor((viewportH - layout.height) / 2));
|
|
262
|
+
const canvasW = Math.max(layout.width, termW) + padX;
|
|
263
|
+
const canvasH = Math.max(layout.height, viewportH) + padY;
|
|
264
|
+
|
|
265
|
+
useEffect(() => {
|
|
266
|
+
const sb = scrollboxRef.current;
|
|
267
|
+
if (!sb || !focused) return;
|
|
268
|
+
|
|
269
|
+
// Node bounds in canvas coordinates (with centering offset)
|
|
270
|
+
const nodeLeft = focused.x + padX;
|
|
271
|
+
const nodeTop = focused.y + padY;
|
|
272
|
+
const nodeRight = nodeLeft + NODE_W;
|
|
273
|
+
const nodeBottom = nodeTop + (layout.rowH[focused.depth] ?? NODE_H);
|
|
274
|
+
|
|
275
|
+
// Current visible viewport bounds
|
|
276
|
+
const curX = sb.scrollLeft;
|
|
277
|
+
const curY = sb.scrollTop;
|
|
278
|
+
const margin = 2;
|
|
279
|
+
|
|
280
|
+
let targetX = curX;
|
|
281
|
+
let targetY = curY;
|
|
282
|
+
|
|
283
|
+
// Only scroll if the node extends outside the visible area
|
|
284
|
+
if (nodeLeft - margin < curX) {
|
|
285
|
+
targetX = Math.max(0, nodeLeft - margin);
|
|
286
|
+
} else if (nodeRight + margin > curX + termW) {
|
|
287
|
+
targetX = Math.max(0, nodeRight + margin - termW);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (nodeTop - margin < curY) {
|
|
291
|
+
targetY = Math.max(0, nodeTop - margin);
|
|
292
|
+
} else if (nodeBottom + margin > curY + viewportH) {
|
|
293
|
+
targetY = Math.max(0, nodeBottom + margin - viewportH);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (targetX !== curX || targetY !== curY) {
|
|
297
|
+
sb.scrollTo({ x: targetX, y: targetY });
|
|
298
|
+
}
|
|
299
|
+
}, [focusedId, focused, termW, termH, padX, padY, viewportH, layout.rowH]);
|
|
300
|
+
|
|
301
|
+
return (
|
|
302
|
+
<box width="100%" height="100%" flexDirection="column" backgroundColor={theme.background}>
|
|
303
|
+
<Header />
|
|
304
|
+
|
|
305
|
+
{/* Graph canvas — scrollable both axes, centered when smaller than viewport */}
|
|
306
|
+
<scrollbox
|
|
307
|
+
ref={scrollboxRef}
|
|
308
|
+
scrollX
|
|
309
|
+
scrollY
|
|
310
|
+
focused
|
|
311
|
+
style={{
|
|
312
|
+
flexGrow: 1,
|
|
313
|
+
rootOptions: {
|
|
314
|
+
backgroundColor: theme.background,
|
|
315
|
+
border: false,
|
|
316
|
+
},
|
|
317
|
+
contentOptions: {
|
|
318
|
+
minHeight: 0,
|
|
319
|
+
minWidth: 0,
|
|
320
|
+
},
|
|
321
|
+
scrollbarOptions: {
|
|
322
|
+
visible: false,
|
|
323
|
+
showArrows: false,
|
|
324
|
+
trackOptions: {
|
|
325
|
+
foregroundColor: theme.borderActive,
|
|
326
|
+
backgroundColor: theme.background,
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
horizontalScrollbarOptions: {
|
|
330
|
+
visible: false,
|
|
331
|
+
showArrows: false,
|
|
332
|
+
trackOptions: {
|
|
333
|
+
foregroundColor: theme.borderActive,
|
|
334
|
+
backgroundColor: theme.background,
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
}}
|
|
338
|
+
>
|
|
339
|
+
<box width={canvasW} height={canvasH} position="relative">
|
|
340
|
+
{/* Offset all content by padding to center the graph */}
|
|
341
|
+
<box position="absolute" left={padX} top={padY} width={layout.width} height={layout.height}>
|
|
342
|
+
{/* Connectors (rendered behind nodes) */}
|
|
343
|
+
{connectors.map((conn, i) => (
|
|
344
|
+
<Edge key={`e${i}`} {...conn} />
|
|
345
|
+
))}
|
|
346
|
+
|
|
347
|
+
{/* Node cards */}
|
|
348
|
+
{nodeList.map((n) => (
|
|
349
|
+
<NodeCard
|
|
350
|
+
key={n.name}
|
|
351
|
+
node={n}
|
|
352
|
+
focused={n.name === focusedId}
|
|
353
|
+
pulsePhase={pulsePhase}
|
|
354
|
+
displayH={layout.rowH[n.depth] ?? NODE_H}
|
|
355
|
+
/>
|
|
356
|
+
))}
|
|
357
|
+
</box>
|
|
358
|
+
</box>
|
|
359
|
+
</scrollbox>
|
|
360
|
+
|
|
361
|
+
<Statusline focusedNode={focused} attachMsg={attachMsg} />
|
|
362
|
+
</box>
|
|
363
|
+
);
|
|
364
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// ─── Status Helpers ───────────────────────────────
|
|
2
|
+
|
|
3
|
+
import type { GraphTheme } from "./graph-theme.ts";
|
|
4
|
+
|
|
5
|
+
export function statusColor(status: string, theme: GraphTheme): string {
|
|
6
|
+
return (
|
|
7
|
+
{
|
|
8
|
+
running: theme.warning,
|
|
9
|
+
complete: theme.success,
|
|
10
|
+
pending: theme.textDim,
|
|
11
|
+
error: theme.error,
|
|
12
|
+
}[status] ?? theme.textDim
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function statusLabel(status: string): string {
|
|
17
|
+
return (
|
|
18
|
+
{ running: "running", complete: "done", pending: "waiting", error: "failed" }[status] ??
|
|
19
|
+
status
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function statusIcon(status: string): string {
|
|
24
|
+
return { running: "●", complete: "✓", pending: "○", error: "✗" }[status] ?? "○";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ─── Duration ─────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
export function fmtDuration(ms: number): string {
|
|
30
|
+
const sec = Math.max(0, Math.floor(ms / 1000));
|
|
31
|
+
return `${Math.floor(sec / 60)}m ${String(sec % 60).padStart(2, "0")}s`;
|
|
32
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/react */
|
|
2
|
+
|
|
3
|
+
import { useStore, useGraphTheme } from "./orchestrator-panel-contexts.ts";
|
|
4
|
+
import { statusIcon, statusColor, statusLabel } from "./status-helpers.ts";
|
|
5
|
+
import type { LayoutNode } from "./layout.ts";
|
|
6
|
+
|
|
7
|
+
export function Statusline({
|
|
8
|
+
focusedNode,
|
|
9
|
+
attachMsg,
|
|
10
|
+
}: {
|
|
11
|
+
focusedNode: LayoutNode | undefined;
|
|
12
|
+
attachMsg: string;
|
|
13
|
+
}) {
|
|
14
|
+
const store = useStore();
|
|
15
|
+
const theme = useGraphTheme();
|
|
16
|
+
const ni = focusedNode ? statusIcon(focusedNode.status) : "";
|
|
17
|
+
const nc = focusedNode ? statusColor(focusedNode.status, theme) : theme.textDim;
|
|
18
|
+
const canExit = store.completionReached;
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<box height={1} flexDirection="row" backgroundColor={theme.backgroundElement}>
|
|
22
|
+
<box backgroundColor={theme.primary} paddingLeft={1} paddingRight={1} alignItems="center">
|
|
23
|
+
<text fg={theme.backgroundElement}>
|
|
24
|
+
<strong>GRAPH</strong>
|
|
25
|
+
</text>
|
|
26
|
+
</box>
|
|
27
|
+
|
|
28
|
+
{focusedNode ? (
|
|
29
|
+
<box backgroundColor="transparent" paddingLeft={1} paddingRight={1} alignItems="center">
|
|
30
|
+
<text>
|
|
31
|
+
<span fg={nc}>{ni} </span>
|
|
32
|
+
<span fg={theme.text}>{focusedNode.name}</span>
|
|
33
|
+
<span fg={theme.textMuted}> {"\u00B7"} {statusLabel(focusedNode.status)}</span>
|
|
34
|
+
{focusedNode.error ? (
|
|
35
|
+
<span fg={theme.error}> {"\u00B7"} {focusedNode.error}</span>
|
|
36
|
+
) : null}
|
|
37
|
+
</text>
|
|
38
|
+
</box>
|
|
39
|
+
) : null}
|
|
40
|
+
|
|
41
|
+
<box flexGrow={1} />
|
|
42
|
+
|
|
43
|
+
<box paddingRight={2} alignItems="center">
|
|
44
|
+
{attachMsg ? (
|
|
45
|
+
<text fg={theme.text}>
|
|
46
|
+
<strong>{attachMsg}</strong>
|
|
47
|
+
</text>
|
|
48
|
+
) : (
|
|
49
|
+
<text>
|
|
50
|
+
<span fg={theme.text}>{"\u2191"} {"\u2193"} {"\u2190"} {"\u2192"}</span>
|
|
51
|
+
<span fg={theme.textMuted}> navigate</span>
|
|
52
|
+
<span fg={theme.textDim}> {"\u00B7"} </span>
|
|
53
|
+
<span fg={theme.text}>{"\u21B5"}</span>
|
|
54
|
+
<span fg={theme.textMuted}> attach</span>
|
|
55
|
+
{canExit ? <span fg={theme.textDim}> {"\u00B7"} </span> : null}
|
|
56
|
+
{canExit ? <span fg={theme.text}>q</span> : null}
|
|
57
|
+
{canExit ? <span fg={theme.textMuted}> quit</span> : null}
|
|
58
|
+
</text>
|
|
59
|
+
)}
|
|
60
|
+
</box>
|
|
61
|
+
</box>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow Builder — chainable DSL for defining multi-session workflows.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* defineWorkflow({ name: "ralph", description: "..." })
|
|
6
|
+
* .session({ name: "research", run: async (ctx) => { ... } })
|
|
7
|
+
* .session({ name: "plan", run: async (ctx) => { ... } })
|
|
8
|
+
* .compile()
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { WorkflowOptions, SessionOptions, WorkflowDefinition } from "./types.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Chainable workflow builder. Records session definitions in order,
|
|
15
|
+
* then .compile() seals them into a WorkflowDefinition.
|
|
16
|
+
*/
|
|
17
|
+
export class WorkflowBuilder {
|
|
18
|
+
/** @internal Brand for detection across package boundaries */
|
|
19
|
+
readonly __brand = "WorkflowBuilder" as const;
|
|
20
|
+
private readonly options: WorkflowOptions;
|
|
21
|
+
private readonly stepDefs: SessionOptions[][] = [];
|
|
22
|
+
private readonly namesSeen = new Set<string>();
|
|
23
|
+
|
|
24
|
+
constructor(options: WorkflowOptions) {
|
|
25
|
+
this.options = options;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Add a session (or parallel group of sessions) to the workflow.
|
|
30
|
+
*
|
|
31
|
+
* Pass a single SessionOptions for sequential execution.
|
|
32
|
+
* Pass an array of SessionOptions for parallel execution —
|
|
33
|
+
* all sessions in the array run concurrently, and the next
|
|
34
|
+
* .session() call waits for the entire group to complete.
|
|
35
|
+
*/
|
|
36
|
+
session(opts: SessionOptions | SessionOptions[]): this {
|
|
37
|
+
const step = Array.isArray(opts) ? opts : [opts];
|
|
38
|
+
if (step.length === 0) {
|
|
39
|
+
throw new Error("session() requires at least one SessionOptions.");
|
|
40
|
+
}
|
|
41
|
+
for (const s of step) {
|
|
42
|
+
if (!s.name || s.name.trim() === "") {
|
|
43
|
+
throw new Error("Session name is required.");
|
|
44
|
+
}
|
|
45
|
+
if (typeof s.run !== "function") {
|
|
46
|
+
throw new Error(`Session "${s.name}": run must be a function, got ${typeof s.run}.`);
|
|
47
|
+
}
|
|
48
|
+
if (this.namesSeen.has(s.name)) {
|
|
49
|
+
throw new Error(`Duplicate session name: "${s.name}"`);
|
|
50
|
+
}
|
|
51
|
+
this.namesSeen.add(s.name);
|
|
52
|
+
}
|
|
53
|
+
this.stepDefs.push(step);
|
|
54
|
+
return this;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Compile the workflow into a sealed WorkflowDefinition.
|
|
59
|
+
*
|
|
60
|
+
* After calling compile(), no more sessions can be added.
|
|
61
|
+
* The returned object is consumed by the Atomic CLI runtime.
|
|
62
|
+
*/
|
|
63
|
+
compile(): WorkflowDefinition {
|
|
64
|
+
if (this.stepDefs.length === 0) {
|
|
65
|
+
throw new Error(`Workflow "${this.options.name}" has no sessions. Add at least one .session() call.`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
__brand: "WorkflowDefinition" as const,
|
|
70
|
+
name: this.options.name,
|
|
71
|
+
description: this.options.description ?? "",
|
|
72
|
+
steps: Object.freeze(this.stepDefs.map((step) => Object.freeze([...step]))),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Entry point for defining a workflow.
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* ```typescript
|
|
82
|
+
* import { defineWorkflow } from "@bastani/atomic/workflows";
|
|
83
|
+
*
|
|
84
|
+
* export default defineWorkflow({
|
|
85
|
+
* name: "ralph",
|
|
86
|
+
* description: "Research, plan, implement",
|
|
87
|
+
* })
|
|
88
|
+
* .session({ name: "research", run: async (ctx) => { ... } })
|
|
89
|
+
* .session({ name: "plan", run: async (ctx) => { ... } })
|
|
90
|
+
* .compile();
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
export function defineWorkflow(options: WorkflowOptions): WorkflowBuilder {
|
|
94
|
+
if (!options.name || options.name.trim() === "") {
|
|
95
|
+
throw new Error("Workflow name is required.");
|
|
96
|
+
}
|
|
97
|
+
return new WorkflowBuilder(options);
|
|
98
|
+
}
|