@ssebastien/tcmd 0.1.3 → 0.1.7

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 (134) hide show
  1. package/README.md +256 -62
  2. package/bin/tcmd +10 -2
  3. package/bin/tcmd.js +13 -2
  4. package/desktop/dist/index.html +13 -0
  5. package/desktop/dist/main.js +20298 -0
  6. package/desktop/dist/style.css +1515 -0
  7. package/desktop/frontend/index.html +13 -0
  8. package/package.json +19 -6
  9. package/public/fonts/OFL.txt +93 -0
  10. package/public/index.html +14 -0
  11. package/public/licenses/LUCIDE.txt +43 -0
  12. package/public/main.js +20679 -0
  13. package/public/style.css +1515 -0
  14. package/scripts/build-desktop.js +31 -0
  15. package/scripts/serve-web.js +61 -0
  16. package/src/js/tcmd/components/App.html +80 -14
  17. package/src/js/tcmd/components/App.js +339 -114
  18. package/src/js/tcmd/components/App.xml +76 -22
  19. package/src/js/tcmd/components/BookmarkList.html +5 -5
  20. package/src/js/tcmd/components/BookmarkList.js +15 -41
  21. package/src/js/tcmd/components/BookmarkList.xml +1 -2
  22. package/src/js/tcmd/components/BookmarkRow.html +2 -1
  23. package/src/js/tcmd/components/BookmarkRow.js +14 -9
  24. package/src/js/tcmd/components/BookmarkRow.xml +1 -1
  25. package/src/js/tcmd/components/Breadcrumbs.js +1 -1
  26. package/src/js/tcmd/components/CommandArgRow.html +28 -0
  27. package/src/js/tcmd/components/CommandArgRow.js +146 -30
  28. package/src/js/tcmd/components/CommandArgRow.xml +55 -4
  29. package/src/js/tcmd/components/CommandBar.html +2 -2
  30. package/src/js/tcmd/components/CommandBar.js +33 -27
  31. package/src/js/tcmd/components/CommandBar.xml +2 -2
  32. package/src/js/tcmd/components/CommandItem.html +1 -1
  33. package/src/js/tcmd/components/CommandItem.js +25 -25
  34. package/src/js/tcmd/components/CommandItem.xml +2 -2
  35. package/src/js/tcmd/components/CommandPanel.html +29 -0
  36. package/src/js/tcmd/components/CommandPanel.js +78 -52
  37. package/src/js/tcmd/components/CommandPanel.xml +49 -25
  38. package/src/js/tcmd/components/Confirm.html +2 -2
  39. package/src/js/tcmd/components/EntryDetail.html +7 -7
  40. package/src/js/tcmd/components/EntryDetail.js +22 -8
  41. package/src/js/tcmd/components/FileList.html +5 -1
  42. package/src/js/tcmd/components/FileList.js +2 -1
  43. package/src/js/tcmd/components/FilePicker.html +10 -0
  44. package/src/js/tcmd/components/FilePicker.js +43 -0
  45. package/src/js/tcmd/components/FilePicker.xml +13 -0
  46. package/src/js/tcmd/components/FilePickerRow.html +6 -0
  47. package/src/js/tcmd/components/FilePickerRow.js +27 -0
  48. package/src/js/tcmd/components/FilePickerRow.xml +10 -0
  49. package/src/js/tcmd/components/FilePreview.html +14 -4
  50. package/src/js/tcmd/components/FilePreview.js +111 -12
  51. package/src/js/tcmd/components/FilePreview.xml +28 -8
  52. package/src/js/tcmd/components/FileRow.html +2 -1
  53. package/src/js/tcmd/components/FileRow.js +70 -12
  54. package/src/js/tcmd/components/FilterBar.html +1 -1
  55. package/src/js/tcmd/components/FilterBar.js +14 -4
  56. package/src/js/tcmd/components/FilterBar.xml +1 -1
  57. package/src/js/tcmd/components/Frame.js +324 -0
  58. package/src/js/tcmd/components/Help.html +15 -0
  59. package/src/js/tcmd/components/Help.js +260 -0
  60. package/src/js/tcmd/components/Help.xml +13 -4
  61. package/src/js/tcmd/components/HelpRow.html +8 -0
  62. package/src/js/tcmd/components/HelpRow.xml +7 -0
  63. package/src/js/tcmd/components/OverlayList.html +8 -0
  64. package/src/js/tcmd/components/OverlayList.js +69 -0
  65. package/src/js/tcmd/components/{CommandPalette.xml → OverlayList.xml} +2 -5
  66. package/src/js/tcmd/components/OverlayRow.html +5 -0
  67. package/src/js/tcmd/components/OverlayRow.js +47 -0
  68. package/src/js/tcmd/components/OverlayRow.xml +9 -0
  69. package/src/js/tcmd/components/Summary.html +1 -1
  70. package/src/js/tcmd/components/Summary.js +1 -6
  71. package/src/js/tcmd/components/format.js +4 -23
  72. package/src/js/tcmd/components/icons.js +176 -5
  73. package/src/js/tcmd/components/index.js +9 -5
  74. package/src/js/tcmd/components/rows.js +176 -48
  75. package/src/js/tcmd/core/base64.js +64 -0
  76. package/src/js/tcmd/core/bookmarks.js +45 -8
  77. package/src/js/tcmd/core/commands.js +978 -260
  78. package/src/js/tcmd/core/concurrency.js +59 -0
  79. package/src/js/tcmd/core/decompress.js +117 -0
  80. package/src/js/tcmd/core/directory.js +219 -93
  81. package/src/js/tcmd/core/format.js +36 -0
  82. package/src/js/tcmd/core/fs.js +438 -54
  83. package/src/js/tcmd/core/history.js +58 -0
  84. package/src/js/tcmd/core/index.js +3 -9
  85. package/src/js/tcmd/core/journal.js +93 -0
  86. package/src/js/tcmd/core/keymap.js +42 -6
  87. package/src/js/tcmd/core/operations.js +813 -48
  88. package/src/js/tcmd/core/os.js +29 -3
  89. package/src/js/tcmd/core/pane.js +33 -8
  90. package/src/js/tcmd/core/panel.js +280 -104
  91. package/src/js/tcmd/core/path.js +30 -3
  92. package/src/js/tcmd/core/permissions.js +107 -0
  93. package/src/js/tcmd/core/picker.js +156 -0
  94. package/src/js/tcmd/core/preview.js +102 -0
  95. package/src/js/tcmd/core/script.js +7 -95
  96. package/src/js/tcmd/core/selection.js +9 -1
  97. package/src/js/tcmd/core/session.js +498 -200
  98. package/src/js/tcmd/core/watch.js +153 -0
  99. package/src/js/tcmd/desktop/backend/api.js +165 -0
  100. package/src/js/tcmd/desktop/backend/fs.js +626 -0
  101. package/src/js/tcmd/desktop/backend/main.ts +9 -0
  102. package/src/js/tcmd/desktop/bookmarks.js +23 -0
  103. package/src/js/tcmd/desktop/fs.js +91 -0
  104. package/src/js/tcmd/desktop/main.js +194 -0
  105. package/src/js/tcmd/desktop/os.js +33 -0
  106. package/src/js/tcmd/index.js +0 -1
  107. package/src/js/tcmd/tui/app.js +118 -60
  108. package/src/js/tcmd/tui/catalogue.js +238 -42
  109. package/src/js/tcmd/tui/index.js +18 -19
  110. package/src/js/tcmd/tui/opentui.js +33 -0
  111. package/src/js/tcmd/tui/syntax.js +91 -0
  112. package/src/js/tcmd/tui/theme.js +32 -2
  113. package/src/js/tcmd/vendor/bzip2.js +276 -0
  114. package/src/js/tcmd/web/app.js +225 -41
  115. package/src/js/tcmd/web/bookmarks.js +36 -0
  116. package/src/js/tcmd/web/catalogue.js +81 -38
  117. package/src/js/tcmd/web/effects.js +223 -0
  118. package/src/js/tcmd/web/header.html +1 -1
  119. package/src/js/tcmd/web/header.js +15 -11
  120. package/src/js/tcmd/web/http.js +13 -0
  121. package/src/js/tcmd/web/index.js +2 -2
  122. package/src/js/tcmd/web/main.js +6 -66
  123. package/src/js/tcmd/web/opfs.js +297 -30
  124. package/src/js/tcmd/web/seed.js +4 -4
  125. package/src/js/tcmd/web/server.js +124 -21
  126. package/src/js/tcmd/web/theme.js +4 -0
  127. package/tinyjs.json +16 -0
  128. package/src/js/tcmd/components/CommandPalette.html +0 -7
  129. package/src/js/tcmd/components/CommandPalette.js +0 -58
  130. package/src/js/tcmd/components/PaletteRow.html +0 -3
  131. package/src/js/tcmd/components/PaletteRow.js +0 -43
  132. package/src/js/tcmd/components/PaletteRow.xml +0 -4
  133. package/src/js/tcmd/tui/frame.js +0 -269
  134. package/src/js/tcmd/tui/help.js +0 -32
@@ -0,0 +1,324 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-10-02
5
+
6
+ // Module: tcmd/components/Frame
7
+ // Shared presentation helpers for the workspace chrome: the responsive
8
+ // breakpoints driven by the `uiSize` cell, the pane borders, header rules, the
9
+ // command-bar geometry and the overlay visibility gates. Everything here is
10
+ // injected into each renderer's App binding. Viewport dimensions are in
11
+ // monospace columns/rows; the browser measures them from its own font. The key
12
+ // reference's text lives with its overlay in `Help.js`.
13
+
14
+ import { unwrap } from "select/state/cells"
15
+ import { commandBarStatus } from "./CommandBar.js"
16
+ import pkg from "../../../../package.json" with { type: "json" }
17
+
18
+ // Constant: VERSION
19
+ // The published version, read from package.json so the identity bar can never
20
+ // drift from what ships.
21
+ const VERSION = `${pkg?.version ?? ""}`
22
+
23
+ // Constant: MODE_SLOTS
24
+ // Per-letter slots the identity bar draws the view-mode word through, one
25
+ // `<text>` each so every letter can carry its own gradient colour (the same
26
+ // trick the `TinyCmd` brand uses). Sized for the longest mode name; a shorter
27
+ // one leaves the trailing slots empty, and an empty text node takes no column.
28
+ const MODE_SLOTS = "commander".length
29
+
30
+ // Function: modeWord
31
+ // The view-mode name as it reads in the identity bar.
32
+ function modeWord(viewMode) {
33
+ return viewMode === "explorer" ? "explorer" : "commander"
34
+ }
35
+
36
+ // Function: modeGradient
37
+ // The `[from, to]` brand pair a mode word shimmers along: explorer takes the
38
+ // Tiny blues, commander the Cmd oranges, so the mode reads as part of the
39
+ // logo rather than as trailing status text.
40
+ function modeGradient(env, viewMode) {
41
+ return viewMode === "explorer"
42
+ ? [env.theme.brandTinyFrom, env.theme.brandTinyTo]
43
+ : [env.theme.brandCmdFrom, env.theme.brandCmdTo]
44
+ }
45
+
46
+ // Function: versionTrail
47
+ // The identity bar's version tail, ` · 0.1.3`. It carries its leading space
48
+ // so it follows the mode word's slots directly in both renderers.
49
+ function versionTrail() {
50
+ return VERSION ? ` · ${VERSION}` : ""
51
+ }
52
+
53
+ // Below this the two-pane workspace is replaced by a resize hint.
54
+ const MIN_WIDTH = 64
55
+ const MIN_HEIGHT = 16
56
+ // At or above this the inspector can sit beside the panes; below it becomes a
57
+ // fullscreen overlay.
58
+ const INSPECTOR_INLINE_WIDTH = 100
59
+
60
+ // Function: sizeOf
61
+ // Reads the viewport size from the App data, falling back to the renderer's
62
+ // own dimensions so the gates work even when no `uiSize` cell is injected
63
+ // (tests, web). Returns `null` when neither is known.
64
+ function sizeOf(data, self) {
65
+ const size = unwrap(data?.uiSize)
66
+ if (size && (size.width || size.height)) return size
67
+ const width = Math.floor(self?.ctx?.width ?? 0)
68
+ const height = Math.floor(self?.ctx?.height ?? 0)
69
+ if (!width && !height) return null
70
+ return { width, height }
71
+ }
72
+
73
+ // Function: isNarrow
74
+ // True when the viewport is known and too narrow for the inline inspector.
75
+ function isNarrow(data, self) {
76
+ const size = sizeOf(data, self)
77
+ return Boolean(size && (size.width ?? 0) > 0 && size.width < INSPECTOR_INLINE_WIDTH)
78
+ }
79
+
80
+ // Function: isExplorer
81
+ // True when the workspace is the three-column explorer view. Absent
82
+ // `viewMode` data (tests, host-less bindings) means the two-pane commander.
83
+ function isExplorer(data) {
84
+ return unwrap(data?.viewMode) === "explorer"
85
+ }
86
+
87
+ // Function: currentColumnId
88
+ // The id of the column holding the current directory: the focused pane when
89
+ // focus is on source/destination, the anchor pane otherwise. (`core/pane` has
90
+ // the same question over a session; this one reads the view's data bag.)
91
+ function currentColumnId(data) {
92
+ return `${unwrap(data?.currentId) ?? unwrap(data?.active) ?? "left"}`
93
+ }
94
+
95
+ // Constant: PANE_GROW
96
+ // The explorer's current column is this much wider than the parent/preview
97
+ // mirrors — enough to read as the working pane, not enough to starve its
98
+ // neighbours. Both renderers apply the same ratio (the TUI's pane row growers
99
+ // and the web CSS `.pane.is-major`), so the geometry has one source of truth
100
+ // here.
101
+ const PANE_GROW = 1.35
102
+
103
+ // Function: paneColumnWidth
104
+ // The columns a pane's column occupies at viewport `width` for the current
105
+ // view. Explorer leads with the parent mirror and grows the current column by
106
+ // PANE_GROW, so its mirrors are narrower than an even split; commander tiles
107
+ // its two panes evenly. The inline inspector adds an equal column to either.
108
+ // Panes hidden in the view, and an unknown viewport (`width <= 0`), are 0.
109
+ //
110
+ // The caller subtracts its own chrome (border, inset, safety) to get a content
111
+ // budget: feeding every pane an even `width / columns` makes the narrower
112
+ // mirror columns overflow, and the renderer then mangles their breadcrumbs.
113
+ function paneColumnWidth(width, paneId, { viewMode, currentId, showPreview } = {}) {
114
+ const total = Math.floor(width ?? 0)
115
+ if (total <= 0) return 0
116
+ const weights = {}
117
+ if (viewMode === "explorer") {
118
+ weights.parent = 1
119
+ weights[`${currentId}` === "right" ? "right" : "left"] = PANE_GROW
120
+ } else {
121
+ weights.left = 1
122
+ weights.right = 1
123
+ }
124
+ if (Boolean(showPreview) && total >= INSPECTOR_INLINE_WIDTH) weights.preview = 1
125
+ const weight = weights[paneId] ?? 0
126
+ if (weight <= 0) return 0
127
+ const sum = Object.values(weights).reduce((acc, value) => acc + value, 0)
128
+ return Math.floor((total * weight) / sum)
129
+ }
130
+
131
+ // Function: paneListHeight
132
+ // Rows available to a pane's listing: the viewport height minus the app header
133
+ // (1 row in the web UI, 2 for the desktop toolbar), the pane border (2), pane
134
+ // header + rule (2), entry detail (2), and the command bar when visible. Fed to
135
+ // the windowed `filelist` so a large directory mounts only the visible slice.
136
+ // `0` means "unknown — render everything to measure".
137
+ function paneListHeight(data, self) {
138
+ const height = Math.floor(sizeOf(data, self)?.height ?? 0)
139
+ if (height <= 0) return 0
140
+ const desktop = Boolean(unwrap(data?.desktopMode))
141
+ // A notice or error keeps a one-line status in desktop mode; otherwise that
142
+ // renderer has no bottom command bar at all. The bar is always one line.
143
+ const bar = commandBarStatus(data) || !desktop ? 1 : 0
144
+ return Math.max(1, height - (desktop ? 8 : 7) - bar)
145
+ }
146
+
147
+ // Function: mixHex
148
+ // Linear interpolation between two `#rrggbb` colours at `t` in [0, 1]. A
149
+ // missing colour (the mono theme) yields `undefined` so the whole run falls
150
+ // back to the terminal default.
151
+ function mixHex(from, to, t) {
152
+ if (!from || !to) return undefined
153
+ const channel = (index) => {
154
+ const a = Number.parseInt(from.slice(index, index + 2), 16)
155
+ const b = Number.parseInt(to.slice(index, index + 2), 16)
156
+ return Math.round(a + (b - a) * t)
157
+ .toString(16)
158
+ .padStart(2, "0")
159
+ }
160
+ return `#${channel(1)}${channel(3)}${channel(5)}`
161
+ }
162
+
163
+ // Function: shineOf
164
+ // One `{ letter, fg }` per letter of a brand word, interpolating `from` → `to`
165
+ // across it so the word reads as a gradient; a missing colour (mono theme)
166
+ // leaves every `fg` undefined and the run falls back to the terminal default.
167
+ function shineOf(letters, from, to) {
168
+ return `${letters}`.split("").map((letter, index, all) => ({
169
+ letter,
170
+ fg: mixHex(from, to, index / (all.length - 1)),
171
+ }))
172
+ }
173
+
174
+ // Function: createFrameBehaviors
175
+ // Behaviors for the App root and its chrome.
176
+ function createFrameBehaviors(env) {
177
+ const tiny = shineOf("Tiny", env.theme.brandTinyFrom, env.theme.brandTinyTo)
178
+ const cmd = shineOf("Cmd", env.theme.brandCmdFrom, env.theme.brandCmdTo)
179
+ // The mode word, letter by letter: `modeL<i>` is the letter, `modeFg<i>`
180
+ // its gradient colour — the same per-letter trick the `TinyCmd` brand
181
+ // uses, since a terminal has no gradient to draw. The run is cached per
182
+ // word (there are only two), and both behaviors read the live `viewMode`,
183
+ // so flipping `t` redraws it in the other brand's colours.
184
+ const modeRuns = new Map()
185
+ const modeRun = (data) => {
186
+ const viewMode = unwrap(data?.viewMode)
187
+ const word = modeWord(viewMode)
188
+ let run = modeRuns.get(word)
189
+ if (!run) {
190
+ run = shineOf(word, ...modeGradient(env, viewMode))
191
+ modeRuns.set(word, run)
192
+ }
193
+ return run
194
+ }
195
+ const modeSlots = {}
196
+ for (let i = 0; i < MODE_SLOTS; i++) {
197
+ // The `when` gate matters: an empty text node still claims a column in
198
+ // the row, so the slots past the end of a shorter word are dropped
199
+ // from the layout instead of padding it.
200
+ modeSlots[`modeHas${i}`] = (_self, data) => modeRun(data)[i] !== undefined
201
+ modeSlots[`modeL${i}`] = (_self, data) => modeRun(data)[i]?.letter ?? ""
202
+ modeSlots[`modeFg${i}`] = (_self, data) => modeRun(data)[i]?.fg
203
+ }
204
+ return {
205
+ // Identity bar: "Tiny" shimmers along the brand blues, "Cmd" along a
206
+ // brighter orange than the mark accent, and the host trail follows in
207
+ // the heading colour. Everything else on screen belongs to the user's
208
+ // files; this one row is the app's own.
209
+ brandT: () => tiny[0].letter,
210
+ brandTFg: () => tiny[0].fg,
211
+ brandI: () => tiny[1].letter,
212
+ brandIFg: () => tiny[1].fg,
213
+ brandN: () => tiny[2].letter,
214
+ brandNFg: () => tiny[2].fg,
215
+ brandY: () => tiny[3].letter,
216
+ brandYFg: () => tiny[3].fg,
217
+ brandC: () => cmd[0].letter,
218
+ brandCFg: () => cmd[0].fg,
219
+ brandM: () => cmd[1].letter,
220
+ brandMFg: () => cmd[1].fg,
221
+ brandD: () => cmd[2].letter,
222
+ brandDFg: () => cmd[2].fg,
223
+ topBar: (_self, data) => {
224
+ const host = `${unwrap(data.appHost) ?? ""}`.trim()
225
+ return host ? ` - ${host} ` : " "
226
+ },
227
+ topBarFg: () => env.theme.textHeadingFg,
228
+ // Right-aligned trail: the version, following the gradiented mode word
229
+ // (`modeL*`) slot by slot. Dimmer than the brand, so the row still
230
+ // reads left to right.
231
+ topBarStatus: () => versionTrail(),
232
+ topBarStatusFg: () => env.theme.textSecondaryFg,
233
+ ...modeSlots,
234
+
235
+ // Responsive gates. `uiSize` is absent in tests and the web, so the
236
+ // workspace renders normally there.
237
+ tooSmall: (self, data) => {
238
+ const size = sizeOf(data, self)
239
+ if (!size) return false
240
+ return (size.width ?? MIN_WIDTH) < MIN_WIDTH || (size.height ?? MIN_HEIGHT) < MIN_HEIGHT
241
+ },
242
+ minSizeTitle: () => "Terminal too small",
243
+ minSizeHint: (self, data) => {
244
+ const size = sizeOf(data, self) ?? { width: 0, height: 0 }
245
+ return `need ${MIN_WIDTH}x${MIN_HEIGHT}, have ${size.width ?? 0}x${size.height ?? 0}`
246
+ },
247
+ // The min-size gate text uses the heading/secondary text tokens.
248
+ titleFg: () => env.theme.textHeadingFg,
249
+ dimFg: () => env.theme.textSecondaryFg,
250
+
251
+ // Each pane is a complete box. Both draw all four edges (the middle
252
+ // shows two adjacent rules) so each pane's border colour can track its
253
+ // own focus independently — a shared edge would be owned by one pane
254
+ // and never highlight when the other is active.
255
+ leftBorder: () => ["top", "left", "bottom", "right"],
256
+ rightBorder: () => ["top", "left", "bottom", "right"],
257
+ // The header rule separates the pane header row from the listing body.
258
+ headRule: () => ["top"],
259
+
260
+ // The breadcrumb yields the pane header row to the filter while a
261
+ // filter is being edited or applied.
262
+ leftFilterOn: (_self, data) => Boolean(unwrap(data.leftFilter)),
263
+ rightFilterOn: (_self, data) => Boolean(unwrap(data.rightFilter)),
264
+ parentFilterOn: (_self, data) => Boolean(unwrap(data.parentFilter)),
265
+
266
+ // Which columns the workspace draws. Commander tiles source and
267
+ // destination; explorer drops destination for the parent of the
268
+ // current directory, which leads the row so the current pane stays in
269
+ // the middle. The inspector column follows `showInspectorInline` and
270
+ // completes the three columns in either mode. Maximizing the inspector
271
+ // (`⇧V`) hides every column so the preview can take the row.
272
+ showParentPane: (_self, data) => isExplorer(data) && !unwrap(data?.previewFull),
273
+ showLeftPane: (_self, data) =>
274
+ (!isExplorer(data) || currentColumnId(data) === "left") && !unwrap(data?.previewFull),
275
+ showRightPane: (_self, data) =>
276
+ (!isExplorer(data) || currentColumnId(data) === "right") && !unwrap(data?.previewFull),
277
+
278
+ // The inline inspector shows when the preview is on and the viewport is
279
+ // wide enough; the viewer overlay shows whenever it is open, at any
280
+ // width (`v` on a narrow terminal, or Enter on a file that cannot be
281
+ // edited). Maximizing (`⇧V`) replaces the column with
282
+ // `showPreviewFull` below.
283
+ inspectorNarrow: (self, data) => isNarrow(data, self),
284
+ showInspectorInline: (self, data) =>
285
+ Boolean(unwrap(data.showPreview)) && !isNarrow(data, self) && !unwrap(data?.previewFull),
286
+ showViewer: (_self, data) => Boolean(unwrap(data.viewerOpen)),
287
+ // The maximized inspector: the preview fills the pane row in either
288
+ // view mode, pushed by `view.maximize` (`⇧V`).
289
+ showPreviewFull: (_self, data) => Boolean(unwrap(data?.previewFull)),
290
+
291
+ // Help overlay visibility; the content lives in `Help.js` so the
292
+ // overlay has a single source of truth.
293
+ helpOpen: (_self, data) => Boolean(unwrap(data.helpOpen)),
294
+
295
+ // The shared list overlay (history / trash) is mounted whenever a mode
296
+ // is set; the component itself gates on `open`.
297
+ overlayOpen: (_self, data) => Boolean(unwrap(data.overlay)),
298
+
299
+ // The windowed listing height for both panes (they share the row).
300
+ listViewport: (self, data) => paneListHeight(data, self),
301
+
302
+ // The command bar's height: always a single line, status or key hints.
303
+ barHeight: () => 1,
304
+ }
305
+ }
306
+
307
+ export {
308
+ createFrameBehaviors,
309
+ currentColumnId,
310
+ INSPECTOR_INLINE_WIDTH,
311
+ isExplorer,
312
+ isNarrow,
313
+ MIN_HEIGHT,
314
+ MIN_WIDTH,
315
+ MODE_SLOTS,
316
+ modeGradient,
317
+ modeWord,
318
+ PANE_GROW,
319
+ paneColumnWidth,
320
+ sizeOf,
321
+ versionTrail,
322
+ VERSION,
323
+ }
324
+ // EOF
@@ -0,0 +1,15 @@
1
+ <section class="help" when="open" role="dialog" aria-modal="true" aria-label="Key reference" tabindex="-1" on:click="!dialog:help-cancel">
2
+ <div class="help-dialog" on:click="dialogStop">
3
+ <header out="title"></header>
4
+ <div class="help-columns">
5
+ <div class="help-column" out="leftRows"></div>
6
+ <div class="help-column" out="rightRows"></div>
7
+ </div>
8
+ <footer class="help-footer">
9
+ <span class="help-bracket" out="hintOpen"></span>
10
+ <span class="help-title" out="hintHead"></span>
11
+ <span class="help-key" out="hintKeys"></span>
12
+ <span class="help-bracket" out="hintClose"></span>
13
+ </footer>
14
+ </div>
15
+ </section>
@@ -0,0 +1,260 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-10-02
5
+
6
+ // Module: tcmd/components/Help
7
+ // Shared behavior for the `?` key reference overlay, plus the reference data
8
+ // itself. The categories are built from the live keymap and command registry,
9
+ // so they cannot drift from the actual bindings. The TUI keeps the bracket
10
+ // shorthand (`[Open ⏎]`); the web renders toolbar-like action chips with Lucide
11
+ // icons and key badges. Rows are balanced across two columns with bright
12
+ // uppercase section headings. Both presentations (`Help.xml` / `Help.html`)
13
+ // render the same row data through `HelpRow`.
14
+
15
+ import { unwrap } from "select/state/cells"
16
+ import { DEFAULT_KEYMAP, keyGlyph } from "../core/keymap.js"
17
+ import { getCommands } from "../core/commands.js"
18
+ import { commandLabel, keyGlyph as keyLabelGlyph } from "./CommandItem.js"
19
+
20
+ // Constant: BRACKET
21
+ // The shorthand delimiters, shared with the command bar.
22
+ const OPEN = "["
23
+ const CLOSE = "]"
24
+
25
+ // Constant: CLOSE_HINT
26
+ // The footer shorthand: Esc dismisses the overlay.
27
+ const CLOSE_HINT = { title: "Close", keys: "Esc" }
28
+
29
+ // Constant: BOLD
30
+ // OpenTUI's `TextAttributes.BOLD` bit, set on section headings through the
31
+ // TUI's `out:attributes` slot. The web bolds through CSS instead.
32
+ const BOLD = 1
33
+
34
+ // Constant: GROUP_ORDER / GROUP_TITLES
35
+ // Help sections, in display order.
36
+ const GROUP_ORDER = ["nav", "pane", "list", "bookmark", "view", "file", "history", "trash", "panel", "filter", "app"]
37
+ const GROUP_TITLES = {
38
+ app: "Session",
39
+ bookmark: "Bookmarks",
40
+ file: "Files",
41
+ filter: "Filter",
42
+ history: "History",
43
+ list: "Selection",
44
+ nav: "Navigation",
45
+ panel: "Commands",
46
+ pane: "Panes",
47
+ trash: "Trash",
48
+ view: "View",
49
+ }
50
+
51
+ // Toolbar actions and key-reference entries share Lucide icons in the web UI.
52
+ // The TUI keeps its compact textual row layout.
53
+ const HELP_ICONS = {
54
+ "nav.up": "arrow-up",
55
+ "nav.down": "arrow-down",
56
+ "nav.jumpUp": "arrow-up",
57
+ "nav.jumpDown": "arrow-down",
58
+ "nav.pageUp": "arrow-up",
59
+ "nav.pageDown": "arrow-down",
60
+ "nav.home": "arrow-up",
61
+ "nav.end": "arrow-down",
62
+ "pane.enter": "arrow-right",
63
+ "pane.up": "arrow-up",
64
+ "pane.back": "arrow-left",
65
+ "pane.forward": "arrow-right",
66
+ "pane.toggle": "columns-2",
67
+ "pane.reload": "refresh-cw",
68
+ "list.mark": "check",
69
+ "bookmark.open": "bookmark",
70
+ "view.togglePreview": "panel-right",
71
+ "view.maximize": "maximize",
72
+ "view.toggleMode": "columns-2",
73
+ "file.mkdir": "folder-plus",
74
+ "file.open": "file",
75
+ "file.edit": "file-text",
76
+ "history.open": "refresh-cw",
77
+ "history.undo": "arrow-left",
78
+ "history.redo": "arrow-right",
79
+ "trash.open": "trash",
80
+ "panel.open": "terminal",
81
+ "panel.shell": "terminal",
82
+ "filter.start": "search",
83
+ "app.quit": "power",
84
+ "app.help": "circle-question-mark",
85
+ }
86
+
87
+ // Function: helpGroups
88
+ // The categorized key reference as `{ title, rows }` groups in display order.
89
+ // One row per command, with every distinct chord from the live keymap. The
90
+ // registry provides the action title.
91
+ function helpGroups() {
92
+ const byId = new Map(getCommands().map((command) => [command.id, command]))
93
+ const groups = new Map()
94
+ for (const binding of DEFAULT_KEYMAP) {
95
+ const command = byId.get(binding.command)
96
+ if (!command) continue
97
+ const group = binding.command.split(".")[0]
98
+ if (!groups.has(group)) groups.set(group, new Map())
99
+ const rows = groups.get(group)
100
+ if (!rows.has(binding.command)) rows.set(binding.command, { keys: [], id: binding.command, title: command.title })
101
+ const row = rows.get(binding.command)
102
+ const glyph = keyGlyph(binding)
103
+ if (!row.keys.includes(glyph)) row.keys.push(glyph)
104
+ }
105
+ const out = []
106
+ for (const group of GROUP_ORDER) {
107
+ const rows = groups.get(group)
108
+ if (!rows) continue
109
+ const list = []
110
+ for (const row of rows.values()) {
111
+ list.push({ id: row.id, title: row.title, keys: row.keys.map(keyLabelGlyph).join("/") })
112
+ }
113
+ out.push({ title: GROUP_TITLES[group] ?? group, rows: list })
114
+ }
115
+ return out
116
+ }
117
+
118
+ // Function: helpLines
119
+ // The flat reference: group headings and rows, one per line, blank line
120
+ // between groups (the single-column fallback).
121
+ function helpLines() {
122
+ const lines = []
123
+ for (const group of helpGroups()) {
124
+ lines.push(group.title)
125
+ for (const row of group.rows) lines.push(commandLabel(row).trim())
126
+ lines.push("")
127
+ }
128
+ return lines
129
+ }
130
+
131
+ // Function: helpBlocks
132
+ // The reference split into two columns balanced by line count, as flat lists of
133
+ // renderable blocks: `{ type: "section", label }` headings,
134
+ // `{ type: "entry", open, head, keys, close }` rows and `{ type: "spacer" }`
135
+ // blank lines between groups. Every entry title and key is padded to the widest
136
+ // one across both columns, so all brackets share one width and the keys align
137
+ // down each column (keys right-aligned against the closing bracket).
138
+ function helpBlocks() {
139
+ const groups = helpGroups()
140
+ const commands = new Map(getCommands().map((command) => [command.id, command]))
141
+ let titleWidth = 0
142
+ let keyWidth = 0
143
+ for (const group of groups) {
144
+ for (const row of group.rows) {
145
+ titleWidth = Math.max(titleWidth, row.title.length)
146
+ keyWidth = Math.max(keyWidth, keyLabelGlyph(row.keys).length)
147
+ }
148
+ }
149
+ const columns = [[], []]
150
+ const loads = [0, 0]
151
+ for (const group of groups) {
152
+ const at = loads[0] <= loads[1] ? 0 : 1
153
+ columns[at].push({ type: "section", key: `section:${group.title}`, label: group.title.toUpperCase() })
154
+ for (const row of group.rows) {
155
+ const command = commands.get(row.id)
156
+ // The compact web badge shows the command's canonical key when the
157
+ // registry has one (lowercase `b`, `Spc`, `⇥`); otherwise fall back
158
+ // to the keymap chords, which may list several alternatives.
159
+ const shortcut = command?.keys ? keyLabelGlyph(command.keys) : keyLabelGlyph(row.keys)
160
+ columns[at].push({
161
+ type: "entry",
162
+ key: `entry:${row.id}`,
163
+ icon: HELP_ICONS[row.id] ?? "chevron-right",
164
+ title: row.title,
165
+ shortcut,
166
+ open: OPEN,
167
+ head: `${row.title.padEnd(titleWidth)} `,
168
+ keys: keyLabelGlyph(row.keys).padStart(keyWidth),
169
+ close: CLOSE,
170
+ })
171
+ }
172
+ // A blank row separates the groups (and costs one line, like the
173
+ // heading).
174
+ columns[at].push({ type: "spacer", key: `spacer:${group.title}` })
175
+ // A group costs its rows plus its heading and the blank after it.
176
+ loads[at] += group.rows.length + 2
177
+ }
178
+ // The blank line separates groups, so the bottom of each column ends on its
179
+ // last entry instead.
180
+ for (const column of columns) {
181
+ if (column[column.length - 1]?.type === "spacer") column.pop()
182
+ }
183
+ return { left: columns[0], right: columns[1] }
184
+ }
185
+
186
+ // Function: createBehaviors
187
+ // Builds the Help behavior map for the given renderer environment.
188
+ function createBehaviors(env) {
189
+ return {
190
+ bg: () => env.theme.surfaceBg,
191
+ borderFg: () => env.theme.borderPrimaryActiveFg,
192
+ dimFg: () => env.theme.textSecondaryFg,
193
+ leftRows: (self, data) => env.rows?.(self, data, "left") ?? [],
194
+ rightRows: (self, data) => env.rows?.(self, data, "right") ?? [],
195
+ // The reference has no cursor for the scrollbox to follow, so its
196
+ // vertical bar is driven here: visible only while the two columns
197
+ // actually overflow the viewport. The viewport height is derived from
198
+ // the injected `size` (so the bar tracks a resize) and the dialog's own
199
+ // geometry (`Help.xml`: 82% tall, minus padding, borders, rule and
200
+ // footer).
201
+ scrollbar: (self, data) => {
202
+ const scroll = self?.ref?.scroll
203
+ const bar = scroll?.verticalScrollBar
204
+ if (!bar) return ""
205
+ const height = unwrap(data?.size)?.height ?? self?.ctx?.height ?? 0
206
+ const viewport = height > 0 ? Math.floor(height * 0.82) - 6 : 0
207
+ const columns = helpBlocks()
208
+ const rows = Math.max(columns.left.length, columns.right.length)
209
+ const want = viewport > 0 && rows > viewport
210
+ if (bar.visible !== want) bar.visible = want
211
+ return ""
212
+ },
213
+ open: (_self, data) => Boolean(unwrap(data.open)),
214
+ // The footer shorthand reads like the bar: dim brackets, bright title,
215
+ // accent key.
216
+ hintOpen: () => OPEN,
217
+ hintHead: () => `${CLOSE_HINT.title} `,
218
+ hintKeys: () => CLOSE_HINT.keys,
219
+ hintClose: () => CLOSE,
220
+ bracketFg: () => env.theme.textSecondaryFg,
221
+ headFg: () => env.theme.textFg,
222
+ keyFg: () => env.theme.accentFg,
223
+ // Separator rule above the footer (a top border on a one-row box).
224
+ rule: () => ["top"],
225
+ ruleFg: () => env.theme.textSecondaryFg,
226
+ textFg: () => env.theme.textFg,
227
+ title: () => " Keys ",
228
+ titleFg: () => env.theme.textHeadingFg,
229
+ }
230
+ }
231
+
232
+ // Function: createRowBehaviors
233
+ // Builds the HelpRow behavior map (one section heading or one key entry).
234
+ function createRowBehaviors(env) {
235
+ return {
236
+ isSection: (_self, data) => data.item?.type === "section",
237
+ isEntry: (_self, data) => data.item?.type === "entry",
238
+ section: (_self, data) => data.item?.label ?? "",
239
+ // Sections are bright; the TUI adds bold via OpenTUI's attribute bit
240
+ // (the web bolds through CSS instead).
241
+ sectionFg: () => env.theme.textHeadingFg,
242
+ sectionAttrs: () => BOLD,
243
+ open: (_self, data) => data.item?.open ?? "",
244
+ head: (_self, data) => data.item?.head ?? "",
245
+ keys: (_self, data) => data.item?.keys ?? "",
246
+ close: (_self, data) => data.item?.close ?? "",
247
+ hasIcon: (_self, data) => Boolean(env.theme.lucideIcons && data.item?.icon),
248
+ iconHref: (_self, data) =>
249
+ env.theme.lucideIcons && data.item?.icon ? `#lucide-${data.item.icon}` : "",
250
+ actionTitle: (_self, data) => data.item?.title ?? `${data.item?.head ?? ""}`.trim(),
251
+ shortcut: (_self, data) => data.item?.shortcut ?? `${data.item?.keys ?? ""}`.trim(),
252
+ bracketFg: () => env.theme.textSecondaryFg,
253
+ headFg: () => env.theme.textFg,
254
+ keyFg: () => env.theme.accentFg,
255
+ }
256
+ }
257
+
258
+ export { createBehaviors, createRowBehaviors, helpBlocks, helpGroups, helpLines, keyGlyph }
259
+
260
+ // EOF
@@ -3,9 +3,18 @@
3
3
  out:title="title" out:titleColor="titleFg" out:borderColor="borderFg"
4
4
  out:backgroundColor="bg" flexDirection="column"
5
5
  paddingLeft="2" paddingRight="2" paddingTop="1" paddingBottom="1">
6
- <scrollbox flexGrow="1">
7
- <text out="text" out:fg="textFg"></text>
8
- </scrollbox>
9
- <text out="hint" out:fg="dimFg"></text>
6
+ <helpscroll out="scrollbar" ref="scroll" flexGrow="1" overflow="hidden">
7
+ <box flexDirection="row" overflow="hidden">
8
+ <box out="leftRows" flexDirection="column" flexGrow="1" flexBasis="0" overflow="hidden"></box>
9
+ <box out="rightRows" flexDirection="column" flexGrow="1" flexBasis="0" overflow="hidden" paddingLeft="2"></box>
10
+ </box>
11
+ </helpscroll>
12
+ <box height="1" flexShrink="0" out:border="rule" out:borderColor="ruleFg"></box>
13
+ <box height="1" flexShrink="0" flexDirection="row" overflow="hidden">
14
+ <text out="hintOpen" out:fg="bracketFg" wrapMode="none"></text>
15
+ <text out="hintHead" out:fg="headFg" wrapMode="none"></text>
16
+ <text out="hintKeys" out:fg="keyFg" wrapMode="none"></text>
17
+ <text out="hintClose" out:fg="bracketFg" wrapMode="none"></text>
18
+ </box>
10
19
  </box>
11
20
  </box>
@@ -0,0 +1,8 @@
1
+ <div class="help-block">
2
+ <span class="help-section" when="isSection" out="section"></span>
3
+ <span class="help-entry" when="isEntry" role="group">
4
+ <svg class="help-icon ui-icon" when="hasIcon" aria-hidden="true" focusable="false" viewBox="0 0 24 24"><use out:href="iconHref"></use></svg>
5
+ <span class="help-title" out="actionTitle"></span>
6
+ <kbd class="help-key" out="shortcut"></kbd>
7
+ </span>
8
+ </div>
@@ -0,0 +1,7 @@
1
+ <box height="1" flexDirection="row" flexShrink="0" overflow="hidden">
2
+ <text when="isSection" out="section" out:fg="sectionFg" out:attributes="sectionAttrs" wrapMode="none"></text>
3
+ <text when="isEntry" out="open" out:fg="bracketFg" wrapMode="none"></text>
4
+ <text when="isEntry" out="head" out:fg="headFg" wrapMode="none"></text>
5
+ <text when="isEntry" out="keys" out:fg="keyFg" wrapMode="none"></text>
6
+ <text when="isEntry" out="close" out:fg="bracketFg" wrapMode="none"></text>
7
+ </box>
@@ -0,0 +1,8 @@
1
+ <div class="overlay-list" when="open" role="dialog" aria-modal="true" aria-label="History and trash" on:click="!dialog:overlay-cancel">
2
+ <div class="overlay-dialog" on:click="dialogStop">
3
+ <h2 out="title"></h2>
4
+ <p class="overlay-empty" when="emptyVisible" out="empty"></p>
5
+ <ul class="overlay-rows" out="rows"></ul>
6
+ <footer out="footer"></footer>
7
+ </div>
8
+ </div>