@carlesandres/house 0.4.12 → 0.4.14

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
@@ -6,6 +6,28 @@ The publish workflow (`.github/workflows/publish.yml`) runs on the `release: pub
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.14] — 2026-06-15
10
+
11
+ ### Added
12
+
13
+ - Browser header now shows the active discovery root, and empty states reuse the same canonical root label for consistent launch context.
14
+
15
+ ## [0.4.13] — 2026-06-15
16
+
17
+ ### Added
18
+
19
+ - Command palette now includes a `Copy file contents` action for the selected markdown file, copying the exact on-disk text to the system clipboard on macOS and Linux.
20
+
21
+ ### Changed
22
+
23
+ - Reader wrapping is now a session mode toggled with `w`: `width` / `--width` defines the fixed wrap width, while `wrap` / `--wrap` / `--no-wrap` controls startup mode. The footer now keeps a persistent `W` indicator for wrap state.
24
+ - The built-in startup focus now defaults to `sidebar` instead of opening the filter prompt immediately; use `--focus filter`, `HOUSE_FOCUS=filter`, or `focus = "filter"` to keep the previous behavior.
25
+ - Removed `--sidebar` as a startup mode; the sidebar now starts visible and remains controlled interactively with `s`, `tab`, and `/` during the session.
26
+
27
+ ### Fixed
28
+
29
+ - Footer status chrome, palette sizing, and reader wrap width calculations now stay within the visible pane.
30
+
9
31
  ## [0.4.12] — 2026-06-14
10
32
 
11
33
  ### Fixed
@@ -317,7 +339,9 @@ The v1 MVP, published as `@carlesandres/openmdr` on npm.
317
339
 
318
340
  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.
319
341
 
320
- [Unreleased]: https://github.com/carlesandres/house/compare/v0.4.12...HEAD
342
+ [Unreleased]: https://github.com/carlesandres/house/compare/v0.4.14...HEAD
343
+ [0.4.14]: https://github.com/carlesandres/house/compare/v0.4.13...v0.4.14
344
+ [0.4.13]: https://github.com/carlesandres/house/compare/v0.4.12...v0.4.13
321
345
  [0.4.12]: https://github.com/carlesandres/house/compare/v0.4.11...v0.4.12
322
346
  [0.4.11]: https://github.com/carlesandres/house/compare/v0.4.10...v0.4.11
323
347
  [0.4.10]: https://github.com/carlesandres/house/compare/v0.4.9...v0.4.10
package/README.md CHANGED
@@ -64,11 +64,12 @@ house --serve README.md
64
64
  | -------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------ |
65
65
  | `--theme <name>` | `opencode` | Starting theme (see list below) |
66
66
  | `--tone dark\|light` | `dark` | Starting tone |
67
- | `--width <N>` | | Cap rendered markdown width at N columns |
67
+ | `--width <N>` | `80` | Reader wrap width used when wrapping is enabled |
68
+ | `--wrap` | off | Start with reader wrapping enabled |
69
+ | `--no-wrap` | off | Start with reader wrapping disabled |
68
70
  | `--show <list>` | `""` | Reveal normally-skipped entries; comma-separated subset of `hidden`, `gitignored`. Use `--show ""` to clear. |
69
71
  | `--root <dir>` | current directory | Discovery root to walk; overrides `defaultRoot` config/env |
70
- | `--sidebar <mode>` | `auto` | Initial sidebar visibility: `auto`, `on`, or `off` |
71
- | `--focus <mode>` | `filter` | Startup focus: `sidebar`, `reader`, or `filter`. `filter` opens the sidebar filter prompt immediately. |
72
+ | `--focus <mode>` | `sidebar` | Startup focus: `sidebar`, `reader`, or `filter`. `filter` opens the sidebar filter prompt immediately. |
72
73
  | `--serve` | off | Serve the positional path as HTML in the browser (skips TUI) |
73
74
  | `--port <N>` | OS-assigned | Port for `--serve` |
74
75
  | `--ext <list>` | none | Include extra file extensions (comma-separated) |
@@ -92,24 +93,28 @@ Run `house --config-path` to print the exact location.
92
93
  theme = "tokyonight"
93
94
  tone = "dark"
94
95
  extensions = []
96
+ width = 80
97
+ wrap = false
95
98
  show = ["hidden", "gitignored"]
96
- focus = "filter"
99
+ focus = "sidebar"
97
100
  defaultRoot = "cwd" # or "git"
98
101
  ```
99
102
 
100
- Supported keys: `theme`, `tone`, `extensions`, `show`, `focus`, `defaultRoot`.
103
+ Supported keys: `theme`, `tone`, `extensions`, `width`, `wrap`, `show`, `focus`, `defaultRoot`.
101
104
 
102
105
  `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.
103
106
 
104
107
  Precedence, highest to lowest:
105
108
 
106
- 1. CLI flags (`--theme`, `--tone`, `--ext`, `--show`, `--focus`, `--root`)
107
- 2. Env vars (`HOUSE_THEME`, `HOUSE_TONE`, `HOUSE_EXTENSIONS`, `HOUSE_SHOW`, `HOUSE_FOCUS`, `HOUSE_DEFAULT_ROOT`)
109
+ 1. CLI flags (`--theme`, `--tone`, `--ext`, `--width`, `--wrap`, `--no-wrap`, `--show`, `--focus`, `--root`)
110
+ 2. Env vars (`HOUSE_THEME`, `HOUSE_TONE`, `HOUSE_EXTENSIONS`, `HOUSE_WIDTH`, `HOUSE_WRAP`, `HOUSE_SHOW`, `HOUSE_FOCUS`, `HOUSE_DEFAULT_ROOT`)
108
111
  3. Config file
109
- 4. Built-in defaults (`opencode` / `dark` / `extensions = []` / `show = []` / `focus = "filter"` / `defaultRoot = "cwd"`)
112
+ 4. Built-in defaults (`opencode` / `dark` / `extensions = []` / `width = 80` / `wrap = false` / `show = []` / `focus = "sidebar"` / `defaultRoot = "cwd"`)
110
113
 
111
114
  `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.
112
115
 
116
+ `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.
117
+
113
118
  The file is optional — a missing file is fine. Invalid keys, unknown themes, or malformed TOML fail loudly with a one-line error. Per-project config (`.house/config.toml`) and additional keys are deferred.
114
119
 
115
120
  ## Keys
@@ -122,6 +127,7 @@ The file is optional — a missing file is fine. Invalid keys, unknown themes, o
122
127
  | `tab` | Toggle focus (sidebar ↔ reader) |
123
128
  | `s` | Toggle sidebar visibility |
124
129
  | `ctrl+p` | Command palette |
130
+ | `w` | Toggle reader wrap |
125
131
  | `O` | Open current file in browser as HTML |
126
132
  | `E` | Open current file in `$EDITOR` (`$VISUAL` takes precedence) |
127
133
  | `t` | Next theme |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carlesandres/house",
3
- "version": "0.4.12",
3
+ "version": "0.4.14",
4
4
  "description": "TUI-first markdown reader on opentui",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/Browser.tsx CHANGED
@@ -25,16 +25,12 @@ import { parseFrontmatter } from "./markdown/frontmatter.ts"
25
25
  import { BRAND, BRAND_NAME } from "./brand.ts"
26
26
  import { Footer, FOOTER_HEIGHT, type FooterProps } from "./Footer.tsx"
27
27
  import { Header, HEADER_HEIGHT } from "./Header.tsx"
28
+ import { copyTextToClipboard } from "./io/clipboard.ts"
28
29
  import { openInEditor, resolveEditor } from "./io/editor.ts"
29
30
  import { readFileText } from "./io/readFile.ts"
30
31
  import { browserBindings, type BrowserCtx } from "./keymap/browser.ts"
31
32
  import { dispatch } from "./keymap/keymap.ts"
32
- import {
33
- canFitInline,
34
- defaultPreferredWidth,
35
- initialShownForAuto,
36
- resolveSidebarWidth,
37
- } from "./layout/resolve.ts"
33
+ import { canFitInline, defaultPreferredWidth, resolveSidebarWidth } from "./layout/resolve.ts"
38
34
  import { fitSidebarEmptyValue } from "./layout/sidebarEmptyState.ts"
39
35
  import { formatSidebarRow } from "./layout/sidebarRow.ts"
40
36
  import { PromptRow } from "./PromptRow.tsx"
@@ -48,7 +44,6 @@ import { themeDefinitions, getThemeDefinition } from "./theme/registry.ts"
48
44
  import { saveThemePreference } from "./config/save.ts"
49
45
  import { middleTruncate } from "./ui/middleTruncate.ts"
50
46
 
51
- export type SidebarMode = "auto" | "on" | "off"
52
47
  export type StartupFocus = "sidebar" | "reader" | "filter"
53
48
 
54
49
  export interface BrowserProps {
@@ -56,10 +51,12 @@ export interface BrowserProps {
56
51
  readonly initialIndex?: number
57
52
  /** Initial applied filter query seeded from the CLI positional. */
58
53
  readonly initialQuery?: string
59
- /** Cap the rendered markdown's width at N columns. Null = fill the pane. */
60
- readonly maxWidth?: number | null
61
- /** Discovery root label used in the post-discovery empty-vault sidebar row. */
62
- readonly emptyRootLabel?: string
54
+ /** Reader wrap width used when wrapping is enabled. */
55
+ readonly wrapWidth?: number
56
+ /** Initial reader wrap mode. Runtime toggles are session-only. */
57
+ readonly initialWrap?: boolean
58
+ /** Canonical discovery root label used anywhere the UI names the scan scope. */
59
+ readonly rootLabel?: string
63
60
  /** Persistent footer indicator (e.g. "indexing… 42"). Pass null/undefined
64
61
  * when discovery has finished; the indicator clears. */
65
62
  readonly discoveryStatus?: string | null
@@ -74,12 +71,11 @@ export interface BrowserProps {
74
71
  readonly renderedPathDebounceMs?: number
75
72
  /** Test seam: disable reader-empty-state tip rotation effect. */
76
73
  readonly disableReaderEmptyStateRotation?: boolean
77
- /** Initial sidebar visibility (`--sidebar` flag). `auto` consults the
78
- * launch viewport bucket once; subsequent visibility goes through `s`. */
79
- readonly sidebarMode?: SidebarMode
80
74
  readonly onQuit?: () => void
81
75
  /** Test seam: replaces the file reader. */
82
76
  readonly readFile?: (path: string) => Promise<string>
77
+ /** Test seam: replaces the system clipboard writer. */
78
+ readonly copyToClipboard?: (text: string) => Promise<void>
83
79
  /** Optional one-shot footer toast surfaced on first appearance (e.g. the
84
80
  * "update available" nudge). Shown with an extended TTL so the user has
85
81
  * time to read it; subsequent transient toasts (theme cycle, etc.)
@@ -189,8 +185,9 @@ export const Browser = ({
189
185
  files,
190
186
  initialIndex = 0,
191
187
  initialQuery = "",
192
- maxWidth = null,
193
- emptyRootLabel = "current root",
188
+ wrapWidth = 80,
189
+ initialWrap = false,
190
+ rootLabel = "current root",
194
191
  discoveryStatus = null,
195
192
  discoverySpinnerIntervalMs,
196
193
  discoverySpinnerInitialFrameIndex,
@@ -198,9 +195,9 @@ export const Browser = ({
198
195
  filterDebounceMs = FILTER_DEBOUNCE_MS,
199
196
  renderedPathDebounceMs = RENDERED_PATH_DEBOUNCE_MS,
200
197
  disableReaderEmptyStateRotation = false,
201
- sidebarMode = "auto",
202
198
  onQuit,
203
199
  readFile = defaultReadFile,
200
+ copyToClipboard = copyTextToClipboard,
204
201
  updateNotice = null,
205
202
  updateNoticeTtlMs = 10000,
206
203
  disableFooterNoticeAutoClear = false,
@@ -216,24 +213,13 @@ export const Browser = ({
216
213
  const [selectedIndex, setSelectedIndex] = useState(() =>
217
214
  clamp(initialIndex, 0, Math.max(0, files.length - 1)),
218
215
  )
216
+ const [wrapEnabled, setWrapEnabled] = useState(initialWrap)
219
217
  const [loaded, setLoaded] = useState<{ path: string; content: string } | null>(null)
220
218
  const [error, setError] = useState<string | null>(null)
221
- // `shown` is the user's sticky preference. Visibility is derived:
222
- // `visible = shown || focus === "sidebar"`. See DESIGN.md §7.1.
223
- //
224
- // Launch consults the viewport bucket once for `--sidebar=auto`. The
225
- // useState initializer pins this to the first render — buckets are
226
- // launch-only by design, so resize must NOT re-evaluate.
227
- const [shown, setShown] = useState<boolean>(() => {
228
- switch (sidebarMode) {
229
- case "on":
230
- return true
231
- case "off":
232
- return false
233
- case "auto":
234
- return initialShownForAuto(width)
235
- }
236
- })
219
+ // `shown` is the user's sticky interactive preference. The sidebar starts
220
+ // visible; after launch, `s`/`tab`/`/` may hide or reveal it. Visibility is
221
+ // derived: `visible = shown || focus === "sidebar"`. See DESIGN.md §7.1.
222
+ const [shown, setShown] = useState<boolean>(true)
237
223
  const startInFilter = startupFocus === "filter"
238
224
  const initialFocus: "sidebar" | "reader" =
239
225
  startupFocus === null
@@ -245,13 +231,12 @@ export const Browser = ({
245
231
  : "sidebar"
246
232
  // `filter` mirrors `openFilter`'s focus rule: the filter input lives in
247
233
  // the sidebar, so opening it on mount also forces sidebar focus regardless
248
- // of `--sidebar=off` (§7.1's visibility derivation surfaces the sidebar via
249
- // focus even when `shown` is false). Plain `sidebar` startup shares the
250
- // same pane focus without opening the prompt. When omitted, preserve the
251
- // legacy Browser behavior: initial focus follows visibility.
252
- const [focus, setFocus] = useState<"sidebar" | "reader">(() =>
253
- shown || initialFocus === "sidebar" ? "sidebar" : "reader",
254
- )
234
+ // of the current interactive visibility state (§7.1's visibility derivation
235
+ // surfaces the sidebar via focus even when `shown` is false). Plain
236
+ // `sidebar` startup shares the same pane focus without opening the prompt.
237
+ // When omitted, preserve the legacy Browser behavior: initial focus follows
238
+ // visibility.
239
+ const [focus, setFocus] = useState<"sidebar" | "reader">(() => initialFocus)
255
240
  const [sidebarScroll, setSidebarScroll] = useState<number>(0)
256
241
  const [filterOpen, setFilterOpen] = useState<boolean>(startInFilter)
257
242
  const [filterInput, setFilterInput] = useState<string>(initialQuery)
@@ -492,6 +477,7 @@ export const Browser = ({
492
477
  restoreFilterOnSidebarFocus: restoreFilterOnSidebarFocusRef.current,
493
478
  filterQuery: filterInput,
494
479
  paletteOpen,
480
+ wrapEnabled,
495
481
  setFocus,
496
482
  // Wrapped so any keymap-driven selection move (j/k/g/G/[/], reader
497
483
  // prev/next) clears the pending-restore ref from #145. Internal
@@ -565,6 +551,7 @@ export const Browser = ({
565
551
  setPaletteIndex(0)
566
552
  dispatchFloatingOverlay({ type: "open-command-palette" })
567
553
  },
554
+ toggleWrap: () => setWrapEnabled((prev) => !prev),
568
555
  cycleTheme,
569
556
  toggleTone,
570
557
  toggleAll: () => {
@@ -674,6 +661,26 @@ export const Browser = ({
674
661
  }
675
662
  })()
676
663
  },
664
+ copyCurrentContents: () => {
665
+ const file = displayedFiles[selectedIndex]
666
+ if (!file) return
667
+ void (async () => {
668
+ let text: string
669
+ try {
670
+ text = await readFile(file.path)
671
+ } catch {
672
+ pushFooterNotice(`copy failed: cannot read ${file.relativePath}`)
673
+ return
674
+ }
675
+ try {
676
+ await copyToClipboard(text)
677
+ pushFooterNotice(`copied ${file.relativePath}`)
678
+ } catch (err) {
679
+ const message = err instanceof Error ? err.message : String(err)
680
+ pushFooterNotice(`copy failed: ${message}`)
681
+ }
682
+ })()
683
+ },
677
684
  }
678
685
 
679
686
  useKeyboard((key) => {
@@ -860,6 +867,12 @@ export const Browser = ({
860
867
  const isNarrow = !canFitInline(width)
861
868
  const sidebarInline = isNarrow ? sidebarActive : shown || sidebarActive
862
869
  const readerVisible = isNarrow ? readerActive : true
870
+ // When fixed-width wrapping is enabled, never size markdown wider than the
871
+ // visible reader body. The scrollbox deliberately disables horizontal
872
+ // scrolling, so an over-wide markdown node clips instead of wrapping.
873
+ const readerPaneWidth = isNarrow || !sidebarInline ? width : Math.max(1, width - sidebarWidth)
874
+ const readerBodyWidth = Math.max(1, readerPaneWidth - 2) // reader padding: 1 left + 1 right
875
+ const markdownWidth = wrapEnabled ? Math.min(wrapWidth, readerBodyWidth) : "100%"
863
876
  // Currently-selected file shown in the Header (which replaced the
864
877
  // per-pane border title that used to carry this information).
865
878
  const currentFile = selected?.relativePath ?? null
@@ -924,6 +937,15 @@ export const Browser = ({
924
937
  width,
925
938
  notice: footerNotice?.text ?? null,
926
939
  discoveryStatus,
940
+ indicators: [
941
+ {
942
+ id: "wrap",
943
+ icon: "W",
944
+ variant: "info",
945
+ active: wrapEnabled,
946
+ onMouseUp: () => setWrapEnabled((prev) => !prev),
947
+ },
948
+ ],
927
949
  ...(discoverySpinnerIntervalMs === undefined ? {} : { discoverySpinnerIntervalMs }),
928
950
  ...(discoverySpinnerInitialFrameIndex === undefined
929
951
  ? {}
@@ -968,7 +990,7 @@ export const Browser = ({
968
990
  : "No markdown files in"
969
991
  : "No files match"
970
992
  }
971
- value={files.length === 0 ? (discoveryActive ? "…" : emptyRootLabel) : filterApplied}
993
+ value={files.length === 0 ? (discoveryActive ? "…" : rootLabel) : filterApplied}
972
994
  />
973
995
  ) : (
974
996
  visibleFiles.map((file, idx) => {
@@ -1022,7 +1044,7 @@ export const Browser = ({
1022
1044
  <box
1023
1045
  style={{ width, height, flexDirection: "column", backgroundColor: colors.backgroundPanel }}
1024
1046
  >
1025
- <Header width={width} currentFile={currentFile} />
1047
+ <Header width={width} currentFile={currentFile} rootLabel={rootLabel} />
1026
1048
  <box
1027
1049
  style={{
1028
1050
  flexDirection: "row",
@@ -1162,7 +1184,7 @@ export const Browser = ({
1162
1184
  fg={colors.text}
1163
1185
  bg={readerActive ? colors.background : colors.backgroundPanel}
1164
1186
  conceal
1165
- style={{ width: maxWidth ?? "100%" }}
1187
+ style={{ width: markdownWidth }}
1166
1188
  />
1167
1189
  </scrollbox>
1168
1190
  )}
@@ -88,9 +88,12 @@ export const CommandPalette = ({
88
88
  const overlayWidth = Math.min(viewportWidth - 4, 64)
89
89
  const rows = buildRows(commands)
90
90
  // Reserve: 2 for border (top+bottom), 1 query row, 1 spacer below query,
91
- // 1 spacer above footer, 1 footer row. Body gets the rest.
91
+ // 1 spacer above footer, 1 footer row. Body gets the rest. Keep a one-row
92
+ // viewport margin above and below when possible; the modal itself already
93
+ // owns a border and clips its body, so a larger fixed margin needlessly hides
94
+ // the final command on medium-height terminals.
92
95
  const chrome = 2 + 1 + 1 + 1 + 1
93
- const maxBody = Math.max(1, viewportHeight - 4 - chrome)
96
+ const maxBody = Math.max(1, viewportHeight - 2 - chrome)
94
97
  const desiredBody = Math.max(1, rows.length || 1)
95
98
  const bodyHeight = Math.min(desiredBody, maxBody)
96
99
  const overlayHeight = chrome + bodyHeight
package/src/Footer.tsx CHANGED
@@ -24,6 +24,7 @@ import type React from "react"
24
24
  import type { KeyBinding } from "./keymap/keymap.ts"
25
25
  import { displayKey } from "./keymap/displayKey.ts"
26
26
  import { Spinner } from "./Spinner.tsx"
27
+ import { StatusIndicator, type StatusIndicatorProps } from "./StatusIndicator.tsx"
27
28
  import { colors } from "./theme/colors.ts"
28
29
 
29
30
  /** Rows the Footer occupies. Importers use it for layout math so a future
@@ -39,6 +40,8 @@ export interface FooterProps<C> {
39
40
  * no TTL, cleared by the caller when the underlying activity finishes.
40
41
  * Loses to `notice` when both are set so transient toasts still surface. */
41
42
  readonly discoveryStatus?: string | null
43
+ /** Persistent compact state/status indicators, rendered after built-in warning indicators. */
44
+ readonly indicators?: readonly (StatusIndicatorProps & { readonly id: string })[]
42
45
  /** Test seam: override spinner tick speed so tests don't sleep on the full
43
46
  * production interval. Ignored when discoveryStatus is null. */
44
47
  readonly discoverySpinnerIntervalMs?: number
@@ -86,6 +89,7 @@ const fitHints = (hints: readonly Hint[], width: number): Hint[] => {
86
89
  }
87
90
 
88
91
  const STATUS_SEPARATOR = " · "
92
+ const NON_WARNING_STATUS_PREFIX_WIDTH = 2 // 1-cell spinner + 1-cell spacer
89
93
 
90
94
  const isPartialDiscoveryWarning = (status: string | null): boolean =>
91
95
  status?.startsWith("scan incomplete:") ?? false
@@ -96,6 +100,7 @@ export const Footer = <C,>({
96
100
  width,
97
101
  notice,
98
102
  discoveryStatus,
103
+ indicators = [],
99
104
  discoverySpinnerIntervalMs,
100
105
  discoverySpinnerInitialFrameIndex,
101
106
  discoverySpinnerRegisterTick,
@@ -130,19 +135,48 @@ export const Footer = <C,>({
130
135
  const status =
131
136
  discoveryStatus && discoveryStatus.length > 0 ? normalizeStatusLine(discoveryStatus) : null
132
137
  const isPartialWarning = isPartialDiscoveryWarning(status)
138
+ const renderedIndicators = [
139
+ ...(isPartialWarning
140
+ ? [
141
+ {
142
+ id: "discovery-warning",
143
+ icon: "!",
144
+ variant: "warning" as const,
145
+ active: true,
146
+ ...(onDiscoveryWarningToggle === undefined
147
+ ? {}
148
+ : { onMouseUp: onDiscoveryWarningToggle }),
149
+ },
150
+ ]
151
+ : []),
152
+ ...indicators,
153
+ ]
154
+ const indicatorBudget = Math.min(usableWidth, renderedIndicators.length * 3)
155
+ const contentBudget = Math.max(0, usableWidth - indicatorBudget)
156
+ const statusChromeWidth = status
157
+ ? (isPartialWarning ? 0 : NON_WARNING_STATUS_PREFIX_WIDTH) + STATUS_SEPARATOR.length
158
+ : 0
133
159
  const statusBudget = status
134
- ? Math.min((isPartialWarning ? 1 : status.length) + STATUS_SEPARATOR.length, usableWidth)
160
+ ? Math.min((isPartialWarning ? 0 : status.length) + statusChromeWidth, contentBudget)
135
161
  : 0
136
- const hintsWidth = Math.max(0, usableWidth - statusBudget)
162
+ const hintsWidth = Math.max(0, contentBudget - statusBudget)
137
163
  const visibleHints = fitHints(hints, hintsWidth)
138
- const statusContent = status
139
- ? status.slice(0, Math.max(0, statusBudget - STATUS_SEPARATOR.length))
164
+ const nonWarningStatusPrefixBudget =
165
+ status && !isPartialWarning ? Math.min(NON_WARNING_STATUS_PREFIX_WIDTH, statusBudget) : 0
166
+ const statusContent = status ? status.slice(0, Math.max(0, statusBudget - statusChromeWidth)) : ""
167
+ const statusSeparatorContent = status
168
+ ? STATUS_SEPARATOR.slice(
169
+ 0,
170
+ Math.max(0, statusBudget - nonWarningStatusPrefixBudget - statusContent.length),
171
+ )
140
172
  : ""
141
173
  const noticeContent = notice
142
- ? notice.length > usableWidth
143
- ? notice.slice(0, usableWidth)
174
+ ? notice.length > contentBudget
175
+ ? notice.slice(0, contentBudget)
144
176
  : notice
145
177
  : null
178
+ const renderIndicators = () =>
179
+ renderedIndicators.map(({ id, ...props }) => <StatusIndicator key={id} {...props} />)
146
180
 
147
181
  // Two-tone hint row: keys render in `text` (foreground-strength), the
148
182
  // `:label` portion in `textMuted`. Matches ghui's footer treatment so
@@ -177,25 +211,12 @@ export const Footer = <C,>({
177
211
  if (noticeContent !== null) {
178
212
  return (
179
213
  <box style={rowStyle}>
214
+ {renderIndicators()}
180
215
  <text content={noticeContent} wrapMode="none" style={{ fg: colors.primary }} />
181
216
  </box>
182
217
  )
183
218
  }
184
219
 
185
- const warningTrigger = isPartialWarning ? (
186
- <box
187
- {...(onDiscoveryWarningToggle === undefined ? {} : { onMouseUp: onDiscoveryWarningToggle })}
188
- style={{
189
- width: 1,
190
- height: 1,
191
- flexDirection: "row",
192
- backgroundColor: colors.backgroundElement,
193
- }}
194
- >
195
- <text content="!" wrapMode="none" style={{ fg: colors.warning, attributes: 1 }} />
196
- </box>
197
- ) : null
198
-
199
220
  if (status !== null) {
200
221
  const spinnerProps = {
201
222
  fg: colors.secondary,
@@ -212,20 +233,26 @@ export const Footer = <C,>({
212
233
 
213
234
  return (
214
235
  <box style={rowStyle}>
215
- {isPartialWarning ? null : <Spinner {...spinnerProps} />}
216
- {isPartialWarning ? null : (
236
+ {renderIndicators()}
237
+ {nonWarningStatusPrefixBudget >= 1 ? <Spinner {...spinnerProps} /> : null}
238
+ {nonWarningStatusPrefixBudget >= 2 ? (
217
239
  <text content=" " wrapMode="none" style={{ fg: colors.textMuted }} />
218
- )}
219
- {isPartialWarning ? (
220
- warningTrigger
221
- ) : (
240
+ ) : null}
241
+ {isPartialWarning ? null : (
222
242
  <text content={statusContent} wrapMode="none" style={{ fg: colors.secondary }} />
223
243
  )}
224
- <text content={STATUS_SEPARATOR} wrapMode="none" style={{ fg: colors.textMuted }} />
244
+ {statusSeparatorContent.length > 0 && (
245
+ <text content={statusSeparatorContent} wrapMode="none" style={{ fg: colors.textMuted }} />
246
+ )}
225
247
  {renderHints()}
226
248
  </box>
227
249
  )
228
250
  }
229
251
 
230
- return <box style={rowStyle}>{renderHints()}</box>
252
+ return (
253
+ <box style={rowStyle}>
254
+ {renderIndicators()}
255
+ {renderHints()}
256
+ </box>
257
+ )
231
258
  }