bunmicro 0.9.10 → 0.9.19

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.
package/src/index.js CHANGED
@@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url";
8
8
  import process from "node:process";
9
9
  import { Config } from "./config/config.js";
10
10
  import { defaultAllSettings, OPTION_CHOICES, LOCAL_SETTINGS } from "./config/defaults.js";
11
+ import { cleanConfig } from "./config/clean.js";
11
12
  import { RuntimeRegistry, RTColorscheme, RTHelp } from "./runtime/registry.js";
12
13
  import { PluginManager } from "./plugins/manager.js";
13
14
  import { JsPluginManager, buildMicroGlobal, runAction, listActions } from "./plugins/js-bridge.js";
@@ -17,8 +18,8 @@ import { Highlighter } from "./highlight/highlighter.js";
17
18
  import { DISABLE_MOUSE, parseInputEvents, parseKey } from "./screen/events.js";
18
19
  import { Screen } from "./screen/screen.js";
19
20
  import { VT100 } from "./screen/vt100.js";
20
- import { ClipboardManager } from "./platform/clipboard.js";
21
- import { platformId, run as runCommand, runSync, fetchHttp, fetchHttpBytes, detectHttpBackend } from "./platform/commands.js";
21
+ import { ClipboardManager, probeOSC52, osc52Clipboard } from "./platform/clipboard.js";
22
+ import { platformId, run as runCommand, runSync, fetchHttpBytes, detectHttpBackend } from "./platform/commands.js";
22
23
  import { shellSplit } from "./shell/shell.js";
23
24
  import { styleToAnsi } from "./display/ansi-style.js";
24
25
  import { encodeBinaryToBuffer, decodeBinaryBytes } from "./buffer/fixed3-codec.js";
@@ -92,9 +93,11 @@ const DEFAULT_SETTINGS = {
92
93
  savecursor: false,
93
94
  softwrap: false,
94
95
  wordwrap: false,
96
+ pageoverlap: 2,
95
97
  scrollmargin: 3,
96
98
  reload: "prompt",
97
99
  encoding: "utf-8",
100
+ fileformat: process.platform === "win32" ? "dos" : "unix",
98
101
  "comment.type": "",
99
102
  commenttype: "",
100
103
  trailingws: false,
@@ -136,8 +139,7 @@ function isHttpUrl(value) {
136
139
  return String(value ?? "").startsWith("http://") || String(value ?? "").startsWith("https://");
137
140
  }
138
141
 
139
- async function readTextFileWithEncoding(path, encoding = "utf-8") {
140
- const bytes = new Uint8Array(await Bun.file(path).arrayBuffer());
142
+ function decodeTextBytesWithEncoding(bytes, encoding = "utf-8") {
141
143
  if (normalizeEncodingLabel(encoding) === "hex3") {
142
144
  return { text: encodeBinaryToBuffer(bytes).toString("latin1"), encoding: "hex3" };
143
145
  }
@@ -145,13 +147,14 @@ async function readTextFileWithEncoding(path, encoding = "utf-8") {
145
147
  return { text: decoder.decode(bytes), encoding: decoder.encoding };
146
148
  }
147
149
 
150
+ async function readTextFileWithEncoding(path, encoding = "utf-8") {
151
+ const bytes = new Uint8Array(await Bun.file(path).arrayBuffer());
152
+ return decodeTextBytesWithEncoding(bytes, encoding);
153
+ }
154
+
148
155
  async function fetchTextWithEncoding(url, encoding = "utf-8") {
149
156
  const bytes = await fetchHttpBytes(url);
150
- if (normalizeEncodingLabel(encoding) === "hex3") {
151
- return { text: encodeBinaryToBuffer(new Uint8Array(bytes)).toString("latin1"), encoding: "hex3" };
152
- }
153
- const decoder = new TextDecoder(normalizeEncodingLabel(encoding));
154
- return { text: decoder.decode(bytes), encoding: decoder.encoding };
157
+ return decodeTextBytesWithEncoding(new Uint8Array(bytes), encoding);
155
158
  }
156
159
 
157
160
  function normalizeEncodingLabel(encoding = "utf-8") {
@@ -160,6 +163,21 @@ function normalizeEncodingLabel(encoding = "utf-8") {
160
163
  return new TextDecoder(s).encoding;
161
164
  }
162
165
 
166
+ function detectFileFormat(text, fallback = DEFAULT_SETTINGS.fileformat) {
167
+ if (text.length === 0) return fallback === "dos" ? "dos" : "unix";
168
+ const newlineIdx = text.indexOf("\n");
169
+ if (newlineIdx < 0) return "unix";
170
+ return newlineIdx > 0 && text.charCodeAt(newlineIdx - 1) === 13 ? "dos" : "unix";
171
+ }
172
+
173
+ function normalizeBufferText(text) {
174
+ return String(text).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
175
+ }
176
+
177
+ function encodeBufferTextForFile(text, fileformat) {
178
+ return fileformat === "dos" ? text.replace(/\n/g, "\r\n") : text;
179
+ }
180
+
163
181
  function isReadonlyBuffer(buf) {
164
182
  return Boolean(buf?.readonly || buf?.Settings?.readonly || buf?.Type?.Readonly);
165
183
  }
@@ -469,6 +487,7 @@ function parseArgs(argv) {
469
487
  help: false,
470
488
  clean: false,
471
489
  cat: false,
490
+ docs: false,
472
491
  configDir: "",
473
492
  debug: false,
474
493
  profile: false,
@@ -484,6 +503,7 @@ function parseArgs(argv) {
484
503
  else if (arg === "-help" || arg === "--help" || arg === "-h") flags.help = true;
485
504
  else if (arg === "-clean") flags.clean = true;
486
505
  else if (arg === "--cat" || arg === "-cat" || arg === "--ccat" || arg === "-ccat" || arg === "--bat" || arg === "-bat" || arg === "--glow" || arg === "-glow") flags.cat = true;
506
+ else if (arg === "--docs" || arg === "--readme") flags.docs = true;
487
507
  else if (arg === "-debug") flags.debug = true;
488
508
  else if (arg === "-profile") flags.profile = true;
489
509
  else if (arg === "-config-dir") flags.configDir = argv[++i] ?? "";
@@ -500,17 +520,13 @@ function parseArgs(argv) {
500
520
 
501
521
  function usage() {
502
522
  return [
503
- `Usage: ${pkg.name} [OPTION]... [FILE]...`,
523
+ `Usage:
524
+ ${pkg.name} [OPTIONs] [FILEs] [+line[.subrow][:col]]\n`,
504
525
  "-clean",
505
- " Clean configuration directory and exit (not implemented in Bun port)",
526
+ " Clean configuration directory and exit",
506
527
  "-config-dir dir",
507
528
  " Specify a custom location for configuration directory",
508
- "-debug",
509
- " Enable debug logging",
510
- "-help, --help, -h",
511
- " Show this help and exit",
512
- "-options",
513
- " Show option help and exit",
529
+ "",
514
530
  "-plugin list",
515
531
  " List installed plugins",
516
532
  "-plugin available|avail",
@@ -523,20 +539,28 @@ function usage() {
523
539
  " Remove installed plugin(s)",
524
540
  "-plugin update [name]...",
525
541
  " Update installed plugin(s) (all if no name given)",
526
-
527
- "-version, -V",
528
- " Show version number and information and exit",
529
- "--cat, --ccat, --bat, --glow",
530
- " Syntax-highlight file(s) and write to stdout, then exit (.md uses Bun.markdown.ansi)",
542
+ "",
531
543
  "-<option> value",
532
544
  " Set an option for this session",
545
+ "-options",
546
+ " Show option help and exit\n",
547
+ "--cat, --ccat, --bat, --glow",
548
+ " Syntax-highlight file(s) and write to stdout, then exit (.md uses Bun.markdown.ansi)\n",
549
+ "-help, -h, --help",
550
+ " Show this help & exit",
551
+ "-version, -V, --version",
552
+ " Show version+backend info & exit",
553
+ "--docs, --readme",
554
+ ` Show ${pkg.name}'s README.md & exit`,
555
+
556
+
533
557
  ].join("\n");
534
558
  }
535
559
 
536
560
  function parseInput(args) {
537
561
  const files = [];
538
562
  const command = {
539
- startCursor: { x: -1, y: -1 },
563
+ startCursor: { line: -1, subRow: 0, col: 1 },
540
564
  searchRegex: "",
541
565
  searchAfterStart: false,
542
566
  };
@@ -545,25 +569,19 @@ function parseInput(args) {
545
569
 
546
570
  for (let i = 0; i < args.length; i++) {
547
571
  const arg = args[i];
548
- const pos = arg.match(/^\+(\d+)(?::(\d+))?$/);
572
+ const pos = arg.match(/^\+(-?\d+(?:\.\d+)?)(?::(-?\d+))?$/);
549
573
  const search = arg.match(/^\+\/(.+)$/);
550
- const cursorFile = arg.match(/^(.+):(\d+)(?::(\d+))$/);
574
+ const cursorFile = arg.match(/^(.+):(-?\d+(?:\.\d+)?)(?::(-?\d+))?$/);
551
575
 
552
576
  if (pos) {
553
- command.startCursor = {
554
- x: pos[2] ? Number(pos[2]) - 1 : 0,
555
- y: Number(pos[1]) - 1,
556
- };
577
+ command.startCursor = parseLineCol(`${pos[1]}${pos[2] ? `:${pos[2]}` : ""}`);
557
578
  posIndex = i;
558
579
  } else if (search) {
559
580
  command.searchRegex = search[1];
560
581
  searchIndex = i;
561
582
  } else if (DEFAULT_SETTINGS.parsecursor && cursorFile && existsSync(cursorFile[1])) {
562
583
  files.push(cursorFile[1]);
563
- command.startCursor = {
564
- x: cursorFile[3] ? Number(cursorFile[3]) - 1 : 0,
565
- y: Number(cursorFile[2]) - 1,
566
- };
584
+ command.startCursor = parseLineCol(`${cursorFile[2]}${cursorFile[3] ? `:${cursorFile[3]}` : ""}`);
567
585
  posIndex = i;
568
586
  } else {
569
587
  files.push(arg);
@@ -582,9 +600,9 @@ class BufferModel {
582
600
  this.path = path;
583
601
  this.type = type;
584
602
  this.name = path ? basename(path) : "No name";
585
- this.fileformat = text.includes("\r\n") ? "dos" : "unix";
603
+ this.fileformat = detectFileFormat(text, DEFAULT_SETTINGS.fileformat);
586
604
  this.encoding = normalizeEncodingLabel(encoding);
587
- this.lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
605
+ this.lines = normalizeBufferText(text).split("\n");
588
606
  if (this.lines.length === 0) this.lines = [""];
589
607
  this.cursor = { x: 0, y: 0 };
590
608
  this.scroll = { x: 0, y: 0, row: 0 };
@@ -623,8 +641,11 @@ class BufferModel {
623
641
  savecursor: DEFAULT_SETTINGS.savecursor,
624
642
  softwrap: DEFAULT_SETTINGS.softwrap,
625
643
  wordwrap: DEFAULT_SETTINGS.wordwrap,
644
+ pageoverlap: DEFAULT_SETTINGS.pageoverlap,
626
645
  scrollmargin: DEFAULT_SETTINGS.scrollmargin,
627
646
  reload: DEFAULT_SETTINGS.reload,
647
+ eofnewline: DEFAULT_SETTINGS.eofnewline,
648
+ fileformat: this.fileformat,
628
649
  trailingws: DEFAULT_SETTINGS.trailingws,
629
650
  encoding: this.encoding,
630
651
  readonly,
@@ -633,9 +654,13 @@ class BufferModel {
633
654
  this.AbsPath = path;
634
655
  this.Type = { Scratch: type !== "default", Kind: 0, Readonly: readonly };
635
656
 
636
- if (command.startCursor && command.startCursor.y >= 0) {
637
- this.cursor.y = clamp(command.startCursor.y, 0, this.lines.length - 1);
638
- this.cursor.x = clamp(command.startCursor.x, 0, this.lines[this.cursor.y].length);
657
+ if (commandHasStartCursor(command)) {
658
+ if (command.startCursor.subRow > 0) {
659
+ this.gotoLoc(command.startCursor.line, 1);
660
+ this._pendingVisualGoto = { subRow: command.startCursor.subRow, col: command.startCursor.col };
661
+ } else {
662
+ this.gotoLoc(command.startCursor.line, command.startCursor.col);
663
+ }
639
664
  }
640
665
  if (command.searchRegex) this.search(command.searchRegex, command.searchAfterStart);
641
666
  }
@@ -897,8 +922,8 @@ class BufferModel {
897
922
  return true;
898
923
  }
899
924
 
900
- page(delta, height) {
901
- this.cursor.y += delta * Math.max(1, height - 2);
925
+ page(delta, amount) {
926
+ this.cursor.y += delta * Math.max(1, amount);
902
927
  this.ensureCursor();
903
928
  }
904
929
 
@@ -1019,8 +1044,9 @@ class BufferModel {
1019
1044
  const text = decoded.text;
1020
1045
  this.encoding = decoded.encoding;
1021
1046
  this.Settings.encoding = decoded.encoding;
1022
- this.fileformat = text.includes("\r\n") ? "dos" : "unix";
1023
- this.lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
1047
+ this.fileformat = detectFileFormat(text, this.Settings.fileformat ?? DEFAULT_SETTINGS.fileformat);
1048
+ this.Settings.fileformat = this.fileformat;
1049
+ this.lines = normalizeBufferText(text).split("\n");
1024
1050
  if (this.lines.length === 0) this.lines = [""];
1025
1051
  this.modTimeMs = null;
1026
1052
  this.readonly = false;
@@ -1044,8 +1070,9 @@ class BufferModel {
1044
1070
  const text = decoded.text;
1045
1071
  this.encoding = decoded.encoding;
1046
1072
  this.Settings.encoding = decoded.encoding;
1047
- this.fileformat = text.includes("\r\n") ? "dos" : "unix";
1048
- this.lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
1073
+ this.fileformat = detectFileFormat(text, this.Settings.fileformat ?? DEFAULT_SETTINGS.fileformat);
1074
+ this.Settings.fileformat = this.fileformat;
1075
+ this.lines = normalizeBufferText(text).split("\n");
1049
1076
  if (this.lines.length === 0) this.lines = [""];
1050
1077
  this.modTimeMs = info.mtimeMs;
1051
1078
  this.readonly = !canWritePath(this.path);
@@ -1080,8 +1107,8 @@ class BufferModel {
1080
1107
  this.message = `Saved ${path}`;
1081
1108
  return;
1082
1109
  }
1083
- if (DEFAULT_SETTINGS.eofnewline && !text.endsWith("\n")) text += "\n";
1084
- await Bun.write(path, text);
1110
+ if ((this.Settings.eofnewline ?? DEFAULT_SETTINGS.eofnewline) && !text.endsWith("\n")) text += "\n";
1111
+ await Bun.write(path, encodeBufferTextForFile(text, this.Settings.fileformat ?? this.fileformat));
1085
1112
  this.encoding = "utf-8";
1086
1113
  this.Settings.encoding = "utf-8";
1087
1114
  this.path = path;
@@ -1222,19 +1249,27 @@ class BufferModel {
1222
1249
  SetOption(option, value) {
1223
1250
  const oldValue = this.Settings[option];
1224
1251
  const parsed = parseOptionValue(value);
1225
- this.Settings[option] = option === "encoding" ? normalizeEncodingLabel(parsed) : parsed;
1252
+ if (option === "fileformat" && !OPTION_CHOICES.fileformat.includes(String(parsed))) {
1253
+ throw new Error(`Invalid value for fileformat: ${parsed}`);
1254
+ }
1255
+ this.Settings[option] = option === "encoding" ? normalizeEncodingLabel(parsed) : option === "fileformat" ? String(parsed) : parsed;
1226
1256
  if (option === "filetype") this.filetype = String(parsed);
1227
1257
  if (option === "encoding") this.encoding = this.Settings.encoding;
1258
+ if (option === "fileformat") this.fileformat = this.Settings.fileformat === "dos" ? "dos" : "unix";
1228
1259
  if (option === "readonly") { this.readonly = Boolean(parsed); this.Type.Readonly = this.readonly; }
1229
- if (option in DEFAULT_SETTINGS) DEFAULT_SETTINGS[option] = this.Settings[option];
1260
+ if (option in DEFAULT_SETTINGS && option !== "fileformat") DEFAULT_SETTINGS[option] = this.Settings[option];
1230
1261
  this._onOptionChange?.(option, oldValue, this.Settings[option]);
1231
1262
  }
1232
1263
 
1233
1264
  DoSetOptionNative(option, value) {
1234
1265
  const oldValue = this.Settings[option];
1235
- this.Settings[option] = option === "encoding" ? normalizeEncodingLabel(value) : value;
1266
+ if (option === "fileformat" && !OPTION_CHOICES.fileformat.includes(String(value))) {
1267
+ throw new Error(`Invalid value for fileformat: ${value}`);
1268
+ }
1269
+ this.Settings[option] = option === "encoding" ? normalizeEncodingLabel(value) : option === "fileformat" ? String(value) : value;
1236
1270
  if (option === "filetype") this.filetype = String(value);
1237
1271
  if (option === "encoding") this.encoding = this.Settings.encoding;
1272
+ if (option === "fileformat") this.fileformat = this.Settings.fileformat === "dos" ? "dos" : "unix";
1238
1273
  if (option === "readonly") { this.readonly = Boolean(value); this.Type.Readonly = this.readonly; }
1239
1274
  this._onOptionChange?.(option, oldValue, this.Settings[option]);
1240
1275
  }
@@ -1263,7 +1298,7 @@ class BufferModel {
1263
1298
  }
1264
1299
 
1265
1300
  Bytes() {
1266
- return this.lines.join("\n");
1301
+ return encodeBufferTextForFile(this.lines.join("\n"), this.Settings.fileformat ?? this.fileformat);
1267
1302
  }
1268
1303
 
1269
1304
  Size() {
@@ -1636,6 +1671,10 @@ class App {
1636
1671
  this._acHScroll = 0;
1637
1672
  this._suppressMouseUntilUp = false;
1638
1673
  this._undoInsertChain = false;
1674
+ this._freshClip = false;
1675
+ this._messageClickAction = null;
1676
+ this._messageRowY = null;
1677
+ this._messageRowClickZone = null;
1639
1678
  }
1640
1679
 
1641
1680
  get tab() { return this.tabs[this.activeTabIdx]; }
@@ -1645,6 +1684,22 @@ class App {
1645
1684
  get active() { return this.activeTabIdx; }
1646
1685
  get buffers() { return this.tabs.map(t => t.buffer).filter(Boolean); }
1647
1686
 
1687
+ paneForBuffer(buffer) {
1688
+ for (const tab of this.tabs) {
1689
+ const pane = tab.panes().find((p) => p.buffer === buffer);
1690
+ if (pane) return pane;
1691
+ }
1692
+ return null;
1693
+ }
1694
+
1695
+ formatCursorLocation(buffer = this.buffer, pane = null) {
1696
+ return formatCursorLocation(buffer, pane ?? this.paneForBuffer(buffer) ?? this.pane);
1697
+ }
1698
+
1699
+ formatAbsoluteCursorLocation(buffer = this.buffer) {
1700
+ return formatAbsoluteCursorLocation(buffer);
1701
+ }
1702
+
1648
1703
  async start() {
1649
1704
  this._started = true;
1650
1705
  // When stdin was a pipe (content already consumed in loadBuffers), open the
@@ -1666,7 +1721,10 @@ class App {
1666
1721
  _activeTtyStream = this._ttyStream;
1667
1722
  this._ttyStream.setRawMode?.(true);
1668
1723
  this._ttyStream.resume();
1669
- this._ttyStream.on("data", (data) => this.handleInput(data));
1724
+ const clipSetting = this.context?.config?.getGlobalOption("clipboard") ?? "external";
1725
+ await this.reinitializeClipboard(clipSetting);
1726
+ this._inputHandler = (data) => this.handleInput(data);
1727
+ this._ttyStream.on("data", this._inputHandler);
1670
1728
  process.stdout.on("resize", () => {
1671
1729
  const resize = this.screen.updateSize();
1672
1730
  this.rows = resize.rows;
@@ -1687,6 +1745,15 @@ class App {
1687
1745
  }
1688
1746
  }
1689
1747
 
1748
+ async reinitializeClipboard(setting) {
1749
+ if (this._inputHandler) this._ttyStream?.removeListener("data", this._inputHandler);
1750
+ try {
1751
+ await this.clipboard.initFromSetting(setting, this._ttyStream, process.stdout, 150);
1752
+ } finally {
1753
+ if (this._inputHandler) this._ttyStream?.on("data", this._inputHandler);
1754
+ }
1755
+ }
1756
+
1690
1757
  async stop(code = 0) {
1691
1758
  this.running = false;
1692
1759
  for (const tab of this.tabs)
@@ -1912,7 +1979,23 @@ class App {
1912
1979
  const statusStyle = this.context.colorscheme?.get("statusline") ?? { ...defaultStyle, reverse: true };
1913
1980
  const style = { ...statusStyle, reverse: false };
1914
1981
  putText(this.screen, 0, row, " ".repeat(this.cols), style, this.cols);
1915
- putText(this.screen, 0, row, String(message).slice(0, this.cols), style, this.cols);
1982
+ this._messageRowY = row;
1983
+ this._messageRowClickZone = null;
1984
+ const msg = String(message);
1985
+ // detect [AltMethod] prefix — render it underlined as a clickable button
1986
+ if (this._messageClickAction && msg.startsWith("[")) {
1987
+ const close = msg.indexOf("]");
1988
+ if (close > 0) {
1989
+ const btnText = msg.slice(0, close + 1);
1990
+ const rest = msg.slice(close + 1);
1991
+ const btnStyle = { ...style, underline: true };
1992
+ let sx = putText(this.screen, 0, row, btnText, btnStyle, this.cols);
1993
+ putText(this.screen, sx, row, rest.slice(0, this.cols - sx), style, this.cols - sx);
1994
+ this._messageRowClickZone = { start: 0, end: sx };
1995
+ return;
1996
+ }
1997
+ }
1998
+ putText(this.screen, 0, row, msg.slice(0, this.cols), style, this.cols);
1916
1999
  }
1917
2000
 
1918
2001
  renderKeyMenu(defaultStyle, statusRow) {
@@ -2008,9 +2091,46 @@ class App {
2008
2091
  return -1;
2009
2092
  }
2010
2093
 
2094
+ gotoLocation(buf, loc, pane = this.pane) {
2095
+ if (!buf) return;
2096
+ if (!loc?.subRow) {
2097
+ buf.gotoLoc(loc.line, loc.col);
2098
+ return;
2099
+ }
2100
+ buf.gotoLoc(loc.line, 1);
2101
+ this.applyVisualGoto(buf, pane, loc.subRow, loc.col);
2102
+ }
2103
+
2104
+ applyVisualGoto(buf, pane, subRow, col = 1) {
2105
+ if (!buf || !pane) return;
2106
+ const softwrap = buf.Settings?.softwrap ?? false;
2107
+ if (!softwrap) {
2108
+ buf.gotoLoc(buf.cursor.y + 1, col);
2109
+ return;
2110
+ }
2111
+ const gutterW = editorGutterWidth(buf);
2112
+ const bufW = Math.max(1, pane.w - gutterW);
2113
+ const wordwrap = buf.Settings?.wordwrap ?? false;
2114
+ const tabsize = buf.Settings?.tabsize ?? DEFAULT_SETTINGS.tabsize;
2115
+ const line = buf.lines[buf.cursor.y] ?? "";
2116
+ const breaks = softwrapBreaks(line, bufW, wordwrap, tabsize);
2117
+ const targetSubRow = clamp(Math.trunc(Number(subRow) || 0), 0, Math.max(0, breaks.length - 1));
2118
+ const segStart = breaks[targetSubRow] ?? 0;
2119
+ buf.cursor.x = visualColToCharIdx(line, segStart, Math.max(0, Math.trunc(Number(col) || 1) - 1));
2120
+ buf.ensureCursor();
2121
+ }
2122
+
2123
+ applyPendingVisualGoto(pane) {
2124
+ const pending = pane?.buffer?._pendingVisualGoto;
2125
+ if (!pending) return;
2126
+ delete pane.buffer._pendingVisualGoto;
2127
+ this.applyVisualGoto(pane.buffer, pane, pending.subRow, pending.col);
2128
+ }
2129
+
2011
2130
  renderEditorPane(pane, defaultStyle) {
2012
2131
  const buf = pane.buffer;
2013
2132
  if (!buf) return;
2133
+ this.applyPendingVisualGoto(pane);
2014
2134
  this.updateScrollForPane(pane);
2015
2135
  const gutterW = editorGutterWidth(buf);
2016
2136
  const braceMatches = findMatchingBracePositions(buf);
@@ -2066,7 +2186,7 @@ class App {
2066
2186
  if (lineNumW > 0) {
2067
2187
  const prefix = subRow === 0
2068
2188
  ? lineNumberText(buf, lineNo, row, lineNumW)
2069
- : " ".repeat(lineNumW);
2189
+ : visualLineNumberText(subRow, lineNumW);
2070
2190
  putText(this.screen, pane.x + msgW + diffCol, screenRow, prefix, isDirtyLongLine(buf, lineNo) ? dirtyGutterStyle : gutterStyle, lineNumW);
2071
2191
  }
2072
2192
  };
@@ -2287,6 +2407,86 @@ class App {
2287
2407
  buf.scroll.x = charIdxForScrollRight(buf.lines[buf.cursor.y] ?? "", buf.cursor.x, bufW);
2288
2408
  }
2289
2409
  }
2410
+
2411
+ pageScroll(pane, delta, amount = null) {
2412
+ const buf = pane?.buffer;
2413
+ if (!buf) return;
2414
+ const gutterW = editorGutterWidth(buf);
2415
+ const bufW = Math.max(1, (pane?.w ?? this.cols) - gutterW);
2416
+ const softwrap = buf.Settings?.softwrap ?? false;
2417
+ const wordwrap = softwrap && (buf.Settings?.wordwrap ?? false);
2418
+ const tabsize = buf.Settings?.tabsize ?? DEFAULT_SETTINGS.tabsize;
2419
+ const pageOverlap = Math.trunc(Number(buf.Settings?.pageoverlap ?? DEFAULT_SETTINGS.pageoverlap) || 0);
2420
+ const scrollAmount = amount ?? Math.max(1, (pane?.h ?? this.rows) - pageOverlap);
2421
+
2422
+ if (softwrap) {
2423
+ const start = { line: buf.scroll.y, row: buf.scroll.row ?? 0 };
2424
+ const next = delta < 0
2425
+ ? slocRetreatN(buf.lines, start, scrollAmount, bufW, wordwrap, tabsize)
2426
+ : slocAdvanceN(buf.lines, start, scrollAmount, bufW, wordwrap, tabsize);
2427
+ buf.scroll.y = next.line;
2428
+ buf.scroll.row = next.row;
2429
+ buf.scroll.x = 0;
2430
+ if (delta > 0) this.scrollAdjust(pane);
2431
+ } else {
2432
+ buf.scroll.y = Math.max(0, (buf.scroll.y ?? 0) + delta * scrollAmount);
2433
+ buf.scroll.row = 0;
2434
+ if (delta > 0) this.scrollAdjust(pane);
2435
+ }
2436
+ buf.allowCursorOffscreen = true;
2437
+ }
2438
+
2439
+ scrollAdjust(pane) {
2440
+ const buf = pane?.buffer;
2441
+ if (!buf || buf.lines.length === 0) return;
2442
+ const gutterW = editorGutterWidth(buf);
2443
+ const bufW = Math.max(1, (pane?.w ?? this.cols) - gutterW);
2444
+ const softwrap = buf.Settings?.softwrap ?? false;
2445
+ const wordwrap = softwrap && (buf.Settings?.wordwrap ?? false);
2446
+ const tabsize = buf.Settings?.tabsize ?? DEFAULT_SETTINGS.tabsize;
2447
+ if (softwrap) {
2448
+ const endLine = Math.max(0, buf.lines.length - 1);
2449
+ const endBreaks = softwrapBreaks(buf.lines[endLine] ?? "", bufW, wordwrap, tabsize);
2450
+ const end = { line: endLine, row: Math.max(0, endBreaks.length - 1) };
2451
+ const start = { line: buf.scroll.y, row: buf.scroll.row ?? 0 };
2452
+ if (slocDiff(buf.lines, start, end, bufW, wordwrap, tabsize) < (pane?.h ?? this.rows) - 1) {
2453
+ const adjusted = slocRetreatN(buf.lines, end, Math.max(0, (pane?.h ?? this.rows) - 1), bufW, wordwrap, tabsize);
2454
+ buf.scroll.y = adjusted.line;
2455
+ buf.scroll.row = adjusted.row;
2456
+ }
2457
+ } else {
2458
+ buf.scroll.y = Math.min(buf.scroll.y ?? 0, Math.max(0, buf.lines.length - (pane?.h ?? this.rows)));
2459
+ }
2460
+ }
2461
+
2462
+ cursorPage(pane, delta, { select = false, amount = null } = {}) {
2463
+ const buf = pane?.buffer;
2464
+ if (!buf) return;
2465
+ const pageOverlap = Math.trunc(Number(buf.Settings?.pageoverlap ?? DEFAULT_SETTINGS.pageoverlap) || 0);
2466
+ const selectionEndNewline = !select && delta > 0 && pane.selection?.end?.x === 0;
2467
+ let scrollAmount = amount ?? Math.max(1, (pane?.h ?? this.rows) - pageOverlap);
2468
+ if (selectionEndNewline) scrollAmount = Math.max(1, scrollAmount - 1);
2469
+ const move = () => {
2470
+ const softwrap = buf.Settings?.softwrap ?? false;
2471
+ if (softwrap) {
2472
+ for (let i = 0; i < scrollAmount; i++) {
2473
+ if (delta < 0) this._moveUpVisual(buf, pane);
2474
+ else this._moveDownVisual(buf, pane);
2475
+ }
2476
+ } else {
2477
+ buf.page(delta, scrollAmount);
2478
+ }
2479
+ };
2480
+ if (select) extendSelection(pane, buf, move);
2481
+ else {
2482
+ pane.selection = null;
2483
+ move();
2484
+ }
2485
+ if (selectionEndNewline) buf.moveHome();
2486
+ this.pageScroll(pane, delta, scrollAmount);
2487
+ buf.allowCursorOffscreen = false;
2488
+ }
2489
+
2290
2490
  // Softwrap-aware vertical cursor movement.
2291
2491
  // Moves cursor by one visual row, maintaining the target visual X column.
2292
2492
  _softwrapGetContext(buf, pane) {
@@ -2395,6 +2595,12 @@ class App {
2395
2595
  async _dispatchInput(data) {
2396
2596
  const text = decoder.decode(data);
2397
2597
 
2598
+ // Any non-mouse input clears the clipboard alt-copy action
2599
+ {
2600
+ const _evts = parseInputEvents(data);
2601
+ if (_evts.some(e => e.type !== "mouse")) this._messageClickAction = null;
2602
+ }
2603
+
2398
2604
  // Any non-mouse input stops TTS
2399
2605
  if (this._ttsState) {
2400
2606
  const events = parseInputEvents(data);
@@ -2504,12 +2710,14 @@ class App {
2504
2710
  else if (event.type === "paste") await this.handlePrompt(event.text);
2505
2711
  else await this.handleEvent(event);
2506
2712
  }
2713
+ this._syncPrimarySelection();
2507
2714
  return;
2508
2715
  }
2509
2716
 
2510
2717
  for (const event of parseInputEvents(data)) {
2511
2718
  await this.handleEvent(event);
2512
2719
  }
2720
+ this._syncPrimarySelection();
2513
2721
  }
2514
2722
 
2515
2723
  async handleEvent(event) {
@@ -2575,40 +2783,9 @@ class App {
2575
2783
  };
2576
2784
  buf.cursor = { ...this.pane.selection.end };
2577
2785
  break;
2578
- case "ctrl-c": { //copy
2579
- const sel = this.pane?.selection;
2580
- if (sel) {
2581
- const text = getSelectionText(buf, sel);
2582
- this.clipboard.write(text);
2583
- this.message = `Copied to ${this.clipboard.methodName()} clipboard`;
2584
- } else {
2585
- this.clipboard.write(buf.currentLineText() + "\n");
2586
- this.message = `Copied line to ${this.clipboard.methodName()} clipboard`;
2587
- }
2588
- break;
2589
- }
2590
- case "ctrl-x": { //cut
2591
- buf.pushUndo();
2592
- if (this.pane?.selection) {
2593
- const text = deleteSelection(buf, this.pane);
2594
- this.clipboard.write(text);
2595
- this.message = `Cut to ${this.clipboard.methodName()} clipboard`;
2596
- } else {
2597
- this.clipboard.write(buf.cutLine() + "\n");
2598
- this.message = `Cut line to ${this.clipboard.methodName()} clipboard`;
2599
- }
2600
- break;
2601
- }
2602
- case "ctrl-v": { //paste
2603
- const pasted = this.clipboard.read();
2604
- if (pasted) {
2605
- buf.pushUndo();
2606
- if (this.pane?.selection) deleteSelection(buf, this.pane);
2607
- buf.insert(pasted);
2608
- this.message = pasteStatusMessage(this.clipboard.methodName(), pasted);
2609
- }
2610
- break;
2611
- }
2786
+ case "ctrl-c": await this.handleCommand("copy"); break; //copy
2787
+ case "ctrl-x": await this.handleCommand("cut"); break; //cut
2788
+ case "ctrl-v": await this.handleCommand("paste"); break; //paste
2612
2789
  case "ctrl-z": //undo
2613
2790
  if (buf.undo()) this.pane.selection = null;
2614
2791
  else this.message = "Nothing to undo";
@@ -2722,17 +2899,7 @@ class App {
2722
2899
  }
2723
2900
  break;
2724
2901
  }
2725
- case "ctrl-k": //cutLine
2726
- buf.pushUndo();
2727
- if (this.pane?.selection) {
2728
- const text = deleteSelection(buf, this.pane);
2729
- this.clipboard.write(text);
2730
- this.message = `Cut to ${this.clipboard.methodName()} clipboard`;
2731
- } else {
2732
- this.clipboard.write(buf.cutLine() + "\n");
2733
- this.message = `Cut line to ${this.clipboard.methodName()} clipboard`;
2734
- }
2735
- break;
2902
+ case "ctrl-k": await this.handleCommand("cutline"); break; //cutLine
2736
2903
  case "ctrl-o": //open
2737
2904
  this.openCommandMode("open ");
2738
2905
  break;
@@ -2840,11 +3007,11 @@ class App {
2840
3007
  break;
2841
3008
  case "shift-pageup":
2842
3009
  buf._lastVisX = null;
2843
- extendSelection(this.pane, buf, () => buf.page(-1, this.rows));
3010
+ this.cursorPage(this.pane, -1, { select: true });
2844
3011
  break;
2845
3012
  case "shift-pagedown":
2846
3013
  buf._lastVisX = null;
2847
- extendSelection(this.pane, buf, () => buf.page(1, this.rows));
3014
+ this.cursorPage(this.pane, 1, { select: true });
2848
3015
  break;
2849
3016
  case "home":
2850
3017
  buf._lastVisX = null;
@@ -2912,11 +3079,11 @@ class App {
2912
3079
  break;
2913
3080
  case "pageup":
2914
3081
  buf._lastVisX = null;
2915
- await runAction("PageUp", this);
3082
+ await runAction("CursorPageUp", this);
2916
3083
  break;
2917
3084
  case "pagedown":
2918
3085
  buf._lastVisX = null;
2919
- await runAction("PageDown", this);
3086
+ await runAction("CursorPageDown", this);
2920
3087
  break;
2921
3088
  case "tab":
2922
3089
  if (buf.acHas) buf.cycleAutocomplete(true);
@@ -2988,14 +3155,14 @@ class App {
2988
3155
  }
2989
3156
  if (ch < " " && ch !== "\t") continue;
2990
3157
  buf.insertChar(ch);
2991
- await this.context.plugins?.run("onRune", makePaneAdapter(buf), ch);
2992
- await this.context.jsPlugins?.run("onRune", makePaneAdapter(buf), ch);
3158
+ await this.context.plugins?.run("onRune", makePaneAdapter(buf, this), ch);
3159
+ await this.context.jsPlugins?.run("onRune", makePaneAdapter(buf, this), ch);
2993
3160
  }
2994
3161
  }
2995
3162
 
2996
3163
  async runPluginBool(fn) {
2997
- const luaOk = await this.context.plugins?.runBool(fn, makePaneAdapter(this.buffer)) ?? true;
2998
- const jsOk = await this.context.jsPlugins?.runBool(fn, makePaneAdapter(this.buffer)) ?? true;
3164
+ const luaOk = await this.context.plugins?.runBool(fn, makePaneAdapter(this.buffer, this)) ?? true;
3165
+ const jsOk = await this.context.jsPlugins?.runBool(fn, makePaneAdapter(this.buffer, this)) ?? true;
2999
3166
  return luaOk && jsOk;
3000
3167
  }
3001
3168
 
@@ -3010,6 +3177,10 @@ class App {
3010
3177
  this.render();
3011
3178
  return;
3012
3179
  }
3180
+ if (await this.handleMessageRowMouse(event)) {
3181
+ this.render();
3182
+ return;
3183
+ }
3013
3184
  if (await this.handleStatusBarMouse(event)) {
3014
3185
  this.render();
3015
3186
  return;
@@ -3060,7 +3231,7 @@ class App {
3060
3231
  }
3061
3232
  return;
3062
3233
  }
3063
- if (!["down", "up", "drag"].includes(event.action) || !["left", "none"].includes(event.button)) return;
3234
+ if (!["down", "up", "drag"].includes(event.action) || !["left", "none", "middle"].includes(event.button)) return;
3064
3235
  buf.allowCursorOffscreen = false;
3065
3236
  const gutterW = _swGutterW;
3066
3237
  const localY = event.y - clicked.y;
@@ -3081,6 +3252,18 @@ class App {
3081
3252
  }
3082
3253
  buf.cursor.y = y;
3083
3254
  buf.cursor.x = x;
3255
+ if (event.button === "middle") {
3256
+ if (event.action === "down") {
3257
+ const pasted = this.clipboard.read("primary");
3258
+ if (pasted) {
3259
+ buf.pushUndo();
3260
+ this.pane.selection = null;
3261
+ buf.insert(pasted);
3262
+ this.message = pasteStatusMessage("primary", pasted);
3263
+ }
3264
+ }
3265
+ return;
3266
+ }
3084
3267
  if (event.action === "down") {
3085
3268
  if (inGutter) {
3086
3269
  // Message column (first msgW cols): show message text in infobar, no selection.
@@ -3162,6 +3345,15 @@ class App {
3162
3345
  buf.ensureCursor();
3163
3346
  }
3164
3347
 
3348
+ _syncPrimarySelection() {
3349
+ const sel = this.pane?.selection;
3350
+ if (!sel || sameLoc(sel.start, sel.end)) return;
3351
+ const buf = this.buffer;
3352
+ if (!buf) return;
3353
+ const text = getSelectionText(buf, sel);
3354
+ if (text) this.clipboard.write(text, "primary");
3355
+ }
3356
+
3165
3357
  handleSuggestionMouse(event) {
3166
3358
  if (this._suggestionsRow == null || event.y !== this._suggestionsRow) return false;
3167
3359
  if (event.action !== "down" || event.button !== "left") return false;
@@ -3181,6 +3373,19 @@ class App {
3181
3373
  return true;
3182
3374
  }
3183
3375
 
3376
+ async handleMessageRowMouse(event) {
3377
+ if (this._messageRowY == null || event.y !== this._messageRowY) return false;
3378
+ if (event.action !== "down" || event.button !== "left") return false;
3379
+ const zone = this._messageRowClickZone;
3380
+ if (!zone || !this._messageClickAction) return false;
3381
+ if (event.x < zone.start || event.x >= zone.end) return false;
3382
+ const result = this._messageClickAction();
3383
+ this._messageClickAction = null;
3384
+ this._messageRowClickZone = null;
3385
+ if (typeof result === "string") this.message = result;
3386
+ return true;
3387
+ }
3388
+
3184
3389
  async handleStatusBarMouse(event) {
3185
3390
  if (this._statusBarRow == null || event.y !== this._statusBarRow) return false;
3186
3391
  if (event.action !== "down" || event.button !== "left") return false;
@@ -3238,7 +3443,11 @@ class App {
3238
3443
  break;
3239
3444
  }
3240
3445
  case "fmt":
3241
- if (buf) { buf.fileformat = buf.fileformat === "dos" ? "unix" : "dos"; buf.modified = true; }
3446
+ if (buf) {
3447
+ buf.fileformat = buf.fileformat === "dos" ? "unix" : "dos";
3448
+ buf.Settings.fileformat = buf.fileformat;
3449
+ buf.modified = true;
3450
+ }
3242
3451
  break;
3243
3452
  case "enc":
3244
3453
  if (buf) {
@@ -3298,9 +3507,9 @@ class App {
3298
3507
  if (index < 0 || index >= this.tabs.length || index === this.activeTabIdx) return false;
3299
3508
  this.activeTabIdx = index;
3300
3509
  this.message = "";
3301
- if (this.context.plugins && this.buffer) this.context.plugins.curPaneAdapter = makePaneAdapter(this.buffer);
3302
- this.context.plugins?.run("onSetActive", makePaneAdapter(this.buffer));
3303
- if (this.buffer) this.context.jsPlugins?.run("onSetActive", makePaneAdapter(this.buffer));
3510
+ if (this.context.plugins && this.buffer) this.context.plugins.curPaneAdapter = makePaneAdapter(this.buffer, this);
3511
+ this.context.plugins?.run("onSetActive", makePaneAdapter(this.buffer, this));
3512
+ if (this.buffer) this.context.jsPlugins?.run("onSetActive", makePaneAdapter(this.buffer, this));
3304
3513
  return true;
3305
3514
  }
3306
3515
 
@@ -3551,14 +3760,14 @@ class App {
3551
3760
  this.openPrompt("Save as: ", async (value) => {
3552
3761
  if (value) {
3553
3762
  await this.buffer.save(resolve(expandHome(value)));
3554
- await this.context.plugins?.run("onSave", makePaneAdapter(this.buffer));
3555
- await this.context.jsPlugins?.run("onSave", makePaneAdapter(this.buffer));
3763
+ await this.context.plugins?.run("onSave", makePaneAdapter(this.buffer, this));
3764
+ await this.context.jsPlugins?.run("onSave", makePaneAdapter(this.buffer, this));
3556
3765
  }
3557
3766
  }, { completer: fileComplete, initial });
3558
3767
  } else {
3559
3768
  await this.buffer.save();
3560
- await this.context.plugins?.run("onSave", makePaneAdapter(this.buffer));
3561
- await this.context.jsPlugins?.run("onSave", makePaneAdapter(this.buffer));
3769
+ await this.context.plugins?.run("onSave", makePaneAdapter(this.buffer, this));
3770
+ await this.context.jsPlugins?.run("onSave", makePaneAdapter(this.buffer, this));
3562
3771
  await this._saveCursorForBuf(this.buffer);
3563
3772
  }
3564
3773
  } catch (error) {
@@ -3715,10 +3924,10 @@ class App {
3715
3924
  this.tabs.splice(this.activeTabIdx, 1);
3716
3925
  this.activeTabIdx = Math.min(this.activeTabIdx, this.tabs.length - 1);
3717
3926
  this.message = "";
3718
- if (this.context.plugins && this.buffer) this.context.plugins.curPaneAdapter = makePaneAdapter(this.buffer);
3719
- await this.context.plugins?.run("onSetActive", makePaneAdapter(this.buffer));
3927
+ if (this.context.plugins && this.buffer) this.context.plugins.curPaneAdapter = makePaneAdapter(this.buffer, this);
3928
+ await this.context.plugins?.run("onSetActive", makePaneAdapter(this.buffer, this));
3720
3929
  await this.context.plugins?.run("onBufferClose", closing);
3721
- if (this.buffer) this.context.jsPlugins?.run("onSetActive", makePaneAdapter(this.buffer));
3930
+ if (this.buffer) this.context.jsPlugins?.run("onSetActive", makePaneAdapter(this.buffer, this));
3722
3931
  await this.context.jsPlugins?.run("onBufferClose", closing);
3723
3932
  this.render();
3724
3933
  }
@@ -3891,6 +4100,9 @@ class App {
3891
4100
  this.message = `colorscheme: ${err.message}`;
3892
4101
  }
3893
4102
  }
4103
+ if (opt === "clipboard") {
4104
+ await this.reinitializeClipboard(buf.Settings[opt]);
4105
+ }
3894
4106
  }
3895
4107
  break;
3896
4108
  }
@@ -3937,8 +4149,8 @@ class App {
3937
4149
  if (answer === "y") {
3938
4150
  try {
3939
4151
  await buf.save(target);
3940
- await this.context.plugins?.run("onSave", makePaneAdapter(buf));
3941
- await this.context.jsPlugins?.run("onSave", makePaneAdapter(buf));
4152
+ await this.context.plugins?.run("onSave", makePaneAdapter(buf, this));
4153
+ await this.context.jsPlugins?.run("onSave", makePaneAdapter(buf, this));
3942
4154
  } catch (err) {
3943
4155
  this.message = err.message;
3944
4156
  }
@@ -3948,8 +4160,8 @@ class App {
3948
4160
  } else if (saveArgs.length > 0) {
3949
4161
  try {
3950
4162
  await buf.save(resolve(expandHome(saveArgs[0])));
3951
- await this.context.plugins?.run("onSave", makePaneAdapter(buf));
3952
- await this.context.jsPlugins?.run("onSave", makePaneAdapter(buf));
4163
+ await this.context.plugins?.run("onSave", makePaneAdapter(buf, this));
4164
+ await this.context.jsPlugins?.run("onSave", makePaneAdapter(buf, this));
3953
4165
  }
3954
4166
  catch (err) { this.message = err.message; }
3955
4167
  } else {
@@ -4006,10 +4218,9 @@ class App {
4006
4218
  this.toggleComment();
4007
4219
  break;
4008
4220
  case "goto": {
4009
- if (cmdArgs.length === 0) { this.message = "Usage: goto <line[:col]>"; break; }
4221
+ if (cmdArgs.length === 0) { this.message = "Usage: goto <line[.subrow][:col]>"; break; }
4010
4222
  try {
4011
- const { line, col } = parseLineCol(cmdArgs[0]);
4012
- buf.gotoLoc(line, col);
4223
+ this.gotoLocation(buf, parseLineCol(cmdArgs[0]), this.pane);
4013
4224
  this.pane.selection = null;
4014
4225
  } catch (error) {
4015
4226
  this.message = String(error.message || error);
@@ -4203,7 +4414,7 @@ class App {
4203
4414
  await this.context.plugins?.run("onBufferOpen", buf);
4204
4415
  await this.context.jsPlugins?.run("onBufferOpen", buf);
4205
4416
  }
4206
- if (cmd !== "togglelocal" && cfg && opt in cfg.globalSettings) {
4417
+ if (cmd !== "togglelocal" && cfg && opt in cfg.globalSettings && !LOCAL_SETTINGS.has(opt)) {
4207
4418
  try { cfg.setGlobalOptionNative(opt, newVal, { modified: true }); await cfg.saveSettings(); } catch {}
4208
4419
  }
4209
4420
  break;
@@ -4217,12 +4428,13 @@ class App {
4217
4428
  const defVal = opt in defaults ? defaults[opt] : true;
4218
4429
  try { buf.SetOption(opt, String(defVal)); } catch (err) { this.message = String(err.message || err); break; }
4219
4430
  this.message = `${opt} = ${defVal}`;
4220
- if (cfgR && opt in cfgR.globalSettings) {
4431
+ if (cfgR && opt in cfgR.globalSettings && !LOCAL_SETTINGS.has(opt)) {
4221
4432
  try { cfgR.setGlobalOptionNative(opt, defVal, { modified: true }); await cfgR.saveSettings(); } catch {}
4222
4433
  }
4223
4434
  if (opt === "colorscheme" && this.context?.runtime) {
4224
4435
  try { this.context.colorscheme = await new Colorscheme(this.context.runtime).load(String(defVal)); } catch {}
4225
4436
  }
4437
+ if (opt === "clipboard") await this.reinitializeClipboard(defVal);
4226
4438
  break;
4227
4439
  }
4228
4440
  case "jump": {
@@ -4371,11 +4583,66 @@ class App {
4371
4583
  await this.runAlert(content);
4372
4584
  break;
4373
4585
  }
4586
+ case "copy": {
4587
+ this._freshClip = false;
4588
+ const sel = this.pane?.selection;
4589
+ const copyText = sel ? getSelectionText(buf, sel) : (buf.currentLineText() + "\n");
4590
+ this.clipboard.write(copyText);
4591
+ this.message = clipboardCopyMsg(this.clipboard, copyText, sel ? "selection" : "line");
4592
+ this._messageClickAction = clipboardAltAction(this.clipboard, copyText);
4593
+ break;
4594
+ }
4595
+ case "cut": {
4596
+ this._freshClip = false;
4597
+ buf.pushUndo();
4598
+ const cutText = this.pane?.selection
4599
+ ? deleteSelection(buf, this.pane)
4600
+ : (buf.cutLine() + "\n");
4601
+ const cutKind = this.pane?.selection ? "selection" : "line";
4602
+ this.clipboard.write(cutText);
4603
+ this.message = clipboardCopyMsg(this.clipboard, cutText, cutKind, "Cut");
4604
+ this._messageClickAction = clipboardAltAction(this.clipboard, cutText);
4605
+ break;
4606
+ }
4607
+ case "cutline": {
4608
+ buf.pushUndo();
4609
+ if (this.pane?.selection) {
4610
+ this._freshClip = false;
4611
+ const text = deleteSelection(buf, this.pane);
4612
+ this.clipboard.write(text);
4613
+ this.message = clipboardCopyMsg(this.clipboard, text, "selection", "Cut");
4614
+ this._messageClickAction = clipboardAltAction(this.clipboard, text);
4615
+ } else {
4616
+ const prev = this._freshClip ? (this.clipboard.read() ?? "") : "";
4617
+ const line = buf.cutLine() + "\n";
4618
+ this.clipboard.write(prev + line);
4619
+ this._freshClip = true;
4620
+ const total = (prev + line).split("\n").length - 1;
4621
+ const label = total > 1 ? `${total} lines` : "line";
4622
+ this.message = clipboardCopyMsg(this.clipboard, prev + line, label, "Cut");
4623
+ this._messageClickAction = clipboardAltAction(this.clipboard, prev + line);
4624
+ }
4625
+ break;
4626
+ }
4627
+ case "paste": {
4628
+ this._freshClip = false;
4629
+ const pasted = this.clipboard.read();
4630
+ if (pasted) {
4631
+ buf.pushUndo();
4632
+ if (this.pane?.selection) deleteSelection(buf, this.pane);
4633
+ buf.insert(pasted);
4634
+ this.message = pasteStatusMessage(this.clipboard.readMethodName(), pasted);
4635
+ }
4636
+ break;
4637
+ }
4638
+ case "pasteprimary":
4639
+ await runAction("PastePrimary", this);
4640
+ break;
4374
4641
  default: {
4375
4642
  const pluginCmd = this.context.plugins?.commands?.get(cmd);
4376
4643
  if (pluginCmd) {
4377
4644
  try {
4378
- await pluginCmd(makePaneAdapter(this.buffer), cmdArgs);
4645
+ await pluginCmd(makePaneAdapter(this.buffer, this), cmdArgs);
4379
4646
  } catch (e) {
4380
4647
  this.message = String(e.message ?? e);
4381
4648
  }
@@ -4700,6 +4967,7 @@ const COMMAND_NAMES = [
4700
4967
  "cd", "pwd", "tab", "run", "vsplit", "hsplit", "term", "tts", "ttsspeed", "ttspitch", "ttslang", "reopen", "theme", "toggle", "tog",
4701
4968
  "togglelocal", "reset", "jump", "tabmove", "tabswitch", "textfilter", "bind", "unbind", "reload", "lintlog", "act", "action", "raw",
4702
4969
  "help", "plugin", "showkey", "memusage", "retab", "eval",
4970
+ "copy", "cut", "cutline", "paste", "pasteprimary",
4703
4971
  ];
4704
4972
 
4705
4973
  const SUPPORTED_ENCODING_LABELS = [
@@ -4894,10 +5162,40 @@ function isEmptyUntitledBuffer(buffer) {
4894
5162
  return !buffer.path && !buffer.modified && buffer.lines.length === 1 && buffer.lines[0] === "";
4895
5163
  }
4896
5164
 
4897
- function makePaneAdapter(buffer) {
5165
+ function formatAbsoluteCursorLocation(buffer) {
5166
+ if (!buffer) return "+1:1";
5167
+ const y = clamp(buffer.cursor?.y ?? 0, 0, Math.max(0, (buffer.lines?.length ?? 1) - 1));
5168
+ const line = buffer.lines?.[y] ?? "";
5169
+ const x = normalizeCharBoundary(line, buffer.cursor?.x ?? 0);
5170
+ return `+${y + 1}:${x + 1}`;
5171
+ }
5172
+
5173
+ function formatCursorLocation(buffer, pane = null) {
5174
+ if (!buffer) return "+1.0:1";
5175
+ const y = clamp(buffer.cursor?.y ?? 0, 0, Math.max(0, (buffer.lines?.length ?? 1) - 1));
5176
+ const line = buffer.lines?.[y] ?? "";
5177
+ const x = normalizeCharBoundary(line, buffer.cursor?.x ?? 0);
5178
+ let subRow = 0;
5179
+ let col = x + 1;
5180
+ if (pane && (buffer.Settings?.softwrap ?? false)) {
5181
+ const gutterW = editorGutterWidth(buffer);
5182
+ const bufW = Math.max(1, (pane.w ?? process.stdout.columns ?? 80) - gutterW);
5183
+ const wordwrap = buffer.Settings?.wordwrap ?? false;
5184
+ const tabsize = buffer.Settings?.tabsize ?? DEFAULT_SETTINGS.tabsize;
5185
+ const breaks = softwrapBreaks(line, bufW, wordwrap, tabsize);
5186
+ subRow = softwrapRowOfCharIdx(breaks, x);
5187
+ const segStart = breaks[subRow] ?? 0;
5188
+ col = displayWidth(line.slice(segStart, x)) + 1;
5189
+ }
5190
+ return `+${y + 1}.${subRow}:${col}`;
5191
+ }
5192
+
5193
+ function makePaneAdapter(buffer, app = null) {
4898
5194
  const pane = {
4899
5195
  Buf: makeBufferAdapter(buffer),
4900
5196
  Cursor: makeCursorAdapter(buffer),
5197
+ CursorLocation: () => formatCursorLocation(buffer, app?.paneForBuffer?.(buffer) ?? app?.pane ?? null),
5198
+ AbsoluteCursorLocation: () => formatAbsoluteCursorLocation(buffer),
4901
5199
  Save: () => buffer.save(),
4902
5200
  Backspace: () => buffer.backspace(),
4903
5201
  Delete: () => buffer.deleteForward(),
@@ -5206,19 +5504,27 @@ function detectTtsCmd() {
5206
5504
  return null;
5207
5505
  }
5208
5506
 
5209
- async function loadBufferForPath(pathOrUrl, context) {
5507
+ function commandHasStartCursor(command = {}) {
5508
+ return Boolean(command.startCursor && command.startCursor.line >= 1);
5509
+ }
5510
+
5511
+ function commandHasStartupJump(command = {}) {
5512
+ return commandHasStartCursor(command) || Boolean(command.searchRegex);
5513
+ }
5514
+
5515
+ async function loadBufferForPath(pathOrUrl, context, command = {}) {
5210
5516
  if (isHttpUrl(pathOrUrl)) {
5211
5517
  let encoding = context.config?.globalSettings?.encoding ?? DEFAULT_SETTINGS.encoding;
5212
5518
  const decoded = await fetchTextWithEncoding(pathOrUrl, encoding);
5213
5519
  const text = decoded.text;
5214
5520
  encoding = decoded.encoding;
5215
5521
  const urlPath = pathOrUrl.replace(/[?#].*$/, "");
5216
- const buffer = new BufferModel({ path: pathOrUrl, text, command: {}, encoding });
5522
+ const buffer = new BufferModel({ path: pathOrUrl, text, command, encoding });
5217
5523
  attachSyntax(buffer, context, urlPath, text);
5218
5524
  return buffer;
5219
5525
  }
5220
- const buffer = await BufferModel.fromFile(pathOrUrl, {}, context);
5221
- if (DEFAULT_SETTINGS.savecursor && context?.cursorStates?.[pathOrUrl]) {
5526
+ const buffer = await BufferModel.fromFile(pathOrUrl, command, context);
5527
+ if (DEFAULT_SETTINGS.savecursor && !commandHasStartupJump(command) && context?.cursorStates?.[pathOrUrl]) {
5222
5528
  const saved = context.cursorStates[pathOrUrl];
5223
5529
  const y = clamp(saved.y ?? 0, 0, buffer.lines.length - 1);
5224
5530
  const x = clamp(saved.x ?? 0, 0, buffer.lines[y]?.length ?? 0);
@@ -5478,6 +5784,15 @@ function lineNumberText(buf, lineNo, row, gutterW) {
5478
5784
  return String(lineNo + 1).padStart(Math.max(0, gutterW - 1)) + " ";
5479
5785
  }
5480
5786
 
5787
+ function visualLineNumberText(subRow, gutterW) {
5788
+ const text = `.${subRow}`;
5789
+ const numberW = Math.max(0, gutterW - 1);
5790
+ const padded = text.length >= numberW
5791
+ ? text.slice(text.length - numberW)
5792
+ : text.padStart(numberW);
5793
+ return padded + " ";
5794
+ }
5795
+
5481
5796
  const BRACE_PAIRS = { "(": ")", "[": "]", "{": "}" };
5482
5797
  const BRACE_REVERSE = { ")": "(", "]": "[", "}": "{" };
5483
5798
 
@@ -5740,7 +6055,7 @@ async function loadBuffers(files, command) {
5740
6055
  if (files.length > 0) {
5741
6056
  for (const file of files) {
5742
6057
  try {
5743
- buffers.push(await loadBufferForPath(file, loadBuffers.context ?? {}));
6058
+ buffers.push(await loadBufferForPath(file, loadBuffers.context ?? {}, command));
5744
6059
  } catch (error) {
5745
6060
  console.error(error.message || error);
5746
6061
  }
@@ -5758,6 +6073,11 @@ async function loadBuffers(files, command) {
5758
6073
  return buffers.length ? buffers : [new BufferModel({ command })];
5759
6074
  }
5760
6075
 
6076
+ async function printReadmeDocs() {
6077
+ const readme = await Bun.file(join(REPO_ROOT, "README.md")).text();
6078
+ process.stdout.write(Bun.markdown.ansi(readme, { hyperlinks: true }));
6079
+ }
6080
+
5761
6081
  async function main() {
5762
6082
  const { flags, files: rawFiles } = parseArgs(process.argv.slice(2));
5763
6083
  if (flags.help) {
@@ -5765,7 +6085,6 @@ async function main() {
5765
6085
  return;
5766
6086
  }
5767
6087
  if (flags.version) {
5768
- const clipboard = new ClipboardManager();
5769
6088
  const ttsCmd = detectTtsCmd();
5770
6089
  console.log(pkg.name+":",pkg.description)
5771
6090
  console.log(" Rewritten by: Dr. John (醫者小智)")
@@ -5774,9 +6093,24 @@ async function main() {
5774
6093
  console.log("Runtime:", `Bun ${Bun.version}`);
5775
6094
  console.log("Platform:", platformId());
5776
6095
  console.log("Http client:",detectHttpBackend());
5777
- console.log("Clipboard:", clipboard.methodName());
5778
6096
  console.log("TTS:", ttsCmd ? ttsCmd.cmd[0] : "not found");
5779
6097
  console.log({SUPPORTED_ENCODING_LABELS})
6098
+ const clipboard = new ClipboardManager();
6099
+ let osc52Available = false;
6100
+ if (process.stdin.isTTY && process.stdout.isTTY) {
6101
+ process.stdin.setRawMode?.(true);
6102
+ process.stdin.resume();
6103
+ osc52Available = await probeOSC52(process.stdin, process.stdout, 150);
6104
+ process.stdin.setRawMode?.(false);
6105
+ process.stdin.pause();
6106
+ }
6107
+ const externalName = clipboard.methodName();
6108
+ const backends = osc52Available ? `${externalName}, OSC 52` : externalName;
6109
+ console.log("Clipboard:", backends);
6110
+ return;
6111
+ }
6112
+ if (flags.docs) {
6113
+ await printReadmeDocs();
5780
6114
  return;
5781
6115
  }
5782
6116
  if (flags.options) {
@@ -5797,7 +6131,7 @@ async function main() {
5797
6131
  const syntaxDefinitions = await loadSyntaxDefinitions(runtime);
5798
6132
 
5799
6133
  if (flags.cat) {
5800
- await catFiles(rawFiles, colorscheme, syntaxDefinitions);
6134
+ await catFiles(rawFiles, colorscheme, syntaxDefinitions, config.getGlobalOption("encoding"));
5801
6135
  return;
5802
6136
  }
5803
6137
 
@@ -5835,8 +6169,8 @@ async function main() {
5835
6169
  }
5836
6170
 
5837
6171
  if (flags.clean) {
5838
- console.error("Clean is not implemented yet.");
5839
- process.exit(1);
6172
+ await cleanConfig(config, plugins);
6173
+ return;
5840
6174
  }
5841
6175
 
5842
6176
  const pluginErr = await plugins.loadAll();
@@ -5878,7 +6212,7 @@ async function main() {
5878
6212
  }
5879
6213
  const app = new App(buffers, context);
5880
6214
  jsPlugins.setApp(app);
5881
- if (plugins && !pluginErr && app.buffer) plugins.curPaneAdapter = makePaneAdapter(app.buffer);
6215
+ if (plugins && !pluginErr && app.buffer) plugins.curPaneAdapter = makePaneAdapter(app.buffer, app);
5882
6216
  // Dispatch all JS plugin lifecycle hooks after setApp so TermMessage,
5883
6217
  // CurPane, cmd/action proxies, and buffer APIs all work correctly.
5884
6218
  await jsPlugins.run("preinit");
@@ -6014,6 +6348,26 @@ function uncommentText(line, commentType) {
6014
6348
  }
6015
6349
  return indent + rest;
6016
6350
  }
6351
+ function clipboardCopyMsg(clipboard, text, kind, verb = "Copied") {
6352
+ const method = clipboard.methodName();
6353
+ const alt = clipboard.altMethodName();
6354
+ const chars = Array.from(String(text).replace(/\n$/, "")).length;
6355
+ const label = typeof kind === "string" && (kind === "line" || kind.endsWith("lines"))
6356
+ ? kind : `${chars} chars`;
6357
+ if (alt) return `[Click:Copy>${alt}] ${method}: ${label} ${verb.toLowerCase()}`;
6358
+ return `${verb} ${label} to ${method} clipboard`;
6359
+ }
6360
+
6361
+ function clipboardAltAction(clipboard, text) {
6362
+ const alt = clipboard.altMethodName();
6363
+ if (!alt) return null;
6364
+ return () => {
6365
+ if (!clipboard.writeAlt(text)) return `${alt}: failed`;
6366
+ const chars = Array.from(String(text).replace(/\n$/, "")).length;
6367
+ return `${alt}: ${chars} chars copied`;
6368
+ };
6369
+ }
6370
+
6017
6371
  function pasteStatusMessage(method, text) {
6018
6372
  const value = String(text);
6019
6373
  const lines = value.split("\n").length;
@@ -6195,14 +6549,16 @@ function segmentSelection(selection, lineNo, start, end) {
6195
6549
  function parseLineCol(value) {
6196
6550
  const input = String(value).trim();
6197
6551
  if (!input) throw new Error("Not enough arguments");
6198
- const parts = input.split(":");
6199
- if (parts.length > 2 || parts[0] === "") throw new Error("Invalid line number");
6200
- if (parts.length === 2 && parts[1] === "") throw new Error("Invalid column number");
6201
- const line = Number(parts[0]);
6202
- const col = parts.length === 2 ? Number(parts[1]) : 1;
6552
+ const match = input.match(/^(-?\d+)(?:\.(\d+))?(?::(-?\d+))?$/);
6553
+ if (!match) throw new Error("Invalid line number");
6554
+ const line = Number(match[1]);
6555
+ const subRow = match[2] == null ? 0 : Number(match[2]);
6556
+ const col = match[3] == null ? 1 : Number(match[3]);
6203
6557
  if (!Number.isInteger(line)) throw new Error("Invalid line number");
6558
+ if (!Number.isInteger(subRow)) throw new Error("Invalid visual line number");
6559
+ if (subRow < 0) throw new Error("Invalid visual line number");
6204
6560
  if (!Number.isInteger(col)) throw new Error("Invalid column number");
6205
- return { line, col };
6561
+ return { line, subRow, col };
6206
6562
  }
6207
6563
 
6208
6564
  function parseOptionValue(value) {
@@ -6218,19 +6574,23 @@ function syncEditorSettings(config) {
6218
6574
  }
6219
6575
  }
6220
6576
 
6221
- async function catFiles(files, colorscheme, syntaxDefinitions) {
6577
+ async function catFiles(files, colorscheme, syntaxDefinitions, encoding = DEFAULT_SETTINGS.encoding) {
6222
6578
  const targets = files.length > 0 ? files.map((f) => ({ path: f, stdin: false })) : [{ path: null, stdin: true }];
6223
6579
  for (const { path: filePath, stdin } of targets) {
6224
6580
  let content;
6225
6581
  let effectivePath = filePath;
6226
6582
  if (stdin) {
6227
- content = await Bun.stdin.text();
6583
+ const chunks = [];
6584
+ for await (const chunk of process.stdin) chunks.push(chunk);
6585
+ content = decodeTextBytesWithEncoding(Buffer.concat(chunks), encoding).text;
6228
6586
  } else if (isHttpUrl(filePath)) {
6229
- content = await fetchHttp(filePath);
6587
+ const decoded = await fetchTextWithEncoding(filePath, encoding);
6588
+ content = decoded.text;
6230
6589
  // Use the URL pathname for syntax/md detection (strip query/hash)
6231
6590
  try { effectivePath = new URL(filePath).pathname; } catch { effectivePath = filePath; }
6232
6591
  } else {
6233
- content = await Bun.file(filePath).text();
6592
+ const decoded = await readTextFileWithEncoding(filePath, encoding);
6593
+ content = decoded.text;
6234
6594
  }
6235
6595
  if (effectivePath && /\.md$/i.test(effectivePath)) {
6236
6596
  process.stdout.write(