@carlesandres/house 0.4.13 → 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/CHANGELOG.md CHANGED
@@ -6,6 +6,12 @@ The publish workflow (`.github/workflows/publish.yml`) runs on the `release: pub
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.14] — 2026-06-15
10
+
11
+ ### Added
12
+
13
+ - Browser header now shows the active discovery root, and empty states reuse the same canonical root label for consistent launch context.
14
+
9
15
  ## [0.4.13] — 2026-06-15
10
16
 
11
17
  ### Added
@@ -333,7 +339,8 @@ The v1 MVP, published as `@carlesandres/openmdr` on npm.
333
339
 
334
340
  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.
335
341
 
336
- [Unreleased]: https://github.com/carlesandres/house/compare/v0.4.13...HEAD
342
+ [Unreleased]: https://github.com/carlesandres/house/compare/v0.4.14...HEAD
343
+ [0.4.14]: https://github.com/carlesandres/house/compare/v0.4.13...v0.4.14
337
344
  [0.4.13]: https://github.com/carlesandres/house/compare/v0.4.12...v0.4.13
338
345
  [0.4.12]: https://github.com/carlesandres/house/compare/v0.4.11...v0.4.12
339
346
  [0.4.11]: https://github.com/carlesandres/house/compare/v0.4.10...v0.4.11
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carlesandres/house",
3
- "version": "0.4.13",
3
+ "version": "0.4.14",
4
4
  "description": "TUI-first markdown reader on opentui",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/Browser.tsx CHANGED
@@ -55,8 +55,8 @@ export interface BrowserProps {
55
55
  readonly wrapWidth?: number
56
56
  /** Initial reader wrap mode. Runtime toggles are session-only. */
57
57
  readonly initialWrap?: boolean
58
- /** Discovery root label used in the post-discovery empty-vault sidebar row. */
59
- readonly emptyRootLabel?: string
58
+ /** Canonical discovery root label used anywhere the UI names the scan scope. */
59
+ readonly rootLabel?: string
60
60
  /** Persistent footer indicator (e.g. "indexing… 42"). Pass null/undefined
61
61
  * when discovery has finished; the indicator clears. */
62
62
  readonly discoveryStatus?: string | null
@@ -187,7 +187,7 @@ export const Browser = ({
187
187
  initialQuery = "",
188
188
  wrapWidth = 80,
189
189
  initialWrap = false,
190
- emptyRootLabel = "current root",
190
+ rootLabel = "current root",
191
191
  discoveryStatus = null,
192
192
  discoverySpinnerIntervalMs,
193
193
  discoverySpinnerInitialFrameIndex,
@@ -990,7 +990,7 @@ export const Browser = ({
990
990
  : "No markdown files in"
991
991
  : "No files match"
992
992
  }
993
- value={files.length === 0 ? (discoveryActive ? "…" : emptyRootLabel) : filterApplied}
993
+ value={files.length === 0 ? (discoveryActive ? "…" : rootLabel) : filterApplied}
994
994
  />
995
995
  ) : (
996
996
  visibleFiles.map((file, idx) => {
@@ -1044,7 +1044,7 @@ export const Browser = ({
1044
1044
  <box
1045
1045
  style={{ width, height, flexDirection: "column", backgroundColor: colors.backgroundPanel }}
1046
1046
  >
1047
- <Header width={width} currentFile={currentFile} />
1047
+ <Header width={width} currentFile={currentFile} rootLabel={rootLabel} />
1048
1048
  <box
1049
1049
  style={{
1050
1050
  flexDirection: "row",
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,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
+ }
package/src/index.tsx CHANGED
@@ -2,6 +2,7 @@
2
2
  /** house — entry point. Boots the browser TUI or `--serve` preview. */
3
3
 
4
4
  import { stat } from "node:fs/promises"
5
+ import { homedir } from "node:os"
5
6
  import { dirname, isAbsolute, relative, resolve } from "node:path"
6
7
  import { createCliRenderer } from "@opentui/core"
7
8
  import { createRoot } from "@opentui/react"
@@ -13,6 +14,7 @@ import { Browser, type StartupFocus } from "./Browser.tsx"
13
14
  import { parseArgv, usage } from "./cli/argv.ts"
14
15
  import { defaultConfigPath, formatConfigError, loadConfig } from "./config/load.ts"
15
16
  import { parseShowList, SHOW_CATEGORIES, type ShowCategory } from "./discovery/show.ts"
17
+ import { formatDiscoveryRootLabel } from "./discovery/rootLabel.ts"
16
18
  import { walk, type FileEntry } from "./discovery/walk.ts"
17
19
  import { openInBrowser } from "./serve/openBrowser.ts"
18
20
  import { startServer } from "./serve/server.ts"
@@ -64,7 +66,7 @@ export const resolveDiscoveryRoot = async ({
64
66
  readonly defaultRoot: "cwd" | "git"
65
67
  readonly cwd: string
66
68
  }): Promise<string> => {
67
- if (cliRoot !== null) return cliRoot
69
+ if (cliRoot !== null) return resolve(cwd, cliRoot)
68
70
  if (defaultRoot === "git") return findGitRoot(cwd)
69
71
  return cwd
70
72
  }
@@ -104,6 +106,7 @@ interface DiscoverShellProps {
104
106
  * and the full vocabulary; the underlying categories remain
105
107
  * independent everywhere else. */
106
108
  readonly initialShow: readonly ShowCategory[]
109
+ readonly rootLabel?: string
107
110
  readonly extensions: readonly string[]
108
111
  readonly wrapWidth: number
109
112
  readonly initialWrap: boolean
@@ -114,6 +117,7 @@ export const DiscoverShell = ({
114
117
  target,
115
118
  initialQuery,
116
119
  initialShow,
120
+ rootLabel = target,
117
121
  extensions,
118
122
  wrapWidth,
119
123
  initialWrap,
@@ -194,7 +198,7 @@ export const DiscoverShell = ({
194
198
  initialQuery={initialQuery}
195
199
  wrapWidth={wrapWidth}
196
200
  initialWrap={initialWrap}
197
- emptyRootLabel={target}
201
+ rootLabel={rootLabel}
198
202
  discoveryStatus={discoveryStatus}
199
203
  startupFocus={startupFocus}
200
204
  updateNotice={updateNotice}
@@ -454,6 +458,7 @@ async function runTui({
454
458
  target={discoveryRoot}
455
459
  initialQuery={initialQuery}
456
460
  initialShow={show}
461
+ rootLabel={formatDiscoveryRootLabel({ discoveryRoot, home: homedir() })}
457
462
  extensions={extensions}
458
463
  wrapWidth={wrapWidth}
459
464
  initialWrap={initialWrap}