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
@@ -30,6 +30,8 @@ const { historyName, historyMark, countText, elapsedText } = historyPart;
30
30
  const { whenLabel, runLabel, logBase, applyExternal } = historyPart;
31
31
  const { recordedItem, sameWindow, noteTests, filteredOutput } = historyPart;
32
32
  const { size } = require('../../common/format.js');
33
+ const { TASKS_FILE } = require('../../common/files.js');
34
+ const { stripAnsi } = require('../../term/ansi.js');
33
35
 
34
36
  const NAME_RE = /^[\w:.-]+$/;
35
37
 
@@ -54,11 +56,30 @@ class NpmController extends RunPanel {
54
56
  this.reset();
55
57
  }
56
58
 
59
+ // Runs belong to the worktree they started in and go on while another
60
+ // one is shown
61
+ get runs() {
62
+ let list = this.byTop.get(this.ui.top);
63
+ if (!list) {
64
+ list = [];
65
+ this.byTop.set(this.ui.top, list);
66
+ }
67
+ return list;
68
+ }
69
+
70
+ set runs(list) {
71
+ this.byTop.set(this.ui.top, list);
72
+ }
73
+
74
+ everyRun() {
75
+ return [...this.byTop.values()].flat();
76
+ }
77
+
57
78
  reset() {
58
- for (const run of this.runs ?? []) {
79
+ for (const run of this.byTop ? this.everyRun() : []) {
59
80
  if (run.status === 'running' && run.child) run.child.kill();
60
81
  }
61
- this.runs = [];
82
+ this.byTop = new Map();
62
83
  this.nextId = 1;
63
84
  this.viewId = 0;
64
85
  this.focus = 'commands';
@@ -83,7 +104,28 @@ class NpmController extends RunPanel {
83
104
  }
84
105
 
85
106
  get running() {
86
- return this.runs.some((run) => run.status === 'running');
107
+ return this.everyRun().some((run) => run.status === 'running');
108
+ }
109
+
110
+ // The count of runs in work by the directory they started in
111
+ liveByRoot() {
112
+ const live = new Map();
113
+ for (const run of this.everyRun()) {
114
+ if (run.status !== 'running') continue;
115
+ live.set(run.top, (live.get(run.top) ?? 0) + 1);
116
+ }
117
+ return live;
118
+ }
119
+
120
+ // Another worktree is shown: its commands and runs replace these
121
+ retarget() {
122
+ this.viewing = false;
123
+ this.verbose = false;
124
+ this.viewId = 0;
125
+ this.runCursor = 0;
126
+ this.commands = [];
127
+ this.dropName = '';
128
+ this.logBytes = 0;
87
129
  }
88
130
 
89
131
  view(active) {
@@ -103,6 +145,7 @@ class NpmController extends RunPanel {
103
145
  npmDraftName: this.draftName,
104
146
  npmDraftCommand: this.draftCommand,
105
147
  npmRunning: this.viewedRunning(),
148
+ npmCanSolve: this.canSolve(),
106
149
  npmLogs: active ? this.logLabel : '',
107
150
  };
108
151
  }
@@ -173,6 +216,39 @@ class NpmController extends RunPanel {
173
216
  return Boolean(run && run.status === 'running');
174
217
  }
175
218
 
219
+ canSolve() {
220
+ const run = this.viewedRun();
221
+ if (!this.viewing || !run || run.status !== 'exited') return false;
222
+ return run.exit !== 0 && run.exit !== '0' && run.exit !== '';
223
+ }
224
+
225
+ solve() {
226
+ const ui = this.ui;
227
+ if (!this.canSolve() || !ui.review.store) return;
228
+ const run = this.viewedRun();
229
+ this.hydrate(run);
230
+ this.syncOutput(run);
231
+ const root = run.top ?? ui.top;
232
+ const output = stripAnsi(
233
+ run.raw
234
+ ? filteredOutput(run.raw, root, run.exit).output
235
+ : reduceOutput(run.output, root, ''),
236
+ ).trim();
237
+ const limit = 6000;
238
+ const shortened =
239
+ output.length > limit
240
+ ? `${output.slice(0, limit)}\n… (truncated)`
241
+ : output;
242
+ const command = run.command || run.label || runLabel(run);
243
+ const text = `${command} failed (exit ${run.exit})\n\n${shortened}`;
244
+ const task = ui.review.store.addTask(TASKS_FILE, text, false, 'bugs');
245
+ this.closeView();
246
+ const tasks = ui.composer.tasks;
247
+ tasks.openTasksPage();
248
+ tasks.followRow({ task });
249
+ ui.flushReview(true);
250
+ }
251
+
176
252
  historyRuns() {
177
253
  const runs = this.runs.filter((run) => !launchedReslop(run));
178
254
  return foldSteps(runs).slice(0, RUN_KEEP);
@@ -239,6 +315,7 @@ class NpmController extends RunPanel {
239
315
  const run = newRun(this.nextId, {
240
316
  name: item.name,
241
317
  kind: item.kind,
318
+ script: item.script ?? '',
242
319
  command: item.command ?? '',
243
320
  label: item.kind === 'command' ? historyName(item) : runLabel(item),
244
321
  status: item.status,
@@ -248,6 +325,7 @@ class NpmController extends RunPanel {
248
325
  logName: item.logName,
249
326
  rawName: item.rawName,
250
327
  externalId: item.externalId || '',
328
+ top: this.ui.top,
251
329
  result: item.result ?? null,
252
330
  progress: item.progress ?? null,
253
331
  saved: true,
@@ -321,6 +399,25 @@ class NpmController extends RunPanel {
321
399
  this.runs = [...done, ...running];
322
400
  }
323
401
 
402
+ followAdded(added) {
403
+ const current = this.viewedRun();
404
+ if (!this.viewing || !current?.externalId || !current.script) return false;
405
+ if (current.followEnd === false) return false;
406
+ const next = added
407
+ .filter(
408
+ (run) =>
409
+ run.script === current.script &&
410
+ run.startedAt >= current.startedAt &&
411
+ sameWindow(current, run),
412
+ )
413
+ .sort((left, right) => left.startedAt - right.startedAt)
414
+ .at(-1);
415
+ if (!next) return false;
416
+ this.viewId = next.id;
417
+ this.focusRun(next);
418
+ return true;
419
+ }
420
+
324
421
  followExternal() {
325
422
  this.syncProgress();
326
423
  if (this.ui.nav.pane === 'npm') this.ui.paint();
@@ -337,8 +434,9 @@ class NpmController extends RunPanel {
337
434
  const added = [];
338
435
  let changed = this.absorbRecorded(recorded, this.knownLogs(), added);
339
436
  if (this.settleLost(recorded)) changed = true;
340
- if (this.hydrate(this.viewedRun())) changed = true;
341
437
  this.storeAdded(added);
438
+ if (this.followAdded(added)) changed = true;
439
+ if (this.hydrate(this.viewedRun())) changed = true;
342
440
  if (changed) this.followExternal();
343
441
  }
344
442
 
@@ -487,8 +585,9 @@ class NpmController extends RunPanel {
487
585
  if (!run.stale) return false;
488
586
  run.stale = false;
489
587
  run.shownAt = Date.now();
490
- noteTests(run, this.runs, run.raw);
491
- run.output = filteredOutput(run.raw, this.ui.top, '').output;
588
+ const top = run.top ?? this.ui.top;
589
+ noteTests(run, this.byTop.get(top) ?? this.runs, run.raw);
590
+ run.output = filteredOutput(run.raw, top, '').output;
492
591
  return true;
493
592
  }
494
593
 
@@ -500,25 +599,23 @@ class NpmController extends RunPanel {
500
599
 
501
600
  async finishRun(run, entry, result) {
502
601
  const ui = this.ui;
602
+ const top = run.top ?? ui.top;
503
603
  const stopped = run.stopping === true;
504
604
  const raw = stopped ? withNotice(run.raw, 'terminated') : result.text;
505
605
  let slot = null;
506
606
  try {
507
- slot = nextLogFile(ui.top, entry.name);
607
+ slot = nextLogFile(top, entry.name);
508
608
  } catch (error) {
509
609
  ui.status = error.message;
510
610
  }
511
611
  const rawFile = slot ? slot.rawName : '';
512
612
  const stoppedOutput = () => {
513
- const output = withNotice(
514
- reduceOutput(run.raw, ui.top, ''),
515
- 'terminated',
516
- );
613
+ const output = withNotice(reduceOutput(run.raw, top, ''), 'terminated');
517
614
  return { output, log: output };
518
615
  };
519
616
  const filtered = stopped
520
617
  ? stoppedOutput()
521
- : filteredOutput(result.text, ui.top, result.status, rawFile);
618
+ : filteredOutput(result.text, top, result.status, rawFile);
522
619
  run.raw = raw;
523
620
  run.stale = false;
524
621
  run.exit = stopped ? '' : result.status;
@@ -555,6 +652,7 @@ class NpmController extends RunPanel {
555
652
  exit: '',
556
653
  startedAt: Date.now(),
557
654
  endedAt: 0,
655
+ top: ui.top,
558
656
  });
559
657
  this.nextId += 1;
560
658
  this.runs.push(run);
@@ -638,7 +736,7 @@ class NpmController extends RunPanel {
638
736
  }
639
737
 
640
738
  stopAll() {
641
- for (const run of this.runs) this.stop(run);
739
+ for (const run of this.everyRun()) this.stop(run);
642
740
  }
643
741
 
644
742
  toggleVerbose() {
@@ -154,6 +154,7 @@ class ChangeQueue {
154
154
  }
155
155
 
156
156
  enqueue(job) {
157
+ this.runner.writeEpoch += 1;
157
158
  if (this.reloading) {
158
159
  this.ui.loader.bump();
159
160
  this.reloading = false;
@@ -170,6 +171,7 @@ class OpsRunner {
170
171
  this.ui = ui;
171
172
  this.gitBusy = false;
172
173
  this.busy = '';
174
+ this.writeEpoch = 0;
173
175
  this.pending = new Set();
174
176
  this.writing = Promise.resolve();
175
177
  this.writers = 0;
@@ -186,6 +188,7 @@ class OpsRunner {
186
188
  }
187
189
 
188
190
  exclusive(write) {
191
+ this.writeEpoch += 1;
189
192
  const start = async () => write(this.ui.top);
190
193
  const turn = this.writers ? this.writing.then(start) : start();
191
194
  const release = () => {
@@ -2,52 +2,19 @@
2
2
 
3
3
  const { extractText, isRange } = require('../term/select.js');
4
4
  const { scrollAtThumb, scrollThumb } = require('../render/primitives.js');
5
- const { indexAtRow } = require('../term/wrap.js');
5
+ const editorPointer = require('./editor-pointer.js');
6
+ const { hitAt, offsetOf, focusField, beginEditDrag, moveEditDrag } =
7
+ editorPointer;
6
8
  const filesPane = require('./files-pane.js');
7
9
  const { hitAction } = require('../input/keys.js');
10
+ const { actionLetter, disabledActions } = require('../input/actions.js');
11
+ const { dimFooterActions } = require('../render/footer.js');
12
+ const textMenu = require('./text-menu.js');
13
+ const { TEXT_ACTIONS, textEditor, textDisabled, runTextAction } = textMenu;
8
14
  const { copyText } = require('../term/clipboard.js');
9
15
  const { stopImport } = require('./import.js');
10
16
 
11
- const hitAt = (hits, cell) => {
12
- for (const hit of hits) {
13
- if (hit.y !== cell.y) continue;
14
- const x0 = hit.x0 ?? hit.textX;
15
- if (cell.x < x0) continue;
16
- if (hit.x1 !== undefined && cell.x >= hit.x1) continue;
17
- return hit;
18
- }
19
- return null;
20
- };
21
-
22
- const offsetOf = (hit, cellX) => {
23
- const shown = cellX - hit.textX;
24
- const col = Math.max(0, shown) + (hit.scroll || 0);
25
- const at = indexAtRow(hit.start || 0, hit.text, col);
26
- if (hit.limit !== undefined && at > hit.limit) return hit.limit;
27
- return at;
28
- };
29
-
30
- const focusField = (ui, hit) => {
31
- if (!hit.field || !ui.npm) return true;
32
- if (hit.field === ui.npm.editField) return true;
33
- return ui.npm.focusField(hit.field);
34
- };
35
-
36
- const placeClick = (ui, cell) => {
37
- if (ui.mode !== 'compose' || !ui.editor) return false;
38
- const hits = ui.lastFrame && ui.lastFrame.editHits;
39
- if (!hits || !hits.length) return false;
40
- const hit = hitAt(hits, cell);
41
- if (!hit) return false;
42
- if (!focusField(ui, hit)) {
43
- ui.paint();
44
- return true;
45
- }
46
- ui.editor.place(offsetOf(hit, cell.x));
47
- ui.composer.syncCodeScroll();
48
- ui.paint();
49
- return true;
50
- };
17
+ const COMBO_FIELDS = ['model', 'effort', 'context', 'plan'];
51
18
 
52
19
  const mouseCell = (ui, event) => {
53
20
  const size = ui.lastSize ?? ui.getSize();
@@ -112,10 +79,278 @@ const dismissImport = (ui, event) => {
112
79
  stopImport(ui);
113
80
  };
114
81
 
82
+ const CONTEXT_ACTIONS = {
83
+ repos: ['open', 'worktreeNew', 'worktreeDrop'],
84
+ files: ['open', 'add', 'unstage', 'revert', 'ignore', 'code'],
85
+ branch: [
86
+ 'open',
87
+ 'newBranch',
88
+ 'worktreeBranch',
89
+ 'rebase',
90
+ 'drop',
91
+ 'pull',
92
+ 'push',
93
+ ],
94
+ tree: ['open', 'worktreeNew', 'worktreeDrop'],
95
+ commits: ['open', 'apply', 'reword', 'drop', 'view'],
96
+ packages: ['packageNew', 'packageDrop', 'packageWanted', 'packageLatest'],
97
+ npm: ['open', 'npmEdit', 'npmDrop', 'npmLogs'],
98
+ npmRuns: ['open'],
99
+ agents: ['open', 'agentFast', 'agentInstall', 'agentStart'],
100
+ agentRuns: ['open', 'agentStart'],
101
+ tasks: ['open', 'check', 'import', 'drop'],
102
+ };
103
+
104
+ const EMPTY_ACTIONS = {
105
+ repos: ['worktreeNew'],
106
+ files: [],
107
+ branches: ['newBranch', 'worktreeBranch', 'pull', 'push'],
108
+ commits: ['newCommit', 'view', 'pull', 'push'],
109
+ npm: ['npmNew', 'npmLogs'],
110
+ packages: ['packageNew'],
111
+ agents: [],
112
+ tasks: ['import'],
113
+ };
114
+
115
+ const rowHit = (hits, cell) => {
116
+ const x = cell.x - 1;
117
+ return (hits ?? []).find((hit) => {
118
+ if (hit.y !== cell.y) return false;
119
+ if (hit.x0 === undefined) return true;
120
+ return x >= hit.x0 && x < hit.x1;
121
+ });
122
+ };
123
+
124
+ const selectContextRow = (ui, hit) => {
125
+ const pane = ui.pane;
126
+ if (pane === 'repos') {
127
+ const at = ui.workspace.rows.findIndex((row) => row.name === hit.cursor);
128
+ if (at >= 0) ui.workspace.cursor = at;
129
+ return CONTEXT_ACTIONS.repos;
130
+ }
131
+ if (pane === 'files') {
132
+ ui.nav.fileCursor = hit.cursor;
133
+ return CONTEXT_ACTIONS.files;
134
+ }
135
+ if (pane === 'branches') {
136
+ if (hit.tree) {
137
+ const at = ui.trees.rows.findIndex((row) => row.name === hit.cursor);
138
+ if (at >= 0) ui.trees.cursor = at;
139
+ ui.nav.treeFocus = true;
140
+ return CONTEXT_ACTIONS.tree;
141
+ }
142
+ ui.nav.treeFocus = false;
143
+ ui.nav.branchCursor = hit.cursor;
144
+ return CONTEXT_ACTIONS.branch;
145
+ }
146
+ if (pane === 'commits') {
147
+ ui.nav.commitCursor = hit.cursor;
148
+ return CONTEXT_ACTIONS.commits;
149
+ }
150
+ if (pane === 'packages') {
151
+ ui.nav.packagesCursor = hit.cursor;
152
+ return CONTEXT_ACTIONS.packages;
153
+ }
154
+ if (pane === 'npm') {
155
+ if (hit.side === 'runs') {
156
+ ui.npm.runCursor = hit.cursor;
157
+ ui.npm.focusRuns();
158
+ return CONTEXT_ACTIONS.npmRuns;
159
+ }
160
+ ui.nav.npmCursor = hit.cursor;
161
+ ui.npm.focusCommands();
162
+ return CONTEXT_ACTIONS.npm;
163
+ }
164
+ if (pane === 'agents') {
165
+ if (hit.side === 'runs') {
166
+ ui.agents.runCursor = hit.cursor;
167
+ ui.agents.focus = 'runs';
168
+ return CONTEXT_ACTIONS.agentRuns;
169
+ }
170
+ ui.nav.agentCursor = hit.cursor;
171
+ ui.agents.focusCli();
172
+ return CONTEXT_ACTIONS.agents;
173
+ }
174
+ if (pane === 'tasks') {
175
+ ui.nav.tasksFocus = hit.cursor;
176
+ return CONTEXT_ACTIONS.tasks;
177
+ }
178
+ return [];
179
+ };
180
+
181
+ const contextDisabled = (ui, actions) => {
182
+ const pane = ui.pane;
183
+ let target = ui.current();
184
+ if (pane === 'files') target = ui.fileCursorEntry();
185
+ if (pane === 'branches') target = ui.gitBranches.selected();
186
+ if (pane === 'commits') {
187
+ target = {
188
+ ...ui.commits.selected(),
189
+ canCommit: ui.commits.staged,
190
+ unstagedCurrent: !ui.rev && !ui.revShort,
191
+ };
192
+ }
193
+ const off = new Set([
194
+ ...disabledActions(pane, target),
195
+ ...dimFooterActions(ui.view()),
196
+ ]);
197
+ const branchWrites = [
198
+ 'newBranch',
199
+ 'worktreeBranch',
200
+ 'worktreeNew',
201
+ 'worktreeDrop',
202
+ 'rebase',
203
+ 'drop',
204
+ 'pull',
205
+ 'push',
206
+ 'newCommit',
207
+ 'apply',
208
+ 'reword',
209
+ ];
210
+ if (!ui.capabilities.branches && pane !== 'tasks') {
211
+ for (const id of branchWrites) off.add(id);
212
+ if (pane === 'branches') off.add('open');
213
+ }
214
+ if (!ui.capabilities.changes) {
215
+ for (const id of ['add', 'unstage', 'revert', 'ignore']) off.add(id);
216
+ if (pane === 'files') off.add('code');
217
+ for (const id of actions) {
218
+ if (id.startsWith('package') || id.startsWith('npm')) off.add(id);
219
+ }
220
+ }
221
+ if (pane === 'repos' || ui.worktrees.focused()) {
222
+ const row = ui.worktrees.selected();
223
+ const current = ui.trees.currentIndex() === ui.trees.cursor;
224
+ if (
225
+ !row ||
226
+ row.main ||
227
+ ui.worktrees.workingIn(row) ||
228
+ (pane === 'branches' && current)
229
+ ) {
230
+ off.add('worktreeDrop');
231
+ }
232
+ if (!row || ui.worktrees.busy) off.add('worktreeNew');
233
+ }
234
+ if (pane === 'branches' && !ui.gitBranches.selected()) {
235
+ off.add('open');
236
+ off.add('worktreeBranch');
237
+ }
238
+ if (pane === 'npm') {
239
+ if (ui.npm.selected()?.kind !== 'script') {
240
+ off.add('npmEdit');
241
+ off.add('npmDrop');
242
+ }
243
+ }
244
+ if (pane === 'tasks') {
245
+ const row = ui.composer.tasks.rowAt(ui.nav.tasksFocus);
246
+ if (!row || row.draft) {
247
+ off.add('drop');
248
+ off.add('check');
249
+ }
250
+ }
251
+ return [...off].filter((id) => actions.includes(id));
252
+ };
253
+
254
+ const comboAt = (ui, cell) => {
255
+ if (ui.agents.pick || ui.composer.tasks.planOpen) return true;
256
+ const status = rowHit(ui.lastFrame?.statusHits, cell);
257
+ if (status && ['plan', 'plan-item', 'branch'].includes(status.id)) {
258
+ return true;
259
+ }
260
+ const hit = rowHit(ui.lastFrame?.fileHits, cell);
261
+ return (
262
+ ui.pane === 'agents' && hit && [...COMBO_FIELDS, 'menu'].includes(hit.field)
263
+ );
264
+ };
265
+
266
+ const onContextPress = (ui, event) => {
267
+ const frame = ui.lastFrame;
268
+ if (!frame) return;
269
+ const cell = mouseCell(ui, event);
270
+ ui.nav.contextMenu = null;
271
+ if (ui.pane === 'dashboard' || comboAt(ui, cell)) return void ui.paint();
272
+ const editor = textEditor(ui, cell);
273
+ if (editor) {
274
+ const hit = hitAt(frame.editHits ?? [], cell);
275
+ if (ui.mode === 'compose' && (!hit || !focusField(ui, hit))) return;
276
+ if (hit && !editor.hasSelect()) editor.place(offsetOf(hit, cell.x));
277
+ ui.nav.clearSelection();
278
+ ui.nav.contextMenu = {
279
+ x: cell.x - 1,
280
+ y: cell.y,
281
+ actions: TEXT_ACTIONS,
282
+ cursor: 0,
283
+ editor,
284
+ disabled: textDisabled(editor),
285
+ };
286
+ return void ui.paint();
287
+ }
288
+ if (ui.mode !== 'review' || cell.y <= 1 || cell.y >= frame.height - 1) return;
289
+ const hits = ui.pane === 'tasks' ? frame.taskHits : frame.fileHits;
290
+ const hit = rowHit(hits, cell);
291
+ if (!hit && ui.pane === 'branches') ui.nav.treeFocus = false;
292
+ const actions = hit ? selectContextRow(ui, hit) : EMPTY_ACTIONS[ui.pane];
293
+ if (!actions?.length) return void ui.paint();
294
+ ui.nav.clearSelection();
295
+ ui.nav.contextMenu = {
296
+ x: cell.x - 1,
297
+ y: cell.y,
298
+ actions,
299
+ cursor: 0,
300
+ disabled: contextDisabled(ui, actions),
301
+ };
302
+ ui.paint();
303
+ };
304
+
305
+ const contextKey = (mark) => {
306
+ if (mark === 'del') return 'delete';
307
+ if (mark === 'ins') return 'insert';
308
+ if (mark === 'space') return ' ';
309
+ return mark?.replace('+', '-');
310
+ };
311
+
312
+ const runContextAction = (ui, action) => {
313
+ const menu = ui.nav.contextMenu;
314
+ if (!menu || (menu.disabled ?? []).includes(action)) return;
315
+ ui.nav.contextMenu = null;
316
+ if (!runTextAction(ui, menu, action)) ui.dispatch(action);
317
+ ui.paint();
318
+ };
319
+
320
+ const handleContextKey = (ui, key) => {
321
+ const menu = ui.nav.contextMenu;
322
+ if (!menu) return false;
323
+ if (key === 'escape') {
324
+ ui.nav.contextMenu = null;
325
+ ui.paint();
326
+ return true;
327
+ }
328
+ if (key === 'up' || key === 'down') {
329
+ const delta = key === 'up' ? -1 : 1;
330
+ const last = menu.actions.length - 1;
331
+ menu.cursor = Math.max(0, Math.min(last, menu.cursor + delta));
332
+ ui.paint();
333
+ return true;
334
+ }
335
+ let action = key === 'enter' ? menu.actions[menu.cursor] : null;
336
+ if (!action) {
337
+ action = menu.actions.find((id) => contextKey(actionLetter(id)) === key);
338
+ }
339
+ if (!action) return true;
340
+ runContextAction(ui, action);
341
+ return true;
342
+ };
343
+
115
344
  const onPress = (ui, event) => {
116
- dismissImport(ui, event);
117
345
  const nav = ui.nav;
118
346
  const cell = mouseCell(ui, event);
347
+ if (nav.contextMenu) {
348
+ const hit = rowHit(ui.lastFrame?.contextHits, cell);
349
+ if (hit) return;
350
+ nav.contextMenu = null;
351
+ }
352
+ dismissImport(ui, event);
353
+ if (beginEditDrag(ui, cell, Boolean(event.button & 4))) return;
119
354
  if (startLogDrag(ui, cell)) return;
120
355
  nav.scrollDrag = null;
121
356
  nav.mouseAnchor = cell;
@@ -130,6 +365,7 @@ const onPress = (ui, event) => {
130
365
  const onDrag = (ui, event) => {
131
366
  const nav = ui.nav;
132
367
  const cell = mouseCell(ui, event);
368
+ if (moveEditDrag(ui, cell)) return;
133
369
  if (nav.scrollDrag) return void moveLogBar(ui, cell);
134
370
  if (!nav.mouseAnchor) return;
135
371
  nav.pendingClick = null;
@@ -190,8 +426,6 @@ const copySelection = (ui) => {
190
426
  ui.status = ok ? 'copied' : 'copy failed';
191
427
  };
192
428
 
193
- const COMBO_FIELDS = ['model', 'effort', 'context', 'plan'];
194
-
195
429
  const onAgentRunHit = (ui, hit) => {
196
430
  ui.agents.focus = 'runs';
197
431
  ui.agents.runCursor = hit.cursor;
@@ -260,6 +494,8 @@ const RELEASE_TARGETS = [
260
494
  match: (ui) => ui.pane === 'branches',
261
495
  hits: 'fileHits',
262
496
  onHit: (ui, hit) => {
497
+ if (hit.tree) return void ui.worktrees.pickTile(hit.cursor);
498
+ ui.nav.treeFocus = false;
263
499
  const again = armDoubleClick(ui.nav, 'branches', hit.cursor);
264
500
  ui.nav.branchCursor = hit.cursor;
265
501
  if (again) ui.gitBranches.onCheckoutBranch();
@@ -348,9 +584,17 @@ const releaseAgentMenu = (ui, cell) => {
348
584
  };
349
585
 
350
586
  const onRelease = (ui, event) => {
351
- dismissImport(ui, event);
352
587
  const nav = ui.nav;
353
588
  const cell = mouseCell(ui, event);
589
+ if (nav.contextMenu) {
590
+ const hit = rowHit(ui.lastFrame?.contextHits, cell);
591
+ if (hit) return void runContextAction(ui, hit.id);
592
+ nav.contextMenu = null;
593
+ ui.paint();
594
+ return;
595
+ }
596
+ if (moveEditDrag(ui, cell, true)) return;
597
+ dismissImport(ui, event);
354
598
  if (nav.scrollDrag) {
355
599
  moveLogBar(ui, cell);
356
600
  nav.scrollDrag = null;
@@ -366,7 +610,6 @@ const onRelease = (ui, event) => {
366
610
  nav.pendingClick = null;
367
611
  nav.mouseAnchor = null;
368
612
  if (clickButton(ui, cell, clickId)) return;
369
- if (placeClick(ui, cell)) return;
370
613
  if (ui.pane === 'agents' && ui.agents.pick) {
371
614
  return void releaseAgentMenu(ui, cell);
372
615
  }
@@ -390,6 +633,20 @@ const POINTER_HANDLERS = {
390
633
 
391
634
  const handlePointer = (ui, event) => {
392
635
  const kind = event.kind;
636
+ if (ui.nav.contextMenu && ['move', 'drag'].includes(kind)) {
637
+ const hit = rowHit(ui.lastFrame?.contextHits, mouseCell(ui, event));
638
+ if (hit && !hit.disabled && hit.cursor !== ui.nav.contextMenu.cursor) {
639
+ ui.nav.contextMenu.cursor = hit.cursor;
640
+ ui.paint();
641
+ }
642
+ return;
643
+ }
644
+ if (kind === 'move') return;
645
+ if (kind === 'press' && event.btn === 2) {
646
+ onContextPress(ui, event);
647
+ return;
648
+ }
649
+ if (event.btn === 2) return;
393
650
  const handler = POINTER_HANDLERS[kind];
394
651
  if (!handler) return;
395
652
  const isWheel = kind === 'wheelUp' || kind === 'wheelDown';
@@ -399,4 +656,5 @@ const handlePointer = (ui, event) => {
399
656
 
400
657
  module.exports = {
401
658
  handlePointer,
659
+ handleContextKey,
402
660
  };