@bastani/atomic 0.5.5 → 0.5.6
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/README.md +60 -34
- package/dist/sdk/components/compact-switcher.d.ts +10 -0
- package/dist/sdk/components/compact-switcher.d.ts.map +1 -0
- package/dist/sdk/components/orchestrator-panel-store.d.ts +21 -1
- package/dist/sdk/components/orchestrator-panel-store.d.ts.map +1 -1
- package/dist/sdk/components/orchestrator-panel-types.d.ts +1 -0
- package/dist/sdk/components/orchestrator-panel-types.d.ts.map +1 -1
- package/dist/sdk/components/session-graph-panel.d.ts.map +1 -1
- package/dist/sdk/components/statusline.d.ts.map +1 -1
- package/dist/sdk/runtime/executor.d.ts +3 -2
- package/dist/sdk/runtime/executor.d.ts.map +1 -1
- package/dist/sdk/runtime/tmux.d.ts +82 -2
- package/dist/sdk/runtime/tmux.d.ts.map +1 -1
- package/dist/sdk/workflows/index.d.ts +2 -2
- package/dist/sdk/workflows/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli.ts +150 -27
- package/src/commands/cli/chat/index.ts +25 -14
- package/src/commands/cli/completions.ts +24 -0
- package/src/commands/cli/session.test.ts +491 -0
- package/src/commands/cli/session.ts +265 -0
- package/src/commands/cli/workflow.ts +1 -1
- package/src/completions/bash.ts +107 -0
- package/src/completions/fish.ts +126 -0
- package/src/completions/index.ts +7 -0
- package/src/completions/powershell.ts +184 -0
- package/src/completions/zsh.ts +144 -0
- package/src/sdk/components/compact-switcher.tsx +73 -0
- package/src/sdk/components/orchestrator-panel-store.test.ts +124 -0
- package/src/sdk/components/orchestrator-panel-store.ts +36 -1
- package/src/sdk/components/orchestrator-panel-types.ts +2 -0
- package/src/sdk/components/session-graph-panel.tsx +138 -10
- package/src/sdk/components/statusline.tsx +13 -8
- package/src/sdk/runtime/executor.ts +18 -27
- package/src/sdk/runtime/tmux.conf +18 -0
- package/src/sdk/runtime/tmux.ts +198 -24
- package/src/sdk/workflows/index.ts +7 -1
|
@@ -18,7 +18,14 @@ import {
|
|
|
18
18
|
useRef,
|
|
19
19
|
useContext,
|
|
20
20
|
} from "react";
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
tmuxRun,
|
|
23
|
+
escapeTmuxFormat,
|
|
24
|
+
TMUX_DEFAULT_STATUS_LEFT,
|
|
25
|
+
TMUX_DEFAULT_STATUS_LEFT_LENGTH,
|
|
26
|
+
TMUX_DEFAULT_STATUS_RIGHT,
|
|
27
|
+
TMUX_DEFAULT_STATUS_RIGHT_LENGTH,
|
|
28
|
+
} from "../runtime/tmux.ts";
|
|
22
29
|
import {
|
|
23
30
|
useStore,
|
|
24
31
|
useGraphTheme,
|
|
@@ -32,6 +39,7 @@ import { NodeCard } from "./node-card.tsx";
|
|
|
32
39
|
import { Edge } from "./edge.tsx";
|
|
33
40
|
import { Header } from "./header.tsx";
|
|
34
41
|
import { Statusline } from "./statusline.tsx";
|
|
42
|
+
import { CompactSwitcher } from "./compact-switcher.tsx";
|
|
35
43
|
|
|
36
44
|
/** Interval (ms) between pulse animation frames — ~60fps feel. */
|
|
37
45
|
const PULSE_INTERVAL_MS = 60;
|
|
@@ -75,6 +83,10 @@ export function SessionGraphPanel() {
|
|
|
75
83
|
const focusedIdRef = useRef(focusedId);
|
|
76
84
|
focusedIdRef.current = focusedId;
|
|
77
85
|
|
|
86
|
+
// Compact switcher state
|
|
87
|
+
const [switcherOpen, setSwitcherOpen] = useState(false);
|
|
88
|
+
const [switcherSel, setSwitcherSel] = useState(0);
|
|
89
|
+
|
|
78
90
|
// Update focus when sessions first appear
|
|
79
91
|
useEffect(() => {
|
|
80
92
|
if (store.sessions.length > 0 && !layout.map[focusedId]) {
|
|
@@ -119,15 +131,40 @@ export function SessionGraphPanel() {
|
|
|
119
131
|
const session = store.sessions.find((s) => s.name === id);
|
|
120
132
|
if (!session || session.status === "pending") return;
|
|
121
133
|
|
|
134
|
+
// Orchestrator = the graph view itself
|
|
135
|
+
if (id === "orchestrator") {
|
|
136
|
+
store.setViewMode("graph");
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
122
140
|
if (attachTimerRef.current) clearTimeout(attachTimerRef.current);
|
|
123
141
|
setAttachMsg(`\u2192 ${n.name}`);
|
|
124
142
|
attachTimerRef.current = setTimeout(() => setAttachMsg(""), ATTACH_MSG_DISPLAY_MS);
|
|
125
143
|
|
|
144
|
+
setFocusedId(id);
|
|
145
|
+
store.setViewMode("attached", id);
|
|
126
146
|
tmuxRun(["switch-client", "-t", `${tmuxSession}:${n.name}`]);
|
|
127
147
|
},
|
|
128
148
|
[layout.map, tmuxSession],
|
|
129
149
|
);
|
|
130
150
|
|
|
151
|
+
const returnToGraph = useCallback(() => {
|
|
152
|
+
store.setViewMode("graph");
|
|
153
|
+
}, []);
|
|
154
|
+
|
|
155
|
+
const openSwitcher = useCallback(() => {
|
|
156
|
+
// Pre-select the current agent or focused node
|
|
157
|
+
const currentId = store.viewMode === "attached" ? store.activeAgentId : focusedIdRef.current;
|
|
158
|
+
const idx = store.sessions.findIndex((s) => s.name === currentId);
|
|
159
|
+
setSwitcherSel(Math.max(0, idx));
|
|
160
|
+
setSwitcherOpen(true);
|
|
161
|
+
}, []);
|
|
162
|
+
|
|
163
|
+
const closeSwitcher = useCallback(() => {
|
|
164
|
+
setSwitcherOpen(false);
|
|
165
|
+
setSwitcherSel(0);
|
|
166
|
+
}, []);
|
|
167
|
+
|
|
131
168
|
// Spatial navigation
|
|
132
169
|
const navigate = useCallback(
|
|
133
170
|
(dir: "left" | "right" | "up" | "down") => {
|
|
@@ -168,18 +205,54 @@ export function SessionGraphPanel() {
|
|
|
168
205
|
[layout.map, nodeList],
|
|
169
206
|
);
|
|
170
207
|
|
|
171
|
-
// gg double-tap tracking
|
|
208
|
+
// gg double-tap tracking (graph mode only)
|
|
172
209
|
const lastKeyRef = useRef({ key: "", time: 0 });
|
|
173
210
|
|
|
174
|
-
// Keyboard handling
|
|
211
|
+
// Keyboard handling — with Ctrl+G return-to-graph and auto-reset
|
|
175
212
|
useKeyboard((key) => {
|
|
176
|
-
//
|
|
213
|
+
// ── Switcher open: intercept all keys ──
|
|
214
|
+
if (switcherOpen) {
|
|
215
|
+
if (key.name === "escape") {
|
|
216
|
+
closeSwitcher();
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (key.name === "up" || key.name === "k") {
|
|
220
|
+
setSwitcherSel((s) => Math.max(0, s - 1));
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (key.name === "down" || key.name === "j") {
|
|
224
|
+
setSwitcherSel((s) => Math.min(store.sessions.length - 1, s + 1));
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (key.name === "return") {
|
|
228
|
+
const agent = store.sessions[switcherSel];
|
|
229
|
+
closeSwitcher();
|
|
230
|
+
if (agent) doAttach(agent.name);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
return; // Swallow all other keys while switcher is open
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ── Global: Ctrl+C or q quits ──
|
|
177
237
|
if ((key.ctrl && key.name === "c") || key.name === "q") {
|
|
178
238
|
store.requestQuit();
|
|
179
239
|
return;
|
|
180
240
|
}
|
|
181
241
|
|
|
182
|
-
//
|
|
242
|
+
// ── Auto-reset: receiving keys while "attached" means user returned to the orchestrator window ──
|
|
243
|
+
if (store.viewMode === "attached") {
|
|
244
|
+
returnToGraph();
|
|
245
|
+
// Fall through to process the key in graph mode
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ── / opens agent switcher ──
|
|
249
|
+
if (key.sequence === "/") {
|
|
250
|
+
openSwitcher();
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── Graph view navigation ──
|
|
255
|
+
// Arrow keys + hjkl
|
|
183
256
|
if (key.name === "left" || key.name === "h") {
|
|
184
257
|
navigate("left");
|
|
185
258
|
return;
|
|
@@ -196,11 +269,6 @@ export function SessionGraphPanel() {
|
|
|
196
269
|
navigate("down");
|
|
197
270
|
return;
|
|
198
271
|
}
|
|
199
|
-
if (key.name === "tab") {
|
|
200
|
-
navigate(key.shift ? "left" : "right");
|
|
201
|
-
return;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
272
|
// Enter: attach to focused node's tmux window
|
|
205
273
|
if (key.name === "return") {
|
|
206
274
|
doAttach(focusedIdRef.current);
|
|
@@ -285,6 +353,63 @@ export function SessionGraphPanel() {
|
|
|
285
353
|
}
|
|
286
354
|
}, [focusedId, focused, termW, termH, padX, padY, viewportH, layout.rowH]);
|
|
287
355
|
|
|
356
|
+
// ── Detect return to graph via Ctrl+G ─────────────────
|
|
357
|
+
// Ctrl+G is bound at the tmux level (select-window -t :0), so tmux
|
|
358
|
+
// swallows the key and the React app never receives it. Poll the
|
|
359
|
+
// active window index while attached; when window 0 becomes active
|
|
360
|
+
// again we know the user returned and can reset viewMode immediately.
|
|
361
|
+
useEffect(() => {
|
|
362
|
+
if (store.viewMode !== "attached") return;
|
|
363
|
+
|
|
364
|
+
const check = () => {
|
|
365
|
+
const result = tmuxRun([
|
|
366
|
+
"display-message", "-t", tmuxSession, "-p", "#{window_index}",
|
|
367
|
+
]);
|
|
368
|
+
if (result.ok && result.stdout.trim() === "0") {
|
|
369
|
+
store.setViewMode("graph");
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
const id = setInterval(check, 300);
|
|
374
|
+
return () => clearInterval(id);
|
|
375
|
+
}, [store.viewMode, tmuxSession]);
|
|
376
|
+
|
|
377
|
+
// ── Tmux status bar sync ──────────────────────────────
|
|
378
|
+
// When attached, the orchestrator panel is hidden (user views the agent's
|
|
379
|
+
// tmux window). Mirror the status line hints into tmux's own status bar
|
|
380
|
+
// so navigation keys remain discoverable.
|
|
381
|
+
const subagentCount = store.getSubagents().length;
|
|
382
|
+
const activeAgentIdx = store.getActiveAgentIndex();
|
|
383
|
+
|
|
384
|
+
useEffect(() => {
|
|
385
|
+
if (store.viewMode === "attached" && store.activeAgentId) {
|
|
386
|
+
const safeName = escapeTmuxFormat(store.activeAgentId);
|
|
387
|
+
const left = `#[bg=#6c7086,fg=#1e1e2e,bold] ATTACHED #[default] #[fg=#7f849c]\u203a #[fg=#cdd6f4]${safeName} #[fg=#7f849c]${activeAgentIdx + 1}/${subagentCount}`;
|
|
388
|
+
const right = `#[fg=#7f849c]Graph: #[fg=#cdd6f4]ctrl+g #[fg=#7f849c]| Next: #[fg=#cdd6f4]ctrl+\\ `;
|
|
389
|
+
|
|
390
|
+
tmuxRun(["set", "-g", "status-left", left]);
|
|
391
|
+
tmuxRun(["set", "-g", "status-left-length", "50"]);
|
|
392
|
+
tmuxRun(["set", "-g", "status-right", right]);
|
|
393
|
+
tmuxRun(["set", "-g", "status-right-length", "40"]);
|
|
394
|
+
} else {
|
|
395
|
+
// Graph mode: restore defaults (constants from tmux.ts match tmux.conf)
|
|
396
|
+
tmuxRun(["set", "-g", "status-left", TMUX_DEFAULT_STATUS_LEFT]);
|
|
397
|
+
tmuxRun(["set", "-g", "status-left-length", TMUX_DEFAULT_STATUS_LEFT_LENGTH]);
|
|
398
|
+
tmuxRun(["set", "-g", "status-right", TMUX_DEFAULT_STATUS_RIGHT]);
|
|
399
|
+
tmuxRun(["set", "-g", "status-right-length", TMUX_DEFAULT_STATUS_RIGHT_LENGTH]);
|
|
400
|
+
}
|
|
401
|
+
}, [store.viewMode, store.activeAgentId, activeAgentIdx, subagentCount]);
|
|
402
|
+
|
|
403
|
+
// Restore default tmux status bar on unmount
|
|
404
|
+
useEffect(() => {
|
|
405
|
+
return () => {
|
|
406
|
+
tmuxRun(["set", "-g", "status-left", TMUX_DEFAULT_STATUS_LEFT]);
|
|
407
|
+
tmuxRun(["set", "-g", "status-left-length", TMUX_DEFAULT_STATUS_LEFT_LENGTH]);
|
|
408
|
+
tmuxRun(["set", "-g", "status-right", TMUX_DEFAULT_STATUS_RIGHT]);
|
|
409
|
+
tmuxRun(["set", "-g", "status-right-length", TMUX_DEFAULT_STATUS_RIGHT_LENGTH]);
|
|
410
|
+
};
|
|
411
|
+
}, []);
|
|
412
|
+
|
|
288
413
|
return (
|
|
289
414
|
<box width="100%" height="100%" flexDirection="column" backgroundColor={theme.background}>
|
|
290
415
|
<Header />
|
|
@@ -345,6 +470,9 @@ export function SessionGraphPanel() {
|
|
|
345
470
|
</box>
|
|
346
471
|
</scrollbox>
|
|
347
472
|
|
|
473
|
+
{/* Compact agent switcher overlay */}
|
|
474
|
+
{switcherOpen ? <CompactSwitcher selectedIndex={switcherSel} /> : null}
|
|
475
|
+
|
|
348
476
|
<Statusline focusedNode={focused} attachMsg={attachMsg} />
|
|
349
477
|
</box>
|
|
350
478
|
);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/react */
|
|
2
2
|
|
|
3
|
-
import { useGraphTheme } from "./orchestrator-panel-contexts.ts";
|
|
4
|
-
import { statusIcon, statusColor
|
|
3
|
+
import { useStore, useGraphTheme, useStoreVersion } from "./orchestrator-panel-contexts.ts";
|
|
4
|
+
import { statusIcon, statusColor } from "./status-helpers.ts";
|
|
5
5
|
import type { LayoutNode } from "./layout.ts";
|
|
6
6
|
|
|
7
7
|
export function Statusline({
|
|
@@ -11,24 +11,25 @@ export function Statusline({
|
|
|
11
11
|
focusedNode: LayoutNode | undefined;
|
|
12
12
|
attachMsg: string;
|
|
13
13
|
}) {
|
|
14
|
+
const store = useStore();
|
|
14
15
|
const theme = useGraphTheme();
|
|
15
|
-
|
|
16
|
-
const nc = focusedNode ? statusColor(focusedNode.status, theme) : theme.textDim;
|
|
16
|
+
useStoreVersion(store);
|
|
17
17
|
|
|
18
18
|
return (
|
|
19
19
|
<box height={1} flexDirection="row" backgroundColor={theme.backgroundElement}>
|
|
20
|
+
{/* Mode badge — always GRAPH since this bar is only visible in the orchestrator window */}
|
|
20
21
|
<box backgroundColor={theme.primary} paddingLeft={1} paddingRight={1} alignItems="center">
|
|
21
22
|
<text fg={theme.backgroundElement}>
|
|
22
23
|
<strong>GRAPH</strong>
|
|
23
24
|
</text>
|
|
24
25
|
</box>
|
|
25
26
|
|
|
27
|
+
{/* Focused node info */}
|
|
26
28
|
{focusedNode ? (
|
|
27
|
-
<box backgroundColor="transparent" paddingLeft={1}
|
|
29
|
+
<box backgroundColor="transparent" paddingLeft={1} alignItems="center">
|
|
28
30
|
<text>
|
|
29
|
-
<span fg={
|
|
31
|
+
<span fg={statusColor(focusedNode.status, theme)}>{statusIcon(focusedNode.status)} </span>
|
|
30
32
|
<span fg={theme.text}>{focusedNode.name}</span>
|
|
31
|
-
<span fg={theme.textMuted}> {"\u00B7"} {statusLabel(focusedNode.status)}</span>
|
|
32
33
|
{focusedNode.error ? (
|
|
33
34
|
<span fg={theme.error}> {"\u00B7"} {focusedNode.error}</span>
|
|
34
35
|
) : null}
|
|
@@ -38,6 +39,7 @@ export function Statusline({
|
|
|
38
39
|
|
|
39
40
|
<box flexGrow={1} />
|
|
40
41
|
|
|
42
|
+
{/* Navigation hints — always graph-mode (tmux status bar handles attached-mode hints) */}
|
|
41
43
|
<box paddingRight={2} alignItems="center">
|
|
42
44
|
{attachMsg ? (
|
|
43
45
|
<text fg={theme.text}>
|
|
@@ -45,12 +47,15 @@ export function Statusline({
|
|
|
45
47
|
</text>
|
|
46
48
|
) : (
|
|
47
49
|
<text>
|
|
48
|
-
<span fg={theme.text}>{"\u2191
|
|
50
|
+
<span fg={theme.text}>{"\u2191\u2193\u2190\u2192"}</span>
|
|
49
51
|
<span fg={theme.textMuted}> navigate</span>
|
|
50
52
|
<span fg={theme.textDim}> {"\u00B7"} </span>
|
|
51
53
|
<span fg={theme.text}>{"\u21B5"}</span>
|
|
52
54
|
<span fg={theme.textMuted}> attach</span>
|
|
53
55
|
<span fg={theme.textDim}> {"\u00B7"} </span>
|
|
56
|
+
<span fg={theme.text}>/</span>
|
|
57
|
+
<span fg={theme.textMuted}> agents</span>
|
|
58
|
+
<span fg={theme.textDim}> {"\u00B7"} </span>
|
|
54
59
|
<span fg={theme.text}>q</span>
|
|
55
60
|
<span fg={theme.textMuted}> quit</span>
|
|
56
61
|
</text>
|
|
@@ -39,7 +39,7 @@ import type { SessionEvent } from "@github/copilot-sdk";
|
|
|
39
39
|
import type { SessionPromptResponse } from "@opencode-ai/sdk/v2";
|
|
40
40
|
import type { SessionMessage } from "@anthropic-ai/claude-agent-sdk";
|
|
41
41
|
import * as tmux from "./tmux.ts";
|
|
42
|
-
import { spawnMuxAttach
|
|
42
|
+
import { spawnMuxAttach } from "./tmux.ts";
|
|
43
43
|
import { WorkflowLoader } from "./loader.ts";
|
|
44
44
|
import {
|
|
45
45
|
clearClaudeSession,
|
|
@@ -303,8 +303,9 @@ export function parseInputsEnv(raw: string | undefined): Record<string, string>
|
|
|
303
303
|
/**
|
|
304
304
|
* Called by `atomic workflow -n <name> -a <agent> <prompt>`.
|
|
305
305
|
*
|
|
306
|
-
*
|
|
307
|
-
* then attaches so the user sees
|
|
306
|
+
* Always creates a tmux session in the atomic socket with the
|
|
307
|
+
* orchestrator as the initial pane, then attaches so the user sees
|
|
308
|
+
* everything live — even when invoked from inside another tmux session.
|
|
308
309
|
*/
|
|
309
310
|
export async function executeWorkflow(
|
|
310
311
|
options: WorkflowRunOptions,
|
|
@@ -318,7 +319,7 @@ export async function executeWorkflow(
|
|
|
318
319
|
} = options;
|
|
319
320
|
|
|
320
321
|
const workflowRunId = generateId();
|
|
321
|
-
const tmuxSessionName = `atomic-wf-${definition.name}-${workflowRunId}`;
|
|
322
|
+
const tmuxSessionName = `atomic-wf-${agent}-${definition.name}-${workflowRunId}`;
|
|
322
323
|
const sessionsBaseDir = join(getSessionsBaseDir(), workflowRunId);
|
|
323
324
|
await ensureDir(sessionsBaseDir);
|
|
324
325
|
|
|
@@ -364,30 +365,20 @@ export async function executeWorkflow(
|
|
|
364
365
|
|
|
365
366
|
await writeFile(launcherPath, launcherScript, { mode: 0o755 });
|
|
366
367
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
stdio: ["inherit", "inherit", "inherit"],
|
|
381
|
-
cwd: projectRoot,
|
|
382
|
-
});
|
|
383
|
-
await proc.exited;
|
|
368
|
+
const shellCmd = isWin
|
|
369
|
+
? `pwsh -NoProfile -File "${escPwsh(launcherPath)}"`
|
|
370
|
+
: `bash "${escBash(launcherPath)}"`;
|
|
371
|
+
tmux.createSession(tmuxSessionName, shellCmd, "orchestrator");
|
|
372
|
+
tmux.setSessionEnv(tmuxSessionName, "ATOMIC_AGENT", agent);
|
|
373
|
+
|
|
374
|
+
if (tmux.isInsideAtomicSocket()) {
|
|
375
|
+
// Already on the atomic server — just switch to the new session.
|
|
376
|
+
tmux.switchClient(tmuxSessionName);
|
|
377
|
+
} else if (tmux.isInsideTmux()) {
|
|
378
|
+
// Inside a different tmux server — detach and replace the client
|
|
379
|
+
// with an attach to the atomic socket (no nesting).
|
|
380
|
+
tmux.detachAndAttachAtomic(tmuxSessionName);
|
|
384
381
|
} else {
|
|
385
|
-
// Outside tmux: create session with the orchestrator and attach to it
|
|
386
|
-
const shellCmd = isWin
|
|
387
|
-
? `pwsh -NoProfile -File "${escPwsh(launcherPath)}"`
|
|
388
|
-
: `bash "${escBash(launcherPath)}"`;
|
|
389
|
-
tmux.createSession(tmuxSessionName, shellCmd, "orchestrator");
|
|
390
|
-
|
|
391
382
|
const attachProc = spawnMuxAttach(tmuxSessionName);
|
|
392
383
|
await attachProc.exited;
|
|
393
384
|
}
|
|
@@ -21,6 +21,8 @@ set -g focus-events on
|
|
|
21
21
|
setw -g aggressive-resize on
|
|
22
22
|
|
|
23
23
|
# Status bar — minimal
|
|
24
|
+
# These defaults are mirrored by TMUX_DEFAULT_STATUS_* constants in tmux.ts.
|
|
25
|
+
# Keep both in sync when changing.
|
|
24
26
|
set -g status-left " "
|
|
25
27
|
set -g status-right " #{session_name} | %H:%M "
|
|
26
28
|
set -g status-right-length 60
|
|
@@ -43,6 +45,22 @@ bind-key -T copy-mode-vi v send-keys -X begin-selection
|
|
|
43
45
|
bind-key -T copy-mode-vi C-v send-keys -X rectangle-toggle
|
|
44
46
|
bind-key -T copy-mode-vi y send-keys -X copy-selection-and-cancel
|
|
45
47
|
|
|
48
|
+
# ── Atomic workflow navigation (prefix-free) ──────────────
|
|
49
|
+
# These bindings use `bind -n` (root table) so they work without a prefix key.
|
|
50
|
+
# They only apply inside Atomic's isolated tmux server (-L atomic) and do NOT
|
|
51
|
+
# affect the user's regular tmux or shell sessions.
|
|
52
|
+
#
|
|
53
|
+
# NOTE: Ctrl+\ overrides the default SIGQUIT signal. Agent CLIs running in
|
|
54
|
+
# these panes will not receive SIGQUIT via Ctrl+\. Use `kill -QUIT <pid>` if
|
|
55
|
+
# needed. The integrated agents (Claude Code, OpenCode, Copilot CLI) use
|
|
56
|
+
# Ctrl+C for interrupts and are not affected.
|
|
57
|
+
|
|
58
|
+
# Ctrl+G: jump straight back to the graph (window 0) from any agent window
|
|
59
|
+
bind -n C-g select-window -t :0
|
|
60
|
+
|
|
61
|
+
# Ctrl+\: cycle to next agent window from anywhere
|
|
62
|
+
bind -n C-\\ next-window
|
|
63
|
+
|
|
46
64
|
# Escape exits copy-mode (clear selection first if one exists, otherwise cancel)
|
|
47
65
|
bind-key -T copy-mode-vi Escape if-shell -F "#{selection_present}" "send-keys -X clear-selection" "send-keys -X cancel"
|
|
48
66
|
|
package/src/sdk/runtime/tmux.ts
CHANGED
|
@@ -30,6 +30,29 @@ export type TmuxResult =
|
|
|
30
30
|
// Core tmux primitives
|
|
31
31
|
// ---------------------------------------------------------------------------
|
|
32
32
|
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Default status-bar values — must match tmux.conf.
|
|
35
|
+
// Centralised here so restore logic in session-graph-panel stays in sync.
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
export const TMUX_DEFAULT_STATUS_LEFT = " ";
|
|
39
|
+
export const TMUX_DEFAULT_STATUS_LEFT_LENGTH = "10";
|
|
40
|
+
export const TMUX_DEFAULT_STATUS_RIGHT = " #{session_name} | %H:%M ";
|
|
41
|
+
export const TMUX_DEFAULT_STATUS_RIGHT_LENGTH = "60";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Escape a string for safe interpolation into tmux format strings.
|
|
45
|
+
* Replaces `#` with `##` to prevent tmux from interpreting `#[...]`
|
|
46
|
+
* as style directives or `#(...)` as shell command expansions.
|
|
47
|
+
*/
|
|
48
|
+
export function escapeTmuxFormat(value: string): string {
|
|
49
|
+
return value.replace(/#/g, "##");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Core tmux primitives
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
33
56
|
/** Cached resolved multiplexer binary path. Resolved once on first use. */
|
|
34
57
|
let resolvedMuxBinary: string | null | undefined; // undefined = not yet resolved
|
|
35
58
|
|
|
@@ -45,9 +68,14 @@ let resolvedMuxBinary: string | null | undefined; // undefined = not yet resolve
|
|
|
45
68
|
export function getMuxBinary(): string | null {
|
|
46
69
|
if (resolvedMuxBinary !== undefined) return resolvedMuxBinary;
|
|
47
70
|
|
|
71
|
+
// Bun.which() reads PATH from the original process environment at startup
|
|
72
|
+
// and ignores runtime mutations to process.env.PATH. Pass PATH explicitly
|
|
73
|
+
// so that callers who modify PATH (e.g. tests) get correct results.
|
|
74
|
+
const pathOpt = { PATH: process.env.PATH ?? "" };
|
|
75
|
+
|
|
48
76
|
if (process.platform === "win32") {
|
|
49
77
|
for (const candidate of ["psmux", "pmux", "tmux"]) {
|
|
50
|
-
if (Bun.which(candidate)) {
|
|
78
|
+
if (Bun.which(candidate, pathOpt)) {
|
|
51
79
|
resolvedMuxBinary = candidate;
|
|
52
80
|
return resolvedMuxBinary;
|
|
53
81
|
}
|
|
@@ -57,7 +85,7 @@ export function getMuxBinary(): string | null {
|
|
|
57
85
|
}
|
|
58
86
|
|
|
59
87
|
// Unix / macOS
|
|
60
|
-
resolvedMuxBinary = Bun.which("tmux") ? "tmux" : null;
|
|
88
|
+
resolvedMuxBinary = Bun.which("tmux", pathOpt) ? "tmux" : null;
|
|
61
89
|
return resolvedMuxBinary;
|
|
62
90
|
}
|
|
63
91
|
|
|
@@ -83,6 +111,22 @@ export function isInsideTmux(): boolean {
|
|
|
83
111
|
return process.env.TMUX !== undefined || process.env.PSMUX !== undefined;
|
|
84
112
|
}
|
|
85
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Check if we're inside the atomic tmux socket specifically.
|
|
116
|
+
*
|
|
117
|
+
* The `TMUX` env var has the format `<socket_path>,<pid>,<index>`.
|
|
118
|
+
* On Unix this looks like `/tmp/tmux-1000/atomic,12345,0` when the
|
|
119
|
+
* socket name is "atomic".
|
|
120
|
+
*/
|
|
121
|
+
export function isInsideAtomicSocket(): boolean {
|
|
122
|
+
const tmuxEnv = process.env.TMUX ?? process.env.PSMUX ?? "";
|
|
123
|
+
// Socket path is everything before the first comma.
|
|
124
|
+
const socketPath = tmuxEnv.split(",")[0] ?? "";
|
|
125
|
+
// The socket name is the last path segment.
|
|
126
|
+
const socketName = socketPath.split("/").pop() ?? "";
|
|
127
|
+
return socketName === SOCKET_NAME;
|
|
128
|
+
}
|
|
129
|
+
|
|
86
130
|
/**
|
|
87
131
|
* Run a tmux command and return a result object.
|
|
88
132
|
* Prefers this over the throwing `tmux()` for cases where callers
|
|
@@ -176,6 +220,9 @@ export function createSession(
|
|
|
176
220
|
}
|
|
177
221
|
args.push(initialCommand);
|
|
178
222
|
const paneId = tmux(args);
|
|
223
|
+
// Reload config into the running server so keybindings are always current
|
|
224
|
+
// (tmux only loads -f on first server start; source-file updates a running server).
|
|
225
|
+
tmuxRun(["source-file", CONFIG_PATH]);
|
|
179
226
|
return paneId || tmux(["list-panes", "-t", sessionName, "-F", "#{pane_id}"]).split("\n")[0]!;
|
|
180
227
|
}
|
|
181
228
|
|
|
@@ -229,36 +276,17 @@ export function createPane(sessionName: string, command: string): string {
|
|
|
229
276
|
// Keystroke sending
|
|
230
277
|
// ---------------------------------------------------------------------------
|
|
231
278
|
|
|
232
|
-
/**
|
|
233
|
-
* Maximum bytes per `send-keys -l` invocation.
|
|
234
|
-
*
|
|
235
|
-
* tmux passes the text as a single command-line argument to the child
|
|
236
|
-
* process. On Linux the per-argument limit (`MAX_ARG_STRLEN`) is 128 KB;
|
|
237
|
-
* on macOS the total `ARG_MAX` is ~1 MB but shared across all args.
|
|
238
|
-
* We stay well under both limits with 50 KB chunks.
|
|
239
|
-
*/
|
|
240
|
-
const SEND_KEYS_CHUNK_SIZE = 50_000;
|
|
241
|
-
|
|
242
279
|
/**
|
|
243
280
|
* Send literal text to a tmux pane using `-l` flag (no special key interpretation).
|
|
244
281
|
* Uses `--` to prevent text starting with `-` from being parsed as flags.
|
|
245
282
|
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
283
|
+
* For large text payloads, prefer {@link sendViaPasteBuffer} which bypasses
|
|
284
|
+
* tmux's ~16 KB internal message buffer limit.
|
|
248
285
|
*/
|
|
249
286
|
export function sendLiteralText(paneId: string, text: string): void {
|
|
250
287
|
// Replace newlines with spaces to avoid premature submission
|
|
251
288
|
const normalized = text.replace(/[\r\n]+/g, " ");
|
|
252
|
-
|
|
253
|
-
if (normalized.length <= SEND_KEYS_CHUNK_SIZE) {
|
|
254
|
-
tmuxExec(["send-keys", "-t", paneId, "-l", "--", normalized]);
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
for (let offset = 0; offset < normalized.length; offset += SEND_KEYS_CHUNK_SIZE) {
|
|
259
|
-
const chunk = normalized.slice(offset, offset + SEND_KEYS_CHUNK_SIZE);
|
|
260
|
-
tmuxExec(["send-keys", "-t", paneId, "-l", "--", chunk]);
|
|
261
|
-
}
|
|
289
|
+
tmuxExec(["send-keys", "-t", paneId, "-l", "--", normalized]);
|
|
262
290
|
}
|
|
263
291
|
|
|
264
292
|
/**
|
|
@@ -398,6 +426,118 @@ export function sessionExists(sessionName: string): boolean {
|
|
|
398
426
|
return result.ok;
|
|
399
427
|
}
|
|
400
428
|
|
|
429
|
+
/**
|
|
430
|
+
* Set a session-level environment variable.
|
|
431
|
+
* Uses `tmux set-environment -t <session>` so the value is scoped to
|
|
432
|
+
* the individual session, not the global server environment.
|
|
433
|
+
*/
|
|
434
|
+
export function setSessionEnv(sessionName: string, key: string, value: string): void {
|
|
435
|
+
tmuxRun(["set-environment", "-t", sessionName, key, value]);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Read a session-level environment variable.
|
|
440
|
+
* Returns `null` when the session doesn't exist or the variable isn't set.
|
|
441
|
+
*/
|
|
442
|
+
export function getSessionEnv(sessionName: string, key: string): string | null {
|
|
443
|
+
const result = tmuxRun(["show-environment", "-t", sessionName, key]);
|
|
444
|
+
if (!result.ok) return null;
|
|
445
|
+
// Output format: "KEY=VALUE"
|
|
446
|
+
const eq = result.stdout.indexOf("=");
|
|
447
|
+
return eq >= 0 ? result.stdout.slice(eq + 1) : null;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Session type derived from the session name prefix. */
|
|
451
|
+
export type SessionType = "chat" | "workflow";
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Parse a session name into its type and agent.
|
|
455
|
+
*
|
|
456
|
+
* Naming conventions:
|
|
457
|
+
* Chat: atomic-chat-<agent>-<id>
|
|
458
|
+
* Workflow: atomic-wf-<agent>-<name>-<id>
|
|
459
|
+
*
|
|
460
|
+
* Agent names are a known, hyphen-free set (claude, copilot, opencode)
|
|
461
|
+
* so parsing is unambiguous even when the workflow name contains hyphens.
|
|
462
|
+
*/
|
|
463
|
+
export function parseSessionName(name: string): { type?: SessionType; agent?: string } {
|
|
464
|
+
const KNOWN_AGENTS = new Set(["claude", "copilot", "opencode"]);
|
|
465
|
+
|
|
466
|
+
if (name.startsWith("atomic-chat-")) {
|
|
467
|
+
// atomic-chat-<agent>-<id>
|
|
468
|
+
const rest = name.slice("atomic-chat-".length);
|
|
469
|
+
const dash = rest.indexOf("-");
|
|
470
|
+
const candidate = dash >= 0 ? rest.slice(0, dash) : rest;
|
|
471
|
+
if (KNOWN_AGENTS.has(candidate)) {
|
|
472
|
+
return { type: "chat", agent: candidate };
|
|
473
|
+
}
|
|
474
|
+
return { type: "chat" };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (name.startsWith("atomic-wf-")) {
|
|
478
|
+
// atomic-wf-<agent>-<name>-<id>
|
|
479
|
+
const rest = name.slice("atomic-wf-".length);
|
|
480
|
+
const dash = rest.indexOf("-");
|
|
481
|
+
const candidate = dash >= 0 ? rest.slice(0, dash) : rest;
|
|
482
|
+
if (KNOWN_AGENTS.has(candidate)) {
|
|
483
|
+
return { type: "workflow", agent: candidate };
|
|
484
|
+
}
|
|
485
|
+
return { type: "workflow" };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return {};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** A single tmux session on the atomic socket. */
|
|
492
|
+
export interface TmuxSession {
|
|
493
|
+
/** Session name (e.g. "atomic-chat-claude-a1b2c3d4") */
|
|
494
|
+
name: string;
|
|
495
|
+
/** Number of windows in the session */
|
|
496
|
+
windows: number;
|
|
497
|
+
/** ISO 8601 creation timestamp */
|
|
498
|
+
created: string;
|
|
499
|
+
/** Whether a client is currently attached */
|
|
500
|
+
attached: boolean;
|
|
501
|
+
/** Session type derived from the name prefix */
|
|
502
|
+
type?: SessionType;
|
|
503
|
+
/** Agent backend that owns this session (e.g. "claude", "copilot", "opencode") */
|
|
504
|
+
agent?: string;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* List all sessions on the atomic tmux socket.
|
|
509
|
+
*
|
|
510
|
+
* Uses a custom format string so output is machine-parseable regardless of
|
|
511
|
+
* locale. Returns an empty array when the server isn't running or has no
|
|
512
|
+
* sessions (tmux exits non-zero in both cases).
|
|
513
|
+
*/
|
|
514
|
+
export function listSessions(): TmuxSession[] {
|
|
515
|
+
const fmt = "#{session_name}\t#{session_windows}\t#{session_created}\t#{session_attached}";
|
|
516
|
+
const result = tmuxRun(["list-sessions", "-F", fmt]);
|
|
517
|
+
if (!result.ok) return [];
|
|
518
|
+
|
|
519
|
+
const sessions = result.stdout
|
|
520
|
+
.split("\n")
|
|
521
|
+
.filter((line) => line.trim() !== "")
|
|
522
|
+
.map((line) => {
|
|
523
|
+
const [name, windowsStr, createdStr, attachedStr] = line.split("\t");
|
|
524
|
+
const epochSec = Number(createdStr);
|
|
525
|
+
const parsed = parseSessionName(name!);
|
|
526
|
+
return {
|
|
527
|
+
name: name!,
|
|
528
|
+
windows: Number(windowsStr) || 1,
|
|
529
|
+
created: Number.isFinite(epochSec) && epochSec > 0
|
|
530
|
+
? new Date(epochSec * 1000).toISOString()
|
|
531
|
+
: createdStr!,
|
|
532
|
+
attached: attachedStr === "1",
|
|
533
|
+
type: parsed.type,
|
|
534
|
+
agent: parsed.agent ?? getSessionEnv(name!, "ATOMIC_AGENT") ?? undefined,
|
|
535
|
+
};
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
return sessions;
|
|
539
|
+
}
|
|
540
|
+
|
|
401
541
|
/** Build the full argument list for an attach-session command. */
|
|
402
542
|
function buildAttachArgs(sessionName: string): string[] {
|
|
403
543
|
const binary = getMuxBinary();
|
|
@@ -450,6 +590,10 @@ export function switchClient(sessionName: string): void {
|
|
|
450
590
|
*/
|
|
451
591
|
export function getCurrentSession(): string | null {
|
|
452
592
|
if (!isInsideTmux()) return null;
|
|
593
|
+
// Only query the atomic server if we're actually inside the atomic socket.
|
|
594
|
+
// Otherwise, display-message picks an arbitrary session on the atomic
|
|
595
|
+
// server that has nothing to do with our terminal.
|
|
596
|
+
if (!isInsideAtomicSocket()) return null;
|
|
453
597
|
const result = tmuxRun(["display-message", "-p", "#{session_name}"]);
|
|
454
598
|
if (!result.ok) return null;
|
|
455
599
|
return result.stdout || null;
|
|
@@ -470,6 +614,36 @@ export function attachOrSwitch(sessionName: string): void {
|
|
|
470
614
|
}
|
|
471
615
|
}
|
|
472
616
|
|
|
617
|
+
/**
|
|
618
|
+
* Detach from the user's current tmux session and replace the client
|
|
619
|
+
* with an attach to a session on the atomic socket.
|
|
620
|
+
*
|
|
621
|
+
* Uses `detach-client -E` so the user's terminal seamlessly transitions
|
|
622
|
+
* from their tmux session to the atomic session — no nesting.
|
|
623
|
+
* Their original tmux session stays alive; they can re-attach with
|
|
624
|
+
* `tmux attach` after leaving the atomic session.
|
|
625
|
+
*
|
|
626
|
+
* Only call when {@link isInsideTmux} returns `true`.
|
|
627
|
+
*/
|
|
628
|
+
export function detachAndAttachAtomic(sessionName: string): void {
|
|
629
|
+
const binary = getMuxBinary();
|
|
630
|
+
if (!binary) {
|
|
631
|
+
throw new Error("No terminal multiplexer (tmux/psmux) found on PATH");
|
|
632
|
+
}
|
|
633
|
+
// Build the shell command that will run on the freed terminal.
|
|
634
|
+
const attachArgs = buildAttachArgs(sessionName);
|
|
635
|
+
const attachCmd = attachArgs
|
|
636
|
+
.map((a) => `"${a.replace(/[\\"$`!]/g, "\\$&")}"`)
|
|
637
|
+
.join(" ");
|
|
638
|
+
|
|
639
|
+
// Target the user's current tmux server (no -L flag) and replace
|
|
640
|
+
// the client process with an attach to the atomic socket.
|
|
641
|
+
Bun.spawnSync({
|
|
642
|
+
cmd: [binary, "detach-client", "-E", attachCmd],
|
|
643
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
|
|
473
647
|
/**
|
|
474
648
|
* Select (switch to) a window within the current tmux session.
|
|
475
649
|
*/
|