bunmicro 0.9.22 → 0.9.25

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/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.25] - 2026-06-10
4
+ - Fixed colorscheme: aligned with go
5
+ - Added colorcolumn taberror showchars
6
+ * e.g. showchars = tab=>,space=.
7
+ - linter underline
8
+ - set hltrailingws on : whitespace
9
+ - Added tests/pty-demo.js
10
+ * Demo of the whole bunmicro App
11
+ * bun tests/pty-demo.js
12
+
13
+ ## [0.9.23] - 2026-06-10
14
+ - Fixed mouse close prompt cursor move
15
+ - Fixed set filetype doesn't apply instantly
16
+ - Added syntax highlighting fallback if user ~/.config/micro yaml fails
17
+ - Redetect highlighting syntax when unknown filetype saves
18
+ - Warning for dos(CRLF) shell scripts
19
+
3
20
  ## [0.9.22] - 2026-06-09
4
21
  - Clicking on icons toggles prompts
5
22
  - Unsaved star triggers save cmd
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunmicro",
3
- "version": "0.9.22",
3
+ "version": "0.9.25",
4
4
  "description": "Bun JavaScript rewrite of the micro editor originally in Golang",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -10,7 +10,8 @@
10
10
  },
11
11
  "scripts": {
12
12
  "start": "bun ./src/index.js",
13
- "check": "node --check ./src/index.js"
13
+ "check": "node --check ./src/index.js",
14
+ "demo:pty": "bun ./tests/pty-demo.js"
14
15
  },
15
16
  "dependencies": {
16
17
  "wasmoon": "^1.16.0"
@@ -7,17 +7,6 @@ export const DEFAULT_STYLE = {
7
7
  underline: false,
8
8
  };
9
9
 
10
- // Built-in fallback styles for color groups not defined in a colorscheme.
11
- // Matches Go micro's default group colours.
12
- const BUILTIN_GROUPS = {
13
- "diff-added": { ...DEFAULT_STYLE, fg: "green" },
14
- "diff-modified": { ...DEFAULT_STYLE, fg: "yellow" },
15
- "diff-deleted": { ...DEFAULT_STYLE, fg: "red" },
16
- "gutter-error": { ...DEFAULT_STYLE, fg: "red" },
17
- "gutter-warning":{ ...DEFAULT_STYLE, fg: "yellow" },
18
- "gutter-info": { ...DEFAULT_STYLE, fg: "brightblue" },
19
- };
20
-
21
10
  const COLOR_LINK = /color-link\s+(\S*)\s+"(.*)"/;
22
11
  const INCLUDE = /include\s+"(.*)"/;
23
12
 
@@ -47,10 +36,11 @@ export class Colorscheme {
47
36
  if (include) {
48
37
  const includeName = include[1];
49
38
  if (!parsed.has(includeName)) {
50
- const child = new Colorscheme(this.runtime);
51
- await child.load(includeName, parsed);
52
- for (const [key, value] of child.styles) styles.set(key, value);
53
- if (child.styles.has("default")) this.defaultStyle = child.styles.get("default");
39
+ const file = this.runtime.find(0, includeName);
40
+ if (!file) throw new Error(`${includeName} is not a valid colorscheme`);
41
+ parsed.add(includeName);
42
+ const includedStyles = await this.parse(includeName, await file.text(), parsed);
43
+ for (const [key, value] of includedStyles) styles.set(key, value);
54
44
  }
55
45
  continue;
56
46
  }
@@ -76,7 +66,7 @@ export class Colorscheme {
76
66
  if (this.styles.has(cur)) style = this.styles.get(cur);
77
67
  }
78
68
  }
79
- return style ?? BUILTIN_GROUPS[group] ?? stringToStyle(group, this.defaultStyle);
69
+ return style ?? stringToStyle(group, this.defaultStyle);
80
70
  }
81
71
  }
82
72
 
@@ -87,10 +77,10 @@ export function stringToStyle(input, base = DEFAULT_STYLE) {
87
77
  return {
88
78
  fg: stringToColor(fgRaw.trim(), base.fg),
89
79
  bg: stringToColor(bgRaw.trim(), base.bg),
90
- bold: text.includes("bold"),
91
- italic: text.includes("italic"),
92
- reverse: text.includes("reverse"),
93
- underline: text.includes("underline"),
80
+ bold: base.bold || text.includes("bold"),
81
+ italic: base.italic || text.includes("italic"),
82
+ reverse: base.reverse || text.includes("reverse"),
83
+ underline: base.underline || text.includes("underline"),
94
84
  };
95
85
  }
96
86
 
@@ -47,25 +47,30 @@ export async function loadSyntaxDefinitions(runtime) {
47
47
  const definitions = [];
48
48
  for (const file of runtime.list(1)) {
49
49
  let text = "";
50
+ let activeFile = file;
51
+ let source = null;
50
52
  try {
51
53
  text = await file.text();
54
+ source = Bun.YAML.parse(text);
52
55
  } catch (e) {
53
- console.error("Failed to read syntax yaml:", file.name);
54
- console.error(" Will not highlight this kind of file");
55
- console.error(" @ loadSyntaxDefinitions ");
56
- continue;
56
+ const fallback = file.real ? runtime.fallback?.(1, file.name) : null;
57
+ if (fallback) {
58
+ try {
59
+ text = await fallback.text();
60
+ source = Bun.YAML.parse(text);
61
+ activeFile = fallback;
62
+ console.error("Failed to load user syntax yaml, using built-in fallback:", file.name);
63
+ } catch {}
64
+ }
57
65
  }
58
66
 
59
- let source = null;
60
- try {
61
- source = Bun.YAML.parse(text);
62
- } catch (e) {
63
- console.error("Failed to parse syntax yaml:", file.name);
67
+ if (!source) {
68
+ console.error("Failed to load syntax yaml:", file.name);
64
69
  console.error(" Will not highlight this kind of file");
65
70
  console.error(" @ loadSyntaxDefinitions ");
66
71
  }
67
72
 
68
- const header = headers.get(file.name) ?? (source ? parseHeaderYaml(source) : parseHeaderTextFallback(text, file.name));
73
+ const header = headers.get(activeFile.name) ?? (source ? parseHeaderYaml(source) : parseHeaderTextFallback(text, activeFile.name));
69
74
  definitions.push(new SyntaxDefinition(header, source ?? { rules: [] }, text));
70
75
  }
71
76
  return definitions;
package/src/index.js CHANGED
@@ -101,7 +101,11 @@ const DEFAULT_SETTINGS = {
101
101
  fileformat: process.platform === "win32" ? "dos" : "unix",
102
102
  "comment.type": "",
103
103
  commenttype: "",
104
- trailingws: false,
104
+ hltrailingws: false,
105
+ hltaberrors: false,
106
+ colorcolumn: 0,
107
+ showchars: "",
108
+ indentchar: " ",
105
109
  };
106
110
 
107
111
  const LONG_LINE_REHIGHLIGHT_LIMIT = 300;
@@ -647,7 +651,11 @@ class BufferModel {
647
651
  reload: DEFAULT_SETTINGS.reload,
648
652
  eofnewline: DEFAULT_SETTINGS.eofnewline,
649
653
  fileformat: this.fileformat,
650
- trailingws: DEFAULT_SETTINGS.trailingws,
654
+ hltrailingws: DEFAULT_SETTINGS.hltrailingws,
655
+ hltaberrors: DEFAULT_SETTINGS.hltaberrors,
656
+ colorcolumn: DEFAULT_SETTINGS.colorcolumn,
657
+ showchars: DEFAULT_SETTINGS.showchars,
658
+ indentchar: DEFAULT_SETTINGS.indentchar,
651
659
  encoding: this.encoding,
652
660
  readonly,
653
661
  };
@@ -1092,6 +1100,7 @@ class BufferModel {
1092
1100
  }
1093
1101
  async save(path = this.path) {
1094
1102
  if (!path) throw new Error("No filename");
1103
+ const detectSyntaxAfterSave = this.filetype === "unknown";
1095
1104
  let text = this.lines.join("\n");
1096
1105
  if (this.encoding === "hex3") {
1097
1106
  await Bun.write(path, decodeBinaryBytes(Buffer.from(text, "latin1")));
@@ -1106,6 +1115,7 @@ class BufferModel {
1106
1115
  this._savedSerial = this._undoSerial ?? 0;
1107
1116
  this.modified = false;
1108
1117
  this.message = `Saved ${path}`;
1118
+ if (detectSyntaxAfterSave && this._syntaxContext) attachSyntax(this, this._syntaxContext, path, text);
1109
1119
  return;
1110
1120
  }
1111
1121
  if ((this.Settings.eofnewline ?? DEFAULT_SETTINGS.eofnewline) && !text.endsWith("\n")) text += "\n";
@@ -1123,6 +1133,7 @@ class BufferModel {
1123
1133
  this._savedSerial = this._undoSerial ?? 0;
1124
1134
  this.modified = false;
1125
1135
  this.message = `Saved ${path}`;
1136
+ if (detectSyntaxAfterSave && this._syntaxContext) attachSyntax(this, this._syntaxContext, path, text);
1126
1137
  }
1127
1138
 
1128
1139
  // --- Autocomplete (BufferComplete) ---
@@ -1788,7 +1799,10 @@ class App {
1788
1799
  const keymenuHeight = this.keymenu ? KEYDISPLAY.length : 0;
1789
1800
  const activeSuggestions = this._activeSuggestions();
1790
1801
  const activeSuggestionIdx = this._activeSuggestionIdx();
1791
- const activeMessage = this.message || this.buffer?.message || "";
1802
+ const formatWarning = this.buffer?.filetype === "shell" && this.buffer?.fileformat === "dos"
1803
+ ? "dos(CRLF fileformat) invalid for shell scripts!"
1804
+ : "";
1805
+ const activeMessage = this.message || this.buffer?.message || formatWarning;
1792
1806
  if (activeSuggestions.length === 0) this._acHScroll = 0;
1793
1807
  const suggestionsHeight = activeSuggestions.length > 1 ? 1 : 0;
1794
1808
  const messageHeight = suggestionsHeight ? 0 : activeMessage ? 1 : 0;
@@ -1873,7 +1887,9 @@ class App {
1873
1887
  const ft = (buf?.filetype && buf.filetype !== "unknown") ? buf.filetype : "?";
1874
1888
  const fmt = buf?.fileformat ?? "unix";
1875
1889
  const enc = buf?.encoding ?? "utf-8";
1876
- const baseStatus = { ...defaultStyle, reverse: true };
1890
+ const baseStatus = this.context.colorscheme?.styles?.has("statusline")
1891
+ ? this.context.colorscheme.get("statusline")
1892
+ : { ...defaultStyle, reverse: true };
1877
1893
  const redStatus = { ...baseStatus, fg: "red" };
1878
1894
  // Fill entire row with base style first
1879
1895
  putText(this.screen, 0, statusRow, " ".repeat(this.cols), baseStatus, this.cols);
@@ -2004,8 +2020,9 @@ class App {
2004
2020
  }
2005
2021
 
2006
2022
  renderMessageRow(defaultStyle, row, message) {
2007
- const statusStyle = this.context.colorscheme?.get("statusline") ?? { ...defaultStyle, reverse: true };
2008
- const style = { ...statusStyle, reverse: false };
2023
+ const style = this.context.colorscheme?.styles?.has("message")
2024
+ ? this.context.colorscheme.get("message")
2025
+ : defaultStyle;
2009
2026
  putText(this.screen, 0, row, " ".repeat(this.cols), style, this.cols);
2010
2027
  this._messageRowY = row;
2011
2028
  this._messageRowClickZone = null;
@@ -2035,15 +2052,15 @@ class App {
2035
2052
  }
2036
2053
 
2037
2054
  renderSuggestions(defaultStyle, row, suggestions, curIdx) {
2038
- const suggestionStyle = this.context.colorscheme?.get("statusline.suggestions")
2039
- ?? this.context.colorscheme?.get("statusline")
2040
- ?? defaultStyle;
2041
- const baseStyle = { ...suggestionStyle, reverse: false };
2055
+ const cs = this.context.colorscheme;
2056
+ const baseStyle = cs?.styles?.has("statusline.suggestions")
2057
+ ? cs.get("statusline.suggestions")
2058
+ : cs?.styles?.has("statusline")
2059
+ ? cs.get("statusline")
2060
+ : { ...defaultStyle, reverse: true };
2042
2061
  const selStyle = {
2043
2062
  ...baseStyle,
2044
- fg: baseStyle.bg === "default" ? "black" : baseStyle.bg,
2045
- bg: baseStyle.fg === "default" ? "brightwhite" : baseStyle.fg,
2046
- reverse: false,
2063
+ reverse: true,
2047
2064
  };
2048
2065
 
2049
2066
  // Compute each item's position in the virtual (pre-scroll) space.
@@ -2166,9 +2183,16 @@ class App {
2166
2183
  const softwrap = buf.Settings?.softwrap ?? false;
2167
2184
  const wordwrap = softwrap && (buf.Settings?.wordwrap ?? false);
2168
2185
  const tabsize = buf.Settings?.tabsize ?? DEFAULT_SETTINGS.tabsize;
2169
- const gutterStyle = { ...defaultStyle, fg: "brightblack" };
2170
- const dirtyGutterStyle = { ...gutterStyle, fg: "red" };
2171
2186
  const isActivePane = pane === this.tab.activePane;
2187
+ const gutterStyle = this.context.colorscheme?.styles?.has("line-number")
2188
+ ? this.context.colorscheme.get("line-number")
2189
+ : defaultStyle;
2190
+ const cursorlineOn = buf.Settings?.cursorline ?? DEFAULT_SETTINGS.cursorline;
2191
+ const csHasCurNum = this.context.colorscheme?.styles?.has("current-line-number");
2192
+ const curNumStyle = !csHasCurNum
2193
+ ? defaultStyle
2194
+ : (cursorlineOn ? this.context.colorscheme.get("current-line-number") : gutterStyle);
2195
+ const dirtyGutterStyle = { ...gutterStyle, fg: "red" };
2172
2196
  const useCursorline = (buf.Settings?.cursorline ?? DEFAULT_SETTINGS.cursorline) && isActivePane;
2173
2197
  const clBg = (useCursorline && this.context.colorscheme?.styles?.has("cursor-line"))
2174
2198
  ? (this.context.colorscheme.get("cursor-line")?.fg ?? null)
@@ -2181,12 +2205,12 @@ class App {
2181
2205
  const diffCol = (buf.Settings?.diffgutter ?? false) ? 1 : 0;
2182
2206
  const lineNumW = gutterW - msgW - diffCol;
2183
2207
  const cs = this.context.colorscheme;
2184
- const diffAddStyle = cs?.get("diff-added") ?? { ...defaultStyle, fg: "green" };
2185
- const diffModStyle = cs?.get("diff-modified") ?? { ...defaultStyle, fg: "yellow" };
2186
- const diffDelStyle = cs?.get("diff-deleted") ?? { ...defaultStyle, fg: "red" };
2187
- const msgInfoStyle = cs?.get("gutter-info") ?? { ...defaultStyle, fg: "cyan" };
2188
- const msgWarnStyle = cs?.get("gutter-warning") ?? { ...defaultStyle, fg: "yellow" };
2189
- const msgErrStyle = cs?.get("gutter-error") ?? { ...defaultStyle, fg: "red" };
2208
+ const diffAddStyle = cs?.styles?.has("diff-added") ? cs.get("diff-added") : null;
2209
+ const diffModStyle = cs?.styles?.has("diff-modified") ? cs.get("diff-modified") : null;
2210
+ const diffDelStyle = cs?.styles?.has("diff-deleted") ? cs.get("diff-deleted") : null;
2211
+ const msgInfoStyle = cs?.styles?.has("gutter-info") ? cs.get("gutter-info") : defaultStyle;
2212
+ const msgWarnStyle = cs?.styles?.has("gutter-warning") ? cs.get("gutter-warning") : defaultStyle;
2213
+ const msgErrStyle = cs?.styles?.has("gutter-error") ? cs.get("gutter-error") : defaultStyle;
2190
2214
 
2191
2215
  const renderGutter = (lineNo, row, screenRow, subRow = 0) => {
2192
2216
  // Message indicator: 2 cols, '> ' with kind-based style (Go: drawGutter)
@@ -2203,19 +2227,22 @@ class App {
2203
2227
  }
2204
2228
  putText(this.screen, pane.x, screenRow, msgCh + " ", msgSt, 2);
2205
2229
  }
2230
+ const isCurrentLine = isActivePane && !pane.selection && lineNo === buf.cursor.y;
2231
+ const lineStyle = isCurrentLine ? curNumStyle : gutterStyle;
2206
2232
  if (diffCol > 0) {
2207
2233
  const m = diffMarks?.[lineNo] ?? 0;
2208
- const [ch, st] = m === 1 ? ["▌", diffAddStyle]
2209
- : m === 2 ? ["▌", diffModStyle]
2210
- : m === 3 ? ["▔", diffDelStyle]
2211
- : [" ", gutterStyle];
2234
+ const [ch, colorStyle] = m === 1 ? ["▌", diffAddStyle]
2235
+ : m === 2 ? ["▌", diffModStyle]
2236
+ : m === 3 ? ["▔", diffDelStyle]
2237
+ : [" ", null];
2238
+ const st = colorStyle ? { ...lineStyle, fg: colorStyle.fg } : lineStyle;
2212
2239
  putText(this.screen, pane.x + msgW, screenRow, subRow === 0 ? ch : " ", st, 1);
2213
2240
  }
2214
2241
  if (lineNumW > 0) {
2215
2242
  const prefix = subRow === 0
2216
2243
  ? lineNumberText(buf, lineNo, row, lineNumW)
2217
2244
  : visualLineNumberText(subRow, lineNumW);
2218
- putText(this.screen, pane.x + msgW + diffCol, screenRow, prefix, isDirtyLongLine(buf, lineNo) ? dirtyGutterStyle : gutterStyle, lineNumW);
2245
+ putText(this.screen, pane.x + msgW + diffCol, screenRow, prefix, isDirtyLongLine(buf, lineNo) ? dirtyGutterStyle : lineStyle, lineNumW);
2219
2246
  }
2220
2247
  };
2221
2248
 
@@ -2299,7 +2326,11 @@ class App {
2299
2326
 
2300
2327
  renderDividers(node, defaultStyle) {
2301
2328
  if (node instanceof Pane) return;
2302
- const divStyle = { ...defaultStyle, fg: "brightblack" };
2329
+ const divReverse = this.context.config?.globalSettings?.divreverse ?? true;
2330
+ const baseDiv = this.context.colorscheme?.styles?.has("divider")
2331
+ ? this.context.colorscheme.get("divider")
2332
+ : defaultStyle;
2333
+ const divStyle = divReverse ? { ...baseDiv, reverse: true } : baseDiv;
2303
2334
  for (let i = 0; i < node.children.length - 1; i++) {
2304
2335
  const child = node.children[i];
2305
2336
  if (node.dir === "h") {
@@ -2316,21 +2347,37 @@ class App {
2316
2347
  }
2317
2348
 
2318
2349
  renderTabbar(defaultStyle) {
2319
- const tabBarStyle = this.context.colorscheme?.get("tabbar") ?? { ...defaultStyle, reverse: true };
2320
- const activeStyle = this.context.colorscheme?.get("tabbar.active") ?? { ...tabBarStyle, bold: true };
2350
+ const cs = this.context.colorscheme;
2351
+ const gs = this.context.config?.globalSettings;
2352
+ const tabReverse = gs?.tabreverse ?? true;
2353
+ const tabHighlight = gs?.tabhighlight ?? false;
2354
+ const tabCharReverse = (tabReverse || tabHighlight) && !(tabReverse && tabHighlight);
2355
+
2356
+ const stylesFor = (reverse) => {
2357
+ const base = cs?.styles?.has("tabbar")
2358
+ ? cs.get("tabbar")
2359
+ : { ...defaultStyle, reverse };
2360
+ const active = cs?.styles?.has("tabbar.active")
2361
+ ? cs.get("tabbar.active")
2362
+ : base;
2363
+ return [base, active];
2364
+ };
2365
+ const [sepStyle] = stylesFor(tabReverse);
2366
+ const [charBase, charActive] = stylesFor(tabCharReverse);
2367
+
2321
2368
  let x = 0;
2322
2369
  for (let i = 0; i < this.tabs.length && x < this.cols; i++) {
2323
2370
  const name = this.tabs[i].name || "No name";
2324
2371
  const isActive = i === this.activeTabIdx;
2325
2372
  const label = isActive ? `[${name}]` : ` ${name} `;
2326
- const style = isActive ? activeStyle : tabBarStyle;
2373
+ const style = isActive ? charActive : charBase;
2327
2374
  const start = x;
2328
2375
  x = putText(this.screen, x, 0, label, style, this.cols - x);
2329
2376
  this.tabRects.push({ index: i, start, end: x });
2330
2377
  if (i < this.tabs.length - 1 && x < this.cols)
2331
- x = putText(this.screen, x, 0, " ", tabBarStyle, this.cols - x);
2378
+ x = putText(this.screen, x, 0, " ", sepStyle, this.cols - x);
2332
2379
  }
2333
- if (x < this.cols) putText(this.screen, x, 0, " ".repeat(this.cols - x), tabBarStyle, this.cols - x);
2380
+ if (x < this.cols) putText(this.screen, x, 0, " ".repeat(this.cols - x), sepStyle, this.cols - x);
2334
2381
  }
2335
2382
 
2336
2383
  updateScrollForPane(pane) {
@@ -3517,18 +3564,7 @@ class App {
3517
3564
  }
3518
3565
  break;
3519
3566
  case "ft": {
3520
- const defs = this.context.syntaxDefinitions ?? [];
3521
- const filetypes = defs.map(d => d.filetype).filter(Boolean).sort();
3522
- const ftComplete = (partial) => filetypes.filter(f => f.startsWith(partial));
3523
- this.openPrompt("Set filetype: ", (value) => {
3524
- if (!value || !buf) return;
3525
- buf.filetype = value;
3526
- buf.Settings.filetype = value;
3527
- const def = defs.find(d => d.filetype === value);
3528
- buf.syntaxDefinition = def ?? null;
3529
- buf.highlighter = def ? new Highlighter(def, defs) : null;
3530
- buf._highlightCache = null;
3531
- }, { completer: ftComplete, initial: buf?.filetype ?? "" });
3567
+ this.openCommandMode("set filetype ");
3532
3568
  break;
3533
3569
  }
3534
3570
  case "fmt":
@@ -3552,9 +3588,11 @@ class App {
3552
3588
  await this.addTab();
3553
3589
  break;
3554
3590
  case "cmdmode":
3591
+ if (this.prompt?.type === "Command") this._suppressMouseUntilUp = true;
3555
3592
  await this.togglePromptMode("Command");
3556
3593
  break;
3557
3594
  case "shellmode":
3595
+ if (this.prompt?.type === "Shell") this._suppressMouseUntilUp = true;
3558
3596
  await this.togglePromptMode("Shell");
3559
3597
  break;
3560
3598
  }
@@ -5149,6 +5187,18 @@ function completeOptionValue(cmd, option, partial, context) {
5149
5187
  const optVal = allSettings[option];
5150
5188
  const suggestions = [];
5151
5189
 
5190
+ if (option === "filetype") {
5191
+ const filetypes = [
5192
+ "off",
5193
+ "unknown",
5194
+ ...(context?.syntaxDefinitions ?? []).map((definition) => definition.filetype),
5195
+ ];
5196
+ return [...new Set(filetypes)]
5197
+ .filter((filetype) => filetype && filetype.startsWith(partial))
5198
+ .sort()
5199
+ .map((filetype) => ({ value: `${cmd} ${option} ${filetype}`, label: filetype }));
5200
+ }
5201
+
5152
5202
  if (typeof optVal === "boolean") {
5153
5203
  if ("on".startsWith(partial)) suggestions.push("on");
5154
5204
  else if ("true".startsWith(partial)) suggestions.push("true");
@@ -6038,22 +6088,75 @@ function renderHighlightedCells(buf, lineNo, scrollX, maxWidth, colorscheme, sel
6038
6088
  // Go: cursor-line bg is skipped when a syntax style already has a non-default background (preservebg)
6039
6089
  const defBg = colorscheme?.defaultStyle?.bg ?? "default";
6040
6090
 
6041
- const showTrailingWs = buf.Settings?.trailingws ?? false;
6091
+ const showTrailingWs = buf.Settings?.hltrailingws ?? false;
6042
6092
  let trailingWsIdx = raw.length;
6043
6093
  if (showTrailingWs) {
6044
6094
  let k = raw.length - 1;
6045
6095
  while (k >= 0 && (raw[k] === " " || raw[k] === "\t")) k--;
6046
6096
  trailingWsIdx = k + 1;
6047
6097
  }
6048
- const trailingWsStyle = showTrailingWs
6049
- ? (colorscheme?.get("trailingws") ?? { fg: "red", underline: true })
6098
+ const trailingWsColor = showTrailingWs && colorscheme?.styles?.has("trailingws")
6099
+ ? colorscheme.get("trailingws")?.fg
6050
6100
  : null;
6051
6101
 
6102
+ // showchars parsing (Go bufwindow.go:455-476)
6103
+ const indentchar = buf.Settings?.indentchar ?? " ";
6104
+ let spacechars = " ";
6105
+ let tabchars = indentchar;
6106
+ let indentspacechars = "";
6107
+ let indenttabchars = "";
6108
+ for (const entry of String(buf.Settings?.showchars ?? "").split(",")) {
6109
+ const eq = entry.indexOf("=");
6110
+ if (eq < 0) continue;
6111
+ const key = entry.slice(0, eq);
6112
+ const val = entry.slice(eq + 1);
6113
+ if (key === "space") spacechars = val;
6114
+ else if (key === "tab") tabchars = val;
6115
+ else if (key === "ispace") indentspacechars = val;
6116
+ else if (key === "itab") indenttabchars = val;
6117
+ }
6118
+ // leadingwsEnd: index of first non-whitespace char (raw code-unit index)
6119
+ let leadingwsEnd = 0;
6120
+ while (leadingwsEnd < raw.length && (raw[leadingwsEnd] === " " || raw[leadingwsEnd] === "\t")) leadingwsEnd++;
6121
+
6122
+ const hltaberrors = buf.Settings?.hltaberrors ?? false;
6123
+ const tabstospaces = buf.Settings?.tabstospaces ?? false;
6124
+ const tabErrorFg = hltaberrors && colorscheme?.styles?.has("tab-error")
6125
+ ? colorscheme.get("tab-error")?.fg
6126
+ : null;
6127
+ const indentCharFg = colorscheme?.styles?.has("indent-char")
6128
+ ? colorscheme.get("indent-char")?.fg
6129
+ : null;
6130
+ const colorcolumn = Number(buf.Settings?.colorcolumn ?? 0) | 0;
6131
+ const colorColumnBg = colorcolumn > 0 && colorscheme?.styles?.has("color-column")
6132
+ ? colorscheme.get("color-column")?.fg
6133
+ : null;
6134
+ const tabsize = buf.Settings?.tabsize ?? DEFAULT_SETTINGS.tabsize;
6135
+
6136
+ // scrollVisualCol: visual column of raw[0..scrollX). Uses displayWidth (tab = full
6137
+ // tabsize, not aligned-to-boundary) to stay consistent with cursor/scroll math.
6138
+ const scrollVisualCol = displayWidth(raw.slice(0, scrollX));
6139
+
6140
+ // Linter messages overlapping this line (Go bufwindow.go:662-668)
6141
+ const lineMessages = (buf.Messages ?? []).filter((m) => {
6142
+ const sy = m.Start?.Y ?? 0, ey = m.End?.Y ?? 0;
6143
+ return sy <= lineNo && ey >= lineNo;
6144
+ });
6145
+ const inMessageAt = (charIdx) => {
6146
+ for (const m of lineMessages) {
6147
+ const sY = m.Start?.Y ?? 0, sX = m.Start?.X ?? 0;
6148
+ const eY = m.End?.Y ?? 0, eX = m.End?.X ?? 0;
6149
+ const ge = lineNo > sY || (lineNo === sY && charIdx >= sX);
6150
+ const lt = lineNo < eY || (lineNo === eY && charIdx < eX);
6151
+ if (ge && lt) return true;
6152
+ }
6153
+ return false;
6154
+ };
6155
+
6052
6156
  let changeIndex = 0;
6053
6157
  let searchIdx = 0;
6054
6158
  let i = scrollX;
6055
6159
  while (i < raw.length && width < maxWidth) {
6056
- const cp = raw.codePointAt(i);
6057
6160
  const unit = displayUnitAt(raw, i);
6058
6161
  const ch = unit.text;
6059
6162
  const charLen = unit.length;
@@ -6062,16 +6165,30 @@ function renderHighlightedCells(buf, lineNo, scrollX, maxWidth, colorscheme, sel
6062
6165
  while (changeIndex + 1 < changes.length && i >= changes[changeIndex + 1][0]) changeIndex++;
6063
6166
  const group = changes[changeIndex]?.[1] ?? "default";
6064
6167
  const syntaxStyle = colorscheme?.get(group) ?? colorscheme?.defaultStyle ?? {};
6065
- const preservebg = cursorLineBg != null && syntaxStyle.bg !== undefined && syntaxStyle.bg !== defBg;
6066
- const baseStyle = (cursorLineBg && !preservebg) ? { ...syntaxStyle, bg: cursorLineBg } : syntaxStyle;
6168
+ let preservebg = syntaxStyle.bg !== undefined && syntaxStyle.bg !== defBg;
6169
+ let baseStyle = (cursorLineBg && !preservebg) ? { ...syntaxStyle, bg: cursorLineBg } : syntaxStyle;
6067
6170
  while (searchIdx < searchRanges.length && searchRanges[searchIdx][1] <= i) searchIdx++;
6068
6171
  const inSearch = searchIdx < searchRanges.length && i >= searchRanges[searchIdx][0] && i < searchRanges[searchIdx][1];
6069
6172
  const selected = isSelected(selection, lineNo, i, i + charLen);
6070
6173
  const braceMatched = braceMatches?.has(String(lineNo) + ":" + String(i));
6071
- let style = (showTrailingWs && i >= trailingWsIdx) ? trailingWsStyle : baseStyle;
6174
+
6175
+ // tab-error in leading whitespace
6176
+ let style = baseStyle;
6177
+ const inLeading = i < leadingwsEnd;
6178
+ if (tabErrorFg != null && inLeading) {
6179
+ if ((tabstospaces && ch === "\t") || (!tabstospaces && ch === " ")) {
6180
+ style = { ...style, bg: tabErrorFg };
6181
+ preservebg = true;
6182
+ }
6183
+ }
6184
+ if (trailingWsColor != null && i >= trailingWsIdx) {
6185
+ style = { ...style, bg: trailingWsColor };
6186
+ preservebg = true;
6187
+ }
6072
6188
  if (inSearch) {
6073
6189
  const searchStyle = colorscheme?.styles?.has("hlsearch") ? colorscheme.get("hlsearch") : null;
6074
- style = searchStyle ?? { ...baseStyle, reverse: !baseStyle.reverse };
6190
+ style = searchStyle ?? { ...(colorscheme?.defaultStyle ?? {}), reverse: true };
6191
+ if ((style.bg ?? "default") !== defBg) preservebg = true;
6075
6192
  }
6076
6193
  if (braceMatched) {
6077
6194
  if ((buf.Settings?.matchbracestyle ?? DEFAULT_SETTINGS.matchbracestyle) === "highlight") {
@@ -6082,22 +6199,73 @@ function renderHighlightedCells(buf, lineNo, scrollX, maxWidth, colorscheme, sel
6082
6199
  }
6083
6200
  }
6084
6201
  if (selected) {
6085
- style = { ...style, reverse: !style.reverse };
6202
+ const selectionStyle = colorscheme?.styles?.has("selection") ? colorscheme.get("selection") : null;
6203
+ style = selectionStyle ?? { ...(colorscheme?.defaultStyle ?? {}), reverse: true };
6204
+ }
6205
+ if (lineMessages.length > 0 && inMessageAt(i)) {
6206
+ style = { ...style, underline: true };
6207
+ }
6208
+
6209
+ // Visualize whitespace
6210
+ let displayCh = ch;
6211
+ let useIndentCharFg = false;
6212
+ if (ch === " ") {
6213
+ // Go bufwindow.go:554-559: ispace only kicks in at tabsize boundaries (indent guide).
6214
+ const visualCol = scrollVisualCol + width;
6215
+ const useIspace = inLeading && indentspacechars && (visualCol % tabsize === 0);
6216
+ const candidate = useIspace ? indentspacechars : spacechars;
6217
+ if (candidate && candidate !== " ") {
6218
+ displayCh = candidate[0] ?? " ";
6219
+ useIndentCharFg = true;
6220
+ }
6221
+ } else if (ch === "\t") {
6222
+ const candidate = (inLeading && indenttabchars) ? indenttabchars : tabchars;
6223
+ if (candidate && candidate !== " ") {
6224
+ displayCh = candidate[0] ?? " ";
6225
+ useIndentCharFg = true;
6226
+ } else {
6227
+ displayCh = " ";
6228
+ }
6086
6229
  }
6230
+ if (useIndentCharFg && indentCharFg != null) {
6231
+ style = { ...style, fg: indentCharFg };
6232
+ }
6233
+
6234
+ const ccAt = (visualCol) => {
6235
+ if (colorColumnBg == null || preservebg) return null;
6236
+ return visualCol === colorcolumn ? colorColumnBg : null;
6237
+ };
6238
+
6087
6239
  if (ch === "\t") {
6088
- const spaces = Math.min(DEFAULT_SETTINGS.tabsize, maxWidth - width);
6089
- for (let j = 0; j < spaces; j++) cells.push({ ch: " ", style });
6090
- width += spaces;
6240
+ const spaces = Math.min(tabsize, maxWidth - width);
6241
+ for (let j = 0; j < spaces; j++) {
6242
+ const visualCol = scrollVisualCol + width;
6243
+ const cellCh = j === 0 ? displayCh : " ";
6244
+ const ccBg = ccAt(visualCol);
6245
+ const cellStyle = ccBg != null ? { ...style, bg: ccBg } : style;
6246
+ cells.push({ ch: cellCh, style: cellStyle });
6247
+ width++;
6248
+ }
6091
6249
  } else if (w > 0 && width + w <= maxWidth) {
6092
- cells.push({ ch, style, width: w });
6250
+ const visualCol = scrollVisualCol + width;
6251
+ const ccBg = ccAt(visualCol);
6252
+ const cellStyle = ccBg != null ? { ...style, bg: ccBg } : style;
6253
+ cells.push({ ch: displayCh, style: cellStyle, width: w });
6093
6254
  width += w;
6094
6255
  }
6095
6256
  i += charLen;
6096
6257
  }
6097
- // Fill rest of line with cursorline background
6098
- if (cursorLineBg) {
6099
- const padStyle = { ...(colorscheme?.defaultStyle ?? {}), bg: cursorLineBg };
6100
- while (width < maxWidth) { cells.push({ ch: " ", style: padStyle }); width++; }
6258
+ // Trailing fill: cursor-line bg and color-column always apply (Go bufwindow.go:807-826)
6259
+ while (width < maxWidth) {
6260
+ const visualCol = scrollVisualCol + width;
6261
+ let padStyle = cursorLineBg
6262
+ ? { ...(colorscheme?.defaultStyle ?? {}), bg: cursorLineBg }
6263
+ : (colorscheme?.defaultStyle ?? {});
6264
+ if (colorColumnBg != null && visualCol === colorcolumn) {
6265
+ padStyle = { ...padStyle, bg: colorColumnBg };
6266
+ }
6267
+ cells.push({ ch: " ", style: padStyle });
6268
+ width++;
6101
6269
  }
6102
6270
  return cells;
6103
6271
  }
@@ -6229,9 +6397,14 @@ async function loadBuffers(files, command) {
6229
6397
  if (loadBuffers.context) attachSyntax(stdinBuf, loadBuffers.context, "", stdinText);
6230
6398
  buffers.push(stdinBuf);
6231
6399
  } else {
6232
- buffers.push(new BufferModel({ command }));
6400
+ const buffer = new BufferModel({ command });
6401
+ if (loadBuffers.context) attachSyntax(buffer, loadBuffers.context, "", "");
6402
+ buffers.push(buffer);
6233
6403
  }
6234
- return buffers.length ? buffers : [new BufferModel({ command })];
6404
+ if (buffers.length > 0) return buffers;
6405
+ const buffer = new BufferModel({ command });
6406
+ if (loadBuffers.context) attachSyntax(buffer, loadBuffers.context, "", "");
6407
+ return [buffer];
6235
6408
  }
6236
6409
 
6237
6410
  async function printReadmeDocs() {
@@ -6666,6 +6839,7 @@ function getSelectionText(buf, selection) {
6666
6839
  }
6667
6840
 
6668
6841
  function attachSyntax(buffer, context, path, text) {
6842
+ buffer._syntaxContext = context;
6669
6843
  const def = detectBufferSyntax(context.syntaxDefinitions, path, text);
6670
6844
  buffer.syntaxDefinition = def;
6671
6845
  buffer.filetype = def?.filetype ?? "unknown";
@@ -6673,21 +6847,32 @@ function attachSyntax(buffer, context, path, text) {
6673
6847
  buffer.highlighter = def ? new Highlighter(def, context.syntaxDefinitions ?? []) : null;
6674
6848
  buffer._highlightCache = null;
6675
6849
  buffer._onOptionChange = (option, oldVal, newVal) => {
6850
+ if (option === "filetype") setBufferFiletype(buffer, context, newVal);
6676
6851
  const ba = makeBufferAdapter(buffer);
6677
6852
  context.plugins?.run("onBufferOptionChanged", ba, option, oldVal, newVal);
6678
6853
  context.jsPlugins?.run("onBufferOptionChanged", ba, option, oldVal, newVal);
6679
6854
  };
6680
6855
  }
6681
6856
 
6857
+ function setBufferFiletype(buffer, context, filetype) {
6858
+ const value = String(filetype);
6859
+ const definitions = context?.syntaxDefinitions ?? [];
6860
+ const def = definitions.find((candidate) => candidate.filetype === value) ?? null;
6861
+ buffer.filetype = value;
6862
+ buffer.Settings.filetype = value;
6863
+ buffer.syntaxDefinition = def;
6864
+ buffer.highlighter = def ? new Highlighter(def, definitions) : null;
6865
+ buffer._highlightCache = null;
6866
+ }
6867
+
6682
6868
  function detectBufferSyntax(definitions, path, text) {
6683
6869
  if (!definitions) return null;
6684
- const lines = String(text).split("\n").slice(0, 50);
6870
+ const lines = normalizeBufferText(text).split("\n").slice(0, 50);
6685
6871
  return detectSyntax(definitions, { path, firstLine: lines[0] ?? "", lines });
6686
6872
  }
6687
6873
 
6688
6874
  function detectBufferFiletype(definitions, path, text) {
6689
6875
  if (!definitions) return "unknown";
6690
- const lines = String(text).split("\n").slice(0, 50);
6691
6876
  return detectBufferSyntax(definitions, path, text)?.filetype ?? "unknown";
6692
6877
  }
6693
6878
 
@@ -6761,7 +6946,7 @@ async function catFiles(files, colorscheme, syntaxDefinitions, encoding = DEFAUL
6761
6946
  );
6762
6947
  continue;
6763
6948
  }
6764
- const lines = content.split("\n");
6949
+ const lines = normalizeBufferText(content).split("\n");
6765
6950
  const def = detectSyntax(syntaxDefinitions, {
6766
6951
  path: effectivePath ?? "",
6767
6952
  firstLine: lines[0] ?? "",
@@ -14,11 +14,13 @@ export class RuntimeRegistry {
14
14
  this.configDir = configDir;
15
15
  this.files = [[], [], [], [], []];
16
16
  this.realFiles = [[], [], [], [], []];
17
+ this.fallbackFiles = [[], [], [], [], []];
17
18
  }
18
19
 
19
20
  async init({ user = true } = {}) {
20
21
  this.files = [[], [], [], [], []];
21
22
  this.realFiles = [[], [], [], [], []];
23
+ this.fallbackFiles = [[], [], [], [], []];
22
24
  await this.addRuntimeKind(RTColorscheme, "colorschemes", ".micro", user);
23
25
  await this.addRuntimeKind(RTSyntax, "syntax", ".yaml", user);
24
26
  await this.addRuntimeKind(RTSyntaxHeader, "syntax", ".hdr", user);
@@ -36,7 +38,10 @@ export class RuntimeRegistry {
36
38
  for (const entry of entries) {
37
39
  if (entry.isDirectory() || !entry.name.endsWith(extension)) continue;
38
40
  const file = new RuntimeFile(join(dir, entry.name), real);
39
- if (!real && this.realFiles[kind].some((f) => f.name === file.name)) continue;
41
+ if (!real && this.realFiles[kind].some((f) => f.name === file.name)) {
42
+ this.fallbackFiles[kind].push(file);
43
+ continue;
44
+ }
40
45
  this.files[kind].push(file);
41
46
  if (real) this.realFiles[kind].push(file);
42
47
  }
@@ -53,6 +58,10 @@ export class RuntimeRegistry {
53
58
  find(kind, name) {
54
59
  return this.list(kind).find((file) => file.name === name) ?? null;
55
60
  }
61
+
62
+ fallback(kind, name) {
63
+ return this.fallbackFiles[kind]?.find((file) => file.name === name) ?? null;
64
+ }
56
65
  }
57
66
 
58
67
  class RuntimeFile {
@@ -0,0 +1,492 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Visual PTY demo for bunmicro.
4
+ *
5
+ * Child terminal output is forwarded directly to the current TTY.
6
+ * stdin is not forwarded; press q, Ctrl-Q, or Esc to stop the demo.
7
+ *
8
+ * Usage:
9
+ * bun tests/pty-demo.js
10
+ * bun tests/pty-demo.js --delay 700 --type-delay 45 --term-delay 1800
11
+ */
12
+
13
+ import { mkdir, mkdtemp, readdir, rm } from "node:fs/promises";
14
+ import { join, resolve,dirname,basename } from "node:path";
15
+ import { tmpdir } from "node:os";
16
+
17
+ const isAnci = Bun.env.TMPK_HOME && Bun.which('ldcustom')
18
+
19
+ const ROOT = resolve(import.meta.dir, "..");
20
+ const BUNMICRO = join(ROOT, "src", "index.js");
21
+
22
+ const DEMO_ONE_TEXT = `
23
+
24
+ # BUNMICRO PTY DEMO
25
+ "Use q, Ctrl-Q, or Esc to stop this demo."
26
+ `;
27
+
28
+ const DEMO_TWO_LONG_LINE = "This deliberately long line demonstrates soft wrapping across the editor viewport without real newline characters. 中文段落用來測試寬字元、游標移動與自動換行是否正確,並確認每一個漢字都能完整顯示。日本語の文章では、ひらがな、カタカナ、漢字を混ぜて、表示幅と折り返し位置を確認します。さらに長い一行を維持したまま、画面端で自然に折り返される動作を丁寧に実演します。中文日本語🚀✨🧪確認。";
29
+ const DEMO_TWO_TEXT = [
30
+ "SECOND TAB",
31
+ DEMO_TWO_LONG_LINE,
32
+ "",
33
+ ].join("\n");
34
+ if ([...DEMO_TWO_LONG_LINE].length !== 250) throw new Error("demo two long line must be exactly 250 characters");
35
+ const options = {
36
+ delay: numberArg("--delay", 500),
37
+ typeDelay: numberArg("--type-delay", 100),
38
+ startupDelay: numberArg("--startup-delay", 5000),
39
+ termDelay: numberArg("--term-delay", 1400),
40
+ cols: numberArg("--cols", process.stdout.columns || 100),
41
+ rows: numberArg("--rows", process.stdout.rows || 30),
42
+ };
43
+
44
+ if (process.argv.includes("--help")) {
45
+ console.log(`Usage: bun tests/pty-demo.js [options]
46
+
47
+ Options:
48
+ --delay MS Delay after each action (default: ${options.delay})
49
+ --type-delay MS Delay between typed characters (default: ${options.typeDelay})
50
+ --startup-delay MS Initial editor startup delay (default: ${options.startupDelay})
51
+ --term-delay MS Extra delay for terminal pane startup (default: ${options.termDelay})
52
+ --cols N PTY columns (default: current TTY width)
53
+ --rows N PTY rows (default: current TTY height)
54
+
55
+ stdin is not forwarded to bunmicro. Press q, Ctrl-Q, or Esc to stop.`);
56
+ process.exit(0);
57
+ }
58
+
59
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
60
+ console.error("pty-demo requires stdin and stdout to be TTYs.");
61
+ process.exit(1);
62
+ }
63
+
64
+ let stopped = false;
65
+ let resolveStopped;
66
+ const stoppedPromise = new Promise((resolve) => { resolveStopped = resolve; });
67
+ let terminal = null;
68
+ let proc = null;
69
+ let temp = "";
70
+ let originalRaw = false;
71
+
72
+ function requestStop() {
73
+ if (stopped) return;
74
+ stopped = true;
75
+ resolveStopped();
76
+ }
77
+
78
+ function onInput(data) {
79
+ const bytes = Buffer.from(data);
80
+ if (bytes.includes(0x71) || bytes.includes(0x11) || bytes.includes(0x1b)) requestStop();
81
+ }
82
+
83
+ function onResize() {
84
+ if (!terminal || terminal.closed) return;
85
+ terminal.resize(process.stdout.columns || options.cols, process.stdout.rows || options.rows);
86
+ }
87
+
88
+ async function pause(ms = options.delay) {
89
+ if (stopped) throw new Error("demo stopped");
90
+ await Promise.race([Bun.sleep(ms), stoppedPromise]);
91
+ if (stopped) throw new Error("demo stopped");
92
+ }
93
+
94
+ function setTitle(title) {
95
+ process.stdout.write(`\x1b]0;bunmicro demo: ${title}\x07`);
96
+ }
97
+
98
+ async function send(data, wait = options.delay) {
99
+ if (stopped) throw new Error("demo stopped");
100
+ terminal.write(data);
101
+ await pause(wait);
102
+ }
103
+
104
+ async function type(text, wait = options.delay) {
105
+ for (const ch of text) {
106
+ if (stopped) throw new Error("demo stopped");
107
+ terminal.write(ch);
108
+ await pause(options.typeDelay);
109
+ }
110
+ await pause(wait);
111
+ }
112
+
113
+ async function paste(text, wait = options.delay) {
114
+ await send(`\x1b[200~${text}\x1b[201~`, wait);
115
+ }
116
+
117
+ async function action(title, fn, waitBefore = Math.min(250, options.delay)) {
118
+ setTitle(title);
119
+ await pause(waitBefore);
120
+ await fn();
121
+ }
122
+
123
+ async function command(value, wait = options.delay) {
124
+ await send("\x05", Math.min(350, options.delay)); // Ctrl-E
125
+ await type(`${value}\r`, wait);
126
+ }
127
+
128
+ function click(x, y, button = 0) {
129
+ return `\x1b[<${button};${x + 1};${y + 1}M\x1b[<${button};${x + 1};${y + 1}m`;
130
+ }
131
+
132
+ async function runDemo({ file2, unknownFile, redetectedFile, crlfShell, themeCount }) {
133
+ await action("bracketed paste JavaScript sample", async () => {
134
+ await send("\x1b[F", 150);
135
+ await paste([
136
+ "",
137
+ "alpha one",
138
+ "alpha two",
139
+ "foo bar foo",
140
+ "",
141
+ "async function loadProfile(userId) {",
142
+ " const enabled = true;",
143
+ " const retries = 3;",
144
+ ' const greeting = "hello from bunmicro";',
145
+ " const tags = [\"editor\", \"javascript\", \"demo\"];",
146
+ " const profile = { userId, enabled, retries, tags };",
147
+ " await new Promise((resolve) => setTimeout(resolve, 250));",
148
+ " if (!profile.enabled) throw new Error(\"disabled profile\");",
149
+ " return { ...profile, greeting };",
150
+ "}",
151
+ "",
152
+ "loadProfile(42).then(console.log).catch(console.error);",
153
+ "mouse target",
154
+ ].join("\n"), 1000);
155
+ });
156
+
157
+ await action("cursor movement and edit", async () => {
158
+ await send("\x1b[A\x1b[A\x1b[H", 200);
159
+ await type("[edited] ");
160
+ await send("\x1b[F", 150);
161
+ await type(" !");
162
+ });
163
+
164
+ await action("undo and redo", async () => {
165
+ await send("\x1a", 350);
166
+ await send("\x19", 500);
167
+ });
168
+
169
+ await action("set filetype applies syntax instantly", async () => {
170
+ await command("set filetype javascript", 1100);
171
+ });
172
+
173
+ await action("theme picker: preview every theme with Down", async () => {
174
+ await send("\x05", 250); // Ctrl-E
175
+ await type("theme");
176
+ setTitle("theme picker: Space opens theme completions");
177
+ await send(" ", 350);
178
+ setTitle("theme picker: Tab previews first theme");
179
+ await send("\t", 850);
180
+ for (let i = 1; i < themeCount; i++) {
181
+ setTitle(`theme picker: Down ${i}/${themeCount - 1}`);
182
+ await send("\x1b[B", 650);
183
+ }
184
+ setTitle("theme picker: reached final theme");
185
+ await pause(1000);
186
+ await send("\x1b", 500); // Cancel preview and restore original theme
187
+ });
188
+
189
+ await action("select fixed theme: dracula-tc", async () => {
190
+ await command("theme darcula", 1000);
191
+ });
192
+
193
+ await action("find alpha", async () => {
194
+ await send("\x06", 300);
195
+ await type("alpha");
196
+ await send("\r", 500);
197
+ await send("\x0e", 500);
198
+ });
199
+
200
+ await action("interactive replace foo", async () => {
201
+ await send("\x1bh", 300);
202
+ await type("foo FIRST");
203
+ await send("\r", 700);
204
+ setTitle("interactive replace: accept first match");
205
+ await send("y", 700);
206
+ setTitle("interactive replace: skip second match");
207
+ await send("n", 700);
208
+ });
209
+
210
+ await action("replace all alpha", async () => {
211
+ await command("replaceall alpha 阿爾法", 650);
212
+ });
213
+
214
+ await action("show whitespace and trailing spaces", async () => {
215
+ await command("set showchars tab=>,space=.", 450);
216
+ await command("set hltrailingws true", 450);
217
+ await command("set colorcolumn 30", 600);
218
+ await command("set hltaberrors true", 450);
219
+ await command("set tabstospaces true", 850);
220
+ await send("\x1b[F", 120);
221
+ await type(" ");
222
+ });
223
+
224
+ await action("save", async () => {
225
+ await send("\x13", 650);
226
+ });
227
+
228
+ await action("unknown filetype saves as .js and redetects", async () => {
229
+ await command(`open ${unknownFile}`, 850);
230
+ await command(`save ${redetectedFile}`, 1200);
231
+ });
232
+
233
+ await action("select all and eval js", async () => {
234
+ await send("\x01", 900); // Ctrl-A
235
+
236
+ if(isAnci)
237
+ await command("js Object.getOwnPropertyNames(''.__proto__)", 2500);
238
+ else
239
+ await command("eval js", 2500);
240
+ setTitle("eval js result: press Enter to continue");
241
+ await send("\r", 1000);
242
+ });
243
+
244
+ await action("DOS CRLF shell script warning", async () => {
245
+ await command(`open ${crlfShell}`, 1200);
246
+ });
247
+
248
+ await action("open second file in another tab", async () => {
249
+ await command(`open ${file2}`, 800);
250
+ });
251
+
252
+ await action("enable softwrap for long line", async () => {
253
+ await command("set softwrap on", 1200);
254
+ });
255
+
256
+ await action("previous and next tab", async () => {
257
+ await send("\x1bp", 650);
258
+ await send("\x1bt", 650);
259
+ });
260
+
261
+ await action("new scratch tab", async () => {
262
+ await send("\x14", 600);
263
+ await type("scratch tab\ncreated by PTY demo");
264
+ });
265
+
266
+ await action("mouse click unsaved star opens save command", async () => {
267
+ await send(click(9, options.rows - 1), 750);
268
+ await send("\x1b", 500);
269
+ });
270
+
271
+ await action("mouse command and shell icons toggle prompts", async () => {
272
+ await send(click(25, options.rows - 1), 650); // € opens command prompt
273
+ await send(click(25, options.rows - 2), 650); // € closes command prompt
274
+ await send(click(32, options.rows - 1), 650); // $ opens shell prompt
275
+ await send(click(32, options.rows - 2), 650); // $ closes shell prompt
276
+ });
277
+
278
+ await action("mouse click first tab", async () => {
279
+ await send(click(2, 0), 800);
280
+ });
281
+
282
+ await action("vertical split and pane switch", async () => {
283
+ await command("vsplit", 800);
284
+ await send("\x17", 650);
285
+ });
286
+
287
+ await action("mouse click editor pane", async () => {
288
+ await send(click(Math.floor(options.cols * 0.75), 5), 700);
289
+ });
290
+
291
+ await action("horizontal split and pane switch", async () => {
292
+ await command("hsplit", 800);
293
+ await send("\x17", 650);
294
+ });
295
+
296
+ await action("Ctrl-B interactive shell", async () => {
297
+ await send("\x02", 350); // Ctrl-B
298
+ await type("echo BUNMICRO_CTRL_B_SHELL");
299
+ await send("\r", options.termDelay);
300
+ setTitle("Ctrl-B shell: press Enter to return");
301
+ await send("\r", 900);
302
+ });
303
+
304
+ await action("open terminal pane", async () => {
305
+ await command("term", options.termDelay);
306
+ await type("printf 'BUNMICRO_TERM_DEMO\\n'\r", options.termDelay);
307
+ await send("\x1b", 800);
308
+ });
309
+
310
+ await action("mouse wheel and editor click", async () => {
311
+ await send(click(10, 8, 65), 500);
312
+ await send(click(10, 8, 64), 500);
313
+ await send(click(12, 6), 700);
314
+ });
315
+
316
+ await action("final tab switching", async () => {
317
+ await send("\x1bp", 600);
318
+ await send("\x1bt", 900);
319
+ });
320
+ }
321
+
322
+ async function cleanup() {
323
+ process.stdin.off("data", onInput);
324
+ process.stdout.off("resize", onResize);
325
+
326
+ if (proc?.exitCode === null) {
327
+ // Let bunmicro restore the terminal itself first. Escape closes a possible
328
+ // terminal pane; repeated Ctrl-Q/n handles panes, tabs, and save prompts.
329
+ terminal?.write("\x1b");
330
+ await Bun.sleep(250);
331
+ for (let i = 0; i < 12 && proc.exitCode === null; i++) {
332
+ terminal?.write("\x11"); // Ctrl-Q
333
+ await Bun.sleep(180);
334
+ terminal?.write("n");
335
+ await Bun.sleep(120);
336
+ }
337
+ await Promise.race([proc.exited, Bun.sleep(700)]);
338
+ }
339
+ if (proc?.exitCode === null) {
340
+ proc.kill();
341
+ await Promise.race([proc.exited, Bun.sleep(500)]);
342
+ }
343
+ if (terminal && !terminal.closed) terminal.close();
344
+
345
+
346
+ let udroot="";
347
+
348
+ if(isAnci)
349
+ {
350
+ udroot=join(Bun.env.HOME,'.udocker/containers/alpine-toolbox/ROOT')
351
+ }
352
+
353
+ if (temp) await rm(udroot+temp, { recursive: true, force: true });
354
+
355
+
356
+ // Defensive reset in case the child was killed before emitting its teardown.
357
+ process.stdout.write([
358
+ "\x1b[?1000l", // normal mouse tracking
359
+ "\x1b[?1001l", // highlight mouse tracking
360
+ "\x1b[?1002l", // button-event mouse tracking
361
+ "\x1b[?1003l", // any-event mouse tracking
362
+ "\x1b[?1004l", // focus events
363
+ "\x1b[?1005l", // UTF-8 mouse encoding
364
+ "\x1b[?1006l", // SGR mouse encoding
365
+ "\x1b[?1007l", // alternate scroll mode
366
+ "\x1b[?1015l", // urxvt mouse encoding
367
+ "\x1b[?1016l", // SGR pixel mouse encoding
368
+ "\x1b[?2004l", // bracketed paste
369
+ "\x1b[?2026l", // synchronized output
370
+ "\x1b[>4;0m", // xterm modifyOtherKeys
371
+ "\x1b[<u", // pop kitty keyboard protocol
372
+ "\x1b[0m",
373
+ "\x1b[?25h",
374
+ "\x1b[?1049l", // leave alternate screen last
375
+ ].join(""));
376
+ if (process.stdin.isTTY) process.stdin.setRawMode(originalRaw);
377
+ process.stdin.pause();
378
+ setTitle(stopped ? "stopped" : "complete");
379
+ process.stdout.write("\n");
380
+ }
381
+
382
+ async function main() {
383
+
384
+ let udroot="";
385
+ if(isAnci)
386
+ {
387
+ await Bun.spawn(['fish','-c echo Using fish shell!'],{env:Bun.env}).exited
388
+
389
+ temp = '/tmp/bunmicro-pty-demo-'+Bun.randomUUIDv7().slice(0,8);
390
+ udroot=join(Bun.env.HOME,'.udocker/containers/alpine-toolbox/ROOT')
391
+ }
392
+ else
393
+ temp = await mkdtemp(join(tmpdir(), "bunmicro-pty-demo-"));
394
+
395
+ const configDir = join(temp, "config");
396
+ const file1 = join(temp, "pty-demo-one.txt");
397
+ const file2 = join(temp, "pty-demo-two.txt");
398
+ const unknownFile = join(temp, "pty-demo-redetect");
399
+ const redetectedFile = join(temp, "pty-demo-redetected.js");
400
+ const crlfShell = join(temp, "pty-demo-crlf.sh");
401
+ const syntaxDir = join(configDir, "syntax");
402
+ const themeCount = (await readdir(join(ROOT, "runtime", "colorschemes")))
403
+ .filter((name) => name.endsWith(".micro")).length;
404
+ await mkdir(udroot+configDir, { recursive: true });
405
+ await mkdir(udroot+syntaxDir, { recursive: true });
406
+ await Bun.write(join(udroot,configDir, "settings.json"), JSON.stringify({
407
+ colorscheme: "default",
408
+ mouse: true,
409
+ savecursor: false,
410
+ savehistory: false,
411
+ }, null, 2));
412
+
413
+ await Bun.write(udroot+file1, DEMO_ONE_TEXT);
414
+ await Bun.write(udroot+file2, DEMO_TWO_TEXT);
415
+ await Bun.write(udroot+unknownFile, `
416
+ async function hello(){
417
+ const redetected = 'yes';
418
+ console.log(
419
+ Object.getOwnPropertyNames(
420
+ redetected.__proto__
421
+ )
422
+ );
423
+ }
424
+
425
+ await hello();
426
+ `);
427
+ await Bun.write(udroot+crlfShell, "#!/bin/sh\r\necho CRLF_SHELL_WARNING\r\n");
428
+ await Bun.write(join(udroot,syntaxDir, "javascript.yaml"), "filetype: javascript\nrules: [invalid yaml");
429
+
430
+ console.log([
431
+ "NOTE: The upcoming JavaScript YAML parse failure is intentional.",
432
+ "It demonstrates fallback to bunmicro's built-in JavaScript syntax.",
433
+ ].join("\n"));
434
+
435
+ originalRaw = process.stdin.isRaw ?? false;
436
+ process.stdin.setRawMode(true);
437
+ process.stdin.resume();
438
+ process.stdin.on("data", onInput);
439
+ process.stdout.on("resize", onResize);
440
+
441
+ terminal = new Bun.Terminal({
442
+ cols: options.cols,
443
+ rows: options.rows,
444
+ data(_terminal, data) {
445
+ process.stdout.write(data);
446
+ },
447
+ });
448
+
449
+ let bunArr
450
+ if(isAnci)
451
+ {
452
+ bunArr=['fish','-c',`/android/bin/ldcustom --library-path /android/glibc:/android/bun /android/bun/bun-linux-aarch64 $argv`,'--']
453
+ }
454
+ else
455
+ bunArr=['bun'];
456
+
457
+ proc = Bun.spawn({
458
+ cmd: [...bunArr, BUNMICRO, "-config-dir", configDir, file1],
459
+ cwd: ROOT,
460
+ terminal,
461
+ env: {
462
+ ...process.env,
463
+ TERM: process.env.TERM || "xterm-256color",
464
+ COLORTERM: process.env.COLORTERM || "truecolor",
465
+ COLUMNS: String(options.cols),
466
+ LINES: String(options.rows),
467
+ },
468
+ });
469
+
470
+ setTitle("startup - press q to stop");
471
+ await pause(options.startupDelay);
472
+ await runDemo({ file2, unknownFile, redetectedFile, crlfShell, themeCount });
473
+ await pause(1000);
474
+ }
475
+
476
+ try {
477
+ await main();
478
+ } catch (error) {
479
+ if (!stopped) throw error;
480
+ } finally {
481
+ await cleanup();
482
+ }
483
+
484
+ function numberArg(name, fallback) {
485
+ const eqArg = process.argv.find((arg) => arg.startsWith(`${name}=`));
486
+ const index = process.argv.indexOf(name);
487
+ const raw = eqArg?.slice(name.length + 1) ?? (index >= 0 ? process.argv[index + 1] : undefined);
488
+ if (raw === undefined) return fallback;
489
+ const value = Number(raw);
490
+ if (!Number.isFinite(value) || value < 0) throw new Error(`${name} must be a non-negative number`);
491
+ return Math.floor(value);
492
+ }