@carlesandres/house 0.4.12 → 0.4.14

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/src/Header.tsx CHANGED
@@ -5,20 +5,113 @@
5
5
  * current filename on the left, version on the right. The row is
6
6
  * informational, not interactive — see issue #38 for the design discussion.
7
7
  *
8
- * Width degrades gracefully: the version drops first when the row gets
9
- * tight, then the filename, leaving the brand mark as the irreducible
10
- * identity element. Always rendered — the row is worth one cell on any
11
- * viewport so the user never loses the filename indicator (notably, when
12
- * the sidebar drawer overlays the reader on narrow viewports).
8
+ * Width degrades gracefully: the discovery root truncates/drops first, then
9
+ * the wordmark, then the version, then the filename truncates, leaving the
10
+ * brand mark as the irreducible identity element. Always rendered — the row is
11
+ * worth one cell on any viewport so the user never loses the filename/root
12
+ * indicator (notably, when the sidebar drawer overlays the reader on narrow
13
+ * viewports).
13
14
  */
14
15
 
15
16
  import pkg from "../package.json" with { type: "json" }
16
17
  import { BRAND, BRAND_NAME } from "./brand.ts"
17
18
  import { colors } from "./theme/colors.ts"
19
+ import { middleTruncate } from "./ui/middleTruncate.ts"
18
20
 
19
21
  export const HEADER_HEIGHT = 1
20
22
 
21
23
  const FILE_SEPARATOR = " · "
24
+ const HEADER_HORIZONTAL_PADDING = 2
25
+ const HEADER_GROUP_GAP = 1
26
+ const MIN_TRUNCATED_ROOT_WIDTH = 5
27
+
28
+ export interface HeaderSegment {
29
+ readonly id: "brand" | "file" | "root"
30
+ readonly text: string
31
+ readonly tone: "brand" | "primary" | "muted"
32
+ }
33
+
34
+ export interface HeaderLayout {
35
+ readonly left: readonly HeaderSegment[]
36
+ readonly right: string | null
37
+ }
38
+
39
+ export interface HeaderLayoutInput {
40
+ readonly width: number
41
+ readonly currentFile?: string | null | undefined
42
+ readonly rootLabel?: string | null | undefined
43
+ readonly version: string
44
+ }
45
+
46
+ export const layoutHeaderSegments = ({
47
+ width,
48
+ currentFile,
49
+ rootLabel,
50
+ version,
51
+ }: HeaderLayoutInput): HeaderLayout => {
52
+ const usableWidth = Math.max(0, width - HEADER_HORIZONTAL_PADDING)
53
+ const file = currentFile && currentFile.length > 0 ? currentFile : null
54
+ const root = rootLabel && rootLabel.length > 0 ? rootLabel : null
55
+ const right = `v${version}`
56
+ const brand: HeaderSegment = { id: "brand", text: `${BRAND} ${BRAND_NAME}`, tone: "brand" }
57
+ const iconBrand: HeaderSegment = { id: "brand", text: BRAND, tone: "brand" }
58
+ const fileSegment: HeaderSegment | null =
59
+ file === null ? null : { id: "file", text: file, tone: "primary" }
60
+ const rootSegment: HeaderSegment | null =
61
+ root === null ? null : { id: "root", text: root, tone: "muted" }
62
+ const segments = [brand, ...(fileSegment === null ? [] : [fileSegment])]
63
+
64
+ if (rootSegment !== null && fits([...segments, rootSegment], right, usableWidth)) {
65
+ return { left: [...segments, rootSegment], right }
66
+ }
67
+
68
+ const rootWidth =
69
+ rootSegment === null
70
+ ? 0
71
+ : usableWidth -
72
+ joinedLength(segments) -
73
+ FILE_SEPARATOR.length -
74
+ HEADER_GROUP_GAP -
75
+ right.length
76
+ if (root !== null && root.length > rootWidth && rootWidth >= MIN_TRUNCATED_ROOT_WIDTH) {
77
+ return {
78
+ left: [...segments, { id: "root", text: middleTruncate(root, rootWidth), tone: "muted" }],
79
+ right,
80
+ }
81
+ }
82
+
83
+ if (fits(segments, right, usableWidth)) return { left: segments, right }
84
+
85
+ const iconSegments = [iconBrand, ...(fileSegment === null ? [] : [fileSegment])]
86
+ if (fits(iconSegments, right, usableWidth)) return { left: iconSegments, right }
87
+ if (fits(iconSegments, null, usableWidth)) return { left: iconSegments, right: null }
88
+ if (file !== null) {
89
+ const fileWidth = usableWidth - BRAND.length - FILE_SEPARATOR.length
90
+ if (fileWidth > 0) {
91
+ return {
92
+ left: [iconBrand, { id: "file", text: middleTruncate(file, fileWidth), tone: "primary" }],
93
+ right: null,
94
+ }
95
+ }
96
+ }
97
+
98
+ return { left: [iconBrand], right: null }
99
+ }
100
+
101
+ const joinedLength = (segments: readonly HeaderSegment[]): number =>
102
+ segments.reduce(
103
+ (total, segment, idx) => total + segment.text.length + (idx === 0 ? 0 : FILE_SEPARATOR.length),
104
+ 0,
105
+ )
106
+
107
+ const fits = (
108
+ segments: readonly HeaderSegment[],
109
+ right: string | null,
110
+ usableWidth: number,
111
+ ): boolean => {
112
+ const rightWidth = right === null ? 0 : HEADER_GROUP_GAP + right.length
113
+ return joinedLength(segments) + rightWidth <= usableWidth
114
+ }
22
115
 
23
116
  export interface HeaderProps {
24
117
  readonly width: number
@@ -26,27 +119,15 @@ export interface HeaderProps {
26
119
  * it next to the brand mark — replaces the per-pane border title that
27
120
  * used to carry this information. */
28
121
  readonly currentFile?: string | null
122
+ /** Canonical discovery root / scan-scope label. */
123
+ readonly rootLabel?: string | null
29
124
  /** Optional override for the version string (testing). Defaults to
30
125
  * the running package's version. */
31
126
  readonly version?: string
32
127
  }
33
128
 
34
- export const Header = ({ width, currentFile, version = pkg.version }: HeaderProps) => {
35
- const brand = `${BRAND} ${BRAND_NAME}`
36
- const right = `v${version}`
37
- const file = currentFile && currentFile.length > 0 ? currentFile : null
38
- const usableWidth = Math.max(0, width - 2) // 1-cell horizontal padding each side
39
-
40
- // Priority: brand > filename > version. Brand is the irreducible identity
41
- // anchor. Filename is per-selection useful info — keep it before the
42
- // largely-static version string. `1` is the minimum gap between left and
43
- // right groups so they never visually collide.
44
- const leftWithFile = file !== null ? `${brand}${FILE_SEPARATOR}${file}` : brand
45
- const showFileWithVersion = leftWithFile.length + 1 + right.length <= usableWidth
46
- const showFileWithoutVersion = leftWithFile.length <= usableWidth
47
- const showFile = file !== null && (showFileWithVersion || showFileWithoutVersion)
48
- const left = showFile ? leftWithFile : brand
49
- const showRight = left.length + 1 + right.length <= usableWidth
129
+ export const Header = ({ width, currentFile, rootLabel, version = pkg.version }: HeaderProps) => {
130
+ const layout = layoutHeaderSegments({ width, currentFile, rootLabel, version })
50
131
 
51
132
  return (
52
133
  <box
@@ -62,10 +143,18 @@ export const Header = ({ width, currentFile, version = pkg.version }: HeaderProp
62
143
  }}
63
144
  >
64
145
  <text wrapMode="none">
65
- <span style={{ fg: colors.text }}>{brand}</span>
66
- {showFile && <span style={{ fg: colors.textMuted }}>{`${FILE_SEPARATOR}${file}`}</span>}
146
+ {layout.left.map((segment, idx) => (
147
+ <span
148
+ key={`${segment.id}-${idx}`}
149
+ style={{ fg: segment.tone === "muted" ? colors.textMuted : colors.text }}
150
+ >
151
+ {`${idx === 0 ? "" : FILE_SEPARATOR}${segment.text}`}
152
+ </span>
153
+ ))}
67
154
  </text>
68
- {showRight && <text content={right} wrapMode="none" style={{ fg: colors.text }} />}
155
+ {layout.right !== null && (
156
+ <text content={layout.right} wrapMode="none" style={{ fg: colors.text }} />
157
+ )}
69
158
  </box>
70
159
  )
71
160
  }
@@ -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
  }
@@ -0,0 +1,18 @@
1
+ import { isAbsolute, relative } from "node:path"
2
+
3
+ export interface DiscoveryRootLabelInput {
4
+ readonly discoveryRoot: string
5
+ readonly home: string
6
+ }
7
+
8
+ export const formatDiscoveryRootLabel = ({
9
+ discoveryRoot,
10
+ home,
11
+ }: DiscoveryRootLabelInput): string => {
12
+ if (discoveryRoot === home) return "~"
13
+ const homeRelative = relative(home, discoveryRoot)
14
+ if (homeRelative.length > 0 && !homeRelative.startsWith("..") && !isAbsolute(homeRelative)) {
15
+ return `~/${homeRelative}`
16
+ }
17
+ return discoveryRoot
18
+ }