@carlesandres/house 0.7.0 → 0.9.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.
package/CHANGELOG.md CHANGED
@@ -2,10 +2,42 @@
2
2
 
3
3
  All notable changes to house land here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) from v0.1.0 onward.
4
4
 
5
- The publish workflow (`.github/workflows/publish.yml`) runs on the `release: published` event, builds per-platform binaries, publishes platform packages then the main package via Trusted Publisher, and lets GitHub auto-generate release notes from commit subjects; this file is the curated, narrative version.
5
+ The publish workflow (`.github/workflows/publish.yml`) runs for a published release or a release-tag dispatch, builds per-platform binaries, publishes platform packages then the main package via Trusted Publisher, and lets GitHub auto-generate release notes from commit subjects; this file is the curated, narrative version.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.9.0] — 2026-09-12
10
+
11
+ ### Changed
12
+
13
+ - Opening a selected file now hides the Sidebar by default so the Reader gets the full pane; set
14
+ `autoHideSidebar = false` or use `--no-auto-hide-sidebar` to keep the two-pane view.
15
+ - Release preparation now runs from a dispatchable GitHub workflow, and merging its PR creates and verifies the release, so publishing no longer depends on a clean local checkout or a long-running terminal.
16
+ - The publish job no longer waits for a GitHub `npm` environment reviewer, so creating the GitHub Release (or a `main` dispatch) is enough to ship. The environment still allows only `v*` tags and `main`.
17
+ - Empty CLI `--extensions` / `--show` again override file and env lists after the Effect 4.0.0-beta.107 bump, so an explicit empty allow-list still wins.
18
+ - House now runs on OpenTUI 0.5.9, matching `@house/ui` and the current renderer/input stack.
19
+
20
+ ### Fixed
21
+
22
+ - Release preparation now creates GitHub-verified commits, so release pull requests satisfy the
23
+ repository's signed-commit rule and can be merged normally.
24
+ - Release preparation now rejects stale managed branches, requests maintainer review, and reports
25
+ dispatched CI on the pull request, so release approvals remain visible through GitHub and `gh`.
26
+ - Release preparation now passes GitHub authentication and step outputs through Turborepo, so
27
+ dispatches can create release pull requests and start their CI.
28
+ - Shift+A no longer drops a hidden-file selection when toggling hidden files back on, so the original file is reselected after the re-walk.
29
+ - Theme resolution now keeps 8-digit hex alpha from bundled palettes, so muted and border tokens in themes like Cursor stay distinct from opaque text.
30
+
31
+
32
+ ## [0.8.0] — 2026-08-30
33
+
34
+ ### Added
35
+
36
+ - `@house/options` specs may declare `footer: { icon, activate? }` so House can derive clickable footer controls from the catalog; wrap's `W` indicator now comes from that opt-in instead of a hard-coded Browser entry.
37
+ - Footer `T` and `O` controls cycle theme and File Navigator browse order one step forward (with a short notice), so multi-value options are adjustable without leaving the keyboard-or-click chrome.
38
+ - Choice footer controls show a declared short abbreviation of the current value (for example `op` / `re`) instead of a fixed letter, so the chip itself reflects state between Activate clicks.
39
+
40
+
9
41
  ## [0.7.0] — 2026-08-30
10
42
 
11
43
  ### Added
@@ -433,7 +465,9 @@ The v1 MVP, published as `@carlesandres/openmdr` on npm.
433
465
 
434
466
  Search, stdin, URL fetching, cross-file link following, `$EDITOR` hand-off, syntax highlighting, persistent config, OS-appearance auto-detect, single-binary distribution (issue [#2](https://github.com/carlesandres/openmdr/issues/2)), Homebrew tap. All tracked.
435
467
 
436
- [Unreleased]: https://github.com/carlesandres/house/compare/v0.7.0...HEAD
468
+ [Unreleased]: https://github.com/carlesandres/house/compare/v0.9.0...HEAD
469
+ [0.9.0]: https://github.com/carlesandres/house/compare/v0.8.0...v0.9.0
470
+ [0.8.0]: https://github.com/carlesandres/house/compare/v0.7.0...v0.8.0
437
471
  [0.7.0]: https://github.com/carlesandres/house/compare/v0.6.0...v0.7.0
438
472
  [0.6.0]: https://github.com/carlesandres/house/compare/v0.5.2...v0.6.0
439
473
  [0.5.2]: https://github.com/carlesandres/house/compare/v0.5.1...v0.5.2
package/README.md CHANGED
@@ -68,6 +68,8 @@ house --serve README.md
68
68
  | `--width <N>` | `80` | Reader wrap width used when wrapping is enabled |
69
69
  | `--wrap` | off | Start with reader wrapping enabled |
70
70
  | `--no-wrap` | off | Start with reader wrapping disabled |
71
+ | `--auto-hide-sidebar` | on | Hide the sidebar after explicitly opening a file |
72
+ | `--no-auto-hide-sidebar` | off | Keep the sidebar visible after explicitly opening a file |
71
73
  | `--show <list>` | `""` | Reveal normally-skipped entries; comma-separated subset of `hidden`, `gitignored`. Use `--show ""` to clear. |
72
74
  | `--root <dir>` | current directory | Discovery root to walk; overrides `defaultRoot` config/env |
73
75
  | `--focus <mode>` | `sidebar` | Startup focus: `sidebar`, `reader`, or `filter`. `filter` opens the sidebar filter prompt immediately. |
@@ -97,27 +99,33 @@ tone = "dark"
97
99
  extensions = []
98
100
  width = 80
99
101
  wrap = false
102
+ autoHideSidebar = true
100
103
  show = ["hidden", "gitignored"]
101
104
  focus = "sidebar"
102
105
  defaultRoot = "cwd" # or "git"
103
106
  order = "recently-modified" # or "tree"
104
107
  ```
105
108
 
106
- Supported keys: `theme`, `tone`, `extensions`, `width`, `wrap`, `show`, `focus`, `defaultRoot`, `order`.
109
+ Supported keys: `theme`, `tone`, `extensions`, `width`, `wrap`, `autoHideSidebar`, `show`, `focus`, `defaultRoot`, `order`.
107
110
 
108
111
  `show` is a list of normally-skipped categories to opt into. Known categories: `hidden` (dot-prefixed entries), `gitignored` (entries matched by a `.gitignore`). Default is the empty list. Hard skips (`node_modules`, `.git`, `.venv`) always apply.
109
112
 
110
113
  Precedence, highest to lowest:
111
114
 
112
- 1. CLI flags (`--theme`, `--tone`, `--ext`, `--width`, `--wrap`, `--no-wrap`, `--show`, `--focus`, `--order`, `--root`)
113
- 2. Env vars (`HOUSE_THEME`, `HOUSE_TONE`, `HOUSE_EXTENSIONS`, `HOUSE_WIDTH`, `HOUSE_WRAP`, `HOUSE_SHOW`, `HOUSE_FOCUS`, `HOUSE_DEFAULT_ROOT`, `HOUSE_ORDER`)
115
+ 1. CLI flags (`--theme`, `--tone`, `--ext`, `--width`, `--wrap`, `--no-wrap`, `--auto-hide-sidebar`, `--no-auto-hide-sidebar`, `--show`, `--focus`, `--order`, `--root`)
116
+ 2. Env vars (`HOUSE_THEME`, `HOUSE_TONE`, `HOUSE_EXTENSIONS`, `HOUSE_WIDTH`, `HOUSE_WRAP`, `HOUSE_AUTO_HIDE_SIDEBAR`, `HOUSE_SHOW`, `HOUSE_FOCUS`, `HOUSE_DEFAULT_ROOT`, `HOUSE_ORDER`)
114
117
  3. Config file
115
- 4. Built-in defaults (`opencode` / `dark` / `extensions = []` / `width = 80` / `wrap = false` / `show = []` / `focus = "sidebar"` / `defaultRoot = "cwd"` / `order = "recently-modified"`)
118
+ 4. Built-in defaults (`opencode` / `dark` / `extensions = []` / `width = 80` / `wrap = false` / `autoHideSidebar = true` / `show = []` / `focus = "sidebar"` / `defaultRoot = "cwd"` / `order = "recently-modified"`)
116
119
 
117
120
  `HOUSE_SHOW` takes a comma-separated list (`HOUSE_SHOW=hidden,gitignored`). For `show` specifically, each source completely replaces the next — categories don't merge across layers. Press `shift+a` in the TUI to round-trip between the configured set and the full vocabulary without editing config.
118
121
 
119
122
  `width` is the fixed reader width used when wrapping is enabled. `wrap` controls startup mode; press `w` in the TUI to toggle reader wrapping for the current session without editing config.
120
123
 
124
+ `autoHideSidebar` hides the sidebar when you explicitly open a selected file with `↵`, `→`, `l`,
125
+ the command palette, or a matching filter Return. Selection movement still previews files without
126
+ hiding it. Press `s` or `/` to bring the sidebar back. Set the option to `false` to retain the
127
+ two-pane view after Open file.
128
+
121
129
  `order` is the File Navigator browse order when no filter query is active. `recently-modified` (default) lists the most recently updated files first, using filesystem modification time, then tree order for ties. `tree` lists files before directories, alphabetically within each group, so current-directory files appear before nested subtrees. An active filter still ranks by relevance first.
122
130
 
123
131
  The file is optional — a missing file is fine. Invalid keys, unknown themes, invalid `defaultRoot`, or malformed TOML fail loudly with a one-line error. Per-project config (`.house/config.toml`) and additional keys are deferred.
@@ -157,7 +165,7 @@ The footer only shows essential app controls (`W`, `q`, `tab`, `s`, `ctrl+p`) to
157
165
  | `G` | Last file |
158
166
  | `/` | Filter files (fuzzy match on path) |
159
167
  | `A` | Toggle hidden + gitignored entries (session-only; round-trips with the configured `show`) |
160
- | `↵` / `→` / `l` | Open file (focus reader) |
168
+ | `↵` / `→` / `l` | Open file (focus reader and auto-hide the sidebar when enabled) |
161
169
 
162
170
  ### Reader
163
171
 
package/dist/index.js CHANGED
@@ -3066,7 +3066,7 @@ import { useState as useState6 } from "react";
3066
3066
  // package.json
3067
3067
  var package_default = {
3068
3068
  name: "@carlesandres/house",
3069
- version: "0.7.0",
3069
+ version: "0.9.0",
3070
3070
  description: "TUI-first markdown reader on opentui",
3071
3071
  type: "module",
3072
3072
  license: "MIT",
@@ -3134,15 +3134,15 @@ var package_default = {
3134
3134
  "@carlesandres/house-linux-x64": "0.5.1"
3135
3135
  },
3136
3136
  dependencies: {
3137
- "@effect/atom-react": "4.0.0-beta.60",
3138
- "@opentui/core": "0.2.15",
3139
- "@opentui/react": "0.2.15",
3137
+ "@effect/atom-react": "4.0.0-beta.107",
3138
+ "@opentui/core": "0.5.9",
3139
+ "@opentui/react": "0.5.9",
3140
3140
  "@parcel/watcher": "2.6.0",
3141
3141
  commander: "^14.0.3",
3142
- effect: "4.0.0-beta.60",
3142
+ effect: "4.0.0-beta.107",
3143
3143
  ignore: "^7.0.5",
3144
3144
  marked: "^18.0.4",
3145
- react: "19.2.6",
3145
+ react: "19.2.8",
3146
3146
  scheduler: "0.27.0",
3147
3147
  "string-width": "7.2.0",
3148
3148
  "web-tree-sitter": "0.25.10"
@@ -3150,7 +3150,7 @@ var package_default = {
3150
3150
  devDependencies: {
3151
3151
  "@effect/language-service": "0.86.2",
3152
3152
  "@types/bun": "1.3.14",
3153
- "@types/react": "19.2.15",
3153
+ "@types/react": "19.2.18",
3154
3154
  emulate: "0.9.0",
3155
3155
  oxfmt: "0.51.0",
3156
3156
  oxlint: "1.66.0",
@@ -3162,6 +3162,279 @@ var package_default = {
3162
3162
  // src/Browser.tsx
3163
3163
  import { join as join4 } from "path";
3164
3164
  import { SyntaxStyle } from "@opentui/core";
3165
+
3166
+ // ../../packages/options/src/footer.ts
3167
+ var footerActivate = (spec) => {
3168
+ if (spec.footer?.activate !== undefined)
3169
+ return spec.footer.activate;
3170
+ if (spec.type === "boolean")
3171
+ return "toggle";
3172
+ if (spec.type === "string" && spec.choices !== undefined && spec.choices.length > 0) {
3173
+ return "cycle";
3174
+ }
3175
+ return;
3176
+ };
3177
+ var footerKeys = (specs) => {
3178
+ const keys = [];
3179
+ for (const key of Object.keys(specs)) {
3180
+ if (specs[key]?.footer !== undefined)
3181
+ keys.push(key);
3182
+ }
3183
+ return keys;
3184
+ };
3185
+ var nextFooterValue = (spec, current) => {
3186
+ const activate = footerActivate(spec);
3187
+ if (activate === "toggle") {
3188
+ if (spec.type !== "boolean") {
3189
+ throw new Error('footer activate "toggle" requires a boolean option');
3190
+ }
3191
+ return !current;
3192
+ }
3193
+ if (activate === "cycle") {
3194
+ if (spec.type !== "string" || spec.choices === undefined || spec.choices.length === 0) {
3195
+ throw new Error('footer activate "cycle" requires a string option with choices');
3196
+ }
3197
+ const choices = spec.choices;
3198
+ const currentIndex = choices.indexOf(current);
3199
+ const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % choices.length;
3200
+ return choices[nextIndex];
3201
+ }
3202
+ throw new Error("option has no footer activate strategy");
3203
+ };
3204
+ var footerControlActive = (spec, value) => {
3205
+ if (spec.type === "boolean")
3206
+ return Boolean(value);
3207
+ return true;
3208
+ };
3209
+ var footerControlGlyph = (spec, value) => {
3210
+ const footer = spec.footer;
3211
+ if (footer === undefined) {
3212
+ throw new Error("footerControlGlyph requires a footer-opted spec");
3213
+ }
3214
+ if (typeof value === "string" && footer.labels !== undefined) {
3215
+ const label = footer.labels[value];
3216
+ if (label !== undefined && label.length > 0)
3217
+ return label;
3218
+ }
3219
+ return footer.icon;
3220
+ };
3221
+ var validateFooterSpecs = (specs) => {
3222
+ for (const key of Object.keys(specs)) {
3223
+ const spec = specs[key];
3224
+ const footer = spec.footer;
3225
+ if (footer === undefined)
3226
+ continue;
3227
+ if (footer.icon.length === 0) {
3228
+ throw new Error(`option ${JSON.stringify(key)}: footer.icon must be non-empty`);
3229
+ }
3230
+ const activate = footerActivate(spec);
3231
+ if (activate === undefined) {
3232
+ throw new Error(`option ${JSON.stringify(key)}: footer requires activate "toggle" or "cycle" ` + "(booleans toggle; strings with choices cycle; other types need an explicit strategy)");
3233
+ }
3234
+ if (activate === "toggle" && spec.type !== "boolean") {
3235
+ throw new Error(`option ${JSON.stringify(key)}: footer activate "toggle" requires type "boolean"`);
3236
+ }
3237
+ if (activate === "cycle") {
3238
+ if (spec.type !== "string" || spec.choices === undefined || spec.choices.length === 0) {
3239
+ throw new Error(`option ${JSON.stringify(key)}: footer activate "cycle" requires string choices`);
3240
+ }
3241
+ const labels = footer.labels;
3242
+ if (labels === undefined) {
3243
+ throw new Error(`option ${JSON.stringify(key)}: footer.labels is required for choice options`);
3244
+ }
3245
+ for (const choice of spec.choices) {
3246
+ const label = labels[choice];
3247
+ if (label === undefined || label.length === 0) {
3248
+ throw new Error(`option ${JSON.stringify(key)}: footer.labels missing entry for ${JSON.stringify(choice)}`);
3249
+ }
3250
+ }
3251
+ }
3252
+ }
3253
+ };
3254
+
3255
+ // ../../packages/options/src/decode.ts
3256
+ var isPresent = (value) => value !== undefined && value !== null;
3257
+ var stringifyReceived = (value) => {
3258
+ if (typeof value === "string")
3259
+ return JSON.stringify(value);
3260
+ if (typeof value === "number" || typeof value === "boolean")
3261
+ return JSON.stringify(value);
3262
+ if (value === null)
3263
+ return "null";
3264
+ return typeof value;
3265
+ };
3266
+ var formatResolveError = (error, filePath) => {
3267
+ if (error.layer === "file") {
3268
+ const where = filePath === undefined ? "" : ` in ${filePath}`;
3269
+ return `invalid value for ${error.key}${where}: ${error.message}`;
3270
+ }
3271
+ return `${error.key}: ${error.message}, got ${stringifyReceived(error.received)}`;
3272
+ };
3273
+ var fail = (key, layer, message, received) => ({
3274
+ key,
3275
+ layer,
3276
+ message,
3277
+ received
3278
+ });
3279
+ var booleanMessage = "expected true or false";
3280
+ var numberMessage = (spec) => {
3281
+ if (spec.integer === true && spec.min === 1 && spec.max === undefined) {
3282
+ return "expected a positive integer";
3283
+ }
3284
+ if (spec.integer === true)
3285
+ return "expected an integer";
3286
+ return "expected a number";
3287
+ };
3288
+ var inRange = (value, spec) => {
3289
+ if (spec.min !== undefined && value < spec.min)
3290
+ return false;
3291
+ if (spec.max !== undefined && value > spec.max)
3292
+ return false;
3293
+ if (spec.integer === true && !Number.isSafeInteger(value))
3294
+ return false;
3295
+ if (!Number.isFinite(value))
3296
+ return false;
3297
+ return true;
3298
+ };
3299
+ var decodeBoolean = (key, layer, value) => {
3300
+ if (layer === "env") {
3301
+ if (value === "true")
3302
+ return { ok: true, value: true };
3303
+ if (value === "false")
3304
+ return { ok: true, value: false };
3305
+ return { ok: false, error: fail(key, layer, booleanMessage, value) };
3306
+ }
3307
+ if (typeof value === "boolean")
3308
+ return { ok: true, value };
3309
+ return { ok: false, error: fail(key, layer, booleanMessage, value) };
3310
+ };
3311
+ var decodeNumber = (key, layer, value, spec) => {
3312
+ const message = numberMessage(spec);
3313
+ if (layer === "env") {
3314
+ if (typeof value !== "string" || !/^-?\d+(\.\d+)?$/.test(value)) {
3315
+ return { ok: false, error: fail(key, layer, message, value) };
3316
+ }
3317
+ if (spec.integer === true && !/^-?\d+$/.test(value)) {
3318
+ return { ok: false, error: fail(key, layer, message, value) };
3319
+ }
3320
+ const parsed = spec.integer === true ? Number.parseInt(value, 10) : Number.parseFloat(value);
3321
+ if (!inRange(parsed, spec))
3322
+ return { ok: false, error: fail(key, layer, message, value) };
3323
+ return { ok: true, value: parsed };
3324
+ }
3325
+ if (typeof value !== "number" || !inRange(value, spec)) {
3326
+ return { ok: false, error: fail(key, layer, message, value) };
3327
+ }
3328
+ return { ok: true, value };
3329
+ };
3330
+ var decodeString = (key, layer, value, spec) => {
3331
+ if (typeof value !== "string") {
3332
+ const message = spec.choices === undefined ? "expected a string" : `expected one of ${spec.choices.join(", ")}`;
3333
+ return { ok: false, error: fail(key, layer, message, value) };
3334
+ }
3335
+ if (spec.choices !== undefined && !spec.choices.includes(value)) {
3336
+ return {
3337
+ ok: false,
3338
+ error: fail(key, layer, `expected one of ${spec.choices.join(", ")}`, value)
3339
+ };
3340
+ }
3341
+ return { ok: true, value };
3342
+ };
3343
+ var decodeValue = (key, spec, layer, value) => {
3344
+ if (spec.type === "boolean")
3345
+ return decodeBoolean(key, layer, value);
3346
+ if (spec.type === "number")
3347
+ return decodeNumber(key, layer, value, spec);
3348
+ return decodeString(key, layer, value, spec);
3349
+ };
3350
+
3351
+ // ../../packages/options/src/resolve.ts
3352
+ var LAYERS = ["cli", "env", "file"];
3353
+ var resolveOptions = (specs, layers = {}) => {
3354
+ const values = {};
3355
+ for (const key of Object.keys(specs)) {
3356
+ const spec = specs[key];
3357
+ let found = false;
3358
+ for (const layer of LAYERS) {
3359
+ const source = layers[layer];
3360
+ if (source === undefined)
3361
+ continue;
3362
+ const raw = source[key];
3363
+ if (!isPresent(raw))
3364
+ continue;
3365
+ const decoded = decodeValue(key, spec, layer, raw);
3366
+ if (!decoded.ok)
3367
+ return decoded;
3368
+ values[key] = decoded.value;
3369
+ found = true;
3370
+ break;
3371
+ }
3372
+ if (!found)
3373
+ values[key] = spec.default;
3374
+ }
3375
+ return { ok: true, value: values };
3376
+ };
3377
+ var catalogDefaults = (specs) => {
3378
+ const values = {};
3379
+ for (const key of Object.keys(specs))
3380
+ values[key] = specs[key].default;
3381
+ return values;
3382
+ };
3383
+
3384
+ // ../../packages/options/src/session.ts
3385
+ var persistPolicy = (persist) => persist ?? "session";
3386
+ var createSession = (specs, values, options = {}) => {
3387
+ let snapshot = values;
3388
+ const listeners = new Set;
3389
+ const get = (key) => snapshot[key];
3390
+ const set = async (key, value) => {
3391
+ const spec = specs[key];
3392
+ if (spec === undefined) {
3393
+ throw new Error(`unknown option ${JSON.stringify(key)}`);
3394
+ }
3395
+ const decoded = decodeValue(key, spec, "cli", value);
3396
+ if (!decoded.ok) {
3397
+ throw new Error(`${decoded.error.key}: ${decoded.error.message}`);
3398
+ }
3399
+ if (Object.is(snapshot[key], decoded.value))
3400
+ return;
3401
+ snapshot = { ...snapshot, [key]: decoded.value };
3402
+ for (const listener of listeners)
3403
+ listener(snapshot);
3404
+ if (persistPolicy(spec.persist) === "file" && options.persist !== undefined) {
3405
+ await options.persist({
3406
+ key,
3407
+ value: decoded.value,
3408
+ values: snapshot
3409
+ });
3410
+ }
3411
+ };
3412
+ return {
3413
+ get,
3414
+ set,
3415
+ subscribe: (listener) => {
3416
+ listeners.add(listener);
3417
+ return () => {
3418
+ listeners.delete(listener);
3419
+ };
3420
+ },
3421
+ get values() {
3422
+ return snapshot;
3423
+ }
3424
+ };
3425
+ };
3426
+
3427
+ // ../../packages/options/src/define.ts
3428
+ var defineOptions = (specs) => {
3429
+ validateFooterSpecs(specs);
3430
+ return {
3431
+ specs,
3432
+ defaults: catalogDefaults(specs),
3433
+ resolve: (layers) => resolveOptions(specs, layers),
3434
+ createSession: (values, options) => createSession(specs, values, options)
3435
+ };
3436
+ };
3437
+ // src/Browser.tsx
3165
3438
  import { useKeyboard, useRenderer, useTerminalDimensions as useTerminalDimensions2 } from "@opentui/react";
3166
3439
  import { useAtomValue, useAtomSet } from "@effect/atom-react";
3167
3440
  import { Effect as Effect3 } from "effect";
@@ -3347,7 +3620,7 @@ var browserBindings = [
3347
3620
  description: "Open file (focus reader)",
3348
3621
  keys: ["return", "right", "l"],
3349
3622
  when: inSidebar,
3350
- run: (c) => c.setFocus("reader")
3623
+ run: (c) => c.openSelectedFile()
3351
3624
  },
3352
3625
  {
3353
3626
  id: "serve.current",
@@ -3572,17 +3845,16 @@ var HARD_FALLBACK_RAW = {
3572
3845
  syntaxPunctuation: "#D8DEE9"
3573
3846
  };
3574
3847
  var HEX_RE_INIT = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
3575
- var lowerHex = (hex) => {
3576
- const v = hex.replace("#", "");
3848
+ var normalizeHex = (value) => {
3849
+ const v = value.replace("#", "");
3577
3850
  const expanded = v.length === 3 || v.length === 4 ? v.split("").map((c) => c + c).join("") : v;
3578
- const rgb = expanded.length === 8 ? expanded.slice(0, 6) : expanded;
3579
- return `#${rgb.toLowerCase()}`;
3851
+ return `#${expanded.toLowerCase()}`;
3580
3852
  };
3581
3853
  for (const v of Object.values(HARD_FALLBACK_RAW)) {
3582
3854
  if (!HEX_RE_INIT.test(v))
3583
3855
  throw new Error(`bad fallback hex: ${v}`);
3584
3856
  }
3585
- var HARD_FALLBACK = Object.fromEntries(Object.entries(HARD_FALLBACK_RAW).map(([k, v]) => [k, lowerHex(v)]));
3857
+ var HARD_FALLBACK = Object.fromEntries(Object.entries(HARD_FALLBACK_RAW).map(([k, v]) => [k, normalizeHex(v)]));
3586
3858
  var TOKEN_FALLBACK = {
3587
3859
  selectedListItemText: "text",
3588
3860
  markdownText: "text",
@@ -3591,12 +3863,6 @@ var TOKEN_FALLBACK = {
3591
3863
  };
3592
3864
  var HEX_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
3593
3865
  var isHex = (value) => HEX_RE.test(value);
3594
- var normalizeHex = (value) => {
3595
- const v = value.replace("#", "");
3596
- const expanded = v.length === 3 || v.length === 4 ? v.split("").map((c) => c + c).join("") : v;
3597
- const rgb = expanded.length === 8 ? expanded.slice(0, 6) : expanded;
3598
- return `#${rgb.toLowerCase()}`;
3599
- };
3600
3866
  var resolveSide = (value, defs) => {
3601
3867
  if (isHex(value))
3602
3868
  return normalizeHex(value);
@@ -4090,7 +4356,7 @@ var BRAND = "\u2302";
4090
4356
  var BRAND_NAME = "house";
4091
4357
 
4092
4358
  // src/Footer.tsx
4093
- import stringWidth from "string-width";
4359
+ import stringWidth2 from "string-width";
4094
4360
 
4095
4361
  // src/keymap/displayKey.ts
4096
4362
  var displayKey = (raw) => {
@@ -4145,7 +4411,9 @@ var Spinner = ({
4145
4411
  };
4146
4412
 
4147
4413
  // src/StatusIndicator.tsx
4414
+ import stringWidth from "string-width";
4148
4415
  import { jsxDEV as jsxDEV5 } from "@opentui/react/jsx-dev-runtime";
4416
+ var statusIndicatorWidth = (icon) => 2 + Math.max(1, stringWidth(icon));
4149
4417
  var statusIndicatorFg = (variant) => {
4150
4418
  switch (variant) {
4151
4419
  case "info":
@@ -4164,18 +4432,20 @@ var StatusIndicator = ({
4164
4432
  active = true,
4165
4433
  onMouseUp
4166
4434
  }) => {
4435
+ const glyph = icon.toLocaleUpperCase();
4167
4436
  const activeColor = statusIndicatorFg(variant);
4168
4437
  const backgroundColor = active ? activeColor : colors.backgroundElement;
4438
+ const width = statusIndicatorWidth(glyph);
4169
4439
  return /* @__PURE__ */ jsxDEV5("box", {
4170
4440
  ...onMouseUp === undefined ? {} : { onMouseUp },
4171
4441
  style: {
4172
- width: 3,
4442
+ width,
4173
4443
  height: 1,
4174
4444
  flexDirection: "row",
4175
4445
  backgroundColor
4176
4446
  },
4177
4447
  children: /* @__PURE__ */ jsxDEV5("text", {
4178
- content: ` ${icon} `,
4448
+ content: ` ${glyph} `,
4179
4449
  wrapMode: "none",
4180
4450
  style: {
4181
4451
  fg: active ? colors.backgroundPanel : colors.textMuted,
@@ -4191,7 +4461,7 @@ import { jsxDEV as jsxDEV6 } from "@opentui/react/jsx-dev-runtime";
4191
4461
  var FOOTER_HEIGHT = 1;
4192
4462
  var normalizeStatusLine = (status) => status.replace(/\s+/g, " ").trim();
4193
4463
  var HINT_SEPARATOR = " ";
4194
- var hintWidth = (h) => stringWidth(h.key) + 1 + stringWidth(h.label);
4464
+ var hintWidth = (h) => stringWidth2(h.key) + 1 + stringWidth2(h.label);
4195
4465
  var formatHint = (b) => {
4196
4466
  if (!b.hint)
4197
4467
  return null;
@@ -4218,6 +4488,8 @@ var fitHints = (hints, width) => {
4218
4488
  return [{ key: first.key.slice(0, width), label: "" }];
4219
4489
  };
4220
4490
  var STATUS_SEPARATOR = " \xB7 ";
4491
+ var INDICATOR_SEPARATOR = " ";
4492
+ var INDICATOR_SEPARATOR_WIDTH = 1;
4221
4493
  var NON_WARNING_STATUS_PREFIX_WIDTH = 2;
4222
4494
  var isPartialDiscoveryWarning = (status) => status?.startsWith("scan incomplete:") ?? false;
4223
4495
  var Footer = ({
@@ -4265,7 +4537,9 @@ var Footer = ({
4265
4537
  ] : [],
4266
4538
  ...indicators
4267
4539
  ];
4268
- const indicatorBudget = Math.min(usableWidth, renderedIndicators.length * 3);
4540
+ const indicatorWidths = renderedIndicators.map((indicator) => statusIndicatorWidth(indicator.icon));
4541
+ const indicatorSeparatorBudget = renderedIndicators.length > 1 ? (renderedIndicators.length - 1) * INDICATOR_SEPARATOR_WIDTH : 0;
4542
+ const indicatorBudget = Math.min(usableWidth, indicatorWidths.reduce((sum, width2) => sum + width2, 0) + indicatorSeparatorBudget);
4269
4543
  const hasContentAfterIndicators = notice !== null || status !== null || hints.length > 0;
4270
4544
  const indicatorContentGapBudget = renderedIndicators.length > 0 && hasContentAfterIndicators && indicatorBudget < usableWidth ? 1 : 0;
4271
4545
  const contentBudget = Math.max(0, usableWidth - indicatorBudget - indicatorContentGapBudget);
@@ -4277,9 +4551,21 @@ var Footer = ({
4277
4551
  const statusContent = status ? status.slice(0, Math.max(0, statusBudget - statusChromeWidth)) : "";
4278
4552
  const statusSeparatorContent = status ? STATUS_SEPARATOR.slice(0, Math.max(0, statusBudget - nonWarningStatusPrefixBudget - statusContent.length)) : "";
4279
4553
  const noticeContent = notice ? notice.length > contentBudget ? notice.slice(0, contentBudget) : notice : null;
4280
- const renderIndicators = () => renderedIndicators.map(({ id, ...props }) => /* @__PURE__ */ jsxDEV6(StatusIndicator, {
4281
- ...props
4282
- }, id, false, undefined, this));
4554
+ const renderIndicators = () => renderedIndicators.flatMap(({ id, ...props }, index) => {
4555
+ const chip = /* @__PURE__ */ jsxDEV6(StatusIndicator, {
4556
+ ...props
4557
+ }, id, false, undefined, this);
4558
+ if (index === 0)
4559
+ return [chip];
4560
+ return [
4561
+ /* @__PURE__ */ jsxDEV6("text", {
4562
+ content: INDICATOR_SEPARATOR,
4563
+ wrapMode: "none",
4564
+ style: { fg: colors.textMuted }
4565
+ }, `indicator-sep-${id}`, false, undefined, this),
4566
+ chip
4567
+ ];
4568
+ });
4283
4569
  const renderIndicatorContentGap = () => indicatorContentGapBudget > 0 ? /* @__PURE__ */ jsxDEV6("text", {
4284
4570
  content: " ",
4285
4571
  wrapMode: "none",
@@ -12742,192 +13028,76 @@ var map = loadBundledThemes();
12742
13028
  var themeDefinitions = [...map.values()];
12743
13029
  var getThemeDefinition = (id) => map.get(id);
12744
13030
 
12745
- // ../../packages/options/src/decode.ts
12746
- var isPresent = (value) => value !== undefined && value !== null;
12747
- var stringifyReceived = (value) => {
12748
- if (typeof value === "string")
12749
- return JSON.stringify(value);
12750
- if (typeof value === "number" || typeof value === "boolean")
12751
- return JSON.stringify(value);
12752
- if (value === null)
12753
- return "null";
12754
- return typeof value;
12755
- };
12756
- var formatResolveError = (error, filePath) => {
12757
- if (error.layer === "file") {
12758
- const where = filePath === undefined ? "" : ` in ${filePath}`;
12759
- return `invalid value for ${error.key}${where}: ${error.message}`;
12760
- }
12761
- return `${error.key}: ${error.message}, got ${stringifyReceived(error.received)}`;
12762
- };
12763
- var fail = (key, layer, message, received) => ({
12764
- key,
12765
- layer,
12766
- message,
12767
- received
12768
- });
12769
- var booleanMessage = "expected true or false";
12770
- var numberMessage = (spec) => {
12771
- if (spec.integer === true && spec.min === 1 && spec.max === undefined) {
12772
- return "expected a positive integer";
12773
- }
12774
- if (spec.integer === true)
12775
- return "expected an integer";
12776
- return "expected a number";
12777
- };
12778
- var inRange = (value, spec) => {
12779
- if (spec.min !== undefined && value < spec.min)
12780
- return false;
12781
- if (spec.max !== undefined && value > spec.max)
12782
- return false;
12783
- if (spec.integer === true && !Number.isSafeInteger(value))
12784
- return false;
12785
- if (!Number.isFinite(value))
12786
- return false;
12787
- return true;
12788
- };
12789
- var decodeBoolean = (key, layer, value) => {
12790
- if (layer === "env") {
12791
- if (value === "true")
12792
- return { ok: true, value: true };
12793
- if (value === "false")
12794
- return { ok: true, value: false };
12795
- return { ok: false, error: fail(key, layer, booleanMessage, value) };
12796
- }
12797
- if (typeof value === "boolean")
12798
- return { ok: true, value };
12799
- return { ok: false, error: fail(key, layer, booleanMessage, value) };
12800
- };
12801
- var decodeNumber = (key, layer, value, spec) => {
12802
- const message = numberMessage(spec);
12803
- if (layer === "env") {
12804
- if (typeof value !== "string" || !/^-?\d+(\.\d+)?$/.test(value)) {
12805
- return { ok: false, error: fail(key, layer, message, value) };
12806
- }
12807
- if (spec.integer === true && !/^-?\d+$/.test(value)) {
12808
- return { ok: false, error: fail(key, layer, message, value) };
12809
- }
12810
- const parsed = spec.integer === true ? Number.parseInt(value, 10) : Number.parseFloat(value);
12811
- if (!inRange(parsed, spec))
12812
- return { ok: false, error: fail(key, layer, message, value) };
12813
- return { ok: true, value: parsed };
12814
- }
12815
- if (typeof value !== "number" || !inRange(value, spec)) {
12816
- return { ok: false, error: fail(key, layer, message, value) };
12817
- }
12818
- return { ok: true, value };
12819
- };
12820
- var decodeString = (key, layer, value, spec) => {
12821
- if (typeof value !== "string") {
12822
- const message = spec.choices === undefined ? "expected a string" : `expected one of ${spec.choices.join(", ")}`;
12823
- return { ok: false, error: fail(key, layer, message, value) };
12824
- }
12825
- if (spec.choices !== undefined && !spec.choices.includes(value)) {
12826
- return {
12827
- ok: false,
12828
- error: fail(key, layer, `expected one of ${spec.choices.join(", ")}`, value)
12829
- };
13031
+ // src/config/footerControls.ts
13032
+ var footerControlsFromSession = (options, session, handlers = {}) => {
13033
+ const controls = [];
13034
+ for (const key of footerKeys(options.specs)) {
13035
+ const spec = options.specs[key];
13036
+ const footer = spec.footer;
13037
+ if (footer === undefined)
13038
+ continue;
13039
+ const value = session.get(key);
13040
+ const handler = handlers[key];
13041
+ controls.push({
13042
+ id: key,
13043
+ icon: footerControlGlyph(spec, value),
13044
+ variant: "info",
13045
+ active: handler?.active ?? footerControlActive(spec, value),
13046
+ onMouseUp: () => {
13047
+ if (handler?.onActivate !== undefined) {
13048
+ handler.onActivate();
13049
+ return;
13050
+ }
13051
+ const current = session.get(key);
13052
+ session.set(key, nextFooterValue(spec, current));
13053
+ }
13054
+ });
12830
13055
  }
12831
- return { ok: true, value };
12832
- };
12833
- var decodeValue = (key, spec, layer, value) => {
12834
- if (spec.type === "boolean")
12835
- return decodeBoolean(key, layer, value);
12836
- if (spec.type === "number")
12837
- return decodeNumber(key, layer, value, spec);
12838
- return decodeString(key, layer, value, spec);
13056
+ return controls;
12839
13057
  };
12840
13058
 
12841
- // ../../packages/options/src/resolve.ts
12842
- var LAYERS = ["cli", "env", "file"];
12843
- var resolveOptions = (specs, layers = {}) => {
12844
- const values = {};
12845
- for (const key of Object.keys(specs)) {
12846
- const spec = specs[key];
12847
- let found = false;
12848
- for (const layer of LAYERS) {
12849
- const source = layers[layer];
12850
- if (source === undefined)
12851
- continue;
12852
- const raw = source[key];
12853
- if (!isPresent(raw))
12854
- continue;
12855
- const decoded = decodeValue(key, spec, layer, raw);
12856
- if (!decoded.ok)
12857
- return decoded;
12858
- values[key] = decoded.value;
12859
- found = true;
12860
- break;
13059
+ // src/config/footerLabels.ts
13060
+ var uniqueFooterLabels = (ids) => {
13061
+ const used = new Set;
13062
+ const out = {};
13063
+ for (const id of ids) {
13064
+ const compact = id.replace(/[^a-zA-Z0-9]+/g, "").toLowerCase();
13065
+ const source = compact.length > 0 ? compact : "x";
13066
+ let label = source.slice(0, 2).padEnd(2, "x");
13067
+ let offset = 2;
13068
+ while (used.has(label)) {
13069
+ const next = source[offset];
13070
+ if (next !== undefined) {
13071
+ label = `${source[0]}${next}`;
13072
+ offset += 1;
13073
+ } else {
13074
+ label = `${source[0]}${used.size % 10}`;
13075
+ offset += 1;
13076
+ if (offset > source.length + 20) {
13077
+ throw new Error(`unable to allocate unique footer label for ${JSON.stringify(id)}`);
13078
+ }
13079
+ }
12861
13080
  }
12862
- if (!found)
12863
- values[key] = spec.default;
13081
+ used.add(label);
13082
+ out[id] = label;
12864
13083
  }
12865
- return { ok: true, value: values };
12866
- };
12867
- var catalogDefaults = (specs) => {
12868
- const values = {};
12869
- for (const key of Object.keys(specs))
12870
- values[key] = specs[key].default;
12871
- return values;
12872
- };
12873
-
12874
- // ../../packages/options/src/session.ts
12875
- var persistPolicy = (persist) => persist ?? "session";
12876
- var createSession = (specs, values, options = {}) => {
12877
- let snapshot = values;
12878
- const listeners = new Set;
12879
- const get = (key) => snapshot[key];
12880
- const set = async (key, value) => {
12881
- const spec = specs[key];
12882
- if (spec === undefined) {
12883
- throw new Error(`unknown option ${JSON.stringify(key)}`);
12884
- }
12885
- const decoded = decodeValue(key, spec, "cli", value);
12886
- if (!decoded.ok) {
12887
- throw new Error(`${decoded.error.key}: ${decoded.error.message}`);
12888
- }
12889
- if (Object.is(snapshot[key], decoded.value))
12890
- return;
12891
- snapshot = { ...snapshot, [key]: decoded.value };
12892
- for (const listener of listeners)
12893
- listener(snapshot);
12894
- if (persistPolicy(spec.persist) === "file" && options.persist !== undefined) {
12895
- await options.persist({
12896
- key,
12897
- value: decoded.value,
12898
- values: snapshot
12899
- });
12900
- }
12901
- };
12902
- return {
12903
- get,
12904
- set,
12905
- subscribe: (listener) => {
12906
- listeners.add(listener);
12907
- return () => {
12908
- listeners.delete(listener);
12909
- };
12910
- },
12911
- get values() {
12912
- return snapshot;
12913
- }
12914
- };
13084
+ return out;
12915
13085
  };
12916
13086
 
12917
- // ../../packages/options/src/define.ts
12918
- var defineOptions = (specs) => ({
12919
- specs,
12920
- defaults: catalogDefaults(specs),
12921
- resolve: (layers) => resolveOptions(specs, layers),
12922
- createSession: (values, options) => createSession(specs, values, options)
12923
- });
12924
13087
  // src/config/options.ts
12925
13088
  var FILE_NAVIGATOR_ORDERS = ["tree", "recently-modified"];
13089
+ var themeIds = themeDefinitions.map((theme) => theme.id);
13090
+ var themeFooterLabels = uniqueFooterLabels(themeIds);
12926
13091
  var houseOptions = defineOptions({
13092
+ autoHideSidebar: {
13093
+ type: "boolean",
13094
+ default: true
13095
+ },
12927
13096
  wrap: {
12928
13097
  type: "boolean",
12929
13098
  default: false,
12930
- persist: "session"
13099
+ persist: "session",
13100
+ footer: { icon: "W" }
12931
13101
  },
12932
13102
  width: {
12933
13103
  type: "number",
@@ -12939,7 +13109,8 @@ var houseOptions = defineOptions({
12939
13109
  type: "string",
12940
13110
  default: "opencode",
12941
13111
  persist: "file",
12942
- choices: themeDefinitions.map((theme) => theme.id)
13112
+ choices: themeIds,
13113
+ footer: { icon: "T", labels: themeFooterLabels }
12943
13114
  },
12944
13115
  tone: {
12945
13116
  type: "string",
@@ -12960,7 +13131,15 @@ var houseOptions = defineOptions({
12960
13131
  order: {
12961
13132
  type: "string",
12962
13133
  default: "recently-modified",
12963
- choices: FILE_NAVIGATOR_ORDERS
13134
+ choices: FILE_NAVIGATOR_ORDERS,
13135
+ persist: "session",
13136
+ footer: {
13137
+ icon: "O",
13138
+ labels: {
13139
+ tree: "tr",
13140
+ "recently-modified": "re"
13141
+ }
13142
+ }
12964
13143
  }
12965
13144
  });
12966
13145
 
@@ -12996,6 +13175,7 @@ var parseShowList = (raw) => {
12996
13175
  var DEFAULT_EXTENSIONS = [];
12997
13176
  var DEFAULT_SHOW = "";
12998
13177
  var KNOWN_FILE_KEYS = new Set([
13178
+ "autoHideSidebar",
12999
13179
  "theme",
13000
13180
  "tone",
13001
13181
  "extensions",
@@ -13010,7 +13190,8 @@ var schema = Config.all({
13010
13190
  extensions: Config.schema(Schema.String, "extensions"),
13011
13191
  show: Config.schema(Schema.String, "show")
13012
13192
  });
13013
- var defaultsProvider = () => ConfigProvider.fromUnknown({
13193
+ var fromUnknown = (root) => ConfigProvider.fromUnknown(root, { preserveEmptyStrings: true });
13194
+ var defaultsProvider = () => fromUnknown({
13014
13195
  extensions: DEFAULT_EXTENSIONS.join(","),
13015
13196
  show: DEFAULT_SHOW
13016
13197
  });
@@ -13097,7 +13278,7 @@ var envProvider = (env) => {
13097
13278
  entries.push(["extensions", extensions]);
13098
13279
  if (show !== undefined)
13099
13280
  entries.push(["show", show]);
13100
- return ConfigProvider.fromUnknown(Object.fromEntries(entries));
13281
+ return fromUnknown(Object.fromEntries(entries));
13101
13282
  };
13102
13283
  var cliProvider = (overrides) => {
13103
13284
  const entries = [];
@@ -13105,7 +13286,7 @@ var cliProvider = (overrides) => {
13105
13286
  entries.push(["extensions", overrides.extensions.join(",")]);
13106
13287
  if (overrides.show !== null)
13107
13288
  entries.push(["show", overrides.show.join(",")]);
13108
- return ConfigProvider.fromUnknown(Object.fromEntries(entries));
13289
+ return fromUnknown(Object.fromEntries(entries));
13109
13290
  };
13110
13291
  var defaultConfigPath = () => join3(process.env["XDG_CONFIG_HOME"] ?? join3(homedir(), ".config"), "house", "config.toml");
13111
13292
  var formatConfigError = (err) => {
@@ -13120,6 +13301,7 @@ var formatConfigError = (err) => {
13120
13301
  };
13121
13302
  var loadConfig = (options = {}) => Effect2.gen(function* () {
13122
13303
  const cli = options.cli ?? {
13304
+ autoHideSidebar: null,
13123
13305
  theme: null,
13124
13306
  tone: null,
13125
13307
  extensions: null,
@@ -13142,6 +13324,7 @@ var loadConfig = (options = {}) => Effect2.gen(function* () {
13142
13324
  }
13143
13325
  const resolved = houseOptions.resolve({
13144
13326
  cli: {
13327
+ autoHideSidebar: cli.autoHideSidebar,
13145
13328
  wrap: cli.wrap,
13146
13329
  width: cli.width,
13147
13330
  theme: cli.theme,
@@ -13150,6 +13333,7 @@ var loadConfig = (options = {}) => Effect2.gen(function* () {
13150
13333
  order: cli.order
13151
13334
  },
13152
13335
  env: {
13336
+ autoHideSidebar: env["HOUSE_AUTO_HIDE_SIDEBAR"],
13153
13337
  wrap: env["HOUSE_WRAP"],
13154
13338
  width: env["HOUSE_WIDTH"],
13155
13339
  theme: env["HOUSE_THEME"],
@@ -13159,6 +13343,7 @@ var loadConfig = (options = {}) => Effect2.gen(function* () {
13159
13343
  order: env["HOUSE_ORDER"]
13160
13344
  },
13161
13345
  file: {
13346
+ autoHideSidebar: fileData?.["autoHideSidebar"],
13162
13347
  wrap: fileData?.["wrap"],
13163
13348
  width: fileData?.["width"],
13164
13349
  theme: fileData?.["theme"],
@@ -13188,6 +13373,7 @@ var loadConfig = (options = {}) => Effect2.gen(function* () {
13188
13373
  return yield* Effect2.fail(new Error(`order: expected one of ${FILE_NAVIGATOR_ORDERS.join(", ")}, got ${JSON.stringify(order)}`));
13189
13374
  }
13190
13375
  return {
13376
+ autoHideSidebar: resolved.value.autoHideSidebar,
13191
13377
  theme: resolved.value.theme,
13192
13378
  tone,
13193
13379
  defaultRoot,
@@ -13297,6 +13483,7 @@ var persistHouseOption = async (event, path) => {
13297
13483
 
13298
13484
  // src/Browser.tsx
13299
13485
  import { jsxDEV as jsxDEV13 } from "@opentui/react/jsx-dev-runtime";
13486
+ var asFileNavigatorOrder = (value) => typeof value === "string" && FILE_NAVIGATOR_ORDERS.includes(value) ? value : "recently-modified";
13300
13487
  var defaultReadFile = (path) => Effect3.runPromise(readFileText(path));
13301
13488
  var defaultDiscoveryPolicy = {
13302
13489
  revision: "house-markdown",
@@ -13343,6 +13530,7 @@ var Browser = ({
13343
13530
  initialQuery = "",
13344
13531
  wrapWidth = 80,
13345
13532
  initialWrap = false,
13533
+ initialAutoHideSidebar = true,
13346
13534
  rootLabel = "current root",
13347
13535
  discoveryStatus = null,
13348
13536
  discoverySpinnerIntervalMs,
@@ -13357,6 +13545,7 @@ var Browser = ({
13357
13545
  updateNotice = null,
13358
13546
  updateNoticeTtlMs = 1e4,
13359
13547
  disableFooterNoticeAutoClear = false,
13548
+ onNavigatorIdle,
13360
13549
  onToggleAll,
13361
13550
  startupFocus = null,
13362
13551
  order = "recently-modified",
@@ -13376,17 +13565,20 @@ var Browser = ({
13376
13565
  optionsSession.current = houseOptions.createSession({
13377
13566
  ...houseOptions.defaults,
13378
13567
  wrap: initialWrap,
13568
+ autoHideSidebar: initialAutoHideSidebar,
13379
13569
  width: wrapWidth,
13380
13570
  theme: theme.id,
13381
- tone: theme.tone
13571
+ tone: theme.tone,
13572
+ order: asFileNavigatorOrder(order)
13382
13573
  }, { persist: persistHouseOption });
13383
13574
  }
13384
13575
  const wrapEnabled = useSyncExternalStore(optionsSession.current.subscribe, () => optionsSession.current.get("wrap"));
13576
+ const browseOrder = useSyncExternalStore(optionsSession.current.subscribe, () => asFileNavigatorOrder(optionsSession.current.get("order")));
13385
13577
  const toggleWrap = () => {
13386
13578
  const session = optionsSession.current;
13387
13579
  if (session === null)
13388
13580
  return;
13389
- session.set("wrap", !session.get("wrap"));
13581
+ session.set("wrap", nextFooterValue(houseOptions.specs.wrap, session.get("wrap")));
13390
13582
  };
13391
13583
  const [loaded, setLoaded] = useState4(null);
13392
13584
  const [error, setError] = useState4(null);
@@ -13520,11 +13712,36 @@ var Browser = ({
13520
13712
  const paletteInputReadyRef = useRef3(false);
13521
13713
  const promptInputReadyRef = useRef3(false);
13522
13714
  const focusRef = useRef3(focus);
13715
+ const autoHideSidebar = optionsSession.current.get("autoHideSidebar");
13716
+ const openSelectedFile = () => {
13717
+ if (autoHideSidebar && navigator2.getSnapshot().selectedFile !== null)
13718
+ setShown(false);
13719
+ focusRef.current = "reader";
13720
+ setFocus("reader");
13721
+ };
13523
13722
  const restoreFilterOnSidebarFocusRef = useRef3(startInFilter);
13524
13723
  const [footerNotice, setFooterNoticeState] = useState4(null);
13525
13724
  const pushFooterNotice = (text, ttlMs = 2000) => setFooterNoticeState({ text, ttlMs });
13526
13725
  const serverRef = useRef3(initialPreviewServer);
13527
13726
  const pendingSelectionPathRef = useRef3(null);
13727
+ const pendingSelectionMissingRef = useRef3(false);
13728
+ const clearPendingSelection = () => {
13729
+ pendingSelectionPathRef.current = null;
13730
+ pendingSelectionMissingRef.current = false;
13731
+ };
13732
+ const takePendingRestoreTarget = (files) => {
13733
+ const target = pendingSelectionPathRef.current;
13734
+ if (target === null)
13735
+ return null;
13736
+ const inList = files.some((file) => file.absolutePath === target);
13737
+ if (!inList) {
13738
+ pendingSelectionMissingRef.current = true;
13739
+ return null;
13740
+ }
13741
+ if (!pendingSelectionMissingRef.current)
13742
+ return null;
13743
+ return target;
13744
+ };
13528
13745
  useLayoutEffect3(() => {
13529
13746
  if (readerRootRef.current === root)
13530
13747
  return;
@@ -13574,6 +13791,15 @@ var Browser = ({
13574
13791
  useEffect4(() => {
13575
13792
  focusRef.current = focus;
13576
13793
  }, [focus]);
13794
+ const onNavigatorIdleRef = useRef3(onNavigatorIdle);
13795
+ onNavigatorIdleRef.current = onNavigatorIdle;
13796
+ const navigatorIdleNotifiedRef = useRef3(false);
13797
+ useEffect4(() => {
13798
+ if (navigatorIdleNotifiedRef.current || liveSnapshot.scanning)
13799
+ return;
13800
+ navigatorIdleNotifiedRef.current = true;
13801
+ onNavigatorIdleRef.current?.();
13802
+ }, [liveSnapshot.scanning]);
13577
13803
  useEffect4(() => {
13578
13804
  if (!updateNotice)
13579
13805
  return;
@@ -13606,6 +13832,14 @@ var Browser = ({
13606
13832
  rememberAppearance(session.set("theme", next.id));
13607
13833
  pushFooterNotice(`theme: ${next.name}`);
13608
13834
  };
13835
+ const cycleOrder = () => {
13836
+ const session = optionsSession.current;
13837
+ if (session === null)
13838
+ return;
13839
+ const next = nextFooterValue(houseOptions.specs.order, session.get("order"));
13840
+ session.set("order", next);
13841
+ pushFooterNotice(`order: ${next}`);
13842
+ };
13609
13843
  const toggleTone = () => {
13610
13844
  const session = optionsSession.current;
13611
13845
  if (session === null)
@@ -13620,15 +13854,13 @@ var Browser = ({
13620
13854
  };
13621
13855
  const filterHasNoMatches = filterInput.length > 0 && displayedFiles.length === 0;
13622
13856
  useEffect4(() => {
13623
- const target = pendingSelectionPathRef.current;
13857
+ const target = takePendingRestoreTarget(displayedFiles);
13624
13858
  if (target === null)
13625
13859
  return;
13626
- if (displayedFiles.some((file) => file.absolutePath === target)) {
13627
- const snapshot = navigator2.selectPath(target);
13628
- if (snapshot.selectedFile?.absolutePath !== target)
13629
- return;
13630
- pendingSelectionPathRef.current = null;
13631
- }
13860
+ const snapshot = navigator2.selectPath(target);
13861
+ if (snapshot.selectedFile?.absolutePath !== target)
13862
+ return;
13863
+ clearPendingSelection();
13632
13864
  }, [displayedFiles, liveSnapshot.files]);
13633
13865
  useEffect4(() => {
13634
13866
  if (liveSnapshot.scanning || initialIndexRef.current === 0)
@@ -13974,18 +14206,19 @@ var Browser = ({
13974
14206
  paletteOpen,
13975
14207
  wrapEnabled,
13976
14208
  setFocus,
14209
+ openSelectedFile,
13977
14210
  moveSelectionBy: (delta) => {
13978
- pendingSelectionPathRef.current = null;
14211
+ clearPendingSelection();
13979
14212
  newFileEditPathRef.current = null;
13980
14213
  navigator2.moveBy(delta);
13981
14214
  },
13982
14215
  selectFirst: () => {
13983
- pendingSelectionPathRef.current = null;
14216
+ clearPendingSelection();
13984
14217
  newFileEditPathRef.current = null;
13985
14218
  navigator2.selectFirst();
13986
14219
  },
13987
14220
  selectLast: () => {
13988
- pendingSelectionPathRef.current = null;
14221
+ clearPendingSelection();
13989
14222
  newFileEditPathRef.current = null;
13990
14223
  navigator2.selectLast();
13991
14224
  },
@@ -14042,6 +14275,7 @@ var Browser = ({
14042
14275
  const current = navigator2.getSnapshot().selectedFile;
14043
14276
  if (pendingSelectionPathRef.current === null && current) {
14044
14277
  pendingSelectionPathRef.current = current.absolutePath;
14278
+ pendingSelectionMissingRef.current = false;
14045
14279
  }
14046
14280
  onToggleAll?.();
14047
14281
  },
@@ -14185,6 +14419,10 @@ var Browser = ({
14185
14419
  key.preventDefault();
14186
14420
  return;
14187
14421
  }
14422
+ if (key.ctrl && !key.meta && key.name === "c") {
14423
+ key.preventDefault();
14424
+ return;
14425
+ }
14188
14426
  if (!promptInputReadyRef.current) {
14189
14427
  if (key.name === "backspace" || key.name === "delete") {
14190
14428
  if (promptInputRef.current.length === 0)
@@ -14209,8 +14447,7 @@ var Browser = ({
14209
14447
  filterOpenRef.current = false;
14210
14448
  setFilterOpen(false);
14211
14449
  if (effectiveCommit) {
14212
- focusRef.current = "reader";
14213
- setFocus("reader");
14450
+ openSelectedFile();
14214
14451
  } else {
14215
14452
  const nextFocus = shown ? "sidebar" : "reader";
14216
14453
  focusRef.current = nextFocus;
@@ -14326,15 +14563,11 @@ var Browser = ({
14326
14563
  width,
14327
14564
  notice: footerNotice?.text ?? null,
14328
14565
  discoveryStatus: effectiveDiscoveryStatus,
14329
- indicators: [
14330
- {
14331
- id: "wrap",
14332
- icon: "W",
14333
- variant: "info",
14334
- active: wrapEnabled,
14335
- onMouseUp: toggleWrap
14336
- }
14337
- ],
14566
+ indicators: optionsSession.current === null ? [] : footerControlsFromSession(houseOptions, optionsSession.current, {
14567
+ wrap: { onActivate: toggleWrap },
14568
+ theme: { onActivate: () => cycleTheme(1) },
14569
+ order: { onActivate: cycleOrder }
14570
+ }),
14338
14571
  ...discoverySpinnerIntervalMs === undefined ? {} : { discoverySpinnerIntervalMs },
14339
14572
  ...discoverySpinnerInitialFrameIndex === undefined ? {} : { discoverySpinnerInitialFrameIndex },
14340
14573
  ...discoverySpinnerRegisterTick === undefined ? {} : { discoverySpinnerRegisterTick },
@@ -14369,7 +14602,7 @@ var Browser = ({
14369
14602
  root,
14370
14603
  policy,
14371
14604
  watch: watch2,
14372
- order,
14605
+ order: browseOrder,
14373
14606
  debounceMs: filterDebounceMs,
14374
14607
  navigatorRef,
14375
14608
  snapshot: liveSnapshot,
@@ -14398,9 +14631,9 @@ var Browser = ({
14398
14631
  if (!handle)
14399
14632
  return;
14400
14633
  const next = handle.getSnapshot();
14401
- const target = pendingSelectionPathRef.current;
14402
- if (target && next.filteredFiles.some((file) => file.absolutePath === target)) {
14403
- pendingSelectionPathRef.current = null;
14634
+ const target = takePendingRestoreTarget(next.filteredFiles);
14635
+ if (target !== null) {
14636
+ clearPendingSelection();
14404
14637
  setNavigatorSnapshot(handle.selectPath(target));
14405
14638
  return;
14406
14639
  }
@@ -14554,7 +14787,7 @@ var Browser = ({
14554
14787
 
14555
14788
  // src/cli/argv.ts
14556
14789
  import { Command } from "commander";
14557
- var createProgram = () => new Command().allowUnknownOption(true).allowExcessArguments(true).exitOverride().helpOption(false).option("--theme [id]").option("--tone [mode]").option("--width [N]").option("--wrap").option("--no-wrap").option("--serve").option("--port [N]").option("--config-path").option("--no-update-check").option("--ext [list]").option("--focus [mode]").option("--order [mode]").option("--show [list]").option("--root [dir]").option("-h, --help").option("-v, --version").argument("[path]");
14790
+ var createProgram = () => new Command().allowUnknownOption(true).allowExcessArguments(true).exitOverride().helpOption(false).option("--theme [id]").option("--tone [mode]").option("--width [N]").option("--wrap").option("--no-wrap").option("--auto-hide-sidebar").option("--no-auto-hide-sidebar").option("--serve").option("--port [N]").option("--config-path").option("--no-update-check").option("--ext [list]").option("--focus [mode]").option("--order [mode]").option("--show [list]").option("--root [dir]").option("-h, --help").option("-v, --version").argument("[path]");
14558
14791
  var VALUE_FLAGS = new Set([
14559
14792
  "--theme",
14560
14793
  "--tone",
@@ -14573,6 +14806,8 @@ var BOOLEAN_FLAGS = new Set([
14573
14806
  "--no-update-check",
14574
14807
  "--wrap",
14575
14808
  "--no-wrap",
14809
+ "--auto-hide-sidebar",
14810
+ "--no-auto-hide-sidebar",
14576
14811
  "--help",
14577
14812
  "-h",
14578
14813
  "--version",
@@ -14603,6 +14838,8 @@ var parseArgv = (argv) => {
14603
14838
  const stringOrNull = (value) => typeof value === "string" ? value : null;
14604
14839
  const hasWrap = argv.includes("--wrap");
14605
14840
  const hasNoWrap = argv.includes("--no-wrap");
14841
+ const hasAutoHideSidebar = argv.includes("--auto-hide-sidebar");
14842
+ const hasNoAutoHideSidebar = argv.includes("--no-auto-hide-sidebar");
14606
14843
  return {
14607
14844
  path: typeof pathArg === "string" ? pathArg : null,
14608
14845
  root: stringOrNull(opts["root"]),
@@ -14611,6 +14848,8 @@ var parseArgv = (argv) => {
14611
14848
  width: stringOrNull(opts["width"]),
14612
14849
  wrap: hasWrap ? true : hasNoWrap ? false : null,
14613
14850
  wrapConflict: hasWrap && hasNoWrap,
14851
+ autoHideSidebar: hasAutoHideSidebar ? true : hasNoAutoHideSidebar ? false : null,
14852
+ autoHideSidebarConflict: hasAutoHideSidebar && hasNoAutoHideSidebar,
14614
14853
  serve: opts["serve"] === true,
14615
14854
  port: stringOrNull(opts["port"]),
14616
14855
  help: opts["help"] === true,
@@ -14636,6 +14875,8 @@ options:
14636
14875
  --width <N> reader wrap width used when wrapping is enabled (default: 80)
14637
14876
  --wrap start with reader wrapping enabled
14638
14877
  --no-wrap start with reader wrapping disabled
14878
+ --auto-hide-sidebar hide the sidebar after opening a file (default)
14879
+ --no-auto-hide-sidebar keep the sidebar visible after opening a file
14639
14880
  --show <list> reveal normally-skipped entries; comma-separated subset of:
14640
14881
  hidden, gitignored. Use --show "" to clear.
14641
14882
  --root <dir> discovery root to walk (overrides defaultRoot config/env)
@@ -14656,8 +14897,8 @@ examples:
14656
14897
 
14657
14898
  configuration:
14658
14899
  file: $XDG_CONFIG_HOME/house/config.toml (default ~/.config/house/config.toml)
14659
- keys: theme, tone, width, wrap, extensions, show, focus, defaultRoot, order
14660
- env: HOUSE_THEME, HOUSE_TONE, HOUSE_WIDTH, HOUSE_WRAP, HOUSE_EXTENSIONS, HOUSE_SHOW, HOUSE_FOCUS, HOUSE_DEFAULT_ROOT, HOUSE_ORDER
14900
+ keys: theme, tone, width, wrap, autoHideSidebar, extensions, show, focus, defaultRoot, order
14901
+ env: HOUSE_THEME, HOUSE_TONE, HOUSE_WIDTH, HOUSE_WRAP, HOUSE_AUTO_HIDE_SIDEBAR, HOUSE_EXTENSIONS, HOUSE_SHOW, HOUSE_FOCUS, HOUSE_DEFAULT_ROOT, HOUSE_ORDER
14661
14902
  precedence (high \u2192 low): flags \u2192 env \u2192 file \u2192 defaults`;
14662
14903
 
14663
14904
  // src/cli/fast-exit.ts
@@ -14671,6 +14912,10 @@ var parseAndHandleFastExit = (argv) => {
14671
14912
  console.error("house: --wrap and --no-wrap cannot be used together");
14672
14913
  process.exit(2);
14673
14914
  }
14915
+ if (args.autoHideSidebarConflict) {
14916
+ console.error("house: --auto-hide-sidebar and --no-auto-hide-sidebar cannot be used together");
14917
+ process.exit(2);
14918
+ }
14674
14919
  if (argv.includes("--width") && args.width === null) {
14675
14920
  console.error("house: --width requires a positive integer");
14676
14921
  process.exit(2);
@@ -14996,6 +15241,7 @@ var DiscoverShell = ({
14996
15241
  extensions,
14997
15242
  wrapWidth,
14998
15243
  initialWrap,
15244
+ initialAutoHideSidebar,
14999
15245
  startupFocus,
15000
15246
  order = "recently-modified"
15001
15247
  }) => {
@@ -15008,6 +15254,7 @@ var DiscoverShell = ({
15008
15254
  initialQuery,
15009
15255
  wrapWidth,
15010
15256
  initialWrap,
15257
+ initialAutoHideSidebar,
15011
15258
  rootLabel,
15012
15259
  startupFocus,
15013
15260
  order,
@@ -15063,7 +15310,8 @@ async function main(argv = Bun.argv.slice(2)) {
15063
15310
  }
15064
15311
  return n;
15065
15312
  })(),
15066
- wrap: args.wrap
15313
+ wrap: args.wrap,
15314
+ autoHideSidebar: args.autoHideSidebar
15067
15315
  }
15068
15316
  })).catch((err) => {
15069
15317
  console.error(`house: ${formatConfigError(err)}`);
@@ -15078,6 +15326,7 @@ async function main(argv = Bun.argv.slice(2)) {
15078
15326
  defaultRoot,
15079
15327
  width: wrapWidth,
15080
15328
  wrap: initialWrap,
15329
+ autoHideSidebar: initialAutoHideSidebar,
15081
15330
  order
15082
15331
  } = config;
15083
15332
  const themeDef = getThemeDefinition(themeId);
@@ -15133,6 +15382,7 @@ async function main(argv = Bun.argv.slice(2)) {
15133
15382
  tone,
15134
15383
  wrapWidth,
15135
15384
  initialWrap,
15385
+ initialAutoHideSidebar,
15136
15386
  show,
15137
15387
  extensions,
15138
15388
  startupFocus,
@@ -15150,6 +15400,7 @@ async function runTui({
15150
15400
  tone,
15151
15401
  wrapWidth,
15152
15402
  initialWrap,
15403
+ initialAutoHideSidebar,
15153
15404
  show,
15154
15405
  extensions,
15155
15406
  startupFocus,
@@ -15191,6 +15442,7 @@ async function runTui({
15191
15442
  extensions,
15192
15443
  wrapWidth,
15193
15444
  initialWrap,
15445
+ initialAutoHideSidebar,
15194
15446
  startupFocus,
15195
15447
  order
15196
15448
  }, undefined, false, undefined, this)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carlesandres/house",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "TUI-first markdown reader on opentui",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -62,21 +62,21 @@
62
62
  "version:set": "bun run dev/set-version.ts"
63
63
  },
64
64
  "optionalDependencies": {
65
- "@carlesandres/house-darwin-arm64": "0.7.0",
66
- "@carlesandres/house-darwin-x64": "0.7.0",
67
- "@carlesandres/house-linux-arm64": "0.7.0",
68
- "@carlesandres/house-linux-x64": "0.7.0"
65
+ "@carlesandres/house-darwin-arm64": "0.9.0",
66
+ "@carlesandres/house-darwin-x64": "0.9.0",
67
+ "@carlesandres/house-linux-arm64": "0.9.0",
68
+ "@carlesandres/house-linux-x64": "0.9.0"
69
69
  },
70
70
  "dependencies": {
71
- "@effect/atom-react": "4.0.0-beta.60",
72
- "@opentui/core": "0.2.15",
73
- "@opentui/react": "0.2.15",
71
+ "@effect/atom-react": "4.0.0-beta.107",
72
+ "@opentui/core": "0.5.9",
73
+ "@opentui/react": "0.5.9",
74
74
  "@parcel/watcher": "2.6.0",
75
75
  "commander": "^14.0.3",
76
- "effect": "4.0.0-beta.60",
76
+ "effect": "4.0.0-beta.107",
77
77
  "ignore": "^7.0.5",
78
78
  "marked": "^18.0.4",
79
- "react": "19.2.6",
79
+ "react": "19.2.8",
80
80
  "scheduler": "0.27.0",
81
81
  "string-width": "7.2.0",
82
82
  "web-tree-sitter": "0.25.10"
@@ -84,7 +84,7 @@
84
84
  "devDependencies": {
85
85
  "@effect/language-service": "0.86.2",
86
86
  "@types/bun": "1.3.14",
87
- "@types/react": "19.2.15",
87
+ "@types/react": "19.2.18",
88
88
  "emulate": "0.9.0",
89
89
  "oxfmt": "0.51.0",
90
90
  "oxlint": "1.66.0",