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
@@ -3,13 +3,16 @@
3
3
  const fs = require('node:fs');
4
4
  const path = require('node:path');
5
5
 
6
- const { runProc, isAbort } = require('../common/process.js');
6
+ const { runProc, isAbort, abortError } = require('../common/process.js');
7
7
  const { oneLine } = require('../common/utilities.js');
8
8
  const diff = require('../diff/diff.js');
9
9
  const { parseDiff, synthesizeNewFile, itemsFromFiles } = diff;
10
10
  const files = require('../common/files.js');
11
11
  const { groupByPath } = files;
12
12
  const { itemPath, isReadOnlyOrigin, lineDelta, REVIEW_DIR } = files;
13
+ const local = require('./local.js');
14
+ const { worktreeRoot, branchOf, remoteOrigin } = local;
15
+ const { parseStatus } = require('./status.js');
13
16
  const { replaceBlockAdds } = require('../diff/patch.js');
14
17
 
15
18
  const GIT_CONFIG = [
@@ -20,10 +23,30 @@ const GIT_CONFIG = [
20
23
  ];
21
24
  const DIFF_OPTS = ['--no-color', '--no-ext-diff', '--no-renames', '-U3'];
22
25
  const BINARY_SIZE = 1000000;
26
+ const STATUS_ARGS = [
27
+ '-c',
28
+ 'core.quotepath=false',
29
+ '-c',
30
+ 'color.ui=false',
31
+ '-c',
32
+ 'diff.renames=false',
33
+ 'status',
34
+ '-vv',
35
+ '--no-renames',
36
+ '--no-ahead-behind',
37
+ '-uall',
38
+ ];
39
+ const STATUS_ENV = { LC_MESSAGES: 'C', LANGUAGE: 'C' };
23
40
 
24
41
  const gitOptions = (cwd, extra) => ({
25
42
  cwd,
26
- env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', ...extra.env },
43
+ env: {
44
+ ...process.env,
45
+ GIT_OPTIONAL_LOCKS: '0',
46
+ GIT_TERMINAL_PROMPT: '0',
47
+ GCM_INTERACTIVE: 'Never',
48
+ ...extra.env,
49
+ },
27
50
  input: extra.input,
28
51
  signal: extra.signal,
29
52
  });
@@ -183,17 +206,12 @@ const untrackedItems = async (top, names) => {
183
206
  return itemsFromFiles(files, 'untracked');
184
207
  };
185
208
 
186
- const countUntrackedAdded = async (top) => {
187
- try {
188
- const result = await runGit(untrackedArgs([]), top);
189
- if (result.status !== 0) return 0;
190
- const items = untrackedItemsFrom(top, namesFromLs(result.stdout));
191
- let added = 0;
192
- for (const item of items) added += lineDelta(item).added;
193
- return added;
194
- } catch {
195
- return 0;
209
+ const addedOf = (names, top) => {
210
+ let added = 0;
211
+ for (const item of untrackedItemsFrom(top, names)) {
212
+ added += lineDelta(item).added;
196
213
  }
214
+ return added;
197
215
  };
198
216
 
199
217
  const gitText = async (top, spec) => {
@@ -240,13 +258,23 @@ const readDepSides = async (top, rev, origin, rel) => {
240
258
  return { oldText, newText };
241
259
  };
242
260
 
243
- const toplevel = async (cwd, signal) => {
261
+ const toplevelGit = async (cwd, signal) => {
244
262
  const args = ['rev-parse', '--show-toplevel'];
245
263
  const result = await runGit(args, cwd, { signal });
246
264
  if (result.status !== 0) throw new Error('not a git repository');
247
265
  return result.stdout.trim();
248
266
  };
249
267
 
268
+ const toplevel = async (cwd, signal) => {
269
+ if (signal && signal.aborted) throw abortError();
270
+ if (process.env.GIT_DIR || process.env.GIT_WORK_TREE) {
271
+ return toplevelGit(cwd, signal);
272
+ }
273
+ const found = worktreeRoot(cwd);
274
+ if (found) return found;
275
+ return toplevelGit(cwd, signal);
276
+ };
277
+
250
278
  const resolveRev = async (cwd, spec) => {
251
279
  if (!spec) return null;
252
280
  const args = ['rev-parse', '--verify', `${spec}^{commit}`];
@@ -259,29 +287,53 @@ const shortRev = async (top, commit) => {
259
287
  return result.status === 0 ? result.stdout.trim() : commit.slice(0, 7);
260
288
  };
261
289
 
262
- const branchName = (result) => {
290
+ const currentBranch = async (top) => {
291
+ const root = worktreeRoot(top);
292
+ if (root) return branchOf(root);
293
+ const args = ['rev-parse', '--abbrev-ref', 'HEAD'];
294
+ const result = await runGit(args, top);
263
295
  if (result.status !== 0) return '';
264
296
  const name = result.stdout.trim();
265
297
  return name === 'HEAD' ? '' : name;
266
298
  };
267
299
 
268
- const BRANCH_ARGS = ['rev-parse', '--abbrev-ref', 'HEAD'];
269
-
270
- const currentBranch = async (top) => branchName(await runGit(BRANCH_ARGS, top));
271
-
272
- const worktreeSnapshot = (top, outputs, untracked, branch) => {
300
+ const repositoryUrl = (raw) => {
301
+ let value = `${raw ?? ''}`.trim();
302
+ if (!value) return '';
303
+ const scp = /^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/.exec(value);
304
+ if (scp && !value.includes('://')) value = `https://${scp[1]}/${scp[2]}`;
305
+ else if (value.startsWith('git+')) value = value.slice(4);
306
+ let url;
307
+ try {
308
+ url = new URL(value);
309
+ } catch {
310
+ return '';
311
+ }
312
+ if (!['http:', 'https:', 'git:', 'ssh:'].includes(url.protocol)) return '';
313
+ url.protocol = 'https:';
314
+ url.username = '';
315
+ url.password = '';
316
+ if (url.port === '22') url.port = '';
317
+ url.search = '';
318
+ url.hash = '';
319
+ url.pathname = url.pathname.replace(/\.git\/?$/i, '').replace(/\/$/, '');
320
+ return url.toString().replace(/\/$/, '');
321
+ };
322
+
323
+ const worktreeSnapshot = (top, outputs, untracked, branch, repoUrl) => {
273
324
  const staged = itemsFromFiles(parseDiff(outputs[0]), 'staged');
274
325
  const unstaged = itemsFromFiles(parseDiff(outputs[1]), 'unstaged');
275
326
  const grouped = groupByPath([...staged, ...unstaged, ...untracked]);
276
327
  const parsed = dropReviewItems([...grouped.values()].flat());
277
- return { top, parsed, branch };
328
+ return { top, parsed, branch, repositoryUrl: repoUrl };
278
329
  };
279
330
 
280
331
  const snapshotCommit = async (top, commit, files) => {
281
332
  const parsed = dropReviewItems(itemsFromFiles(files, 'commit'));
282
- const names = [shortRev(top, commit), currentBranch(top)];
283
- const [revShort, branch] = await Promise.all(names);
284
- return { top, parsed, rev: commit, revShort, branch };
333
+ const revShort = await shortRev(top, commit);
334
+ const branch = await currentBranch(top);
335
+ const repoUrl = repositoryUrl(remoteOrigin(top));
336
+ return { top, parsed, rev: commit, revShort, branch, repositoryUrl: repoUrl };
285
337
  };
286
338
 
287
339
  function* commitDiff(commit, paths) {
@@ -296,6 +348,38 @@ const worktreeCommands = (paths) => [
296
348
  untrackedArgs(paths),
297
349
  ];
298
350
 
351
+ const gitView = async (top, paths, signal) => {
352
+ const commands = worktreeCommands(paths);
353
+ const running = commands.map((args) => runGit(args, top, { signal }));
354
+ const results = await Promise.all(running);
355
+ const outputs = results.map((result) => requireOk(result).stdout);
356
+ return {
357
+ staged: outputs[0],
358
+ work: outputs[1],
359
+ untracked: namesFromLs(outputs[2]),
360
+ };
361
+ };
362
+
363
+ const worktreeView = async (top, paths, signal) => {
364
+ // status -vv prints every diff and ignores the pathspec
365
+ if (paths.length) return gitView(top, paths, signal);
366
+ const extra = { signal, env: STATUS_ENV };
367
+ const result = await runGit(STATUS_ARGS, top, extra);
368
+ if (result.status !== 0) return gitView(top, paths, signal);
369
+ const parsed = parseStatus(result.stdout);
370
+ if (!parsed) return gitView(top, paths, signal);
371
+ return parsed;
372
+ };
373
+
374
+ const countUntrackedAdded = async (top) => {
375
+ try {
376
+ const view = await worktreeView(top, []);
377
+ return addedOf(view.untracked, top);
378
+ } catch {
379
+ return 0;
380
+ }
381
+ };
382
+
299
383
  const readSnapshot = async (cwd, paths, options) => {
300
384
  const { commit: rev, signal } = options;
301
385
  const top = await toplevel(cwd, signal);
@@ -303,14 +387,12 @@ const readSnapshot = async (cwd, paths, options) => {
303
387
  const files = await runSteps(commitDiff(rev, paths), top, signal);
304
388
  return snapshotCommit(top, rev, files);
305
389
  }
306
- const commands = [...worktreeCommands(paths), BRANCH_ARGS];
307
- const running = commands.map((args) => runGit(args, top, { signal }));
308
- const results = await Promise.all(running);
309
- const branch = branchName(results.pop());
310
- const outputs = results.map((result) => requireOk(result).stdout);
311
- const names = namesFromLs(outputs[2]);
312
- const untracked = await untrackedItems(top, names);
313
- return worktreeSnapshot(top, outputs, untracked, branch);
390
+ const view = await worktreeView(top, paths, signal);
391
+ const untracked = await untrackedItems(top, view.untracked);
392
+ const branch = await currentBranch(top);
393
+ const repoUrl = repositoryUrl(remoteOrigin(top));
394
+ const outputs = [view.staged, view.work];
395
+ return worktreeSnapshot(top, outputs, untracked, branch, repoUrl);
314
396
  };
315
397
 
316
398
  const applyPatch = async (cwd, patch, flags) => {
@@ -446,6 +528,29 @@ const stagePath = async (top, rel) => {
446
528
  if (rel) await gitOk(['add', '--', rel], top);
447
529
  };
448
530
 
531
+ const sumDelta = (items) => {
532
+ let added = 0;
533
+ let removed = 0;
534
+ for (const item of items) {
535
+ const delta = lineDelta(item);
536
+ added += delta.added;
537
+ removed += delta.removed;
538
+ }
539
+ return { added, removed };
540
+ };
541
+
542
+ const diffCounts = async (top) => {
543
+ const view = await worktreeView(top, []);
544
+ const staged = sumDelta(itemsFromFiles(parseDiff(view.staged), 'staged'));
545
+ const open = sumDelta(itemsFromFiles(parseDiff(view.work), 'unstaged'));
546
+ return {
547
+ stagedAdded: staged.added,
548
+ unstagedAdded: open.added + addedOf(view.untracked, top),
549
+ stagedRemoved: staged.removed,
550
+ unstagedRemoved: open.removed,
551
+ };
552
+ };
553
+
449
554
  module.exports = {
450
555
  requireOk,
451
556
  gitOk,
@@ -463,6 +568,7 @@ module.exports = {
463
568
  currentBranch,
464
569
  fileText,
465
570
  countUntrackedAdded,
571
+ diffCounts,
466
572
  listScopeFiles,
467
573
  readSnapshot,
468
574
  editItem,
@@ -59,6 +59,7 @@ const NPM_ACTIONS = {
59
59
  npmDrop: ['delete'],
60
60
  npmLogs: ['l'],
61
61
  npmStop: ['s'],
62
+ npmSolve: ['s'],
62
63
  npmVerbose: ['v'],
63
64
  npmRerun: ['r'],
64
65
  };
@@ -69,6 +70,8 @@ const AGENT_ACTIONS = {
69
70
  agentFast: ['a'],
70
71
  agentContext: ['t'],
71
72
  agentReview: ['p'],
73
+ agentInstall: ['i'],
74
+ agentStart: ['s'],
72
75
  agentStop: ['s'],
73
76
  agentRerun: ['r'],
74
77
  };
@@ -79,6 +82,8 @@ const AGENT_LIST_ACTIONS = {
79
82
  agentFast: AGENT_ACTIONS.agentFast,
80
83
  agentContext: AGENT_ACTIONS.agentContext,
81
84
  agentReview: AGENT_ACTIONS.agentReview,
85
+ agentInstall: AGENT_ACTIONS.agentInstall,
86
+ agentStart: AGENT_ACTIONS.agentStart,
82
87
  };
83
88
 
84
89
  const DASH_TILES = [
@@ -100,6 +105,11 @@ for (const tile of DASH_TILES) {
100
105
  DASH_KEYS[tile.key] = tile.action;
101
106
  }
102
107
  const DASH_IDS = Object.keys(DASH_ACTIONS);
108
+ const WORKTREE_ACTIONS = {
109
+ worktreeNew: ['insert'],
110
+ worktreeDrop: ['delete'],
111
+ worktreeBranch: ['w'],
112
+ };
103
113
  const DASH_ALLOWED = [
104
114
  ...Object.keys(DASH_BLOCKS),
105
115
  'back',
@@ -115,17 +125,28 @@ const REPOS_ALLOWED = [
115
125
  'repoDown',
116
126
  'open',
117
127
  'back',
128
+ 'worktreeNew',
129
+ 'worktreeDrop',
118
130
  'quit',
119
131
  'theme',
120
132
  ];
121
133
  const DASH_HIDDEN = ACTION_IDS.filter((id) => id !== 'quit');
122
134
 
123
135
  const ACTION_HOT = {
136
+ textCut: 'ctrl+x',
137
+ textCopy: 'ctrl+c',
138
+ textPaste: 'ctrl+v',
139
+ textDelete: 'del',
140
+ textSelectAll: 'ctrl+a',
141
+ open: 'enter',
124
142
  prev: '←',
125
143
  next: '→',
126
144
  drop: 'del',
145
+ worktreeDrop: 'del',
146
+ worktreeBranch: 'w',
127
147
  newBranch: 'ins',
128
148
  newCommit: 'ins',
149
+ worktreeNew: 'ins',
129
150
  feedback: 'q',
130
151
  check: 'space',
131
152
  taskUp: 'ctrl+up',
@@ -143,7 +164,13 @@ const ACTION_HOT = {
143
164
  };
144
165
 
145
166
  const ACTION_LABEL = {
167
+ textCut: 'ctrl+x cut',
168
+ textCopy: 'ctrl+c copy',
169
+ textPaste: 'ctrl+v paste',
170
+ textDelete: 'delete',
171
+ textSelectAll: 'ctrl+a select all',
146
172
  ...DASH_BLOCKS,
173
+ open: 'enter open',
147
174
  revert: 'drop',
148
175
  check: 'space',
149
176
  taskUp: 'ctrl+up',
@@ -161,6 +188,9 @@ const ACTION_LABEL = {
161
188
  drop: 'delete',
162
189
  newBranch: 'insert',
163
190
  newCommit: 'insert',
191
+ worktreeNew: 'insert',
192
+ worktreeDrop: 'delete',
193
+ worktreeBranch: 'worktree',
164
194
  feedback: 'quote',
165
195
  npmEdit: 'edit',
166
196
  npmNew: 'insert',
@@ -171,6 +201,7 @@ const ACTION_LABEL = {
171
201
  packageWanted: 'wanted',
172
202
  packageLatest: 'latest',
173
203
  npmStop: 'stop',
204
+ npmSolve: 'solve',
174
205
  npmVerbose: 'verbose',
175
206
  npmRerun: 're-run',
176
207
  agentModel: 'model',
@@ -178,6 +209,8 @@ const ACTION_LABEL = {
178
209
  agentFast: 'fast',
179
210
  agentContext: 'context',
180
211
  agentReview: 'plan',
212
+ agentInstall: 'install',
213
+ agentStart: 'start',
181
214
  agentStop: 'stop',
182
215
  agentRerun: 're-run',
183
216
  quit: 'q',
@@ -384,7 +417,12 @@ const keysForActions = (actions) => {
384
417
  const PANE_ONLY = ['branch', 'file', 'diff'];
385
418
  const REVIEW_DROP = { d: 'revert' };
386
419
  const DIFF_KEYS = { ...REVIEW_DROP, q: 'feedback', 'ctrl-l': 'lines' };
387
- const BRANCH_KEYS = keysForActions(BRANCH_ACTIONS);
420
+ const BRANCH_KEYS = {
421
+ ...keysForActions(BRANCH_ACTIONS),
422
+ w: 'worktreeBranch',
423
+ left: 'treeLeft',
424
+ right: 'treeRight',
425
+ };
388
426
  const COMMIT_KEYS = { ...keysForActions(COMMIT_ACTIONS), s: 'push' };
389
427
  const NPM_KEYS = {
390
428
  e: 'npmEdit',
@@ -413,6 +451,8 @@ const PANE_KEYS = {
413
451
  j: 'repoDown',
414
452
  p: 'repoLeft',
415
453
  n: 'repoRight',
454
+ insert: 'worktreeNew',
455
+ delete: 'worktreeDrop',
416
456
  },
417
457
  };
418
458
 
@@ -427,7 +467,8 @@ const KEY_TO_ACTION = {
427
467
  };
428
468
 
429
469
  const actionFromKey = (key, pane) => {
430
- if (LIST_PANES.includes(pane) && (key === 'left' || key === 'right')) {
470
+ const sideways = key === 'left' || key === 'right';
471
+ if (LIST_PANES.includes(pane) && sideways && pane !== 'branches') {
431
472
  return null;
432
473
  }
433
474
  const paneKeys = PANE_KEYS[pane];
@@ -443,6 +484,7 @@ const ACTION_KEYS = {
443
484
  ...NPM_ACTIONS,
444
485
  ...COMMIT_ACTIONS,
445
486
  ...BRANCH_ACTIONS,
487
+ ...WORKTREE_ACTIONS,
446
488
  ...ACTIONS,
447
489
  };
448
490
 
@@ -450,6 +492,17 @@ const actionKeys = (id) => ACTION_KEYS[id] ?? [];
450
492
 
451
493
  const actionLetter = (id) => ACTION_HOT[id] ?? actionKeys(id)[0];
452
494
 
495
+ const ACTION_CAPTION = {
496
+ open: 'open',
497
+ check: 'check',
498
+ textCut: 'cut',
499
+ textCopy: 'copy',
500
+ textPaste: 'paste',
501
+ textSelectAll: 'select all',
502
+ };
503
+
504
+ const actionCaption = (id) => ACTION_CAPTION[id] ?? ACTION_LABEL[id] ?? id;
505
+
453
506
  const buttonWord = (id) => {
454
507
  const word = ACTION_LABEL[id] ?? ACTION_HOT[id] ?? id;
455
508
  const mark = actionLetter(id);
@@ -486,6 +539,15 @@ const CONFIRM = {
486
539
  escape: 'decline',
487
540
  },
488
541
  },
542
+ confirmAgentInstall: {
543
+ choices: YES_NO,
544
+ prefix: (view) => ` install ${view.agentInstallPackage ?? ''}?`,
545
+ keys: {
546
+ y: 'confirm',
547
+ n: 'cancel',
548
+ escape: 'cancel',
549
+ },
550
+ },
489
551
  confirmDrop: {
490
552
  choices: YES_NO,
491
553
  prefix: (view) => ` drop ${view.dropName ?? ''}?`,
@@ -495,6 +557,24 @@ const CONFIRM = {
495
557
  escape: 'cancel',
496
558
  },
497
559
  },
560
+ confirmWorktree: {
561
+ choices: YES_NO,
562
+ prefix: (view) => ` remove ${view.dropName ?? ''}?`,
563
+ keys: {
564
+ y: 'confirm',
565
+ n: 'cancel',
566
+ escape: 'cancel',
567
+ },
568
+ },
569
+ confirmCreateWorktree: {
570
+ choices: YES_NO,
571
+ prefix: (view) => ` create ${view.worktreeTarget ?? ''}?`,
572
+ keys: {
573
+ y: 'confirm',
574
+ n: 'cancel',
575
+ escape: 'cancel',
576
+ },
577
+ },
498
578
  confirmPush: {
499
579
  choices: [{ letter: 'f', label: 'force push', lead: ' ' }],
500
580
  prefix: ' Need force-push?',
@@ -571,6 +651,7 @@ module.exports = {
571
651
  isFixupCommit,
572
652
  actionFromKey,
573
653
  actionLetter,
654
+ actionCaption,
574
655
  buttonWord,
575
656
  CONFIRM,
576
657
  CONFIRM_MODE,
@@ -139,6 +139,15 @@ class Editor {
139
139
  this.edit(next, this.cursor - size);
140
140
  }
141
141
 
142
+ backspaceWord() {
143
+ this.wantCol = null;
144
+ if (this.removeSpan()) return;
145
+ const { parts, i } = graphemeIndex(this.text, this.cursor);
146
+ const start = offsetAt(parts, wordIndex(parts, i, -1));
147
+ if (start === this.cursor) return;
148
+ this.edit(this.text.slice(0, start) + this.text.slice(this.cursor), start);
149
+ }
150
+
142
151
  delete() {
143
152
  this.wantCol = null;
144
153
  if (this.removeSpan()) return;
package/lib/input/keys.js CHANGED
@@ -45,6 +45,15 @@ const CSI_TILDE = {
45
45
  24: 'f12',
46
46
  };
47
47
 
48
+ // Kitty CSI-u and xterm modifyOtherKeys. 13 is Enter, 10 is line feed.
49
+ const CSI_U = {
50
+ 10: 'enter',
51
+ 13: 'enter',
52
+ 127: 'backspace',
53
+ };
54
+
55
+ const MODIFY_OTHER = '27';
56
+
48
57
  const CSI_MAX = 16;
49
58
 
50
59
  const LITERAL_KEY = {
@@ -71,20 +80,40 @@ const keyAt = (key, end) => ({ event: { type: 'key', key }, end });
71
80
  const modifiedKey = (base, modText) => {
72
81
  const arrow = ARROW_KEYS.includes(base);
73
82
  const end = EDGE_KEYS.includes(base);
74
- if (!arrow && !end) return base;
75
83
  const mod = parseInt(modText, 10);
76
84
  const bits = Number.isFinite(mod) ? mod - 1 : 0;
77
85
  const ctrl = (bits & 4) !== 0;
78
86
  const shift = (bits & 1) !== 0;
87
+ if (base === 'backspace' && ctrl) return 'ctrl-backspace';
88
+ if (base === 'enter') {
89
+ if (ctrl && shift) return 'ctrl-shift-enter';
90
+ if (ctrl) return 'ctrl-enter';
91
+ if (shift) return 'shift-enter';
92
+ return 'enter';
93
+ }
94
+ if (!arrow && !end) return base;
79
95
  if (ctrl && shift && arrow) return `ctrl-shift-${base}`;
80
96
  if (ctrl && arrow) return `ctrl-${base}`;
81
97
  if (shift) return `shift-${base}`;
82
98
  return base;
83
99
  };
84
100
 
101
+ const NEWLINE_KEYS = ['ctrl-enter', 'shift-enter', 'ctrl-shift-enter'];
102
+
103
+ const isNewlineKey = (key) => NEWLINE_KEYS.includes(key);
104
+
105
+ const otherModified = (params) => {
106
+ const base = CSI_U[params[2]];
107
+ if (!base) return null;
108
+ return modifiedKey(base, params[1] ?? '1');
109
+ };
110
+
85
111
  const keyFromCsi = (paramsText, final) => {
86
112
  const params = paramsText ? paramsText.split(';') : [];
87
- const base = final === '~' ? CSI_TILDE[params[0]] : CSI_KEYS[final];
113
+ if (final === '~' && params[0] === MODIFY_OTHER) return otherModified(params);
114
+ let base = CSI_KEYS[final];
115
+ if (final === '~') base = CSI_TILDE[params[0]];
116
+ if (final === 'u') base = CSI_U[params[0]];
88
117
  if (!base) return null;
89
118
  if (params.length < 2) return base;
90
119
  return modifiedKey(base, params[1]);
@@ -108,6 +137,7 @@ const mouseKind = (button, press) => {
108
137
  const wheel = MOUSE_WHEEL[button];
109
138
  if (wheel) return wheel;
110
139
  if (!press) return 'release';
140
+ if ((button & 35) === 35) return 'move';
111
141
  return button & 32 ? 'drag' : 'press';
112
142
  };
113
143
 
@@ -190,4 +220,10 @@ const hitAction = (hits, x) => {
190
220
  return null;
191
221
  };
192
222
 
193
- module.exports = { ACTIONS, decodeChunk, hitAction, isTextKey };
223
+ module.exports = {
224
+ ACTIONS,
225
+ decodeChunk,
226
+ hitAction,
227
+ isTextKey,
228
+ isNewlineKey,
229
+ };
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { Editor } = require('./editor.js');
4
- const { isTextKey } = require('./keys.js');
4
+ const { isTextKey, isNewlineKey } = require('./keys.js');
5
5
  const clipboard = require('../term/clipboard.js');
6
6
 
7
7
  const copyLine = (editor, ui) => {
@@ -33,6 +33,7 @@ const EDIT_OP = {
33
33
  'ctrl-shift-left': (editor) => editor.moveWord(-1, true),
34
34
  'ctrl-shift-right': (editor) => editor.moveWord(1, true),
35
35
  backspace: (editor) => editor.backspace(),
36
+ 'ctrl-backspace': (editor) => editor.backspaceWord(),
36
37
  delete: (editor) => editor.delete(),
37
38
  };
38
39
 
@@ -56,6 +57,10 @@ const lineKey = (editor, key, ui, ends) => {
56
57
  copyLine(editor, ui);
57
58
  return 'caret';
58
59
  }
60
+ if (isNewlineKey(key)) {
61
+ editor.insert('\n');
62
+ return 'text';
63
+ }
59
64
  const op = (ends ? LINE_ENDS : LINE_OP)[key];
60
65
  if (!op && !isTextKey(key)) return null;
61
66
  const before = editor.text;
@@ -64,8 +69,8 @@ const lineKey = (editor, key, ui, ends) => {
64
69
  return editor.text === before ? 'caret' : 'text';
65
70
  };
66
71
 
67
- const lineField = (name) => {
68
- const editor = new Editor('');
72
+ const lineField = (name, text = '') => {
73
+ const editor = new Editor(text);
69
74
  return {
70
75
  editor,
71
76
  get [name]() {