@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.
@@ -18,39 +18,96 @@ import { themeDefinitions } from "../theme/registry.ts"
18
18
  export interface HouseConfig {
19
19
  readonly theme: string
20
20
  readonly tone: "dark" | "light"
21
+ readonly mdx: boolean
21
22
  }
22
23
 
23
24
  export interface CliOverrides {
24
25
  readonly theme: string | null
25
26
  readonly tone: string | null
27
+ readonly mdx: boolean | null
26
28
  }
27
29
 
28
30
  const DEFAULT_THEME = "opencode"
29
31
  const DEFAULT_TONE: "dark" | "light" = "dark"
32
+ const DEFAULT_MDX = true
30
33
 
31
34
  const themeIds = themeDefinitions.map((t) => t.id)
32
35
 
33
36
  /**
34
37
  * Top-level keys the config file is allowed to set. Kept in sync by hand
35
38
  * with `schema` below — when adding a key, add it both places.
36
- * Used by `fileProvider` to reject typo'd keys (e.g. `them = "..."`) loudly
37
- * rather than silently falling back to defaults.
39
+ * Used by `fileProvider` to warn about unrecognized keys (with a
40
+ * did-you-mean hint when one is close) while still loading the rest.
38
41
  */
39
- const KNOWN_FILE_KEYS: ReadonlySet<string> = new Set(["theme", "tone"])
42
+ const KNOWN_FILE_KEYS: ReadonlySet<string> = new Set(["theme", "tone", "mdx"])
40
43
 
41
44
  const schema = Config.all({
42
45
  theme: Config.schema(Schema.Literals(themeIds), "theme"),
43
46
  tone: Config.schema(Schema.Literals(["dark", "light"] as const), "tone"),
47
+ // Boolean stored as string literal because providers stringify values
48
+ // (TOML bools, env vars, CLI flags all flow through as text). Mapped to
49
+ // a real boolean in `loadConfig` below.
50
+ mdx: Config.schema(Schema.Literals(["true", "false"] as const), "mdx"),
44
51
  })
45
52
 
46
53
  const defaultsProvider = (): ConfigProvider.ConfigProvider =>
47
- ConfigProvider.fromUnknown({ theme: DEFAULT_THEME, tone: DEFAULT_TONE })
54
+ ConfigProvider.fromUnknown({
55
+ theme: DEFAULT_THEME,
56
+ tone: DEFAULT_TONE,
57
+ mdx: String(DEFAULT_MDX),
58
+ })
59
+
60
+ /**
61
+ * Levenshtein edit distance, capped at `cap` for early exit.
62
+ * Used only to suggest "did you mean X?" when a config key looks like a
63
+ * typo of a known one. Tiny inputs (≤ ~20 chars), so the naive O(n·m)
64
+ * fill is fine.
65
+ */
66
+ const editDistance = (a: string, b: string, cap: number): number => {
67
+ if (Math.abs(a.length - b.length) > cap) return cap + 1
68
+ const prev: number[] = Array.from({ length: b.length + 1 })
69
+ const curr: number[] = Array.from({ length: b.length + 1 })
70
+ for (let j = 0; j <= b.length; j++) prev[j] = j
71
+ for (let i = 1; i <= a.length; i++) {
72
+ curr[0] = i
73
+ let rowMin = curr[0]!
74
+ for (let j = 1; j <= b.length; j++) {
75
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1
76
+ curr[j] = Math.min(prev[j]! + 1, curr[j - 1]! + 1, prev[j - 1]! + cost)
77
+ if (curr[j]! < rowMin) rowMin = curr[j]!
78
+ }
79
+ if (rowMin > cap) return cap + 1
80
+ for (let j = 0; j <= b.length; j++) prev[j] = curr[j]!
81
+ }
82
+ return prev[b.length]!
83
+ }
84
+
85
+ const suggestKey = (unknown: string, known: readonly string[]): string | null => {
86
+ let best: { key: string; dist: number } | null = null
87
+ for (const k of known) {
88
+ const d = editDistance(unknown, k, 2)
89
+ if (d <= 2 && (best === null || d < best.dist)) best = { key: k, dist: d }
90
+ }
91
+ return best?.key ?? null
92
+ }
93
+
94
+ const formatUnknownKeyWarning = (path: string, key: string, known: readonly string[]): string => {
95
+ const suggestion = suggestKey(key, known)
96
+ const hint = suggestion ? ` — did you mean "${suggestion}"?` : ""
97
+ return `house: ignoring unknown key "${key}" in ${path}${hint}`
98
+ }
48
99
 
49
100
  /**
50
101
  * Reads a TOML file at `path`. Missing file → `undefined` for every key
51
- * (per-key fallthrough). Malformed TOML → `SourceError` (hard fail upstream).
102
+ * (per-key fallthrough). Malformed TOML → `SourceError` (hard fail
103
+ * upstream). Unknown top-level keys are warned about via `onWarning` and
104
+ * dropped — this preserves forward-compat with newer config schemas while
105
+ * still flagging typos like `them = "..."`.
52
106
  */
53
- const fileProvider = (path: string): ConfigProvider.ConfigProvider => {
107
+ const fileProvider = (
108
+ path: string,
109
+ onWarning: (message: string) => void,
110
+ ): ConfigProvider.ConfigProvider => {
54
111
  let cache: { data: Record<string, unknown> | null } | null = null
55
112
  const load = Effect.gen(function* () {
56
113
  if (cache !== null) return cache.data
@@ -69,17 +126,17 @@ const fileProvider = (path: string): ConfigProvider.ConfigProvider => {
69
126
  cause,
70
127
  }),
71
128
  })
72
- const unknown = Object.keys(parsed).filter((k) => !KNOWN_FILE_KEYS.has(k))
73
- if (unknown.length > 0) {
74
- const known = [...KNOWN_FILE_KEYS].join(", ")
75
- return yield* Effect.fail(
76
- new ConfigProvider.SourceError({
77
- message: `unknown key${unknown.length > 1 ? "s" : ""} in ${path}: ${unknown.map((k) => `"${k}"`).join(", ")} (known: ${known})`,
78
- }),
79
- )
129
+ const known = [...KNOWN_FILE_KEYS]
130
+ const filtered: Record<string, unknown> = {}
131
+ for (const [k, v] of Object.entries(parsed)) {
132
+ if (KNOWN_FILE_KEYS.has(k)) {
133
+ filtered[k] = v
134
+ } else {
135
+ onWarning(formatUnknownKeyWarning(path, k, known))
136
+ }
80
137
  }
81
- cache = { data: parsed }
82
- return parsed
138
+ cache = { data: filtered }
139
+ return filtered
83
140
  })
84
141
  return ConfigProvider.make((path) =>
85
142
  Effect.gen(function* () {
@@ -112,8 +169,10 @@ const envProvider = (env: Record<string, string | undefined>): ConfigProvider.Co
112
169
  const entries: Array<[string, string]> = []
113
170
  const theme = env["HOUSE_THEME"]
114
171
  const tone = env["HOUSE_TONE"]
172
+ const mdx = env["HOUSE_MDX"]
115
173
  if (theme !== undefined) entries.push(["theme", theme])
116
174
  if (tone !== undefined) entries.push(["tone", tone])
175
+ if (mdx !== undefined) entries.push(["mdx", mdx])
117
176
  return ConfigProvider.fromUnknown(Object.fromEntries(entries))
118
177
  }
119
178
 
@@ -121,6 +180,7 @@ const cliProvider = (overrides: CliOverrides): ConfigProvider.ConfigProvider =>
121
180
  const entries: Array<[string, string]> = []
122
181
  if (overrides.theme !== null) entries.push(["theme", overrides.theme])
123
182
  if (overrides.tone !== null) entries.push(["tone", overrides.tone])
183
+ if (overrides.mdx !== null) entries.push(["mdx", String(overrides.mdx)])
124
184
  return ConfigProvider.fromUnknown(Object.fromEntries(entries))
125
185
  }
126
186
 
@@ -130,6 +190,8 @@ export interface LoadOptions {
130
190
  readonly filePath?: string
131
191
  /** Override env (tests). Defaults to `process.env`. */
132
192
  readonly env?: Record<string, string>
193
+ /** Sink for non-fatal warnings (unknown keys). Defaults to stderr. */
194
+ readonly onWarning?: (message: string) => void
133
195
  }
134
196
 
135
197
  export const defaultConfigPath = (): string =>
@@ -156,11 +218,14 @@ export const formatConfigError = (err: unknown): string => {
156
218
  export const loadConfig = (
157
219
  options: LoadOptions = {},
158
220
  ): Effect.Effect<HouseConfig, Config.ConfigError> => {
159
- const cli = options.cli ?? { theme: null, tone: null }
221
+ const cli = options.cli ?? { theme: null, tone: null, mdx: null }
222
+ const onWarning = options.onWarning ?? ((msg) => process.stderr.write(`${msg}\n`))
160
223
  const provider = cliProvider(cli).pipe(
161
224
  ConfigProvider.orElse(envProvider(options.env ?? process.env)),
162
- ConfigProvider.orElse(fileProvider(options.filePath ?? defaultConfigPath())),
225
+ ConfigProvider.orElse(fileProvider(options.filePath ?? defaultConfigPath(), onWarning)),
163
226
  ConfigProvider.orElse(defaultsProvider()),
164
227
  )
165
- return schema.parse(provider)
228
+ return schema
229
+ .parse(provider)
230
+ .pipe(Effect.map((raw) => ({ theme: raw.theme, tone: raw.tone, mdx: raw.mdx === "true" })))
166
231
  }
@@ -19,6 +19,8 @@ export interface WalkOptions {
19
19
  readonly all?: boolean
20
20
  /** Group order within each directory. Default `dirs-first`. */
21
21
  readonly sort?: SortOrder
22
+ /** Include `.mdx` files alongside `.md`/`.markdown`. Default `true`. */
23
+ readonly mdx?: boolean
22
24
  }
23
25
 
24
26
  export class DiscoveryError extends Data.TaggedError("DiscoveryError")<{
@@ -27,6 +29,7 @@ export class DiscoveryError extends Data.TaggedError("DiscoveryError")<{
27
29
  }> {}
28
30
 
29
31
  const MARKDOWN_EXTENSIONS = new Set([".md", ".markdown", ".mdx"])
32
+ const MARKDOWN_EXTENSIONS_NO_MDX = new Set([".md", ".markdown"])
30
33
  const HARD_SKIP_DIRS = new Set(["node_modules", ".git", ".venv"])
31
34
 
32
35
  interface IgnoreLevel {
@@ -85,7 +88,7 @@ async function* walkDirGen(
85
88
  dirPath: string,
86
89
  rootPath: string,
87
90
  parentLevels: readonly IgnoreLevel[],
88
- opts: { all: boolean; sort: SortOrder },
91
+ opts: { all: boolean; sort: SortOrder; mdx: boolean },
89
92
  signal: AbortSignal,
90
93
  ): AsyncGenerator<FileEntry, void, void> {
91
94
  if (signal.aborted) return
@@ -119,7 +122,8 @@ async function* walkDirGen(
119
122
 
120
123
  if (!entry.isFile()) continue
121
124
  if (!opts.all && entry.name.startsWith(".")) continue
122
- if (!MARKDOWN_EXTENSIONS.has(extname(entry.name).toLowerCase())) continue
125
+ const allowed = opts.mdx ? MARKDOWN_EXTENSIONS : MARKDOWN_EXTENSIONS_NO_MDX
126
+ if (!allowed.has(extname(entry.name).toLowerCase())) continue
123
127
  if (!opts.all && isIgnored(entryPath, false, levels)) continue
124
128
 
125
129
  yield {
@@ -137,7 +141,7 @@ async function* walkDirGen(
137
141
  * at its next `signal.aborted` check.
138
142
  *
139
143
  * Rules (see DESIGN.md §6):
140
- * - Extensions: `.md`, `.markdown`, `.mdx`.
144
+ * - Extensions: `.md`, `.markdown`, and `.mdx` (unless `mdx: false`).
141
145
  * - Hard skips (always): `node_modules`, `.git`, `.venv`.
142
146
  * - Hidden files/dirs (leading `.`) skipped unless `all: true`.
143
147
  * - `.gitignore` honored, including nested `.gitignore` files.
@@ -153,6 +157,7 @@ export const walk = (
153
157
  const opts = {
154
158
  all: options.all ?? false,
155
159
  sort: options.sort ?? ("dirs-first" as SortOrder),
160
+ mdx: options.mdx ?? true,
156
161
  }
157
162
  const controller = new AbortController()
158
163
  const iterable: AsyncIterable<FileEntry> = {
package/src/index.tsx CHANGED
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { stat } from "node:fs/promises"
12
12
  import { createCliRenderer, SyntaxStyle } from "@opentui/core"
13
+ import type { BorderSides } from "@opentui/core"
13
14
  import { createRoot, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
14
15
  import { RegistryProvider, useAtomSet, useAtomValue } from "@effect/atom-react"
15
16
  import { Cause, Duration, Effect, Fiber, Stream } from "effect"
@@ -19,17 +20,21 @@ import { Browser } from "./Browser.tsx"
19
20
  import { parseArgv, usage } from "./cli/argv.ts"
20
21
  import { defaultConfigPath, formatConfigError, loadConfig } from "./config/load.ts"
21
22
  import { walk, type FileEntry, type SortOrder } from "./discovery/walk.ts"
23
+ import { Header } from "./Header.tsx"
22
24
  import { readFileText } from "./io/readFile.ts"
23
25
  import { openInBrowser } from "./serve/openBrowser.ts"
24
26
  import { startServer } from "./serve/server.ts"
25
27
  import { colors, setActiveTheme } from "./theme/colors.ts"
26
28
  import { themeAtom, type ThemeState } from "./theme/atom.ts"
27
29
  import { getThemeDefinition, themeDefinitions } from "./theme/registry.ts"
30
+ import { formatQuitNotice } from "./update/notice.ts"
31
+ import { currentUpdateInfo, startUpdateProbe } from "./update/runtime.ts"
32
+ import { useUpdateNotice } from "./update/useUpdateNotice.ts"
28
33
 
29
34
  export interface AppProps {
30
35
  /** Markdown source to render. */
31
36
  readonly content: string
32
- /** Optional title shown in the frame border. Defaults to a generic label. */
37
+ /** Optional title shown in the header's current-file slot. Defaults to a generic label. */
33
38
  readonly title?: string
34
39
  /** Cap the rendered markdown's width at N columns (left-aligned). Null = fill the pane. */
35
40
  readonly maxWidth?: number | null
@@ -56,11 +61,13 @@ interface DiscoverShellProps {
56
61
  readonly target: string
57
62
  readonly all: boolean
58
63
  readonly sort: SortOrder
64
+ readonly mdx: boolean
59
65
  readonly maxWidth: number | null
60
66
  readonly sidebarMode: SidebarMode
61
67
  }
62
68
 
63
- const DiscoverShell = ({ target, all, sort, maxWidth, sidebarMode }: DiscoverShellProps) => {
69
+ const DiscoverShell = ({ target, all, sort, mdx, maxWidth, sidebarMode }: DiscoverShellProps) => {
70
+ const updateNotice = useUpdateNotice()
64
71
  const [files, setFiles] = useState<readonly FileEntry[]>([])
65
72
  const [scanning, setScanning] = useState<boolean>(true)
66
73
  const [scanError, setScanError] = useState<string | null>(null)
@@ -69,7 +76,7 @@ const DiscoverShell = ({ target, all, sort, maxWidth, sidebarMode }: DiscoverShe
69
76
  const countRef = useRef(0)
70
77
 
71
78
  useEffect(() => {
72
- const program = walk(target, { all, sort }).pipe(
79
+ const program = walk(target, { all, sort, mdx }).pipe(
73
80
  Stream.groupedWithin(64, Duration.millis(60)),
74
81
  Stream.runForEach((chunk) =>
75
82
  Effect.sync(() => {
@@ -94,17 +101,17 @@ const DiscoverShell = ({ target, all, sort, maxWidth, sidebarMode }: DiscoverShe
94
101
  return () => {
95
102
  Effect.runFork(Fiber.interrupt(fiber))
96
103
  }
97
- }, [target, all, sort])
104
+ }, [target, all, sort, mdx])
98
105
 
99
106
  const discoveryStatus = scanError ?? (scanning ? `indexing… ${countRef.current}` : null)
100
107
 
101
108
  return (
102
109
  <Browser
103
110
  files={files}
104
- title={target}
105
111
  maxWidth={maxWidth}
106
112
  discoveryStatus={discoveryStatus}
107
113
  sidebarMode={sidebarMode}
114
+ updateNotice={updateNotice}
108
115
  />
109
116
  )
110
117
  }
@@ -145,13 +152,14 @@ export const App = ({ content, title = "house", maxWidth = null, onQuit }: AppPr
145
152
  if (key.name === "l" && key.shift) toggleTone()
146
153
  })
147
154
 
155
+ const paneBorderSides: BorderSides[] = ["top", "bottom"]
156
+
148
157
  return (
149
158
  <box style={{ width, height, flexDirection: "column", backgroundColor: colors.background }}>
159
+ <Header width={width} currentFile={title} />
150
160
  <box
151
- title={` ${title} `}
152
- titleAlignment="left"
153
161
  style={{
154
- border: true,
162
+ border: paneBorderSides,
155
163
  borderColor: colors.border,
156
164
  padding: 1,
157
165
  flexGrow: 1,
@@ -183,6 +191,8 @@ export const App = ({ content, title = "house", maxWidth = null, onQuit }: AppPr
183
191
  )
184
192
  }
185
193
 
194
+ let updateExitHookRegistered = false
195
+
186
196
  if (import.meta.main) {
187
197
  const args = parseArgv(Bun.argv.slice(2))
188
198
 
@@ -200,12 +210,20 @@ if (import.meta.main) {
200
210
  }
201
211
 
202
212
  const config = await Effect.runPromise(
203
- loadConfig({ cli: { theme: args.theme, tone: args.tone } }),
213
+ loadConfig({
214
+ cli: {
215
+ theme: args.theme,
216
+ tone: args.tone,
217
+ // --no-mdx is a one-way override: present means "off". When
218
+ // absent, fall through to env/file/default.
219
+ mdx: args.noMdx ? false : null,
220
+ },
221
+ }),
204
222
  ).catch((err: unknown) => {
205
223
  console.error(`house: ${formatConfigError(err)}`)
206
224
  process.exit(2)
207
225
  })
208
- const { theme: themeId, tone } = config
226
+ const { theme: themeId, tone, mdx } = config
209
227
  const themeDef = getThemeDefinition(themeId)
210
228
  if (themeDef === undefined) {
211
229
  // Unreachable: Config.schema validated themeId against themeDefinitions.
@@ -275,7 +293,17 @@ if (import.meta.main) {
275
293
  }
276
294
  sidebarMode = args.sidebar
277
295
  }
278
- await runTui({ target, themeId, tone, maxWidth, all: args.all, sort, sidebarMode })
296
+ await runTui({
297
+ target,
298
+ themeId,
299
+ tone,
300
+ maxWidth,
301
+ all: args.all,
302
+ sort,
303
+ mdx,
304
+ sidebarMode,
305
+ updateCheck: !args.noUpdateCheck,
306
+ })
279
307
  }
280
308
  }
281
309
 
@@ -286,7 +314,11 @@ interface TuiBootOptions {
286
314
  readonly maxWidth: number | null
287
315
  readonly all: boolean
288
316
  readonly sort: SortOrder
317
+ readonly mdx: boolean
289
318
  readonly sidebarMode: SidebarMode
319
+ /** Run the npm-registry probe and surface the "update available" notice.
320
+ * False suppresses both the toast and the quit-time print. */
321
+ readonly updateCheck: boolean
290
322
  }
291
323
 
292
324
  async function runTui({
@@ -296,7 +328,9 @@ async function runTui({
296
328
  maxWidth,
297
329
  all,
298
330
  sort,
331
+ mdx,
299
332
  sidebarMode,
333
+ updateCheck,
300
334
  }: TuiBootOptions): Promise<void> {
301
335
  let stats: Awaited<ReturnType<typeof stat>>
302
336
  try {
@@ -306,6 +340,24 @@ async function runTui({
306
340
  process.exit(1)
307
341
  }
308
342
 
343
+ if (updateCheck) {
344
+ // Fire the npm-registry probe in the background. Result lands in a
345
+ // module singleton; the React tree picks it up via `useUpdateNotice`
346
+ // for the footer toast, and the 'exit' hook below reads it
347
+ // synchronously for the scrollback print. Failures are silent — this
348
+ // whole feature is opportunistic.
349
+ startUpdateProbe(pkg.name, pkg.version)
350
+ // Register once per process. Multiple 'exit' listeners would print
351
+ // the notice multiple times if runTui were ever re-entered.
352
+ if (!updateExitHookRegistered) {
353
+ updateExitHookRegistered = true
354
+ process.on("exit", () => {
355
+ const info = currentUpdateInfo()
356
+ if (info) process.stderr.write(formatQuitNotice(info))
357
+ })
358
+ }
359
+ }
360
+
309
361
  const renderer = await createCliRenderer({ exitOnCtrlC: false })
310
362
  const initialTheme: ThemeState = { id: themeId, tone }
311
363
 
@@ -316,6 +368,7 @@ async function runTui({
316
368
  target={target}
317
369
  all={all}
318
370
  sort={sort}
371
+ mdx={mdx}
319
372
  maxWidth={maxWidth}
320
373
  sidebarMode={sidebarMode}
321
374
  />
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Resolve `$VISUAL` / `$EDITOR` into a spawnable `{ cmd, args }`.
3
+ *
4
+ * The string is POSIX shell-split so users with values like
5
+ * `code --wait` or `"/Applications/Sublime Text/subl" --wait` get the
6
+ * expected argv. We deliberately do *not* shell out via `sh -c`: the
7
+ * caller appends the file path as a separate argv element, which avoids
8
+ * command-injection risk for paths containing shell metacharacters.
9
+ *
10
+ * Resolution order: `$VISUAL` → `$EDITOR` → `null`. No silent fallback to
11
+ * `vi`; the caller surfaces a footer notice when neither is set.
12
+ */
13
+
14
+ export interface ResolvedEditor {
15
+ readonly cmd: string
16
+ readonly args: readonly string[]
17
+ }
18
+
19
+ /**
20
+ * Split a command string the way `sh` would for the simple cases users
21
+ * actually put in `$EDITOR`: single quotes (literal), double quotes
22
+ * (with `\"`, `\\` escapes), backslash escapes outside quotes, and
23
+ * whitespace separation. No variable expansion, no globbing, no command
24
+ * substitution — by design. If the input is unbalanced (an unclosed
25
+ * quote), the partial token is emitted as-is so the caller's spawn
26
+ * surfaces a real error instead of us throwing.
27
+ */
28
+ export const splitEditorString = (input: string): string[] => {
29
+ const tokens: string[] = []
30
+ let buf = ""
31
+ let inSingle = false
32
+ let inDouble = false
33
+ let pendingToken = false
34
+
35
+ for (let i = 0; i < input.length; i++) {
36
+ const ch = input[i]!
37
+ if (inSingle) {
38
+ if (ch === "'") inSingle = false
39
+ else buf += ch
40
+ continue
41
+ }
42
+ if (inDouble) {
43
+ if (ch === "\\" && i + 1 < input.length) {
44
+ const next = input[i + 1]!
45
+ // In double quotes, `sh` only treats `\` as an escape before
46
+ // `$`, `` ` ``, `"`, `\`, or newline. Otherwise the backslash
47
+ // is literal. We collapse it for `"` and `\` (the cases users
48
+ // hit with Windows-style paths in double quotes) and keep it
49
+ // literal otherwise.
50
+ if (next === '"' || next === "\\") {
51
+ buf += next
52
+ i++
53
+ continue
54
+ }
55
+ }
56
+ if (ch === '"') {
57
+ inDouble = false
58
+ continue
59
+ }
60
+ buf += ch
61
+ continue
62
+ }
63
+ if (ch === "'") {
64
+ inSingle = true
65
+ pendingToken = true
66
+ continue
67
+ }
68
+ if (ch === '"') {
69
+ inDouble = true
70
+ pendingToken = true
71
+ continue
72
+ }
73
+ if (ch === "\\" && i + 1 < input.length) {
74
+ buf += input[i + 1]!
75
+ i++
76
+ pendingToken = true
77
+ continue
78
+ }
79
+ if (ch === " " || ch === "\t") {
80
+ if (pendingToken) {
81
+ tokens.push(buf)
82
+ buf = ""
83
+ pendingToken = false
84
+ }
85
+ continue
86
+ }
87
+ buf += ch
88
+ pendingToken = true
89
+ }
90
+ if (pendingToken) tokens.push(buf)
91
+ return tokens
92
+ }
93
+
94
+ /**
95
+ * Pick the user's editor from env vars. Returns `null` when neither
96
+ * `$VISUAL` nor `$EDITOR` is set to a non-empty, non-whitespace value.
97
+ *
98
+ * `env` is parameterised so tests don't need to mutate `process.env`.
99
+ */
100
+ export const resolveEditor = (
101
+ env: Readonly<Record<string, string | undefined>>,
102
+ ): ResolvedEditor | null => {
103
+ const raw = pickEnv(env["VISUAL"]) ?? pickEnv(env["EDITOR"])
104
+ if (raw == null) return null
105
+ const parts = splitEditorString(raw)
106
+ if (parts.length === 0) return null
107
+ const [cmd, ...args] = parts
108
+ if (!cmd) return null
109
+ return { cmd, args }
110
+ }
111
+
112
+ const pickEnv = (value: string | undefined): string | null => {
113
+ if (value == null) return null
114
+ const trimmed = value.trim()
115
+ return trimmed.length === 0 ? null : trimmed
116
+ }
117
+
118
+ /** Result of a launched editor session. Never throws — every failure is
119
+ * surfaced as a tagged value so the caller can decide UX. */
120
+ export type EditorRunResult =
121
+ | { readonly ok: true; readonly exitCode: number }
122
+ | {
123
+ readonly ok: false
124
+ readonly reason: "spawn-failed" | "non-zero"
125
+ readonly detail?: string
126
+ }
127
+
128
+ export interface OpenInEditorOptions {
129
+ readonly editor: ResolvedEditor
130
+ readonly filePath: string
131
+ }
132
+
133
+ /**
134
+ * Launch the resolved editor on `filePath`, inheriting stdio so the
135
+ * editor takes over the TTY. Caller is responsible for suspending /
136
+ * resuming the renderer around this call (see Browser.tsx).
137
+ *
138
+ * The file path is passed as a separate argv element — never interpolated
139
+ * into a shell string — so paths with shell metacharacters can't be
140
+ * misinterpreted.
141
+ *
142
+ * Windows is unsupported (see #129). The PATHEXT gap that breaks `.cmd`
143
+ * shims (`code.cmd`, `nvim.cmd`) is tracked specifically in #128.
144
+ */
145
+ export const openInEditor = async ({
146
+ editor,
147
+ filePath,
148
+ }: OpenInEditorOptions): Promise<EditorRunResult> => {
149
+ const argv = [editor.cmd, ...editor.args, filePath]
150
+ try {
151
+ const proc = Bun.spawn(argv, {
152
+ stdin: "inherit",
153
+ stdout: "inherit",
154
+ stderr: "inherit",
155
+ })
156
+ const exitCode = await proc.exited
157
+ if (exitCode === 0) return { ok: true, exitCode }
158
+ return { ok: false, reason: "non-zero", detail: String(exitCode) }
159
+ } catch (err) {
160
+ return { ok: false, reason: "spawn-failed", detail: String(err) }
161
+ }
162
+ }