bunmicro 0.9.10 → 0.9.20

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";
@@ -80,6 +81,7 @@ const DEFAULT_SETTINGS = {
80
81
  tabsize: 4,
81
82
  tabstospaces: false,
82
83
  autosave: 0,
84
+ cursorshape: "block",
83
85
  cursorline: true,
84
86
  diffgutter: false,
85
87
  eofnewline: true,
@@ -92,9 +94,11 @@ const DEFAULT_SETTINGS = {
92
94
  savecursor: false,
93
95
  softwrap: false,
94
96
  wordwrap: false,
97
+ pageoverlap: 2,
95
98
  scrollmargin: 3,
96
99
  reload: "prompt",
97
100
  encoding: "utf-8",
101
+ fileformat: process.platform === "win32" ? "dos" : "unix",
98
102
  "comment.type": "",
99
103
  commenttype: "",
100
104
  trailingws: false,
@@ -136,8 +140,7 @@ function isHttpUrl(value) {
136
140
  return String(value ?? "").startsWith("http://") || String(value ?? "").startsWith("https://");
137
141
  }
138
142
 
139
- async function readTextFileWithEncoding(path, encoding = "utf-8") {
140
- const bytes = new Uint8Array(await Bun.file(path).arrayBuffer());
143
+ function decodeTextBytesWithEncoding(bytes, encoding = "utf-8") {
141
144
  if (normalizeEncodingLabel(encoding) === "hex3") {
142
145
  return { text: encodeBinaryToBuffer(bytes).toString("latin1"), encoding: "hex3" };
143
146
  }
@@ -145,13 +148,14 @@ async function readTextFileWithEncoding(path, encoding = "utf-8") {
145
148
  return { text: decoder.decode(bytes), encoding: decoder.encoding };
146
149
  }
147
150
 
151
+ async function readTextFileWithEncoding(path, encoding = "utf-8") {
152
+ const bytes = new Uint8Array(await Bun.file(path).arrayBuffer());
153
+ return decodeTextBytesWithEncoding(bytes, encoding);
154
+ }
155
+
148
156
  async function fetchTextWithEncoding(url, encoding = "utf-8") {
149
157
  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 };
158
+ return decodeTextBytesWithEncoding(new Uint8Array(bytes), encoding);
155
159
  }
156
160
 
157
161
  function normalizeEncodingLabel(encoding = "utf-8") {
@@ -160,6 +164,21 @@ function normalizeEncodingLabel(encoding = "utf-8") {
160
164
  return new TextDecoder(s).encoding;
161
165
  }
162
166
 
167
+ function detectFileFormat(text, fallback = DEFAULT_SETTINGS.fileformat) {
168
+ if (text.length === 0) return fallback === "dos" ? "dos" : "unix";
169
+ const newlineIdx = text.indexOf("\n");
170
+ if (newlineIdx < 0) return "unix";
171
+ return newlineIdx > 0 && text.charCodeAt(newlineIdx - 1) === 13 ? "dos" : "unix";
172
+ }
173
+
174
+ function normalizeBufferText(text) {
175
+ return String(text).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
176
+ }
177
+
178
+ function encodeBufferTextForFile(text, fileformat) {
179
+ return fileformat === "dos" ? text.replace(/\n/g, "\r\n") : text;
180
+ }
181
+
163
182
  function isReadonlyBuffer(buf) {
164
183
  return Boolean(buf?.readonly || buf?.Settings?.readonly || buf?.Type?.Readonly);
165
184
  }
@@ -469,6 +488,7 @@ function parseArgs(argv) {
469
488
  help: false,
470
489
  clean: false,
471
490
  cat: false,
491
+ docs: false,
472
492
  configDir: "",
473
493
  debug: false,
474
494
  profile: false,
@@ -484,6 +504,7 @@ function parseArgs(argv) {
484
504
  else if (arg === "-help" || arg === "--help" || arg === "-h") flags.help = true;
485
505
  else if (arg === "-clean") flags.clean = true;
486
506
  else if (arg === "--cat" || arg === "-cat" || arg === "--ccat" || arg === "-ccat" || arg === "--bat" || arg === "-bat" || arg === "--glow" || arg === "-glow") flags.cat = true;
507
+ else if (arg === "--docs" || arg === "--readme") flags.docs = true;
487
508
  else if (arg === "-debug") flags.debug = true;
488
509
  else if (arg === "-profile") flags.profile = true;
489
510
  else if (arg === "-config-dir") flags.configDir = argv[++i] ?? "";
@@ -500,17 +521,13 @@ function parseArgs(argv) {
500
521
 
501
522
  function usage() {
502
523
  return [
503
- `Usage: ${pkg.name} [OPTION]... [FILE]...`,
524
+ `Usage:
525
+ ${pkg.name} [OPTIONs] [FILEs] [+line[.subrow][:col]]\n`,
504
526
  "-clean",
505
- " Clean configuration directory and exit (not implemented in Bun port)",
527
+ " Clean configuration directory and exit",
506
528
  "-config-dir dir",
507
529
  " 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",
530
+ "",
514
531
  "-plugin list",
515
532
  " List installed plugins",
516
533
  "-plugin available|avail",
@@ -523,20 +540,28 @@ function usage() {
523
540
  " Remove installed plugin(s)",
524
541
  "-plugin update [name]...",
525
542
  " 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)",
543
+ "",
531
544
  "-<option> value",
532
545
  " Set an option for this session",
546
+ "-options",
547
+ " Show option help and exit\n",
548
+ "--cat, --ccat, --bat, --glow",
549
+ " Syntax-highlight file(s) and write to stdout, then exit (.md uses Bun.markdown.ansi)\n",
550
+ "-help, -h, --help",
551
+ " Show this help & exit",
552
+ "-version, -V, --version",
553
+ " Show version+backend info & exit",
554
+ "--docs, --readme",
555
+ ` Show ${pkg.name}'s README.md & exit`,
556
+
557
+
533
558
  ].join("\n");
534
559
  }
535
560
 
536
561
  function parseInput(args) {
537
562
  const files = [];
538
563
  const command = {
539
- startCursor: { x: -1, y: -1 },
564
+ startCursor: { line: -1, subRow: 0, col: 1 },
540
565
  searchRegex: "",
541
566
  searchAfterStart: false,
542
567
  };
@@ -545,25 +570,19 @@ function parseInput(args) {
545
570
 
546
571
  for (let i = 0; i < args.length; i++) {
547
572
  const arg = args[i];
548
- const pos = arg.match(/^\+(\d+)(?::(\d+))?$/);
573
+ const pos = arg.match(/^\+(-?\d+(?:\.\d+)?)(?::(-?\d+))?$/);
549
574
  const search = arg.match(/^\+\/(.+)$/);
550
- const cursorFile = arg.match(/^(.+):(\d+)(?::(\d+))$/);
575
+ const cursorFile = arg.match(/^(.+):(-?\d+(?:\.\d+)?)(?::(-?\d+))?$/);
551
576
 
552
577
  if (pos) {
553
- command.startCursor = {
554
- x: pos[2] ? Number(pos[2]) - 1 : 0,
555
- y: Number(pos[1]) - 1,
556
- };
578
+ command.startCursor = parseLineCol(`${pos[1]}${pos[2] ? `:${pos[2]}` : ""}`);
557
579
  posIndex = i;
558
580
  } else if (search) {
559
581
  command.searchRegex = search[1];
560
582
  searchIndex = i;
561
583
  } else if (DEFAULT_SETTINGS.parsecursor && cursorFile && existsSync(cursorFile[1])) {
562
584
  files.push(cursorFile[1]);
563
- command.startCursor = {
564
- x: cursorFile[3] ? Number(cursorFile[3]) - 1 : 0,
565
- y: Number(cursorFile[2]) - 1,
566
- };
585
+ command.startCursor = parseLineCol(`${cursorFile[2]}${cursorFile[3] ? `:${cursorFile[3]}` : ""}`);
567
586
  posIndex = i;
568
587
  } else {
569
588
  files.push(arg);
@@ -582,9 +601,9 @@ class BufferModel {
582
601
  this.path = path;
583
602
  this.type = type;
584
603
  this.name = path ? basename(path) : "No name";
585
- this.fileformat = text.includes("\r\n") ? "dos" : "unix";
604
+ this.fileformat = detectFileFormat(text, DEFAULT_SETTINGS.fileformat);
586
605
  this.encoding = normalizeEncodingLabel(encoding);
587
- this.lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
606
+ this.lines = normalizeBufferText(text).split("\n");
588
607
  if (this.lines.length === 0) this.lines = [""];
589
608
  this.cursor = { x: 0, y: 0 };
590
609
  this.scroll = { x: 0, y: 0, row: 0 };
@@ -623,8 +642,11 @@ class BufferModel {
623
642
  savecursor: DEFAULT_SETTINGS.savecursor,
624
643
  softwrap: DEFAULT_SETTINGS.softwrap,
625
644
  wordwrap: DEFAULT_SETTINGS.wordwrap,
645
+ pageoverlap: DEFAULT_SETTINGS.pageoverlap,
626
646
  scrollmargin: DEFAULT_SETTINGS.scrollmargin,
627
647
  reload: DEFAULT_SETTINGS.reload,
648
+ eofnewline: DEFAULT_SETTINGS.eofnewline,
649
+ fileformat: this.fileformat,
628
650
  trailingws: DEFAULT_SETTINGS.trailingws,
629
651
  encoding: this.encoding,
630
652
  readonly,
@@ -633,9 +655,13 @@ class BufferModel {
633
655
  this.AbsPath = path;
634
656
  this.Type = { Scratch: type !== "default", Kind: 0, Readonly: readonly };
635
657
 
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);
658
+ if (commandHasStartCursor(command)) {
659
+ if (command.startCursor.subRow > 0) {
660
+ this.gotoLoc(command.startCursor.line, 1);
661
+ this._pendingVisualGoto = { subRow: command.startCursor.subRow, col: command.startCursor.col };
662
+ } else {
663
+ this.gotoLoc(command.startCursor.line, command.startCursor.col);
664
+ }
639
665
  }
640
666
  if (command.searchRegex) this.search(command.searchRegex, command.searchAfterStart);
641
667
  }
@@ -897,8 +923,8 @@ class BufferModel {
897
923
  return true;
898
924
  }
899
925
 
900
- page(delta, height) {
901
- this.cursor.y += delta * Math.max(1, height - 2);
926
+ page(delta, amount) {
927
+ this.cursor.y += delta * Math.max(1, amount);
902
928
  this.ensureCursor();
903
929
  }
904
930
 
@@ -1019,8 +1045,9 @@ class BufferModel {
1019
1045
  const text = decoded.text;
1020
1046
  this.encoding = decoded.encoding;
1021
1047
  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");
1048
+ this.fileformat = detectFileFormat(text, this.Settings.fileformat ?? DEFAULT_SETTINGS.fileformat);
1049
+ this.Settings.fileformat = this.fileformat;
1050
+ this.lines = normalizeBufferText(text).split("\n");
1024
1051
  if (this.lines.length === 0) this.lines = [""];
1025
1052
  this.modTimeMs = null;
1026
1053
  this.readonly = false;
@@ -1044,8 +1071,9 @@ class BufferModel {
1044
1071
  const text = decoded.text;
1045
1072
  this.encoding = decoded.encoding;
1046
1073
  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");
1074
+ this.fileformat = detectFileFormat(text, this.Settings.fileformat ?? DEFAULT_SETTINGS.fileformat);
1075
+ this.Settings.fileformat = this.fileformat;
1076
+ this.lines = normalizeBufferText(text).split("\n");
1049
1077
  if (this.lines.length === 0) this.lines = [""];
1050
1078
  this.modTimeMs = info.mtimeMs;
1051
1079
  this.readonly = !canWritePath(this.path);
@@ -1080,8 +1108,8 @@ class BufferModel {
1080
1108
  this.message = `Saved ${path}`;
1081
1109
  return;
1082
1110
  }
1083
- if (DEFAULT_SETTINGS.eofnewline && !text.endsWith("\n")) text += "\n";
1084
- await Bun.write(path, text);
1111
+ if ((this.Settings.eofnewline ?? DEFAULT_SETTINGS.eofnewline) && !text.endsWith("\n")) text += "\n";
1112
+ await Bun.write(path, encodeBufferTextForFile(text, this.Settings.fileformat ?? this.fileformat));
1085
1113
  this.encoding = "utf-8";
1086
1114
  this.Settings.encoding = "utf-8";
1087
1115
  this.path = path;
@@ -1222,19 +1250,33 @@ class BufferModel {
1222
1250
  SetOption(option, value) {
1223
1251
  const oldValue = this.Settings[option];
1224
1252
  const parsed = parseOptionValue(value);
1225
- this.Settings[option] = option === "encoding" ? normalizeEncodingLabel(parsed) : parsed;
1253
+ if (option === "cursorshape" && !OPTION_CHOICES.cursorshape.includes(String(parsed))) {
1254
+ throw new Error(`Invalid value for cursorshape: ${parsed}`);
1255
+ }
1256
+ if (option === "fileformat" && !OPTION_CHOICES.fileformat.includes(String(parsed))) {
1257
+ throw new Error(`Invalid value for fileformat: ${parsed}`);
1258
+ }
1259
+ this.Settings[option] = option === "encoding" ? normalizeEncodingLabel(parsed) : option === "fileformat" ? String(parsed) : parsed;
1226
1260
  if (option === "filetype") this.filetype = String(parsed);
1227
1261
  if (option === "encoding") this.encoding = this.Settings.encoding;
1262
+ if (option === "fileformat") this.fileformat = this.Settings.fileformat === "dos" ? "dos" : "unix";
1228
1263
  if (option === "readonly") { this.readonly = Boolean(parsed); this.Type.Readonly = this.readonly; }
1229
- if (option in DEFAULT_SETTINGS) DEFAULT_SETTINGS[option] = this.Settings[option];
1264
+ if (option in DEFAULT_SETTINGS && option !== "fileformat") DEFAULT_SETTINGS[option] = this.Settings[option];
1230
1265
  this._onOptionChange?.(option, oldValue, this.Settings[option]);
1231
1266
  }
1232
1267
 
1233
1268
  DoSetOptionNative(option, value) {
1234
1269
  const oldValue = this.Settings[option];
1235
- this.Settings[option] = option === "encoding" ? normalizeEncodingLabel(value) : value;
1270
+ if (option === "cursorshape" && !OPTION_CHOICES.cursorshape.includes(String(value))) {
1271
+ throw new Error(`Invalid value for cursorshape: ${value}`);
1272
+ }
1273
+ if (option === "fileformat" && !OPTION_CHOICES.fileformat.includes(String(value))) {
1274
+ throw new Error(`Invalid value for fileformat: ${value}`);
1275
+ }
1276
+ this.Settings[option] = option === "encoding" ? normalizeEncodingLabel(value) : option === "fileformat" ? String(value) : value;
1236
1277
  if (option === "filetype") this.filetype = String(value);
1237
1278
  if (option === "encoding") this.encoding = this.Settings.encoding;
1279
+ if (option === "fileformat") this.fileformat = this.Settings.fileformat === "dos" ? "dos" : "unix";
1238
1280
  if (option === "readonly") { this.readonly = Boolean(value); this.Type.Readonly = this.readonly; }
1239
1281
  this._onOptionChange?.(option, oldValue, this.Settings[option]);
1240
1282
  }
@@ -1263,7 +1305,7 @@ class BufferModel {
1263
1305
  }
1264
1306
 
1265
1307
  Bytes() {
1266
- return this.lines.join("\n");
1308
+ return encodeBufferTextForFile(this.lines.join("\n"), this.Settings.fileformat ?? this.fileformat);
1267
1309
  }
1268
1310
 
1269
1311
  Size() {
@@ -1636,6 +1678,10 @@ class App {
1636
1678
  this._acHScroll = 0;
1637
1679
  this._suppressMouseUntilUp = false;
1638
1680
  this._undoInsertChain = false;
1681
+ this._freshClip = false;
1682
+ this._messageClickAction = null;
1683
+ this._messageRowY = null;
1684
+ this._messageRowClickZone = null;
1639
1685
  }
1640
1686
 
1641
1687
  get tab() { return this.tabs[this.activeTabIdx]; }
@@ -1645,6 +1691,22 @@ class App {
1645
1691
  get active() { return this.activeTabIdx; }
1646
1692
  get buffers() { return this.tabs.map(t => t.buffer).filter(Boolean); }
1647
1693
 
1694
+ paneForBuffer(buffer) {
1695
+ for (const tab of this.tabs) {
1696
+ const pane = tab.panes().find((p) => p.buffer === buffer);
1697
+ if (pane) return pane;
1698
+ }
1699
+ return null;
1700
+ }
1701
+
1702
+ formatCursorLocation(buffer = this.buffer, pane = null) {
1703
+ return formatCursorLocation(buffer, pane ?? this.paneForBuffer(buffer) ?? this.pane);
1704
+ }
1705
+
1706
+ formatAbsoluteCursorLocation(buffer = this.buffer) {
1707
+ return formatAbsoluteCursorLocation(buffer);
1708
+ }
1709
+
1648
1710
  async start() {
1649
1711
  this._started = true;
1650
1712
  // When stdin was a pipe (content already consumed in loadBuffers), open the
@@ -1666,7 +1728,10 @@ class App {
1666
1728
  _activeTtyStream = this._ttyStream;
1667
1729
  this._ttyStream.setRawMode?.(true);
1668
1730
  this._ttyStream.resume();
1669
- this._ttyStream.on("data", (data) => this.handleInput(data));
1731
+ const clipSetting = this.context?.config?.getGlobalOption("clipboard") ?? "external";
1732
+ await this.reinitializeClipboard(clipSetting);
1733
+ this._inputHandler = (data) => this.handleInput(data);
1734
+ this._ttyStream.on("data", this._inputHandler);
1670
1735
  process.stdout.on("resize", () => {
1671
1736
  const resize = this.screen.updateSize();
1672
1737
  this.rows = resize.rows;
@@ -1687,6 +1752,15 @@ class App {
1687
1752
  }
1688
1753
  }
1689
1754
 
1755
+ async reinitializeClipboard(setting) {
1756
+ if (this._inputHandler) this._ttyStream?.removeListener("data", this._inputHandler);
1757
+ try {
1758
+ await this.clipboard.initFromSetting(setting, this._ttyStream, process.stdout, 150);
1759
+ } finally {
1760
+ if (this._inputHandler) this._ttyStream?.on("data", this._inputHandler);
1761
+ }
1762
+ }
1763
+
1690
1764
  async stop(code = 0) {
1691
1765
  this.running = false;
1692
1766
  for (const tab of this.tabs)
@@ -1899,8 +1973,19 @@ class App {
1899
1973
  cursorCol = p.x + gutterW + displayWidth(line.slice(buf.scroll.x, cursorX));
1900
1974
  }
1901
1975
 
1902
- const cursorVisible = cursorRow >= p.y && cursorRow < p.y + p.h && cursorCol >= p.x && cursorCol < p.x + p.w;
1903
- this.screen.setCursor(clamp(cursorCol, 0, this.cols - 1), clamp(cursorRow, 0, this.rows - 1), cursorVisible, "steady-block");
1976
+ // Go micro hides the terminal cursor while a non-empty selection is
1977
+ // active. Otherwise its block cursor makes the exclusive selection end
1978
+ // look selected even though copy/cut correctly omit that character.
1979
+ const hasSelection = p.selection && !sameLoc(p.selection.start, p.selection.end);
1980
+ const cursorVisible = !hasSelection &&
1981
+ cursorRow >= p.y && cursorRow < p.y + p.h &&
1982
+ cursorCol >= p.x && cursorCol < p.x + p.w;
1983
+ this.screen.setCursor(
1984
+ clamp(cursorCol, 0, this.cols - 1),
1985
+ clamp(cursorRow, 0, this.rows - 1),
1986
+ cursorVisible,
1987
+ DEFAULT_SETTINGS.cursorshape,
1988
+ );
1904
1989
  } else if (!this.prompt && activePaneObj?.type !== "term") {
1905
1990
  this.screen.setCursor(0, 0, false);
1906
1991
  }
@@ -1912,7 +1997,23 @@ class App {
1912
1997
  const statusStyle = this.context.colorscheme?.get("statusline") ?? { ...defaultStyle, reverse: true };
1913
1998
  const style = { ...statusStyle, reverse: false };
1914
1999
  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);
2000
+ this._messageRowY = row;
2001
+ this._messageRowClickZone = null;
2002
+ const msg = String(message);
2003
+ // detect [AltMethod] prefix — render it underlined as a clickable button
2004
+ if (this._messageClickAction && msg.startsWith("[")) {
2005
+ const close = msg.indexOf("]");
2006
+ if (close > 0) {
2007
+ const btnText = msg.slice(0, close + 1);
2008
+ const rest = msg.slice(close + 1);
2009
+ const btnStyle = { ...style, underline: true };
2010
+ let sx = putText(this.screen, 0, row, btnText, btnStyle, this.cols);
2011
+ putText(this.screen, sx, row, rest.slice(0, this.cols - sx), style, this.cols - sx);
2012
+ this._messageRowClickZone = { start: 0, end: sx };
2013
+ return;
2014
+ }
2015
+ }
2016
+ putText(this.screen, 0, row, msg.slice(0, this.cols), style, this.cols);
1916
2017
  }
1917
2018
 
1918
2019
  renderKeyMenu(defaultStyle, statusRow) {
@@ -2008,9 +2109,46 @@ class App {
2008
2109
  return -1;
2009
2110
  }
2010
2111
 
2112
+ gotoLocation(buf, loc, pane = this.pane) {
2113
+ if (!buf) return;
2114
+ if (!loc?.subRow) {
2115
+ buf.gotoLoc(loc.line, loc.col);
2116
+ return;
2117
+ }
2118
+ buf.gotoLoc(loc.line, 1);
2119
+ this.applyVisualGoto(buf, pane, loc.subRow, loc.col);
2120
+ }
2121
+
2122
+ applyVisualGoto(buf, pane, subRow, col = 1) {
2123
+ if (!buf || !pane) return;
2124
+ const softwrap = buf.Settings?.softwrap ?? false;
2125
+ if (!softwrap) {
2126
+ buf.gotoLoc(buf.cursor.y + 1, col);
2127
+ return;
2128
+ }
2129
+ const gutterW = editorGutterWidth(buf);
2130
+ const bufW = Math.max(1, pane.w - gutterW);
2131
+ const wordwrap = buf.Settings?.wordwrap ?? false;
2132
+ const tabsize = buf.Settings?.tabsize ?? DEFAULT_SETTINGS.tabsize;
2133
+ const line = buf.lines[buf.cursor.y] ?? "";
2134
+ const breaks = softwrapBreaks(line, bufW, wordwrap, tabsize);
2135
+ const targetSubRow = clamp(Math.trunc(Number(subRow) || 0), 0, Math.max(0, breaks.length - 1));
2136
+ const segStart = breaks[targetSubRow] ?? 0;
2137
+ buf.cursor.x = visualColToCharIdx(line, segStart, Math.max(0, Math.trunc(Number(col) || 1) - 1));
2138
+ buf.ensureCursor();
2139
+ }
2140
+
2141
+ applyPendingVisualGoto(pane) {
2142
+ const pending = pane?.buffer?._pendingVisualGoto;
2143
+ if (!pending) return;
2144
+ delete pane.buffer._pendingVisualGoto;
2145
+ this.applyVisualGoto(pane.buffer, pane, pending.subRow, pending.col);
2146
+ }
2147
+
2011
2148
  renderEditorPane(pane, defaultStyle) {
2012
2149
  const buf = pane.buffer;
2013
2150
  if (!buf) return;
2151
+ this.applyPendingVisualGoto(pane);
2014
2152
  this.updateScrollForPane(pane);
2015
2153
  const gutterW = editorGutterWidth(buf);
2016
2154
  const braceMatches = findMatchingBracePositions(buf);
@@ -2066,7 +2204,7 @@ class App {
2066
2204
  if (lineNumW > 0) {
2067
2205
  const prefix = subRow === 0
2068
2206
  ? lineNumberText(buf, lineNo, row, lineNumW)
2069
- : " ".repeat(lineNumW);
2207
+ : visualLineNumberText(subRow, lineNumW);
2070
2208
  putText(this.screen, pane.x + msgW + diffCol, screenRow, prefix, isDirtyLongLine(buf, lineNo) ? dirtyGutterStyle : gutterStyle, lineNumW);
2071
2209
  }
2072
2210
  };
@@ -2287,6 +2425,86 @@ class App {
2287
2425
  buf.scroll.x = charIdxForScrollRight(buf.lines[buf.cursor.y] ?? "", buf.cursor.x, bufW);
2288
2426
  }
2289
2427
  }
2428
+
2429
+ pageScroll(pane, delta, amount = null) {
2430
+ const buf = pane?.buffer;
2431
+ if (!buf) return;
2432
+ const gutterW = editorGutterWidth(buf);
2433
+ const bufW = Math.max(1, (pane?.w ?? this.cols) - gutterW);
2434
+ const softwrap = buf.Settings?.softwrap ?? false;
2435
+ const wordwrap = softwrap && (buf.Settings?.wordwrap ?? false);
2436
+ const tabsize = buf.Settings?.tabsize ?? DEFAULT_SETTINGS.tabsize;
2437
+ const pageOverlap = Math.trunc(Number(buf.Settings?.pageoverlap ?? DEFAULT_SETTINGS.pageoverlap) || 0);
2438
+ const scrollAmount = amount ?? Math.max(1, (pane?.h ?? this.rows) - pageOverlap);
2439
+
2440
+ if (softwrap) {
2441
+ const start = { line: buf.scroll.y, row: buf.scroll.row ?? 0 };
2442
+ const next = delta < 0
2443
+ ? slocRetreatN(buf.lines, start, scrollAmount, bufW, wordwrap, tabsize)
2444
+ : slocAdvanceN(buf.lines, start, scrollAmount, bufW, wordwrap, tabsize);
2445
+ buf.scroll.y = next.line;
2446
+ buf.scroll.row = next.row;
2447
+ buf.scroll.x = 0;
2448
+ if (delta > 0) this.scrollAdjust(pane);
2449
+ } else {
2450
+ buf.scroll.y = Math.max(0, (buf.scroll.y ?? 0) + delta * scrollAmount);
2451
+ buf.scroll.row = 0;
2452
+ if (delta > 0) this.scrollAdjust(pane);
2453
+ }
2454
+ buf.allowCursorOffscreen = true;
2455
+ }
2456
+
2457
+ scrollAdjust(pane) {
2458
+ const buf = pane?.buffer;
2459
+ if (!buf || buf.lines.length === 0) return;
2460
+ const gutterW = editorGutterWidth(buf);
2461
+ const bufW = Math.max(1, (pane?.w ?? this.cols) - gutterW);
2462
+ const softwrap = buf.Settings?.softwrap ?? false;
2463
+ const wordwrap = softwrap && (buf.Settings?.wordwrap ?? false);
2464
+ const tabsize = buf.Settings?.tabsize ?? DEFAULT_SETTINGS.tabsize;
2465
+ if (softwrap) {
2466
+ const endLine = Math.max(0, buf.lines.length - 1);
2467
+ const endBreaks = softwrapBreaks(buf.lines[endLine] ?? "", bufW, wordwrap, tabsize);
2468
+ const end = { line: endLine, row: Math.max(0, endBreaks.length - 1) };
2469
+ const start = { line: buf.scroll.y, row: buf.scroll.row ?? 0 };
2470
+ if (slocDiff(buf.lines, start, end, bufW, wordwrap, tabsize) < (pane?.h ?? this.rows) - 1) {
2471
+ const adjusted = slocRetreatN(buf.lines, end, Math.max(0, (pane?.h ?? this.rows) - 1), bufW, wordwrap, tabsize);
2472
+ buf.scroll.y = adjusted.line;
2473
+ buf.scroll.row = adjusted.row;
2474
+ }
2475
+ } else {
2476
+ buf.scroll.y = Math.min(buf.scroll.y ?? 0, Math.max(0, buf.lines.length - (pane?.h ?? this.rows)));
2477
+ }
2478
+ }
2479
+
2480
+ cursorPage(pane, delta, { select = false, amount = null } = {}) {
2481
+ const buf = pane?.buffer;
2482
+ if (!buf) return;
2483
+ const pageOverlap = Math.trunc(Number(buf.Settings?.pageoverlap ?? DEFAULT_SETTINGS.pageoverlap) || 0);
2484
+ const selectionEndNewline = !select && delta > 0 && pane.selection?.end?.x === 0;
2485
+ let scrollAmount = amount ?? Math.max(1, (pane?.h ?? this.rows) - pageOverlap);
2486
+ if (selectionEndNewline) scrollAmount = Math.max(1, scrollAmount - 1);
2487
+ const move = () => {
2488
+ const softwrap = buf.Settings?.softwrap ?? false;
2489
+ if (softwrap) {
2490
+ for (let i = 0; i < scrollAmount; i++) {
2491
+ if (delta < 0) this._moveUpVisual(buf, pane);
2492
+ else this._moveDownVisual(buf, pane);
2493
+ }
2494
+ } else {
2495
+ buf.page(delta, scrollAmount);
2496
+ }
2497
+ };
2498
+ if (select) extendSelection(pane, buf, move);
2499
+ else {
2500
+ pane.selection = null;
2501
+ move();
2502
+ }
2503
+ if (selectionEndNewline) buf.moveHome();
2504
+ this.pageScroll(pane, delta, scrollAmount);
2505
+ buf.allowCursorOffscreen = false;
2506
+ }
2507
+
2290
2508
  // Softwrap-aware vertical cursor movement.
2291
2509
  // Moves cursor by one visual row, maintaining the target visual X column.
2292
2510
  _softwrapGetContext(buf, pane) {
@@ -2395,6 +2613,12 @@ class App {
2395
2613
  async _dispatchInput(data) {
2396
2614
  const text = decoder.decode(data);
2397
2615
 
2616
+ // Any non-mouse input clears the clipboard alt-copy action
2617
+ {
2618
+ const _evts = parseInputEvents(data);
2619
+ if (_evts.some(e => e.type !== "mouse")) this._messageClickAction = null;
2620
+ }
2621
+
2398
2622
  // Any non-mouse input stops TTS
2399
2623
  if (this._ttsState) {
2400
2624
  const events = parseInputEvents(data);
@@ -2504,12 +2728,14 @@ class App {
2504
2728
  else if (event.type === "paste") await this.handlePrompt(event.text);
2505
2729
  else await this.handleEvent(event);
2506
2730
  }
2731
+ this._syncPrimarySelection();
2507
2732
  return;
2508
2733
  }
2509
2734
 
2510
2735
  for (const event of parseInputEvents(data)) {
2511
2736
  await this.handleEvent(event);
2512
2737
  }
2738
+ this._syncPrimarySelection();
2513
2739
  }
2514
2740
 
2515
2741
  async handleEvent(event) {
@@ -2575,40 +2801,9 @@ class App {
2575
2801
  };
2576
2802
  buf.cursor = { ...this.pane.selection.end };
2577
2803
  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
- }
2804
+ case "ctrl-c": await this.handleCommand("copy"); break; //copy
2805
+ case "ctrl-x": await this.handleCommand("cut"); break; //cut
2806
+ case "ctrl-v": await this.handleCommand("paste"); break; //paste
2612
2807
  case "ctrl-z": //undo
2613
2808
  if (buf.undo()) this.pane.selection = null;
2614
2809
  else this.message = "Nothing to undo";
@@ -2722,17 +2917,7 @@ class App {
2722
2917
  }
2723
2918
  break;
2724
2919
  }
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;
2920
+ case "ctrl-k": await this.handleCommand("cutline"); break; //cutLine
2736
2921
  case "ctrl-o": //open
2737
2922
  this.openCommandMode("open ");
2738
2923
  break;
@@ -2840,11 +3025,11 @@ class App {
2840
3025
  break;
2841
3026
  case "shift-pageup":
2842
3027
  buf._lastVisX = null;
2843
- extendSelection(this.pane, buf, () => buf.page(-1, this.rows));
3028
+ this.cursorPage(this.pane, -1, { select: true });
2844
3029
  break;
2845
3030
  case "shift-pagedown":
2846
3031
  buf._lastVisX = null;
2847
- extendSelection(this.pane, buf, () => buf.page(1, this.rows));
3032
+ this.cursorPage(this.pane, 1, { select: true });
2848
3033
  break;
2849
3034
  case "home":
2850
3035
  buf._lastVisX = null;
@@ -2912,11 +3097,11 @@ class App {
2912
3097
  break;
2913
3098
  case "pageup":
2914
3099
  buf._lastVisX = null;
2915
- await runAction("PageUp", this);
3100
+ await runAction("CursorPageUp", this);
2916
3101
  break;
2917
3102
  case "pagedown":
2918
3103
  buf._lastVisX = null;
2919
- await runAction("PageDown", this);
3104
+ await runAction("CursorPageDown", this);
2920
3105
  break;
2921
3106
  case "tab":
2922
3107
  if (buf.acHas) buf.cycleAutocomplete(true);
@@ -2988,14 +3173,14 @@ class App {
2988
3173
  }
2989
3174
  if (ch < " " && ch !== "\t") continue;
2990
3175
  buf.insertChar(ch);
2991
- await this.context.plugins?.run("onRune", makePaneAdapter(buf), ch);
2992
- await this.context.jsPlugins?.run("onRune", makePaneAdapter(buf), ch);
3176
+ await this.context.plugins?.run("onRune", makePaneAdapter(buf, this), ch);
3177
+ await this.context.jsPlugins?.run("onRune", makePaneAdapter(buf, this), ch);
2993
3178
  }
2994
3179
  }
2995
3180
 
2996
3181
  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;
3182
+ const luaOk = await this.context.plugins?.runBool(fn, makePaneAdapter(this.buffer, this)) ?? true;
3183
+ const jsOk = await this.context.jsPlugins?.runBool(fn, makePaneAdapter(this.buffer, this)) ?? true;
2999
3184
  return luaOk && jsOk;
3000
3185
  }
3001
3186
 
@@ -3010,6 +3195,10 @@ class App {
3010
3195
  this.render();
3011
3196
  return;
3012
3197
  }
3198
+ if (await this.handleMessageRowMouse(event)) {
3199
+ this.render();
3200
+ return;
3201
+ }
3013
3202
  if (await this.handleStatusBarMouse(event)) {
3014
3203
  this.render();
3015
3204
  return;
@@ -3060,7 +3249,7 @@ class App {
3060
3249
  }
3061
3250
  return;
3062
3251
  }
3063
- if (!["down", "up", "drag"].includes(event.action) || !["left", "none"].includes(event.button)) return;
3252
+ if (!["down", "up", "drag"].includes(event.action) || !["left", "none", "middle"].includes(event.button)) return;
3064
3253
  buf.allowCursorOffscreen = false;
3065
3254
  const gutterW = _swGutterW;
3066
3255
  const localY = event.y - clicked.y;
@@ -3081,6 +3270,18 @@ class App {
3081
3270
  }
3082
3271
  buf.cursor.y = y;
3083
3272
  buf.cursor.x = x;
3273
+ if (event.button === "middle") {
3274
+ if (event.action === "down") {
3275
+ const pasted = this.clipboard.read("primary");
3276
+ if (pasted) {
3277
+ buf.pushUndo();
3278
+ this.pane.selection = null;
3279
+ buf.insert(pasted);
3280
+ this.message = pasteStatusMessage("primary", pasted);
3281
+ }
3282
+ }
3283
+ return;
3284
+ }
3084
3285
  if (event.action === "down") {
3085
3286
  if (inGutter) {
3086
3287
  // Message column (first msgW cols): show message text in infobar, no selection.
@@ -3162,6 +3363,15 @@ class App {
3162
3363
  buf.ensureCursor();
3163
3364
  }
3164
3365
 
3366
+ _syncPrimarySelection() {
3367
+ const sel = this.pane?.selection;
3368
+ if (!sel || sameLoc(sel.start, sel.end)) return;
3369
+ const buf = this.buffer;
3370
+ if (!buf) return;
3371
+ const text = getSelectionText(buf, sel);
3372
+ if (text) this.clipboard.write(text, "primary");
3373
+ }
3374
+
3165
3375
  handleSuggestionMouse(event) {
3166
3376
  if (this._suggestionsRow == null || event.y !== this._suggestionsRow) return false;
3167
3377
  if (event.action !== "down" || event.button !== "left") return false;
@@ -3181,6 +3391,19 @@ class App {
3181
3391
  return true;
3182
3392
  }
3183
3393
 
3394
+ async handleMessageRowMouse(event) {
3395
+ if (this._messageRowY == null || event.y !== this._messageRowY) return false;
3396
+ if (event.action !== "down" || event.button !== "left") return false;
3397
+ const zone = this._messageRowClickZone;
3398
+ if (!zone || !this._messageClickAction) return false;
3399
+ if (event.x < zone.start || event.x >= zone.end) return false;
3400
+ const result = this._messageClickAction();
3401
+ this._messageClickAction = null;
3402
+ this._messageRowClickZone = null;
3403
+ if (typeof result === "string") this.message = result;
3404
+ return true;
3405
+ }
3406
+
3184
3407
  async handleStatusBarMouse(event) {
3185
3408
  if (this._statusBarRow == null || event.y !== this._statusBarRow) return false;
3186
3409
  if (event.action !== "down" || event.button !== "left") return false;
@@ -3238,7 +3461,11 @@ class App {
3238
3461
  break;
3239
3462
  }
3240
3463
  case "fmt":
3241
- if (buf) { buf.fileformat = buf.fileformat === "dos" ? "unix" : "dos"; buf.modified = true; }
3464
+ if (buf) {
3465
+ buf.fileformat = buf.fileformat === "dos" ? "unix" : "dos";
3466
+ buf.Settings.fileformat = buf.fileformat;
3467
+ buf.modified = true;
3468
+ }
3242
3469
  break;
3243
3470
  case "enc":
3244
3471
  if (buf) {
@@ -3298,9 +3525,9 @@ class App {
3298
3525
  if (index < 0 || index >= this.tabs.length || index === this.activeTabIdx) return false;
3299
3526
  this.activeTabIdx = index;
3300
3527
  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));
3528
+ if (this.context.plugins && this.buffer) this.context.plugins.curPaneAdapter = makePaneAdapter(this.buffer, this);
3529
+ this.context.plugins?.run("onSetActive", makePaneAdapter(this.buffer, this));
3530
+ if (this.buffer) this.context.jsPlugins?.run("onSetActive", makePaneAdapter(this.buffer, this));
3304
3531
  return true;
3305
3532
  }
3306
3533
 
@@ -3551,14 +3778,14 @@ class App {
3551
3778
  this.openPrompt("Save as: ", async (value) => {
3552
3779
  if (value) {
3553
3780
  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));
3781
+ await this.context.plugins?.run("onSave", makePaneAdapter(this.buffer, this));
3782
+ await this.context.jsPlugins?.run("onSave", makePaneAdapter(this.buffer, this));
3556
3783
  }
3557
3784
  }, { completer: fileComplete, initial });
3558
3785
  } else {
3559
3786
  await this.buffer.save();
3560
- await this.context.plugins?.run("onSave", makePaneAdapter(this.buffer));
3561
- await this.context.jsPlugins?.run("onSave", makePaneAdapter(this.buffer));
3787
+ await this.context.plugins?.run("onSave", makePaneAdapter(this.buffer, this));
3788
+ await this.context.jsPlugins?.run("onSave", makePaneAdapter(this.buffer, this));
3562
3789
  await this._saveCursorForBuf(this.buffer);
3563
3790
  }
3564
3791
  } catch (error) {
@@ -3715,10 +3942,10 @@ class App {
3715
3942
  this.tabs.splice(this.activeTabIdx, 1);
3716
3943
  this.activeTabIdx = Math.min(this.activeTabIdx, this.tabs.length - 1);
3717
3944
  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));
3945
+ if (this.context.plugins && this.buffer) this.context.plugins.curPaneAdapter = makePaneAdapter(this.buffer, this);
3946
+ await this.context.plugins?.run("onSetActive", makePaneAdapter(this.buffer, this));
3720
3947
  await this.context.plugins?.run("onBufferClose", closing);
3721
- if (this.buffer) this.context.jsPlugins?.run("onSetActive", makePaneAdapter(this.buffer));
3948
+ if (this.buffer) this.context.jsPlugins?.run("onSetActive", makePaneAdapter(this.buffer, this));
3722
3949
  await this.context.jsPlugins?.run("onBufferClose", closing);
3723
3950
  this.render();
3724
3951
  }
@@ -3891,6 +4118,9 @@ class App {
3891
4118
  this.message = `colorscheme: ${err.message}`;
3892
4119
  }
3893
4120
  }
4121
+ if (opt === "clipboard") {
4122
+ await this.reinitializeClipboard(buf.Settings[opt]);
4123
+ }
3894
4124
  }
3895
4125
  break;
3896
4126
  }
@@ -3937,8 +4167,8 @@ class App {
3937
4167
  if (answer === "y") {
3938
4168
  try {
3939
4169
  await buf.save(target);
3940
- await this.context.plugins?.run("onSave", makePaneAdapter(buf));
3941
- await this.context.jsPlugins?.run("onSave", makePaneAdapter(buf));
4170
+ await this.context.plugins?.run("onSave", makePaneAdapter(buf, this));
4171
+ await this.context.jsPlugins?.run("onSave", makePaneAdapter(buf, this));
3942
4172
  } catch (err) {
3943
4173
  this.message = err.message;
3944
4174
  }
@@ -3948,8 +4178,8 @@ class App {
3948
4178
  } else if (saveArgs.length > 0) {
3949
4179
  try {
3950
4180
  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));
4181
+ await this.context.plugins?.run("onSave", makePaneAdapter(buf, this));
4182
+ await this.context.jsPlugins?.run("onSave", makePaneAdapter(buf, this));
3953
4183
  }
3954
4184
  catch (err) { this.message = err.message; }
3955
4185
  } else {
@@ -4006,10 +4236,9 @@ class App {
4006
4236
  this.toggleComment();
4007
4237
  break;
4008
4238
  case "goto": {
4009
- if (cmdArgs.length === 0) { this.message = "Usage: goto <line[:col]>"; break; }
4239
+ if (cmdArgs.length === 0) { this.message = "Usage: goto <line[.subrow][:col]>"; break; }
4010
4240
  try {
4011
- const { line, col } = parseLineCol(cmdArgs[0]);
4012
- buf.gotoLoc(line, col);
4241
+ this.gotoLocation(buf, parseLineCol(cmdArgs[0]), this.pane);
4013
4242
  this.pane.selection = null;
4014
4243
  } catch (error) {
4015
4244
  this.message = String(error.message || error);
@@ -4203,7 +4432,7 @@ class App {
4203
4432
  await this.context.plugins?.run("onBufferOpen", buf);
4204
4433
  await this.context.jsPlugins?.run("onBufferOpen", buf);
4205
4434
  }
4206
- if (cmd !== "togglelocal" && cfg && opt in cfg.globalSettings) {
4435
+ if (cmd !== "togglelocal" && cfg && opt in cfg.globalSettings && !LOCAL_SETTINGS.has(opt)) {
4207
4436
  try { cfg.setGlobalOptionNative(opt, newVal, { modified: true }); await cfg.saveSettings(); } catch {}
4208
4437
  }
4209
4438
  break;
@@ -4217,12 +4446,13 @@ class App {
4217
4446
  const defVal = opt in defaults ? defaults[opt] : true;
4218
4447
  try { buf.SetOption(opt, String(defVal)); } catch (err) { this.message = String(err.message || err); break; }
4219
4448
  this.message = `${opt} = ${defVal}`;
4220
- if (cfgR && opt in cfgR.globalSettings) {
4449
+ if (cfgR && opt in cfgR.globalSettings && !LOCAL_SETTINGS.has(opt)) {
4221
4450
  try { cfgR.setGlobalOptionNative(opt, defVal, { modified: true }); await cfgR.saveSettings(); } catch {}
4222
4451
  }
4223
4452
  if (opt === "colorscheme" && this.context?.runtime) {
4224
4453
  try { this.context.colorscheme = await new Colorscheme(this.context.runtime).load(String(defVal)); } catch {}
4225
4454
  }
4455
+ if (opt === "clipboard") await this.reinitializeClipboard(defVal);
4226
4456
  break;
4227
4457
  }
4228
4458
  case "jump": {
@@ -4371,11 +4601,66 @@ class App {
4371
4601
  await this.runAlert(content);
4372
4602
  break;
4373
4603
  }
4604
+ case "copy": {
4605
+ this._freshClip = false;
4606
+ const sel = this.pane?.selection;
4607
+ const copyText = sel ? getSelectionText(buf, sel) : (buf.currentLineText() + "\n");
4608
+ this.clipboard.write(copyText);
4609
+ this.message = clipboardCopyMsg(this.clipboard, copyText, sel ? "selection" : "line");
4610
+ this._messageClickAction = clipboardAltAction(this.clipboard, copyText);
4611
+ break;
4612
+ }
4613
+ case "cut": {
4614
+ this._freshClip = false;
4615
+ buf.pushUndo();
4616
+ const cutText = this.pane?.selection
4617
+ ? deleteSelection(buf, this.pane)
4618
+ : (buf.cutLine() + "\n");
4619
+ const cutKind = this.pane?.selection ? "selection" : "line";
4620
+ this.clipboard.write(cutText);
4621
+ this.message = clipboardCopyMsg(this.clipboard, cutText, cutKind, "Cut");
4622
+ this._messageClickAction = clipboardAltAction(this.clipboard, cutText);
4623
+ break;
4624
+ }
4625
+ case "cutline": {
4626
+ buf.pushUndo();
4627
+ if (this.pane?.selection) {
4628
+ this._freshClip = false;
4629
+ const text = deleteSelection(buf, this.pane);
4630
+ this.clipboard.write(text);
4631
+ this.message = clipboardCopyMsg(this.clipboard, text, "selection", "Cut");
4632
+ this._messageClickAction = clipboardAltAction(this.clipboard, text);
4633
+ } else {
4634
+ const prev = this._freshClip ? (this.clipboard.read() ?? "") : "";
4635
+ const line = buf.cutLine() + "\n";
4636
+ this.clipboard.write(prev + line);
4637
+ this._freshClip = true;
4638
+ const total = (prev + line).split("\n").length - 1;
4639
+ const label = total > 1 ? `${total} lines` : "line";
4640
+ this.message = clipboardCopyMsg(this.clipboard, prev + line, label, "Cut");
4641
+ this._messageClickAction = clipboardAltAction(this.clipboard, prev + line);
4642
+ }
4643
+ break;
4644
+ }
4645
+ case "paste": {
4646
+ this._freshClip = false;
4647
+ const pasted = this.clipboard.read();
4648
+ if (pasted) {
4649
+ buf.pushUndo();
4650
+ if (this.pane?.selection) deleteSelection(buf, this.pane);
4651
+ buf.insert(pasted);
4652
+ this.message = pasteStatusMessage(this.clipboard.readMethodName(), pasted);
4653
+ }
4654
+ break;
4655
+ }
4656
+ case "pasteprimary":
4657
+ await runAction("PastePrimary", this);
4658
+ break;
4374
4659
  default: {
4375
4660
  const pluginCmd = this.context.plugins?.commands?.get(cmd);
4376
4661
  if (pluginCmd) {
4377
4662
  try {
4378
- await pluginCmd(makePaneAdapter(this.buffer), cmdArgs);
4663
+ await pluginCmd(makePaneAdapter(this.buffer, this), cmdArgs);
4379
4664
  } catch (e) {
4380
4665
  this.message = String(e.message ?? e);
4381
4666
  }
@@ -4700,6 +4985,7 @@ const COMMAND_NAMES = [
4700
4985
  "cd", "pwd", "tab", "run", "vsplit", "hsplit", "term", "tts", "ttsspeed", "ttspitch", "ttslang", "reopen", "theme", "toggle", "tog",
4701
4986
  "togglelocal", "reset", "jump", "tabmove", "tabswitch", "textfilter", "bind", "unbind", "reload", "lintlog", "act", "action", "raw",
4702
4987
  "help", "plugin", "showkey", "memusage", "retab", "eval",
4988
+ "copy", "cut", "cutline", "paste", "pasteprimary",
4703
4989
  ];
4704
4990
 
4705
4991
  const SUPPORTED_ENCODING_LABELS = [
@@ -4894,10 +5180,40 @@ function isEmptyUntitledBuffer(buffer) {
4894
5180
  return !buffer.path && !buffer.modified && buffer.lines.length === 1 && buffer.lines[0] === "";
4895
5181
  }
4896
5182
 
4897
- function makePaneAdapter(buffer) {
5183
+ function formatAbsoluteCursorLocation(buffer) {
5184
+ if (!buffer) return "+1:1";
5185
+ const y = clamp(buffer.cursor?.y ?? 0, 0, Math.max(0, (buffer.lines?.length ?? 1) - 1));
5186
+ const line = buffer.lines?.[y] ?? "";
5187
+ const x = normalizeCharBoundary(line, buffer.cursor?.x ?? 0);
5188
+ return `+${y + 1}:${x + 1}`;
5189
+ }
5190
+
5191
+ function formatCursorLocation(buffer, pane = null) {
5192
+ if (!buffer) return "+1.0:1";
5193
+ const y = clamp(buffer.cursor?.y ?? 0, 0, Math.max(0, (buffer.lines?.length ?? 1) - 1));
5194
+ const line = buffer.lines?.[y] ?? "";
5195
+ const x = normalizeCharBoundary(line, buffer.cursor?.x ?? 0);
5196
+ let subRow = 0;
5197
+ let col = x + 1;
5198
+ if (pane && (buffer.Settings?.softwrap ?? false)) {
5199
+ const gutterW = editorGutterWidth(buffer);
5200
+ const bufW = Math.max(1, (pane.w ?? process.stdout.columns ?? 80) - gutterW);
5201
+ const wordwrap = buffer.Settings?.wordwrap ?? false;
5202
+ const tabsize = buffer.Settings?.tabsize ?? DEFAULT_SETTINGS.tabsize;
5203
+ const breaks = softwrapBreaks(line, bufW, wordwrap, tabsize);
5204
+ subRow = softwrapRowOfCharIdx(breaks, x);
5205
+ const segStart = breaks[subRow] ?? 0;
5206
+ col = displayWidth(line.slice(segStart, x)) + 1;
5207
+ }
5208
+ return `+${y + 1}.${subRow}:${col}`;
5209
+ }
5210
+
5211
+ function makePaneAdapter(buffer, app = null) {
4898
5212
  const pane = {
4899
5213
  Buf: makeBufferAdapter(buffer),
4900
5214
  Cursor: makeCursorAdapter(buffer),
5215
+ CursorLocation: () => formatCursorLocation(buffer, app?.paneForBuffer?.(buffer) ?? app?.pane ?? null),
5216
+ AbsoluteCursorLocation: () => formatAbsoluteCursorLocation(buffer),
4901
5217
  Save: () => buffer.save(),
4902
5218
  Backspace: () => buffer.backspace(),
4903
5219
  Delete: () => buffer.deleteForward(),
@@ -5206,19 +5522,27 @@ function detectTtsCmd() {
5206
5522
  return null;
5207
5523
  }
5208
5524
 
5209
- async function loadBufferForPath(pathOrUrl, context) {
5525
+ function commandHasStartCursor(command = {}) {
5526
+ return Boolean(command.startCursor && command.startCursor.line >= 1);
5527
+ }
5528
+
5529
+ function commandHasStartupJump(command = {}) {
5530
+ return commandHasStartCursor(command) || Boolean(command.searchRegex);
5531
+ }
5532
+
5533
+ async function loadBufferForPath(pathOrUrl, context, command = {}) {
5210
5534
  if (isHttpUrl(pathOrUrl)) {
5211
5535
  let encoding = context.config?.globalSettings?.encoding ?? DEFAULT_SETTINGS.encoding;
5212
5536
  const decoded = await fetchTextWithEncoding(pathOrUrl, encoding);
5213
5537
  const text = decoded.text;
5214
5538
  encoding = decoded.encoding;
5215
5539
  const urlPath = pathOrUrl.replace(/[?#].*$/, "");
5216
- const buffer = new BufferModel({ path: pathOrUrl, text, command: {}, encoding });
5540
+ const buffer = new BufferModel({ path: pathOrUrl, text, command, encoding });
5217
5541
  attachSyntax(buffer, context, urlPath, text);
5218
5542
  return buffer;
5219
5543
  }
5220
- const buffer = await BufferModel.fromFile(pathOrUrl, {}, context);
5221
- if (DEFAULT_SETTINGS.savecursor && context?.cursorStates?.[pathOrUrl]) {
5544
+ const buffer = await BufferModel.fromFile(pathOrUrl, command, context);
5545
+ if (DEFAULT_SETTINGS.savecursor && !commandHasStartupJump(command) && context?.cursorStates?.[pathOrUrl]) {
5222
5546
  const saved = context.cursorStates[pathOrUrl];
5223
5547
  const y = clamp(saved.y ?? 0, 0, buffer.lines.length - 1);
5224
5548
  const x = clamp(saved.x ?? 0, 0, buffer.lines[y]?.length ?? 0);
@@ -5478,6 +5802,15 @@ function lineNumberText(buf, lineNo, row, gutterW) {
5478
5802
  return String(lineNo + 1).padStart(Math.max(0, gutterW - 1)) + " ";
5479
5803
  }
5480
5804
 
5805
+ function visualLineNumberText(subRow, gutterW) {
5806
+ const text = `.${subRow}`;
5807
+ const numberW = Math.max(0, gutterW - 1);
5808
+ const padded = text.length >= numberW
5809
+ ? text.slice(text.length - numberW)
5810
+ : text.padStart(numberW);
5811
+ return padded + " ";
5812
+ }
5813
+
5481
5814
  const BRACE_PAIRS = { "(": ")", "[": "]", "{": "}" };
5482
5815
  const BRACE_REVERSE = { ")": "(", "]": "[", "}": "{" };
5483
5816
 
@@ -5740,7 +6073,7 @@ async function loadBuffers(files, command) {
5740
6073
  if (files.length > 0) {
5741
6074
  for (const file of files) {
5742
6075
  try {
5743
- buffers.push(await loadBufferForPath(file, loadBuffers.context ?? {}));
6076
+ buffers.push(await loadBufferForPath(file, loadBuffers.context ?? {}, command));
5744
6077
  } catch (error) {
5745
6078
  console.error(error.message || error);
5746
6079
  }
@@ -5758,6 +6091,11 @@ async function loadBuffers(files, command) {
5758
6091
  return buffers.length ? buffers : [new BufferModel({ command })];
5759
6092
  }
5760
6093
 
6094
+ async function printReadmeDocs() {
6095
+ const readme = await Bun.file(join(REPO_ROOT, "README.md")).text();
6096
+ process.stdout.write(Bun.markdown.ansi(readme, { hyperlinks: true }));
6097
+ }
6098
+
5761
6099
  async function main() {
5762
6100
  const { flags, files: rawFiles } = parseArgs(process.argv.slice(2));
5763
6101
  if (flags.help) {
@@ -5765,7 +6103,6 @@ async function main() {
5765
6103
  return;
5766
6104
  }
5767
6105
  if (flags.version) {
5768
- const clipboard = new ClipboardManager();
5769
6106
  const ttsCmd = detectTtsCmd();
5770
6107
  console.log(pkg.name+":",pkg.description)
5771
6108
  console.log(" Rewritten by: Dr. John (醫者小智)")
@@ -5774,9 +6111,24 @@ async function main() {
5774
6111
  console.log("Runtime:", `Bun ${Bun.version}`);
5775
6112
  console.log("Platform:", platformId());
5776
6113
  console.log("Http client:",detectHttpBackend());
5777
- console.log("Clipboard:", clipboard.methodName());
5778
6114
  console.log("TTS:", ttsCmd ? ttsCmd.cmd[0] : "not found");
5779
6115
  console.log({SUPPORTED_ENCODING_LABELS})
6116
+ const clipboard = new ClipboardManager();
6117
+ let osc52Available = false;
6118
+ if (process.stdin.isTTY && process.stdout.isTTY) {
6119
+ process.stdin.setRawMode?.(true);
6120
+ process.stdin.resume();
6121
+ osc52Available = await probeOSC52(process.stdin, process.stdout, 150);
6122
+ process.stdin.setRawMode?.(false);
6123
+ process.stdin.pause();
6124
+ }
6125
+ const externalName = clipboard.methodName();
6126
+ const backends = osc52Available ? `${externalName}, OSC 52` : externalName;
6127
+ console.log("Clipboard:", backends);
6128
+ return;
6129
+ }
6130
+ if (flags.docs) {
6131
+ await printReadmeDocs();
5780
6132
  return;
5781
6133
  }
5782
6134
  if (flags.options) {
@@ -5797,7 +6149,7 @@ async function main() {
5797
6149
  const syntaxDefinitions = await loadSyntaxDefinitions(runtime);
5798
6150
 
5799
6151
  if (flags.cat) {
5800
- await catFiles(rawFiles, colorscheme, syntaxDefinitions);
6152
+ await catFiles(rawFiles, colorscheme, syntaxDefinitions, config.getGlobalOption("encoding"));
5801
6153
  return;
5802
6154
  }
5803
6155
 
@@ -5835,8 +6187,8 @@ async function main() {
5835
6187
  }
5836
6188
 
5837
6189
  if (flags.clean) {
5838
- console.error("Clean is not implemented yet.");
5839
- process.exit(1);
6190
+ await cleanConfig(config, plugins);
6191
+ return;
5840
6192
  }
5841
6193
 
5842
6194
  const pluginErr = await plugins.loadAll();
@@ -5878,7 +6230,7 @@ async function main() {
5878
6230
  }
5879
6231
  const app = new App(buffers, context);
5880
6232
  jsPlugins.setApp(app);
5881
- if (plugins && !pluginErr && app.buffer) plugins.curPaneAdapter = makePaneAdapter(app.buffer);
6233
+ if (plugins && !pluginErr && app.buffer) plugins.curPaneAdapter = makePaneAdapter(app.buffer, app);
5882
6234
  // Dispatch all JS plugin lifecycle hooks after setApp so TermMessage,
5883
6235
  // CurPane, cmd/action proxies, and buffer APIs all work correctly.
5884
6236
  await jsPlugins.run("preinit");
@@ -6014,6 +6366,26 @@ function uncommentText(line, commentType) {
6014
6366
  }
6015
6367
  return indent + rest;
6016
6368
  }
6369
+ function clipboardCopyMsg(clipboard, text, kind, verb = "Copied") {
6370
+ const method = clipboard.methodName();
6371
+ const alt = clipboard.altMethodName();
6372
+ const chars = Array.from(String(text).replace(/\n$/, "")).length;
6373
+ const label = typeof kind === "string" && (kind === "line" || kind.endsWith("lines"))
6374
+ ? kind : `${chars} chars`;
6375
+ if (alt) return `[Click:Copy>${alt}] ${method}: ${label} ${verb.toLowerCase()}`;
6376
+ return `${verb} ${label} to ${method} clipboard`;
6377
+ }
6378
+
6379
+ function clipboardAltAction(clipboard, text) {
6380
+ const alt = clipboard.altMethodName();
6381
+ if (!alt) return null;
6382
+ return () => {
6383
+ if (!clipboard.writeAlt(text)) return `${alt}: failed`;
6384
+ const chars = Array.from(String(text).replace(/\n$/, "")).length;
6385
+ return `${alt}: ${chars} chars copied`;
6386
+ };
6387
+ }
6388
+
6017
6389
  function pasteStatusMessage(method, text) {
6018
6390
  const value = String(text);
6019
6391
  const lines = value.split("\n").length;
@@ -6195,14 +6567,16 @@ function segmentSelection(selection, lineNo, start, end) {
6195
6567
  function parseLineCol(value) {
6196
6568
  const input = String(value).trim();
6197
6569
  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;
6570
+ const match = input.match(/^(-?\d+)(?:\.(\d+))?(?::(-?\d+))?$/);
6571
+ if (!match) throw new Error("Invalid line number");
6572
+ const line = Number(match[1]);
6573
+ const subRow = match[2] == null ? 0 : Number(match[2]);
6574
+ const col = match[3] == null ? 1 : Number(match[3]);
6203
6575
  if (!Number.isInteger(line)) throw new Error("Invalid line number");
6576
+ if (!Number.isInteger(subRow)) throw new Error("Invalid visual line number");
6577
+ if (subRow < 0) throw new Error("Invalid visual line number");
6204
6578
  if (!Number.isInteger(col)) throw new Error("Invalid column number");
6205
- return { line, col };
6579
+ return { line, subRow, col };
6206
6580
  }
6207
6581
 
6208
6582
  function parseOptionValue(value) {
@@ -6218,19 +6592,23 @@ function syncEditorSettings(config) {
6218
6592
  }
6219
6593
  }
6220
6594
 
6221
- async function catFiles(files, colorscheme, syntaxDefinitions) {
6595
+ async function catFiles(files, colorscheme, syntaxDefinitions, encoding = DEFAULT_SETTINGS.encoding) {
6222
6596
  const targets = files.length > 0 ? files.map((f) => ({ path: f, stdin: false })) : [{ path: null, stdin: true }];
6223
6597
  for (const { path: filePath, stdin } of targets) {
6224
6598
  let content;
6225
6599
  let effectivePath = filePath;
6226
6600
  if (stdin) {
6227
- content = await Bun.stdin.text();
6601
+ const chunks = [];
6602
+ for await (const chunk of process.stdin) chunks.push(chunk);
6603
+ content = decodeTextBytesWithEncoding(Buffer.concat(chunks), encoding).text;
6228
6604
  } else if (isHttpUrl(filePath)) {
6229
- content = await fetchHttp(filePath);
6605
+ const decoded = await fetchTextWithEncoding(filePath, encoding);
6606
+ content = decoded.text;
6230
6607
  // Use the URL pathname for syntax/md detection (strip query/hash)
6231
6608
  try { effectivePath = new URL(filePath).pathname; } catch { effectivePath = filePath; }
6232
6609
  } else {
6233
- content = await Bun.file(filePath).text();
6610
+ const decoded = await readTextFileWithEncoding(filePath, encoding);
6611
+ content = decoded.text;
6234
6612
  }
6235
6613
  if (effectivePath && /\.md$/i.test(effectivePath)) {
6236
6614
  process.stdout.write(