@carlesandres/house 0.4.0 → 0.4.2

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.
@@ -9,9 +9,17 @@
9
9
  * no mouse, no recency. See #91/#93/#94/#95/#96 for the follow-ups.
10
10
  */
11
11
 
12
+ import { RGBA } from "@opentui/core"
12
13
  import { colors } from "./theme/colors.ts"
14
+ import { PromptRow } from "./PromptRow.tsx"
13
15
  import type { AppCommand } from "./commands/types.ts"
14
16
 
17
+ // Semi-transparent black scrim painted across the viewport behind the modal.
18
+ // Opentui composites it over the chrome underneath, so the rest of the UI
19
+ // reads as darkened while the modal stays fully opaque. Mirrors opencode's
20
+ // dialog backdrop (cli/cmd/tui/ui/dialog.tsx).
21
+ const SCRIM = RGBA.fromInts(0, 0, 0, 150)
22
+
15
23
  export interface CommandPaletteProps {
16
24
  readonly commands: readonly AppCommand[]
17
25
  readonly query: string
@@ -73,54 +81,67 @@ export const CommandPalette = ({
73
81
  return (
74
82
  <box
75
83
  position="absolute"
76
- left={left}
77
- top={top}
78
- width={overlayWidth}
79
- height={overlayHeight}
84
+ left={0}
85
+ top={0}
86
+ width={viewportWidth}
87
+ height={viewportHeight}
80
88
  zIndex={20}
81
- title=" Commands "
82
- titleAlignment="left"
83
- paddingLeft={1}
84
- paddingRight={1}
85
- style={{
86
- border: true,
87
- borderColor: colors.borderActive,
88
- flexDirection: "column",
89
- backgroundColor: colors.surface,
90
- }}
89
+ style={{ backgroundColor: SCRIM }}
91
90
  >
92
- <text
93
- wrapMode="none"
94
- content={fit(`> ${query}▏`, rowWidth)}
95
- style={{ fg: colors.textStrong }}
96
- />
97
- <text content=" " />
98
- {commands.length === 0 ? (
99
- <text wrapMode="none" content=" (no matches)" style={{ fg: colors.textMuted }} />
100
- ) : (
101
- visible.map((cmd, i) => {
102
- const realIdx = scrollTop + i
103
- const isSelected = realIdx === selectedIndex
104
- const selector = isSelected ? "▸ " : " "
105
- const titleText = fit(cmd.title, titleWidth)
106
- const shortcutText = cmd.shortcut
107
- ? fitRight(cmd.shortcut, SHORTCUT_WIDTH)
108
- : " ".repeat(SHORTCUT_WIDTH)
109
- // Title and shortcut render as separate spans so the shortcut
110
- // can use `textMuted` while the title uses `text`/`textStrong`.
111
- // Same trick opencode pulls with `--text-weak` — the theme
112
- // guarantees the contrast, we just pick the right role.
113
- const titleFg = isSelected ? colors.textStrong : colors.text
114
- return (
115
- <text key={cmd.id} wrapMode="none" style={isSelected ? { bg: colors.selectedBg } : {}}>
116
- <span style={{ fg: titleFg }}>{`${selector}${titleText} `}</span>
117
- <span style={{ fg: colors.textMuted }}>{shortcutText}</span>
118
- </text>
119
- )
120
- })
121
- )}
122
- <text content=" " />
123
- <text wrapMode="none" content={fit(FOOTER_HINT, rowWidth)} style={{ fg: colors.textMuted }} />
91
+ <box
92
+ position="absolute"
93
+ left={left}
94
+ top={top}
95
+ width={overlayWidth}
96
+ height={overlayHeight}
97
+ title=" Commands "
98
+ titleAlignment="left"
99
+ paddingLeft={1}
100
+ paddingRight={1}
101
+ style={{
102
+ border: true,
103
+ borderColor: colors.textMuted,
104
+ flexDirection: "column",
105
+ backgroundColor: colors.surface,
106
+ }}
107
+ >
108
+ <PromptRow query={query} editing={true} width={rowWidth} />
109
+ <text content=" " />
110
+ {commands.length === 0 ? (
111
+ <text wrapMode="none" content=" (no matches)" style={{ fg: colors.textMuted }} />
112
+ ) : (
113
+ visible.map((cmd, i) => {
114
+ const realIdx = scrollTop + i
115
+ const isSelected = realIdx === selectedIndex
116
+ const selector = isSelected ? "▸ " : " "
117
+ const titleText = fit(cmd.title, titleWidth)
118
+ const shortcutText = cmd.shortcut
119
+ ? fitRight(cmd.shortcut, SHORTCUT_WIDTH)
120
+ : " ".repeat(SHORTCUT_WIDTH)
121
+ // Title and shortcut render as separate spans so the shortcut
122
+ // can use `textMuted` while the title uses `text`/`textStrong`.
123
+ // Same trick opencode pulls with `--text-weak` — the theme
124
+ // guarantees the contrast, we just pick the right role.
125
+ const titleFg = isSelected ? colors.textStrong : colors.text
126
+ return (
127
+ <text
128
+ key={cmd.id}
129
+ wrapMode="none"
130
+ style={isSelected ? { bg: colors.selectedBg } : {}}
131
+ >
132
+ <span style={{ fg: titleFg }}>{`${selector}${titleText} `}</span>
133
+ <span style={{ fg: colors.textMuted }}>{shortcutText}</span>
134
+ </text>
135
+ )
136
+ })
137
+ )}
138
+ <text content=" " />
139
+ <text
140
+ wrapMode="none"
141
+ content={fit(FOOTER_HINT, rowWidth)}
142
+ style={{ fg: colors.textMuted }}
143
+ />
144
+ </box>
124
145
  </box>
125
146
  )
126
147
  }
package/src/Footer.tsx CHANGED
@@ -71,30 +71,40 @@ const displayKey = (raw: string): string => {
71
71
  }
72
72
  }
73
73
 
74
- const formatHint = <C,>(b: KeyBinding<C>): string | null => {
74
+ /** Hint row entries. `key === null` is a standalone chip (e.g. the filter
75
+ * chip) and renders as muted text without the key/label split. */
76
+ interface Hint {
77
+ readonly key: string | null
78
+ readonly label: string
79
+ }
80
+
81
+ const hintWidth = (h: Hint): number =>
82
+ h.key === null ? h.label.length : h.key.length + 1 + h.label.length // key + " " + label
83
+
84
+ const formatHint = <C,>(b: KeyBinding<C>): Hint | null => {
75
85
  if (!b.hint) return null
76
86
  const first = b.keys[0]
77
87
  if (!first) return null
78
- return `${displayKey(first)}:${b.hint}`
88
+ return { key: displayKey(first), label: b.hint }
79
89
  }
80
90
 
81
- /** Drop hints from the end until the joined string fits within `width`.
82
- * If not even the first hint fits, fall back to the bare key portion so
83
- * the user still sees a discoverability anchor (e.g. `?` instead of an
84
- * empty row on an 8-column terminal). */
85
- const fitHints = (hints: readonly string[], width: number): string => {
86
- if (width <= 0 || hints.length === 0) return ""
87
- let acc = ""
91
+ /** Drop hints from the end until they fit within `width`. If not even the
92
+ * first hint fits, fall back to the bare key (or label chip) truncated to
93
+ * width, so the row is never silently blank on tight viewports. */
94
+ const fitHints = (hints: readonly Hint[], width: number): Hint[] => {
95
+ if (width <= 0 || hints.length === 0) return []
96
+ const acc: Hint[] = []
97
+ let used = 0
88
98
  for (const h of hints) {
89
- const next = acc.length === 0 ? h : `${acc}${HINT_SEPARATOR}${h}`
90
- if (next.length > width) break
91
- acc = next
99
+ const add = acc.length === 0 ? hintWidth(h) : HINT_SEPARATOR.length + hintWidth(h)
100
+ if (used + add > width) break
101
+ acc.push(h)
102
+ used += add
92
103
  }
93
104
  if (acc.length > 0) return acc
94
- // Nothing fit. Render just the first hint's key (everything before `:`)
95
- // truncated to width, so the row is never silently blank.
96
- const firstKey = hints[0]!.split(":")[0] ?? ""
97
- return firstKey.slice(0, width)
105
+ const first = hints[0]!
106
+ if (first.key === null) return [{ key: null, label: first.label.slice(0, width) }]
107
+ return [{ key: first.key.slice(0, width), label: "" }]
98
108
  }
99
109
 
100
110
  const STATUS_SEPARATOR = " · "
@@ -116,19 +126,23 @@ export const Footer = <C,>({
116
126
  flexDirection: "row",
117
127
  paddingLeft: 1,
118
128
  paddingRight: 1,
119
- backgroundColor: colors.background,
129
+ backgroundColor: colors.surface,
120
130
  } as const
121
131
 
122
- const hints: string[] = []
132
+ const hints: Hint[] = []
123
133
  // The filter chip prepends to the hint row when a filter is applied and the
124
134
  // input is closed. Bracketed to avoid looking like a `key:hint` binding —
125
135
  // "filter" is not a key. Surfaces the otherwise-invisible invariant that
126
136
  // `[`/`]` walks the filtered set. See DESIGN.md §7.1 Q1.
127
137
  if (filterQuery && filterQuery.length > 0) {
128
- hints.push(`[filter: ${filterQuery}]`)
138
+ hints.push({ key: null, label: `[filter: ${filterQuery}]` })
129
139
  }
130
140
  for (const b of bindings) {
131
- if (b.when && !b.when(ctx)) continue
141
+ // Hint visibility prefers `hintWhen` (binding-specific) over `when`
142
+ // (dispatch gate). Falling back to `when` keeps the original
143
+ // "hint shows when binding is enabled" behavior for the common case.
144
+ const visibleGate = b.hintWhen ?? b.when
145
+ if (visibleGate && !visibleGate(ctx)) continue
132
146
  const h = formatHint(b)
133
147
  if (h !== null) hints.push(h)
134
148
  }
@@ -139,7 +153,7 @@ export const Footer = <C,>({
139
153
  const status = discoveryStatus && discoveryStatus.length > 0 ? discoveryStatus : null
140
154
  const statusBudget = status ? Math.min(status.length + STATUS_SEPARATOR.length, usableWidth) : 0
141
155
  const hintsWidth = Math.max(0, usableWidth - statusBudget)
142
- const hintContent = fitHints(hints, hintsWidth)
156
+ const visibleHints = fitHints(hints, hintsWidth)
143
157
  const statusContent = status
144
158
  ? status.slice(0, Math.max(0, statusBudget - STATUS_SEPARATOR.length))
145
159
  : ""
@@ -150,6 +164,40 @@ export const Footer = <C,>({
150
164
  : notice
151
165
  : null
152
166
 
167
+ // Two-tone hint row: keys render in `text` (foreground-strength), the
168
+ // `:label` portion in `textMuted`. Matches ghui's footer treatment so
169
+ // the key — the actionable token — visually leads each hint.
170
+ const renderHints = () =>
171
+ visibleHints.flatMap((h, i) => {
172
+ const sep =
173
+ i > 0
174
+ ? [
175
+ <text
176
+ key={`s${i}`}
177
+ content={HINT_SEPARATOR}
178
+ wrapMode="none"
179
+ style={{ fg: colors.textMuted }}
180
+ />,
181
+ ]
182
+ : []
183
+ if (h.key === null) {
184
+ return [
185
+ ...sep,
186
+ <text key={`l${i}`} content={h.label} wrapMode="none" style={{ fg: colors.textMuted }} />,
187
+ ]
188
+ }
189
+ return [
190
+ ...sep,
191
+ <text key={`k${i}`} content={h.key} wrapMode="none" style={{ fg: colors.text }} />,
192
+ <text
193
+ key={`l${i}`}
194
+ content={` ${h.label}`}
195
+ wrapMode="none"
196
+ style={{ fg: colors.textMuted }}
197
+ />,
198
+ ]
199
+ })
200
+
153
201
  // Priority: notice > (status + hints). Notice fg is strong; status sits
154
202
  // at the muted level so it reads as ambient state, not an event.
155
203
  if (noticeContent !== null) {
@@ -165,14 +213,10 @@ export const Footer = <C,>({
165
213
  <box style={rowStyle}>
166
214
  <text content={statusContent} wrapMode="none" style={{ fg: colors.textMuted }} />
167
215
  <text content={STATUS_SEPARATOR} wrapMode="none" style={{ fg: colors.textMuted }} />
168
- <text content={hintContent} wrapMode="none" style={{ fg: colors.textMuted }} />
216
+ {renderHints()}
169
217
  </box>
170
218
  )
171
219
  }
172
220
 
173
- return (
174
- <box style={rowStyle}>
175
- <text content={hintContent} wrapMode="none" style={{ fg: colors.textMuted }} />
176
- </box>
177
- )
221
+ return <box style={rowStyle}>{renderHints()}</box>
178
222
  }
package/src/Header.tsx ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Header — single-row chrome above the two-pane area.
3
+ *
4
+ * Borderless single line modeled on ghui's PlainLine header: brand and
5
+ * current filename on the left, version on the right. The row is
6
+ * informational, not interactive — see issue #38 for the design discussion.
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).
13
+ */
14
+
15
+ import pkg from "../package.json" with { type: "json" }
16
+ import { BRAND, BRAND_NAME } from "./brand.ts"
17
+ import { colors } from "./theme/colors.ts"
18
+
19
+ export const HEADER_HEIGHT = 1
20
+
21
+ const FILE_SEPARATOR = " · "
22
+
23
+ export interface HeaderProps {
24
+ readonly width: number
25
+ /** Currently selected file's relative path. When set, the Header shows
26
+ * it next to the brand mark — replaces the per-pane border title that
27
+ * used to carry this information. */
28
+ readonly currentFile?: string | null
29
+ /** Optional override for the version string (testing). Defaults to
30
+ * the running package's version. */
31
+ readonly version?: string
32
+ }
33
+
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
50
+
51
+ return (
52
+ <box
53
+ style={{
54
+ width,
55
+ height: HEADER_HEIGHT,
56
+ flexShrink: 0,
57
+ flexDirection: "row",
58
+ justifyContent: "space-between",
59
+ paddingLeft: 1,
60
+ paddingRight: 1,
61
+ backgroundColor: colors.surface,
62
+ }}
63
+ >
64
+ <text content={left} wrapMode="none" style={{ fg: colors.text }} />
65
+ {showRight && <text content={right} wrapMode="none" style={{ fg: colors.text }} />}
66
+ </box>
67
+ )
68
+ }
@@ -6,9 +6,13 @@
6
6
  * keys — the dispatcher and the help text are the same source of truth.
7
7
  */
8
8
 
9
+ import { RGBA } from "@opentui/core"
9
10
  import type { KeyBinding } from "./keymap/keymap.ts"
10
11
  import { colors } from "./theme/colors.ts"
11
12
 
13
+ // See CommandPalette.tsx for the scrim rationale.
14
+ const SCRIM = RGBA.fromInts(0, 0, 0, 150)
15
+
12
16
  export interface HelpOverlayProps<C> {
13
17
  readonly bindings: readonly KeyBinding<C>[]
14
18
  readonly viewportWidth: number
@@ -82,49 +86,63 @@ export const HelpOverlay = <C,>({
82
86
  return (
83
87
  <box
84
88
  position="absolute"
85
- left={left}
86
- top={top}
87
- width={overlayWidth}
88
- height={overlayHeight}
89
+ left={0}
90
+ top={0}
91
+ width={viewportWidth}
92
+ height={viewportHeight}
89
93
  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
- }}
94
+ style={{ backgroundColor: SCRIM }}
99
95
  >
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
- })}
96
+ <box
97
+ position="absolute"
98
+ left={left}
99
+ top={top}
100
+ width={overlayWidth}
101
+ height={overlayHeight}
102
+ title=" Help "
103
+ titleAlignment="left"
104
+ style={{
105
+ border: true,
106
+ borderColor: colors.textMuted,
107
+ padding: 1,
108
+ flexDirection: "column",
109
+ backgroundColor: colors.surface,
110
+ }}
111
+ >
112
+ {rows.map((row) => {
113
+ switch (row.kind) {
114
+ case "header":
115
+ return (
116
+ <text
117
+ key={row.key}
118
+ wrapMode="none"
119
+ content={row.text}
120
+ style={{ fg: colors.borderActive, attributes: 1 /* bold */ }}
121
+ />
122
+ )
123
+ case "footer":
124
+ return (
125
+ <text
126
+ key={row.key}
127
+ wrapMode="none"
128
+ content={row.text}
129
+ style={{ fg: colors.textMuted }}
130
+ />
131
+ )
132
+ case "spacer":
133
+ return <text key={row.key} content=" " />
134
+ case "binding":
135
+ return (
136
+ <text
137
+ key={row.key}
138
+ wrapMode="none"
139
+ content={row.text}
140
+ style={{ fg: colors.text }}
141
+ />
142
+ )
143
+ }
144
+ })}
145
+ </box>
128
146
  </box>
129
147
  )
130
148
  }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * PromptRow — single-line `> query` row shared by the sidebar filter and
3
+ * the command palette query input.
4
+ *
5
+ * Render-only: the parent owns query state and the focus/editing flag.
6
+ * The `> ` prefix always renders in `textStrong` regardless of state so it
7
+ * reads as chrome, not placeholder text — only the body span shifts color
8
+ * (textStrong while editing, text when applied, textMuted as placeholder).
9
+ *
10
+ * Overflow: when editing, an overflowing body anchors its right edge with a
11
+ * leading `…` so the cursor stays on screen; otherwise it anchors the left
12
+ * edge with a trailing `…`.
13
+ */
14
+
15
+ import { colors } from "./theme/colors.ts"
16
+
17
+ export interface PromptRowProps {
18
+ readonly query: string
19
+ /** True while the input is focused — shows a cursor and uses textStrong fg. */
20
+ readonly editing: boolean
21
+ /** Body fallback when !editing && query === "". Pass without the `> ` prefix. */
22
+ readonly placeholder?: string
23
+ /** Total cell width available for the row (prefix + body). */
24
+ readonly width: number
25
+ }
26
+
27
+ const PREFIX = "> "
28
+ const CURSOR = "▏"
29
+
30
+ export const PromptRow = ({ query, editing, placeholder = "", width }: PromptRowProps) => {
31
+ const bodyBudget = Math.max(1, width - PREFIX.length)
32
+
33
+ const rawBody = editing ? `${query}${CURSOR}` : query.length > 0 ? query : placeholder
34
+ const bodyFg = editing ? colors.textStrong : query.length > 0 ? colors.text : colors.textMuted
35
+
36
+ const body =
37
+ rawBody.length <= bodyBudget
38
+ ? rawBody
39
+ : editing
40
+ ? "…" + rawBody.slice(rawBody.length - bodyBudget + 1)
41
+ : rawBody.slice(0, bodyBudget - 1) + "…"
42
+
43
+ return (
44
+ <text wrapMode="none">
45
+ <span style={{ fg: colors.textStrong }}>{PREFIX}</span>
46
+ <span style={{ fg: bodyFg }}>{body}</span>
47
+ </text>
48
+ )
49
+ }
package/src/brand.ts ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Brand mark. U+2302 HOUSE is a single-cell glyph in every monospace font
3
+ * we care about; falls back to a tofu box on rare fonts that lack it.
4
+ * Single import point so future placements (#76) stay in sync.
5
+ */
6
+ export const BRAND = "⌂"
7
+ export const BRAND_NAME = "house"
package/src/cli/argv.ts CHANGED
@@ -25,6 +25,12 @@ export interface ParsedArgs {
25
25
  readonly configPath: boolean
26
26
  /** Value of `--sidebar <mode>` (`auto`, `on`, `off`), or null. Validated by the boot layer. */
27
27
  readonly sidebar: string | null
28
+ /** True when `--no-update-check` was passed: suppress the npm-registry
29
+ * probe and the "update available" notice. Mirrors the
30
+ * `NO_UPDATE_NOTIFIER` env var so opt-out is reachable without env state. */
31
+ readonly noUpdateCheck: boolean
32
+ /** True when `--no-mdx` was passed: exclude `.mdx` files from discovery. */
33
+ readonly noMdx: boolean
28
34
  }
29
35
 
30
36
  /**
@@ -47,6 +53,8 @@ export const parseArgv = (argv: readonly string[]): ParsedArgs => {
47
53
  let version = false
48
54
  let configPath = false
49
55
  let sidebar: string | null = null
56
+ let noUpdateCheck = false
57
+ let noMdx = false
50
58
 
51
59
  for (let i = 0; i < argv.length; i++) {
52
60
  const arg = argv[i]!
@@ -99,13 +107,34 @@ export const parseArgv = (argv: readonly string[]): ParsedArgs => {
99
107
  }
100
108
  continue
101
109
  }
110
+ case "--no-update-check":
111
+ noUpdateCheck = true
112
+ continue
113
+ case "--no-mdx":
114
+ noMdx = true
115
+ continue
102
116
  }
103
117
  if (path === null && !arg.startsWith("-")) {
104
118
  path = arg
105
119
  }
106
120
  }
107
121
 
108
- return { path, theme, tone, width, all, sort, serve, port, help, version, configPath, sidebar }
122
+ return {
123
+ path,
124
+ theme,
125
+ tone,
126
+ width,
127
+ all,
128
+ sort,
129
+ serve,
130
+ port,
131
+ help,
132
+ version,
133
+ configPath,
134
+ sidebar,
135
+ noUpdateCheck,
136
+ noMdx,
137
+ }
109
138
  }
110
139
 
111
140
  const themeList = themeDefinitions.map((t) => t.id).join(", ")
@@ -126,9 +155,11 @@ options:
126
155
  -h, --help show this help and exit
127
156
  -v, --version print version and exit
128
157
  --config-path print path to the config file and exit
158
+ --no-update-check suppress the "newer version available" check (also via NO_UPDATE_NOTIFIER=1)
159
+ --no-mdx exclude .mdx files from discovery (default: included)
129
160
 
130
161
  configuration:
131
162
  file: $XDG_CONFIG_HOME/house/config.toml (default ~/.config/house/config.toml)
132
- keys: theme, tone
133
- env: HOUSE_THEME, HOUSE_TONE
163
+ keys: theme, tone, mdx
164
+ env: HOUSE_THEME, HOUSE_TONE, HOUSE_MDX
134
165
  precedence (high → low): flags → env → file → defaults`
@@ -45,6 +45,7 @@ const annotations: Record<string, Annotation> = {
45
45
  "help.toggle": { title: "Show help", category: "App" },
46
46
  "filter.open": { title: "Filter files…", category: "Navigation" },
47
47
  "serve.current": { title: "Open in browser", category: "File" },
48
+ "file.edit": { title: "Open in editor", category: "File", keywords: ["editor", "vim", "vscode"] },
48
49
  "theme.next": { category: "Appearance" },
49
50
  "theme.prev": { category: "Appearance" },
50
51
  "theme.toneToggle": { title: "Toggle dark/light tone", category: "Appearance" },