buninu 0.4.12 → 0.4.13

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.
@@ -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 {
@@ -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
@@ -793,6 +793,8 @@ selection, search, and copy remain available.
793
793
  | `Ctrl-F` | Search; use `Ctrl-N` and `Ctrl-P` for the next and previous match. |
794
794
  | `Ctrl-G` | Open the editor help. |
795
795
  | `Alt-G` | Show or hide the shortcut bar. |
796
+ | `Ctrl-W` | Switch to the next pane in the current tab. |
797
+ | `Ctrl-W` twice quickly | Switch to the next tab like `Alt-T`. At the rightmost tab, if every tab contains only terminal panes, create a new editor tab instead of cycling back. |
796
798
  | `Ctrl-Q` or `Alt-Q` | Close the current UI. |
797
799
 
798
800
  To preview another color theme, press `Ctrl-E` or click on `€`, type `theme `, then use `Tab`
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jsmdcui",
3
- "version": "0.19.1",
3
+ "version": "0.19.2",
4
4
  "description": "Markdown as a Common UI for Terminals and Web Browsers",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -151,7 +151,10 @@ quotes here but these are not necessary when entering the command in micro.
151
151
 
152
152
  * `term ['exec']`: Open a terminal emulator running the given executable. If no
153
153
  executable is given, this will open the default shell in the terminal
154
- emulator.
154
+ emulator. `Ctrl-W` switches panes in both editor and terminal panes. Press
155
+ it twice quickly to cycle to the next tab like `Alt-T`; at the rightmost
156
+ tab, if every tab is a terminal, this creates a new editor tab instead of
157
+ cycling back.
155
158
 
156
159
  ---
157
160
 
@@ -793,6 +793,8 @@ selection, search, and copy remain available.
793
793
  | `Ctrl-F` | Search; use `Ctrl-N` and `Ctrl-P` for the next and previous match. |
794
794
  | `Ctrl-G` | Open the editor help. |
795
795
  | `Alt-G` | Show or hide the shortcut bar. |
796
+ | `Ctrl-W` | Switch to the next pane in the current tab. |
797
+ | `Ctrl-W` twice quickly | Switch to the next tab like `Alt-T`. At the rightmost tab, if every tab contains only terminal panes, create a new editor tab instead of cycling back. |
796
798
  | `Ctrl-Q` or `Alt-Q` | Close the current UI. |
797
799
 
798
800
  To preview another color theme, press `Ctrl-E` or click on `€`, type `theme `, then use `Tab`
@@ -2796,6 +2796,8 @@ class App {
2796
2796
  this.tabRects = [];
2797
2797
  this._escBuf = null; // pending lone ESC bytes waiting for alt-key combo
2798
2798
  this._escTimer = null;
2799
+ this._ctrlWTime = 0;
2800
+ this._ctrlWTimer = null;
2799
2801
  this._suggestionsRow = null;
2800
2802
  this._suggestionRects = [];
2801
2803
  this._acHScroll = 0;
@@ -3790,7 +3792,7 @@ class App {
3790
3792
  const titleStyle = { ...defaultStyle, reverse: true };
3791
3793
  const scrollMsg = vt && vt.scrollOffset > 0
3792
3794
  ? ` -- SCROLLBACK (${vt.scrollOffset}/${vt.scrollback.length}) wheel↑↓ to browse, any key to return`
3793
- : (isActive ? " [Esc: close Ctrl-W: switch pane]" : "");
3795
+ : (isActive ? " [Esc=close,Ctrl-W/*2=Next Pane/Tab]" : "");
3794
3796
  putText(this.screen, pane.x, pane.y, ` Terminal${scrollMsg}`.padEnd(pane.w), titleStyle, pane.w);
3795
3797
  if (!vt) return;
3796
3798
  const renderRows = pane.h - 1;
@@ -4228,14 +4230,12 @@ class App {
4228
4230
  this.render();
4229
4231
  return;
4230
4232
  }
4231
- // Ctrl-W: switch pane focus without closing
4233
+ // Ctrl-W: switch pane focus without closing. A quick second Ctrl-W
4234
+ // cycles tabs, with an editor-tab escape hatch when every tab is a
4235
+ // terminal and the current one is already the rightmost tab.
4232
4236
  if (text === "\x17") {
4233
- const panes = this.tab.panes();
4234
- if (panes.length > 1) {
4235
- const idx = panes.indexOf(this.tab.activePane);
4236
- this.tab.activePane = panes[(idx + 1) % panes.length];
4237
- this.render();
4238
- }
4237
+ await this.handleCtrlW();
4238
+ this.render();
4239
4239
  return;
4240
4240
  }
4241
4241
 
@@ -4525,11 +4525,7 @@ class App {
4525
4525
  await runAction("AddTab", this);
4526
4526
  break;
4527
4527
  case "ctrl-w": { //switchPane
4528
- const panes = this.tab.panes();
4529
- if (panes.length > 1) {
4530
- const idx = panes.indexOf(this.tab.activePane);
4531
- this.tab.activePane = panes[(idx + 1) % panes.length];
4532
- }
4528
+ await this.handleCtrlW();
4533
4529
  break;
4534
4530
  }
4535
4531
  case "ctrl-y": //redo
@@ -5403,6 +5399,37 @@ class App {
5403
5399
  return this.setActiveTab((this.activeTabIdx + 1) % this.tabs.length);
5404
5400
  }
5405
5401
 
5402
+ _clearCtrlW() {
5403
+ if (this._ctrlWTimer) clearTimeout(this._ctrlWTimer);
5404
+ this._ctrlWTimer = null;
5405
+ this._ctrlWTime = 0;
5406
+ }
5407
+
5408
+ async handleCtrlW() {
5409
+ const now = Date.now();
5410
+ const isDouble = this._ctrlWTime > 0 && now - this._ctrlWTime < 400;
5411
+ this._clearCtrlW();
5412
+
5413
+ if (isDouble) {
5414
+ const atRightmostTab = this.activeTabIdx === this.tabs.length - 1;
5415
+ const allTabsAreTerminal = this.tabs.every((tab) =>
5416
+ tab.panes().length > 0 && tab.panes().every((pane) => pane.type === "term")
5417
+ );
5418
+ if (atRightmostTab && allTabsAreTerminal) await this.addTab();
5419
+ else this.nextTab();
5420
+ return;
5421
+ }
5422
+
5423
+ this._ctrlWTime = now;
5424
+ this._ctrlWTimer = setTimeout(() => this._clearCtrlW(), 400);
5425
+
5426
+ const panes = this.tab.panes();
5427
+ if (panes.length > 1) {
5428
+ const idx = panes.indexOf(this.tab.activePane);
5429
+ this.tab.activePane = panes[(idx + 1) % panes.length];
5430
+ }
5431
+ }
5432
+
5406
5433
  async handlePrompt(text) {
5407
5434
  const key = parseKey(text);
5408
5435
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buninu",
3
- "version": "0.4.12",
3
+ "version": "0.4.13",
4
4
  "description": "Buninu is a portable, self-bootstrapping Unix-like userspace built on Bun that runs across operating systems",
5
5
  "type": "module",
6
6
  "main": "./bin/init.js",