@carlesandres/house 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/CHANGELOG.md +11 -1
  2. package/dist/index.js +385 -196
  3. package/package.json +5 -5
package/CHANGELOG.md CHANGED
@@ -6,6 +6,15 @@ The publish workflow (`.github/workflows/publish.yml`) runs on the `release: pub
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.8.0] — 2026-08-30
10
+
11
+ ### Added
12
+
13
+ - `@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.
14
+ - 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.
15
+ - 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.
16
+
17
+
9
18
  ## [0.7.0] — 2026-08-30
10
19
 
11
20
  ### Added
@@ -433,7 +442,8 @@ The v1 MVP, published as `@carlesandres/openmdr` on npm.
433
442
 
434
443
  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
444
 
436
- [Unreleased]: https://github.com/carlesandres/house/compare/v0.7.0...HEAD
445
+ [Unreleased]: https://github.com/carlesandres/house/compare/v0.8.0...HEAD
446
+ [0.8.0]: https://github.com/carlesandres/house/compare/v0.7.0...v0.8.0
437
447
  [0.7.0]: https://github.com/carlesandres/house/compare/v0.6.0...v0.7.0
438
448
  [0.6.0]: https://github.com/carlesandres/house/compare/v0.5.2...v0.6.0
439
449
  [0.5.2]: https://github.com/carlesandres/house/compare/v0.5.1...v0.5.2
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.8.0",
3070
3070
  description: "TUI-first markdown reader on opentui",
3071
3071
  type: "module",
3072
3072
  license: "MIT",
@@ -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";
@@ -4090,7 +4363,7 @@ var BRAND = "\u2302";
4090
4363
  var BRAND_NAME = "house";
4091
4364
 
4092
4365
  // src/Footer.tsx
4093
- import stringWidth from "string-width";
4366
+ import stringWidth2 from "string-width";
4094
4367
 
4095
4368
  // src/keymap/displayKey.ts
4096
4369
  var displayKey = (raw) => {
@@ -4145,7 +4418,9 @@ var Spinner = ({
4145
4418
  };
4146
4419
 
4147
4420
  // src/StatusIndicator.tsx
4421
+ import stringWidth from "string-width";
4148
4422
  import { jsxDEV as jsxDEV5 } from "@opentui/react/jsx-dev-runtime";
4423
+ var statusIndicatorWidth = (icon) => 2 + Math.max(1, stringWidth(icon));
4149
4424
  var statusIndicatorFg = (variant) => {
4150
4425
  switch (variant) {
4151
4426
  case "info":
@@ -4164,18 +4439,20 @@ var StatusIndicator = ({
4164
4439
  active = true,
4165
4440
  onMouseUp
4166
4441
  }) => {
4442
+ const glyph = icon.toLocaleUpperCase();
4167
4443
  const activeColor = statusIndicatorFg(variant);
4168
4444
  const backgroundColor = active ? activeColor : colors.backgroundElement;
4445
+ const width = statusIndicatorWidth(glyph);
4169
4446
  return /* @__PURE__ */ jsxDEV5("box", {
4170
4447
  ...onMouseUp === undefined ? {} : { onMouseUp },
4171
4448
  style: {
4172
- width: 3,
4449
+ width,
4173
4450
  height: 1,
4174
4451
  flexDirection: "row",
4175
4452
  backgroundColor
4176
4453
  },
4177
4454
  children: /* @__PURE__ */ jsxDEV5("text", {
4178
- content: ` ${icon} `,
4455
+ content: ` ${glyph} `,
4179
4456
  wrapMode: "none",
4180
4457
  style: {
4181
4458
  fg: active ? colors.backgroundPanel : colors.textMuted,
@@ -4191,7 +4468,7 @@ import { jsxDEV as jsxDEV6 } from "@opentui/react/jsx-dev-runtime";
4191
4468
  var FOOTER_HEIGHT = 1;
4192
4469
  var normalizeStatusLine = (status) => status.replace(/\s+/g, " ").trim();
4193
4470
  var HINT_SEPARATOR = " ";
4194
- var hintWidth = (h) => stringWidth(h.key) + 1 + stringWidth(h.label);
4471
+ var hintWidth = (h) => stringWidth2(h.key) + 1 + stringWidth2(h.label);
4195
4472
  var formatHint = (b) => {
4196
4473
  if (!b.hint)
4197
4474
  return null;
@@ -4218,6 +4495,8 @@ var fitHints = (hints, width) => {
4218
4495
  return [{ key: first.key.slice(0, width), label: "" }];
4219
4496
  };
4220
4497
  var STATUS_SEPARATOR = " \xB7 ";
4498
+ var INDICATOR_SEPARATOR = " ";
4499
+ var INDICATOR_SEPARATOR_WIDTH = 1;
4221
4500
  var NON_WARNING_STATUS_PREFIX_WIDTH = 2;
4222
4501
  var isPartialDiscoveryWarning = (status) => status?.startsWith("scan incomplete:") ?? false;
4223
4502
  var Footer = ({
@@ -4265,7 +4544,9 @@ var Footer = ({
4265
4544
  ] : [],
4266
4545
  ...indicators
4267
4546
  ];
4268
- const indicatorBudget = Math.min(usableWidth, renderedIndicators.length * 3);
4547
+ const indicatorWidths = renderedIndicators.map((indicator) => statusIndicatorWidth(indicator.icon));
4548
+ const indicatorSeparatorBudget = renderedIndicators.length > 1 ? (renderedIndicators.length - 1) * INDICATOR_SEPARATOR_WIDTH : 0;
4549
+ const indicatorBudget = Math.min(usableWidth, indicatorWidths.reduce((sum, width2) => sum + width2, 0) + indicatorSeparatorBudget);
4269
4550
  const hasContentAfterIndicators = notice !== null || status !== null || hints.length > 0;
4270
4551
  const indicatorContentGapBudget = renderedIndicators.length > 0 && hasContentAfterIndicators && indicatorBudget < usableWidth ? 1 : 0;
4271
4552
  const contentBudget = Math.max(0, usableWidth - indicatorBudget - indicatorContentGapBudget);
@@ -4277,9 +4558,21 @@ var Footer = ({
4277
4558
  const statusContent = status ? status.slice(0, Math.max(0, statusBudget - statusChromeWidth)) : "";
4278
4559
  const statusSeparatorContent = status ? STATUS_SEPARATOR.slice(0, Math.max(0, statusBudget - nonWarningStatusPrefixBudget - statusContent.length)) : "";
4279
4560
  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));
4561
+ const renderIndicators = () => renderedIndicators.flatMap(({ id, ...props }, index) => {
4562
+ const chip = /* @__PURE__ */ jsxDEV6(StatusIndicator, {
4563
+ ...props
4564
+ }, id, false, undefined, this);
4565
+ if (index === 0)
4566
+ return [chip];
4567
+ return [
4568
+ /* @__PURE__ */ jsxDEV6("text", {
4569
+ content: INDICATOR_SEPARATOR,
4570
+ wrapMode: "none",
4571
+ style: { fg: colors.textMuted }
4572
+ }, `indicator-sep-${id}`, false, undefined, this),
4573
+ chip
4574
+ ];
4575
+ });
4283
4576
  const renderIndicatorContentGap = () => indicatorContentGapBudget > 0 ? /* @__PURE__ */ jsxDEV6("text", {
4284
4577
  content: " ",
4285
4578
  wrapMode: "none",
@@ -12742,192 +13035,72 @@ var map = loadBundledThemes();
12742
13035
  var themeDefinitions = [...map.values()];
12743
13036
  var getThemeDefinition = (id) => map.get(id);
12744
13037
 
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
- };
13038
+ // src/config/footerControls.ts
13039
+ var footerControlsFromSession = (options, session, handlers = {}) => {
13040
+ const controls = [];
13041
+ for (const key of footerKeys(options.specs)) {
13042
+ const spec = options.specs[key];
13043
+ const footer = spec.footer;
13044
+ if (footer === undefined)
13045
+ continue;
13046
+ const value = session.get(key);
13047
+ const handler = handlers[key];
13048
+ controls.push({
13049
+ id: key,
13050
+ icon: footerControlGlyph(spec, value),
13051
+ variant: "info",
13052
+ active: handler?.active ?? footerControlActive(spec, value),
13053
+ onMouseUp: () => {
13054
+ if (handler?.onActivate !== undefined) {
13055
+ handler.onActivate();
13056
+ return;
13057
+ }
13058
+ const current = session.get(key);
13059
+ session.set(key, nextFooterValue(spec, current));
13060
+ }
13061
+ });
12830
13062
  }
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);
13063
+ return controls;
12839
13064
  };
12840
13065
 
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;
13066
+ // src/config/footerLabels.ts
13067
+ var uniqueFooterLabels = (ids) => {
13068
+ const used = new Set;
13069
+ const out = {};
13070
+ for (const id of ids) {
13071
+ const compact = id.replace(/[^a-zA-Z0-9]+/g, "").toLowerCase();
13072
+ const source = compact.length > 0 ? compact : "x";
13073
+ let label = source.slice(0, 2).padEnd(2, "x");
13074
+ let offset = 2;
13075
+ while (used.has(label)) {
13076
+ const next = source[offset];
13077
+ if (next !== undefined) {
13078
+ label = `${source[0]}${next}`;
13079
+ offset += 1;
13080
+ } else {
13081
+ label = `${source[0]}${used.size % 10}`;
13082
+ offset += 1;
13083
+ if (offset > source.length + 20) {
13084
+ throw new Error(`unable to allocate unique footer label for ${JSON.stringify(id)}`);
13085
+ }
13086
+ }
12861
13087
  }
12862
- if (!found)
12863
- values[key] = spec.default;
13088
+ used.add(label);
13089
+ out[id] = label;
12864
13090
  }
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
- };
13091
+ return out;
12915
13092
  };
12916
13093
 
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
13094
  // src/config/options.ts
12925
13095
  var FILE_NAVIGATOR_ORDERS = ["tree", "recently-modified"];
13096
+ var themeIds = themeDefinitions.map((theme) => theme.id);
13097
+ var themeFooterLabels = uniqueFooterLabels(themeIds);
12926
13098
  var houseOptions = defineOptions({
12927
13099
  wrap: {
12928
13100
  type: "boolean",
12929
13101
  default: false,
12930
- persist: "session"
13102
+ persist: "session",
13103
+ footer: { icon: "W" }
12931
13104
  },
12932
13105
  width: {
12933
13106
  type: "number",
@@ -12939,7 +13112,8 @@ var houseOptions = defineOptions({
12939
13112
  type: "string",
12940
13113
  default: "opencode",
12941
13114
  persist: "file",
12942
- choices: themeDefinitions.map((theme) => theme.id)
13115
+ choices: themeIds,
13116
+ footer: { icon: "T", labels: themeFooterLabels }
12943
13117
  },
12944
13118
  tone: {
12945
13119
  type: "string",
@@ -12960,7 +13134,15 @@ var houseOptions = defineOptions({
12960
13134
  order: {
12961
13135
  type: "string",
12962
13136
  default: "recently-modified",
12963
- choices: FILE_NAVIGATOR_ORDERS
13137
+ choices: FILE_NAVIGATOR_ORDERS,
13138
+ persist: "session",
13139
+ footer: {
13140
+ icon: "O",
13141
+ labels: {
13142
+ tree: "tr",
13143
+ "recently-modified": "re"
13144
+ }
13145
+ }
12964
13146
  }
12965
13147
  });
12966
13148
 
@@ -13297,6 +13479,7 @@ var persistHouseOption = async (event, path) => {
13297
13479
 
13298
13480
  // src/Browser.tsx
13299
13481
  import { jsxDEV as jsxDEV13 } from "@opentui/react/jsx-dev-runtime";
13482
+ var asFileNavigatorOrder = (value) => typeof value === "string" && FILE_NAVIGATOR_ORDERS.includes(value) ? value : "recently-modified";
13300
13483
  var defaultReadFile = (path) => Effect3.runPromise(readFileText(path));
13301
13484
  var defaultDiscoveryPolicy = {
13302
13485
  revision: "house-markdown",
@@ -13378,15 +13561,17 @@ var Browser = ({
13378
13561
  wrap: initialWrap,
13379
13562
  width: wrapWidth,
13380
13563
  theme: theme.id,
13381
- tone: theme.tone
13564
+ tone: theme.tone,
13565
+ order: asFileNavigatorOrder(order)
13382
13566
  }, { persist: persistHouseOption });
13383
13567
  }
13384
13568
  const wrapEnabled = useSyncExternalStore(optionsSession.current.subscribe, () => optionsSession.current.get("wrap"));
13569
+ const browseOrder = useSyncExternalStore(optionsSession.current.subscribe, () => asFileNavigatorOrder(optionsSession.current.get("order")));
13385
13570
  const toggleWrap = () => {
13386
13571
  const session = optionsSession.current;
13387
13572
  if (session === null)
13388
13573
  return;
13389
- session.set("wrap", !session.get("wrap"));
13574
+ session.set("wrap", nextFooterValue(houseOptions.specs.wrap, session.get("wrap")));
13390
13575
  };
13391
13576
  const [loaded, setLoaded] = useState4(null);
13392
13577
  const [error, setError] = useState4(null);
@@ -13606,6 +13791,14 @@ var Browser = ({
13606
13791
  rememberAppearance(session.set("theme", next.id));
13607
13792
  pushFooterNotice(`theme: ${next.name}`);
13608
13793
  };
13794
+ const cycleOrder = () => {
13795
+ const session = optionsSession.current;
13796
+ if (session === null)
13797
+ return;
13798
+ const next = nextFooterValue(houseOptions.specs.order, session.get("order"));
13799
+ session.set("order", next);
13800
+ pushFooterNotice(`order: ${next}`);
13801
+ };
13609
13802
  const toggleTone = () => {
13610
13803
  const session = optionsSession.current;
13611
13804
  if (session === null)
@@ -14326,15 +14519,11 @@ var Browser = ({
14326
14519
  width,
14327
14520
  notice: footerNotice?.text ?? null,
14328
14521
  discoveryStatus: effectiveDiscoveryStatus,
14329
- indicators: [
14330
- {
14331
- id: "wrap",
14332
- icon: "W",
14333
- variant: "info",
14334
- active: wrapEnabled,
14335
- onMouseUp: toggleWrap
14336
- }
14337
- ],
14522
+ indicators: optionsSession.current === null ? [] : footerControlsFromSession(houseOptions, optionsSession.current, {
14523
+ wrap: { onActivate: toggleWrap },
14524
+ theme: { onActivate: () => cycleTheme(1) },
14525
+ order: { onActivate: cycleOrder }
14526
+ }),
14338
14527
  ...discoverySpinnerIntervalMs === undefined ? {} : { discoverySpinnerIntervalMs },
14339
14528
  ...discoverySpinnerInitialFrameIndex === undefined ? {} : { discoverySpinnerInitialFrameIndex },
14340
14529
  ...discoverySpinnerRegisterTick === undefined ? {} : { discoverySpinnerRegisterTick },
@@ -14369,7 +14558,7 @@ var Browser = ({
14369
14558
  root,
14370
14559
  policy,
14371
14560
  watch: watch2,
14372
- order,
14561
+ order: browseOrder,
14373
14562
  debounceMs: filterDebounceMs,
14374
14563
  navigatorRef,
14375
14564
  snapshot: liveSnapshot,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carlesandres/house",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "TUI-first markdown reader on opentui",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -62,10 +62,10 @@
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.8.0",
66
+ "@carlesandres/house-darwin-x64": "0.8.0",
67
+ "@carlesandres/house-linux-arm64": "0.8.0",
68
+ "@carlesandres/house-linux-x64": "0.8.0"
69
69
  },
70
70
  "dependencies": {
71
71
  "@effect/atom-react": "4.0.0-beta.60",