reslop 0.3.1 → 0.3.3

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 (66) hide show
  1. package/CHANGELOG.md +28 -1
  2. package/README.md +30 -4
  3. package/lib/agents/acp.js +309 -0
  4. package/lib/agents/agents.js +274 -438
  5. package/lib/agents/allow.js +141 -52
  6. package/lib/agents/allow.json +16 -14
  7. package/lib/agents/models.js +250 -383
  8. package/lib/agents/registry.js +437 -0
  9. package/lib/agents/run.js +234 -0
  10. package/lib/agents/script.js +93 -0
  11. package/lib/agents/transcript.js +143 -0
  12. package/lib/cli.js +2 -1
  13. package/lib/common/process.js +26 -1
  14. package/lib/dashboard/git.js +11 -1
  15. package/lib/dashboard/reports.js +80 -0
  16. package/lib/dashboard/watch.js +43 -8
  17. package/lib/dashboard/workspace.js +19 -20
  18. package/lib/dashboard/worktrees.js +145 -0
  19. package/lib/git/dir.js +50 -0
  20. package/lib/git/linked.js +152 -0
  21. package/lib/git/local.js +179 -0
  22. package/lib/git/status.js +126 -0
  23. package/lib/git/worktree.js +113 -36
  24. package/lib/input/actions.js +83 -5
  25. package/lib/input/keys.js +30 -1
  26. package/lib/input/line-edit.js +5 -1
  27. package/lib/render/agents.js +84 -34
  28. package/lib/render/branches.js +55 -27
  29. package/lib/render/field.js +19 -5
  30. package/lib/render/footer.js +14 -6
  31. package/lib/render/frame.js +16 -2
  32. package/lib/render/menu.js +103 -6
  33. package/lib/render/name-edit.js +31 -0
  34. package/lib/render/repos.js +52 -8
  35. package/lib/render/status.js +22 -4
  36. package/lib/report/run.js +16 -23
  37. package/lib/review/agent-instructions.md +2 -2
  38. package/lib/runs/process.js +3 -3
  39. package/lib/runs/runs.js +27 -4
  40. package/lib/session/agents/agents.js +220 -205
  41. package/lib/session/agents/away.js +48 -0
  42. package/lib/session/agents/job.js +23 -86
  43. package/lib/session/agents/jobs.js +14 -27
  44. package/lib/session/agents/options.js +45 -101
  45. package/lib/session/agents/plans.js +53 -0
  46. package/lib/session/agents/view.js +34 -0
  47. package/lib/session/branches.js +18 -1
  48. package/lib/session/compose.js +13 -5
  49. package/lib/session/dispatch.js +43 -4
  50. package/lib/session/editor-pointer.js +98 -0
  51. package/lib/session/files-pane.js +3 -0
  52. package/lib/session/lifecycle.js +67 -10
  53. package/lib/session/navigation.js +5 -0
  54. package/lib/session/npm/history.js +2 -1
  55. package/lib/session/npm/npm.js +89 -12
  56. package/lib/session/ops.js +3 -0
  57. package/lib/session/pointer.js +304 -46
  58. package/lib/session/session.js +15 -2
  59. package/lib/session/terminal.js +2 -2
  60. package/lib/session/text-menu.js +50 -0
  61. package/lib/session/view.js +19 -2
  62. package/lib/session/workspace.js +179 -38
  63. package/lib/session/worktrees.js +280 -0
  64. package/lib/term/themes/dark.json +4 -0
  65. package/lib/term/themes/light.json +4 -0
  66. package/package.json +2 -2
@@ -17,10 +17,11 @@ const { flexCell, tableLines, titleAside } = table;
17
17
  const logPaint = require('./log.js');
18
18
  const { LOG_PAD_X } = logPaint;
19
19
  const { paintNpmLog, withLogScroller } = logPaint;
20
- const { paintField } = require('./field.js');
20
+ const { paintField, queryTones, fieldHit } = require('./field.js');
21
21
 
22
22
  const BUBBLE = '●';
23
23
  const BUBBLE_GAP = 1;
24
+ const COLUMN_GAP = 2;
24
25
  const BUBBLE_PHASE = ['key', 'add', 'sha', 'warn'];
25
26
  const PAD_W = 2;
26
27
  const TAIL = 2;
@@ -45,14 +46,8 @@ const COL_TITLES = {
45
46
 
46
47
  const agentFields = (row) => {
47
48
  if (!row.bin) {
48
- return {
49
- name: row.name,
50
- model: row.model || '',
51
- effort: '',
52
- fast: '',
53
- context: '',
54
- plan: 'not installed',
55
- };
49
+ const none = { model: '', effort: '', fast: '', context: '', plan: '' };
50
+ return { name: row.name, ...none };
56
51
  }
57
52
  const levels = row.efforts ?? [];
58
53
  return {
@@ -166,7 +161,28 @@ const cellFace = (text, width, query, end = false) => {
166
161
  return end && !query ? `${pad}${shown}` : `${shown}${pad}`;
167
162
  };
168
163
 
164
+ // A missing agent has no settings: the row says what would bring it
165
+ const paintHintRow = (row, width, color, selected, cols) => {
166
+ const prefix = ' '.repeat(PAD_W);
167
+ const tail = ' '.repeat(TAIL);
168
+ const gap = cols.gap || ' ';
169
+ const name = cellFace(row.name, cols.nameW, '');
170
+ const taken = PAD_W + TAIL + visibleWidth(name) + visibleWidth(gap);
171
+ const hint = cellFace(row.hint || 'not installed', width - taken, '');
172
+ const fgRgb = selected ? THEME.chromeFg : THEME.mutedFg;
173
+ const bgRgb = selected ? THEME.buttonBg : THEME.ctxBg;
174
+ const text = `${prefix}${name}${gap}${hint}${tail}`;
175
+ if (!color) return { row: text, spans: [] };
176
+ let out = `${seq(fgRgb, bgRgb)}${EL}`;
177
+ out += paint(`${prefix}${name}`, THEME.mutedFg, bgRgb, color);
178
+ out += paint(gap, fgRgb, bgRgb, color);
179
+ out += paint(hint, THEME.warnFg, bgRgb, color);
180
+ out += paint(tail, fgRgb, bgRgb, color);
181
+ return { row: out, spans: [] };
182
+ };
183
+
169
184
  const paintAgentRow = (row, width, color, selected, cols, search) => {
185
+ if (!row.bin) return paintHintRow(row, width, color, selected, cols);
170
186
  const fields = agentFields(row);
171
187
  const prefix = ' '.repeat(PAD_W);
172
188
  const rest = Math.max(1, width - PAD_W - TAIL);
@@ -183,6 +199,17 @@ const paintAgentRow = (row, width, color, selected, cols, search) => {
183
199
  ),
184
200
  plan: cellFace(fields.plan, cols.planW, typedQuery(search, 'plan'), true),
185
201
  };
202
+ if (!color && search?.editor) {
203
+ const size = colW(cols, search.field);
204
+ search.editor.reveal(size);
205
+ cells[search.field] = paintField(
206
+ search.query,
207
+ search.editor,
208
+ size,
209
+ false,
210
+ queryTones(THEME.warnFg, THEME.buttonBg),
211
+ );
212
+ }
186
213
  let mid = name;
187
214
  for (const field of VALUE_FIELDS) {
188
215
  if (colW(cols, field)) mid += `${gap}${cells[field]}`;
@@ -193,11 +220,9 @@ const paintAgentRow = (row, width, color, selected, cols, search) => {
193
220
  const fgRgb = selected ? THEME.chromeFg : THEME.mutedFg;
194
221
  const bgRgb = selected ? THEME.buttonBg : THEME.ctxBg;
195
222
  if (!color) return { row: `${prefix}${mid}${pad}${tail}`, spans };
196
- const nameFg = row.bin ? THEME.buttonHotFg : THEME.mutedFg;
197
- const modelFg = row.bin ? THEME.shaFg : THEME.shaDarkFg;
198
- const valueFg = row.bin ? THEME.buttonHotFg : fgRgb;
223
+ const valueFg = THEME.buttonHotFg;
199
224
  const tones = {
200
- model: { fg: modelFg, bold: Boolean(row.bin) },
225
+ model: { fg: THEME.shaFg, bold: true },
201
226
  effort: { fg: valueFg, bold: false },
202
227
  fast: { fg: valueFg, bold: false, glue: true },
203
228
  context: { fg: valueFg, bold: false },
@@ -205,7 +230,7 @@ const paintAgentRow = (row, width, color, selected, cols, search) => {
205
230
  };
206
231
  let out = `${seq(fgRgb, bgRgb)}${EL}`;
207
232
  out += paint(prefix, fgRgb, bgRgb, color);
208
- out += paint(name, nameFg, bgRgb, color, Boolean(row.bin));
233
+ out += paint(name, valueFg, bgRgb, color, true);
209
234
  for (const field of VALUE_FIELDS) {
210
235
  const size = colW(cols, field);
211
236
  if (!size) continue;
@@ -219,14 +244,10 @@ const paintAgentRow = (row, width, color, selected, cols, search) => {
219
244
  out += paint(gap, valueFg, bgRgb, color);
220
245
  if (query && search.editor) {
221
246
  search.editor.reveal(size);
222
- const queryTones = {
223
- plain: { fg: THEME.searchFg, bg: THEME.searchBg },
224
- select: { fg: THEME.chromeFg, bg: THEME.checkBg },
225
- caret: { fg: THEME.searchOnFg, bg: THEME.searchOnBg },
226
- };
227
- out += paintField(query, search.editor, size, color, queryTones);
247
+ const typed = queryTones(THEME.warnFg, bgRgb);
248
+ out += paintField(query, search.editor, size, color, typed);
228
249
  } else if (query) {
229
- out += paint(face, THEME.searchFg, THEME.searchBg, color, true);
250
+ out += paint(face, THEME.warnFg, bgRgb, color, true);
230
251
  } else {
231
252
  out += paint(face, tone.fg, bgRgb, color, tone.bold);
232
253
  }
@@ -273,7 +294,7 @@ const overlayMenu = (painted, view, cols, gap, width, color, headerLines) => {
273
294
  const boxW = menuBoxWidth(options, columnW, width - x);
274
295
  if (x + boxW > width) x = Math.max(0, width - boxW);
275
296
  const origin = down ? rowAt + 1 : Math.max(0, rowAt - box.span);
276
- const faces = menuFaces(options, box, boxW, color, off);
297
+ const faces = menuFaces(options, box, boxW, color, view.agentQuery, off);
277
298
  const body = painted.body.slice();
278
299
  const fileHits = painted.fileHits.slice();
279
300
  for (let i = 0; i < faces.length && origin + i < body.length; i++) {
@@ -289,7 +310,13 @@ const overlayMenu = (painted, view, cols, gap, width, color, headerLines) => {
289
310
  field: 'menu',
290
311
  });
291
312
  }
292
- return { ...painted, body, fileHits, menuScroll: box.start };
313
+ const editHits = painted.editHits.slice();
314
+ if (view.agentEditor) {
315
+ const start = fieldX(cols, gap, field);
316
+ const hit = fieldHit(view.agentEditor, start, columnW, rowHit.y);
317
+ editHits.push({ ...hit, row: rowAt });
318
+ }
319
+ return { ...painted, body, fileHits, editHits, menuScroll: box.start };
293
320
  };
294
321
 
295
322
  const chooserSplit = (width) => {
@@ -616,27 +643,50 @@ const agentLead = (item, frame) => {
616
643
  return cellSegs(segs);
617
644
  };
618
645
 
619
- const agentName = (item) => {
646
+ // The last column clips its names when the tile is narrow
647
+ const agentCells = (item, frame, last) => {
648
+ if (!item) return [];
620
649
  const tone = item.bin ? 'text' : 'muted';
621
- const name = flexCell(item.name, tone);
650
+ const name = last ? flexCell(item.name, tone) : cell(item.name, tone);
622
651
  name.gap = BUBBLE_GAP;
623
- return name;
652
+ return [agentLead(item, frame), name];
653
+ };
654
+
655
+ // Two columns while both fit their widest agent, else one
656
+ const columnCount = (items, width) => {
657
+ let lead = 0;
658
+ let name = 0;
659
+ for (const item of items) {
660
+ const sessions = item.running ? ` ${item.running}` : '';
661
+ lead = Math.max(lead, visibleWidth(`${BUBBLE}${sessions}`));
662
+ name = Math.max(name, visibleWidth(item.name));
663
+ }
664
+ const entry = lead + BUBBLE_GAP + name;
665
+ return entry * 2 + COLUMN_GAP <= width ? 2 : 1;
624
666
  };
625
667
 
668
+ // Agents fill the left column first
626
669
  const agentsBlock = (model, width, height, ctx, tile) => {
627
670
  const data = model.agents;
628
- if (!data || !data.ready) return waiting('detecting clis…');
671
+ if (!data || !data.ready) return waiting('detecting agents…');
629
672
  const aside = [seg(`${data.running || 0}`, 'key', true)];
630
673
  const titleLine = titleAside(tile, aside, width);
631
674
  const items = data.items ?? [];
632
675
  if (!items.length) return notice('no agents', titleLine);
633
- const shown = items.slice(0, Math.max(0, height));
634
- const rows = shown.map((item) => [
635
- agentLead(item, ctx.frame),
636
- agentName(item),
637
- cell(item.bin ? item.model : 'missing', item.bin ? 'sha' : 'muted', 'r'),
638
- ]);
639
- return { titleLine, lines: tableLines(rows, width) };
676
+ const columns = columnCount(items, width);
677
+ const perColumn = Math.ceil(items.length / columns);
678
+ const count = Math.min(Math.max(0, height), perColumn);
679
+ const rows = [];
680
+ for (let i = 0; i < count; i++) {
681
+ const left = agentCells(items[i], ctx.frame, columns === 1);
682
+ if (columns === 1) {
683
+ rows.push(left);
684
+ continue;
685
+ }
686
+ const right = agentCells(items[i + count], ctx.frame, true);
687
+ rows.push([...left, ...right]);
688
+ }
689
+ return { titleLine, lines: tableLines(rows, width, COLUMN_GAP) };
640
690
  };
641
691
 
642
692
  module.exports = { paintBodyAgents, agentsBlock };
@@ -5,9 +5,12 @@ const { shortAge } = require('../common/format.js');
5
5
  const primitives = require('./primitives.js');
6
6
 
7
7
  const { THEME, paint, visibleWidth, trimVisible, seq, EL } = ansi;
8
- const { MARK_W, markPrefix, branchLabel, editSpan } = primitives;
9
- const { paintCursorList, fieldWindow } = primitives;
8
+ const { markPrefix, branchLabel, paintCursorList } = primitives;
9
+ const { paintBodyFill } = primitives;
10
10
  const { measureColumns, padColumns, joinColumns, paintColumns } = primitives;
11
+ const { paintNameEdit } = require('./name-edit.js');
12
+ const { paintTreeBand } = require('./repos.js');
13
+ const { tileHits, pageFill } = require('./tiles.js');
11
14
 
12
15
  const BRANCH_COL_ALIGN = {
13
16
  sha: 'end',
@@ -92,27 +95,23 @@ const paintBranchRow = (entry, width, color, selected, cols) => {
92
95
  return out + paint(tail, fgRgb, bgRgb, color);
93
96
  };
94
97
 
95
- const paintBranchEdit = (compose, width, color) => {
96
- const prefix = markPrefix(true);
97
- const rest = Math.max(1, width - MARK_W);
98
- const text = `${compose.text ?? ''}`.replaceAll('\n', ' ');
99
- const win = fieldWindow(text, compose.cursor, compose.scrollCol, rest);
100
- const gap = ' '.repeat(Math.max(0, rest - visibleWidth(win.text)));
101
- const fgRgb = THEME.chromeFg;
102
- const bgRgb = THEME.buttonBg;
103
- let row = `${prefix}${win.text}${gap}`;
104
- if (color) {
105
- row = `${seq(fgRgb, bgRgb)}${EL}`;
106
- row += paint(prefix, fgRgb, bgRgb, color);
107
- row += paint(win.text, THEME.buttonHotFg, bgRgb, color);
108
- row += paint(gap, fgRgb, bgRgb, color);
109
- }
110
- const textX = MARK_W + 1;
111
- const edit = editSpan(1, textX, textX + rest, 0, text);
112
- const cursor = { x: textX + win.col, scroll: win.scroll };
113
- return { row, cursor, edits: [{ ...edit, scroll: win.scroll }] };
114
- };
98
+ // A title and the five lines of a tile
99
+ const TREES_H = 6;
100
+ const LIST_MIN_H = 4;
101
+ // A blank line above the tiles, one under them, one under the list
102
+ const TREES_GAPS = 3;
103
+
104
+ // The tiles of the worktrees show when the list of branches can spare it
105
+ const treesHeight = (bodyH) =>
106
+ bodyH - TREES_GAPS - LIST_MIN_H >= TREES_H ? TREES_H : 0;
107
+
108
+ const shiftRows = (list, by) => ({
109
+ ...list,
110
+ cursor: list.cursor ? { ...list.cursor, row: list.cursor.row + by } : null,
111
+ editHits: list.editHits.map((span) => ({ ...span, row: span.row + by })),
112
+ });
115
113
 
114
+ // The worktrees above the branches; one element is selected, a tile or a branch
116
115
  const paintBodyBranches = (view, width, color, bodyH, headerLines) => {
117
116
  const branches = view.branches ?? [];
118
117
  const cols = measureColumns(branches, branchRowFields, BRANCH_COL_ALIGN);
@@ -123,12 +122,41 @@ const paintBodyBranches = (view, width, color, bodyH, headerLines) => {
123
122
  cols.name = Math.min(BRANCH_NAME_MAX, cols.name);
124
123
  const cursor = view.branchCursor ?? 0;
125
124
  const { compose } = view;
126
- const branchEdit = compose?.kind === 'branch';
127
- const edit = branchEdit ? paintBranchEdit(compose, width, color) : null;
128
- const pane = { width, color, bodyH, headerLines, offset: view.listScroll };
125
+ const trees = view.trees ? treesHeight(bodyH) : 0;
126
+ const typing = compose?.kind === 'branch' || compose?.kind === 'worktree';
127
+ const typed = typing ? paintNameEdit(compose, width, color) : null;
128
+ const above = trees && compose?.kind === 'worktree' ? typed : null;
129
+ const edit = above ? null : typed;
130
+ const lead = above ? 1 : 0;
131
+ const top = trees ? lead + 1 + trees + 1 : 0;
132
+ const room = trees ? bodyH - top - 1 : bodyH;
133
+ const pane = { width, color, bodyH: room, headerLines: headerLines + top };
134
+ pane.offset = view.listScroll;
135
+ if (trees) pane.pad = 0;
136
+ const picked = !view.treeFocus;
129
137
  const paintRow = (entry, selected) =>
130
- paintBranchRow(entry, width, color, selected, cols);
131
- return paintCursorList(branches, cursor, pane, paintRow, edit);
138
+ paintBranchRow(entry, width, color, selected && picked, cols);
139
+ const list = paintCursorList(branches, cursor, pane, paintRow, edit);
140
+ if (!trees) return list;
141
+ const band = paintTreeBand(view.trees, width, trees, color);
142
+ const hits = tileHits(band.bands, headerLines + lead + 1);
143
+ const treeHits = hits.map((hit) => ({ ...hit, tree: true }));
144
+ const head = above ? [above.row] : [];
145
+ const gap = pageFill(width, color);
146
+ const tail = paintBodyFill(width, color, 'files');
147
+ const body = [...head, gap, ...band.rows, gap, ...list.body, tail];
148
+ const fileHits = [...treeHits, ...list.fileHits];
149
+ const moved = shiftRows(list, top);
150
+ const typedAbove = above ? { ...above.cursor, row: 0 } : null;
151
+ const spans = above ? above.edits.map((span) => ({ ...span, row: 0 })) : [];
152
+ return {
153
+ ...moved,
154
+ body,
155
+ fileHits,
156
+ cursor: typedAbove ?? moved.cursor,
157
+ editHits: [...spans, ...moved.editHits],
158
+ gridCols: band.cols,
159
+ };
132
160
  };
133
161
 
134
162
  module.exports = { paintBodyBranches };
@@ -4,6 +4,16 @@ const ansi = require('../term/ansi.js');
4
4
 
5
5
  const { THEME, paint, graphemes, graphemeWidth, visibleWidth } = ansi;
6
6
  const { fileMatches } = require('../common/find.js');
7
+ const { editSpan } = require('./primitives.js');
8
+
9
+ // Inline fields paint their own selection; their spans map pointer positions.
10
+ const fieldHit = (editor, x, width, y) => ({
11
+ ...editSpan(x + 1, x + 1, x + width + 1, 0, editor.text),
12
+ editor,
13
+ y,
14
+ scroll: editor.scrollCol,
15
+ live: false,
16
+ });
7
17
 
8
18
  const pieceKind = (index, cursor, lo, hi) => {
9
19
  if (index === cursor) return 'caret';
@@ -62,6 +72,12 @@ const paintField = (text, editor, room, color, tones) => {
62
72
  return out + paint(pad, plain.fg, plain.bg, color);
63
73
  };
64
74
 
75
+ const queryTones = (fgRgb, bgRgb) => ({
76
+ plain: { fg: fgRgb, bg: bgRgb },
77
+ select: { fg: THEME.chromeFg, bg: THEME.checkBg },
78
+ caret: { fg: THEME.searchOnFg, bg: THEME.searchOnBg },
79
+ });
80
+
65
81
  const paintPlanFace = (view, face, fgRgb, bgRgb, color) => {
66
82
  const editor = view.planEditor;
67
83
  if (!editor || !view.planQuery) {
@@ -69,11 +85,7 @@ const paintPlanFace = (view, face, fgRgb, bgRgb, color) => {
69
85
  }
70
86
  const room = visibleWidth(face);
71
87
  if (room) editor.reveal(room);
72
- const tones = {
73
- plain: { fg: THEME.searchFg, bg: THEME.searchBg },
74
- select: { fg: THEME.chromeFg, bg: THEME.checkBg },
75
- caret: { fg: THEME.searchOnFg, bg: THEME.searchOnBg },
76
- };
88
+ const tones = queryTones(fgRgb, bgRgb);
77
89
  return paintField(view.planQuery, editor, room, color, tones);
78
90
  };
79
91
 
@@ -125,7 +137,9 @@ const paintFindStatus = (view, width, color) => {
125
137
 
126
138
  module.exports = {
127
139
  paintField,
140
+ queryTones,
128
141
  paintPlanFace,
129
142
  paintImportStatus,
130
143
  paintFindStatus,
144
+ fieldHit,
131
145
  };
@@ -296,9 +296,10 @@ const agentListDim = (view) => {
296
296
  const off = [];
297
297
  if (!installed || levels.length < 2) off.push('agentEffort');
298
298
  if (!installed) off.push('agentFast');
299
- if (!installed || !sizes.length) off.push('agentContext');
299
+ if (!installed || sizes.length < 2) off.push('agentContext');
300
300
  if ((view.agentPlanCount ?? 0) < 2) off.push('agentReview');
301
- if (!view.agentCanContinue) off.push('agentContinue');
301
+ if (installed || !row || !row.installable) off.push('agentInstall');
302
+ if (!view.agentCanStart) off.push('agentStart');
302
303
  return off;
303
304
  };
304
305
 
@@ -331,14 +332,16 @@ const dimFooterActions = (view) => {
331
332
  const GIT_FOOTER = ['add', 'unstage', 'revert', 'ignore'];
332
333
 
333
334
  const FOOTER_EXTRA = {
334
- repos: ['theme'],
335
+ repos: ['theme', 'worktreeNew', 'worktreeDrop'],
335
336
  dashboard: ['theme', 'pull', 'push'],
336
337
  unit: ['add', 'unstage', 'revert', 'feedback', 'code', 'lines'],
337
338
  files: [...GIT_FOOTER, 'code'],
338
339
  diff: [...GIT_FOOTER, 'feedback', 'code', 'layout', 'lines'],
339
- branches: ['newBranch', 'rebase', 'drop', 'pull', 'push'],
340
+ branches: ['newBranch', 'worktreeBranch', 'rebase', 'drop', 'pull', 'push'],
341
+ trees: ['worktreeNew', 'worktreeDrop', 'pull', 'push'],
340
342
  commits: ['newCommit', 'apply', 'reword', 'drop', 'view', 'pull', 'push'],
341
343
  npmLog: ['npmVerbose', 'npmStop', 'npmRerun'],
344
+ npmFailedLog: ['npmVerbose', 'npmSolve', 'npmRerun'],
342
345
  npm: ['npmEdit', 'npmNew', 'npmDrop', 'npmLogs'],
343
346
  packages: ['packageNew', 'packageDrop', 'packageWanted', 'packageLatest'],
344
347
  agentLog: ['agentStop', 'agentRerun'],
@@ -348,14 +351,18 @@ const FOOTER_EXTRA = {
348
351
  'agentFast',
349
352
  'agentContext',
350
353
  'agentReview',
351
- 'agentContinue',
354
+ 'agentInstall',
355
+ 'agentStart',
352
356
  ],
353
357
  tasks: ['plan', 'import', 'drop', 'check', 'taskNudge'],
354
358
  };
355
359
 
356
360
  const extraFooterActions = (view) => {
357
361
  const { pane } = view;
358
- if (pane === 'npm' && view.npmView) return FOOTER_EXTRA.npmLog;
362
+ if (pane === 'branches' && view.treeFocus) return FOOTER_EXTRA.trees;
363
+ if (pane === 'npm' && view.npmView) {
364
+ return view.npmCanSolve ? FOOTER_EXTRA.npmFailedLog : FOOTER_EXTRA.npmLog;
365
+ }
359
366
  if (pane === 'npm') return FOOTER_EXTRA.npm;
360
367
  if (pane === 'agents' && view.agentView) return FOOTER_EXTRA.agentLog;
361
368
  if (pane === 'agents') return FOOTER_EXTRA.agents;
@@ -382,4 +389,5 @@ module.exports = {
382
389
  paintFooter,
383
390
  footerHits,
384
391
  footerLayout,
392
+ dimFooterActions,
385
393
  };
@@ -18,11 +18,12 @@ const dashboard = require('./dashboard/dashboard.js');
18
18
  const packages = require('./packages.js');
19
19
  const agents = require('./agents.js');
20
20
  const repos = require('./repos.js');
21
+ const { contextMenu } = require('./menu.js');
21
22
 
22
23
  const { isListPane, isTaskView, paintBodyFill, paneResult } = primitives;
23
24
  const { bodyRow } = primitives;
24
25
  const { paintStatusLine, statusBranchHit, statusChoiceHits } = status;
25
- const { statusPlanHit, planMenu } = status;
26
+ const { statusPlanHit, statusEditHits, planMenu } = status;
26
27
 
27
28
  const PANE_BODY = {
28
29
  dashboard: dashboard.paintBodyDashboard,
@@ -250,8 +251,20 @@ const renderFrame = (view, options = {}) => {
250
251
  }
251
252
  statusHits.push(...menu.hits);
252
253
  }
253
- const editHits = [...windowed.editHits, ...hits.editHits];
254
+ const editHits = [
255
+ ...windowed.editHits,
256
+ ...hits.editHits,
257
+ ...statusEditHits(view, width, statusY),
258
+ ];
254
259
  const shown = markEditorSelection(rows, view, editHits, color);
260
+ const popup = contextMenu(view.contextMenu, width, rows.length, color);
261
+ if (popup) {
262
+ for (const line of popup.lines) {
263
+ const index = line.y - 1;
264
+ if (index <= 0 || index >= rows.length - 1) continue;
265
+ shown[index] = stampVisible(shown[index], popup.x, line.text);
266
+ }
267
+ }
255
268
  const text = shown.map((row) => (color ? row + RESET : row)).join('\n');
256
269
  return {
257
270
  text,
@@ -262,6 +275,7 @@ const renderFrame = (view, options = {}) => {
262
275
  templateHits: hits.templateHits,
263
276
  editHits,
264
277
  statusHits,
278
+ contextHits: popup ? popup.hits : [],
265
279
  footerY: rows.length,
266
280
  height: rows.length,
267
281
  scroll: windowed.scroll,
@@ -4,6 +4,11 @@ const { clamp } = require('../common/utilities.js');
4
4
  const ansi = require('../term/ansi.js');
5
5
  const { listWindowStart, scrollMark } = require('./primitives.js');
6
6
  const { THEME, paint, visibleWidth, trimVisible } = ansi;
7
+ const {
8
+ actionLetter,
9
+ actionCaption,
10
+ buttonWord,
11
+ } = require('../input/actions.js');
7
12
 
8
13
  const menuBoxWidth = (options, columnW, room) => {
9
14
  let widest = Math.max(columnW, 4);
@@ -13,7 +18,24 @@ const menuBoxWidth = (options, columnW, room) => {
13
18
  return clamp(widest + 2, 4, room);
14
19
  };
15
20
 
16
- const menuItem = (label, boxW, color, selected, mark, disabled) => {
21
+ const paintQuery = (text, query, fgRgb, bgRgb, color, bold) => {
22
+ const needle = `${query ?? ''}`.trim().toLowerCase();
23
+ if (!color || !needle) return paint(text, fgRgb, bgRgb, color, bold);
24
+ const hay = text.toLowerCase();
25
+ let out = '';
26
+ let from = 0;
27
+ let at = hay.indexOf(needle, from);
28
+ while (at >= 0) {
29
+ const end = at + needle.length;
30
+ out += paint(text.slice(from, at), fgRgb, bgRgb, color, bold);
31
+ out += paint(text.slice(at, end), THEME.warnFg, bgRgb, color, true);
32
+ from = end;
33
+ at = hay.indexOf(needle, from);
34
+ }
35
+ return out + paint(text.slice(from), fgRgb, bgRgb, color, bold);
36
+ };
37
+
38
+ const menuItem = (label, boxW, color, selected, mark, disabled, query) => {
17
39
  const barW = mark ? 1 : 0;
18
40
  const faceW = Math.max(1, boxW - barW);
19
41
  const room = Math.max(0, faceW - 1);
@@ -25,7 +47,7 @@ const menuItem = (label, boxW, color, selected, mark, disabled) => {
25
47
  const fgRgb = disabled ? THEME.mutedFg : THEME.chromeFg;
26
48
  const hotFg = hot ? THEME.buttonHotFg : fgRgb;
27
49
  const bgRgb = hot ? THEME.checkBg : THEME.noteBg;
28
- let row = paint(face, hotFg, bgRgb, color, hot);
50
+ let row = paintQuery(face, query, hotFg, bgRgb, color, hot);
29
51
  if (!mark) return row;
30
52
  const barBg = mark === 'thumb' ? THEME.taskHeadBg : THEME.buttonBg;
31
53
  row += paint(' ', fgRgb, barBg, color);
@@ -40,7 +62,7 @@ const dropBox = (options, cursor, scroll, room) => {
40
62
  return { focus, inner, span: inner, start, count };
41
63
  };
42
64
 
43
- const menuFaces = (options, box, boxW, color, off = new Set()) => {
65
+ const menuFaces = (options, box, boxW, color, query = '', off = new Set()) => {
44
66
  const faces = [];
45
67
  for (let i = 0; i < box.inner; i++) {
46
68
  const index = box.start + i;
@@ -50,11 +72,86 @@ const menuFaces = (options, box, boxW, color, off = new Set()) => {
50
72
  const focused = index === box.focus;
51
73
  const face =
52
74
  label === undefined
53
- ? menuItem('none', boxW, color, false, mark, false)
54
- : menuItem(label, boxW, color, focused, mark, disabled);
75
+ ? menuItem('none', boxW, color, false, mark, false, '')
76
+ : menuItem(label, boxW, color, focused, mark, disabled, query);
55
77
  faces.push({ index, face });
56
78
  }
57
79
  return faces;
58
80
  };
59
81
 
60
- module.exports = { menuBoxWidth, dropBox, menuFaces };
82
+ const contextText = (id, keyWidth) => {
83
+ if (!keyWidth) return buttonWord(id);
84
+ const mark = actionLetter(id) ?? '';
85
+ const caption = actionCaption(id);
86
+ const key = mark.padEnd(keyWidth);
87
+ return `${key} ${caption}`;
88
+ };
89
+
90
+ const contextLabel = (id, width, color, selected, disabled, keyWidth) => {
91
+ const label = contextText(id, keyWidth);
92
+ const mark = actionLetter(id) ?? '';
93
+ const shown = trimVisible(label, Math.max(1, width - 2));
94
+ const pad = ' '.repeat(Math.max(0, width - 2 - visibleWidth(shown)));
95
+ const face = ` ${shown}${pad} `;
96
+ if (!color || !mark) return face;
97
+ const bgRgb = selected && !disabled ? THEME.popupHotBg : THEME.popupBg;
98
+ const fgRgb = disabled ? THEME.popupDisabledFg : THEME.popupFg;
99
+ if (disabled) return paint(face, fgRgb, bgRgb, color);
100
+ const at = shown.indexOf(mark);
101
+ if (at < 0) return paint(face, fgRgb, bgRgb, color);
102
+ let out = paint(face.slice(0, at + 1), fgRgb, bgRgb, color);
103
+ out += paint(mark, THEME.buttonHotFg, bgRgb, color, true);
104
+ return out + paint(face.slice(at + mark.length + 1), fgRgb, bgRgb, color);
105
+ };
106
+
107
+ const contextMenu = (model, width, height, color) => {
108
+ if (!model || !model.actions || !model.actions.length) return null;
109
+ const keyWidth = model.editor
110
+ ? Math.max(
111
+ ...model.actions.map((id) => visibleWidth(actionLetter(id) ?? '')),
112
+ )
113
+ : 0;
114
+ const labels = model.actions.map((id) => contextText(id, keyWidth));
115
+ const widest = labels.reduce(
116
+ (size, label) => Math.max(size, visibleWidth(label)),
117
+ 4,
118
+ );
119
+ const boxW = Math.min(width, widest + 2);
120
+ const count = model.actions.length;
121
+ const inner = Math.min(count, Math.max(1, height - 3));
122
+ const x = clamp(model.x ?? 0, 0, Math.max(0, width - boxW));
123
+ const anchorY = clamp(model.y ?? 2, 2, Math.max(2, height - 1));
124
+ const y = Math.min(anchorY, Math.max(2, height - inner - 1));
125
+ const cursor = clamp(model.cursor ?? 0, 0, count - 1);
126
+ const start = listWindowStart(cursor, 0, inner, count);
127
+ const lines = [];
128
+ const hits = [];
129
+ for (let row = 0; row < inner; row++) {
130
+ const index = start + row;
131
+ const id = model.actions[index];
132
+ const disabled = (model.disabled ?? []).includes(id);
133
+ const face = contextLabel(
134
+ id,
135
+ boxW,
136
+ color,
137
+ index === cursor,
138
+ disabled,
139
+ keyWidth,
140
+ );
141
+ lines.push({
142
+ y: y + row,
143
+ text: face,
144
+ });
145
+ hits.push({
146
+ id,
147
+ cursor: index,
148
+ disabled,
149
+ y: y + row,
150
+ x0: x,
151
+ x1: x + boxW,
152
+ });
153
+ }
154
+ return { x, lines, hits };
155
+ };
156
+
157
+ module.exports = { menuBoxWidth, dropBox, menuFaces, contextMenu };
@@ -0,0 +1,31 @@
1
+ 'use strict';
2
+
3
+ const ansi = require('../term/ansi.js');
4
+ const primitives = require('./primitives.js');
5
+
6
+ const { THEME, paint, visibleWidth, seq, EL } = ansi;
7
+ const { MARK_W, markPrefix, editSpan, fieldWindow } = primitives;
8
+
9
+ // The row where a name is typed: of a new branch or of a new worktree
10
+ const paintNameEdit = (compose, width, color) => {
11
+ const prefix = markPrefix(true);
12
+ const rest = Math.max(1, width - MARK_W);
13
+ const text = `${compose.text ?? ''}`.replaceAll('\n', ' ');
14
+ const win = fieldWindow(text, compose.cursor, compose.scrollCol, rest);
15
+ const gap = ' '.repeat(Math.max(0, rest - visibleWidth(win.text)));
16
+ const fgRgb = THEME.chromeFg;
17
+ const bgRgb = THEME.buttonBg;
18
+ let row = `${prefix}${win.text}${gap}`;
19
+ if (color) {
20
+ row = `${seq(fgRgb, bgRgb)}${EL}`;
21
+ row += paint(prefix, fgRgb, bgRgb, color);
22
+ row += paint(win.text, THEME.buttonHotFg, bgRgb, color);
23
+ row += paint(gap, fgRgb, bgRgb, color);
24
+ }
25
+ const textX = MARK_W + 1;
26
+ const edit = editSpan(1, textX, textX + rest, 0, text);
27
+ const cursor = { x: textX + win.col, scroll: win.scroll };
28
+ return { row, cursor, edits: [{ ...edit, scroll: win.scroll }] };
29
+ };
30
+
31
+ module.exports = { paintNameEdit };