buninu-linux 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/LICENSES/Apache-2.0.txt +202 -0
  2. package/LICENSES/BSD-3-Clause-Skia.txt +29 -0
  3. package/LICENSES/Bitstream-Vera.txt +44 -0
  4. package/LICENSES/MIT-xterm.js.txt +21 -0
  5. package/LICENSES/OFL-1.1.txt +91 -0
  6. package/NOTICE.md +18 -1
  7. package/README.md +226 -22
  8. package/fetch-alpine.sh +9 -4
  9. package/graphics.md +213 -0
  10. package/initramfs/bin/bunterm +66 -0
  11. package/initramfs/bin/ip +2 -1
  12. package/initramfs/bin/mount +2 -1
  13. package/initramfs/bin/poweroff +6 -27
  14. package/initramfs/bin/ps +256 -0
  15. package/initramfs/bin/reboot +10 -0
  16. package/initramfs/bin/tar +169 -0
  17. package/initramfs/bin/top +129 -0
  18. package/initramfs/bin/umount +2 -1
  19. package/initramfs/buninu/CHANGELOG.md +40 -0
  20. package/initramfs/buninu/apps/bunmsh/CHANGELOG.md +36 -0
  21. package/initramfs/buninu/apps/bunmsh/README.md +7 -0
  22. package/initramfs/buninu/apps/bunmsh/package.json +1 -1
  23. package/initramfs/buninu/apps/bunmsh/src/fancy-ls.js +29 -14
  24. package/initramfs/buninu/apps/bunmsh/src/line-edit.js +43 -0
  25. package/initramfs/buninu/apps/bunmsh/src/main.js +15 -1
  26. package/initramfs/buninu/apps/bunmsh/src/mouse.js +29 -4
  27. package/initramfs/buninu/apps/bunmsh/src/shell.js +9 -1
  28. package/initramfs/buninu/apps/bunmsh/test/shell1.test.js +70 -0
  29. package/initramfs/buninu/apps/jsgotty/gotty.js +4 -3
  30. package/initramfs/buninu/apps/jsgotty/package.json +1 -1
  31. package/initramfs/buninu/apps/jsmdcui/CHANGELOG.md +10 -0
  32. package/initramfs/buninu/apps/jsmdcui/README.md +2 -0
  33. package/initramfs/buninu/apps/jsmdcui/package.json +1 -1
  34. package/initramfs/buninu/apps/jsmdcui/runtime/help/commands.md +4 -1
  35. package/initramfs/buninu/apps/jsmdcui/runtime/help/help.md +2 -0
  36. package/initramfs/buninu/apps/jsmdcui/src/index.js +40 -13
  37. package/initramfs/buninu/package.json +1 -1
  38. package/initramfs/init.js +6 -0
  39. package/initramfs/lib/bunterm/glyphs.js +446 -0
  40. package/initramfs/lib/bunterm/images.js +225 -0
  41. package/initramfs/lib/bunterm/input.js +140 -0
  42. package/initramfs/lib/bunterm/renderer.js +424 -0
  43. package/initramfs/lib/bunterm/terminal.js +203 -0
  44. package/initramfs/lib/canvas.js +181 -0
  45. package/initramfs/lib/canvaskit/LICENSE +29 -0
  46. package/initramfs/lib/canvaskit/canvaskit.js +264 -0
  47. package/initramfs/lib/canvaskit/canvaskit.wasm +0 -0
  48. package/initramfs/lib/dlopen.js +14 -11
  49. package/initramfs/lib/document.js +6 -0
  50. package/initramfs/lib/fbdev.js +581 -0
  51. package/initramfs/lib/power.js +35 -0
  52. package/initramfs/lib/xterm/LICENSE +21 -0
  53. package/initramfs/lib/xterm/addon-unicode-graphemes.mjs +44 -0
  54. package/initramfs/lib/xterm/xterm-headless.mjs +46 -0
  55. package/initramfs/usr/share/doc/buninu-linux/bunterm.md +83 -0
  56. package/initramfs/usr/share/doc/buninu-linux/canvas.md +91 -0
  57. package/initramfs/usr/share/doc/buninu-linux/fbdev.md +113 -0
  58. package/initramfs/usr/share/doc/buninu-linux/ps.md +94 -0
  59. package/initramfs/usr/share/doc/buninu-linux/reboot.md +15 -0
  60. package/initramfs/usr/share/doc/buninu-linux/tar.md +73 -0
  61. package/initramfs/usr/share/doc/buninu-linux/top.md +23 -0
  62. package/initramfs/usr/share/fonts/DejaVuSansMono-Bold.ttf +0 -0
  63. package/initramfs/usr/share/fonts/DejaVuSansMono.ttf +0 -0
  64. package/initramfs/usr/share/fonts/LICENSE-Apache-2.0.txt +202 -0
  65. package/initramfs/usr/share/fonts/LICENSE-DejaVu.txt +44 -0
  66. package/initramfs/usr/share/fonts/LICENSE-OFL-1.1.txt +91 -0
  67. package/initramfs/usr/share/fonts/NotoColorEmoji.ttf +0 -0
  68. package/initramfs/usr/share/fonts/NotoColorEmojiFlags.ttf +0 -0
  69. package/initramfs/usr/share/fonts/NotoSansCJK-Regular.ttc +0 -0
  70. package/initramfs/usr/share/fonts/NotoSansSymbols-Regular-Subsetted.ttf +0 -0
  71. package/initramfs/usr/share/fonts/NotoSansSymbols-Regular-Subsetted2.ttf +0 -0
  72. package/initramfs/usr/share/fonts/Roboto-Regular.ttf +0 -0
  73. package/initramfs/usr/share/fonts/fonts.json +28 -0
  74. package/package.json +2 -2
  75. package/run-qemu.sh +1 -1
@@ -0,0 +1,129 @@
1
+ #!/bin/bun
2
+ // top — periodically render snapshots and process lines supplied by ps.
3
+
4
+ import { showDocument } from "../lib/document.js";
5
+ import {
6
+ CLOCK_TICKS,
7
+ formatProcessHeader,
8
+ formatProcessLine,
9
+ prepareProcessFormat,
10
+ readProcessSnapshot,
11
+ } from "./ps";
12
+
13
+ const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
14
+ const fields = ["pid", "user", "pri", "ni", "vsz", "rss", "state", "%cpu", "%mem", "time", "comm"];
15
+
16
+ const uptimeText = (seconds) => {
17
+ const days = Math.floor(seconds / 86400);
18
+ const hours = Math.floor(seconds % 86400 / 3600);
19
+ const minutes = Math.floor(seconds % 3600 / 60);
20
+ return `${days ? `${days} day${days === 1 ? "" : "s"}, ` : ""}${hours}:${String(minutes).padStart(2, "0")}`;
21
+ };
22
+
23
+ const memoryText = (memory) => {
24
+ const total = memory.MemTotal ?? 0;
25
+ const free = memory.MemFree ?? 0;
26
+ const cache = (memory.Buffers ?? 0) + (memory.Cached ?? 0) + (memory.SReclaimable ?? 0);
27
+ return `MiB Mem : ${(total / 1024).toFixed(1)} total, ${(free / 1024).toFixed(1)} free, ${((total - free - cache) / 1024).toFixed(1)} used, ${(cache / 1024).toFixed(1)} buff/cache`;
28
+ };
29
+
30
+ const intervalRows = (snapshot, previous) => {
31
+ if (!previous) return snapshot.processes;
32
+ const elapsed = Math.max(0.001, (snapshot.at - previous.at) / 1000);
33
+ const oldTicks = new Map(previous.processes.map((row) => [row.pid, row.ticks]));
34
+ return snapshot.processes.map((row) => ({
35
+ ...row,
36
+ cpu: Math.max(0, row.ticks - (oldTicks.get(row.pid) ?? row.ticks)) / CLOCK_TICKS / elapsed * 100,
37
+ }));
38
+ };
39
+
40
+ const render = (snapshot, previous, sort, batch) => {
41
+ const rows = intervalRows(snapshot, previous);
42
+ rows.sort(sort === "memory"
43
+ ? (a, b) => b.memory - a.memory || a.pid - b.pid
44
+ : sort === "pid" ? (a, b) => a.pid - b.pid : (a, b) => b.cpu - a.cpu || a.pid - b.pid);
45
+ const states = { running: 0, sleeping: 0, stopped: 0, zombie: 0 };
46
+ for (const row of rows) {
47
+ if (row.state[0] === "R") states.running++;
48
+ else if (row.state[0] === "T") states.stopped++;
49
+ else if (row.state[0] === "Z") states.zombie++;
50
+ else states.sleeping++;
51
+ }
52
+ const total = previous ? snapshot.cpu.total - previous.cpu.total : snapshot.cpu.total;
53
+ const idleTicks = previous ? snapshot.cpu.idle - previous.cpu.idle : snapshot.cpu.idle;
54
+ const idle = total > 0 ? idleTicks / total * 100 : 0;
55
+ const clock = new Date().toLocaleTimeString("en-US", { hour12: false });
56
+ const lines = [
57
+ `top - ${clock} up ${uptimeText(snapshot.uptime)}, load average: ${snapshot.loadAverage.map((v) => v.toFixed(2)).join(", ")}`,
58
+ `Tasks: ${rows.length} total, ${states.running} running, ${states.sleeping} sleeping, ${states.stopped} stopped, ${states.zombie} zombie`,
59
+ `%Cpu(s): ${(100 - idle).toFixed(1)} used, ${idle.toFixed(1)} id`,
60
+ memoryText(snapshot.memory),
61
+ "",
62
+ ];
63
+ const format = prepareProcessFormat(rows, fields);
64
+ lines.push(formatProcessHeader(format));
65
+ // Some early boot consoles report a TTY size of 0x0. Treat that as
66
+ // unknown; otherwise Math.max(1, 0 - headers) displays only one process
67
+ // and makes it look as if the snapshot contained only top itself.
68
+ const terminalRows = Number(process.stdout.rows);
69
+ const screenRows = Number.isFinite(terminalRows) && terminalRows > lines.length
70
+ ? terminalRows
71
+ : 24;
72
+ const limit = batch ? rows.length : Math.max(1, screenRows - lines.length);
73
+ for (const row of rows.slice(0, limit)) lines.push(formatProcessLine(row, format));
74
+ return lines.join("\n");
75
+ };
76
+
77
+ async function main(argv) {
78
+ let delay = 3;
79
+ let iterations = Infinity;
80
+ let batch = false;
81
+ for (let index = 0; index < argv.length; index++) {
82
+ const argument = argv[index];
83
+ if (argument === "-h" || argument === "--help") return showDocument("top");
84
+ if (argument === "-b") { batch = true; continue; }
85
+ if (argument === "-d") { delay = Number(argv[++index]); continue; }
86
+ if (argument === "-n") { iterations = Number(argv[++index]); continue; }
87
+ throw new Error(`top: unsupported option '${argument}'`);
88
+ }
89
+ if (!(delay > 0) || !(iterations > 0)) throw new Error("top: -d and -n require positive numbers");
90
+ let quit = false;
91
+ let sort = "cpu";
92
+ const interactive = !batch && process.stdin.isTTY && process.stdout.isTTY;
93
+ const cleanup = () => {
94
+ if (interactive) {
95
+ process.stdin.setRawMode(false);
96
+ process.stdin.pause();
97
+ process.stdout.write("\x1b[?25h\n");
98
+ }
99
+ };
100
+ if (interactive) {
101
+ process.stdin.setRawMode(true);
102
+ process.stdin.resume();
103
+ process.stdin.setEncoding("utf8");
104
+ process.stdin.on("data", (key) => {
105
+ if (key === "q" || key === "\u0003") quit = true;
106
+ else if (key === "P") sort = "cpu";
107
+ else if (key === "M") sort = "memory";
108
+ else if (key === "N") sort = "pid";
109
+ });
110
+ process.stdout.write("\x1b[?25l");
111
+ process.on("exit", cleanup);
112
+ } else if (!batch && iterations === Infinity) iterations = 1;
113
+
114
+ let previous = null;
115
+ try {
116
+ for (let count = 0; count < iterations && !quit; count++) {
117
+ const snapshot = readProcessSnapshot();
118
+ const output = render(snapshot, previous, sort, batch);
119
+ process.stdout.write(`${interactive ? "\x1b[H\x1b[2J" : ""}${output}\n`);
120
+ previous = snapshot;
121
+ if (count + 1 < iterations && !quit) await sleep(delay * 1000);
122
+ }
123
+ } finally { cleanup(); }
124
+ }
125
+
126
+ if (import.meta.main) {
127
+ try { await main(process.argv.slice(2)); }
128
+ catch (error) { console.error(error.message); process.exit(1); }
129
+ }
@@ -2,7 +2,8 @@
2
2
  // umount — unmount a filesystem. `umount --help` renders
3
3
  // /usr/share/doc/buninu-linux/umount.md.
4
4
 
5
- import { libc, cString, ptr, errno, strerror, Errno, showDocument } from "../lib/dlopen.js";
5
+ import { libc, cString, ptr, errno, strerror, Errno } from "../lib/dlopen.js";
6
+ import { showDocument } from "../lib/document.js";
6
7
 
7
8
  const MNT = { FORCE: 1, DETACH: 2, EXPIRE: 4, NOFOLLOW: 8 };
8
9
 
@@ -1,5 +1,45 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.15 - 2026-09-22
4
+
5
+ ### Changed
6
+
7
+ - Sync jsgotty to 1.1.12
8
+ * Export `BunPtyBackend` from `gotty.js`, the PTY layer built on
9
+ `Bun.spawn({ terminal })`, so other programs can run a command on a
10
+ pseudo-terminal without their own FFI. Buninu Linux's `bunterm`, a
11
+ graphical terminal on the framebuffer, is its first user, together with
12
+ the already exported `KittyGraphicsParser`
13
+
14
+ ### Fixed
15
+
16
+ - jsgotty: a chunked kitty graphics transfer whose first chunk names an
17
+ image id (`i=`) is now continued by later chunks that carry only `m=`, as
18
+ the protocol allows. Previously those chunks were treated as new anonymous
19
+ transfers, so the image never completed. jsgotty's own `showimg` repeats
20
+ the id in every chunk and was unaffected; `kitten icat`, timg, chafa and
21
+ similar clients were
22
+
23
+ ## 0.4.13 - 2026-09-22
24
+
25
+ ### Changed
26
+
27
+ - Sync bunmsh to 0.3.9
28
+ * `Ctrl-U` removes and saves the text before the cursor; pressing it again
29
+ at the beginning of the line restores it. `Ctrl-K` independently toggles
30
+ the text after the cursor when at the end of the line
31
+ * Unquoted pathname patterns follow directory symbolic links used as
32
+ intermediate path components, as traditional shells do, so patterns such
33
+ as `/sys/class/net/*/device` pass through sysfs class links
34
+ * The fallback `ls`/`lsfancy` lists a glob's files as one listing instead
35
+ of one section per file, honors `--`, and follows a command-line symlink
36
+ to a directory
37
+ - Sync jsmdcui to 0.19.2
38
+ * Pressing `Ctrl-W` twice quickly switches to the next tab like `Alt-T`
39
+ from either an editor or a terminal pane; at the rightmost tab, when every
40
+ tab holds only terminal panes, it opens a new editor tab instead of
41
+ cycling back
42
+
3
43
  ## 0.4.12 - 2026-09-20
4
44
 
5
45
  ### Fixed
@@ -2,6 +2,42 @@
2
2
 
3
3
  All notable user-visible changes to bunmsh are documented here.
4
4
 
5
+ ## [0.3.9] - 2026-09-22
6
+
7
+ ### Fixed
8
+
9
+ - Make pathname expansion follow directory symbolic links used as intermediate
10
+ path components, matching traditional shell behaviour. `Bun.Glob` disables
11
+ that traversal by default, so patterns such as `./*/*.js` silently skipped
12
+ a matching directory symlink, and sysfs patterns such as
13
+ `/sys/class/net/*/device` failed to pass through class-device links. bunmsh
14
+ now enables `followSymlinks` while scanning; a symlink matched as the final
15
+ component is still passed to the command as a symlink.
16
+
17
+ - Stop the fallback `ls`/`lsfancy` from printing every glob-expanded file as
18
+ a directory section. `builtin ls L*` previously expanded the glob
19
+ correctly, but multiple operands unconditionally received `filename:`
20
+ headings and blank separators. Only directories whose contents are being
21
+ listed now receive section headings; multiple ordinary files form one
22
+ continuous listing and retain their full operand paths rather than being
23
+ reduced to basenames.
24
+
25
+ - Honor `--` in `ls`/`lsfancy`, so names such as `-odd` after it are operands
26
+ rather than option clusters. Also follow a command-line symlink to a
27
+ directory for ordinary and recursive listings, as traditional `ls` does;
28
+ `-d`, `-l`, and `-F` continue to inspect the symlink itself.
29
+
30
+ ## [0.3.8] - 2026-09-21
31
+
32
+ ### Added
33
+
34
+ - Make `Ctrl-U` and `Ctrl-K` toggle their edits in the interactive line
35
+ editor. `Ctrl-U` saves the text removed before the cursor and restores it
36
+ when pressed again at the beginning of the line; `Ctrl-K` does the same for
37
+ text after the cursor when pressed again at the end. Each shortcut keeps an
38
+ independent saved value, and control bytes inside bracketed paste remain
39
+ literal pasted content rather than triggering either action.
40
+
5
41
  ## [0.3.7] - 2026-09-20
6
42
 
7
43
  ### Fixed
@@ -476,6 +476,13 @@ Bun.e, Bun.file("/tm # -> a path, completed inside the string
476
476
 
477
477
  - `Ctrl-D`: On an empty input line, sends EOF and exits the interactive shell.
478
478
 
479
+ - `Ctrl-U`: Removes and saves everything before the cursor. Press it again
480
+ while the cursor is at the beginning to restore that saved prefix.
481
+
482
+ - `Ctrl-K`: Removes and saves everything after the cursor. Press it again
483
+ while the cursor is at the end to restore that saved suffix. Its saved text
484
+ is independent from `Ctrl-U`.
485
+
479
486
  - `Ctrl-T`: Calls `builtin tab` without adding a command to history. It creates
480
487
  a tab when only one exists, or switches to the next tab otherwise. Any
481
488
  command text currently being edited is preserved.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunmsh",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
4
4
  "description": "Cross-platform command shell for Bun JavaScript",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,5 +1,5 @@
1
- import { existsSync, lstatSync, readdirSync, readlinkSync } from "node:fs";
2
- import { basename, isAbsolute, resolve } from "node:path";
1
+ import { existsSync, lstatSync, readdirSync, readlinkSync, statSync } from "node:fs";
2
+ import { isAbsolute, resolve } from "node:path";
3
3
 
4
4
  const IMAGE = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif", "heic"]);
5
5
  const MUSIC = new Set(["mp3", "wav", "flac", "ogg", "m4a", "aac", "opus", "mid", "midi"]);
@@ -120,9 +120,11 @@ export function fancyLs(argv, state, terminal = Boolean(process.stdout.isTTY)) {
120
120
  recursive: false, time: false, reverse: false, size: false, one: false, classify: false,
121
121
  };
122
122
  const operands = [];
123
+ let parseOptions = true;
123
124
  for (const argument of argv.slice(1)) {
124
- if (argument === "--color=auto" || argument === "--color" || argument === "--") continue;
125
- if (/^-[^-]/.test(argument)) {
125
+ if (parseOptions && argument === "--") { parseOptions = false; continue; }
126
+ if (parseOptions && (argument === "--color=auto" || argument === "--color")) continue;
127
+ if (parseOptions && /^-[^-]/.test(argument)) {
126
128
  for (const flag of argument.slice(1)) {
127
129
  if (flag === "a") options.all = true;
128
130
  else if (flag === "A") options.almostAll = true;
@@ -142,15 +144,30 @@ export function fancyLs(argv, state, terminal = Boolean(process.stdout.isTTY)) {
142
144
  if (!operands.length) operands.push(".");
143
145
  let status = 0, stdout = "", stderr = "";
144
146
  const visited = new Set();
145
- const show = (operand, heading = operands.length > 1) => {
147
+ let previousWasDirectory = false;
148
+ const show = (operand, heading = operands.length > 1, separate = true) => {
146
149
  const path = isAbsolute(operand) ? operand : resolve(state.cwd, operand);
147
150
  try {
148
151
  const stats = lstatSync(path);
152
+ let listingDirectory = stats.isDirectory() && !options.directory;
153
+ // GNU/POSIX-style ls follows a command-line symlink to a directory for
154
+ // an ordinary listing and for -R. -d, -l, and -F request information
155
+ // about the link itself instead. A broken link simply cannot qualify.
156
+ if (stats.isSymbolicLink() && !options.directory && !options.long && !options.classify) {
157
+ try { listingDirectory = statSync(path).isDirectory(); } catch {}
158
+ }
159
+ // Multiple file operands are one listing, not a series of directory
160
+ // sections. Only actual directory contents receive `name:` headings
161
+ // and blank section separators.
162
+ if (separate && stdout && (listingDirectory || previousWasDirectory)) stdout += "\n";
149
163
  let entries;
150
- if (!stats.isDirectory() || options.directory)
151
- entries = [{ name: basename(operand) || operand, stats, path }];
164
+ if (!listingDirectory)
165
+ // Preserve the operand spelling just like traditional ls. A glob such
166
+ // as ./*/*.js has already expanded to paths, and reducing each one to
167
+ // basename would lose the information that distinguishes its parent.
168
+ entries = [{ name: operand, stats, path }];
152
169
  else entries = listDirectory(path, options);
153
- if (heading) stdout += `${operand}:\n`;
170
+ if (heading && listingDirectory) stdout += `${operand}:\n`;
154
171
  const rendered = entries.map((entry) => displayEntry(entry, options));
155
172
  if (options.long) {
156
173
  for (let i = 0; i < entries.length; i++) {
@@ -164,23 +181,21 @@ export function fancyLs(argv, state, terminal = Boolean(process.stdout.isTTY)) {
164
181
  } else stdout += terminal && !options.one
165
182
  ? columns(rendered, process.stdout.columns ?? 80)
166
183
  : `${rendered.join("\n")}${rendered.length ? "\n" : ""}`;
167
- if (options.recursive && stats.isDirectory() && !options.directory) {
184
+ if (options.recursive && listingDirectory) {
168
185
  for (const entry of entries) if (entry.stats.isDirectory() && entry.name !== "." && entry.name !== "..") {
169
186
  const child = resolve(path, entry.name);
170
187
  if (visited.has(child)) continue;
171
188
  visited.add(child);
172
189
  stdout += "\n";
173
- show(`${operand.replace(/\/$/, "")}/${entry.name}`, true);
190
+ show(`${operand.replace(/\/$/, "")}/${entry.name}`, true, false);
174
191
  }
175
192
  }
193
+ previousWasDirectory = listingDirectory;
176
194
  } catch (error) {
177
195
  status = 1;
178
196
  stderr += `bunmsh: ${argv[0]}: ${operand}: ${error.message}\n`;
179
197
  }
180
198
  };
181
- for (let i = 0; i < operands.length; i++) {
182
- if (i) stdout += "\n";
183
- show(operands[i]);
184
- }
199
+ for (const operand of operands) show(operand);
185
200
  return { status, stdout, stderr };
186
201
  }
@@ -0,0 +1,43 @@
1
+ export function toggleSavedText(line, cursor, saved, side) {
2
+ line = String(line ?? "");
3
+ cursor = Math.max(0, Math.min(line.length, Number.isFinite(cursor) ? cursor : line.length));
4
+ saved = String(saved ?? "");
5
+
6
+ if (side === "head") {
7
+ if (cursor > 0) {
8
+ return {
9
+ line: line.slice(cursor),
10
+ cursor: 0,
11
+ saved: line.slice(0, cursor),
12
+ changed: true,
13
+ };
14
+ }
15
+ if (saved) {
16
+ return {
17
+ line: saved + line,
18
+ cursor: saved.length,
19
+ saved: "",
20
+ changed: true,
21
+ };
22
+ }
23
+ } else if (side === "tail") {
24
+ if (cursor < line.length) {
25
+ return {
26
+ line: line.slice(0, cursor),
27
+ cursor,
28
+ saved: line.slice(cursor),
29
+ changed: true,
30
+ };
31
+ }
32
+ if (saved) {
33
+ return {
34
+ line: line + saved,
35
+ cursor,
36
+ saved: "",
37
+ changed: true,
38
+ };
39
+ }
40
+ }
41
+
42
+ return { line, cursor, saved, changed: false };
43
+ }
@@ -29,6 +29,7 @@ import { importedHistory, readlineHistory, saveBunmshHistory } from "./history.j
29
29
  import pkg from "../package.json" with { type:"json" }
30
30
  import { MOUSE_OFF, MOUSE_ON, PASTE_OFF, PASTE_ON, mouseInput } from "./mouse.js";
31
31
  import { homeRelativePath } from "./environment.js";
32
+ import { toggleSavedText } from "./line-edit.js";
32
33
 
33
34
  const VERSION = `
34
35
  ${pkg.name}: ${pkg.description}
@@ -223,6 +224,8 @@ async function interactive(state) {
223
224
  let lastTabClick = null;
224
225
  let mouseCommandRunning = false;
225
226
  let foregroundCommand = false;
227
+ let savedHead = "";
228
+ let savedTail = "";
226
229
  let promptAbort = null;
227
230
  // Set by onPaste (below) when a bracketed paste contains a newline, and
228
231
  // consumed by the main loop's readline.question() catch block right after
@@ -332,6 +335,17 @@ async function interactive(state) {
332
335
  const key = { name: delta > 0 ? "right" : "left" };
333
336
  for (let n = 0; n < Math.abs(delta); n++) readline.write(null, key);
334
337
  };
338
+ const toggleLineText = (side) => {
339
+ if (!readline || foregroundCommand) return;
340
+ const saved = side === "head" ? savedHead : savedTail;
341
+ const result = toggleSavedText(readline.line, readline.cursor, saved, side);
342
+ if (side === "head") savedHead = result.saved;
343
+ else savedTail = result.saved;
344
+ if (!result.changed) return;
345
+ readline.line = result.line;
346
+ readline.cursor = result.cursor;
347
+ readline.prompt(true);
348
+ };
335
349
  const filteredInput = terminal ? mouseInput((mouse) => {
336
350
  if (!mouse.press || (mouse.button & 3) !== 0 || (mouse.button & 32)) return;
337
351
  pendingClick = { ...mouse, at: Date.now() };
@@ -384,7 +398,7 @@ async function interactive(state) {
384
398
  else if (shortcut === "lsfancy") runFancyShortcut();
385
399
  else if (shortcut === "lsfancy-parent") runFancyShortcut([".."]);
386
400
  else if (shortcut === "tab-close") runTabShortcut(["x"]);
387
- }, onPaste) : process.stdin;
401
+ }, onPaste, toggleLineText) : process.stdin;
388
402
  const completer = (line) => {
389
403
  commandIndex.refreshIfChanged(state);
390
404
  if (javascriptLine(line)) {
@@ -21,7 +21,13 @@ function partialMarkerSuffixLength(text, marker) {
21
21
  return 0;
22
22
  }
23
23
 
24
- export function mouseInput(onMouse, onCursorPosition, onShortcut = () => {}, onPaste = () => {}) {
24
+ export function mouseInput(
25
+ onMouse,
26
+ onCursorPosition,
27
+ onShortcut = () => {},
28
+ onPaste = () => {},
29
+ onEditShortcut = () => {},
30
+ ) {
25
31
  const decoder = new StringDecoder("utf8");
26
32
  let pending = "";
27
33
  // Bracketed paste (\x1b[200~ ... \x1b[201~): everything between the
@@ -48,6 +54,25 @@ export function mouseInput(onMouse, onCursorPosition, onShortcut = () => {}, onP
48
54
  source = source.replace(/\x1bc/g, () => { onShortcut("tab-close"); return ""; });
49
55
  }
50
56
  let output = "";
57
+ const flushOutput = () => {
58
+ if (!output) return;
59
+ this.push(output);
60
+ output = "";
61
+ };
62
+ const appendPlainText = (text) => {
63
+ let start = 0;
64
+ for (let index = 0; index < text.length; index++) {
65
+ const side = text[index] === "\x15" ? "head"
66
+ : text[index] === "\x0b" ? "tail"
67
+ : null;
68
+ if (!side) continue;
69
+ output += text.slice(start, index);
70
+ flushOutput();
71
+ onEditShortcut(side);
72
+ start = index + 1;
73
+ }
74
+ output += text.slice(start);
75
+ };
51
76
  while (source) {
52
77
  if (pasting) {
53
78
  const end = source.indexOf(PASTE_END);
@@ -66,8 +91,8 @@ export function mouseInput(onMouse, onCursorPosition, onShortcut = () => {}, onP
66
91
  continue;
67
92
  }
68
93
  const escape = source.indexOf("\x1b[");
69
- if (escape < 0) { output += source; break; }
70
- output += source.slice(0, escape);
94
+ if (escape < 0) { appendPlainText(source); break; }
95
+ appendPlainText(source.slice(0, escape));
71
96
  source = source.slice(escape);
72
97
  if (source.startsWith(PASTE_START)) {
73
98
  pasting = true;
@@ -93,7 +118,7 @@ export function mouseInput(onMouse, onCursorPosition, onShortcut = () => {}, onP
93
118
  output += source.slice(0, 2);
94
119
  source = source.slice(2);
95
120
  }
96
- if (output) this.push(output);
121
+ flushOutput();
97
122
  done();
98
123
  },
99
124
  });
@@ -787,7 +787,15 @@ async function pathnameFields(field, state) {
787
787
  return ch;
788
788
  }).join("");
789
789
  const glob = new Bun.Glob(pattern);
790
- const matches = [...glob.scanSync({ cwd: state.cwd, dot: false, onlyFiles: false })].sort();
790
+ // Shell pathname expansion follows a directory symlink used as an
791
+ // intermediate path component. Bun.Glob defaults this off, which makes
792
+ // patterns such as /sys/class/*/device silently miss sysfs class links.
793
+ const matches = [...glob.scanSync({
794
+ cwd: state.cwd,
795
+ dot: false,
796
+ onlyFiles: false,
797
+ followSymlinks: true,
798
+ })].sort();
791
799
  return matches.length ? matches : [value];
792
800
  }
793
801
 
@@ -53,6 +53,7 @@ import { fancyLs } from "../src/fancy-ls.js";
53
53
  import { MOUSE_OFF, MOUSE_ON, mouseInput } from "../src/mouse.js";
54
54
  import { canonicalEnvironment, environmentValue, homeRelativePath } from "../src/environment.js";
55
55
  import { findIsRegularBuiltin } from "../src/find.js";
56
+ import { toggleSavedText } from "../src/line-edit.js";
56
57
 
57
58
  async function run(source, options = {}) {
58
59
  const state = createState({
@@ -181,6 +182,36 @@ describe("completion", () => {
181
182
  ]);
182
183
  });
183
184
 
185
+ test("toggles independently saved Ctrl-U heads and Ctrl-K tails", () => {
186
+ const cutHead = toggleSavedText("headtail", 4, "", "head");
187
+ expect(cutHead).toEqual({ line: "tail", cursor: 0, saved: "head", changed: true });
188
+ expect(toggleSavedText(cutHead.line, cutHead.cursor, cutHead.saved, "head"))
189
+ .toEqual({ line: "headtail", cursor: 4, saved: "", changed: true });
190
+
191
+ const cutTail = toggleSavedText("headtail", 4, "", "tail");
192
+ expect(cutTail).toEqual({ line: "head", cursor: 4, saved: "tail", changed: true });
193
+ expect(toggleSavedText(cutTail.line, cutTail.cursor, cutTail.saved, "tail"))
194
+ .toEqual({ line: "headtail", cursor: 4, saved: "", changed: true });
195
+ });
196
+
197
+ test("intercepts Ctrl-U and Ctrl-K outside bracketed paste", async () => {
198
+ const edits = [];
199
+ const pasted = [];
200
+ let forwarded = "";
201
+ const input = mouseInput(() => {}, () => {}, () => {},
202
+ (text) => pasted.push(text),
203
+ (side) => edits.push({ side, forwarded }));
204
+ input.on("data", (chunk) => { forwarded += chunk.toString(); });
205
+ input.end("abc\x15def\x0b\x1b[200~x\x15y\x0bz\x1b[201~");
206
+ await new Promise((resolve) => input.once("end", resolve));
207
+ expect(edits).toEqual([
208
+ { side: "head", forwarded: "abc" },
209
+ { side: "tail", forwarded: "abcdef" },
210
+ ]);
211
+ expect(forwarded).toBe("abcdef");
212
+ expect(pasted).toEqual(["x\x15y\x0bz"]);
213
+ });
214
+
184
215
  test("imports Bash and Fish history by default and can be disabled", async () => {
185
216
  expect(parseBashHistory("#1720000000\necho bash\n\nshared\n"))
186
217
  .toEqual(["echo bash", "shared"]);
@@ -575,6 +606,7 @@ describe("execution", () => {
575
606
  mkdirSync(join(cwd, "targetdir"));
576
607
  symlinkSync("target.txt", join(cwd, "link_ok"));
577
608
  symlinkSync("targetdir", join(cwd, "link_dir"));
609
+ await Bun.write(join(cwd, "targetdir", "inside.js"), "export {};\n");
578
610
  symlinkSync("/nonexistent/path", join(cwd, "link_broken"));
579
611
  symlinkSync("loop_b", join(cwd, "loop_a"));
580
612
  symlinkSync("loop_a", join(cwd, "loop_b"));
@@ -597,6 +629,15 @@ describe("execution", () => {
597
629
  const plain = await run("builtin lsfancy", { cwd });
598
630
  expect(plain.stdout).toContain("🔗 link_ok");
599
631
  expect(plain.stdout).toContain("🚫 link_broken");
632
+
633
+ // A command-line link to a directory is followed for an ordinary
634
+ // listing, but -l, -d, and -F inspect/classify the link itself.
635
+ const followed = await run("builtin lsfancy link_dir", { cwd });
636
+ expect(followed.stdout).toContain("inside.js");
637
+ const linkLong = await run("builtin lsfancy -l link_dir", { cwd });
638
+ expect(linkLong.stdout).toContain("link_dir -> targetdir");
639
+ expect((await run("builtin lsfancy -d link_dir", { cwd })).stdout).toContain("🔗 link_dir");
640
+ expect((await run("builtin lsfancy -F link_dir", { cwd })).stdout).toContain("link_dir@");
600
641
  } finally { rmSync(cwd, { recursive: true, force: true }); }
601
642
  });
602
643
 
@@ -624,6 +665,35 @@ describe("execution", () => {
624
665
  } finally { rmSync(cwd, { recursive: true, force: true }); }
625
666
  });
626
667
 
668
+ test("builtin ls lists glob-expanded files as files, not directory sections", async () => {
669
+ const cwd = mkdtempSync(join(tmpdir(), "bunmsh-ls-glob-"));
670
+ try {
671
+ await Bun.write(join(cwd, "first.sh"), "#!/bin/sh\n");
672
+ await Bun.write(join(cwd, "final.sh"), "#!/bin/sh\n");
673
+ await Bun.write(join(cwd, "other.txt"), "other\n");
674
+ const output = await run("builtin ls f*.sh", { cwd });
675
+ expect(output).toMatchObject({ status: 0, stderr: "" });
676
+ expect(output.stdout).toContain("first.sh");
677
+ expect(output.stdout).toContain("final.sh");
678
+ expect(output.stdout).not.toContain("other.txt");
679
+ expect(output.stdout).not.toContain("first.sh:");
680
+ expect(output.stdout).not.toContain("final.sh:");
681
+ expect(output.stdout).not.toContain("\n\n");
682
+
683
+ await Bun.write(join(cwd, "-odd"), "odd\n");
684
+ const afterDoubleDash = await run("builtin ls -- -odd", { cwd });
685
+ expect(afterDoubleDash).toMatchObject({ status: 0, stderr: "" });
686
+ expect(afterDoubleDash.stdout).toContain("-odd");
687
+
688
+ mkdirSync(join(cwd, ".hidden-target"));
689
+ await Bun.write(join(cwd, ".hidden-target", "through-link.js"), "export {};\n");
690
+ symlinkSync(".hidden-target", join(cwd, "class-link"));
691
+ const throughLink = await run("builtin ls ./*/*.js", { cwd });
692
+ expect(throughLink).toMatchObject({ status: 0, stderr: "" });
693
+ expect(throughLink.stdout).toContain("./class-link/through-link.js");
694
+ } finally { rmSync(cwd, { recursive: true, force: true }); }
695
+ });
696
+
627
697
  test("fallback find filters paths and supports both -exec modes", async () => {
628
698
  const cwd = mkdtempSync(join(tmpdir(), "bunmsh-find-"));
629
699
  try {
@@ -1173,9 +1173,9 @@ class KittyGraphicsParser {
1173
1173
 
1174
1174
  if (control.m === "1") {
1175
1175
  this.transfers.set(key, existing);
1176
- if (!control.i && !control.I) {
1177
- this.implicitTransferKey = key;
1178
- }
1176
+ // Later chunks of a transfer only need m=; they continue the transfer
1177
+ // in progress whether or not its first chunk named an image id.
1178
+ this.implicitTransferKey = key;
1179
1179
  return null;
1180
1180
  }
1181
1181
 
@@ -3314,6 +3314,7 @@ async function bootstrap() {
3314
3314
 
3315
3315
  module.exports.CursorStateTracker = CursorStateTracker;
3316
3316
  module.exports.KittyGraphicsParser = KittyGraphicsParser;
3317
+ module.exports.BunPtyBackend = BunPtyBackend;
3317
3318
  module.exports.webSocketOriginAllowed = webSocketOriginAllowed;
3318
3319
 
3319
3320
  if (require.main === module) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jsgotty",
3
- "version": "1.1.11",
3
+ "version": "1.1.12",
4
4
  "description": "Share your terminal as a web application: A JavaScript rewrite of GoTTY",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -2,6 +2,16 @@
2
2
 
3
3
  All notable user-visible changes to jsmdcui are documented here.
4
4
 
5
+ ## [0.19.2] - 2026-09-21
6
+
7
+ ### Added
8
+
9
+ - Quickly pressing `Ctrl-W` twice now switches to the next tab like `Alt-T`
10
+ from either an editor or terminal pane. A single press retains its existing
11
+ next-pane behavior. At the rightmost tab, when every tab contains only
12
+ terminal panes, the double press creates a new editor tab instead of cycling
13
+ back, ensuring keyboard-only access to editing and other non-terminal work.
14
+
5
15
  ## [0.19.0] - 2026-08-24
6
16
 
7
17
  ### Fixed