bunmicro 0.9.9 → 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) {
@@ -2551,8 +2759,8 @@ class App {
2551
2759
  // Reset undo insert chain on any non-printable-char key
2552
2760
  if (!(seq === text && text.length === 1 && text >= " ")) this._undoInsertChain = false;
2553
2761
 
2554
- // Any key other than tab/backtab clears the autocomplete cycle state
2555
- if (seq !== "tab" && seq !== "backtab" && buf?.acHas) buf.clearAutocomplete();
2762
+ // Keep autocomplete active while cycling candidates with Tab/Shift-Tab or Up/Down.
2763
+ if (!["tab", "backtab", "up", "down"].includes(seq) && buf?.acHas) buf.clearAutocomplete();
2556
2764
 
2557
2765
  switch (seq) {
2558
2766
  case "escape": {
@@ -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;
@@ -2799,10 +2966,18 @@ class App {
2799
2966
  buf.moveRight();
2800
2967
  break;
2801
2968
  case "up":
2969
+ if (buf.acHas) {
2970
+ buf.cycleAutocomplete(false);
2971
+ break;
2972
+ }
2802
2973
  this.pane.selection = null;
2803
2974
  this._moveUpVisual(buf, this.pane);
2804
2975
  break;
2805
2976
  case "down":
2977
+ if (buf.acHas) {
2978
+ buf.cycleAutocomplete(true);
2979
+ break;
2980
+ }
2806
2981
  this.pane.selection = null;
2807
2982
  this._moveDownVisual(buf, this.pane);
2808
2983
  break;
@@ -2832,11 +3007,11 @@ class App {
2832
3007
  break;
2833
3008
  case "shift-pageup":
2834
3009
  buf._lastVisX = null;
2835
- extendSelection(this.pane, buf, () => buf.page(-1, this.rows));
3010
+ this.cursorPage(this.pane, -1, { select: true });
2836
3011
  break;
2837
3012
  case "shift-pagedown":
2838
3013
  buf._lastVisX = null;
2839
- extendSelection(this.pane, buf, () => buf.page(1, this.rows));
3014
+ this.cursorPage(this.pane, 1, { select: true });
2840
3015
  break;
2841
3016
  case "home":
2842
3017
  buf._lastVisX = null;
@@ -2904,11 +3079,11 @@ class App {
2904
3079
  break;
2905
3080
  case "pageup":
2906
3081
  buf._lastVisX = null;
2907
- await runAction("PageUp", this);
3082
+ await runAction("CursorPageUp", this);
2908
3083
  break;
2909
3084
  case "pagedown":
2910
3085
  buf._lastVisX = null;
2911
- await runAction("PageDown", this);
3086
+ await runAction("CursorPageDown", this);
2912
3087
  break;
2913
3088
  case "tab":
2914
3089
  if (buf.acHas) buf.cycleAutocomplete(true);
@@ -2980,14 +3155,14 @@ class App {
2980
3155
  }
2981
3156
  if (ch < " " && ch !== "\t") continue;
2982
3157
  buf.insertChar(ch);
2983
- await this.context.plugins?.run("onRune", makePaneAdapter(buf), ch);
2984
- 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);
2985
3160
  }
2986
3161
  }
2987
3162
 
2988
3163
  async runPluginBool(fn) {
2989
- const luaOk = await this.context.plugins?.runBool(fn, makePaneAdapter(this.buffer)) ?? true;
2990
- 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;
2991
3166
  return luaOk && jsOk;
2992
3167
  }
2993
3168
 
@@ -3002,6 +3177,10 @@ class App {
3002
3177
  this.render();
3003
3178
  return;
3004
3179
  }
3180
+ if (await this.handleMessageRowMouse(event)) {
3181
+ this.render();
3182
+ return;
3183
+ }
3005
3184
  if (await this.handleStatusBarMouse(event)) {
3006
3185
  this.render();
3007
3186
  return;
@@ -3052,7 +3231,7 @@ class App {
3052
3231
  }
3053
3232
  return;
3054
3233
  }
3055
- 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;
3056
3235
  buf.allowCursorOffscreen = false;
3057
3236
  const gutterW = _swGutterW;
3058
3237
  const localY = event.y - clicked.y;
@@ -3073,6 +3252,18 @@ class App {
3073
3252
  }
3074
3253
  buf.cursor.y = y;
3075
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
+ }
3076
3267
  if (event.action === "down") {
3077
3268
  if (inGutter) {
3078
3269
  // Message column (first msgW cols): show message text in infobar, no selection.
@@ -3154,6 +3345,15 @@ class App {
3154
3345
  buf.ensureCursor();
3155
3346
  }
3156
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
+
3157
3357
  handleSuggestionMouse(event) {
3158
3358
  if (this._suggestionsRow == null || event.y !== this._suggestionsRow) return false;
3159
3359
  if (event.action !== "down" || event.button !== "left") return false;
@@ -3173,6 +3373,19 @@ class App {
3173
3373
  return true;
3174
3374
  }
3175
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
+
3176
3389
  async handleStatusBarMouse(event) {
3177
3390
  if (this._statusBarRow == null || event.y !== this._statusBarRow) return false;
3178
3391
  if (event.action !== "down" || event.button !== "left") return false;
@@ -3230,7 +3443,11 @@ class App {
3230
3443
  break;
3231
3444
  }
3232
3445
  case "fmt":
3233
- 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
+ }
3234
3451
  break;
3235
3452
  case "enc":
3236
3453
  if (buf) {
@@ -3290,9 +3507,9 @@ class App {
3290
3507
  if (index < 0 || index >= this.tabs.length || index === this.activeTabIdx) return false;
3291
3508
  this.activeTabIdx = index;
3292
3509
  this.message = "";
3293
- if (this.context.plugins && this.buffer) this.context.plugins.curPaneAdapter = makePaneAdapter(this.buffer);
3294
- this.context.plugins?.run("onSetActive", makePaneAdapter(this.buffer));
3295
- 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));
3296
3513
  return true;
3297
3514
  }
3298
3515
 
@@ -3543,14 +3760,14 @@ class App {
3543
3760
  this.openPrompt("Save as: ", async (value) => {
3544
3761
  if (value) {
3545
3762
  await this.buffer.save(resolve(expandHome(value)));
3546
- await this.context.plugins?.run("onSave", makePaneAdapter(this.buffer));
3547
- 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));
3548
3765
  }
3549
3766
  }, { completer: fileComplete, initial });
3550
3767
  } else {
3551
3768
  await this.buffer.save();
3552
- await this.context.plugins?.run("onSave", makePaneAdapter(this.buffer));
3553
- 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));
3554
3771
  await this._saveCursorForBuf(this.buffer);
3555
3772
  }
3556
3773
  } catch (error) {
@@ -3707,10 +3924,10 @@ class App {
3707
3924
  this.tabs.splice(this.activeTabIdx, 1);
3708
3925
  this.activeTabIdx = Math.min(this.activeTabIdx, this.tabs.length - 1);
3709
3926
  this.message = "";
3710
- if (this.context.plugins && this.buffer) this.context.plugins.curPaneAdapter = makePaneAdapter(this.buffer);
3711
- 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));
3712
3929
  await this.context.plugins?.run("onBufferClose", closing);
3713
- if (this.buffer) this.context.jsPlugins?.run("onSetActive", makePaneAdapter(this.buffer));
3930
+ if (this.buffer) this.context.jsPlugins?.run("onSetActive", makePaneAdapter(this.buffer, this));
3714
3931
  await this.context.jsPlugins?.run("onBufferClose", closing);
3715
3932
  this.render();
3716
3933
  }
@@ -3883,6 +4100,9 @@ class App {
3883
4100
  this.message = `colorscheme: ${err.message}`;
3884
4101
  }
3885
4102
  }
4103
+ if (opt === "clipboard") {
4104
+ await this.reinitializeClipboard(buf.Settings[opt]);
4105
+ }
3886
4106
  }
3887
4107
  break;
3888
4108
  }
@@ -3929,8 +4149,8 @@ class App {
3929
4149
  if (answer === "y") {
3930
4150
  try {
3931
4151
  await buf.save(target);
3932
- await this.context.plugins?.run("onSave", makePaneAdapter(buf));
3933
- 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));
3934
4154
  } catch (err) {
3935
4155
  this.message = err.message;
3936
4156
  }
@@ -3940,8 +4160,8 @@ class App {
3940
4160
  } else if (saveArgs.length > 0) {
3941
4161
  try {
3942
4162
  await buf.save(resolve(expandHome(saveArgs[0])));
3943
- await this.context.plugins?.run("onSave", makePaneAdapter(buf));
3944
- 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));
3945
4165
  }
3946
4166
  catch (err) { this.message = err.message; }
3947
4167
  } else {
@@ -3998,10 +4218,9 @@ class App {
3998
4218
  this.toggleComment();
3999
4219
  break;
4000
4220
  case "goto": {
4001
- if (cmdArgs.length === 0) { this.message = "Usage: goto <line[:col]>"; break; }
4221
+ if (cmdArgs.length === 0) { this.message = "Usage: goto <line[.subrow][:col]>"; break; }
4002
4222
  try {
4003
- const { line, col } = parseLineCol(cmdArgs[0]);
4004
- buf.gotoLoc(line, col);
4223
+ this.gotoLocation(buf, parseLineCol(cmdArgs[0]), this.pane);
4005
4224
  this.pane.selection = null;
4006
4225
  } catch (error) {
4007
4226
  this.message = String(error.message || error);
@@ -4195,7 +4414,7 @@ class App {
4195
4414
  await this.context.plugins?.run("onBufferOpen", buf);
4196
4415
  await this.context.jsPlugins?.run("onBufferOpen", buf);
4197
4416
  }
4198
- if (cmd !== "togglelocal" && cfg && opt in cfg.globalSettings) {
4417
+ if (cmd !== "togglelocal" && cfg && opt in cfg.globalSettings && !LOCAL_SETTINGS.has(opt)) {
4199
4418
  try { cfg.setGlobalOptionNative(opt, newVal, { modified: true }); await cfg.saveSettings(); } catch {}
4200
4419
  }
4201
4420
  break;
@@ -4209,12 +4428,13 @@ class App {
4209
4428
  const defVal = opt in defaults ? defaults[opt] : true;
4210
4429
  try { buf.SetOption(opt, String(defVal)); } catch (err) { this.message = String(err.message || err); break; }
4211
4430
  this.message = `${opt} = ${defVal}`;
4212
- if (cfgR && opt in cfgR.globalSettings) {
4431
+ if (cfgR && opt in cfgR.globalSettings && !LOCAL_SETTINGS.has(opt)) {
4213
4432
  try { cfgR.setGlobalOptionNative(opt, defVal, { modified: true }); await cfgR.saveSettings(); } catch {}
4214
4433
  }
4215
4434
  if (opt === "colorscheme" && this.context?.runtime) {
4216
4435
  try { this.context.colorscheme = await new Colorscheme(this.context.runtime).load(String(defVal)); } catch {}
4217
4436
  }
4437
+ if (opt === "clipboard") await this.reinitializeClipboard(defVal);
4218
4438
  break;
4219
4439
  }
4220
4440
  case "jump": {
@@ -4363,11 +4583,66 @@ class App {
4363
4583
  await this.runAlert(content);
4364
4584
  break;
4365
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;
4366
4641
  default: {
4367
4642
  const pluginCmd = this.context.plugins?.commands?.get(cmd);
4368
4643
  if (pluginCmd) {
4369
4644
  try {
4370
- await pluginCmd(makePaneAdapter(this.buffer), cmdArgs);
4645
+ await pluginCmd(makePaneAdapter(this.buffer, this), cmdArgs);
4371
4646
  } catch (e) {
4372
4647
  this.message = String(e.message ?? e);
4373
4648
  }
@@ -4692,6 +4967,7 @@ const COMMAND_NAMES = [
4692
4967
  "cd", "pwd", "tab", "run", "vsplit", "hsplit", "term", "tts", "ttsspeed", "ttspitch", "ttslang", "reopen", "theme", "toggle", "tog",
4693
4968
  "togglelocal", "reset", "jump", "tabmove", "tabswitch", "textfilter", "bind", "unbind", "reload", "lintlog", "act", "action", "raw",
4694
4969
  "help", "plugin", "showkey", "memusage", "retab", "eval",
4970
+ "copy", "cut", "cutline", "paste", "pasteprimary",
4695
4971
  ];
4696
4972
 
4697
4973
  const SUPPORTED_ENCODING_LABELS = [
@@ -4886,10 +5162,40 @@ function isEmptyUntitledBuffer(buffer) {
4886
5162
  return !buffer.path && !buffer.modified && buffer.lines.length === 1 && buffer.lines[0] === "";
4887
5163
  }
4888
5164
 
4889
- 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) {
4890
5194
  const pane = {
4891
5195
  Buf: makeBufferAdapter(buffer),
4892
5196
  Cursor: makeCursorAdapter(buffer),
5197
+ CursorLocation: () => formatCursorLocation(buffer, app?.paneForBuffer?.(buffer) ?? app?.pane ?? null),
5198
+ AbsoluteCursorLocation: () => formatAbsoluteCursorLocation(buffer),
4893
5199
  Save: () => buffer.save(),
4894
5200
  Backspace: () => buffer.backspace(),
4895
5201
  Delete: () => buffer.deleteForward(),
@@ -5198,19 +5504,27 @@ function detectTtsCmd() {
5198
5504
  return null;
5199
5505
  }
5200
5506
 
5201
- 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 = {}) {
5202
5516
  if (isHttpUrl(pathOrUrl)) {
5203
5517
  let encoding = context.config?.globalSettings?.encoding ?? DEFAULT_SETTINGS.encoding;
5204
5518
  const decoded = await fetchTextWithEncoding(pathOrUrl, encoding);
5205
5519
  const text = decoded.text;
5206
5520
  encoding = decoded.encoding;
5207
5521
  const urlPath = pathOrUrl.replace(/[?#].*$/, "");
5208
- const buffer = new BufferModel({ path: pathOrUrl, text, command: {}, encoding });
5522
+ const buffer = new BufferModel({ path: pathOrUrl, text, command, encoding });
5209
5523
  attachSyntax(buffer, context, urlPath, text);
5210
5524
  return buffer;
5211
5525
  }
5212
- const buffer = await BufferModel.fromFile(pathOrUrl, {}, context);
5213
- 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]) {
5214
5528
  const saved = context.cursorStates[pathOrUrl];
5215
5529
  const y = clamp(saved.y ?? 0, 0, buffer.lines.length - 1);
5216
5530
  const x = clamp(saved.x ?? 0, 0, buffer.lines[y]?.length ?? 0);
@@ -5470,6 +5784,15 @@ function lineNumberText(buf, lineNo, row, gutterW) {
5470
5784
  return String(lineNo + 1).padStart(Math.max(0, gutterW - 1)) + " ";
5471
5785
  }
5472
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
+
5473
5796
  const BRACE_PAIRS = { "(": ")", "[": "]", "{": "}" };
5474
5797
  const BRACE_REVERSE = { ")": "(", "]": "[", "}": "{" };
5475
5798
 
@@ -5732,7 +6055,7 @@ async function loadBuffers(files, command) {
5732
6055
  if (files.length > 0) {
5733
6056
  for (const file of files) {
5734
6057
  try {
5735
- buffers.push(await loadBufferForPath(file, loadBuffers.context ?? {}));
6058
+ buffers.push(await loadBufferForPath(file, loadBuffers.context ?? {}, command));
5736
6059
  } catch (error) {
5737
6060
  console.error(error.message || error);
5738
6061
  }
@@ -5750,6 +6073,11 @@ async function loadBuffers(files, command) {
5750
6073
  return buffers.length ? buffers : [new BufferModel({ command })];
5751
6074
  }
5752
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
+
5753
6081
  async function main() {
5754
6082
  const { flags, files: rawFiles } = parseArgs(process.argv.slice(2));
5755
6083
  if (flags.help) {
@@ -5757,7 +6085,6 @@ async function main() {
5757
6085
  return;
5758
6086
  }
5759
6087
  if (flags.version) {
5760
- const clipboard = new ClipboardManager();
5761
6088
  const ttsCmd = detectTtsCmd();
5762
6089
  console.log(pkg.name+":",pkg.description)
5763
6090
  console.log(" Rewritten by: Dr. John (醫者小智)")
@@ -5766,9 +6093,24 @@ async function main() {
5766
6093
  console.log("Runtime:", `Bun ${Bun.version}`);
5767
6094
  console.log("Platform:", platformId());
5768
6095
  console.log("Http client:",detectHttpBackend());
5769
- console.log("Clipboard:", clipboard.methodName());
5770
6096
  console.log("TTS:", ttsCmd ? ttsCmd.cmd[0] : "not found");
5771
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();
5772
6114
  return;
5773
6115
  }
5774
6116
  if (flags.options) {
@@ -5789,7 +6131,7 @@ async function main() {
5789
6131
  const syntaxDefinitions = await loadSyntaxDefinitions(runtime);
5790
6132
 
5791
6133
  if (flags.cat) {
5792
- await catFiles(rawFiles, colorscheme, syntaxDefinitions);
6134
+ await catFiles(rawFiles, colorscheme, syntaxDefinitions, config.getGlobalOption("encoding"));
5793
6135
  return;
5794
6136
  }
5795
6137
 
@@ -5827,8 +6169,8 @@ async function main() {
5827
6169
  }
5828
6170
 
5829
6171
  if (flags.clean) {
5830
- console.error("Clean is not implemented yet.");
5831
- process.exit(1);
6172
+ await cleanConfig(config, plugins);
6173
+ return;
5832
6174
  }
5833
6175
 
5834
6176
  const pluginErr = await plugins.loadAll();
@@ -5870,7 +6212,7 @@ async function main() {
5870
6212
  }
5871
6213
  const app = new App(buffers, context);
5872
6214
  jsPlugins.setApp(app);
5873
- if (plugins && !pluginErr && app.buffer) plugins.curPaneAdapter = makePaneAdapter(app.buffer);
6215
+ if (plugins && !pluginErr && app.buffer) plugins.curPaneAdapter = makePaneAdapter(app.buffer, app);
5874
6216
  // Dispatch all JS plugin lifecycle hooks after setApp so TermMessage,
5875
6217
  // CurPane, cmd/action proxies, and buffer APIs all work correctly.
5876
6218
  await jsPlugins.run("preinit");
@@ -6006,6 +6348,26 @@ function uncommentText(line, commentType) {
6006
6348
  }
6007
6349
  return indent + rest;
6008
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
+
6009
6371
  function pasteStatusMessage(method, text) {
6010
6372
  const value = String(text);
6011
6373
  const lines = value.split("\n").length;
@@ -6187,14 +6549,16 @@ function segmentSelection(selection, lineNo, start, end) {
6187
6549
  function parseLineCol(value) {
6188
6550
  const input = String(value).trim();
6189
6551
  if (!input) throw new Error("Not enough arguments");
6190
- const parts = input.split(":");
6191
- if (parts.length > 2 || parts[0] === "") throw new Error("Invalid line number");
6192
- if (parts.length === 2 && parts[1] === "") throw new Error("Invalid column number");
6193
- const line = Number(parts[0]);
6194
- 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]);
6195
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");
6196
6560
  if (!Number.isInteger(col)) throw new Error("Invalid column number");
6197
- return { line, col };
6561
+ return { line, subRow, col };
6198
6562
  }
6199
6563
 
6200
6564
  function parseOptionValue(value) {
@@ -6210,19 +6574,23 @@ function syncEditorSettings(config) {
6210
6574
  }
6211
6575
  }
6212
6576
 
6213
- async function catFiles(files, colorscheme, syntaxDefinitions) {
6577
+ async function catFiles(files, colorscheme, syntaxDefinitions, encoding = DEFAULT_SETTINGS.encoding) {
6214
6578
  const targets = files.length > 0 ? files.map((f) => ({ path: f, stdin: false })) : [{ path: null, stdin: true }];
6215
6579
  for (const { path: filePath, stdin } of targets) {
6216
6580
  let content;
6217
6581
  let effectivePath = filePath;
6218
6582
  if (stdin) {
6219
- 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;
6220
6586
  } else if (isHttpUrl(filePath)) {
6221
- content = await fetchHttp(filePath);
6587
+ const decoded = await fetchTextWithEncoding(filePath, encoding);
6588
+ content = decoded.text;
6222
6589
  // Use the URL pathname for syntax/md detection (strip query/hash)
6223
6590
  try { effectivePath = new URL(filePath).pathname; } catch { effectivePath = filePath; }
6224
6591
  } else {
6225
- content = await Bun.file(filePath).text();
6592
+ const decoded = await readTextFileWithEncoding(filePath, encoding);
6593
+ content = decoded.text;
6226
6594
  }
6227
6595
  if (effectivePath && /\.md$/i.test(effectivePath)) {
6228
6596
  process.stdout.write(