@carlesandres/house 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +179 -0
  2. package/LICENSE +21 -0
  3. package/README.md +99 -0
  4. package/package.json +67 -0
  5. package/src/Browser.tsx +472 -0
  6. package/src/Footer.tsx +151 -0
  7. package/src/HelpOverlay.tsx +130 -0
  8. package/src/cli/argv.ts +106 -0
  9. package/src/discovery/filter.ts +56 -0
  10. package/src/discovery/walk.ts +143 -0
  11. package/src/index.tsx +265 -0
  12. package/src/io/readFile.ts +14 -0
  13. package/src/keymap/browser.ts +229 -0
  14. package/src/keymap/keymap.ts +86 -0
  15. package/src/serve/css.ts +120 -0
  16. package/src/serve/openBrowser.ts +19 -0
  17. package/src/serve/render.ts +56 -0
  18. package/src/serve/server.ts +163 -0
  19. package/src/theme/atom.ts +23 -0
  20. package/src/theme/colors.ts +79 -0
  21. package/src/theme/loader.ts +110 -0
  22. package/src/theme/registry.ts +12 -0
  23. package/src/theme/resolve.ts +168 -0
  24. package/src/theme/themes/aura.json +58 -0
  25. package/src/theme/themes/ayu.json +69 -0
  26. package/src/theme/themes/carbonfox.json +201 -0
  27. package/src/theme/themes/catppuccin-frappe.json +186 -0
  28. package/src/theme/themes/catppuccin-macchiato.json +186 -0
  29. package/src/theme/themes/catppuccin.json +212 -0
  30. package/src/theme/themes/cobalt2.json +181 -0
  31. package/src/theme/themes/cursor.json +202 -0
  32. package/src/theme/themes/dracula.json +172 -0
  33. package/src/theme/themes/everforest.json +194 -0
  34. package/src/theme/themes/flexoki.json +190 -0
  35. package/src/theme/themes/github.json +186 -0
  36. package/src/theme/themes/gruvbox.json +195 -0
  37. package/src/theme/themes/kanagawa.json +180 -0
  38. package/src/theme/themes/lucent-orng.json +186 -0
  39. package/src/theme/themes/material.json +188 -0
  40. package/src/theme/themes/matrix.json +180 -0
  41. package/src/theme/themes/mercury.json +198 -0
  42. package/src/theme/themes/monokai.json +174 -0
  43. package/src/theme/themes/nightowl.json +174 -0
  44. package/src/theme/themes/nord.json +176 -0
  45. package/src/theme/themes/one-dark.json +184 -0
  46. package/src/theme/themes/opencode.json +198 -0
  47. package/src/theme/themes/orng.json +202 -0
  48. package/src/theme/themes/osaka-jade.json +193 -0
  49. package/src/theme/themes/palenight.json +175 -0
  50. package/src/theme/themes/rosepine.json +187 -0
  51. package/src/theme/themes/solarized.json +176 -0
  52. package/src/theme/themes/synthwave84.json +179 -0
  53. package/src/theme/themes/tokyonight.json +196 -0
  54. package/src/theme/themes/vercel.json +198 -0
  55. package/src/theme/themes/vesper.json +171 -0
  56. package/src/theme/themes/zenburn.json +176 -0
  57. package/src/theme/types.ts +109 -0
@@ -0,0 +1,130 @@
1
+ /**
2
+ * HelpOverlay — modal panel listing the keymap.
3
+ *
4
+ * Renders absolute-positioned over the rest of the UI. Iterates the
5
+ * KeyBinding[] array directly; there is no separate hand-written list of
6
+ * keys — the dispatcher and the help text are the same source of truth.
7
+ */
8
+
9
+ import type { KeyBinding } from "./keymap/keymap.ts"
10
+ import { colors } from "./theme/colors.ts"
11
+
12
+ export interface HelpOverlayProps<C> {
13
+ readonly bindings: readonly KeyBinding<C>[]
14
+ readonly viewportWidth: number
15
+ readonly viewportHeight: number
16
+ }
17
+
18
+ const formatKeys = (keys: readonly string[]): string => keys.join(", ")
19
+
20
+ interface Row {
21
+ readonly key: string
22
+ readonly text: string
23
+ readonly kind: "header" | "binding" | "spacer" | "footer"
24
+ }
25
+
26
+ const buildRows = <C,>(bindings: readonly KeyBinding<C>[]): Row[] => {
27
+ const order: string[] = []
28
+ const grouped = new Map<string, KeyBinding<C>[]>()
29
+ for (const b of bindings) {
30
+ if (!b.group) continue
31
+ if (!grouped.has(b.group)) {
32
+ grouped.set(b.group, [])
33
+ order.push(b.group)
34
+ }
35
+ grouped.get(b.group)!.push(b)
36
+ }
37
+
38
+ // Width of the keys column: longest formatted-keys string across all
39
+ // shown bindings, plus a small gap before descriptions.
40
+ let keyColumn = 0
41
+ for (const list of grouped.values()) {
42
+ for (const b of list) keyColumn = Math.max(keyColumn, formatKeys(b.keys).length)
43
+ }
44
+ const gap = 2
45
+ const padTo = keyColumn + gap
46
+
47
+ const rows: Row[] = []
48
+ for (let i = 0; i < order.length; i++) {
49
+ const group = order[i]!
50
+ // A spacer before *every* group, including the first. Without one
51
+ // before the first, opentui collapses the header onto the first
52
+ // binding row — appears to be an interaction between the title
53
+ // border and the first child of a padded column.
54
+ rows.push({ key: `spacer-before-${group}`, text: " ", kind: "spacer" })
55
+ rows.push({ key: `header-${group}`, text: group, kind: "header" })
56
+ for (const b of grouped.get(group)!) {
57
+ rows.push({
58
+ key: `binding-${b.id}`,
59
+ text: ` ${formatKeys(b.keys).padEnd(padTo)}${b.description}`,
60
+ kind: "binding",
61
+ })
62
+ }
63
+ }
64
+ rows.push({ key: "spacer-footer", text: " ", kind: "spacer" })
65
+ rows.push({ key: "footer", text: "press ? or esc to dismiss", kind: "footer" })
66
+ return rows
67
+ }
68
+
69
+ export const HelpOverlay = <C,>({
70
+ bindings,
71
+ viewportWidth,
72
+ viewportHeight,
73
+ }: HelpOverlayProps<C>) => {
74
+ const rows = buildRows(bindings)
75
+
76
+ const overlayWidth = Math.min(viewportWidth - 4, 64)
77
+ const desiredHeight = rows.length + 2 // border top + bottom
78
+ const overlayHeight = Math.min(viewportHeight - 4, desiredHeight + 2) // +2 for vertical padding
79
+ const left = Math.max(0, Math.floor((viewportWidth - overlayWidth) / 2))
80
+ const top = Math.max(0, Math.floor((viewportHeight - overlayHeight) / 2))
81
+
82
+ return (
83
+ <box
84
+ position="absolute"
85
+ left={left}
86
+ top={top}
87
+ width={overlayWidth}
88
+ height={overlayHeight}
89
+ zIndex={10}
90
+ title=" Help "
91
+ titleAlignment="left"
92
+ style={{
93
+ border: true,
94
+ borderColor: colors.borderActive,
95
+ padding: 1,
96
+ flexDirection: "column",
97
+ backgroundColor: colors.surface,
98
+ }}
99
+ >
100
+ {rows.map((row) => {
101
+ switch (row.kind) {
102
+ case "header":
103
+ return (
104
+ <text
105
+ key={row.key}
106
+ wrapMode="none"
107
+ content={row.text}
108
+ style={{ fg: colors.borderActive, attributes: 1 /* bold */ }}
109
+ />
110
+ )
111
+ case "footer":
112
+ return (
113
+ <text
114
+ key={row.key}
115
+ wrapMode="none"
116
+ content={row.text}
117
+ style={{ fg: colors.textMuted }}
118
+ />
119
+ )
120
+ case "spacer":
121
+ return <text key={row.key} content=" " />
122
+ case "binding":
123
+ return (
124
+ <text key={row.key} wrapMode="none" content={row.text} style={{ fg: colors.text }} />
125
+ )
126
+ }
127
+ })}
128
+ </box>
129
+ )
130
+ }
@@ -0,0 +1,106 @@
1
+ import { themeDefinitions } from "../theme/registry.ts"
2
+
3
+ export interface ParsedArgs {
4
+ /** First positional argument, or null if none was given. */
5
+ readonly path: string | null
6
+ /** Value of `--theme <id>`, or null. Validated by the boot layer against the registry. */
7
+ readonly theme: string | null
8
+ /** Value of `--tone dark|light`, or null. Validated by the boot layer. */
9
+ readonly tone: string | null
10
+ /** Value of `--width <N>`, or null. Validated by the boot layer (must be a positive integer). */
11
+ readonly width: string | null
12
+ /** True when `--all` was passed: include hidden + gitignored files in discovery. */
13
+ readonly all: boolean
14
+ /** Value of `--sort <mode>` (`dirs-first` or `files-first`), or null. Validated by the boot layer. */
15
+ readonly sort: string | null
16
+ /** True when `--serve` was passed: serve the given file as HTML, skip TUI. */
17
+ readonly serve: boolean
18
+ /** Value of `--port <N>`, or null. Validated by the boot layer. */
19
+ readonly port: string | null
20
+ /** True when `--help` was passed. */
21
+ readonly help: boolean
22
+ /** True when `--version` was passed. */
23
+ readonly version: boolean
24
+ }
25
+
26
+ /**
27
+ * Minimal argv parser.
28
+ *
29
+ * Does not validate flag values — boot layers do, so error messages can
30
+ * reference domain knowledge (registered themes, valid integer ranges)
31
+ * without coupling the parser to it.
32
+ */
33
+ export const parseArgv = (argv: readonly string[]): ParsedArgs => {
34
+ let path: string | null = null
35
+ let theme: string | null = null
36
+ let tone: string | null = null
37
+ let width: string | null = null
38
+ let all = false
39
+ let sort: string | null = null
40
+ let serve = false
41
+ let port: string | null = null
42
+ let help = false
43
+ let version = false
44
+
45
+ for (let i = 0; i < argv.length; i++) {
46
+ const arg = argv[i]!
47
+ switch (arg) {
48
+ case "--theme":
49
+ theme = argv[i + 1] ?? null
50
+ i++
51
+ continue
52
+ case "--tone":
53
+ tone = argv[i + 1] ?? null
54
+ i++
55
+ continue
56
+ case "--width":
57
+ width = argv[i + 1] ?? null
58
+ i++
59
+ continue
60
+ case "--all":
61
+ all = true
62
+ continue
63
+ case "--sort":
64
+ sort = argv[i + 1] ?? null
65
+ i++
66
+ continue
67
+ case "--serve":
68
+ serve = true
69
+ continue
70
+ case "--port":
71
+ port = argv[i + 1] ?? null
72
+ i++
73
+ continue
74
+ case "--help":
75
+ case "-h":
76
+ help = true
77
+ continue
78
+ case "--version":
79
+ case "-v":
80
+ version = true
81
+ continue
82
+ }
83
+ if (path === null && !arg.startsWith("-")) {
84
+ path = arg
85
+ }
86
+ }
87
+
88
+ return { path, theme, tone, width, all, sort, serve, port, help, version }
89
+ }
90
+
91
+ const themeList = themeDefinitions.map((t) => t.id).join(", ")
92
+
93
+ export const usage = `usage: house [path] [options]
94
+
95
+ path file or directory; defaults to the current directory
96
+
97
+ options:
98
+ --theme <id> color theme: ${themeList} (default: opencode)
99
+ --tone <mode> dark or light (default: dark)
100
+ --width <N> cap rendered markdown width at N columns
101
+ --all include hidden and gitignored files in discovery
102
+ --sort <mode> sidebar order: dirs-first (default) or files-first
103
+ --serve serve the given file as HTML in the browser (skips TUI)
104
+ --port <N> port for --serve (default: OS-assigned)
105
+ -h, --help show this help and exit
106
+ -v, --version print version and exit`
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Fuzzy filter for the sidebar.
3
+ *
4
+ * Matching: case-insensitive subsequence on `relativePath`. A query "drm"
5
+ * matches "docs/readme.md". Scoring (higher is better):
6
+ * - +10 for a match at the start of the string or right after `/`
7
+ * (word boundary — what the user typed lines up with a path segment)
8
+ * - +5 when the current match is adjacent to the previous one
9
+ * (consecutive runs read as "drm" matching the literal substring)
10
+ * - +1 otherwise
11
+ *
12
+ * The scorer is intentionally tiny — the only goal is to surface the
13
+ * "obvious" match for short queries against a few hundred paths. A full
14
+ * fzf-style scorer (with bonuses for camelCase, separators, etc.) is
15
+ * deferred until the simple version proves insufficient.
16
+ */
17
+
18
+ import type { FileEntry } from "./walk.ts"
19
+
20
+ export const fuzzyScore = (query: string, target: string): number | null => {
21
+ if (query.length === 0) return 0
22
+ const q = query.toLowerCase()
23
+ const t = target.toLowerCase()
24
+ let qi = 0
25
+ let score = 0
26
+ let lastMatch = -2
27
+ for (let i = 0; i < t.length && qi < q.length; i++) {
28
+ if (t[i] !== q[qi]) continue
29
+ const isWordStart = i === 0 || t[i - 1] === "/"
30
+ score += isWordStart ? 10 : 1
31
+ if (lastMatch === i - 1) score += 5
32
+ lastMatch = i
33
+ qi++
34
+ }
35
+ if (qi < q.length) return null
36
+ return score
37
+ }
38
+
39
+ /**
40
+ * Filter and re-rank a file list by a query. Empty query returns the input
41
+ * unchanged (preserves the discovery sort order). Non-empty query keeps
42
+ * matches only, sorted by score desc; ties fall back to the input order so
43
+ * the discovery sort still leaks through.
44
+ */
45
+ export const filterFiles = (files: readonly FileEntry[], query: string): readonly FileEntry[] => {
46
+ if (query.length === 0) return files
47
+ const scored: { file: FileEntry; score: number; index: number }[] = []
48
+ for (let i = 0; i < files.length; i++) {
49
+ const file = files[i]!
50
+ const score = fuzzyScore(query, file.relativePath)
51
+ if (score === null) continue
52
+ scored.push({ file, score, index: i })
53
+ }
54
+ scored.sort((a, b) => b.score - a.score || a.index - b.index)
55
+ return scored.map((s) => s.file)
56
+ }
@@ -0,0 +1,143 @@
1
+ import { readdir, readFile } from "node:fs/promises"
2
+ import { extname, join, relative, resolve } from "node:path"
3
+ import { Data, Effect } from "effect"
4
+ import ignore, { type Ignore } from "ignore"
5
+
6
+ export interface FileEntry {
7
+ /** Absolute path on disk. */
8
+ readonly path: string
9
+ /** Path relative to the discovery root, with forward slashes. */
10
+ readonly relativePath: string
11
+ /** File basename. */
12
+ readonly name: string
13
+ }
14
+
15
+ export type SortOrder = "dirs-first" | "files-first"
16
+
17
+ export interface WalkOptions {
18
+ /** Include hidden files and gitignored entries. Hard skips still apply. */
19
+ readonly all?: boolean
20
+ /** Group order within each directory. Default `dirs-first`. */
21
+ readonly sort?: SortOrder
22
+ }
23
+
24
+ export class DiscoveryError extends Data.TaggedError("DiscoveryError")<{
25
+ readonly root: string
26
+ readonly cause: unknown
27
+ }> {}
28
+
29
+ const MARKDOWN_EXTENSIONS = new Set([".md", ".markdown", ".mdx"])
30
+ const HARD_SKIP_DIRS = new Set(["node_modules", ".git", ".venv"])
31
+
32
+ interface IgnoreLevel {
33
+ readonly dir: string
34
+ readonly ig: Ignore
35
+ }
36
+
37
+ const isIgnored = (
38
+ entryPath: string,
39
+ isDirectory: boolean,
40
+ levels: readonly IgnoreLevel[],
41
+ ): boolean => {
42
+ for (const { dir, ig } of levels) {
43
+ const rel = relative(dir, entryPath)
44
+ if (!rel || rel.startsWith("..")) continue
45
+ const candidate = isDirectory ? `${rel}/` : rel
46
+ if (ig.ignores(candidate)) return true
47
+ }
48
+ return false
49
+ }
50
+
51
+ const tryLoadGitignore = async (dir: string): Promise<Ignore | null> => {
52
+ try {
53
+ const content = await readFile(join(dir, ".gitignore"), "utf8")
54
+ return ignore().add(content)
55
+ } catch {
56
+ return null
57
+ }
58
+ }
59
+
60
+ const sortEntries = <T extends { name: string; isDirectory: () => boolean }>(
61
+ entries: readonly T[],
62
+ order: SortOrder,
63
+ ): T[] =>
64
+ [...entries].sort((a, b) => {
65
+ const aDir = a.isDirectory()
66
+ const bDir = b.isDirectory()
67
+ if (aDir !== bDir) {
68
+ if (order === "files-first") return aDir ? 1 : -1
69
+ return aDir ? -1 : 1
70
+ }
71
+ return a.name.localeCompare(b.name)
72
+ })
73
+
74
+ const walkDir = async (
75
+ dirPath: string,
76
+ rootPath: string,
77
+ parentLevels: readonly IgnoreLevel[],
78
+ results: FileEntry[],
79
+ opts: { all: boolean; sort: SortOrder },
80
+ ): Promise<void> => {
81
+ let levels = parentLevels
82
+ if (!opts.all) {
83
+ const ig = await tryLoadGitignore(dirPath)
84
+ if (ig) levels = [...parentLevels, { dir: dirPath, ig }]
85
+ }
86
+
87
+ const raw = await readdir(dirPath, { withFileTypes: true })
88
+ for (const entry of sortEntries(raw, opts.sort)) {
89
+ // Never follow symlinks — cycle hazard, and a markdown reader doesn't
90
+ // need them. May be relaxed (files only) in a later iteration.
91
+ if (entry.isSymbolicLink()) continue
92
+
93
+ const entryPath = join(dirPath, entry.name)
94
+
95
+ if (entry.isDirectory()) {
96
+ if (HARD_SKIP_DIRS.has(entry.name)) continue
97
+ if (!opts.all && entry.name.startsWith(".")) continue
98
+ if (!opts.all && isIgnored(entryPath, true, levels)) continue
99
+ await walkDir(entryPath, rootPath, levels, results, opts)
100
+ continue
101
+ }
102
+
103
+ if (!entry.isFile()) continue
104
+ if (!opts.all && entry.name.startsWith(".")) continue
105
+ if (!MARKDOWN_EXTENSIONS.has(extname(entry.name).toLowerCase())) continue
106
+ if (!opts.all && isIgnored(entryPath, false, levels)) continue
107
+
108
+ results.push({
109
+ path: entryPath,
110
+ relativePath: relative(rootPath, entryPath),
111
+ name: entry.name,
112
+ })
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Walk a directory tree and return markdown files in a stable order.
118
+ *
119
+ * Rules (see DESIGN.md §6):
120
+ * - Extensions: `.md`, `.markdown`, `.mdx`.
121
+ * - Hard skips (always): `node_modules`, `.git`, `.venv`.
122
+ * - Hidden files/dirs (leading `.`) skipped unless `all: true`.
123
+ * - `.gitignore` honored, including nested `.gitignore` files.
124
+ * - Symlinks not followed.
125
+ * - Sort: alphabetical within each group; directories before files
126
+ * (`dirs-first`, default) or files before directories (`files-first`).
127
+ */
128
+ export const walk = (
129
+ root: string,
130
+ options: WalkOptions = {},
131
+ ): Effect.Effect<readonly FileEntry[], DiscoveryError> =>
132
+ Effect.tryPromise({
133
+ try: async () => {
134
+ const absRoot = resolve(root)
135
+ const results: FileEntry[] = []
136
+ await walkDir(absRoot, absRoot, [], results, {
137
+ all: options.all ?? false,
138
+ sort: options.sort ?? "dirs-first",
139
+ })
140
+ return results
141
+ },
142
+ catch: (cause) => new DiscoveryError({ root, cause }),
143
+ })
package/src/index.tsx ADDED
@@ -0,0 +1,265 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * house — entry point.
4
+ *
5
+ * Reads a markdown file path from argv and renders it via opentui's built-in
6
+ * <markdown> component inside a scrollbox. q / ctrl+c to quit.
7
+ *
8
+ * Discovery, sidebar, theming, and richer Effect wiring all land after this.
9
+ */
10
+
11
+ import { stat } from "node:fs/promises"
12
+ import { createCliRenderer, SyntaxStyle } from "@opentui/core"
13
+ import { createRoot, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
14
+ import { RegistryProvider, useAtomSet, useAtomValue } from "@effect/atom-react"
15
+ import { Effect } from "effect"
16
+ import { useMemo } from "react"
17
+ import pkg from "../package.json" with { type: "json" }
18
+ import { Browser } from "./Browser.tsx"
19
+ import { parseArgv, usage } from "./cli/argv.ts"
20
+ import { walk, type SortOrder } from "./discovery/walk.ts"
21
+ import { readFileText } from "./io/readFile.ts"
22
+ import { openInBrowser } from "./serve/openBrowser.ts"
23
+ import { startServer } from "./serve/server.ts"
24
+ import { colors, setActiveTheme } from "./theme/colors.ts"
25
+ import { themeAtom, type ThemeState } from "./theme/atom.ts"
26
+ import { getThemeDefinition, isThemeId, themeDefinitions } from "./theme/registry.ts"
27
+
28
+ export interface AppProps {
29
+ /** Markdown source to render. */
30
+ readonly content: string
31
+ /** Optional title shown in the frame border. Defaults to a generic label. */
32
+ readonly title?: string
33
+ /** Cap the rendered markdown's width at N columns (left-aligned). Null = fill the pane. */
34
+ readonly maxWidth?: number | null
35
+ /** Override quit behavior. Tests pass a spy; the binary uses the default. */
36
+ readonly onQuit?: () => void
37
+ }
38
+
39
+ export const App = ({ content, title = "house", maxWidth = null, onQuit }: AppProps) => {
40
+ const renderer = useRenderer()
41
+ const { width, height } = useTerminalDimensions()
42
+ const theme = useAtomValue(themeAtom)
43
+ const setTheme = useAtomSet(themeAtom)
44
+ const syntaxStyle = useMemo(() => SyntaxStyle.fromStyles(colors.syntax), [theme])
45
+
46
+ const cycleTheme = (delta: 1 | -1) => {
47
+ const idx = themeDefinitions.findIndex((d) => d.id === theme.id)
48
+ const next = themeDefinitions[(idx + delta + themeDefinitions.length) % themeDefinitions.length]
49
+ if (!next) return
50
+ setActiveTheme(next, theme.tone)
51
+ setTheme({ id: next.id, tone: theme.tone })
52
+ }
53
+
54
+ const toggleTone = () => {
55
+ const nextTone = theme.tone === "dark" ? "light" : "dark"
56
+ const def = getThemeDefinition(theme.id)
57
+ if (def) setActiveTheme(def, nextTone)
58
+ setTheme({ id: theme.id, tone: nextTone })
59
+ }
60
+
61
+ useKeyboard((key) => {
62
+ if (key.name === "q" || (key.ctrl && key.name === "c")) {
63
+ if (onQuit) {
64
+ onQuit()
65
+ return
66
+ }
67
+ renderer?.destroy()
68
+ process.exit(0)
69
+ }
70
+ if (key.name === "t" && !key.shift) cycleTheme(1)
71
+ if (key.name === "t" && key.shift) cycleTheme(-1)
72
+ if (key.name === "l" && key.shift) toggleTone()
73
+ })
74
+
75
+ return (
76
+ <box style={{ width, height, flexDirection: "column", backgroundColor: colors.background }}>
77
+ <box
78
+ title={` ${title} `}
79
+ titleAlignment="left"
80
+ style={{
81
+ border: true,
82
+ borderColor: colors.border,
83
+ padding: 1,
84
+ flexGrow: 1,
85
+ flexShrink: 1,
86
+ backgroundColor: colors.background,
87
+ }}
88
+ >
89
+ <scrollbox
90
+ style={{
91
+ scrollY: true,
92
+ scrollX: false,
93
+ flexGrow: 1,
94
+ flexShrink: 1,
95
+ backgroundColor: colors.background,
96
+ }}
97
+ focused
98
+ >
99
+ <markdown
100
+ content={content}
101
+ syntaxStyle={syntaxStyle}
102
+ fg={colors.text}
103
+ bg={colors.background}
104
+ conceal
105
+ style={{ width: maxWidth ?? "100%" }}
106
+ />
107
+ </scrollbox>
108
+ </box>
109
+ </box>
110
+ )
111
+ }
112
+
113
+ if (import.meta.main) {
114
+ const args = parseArgv(Bun.argv.slice(2))
115
+
116
+ if (args.help) {
117
+ console.log(usage)
118
+ process.exit(0)
119
+ }
120
+ if (args.version) {
121
+ console.log(pkg.version)
122
+ process.exit(0)
123
+ }
124
+
125
+ const themeId = args.theme ?? "opencode"
126
+ if (!isThemeId(themeId)) {
127
+ const known = themeDefinitions.map((t) => t.id).join(", ")
128
+ console.error(`house: unknown theme "${themeId}". Known: ${known}`)
129
+ process.exit(2)
130
+ }
131
+ const tone = args.tone ?? "dark"
132
+ if (tone !== "dark" && tone !== "light") {
133
+ console.error(`house: --tone must be "dark" or "light", got "${tone}"`)
134
+ process.exit(2)
135
+ }
136
+ const themeDef = getThemeDefinition(themeId)
137
+ if (themeDef === undefined) {
138
+ console.error(`house: unknown theme "${themeId}"`)
139
+ process.exit(2)
140
+ }
141
+ setActiveTheme(themeDef, tone)
142
+
143
+ let maxWidth: number | null = null
144
+ if (args.width !== null) {
145
+ const n = Number.parseInt(args.width, 10)
146
+ if (!Number.isFinite(n) || n <= 0) {
147
+ console.error(`house: --width must be a positive integer, got "${args.width}"`)
148
+ process.exit(2)
149
+ }
150
+ maxWidth = n
151
+ }
152
+
153
+ const target = args.path ?? "."
154
+
155
+ if (args.serve) {
156
+ let stats: Awaited<ReturnType<typeof stat>>
157
+ try {
158
+ stats = await stat(target)
159
+ } catch (err) {
160
+ console.error(`house: cannot access ${target}: ${String(err)}`)
161
+ process.exit(1)
162
+ }
163
+ if (stats.isDirectory()) {
164
+ console.error(`house: --serve requires a file, got directory ${target}`)
165
+ process.exit(2)
166
+ }
167
+ let port = 0
168
+ if (args.port !== null) {
169
+ const n = Number.parseInt(args.port, 10)
170
+ if (!Number.isFinite(n) || n < 0 || n > 65535) {
171
+ console.error(`house: --port must be 0-65535, got "${args.port}"`)
172
+ process.exit(2)
173
+ }
174
+ port = n
175
+ }
176
+ const handle = startServer({ path: target, port })
177
+ console.log(`house serving ${target} at ${handle.url}`)
178
+ console.log("press ctrl+c to stop")
179
+ openInBrowser(handle.url)
180
+ const shutdown = async () => {
181
+ await handle.stop()
182
+ process.exit(0)
183
+ }
184
+ process.on("SIGINT", shutdown)
185
+ process.on("SIGTERM", shutdown)
186
+ // Bun.serve keeps the event loop alive until stop().
187
+ } else {
188
+ let sort: SortOrder = "dirs-first"
189
+ if (args.sort !== null) {
190
+ if (args.sort !== "dirs-first" && args.sort !== "files-first") {
191
+ console.error(`house: --sort must be "dirs-first" or "files-first", got "${args.sort}"`)
192
+ process.exit(2)
193
+ }
194
+ sort = args.sort
195
+ }
196
+ await runTui({ target, themeId, tone, maxWidth, all: args.all, sort })
197
+ }
198
+ }
199
+
200
+ interface TuiBootOptions {
201
+ readonly target: string
202
+ readonly themeId: string
203
+ readonly tone: "dark" | "light"
204
+ readonly maxWidth: number | null
205
+ readonly all: boolean
206
+ readonly sort: SortOrder
207
+ }
208
+
209
+ async function runTui({
210
+ target,
211
+ themeId,
212
+ tone,
213
+ maxWidth,
214
+ all,
215
+ sort,
216
+ }: TuiBootOptions): Promise<void> {
217
+ let stats: Awaited<ReturnType<typeof stat>>
218
+ try {
219
+ stats = await stat(target)
220
+ } catch (err) {
221
+ console.error(`house: cannot access ${target}: ${String(err)}`)
222
+ process.exit(1)
223
+ }
224
+
225
+ const renderer = await createCliRenderer({ exitOnCtrlC: false })
226
+ const initialTheme: ThemeState = { id: themeId, tone }
227
+
228
+ if (stats.isDirectory()) {
229
+ const files = await Effect.runPromise(
230
+ walk(target, { all, sort }).pipe(
231
+ Effect.tapError((err) =>
232
+ Effect.sync(() => {
233
+ console.error(`house: cannot walk ${target}: ${String(err.cause)}`)
234
+ }),
235
+ ),
236
+ ),
237
+ ).catch(() => {
238
+ process.exit(1)
239
+ })
240
+ if (!Array.isArray(files)) process.exit(1)
241
+ createRoot(renderer).render(
242
+ <RegistryProvider initialValues={[[themeAtom, initialTheme]]}>
243
+ <Browser files={files} title={target} maxWidth={maxWidth} />
244
+ </RegistryProvider>,
245
+ )
246
+ } else {
247
+ const content = await Effect.runPromise(
248
+ readFileText(target).pipe(
249
+ Effect.tapError((err) =>
250
+ Effect.sync(() => {
251
+ console.error(`house: cannot read ${err.path}: ${String(err.cause)}`)
252
+ }),
253
+ ),
254
+ ),
255
+ ).catch(() => {
256
+ process.exit(1)
257
+ })
258
+ if (typeof content !== "string") process.exit(1)
259
+ createRoot(renderer).render(
260
+ <RegistryProvider initialValues={[[themeAtom, initialTheme]]}>
261
+ <App content={content} title={target} maxWidth={maxWidth} />
262
+ </RegistryProvider>,
263
+ )
264
+ }
265
+ }