recomposable 1.1.6 → 1.2.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 (49) hide show
  1. package/README.md +23 -8
  2. package/dist/components/App.d.ts +10 -0
  3. package/dist/components/App.js +149 -0
  4. package/dist/components/App.js.map +1 -0
  5. package/dist/components/BottomPanel.d.ts +9 -0
  6. package/dist/components/BottomPanel.js +205 -0
  7. package/dist/components/BottomPanel.js.map +1 -0
  8. package/dist/components/ExecScreen.d.ts +9 -0
  9. package/dist/components/ExecScreen.js +21 -0
  10. package/dist/components/ExecScreen.js.map +1 -0
  11. package/dist/components/Legend.d.ts +3 -0
  12. package/dist/components/Legend.js +86 -0
  13. package/dist/components/Legend.js.map +1 -0
  14. package/dist/components/ListScreen.d.ts +9 -0
  15. package/dist/components/ListScreen.js +85 -0
  16. package/dist/components/ListScreen.js.map +1 -0
  17. package/dist/components/LogScreen.d.ts +9 -0
  18. package/dist/components/LogScreen.js +130 -0
  19. package/dist/components/LogScreen.js.map +1 -0
  20. package/dist/components/Logo.d.ts +2 -0
  21. package/dist/components/Logo.js +8 -0
  22. package/dist/components/Logo.js.map +1 -0
  23. package/dist/components/Separator.d.ts +6 -0
  24. package/dist/components/Separator.js +8 -0
  25. package/dist/components/Separator.js.map +1 -0
  26. package/dist/components/ServiceRow.d.ts +11 -0
  27. package/dist/components/ServiceRow.js +159 -0
  28. package/dist/components/ServiceRow.js.map +1 -0
  29. package/dist/index.d.ts +26 -1
  30. package/dist/index.js +715 -293
  31. package/dist/index.js.map +1 -1
  32. package/dist/lib/docker.d.ts +12 -10
  33. package/dist/lib/docker.js +190 -103
  34. package/dist/lib/docker.js.map +1 -1
  35. package/dist/lib/inkTheme.d.ts +18 -0
  36. package/dist/lib/inkTheme.js +33 -0
  37. package/dist/lib/inkTheme.js.map +1 -0
  38. package/dist/lib/renderer.d.ts +1 -1
  39. package/dist/lib/renderer.js +120 -76
  40. package/dist/lib/renderer.js.map +1 -1
  41. package/dist/lib/state.d.ts +12 -1
  42. package/dist/lib/state.js +30 -21
  43. package/dist/lib/state.js.map +1 -1
  44. package/dist/lib/theme.js +15 -25
  45. package/dist/lib/theme.js.map +1 -1
  46. package/dist/lib/types.d.ts +14 -0
  47. package/dist/lib/types.js +1 -4
  48. package/dist/lib/types.js.map +1 -1
  49. package/package.json +8 -1
package/dist/index.js CHANGED
@@ -1,55 +1,17 @@
1
1
  #!/usr/bin/env node
2
- 'use strict';
3
- var __importDefault = (this && this.__importDefault) || function (mod) {
4
- return (mod && mod.__esModule) ? mod : { "default": mod };
5
- };
6
- Object.defineProperty(exports, "__esModule", { value: true });
7
- exports.createModuleState = createModuleState;
8
- exports.loadConfig = loadConfig;
9
- exports.discoverServices = discoverServices;
10
- exports.pollStatuses = pollStatuses;
11
- exports.detectMultipleWorktrees = detectMultipleWorktrees;
12
- exports.pollLogCounts = pollLogCounts;
13
- exports.pollContainerStats = pollContainerStats;
14
- exports.render = render;
15
- exports.stripAnsi = stripAnsi;
16
- exports.throttledRender = throttledRender;
17
- exports.updateSelectedLogs = updateSelectedLogs;
18
- exports.doRebuild = doRebuild;
19
- exports.doRestart = doRestart;
20
- exports.doStop = doStop;
21
- exports.doStart = doStart;
22
- exports.mapComposeFileToWorktree = mapComposeFileToWorktree;
23
- exports.openWorktreePicker = openWorktreePicker;
24
- exports.doWorktreeSwitch = doWorktreeSwitch;
25
- exports.doWatch = doWatch;
26
- exports.initDepGraphs = initDepGraphs;
27
- exports.doCascadeRebuild = doCascadeRebuild;
28
- exports.enterExecInline = enterExecInline;
29
- exports.enterExec = enterExec;
30
- exports.exitExec = exitExec;
31
- exports.shellEscape = shellEscape;
32
- exports.runExecCommand = runExecCommand;
33
- exports.enterLogs = enterLogs;
34
- exports.exitLogs = exitLogs;
35
- exports.loadMoreLogHistory = loadMoreLogHistory;
36
- exports.executeLogSearch = executeLogSearch;
37
- exports.jumpToNextMatch = jumpToNextMatch;
38
- exports.jumpToPrevMatch = jumpToPrevMatch;
39
- exports.executeBottomSearch = executeBottomSearch;
40
- exports.clearBottomSearch = clearBottomSearch;
41
- exports.handleKeypress = handleKeypress;
42
- exports.createInputHandler = createInputHandler;
43
- exports.cleanup = cleanup;
44
- exports._getModuleState = _getModuleState;
45
- exports._setModuleState = _setModuleState;
46
- const fs_1 = __importDefault(require("fs"));
47
- const path_1 = __importDefault(require("path"));
48
- const docker_1 = require("./lib/docker");
49
- const state_1 = require("./lib/state");
50
- const renderer_1 = require("./lib/renderer");
51
- const theme_1 = require("./lib/theme");
52
- function createModuleState() {
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ import { exec } from 'child_process';
6
+ import { listServices, getStatusesAsync, rebuildService, restartService, stopService, startService, tailLogs, fetchServiceLogs, getContainerIdAsync, tailContainerLogs, fetchContainerLogs, fetchContainerStats, parseStatsLine, isWatchAvailable, watchService, parseDependencyGraph, execInContainer, getGitRoot, listGitWorktrees, validateServiceInComposeFile } from './lib/docker.js';
7
+ import { MODE, createState, statusKey, buildFlatList, moveCursor, selectedEntry, getEffectiveFile, getComposeTarget, composeProjectName } from './lib/state.js';
8
+ import { clearScreen, showCursor, renderListView, renderLogView, renderExecView, CLEAR_EOL, CLEAR_EOS } from './lib/renderer.js';
9
+ import { detectTheme, getPalette, setActivePalette } from './lib/theme.js';
10
+ // Ink imports (lazy-loaded in main to avoid startup cost in tests)
11
+ let inkRenderFn = null;
12
+ let React = null;
13
+ let AppComponent = null;
14
+ export function createModuleState() {
53
15
  return {
54
16
  logScanActive: false,
55
17
  statsPollActive: false,
@@ -59,8 +21,55 @@ function createModuleState() {
59
21
  };
60
22
  }
61
23
  let moduleState = createModuleState();
24
+ // --- Reserved keys (built-in LIST mode bindings) ---
25
+ export const RESERVED_KEYS = new Set([
26
+ 'j', 'k', 'b', 'd', 'w', 'e', 'x', 's', 'p', 'n', 'o', 'v',
27
+ 'f', 'l', 't', 'q', 'G', 'g', '/',
28
+ '\r', '\x1b', '\x1b[A', '\x1b[B',
29
+ ]);
30
+ // --- Variable substitution for custom actions ---
31
+ export function substituteVariables(template, vars) {
32
+ return template.replace(/\{(\w+)\}/g, (match, name) => {
33
+ return name in vars ? vars[name] : match;
34
+ });
35
+ }
36
+ // --- Execute custom action ---
37
+ export function executeCustomAction(state, key) {
38
+ const action = state.config.customActions.find(a => a.key === key);
39
+ if (!action)
40
+ return false;
41
+ const entry = selectedEntry(state);
42
+ if (!entry)
43
+ return false;
44
+ const sk = statusKey(entry.file, entry.service);
45
+ const st = state.statuses.get(sk);
46
+ // Resolve worktree filesystem path from branch name
47
+ let worktreePath = '';
48
+ if (st?.worktree) {
49
+ const gitRoot = getGitRoot(path.dirname(entry.file));
50
+ if (gitRoot) {
51
+ const wts = listGitWorktrees(gitRoot);
52
+ const match = wts.find(wt => wt.branch === st.worktree);
53
+ if (match)
54
+ worktreePath = match.path;
55
+ }
56
+ }
57
+ const vars = {
58
+ service: entry.service,
59
+ file: entry.file,
60
+ workingDir: st?.workingDir || '',
61
+ worktree: st?.worktree || '',
62
+ worktreePath,
63
+ containerId: st?.id || '',
64
+ cwd: process.cwd(),
65
+ };
66
+ const cmd = substituteVariables(action.command, vars);
67
+ const child = exec(cmd, { stdio: 'ignore' });
68
+ child.unref();
69
+ return true;
70
+ }
62
71
  // --- Config ---
63
- function loadConfig() {
72
+ export function loadConfig() {
64
73
  const defaults = {
65
74
  composeFiles: [],
66
75
  pollInterval: 3000,
@@ -76,10 +85,11 @@ function loadConfig() {
76
85
  memWarnThreshold: 512,
77
86
  memDangerThreshold: 1024,
78
87
  theme: 'auto',
88
+ customActions: [],
79
89
  };
80
- const configPath = path_1.default.join(process.cwd(), 'recomposable.json');
81
- if (fs_1.default.existsSync(configPath)) {
82
- const raw = JSON.parse(fs_1.default.readFileSync(configPath, 'utf8'));
90
+ const configPath = path.join(process.cwd(), 'recomposable.json');
91
+ if (fs.existsSync(configPath)) {
92
+ const raw = JSON.parse(fs.readFileSync(configPath, 'utf8'));
83
93
  if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
84
94
  if (Array.isArray(raw.composeFiles) && raw.composeFiles.every((f) => typeof f === 'string')) {
85
95
  defaults.composeFiles = raw.composeFiles;
@@ -108,6 +118,39 @@ function loadConfig() {
108
118
  if (raw.theme === 'light' || raw.theme === 'dark' || raw.theme === 'auto') {
109
119
  defaults.theme = raw.theme;
110
120
  }
121
+ // Parse customActions
122
+ if (Array.isArray(raw.customActions)) {
123
+ const seen = new Set();
124
+ for (const entry of raw.customActions) {
125
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
126
+ process.stderr.write(`customActions: skipping invalid entry (not an object)\n`);
127
+ continue;
128
+ }
129
+ const { key, label, command } = entry;
130
+ if (typeof key !== 'string' || key.length !== 1) {
131
+ process.stderr.write(`customActions: skipping entry with invalid key "${key}" (must be a single character)\n`);
132
+ continue;
133
+ }
134
+ if (typeof label !== 'string' || !label) {
135
+ process.stderr.write(`customActions: skipping entry "${key}" with empty label\n`);
136
+ continue;
137
+ }
138
+ if (typeof command !== 'string' || !command) {
139
+ process.stderr.write(`customActions: skipping entry "${key}" with empty command\n`);
140
+ continue;
141
+ }
142
+ if (RESERVED_KEYS.has(key)) {
143
+ process.stderr.write(`customActions: skipping entry "${key}" — conflicts with built-in key\n`);
144
+ continue;
145
+ }
146
+ if (seen.has(key)) {
147
+ process.stderr.write(`customActions: skipping duplicate key "${key}"\n`);
148
+ continue;
149
+ }
150
+ seen.add(key);
151
+ defaults.customActions.push({ key, label, command });
152
+ }
153
+ }
111
154
  }
112
155
  }
113
156
  const args = process.argv.slice(2);
@@ -127,15 +170,15 @@ function loadConfig() {
127
170
  return defaults;
128
171
  }
129
172
  // --- Service Discovery ---
130
- function discoverServices(config) {
173
+ export function discoverServices(config) {
131
174
  const groups = [];
132
175
  for (const file of config.composeFiles) {
133
- const resolved = path_1.default.resolve(file);
134
- const label = path_1.default.basename(file, path_1.default.extname(file)).replace(/^docker-compose\.?/, '') || path_1.default.basename(file);
176
+ const resolved = path.resolve(file);
177
+ const label = path.basename(file, path.extname(file)).replace(/^docker-compose\.?/, '') || path.basename(file);
135
178
  let services = [];
136
179
  let error = null;
137
180
  try {
138
- services = (0, docker_1.listServices)(resolved);
181
+ services = listServices(resolved);
139
182
  }
140
183
  catch (e) {
141
184
  const msg = e instanceof Error ? e.message : String(e);
@@ -146,26 +189,31 @@ function discoverServices(config) {
146
189
  return groups;
147
190
  }
148
191
  // --- Status Polling ---
149
- function pollStatuses(state) {
192
+ export function pollStatuses(state) {
193
+ pollStatusesAsync(state).catch(() => { });
194
+ }
195
+ async function pollStatusesAsync(state) {
150
196
  // Collect services by their effective file (may differ from group file due to worktree overrides)
151
197
  const fileToServices = new Map();
152
198
  for (const group of state.groups) {
153
199
  if (group.error)
154
200
  continue;
155
201
  for (const service of group.services) {
156
- const sk = (0, state_1.statusKey)(group.file, service);
157
- const file = (0, state_1.getEffectiveFile)(state, group.file, service);
202
+ const sk = statusKey(group.file, service);
203
+ const { file, projectName } = getComposeTarget(state, group.file, service);
158
204
  if (!fileToServices.has(file))
159
205
  fileToServices.set(file, []);
160
- fileToServices.get(file).push({ sk, service });
206
+ fileToServices.get(file).push({ sk, service, projectName });
161
207
  }
162
208
  }
163
- for (const [file, services] of fileToServices) {
164
- const statuses = (0, docker_1.getStatuses)(file);
209
+ const results = await Promise.all([...fileToServices.entries()].map(async ([file, services]) => {
210
+ const statuses = await getStatusesAsync(file, services[0].projectName);
211
+ return { services, statuses };
212
+ }));
213
+ for (const { services, statuses } of results) {
165
214
  const serviceSet = new Set(services.map(s => s.service));
166
215
  for (const [svc, st] of statuses) {
167
216
  if (serviceSet.has(svc)) {
168
- // Store under the original statusKey (group.file based)
169
217
  const match = services.find(s => s.service === svc);
170
218
  if (match)
171
219
  state.statuses.set(match.sk, st);
@@ -173,8 +221,10 @@ function pollStatuses(state) {
173
221
  }
174
222
  }
175
223
  detectMultipleWorktrees(state);
224
+ if (state.mode === MODE.LIST)
225
+ render(state);
176
226
  }
177
- function detectMultipleWorktrees(state) {
227
+ export function detectMultipleWorktrees(state) {
178
228
  const worktrees = new Set();
179
229
  for (const st of state.statuses.values()) {
180
230
  if (st.state === 'running' && st.worktree) {
@@ -184,7 +234,7 @@ function detectMultipleWorktrees(state) {
184
234
  state.showWorktreeColumn = worktrees.size > 1;
185
235
  }
186
236
  // --- Log Pattern Scanning ---
187
- function pollLogCounts(state) {
237
+ export function pollLogCounts(state) {
188
238
  if (moduleState.logScanActive)
189
239
  return;
190
240
  const scanPatterns = state.config.logScanPatterns || [];
@@ -196,7 +246,7 @@ function pollLogCounts(state) {
196
246
  if (group.error)
197
247
  continue;
198
248
  for (const service of group.services) {
199
- const sk = (0, state_1.statusKey)(group.file, service);
249
+ const sk = statusKey(group.file, service);
200
250
  const st = state.statuses.get(sk);
201
251
  if (!st || st.state !== 'running' || !st.id)
202
252
  continue;
@@ -208,7 +258,7 @@ function pollLogCounts(state) {
208
258
  moduleState.logScanActive = true;
209
259
  let remaining = toScan.length;
210
260
  for (const { sk, containerId } of toScan) {
211
- const child = (0, docker_1.fetchContainerLogs)(containerId, tailLines);
261
+ const child = fetchContainerLogs(containerId, tailLines);
212
262
  let output = '';
213
263
  child.stdout.on('data', (d) => { output += d.toString(); });
214
264
  child.stderr.on('data', (d) => { output += d.toString(); });
@@ -231,7 +281,7 @@ function pollLogCounts(state) {
231
281
  remaining--;
232
282
  if (remaining === 0) {
233
283
  moduleState.logScanActive = false;
234
- if (state.mode === state_1.MODE.LIST)
284
+ if (state.mode === MODE.LIST)
235
285
  throttledRender(state);
236
286
  }
237
287
  });
@@ -239,14 +289,14 @@ function pollLogCounts(state) {
239
289
  remaining--;
240
290
  if (remaining === 0) {
241
291
  moduleState.logScanActive = false;
242
- if (state.mode === state_1.MODE.LIST)
292
+ if (state.mode === MODE.LIST)
243
293
  throttledRender(state);
244
294
  }
245
295
  });
246
296
  }
247
297
  }
248
298
  // --- Stats Polling ---
249
- function pollContainerStats(state) {
299
+ export function pollContainerStats(state) {
250
300
  if (moduleState.statsPollActive)
251
301
  return;
252
302
  const idToKey = new Map();
@@ -254,7 +304,7 @@ function pollContainerStats(state) {
254
304
  if (group.error)
255
305
  continue;
256
306
  for (const service of group.services) {
257
- const sk = (0, state_1.statusKey)(group.file, service);
307
+ const sk = statusKey(group.file, service);
258
308
  const st = state.statuses.get(sk);
259
309
  if (!st || st.state !== 'running' || !st.id)
260
310
  continue;
@@ -265,7 +315,7 @@ function pollContainerStats(state) {
265
315
  if (ids.length === 0)
266
316
  return;
267
317
  moduleState.statsPollActive = true;
268
- const child = (0, docker_1.fetchContainerStats)(ids);
318
+ const child = fetchContainerStats(ids);
269
319
  let output = '';
270
320
  child.stdout.on('data', (d) => { output += d.toString(); });
271
321
  child.stderr.on('data', () => { });
@@ -275,7 +325,7 @@ function pollContainerStats(state) {
275
325
  for (const line of output.trim().split('\n')) {
276
326
  if (!line.trim())
277
327
  continue;
278
- const parsed = (0, docker_1.parseStatsLine)(line);
328
+ const parsed = parseStatsLine(line);
279
329
  if (!parsed)
280
330
  continue;
281
331
  let sk = null;
@@ -305,7 +355,7 @@ function pollContainerStats(state) {
305
355
  memUsageBytes: memSum / hist.count,
306
356
  });
307
357
  }
308
- if (state.mode === state_1.MODE.LIST)
358
+ if (state.mode === MODE.LIST)
309
359
  throttledRender(state);
310
360
  });
311
361
  child.on('error', () => {
@@ -313,21 +363,27 @@ function pollContainerStats(state) {
313
363
  });
314
364
  }
315
365
  // --- Rendering ---
316
- function render(state) {
366
+ export function render(state) {
367
+ // If Ink is active, trigger a React re-render
368
+ if (state._inkRender) {
369
+ state._inkRender();
370
+ return;
371
+ }
372
+ // Fallback: ANSI string rendering (used in tests and pre-Ink mode)
317
373
  let view = '';
318
- if (state.mode === state_1.MODE.LIST) {
319
- view = (0, renderer_1.renderListView)(state);
374
+ if (state.mode === MODE.LIST) {
375
+ view = renderListView(state);
320
376
  }
321
- else if (state.mode === state_1.MODE.LOGS) {
322
- view = (0, renderer_1.renderLogView)(state);
377
+ else if (state.mode === MODE.LOGS) {
378
+ view = renderLogView(state);
323
379
  }
324
- else if (state.mode === state_1.MODE.EXEC) {
325
- view = (0, renderer_1.renderExecView)(state);
380
+ else if (state.mode === MODE.EXEC) {
381
+ view = renderExecView(state);
326
382
  }
327
383
  // View functions already embed CLEAR_EOL per line; just clear below last line
328
- process.stdout.write((0, renderer_1.clearScreen)() + view + renderer_1.CLEAR_EOL + renderer_1.CLEAR_EOS);
384
+ process.stdout.write(clearScreen() + view + CLEAR_EOL + CLEAR_EOS);
329
385
  }
330
- function stripAnsi(str) {
386
+ export function stripAnsi(str) {
331
387
  return str.replace(
332
388
  // CSI sequences: \x1b[ ... letter
333
389
  // OSC sequences: \x1b] ... BEL or \x1b] ... ST
@@ -335,7 +391,7 @@ function stripAnsi(str) {
335
391
  // Two-byte escape sequences: \x1b + any char
336
392
  /\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[P_^X][^\x1b]*(?:\x1b\\|\x07)|\x1b[^[\]P_^X]/g, '');
337
393
  }
338
- function throttledRender(state) {
394
+ export function throttledRender(state) {
339
395
  const now = Date.now();
340
396
  const elapsed = now - moduleState.lastRenderTime;
341
397
  if (elapsed >= 150) {
@@ -351,52 +407,91 @@ function throttledRender(state) {
351
407
  }
352
408
  }
353
409
  // --- Actions ---
354
- function updateSelectedLogs(state) {
355
- const entry = (0, state_1.selectedEntry)(state);
410
+ function ensureAnimTimer(state) {
411
+ if (state.animTimer)
412
+ return;
413
+ state.animDots = 0;
414
+ state.animTimer = setInterval(() => {
415
+ state.animDots = (state.animDots + 1) % 3;
416
+ // Only re-render if there's something animating
417
+ const hasActivity = state.bottomLogLoading
418
+ || state.rebuilding.size > 0
419
+ || state.restarting.size > 0
420
+ || state.stopping.size > 0
421
+ || state.starting.size > 0
422
+ || state.cascading.size > 0;
423
+ if (hasActivity && state.mode === MODE.LIST)
424
+ render(state);
425
+ }, 500);
426
+ }
427
+ function startBottomLogLoadingAnim(state) {
428
+ state.bottomLogLoading = true;
429
+ ensureAnimTimer(state);
430
+ }
431
+ function stopBottomLogLoadingAnim(state) {
432
+ state.bottomLogLoading = false;
433
+ }
434
+ export function updateSelectedLogs(state) {
435
+ const entry = selectedEntry(state);
356
436
  if (!entry)
357
437
  return;
358
- const sk = (0, state_1.statusKey)(entry.file, entry.service);
438
+ const sk = statusKey(entry.file, entry.service);
359
439
  if (state.selectedLogKey === sk)
360
440
  return;
361
- state.bottomSearchQuery = '';
362
- state.bottomSearchActive = false;
363
- clearBottomSearch(state);
441
+ // Cancel any pending log fetch
364
442
  if (moduleState.logFetchTimer) {
365
443
  clearTimeout(moduleState.logFetchTimer);
366
444
  moduleState.logFetchTimer = null;
367
445
  }
446
+ // Immediately kill old tail so it stops pumping data during scrolling
368
447
  if (state.selectedLogKey) {
369
- const oldInfo = state.bottomLogLines.get(state.selectedLogKey);
448
+ const oldKey = state.selectedLogKey;
449
+ const oldInfo = state.bottomLogLines.get(oldKey);
370
450
  if (oldInfo && (oldInfo.action === 'logs' || oldInfo.action === 'started')) {
371
- if (!state.rebuilding.has(state.selectedLogKey) && !state.restarting.has(state.selectedLogKey)) {
372
- state.bottomLogLines.delete(state.selectedLogKey);
373
- if (state.bottomLogTails.has(state.selectedLogKey)) {
374
- state.bottomLogTails.get(state.selectedLogKey).kill('SIGTERM');
375
- state.bottomLogTails.delete(state.selectedLogKey);
451
+ if (!state.rebuilding.has(oldKey) && !state.restarting.has(oldKey)) {
452
+ if (state.bottomLogTails.has(oldKey)) {
453
+ state.bottomLogTails.get(oldKey).kill('SIGTERM');
454
+ state.bottomLogTails.delete(oldKey);
376
455
  }
456
+ state.bottomLogLines.delete(oldKey);
377
457
  }
378
458
  }
379
459
  }
380
460
  state.selectedLogKey = sk;
381
- if (state.bottomLogLines.has(sk))
461
+ state.bottomSearchQuery = '';
462
+ state.bottomSearchActive = false;
463
+ clearBottomSearch(state);
464
+ // If we already have cached log data for this service, show it immediately
465
+ if (state.bottomLogLines.has(sk)) {
466
+ stopBottomLogLoadingAnim(state);
382
467
  return;
383
- state.bottomLogLines.set(sk, { action: 'logs', service: entry.service, lines: [] });
384
- const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
468
+ }
469
+ // Start animated "loading logs." indicator immediately
470
+ startBottomLogLoadingAnim(state);
471
+ // Debounce: only start loading logs after the user stops scrolling
472
+ const { file: effectiveFile, projectName } = getComposeTarget(state, entry.file, entry.service);
385
473
  moduleState.logFetchTimer = setTimeout(() => {
386
474
  moduleState.logFetchTimer = null;
387
- startBottomLogTail(state, sk, effectiveFile, entry.service);
388
- }, 500);
475
+ // Bail if user scrolled away during the debounce
476
+ if (state.selectedLogKey !== sk)
477
+ return;
478
+ state.bottomLogLines.set(sk, { action: 'logs', service: entry.service, lines: [] });
479
+ startBottomLogTail(state, sk, effectiveFile, entry.service, projectName);
480
+ if (state.mode === MODE.LIST)
481
+ render(state);
482
+ }, 1200);
389
483
  }
390
- function startBottomLogTail(state, sk, file, service) {
484
+ async function startBottomLogTail(state, sk, file, service, projectName) {
391
485
  if (state.bottomLogTails.has(sk)) {
392
486
  state.bottomLogTails.get(sk).kill('SIGTERM');
393
487
  state.bottomLogTails.delete(sk);
394
488
  }
395
- const containerId = (0, docker_1.getContainerId)(file, service);
396
- if (!containerId)
489
+ const containerId = await getContainerIdAsync(file, service, projectName);
490
+ // If the user scrolled away while we were waiting, bail out
491
+ if (!containerId || state.selectedLogKey !== sk)
397
492
  return;
398
493
  const maxLines = state.config.bottomLogCount || 10;
399
- const logChild = (0, docker_1.tailContainerLogs)(containerId, maxLines);
494
+ const logChild = tailContainerLogs(containerId, maxLines);
400
495
  state.bottomLogTails.set(sk, logChild);
401
496
  let buf = '';
402
497
  const onData = (data) => {
@@ -412,25 +507,30 @@ function startBottomLogTail(state, sk, file, service) {
412
507
  info.lines.push(...newLines);
413
508
  if (info.lines.length > maxLines)
414
509
  info.lines = info.lines.slice(-maxLines);
415
- if (state.mode === state_1.MODE.LIST)
510
+ if (state.bottomLogLoading)
511
+ stopBottomLogLoadingAnim(state);
512
+ if (state.mode === MODE.LIST)
416
513
  throttledRender(state);
417
514
  };
418
515
  logChild.stdout.on('data', onData);
419
516
  logChild.stderr.on('data', onData);
420
517
  }
421
- function doRebuild(state) {
422
- const entry = (0, state_1.selectedEntry)(state);
518
+ export function doRebuild(state) {
519
+ const entry = selectedEntry(state);
423
520
  if (!entry)
424
521
  return;
425
- const sk = (0, state_1.statusKey)(entry.file, entry.service);
522
+ doRebuildEntry(state, entry);
523
+ }
524
+ export function doRebuildEntry(state, entry) {
525
+ const sk = statusKey(entry.file, entry.service);
426
526
  if (state.rebuilding.has(sk))
427
527
  return;
428
- const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
528
+ const { file: effectiveFile, projectName } = getComposeTarget(state, entry.file, entry.service);
429
529
  if (state.bottomLogTails.has(sk)) {
430
530
  state.bottomLogTails.get(sk).kill('SIGTERM');
431
531
  state.bottomLogTails.delete(sk);
432
532
  }
433
- const child = (0, docker_1.rebuildService)(effectiveFile, entry.service, { noCache: state.noCache, noDeps: state.noDeps });
533
+ const child = rebuildService(effectiveFile, entry.service, { noCache: state.noCache, noDeps: state.noDeps }, projectName);
434
534
  state.rebuilding.set(sk, child);
435
535
  state.bottomLogLines.set(sk, { action: 'rebuilding', service: entry.service, lines: [] });
436
536
  let lineBuf = '';
@@ -445,12 +545,12 @@ function doRebuild(state) {
445
545
  if (newLines.length === 0)
446
546
  return;
447
547
  info.lines.push(...newLines);
448
- if (state.mode === state_1.MODE.LOGS && state.logBuildKey === sk) {
548
+ if (state.mode === MODE.LOGS && state.logBuildKey === sk) {
449
549
  state.logLines.push(...newLines);
450
550
  if (state.logAutoScroll)
451
551
  throttledRender(state);
452
552
  }
453
- if (state.mode === state_1.MODE.LIST)
553
+ if (state.mode === MODE.LIST)
454
554
  throttledRender(state);
455
555
  };
456
556
  child.stdout.on('data', onData);
@@ -465,7 +565,7 @@ function doRebuild(state) {
465
565
  if (code !== 0 && code !== null) {
466
566
  if (info)
467
567
  info.action = 'build_failed';
468
- if (state.mode === state_1.MODE.LIST)
568
+ if (state.mode === MODE.LIST)
469
569
  render(state);
470
570
  return;
471
571
  }
@@ -474,24 +574,27 @@ function doRebuild(state) {
474
574
  if (state.logBuildKey !== sk)
475
575
  info.lines = [];
476
576
  }
477
- startBottomLogTail(state, sk, effectiveFile, entry.service);
478
- if (state.mode === state_1.MODE.LIST)
577
+ startBottomLogTail(state, sk, effectiveFile, entry.service, projectName);
578
+ if (state.mode === MODE.LIST)
479
579
  render(state);
480
580
  });
481
581
  }
482
- function doRestart(state) {
483
- const entry = (0, state_1.selectedEntry)(state);
582
+ export function doRestart(state) {
583
+ const entry = selectedEntry(state);
484
584
  if (!entry)
485
585
  return;
486
- const sk = (0, state_1.statusKey)(entry.file, entry.service);
586
+ doRestartEntry(state, entry);
587
+ }
588
+ export function doRestartEntry(state, entry) {
589
+ const sk = statusKey(entry.file, entry.service);
487
590
  if (state.restarting.has(sk) || state.rebuilding.has(sk))
488
591
  return;
489
- const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
592
+ const { file: effectiveFile, projectName } = getComposeTarget(state, entry.file, entry.service);
490
593
  if (state.bottomLogTails.has(sk)) {
491
594
  state.bottomLogTails.get(sk).kill('SIGTERM');
492
595
  state.bottomLogTails.delete(sk);
493
596
  }
494
- const child = (0, docker_1.restartService)(effectiveFile, entry.service);
597
+ const child = restartService(effectiveFile, entry.service, projectName);
495
598
  state.restarting.set(sk, child);
496
599
  state.bottomLogLines.set(sk, { action: 'restarting', service: entry.service, lines: [] });
497
600
  render(state);
@@ -504,7 +607,7 @@ function doRestart(state) {
504
607
  if (code !== 0 && code !== null) {
505
608
  if (info)
506
609
  info.action = 'restart_failed';
507
- if (state.mode === state_1.MODE.LIST)
610
+ if (state.mode === MODE.LIST)
508
611
  render(state);
509
612
  return;
510
613
  }
@@ -512,16 +615,19 @@ function doRestart(state) {
512
615
  info.action = 'started';
513
616
  info.lines = [];
514
617
  }
515
- startBottomLogTail(state, sk, effectiveFile, entry.service);
516
- if (state.mode === state_1.MODE.LIST)
618
+ startBottomLogTail(state, sk, effectiveFile, entry.service, projectName);
619
+ if (state.mode === MODE.LIST)
517
620
  render(state);
518
621
  });
519
622
  }
520
- function doStop(state) {
521
- const entry = (0, state_1.selectedEntry)(state);
623
+ export function doStop(state) {
624
+ const entry = selectedEntry(state);
522
625
  if (!entry)
523
626
  return;
524
- const sk = (0, state_1.statusKey)(entry.file, entry.service);
627
+ doStopEntry(state, entry);
628
+ }
629
+ export function doStopEntry(state, entry) {
630
+ const sk = statusKey(entry.file, entry.service);
525
631
  if (state.stopping.has(sk) || state.rebuilding.has(sk) || state.restarting.has(sk))
526
632
  return;
527
633
  const st = state.statuses.get(sk);
@@ -531,8 +637,8 @@ function doStop(state) {
531
637
  state.bottomLogTails.get(sk).kill('SIGTERM');
532
638
  state.bottomLogTails.delete(sk);
533
639
  }
534
- const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
535
- const child = (0, docker_1.stopService)(effectiveFile, entry.service);
640
+ const { file: effectiveFile, projectName } = getComposeTarget(state, entry.file, entry.service);
641
+ const child = stopService(effectiveFile, entry.service, projectName);
536
642
  state.stopping.set(sk, child);
537
643
  state.bottomLogLines.set(sk, { action: 'stopping', service: entry.service, lines: [] });
538
644
  render(state);
@@ -547,22 +653,25 @@ function doStop(state) {
547
653
  state.bottomLogLines.delete(sk);
548
654
  }
549
655
  pollStatuses(state);
550
- if (state.mode === state_1.MODE.LIST)
656
+ if (state.mode === MODE.LIST)
551
657
  render(state);
552
658
  });
553
659
  }
554
- function doStart(state) {
555
- const entry = (0, state_1.selectedEntry)(state);
660
+ export function doStart(state) {
661
+ const entry = selectedEntry(state);
556
662
  if (!entry)
557
663
  return;
558
- const sk = (0, state_1.statusKey)(entry.file, entry.service);
664
+ doStartEntry(state, entry);
665
+ }
666
+ export function doStartEntry(state, entry) {
667
+ const sk = statusKey(entry.file, entry.service);
559
668
  if (state.starting.has(sk) || state.rebuilding.has(sk) || state.restarting.has(sk) || state.stopping.has(sk))
560
669
  return;
561
670
  const st = state.statuses.get(sk);
562
671
  if (st && st.state === 'running')
563
672
  return;
564
- const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
565
- const child = (0, docker_1.startService)(effectiveFile, entry.service);
673
+ const { file: effectiveFile, projectName } = getComposeTarget(state, entry.file, entry.service);
674
+ const child = startService(effectiveFile, entry.service, projectName);
566
675
  state.starting.set(sk, child);
567
676
  state.bottomLogLines.set(sk, { action: 'starting', service: entry.service, lines: [] });
568
677
  render(state);
@@ -573,7 +682,7 @@ function doStart(state) {
573
682
  if (code !== 0 && code !== null) {
574
683
  if (info)
575
684
  info.action = 'start_failed';
576
- if (state.mode === state_1.MODE.LIST)
685
+ if (state.mode === MODE.LIST)
577
686
  render(state);
578
687
  return;
579
688
  }
@@ -581,60 +690,64 @@ function doStart(state) {
581
690
  info.action = 'started';
582
691
  info.lines = [];
583
692
  }
584
- startBottomLogTail(state, sk, effectiveFile, entry.service);
585
- if (state.mode === state_1.MODE.LIST)
693
+ startBottomLogTail(state, sk, effectiveFile, entry.service, projectName);
694
+ if (state.mode === MODE.LIST)
586
695
  render(state);
587
696
  });
588
697
  }
589
698
  // --- Worktree Switching ---
590
- function mapComposeFileToWorktree(composeFile, targetWorktreePath) {
591
- const resolved = path_1.default.resolve(composeFile);
592
- const dir = path_1.default.dirname(resolved);
593
- const gitRoot = (0, docker_1.getGitRoot)(dir);
699
+ export function mapComposeFileToWorktree(composeFile, targetWorktreePath) {
700
+ const resolved = path.resolve(composeFile);
701
+ const dir = path.dirname(resolved);
702
+ const gitRoot = getGitRoot(dir);
594
703
  if (!gitRoot)
595
704
  return null;
596
- const relPath = path_1.default.relative(gitRoot, resolved);
597
- const newFile = path_1.default.join(targetWorktreePath, relPath);
705
+ const relPath = path.relative(gitRoot, resolved);
706
+ const newFile = path.join(targetWorktreePath, relPath);
598
707
  try {
599
- fs_1.default.accessSync(newFile);
708
+ fs.accessSync(newFile);
600
709
  return newFile;
601
710
  }
602
711
  catch {
603
712
  return null;
604
713
  }
605
714
  }
606
- function openWorktreePicker(state) {
607
- const entry = (0, state_1.selectedEntry)(state);
715
+ export function openWorktreePicker(state) {
716
+ const entry = selectedEntry(state);
608
717
  if (!entry)
609
718
  return;
610
- const sk = (0, state_1.statusKey)(entry.file, entry.service);
719
+ const sk = statusKey(entry.file, entry.service);
611
720
  if (state.rebuilding.has(sk) || state.restarting.has(sk) || state.stopping.has(sk) || state.starting.has(sk) || state.cascading.has(sk))
612
721
  return;
613
- const composeDir = path_1.default.dirname(path_1.default.resolve(entry.file));
614
- const worktrees = (0, docker_1.listGitWorktrees)(composeDir);
722
+ const composeDir = path.dirname(path.resolve(entry.file));
723
+ const worktrees = listGitWorktrees(composeDir);
615
724
  if (worktrees.length <= 1) {
616
725
  state.bottomLogLines.set(sk, { action: 'switch_failed', service: entry.service, lines: ['no other worktrees available — use `git worktree add` to create one'] });
617
726
  state.showBottomLogs = true;
618
727
  render(state);
619
728
  return;
620
729
  }
621
- const gitRoot = (0, docker_1.getGitRoot)(composeDir);
730
+ // Determine current worktree from container status, not original file location
731
+ const st = state.statuses.get(sk);
732
+ const containerBranch = st?.worktree || null;
733
+ const currentWorktree = containerBranch ? worktrees.find(w => w.branch === containerBranch) : null;
734
+ const currentPath = currentWorktree?.path || getGitRoot(composeDir);
622
735
  state.worktreePickerEntries = worktrees;
623
736
  state.worktreePickerActive = true;
624
- state.worktreePickerCurrentPath = gitRoot;
737
+ state.worktreePickerCurrentPath = currentPath;
625
738
  // Pre-select first non-current worktree
626
- const currentIdx = gitRoot ? worktrees.findIndex(w => w.path === gitRoot) : -1;
739
+ const currentIdx = currentPath ? worktrees.findIndex(w => w.path === currentPath) : -1;
627
740
  const firstOther = worktrees.findIndex((_, i) => i !== currentIdx);
628
741
  state.worktreePickerCursor = firstOther >= 0 ? firstOther : 0;
629
742
  state.showBottomLogs = true;
630
743
  render(state);
631
744
  }
632
- function doWorktreeSwitch(state, targetWorktree) {
633
- const entry = (0, state_1.selectedEntry)(state);
745
+ export function doWorktreeSwitch(state, targetWorktree) {
746
+ const entry = selectedEntry(state);
634
747
  if (!entry)
635
748
  return;
636
749
  const service = entry.service;
637
- const sk = (0, state_1.statusKey)(entry.file, service);
750
+ const sk = statusKey(entry.file, service);
638
751
  // Close picker
639
752
  state.worktreePickerActive = false;
640
753
  state.worktreePickerEntries = [];
@@ -649,17 +762,21 @@ function doWorktreeSwitch(state, targetWorktree) {
649
762
  render(state);
650
763
  return;
651
764
  }
652
- // If target is the same as current effective file, nothing to do
653
- const currentEffective = (0, state_1.getEffectiveFile)(state, entry.file, service);
654
- if (newFile === currentEffective) {
765
+ // If target is the same as current effective file AND container is on the target branch, nothing to do
766
+ const currentEffective = getEffectiveFile(state, entry.file, service);
767
+ const currentSt = state.statuses.get(sk);
768
+ const containerBranch = currentSt?.worktree || null;
769
+ const alreadyOnTarget = path.resolve(newFile) === path.resolve(currentEffective)
770
+ && (!containerBranch || containerBranch === targetWorktree.branch);
771
+ if (alreadyOnTarget) {
655
772
  render(state);
656
773
  return;
657
774
  }
658
775
  // Validate service exists in target compose file
659
- if (!(0, docker_1.validateServiceInComposeFile)(newFile, service)) {
776
+ if (!validateServiceInComposeFile(newFile, service)) {
660
777
  state.bottomLogLines.set(sk, {
661
778
  action: 'switch_failed', service,
662
- lines: [`service "${service}" not found in ${path_1.default.basename(newFile)} on branch "${targetWorktree.branch}"`],
779
+ lines: [`service "${service}" not found in ${path.basename(newFile)} on branch "${targetWorktree.branch}"`],
663
780
  });
664
781
  render(state);
665
782
  return;
@@ -667,6 +784,12 @@ function doWorktreeSwitch(state, targetWorktree) {
667
784
  // Show switching progress
668
785
  state.bottomLogLines.set(sk, { action: 'switching', service, lines: [`switching to worktree "${targetWorktree.branch}"...`] });
669
786
  render(state);
787
+ // Project name always derived from original file for consistency
788
+ const origProjectName = composeProjectName(entry.file);
789
+ // Current effective file might also be a worktree override — use original project name
790
+ const currentProjectName = currentEffective !== entry.file ? origProjectName : undefined;
791
+ // New file is from the target worktree — use original project name (unless switching back to original)
792
+ const newProjectName = newFile !== entry.file ? origProjectName : undefined;
670
793
  const performSwitch = () => {
671
794
  // Store the worktree override (or remove if switching back to original)
672
795
  if (newFile === entry.file) {
@@ -678,7 +801,7 @@ function doWorktreeSwitch(state, targetWorktree) {
678
801
  // Update bottomLogLines to show rebuild
679
802
  state.bottomLogLines.set(sk, { action: 'switching', service, lines: [`rebuilding in worktree "${targetWorktree.branch}"...`] });
680
803
  // Rebuild in new worktree
681
- const child = (0, docker_1.rebuildService)(newFile, service, { noCache: state.noCache, noDeps: state.noDeps });
804
+ const child = rebuildService(newFile, service, { noCache: state.noCache, noDeps: state.noDeps }, newProjectName);
682
805
  state.rebuilding.set(sk, child);
683
806
  let lineBuf = '';
684
807
  const onData = (data) => {
@@ -692,7 +815,7 @@ function doWorktreeSwitch(state, targetWorktree) {
692
815
  if (newLines.length === 0)
693
816
  return;
694
817
  info.lines.push(...newLines);
695
- if (state.mode === state_1.MODE.LIST)
818
+ if (state.mode === MODE.LIST)
696
819
  throttledRender(state);
697
820
  };
698
821
  child.stdout.on('data', onData);
@@ -707,7 +830,7 @@ function doWorktreeSwitch(state, targetWorktree) {
707
830
  if (code !== 0 && code !== null) {
708
831
  if (info)
709
832
  info.action = 'build_failed';
710
- if (state.mode === state_1.MODE.LIST)
833
+ if (state.mode === MODE.LIST)
711
834
  render(state);
712
835
  return;
713
836
  }
@@ -715,8 +838,8 @@ function doWorktreeSwitch(state, targetWorktree) {
715
838
  info.action = 'started';
716
839
  info.lines = [];
717
840
  }
718
- startBottomLogTail(state, sk, newFile, service);
719
- if (state.mode === state_1.MODE.LIST)
841
+ startBottomLogTail(state, sk, newFile, service, newProjectName);
842
+ if (state.mode === MODE.LIST)
720
843
  render(state);
721
844
  });
722
845
  };
@@ -727,7 +850,7 @@ function doWorktreeSwitch(state, targetWorktree) {
727
850
  state.bottomLogTails.get(sk).kill('SIGTERM');
728
851
  state.bottomLogTails.delete(sk);
729
852
  }
730
- const stopChild = (0, docker_1.stopService)(currentEffective, service);
853
+ const stopChild = stopService(currentEffective, service, currentProjectName);
731
854
  state.stopping.set(sk, stopChild);
732
855
  render(state);
733
856
  stopChild.on('close', () => {
@@ -739,12 +862,138 @@ function doWorktreeSwitch(state, targetWorktree) {
739
862
  performSwitch();
740
863
  }
741
864
  }
865
+ export function openWorktreePickerMulti(state) {
866
+ // Use the first selected service to determine worktrees
867
+ const firstSk = [...state.multiSelected][0];
868
+ const firstEntry = state.flatList.find(e => statusKey(e.file, e.service) === firstSk);
869
+ if (!firstEntry)
870
+ return;
871
+ const composeDir = path.dirname(path.resolve(firstEntry.file));
872
+ const worktrees = listGitWorktrees(composeDir);
873
+ if (worktrees.length <= 1) {
874
+ return;
875
+ }
876
+ // Determine current worktree from container status, not original file location
877
+ const firstSt = state.statuses.get(firstSk);
878
+ const containerBranch = firstSt?.worktree || null;
879
+ const currentWorktree = containerBranch ? worktrees.find(w => w.branch === containerBranch) : null;
880
+ const currentPath = currentWorktree?.path || getGitRoot(composeDir);
881
+ state.worktreePickerEntries = worktrees;
882
+ state.worktreePickerActive = true;
883
+ state.worktreePickerCurrentPath = currentPath;
884
+ const currentIdx = currentPath ? worktrees.findIndex(w => w.path === currentPath) : -1;
885
+ const firstOther = worktrees.findIndex((_, i) => i !== currentIdx);
886
+ state.worktreePickerCursor = firstOther >= 0 ? firstOther : 0;
887
+ state.showBottomLogs = true;
888
+ render(state);
889
+ }
890
+ export function doWorktreeSwitchMulti(state, targetWorktree) {
891
+ // Close picker
892
+ state.worktreePickerActive = false;
893
+ state.worktreePickerEntries = [];
894
+ state.worktreePickerCursor = 0;
895
+ for (const mSk of state.multiSelected) {
896
+ const entry = state.flatList.find(e => statusKey(e.file, e.service) === mSk);
897
+ if (!entry)
898
+ continue;
899
+ if (state.rebuilding.has(mSk) || state.restarting.has(mSk) || state.stopping.has(mSk) || state.starting.has(mSk) || state.cascading.has(mSk))
900
+ continue;
901
+ doWorktreeSwitchEntry(state, entry, targetWorktree);
902
+ }
903
+ render(state);
904
+ }
905
+ function doWorktreeSwitchEntry(state, entry, targetWorktree) {
906
+ const service = entry.service;
907
+ const sk = statusKey(entry.file, service);
908
+ const newFile = mapComposeFileToWorktree(entry.file, targetWorktree.path);
909
+ if (!newFile)
910
+ return;
911
+ const currentEffective = getEffectiveFile(state, entry.file, service);
912
+ // Also check the container's actual worktree — overrides are lost on restart
913
+ const currentSt = state.statuses.get(sk);
914
+ const containerBranch = currentSt?.worktree || null;
915
+ const alreadyOnTarget = path.resolve(newFile) === path.resolve(currentEffective)
916
+ && (!containerBranch || containerBranch === targetWorktree.branch);
917
+ if (alreadyOnTarget)
918
+ return;
919
+ if (!validateServiceInComposeFile(newFile, service))
920
+ return;
921
+ state.bottomLogLines.set(sk, { action: 'switching', service, lines: [`switching to worktree "${targetWorktree.branch}"...`] });
922
+ const origProjectName = composeProjectName(entry.file);
923
+ const currentProjectName = currentEffective !== entry.file ? origProjectName : undefined;
924
+ const newProjectName = newFile !== entry.file ? origProjectName : undefined;
925
+ const performSwitch = () => {
926
+ if (newFile === entry.file) {
927
+ state.worktreeOverrides.delete(sk);
928
+ }
929
+ else {
930
+ state.worktreeOverrides.set(sk, newFile);
931
+ }
932
+ state.bottomLogLines.set(sk, { action: 'switching', service, lines: [`rebuilding in worktree "${targetWorktree.branch}"...`] });
933
+ const child = rebuildService(newFile, service, { noCache: state.noCache, noDeps: state.noDeps }, newProjectName);
934
+ state.rebuilding.set(sk, child);
935
+ let lineBuf = '';
936
+ const onData = (data) => {
937
+ const info = state.bottomLogLines.get(sk);
938
+ if (!info)
939
+ return;
940
+ lineBuf += data.toString();
941
+ const parts = lineBuf.split(/\r?\n|\r/);
942
+ lineBuf = parts.pop();
943
+ const newLines = parts.filter(l => l.trim().length > 0).map(stripAnsi).filter(Boolean);
944
+ if (newLines.length === 0)
945
+ return;
946
+ info.lines.push(...newLines);
947
+ if (state.mode === MODE.LIST)
948
+ throttledRender(state);
949
+ };
950
+ child.stdout.on('data', onData);
951
+ child.stderr.on('data', onData);
952
+ child.on('close', (code) => {
953
+ state.rebuilding.delete(sk);
954
+ state.containerStatsHistory.delete(sk);
955
+ state.containerStats.delete(sk);
956
+ pollStatuses(state);
957
+ const info = state.bottomLogLines.get(sk);
958
+ if (code !== 0 && code !== null) {
959
+ if (info)
960
+ info.action = 'switch_failed';
961
+ if (state.mode === MODE.LIST)
962
+ render(state);
963
+ return;
964
+ }
965
+ if (info) {
966
+ info.action = 'started';
967
+ info.lines = [];
968
+ }
969
+ startBottomLogTail(state, sk, newFile, service, newProjectName);
970
+ if (state.mode === MODE.LIST)
971
+ render(state);
972
+ });
973
+ };
974
+ const st = state.statuses.get(sk);
975
+ if (st && st.state === 'running') {
976
+ if (state.bottomLogTails.has(sk)) {
977
+ state.bottomLogTails.get(sk).kill('SIGTERM');
978
+ state.bottomLogTails.delete(sk);
979
+ }
980
+ const stopChild = stopService(currentEffective, service, currentProjectName);
981
+ state.stopping.set(sk, stopChild);
982
+ stopChild.on('close', () => {
983
+ state.stopping.delete(sk);
984
+ performSwitch();
985
+ });
986
+ }
987
+ else {
988
+ performSwitch();
989
+ }
990
+ }
742
991
  // --- Watch ---
743
- function doWatch(state) {
744
- const entry = (0, state_1.selectedEntry)(state);
992
+ export function doWatch(state) {
993
+ const entry = selectedEntry(state);
745
994
  if (!entry)
746
995
  return;
747
- const sk = (0, state_1.statusKey)(entry.file, entry.service);
996
+ const sk = statusKey(entry.file, entry.service);
748
997
  // Toggle off if already watching
749
998
  if (state.watching.has(sk)) {
750
999
  state.watching.get(sk).kill('SIGTERM');
@@ -758,7 +1007,7 @@ function doWatch(state) {
758
1007
  }
759
1008
  // Check availability on first use
760
1009
  if (state.watchAvailable === null) {
761
- state.watchAvailable = (0, docker_1.isWatchAvailable)();
1010
+ state.watchAvailable = isWatchAvailable();
762
1011
  }
763
1012
  if (!state.watchAvailable) {
764
1013
  state.bottomLogLines.set(sk, { action: 'watching', service: entry.service, lines: ['docker compose watch is not available (requires Docker Compose v2.22+)'] });
@@ -766,8 +1015,8 @@ function doWatch(state) {
766
1015
  render(state);
767
1016
  return;
768
1017
  }
769
- const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
770
- const child = (0, docker_1.watchService)(effectiveFile, entry.service);
1018
+ const { file: effectiveFile, projectName } = getComposeTarget(state, entry.file, entry.service);
1019
+ const child = watchService(effectiveFile, entry.service, projectName);
771
1020
  state.watching.set(sk, child);
772
1021
  state.bottomLogLines.set(sk, { action: 'watching', service: entry.service, lines: [] });
773
1022
  state.showBottomLogs = true;
@@ -786,25 +1035,25 @@ function doWatch(state) {
786
1035
  info.lines.push(...newLines);
787
1036
  if (info.lines.length > maxLines)
788
1037
  info.lines = info.lines.slice(-maxLines);
789
- if (state.mode === state_1.MODE.LIST)
1038
+ if (state.mode === MODE.LIST)
790
1039
  throttledRender(state);
791
1040
  };
792
1041
  child.stdout.on('data', onData);
793
1042
  child.stderr.on('data', onData);
794
1043
  child.on('close', () => {
795
1044
  state.watching.delete(sk);
796
- if (state.mode === state_1.MODE.LIST)
1045
+ if (state.mode === MODE.LIST)
797
1046
  render(state);
798
1047
  });
799
1048
  render(state);
800
1049
  }
801
1050
  // --- Dependency-Aware Rebuild ---
802
- function initDepGraphs(state) {
1051
+ export function initDepGraphs(state) {
803
1052
  for (const group of state.groups) {
804
1053
  if (group.error)
805
1054
  continue;
806
1055
  try {
807
- state.depGraphs.set(group.file, (0, docker_1.parseDependencyGraph)(group.file));
1056
+ state.depGraphs.set(group.file, parseDependencyGraph(group.file));
808
1057
  }
809
1058
  catch {
810
1059
  // Ignore — no dep info for this file
@@ -858,19 +1107,19 @@ function topoSortDependents(graph, services, root) {
858
1107
  }
859
1108
  return sorted;
860
1109
  }
861
- function doCascadeRebuild(state) {
862
- const entry = (0, state_1.selectedEntry)(state);
1110
+ export function doCascadeRebuild(state) {
1111
+ const entry = selectedEntry(state);
863
1112
  if (!entry)
864
1113
  return;
865
- const sk = (0, state_1.statusKey)(entry.file, entry.service);
1114
+ const sk = statusKey(entry.file, entry.service);
866
1115
  if (state.rebuilding.has(sk) || state.cascading.has(sk))
867
1116
  return;
868
- const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
1117
+ const { file: effectiveFile, projectName } = getComposeTarget(state, entry.file, entry.service);
869
1118
  let graph = state.depGraphs.get(effectiveFile);
870
1119
  if (!graph) {
871
1120
  // Try to parse dep graph for the effective file (may differ from original)
872
1121
  try {
873
- graph = (0, docker_1.parseDependencyGraph)(effectiveFile);
1122
+ graph = parseDependencyGraph(effectiveFile);
874
1123
  state.depGraphs.set(effectiveFile, graph);
875
1124
  }
876
1125
  catch {
@@ -898,26 +1147,26 @@ function doCascadeRebuild(state) {
898
1147
  state.cascading.set(sk, cascade);
899
1148
  state.bottomLogLines.set(sk, { action: 'cascading', service: entry.service, lines: [] });
900
1149
  state.showBottomLogs = true;
901
- executeCascadeStep(state, effectiveFile, sk, cascade);
1150
+ executeCascadeStep(state, effectiveFile, sk, cascade, projectName);
902
1151
  render(state);
903
1152
  }
904
- function executeCascadeStep(state, file, sk, cascade) {
1153
+ function executeCascadeStep(state, file, sk, cascade, projectName) {
905
1154
  const step = cascade.steps[cascade.currentStepIdx];
906
1155
  if (!step) {
907
1156
  // All done
908
1157
  state.cascading.delete(sk);
909
1158
  pollStatuses(state);
910
- if (state.mode === state_1.MODE.LIST)
1159
+ if (state.mode === MODE.LIST)
911
1160
  render(state);
912
1161
  return;
913
1162
  }
914
1163
  step.status = 'in_progress';
915
1164
  let child;
916
1165
  if (step.action === 'rebuild') {
917
- child = (0, docker_1.rebuildService)(file, step.service, { noCache: state.noCache, noDeps: state.noDeps });
1166
+ child = rebuildService(file, step.service, { noCache: state.noCache, noDeps: state.noDeps }, projectName);
918
1167
  }
919
1168
  else {
920
- child = (0, docker_1.restartService)(file, step.service);
1169
+ child = restartService(file, step.service, projectName);
921
1170
  }
922
1171
  cascade.child = child;
923
1172
  let lineBuf = '';
@@ -932,12 +1181,12 @@ function executeCascadeStep(state, file, sk, cascade) {
932
1181
  if (newLines.length === 0)
933
1182
  return;
934
1183
  info.lines.push(...newLines);
935
- if (state.mode === state_1.MODE.LOGS && state.logBuildKey === sk) {
1184
+ if (state.mode === MODE.LOGS && state.logBuildKey === sk) {
936
1185
  state.logLines.push(...newLines);
937
1186
  if (state.logAutoScroll)
938
1187
  throttledRender(state);
939
1188
  }
940
- if (state.mode === state_1.MODE.LIST)
1189
+ if (state.mode === MODE.LIST)
941
1190
  throttledRender(state);
942
1191
  };
943
1192
  const childProcess = child;
@@ -948,7 +1197,7 @@ function executeCascadeStep(state, file, sk, cascade) {
948
1197
  step.status = 'failed';
949
1198
  state.cascading.delete(sk);
950
1199
  pollStatuses(state);
951
- if (state.mode === state_1.MODE.LIST)
1200
+ if (state.mode === MODE.LIST)
952
1201
  render(state);
953
1202
  return;
954
1203
  }
@@ -956,11 +1205,11 @@ function executeCascadeStep(state, file, sk, cascade) {
956
1205
  cascade.currentStepIdx++;
957
1206
  cascade.child = null;
958
1207
  // Reset stats for rebuilt/restarted service
959
- const stepSk = (0, state_1.statusKey)(file, step.service);
1208
+ const stepSk = statusKey(file, step.service);
960
1209
  state.containerStatsHistory.delete(stepSk);
961
1210
  state.containerStats.delete(stepSk);
962
1211
  if (cascade.currentStepIdx < cascade.steps.length) {
963
- executeCascadeStep(state, file, sk, cascade);
1212
+ executeCascadeStep(state, file, sk, cascade, projectName);
964
1213
  }
965
1214
  else {
966
1215
  state.cascading.delete(sk);
@@ -970,18 +1219,18 @@ function executeCascadeStep(state, file, sk, cascade) {
970
1219
  info.action = 'started';
971
1220
  info.lines = [];
972
1221
  }
973
- startBottomLogTail(state, sk, file, state.flatList[state.cursor]?.service || '');
1222
+ startBottomLogTail(state, sk, file, state.flatList[state.cursor]?.service || '', projectName);
974
1223
  }
975
- if (state.mode === state_1.MODE.LIST)
1224
+ if (state.mode === MODE.LIST)
976
1225
  render(state);
977
1226
  });
978
1227
  }
979
1228
  // --- Exec ---
980
1229
  function initExecState(state) {
981
- const entry = (0, state_1.selectedEntry)(state);
1230
+ const entry = selectedEntry(state);
982
1231
  if (!entry)
983
1232
  return false;
984
- const sk = (0, state_1.statusKey)(entry.file, entry.service);
1233
+ const sk = statusKey(entry.file, entry.service);
985
1234
  const st = state.statuses.get(sk);
986
1235
  if (!st || st.state !== 'running' || !st.id)
987
1236
  return false;
@@ -994,29 +1243,29 @@ function initExecState(state) {
994
1243
  state.execCwd = null;
995
1244
  return true;
996
1245
  }
997
- function enterExecInline(state) {
1246
+ export function enterExecInline(state) {
998
1247
  if (!initExecState(state))
999
1248
  return;
1000
1249
  state.execActive = true;
1001
1250
  state.showBottomLogs = true;
1002
1251
  render(state);
1003
1252
  }
1004
- function enterExec(state) {
1253
+ export function enterExec(state) {
1005
1254
  if (!state.execActive) {
1006
1255
  if (!initExecState(state))
1007
1256
  return;
1008
1257
  }
1009
1258
  state.execActive = false;
1010
- state.mode = state_1.MODE.EXEC;
1259
+ state.mode = MODE.EXEC;
1011
1260
  render(state);
1012
1261
  }
1013
- function exitExec(state) {
1262
+ export function exitExec(state) {
1014
1263
  if (state.execChild) {
1015
1264
  state.execChild.kill('SIGTERM');
1016
1265
  state.execChild = null;
1017
1266
  }
1018
- const wasFullscreen = state.mode === state_1.MODE.EXEC;
1019
- state.mode = state_1.MODE.LIST;
1267
+ const wasFullscreen = state.mode === MODE.EXEC;
1268
+ state.mode = MODE.LIST;
1020
1269
  state.execActive = false;
1021
1270
  state.execInput = '';
1022
1271
  state.execOutputLines = [];
@@ -1034,10 +1283,10 @@ function isCdCommand(cmd) {
1034
1283
  return null;
1035
1284
  return match[2] ? match[2].trim() : '';
1036
1285
  }
1037
- function shellEscape(str) {
1286
+ export function shellEscape(str) {
1038
1287
  return "'" + str.replace(/'/g, "'\\''") + "'";
1039
1288
  }
1040
- function runExecCommand(state) {
1289
+ export function runExecCommand(state) {
1041
1290
  const cmd = state.execInput.trim();
1042
1291
  if (!cmd || !state.execContainerId)
1043
1292
  return;
@@ -1059,7 +1308,7 @@ function runExecCommand(state) {
1059
1308
  const cdTarget = isCdCommand(cmd);
1060
1309
  if (cdTarget !== null) {
1061
1310
  const resolveCmd = cdTarget ? `cd ${shellEscape(cdTarget)} && pwd` : 'cd && pwd';
1062
- const child = (0, docker_1.execInContainer)(state.execContainerId, resolveCmd, state.execCwd || undefined);
1311
+ const child = execInContainer(state.execContainerId, resolveCmd, state.execCwd || undefined);
1063
1312
  state.execChild = child;
1064
1313
  let stdout = '';
1065
1314
  let stderr = '';
@@ -1081,13 +1330,13 @@ function runExecCommand(state) {
1081
1330
  state.execOutputLines.push(stripAnsi(line));
1082
1331
  }
1083
1332
  }
1084
- if (state.mode === state_1.MODE.EXEC || state.execActive)
1333
+ if (state.mode === MODE.EXEC || state.execActive)
1085
1334
  throttledRender(state);
1086
1335
  });
1087
1336
  render(state);
1088
1337
  return;
1089
1338
  }
1090
- const child = (0, docker_1.execInContainer)(state.execContainerId, cmd, state.execCwd || undefined);
1339
+ const child = execInContainer(state.execContainerId, cmd, state.execCwd || undefined);
1091
1340
  state.execChild = child;
1092
1341
  let lineBuf = '';
1093
1342
  const onData = (data) => {
@@ -1101,7 +1350,7 @@ function runExecCommand(state) {
1101
1350
  if (state.execOutputLines.length > 200) {
1102
1351
  state.execOutputLines = state.execOutputLines.slice(-200);
1103
1352
  }
1104
- if (state.mode === state_1.MODE.EXEC || state.execActive)
1353
+ if (state.mode === MODE.EXEC || state.execActive)
1105
1354
  throttledRender(state);
1106
1355
  };
1107
1356
  child.stdout.on('data', onData);
@@ -1115,13 +1364,13 @@ function runExecCommand(state) {
1115
1364
  state.execOutputLines.push(stripAnsi(lineBuf));
1116
1365
  lineBuf = '';
1117
1366
  }
1118
- if (state.mode === state_1.MODE.EXEC || state.execActive)
1367
+ if (state.mode === MODE.EXEC || state.execActive)
1119
1368
  throttledRender(state);
1120
1369
  });
1121
1370
  render(state);
1122
1371
  }
1123
- function enterLogs(state) {
1124
- const entry = (0, state_1.selectedEntry)(state);
1372
+ export function enterLogs(state) {
1373
+ const entry = selectedEntry(state);
1125
1374
  if (!entry)
1126
1375
  return;
1127
1376
  if (moduleState.logFetchTimer) {
@@ -1131,11 +1380,11 @@ function enterLogs(state) {
1131
1380
  // Carry over bottom panel search query to full log search
1132
1381
  const carryQuery = state.bottomSearchQuery || '';
1133
1382
  clearBottomSearch(state);
1134
- const sk = (0, state_1.statusKey)(entry.file, entry.service);
1383
+ const sk = statusKey(entry.file, entry.service);
1135
1384
  const info = state.bottomLogLines.get(sk);
1136
1385
  const isBuilding = state.rebuilding.has(sk) || state.cascading.has(sk);
1137
1386
  const isBuildFailed = info && info.action === 'build_failed';
1138
- state.mode = state_1.MODE.LOGS;
1387
+ state.mode = MODE.LOGS;
1139
1388
  state.logLines = [];
1140
1389
  state.logScrollOffset = 0;
1141
1390
  state.logAutoScroll = true;
@@ -1158,8 +1407,8 @@ function enterLogs(state) {
1158
1407
  }
1159
1408
  else {
1160
1409
  state.logBuildKey = null;
1161
- const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
1162
- const child = (0, docker_1.tailLogs)(effectiveFile, entry.service, 200);
1410
+ const { file: effectiveFile, projectName } = getComposeTarget(state, entry.file, entry.service);
1411
+ const child = tailLogs(effectiveFile, entry.service, 200, projectName);
1163
1412
  state.logChild = child;
1164
1413
  let lineBuf = '';
1165
1414
  const onData = (data) => {
@@ -1190,7 +1439,7 @@ function enterLogs(state) {
1190
1439
  }
1191
1440
  render(state);
1192
1441
  }
1193
- function exitLogs(state) {
1442
+ export function exitLogs(state) {
1194
1443
  if (state.logChild) {
1195
1444
  state.logChild.kill('SIGTERM');
1196
1445
  state.logChild = null;
@@ -1204,15 +1453,15 @@ function exitLogs(state) {
1204
1453
  state.logHistoryLoaded = false;
1205
1454
  state.logHistoryLoading = false;
1206
1455
  state.logSearchPending = false;
1207
- state.mode = state_1.MODE.LIST;
1456
+ state.mode = MODE.LIST;
1208
1457
  pollStatuses(state);
1209
1458
  render(state);
1210
1459
  }
1211
1460
  // --- Log History Loading ---
1212
- function loadMoreLogHistory(state) {
1461
+ export function loadMoreLogHistory(state) {
1213
1462
  if (state.logHistoryLoaded || state.logHistoryLoading)
1214
1463
  return;
1215
- const entry = (0, state_1.selectedEntry)(state);
1464
+ const entry = selectedEntry(state);
1216
1465
  if (!entry)
1217
1466
  return;
1218
1467
  // Escalate: 200 → 1000 → 5000 → all
@@ -1225,8 +1474,8 @@ function loadMoreLogHistory(state) {
1225
1474
  nextTail = 'all';
1226
1475
  state.logHistoryLoading = true;
1227
1476
  const snapshotLen = state.logLines.length;
1228
- const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
1229
- const child = (0, docker_1.fetchServiceLogs)(effectiveFile, entry.service, nextTail);
1477
+ const { file: effectiveFile, projectName } = getComposeTarget(state, entry.file, entry.service);
1478
+ const child = fetchServiceLogs(effectiveFile, entry.service, nextTail, projectName);
1230
1479
  state.logHistoryChild = child;
1231
1480
  let output = '';
1232
1481
  child.stdout.on('data', (d) => { output += d.toString(); });
@@ -1263,7 +1512,7 @@ function loadMoreLogHistory(state) {
1263
1512
  });
1264
1513
  }
1265
1514
  // --- Log Search ---
1266
- function executeLogSearch(state) {
1515
+ export function executeLogSearch(state) {
1267
1516
  const query = state.logSearchQuery;
1268
1517
  state.logSearchMatches = [];
1269
1518
  state.logSearchMatchIdx = -1;
@@ -1290,24 +1539,24 @@ function scrollToLogLine(state, lineIdx) {
1290
1539
  state.logAutoScroll = state.logScrollOffset === 0;
1291
1540
  render(state);
1292
1541
  }
1293
- function jumpToNextMatch(state) {
1542
+ export function jumpToNextMatch(state) {
1294
1543
  if (state.logSearchMatches.length === 0)
1295
1544
  return;
1296
1545
  state.logSearchMatchIdx = (state.logSearchMatchIdx + 1) % state.logSearchMatches.length;
1297
1546
  scrollToLogLine(state, state.logSearchMatches[state.logSearchMatchIdx]);
1298
1547
  }
1299
- function jumpToPrevMatch(state) {
1548
+ export function jumpToPrevMatch(state) {
1300
1549
  if (state.logSearchMatches.length === 0)
1301
1550
  return;
1302
1551
  state.logSearchMatchIdx = (state.logSearchMatchIdx - 1 + state.logSearchMatches.length) % state.logSearchMatches.length;
1303
1552
  scrollToLogLine(state, state.logSearchMatches[state.logSearchMatchIdx]);
1304
1553
  }
1305
1554
  // --- Bottom Panel Search ---
1306
- function executeBottomSearch(state) {
1307
- const entry = (0, state_1.selectedEntry)(state);
1555
+ export function executeBottomSearch(state) {
1556
+ const entry = selectedEntry(state);
1308
1557
  if (!entry || !state.bottomSearchQuery)
1309
1558
  return;
1310
- const sk = (0, state_1.statusKey)(entry.file, entry.service);
1559
+ const sk = statusKey(entry.file, entry.service);
1311
1560
  const info = state.bottomLogLines.get(sk);
1312
1561
  if (!info)
1313
1562
  return;
@@ -1323,8 +1572,8 @@ function executeBottomSearch(state) {
1323
1572
  state.bottomSearchLoading = true;
1324
1573
  state.bottomSearchTotalMatches = 0;
1325
1574
  render(state);
1326
- const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
1327
- const child = (0, docker_1.fetchServiceLogs)(effectiveFile, entry.service, 'all');
1575
+ const { file: effectiveFile, projectName } = getComposeTarget(state, entry.file, entry.service);
1576
+ const child = fetchServiceLogs(effectiveFile, entry.service, 'all', projectName);
1328
1577
  state.bottomSearchChild = child;
1329
1578
  let output = '';
1330
1579
  child.stdout.on('data', (d) => { output += d.toString(); });
@@ -1355,11 +1604,11 @@ function executeBottomSearch(state) {
1355
1604
  if (currentInfo) {
1356
1605
  currentInfo.lines = matchingLines.slice(-maxLines);
1357
1606
  }
1358
- if (state.mode === state_1.MODE.LIST)
1607
+ if (state.mode === MODE.LIST)
1359
1608
  render(state);
1360
1609
  });
1361
1610
  }
1362
- function clearBottomSearch(state) {
1611
+ export function clearBottomSearch(state) {
1363
1612
  if (state.bottomSearchChild) {
1364
1613
  state.bottomSearchChild.kill('SIGTERM');
1365
1614
  state.bottomSearchChild = null;
@@ -1376,12 +1625,12 @@ function clearBottomSearch(state) {
1376
1625
  }
1377
1626
  }
1378
1627
  // --- Input Handling ---
1379
- function handleKeypress(state, key) {
1380
- if (key === '\x03' && state.mode !== state_1.MODE.EXEC && !state.execActive) {
1628
+ export function handleKeypress(state, key) {
1629
+ if (key === '\x03' && state.mode !== MODE.EXEC && !state.execActive) {
1381
1630
  cleanup(state);
1382
1631
  process.exit(0);
1383
1632
  }
1384
- if (state.mode === state_1.MODE.EXEC) {
1633
+ if (state.mode === MODE.EXEC) {
1385
1634
  if (key === '\x1b') {
1386
1635
  exitExec(state);
1387
1636
  }
@@ -1419,6 +1668,11 @@ function handleKeypress(state, key) {
1419
1668
  render(state);
1420
1669
  }
1421
1670
  }
1671
+ else if (key === '\x11') {
1672
+ // Ctrl+Q — quit
1673
+ cleanup(state);
1674
+ process.exit(0);
1675
+ }
1422
1676
  else if (key === '\x03') {
1423
1677
  // Ctrl+C — kill current exec child
1424
1678
  if (state.execChild) {
@@ -1438,7 +1692,7 @@ function handleKeypress(state, key) {
1438
1692
  }
1439
1693
  return;
1440
1694
  }
1441
- if (state.mode === state_1.MODE.LOGS) {
1695
+ if (state.mode === MODE.LOGS) {
1442
1696
  if (state.logSearchActive) {
1443
1697
  if (key === '\x1b') {
1444
1698
  state.logSearchActive = false;
@@ -1563,8 +1817,14 @@ function handleKeypress(state, key) {
1563
1817
  }
1564
1818
  else if (key === '\r') {
1565
1819
  const target = state.worktreePickerEntries[state.worktreePickerCursor];
1566
- if (target)
1567
- doWorktreeSwitch(state, target);
1820
+ if (target) {
1821
+ if (state.multiSelected.size > 0) {
1822
+ doWorktreeSwitchMulti(state, target);
1823
+ }
1824
+ else {
1825
+ doWorktreeSwitch(state, target);
1826
+ }
1827
+ }
1568
1828
  }
1569
1829
  else if (key === 'j' || key === '\x1b[B') {
1570
1830
  state.worktreePickerCursor = Math.min(state.worktreePickerEntries.length - 1, state.worktreePickerCursor + 1);
@@ -1617,6 +1877,11 @@ function handleKeypress(state, key) {
1617
1877
  render(state);
1618
1878
  }
1619
1879
  }
1880
+ else if (key === '\x11') {
1881
+ // Ctrl+Q — quit
1882
+ cleanup(state);
1883
+ process.exit(0);
1884
+ }
1620
1885
  else if (key === '\x03') {
1621
1886
  if (state.execChild) {
1622
1887
  state.execChild.kill('SIGTERM');
@@ -1629,7 +1894,8 @@ function handleKeypress(state, key) {
1629
1894
  process.exit(0);
1630
1895
  }
1631
1896
  }
1632
- else if (key === 'x') {
1897
+ else if (key === '\x06') {
1898
+ // Ctrl+F — expand to full screen
1633
1899
  enterExec(state);
1634
1900
  }
1635
1901
  else if (key.length === 1 && key >= ' ') {
@@ -1663,51 +1929,157 @@ function handleKeypress(state, key) {
1663
1929
  }
1664
1930
  return;
1665
1931
  }
1932
+ // LIST mode - multiselect Esc
1933
+ if (key === '\x1b' && state.multiSelected.size > 0) {
1934
+ state.multiSelected.clear();
1935
+ updateSelectedLogs(state);
1936
+ render(state);
1937
+ return;
1938
+ }
1939
+ const isMulti = state.multiSelected.size > 0;
1666
1940
  // LIST mode
1667
1941
  switch (key) {
1668
1942
  case 'j':
1669
1943
  case '\x1b[B':
1670
- (0, state_1.moveCursor)(state, 1);
1671
- updateSelectedLogs(state);
1944
+ moveCursor(state, 1);
1945
+ if (!isMulti)
1946
+ updateSelectedLogs(state);
1672
1947
  render(state);
1673
1948
  break;
1674
1949
  case 'k':
1675
1950
  case '\x1b[A':
1676
- (0, state_1.moveCursor)(state, -1);
1677
- updateSelectedLogs(state);
1951
+ moveCursor(state, -1);
1952
+ if (!isMulti)
1953
+ updateSelectedLogs(state);
1678
1954
  render(state);
1679
1955
  break;
1680
- case 'b':
1681
- doRebuild(state);
1956
+ case 'v': {
1957
+ const vEntry = selectedEntry(state);
1958
+ if (vEntry) {
1959
+ const vSk = statusKey(vEntry.file, vEntry.service);
1960
+ if (state.multiSelected.has(vSk)) {
1961
+ state.multiSelected.delete(vSk);
1962
+ }
1963
+ else {
1964
+ state.multiSelected.add(vSk);
1965
+ }
1966
+ moveCursor(state, 1);
1967
+ render(state);
1968
+ }
1969
+ break;
1970
+ }
1971
+ case 'b': {
1972
+ if (isMulti) {
1973
+ for (const mSk of state.multiSelected) {
1974
+ const mEntry = state.flatList.find(e => statusKey(e.file, e.service) === mSk);
1975
+ if (mEntry && !state.rebuilding.has(mSk)) {
1976
+ doRebuildEntry(state, mEntry);
1977
+ }
1978
+ }
1979
+ render(state);
1980
+ }
1981
+ else {
1982
+ const bEntry = selectedEntry(state);
1983
+ if (bEntry) {
1984
+ const bSk = statusKey(bEntry.file, bEntry.service);
1985
+ if (state.rebuilding.has(bSk)) {
1986
+ state.rebuilding.get(bSk).kill('SIGTERM');
1987
+ state.rebuilding.delete(bSk);
1988
+ const bInfo = state.bottomLogLines.get(bSk);
1989
+ if (bInfo)
1990
+ bInfo.action = 'build_failed';
1991
+ pollStatuses(state);
1992
+ render(state);
1993
+ }
1994
+ else {
1995
+ doRebuild(state);
1996
+ }
1997
+ }
1998
+ }
1682
1999
  break;
2000
+ }
1683
2001
  case 'd':
1684
- doCascadeRebuild(state);
2002
+ if (!isMulti)
2003
+ doCascadeRebuild(state);
1685
2004
  break;
1686
2005
  case 'w':
1687
- doWatch(state);
2006
+ if (!isMulti)
2007
+ doWatch(state);
1688
2008
  break;
1689
2009
  case 'e':
1690
- enterExecInline(state);
2010
+ if (!isMulti)
2011
+ enterExecInline(state);
1691
2012
  break;
1692
2013
  case 'x':
1693
- enterExec(state);
2014
+ if (!isMulti)
2015
+ enterExec(state);
1694
2016
  break;
1695
2017
  case 's': {
1696
- const sEntry = (0, state_1.selectedEntry)(state);
1697
- if (sEntry) {
1698
- const sSk = (0, state_1.statusKey)(sEntry.file, sEntry.service);
1699
- const sSt = state.statuses.get(sSk);
1700
- if (sSt && sSt.state === 'running') {
1701
- doRestart(state);
2018
+ if (isMulti) {
2019
+ for (const mSk of state.multiSelected) {
2020
+ const mEntry = state.flatList.find(e => statusKey(e.file, e.service) === mSk);
2021
+ if (!mEntry)
2022
+ continue;
2023
+ const mSt = state.statuses.get(mSk);
2024
+ if (state.restarting.has(mSk) || state.starting.has(mSk))
2025
+ continue;
2026
+ if (mSt && mSt.state === 'running') {
2027
+ doRestartEntry(state, mEntry);
2028
+ }
2029
+ else {
2030
+ doStartEntry(state, mEntry);
2031
+ }
1702
2032
  }
1703
- else {
1704
- doStart(state);
2033
+ render(state);
2034
+ }
2035
+ else {
2036
+ const sEntry = selectedEntry(state);
2037
+ if (sEntry) {
2038
+ const sSk = statusKey(sEntry.file, sEntry.service);
2039
+ if (state.restarting.has(sSk)) {
2040
+ state.restarting.get(sSk).kill('SIGTERM');
2041
+ state.restarting.delete(sSk);
2042
+ const sInfo = state.bottomLogLines.get(sSk);
2043
+ if (sInfo)
2044
+ sInfo.action = 'restart_failed';
2045
+ pollStatuses(state);
2046
+ render(state);
2047
+ }
2048
+ else if (state.starting.has(sSk)) {
2049
+ state.starting.get(sSk).kill('SIGTERM');
2050
+ state.starting.delete(sSk);
2051
+ const sInfo = state.bottomLogLines.get(sSk);
2052
+ if (sInfo)
2053
+ sInfo.action = 'start_failed';
2054
+ pollStatuses(state);
2055
+ render(state);
2056
+ }
2057
+ else {
2058
+ const sSt = state.statuses.get(sSk);
2059
+ if (sSt && sSt.state === 'running') {
2060
+ doRestart(state);
2061
+ }
2062
+ else {
2063
+ doStart(state);
2064
+ }
2065
+ }
1705
2066
  }
1706
2067
  }
1707
2068
  break;
1708
2069
  }
1709
2070
  case 'p':
1710
- doStop(state);
2071
+ if (isMulti) {
2072
+ for (const mSk of state.multiSelected) {
2073
+ const mEntry = state.flatList.find(e => statusKey(e.file, e.service) === mSk);
2074
+ if (mEntry && !state.stopping.has(mSk)) {
2075
+ doStopEntry(state, mEntry);
2076
+ }
2077
+ }
2078
+ render(state);
2079
+ }
2080
+ else {
2081
+ doStop(state);
2082
+ }
1711
2083
  break;
1712
2084
  case 'n':
1713
2085
  state.noCache = !state.noCache;
@@ -1719,14 +2091,22 @@ function handleKeypress(state, key) {
1719
2091
  break;
1720
2092
  case 'f':
1721
2093
  case '\r':
1722
- enterLogs(state);
2094
+ if (!isMulti)
2095
+ enterLogs(state);
1723
2096
  break;
1724
2097
  case 'l':
1725
- state.showBottomLogs = !state.showBottomLogs;
1726
- render(state);
2098
+ if (!isMulti) {
2099
+ state.showBottomLogs = !state.showBottomLogs;
2100
+ render(state);
2101
+ }
1727
2102
  break;
1728
2103
  case 't':
1729
- openWorktreePicker(state);
2104
+ if (isMulti) {
2105
+ openWorktreePickerMulti(state);
2106
+ }
2107
+ else {
2108
+ openWorktreePicker(state);
2109
+ }
1730
2110
  break;
1731
2111
  case 'q':
1732
2112
  cleanup(state);
@@ -1734,22 +2114,27 @@ function handleKeypress(state, key) {
1734
2114
  break;
1735
2115
  case 'G':
1736
2116
  state.cursor = state.flatList.length - 1;
1737
- updateSelectedLogs(state);
2117
+ if (!isMulti)
2118
+ updateSelectedLogs(state);
1738
2119
  render(state);
1739
2120
  break;
1740
2121
  case 'g':
1741
2122
  break;
1742
2123
  case '/':
1743
- if (state.showBottomLogs) {
2124
+ if (!isMulti && state.showBottomLogs) {
1744
2125
  state.bottomSearchActive = true;
1745
2126
  state.bottomSearchQuery = '';
1746
2127
  render(state);
1747
2128
  }
1748
2129
  break;
2130
+ default:
2131
+ if (!isMulti)
2132
+ executeCustomAction(state, key);
2133
+ break;
1749
2134
  }
1750
2135
  }
1751
2136
  // --- Arrow key sequence buffering ---
1752
- function createInputHandler(state) {
2137
+ export function createInputHandler(state) {
1753
2138
  let buf = '';
1754
2139
  let gPending = false;
1755
2140
  return function onData(data) {
@@ -1781,19 +2166,19 @@ function createInputHandler(state) {
1781
2166
  }
1782
2167
  const ch = buf[0];
1783
2168
  buf = buf.slice(1);
1784
- if (state.logSearchActive || state.bottomSearchActive || state.worktreePickerActive || state.mode === state_1.MODE.EXEC || state.execActive) {
2169
+ if (state.logSearchActive || state.bottomSearchActive || state.worktreePickerActive || state.mode === MODE.EXEC || state.execActive) {
1785
2170
  handleKeypress(state, ch);
1786
2171
  continue;
1787
2172
  }
1788
2173
  if (ch === 'g') {
1789
2174
  if (gPending) {
1790
2175
  gPending = false;
1791
- if (state.mode === state_1.MODE.LIST) {
2176
+ if (state.mode === MODE.LIST) {
1792
2177
  state.cursor = 0;
1793
2178
  state.scrollOffset = 0;
1794
2179
  updateSelectedLogs(state);
1795
2180
  }
1796
- else if (state.mode === state_1.MODE.LOGS) {
2181
+ else if (state.mode === MODE.LOGS) {
1797
2182
  state.logAutoScroll = false;
1798
2183
  const ggRows = process.stdout.rows ?? 24;
1799
2184
  const ggAvailable = Math.max(1, ggRows - 9);
@@ -1821,7 +2206,7 @@ function createInputHandler(state) {
1821
2206
  };
1822
2207
  }
1823
2208
  // --- Cleanup ---
1824
- function cleanup(state) {
2209
+ export function cleanup(state) {
1825
2210
  if (state.logChild) {
1826
2211
  state.logChild.kill('SIGTERM');
1827
2212
  state.logChild = null;
@@ -1872,6 +2257,11 @@ function cleanup(state) {
1872
2257
  clearTimeout(moduleState.pendingRender);
1873
2258
  moduleState.pendingRender = null;
1874
2259
  }
2260
+ stopBottomLogLoadingAnim(state);
2261
+ if (state.animTimer) {
2262
+ clearInterval(state.animTimer);
2263
+ state.animTimer = null;
2264
+ }
1875
2265
  if (state.logScanTimer) {
1876
2266
  clearInterval(state.logScanTimer);
1877
2267
  }
@@ -1881,13 +2271,19 @@ function cleanup(state) {
1881
2271
  if (state.statsTimer) {
1882
2272
  clearInterval(state.statsTimer);
1883
2273
  }
1884
- process.stdout.write('\x1b[r' + (0, renderer_1.showCursor)() + '\x1b[0m\x1b[?1049l');
2274
+ // Only write raw ANSI cleanup if Ink is not managing the terminal
2275
+ if (!state._inkRender) {
2276
+ process.stdout.write('\x1b[r' + showCursor() + '\x1b[0m\x1b[?1049l');
2277
+ }
2278
+ else {
2279
+ process.stdout.write('\x1b[?1049l');
2280
+ }
1885
2281
  }
1886
2282
  // Expose for testing
1887
- function _getModuleState() {
2283
+ export function _getModuleState() {
1888
2284
  return moduleState;
1889
2285
  }
1890
- function _setModuleState(ms) {
2286
+ export function _setModuleState(ms) {
1891
2287
  moduleState = ms;
1892
2288
  }
1893
2289
  // --- Main ---
@@ -1895,46 +2291,70 @@ async function main() {
1895
2291
  // Enter alternate screen buffer so pre-launch output (e.g. npx install) is hidden
1896
2292
  process.stdout.write('\x1b[?1049h');
1897
2293
  const config = loadConfig();
1898
- const state = (0, state_1.createState)(config);
2294
+ const state = createState(config);
1899
2295
  state.groups = discoverServices(config);
1900
- state.flatList = (0, state_1.buildFlatList)(state.groups);
2296
+ state.flatList = buildFlatList(state.groups);
1901
2297
  if (state.flatList.length === 0) {
1902
2298
  process.stderr.write('No services found in any compose file.\n');
1903
2299
  process.exit(1);
1904
2300
  }
1905
2301
  pollStatuses(state);
1906
2302
  initDepGraphs(state);
1907
- if (process.stdin.isTTY) {
1908
- process.stdin.setRawMode(true);
1909
- }
1910
- process.stdin.resume();
1911
- process.stdin.setEncoding('utf8');
1912
- const themeMode = await (0, theme_1.detectTheme)(config.theme);
1913
- (0, theme_1.setActivePalette)((0, theme_1.getPalette)(themeMode));
1914
- process.stdin.on('data', createInputHandler(state));
2303
+ const themeMode = await detectTheme(config.theme);
2304
+ setActivePalette(getPalette(themeMode));
2305
+ // Load Ink and React
2306
+ const inkModule = await import('ink');
2307
+ React = await import('react');
2308
+ const appModule = await import('./components/App.js');
2309
+ AppComponent = appModule.App;
2310
+ inkRenderFn = inkModule.render;
2311
+ // Handle keypresses through the imperative handleKeypress + gg logic
2312
+ const onKeypress = (key) => {
2313
+ if (key === 'gg') {
2314
+ if (state.mode === MODE.LIST) {
2315
+ state.cursor = 0;
2316
+ state.scrollOffset = 0;
2317
+ updateSelectedLogs(state);
2318
+ }
2319
+ else if (state.mode === MODE.LOGS) {
2320
+ state.logAutoScroll = false;
2321
+ const ggRows = process.stdout.rows ?? 24;
2322
+ const ggAvailable = Math.max(1, ggRows - 9);
2323
+ state.logScrollOffset = Math.max(0, state.logLines.length - ggAvailable);
2324
+ if (!state.logHistoryLoaded) {
2325
+ state.logFetchedTailCount = 5000;
2326
+ loadMoreLogHistory(state);
2327
+ }
2328
+ }
2329
+ render(state);
2330
+ return;
2331
+ }
2332
+ handleKeypress(state, key);
2333
+ };
2334
+ // Render with Ink
2335
+ const inkInstance = inkRenderFn(React.createElement(AppComponent, { state, themeMode, onKeypress }), {
2336
+ exitOnCtrlC: false,
2337
+ patchConsole: false,
2338
+ });
1915
2339
  pollLogCounts(state);
1916
2340
  updateSelectedLogs(state);
1917
- render(state);
2341
+ ensureAnimTimer(state);
1918
2342
  state.pollTimer = setInterval(() => {
1919
- if (state.mode === state_1.MODE.LIST) {
2343
+ if (state.mode === MODE.LIST) {
1920
2344
  pollStatuses(state);
1921
- render(state);
1922
2345
  }
1923
2346
  }, config.pollInterval);
1924
2347
  state.logScanTimer = setInterval(() => {
1925
- if (state.mode === state_1.MODE.LIST) {
2348
+ if (state.mode === MODE.LIST) {
1926
2349
  pollLogCounts(state);
1927
2350
  }
1928
2351
  }, config.logScanInterval || 10000);
1929
2352
  pollContainerStats(state);
1930
2353
  state.statsTimer = setInterval(() => {
1931
- if (state.mode === state_1.MODE.LIST) {
2354
+ if (state.mode === MODE.LIST) {
1932
2355
  pollContainerStats(state);
1933
2356
  }
1934
2357
  }, config.statsInterval || 5000);
1935
- process.stdout.on('resize', () => {
1936
- render(state);
1937
- });
1938
2358
  process.on('exit', () => cleanup(state));
1939
2359
  process.on('SIGINT', () => {
1940
2360
  cleanup(state);
@@ -1946,7 +2366,9 @@ async function main() {
1946
2366
  });
1947
2367
  }
1948
2368
  // Only run main when executed directly (not when imported for testing)
1949
- if (require.main === module) {
2369
+ const __filename = fileURLToPath(import.meta.url);
2370
+ const __argv1 = process.argv[1] ? fs.realpathSync(path.resolve(process.argv[1])) : '';
2371
+ if (__argv1 === __filename) {
1950
2372
  main().catch((err) => {
1951
2373
  process.stderr.write(`${err}\n`);
1952
2374
  process.exit(1);