@carlesandres/house 0.4.7 → 0.4.8

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.
@@ -1,12 +1,14 @@
1
1
  /**
2
2
  * CommandPalette — modal overlay for searching and running commands.
3
3
  *
4
- * Render-only: state lives in Browser.tsx alongside helpVisible / filterOpen
4
+ * Render-only: state lives in Browser.tsx alongside filterOpen
5
5
  * (#70 design log §state location). Key handling sits in Browser.tsx's
6
6
  * `useKeyboard` palette-branch, mirroring the filterOpen pattern.
7
7
  *
8
- * V1 ships a flat list in `browserBindings` order no category headers,
9
- * no mouse, no recency. See #91/#93/#94/#95/#96 for the follow-ups.
8
+ * Commands are grouped by semantic category while preserving command order
9
+ * inside each group. Browser.tsx owns the query and selected command index;
10
+ * this component turns those commands into render rows and keeps the selected
11
+ * row visible when the list is taller than the modal body.
10
12
  */
11
13
 
12
14
  import { RGBA } from "@opentui/core"
@@ -29,6 +31,52 @@ export interface CommandPaletteProps {
29
31
  }
30
32
 
31
33
  const FOOTER_HINT = "↑↓ select enter run esc close"
34
+ const CATEGORY_ORDER = ["Navigation", "View", "File", "Appearance", "App"] as const
35
+
36
+ type PaletteRow =
37
+ | { readonly kind: "spacer"; readonly key: string }
38
+ | { readonly kind: "header"; readonly key: string; readonly text: string }
39
+ | {
40
+ readonly kind: "command"
41
+ readonly key: string
42
+ readonly command: AppCommand
43
+ readonly commandIndex: number
44
+ }
45
+
46
+ export const orderCommandsForPalette = (commands: readonly AppCommand[]): readonly AppCommand[] => {
47
+ const grouped = new Map<string, AppCommand[]>()
48
+ for (const command of commands) {
49
+ const category = command.category ?? "Other"
50
+ const list = grouped.get(category)
51
+ if (list) list.push(command)
52
+ else grouped.set(category, [command])
53
+ }
54
+
55
+ const orderedCategories = [
56
+ ...CATEGORY_ORDER.filter((category) => grouped.has(category)),
57
+ ...Array.from(grouped.keys()).filter((category) => !CATEGORY_ORDER.includes(category as never)),
58
+ ]
59
+
60
+ return orderedCategories.flatMap((category) => grouped.get(category) ?? [])
61
+ }
62
+
63
+ const buildRows = (commands: readonly AppCommand[]): readonly PaletteRow[] => {
64
+ const orderedCommands = orderCommandsForPalette(commands)
65
+ const rows: PaletteRow[] = []
66
+ let previousCategory: string | null = null
67
+ let commandIndex = 0
68
+ for (const command of orderedCommands) {
69
+ const category = command.category ?? "Other"
70
+ if (category !== previousCategory) {
71
+ if (previousCategory !== null) rows.push({ kind: "spacer", key: `spacer-${category}` })
72
+ rows.push({ kind: "header", key: `header-${category}`, text: category })
73
+ previousCategory = category
74
+ }
75
+ rows.push({ kind: "command", key: command.id, command, commandIndex })
76
+ commandIndex += 1
77
+ }
78
+ return rows
79
+ }
32
80
 
33
81
  export const CommandPalette = ({
34
82
  commands,
@@ -38,11 +86,12 @@ export const CommandPalette = ({
38
86
  viewportHeight,
39
87
  }: CommandPaletteProps) => {
40
88
  const overlayWidth = Math.min(viewportWidth - 4, 64)
89
+ const rows = buildRows(commands)
41
90
  // Reserve: 2 for border (top+bottom), 1 query row, 1 spacer below query,
42
91
  // 1 spacer above footer, 1 footer row. Body gets the rest.
43
92
  const chrome = 2 + 1 + 1 + 1 + 1
44
93
  const maxBody = Math.max(1, viewportHeight - 4 - chrome)
45
- const desiredBody = Math.max(1, commands.length || 1)
94
+ const desiredBody = Math.max(1, rows.length || 1)
46
95
  const bodyHeight = Math.min(desiredBody, maxBody)
47
96
  const overlayHeight = chrome + bodyHeight
48
97
  const left = Math.max(0, Math.floor((viewportWidth - overlayWidth) / 2))
@@ -51,17 +100,19 @@ export const CommandPalette = ({
51
100
  // Inner content width: overlay minus 1-cell border + 1-cell padding on each side.
52
101
  const rowWidth = Math.max(4, overlayWidth - 4)
53
102
 
54
- // Window the visible slice around the selection. With 9 commands in v1
55
- // this is usually a no-op (list fits), but the math is in place for the
56
- // inevitable backlog growth.
103
+ // Window the visible slice around the selected command row. Headers and
104
+ // spacers are render-only rows, so selection still tracks command indexes.
57
105
  const scrollTop = (() => {
58
- if (commands.length <= bodyHeight) return 0
59
- const maxScroll = commands.length - bodyHeight
106
+ if (rows.length <= bodyHeight) return 0
107
+ const maxScroll = rows.length - bodyHeight
60
108
  let s = 0
61
- if (selectedIndex >= bodyHeight) s = selectedIndex - bodyHeight + 1
109
+ const selectedRowIndex = rows.findIndex(
110
+ (row) => row.kind === "command" && row.commandIndex === selectedIndex,
111
+ )
112
+ if (selectedRowIndex >= bodyHeight) s = selectedRowIndex - bodyHeight + 1
62
113
  return Math.max(0, Math.min(s, maxScroll))
63
114
  })()
64
- const visible = commands.slice(scrollTop, scrollTop + bodyHeight)
115
+ const visible = rows.slice(scrollTop, scrollTop + bodyHeight)
65
116
 
66
117
  // Shortcut column width — long enough for `shift+t`-style chords but
67
118
  // trimmed to prevent the title from being squeezed below ~16 cells.
@@ -110,9 +161,22 @@ export const CommandPalette = ({
110
161
  {commands.length === 0 ? (
111
162
  <text wrapMode="none" content=" (no matches)" style={{ fg: colors.textMuted }} />
112
163
  ) : (
113
- visible.map((cmd, i) => {
114
- const realIdx = scrollTop + i
115
- const isSelected = realIdx === selectedIndex
164
+ visible.map((row) => {
165
+ if (row.kind === "spacer") {
166
+ return <text key={row.key} content=" " />
167
+ }
168
+ if (row.kind === "header") {
169
+ return (
170
+ <text
171
+ key={row.key}
172
+ wrapMode="none"
173
+ content={fit(row.text, rowWidth)}
174
+ style={{ fg: colors.textMuted, attributes: 1 }}
175
+ />
176
+ )
177
+ }
178
+ const cmd = row.command
179
+ const isSelected = row.commandIndex === selectedIndex
116
180
  const selector = isSelected ? "▸ " : " "
117
181
  const titleText = fit(cmd.title, titleWidth)
118
182
  const shortcutText = cmd.shortcut
package/src/Footer.tsx CHANGED
@@ -40,30 +40,26 @@ export interface FooterProps<C> {
40
40
  * no TTL, cleared by the caller when the underlying activity finishes.
41
41
  * Loses to `notice` when both are set so transient toasts still surface. */
42
42
  readonly discoveryStatus?: string | null
43
- /** When a filter is applied but the input is closed, surface a chip in the
44
- * hint row so the user remembers `[`/`]` walks the filtered set. Pass null
45
- * while the filter input is open (the sidebar already shows the query) or
46
- * when no filter is applied. */
47
- readonly filterQuery?: string | null
48
43
  /** Test seam: override spinner tick speed so tests don't sleep on the full
49
44
  * production interval. Ignored when discoveryStatus is null. */
50
45
  readonly discoverySpinnerIntervalMs?: number
51
46
  readonly discoverySpinnerInitialFrameIndex?: number
52
47
  /** Test seam: deterministic footer spinner driver. */
53
48
  readonly discoverySpinnerRegisterTick?: ((tick: () => void) => void) | null
49
+ /** Optional toggle callback for the discovery-warning popover. */
50
+ readonly onDiscoveryWarningToggle?: () => void
54
51
  }
55
52
 
53
+ const normalizeStatusLine = (status: string): string => status.replace(/\s+/g, " ").trim()
54
+
56
55
  const HINT_SEPARATOR = " "
57
56
 
58
- /** Hint row entries. `key === null` is a standalone chip (e.g. the filter
59
- * chip) and renders as muted text without the key/label split. */
60
57
  interface Hint {
61
- readonly key: string | null
58
+ readonly key: string
62
59
  readonly label: string
63
60
  }
64
61
 
65
- const hintWidth = (h: Hint): number =>
66
- h.key === null ? h.label.length : h.key.length + 1 + h.label.length // key + " " + label
62
+ const hintWidth = (h: Hint): number => h.key.length + 1 + h.label.length // key + " " + label
67
63
 
68
64
  const formatHint = <C,>(b: KeyBinding<C>): Hint | null => {
69
65
  if (!b.hint) return null
@@ -73,8 +69,8 @@ const formatHint = <C,>(b: KeyBinding<C>): Hint | null => {
73
69
  }
74
70
 
75
71
  /** Drop hints from the end until they fit within `width`. If not even the
76
- * first hint fits, fall back to the bare key (or label chip) truncated to
77
- * width, so the row is never silently blank on tight viewports. */
72
+ * first hint fits, fall back to the bare key truncated to width, so the row
73
+ * is never silently blank on tight viewports. */
78
74
  const fitHints = (hints: readonly Hint[], width: number): Hint[] => {
79
75
  if (width <= 0 || hints.length === 0) return []
80
76
  const acc: Hint[] = []
@@ -87,22 +83,24 @@ const fitHints = (hints: readonly Hint[], width: number): Hint[] => {
87
83
  }
88
84
  if (acc.length > 0) return acc
89
85
  const first = hints[0]!
90
- if (first.key === null) return [{ key: null, label: first.label.slice(0, width) }]
91
86
  return [{ key: first.key.slice(0, width), label: "" }]
92
87
  }
93
88
 
94
89
  const STATUS_SEPARATOR = " · "
95
90
 
91
+ const isPartialDiscoveryWarning = (status: string | null): boolean =>
92
+ status?.startsWith("scan incomplete:") ?? false
93
+
96
94
  export const Footer = <C,>({
97
95
  bindings,
98
96
  ctx,
99
97
  width,
100
98
  notice,
101
99
  discoveryStatus,
102
- filterQuery,
103
100
  discoverySpinnerIntervalMs,
104
101
  discoverySpinnerInitialFrameIndex,
105
102
  discoverySpinnerRegisterTick,
103
+ onDiscoveryWarningToggle,
106
104
  }: FooterProps<C>) => {
107
105
  const usableWidth = Math.max(0, width - 2) // 1-cell horizontal padding each side
108
106
 
@@ -117,13 +115,6 @@ export const Footer = <C,>({
117
115
  } as const
118
116
 
119
117
  const hints: Hint[] = []
120
- // The filter chip prepends to the hint row when a filter is applied and the
121
- // input is closed. Bracketed to avoid looking like a `key:hint` binding —
122
- // "filter" is not a key. Surfaces the otherwise-invisible invariant that
123
- // `[`/`]` walks the filtered set. See DESIGN.md §7.1 Q1.
124
- if (filterQuery && filterQuery.length > 0) {
125
- hints.push({ key: null, label: `[filter: ${filterQuery}]` })
126
- }
127
118
  for (const b of bindings) {
128
119
  // Hint visibility prefers `hintWhen` (binding-specific) over `when`
129
120
  // (dispatch gate). Falling back to `when` keeps the original
@@ -137,14 +128,17 @@ export const Footer = <C,>({
137
128
  // Discovery status sits left of the hints, separated by " · ". On tight
138
129
  // viewports it claims its budget first; hints fit into the remainder so
139
130
  // the indicator stays visible while less-essential hints drop off.
140
- const status = discoveryStatus && discoveryStatus.length > 0 ? discoveryStatus : null
141
- const statusBudget = status ? Math.min(status.length + STATUS_SEPARATOR.length, usableWidth) : 0
131
+ const status =
132
+ discoveryStatus && discoveryStatus.length > 0 ? normalizeStatusLine(discoveryStatus) : null
133
+ const isPartialWarning = isPartialDiscoveryWarning(status)
134
+ const statusBudget = status
135
+ ? Math.min((isPartialWarning ? 1 : status.length) + STATUS_SEPARATOR.length, usableWidth)
136
+ : 0
142
137
  const hintsWidth = Math.max(0, usableWidth - statusBudget)
143
138
  const visibleHints = fitHints(hints, hintsWidth)
144
139
  const statusContent = status
145
140
  ? status.slice(0, Math.max(0, statusBudget - STATUS_SEPARATOR.length))
146
141
  : ""
147
-
148
142
  const noticeContent = notice
149
143
  ? notice.length > usableWidth
150
144
  ? notice.slice(0, usableWidth)
@@ -167,12 +161,6 @@ export const Footer = <C,>({
167
161
  />,
168
162
  ]
169
163
  : []
170
- if (h.key === null) {
171
- return [
172
- ...sep,
173
- <text key={`l${i}`} content={h.label} wrapMode="none" style={{ fg: colors.secondary }} />,
174
- ]
175
- }
176
164
  return [
177
165
  ...sep,
178
166
  <text key={`k${i}`} content={h.key} wrapMode="none" style={{ fg: colors.text }} />,
@@ -195,6 +183,20 @@ export const Footer = <C,>({
195
183
  )
196
184
  }
197
185
 
186
+ const warningTrigger = isPartialWarning ? (
187
+ <box
188
+ {...(onDiscoveryWarningToggle === undefined ? {} : { onMouseUp: onDiscoveryWarningToggle })}
189
+ style={{
190
+ width: 1,
191
+ height: 1,
192
+ flexDirection: "row",
193
+ backgroundColor: colors.backgroundElement,
194
+ }}
195
+ >
196
+ <text content="!" wrapMode="none" style={{ fg: colors.warning, attributes: 1 }} />
197
+ </box>
198
+ ) : null
199
+
198
200
  if (status !== null) {
199
201
  const spinnerProps = {
200
202
  fg: colors.secondary,
@@ -211,9 +213,15 @@ export const Footer = <C,>({
211
213
 
212
214
  return (
213
215
  <box style={rowStyle}>
214
- <Spinner {...spinnerProps} />
215
- <text content=" " wrapMode="none" style={{ fg: colors.textMuted }} />
216
- <text content={statusContent} wrapMode="none" style={{ fg: colors.secondary }} />
216
+ {isPartialWarning ? null : <Spinner {...spinnerProps} />}
217
+ {isPartialWarning ? null : (
218
+ <text content=" " wrapMode="none" style={{ fg: colors.textMuted }} />
219
+ )}
220
+ {isPartialWarning ? (
221
+ warningTrigger
222
+ ) : (
223
+ <text content={statusContent} wrapMode="none" style={{ fg: colors.secondary }} />
224
+ )}
217
225
  <text content={STATUS_SEPARATOR} wrapMode="none" style={{ fg: colors.textMuted }} />
218
226
  {renderHints()}
219
227
  </box>
package/src/Header.tsx CHANGED
@@ -61,7 +61,10 @@ export const Header = ({ width, currentFile, version = pkg.version }: HeaderProp
61
61
  backgroundColor: colors.backgroundPanel,
62
62
  }}
63
63
  >
64
- <text content={left} wrapMode="none" style={{ fg: colors.text }} />
64
+ <text wrapMode="none">
65
+ <span style={{ fg: colors.text }}>{brand}</span>
66
+ {showFile && <span style={{ fg: colors.textMuted }}>{`${FILE_SEPARATOR}${file}`}</span>}
67
+ </text>
65
68
  {showRight && <text content={right} wrapMode="none" style={{ fg: colors.text }} />}
66
69
  </box>
67
70
  )
@@ -0,0 +1,189 @@
1
+ import { useTerminalDimensions } from "@opentui/react"
2
+ import { useMemo, useState } from "react"
3
+ import { colors } from "./theme/colors.ts"
4
+
5
+ export type StatusPopoverVariant = "info" | "warning" | "error" | "success"
6
+
7
+ export interface StatusPopoverProps {
8
+ readonly icon: string
9
+ readonly content: string
10
+ readonly variant?: StatusPopoverVariant
11
+ readonly open?: boolean
12
+ readonly defaultOpen?: boolean
13
+ readonly onOpenChange?: (open: boolean) => void
14
+ readonly showPanel?: boolean
15
+ readonly minWidth?: number
16
+ readonly maxWidth?: number
17
+ readonly maxHeight?: number
18
+ readonly zIndex?: number
19
+ }
20
+
21
+ export interface StatusPopoverPanelProps {
22
+ readonly content: string
23
+ readonly variant?: StatusPopoverVariant
24
+ readonly minWidth?: number
25
+ readonly maxWidth?: number
26
+ readonly maxHeight?: number
27
+ readonly zIndex?: number
28
+ }
29
+
30
+ const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n))
31
+
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
+ const measureLine = (line: string): number => line.length
46
+
47
+ const wrapLine = (line: string, width: number): string[] => {
48
+ if (width <= 0) return [line]
49
+ if (line.length <= width) return [line]
50
+ const out: string[] = []
51
+ let i = 0
52
+ while (i < line.length) {
53
+ out.push(line.slice(i, i + width))
54
+ i += width
55
+ }
56
+ return out
57
+ }
58
+
59
+ export const StatusPopover = ({
60
+ icon,
61
+ content,
62
+ variant = "warning",
63
+ open,
64
+ defaultOpen = false,
65
+ onOpenChange,
66
+ showPanel = true,
67
+ minWidth = 14,
68
+ maxWidth = 40,
69
+ maxHeight = 12,
70
+ zIndex = 30,
71
+ }: StatusPopoverProps) => {
72
+ const { width: viewportWidth, height: viewportHeight } = useTerminalDimensions()
73
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen)
74
+ const isOpen = open ?? uncontrolledOpen
75
+ const setOpen = (next: boolean) => {
76
+ if (open === undefined) setUncontrolledOpen(next)
77
+ onOpenChange?.(next)
78
+ }
79
+
80
+ const lines = useMemo(() => content.split(/\r?\n/), [content])
81
+ const textWidth = useMemo(() => {
82
+ const widest = Math.max(1, ...lines.map(measureLine))
83
+ return clamp(widest, minWidth, Math.min(maxWidth, Math.max(1, viewportWidth - 4)))
84
+ }, [content, lines, maxWidth, minWidth, viewportWidth])
85
+
86
+ const wrapped = useMemo(
87
+ () => lines.flatMap((line) => wrapLine(line, textWidth)),
88
+ [lines, textWidth],
89
+ )
90
+ const bodyHeight = Math.min(maxHeight, Math.max(1, wrapped.length))
91
+ const popoverWidth = Math.min(textWidth + 2, Math.max(1, viewportWidth - 2))
92
+ const popoverHeight = Math.min(bodyHeight + 2, Math.max(3, viewportHeight - 2))
93
+
94
+ const left = 1
95
+ const top = Math.max(0, viewportHeight - popoverHeight - 2)
96
+
97
+ const linesToRender = wrapped.slice(0, Math.max(0, popoverHeight - 2))
98
+ while (linesToRender.length < popoverHeight - 2) linesToRender.push("")
99
+ const triggerFg = variantFg(variant)
100
+ const borderColor = variantFg(variant)
101
+
102
+ return (
103
+ <>
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>
115
+ {showPanel && isOpen && (
116
+ <box
117
+ position="absolute"
118
+ left={left}
119
+ top={top}
120
+ width={popoverWidth}
121
+ height={popoverHeight}
122
+ zIndex={zIndex}
123
+ style={{
124
+ border: true,
125
+ borderColor,
126
+ backgroundColor: colors.backgroundPanel,
127
+ flexDirection: "column",
128
+ }}
129
+ >
130
+ <text content="" />
131
+ {linesToRender.map((line, i) => (
132
+ <text key={i} content={line} wrapMode="none" style={{ fg: colors.text }} />
133
+ ))}
134
+ <text content="" />
135
+ </box>
136
+ )}
137
+ </>
138
+ )
139
+ }
140
+
141
+ export const StatusPopoverPanel = ({
142
+ content,
143
+ variant = "warning",
144
+ minWidth = 14,
145
+ maxWidth = 40,
146
+ maxHeight = 12,
147
+ zIndex = 30,
148
+ }: StatusPopoverPanelProps) => {
149
+ const { width: viewportWidth, height: viewportHeight } = useTerminalDimensions()
150
+ const lines = useMemo(() => content.split(/\r?\n/), [content])
151
+ const textWidth = useMemo(() => {
152
+ const widest = Math.max(1, ...lines.map(measureLine))
153
+ return clamp(widest, minWidth, Math.min(maxWidth, Math.max(1, viewportWidth - 4)))
154
+ }, [lines, maxWidth, minWidth, viewportWidth])
155
+ const wrapped = useMemo(
156
+ () => lines.flatMap((line) => wrapLine(line, textWidth)),
157
+ [lines, textWidth],
158
+ )
159
+ const bodyHeight = Math.min(maxHeight, Math.max(1, wrapped.length))
160
+ const popoverWidth = Math.min(textWidth + 2, Math.max(1, viewportWidth - 2))
161
+ const popoverHeight = Math.min(bodyHeight + 2, Math.max(3, viewportHeight - 2))
162
+ const linesToRender = wrapped.slice(0, Math.max(0, popoverHeight - 2))
163
+ while (linesToRender.length < popoverHeight - 2) linesToRender.push("")
164
+
165
+ const borderColor = variantFg(variant)
166
+
167
+ return (
168
+ <box
169
+ position="absolute"
170
+ left={1}
171
+ top={Math.max(0, viewportHeight - popoverHeight - 2)}
172
+ width={popoverWidth}
173
+ height={popoverHeight}
174
+ zIndex={zIndex}
175
+ style={{
176
+ border: true,
177
+ borderColor,
178
+ backgroundColor: colors.backgroundPanel,
179
+ flexDirection: "column",
180
+ }}
181
+ >
182
+ <text content="" />
183
+ {linesToRender.map((line, i) => (
184
+ <text key={i} content={line} wrapMode="none" style={{ fg: colors.text }} />
185
+ ))}
186
+ <text content="" />
187
+ </box>
188
+ )
189
+ }
package/src/cli/argv.ts CHANGED
@@ -14,7 +14,7 @@ export interface ParsedArgs {
14
14
  readonly width: string | null
15
15
  /** Value of `--sort <mode>` (`dirs-first` or `files-first`), or null. Validated by the boot layer. */
16
16
  readonly sort: string | null
17
- /** True when `--serve` was passed: serve the given file as HTML, skip TUI. */
17
+ /** True when `--serve` was passed: serve the positional path as HTML, skip TUI. */
18
18
  readonly serve: boolean
19
19
  /** Value of `--port <N>`, or null. Validated by the boot layer. */
20
20
  readonly port: string | null
@@ -139,9 +139,11 @@ export const parseArgv = (argv: readonly string[]): ParsedArgs => {
139
139
 
140
140
  const themeList = themeDefinitions.map((t) => t.id).join(", ")
141
141
 
142
- export const usage = `usage: house [path] [options]
142
+ export const usage = `usage:
143
+ house [query] [options]
144
+ house --serve <path> [--port N]
143
145
 
144
- path file or directory; defaults to the current directory
146
+ query initial filter query; omit to browse the full discovery root
145
147
 
146
148
  options:
147
149
  --theme <id> color theme: ${themeList} (default: opencode)
@@ -149,11 +151,11 @@ options:
149
151
  --width <N> cap rendered markdown width at N columns
150
152
  --show <list> reveal normally-skipped entries; comma-separated subset of:
151
153
  hidden, gitignored. Use --show "" to clear.
152
- --root <dir> discovery root to walk (overrides defaultRoot config)
154
+ --root <dir> discovery root to walk (overrides defaultRoot config/env)
153
155
  --sort <mode> sidebar order: dirs-first (default) or files-first
154
156
  --sidebar <m> initial sidebar visibility: auto (default), on, or off
155
157
  --focus <m> startup focus: sidebar, reader, or filter (default: filter)
156
- --serve serve the given file as HTML in the browser (skips TUI)
158
+ --serve serve the positional path as HTML in the browser (skips TUI)
157
159
  --port <N> port for --serve (default: OS-assigned)
158
160
  -h, --help show this help and exit
159
161
  -v, --version print version and exit
@@ -161,6 +163,11 @@ options:
161
163
  --no-update-check suppress the "newer version available" check (also via NO_UPDATE_NOTIFIER=1)
162
164
  --no-mdx exclude .mdx files from discovery (default: included)
163
165
 
166
+ examples:
167
+ house README.md
168
+ house --root docs
169
+ house --serve README.md
170
+
164
171
  configuration:
165
172
  file: $XDG_CONFIG_HOME/house/config.toml (default ~/.config/house/config.toml)
166
173
  keys: theme, tone, mdx, show, focus, defaultRoot
@@ -2,9 +2,8 @@
2
2
  * Derive palette commands from `browserBindings` plus the annotation map.
3
3
  *
4
4
  * The annotation map is the *only* hand-written list keyed by binding id:
5
- * it carries (a) hide flags for bindings that are pure keystroke nav, and
6
- * (b) title rewrites for bindings whose `description` reads as a help-row
7
- * entry rather than a palette command. Every other binding is exposed
5
+ * it carries title rewrites and metadata for bindings whose raw
6
+ * `description` reads awkwardly as a palette command. Every enabled binding is exposed
8
7
  * verbatim — its `description` becomes the palette `title`, its first key
9
8
  * becomes the `shortcut`.
10
9
  *
@@ -30,49 +29,40 @@ interface Annotation {
30
29
  }
31
30
 
32
31
  /**
33
- * Pure-keystroke nav (j/k/space/b/[/]…) is intentionally hidden those
34
- * bindings have no command-shaped meaning. Reader prev/next file (`[`/`]`)
35
- * and sidebar `open` (Return/l) are the borderline cases from #70 Q6a;
36
- * hidden in v1, reconsider if users ask. Title rewrites convert
37
- * help-overlay phrasing ("Toggle sidebar visibility") into imperative
38
- * palette phrasing ("Toggle sidebar"). See #70 design log §6.
32
+ * Title rewrites convert keymap phrasing into imperative palette phrasing.
33
+ * With the help overlay removed (#139), the palette is the only in-app full
34
+ * action index, so all currently-enabled actions stay discoverable here.
39
35
  */
40
36
  const annotations: Record<string, Annotation> = {
41
37
  // --- Keep, with title rewrites where the binding description reads awkwardly as a command ---
42
38
  quit: { category: "App" },
43
39
  "focus.toggle": { title: "Toggle focus", category: "View" },
44
40
  "sidebar.toggle": { title: "Toggle sidebar", category: "View" },
45
- "help.toggle": { title: "Show help", category: "App" },
41
+ "sidebar.down": { category: "Navigation" },
42
+ "sidebar.up": { category: "Navigation" },
43
+ "sidebar.jumpDown": { category: "Navigation" },
44
+ "sidebar.jumpUp": { category: "Navigation" },
45
+ "sidebar.pageDown": { category: "Navigation" },
46
+ "sidebar.pageUp": { category: "Navigation" },
47
+ "sidebar.top": { category: "Navigation" },
48
+ "sidebar.bottom": { category: "Navigation" },
49
+ "sidebar.open": { title: "Open file", category: "Navigation" },
46
50
  "filter.open": { title: "Filter files…", category: "Navigation" },
51
+ "filter.clearOrOpen": { category: "Navigation" },
47
52
  "discovery.toggleAll": {
48
53
  title: "Toggle hidden / gitignored files",
49
54
  category: "Navigation",
50
55
  keywords: ["hidden", "gitignore", "dotfiles", "all"],
51
56
  },
57
+ "reader.back": { title: "Back to sidebar", category: "Navigation" },
58
+ "reader.prevFile": { title: "Previous file", category: "Navigation" },
59
+ "reader.nextFile": { title: "Next file", category: "Navigation" },
52
60
  "serve.current": { title: "Open in browser", category: "File" },
53
61
  "file.edit": { title: "Open in editor", category: "File", keywords: ["editor", "vim", "vscode"] },
54
62
  "theme.next": { category: "Appearance" },
55
63
  "theme.prev": { category: "Appearance" },
56
64
  "theme.toneToggle": { title: "Toggle dark/light tone", category: "Appearance" },
57
65
 
58
- // --- Hide: pure keystroke navigation (j/k/space/b/g/G…) ---
59
- "sidebar.down": { hidden: true },
60
- "sidebar.up": { hidden: true },
61
- "sidebar.jumpDown": { hidden: true },
62
- "sidebar.jumpUp": { hidden: true },
63
- "sidebar.pageDown": { hidden: true },
64
- "sidebar.pageUp": { hidden: true },
65
- "sidebar.top": { hidden: true },
66
- "sidebar.bottom": { hidden: true },
67
-
68
- // --- Hide: borderline reader nav. `[`/`]` and Return-to-open feel command-shaped
69
- // but are pure keystroke navigation under the hood. #70 Q6a — reconsider
70
- // if user feedback expects them in the palette.
71
- "sidebar.open": { hidden: true },
72
- "reader.back": { hidden: true },
73
- "reader.prevFile": { hidden: true },
74
- "reader.nextFile": { hidden: true },
75
-
76
66
  // --- Hide: the palette opener itself shouldn't appear in the palette ---
77
67
  "palette.open": { hidden: true },
78
68
  }
@@ -80,8 +70,8 @@ const annotations: Record<string, Annotation> = {
80
70
  /**
81
71
  * Build the AppCommand list for a given render. Iterates `browserBindings`
82
72
  * in array order (the empty-query palette renders in this order, by design
83
- * — see #70 design log §empty-state ordering), drops hidden entries and
84
- * those whose `when` predicate currently returns false, and resolves
73
+ * — see #70 design log §empty-state ordering), drops explicitly-hidden entries
74
+ * and those whose `when` predicate currently returns false, and resolves
85
75
  * annotations to populate title / category / keywords.
86
76
  */
87
77
  export const buildCommands = (ctx: BrowserCtx): readonly AppCommand[] => {
@@ -89,9 +79,8 @@ export const buildCommands = (ctx: BrowserCtx): readonly AppCommand[] => {
89
79
  for (const binding of browserBindings) {
90
80
  const ann = annotations[binding.id]
91
81
  if (ann?.hidden) continue
92
- // Same gating the keymap dispatcher uses. Disabled bindings get
93
- // hidden from the palette (per #70 Q5b see #96 for the show-with-
94
- // reason follow-up after the atom-driven migration).
82
+ // Same gating the keymap dispatcher uses. Disabled bindings stay out of
83
+ // the palette; #96 tracks a future show-with-reason mode.
95
84
  if (binding.when && !binding.when(ctx)) continue
96
85
  const cmd: AppCommand = {
97
86
  id: binding.id,
@@ -34,7 +34,7 @@ const fuzzyIncludes = (text: string, query: string): boolean => {
34
34
 
35
35
  const searchText = (command: AppCommand): string =>
36
36
  normalize(
37
- [command.title, command.category, command.shortcut, ...(command.keywords ?? [])]
37
+ [command.title, command.category, ...(command.keywords ?? [])]
38
38
  .filter((s): s is string => Boolean(s))
39
39
  .join(" "),
40
40
  )