reslop 0.3.0 → 0.3.2

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 (74) hide show
  1. package/CHANGELOG.md +29 -1
  2. package/README.md +21 -3
  3. package/lib/agents/acp.js +309 -0
  4. package/lib/agents/agents.js +166 -370
  5. package/lib/agents/allow.js +141 -52
  6. package/lib/agents/allow.json +16 -13
  7. package/lib/agents/models.js +250 -376
  8. package/lib/agents/registry.js +437 -0
  9. package/lib/agents/run.js +234 -0
  10. package/lib/agents/transcript.js +143 -0
  11. package/lib/cli.js +2 -1
  12. package/lib/common/process.js +26 -1
  13. package/lib/dashboard/git.js +11 -1
  14. package/lib/dashboard/reports.js +80 -0
  15. package/lib/dashboard/watch.js +43 -8
  16. package/lib/dashboard/workspace.js +19 -20
  17. package/lib/dashboard/worktrees.js +145 -0
  18. package/lib/git/deps.js +29 -0
  19. package/lib/git/dir.js +50 -0
  20. package/lib/git/git.js +1 -1
  21. package/lib/git/linked.js +152 -0
  22. package/lib/git/local.js +179 -0
  23. package/lib/git/status.js +126 -0
  24. package/lib/git/worktree.js +137 -31
  25. package/lib/input/actions.js +83 -2
  26. package/lib/input/editor.js +9 -0
  27. package/lib/input/keys.js +39 -3
  28. package/lib/input/line-edit.js +8 -3
  29. package/lib/render/agents.js +64 -36
  30. package/lib/render/branches.js +55 -27
  31. package/lib/render/field.js +19 -5
  32. package/lib/render/footer.js +14 -4
  33. package/lib/render/frame.js +16 -2
  34. package/lib/render/log.js +5 -4
  35. package/lib/render/menu.js +103 -6
  36. package/lib/render/name-edit.js +31 -0
  37. package/lib/render/repos.js +52 -8
  38. package/lib/render/status.js +22 -4
  39. package/lib/report/render.js +4 -7
  40. package/lib/report/run.js +16 -23
  41. package/lib/review/agent-instructions.md +13 -8
  42. package/lib/review/format.js +0 -6
  43. package/lib/runs/process.js +3 -3
  44. package/lib/runs/runs.js +27 -4
  45. package/lib/session/agents/agents.js +227 -194
  46. package/lib/session/agents/away.js +48 -0
  47. package/lib/session/agents/job.js +23 -86
  48. package/lib/session/agents/jobs.js +14 -27
  49. package/lib/session/agents/options.js +45 -101
  50. package/lib/session/agents/plans.js +53 -0
  51. package/lib/session/agents/view.js +34 -0
  52. package/lib/session/branches.js +18 -2
  53. package/lib/session/compose.js +20 -6
  54. package/lib/session/dispatch.js +43 -3
  55. package/lib/session/editor-pointer.js +98 -0
  56. package/lib/session/files-pane.js +3 -0
  57. package/lib/session/import.js +12 -1
  58. package/lib/session/lifecycle.js +70 -10
  59. package/lib/session/navigation.js +5 -0
  60. package/lib/session/npm/history.js +3 -1
  61. package/lib/session/npm/npm.js +111 -13
  62. package/lib/session/ops.js +3 -0
  63. package/lib/session/pointer.js +304 -46
  64. package/lib/session/session.js +17 -2
  65. package/lib/session/tasks.js +1 -1
  66. package/lib/session/terminal.js +2 -2
  67. package/lib/session/text-menu.js +50 -0
  68. package/lib/session/view.js +19 -2
  69. package/lib/session/workspace.js +179 -38
  70. package/lib/session/worktrees.js +280 -0
  71. package/lib/term/select.js +2 -1
  72. package/lib/term/themes/dark.json +4 -0
  73. package/lib/term/themes/light.json +4 -0
  74. package/package.json +2 -2
@@ -27,6 +27,7 @@ const dispatch = require('./dispatch.js');
27
27
  const { dispatchAction, confirmChoice, handleEvent } = dispatch;
28
28
  const { Dashboard } = require('./dashboard.js');
29
29
  const { Workspace } = require('./workspace.js');
30
+ const { WorktreeController } = require('./worktrees.js');
30
31
 
31
32
  const bindAccessors = (target, owner, names) => {
32
33
  for (const name of names) {
@@ -134,6 +135,9 @@ const assignParts = (session, options) => {
134
135
  session.agents = new AgentsController(session);
135
136
  session.dashboard = new Dashboard(session);
136
137
  session.workspace = new Workspace(session);
138
+ const infoOf = () => session.treesInfo;
139
+ session.trees = new Workspace(session, { pane: 'branches', infoOf });
140
+ session.worktrees = new WorktreeController(session);
137
141
  session.lifecycle = new Lifecycle(session);
138
142
  };
139
143
 
@@ -181,8 +185,12 @@ class Session {
181
185
  this.agents.reset();
182
186
  this.dashboard.reset();
183
187
  this.workspace.reset();
188
+ this.worktrees.reset();
189
+ this.trees.reset();
190
+ this.treesInfo = null;
184
191
  this.top = this.cwd;
185
192
  this.branch = '';
193
+ this.repositoryUrl = '';
186
194
  this.status = '';
187
195
  this.layout = 'unified';
188
196
  this.lineNumbers = false;
@@ -261,7 +269,8 @@ class Session {
261
269
  panel.runScroll = frame.runScroll;
262
270
  }
263
271
  if (typeof frame.gridCols === 'number') {
264
- this.workspace.cols = frame.gridCols;
272
+ const tiles = this.nav.pane === 'branches' ? this.trees : this.workspace;
273
+ tiles.cols = frame.gridCols;
265
274
  }
266
275
  if (this.nav.tasksOpen) this.nav.scroll = frame.scroll;
267
276
  const editScroll = frame.cursor && frame.cursor.scroll;
@@ -273,6 +282,7 @@ class Session {
273
282
  this.scheduleAutosave();
274
283
  this.dashboard.afterDraw();
275
284
  this.workspace.afterDraw();
285
+ this.trees.afterDraw();
276
286
  }
277
287
 
278
288
  scheduleAutosave() {
@@ -363,6 +373,7 @@ class Session {
363
373
  if (!result) return;
364
374
  if (result.kind === 'commit') this.commits.finishGitCommit();
365
375
  if (result.kind === 'branch') this.gitBranches.finishCreateBranch();
376
+ if (result.kind === 'worktree') this.worktrees.finishAdd();
366
377
  if (result.kind === 'npm') this.npm.finishEdit();
367
378
  if (result.kind === 'package') this.packages.finishAdd();
368
379
  }
@@ -384,6 +395,7 @@ class Session {
384
395
  this.paths = [];
385
396
  this.repoName = repoName;
386
397
  this.branch = '';
398
+ this.repositoryUrl = '';
387
399
  this.rev = null;
388
400
  this.revShort = '';
389
401
  this.change = null;
@@ -391,6 +403,8 @@ class Session {
391
403
  this.collection.reset();
392
404
  this.reads.reset();
393
405
  this.review.reset();
406
+ this.npm.retarget();
407
+ this.agents.retarget();
394
408
  this.nav.pane = pane;
395
409
  this.nav.tasksOpen = false;
396
410
  this.nav.scroll = 0;
@@ -489,7 +503,7 @@ class Session {
489
503
  this.openLoad();
490
504
  // Populate the agent model cache while the initial screen is loading.
491
505
  // Opening the Agents pane can then use the cache instead of being the
492
- // first place that starts its CLI probes.
506
+ // first place that asks each agent for its models.
493
507
  void this.agents.loadModels();
494
508
  this.openUpdate();
495
509
  this.draw();
@@ -518,6 +532,7 @@ class Session {
518
532
  this.lifecycle.stopWatch();
519
533
  this.dashboard.stop();
520
534
  this.workspace.stop();
535
+ this.trees.stop();
521
536
  this.term.stopListening();
522
537
  this.progress.clear();
523
538
  this.term.clearTimers();
@@ -430,7 +430,7 @@ class TaskPage {
430
430
  taskEditView() {
431
431
  const { composeKind, editor } = this.composer;
432
432
  if (composeKind !== 'tasks' || !editor) return null;
433
- return { cursor: editor.cursor };
433
+ return { cursor: editor.cursor, anchor: editor.anchor };
434
434
  }
435
435
 
436
436
  commitTask(text) {
@@ -32,9 +32,9 @@ const watchResize = (stdout, proc, onResize, signal) => {
32
32
  return stop;
33
33
  };
34
34
 
35
- const ENTER_TERM = '\x1b[?1049h\x1b[?25l\x1b[?7l\x1b[?1002h\x1b[?1006h';
35
+ const ENTER_TERM = '\x1b[?1049h\x1b[?25l\x1b[?7l\x1b[?1003h\x1b[?1006h';
36
36
  const LEAVE_TERM =
37
- '\x1b[?1006l\x1b[?1002l\x1b[?7h\x1b[0 q\x1b[?12l\x1b[?25h\x1b[?1049l';
37
+ '\x1b[?1006l\x1b[?1003l\x1b[?7h\x1b[0 q\x1b[?12l\x1b[?25h\x1b[?1049l';
38
38
 
39
39
  const sameCursor = (a, b) => {
40
40
  if (!a || !b) return !a && !b;
@@ -0,0 +1,50 @@
1
+ 'use strict';
2
+
3
+ const TEXT_ACTIONS = [
4
+ 'textCut',
5
+ 'textCopy',
6
+ 'textPaste',
7
+ 'textDelete',
8
+ 'textSelectAll',
9
+ ];
10
+
11
+ const textEditor = (ui, cell) => {
12
+ if (ui.mode === 'compose') return ui.editor;
13
+ const statusY = ui.lastFrame?.height - 1;
14
+ if (cell.y !== statusY) return null;
15
+ if (ui.mode === 'find') return ui.nav.find?.editor;
16
+ if (ui.mode === 'import') return ui.nav.import?.editor;
17
+ return null;
18
+ };
19
+
20
+ const textDisabled = (editor) => {
21
+ const off = editor.hasSelect() ? [] : ['textCut', 'textCopy'];
22
+ if (!editor.hasSelect() && editor.cursor >= editor.text.length) {
23
+ off.push('textDelete');
24
+ }
25
+ if (!editor.text) off.push('textSelectAll');
26
+ return off;
27
+ };
28
+
29
+ const TEXT_KEYS = {
30
+ textCut: 'ctrl-x',
31
+ textCopy: 'ctrl-c',
32
+ textPaste: 'ctrl-v',
33
+ textDelete: 'delete',
34
+ };
35
+
36
+ const runTextAction = (ui, menu, action) => {
37
+ const editor = menu.editor;
38
+ if (!editor) return false;
39
+ if (action === 'textSelectAll') {
40
+ editor.anchor = 0;
41
+ editor.cursor = editor.text.length;
42
+ if (ui.mode === 'compose') ui.composer.afterEdit();
43
+ } else {
44
+ const key = TEXT_KEYS[action];
45
+ if (key) ui.handleEvent({ type: 'key', key });
46
+ }
47
+ return true;
48
+ };
49
+
50
+ module.exports = { TEXT_ACTIONS, textEditor, textDisabled, runTextAction };
@@ -137,6 +137,7 @@ const navView = (ui) => {
137
137
  scroll: nav.scroll,
138
138
  listScroll: nav.listScroll,
139
139
  selection: nav.selection,
140
+ contextMenu: nav.contextMenu,
140
141
  pane: nav.pane,
141
142
  fileScope: nav.fileScope,
142
143
  fileCursor: nav.fileCursor,
@@ -151,9 +152,22 @@ const navView = (ui) => {
151
152
  };
152
153
 
153
154
  const dropNameOf = (ui) => {
154
- const { packages, gitBranches, commits, npm } = ui;
155
+ const { packages, gitBranches, commits, npm, worktrees } = ui;
155
156
  const name = packages.dropName || gitBranches.dropName;
156
- return name || commits.dropName || npm.dropName;
157
+ return name || commits.dropName || npm.dropName || worktrees.dropName;
158
+ };
159
+
160
+ // One tile looks selected: the one under the cursor while the worktrees have
161
+ // the focus, else the worktree that is shown
162
+ const treesView = (ui) => {
163
+ const model = ui.trees.view();
164
+ const picked = ui.worktrees.focused() ? model.cursor : model.current;
165
+ return { ...model, cursor: picked < 0 ? model.cursor : picked };
166
+ };
167
+
168
+ const paneTrees = (ui, pane) => {
169
+ if (pane !== 'branches' || !ui.worktrees.hasPanels()) return null;
170
+ return treesView(ui);
157
171
  };
158
172
 
159
173
  const repoView = (ui) => {
@@ -180,6 +194,7 @@ const repoView = (ui) => {
180
194
  updateFrom: updater.from,
181
195
  updateTo: updater.to,
182
196
  dropName: dropNameOf(ui),
197
+ worktreeTarget: ui.worktrees.branchTarget,
183
198
  };
184
199
  };
185
200
 
@@ -200,6 +215,8 @@ const screenView = (ui) => {
200
215
  dashboard: pane === 'dashboard' ? ui.dashboard.view() : null,
201
216
  packages: pane === 'packages' ? ui.dashboard.packagesView() : null,
202
217
  repos: pane === 'repos' ? ui.workspace.view() : null,
218
+ trees: paneTrees(ui, pane),
219
+ treeFocus: ui.worktrees.focused(),
203
220
  };
204
221
  };
205
222
 
@@ -1,20 +1,24 @@
1
1
  'use strict';
2
2
 
3
+ const fs = require('node:fs');
3
4
  const path = require('node:path');
4
5
  const { REVIEW_DIR } = require('../common/files.js');
5
6
  const { FileIndex, HEAT_MS } = require('../dashboard/tree.js');
6
7
  const { UNKNOWN_PATH } = require('../dashboard/watch.js');
7
8
  const workspace = require('../dashboard/workspace.js');
8
9
  const { emptyDiff, countCommits, readDiffStat, taskStats, pickRun } = workspace;
10
+ const { currentBranchName } = workspace;
9
11
  const { readNpmSummary } = require('../dashboard/npm.js');
10
- const { collectReport } = require('../git/deps.js');
12
+ const { reportCounts } = require('../dashboard/reports.js');
13
+ const { dirKey, changedFiles } = require('../git/linked.js');
14
+ const { resolveGitDirs } = require('../git/dir.js');
11
15
 
12
16
  const TICK_ID = 'repos';
13
- const HEAT_TIMER = 'repos-heat';
14
17
 
15
18
  const freshBag = () => ({
16
19
  files: [],
17
20
  git: false,
21
+ diff: false,
18
22
  runs: false,
19
23
  tasks: false,
20
24
  npm: false,
@@ -24,6 +28,7 @@ const freshBag = () => ({
24
28
  const fullBag = () => ({
25
29
  files: [UNKNOWN_PATH],
26
30
  git: true,
31
+ diff: true,
27
32
  runs: true,
28
33
  tasks: true,
29
34
  npm: true,
@@ -35,6 +40,7 @@ const mergeBag = (left, right) => {
35
40
  return {
36
41
  files: [...base.files, ...right.files],
37
42
  git: base.git || right.git,
43
+ diff: base.diff || right.diff,
38
44
  runs: base.runs || right.runs,
39
45
  tasks: base.tasks || right.tasks,
40
46
  npm: base.npm || right.npm,
@@ -42,9 +48,37 @@ const mergeBag = (left, right) => {
42
48
  };
43
49
  };
44
50
 
45
- const blankRow = (repo) => ({
51
+ // The path inside of a directory, or null for one outside of it
52
+ const insideOf = (base, full) => {
53
+ const rel = path.relative(base, full);
54
+ const out = rel === '..' || rel.startsWith(`..${path.sep}`);
55
+ if (out || path.isAbsolute(rel)) return null;
56
+ return rel.split(path.sep).join('/');
57
+ };
58
+
59
+ const gitDirKey = (dir) => {
60
+ const dirs = resolveGitDirs(dir);
61
+ return dirs ? dirKey(dirs.gitDir) : '';
62
+ };
63
+
64
+ // What a list of worktrees says of a repository
65
+ const rowMeta = (repo) => ({
46
66
  name: repo.name,
67
+ group: repo.group ?? repo.name,
68
+ main: repo.main ?? true,
69
+ linked: repo.linked ?? false,
70
+ branch: repo.branch ?? '',
71
+ head: repo.head ?? '',
72
+ detached: repo.detached ?? false,
73
+ locked: repo.locked ?? false,
74
+ });
75
+
76
+ const blankRow = (repo) => ({
77
+ ...rowMeta(repo),
47
78
  dir: repo.dir,
79
+ key: dirKey(repo.dir),
80
+ gitDir: gitDirKey(repo.dir),
81
+ dirty: false,
48
82
  index: new FileIndex(repo.dir),
49
83
  bytes: 0,
50
84
  exts: [],
@@ -78,12 +112,23 @@ const readFiles = async (row, bag) => {
78
112
  };
79
113
 
80
114
  const readCommits = async (row) => {
81
- row.commits = await countCommits(row.dir);
115
+ const [commits, branch] = await Promise.all([
116
+ countCommits(row.dir),
117
+ currentBranchName(row.dir),
118
+ ]);
119
+ row.commits = commits;
120
+ row.branch = branch;
121
+ row.detached = !branch;
82
122
  row.commitsReady = true;
83
123
  };
84
124
 
85
125
  const readDiff = async (row) => {
86
- row.diff = await readDiffStat(row.dir);
126
+ const [diff, changed] = await Promise.all([
127
+ readDiffStat(row.dir),
128
+ changedFiles(row.dir).catch(() => []),
129
+ ]);
130
+ row.diff = diff;
131
+ row.dirty = changed.length > 0;
87
132
  row.diffReady = true;
88
133
  };
89
134
 
@@ -99,24 +144,26 @@ const readRuns = (row) => {
99
144
  row.runsReady = true;
100
145
  };
101
146
 
102
- const reportCount = async (dir, kind) => {
103
- const report = await collectReport(dir, kind);
104
- return report ? report.size : 0;
105
- };
106
-
107
147
  const readNpm = async (row) => {
108
- const summary = await readNpmSummary(row.dir, row.npm);
109
- const [audit, outdated] = await Promise.all([
110
- reportCount(row.dir, 'audit'),
111
- reportCount(row.dir, 'outdated'),
148
+ const [summary, counts] = await Promise.all([
149
+ readNpmSummary(row.dir, row.npm),
150
+ reportCounts(row.dir),
112
151
  ]);
113
- row.npm = { ...summary, audit, outdated };
152
+ row.npm = { ...summary, ...counts };
114
153
  row.npmReady = true;
115
154
  };
116
155
 
117
- const toTile = (row) => ({
156
+ const toTile = (row, live) => ({
118
157
  id: row.name,
119
158
  name: row.name,
159
+ group: row.group,
160
+ linked: row.linked,
161
+ branch: row.branch,
162
+ detached: row.detached,
163
+ head: row.head,
164
+ locked: row.locked,
165
+ dirty: row.dirty,
166
+ agents: live.get(row.key) ?? 0,
120
167
  bytes: row.bytes,
121
168
  exts: row.exts,
122
169
  tasks: row.tasks,
@@ -171,13 +218,20 @@ const classifyRest = (rest) => {
171
218
  return bag;
172
219
  }
173
220
  bag.files.push(rest);
221
+ bag.diff = true;
174
222
  bag.activity = true;
175
223
  return bag;
176
224
  };
177
225
 
226
+ // The tiles of a folder on the repos screen, and the ones of the worktrees
227
+ // of a repository above the branches
178
228
  class Workspace {
179
- constructor(ui) {
229
+ constructor(ui, options = {}) {
180
230
  this.ui = ui;
231
+ this.pane = options.pane ?? 'repos';
232
+ this.tick = this.pane === 'repos' ? TICK_ID : `${TICK_ID}-${this.pane}`;
233
+ this.heat = `${this.tick}-heat`;
234
+ this.infoOf = options.infoOf ?? (() => this.ui.workspaceInfo);
181
235
  this.epoch = 0;
182
236
  this.reset();
183
237
  }
@@ -190,14 +244,46 @@ class Workspace {
190
244
  this.cols = 1;
191
245
  }
192
246
 
247
+ // Whether the tiles are on the screen
248
+ shown() {
249
+ return this.ui.uiOpen && this.ui.nav.pane === this.pane;
250
+ }
251
+
193
252
  seed() {
194
- const info = this.ui.workspaceInfo;
253
+ const info = this.infoOf();
195
254
  const repos = info ? info.repos : [];
196
255
  this.rows = repos.map(blankRow);
197
256
  this.byName = new Map(this.rows.map((row) => [row.name, row]));
198
257
  if (this.cursor >= this.rows.length) this.cursor = 0;
199
258
  }
200
259
 
260
+ // The list of worktrees changed: what is known of the rest is kept
261
+ adopt(repos) {
262
+ const focus = this.rows[this.cursor] ? this.rows[this.cursor].key : '';
263
+ const known = new Map(this.rows.map((row) => [row.key, row]));
264
+ const fresh = [];
265
+ this.rows = repos.map((repo) => {
266
+ const prev = known.get(dirKey(repo.dir));
267
+ if (!prev) {
268
+ const row = blankRow(repo);
269
+ fresh.push(row);
270
+ return row;
271
+ }
272
+ return Object.assign(prev, rowMeta(repo));
273
+ });
274
+ this.byName = new Map(this.rows.map((row) => [row.name, row]));
275
+ const at = this.rows.findIndex((row) => row.key === focus);
276
+ this.cursor = at < 0 ? Math.min(this.cursor, this.rows.length - 1) : at;
277
+ this.cursor = Math.max(0, this.cursor);
278
+ for (const row of fresh) this.kick(row, fullBag());
279
+ }
280
+
281
+ focusDir(dir) {
282
+ const key = dirKey(dir);
283
+ const at = this.rows.findIndex((row) => row.key === key);
284
+ if (at >= 0) this.cursor = at;
285
+ }
286
+
201
287
  stale(epoch) {
202
288
  return epoch !== this.epoch || !this.active;
203
289
  }
@@ -205,7 +291,7 @@ class Workspace {
205
291
  publish(epoch) {
206
292
  if (this.stale(epoch)) return;
207
293
  this.afterDraw();
208
- if (this.ui.uiOpen && this.ui.nav.pane === 'repos') this.ui.paint();
294
+ if (this.shown()) this.ui.paint();
209
295
  }
210
296
 
211
297
  async settle(epoch, read) {
@@ -219,8 +305,8 @@ class Workspace {
219
305
  if (bag.files.length) {
220
306
  jobs.push(this.settle(epoch, () => readFiles(row, bag)));
221
307
  }
222
- if (bag.git) {
223
- jobs.push(this.settle(epoch, () => readCommits(row)));
308
+ if (bag.git) jobs.push(this.settle(epoch, () => readCommits(row)));
309
+ if (bag.git || bag.diff) {
224
310
  jobs.push(this.settle(epoch, () => readDiff(row)));
225
311
  }
226
312
  if (bag.tasks) jobs.push(this.settle(epoch, () => readTasks(row)));
@@ -242,7 +328,7 @@ class Workspace {
242
328
  }
243
329
  if (this.stale(epoch)) break;
244
330
  this.afterDraw();
245
- if (this.ui.uiOpen && this.ui.nav.pane === 'repos') this.ui.paint();
331
+ if (this.shown()) this.ui.paint();
246
332
  }
247
333
  row.running = false;
248
334
  if (row.pending && this.active) {
@@ -250,7 +336,7 @@ class Workspace {
250
336
  return;
251
337
  }
252
338
  this.afterDraw();
253
- if (this.ui.uiOpen && this.ui.nav.pane === 'repos') this.ui.paint();
339
+ if (this.shown()) this.ui.paint();
254
340
  }
255
341
 
256
342
  kick(row, bag) {
@@ -265,24 +351,65 @@ class Workspace {
265
351
  for (const row of this.rows) this.kick(row, fullBag());
266
352
  }
267
353
 
354
+ // Everything is read again, but npm only where there is no answer yet:
355
+ // the lists of packages change when their files do, and the watcher says so
356
+ refresh() {
357
+ for (const row of this.rows) {
358
+ this.kick(row, { ...fullBag(), npm: !row.npmReady });
359
+ }
360
+ }
361
+
362
+ // The worktree that owns a path of the watcher, and the path inside it.
363
+ // A worktree can be anywhere, and the state of git of a linked one is
364
+ // in the directory of the main one
365
+ placeOf(rel, root) {
366
+ const full = path.resolve(root, rel);
367
+ let best = null;
368
+ for (const row of this.rows) {
369
+ for (const [base, lead] of [
370
+ [row.key, ''],
371
+ [row.gitDir, '.git/'],
372
+ ]) {
373
+ const inside = base ? insideOf(base, full) : null;
374
+ if (inside === null) continue;
375
+ if (best && base.length <= best.size) continue;
376
+ best = { row, rest: `${lead}${inside}`, size: base.length };
377
+ }
378
+ }
379
+ return best ?? this.placeByName(rel);
380
+ }
381
+
382
+ placeByName(rel) {
383
+ const slash = rel.indexOf('/');
384
+ const name = slash < 0 ? rel : rel.slice(0, slash);
385
+ const row = this.byName.get(name);
386
+ return row ? { row, rest: slash < 0 ? '' : rel.slice(slash + 1) } : null;
387
+ }
388
+
268
389
  notePaths(paths) {
269
390
  if (!this.active) return;
391
+ const missing = this.rows.some((row) => !fs.existsSync(row.dir));
392
+ if (missing) {
393
+ this.ui.ops.hold(this.ui.worktrees.reload());
394
+ return;
395
+ }
270
396
  for (const rel of paths) {
271
397
  if (rel === UNKNOWN_PATH) return void this.requestAll();
272
398
  }
273
399
  const found = new Map();
400
+ const info = this.infoOf();
401
+ const root = dirKey(info ? info.root : '.');
274
402
  for (const rel of paths) {
275
- const slash = rel.indexOf('/');
276
- const name = slash < 0 ? rel : rel.slice(0, slash);
277
- const rest = slash < 0 ? '' : rel.slice(slash + 1);
278
- const row = this.byName.get(name);
279
- if (!row) continue;
403
+ const place = this.placeOf(rel, root);
404
+ if (!place) continue;
405
+ const { row, rest } = place;
406
+ const name = row.name;
280
407
  found.set(name, mergeBag(found.get(name), classifyRest(rest)));
281
408
  }
282
409
  for (const [name, bag] of found) this.kick(this.byName.get(name), bag);
283
410
  if (found.size) {
284
411
  this.afterDraw();
285
- if (this.ui.uiOpen) this.ui.paint();
412
+ if (this.shown()) this.ui.paint();
286
413
  }
287
414
  }
288
415
 
@@ -302,8 +429,8 @@ class Workspace {
302
429
  cancelTimers() {
303
430
  const { progress, term } = this.ui;
304
431
  if (!progress || !term) return;
305
- progress.stop(TICK_ID);
306
- term.cancel(HEAT_TIMER);
432
+ progress.stop(this.tick);
433
+ term.cancel(this.heat);
307
434
  }
308
435
 
309
436
  hotUntil() {
@@ -316,32 +443,46 @@ class Workspace {
316
443
 
317
444
  afterDraw() {
318
445
  if (!this.active) return;
319
- const visible = this.ui.uiOpen && this.ui.nav.pane === 'repos';
320
- if (!visible) return void this.cancelTimers();
446
+ if (!this.shown()) return void this.cancelTimers();
321
447
  const now = Date.now();
322
448
  const until = this.hotUntil();
323
449
  const hot = now < until;
324
450
  const running = this.rows.some((row) => row.run?.status === 'running');
325
451
  const loading = this.rows.some((row) => rowBusy(row));
326
452
  const { progress, term } = this.ui;
327
- if (running || hot || loading) progress.start(TICK_ID);
328
- else progress.stop(TICK_ID);
453
+ if (running || hot || loading) progress.start(this.tick);
454
+ else progress.stop(this.tick);
329
455
  if (!hot) return;
330
456
  term.later(
331
- HEAT_TIMER,
457
+ this.heat,
332
458
  () => {
333
- if (this.ui.nav.pane === 'repos') this.ui.paint();
459
+ if (this.ui.nav.pane === this.pane) this.ui.paint();
334
460
  },
335
461
  until - now + 20,
336
462
  );
337
463
  }
338
464
 
465
+ liveAgents() {
466
+ const live = new Map();
467
+ const running = this.ui.agents ? this.ui.agents.liveByRoot() : new Map();
468
+ for (const [root, count] of running) live.set(dirKey(root), count);
469
+ return live;
470
+ }
471
+
472
+ // The index of the worktree that is shown, -1 for none of the tiles
473
+ currentIndex() {
474
+ const key = dirKey(this.ui.top || this.ui.cwd || '.');
475
+ return this.rows.findIndex((row) => row.key === key);
476
+ }
477
+
339
478
  view() {
479
+ const live = this.liveAgents();
340
480
  return {
341
481
  now: Date.now(),
342
482
  frame: this.ui.progressFrame,
343
483
  cursor: this.cursor,
344
- tiles: this.rows.map(toTile),
484
+ current: this.currentIndex(),
485
+ tiles: this.rows.map((row) => toTile(row, live)),
345
486
  };
346
487
  }
347
488