@dunkinfrunkin/mdcat 0.1.0 → 0.1.2

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 (5) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +60 -109
  3. package/package.json +1 -1
  4. package/src/cli.js +91 -15
  5. package/src/tui.js +57 -12
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Frank Chan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,141 +1,92 @@
1
- # mdcat
1
+ # mdcat /\(o.o)/\
2
2
 
3
- **View markdown files beautifully in your terminal.**
3
+ [![npm](https://img.shields.io/npm/v/@dunkinfrunkin/mdcat?color=61afef&label=npm)](https://www.npmjs.com/package/@dunkinfrunkin/mdcat)
4
+ [![license](https://img.shields.io/badge/license-MIT-98c379)](LICENSE)
5
+ [![node](https://img.shields.io/badge/node-%3E%3D18-e5c07b)](package.json)
4
6
 
5
- [![npm version](https://img.shields.io/npm/v/mdcat.svg)](https://www.npmjs.com/package/mdcat)
6
- [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7
+ **Terminal pager for Markdown.** Full colour, syntax highlighting, incremental search, mouse support — zero config.
7
8
 
8
- `mdcat` is a terminal pager for Markdown. It renders GitHub-Flavoured Markdown with full colour, syntax-highlighted code blocks, clickable hyperlinks (OSC 8), and a keyboard-driven TUI — all with zero configuration. Pipe a file into it or open one directly; it just works.
9
-
10
- ---
11
-
12
- ## What it looks like
13
-
14
- - **H1** headings render in a purple Unicode box
15
- - **H2** headings render in bold blue with an underline bar
16
- - **H3–H6** headings use green → yellow → cyan → dim
17
- - **Code blocks** get a bordered box with a language tag and syntax highlighting
18
- - **Blockquotes** get an amber `▌` bar with dim italic text
19
- - **Lists** use `●` / `○` / `‣` bullets; task lists use `☑` / `☐`
20
- - **Tables** render with full Unicode box-drawing borders
21
- - **Links** are blue and underlined, clickable in iTerm2 / Kitty / WezTerm
22
-
23
- The top chrome bar shows the filename and file type. The bottom bar shows key hints and scroll percentage. Press `/` to search — matching lines get a gold `▶` gutter marker.
9
+ ```sh
10
+ npm install -g @dunkinfrunkin/mdcat
11
+ ```
24
12
 
25
13
  ---
26
14
 
27
15
  ## Install
28
16
 
29
- **Zero-install (recommended for one-off use):**
30
-
31
17
  ```sh
32
- npx mdcat README.md
33
- ```
18
+ # Global
19
+ npm install -g @dunkinfrunkin/mdcat
34
20
 
35
- **Global install via npm:**
36
-
37
- ```sh
38
- npm install -g mdcat
39
- ```
40
-
41
- **macOS via Homebrew:**
42
-
43
- ```sh
21
+ # Homebrew
44
22
  brew install frankchan/tap/mdcat
45
- ```
46
23
 
47
- ---
24
+ # Zero-install
25
+ npx @dunkinfrunkin/mdcat README.md
26
+ ```
48
27
 
49
28
  ## Usage
50
29
 
51
30
  ```sh
52
- # Open a file
53
- mdcat README.md
54
-
55
- # Pipe from stdin
56
- cat CHANGELOG.md | mdcat
57
-
58
- # Read from curl
59
- curl -s https://raw.githubusercontent.com/user/repo/main/README.md | mdcat
60
-
61
- # Check version
62
- mdcat --version
63
-
64
- # Help
65
- mdcat --help
31
+ mdcat README.md # open a file
32
+ mdcat --web README.md # render and open in browser
33
+ cat CHANGELOG.md | mdcat # pipe from stdin
34
+ curl -s https://… | mdcat # pipe from curl
35
+ mdcat --help # show help
36
+ mdcat --version # show version
66
37
  ```
67
38
 
68
- ---
69
-
70
- ## Keyboard shortcuts
71
-
72
- | Key | Action |
73
- |----------------|---------------------------------|
74
- | `q` | Quit |
75
- | `j` / `k` | Scroll down / up one line |
76
- | `↑` / `↓` | Scroll up / down one line |
77
- | `Space` / `b` | Page down / page up |
78
- | `d` / `u` | Half-page down / up |
79
- | `g` / `G` | Jump to top / bottom |
80
- | `/` | Enter search mode |
81
- | `n` / `N` | Next / previous search match |
82
- | `Esc` | Cancel search or clear matches |
83
- | Mouse wheel | Scroll up / down three lines |
84
-
85
- ---
86
-
87
- ## Features
88
-
89
- - Renders all GitHub-Flavoured Markdown (GFM) elements
90
- - Syntax-highlighted code blocks via `cli-highlight` / highlight.js
91
- - One Dark colour palette easy on the eyes in dark terminals
92
- - OSC 8 clickable hyperlinks (iTerm2, Kitty, WezTerm, foot, …)
93
- - Full mouse wheel support
94
- - Incremental search with `/` — highlights all matches, jumps to each
95
- - Respects terminal width long lines and tables are truncated cleanly
96
- - Works with pipes: `curl | mdcat`
97
- - No configuration files, no dependencies to configure
98
-
99
- ---
100
-
101
- ## Supported Markdown
102
-
103
- | Element | Rendering |
104
- |------------------|------------------------------------------------|
105
- | H1–H6 headings | Coloured by level; H1 gets a Unicode box |
106
- | Paragraphs | Word-wrapped to terminal width |
107
- | **Bold** | Bold white |
108
- | _Italic_ | Italic |
109
- | ~~Strikethrough~~| Strikethrough + dim |
110
- | `inline code` | Amber text on dark background |
111
- | Fenced code | Bordered box + syntax highlighting |
112
- | Blockquotes | Amber `▌` bar with dim italic text |
113
- | Unordered lists | `●` / `○` / `‣` bullets at increasing depth |
114
- | Ordered lists | Cyan numbers |
115
- | Task lists | `☑` / `☐` with green / dim colouring |
116
- | Tables | Box-drawing borders; respects column alignment |
117
- | Links | Blue underline + OSC 8 clickable |
118
- | Images | `🖼 alt-text` in dim |
119
- | Horizontal rules | Full-width dim `─` line |
120
- | HTML entities | Decoded (`&`, `<`, `>`, …) |
121
-
122
- ---
39
+ ## Keys
40
+
41
+ | Key | Action |
42
+ |-----|--------|
43
+ | `q` | Quit |
44
+ | `y` | Copy visible page to clipboard |
45
+ | `M` | Toggle mouse (off = free text selection) |
46
+ | `j` / `k` | Scroll down / up |
47
+ | `Space` / `b` | Page down / up |
48
+ | `d` / `u` | Half-page down / up |
49
+ | `g` / `G` | Top / bottom |
50
+ | `/` | Search |
51
+ | `n` / `N` | Next / previous match |
52
+ | `Esc` | Clear search |
53
+ | Mouse wheel | Scroll three lines |
54
+
55
+ ## What it renders
56
+
57
+ | Element | Rendering |
58
+ |---------|-----------|
59
+ | H1 | Purple Unicode box |
60
+ | H2 | Bold blue with underline |
61
+ | H3–H6 | Green yellow cyan → dim |
62
+ | **Bold** / _italic_ / ~~strike~~ | Standard ANSI |
63
+ | `inline code` | Amber on dark background |
64
+ | Fenced code | Bordered box with syntax highlighting |
65
+ | Blockquotes | Amber `▌` bar, dim italic |
66
+ | Unordered lists | `●` / `○` / `‣` bullets |
67
+ | Ordered lists | Cyan numbers |
68
+ | Task lists | `☑` / `☐` with green / dim |
69
+ | Tables | Box-drawing borders, column alignment |
70
+ | Links | Blue underline, OSC 8 clickable |
71
+ | Images | `[alt text]` badge in dim |
72
+ | Horizontal rules | Full-width dim `─` line |
73
+ | HTML entities | Decoded (`&`, `<`, …) |
123
74
 
124
75
  ## Contributing
125
76
 
126
- Contributions are welcome! Please open an issue to discuss a feature or bug before sending a PR. The codebase is small and deliberately dependency-light — keep it that way.
77
+ Bug reports and pull requests are welcome. Please open an issue first to discuss significant changes.
127
78
 
128
79
  ```sh
129
- git clone https://github.com/frankchan/mdcat
80
+ git clone https://github.com/dunkinfrunkin/mdcat
130
81
  cd mdcat
131
82
  npm install
132
83
  npm test
133
84
  ```
134
85
 
135
- Please make sure `npm test` passes before submitting.
86
+ All PRs must pass `npm test` (68 tests).
136
87
 
137
- ---
88
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for more details.
138
89
 
139
90
  ## License
140
91
 
141
- MIT — see [LICENSE](LICENSE) for details.
92
+ [MIT](LICENSE) © Frank Chan
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dunkinfrunkin/mdcat",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "View markdown files beautifully in your terminal",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -1,53 +1,129 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync } from "fs";
2
+ import { readFileSync, writeFileSync } from "fs";
3
3
  import { resolve, basename } from "path";
4
+ import { tmpdir } from "os";
5
+ import { execFileSync } from "child_process";
4
6
  import { marked } from "marked";
5
7
  import { renderTokens } from "./render.js";
6
8
  import { launch } from "./tui.js";
7
9
 
8
10
  marked.use({ gfm: true });
9
11
 
12
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
13
+
14
+ const E = "\x1B";
15
+ const dim = s => `${E}[2m${s}${E}[0m`;
16
+ const blue = s => `${E}[38;2;97;175;239m${s}${E}[0m`;
17
+ const gray = s => `${E}[38;2;92;99;112m${s}${E}[0m`;
18
+ const bold = s => `${E}[1m${s}${E}[0m`;
19
+
20
+ const CAT = ` ${gray("/\\")}${blue("(o.o)")}${gray("/\\")} `;
21
+
10
22
  const args = process.argv.slice(2);
11
23
 
12
24
  if (args[0] === "--help" || args[0] === "-h") {
13
- console.log("Usage: mdcat <file.md>");
14
- console.log(" cat file.md | mdcat");
15
- console.log("\nKeys: q quit j/k or ↑↓ scroll space/b page d/u half-page g/G top/bottom");
25
+ console.log(`\n${CAT} ${bold("mdcat")} ${dim(`v${pkg.version}`)}`);
26
+ console.log(`${dim(" markdown pager for your terminal")}\n`);
27
+ console.log(`${bold("Usage:")}`);
28
+ console.log(` mdcat ${dim("<file.md>")}`);
29
+ console.log(` mdcat ${dim("--web <file.md>")} ${dim("# open in browser")}`);
30
+ console.log(` cat file.md ${dim("|")} mdcat\n`);
31
+ console.log(`${bold("Keys:")}`);
32
+ console.log(` ${blue("/")} search ${blue("n/N")} next/prev match`);
33
+ console.log(` ${blue("j/k")} ${dim("↑↓")} scroll line ${blue("space/b")} page down/up`);
34
+ console.log(` ${blue("d/u")} half page ${blue("g/G")} top/bottom`);
35
+ console.log(` ${blue("q")} quit\n`);
16
36
  process.exit(0);
17
37
  }
18
38
 
19
39
  if (args[0] === "--version" || args[0] === "-v") {
20
- const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
21
- console.log(pkg.version);
40
+ console.log(`${CAT} ${bold("mdcat")} ${dim(`v${pkg.version}`)}`);
22
41
  process.exit(0);
23
42
  }
24
43
 
25
- const MAX_COLS = 100; // browser-like reading width cap
44
+ const MAX_COLS = 100;
45
+
46
+ function openInBrowser(title, content) {
47
+ const html = marked.parse(content);
48
+ const page = `<!DOCTYPE html>
49
+ <html lang="en">
50
+ <head>
51
+ <meta charset="UTF-8">
52
+ <meta name="viewport" content="width=device-width, initial-scale=1">
53
+ <title>${title}</title>
54
+ <style>
55
+ * { box-sizing: border-box; margin: 0; padding: 0; }
56
+ body {
57
+ background: #282c34;
58
+ color: #abb2bf;
59
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
60
+ font-size: 16px;
61
+ line-height: 1.7;
62
+ padding: 2rem 1rem;
63
+ }
64
+ .wrap { max-width: 760px; margin: 0 auto; }
65
+ h1, h2, h3, h4, h5, h6 { color: #e5c07b; margin: 1.5rem 0 0.5rem; font-weight: 700; }
66
+ h1 { font-size: 2rem; color: #c678dd; border-bottom: 2px solid #3e4451; padding-bottom: 0.4rem; }
67
+ h2 { font-size: 1.4rem; color: #61afef; border-bottom: 1px solid #3e4451; padding-bottom: 0.3rem; }
68
+ h3 { font-size: 1.15rem; color: #98c379; }
69
+ p { margin: 0.75rem 0; }
70
+ a { color: #61afef; text-decoration: underline; }
71
+ code { background: #2c313a; color: #e5c07b; padding: 0.15em 0.4em; border-radius: 4px; font-size: 0.9em; font-family: 'JetBrains Mono', 'Fira Code', monospace; }
72
+ pre { background: #21252b; border: 1px solid #3e4451; border-radius: 8px; padding: 1rem 1.25rem; overflow-x: auto; margin: 1rem 0; }
73
+ pre code { background: none; color: #abb2bf; padding: 0; font-size: 0.875rem; }
74
+ blockquote { border-left: 3px solid #e5c07b; padding: 0.5rem 1rem; margin: 1rem 0; color: #5c6370; font-style: italic; }
75
+ ul, ol { padding-left: 1.5rem; margin: 0.75rem 0; }
76
+ li { margin: 0.25rem 0; }
77
+ table { border-collapse: collapse; width: 100%; margin: 1rem 0; }
78
+ th { background: #21252b; color: #61afef; padding: 0.5rem 0.75rem; border: 1px solid #3e4451; text-align: left; }
79
+ td { padding: 0.5rem 0.75rem; border: 1px solid #3e4451; }
80
+ tr:nth-child(even) { background: #2c313a; }
81
+ hr { border: none; border-top: 1px solid #3e4451; margin: 1.5rem 0; }
82
+ img { max-width: 100%; border-radius: 6px; }
83
+ </style>
84
+ </head>
85
+ <body><div class="wrap">${html}</div></body>
86
+ </html>`;
26
87
 
27
- function run(title, content) {
88
+ const tmp = `${tmpdir()}/mdcat-${Date.now()}.html`;
89
+ writeFileSync(tmp, page);
90
+
91
+ const opener = process.platform === "darwin" ? "open"
92
+ : process.platform === "win32" ? "start"
93
+ : "xdg-open";
94
+ try { execFileSync(opener, [tmp]); }
95
+ catch { console.error(`mdcat: could not open browser`); process.exit(1); }
96
+ }
97
+
98
+ function runTUI(title, content) {
28
99
  const cols = Math.min(process.stdout.columns || 80, MAX_COLS);
29
100
  const tokens = marked.lexer(content);
30
101
  const lines = renderTokens(tokens, cols);
31
102
  launch(title, lines);
32
103
  }
33
104
 
34
- // Piped input: cat file.md | mdcat
35
- if (!process.stdin.isTTY && args.length === 0) {
105
+ // --web flag
106
+ const webMode = args[0] === "--web" || args[0] === "-w";
107
+ const fileArgs = webMode ? args.slice(1) : args;
108
+
109
+ // Piped input
110
+ if (!process.stdin.isTTY && fileArgs.length === 0) {
36
111
  let input = "";
37
112
  process.stdin.setEncoding("utf8");
38
113
  process.stdin.on("data", (chunk) => (input += chunk));
39
- process.stdin.on("end", () => run("stdin", input));
40
- } else if (args.length === 0) {
114
+ process.stdin.on("end", () => webMode ? openInBrowser("stdin", input) : runTUI("stdin", input));
115
+ } else if (fileArgs.length === 0) {
41
116
  console.error("Usage: mdcat <file.md>");
42
117
  process.exit(1);
43
118
  } else {
44
- const filePath = resolve(args[0]);
119
+ const filePath = resolve(fileArgs[0]);
45
120
  let content;
46
121
  try {
47
122
  content = readFileSync(filePath, "utf8");
48
123
  } catch (err) {
49
- console.error(`mdcat: ${args[0]}: ${err.code === "ENOENT" ? "No such file" : err.message}`);
124
+ console.error(`mdcat: ${fileArgs[0]}: ${err.code === "ENOENT" ? "No such file" : err.message}`);
50
125
  process.exit(1);
51
126
  }
52
- run(basename(filePath), content);
127
+ const title = basename(filePath);
128
+ webMode ? openInBrowser(title, content) : runTUI(title, content);
53
129
  }
package/src/tui.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { execFileSync } from "child_process";
2
+
1
3
  const ESC = "\x1B";
2
4
  const ALT_ON = `${ESC}[?1049h`;
3
5
  const ALT_OFF = `${ESC}[?1049l`;
@@ -99,6 +101,15 @@ function highlightInLine(line, query, isCurrent) {
99
101
  return out;
100
102
  }
101
103
 
104
+ function copyText(text) {
105
+ // OSC 52 — works in iTerm2, Kitty, WezTerm, tmux (with allow-passthrough)
106
+ const b64 = Buffer.from(text).toString("base64");
107
+ process.stdout.write(`${ESC}]52;c;${b64}${ESC}\\`);
108
+ // pbcopy fallback for macOS
109
+ try { execFileSync("pbcopy", [], { input: text, stdio: ["pipe", "ignore", "ignore"] }); }
110
+ catch { /* not on macOS or pbcopy unavailable */ }
111
+ }
112
+
102
113
  export function launch(title, lines) {
103
114
  // Viewport state
104
115
  let offset = 0;
@@ -110,6 +121,11 @@ export function launch(title, lines) {
110
121
  let matchSet = new Set(); // for O(1) gutter lookup
111
122
  let matchIdx = 0;
112
123
 
124
+ // Mouse / clipboard state
125
+ let mouseEnabled = true;
126
+ let toast = ""; // brief status message
127
+ let toastTimer = null;
128
+
113
129
  const rows = () => process.stdout.rows || 24;
114
130
  const cols = () => process.stdout.columns || 80;
115
131
  const visible = () => Math.max(1, rows() - 2); // top bar + bottom bar
@@ -174,14 +190,12 @@ export function launch(title, lines) {
174
190
  // ─── Chrome helpers ────────────────────────────────────────────────────────
175
191
 
176
192
  function topBar(title, w) {
177
- const ext = /\.(md|markdown|mdx)$/i.test(title) ? "md" : "~";
178
- const badge = `${C.badge}[${ext}]${RESET}${C.chromeBg}`;
179
- const left = ` ${badge} ${C.bold}${C.titleFg}${title}${RESET}${C.chromeBg}`;
180
- const right = `${C.dimFg}mdcat ${RESET}`;
181
- const leftW = 1 + ext.length + 2 + 1 + title.length + 1;
182
- const rightW = "mdcat ".length;
193
+ const left = ` ${C.bold}${C.titleFg}${title}${RESET}${C.chromeBg}`;
194
+ const cat = `${C.dimFg}/\\${RESET}${C.chromeBg}${C.accentFg}(o.o)${RESET}${C.chromeBg}${C.dimFg}/\\ mdcat ${RESET}`;
195
+ const leftW = 1 + title.length;
196
+ const rightW = "/\\(o.o)/\\ mdcat ".length;
183
197
  const gap = Math.max(0, w - leftW - rightW);
184
- return `${C.chromeBg}${left}${C.dimFg}${" ".repeat(gap)}${right}${RESET}`;
198
+ return `${C.chromeBg}${left}${C.dimFg}${" ".repeat(gap)}${cat}${RESET}`;
185
199
  }
186
200
 
187
201
  function gutterFor(absLine) {
@@ -223,12 +237,32 @@ export function launch(title, lines) {
223
237
  // Normal mode
224
238
  const end = Math.min(offset + visible(), lines.length);
225
239
  const pct = lines.length === 0 ? "100%" : Math.round((end / lines.length) * 100) + "%";
226
- const hints = `${C.dim} q / j k ↑↓ space g G${RESET}`;
227
- const right = `${C.dimFg} ${pct} ${RESET}`;
228
- const hintsW = " q / j k ↑↓ space g G".length;
240
+
241
+ if (toast) {
242
+ const toastStr = `${C.greenFg} ${toast}${RESET}`;
243
+ const right = `${C.dimFg} ${pct} ${RESET}`;
244
+ const rightW = ` ${pct} `.length;
245
+ const gap = Math.max(0, w - (3 + toast.length) - rightW);
246
+ return `${C.chromeBg}${toastStr}${" ".repeat(gap)}${right}${RESET}`;
247
+ }
248
+
249
+ const mouseHint = mouseEnabled ? "" : `${C.matchFg} [select mode]${RESET}`;
250
+ const mouseW = mouseEnabled ? 0 : " [select mode]".length;
251
+ const hints = `${C.dim} q y / j k ↑↓ space g G${RESET}`;
252
+ const right = `${C.dimFg} ${pct} ${RESET}`;
253
+ const hintsW = " q y / j k ↑↓ space g G".length;
229
254
  const rightW = ` ${pct} `.length;
230
- const gap = Math.max(0, w - hintsW - rightW);
231
- return `${C.chromeBg}${hints}${" ".repeat(gap)}${right}${RESET}`;
255
+ const gap = Math.max(0, w - hintsW - mouseW - rightW);
256
+ return `${C.chromeBg}${hints}${mouseHint}${" ".repeat(gap)}${right}${RESET}`;
257
+ }
258
+
259
+ // ─── Toast ─────────────────────────────────────────────────────────────────
260
+
261
+ function showToast(msg, ms = 1500) {
262
+ toast = msg;
263
+ draw();
264
+ if (toastTimer) clearTimeout(toastTimer);
265
+ toastTimer = setTimeout(() => { toast = ""; draw(); }, ms);
232
266
  }
233
267
 
234
268
  // ─── Cleanup ───────────────────────────────────────────────────────────────
@@ -302,6 +336,17 @@ export function launch(title, lines) {
302
336
  case "g": offset = 0; break;
303
337
  case "G": offset = maxOff(); break;
304
338
 
339
+ case "y": {
340
+ const text = lines.slice(offset, offset + visible()).map(plain).join("\n");
341
+ copyText(text);
342
+ showToast("Copied to clipboard"); return;
343
+ }
344
+
345
+ case "M":
346
+ mouseEnabled = !mouseEnabled;
347
+ process.stdout.write(mouseEnabled ? MOUSE_ON : MOUSE_OFF);
348
+ showToast(mouseEnabled ? "Mouse scroll on" : "Mouse off — select text freely"); return;
349
+
305
350
  case "/": case "\x06": // Ctrl+F
306
351
  mode = "search"; searchQuery = ""; matchLines = []; matchSet = new Set();
307
352
  draw(); return;