@carlesandres/house 0.4.1 → 0.4.3

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
@@ -6,6 +6,40 @@ The publish workflow (`.github/workflows/publish.yml`) runs on the `release: pub
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.3] — 2026-05-23
10
+
11
+ ### Fixed
12
+
13
+ - Dependency alignment: bumped `effect` to `4.0.0-beta.70` to match `@effect/atom-react@4.0.0-beta.70`, removing npm `ERESOLVE overriding peer dependency` warnings during global installs.
14
+
15
+ ## [0.4.2] — 2026-05-23
16
+
17
+ ### Added
18
+
19
+ - Editor hand-off: press `e` to open the selected file in `$EDITOR`/`$VISUAL` (`#19`).
20
+ - Sidebar rows now render basename-first with segment-aware parent-path elision, improving scanability in deep trees.
21
+ - `ctrl+\\` clears an active sidebar filter query without leaving filter mode.
22
+
23
+ ### Changed
24
+
25
+ - Sidebar filter input and command palette input now share a single `PromptRow` UI path for consistent rendering and behavior.
26
+
27
+ ### Fixed
28
+
29
+ - Dev server SSE stream (`/__reload`) now stays open past Bun's 10s idle timeout.
30
+ - Filter Escape behavior no longer reverts to the pre-filter selection; Escape now closes/clears filter predictably.
31
+
32
+ ### Docs
33
+
34
+ - README synced with current features, options, keys, and config behavior.
35
+ - Windows support is now documented as POSIX-only for this release line, with tracking issues linked.
36
+ - ROADMAP docs updated for release tooling and standalone-binary epic breakdown.
37
+
38
+ ### Tests
39
+
40
+ - Added PTY regression coverage for selection background behavior.
41
+ - Added sidebar coverage for basename+parent rendering and `fitTail` behavior at very narrow widths.
42
+
9
43
  ## [0.4.1] — 2026-05-22
10
44
 
11
45
  ### Added
@@ -139,4 +173,13 @@ The v1 MVP, published as `@carlesandres/openmdr` on npm.
139
173
 
140
174
  Search, stdin, URL fetching, cross-file link following, `$EDITOR` hand-off, syntax highlighting, persistent config, OS-appearance auto-detect, single-binary distribution (issue [#2](https://github.com/carlesandres/openmdr/issues/2)), Homebrew tap. All tracked.
141
175
 
142
- [Unreleased]: https://github.com/carlesandres/house/compare/v0.4.1...HEAD [0.4.1]: https://github.com/carlesandres/house/compare/v0.4.0...v0.4.1 [0.4.0]: https://github.com/carlesandres/house/compare/v0.3.1...v0.4.0 [0.3.1]: https://github.com/carlesandres/house/compare/v0.3.0...v0.3.1 [0.3.0]: https://github.com/carlesandres/house/compare/v0.2.1...v0.3.0 [0.2.1]: https://github.com/carlesandres/house/compare/v0.2.0...v0.2.1 [0.2.0]: https://github.com/carlesandres/house/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/carlesandres/house/releases/tag/v0.1.0
176
+ [Unreleased]: https://github.com/carlesandres/house/compare/v0.4.3...HEAD
177
+ [0.4.3]: https://github.com/carlesandres/house/compare/v0.4.2...v0.4.3
178
+ [0.4.2]: https://github.com/carlesandres/house/compare/v0.4.1...v0.4.2
179
+ [0.4.1]: https://github.com/carlesandres/house/compare/v0.4.0...v0.4.1
180
+ [0.4.0]: https://github.com/carlesandres/house/compare/v0.3.1...v0.4.0
181
+ [0.3.1]: https://github.com/carlesandres/house/compare/v0.3.0...v0.3.1
182
+ [0.3.0]: https://github.com/carlesandres/house/compare/v0.2.1...v0.3.0
183
+ [0.2.1]: https://github.com/carlesandres/house/compare/v0.2.0...v0.2.1
184
+ [0.2.0]: https://github.com/carlesandres/house/compare/v0.1.0...v0.2.0
185
+ [0.1.0]: https://github.com/carlesandres/house/releases/tag/v0.1.0
package/README.md CHANGED
@@ -12,8 +12,9 @@ A terminal markdown reader and navigator — themable and configurable, with a k
12
12
  - **Command palette**
13
13
  - **Keyboard-driven**
14
14
  - **Open in browser**
15
+ - **Open in `$EDITOR`**
15
16
 
16
- Requires [Bun](https://bun.sh) on `PATH`.
17
+ Requires [Bun](https://bun.sh) on `PATH`. Supported on macOS and Linux; Windows is unsupported and unvalidated (see [#129](https://github.com/carlesandres/house/issues/129)).
17
18
 
18
19
  ## Install
19
20
 
@@ -37,7 +38,7 @@ bun add -g @carlesandres/house
37
38
  house [options] <path>
38
39
  ```
39
40
 
40
- `<path>` can be a directory (walks for `.md` files) or a single `.md` file.
41
+ `<path>` can be a directory (walks for `.md`, `.markdown`, and `.mdx` files) or a single markdown file. Defaults to the current directory if omitted.
41
42
 
42
43
  ### Options
43
44
 
@@ -45,7 +46,17 @@ house [options] <path>
45
46
  |------|---------|-------------|
46
47
  | `--theme <name>` | `opencode` | Starting theme (see list below) |
47
48
  | `--tone dark\|light` | `dark` | Starting tone |
49
+ | `--width <N>` | — | Cap rendered markdown width at N columns |
50
+ | `--all` | off | Include hidden and gitignored files in discovery |
51
+ | `--sort <mode>` | `dirs-first` | Sidebar order: `dirs-first` or `files-first` |
52
+ | `--sidebar <mode>` | `auto` | Initial sidebar visibility: `auto`, `on`, or `off` |
53
+ | `--serve` | off | Serve the given file as HTML in the browser (skips TUI) |
54
+ | `--port <N>` | OS-assigned | Port for `--serve` |
55
+ | `--no-mdx` | off | Exclude `.mdx` files from discovery |
56
+ | `--no-update-check` | off | Suppress the "newer version available" check (also via `NO_UPDATE_NOTIFIER=1`) |
48
57
  | `--config-path` | — | Print the resolved config-file path and exit |
58
+ | `-h`, `--help` | — | Show help and exit |
59
+ | `-v`, `--version` | — | Print version and exit |
49
60
 
50
61
  ## Configuration
51
62
 
@@ -61,14 +72,17 @@ Run `house --config-path` to print the exact location.
61
72
  # ~/.config/house/config.toml
62
73
  theme = "tokyonight"
63
74
  tone = "dark"
75
+ mdx = true
64
76
  ```
65
77
 
78
+ Supported keys: `theme`, `tone`, `mdx`.
79
+
66
80
  Precedence, highest to lowest:
67
81
 
68
- 1. CLI flags (`--theme`, `--tone`)
69
- 2. Env vars (`HOUSE_THEME`, `HOUSE_TONE`)
82
+ 1. CLI flags (`--theme`, `--tone`, `--no-mdx`)
83
+ 2. Env vars (`HOUSE_THEME`, `HOUSE_TONE`, `HOUSE_MDX`)
70
84
  3. Config file
71
- 4. Built-in defaults (`opencode` / `dark`)
85
+ 4. Built-in defaults (`opencode` / `dark` / `mdx = true`)
72
86
 
73
87
  The file is optional — a missing file is fine. Invalid keys, unknown themes, or malformed TOML fail loudly with a one-line error. Per-project config (`.house/config.toml`) and additional keys are deferred.
74
88
 
@@ -82,7 +96,9 @@ The file is optional — a missing file is fine. Invalid keys, unknown themes, o
82
96
  | `tab` | Toggle focus (sidebar ↔ reader) |
83
97
  | `s` | Toggle sidebar visibility |
84
98
  | `?` | Show / dismiss help overlay |
99
+ | `ctrl+p` | Command palette |
85
100
  | `o` | Open current file in browser as HTML |
101
+ | `e` | Open current file in `$EDITOR` (`$VISUAL` takes precedence) |
86
102
  | `t` | Next theme |
87
103
  | `T` | Previous theme |
88
104
  | `L` | Toggle dark / light tone |
@@ -123,6 +139,7 @@ Each theme supports dark and light tones. Cycle with `t` / `T`; toggle tone with
123
139
  - [glow](https://github.com/charmbracelet/glow) — render markdown on the CLI, with pizzazz
124
140
  - [ghui](https://github.com/kitlangton/ghui) — keyboard-driven terminal UI for GitHub pull requests
125
141
  - [hunk](https://github.com/modem-dev/hunk) — review-first terminal diff viewer for agent-authored changesets
142
+ - [opencode](https://github.com/anomalyco/opencode) — terminal UI whose palette inspired house's themes
126
143
 
127
144
  ## License
128
145
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carlesandres/house",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "TUI-first markdown reader on opentui",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -44,26 +44,28 @@
44
44
  "format": "oxfmt src/",
45
45
  "format:check": "oxfmt --check src/",
46
46
  "test": "bun test",
47
+ "test:pty": "HOUSE_PTY=1 bun test test/pty/",
47
48
  "build:themes": "bun run dev/build-themes.ts",
48
49
  "repro:pty-codeblocks": "bun run dev/repro-pty-codeblocks.ts",
49
50
  "prepare": "git config core.hooksPath .githooks 2>/dev/null || true"
50
51
  },
51
52
  "dependencies": {
52
- "@effect/atom-react": "4.0.0-beta.59",
53
- "@opentui/core": "0.2.12",
54
- "@opentui/react": "0.2.12",
55
- "effect": "4.0.0-beta.59",
53
+ "@effect/atom-react": "4.0.0-beta.70",
54
+ "@opentui/core": "0.2.15",
55
+ "@opentui/react": "0.2.15",
56
+ "effect": "4.0.0-beta.70",
56
57
  "ignore": "^7.0.5",
57
- "marked": "^18.0.3",
58
- "react": "19.2.5",
58
+ "marked": "^18.0.4",
59
+ "react": "19.2.6",
59
60
  "scheduler": "0.27.0"
60
61
  },
61
62
  "devDependencies": {
62
- "@effect/language-service": "0.85.1",
63
- "@types/bun": "1.3.12",
64
- "@types/react": "19.2.14",
65
- "oxfmt": "0.47.0",
66
- "oxlint": "1.62.0",
67
- "typescript": "6.0.2"
63
+ "@effect/language-service": "0.86.2",
64
+ "@types/bun": "1.3.14",
65
+ "@types/react": "19.2.15",
66
+ "oxfmt": "0.51.0",
67
+ "oxlint": "1.66.0",
68
+ "tuistory": "^0.8.0",
69
+ "typescript": "6.0.3"
68
70
  }
69
71
  }
package/src/Browser.tsx CHANGED
@@ -25,6 +25,7 @@ import { BRAND, BRAND_NAME } from "./brand.ts"
25
25
  import { Footer, FOOTER_HEIGHT } from "./Footer.tsx"
26
26
  import { Header, HEADER_HEIGHT } from "./Header.tsx"
27
27
  import { HelpOverlay } from "./HelpOverlay.tsx"
28
+ import { openInEditor, resolveEditor } from "./io/editor.ts"
28
29
  import { readFileText } from "./io/readFile.ts"
29
30
  import { browserBindings, type BrowserCtx } from "./keymap/browser.ts"
30
31
  import { dispatch } from "./keymap/keymap.ts"
@@ -34,6 +35,8 @@ import {
34
35
  initialShownForAuto,
35
36
  resolveSidebarWidth,
36
37
  } from "./layout/resolve.ts"
38
+ import { formatSidebarRow } from "./layout/sidebarRow.ts"
39
+ import { PromptRow } from "./PromptRow.tsx"
37
40
  import { openInBrowser } from "./serve/openBrowser.ts"
38
41
  import { startServer, type ServerHandle } from "./serve/server.ts"
39
42
  import { colors, setActiveTheme } from "./theme/colors.ts"
@@ -141,9 +144,6 @@ export const Browser = ({
141
144
  // otherwise still observe filterOpen=false through closure).
142
145
  const filterOpenRef = useRef(false)
143
146
  const filterQueryRef = useRef("")
144
- // Snapshot the query at filter-open so Esc reverts edits but commit (Return)
145
- // keeps them.
146
- const priorFilterQueryRef = useRef("")
147
147
  const [footerNotice, setFooterNoticeState] = useState<{
148
148
  readonly text: string
149
149
  readonly ttlMs: number
@@ -261,10 +261,12 @@ export const Browser = ({
261
261
  // operate on what the user actually sees.
262
262
  const ctx: BrowserCtx = {
263
263
  files: displayedFiles,
264
+ hasSelected: selected != null,
264
265
  focus,
265
266
  sidebarShown: shown,
266
267
  helpVisible,
267
268
  filterOpen,
269
+ filterQuery,
268
270
  paletteOpen,
269
271
  setFocus,
270
272
  setSelectedIndex,
@@ -299,7 +301,17 @@ export const Browser = ({
299
301
  // the inline sidebar back on screen if it was hidden. In narrow,
300
302
  // focusing the sidebar swaps to the sidebar screen. Either way
301
303
  // no need to mutate `shown`.
302
- priorFilterQueryRef.current = filterQueryRef.current
304
+ if (focus !== "sidebar") setFocus("sidebar")
305
+ filterOpenRef.current = true
306
+ setFilterOpen(true)
307
+ },
308
+ clearAndOpenFilter: () => {
309
+ // Reset both the ref and the state so the freshly-opened modal
310
+ // shows an empty input and selection lands on the first file in
311
+ // the (now unfiltered) list.
312
+ filterQueryRef.current = ""
313
+ setFilterQuery("")
314
+ setSelectedIndex(() => 0)
303
315
  if (focus !== "sidebar") setFocus("sidebar")
304
316
  filterOpenRef.current = true
305
317
  setFilterOpen(true)
@@ -351,65 +363,96 @@ export const Browser = ({
351
363
  renderer?.destroy()
352
364
  process.exit(0)
353
365
  },
366
+ editCurrent: () => {
367
+ const file = displayedFiles[selectedIndex]
368
+ if (!file) return
369
+ const editor = resolveEditor(process.env)
370
+ if (!editor) {
371
+ pushFooterNotice("set $EDITOR or $VISUAL to use e")
372
+ return
373
+ }
374
+ if (!renderer) {
375
+ // Test environments without a real renderer (e.g. testRender's
376
+ // host) don't expose suspend/resume. Nothing safe to do here.
377
+ pushFooterNotice("editor unavailable in this environment")
378
+ return
379
+ }
380
+ // Fire-and-forget: useKeyboard's run() is synchronous, but the
381
+ // editor session is naturally async. We always re-enter the
382
+ // renderer in the finally block so a thrown error never leaves
383
+ // the user staring at a dead terminal.
384
+ void (async () => {
385
+ renderer.suspend()
386
+ renderer.currentRenderBuffer.clear()
387
+ let result
388
+ try {
389
+ result = await openInEditor({ editor, filePath: file.path })
390
+ } finally {
391
+ renderer.currentRenderBuffer.clear()
392
+ renderer.resume()
393
+ renderer.requestRender()
394
+ }
395
+ // Only reload the in-memory cache when the edited file is the
396
+ // one currently displayed. Editing a sidebar-selected file that
397
+ // the reader hasn't caught up to (debounce in flight) is fine —
398
+ // the regular load path picks up the new mtime when renderedPath
399
+ // advances.
400
+ if (file.path === renderedPath) {
401
+ try {
402
+ const text = await readFile(file.path)
403
+ setLoaded({ path: file.path, content: text })
404
+ setError(null)
405
+ } catch (err) {
406
+ const message = String(err)
407
+ const enoent =
408
+ (err as { code?: string } | null)?.code === "ENOENT" || message.includes("ENOENT")
409
+ if (enoent) {
410
+ pushFooterNotice(`${file.relativePath} no longer exists`)
411
+ setError(`Cannot read ${file.path}: ${message}`)
412
+ setLoaded(null)
413
+ } else {
414
+ pushFooterNotice(`reload failed: ${message}`)
415
+ }
416
+ }
417
+ }
418
+ if (!result.ok) {
419
+ if (result.reason === "spawn-failed") {
420
+ pushFooterNotice(`editor not found: ${editor.cmd}`)
421
+ } else if (result.reason === "non-zero") {
422
+ pushFooterNotice(`editor exited ${result.detail}`)
423
+ }
424
+ }
425
+ })()
426
+ },
354
427
  }
355
428
 
356
429
  useKeyboard((key) => {
357
- // Filter modal: capture keystrokes for the input. Esc closes and
358
- // clears; Return closes, clears, and focuses the reader (open the
359
- // match); Backspace edits; Up/Down navigate the filtered list;
360
- // printable characters extend the query and reset selection to 0.
361
- // Everything else is swallowed so normal bindings (j/k as nav,
362
- // `s`, `t`, …) don't fire while the user is typing. This sits
363
- // outside the data-driven keymap for the same reason the help
364
- // branch does see DESIGN.md §12.
430
+ // Filter modal: capture keystrokes for the input. Esc closes,
431
+ // leaving the typed query applied as the active filter; Return
432
+ // closes and focuses the reader (open the match); Ctrl+\ clears
433
+ // the input but stays in filter mode (same binding used from
434
+ // outside the modal single chord, single mental model. Ctrl+U
435
+ // is deliberately not overloaded here; it stays reserved for its
436
+ // sidebar/reader half-page-up role); Backspace edits; Up/Down
437
+ // navigate the filtered list; printable characters extend the
438
+ // query and reset selection to 0. Everything else is swallowed
439
+ // so normal bindings (j/k as nav, `s`, `t`, …) don't fire while
440
+ // the user is typing. This sits outside the data-driven keymap
441
+ // for the same reason the help branch does — see DESIGN.md §12.
365
442
  if (filterOpenRef.current) {
366
- // One close path used by both Esc and Return. Closing the filter
367
- // restores the full list; translating the highlighted match to
368
- // its index in `files` keeps the cursor on whatever the user was
369
- // looking at when they hit the key, instead of landing on a
370
- // random file at the same numeric position in a now-different
371
- // list. `focusReader=true` is the Return semantic (open the
372
- // match); false is Esc (cancel, stay in sidebar).
373
- //
374
- // Centralized so the dual filterOpenRef / filterOpen invariant
375
- // only has to be maintained in one place (plus `openFilter`).
443
+ // One close path used by both Esc and Return. `commit=true` is
444
+ // the Return semantic (open the match in the reader); false is
445
+ // Esc (stop typing, keep the applied filter, stay in sidebar).
376
446
  const closeFilter = (commit: boolean) => {
377
447
  const picked = displayedFiles[selectedIndex] ?? null
378
- // Return on a zero-match list has nothing to commit. Treat it
379
- // as Esc so the user isn't stranded in an "applied filter with
380
- // no visible files" state they'd have to back out of manually.
381
448
  const effectiveCommit = commit && picked !== null
382
449
  filterOpenRef.current = false
383
450
  setFilterOpen(false)
384
- if (effectiveCommit) {
385
- // Return keeps the query. selectedIndex is already a valid
386
- // position in the (still-filtered) displayedFiles list, so
387
- // no translation is needed.
388
- } else {
389
- // Esc reverts the query to its pre-session value. After the
390
- // revert, displayedFiles may change shape — translate the
391
- // cursor by path so it stays on whatever the user was
392
- // looking at, instead of snapping to a numerically-equivalent
393
- // row in the restored list.
394
- const before = priorFilterQueryRef.current
395
- filterQueryRef.current = before
396
- setFilterQuery(before)
397
- if (picked) {
398
- const restored = before === "" ? files : filterFiles(files, before)
399
- const idx = restored.findIndex((f) => f.path === picked.path)
400
- if (idx >= 0) setSelectedIndex(() => idx)
401
- }
402
- }
403
451
  // Where focus lands after the filter closes:
404
- // commit (Return on a real pick) → reader, always. The user
405
- // asked to open the match; show them what they picked.
406
- // cancel (Esc, or Return with no pick) restore the user's
407
- // pre-filter intent. If the sidebar was up before the
408
- // filter opened (shown=true), stay in it so j/k keeps
409
- // walking. If the sidebar was hidden (shown=false), go
410
- // back to the reader so the sidebar dismisses — in narrow
411
- // that swaps screens; in wide that drops the focus-driven
412
- // sidebar revival.
452
+ // commit (Return on a real pick) → reader. The user asked
453
+ // to open the match; show them what they picked.
454
+ // otherwise sidebar if it's up so j/k keeps walking the
455
+ // filtered list; reader if the sidebar was hidden.
413
456
  if (effectiveCommit) {
414
457
  setFocus("reader")
415
458
  } else {
@@ -424,11 +467,19 @@ export const Browser = ({
424
467
  closeFilter(true)
425
468
  return
426
469
  }
470
+ if (key.ctrl && key.name === "\\") {
471
+ // Same action as the `filter.clearOrOpen` binding fires from
472
+ // outside the modal: clear the query, reset selection. The
473
+ // keymap doesn't see keys in filter mode, so this branch is
474
+ // the in-modal half of that single chord.
475
+ filterQueryRef.current = ""
476
+ setFilterQuery("")
477
+ setSelectedIndex(() => 0)
478
+ return
479
+ }
427
480
  if (key.name === "backspace" || key.name === "delete") {
428
- // Pressing backspace/delete with no query left removes the
429
- // leading `/` i.e. closes the modal. Equivalent to Esc:
430
- // reverts to the pre-session query (so an applied filter
431
- // survives a "I changed my mind" tap).
481
+ // Backspace on empty input closes the modal the leading `/`
482
+ // chevron is the last thing left to "delete."
432
483
  if (filterQueryRef.current.length === 0) {
433
484
  closeFilter(false)
434
485
  return
@@ -602,38 +653,11 @@ export const Browser = ({
602
653
  // budget is the viewport minus the 1-cell left padding only.
603
654
  const sidebarPaneWidth = isNarrow ? width : sidebarWidth
604
655
  const sidebarTextWidth = Math.max(4, sidebarPaneWidth - (isNarrow ? 1 : 2))
605
- // Right-anchored truncation: keep the filename visible, lose the prefix
606
- // with a leading ellipsis when the path is too long.
607
- const truncatePath = useCallback(
608
- (s: string): string =>
609
- s.length <= sidebarTextWidth ? s : "…" + s.slice(s.length - sidebarTextWidth + 1),
656
+ const layoutSidebarRow = useCallback(
657
+ (relativePath: string) => formatSidebarRow(relativePath, sidebarTextWidth),
610
658
  [sidebarTextWidth],
611
659
  )
612
660
 
613
- // Filter row content + color. Three reachable states:
614
- // editing — filterOpen=true → /<query>▏ in textStrong
615
- // applied — !filterOpen && query !== "" → /<query> in text
616
- // idle — !filterOpen && query === "" → "/ filter…" in textMuted
617
- const filterRowFg = filterOpen
618
- ? colors.textStrong
619
- : filterQuery.length > 0
620
- ? colors.text
621
- : colors.textMuted
622
- const filterRowRaw = filterOpen
623
- ? `/${filterQuery}▏`
624
- : filterQuery.length > 0
625
- ? `/${filterQuery}`
626
- : "/ filter…"
627
- // Editing keeps the cursor visible — anchor the right edge with a leading
628
- // ellipsis when the query overflows. Applied/idle anchor the left edge
629
- // (lose the tail) so the leading `/` always reads as a filter marker.
630
- const filterRowContent =
631
- filterRowRaw.length <= sidebarTextWidth
632
- ? filterRowRaw
633
- : filterOpen
634
- ? "…" + filterRowRaw.slice(filterRowRaw.length - sidebarTextWidth + 1)
635
- : filterRowRaw.slice(0, sidebarTextWidth - 1) + "…"
636
-
637
661
  // While help is open, the `?` key closes the overlay — relabel its hint
638
662
  // so the footer accurately describes what pressing the key will do.
639
663
  // Memoized: `helpVisible` changes rarely; `browserBindings` and
@@ -653,7 +677,12 @@ export const Browser = ({
653
677
  const sidebarBody = (
654
678
  <>
655
679
  {filterRowVisible && (
656
- <text content={filterRowContent} wrapMode="none" style={{ fg: filterRowFg }} />
680
+ <PromptRow
681
+ query={filterQuery}
682
+ editing={filterOpen}
683
+ placeholder="/ to filter…"
684
+ width={sidebarTextWidth}
685
+ />
657
686
  )}
658
687
  {displayedFiles.length === 0 ? (
659
688
  <text
@@ -670,20 +699,18 @@ export const Browser = ({
670
699
  visibleFiles.map((file, idx) => {
671
700
  const realIdx = desiredScroll + idx
672
701
  const isSelected = realIdx === selectedIndex
673
- const display = truncatePath(file.relativePath)
674
- if (!isSelected) {
675
- return (
676
- <text key={file.path} content={display} wrapMode="none" style={{ fg: colors.text }} />
677
- )
678
- }
679
- const bg = sidebarActive ? colors.selectedBg : colors.selectedBgInactive
702
+ const { basename, separator, parent } = layoutSidebarRow(file.relativePath)
703
+ const basenameFg = isSelected ? colors.textStrong : colors.text
704
+ const rowStyle = isSelected
705
+ ? { bg: sidebarActive ? colors.selectedBg : colors.selectedBgInactive }
706
+ : {}
680
707
  return (
681
- <text
682
- key={file.path}
683
- content={display}
684
- wrapMode="none"
685
- style={{ fg: colors.textStrong, bg }}
686
- />
708
+ <text key={file.path} wrapMode="none" style={rowStyle}>
709
+ <span style={{ fg: basenameFg }}>{basename}</span>
710
+ {parent !== "" && (
711
+ <span style={{ fg: colors.textMuted }}>{`${separator}${parent}`}</span>
712
+ )}
713
+ </text>
687
714
  )
688
715
  })
689
716
  )}
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { RGBA } from "@opentui/core"
13
13
  import { colors } from "./theme/colors.ts"
14
+ import { PromptRow } from "./PromptRow.tsx"
14
15
  import type { AppCommand } from "./commands/types.ts"
15
16
 
16
17
  // Semi-transparent black scrim painted across the viewport behind the modal.
@@ -104,11 +105,7 @@ export const CommandPalette = ({
104
105
  backgroundColor: colors.surface,
105
106
  }}
106
107
  >
107
- <text
108
- wrapMode="none"
109
- content={fit(`> ${query}▏`, rowWidth)}
110
- style={{ fg: colors.textStrong }}
111
- />
108
+ <PromptRow query={query} editing={true} width={rowWidth} />
112
109
  <text content=" " />
113
110
  {commands.length === 0 ? (
114
111
  <text wrapMode="none" content=" (no matches)" style={{ fg: colors.textMuted }} />
package/src/Footer.tsx CHANGED
@@ -138,7 +138,11 @@ export const Footer = <C,>({
138
138
  hints.push({ key: null, label: `[filter: ${filterQuery}]` })
139
139
  }
140
140
  for (const b of bindings) {
141
- if (b.when && !b.when(ctx)) continue
141
+ // Hint visibility prefers `hintWhen` (binding-specific) over `when`
142
+ // (dispatch gate). Falling back to `when` keeps the original
143
+ // "hint shows when binding is enabled" behavior for the common case.
144
+ const visibleGate = b.hintWhen ?? b.when
145
+ if (visibleGate && !visibleGate(ctx)) continue
142
146
  const h = formatHint(b)
143
147
  if (h !== null) hints.push(h)
144
148
  }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * PromptRow — single-line `> query` row shared by the sidebar filter and
3
+ * the command palette query input.
4
+ *
5
+ * Render-only: the parent owns query state and the focus/editing flag.
6
+ * The `> ` prefix always renders in `textStrong` regardless of state so it
7
+ * reads as chrome, not placeholder text — only the body span shifts color
8
+ * (textStrong while editing, text when applied, textMuted as placeholder).
9
+ *
10
+ * Overflow: when editing, an overflowing body anchors its right edge with a
11
+ * leading `…` so the cursor stays on screen; otherwise it anchors the left
12
+ * edge with a trailing `…`.
13
+ */
14
+
15
+ import { colors } from "./theme/colors.ts"
16
+
17
+ export interface PromptRowProps {
18
+ readonly query: string
19
+ /** True while the input is focused — shows a cursor and uses textStrong fg. */
20
+ readonly editing: boolean
21
+ /** Body fallback when !editing && query === "". Pass without the `> ` prefix. */
22
+ readonly placeholder?: string
23
+ /** Total cell width available for the row (prefix + body). */
24
+ readonly width: number
25
+ }
26
+
27
+ const PREFIX = "> "
28
+ const CURSOR = "▏"
29
+
30
+ export const PromptRow = ({ query, editing, placeholder = "", width }: PromptRowProps) => {
31
+ const bodyBudget = Math.max(1, width - PREFIX.length)
32
+
33
+ const rawBody = editing ? `${query}${CURSOR}` : query.length > 0 ? query : placeholder
34
+ const bodyFg = editing ? colors.textStrong : query.length > 0 ? colors.text : colors.textMuted
35
+
36
+ const body =
37
+ rawBody.length <= bodyBudget
38
+ ? rawBody
39
+ : editing
40
+ ? "…" + rawBody.slice(rawBody.length - bodyBudget + 1)
41
+ : rawBody.slice(0, bodyBudget - 1) + "…"
42
+
43
+ return (
44
+ <text wrapMode="none">
45
+ <span style={{ fg: colors.textStrong }}>{PREFIX}</span>
46
+ <span style={{ fg: bodyFg }}>{body}</span>
47
+ </text>
48
+ )
49
+ }
@@ -45,6 +45,7 @@ const annotations: Record<string, Annotation> = {
45
45
  "help.toggle": { title: "Show help", category: "App" },
46
46
  "filter.open": { title: "Filter files…", category: "Navigation" },
47
47
  "serve.current": { title: "Open in browser", category: "File" },
48
+ "file.edit": { title: "Open in editor", category: "File", keywords: ["editor", "vim", "vscode"] },
48
49
  "theme.next": { category: "Appearance" },
49
50
  "theme.prev": { category: "Appearance" },
50
51
  "theme.toneToggle": { title: "Toggle dark/light tone", category: "Appearance" },
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Resolve `$VISUAL` / `$EDITOR` into a spawnable `{ cmd, args }`.
3
+ *
4
+ * The string is POSIX shell-split so users with values like
5
+ * `code --wait` or `"/Applications/Sublime Text/subl" --wait` get the
6
+ * expected argv. We deliberately do *not* shell out via `sh -c`: the
7
+ * caller appends the file path as a separate argv element, which avoids
8
+ * command-injection risk for paths containing shell metacharacters.
9
+ *
10
+ * Resolution order: `$VISUAL` → `$EDITOR` → `null`. No silent fallback to
11
+ * `vi`; the caller surfaces a footer notice when neither is set.
12
+ */
13
+
14
+ export interface ResolvedEditor {
15
+ readonly cmd: string
16
+ readonly args: readonly string[]
17
+ }
18
+
19
+ /**
20
+ * Split a command string the way `sh` would for the simple cases users
21
+ * actually put in `$EDITOR`: single quotes (literal), double quotes
22
+ * (with `\"`, `\\` escapes), backslash escapes outside quotes, and
23
+ * whitespace separation. No variable expansion, no globbing, no command
24
+ * substitution — by design. If the input is unbalanced (an unclosed
25
+ * quote), the partial token is emitted as-is so the caller's spawn
26
+ * surfaces a real error instead of us throwing.
27
+ */
28
+ export const splitEditorString = (input: string): string[] => {
29
+ const tokens: string[] = []
30
+ let buf = ""
31
+ let inSingle = false
32
+ let inDouble = false
33
+ let pendingToken = false
34
+
35
+ for (let i = 0; i < input.length; i++) {
36
+ const ch = input[i]!
37
+ if (inSingle) {
38
+ if (ch === "'") inSingle = false
39
+ else buf += ch
40
+ continue
41
+ }
42
+ if (inDouble) {
43
+ if (ch === "\\" && i + 1 < input.length) {
44
+ const next = input[i + 1]!
45
+ // In double quotes, `sh` only treats `\` as an escape before
46
+ // `$`, `` ` ``, `"`, `\`, or newline. Otherwise the backslash
47
+ // is literal. We collapse it for `"` and `\` (the cases users
48
+ // hit with Windows-style paths in double quotes) and keep it
49
+ // literal otherwise.
50
+ if (next === '"' || next === "\\") {
51
+ buf += next
52
+ i++
53
+ continue
54
+ }
55
+ }
56
+ if (ch === '"') {
57
+ inDouble = false
58
+ continue
59
+ }
60
+ buf += ch
61
+ continue
62
+ }
63
+ if (ch === "'") {
64
+ inSingle = true
65
+ pendingToken = true
66
+ continue
67
+ }
68
+ if (ch === '"') {
69
+ inDouble = true
70
+ pendingToken = true
71
+ continue
72
+ }
73
+ if (ch === "\\" && i + 1 < input.length) {
74
+ buf += input[i + 1]!
75
+ i++
76
+ pendingToken = true
77
+ continue
78
+ }
79
+ if (ch === " " || ch === "\t") {
80
+ if (pendingToken) {
81
+ tokens.push(buf)
82
+ buf = ""
83
+ pendingToken = false
84
+ }
85
+ continue
86
+ }
87
+ buf += ch
88
+ pendingToken = true
89
+ }
90
+ if (pendingToken) tokens.push(buf)
91
+ return tokens
92
+ }
93
+
94
+ /**
95
+ * Pick the user's editor from env vars. Returns `null` when neither
96
+ * `$VISUAL` nor `$EDITOR` is set to a non-empty, non-whitespace value.
97
+ *
98
+ * `env` is parameterised so tests don't need to mutate `process.env`.
99
+ */
100
+ export const resolveEditor = (
101
+ env: Readonly<Record<string, string | undefined>>,
102
+ ): ResolvedEditor | null => {
103
+ const raw = pickEnv(env["VISUAL"]) ?? pickEnv(env["EDITOR"])
104
+ if (raw == null) return null
105
+ const parts = splitEditorString(raw)
106
+ if (parts.length === 0) return null
107
+ const [cmd, ...args] = parts
108
+ if (!cmd) return null
109
+ return { cmd, args }
110
+ }
111
+
112
+ const pickEnv = (value: string | undefined): string | null => {
113
+ if (value == null) return null
114
+ const trimmed = value.trim()
115
+ return trimmed.length === 0 ? null : trimmed
116
+ }
117
+
118
+ /** Result of a launched editor session. Never throws — every failure is
119
+ * surfaced as a tagged value so the caller can decide UX. */
120
+ export type EditorRunResult =
121
+ | { readonly ok: true; readonly exitCode: number }
122
+ | {
123
+ readonly ok: false
124
+ readonly reason: "spawn-failed" | "non-zero"
125
+ readonly detail?: string
126
+ }
127
+
128
+ export interface OpenInEditorOptions {
129
+ readonly editor: ResolvedEditor
130
+ readonly filePath: string
131
+ }
132
+
133
+ /**
134
+ * Launch the resolved editor on `filePath`, inheriting stdio so the
135
+ * editor takes over the TTY. Caller is responsible for suspending /
136
+ * resuming the renderer around this call (see Browser.tsx).
137
+ *
138
+ * The file path is passed as a separate argv element — never interpolated
139
+ * into a shell string — so paths with shell metacharacters can't be
140
+ * misinterpreted.
141
+ *
142
+ * Windows is unsupported (see #129). The PATHEXT gap that breaks `.cmd`
143
+ * shims (`code.cmd`, `nvim.cmd`) is tracked specifically in #128.
144
+ */
145
+ export const openInEditor = async ({
146
+ editor,
147
+ filePath,
148
+ }: OpenInEditorOptions): Promise<EditorRunResult> => {
149
+ const argv = [editor.cmd, ...editor.args, filePath]
150
+ try {
151
+ const proc = Bun.spawn(argv, {
152
+ stdin: "inherit",
153
+ stdout: "inherit",
154
+ stderr: "inherit",
155
+ })
156
+ const exitCode = await proc.exited
157
+ if (exitCode === 0) return { ok: true, exitCode }
158
+ return { ok: false, reason: "non-zero", detail: String(exitCode) }
159
+ } catch (err) {
160
+ return { ok: false, reason: "spawn-failed", detail: String(err) }
161
+ }
162
+ }
@@ -10,11 +10,19 @@ export type BrowserFocus = "sidebar" | "reader"
10
10
 
11
11
  export interface BrowserCtx {
12
12
  readonly files: readonly FileEntry[]
13
+ /** True iff `files[selectedIndex]` resolves to an entry. The honest
14
+ * predicate for File-group actions (`o`, `e`, `[`, `]`): with debounced
15
+ * filter and sticky auto-select, `files.length > 0` can be true while
16
+ * `selectedIndex` is invalid for the displayed list. See #115. */
17
+ readonly hasSelected: boolean
13
18
  readonly focus: BrowserFocus
14
19
  /** User's sticky sidebar preference. Visibility is `shown || focus==="sidebar"`. */
15
20
  readonly sidebarShown: boolean
16
21
  readonly helpVisible: boolean
17
22
  readonly filterOpen: boolean
23
+ /** Current applied/edited filter query. Used by `filter.clearOrOpen`'s
24
+ * hint gate so the hint only appears when there is something to clear. */
25
+ readonly filterQuery: string
18
26
  readonly paletteOpen: boolean
19
27
  readonly setFocus: (next: BrowserFocus | ((prev: BrowserFocus) => BrowserFocus)) => void
20
28
  readonly setSelectedIndex: (updater: (prev: number) => number) => void
@@ -22,12 +30,19 @@ export interface BrowserCtx {
22
30
  readonly toggleShown: () => void
23
31
  readonly setHelpVisible: (updater: (prev: boolean) => boolean) => void
24
32
  readonly openFilter: () => void
33
+ /** Clear the current filter query and open the filter modal in a single
34
+ * action. Bound to `\` so users can reset a stranded zero-match filter
35
+ * without first reopening with `/` and backspacing. */
36
+ readonly clearAndOpenFilter: () => void
25
37
  readonly openPalette: () => void
26
38
  readonly cycleTheme: (delta: 1 | -1) => void
27
39
  readonly toggleTone: () => void
28
40
  readonly quit: () => void
29
41
  /** Start (or retarget) the HTML preview server on the focused file. */
30
42
  readonly serveCurrent: () => void
43
+ /** Suspend the TUI, hand the TTY to `$EDITOR`, resume and re-read on
44
+ * exit. No-op when nothing is selected; gating is the binding's job. */
45
+ readonly editCurrent: () => void
31
46
  }
32
47
 
33
48
  /** Step size for shift+j/k and the space/b/page keys. Constant for v1; could
@@ -37,6 +52,7 @@ const JUMP = 8
37
52
  const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n))
38
53
  const lastIndex = (c: BrowserCtx) => Math.max(0, c.files.length - 1)
39
54
  const haveFiles = (c: BrowserCtx) => c.files.length > 0
55
+ const hasSelected = (c: BrowserCtx) => c.hasSelected
40
56
  const stepBy = (c: BrowserCtx, delta: number) =>
41
57
  c.setSelectedIndex((i) => clamp(i + delta, 0, lastIndex(c)))
42
58
 
@@ -45,7 +61,10 @@ const filterClosed = (c: BrowserCtx) => !c.filterOpen
45
61
  const paletteClosed = (c: BrowserCtx) => !c.paletteOpen
46
62
  const inReader = (c: BrowserCtx) => c.focus === "reader"
47
63
  const inSidebarWithFiles = (c: BrowserCtx) => inSidebar(c) && haveFiles(c)
48
- const inReaderWithFiles = (c: BrowserCtx) => inReader(c) && haveFiles(c)
64
+ /** Reader-only sibling-step gate: needs a current selection plus a sibling
65
+ * to step to. `hasSelected` implies `files.length >= 1`, so `>= 2` is the
66
+ * meaningful extra condition. */
67
+ const inReaderWithSibling = (c: BrowserCtx) => inReader(c) && hasSelected(c) && c.files.length >= 2
49
68
 
50
69
  export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
51
70
  // Global
@@ -93,6 +112,28 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
93
112
  when: filterClosed,
94
113
  run: (c) => c.openFilter(),
95
114
  },
115
+ {
116
+ id: "filter.clearOrOpen",
117
+ group: "Sidebar",
118
+ description: "Clear filter",
119
+ hint: "clear",
120
+ keys: ["ctrl+\\"],
121
+ // Fires from anywhere outside the filter modal via the keymap.
122
+ // Inside the filter modal it's intercepted directly in Browser.tsx
123
+ // (the filter mode owns key handling), but the action is the same —
124
+ // clear input, keep modal open. Palette/help branches short-circuit
125
+ // dispatch in Browser.tsx, so we don't need to gate on them for
126
+ // behavior; the `hintWhen` gate keeps the footer chip from showing
127
+ // when there's nothing to clear or when a modal owns the input.
128
+ // Chord chosen over single `\` so the binding works inside the
129
+ // filter input without colliding with the typed character; ctrl+u
130
+ // is deliberately left to its reader/sidebar half-page-up role to
131
+ // avoid overload.
132
+ when: filterClosed,
133
+ hintWhen: (c) =>
134
+ filterClosed(c) && !c.paletteOpen && !c.helpVisible && c.filterQuery.length > 0,
135
+ run: (c) => c.clearAndOpenFilter(),
136
+ },
96
137
  {
97
138
  id: "palette.open",
98
139
  group: "Global",
@@ -106,15 +147,6 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
106
147
  when: paletteClosed,
107
148
  run: (c) => c.openPalette(),
108
149
  },
109
- {
110
- id: "serve.current",
111
- group: "Global",
112
- description: "Open current file in browser as HTML",
113
- hint: "html",
114
- keys: ["o"],
115
- when: haveFiles,
116
- run: (c) => c.serveCurrent(),
117
- },
118
150
  {
119
151
  id: "theme.next",
120
152
  group: "Global",
@@ -213,32 +245,57 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
213
245
  run: (c) => c.setFocus("reader"),
214
246
  },
215
247
 
216
- // Reader
248
+ // File — actions on the currently-selected file. Gated on `hasSelected`
249
+ // (per #115) so they're available exactly when the reader has something
250
+ // to act on, regardless of focus or filter state.
217
251
  {
218
- id: "reader.back",
219
- group: "Reader",
220
- description: "Back to sidebar",
221
- hint: "back",
222
- keys: ["escape", "left", "h"],
223
- when: inReader,
224
- run: (c) => c.setFocus("sidebar"),
252
+ id: "serve.current",
253
+ group: "File",
254
+ description: "Open current file in browser as HTML",
255
+ hint: "html",
256
+ keys: ["o"],
257
+ when: hasSelected,
258
+ run: (c) => c.serveCurrent(),
225
259
  },
226
260
  {
261
+ id: "file.edit",
262
+ group: "File",
263
+ description: "Open current file in $EDITOR",
264
+ hint: "edit",
265
+ keys: ["e"],
266
+ when: hasSelected,
267
+ run: (c) => c.editCurrent(),
268
+ },
269
+ {
270
+ // `[`/`]` keep the `inReader` clause so they're only typed from the
271
+ // reader (sidebar uses j/k for stepping). The File-group predicate is
272
+ // additive: needs a selection *and* a sibling to step to.
227
273
  id: "reader.prevFile",
228
- group: "Reader",
274
+ group: "File",
229
275
  description: "Prev file",
230
276
  hint: "prev",
231
277
  keys: ["["],
232
- when: inReaderWithFiles,
278
+ when: inReaderWithSibling,
233
279
  run: (c) => stepBy(c, -1),
234
280
  },
235
281
  {
236
282
  id: "reader.nextFile",
237
- group: "Reader",
283
+ group: "File",
238
284
  description: "Next file",
239
285
  hint: "next",
240
286
  keys: ["]"],
241
- when: inReaderWithFiles,
287
+ when: inReaderWithSibling,
242
288
  run: (c) => stepBy(c, 1),
243
289
  },
290
+
291
+ // Reader
292
+ {
293
+ id: "reader.back",
294
+ group: "Reader",
295
+ description: "Back to sidebar",
296
+ hint: "back",
297
+ keys: ["escape", "left", "h"],
298
+ when: inReader,
299
+ run: (c) => c.setFocus("sidebar"),
300
+ },
244
301
  ]
@@ -34,6 +34,12 @@ export interface KeyBinding<C> {
34
34
  readonly hint?: string
35
35
  /** If present, the binding only fires when this returns true. */
36
36
  readonly when?: (ctx: C) => boolean
37
+ /** If present, the footer hint is shown only when this returns true. When
38
+ * absent, hint visibility falls back to `when`. Use when a binding's
39
+ * dispatch gate is broader than the situations where the hint is useful
40
+ * (e.g. "clear filter" fires from anywhere but only deserves a hint when
41
+ * there is actually a filter to clear). */
42
+ readonly hintWhen?: (ctx: C) => boolean
37
43
  readonly run: (ctx: C) => void
38
44
  }
39
45
 
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Sidebar row layout — basename-first with a dim parent suffix sized to the
3
+ * available width.
4
+ *
5
+ * Pure per-row formatting: a row's rendered shape depends only on its own
6
+ * path and the column budget — never on neighboring rows. That keeps the
7
+ * sidebar stable as filters change and the file set grows, and keeps the
8
+ * function trivially predictable. Disambiguation against same-basename
9
+ * siblings is the header's job (it shows the full relative path of the
10
+ * selected row); a future auto-scroll on the selected sidebar row can carry
11
+ * the same information without altering layout for the rest.
12
+ *
13
+ * Truncation policy (head-elide, segment-aware):
14
+ * - Full parent fits → render whole.
15
+ * - Else drop leading segments one at a time, prefixed with `…/`, until
16
+ * the remainder fits — never chops a segment mid-character.
17
+ * - When even the tail segment with `…/` overflows, drop the marker.
18
+ * - When the tail segment alone overflows, hard-truncate it from its head
19
+ * (leading `…`) as a last resort.
20
+ *
21
+ * Why head-elide: the immediate parent is the segment closest to the file
22
+ * and the most universally meaningful one when context shrinks.
23
+ */
24
+
25
+ export const SIDEBAR_ROW_SEPARATOR = " · "
26
+ const ELISION_PREFIX = "…/"
27
+ const MIN_PARENT_BUDGET = 3
28
+
29
+ export interface SidebarRowParts {
30
+ readonly basename: string
31
+ readonly separator: string
32
+ readonly parent: string
33
+ }
34
+
35
+ export const formatSidebarRow = (relativePath: string, totalWidth: number): SidebarRowParts => {
36
+ const slash = relativePath.lastIndexOf("/")
37
+ if (slash < 0) {
38
+ return { basename: fitTail(relativePath, totalWidth), separator: "", parent: "" }
39
+ }
40
+
41
+ const basename = relativePath.slice(slash + 1)
42
+ const parentFull = relativePath.slice(0, slash)
43
+
44
+ if (basename.length >= totalWidth) {
45
+ return { basename: fitTail(basename, totalWidth), separator: "", parent: "" }
46
+ }
47
+
48
+ const remaining = totalWidth - basename.length - SIDEBAR_ROW_SEPARATOR.length
49
+ if (remaining < MIN_PARENT_BUDGET || parentFull.length === 0) {
50
+ return { basename, separator: "", parent: "" }
51
+ }
52
+
53
+ if (parentFull.length <= remaining) {
54
+ return row(basename, parentFull)
55
+ }
56
+
57
+ const segments = parentFull.split("/")
58
+ for (let k = segments.length - 1; k >= 1; k--) {
59
+ const candidate = ELISION_PREFIX + segments.slice(segments.length - k).join("/")
60
+ if (candidate.length <= remaining) return row(basename, candidate)
61
+ }
62
+
63
+ // Even one segment with the `…/` marker doesn't fit. Try without the marker.
64
+ const tail = segments[segments.length - 1]!
65
+ if (tail.length <= remaining) return row(basename, tail)
66
+
67
+ // Hard-chop the tail segment from its head as a last resort.
68
+ return row(basename, "…" + tail.slice(tail.length - remaining + 1))
69
+ }
70
+
71
+ const row = (basename: string, parent: string): SidebarRowParts => ({
72
+ basename,
73
+ separator: SIDEBAR_ROW_SEPARATOR,
74
+ parent,
75
+ })
76
+
77
+ const fitTail = (s: string, width: number): string => {
78
+ if (s.length <= width) return s
79
+ // At width ≤ 1 there's no room for both a character and the ellipsis; emit
80
+ // a single char so the result respects the budget. Callers currently clamp
81
+ // width to ≥ 4, but the helper carries its own floor so a tighter future
82
+ // caller can't silently overflow the column.
83
+ if (width <= 1) return s.slice(0, 1)
84
+ return s.slice(0, width - 1) + "…"
85
+ }
@@ -86,9 +86,12 @@ export const startServer = ({ path, port = 0 }: StartOptions): ServerHandle => {
86
86
  // user's local files, so leaking them to the network would be a
87
87
  // surprise. URL strings are localhost-only by construction below.
88
88
  hostname: "127.0.0.1",
89
- async fetch(req) {
89
+ async fetch(req, server) {
90
90
  const url = new URL(req.url)
91
91
  if (url.pathname === "/__reload") {
92
+ // SSE stream is silent between file changes; without this Bun
93
+ // closes the request at the default 10s idleTimeout and warns.
94
+ server.timeout(req, 0)
92
95
  // `cancel` receives a reason, not the controller — capture
93
96
  // the controller in `start` so we can remove it from the set
94
97
  // on disconnect. Without this, dead clients accumulate.