@carlesandres/house 0.4.11 → 0.4.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,28 @@ The publish workflow (`.github/workflows/publish.yml`) runs on the `release: pub
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.13] — 2026-06-15
10
+
11
+ ### Added
12
+
13
+ - Command palette now includes a `Copy file contents` action for the selected markdown file, copying the exact on-disk text to the system clipboard on macOS and Linux.
14
+
15
+ ### Changed
16
+
17
+ - Reader wrapping is now a session mode toggled with `w`: `width` / `--width` defines the fixed wrap width, while `wrap` / `--wrap` / `--no-wrap` controls startup mode. The footer now keeps a persistent `W` indicator for wrap state.
18
+ - The built-in startup focus now defaults to `sidebar` instead of opening the filter prompt immediately; use `--focus filter`, `HOUSE_FOCUS=filter`, or `focus = "filter"` to keep the previous behavior.
19
+ - Removed `--sidebar` as a startup mode; the sidebar now starts visible and remains controlled interactively with `s`, `tab`, and `/` during the session.
20
+
21
+ ### Fixed
22
+
23
+ - Footer status chrome, palette sizing, and reader wrap width calculations now stay within the visible pane.
24
+
25
+ ## [0.4.12] — 2026-06-14
26
+
27
+ ### Fixed
28
+
29
+ - Pinned the Effect runtime stack to the latest beta that does not pull `ini@7`, avoiding npm install engine warnings for users on older Node 22 installers.
30
+
9
31
  ## [0.4.11] — 2026-06-14
10
32
 
11
33
  ### Fixed
@@ -311,7 +333,9 @@ The v1 MVP, published as `@carlesandres/openmdr` on npm.
311
333
 
312
334
  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.
313
335
 
314
- [Unreleased]: https://github.com/carlesandres/house/compare/v0.4.11...HEAD
336
+ [Unreleased]: https://github.com/carlesandres/house/compare/v0.4.13...HEAD
337
+ [0.4.13]: https://github.com/carlesandres/house/compare/v0.4.12...v0.4.13
338
+ [0.4.12]: https://github.com/carlesandres/house/compare/v0.4.11...v0.4.12
315
339
  [0.4.11]: https://github.com/carlesandres/house/compare/v0.4.10...v0.4.11
316
340
  [0.4.10]: https://github.com/carlesandres/house/compare/v0.4.9...v0.4.10
317
341
  [0.4.9]: https://github.com/carlesandres/house/compare/v0.4.8...v0.4.9
package/README.md CHANGED
@@ -64,11 +64,12 @@ house --serve README.md
64
64
  | -------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------ |
65
65
  | `--theme <name>` | `opencode` | Starting theme (see list below) |
66
66
  | `--tone dark\|light` | `dark` | Starting tone |
67
- | `--width <N>` | | Cap rendered markdown width at N columns |
67
+ | `--width <N>` | `80` | Reader wrap width used when wrapping is enabled |
68
+ | `--wrap` | off | Start with reader wrapping enabled |
69
+ | `--no-wrap` | off | Start with reader wrapping disabled |
68
70
  | `--show <list>` | `""` | Reveal normally-skipped entries; comma-separated subset of `hidden`, `gitignored`. Use `--show ""` to clear. |
69
71
  | `--root <dir>` | current directory | Discovery root to walk; overrides `defaultRoot` config/env |
70
- | `--sidebar <mode>` | `auto` | Initial sidebar visibility: `auto`, `on`, or `off` |
71
- | `--focus <mode>` | `filter` | Startup focus: `sidebar`, `reader`, or `filter`. `filter` opens the sidebar filter prompt immediately. |
72
+ | `--focus <mode>` | `sidebar` | Startup focus: `sidebar`, `reader`, or `filter`. `filter` opens the sidebar filter prompt immediately. |
72
73
  | `--serve` | off | Serve the positional path as HTML in the browser (skips TUI) |
73
74
  | `--port <N>` | OS-assigned | Port for `--serve` |
74
75
  | `--ext <list>` | none | Include extra file extensions (comma-separated) |
@@ -92,24 +93,28 @@ Run `house --config-path` to print the exact location.
92
93
  theme = "tokyonight"
93
94
  tone = "dark"
94
95
  extensions = []
96
+ width = 80
97
+ wrap = false
95
98
  show = ["hidden", "gitignored"]
96
- focus = "filter"
99
+ focus = "sidebar"
97
100
  defaultRoot = "cwd" # or "git"
98
101
  ```
99
102
 
100
- Supported keys: `theme`, `tone`, `extensions`, `show`, `focus`, `defaultRoot`.
103
+ Supported keys: `theme`, `tone`, `extensions`, `width`, `wrap`, `show`, `focus`, `defaultRoot`.
101
104
 
102
105
  `show` is a list of normally-skipped categories to opt into. Known categories: `hidden` (dot-prefixed entries), `gitignored` (entries matched by a `.gitignore`). Default is the empty list. Hard skips (`node_modules`, `.git`, `.venv`) always apply.
103
106
 
104
107
  Precedence, highest to lowest:
105
108
 
106
- 1. CLI flags (`--theme`, `--tone`, `--ext`, `--show`, `--focus`, `--root`)
107
- 2. Env vars (`HOUSE_THEME`, `HOUSE_TONE`, `HOUSE_EXTENSIONS`, `HOUSE_SHOW`, `HOUSE_FOCUS`, `HOUSE_DEFAULT_ROOT`)
109
+ 1. CLI flags (`--theme`, `--tone`, `--ext`, `--width`, `--wrap`, `--no-wrap`, `--show`, `--focus`, `--root`)
110
+ 2. Env vars (`HOUSE_THEME`, `HOUSE_TONE`, `HOUSE_EXTENSIONS`, `HOUSE_WIDTH`, `HOUSE_WRAP`, `HOUSE_SHOW`, `HOUSE_FOCUS`, `HOUSE_DEFAULT_ROOT`)
108
111
  3. Config file
109
- 4. Built-in defaults (`opencode` / `dark` / `extensions = []` / `show = []` / `focus = "filter"` / `defaultRoot = "cwd"`)
112
+ 4. Built-in defaults (`opencode` / `dark` / `extensions = []` / `width = 80` / `wrap = false` / `show = []` / `focus = "sidebar"` / `defaultRoot = "cwd"`)
110
113
 
111
114
  `HOUSE_SHOW` takes a comma-separated list (`HOUSE_SHOW=hidden,gitignored`). For `show` specifically, each source completely replaces the next — categories don't merge across layers. Press `shift+a` in the TUI to round-trip between the configured set and the full vocabulary without editing config.
112
115
 
116
+ `width` is the fixed reader width used when wrapping is enabled. `wrap` controls startup mode; press `w` in the TUI to toggle reader wrapping for the current session without editing config.
117
+
113
118
  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.
114
119
 
115
120
  ## Keys
@@ -122,6 +127,7 @@ The file is optional — a missing file is fine. Invalid keys, unknown themes, o
122
127
  | `tab` | Toggle focus (sidebar ↔ reader) |
123
128
  | `s` | Toggle sidebar visibility |
124
129
  | `ctrl+p` | Command palette |
130
+ | `w` | Toggle reader wrap |
125
131
  | `O` | Open current file in browser as HTML |
126
132
  | `E` | Open current file in `$EDITOR` (`$VISUAL` takes precedence) |
127
133
  | `t` | Next theme |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carlesandres/house",
3
- "version": "0.4.11",
3
+ "version": "0.4.13",
4
4
  "description": "TUI-first markdown reader on opentui",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -51,11 +51,11 @@
51
51
  "prepare": "git config core.hooksPath .githooks 2>/dev/null || true"
52
52
  },
53
53
  "dependencies": {
54
- "@effect/atom-react": "4.0.0-beta.70",
54
+ "@effect/atom-react": "4.0.0-beta.60",
55
55
  "@opentui/core": "0.2.15",
56
56
  "@opentui/react": "0.2.15",
57
57
  "commander": "^14.0.3",
58
- "effect": "4.0.0-beta.70",
58
+ "effect": "4.0.0-beta.60",
59
59
  "ignore": "^7.0.5",
60
60
  "marked": "^18.0.4",
61
61
  "react": "19.2.6",
package/src/Browser.tsx CHANGED
@@ -25,16 +25,12 @@ import { parseFrontmatter } from "./markdown/frontmatter.ts"
25
25
  import { BRAND, BRAND_NAME } from "./brand.ts"
26
26
  import { Footer, FOOTER_HEIGHT, type FooterProps } from "./Footer.tsx"
27
27
  import { Header, HEADER_HEIGHT } from "./Header.tsx"
28
+ import { copyTextToClipboard } from "./io/clipboard.ts"
28
29
  import { openInEditor, resolveEditor } from "./io/editor.ts"
29
30
  import { readFileText } from "./io/readFile.ts"
30
31
  import { browserBindings, type BrowserCtx } from "./keymap/browser.ts"
31
32
  import { dispatch } from "./keymap/keymap.ts"
32
- import {
33
- canFitInline,
34
- defaultPreferredWidth,
35
- initialShownForAuto,
36
- resolveSidebarWidth,
37
- } from "./layout/resolve.ts"
33
+ import { canFitInline, defaultPreferredWidth, resolveSidebarWidth } from "./layout/resolve.ts"
38
34
  import { fitSidebarEmptyValue } from "./layout/sidebarEmptyState.ts"
39
35
  import { formatSidebarRow } from "./layout/sidebarRow.ts"
40
36
  import { PromptRow } from "./PromptRow.tsx"
@@ -48,7 +44,6 @@ import { themeDefinitions, getThemeDefinition } from "./theme/registry.ts"
48
44
  import { saveThemePreference } from "./config/save.ts"
49
45
  import { middleTruncate } from "./ui/middleTruncate.ts"
50
46
 
51
- export type SidebarMode = "auto" | "on" | "off"
52
47
  export type StartupFocus = "sidebar" | "reader" | "filter"
53
48
 
54
49
  export interface BrowserProps {
@@ -56,8 +51,10 @@ export interface BrowserProps {
56
51
  readonly initialIndex?: number
57
52
  /** Initial applied filter query seeded from the CLI positional. */
58
53
  readonly initialQuery?: string
59
- /** Cap the rendered markdown's width at N columns. Null = fill the pane. */
60
- readonly maxWidth?: number | null
54
+ /** Reader wrap width used when wrapping is enabled. */
55
+ readonly wrapWidth?: number
56
+ /** Initial reader wrap mode. Runtime toggles are session-only. */
57
+ readonly initialWrap?: boolean
61
58
  /** Discovery root label used in the post-discovery empty-vault sidebar row. */
62
59
  readonly emptyRootLabel?: string
63
60
  /** Persistent footer indicator (e.g. "indexing… 42"). Pass null/undefined
@@ -74,12 +71,11 @@ export interface BrowserProps {
74
71
  readonly renderedPathDebounceMs?: number
75
72
  /** Test seam: disable reader-empty-state tip rotation effect. */
76
73
  readonly disableReaderEmptyStateRotation?: boolean
77
- /** Initial sidebar visibility (`--sidebar` flag). `auto` consults the
78
- * launch viewport bucket once; subsequent visibility goes through `s`. */
79
- readonly sidebarMode?: SidebarMode
80
74
  readonly onQuit?: () => void
81
75
  /** Test seam: replaces the file reader. */
82
76
  readonly readFile?: (path: string) => Promise<string>
77
+ /** Test seam: replaces the system clipboard writer. */
78
+ readonly copyToClipboard?: (text: string) => Promise<void>
83
79
  /** Optional one-shot footer toast surfaced on first appearance (e.g. the
84
80
  * "update available" nudge). Shown with an extended TTL so the user has
85
81
  * time to read it; subsequent transient toasts (theme cycle, etc.)
@@ -189,7 +185,8 @@ export const Browser = ({
189
185
  files,
190
186
  initialIndex = 0,
191
187
  initialQuery = "",
192
- maxWidth = null,
188
+ wrapWidth = 80,
189
+ initialWrap = false,
193
190
  emptyRootLabel = "current root",
194
191
  discoveryStatus = null,
195
192
  discoverySpinnerIntervalMs,
@@ -198,9 +195,9 @@ export const Browser = ({
198
195
  filterDebounceMs = FILTER_DEBOUNCE_MS,
199
196
  renderedPathDebounceMs = RENDERED_PATH_DEBOUNCE_MS,
200
197
  disableReaderEmptyStateRotation = false,
201
- sidebarMode = "auto",
202
198
  onQuit,
203
199
  readFile = defaultReadFile,
200
+ copyToClipboard = copyTextToClipboard,
204
201
  updateNotice = null,
205
202
  updateNoticeTtlMs = 10000,
206
203
  disableFooterNoticeAutoClear = false,
@@ -216,24 +213,13 @@ export const Browser = ({
216
213
  const [selectedIndex, setSelectedIndex] = useState(() =>
217
214
  clamp(initialIndex, 0, Math.max(0, files.length - 1)),
218
215
  )
216
+ const [wrapEnabled, setWrapEnabled] = useState(initialWrap)
219
217
  const [loaded, setLoaded] = useState<{ path: string; content: string } | null>(null)
220
218
  const [error, setError] = useState<string | null>(null)
221
- // `shown` is the user's sticky preference. Visibility is derived:
222
- // `visible = shown || focus === "sidebar"`. See DESIGN.md §7.1.
223
- //
224
- // Launch consults the viewport bucket once for `--sidebar=auto`. The
225
- // useState initializer pins this to the first render — buckets are
226
- // launch-only by design, so resize must NOT re-evaluate.
227
- const [shown, setShown] = useState<boolean>(() => {
228
- switch (sidebarMode) {
229
- case "on":
230
- return true
231
- case "off":
232
- return false
233
- case "auto":
234
- return initialShownForAuto(width)
235
- }
236
- })
219
+ // `shown` is the user's sticky interactive preference. The sidebar starts
220
+ // visible; after launch, `s`/`tab`/`/` may hide or reveal it. Visibility is
221
+ // derived: `visible = shown || focus === "sidebar"`. See DESIGN.md §7.1.
222
+ const [shown, setShown] = useState<boolean>(true)
237
223
  const startInFilter = startupFocus === "filter"
238
224
  const initialFocus: "sidebar" | "reader" =
239
225
  startupFocus === null
@@ -245,13 +231,12 @@ export const Browser = ({
245
231
  : "sidebar"
246
232
  // `filter` mirrors `openFilter`'s focus rule: the filter input lives in
247
233
  // the sidebar, so opening it on mount also forces sidebar focus regardless
248
- // of `--sidebar=off` (§7.1's visibility derivation surfaces the sidebar via
249
- // focus even when `shown` is false). Plain `sidebar` startup shares the
250
- // same pane focus without opening the prompt. When omitted, preserve the
251
- // legacy Browser behavior: initial focus follows visibility.
252
- const [focus, setFocus] = useState<"sidebar" | "reader">(() =>
253
- shown || initialFocus === "sidebar" ? "sidebar" : "reader",
254
- )
234
+ // of the current interactive visibility state (§7.1's visibility derivation
235
+ // surfaces the sidebar via focus even when `shown` is false). Plain
236
+ // `sidebar` startup shares the same pane focus without opening the prompt.
237
+ // When omitted, preserve the legacy Browser behavior: initial focus follows
238
+ // visibility.
239
+ const [focus, setFocus] = useState<"sidebar" | "reader">(() => initialFocus)
255
240
  const [sidebarScroll, setSidebarScroll] = useState<number>(0)
256
241
  const [filterOpen, setFilterOpen] = useState<boolean>(startInFilter)
257
242
  const [filterInput, setFilterInput] = useState<string>(initialQuery)
@@ -492,6 +477,7 @@ export const Browser = ({
492
477
  restoreFilterOnSidebarFocus: restoreFilterOnSidebarFocusRef.current,
493
478
  filterQuery: filterInput,
494
479
  paletteOpen,
480
+ wrapEnabled,
495
481
  setFocus,
496
482
  // Wrapped so any keymap-driven selection move (j/k/g/G/[/], reader
497
483
  // prev/next) clears the pending-restore ref from #145. Internal
@@ -565,6 +551,7 @@ export const Browser = ({
565
551
  setPaletteIndex(0)
566
552
  dispatchFloatingOverlay({ type: "open-command-palette" })
567
553
  },
554
+ toggleWrap: () => setWrapEnabled((prev) => !prev),
568
555
  cycleTheme,
569
556
  toggleTone,
570
557
  toggleAll: () => {
@@ -674,6 +661,26 @@ export const Browser = ({
674
661
  }
675
662
  })()
676
663
  },
664
+ copyCurrentContents: () => {
665
+ const file = displayedFiles[selectedIndex]
666
+ if (!file) return
667
+ void (async () => {
668
+ let text: string
669
+ try {
670
+ text = await readFile(file.path)
671
+ } catch {
672
+ pushFooterNotice(`copy failed: cannot read ${file.relativePath}`)
673
+ return
674
+ }
675
+ try {
676
+ await copyToClipboard(text)
677
+ pushFooterNotice(`copied ${file.relativePath}`)
678
+ } catch (err) {
679
+ const message = err instanceof Error ? err.message : String(err)
680
+ pushFooterNotice(`copy failed: ${message}`)
681
+ }
682
+ })()
683
+ },
677
684
  }
678
685
 
679
686
  useKeyboard((key) => {
@@ -860,6 +867,12 @@ export const Browser = ({
860
867
  const isNarrow = !canFitInline(width)
861
868
  const sidebarInline = isNarrow ? sidebarActive : shown || sidebarActive
862
869
  const readerVisible = isNarrow ? readerActive : true
870
+ // When fixed-width wrapping is enabled, never size markdown wider than the
871
+ // visible reader body. The scrollbox deliberately disables horizontal
872
+ // scrolling, so an over-wide markdown node clips instead of wrapping.
873
+ const readerPaneWidth = isNarrow || !sidebarInline ? width : Math.max(1, width - sidebarWidth)
874
+ const readerBodyWidth = Math.max(1, readerPaneWidth - 2) // reader padding: 1 left + 1 right
875
+ const markdownWidth = wrapEnabled ? Math.min(wrapWidth, readerBodyWidth) : "100%"
863
876
  // Currently-selected file shown in the Header (which replaced the
864
877
  // per-pane border title that used to carry this information).
865
878
  const currentFile = selected?.relativePath ?? null
@@ -924,6 +937,15 @@ export const Browser = ({
924
937
  width,
925
938
  notice: footerNotice?.text ?? null,
926
939
  discoveryStatus,
940
+ indicators: [
941
+ {
942
+ id: "wrap",
943
+ icon: "W",
944
+ variant: "info",
945
+ active: wrapEnabled,
946
+ onMouseUp: () => setWrapEnabled((prev) => !prev),
947
+ },
948
+ ],
927
949
  ...(discoverySpinnerIntervalMs === undefined ? {} : { discoverySpinnerIntervalMs }),
928
950
  ...(discoverySpinnerInitialFrameIndex === undefined
929
951
  ? {}
@@ -1162,7 +1184,7 @@ export const Browser = ({
1162
1184
  fg={colors.text}
1163
1185
  bg={readerActive ? colors.background : colors.backgroundPanel}
1164
1186
  conceal
1165
- style={{ width: maxWidth ?? "100%" }}
1187
+ style={{ width: markdownWidth }}
1166
1188
  />
1167
1189
  </scrollbox>
1168
1190
  )}
@@ -88,9 +88,12 @@ export const CommandPalette = ({
88
88
  const overlayWidth = Math.min(viewportWidth - 4, 64)
89
89
  const rows = buildRows(commands)
90
90
  // Reserve: 2 for border (top+bottom), 1 query row, 1 spacer below query,
91
- // 1 spacer above footer, 1 footer row. Body gets the rest.
91
+ // 1 spacer above footer, 1 footer row. Body gets the rest. Keep a one-row
92
+ // viewport margin above and below when possible; the modal itself already
93
+ // owns a border and clips its body, so a larger fixed margin needlessly hides
94
+ // the final command on medium-height terminals.
92
95
  const chrome = 2 + 1 + 1 + 1 + 1
93
- const maxBody = Math.max(1, viewportHeight - 4 - chrome)
96
+ const maxBody = Math.max(1, viewportHeight - 2 - chrome)
94
97
  const desiredBody = Math.max(1, rows.length || 1)
95
98
  const bodyHeight = Math.min(desiredBody, maxBody)
96
99
  const overlayHeight = chrome + bodyHeight
package/src/Footer.tsx CHANGED
@@ -24,6 +24,7 @@ import type React from "react"
24
24
  import type { KeyBinding } from "./keymap/keymap.ts"
25
25
  import { displayKey } from "./keymap/displayKey.ts"
26
26
  import { Spinner } from "./Spinner.tsx"
27
+ import { StatusIndicator, type StatusIndicatorProps } from "./StatusIndicator.tsx"
27
28
  import { colors } from "./theme/colors.ts"
28
29
 
29
30
  /** Rows the Footer occupies. Importers use it for layout math so a future
@@ -39,6 +40,8 @@ export interface FooterProps<C> {
39
40
  * no TTL, cleared by the caller when the underlying activity finishes.
40
41
  * Loses to `notice` when both are set so transient toasts still surface. */
41
42
  readonly discoveryStatus?: string | null
43
+ /** Persistent compact state/status indicators, rendered after built-in warning indicators. */
44
+ readonly indicators?: readonly (StatusIndicatorProps & { readonly id: string })[]
42
45
  /** Test seam: override spinner tick speed so tests don't sleep on the full
43
46
  * production interval. Ignored when discoveryStatus is null. */
44
47
  readonly discoverySpinnerIntervalMs?: number
@@ -86,6 +89,7 @@ const fitHints = (hints: readonly Hint[], width: number): Hint[] => {
86
89
  }
87
90
 
88
91
  const STATUS_SEPARATOR = " · "
92
+ const NON_WARNING_STATUS_PREFIX_WIDTH = 2 // 1-cell spinner + 1-cell spacer
89
93
 
90
94
  const isPartialDiscoveryWarning = (status: string | null): boolean =>
91
95
  status?.startsWith("scan incomplete:") ?? false
@@ -96,6 +100,7 @@ export const Footer = <C,>({
96
100
  width,
97
101
  notice,
98
102
  discoveryStatus,
103
+ indicators = [],
99
104
  discoverySpinnerIntervalMs,
100
105
  discoverySpinnerInitialFrameIndex,
101
106
  discoverySpinnerRegisterTick,
@@ -130,19 +135,48 @@ export const Footer = <C,>({
130
135
  const status =
131
136
  discoveryStatus && discoveryStatus.length > 0 ? normalizeStatusLine(discoveryStatus) : null
132
137
  const isPartialWarning = isPartialDiscoveryWarning(status)
138
+ const renderedIndicators = [
139
+ ...(isPartialWarning
140
+ ? [
141
+ {
142
+ id: "discovery-warning",
143
+ icon: "!",
144
+ variant: "warning" as const,
145
+ active: true,
146
+ ...(onDiscoveryWarningToggle === undefined
147
+ ? {}
148
+ : { onMouseUp: onDiscoveryWarningToggle }),
149
+ },
150
+ ]
151
+ : []),
152
+ ...indicators,
153
+ ]
154
+ const indicatorBudget = Math.min(usableWidth, renderedIndicators.length * 3)
155
+ const contentBudget = Math.max(0, usableWidth - indicatorBudget)
156
+ const statusChromeWidth = status
157
+ ? (isPartialWarning ? 0 : NON_WARNING_STATUS_PREFIX_WIDTH) + STATUS_SEPARATOR.length
158
+ : 0
133
159
  const statusBudget = status
134
- ? Math.min((isPartialWarning ? 1 : status.length) + STATUS_SEPARATOR.length, usableWidth)
160
+ ? Math.min((isPartialWarning ? 0 : status.length) + statusChromeWidth, contentBudget)
135
161
  : 0
136
- const hintsWidth = Math.max(0, usableWidth - statusBudget)
162
+ const hintsWidth = Math.max(0, contentBudget - statusBudget)
137
163
  const visibleHints = fitHints(hints, hintsWidth)
138
- const statusContent = status
139
- ? status.slice(0, Math.max(0, statusBudget - STATUS_SEPARATOR.length))
164
+ const nonWarningStatusPrefixBudget =
165
+ status && !isPartialWarning ? Math.min(NON_WARNING_STATUS_PREFIX_WIDTH, statusBudget) : 0
166
+ const statusContent = status ? status.slice(0, Math.max(0, statusBudget - statusChromeWidth)) : ""
167
+ const statusSeparatorContent = status
168
+ ? STATUS_SEPARATOR.slice(
169
+ 0,
170
+ Math.max(0, statusBudget - nonWarningStatusPrefixBudget - statusContent.length),
171
+ )
140
172
  : ""
141
173
  const noticeContent = notice
142
- ? notice.length > usableWidth
143
- ? notice.slice(0, usableWidth)
174
+ ? notice.length > contentBudget
175
+ ? notice.slice(0, contentBudget)
144
176
  : notice
145
177
  : null
178
+ const renderIndicators = () =>
179
+ renderedIndicators.map(({ id, ...props }) => <StatusIndicator key={id} {...props} />)
146
180
 
147
181
  // Two-tone hint row: keys render in `text` (foreground-strength), the
148
182
  // `:label` portion in `textMuted`. Matches ghui's footer treatment so
@@ -177,25 +211,12 @@ export const Footer = <C,>({
177
211
  if (noticeContent !== null) {
178
212
  return (
179
213
  <box style={rowStyle}>
214
+ {renderIndicators()}
180
215
  <text content={noticeContent} wrapMode="none" style={{ fg: colors.primary }} />
181
216
  </box>
182
217
  )
183
218
  }
184
219
 
185
- const warningTrigger = isPartialWarning ? (
186
- <box
187
- {...(onDiscoveryWarningToggle === undefined ? {} : { onMouseUp: onDiscoveryWarningToggle })}
188
- style={{
189
- width: 1,
190
- height: 1,
191
- flexDirection: "row",
192
- backgroundColor: colors.backgroundElement,
193
- }}
194
- >
195
- <text content="!" wrapMode="none" style={{ fg: colors.warning, attributes: 1 }} />
196
- </box>
197
- ) : null
198
-
199
220
  if (status !== null) {
200
221
  const spinnerProps = {
201
222
  fg: colors.secondary,
@@ -212,20 +233,26 @@ export const Footer = <C,>({
212
233
 
213
234
  return (
214
235
  <box style={rowStyle}>
215
- {isPartialWarning ? null : <Spinner {...spinnerProps} />}
216
- {isPartialWarning ? null : (
236
+ {renderIndicators()}
237
+ {nonWarningStatusPrefixBudget >= 1 ? <Spinner {...spinnerProps} /> : null}
238
+ {nonWarningStatusPrefixBudget >= 2 ? (
217
239
  <text content=" " wrapMode="none" style={{ fg: colors.textMuted }} />
218
- )}
219
- {isPartialWarning ? (
220
- warningTrigger
221
- ) : (
240
+ ) : null}
241
+ {isPartialWarning ? null : (
222
242
  <text content={statusContent} wrapMode="none" style={{ fg: colors.secondary }} />
223
243
  )}
224
- <text content={STATUS_SEPARATOR} wrapMode="none" style={{ fg: colors.textMuted }} />
244
+ {statusSeparatorContent.length > 0 && (
245
+ <text content={statusSeparatorContent} wrapMode="none" style={{ fg: colors.textMuted }} />
246
+ )}
225
247
  {renderHints()}
226
248
  </box>
227
249
  )
228
250
  }
229
251
 
230
- return <box style={rowStyle}>{renderHints()}</box>
252
+ return (
253
+ <box style={rowStyle}>
254
+ {renderIndicators()}
255
+ {renderHints()}
256
+ </box>
257
+ )
231
258
  }
@@ -0,0 +1,55 @@
1
+ import { colors } from "./theme/colors.ts"
2
+
3
+ export type StatusIndicatorVariant = "info" | "warning" | "error" | "success"
4
+
5
+ export interface StatusIndicatorProps {
6
+ readonly icon: string
7
+ readonly variant?: StatusIndicatorVariant
8
+ readonly active?: boolean
9
+ readonly onMouseUp?: () => void
10
+ }
11
+
12
+ export const statusIndicatorFg = (variant: StatusIndicatorVariant): string => {
13
+ switch (variant) {
14
+ case "info":
15
+ return colors.info
16
+ case "warning":
17
+ return colors.warning
18
+ case "error":
19
+ return colors.error
20
+ case "success":
21
+ return colors.success
22
+ }
23
+ }
24
+
25
+ export const StatusIndicator = ({
26
+ icon,
27
+ variant = "info",
28
+ active = true,
29
+ onMouseUp,
30
+ }: StatusIndicatorProps) => {
31
+ const activeColor = statusIndicatorFg(variant)
32
+ const backgroundColor = active ? activeColor : colors.backgroundElement
33
+
34
+ return (
35
+ <box
36
+ {...(onMouseUp === undefined ? {} : { onMouseUp })}
37
+ style={{
38
+ width: 3,
39
+ height: 1,
40
+ flexDirection: "row",
41
+ backgroundColor,
42
+ }}
43
+ >
44
+ <text
45
+ content={` ${icon} `}
46
+ wrapMode="none"
47
+ style={{
48
+ fg: active ? colors.backgroundPanel : colors.textMuted,
49
+ bg: backgroundColor,
50
+ attributes: active ? 1 : 0,
51
+ }}
52
+ />
53
+ </box>
54
+ )
55
+ }
@@ -1,5 +1,6 @@
1
1
  import { useTerminalDimensions } from "@opentui/react"
2
2
  import { useMemo, useState } from "react"
3
+ import { StatusIndicator, statusIndicatorFg } from "./StatusIndicator.tsx"
3
4
  import { colors } from "./theme/colors.ts"
4
5
 
5
6
  export type StatusPopoverVariant = "info" | "warning" | "error" | "success"
@@ -29,19 +30,6 @@ export interface StatusPopoverPanelProps {
29
30
 
30
31
  const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n))
31
32
 
32
- const variantFg = (variant: StatusPopoverVariant): string => {
33
- switch (variant) {
34
- case "info":
35
- return colors.info
36
- case "warning":
37
- return colors.warning
38
- case "error":
39
- return colors.error
40
- case "success":
41
- return colors.success
42
- }
43
- }
44
-
45
33
  const measureLine = (line: string): number => line.length
46
34
 
47
35
  const wrapLine = (line: string, width: number): string[] => {
@@ -96,22 +84,11 @@ export const StatusPopover = ({
96
84
 
97
85
  const linesToRender = wrapped.slice(0, Math.max(0, popoverHeight - 2))
98
86
  while (linesToRender.length < popoverHeight - 2) linesToRender.push("")
99
- const triggerFg = variantFg(variant)
100
- const borderColor = variantFg(variant)
87
+ const borderColor = statusIndicatorFg(variant)
101
88
 
102
89
  return (
103
90
  <>
104
- <box
105
- onMouseUp={() => setOpen(!isOpen)}
106
- style={{
107
- width: 3,
108
- height: 1,
109
- flexDirection: "row",
110
- backgroundColor: colors.backgroundElement,
111
- }}
112
- >
113
- <text content={` ${icon} `} wrapMode="none" style={{ fg: triggerFg, attributes: 1 }} />
114
- </box>
91
+ <StatusIndicator icon={icon} variant={variant} active onMouseUp={() => setOpen(!isOpen)} />
115
92
  {showPanel && isOpen && (
116
93
  <box
117
94
  position="absolute"
@@ -162,7 +139,7 @@ export const StatusPopoverPanel = ({
162
139
  const linesToRender = wrapped.slice(0, Math.max(0, popoverHeight - 2))
163
140
  while (linesToRender.length < popoverHeight - 2) linesToRender.push("")
164
141
 
165
- const borderColor = variantFg(variant)
142
+ const borderColor = statusIndicatorFg(variant)
166
143
 
167
144
  return (
168
145
  <box
package/src/cli/argv.ts CHANGED
@@ -12,6 +12,10 @@ export interface ParsedArgs {
12
12
  readonly tone: string | null
13
13
  /** Value of `--width <N>`, or null. Validated by the boot layer (must be a positive integer). */
14
14
  readonly width: string | null
15
+ /** Startup reader wrap override. Null means config/env/default decides. */
16
+ readonly wrap: boolean | null
17
+ /** True when both `--wrap` and `--no-wrap` were passed. */
18
+ readonly wrapConflict: boolean
15
19
  /** True when `--serve` was passed: serve the positional path as HTML, skip TUI. */
16
20
  readonly serve: boolean
17
21
  /** Value of `--port <N>`, or null. Validated by the boot layer. */
@@ -22,8 +26,6 @@ export interface ParsedArgs {
22
26
  readonly version: boolean
23
27
  /** True when `--config-path` was passed: print resolved config path and exit. */
24
28
  readonly configPath: boolean
25
- /** Value of `--sidebar <mode>` (`auto`, `on`, `off`), or null. Validated by the boot layer. */
26
- readonly sidebar: string | null
27
29
  /** True when `--no-update-check` was passed: suppress the npm-registry
28
30
  * probe and the "update available" notice. Mirrors the
29
31
  * `NO_UPDATE_NOTIFIER` env var so opt-out is reachable without env state. */
@@ -49,10 +51,11 @@ const createProgram = () =>
49
51
  .option("--theme [id]")
50
52
  .option("--tone [mode]")
51
53
  .option("--width [N]")
54
+ .option("--wrap")
55
+ .option("--no-wrap")
52
56
  .option("--serve")
53
57
  .option("--port [N]")
54
58
  .option("--config-path")
55
- .option("--sidebar [mode]")
56
59
  .option("--no-update-check")
57
60
  .option("--ext [list]")
58
61
  .option("--focus [mode]")
@@ -67,19 +70,20 @@ const VALUE_FLAGS: ReadonlySet<string> = new Set([
67
70
  "--tone",
68
71
  "--width",
69
72
  "--port",
70
- "--sidebar",
71
73
  "--focus",
72
74
  "--show",
73
75
  "--root",
74
76
  "--ext",
75
77
  ])
76
78
 
77
- const REMOVED_VALUE_FLAGS: ReadonlySet<string> = new Set(["--sort"])
79
+ const REMOVED_VALUE_FLAGS: ReadonlySet<string> = new Set(["--sort", "--sidebar"])
78
80
 
79
81
  const BOOLEAN_FLAGS: ReadonlySet<string> = new Set([
80
82
  "--serve",
81
83
  "--config-path",
82
84
  "--no-update-check",
85
+ "--wrap",
86
+ "--no-wrap",
83
87
  "--help",
84
88
  "-h",
85
89
  "--version",
@@ -114,6 +118,8 @@ export const parseArgv = (argv: readonly string[]): ParsedArgs => {
114
118
  const opts = program.opts<Record<string, unknown>>()
115
119
  const pathArg = findPathArg(argv)
116
120
  const stringOrNull = (value: unknown): string | null => (typeof value === "string" ? value : null)
121
+ const hasWrap = argv.includes("--wrap")
122
+ const hasNoWrap = argv.includes("--no-wrap")
117
123
 
118
124
  return {
119
125
  path: typeof pathArg === "string" ? pathArg : null,
@@ -121,12 +127,13 @@ export const parseArgv = (argv: readonly string[]): ParsedArgs => {
121
127
  theme: stringOrNull(opts["theme"]),
122
128
  tone: stringOrNull(opts["tone"]),
123
129
  width: stringOrNull(opts["width"]),
130
+ wrap: hasWrap ? true : hasNoWrap ? false : null,
131
+ wrapConflict: hasWrap && hasNoWrap,
124
132
  serve: opts["serve"] === true,
125
133
  port: stringOrNull(opts["port"]),
126
134
  help: opts["help"] === true,
127
135
  version: opts["version"] === true,
128
136
  configPath: opts["configPath"] === true,
129
- sidebar: stringOrNull(opts["sidebar"]),
130
137
  noUpdateCheck: opts["noUpdateCheck"] === true,
131
138
  extensions: stringOrNull(opts["ext"]),
132
139
  show: stringOrNull(opts["show"]),
@@ -145,12 +152,13 @@ export const usage = `usage:
145
152
  options:
146
153
  --theme <id> color theme: ${themeList} (default: opencode)
147
154
  --tone <mode> dark or light (default: dark)
148
- --width <N> cap rendered markdown width at N columns
155
+ --width <N> reader wrap width used when wrapping is enabled (default: 80)
156
+ --wrap start with reader wrapping enabled
157
+ --no-wrap start with reader wrapping disabled
149
158
  --show <list> reveal normally-skipped entries; comma-separated subset of:
150
159
  hidden, gitignored. Use --show "" to clear.
151
160
  --root <dir> discovery root to walk (overrides defaultRoot config/env)
152
- --sidebar <m> initial sidebar visibility: auto (default), on, or off
153
- --focus <m> startup focus: sidebar, reader, or filter (default: filter)
161
+ --focus <m> startup focus: sidebar, reader, or filter (default: sidebar)
154
162
  --serve serve the positional path as HTML in the browser (skips TUI)
155
163
  --port <N> port for --serve (default: OS-assigned)
156
164
  -h, --help show this help and exit
@@ -166,6 +174,6 @@ examples:
166
174
 
167
175
  configuration:
168
176
  file: $XDG_CONFIG_HOME/house/config.toml (default ~/.config/house/config.toml)
169
- keys: theme, tone, extensions, show, focus, defaultRoot
170
- env: HOUSE_THEME, HOUSE_TONE, HOUSE_EXTENSIONS, HOUSE_SHOW, HOUSE_FOCUS, HOUSE_DEFAULT_ROOT
177
+ keys: theme, tone, width, wrap, extensions, show, focus, defaultRoot
178
+ env: HOUSE_THEME, HOUSE_TONE, HOUSE_WIDTH, HOUSE_WRAP, HOUSE_EXTENSIONS, HOUSE_SHOW, HOUSE_FOCUS, HOUSE_DEFAULT_ROOT
171
179
  precedence (high → low): flags → env → file → defaults`
@@ -55,6 +55,7 @@ const annotations: Record<string, Annotation> = {
55
55
  keywords: ["hidden", "gitignore", "dotfiles", "all"],
56
56
  },
57
57
  "reader.back": { title: "Back to sidebar", category: "Navigation" },
58
+ "reader.wrap.toggle": { title: "Toggle reader wrap", category: "View" },
58
59
  "reader.prevFile": { title: "Previous file", category: "Navigation" },
59
60
  "reader.nextFile": { title: "Next file", category: "Navigation" },
60
61
  "serve.current": { title: "Open in browser", category: "File" },
@@ -13,4 +13,15 @@
13
13
  import type { BrowserCtx } from "../keymap/browser.ts"
14
14
  import type { AppCommand } from "./types.ts"
15
15
 
16
- export const paletteOnlyCommands = (_ctx: BrowserCtx): readonly AppCommand[] => []
16
+ export const paletteOnlyCommands = (ctx: BrowserCtx): readonly AppCommand[] =>
17
+ ctx.hasSelected
18
+ ? [
19
+ {
20
+ id: "file.copyContents",
21
+ title: "Copy file contents",
22
+ category: "File",
23
+ keywords: ["copy", "clipboard", "contents", "markdown"],
24
+ run: () => ctx.copyCurrentContents(),
25
+ },
26
+ ]
27
+ : []
@@ -20,6 +20,10 @@ export interface HouseConfig {
20
20
  readonly theme: string
21
21
  readonly tone: "dark" | "light"
22
22
  readonly extensions: readonly string[]
23
+ /** Reader wrap width used when wrapping is enabled. */
24
+ readonly width: number
25
+ /** Whether the reader starts with fixed-width wrapping enabled. */
26
+ readonly wrap: boolean
23
27
  /** Default discovery-root strategy when no explicit `--root` flag is passed. */
24
28
  readonly defaultRoot: "cwd" | "git"
25
29
  /** Categories of normally-skipped entries to opt into. See
@@ -40,6 +44,8 @@ export interface CliOverrides {
40
44
  * other CLI override here). `--show ""` sets the empty set. */
41
45
  readonly show: readonly ShowCategory[] | null
42
46
  readonly focus: "sidebar" | "reader" | "filter" | null
47
+ readonly width: number | null
48
+ readonly wrap: boolean | null
43
49
  }
44
50
 
45
51
  const DEFAULT_THEME = "opencode"
@@ -47,7 +53,9 @@ const DEFAULT_TONE: "dark" | "light" = "dark"
47
53
  const DEFAULT_EXTENSIONS: readonly string[] = []
48
54
  const DEFAULT_ROOT: "cwd" | "git" = "cwd"
49
55
  const DEFAULT_SHOW = ""
50
- const DEFAULT_FOCUS: "sidebar" | "reader" | "filter" = "filter"
56
+ const DEFAULT_FOCUS: "sidebar" | "reader" | "filter" = "sidebar"
57
+ const DEFAULT_WIDTH = 80
58
+ const DEFAULT_WRAP = false
51
59
 
52
60
  const themeIds = themeDefinitions.map((t) => t.id)
53
61
 
@@ -63,6 +71,8 @@ const KNOWN_FILE_KEYS: ReadonlySet<string> = new Set([
63
71
  "extensions",
64
72
  "show",
65
73
  "focus",
74
+ "width",
75
+ "wrap",
66
76
  "defaultRoot",
67
77
  ])
68
78
 
@@ -78,6 +88,8 @@ const schema = Config.all({
78
88
  // so the error message can list valid categories at the field's path.
79
89
  show: Config.schema(Schema.String, "show"),
80
90
  focus: Config.schema(Schema.Literals(["sidebar", "reader", "filter"] as const), "focus"),
91
+ width: Config.schema(Schema.String, "width"),
92
+ wrap: Config.schema(Schema.String, "wrap"),
81
93
  })
82
94
 
83
95
  const defaultsProvider = (): ConfigProvider.ConfigProvider =>
@@ -88,8 +100,26 @@ const defaultsProvider = (): ConfigProvider.ConfigProvider =>
88
100
  extensions: DEFAULT_EXTENSIONS.join(","),
89
101
  show: DEFAULT_SHOW,
90
102
  focus: DEFAULT_FOCUS,
103
+ width: String(DEFAULT_WIDTH),
104
+ wrap: String(DEFAULT_WRAP),
91
105
  })
92
106
 
107
+ const sourceError = (message: string, cause?: unknown): ConfigProvider.SourceError =>
108
+ new ConfigProvider.SourceError({ message, cause })
109
+
110
+ const validateFileValue = (path: string, key: string, value: unknown): void => {
111
+ if (key === "width") {
112
+ if (!Number.isSafeInteger(value) || (value as number) <= 0) {
113
+ throw sourceError(`invalid value for width in ${path}: expected a positive integer`)
114
+ }
115
+ }
116
+ if (key === "wrap") {
117
+ if (typeof value !== "boolean") {
118
+ throw sourceError(`invalid value for wrap in ${path}: expected true or false`)
119
+ }
120
+ }
121
+ }
122
+
93
123
  /**
94
124
  * Levenshtein edit distance, capped at `cap` for early exit.
95
125
  * Used only to suggest "did you mean X?" when a config key looks like a
@@ -154,15 +184,22 @@ const fileProvider = (
154
184
  const parsed = yield* Effect.try({
155
185
  try: () => Bun.TOML.parse(text) as Record<string, unknown>,
156
186
  catch: (cause) =>
157
- new ConfigProvider.SourceError({
158
- message: `invalid TOML in ${path}: ${cause instanceof Error ? cause.message : String(cause)}`,
187
+ sourceError(
188
+ `invalid TOML in ${path}: ${cause instanceof Error ? cause.message : String(cause)}`,
159
189
  cause,
160
- }),
190
+ ),
161
191
  })
162
192
  const known = [...KNOWN_FILE_KEYS]
163
193
  const filtered: Record<string, unknown> = {}
164
194
  for (const [k, v] of Object.entries(parsed)) {
165
195
  if (KNOWN_FILE_KEYS.has(k)) {
196
+ yield* Effect.try({
197
+ try: () => validateFileValue(path, k, v),
198
+ catch: (cause) =>
199
+ cause instanceof ConfigProvider.SourceError
200
+ ? cause
201
+ : sourceError(`invalid value for ${k} in ${path}`, cause),
202
+ })
166
203
  filtered[k] = v
167
204
  } else {
168
205
  onWarning(formatUnknownKeyWarning(path, k, known))
@@ -206,12 +243,16 @@ const envProvider = (env: Record<string, string | undefined>): ConfigProvider.Co
206
243
  const extensions = env["HOUSE_EXTENSIONS"]
207
244
  const show = env["HOUSE_SHOW"]
208
245
  const focus = env["HOUSE_FOCUS"]
246
+ const width = env["HOUSE_WIDTH"]
247
+ const wrap = env["HOUSE_WRAP"]
209
248
  if (theme !== undefined) entries.push(["theme", theme])
210
249
  if (tone !== undefined) entries.push(["tone", tone])
211
250
  if (defaultRoot !== undefined) entries.push(["defaultRoot", defaultRoot])
212
251
  if (extensions !== undefined) entries.push(["extensions", extensions])
213
252
  if (show !== undefined) entries.push(["show", show])
214
253
  if (focus !== undefined) entries.push(["focus", focus])
254
+ if (width !== undefined) entries.push(["width", width])
255
+ if (wrap !== undefined) entries.push(["wrap", wrap])
215
256
  return ConfigProvider.fromUnknown(Object.fromEntries(entries))
216
257
  }
217
258
 
@@ -222,9 +263,28 @@ const cliProvider = (overrides: CliOverrides): ConfigProvider.ConfigProvider =>
222
263
  if (overrides.extensions !== null) entries.push(["extensions", overrides.extensions.join(",")])
223
264
  if (overrides.show !== null) entries.push(["show", overrides.show.join(",")])
224
265
  if (overrides.focus !== null) entries.push(["focus", overrides.focus])
266
+ if (overrides.width !== null) entries.push(["width", String(overrides.width)])
267
+ if (overrides.wrap !== null) entries.push(["wrap", String(overrides.wrap)])
225
268
  return ConfigProvider.fromUnknown(Object.fromEntries(entries))
226
269
  }
227
270
 
271
+ const parsePositiveInteger = (key: string, raw: string): Effect.Effect<number, Error> => {
272
+ if (!/^\d+$/.test(raw)) {
273
+ return Effect.fail(new Error(`${key}: expected a positive integer, got ${JSON.stringify(raw)}`))
274
+ }
275
+ const value = Number.parseInt(raw, 10)
276
+ if (!Number.isSafeInteger(value) || value <= 0) {
277
+ return Effect.fail(new Error(`${key}: expected a positive integer, got ${JSON.stringify(raw)}`))
278
+ }
279
+ return Effect.succeed(value)
280
+ }
281
+
282
+ const parseBoolean = (key: string, raw: string): Effect.Effect<boolean, Error> => {
283
+ if (raw === "true") return Effect.succeed(true)
284
+ if (raw === "false") return Effect.succeed(false)
285
+ return Effect.fail(new Error(`${key}: expected true or false, got ${JSON.stringify(raw)}`))
286
+ }
287
+
228
288
  export interface LoadOptions {
229
289
  readonly cli?: CliOverrides
230
290
  /** Override the TOML path (tests). Defaults to `$XDG_CONFIG_HOME/house/config.toml`. */
@@ -265,6 +325,8 @@ export const loadConfig = (
265
325
  extensions: null,
266
326
  show: null,
267
327
  focus: null,
328
+ width: null,
329
+ wrap: null,
268
330
  }
269
331
  const onWarning = options.onWarning ?? ((msg) => process.stderr.write(`${msg}\n`))
270
332
  const provider = cliProvider(cli).pipe(
@@ -273,41 +335,47 @@ export const loadConfig = (
273
335
  ConfigProvider.orElse(defaultsProvider()),
274
336
  )
275
337
  return schema.parse(provider).pipe(
276
- Effect.flatMap((raw) => {
277
- const defaultRoot =
278
- raw.defaultRoot === "cwd" || raw.defaultRoot === "git" ? raw.defaultRoot : DEFAULT_ROOT
279
- if (raw.defaultRoot !== defaultRoot) {
280
- onWarning(
281
- `house: ignoring invalid value ${JSON.stringify(raw.defaultRoot)} for defaultRoot in config/env; using "${DEFAULT_ROOT}"`,
282
- )
283
- }
284
- const parsed = parseShowList(raw.show)
285
- if (!parsed.ok) {
286
- // Effect's `Config.ConfigError` requires a `SchemaError` or
287
- // `SourceError` cause that we don't have a clean constructor
288
- // for here surface as a plain Error and let the boot
289
- // layer's existing `formatConfigError` (which already handles
290
- // `instanceof Error`) render it.
291
- return Effect.fail(
292
- new Error(
293
- `show: unknown category "${parsed.invalid.join('", "')}" (valid: ${SHOW_CATEGORIES.join(", ")})`,
294
- ),
295
- )
296
- }
297
- return Effect.succeed({
298
- theme: raw.theme,
299
- tone: raw.tone,
300
- defaultRoot,
301
- extensions:
302
- raw.extensions === ""
303
- ? []
304
- : raw.extensions
305
- .split(",")
306
- .map((s) => s.trim())
307
- .filter(Boolean),
308
- show: parsed.value,
309
- focus: raw.focus,
310
- })
311
- }),
338
+ Effect.flatMap((raw) =>
339
+ Effect.gen(function* () {
340
+ const defaultRoot =
341
+ raw.defaultRoot === "cwd" || raw.defaultRoot === "git" ? raw.defaultRoot : DEFAULT_ROOT
342
+ if (raw.defaultRoot !== defaultRoot) {
343
+ onWarning(
344
+ `house: ignoring invalid value ${JSON.stringify(raw.defaultRoot)} for defaultRoot in config/env; using "${DEFAULT_ROOT}"`,
345
+ )
346
+ }
347
+ const parsed = parseShowList(raw.show)
348
+ if (!parsed.ok) {
349
+ // Effect's `Config.ConfigError` requires a `SchemaError` or
350
+ // `SourceError` cause that we don't have a clean constructor
351
+ // for here surface as a plain Error and let the boot
352
+ // layer's existing `formatConfigError` (which already handles
353
+ // `instanceof Error`) render it.
354
+ return yield* Effect.fail(
355
+ new Error(
356
+ `show: unknown category "${parsed.invalid.join('", "')}" (valid: ${SHOW_CATEGORIES.join(", ")})`,
357
+ ),
358
+ )
359
+ }
360
+ const width = yield* parsePositiveInteger("width", raw.width)
361
+ const wrap = yield* parseBoolean("wrap", raw.wrap)
362
+ return {
363
+ theme: raw.theme,
364
+ tone: raw.tone,
365
+ defaultRoot,
366
+ width,
367
+ wrap,
368
+ extensions:
369
+ raw.extensions === ""
370
+ ? []
371
+ : raw.extensions
372
+ .split(",")
373
+ .map((s) => s.trim())
374
+ .filter(Boolean),
375
+ show: parsed.value,
376
+ focus: raw.focus,
377
+ }
378
+ }),
379
+ ),
312
380
  )
313
381
  }
package/src/index.tsx CHANGED
@@ -36,8 +36,6 @@ import { useUpdateNotice } from "./update/useUpdateNotice.ts"
36
36
  * `Quit` in Browser tears down the renderer and exits, which propagates
37
37
  * naturally — the cleanup effect still fires before process.exit completes.
38
38
  */
39
- export type SidebarMode = "auto" | "on" | "off"
40
-
41
39
  const pathIsDirectory = async (path: string): Promise<boolean> => {
42
40
  try {
43
41
  return (await stat(path)).isDirectory()
@@ -107,8 +105,8 @@ interface DiscoverShellProps {
107
105
  * independent everywhere else. */
108
106
  readonly initialShow: readonly ShowCategory[]
109
107
  readonly extensions: readonly string[]
110
- readonly maxWidth: number | null
111
- readonly sidebarMode: SidebarMode
108
+ readonly wrapWidth: number
109
+ readonly initialWrap: boolean
112
110
  readonly startupFocus: StartupFocus
113
111
  }
114
112
 
@@ -117,8 +115,8 @@ export const DiscoverShell = ({
117
115
  initialQuery,
118
116
  initialShow,
119
117
  extensions,
120
- maxWidth,
121
- sidebarMode,
118
+ wrapWidth,
119
+ initialWrap,
122
120
  startupFocus,
123
121
  }: DiscoverShellProps) => {
124
122
  const updateNotice = useUpdateNotice()
@@ -194,10 +192,10 @@ export const DiscoverShell = ({
194
192
  <Browser
195
193
  files={files}
196
194
  initialQuery={initialQuery}
197
- maxWidth={maxWidth}
195
+ wrapWidth={wrapWidth}
196
+ initialWrap={initialWrap}
198
197
  emptyRootLabel={target}
199
198
  discoveryStatus={discoveryStatus}
200
- sidebarMode={sidebarMode}
201
199
  startupFocus={startupFocus}
202
200
  updateNotice={updateNotice}
203
201
  onToggleAll={() => {
@@ -240,6 +238,14 @@ if (import.meta.main) {
240
238
  console.log(usage)
241
239
  process.exit(0)
242
240
  }
241
+ if (args.wrapConflict) {
242
+ console.error("house: --wrap and --no-wrap cannot be used together")
243
+ process.exit(2)
244
+ }
245
+ if (Bun.argv.slice(2).includes("--width") && args.width === null) {
246
+ console.error("house: --width requires a positive integer")
247
+ process.exit(2)
248
+ }
243
249
  if (args.version) {
244
250
  console.log(pkg.version)
245
251
  process.exit(0)
@@ -282,13 +288,34 @@ if (import.meta.main) {
282
288
  args.focus === "sidebar" || args.focus === "reader" || args.focus === "filter"
283
289
  ? args.focus
284
290
  : null,
291
+ width:
292
+ args.width === null
293
+ ? null
294
+ : (() => {
295
+ const n = Number.parseInt(args.width, 10)
296
+ if (!/^\d+$/.test(args.width) || !Number.isSafeInteger(n) || n <= 0) {
297
+ console.error(`house: --width must be a positive integer, got "${args.width}"`)
298
+ process.exit(2)
299
+ }
300
+ return n
301
+ })(),
302
+ wrap: args.wrap,
285
303
  },
286
304
  }),
287
305
  ).catch((err: unknown) => {
288
306
  console.error(`house: ${formatConfigError(err)}`)
289
307
  process.exit(2)
290
308
  })
291
- const { theme: themeId, tone, extensions, show, focus: startupFocus, defaultRoot } = config
309
+ const {
310
+ theme: themeId,
311
+ tone,
312
+ extensions,
313
+ show,
314
+ focus: startupFocus,
315
+ defaultRoot,
316
+ width: wrapWidth,
317
+ wrap: initialWrap,
318
+ } = config
292
319
  const themeDef = getThemeDefinition(themeId)
293
320
  if (themeDef === undefined) {
294
321
  // Unreachable: Config.schema validated themeId against themeDefinitions.
@@ -297,16 +324,6 @@ if (import.meta.main) {
297
324
  }
298
325
  setActiveTheme(themeDef, tone)
299
326
 
300
- let maxWidth: number | null = null
301
- if (args.width !== null) {
302
- const n = Number.parseInt(args.width, 10)
303
- if (!Number.isFinite(n) || n <= 0) {
304
- console.error(`house: --width must be a positive integer, got "${args.width}"`)
305
- process.exit(2)
306
- }
307
- maxWidth = n
308
- }
309
-
310
327
  const cwd = process.cwd()
311
328
  const discoveryRoot = await resolveDiscoveryRoot({ cliRoot: args.root, defaultRoot, cwd })
312
329
  const initialQuery = resolveInitialQuery({ pathArg: args.path, discoveryRoot, cwd })
@@ -349,14 +366,6 @@ if (import.meta.main) {
349
366
  process.on("SIGTERM", shutdown)
350
367
  // Bun.serve keeps the event loop alive until stop().
351
368
  } else {
352
- let sidebarMode: SidebarMode = "auto"
353
- if (args.sidebar !== null) {
354
- if (args.sidebar !== "auto" && args.sidebar !== "on" && args.sidebar !== "off") {
355
- console.error(`house: --sidebar must be "auto", "on", or "off", got "${args.sidebar}"`)
356
- process.exit(2)
357
- }
358
- sidebarMode = args.sidebar
359
- }
360
369
  if (args.focus !== null) {
361
370
  if (args.focus !== "sidebar" && args.focus !== "reader" && args.focus !== "filter") {
362
371
  console.error(
@@ -370,10 +379,10 @@ if (import.meta.main) {
370
379
  initialQuery,
371
380
  themeId,
372
381
  tone,
373
- maxWidth,
382
+ wrapWidth,
383
+ initialWrap,
374
384
  show,
375
385
  extensions,
376
- sidebarMode,
377
386
  startupFocus,
378
387
  updateCheck: !args.noUpdateCheck,
379
388
  })
@@ -385,10 +394,10 @@ interface TuiBootOptions {
385
394
  readonly initialQuery: string
386
395
  readonly themeId: string
387
396
  readonly tone: "dark" | "light"
388
- readonly maxWidth: number | null
397
+ readonly wrapWidth: number
398
+ readonly initialWrap: boolean
389
399
  readonly show: readonly ShowCategory[]
390
400
  readonly extensions: readonly string[]
391
- readonly sidebarMode: SidebarMode
392
401
  readonly startupFocus: StartupFocus
393
402
  /** Run the npm-registry probe and surface the "update available" notice.
394
403
  * False suppresses both the toast and the quit-time print. */
@@ -400,10 +409,10 @@ async function runTui({
400
409
  initialQuery,
401
410
  themeId,
402
411
  tone,
403
- maxWidth,
412
+ wrapWidth,
413
+ initialWrap,
404
414
  show,
405
415
  extensions,
406
- sidebarMode,
407
416
  startupFocus,
408
417
  updateCheck,
409
418
  }: TuiBootOptions): Promise<void> {
@@ -446,8 +455,8 @@ async function runTui({
446
455
  initialQuery={initialQuery}
447
456
  initialShow={show}
448
457
  extensions={extensions}
449
- maxWidth={maxWidth}
450
- sidebarMode={sidebarMode}
458
+ wrapWidth={wrapWidth}
459
+ initialWrap={initialWrap}
451
460
  startupFocus={startupFocus}
452
461
  />
453
462
  </RegistryProvider>,
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Best-effort clipboard write for macOS/Linux.
3
+ *
4
+ * The selected tool is fed through stdin so large or multiline markdown can be
5
+ * copied without shell quoting issues.
6
+ */
7
+
8
+ export interface ClipboardCommand {
9
+ readonly cmd: string
10
+ readonly args: readonly string[]
11
+ }
12
+
13
+ type WhichFn = (command: string) => string | null | undefined
14
+
15
+ interface CopyToClipboardDeps {
16
+ readonly platform?: NodeJS.Platform
17
+ readonly which?: WhichFn
18
+ readonly spawn?: (command: ClipboardCommand, text: string) => Promise<number>
19
+ }
20
+
21
+ export const resolveClipboardCommand = (
22
+ platform: NodeJS.Platform,
23
+ which: WhichFn,
24
+ ): ClipboardCommand | null => {
25
+ if (platform === "darwin") return { cmd: "pbcopy", args: [] }
26
+ if (platform !== "linux") return null
27
+ if (which("wl-copy")) return { cmd: "wl-copy", args: [] }
28
+ if (which("xclip")) return { cmd: "xclip", args: ["-selection", "clipboard"] }
29
+ return null
30
+ }
31
+
32
+ const spawnClipboardCommand = async (command: ClipboardCommand, text: string): Promise<number> => {
33
+ const proc = Bun.spawn([command.cmd, ...command.args], {
34
+ stdin: new Response(text),
35
+ stdout: "ignore",
36
+ stderr: "ignore",
37
+ })
38
+ return await proc.exited
39
+ }
40
+
41
+ export const copyTextToClipboard = async (
42
+ text: string,
43
+ {
44
+ platform = process.platform,
45
+ which = Bun.which.bind(Bun),
46
+ spawn = spawnClipboardCommand,
47
+ }: CopyToClipboardDeps = {},
48
+ ): Promise<void> => {
49
+ const command = resolveClipboardCommand(platform, which)
50
+ if (!command) throw new Error("no clipboard tool found")
51
+ const exitCode = await spawn(command, text)
52
+ if (exitCode !== 0) throw new Error("clipboard command failed")
53
+ }
@@ -23,6 +23,7 @@ export interface BrowserCtx {
23
23
  * hint gate so the hint only appears when there is something to clear. */
24
24
  readonly filterQuery: string
25
25
  readonly paletteOpen: boolean
26
+ readonly wrapEnabled: boolean
26
27
  readonly setFocus: (next: BrowserFocus | ((prev: BrowserFocus) => BrowserFocus)) => void
27
28
  readonly setSelectedIndex: (updater: (prev: number) => number) => void
28
29
  /** Toggle `shown` and adjust focus per DESIGN.md §7.1 (see s-behavior table). */
@@ -33,6 +34,7 @@ export interface BrowserCtx {
33
34
  * without first reopening with `/` and backspacing. */
34
35
  readonly clearAndOpenFilter: () => void
35
36
  readonly openPalette: () => void
37
+ readonly toggleWrap: () => void
36
38
  readonly cycleTheme: (delta: 1 | -1) => void
37
39
  readonly toggleTone: () => void
38
40
  readonly quit: () => void
@@ -41,6 +43,10 @@ export interface BrowserCtx {
41
43
  /** Suspend the TUI, hand the TTY to `$EDITOR`, resume and re-read on
42
44
  * exit. No-op when nothing is selected; gating is the binding's job. */
43
45
  readonly editCurrent: () => void
46
+ /** Copy the currently-selected file's raw contents to the system
47
+ * clipboard. No-op when nothing is selected; palette gating is the
48
+ * primary availability control. */
49
+ readonly copyCurrentContents: () => void
44
50
  /** Toggle hidden + gitignored discovery axes together (#145 — UI sugar
45
51
  * for `shift+a`; the underlying flags stay independent everywhere
46
52
  * else). Snapshots the current selected path so it can be restored
@@ -162,6 +168,15 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
162
168
  // does not write back to the TOML config.
163
169
  run: (c) => c.toggleAll(),
164
170
  },
171
+ {
172
+ id: "reader.wrap.toggle",
173
+ group: "Global",
174
+ description: "Toggle reader wrap",
175
+ hint: "wrap",
176
+ keys: ["w"],
177
+ hintWhen: inputClosed,
178
+ run: (c) => c.toggleWrap(),
179
+ },
165
180
  {
166
181
  id: "theme.next",
167
182
  group: "Global",
@@ -50,11 +50,3 @@ export const defaultPreferredWidth = (viewport: number): number =>
50
50
  */
51
51
  export const canFitInline = (viewport: number): boolean =>
52
52
  viewport >= SIDEBAR_MIN_WIDTH + DIVIDER_WIDTH + READER_MIN_WIDTH
53
-
54
- /**
55
- * Initial sidebar visibility for `--sidebar=auto`. Always true — every
56
- * viewport now boots on the sidebar (narrow: as the single visible screen;
57
- * wide: as the focused inline pane). `--sidebar=off` is the only way to
58
- * boot directly into the reader.
59
- */
60
- export const initialShownForAuto = (_viewport: number): boolean => true