@ssebastien/tcmd 0.0.0-stage → 0.1.1

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 (95) hide show
  1. package/README.md +234 -2
  2. package/bin/tcmd +17 -0
  3. package/package.json +37 -5
  4. package/src/js/tcmd/components/App.html +28 -0
  5. package/src/js/tcmd/components/App.js +355 -0
  6. package/src/js/tcmd/components/App.xml +98 -0
  7. package/src/js/tcmd/components/BookmarkList.html +12 -0
  8. package/src/js/tcmd/components/BookmarkList.js +73 -0
  9. package/src/js/tcmd/components/BookmarkList.xml +18 -0
  10. package/src/js/tcmd/components/BookmarkRow.html +5 -0
  11. package/src/js/tcmd/components/BookmarkRow.js +40 -0
  12. package/src/js/tcmd/components/BookmarkRow.xml +10 -0
  13. package/src/js/tcmd/components/Breadcrumbs.js +27 -0
  14. package/src/js/tcmd/components/CommandArgRow.js +90 -0
  15. package/src/js/tcmd/components/CommandArgRow.xml +5 -0
  16. package/src/js/tcmd/components/CommandBar.html +4 -0
  17. package/src/js/tcmd/components/CommandBar.js +126 -0
  18. package/src/js/tcmd/components/CommandBar.xml +4 -0
  19. package/src/js/tcmd/components/CommandItem.html +3 -0
  20. package/src/js/tcmd/components/CommandItem.js +78 -0
  21. package/src/js/tcmd/components/CommandItem.xml +6 -0
  22. package/src/js/tcmd/components/CommandPalette.html +7 -0
  23. package/src/js/tcmd/components/CommandPalette.js +58 -0
  24. package/src/js/tcmd/components/CommandPalette.xml +16 -0
  25. package/src/js/tcmd/components/CommandPanel.js +98 -0
  26. package/src/js/tcmd/components/CommandPanel.xml +35 -0
  27. package/src/js/tcmd/components/Confirm.html +3 -0
  28. package/src/js/tcmd/components/Confirm.js +36 -0
  29. package/src/js/tcmd/components/Confirm.xml +7 -0
  30. package/src/js/tcmd/components/Crumb.html +4 -0
  31. package/src/js/tcmd/components/Crumb.js +48 -0
  32. package/src/js/tcmd/components/Crumb.xml +4 -0
  33. package/src/js/tcmd/components/EntryDetail.html +15 -0
  34. package/src/js/tcmd/components/EntryDetail.js +194 -0
  35. package/src/js/tcmd/components/EntryDetail.xml +16 -0
  36. package/src/js/tcmd/components/FileList.html +1 -0
  37. package/src/js/tcmd/components/FileList.js +41 -0
  38. package/src/js/tcmd/components/FileList.xml +7 -0
  39. package/src/js/tcmd/components/FilePreview.html +5 -0
  40. package/src/js/tcmd/components/FilePreview.js +110 -0
  41. package/src/js/tcmd/components/FilePreview.xml +10 -0
  42. package/src/js/tcmd/components/FileRow.html +5 -0
  43. package/src/js/tcmd/components/FileRow.js +135 -0
  44. package/src/js/tcmd/components/FileRow.xml +10 -0
  45. package/src/js/tcmd/components/FilterBar.html +6 -0
  46. package/src/js/tcmd/components/FilterBar.js +50 -0
  47. package/src/js/tcmd/components/FilterBar.xml +6 -0
  48. package/src/js/tcmd/components/Help.xml +11 -0
  49. package/src/js/tcmd/components/PaletteRow.html +3 -0
  50. package/src/js/tcmd/components/PaletteRow.js +43 -0
  51. package/src/js/tcmd/components/PaletteRow.xml +4 -0
  52. package/src/js/tcmd/components/RowList.html +1 -0
  53. package/src/js/tcmd/components/RowList.xml +1 -0
  54. package/src/js/tcmd/components/Summary.html +3 -0
  55. package/src/js/tcmd/components/Summary.js +77 -0
  56. package/src/js/tcmd/components/Summary.xml +3 -0
  57. package/src/js/tcmd/components/bind.js +20 -0
  58. package/src/js/tcmd/components/format.js +155 -0
  59. package/src/js/tcmd/components/icons.js +185 -0
  60. package/src/js/tcmd/components/index.js +41 -0
  61. package/src/js/tcmd/components/rows.js +212 -0
  62. package/src/js/tcmd/core/bookmarks.js +175 -0
  63. package/src/js/tcmd/core/commands.js +930 -0
  64. package/src/js/tcmd/core/directory.js +194 -0
  65. package/src/js/tcmd/core/entries.js +88 -0
  66. package/src/js/tcmd/core/fs.js +266 -0
  67. package/src/js/tcmd/core/index.js +39 -0
  68. package/src/js/tcmd/core/keymap.js +60 -0
  69. package/src/js/tcmd/core/operations.js +453 -0
  70. package/src/js/tcmd/core/os.js +128 -0
  71. package/src/js/tcmd/core/pane.js +74 -0
  72. package/src/js/tcmd/core/panel.js +383 -0
  73. package/src/js/tcmd/core/path.js +57 -0
  74. package/src/js/tcmd/core/script.js +186 -0
  75. package/src/js/tcmd/core/selection.js +43 -0
  76. package/src/js/tcmd/core/session.js +405 -0
  77. package/src/js/tcmd/index.js +53 -0
  78. package/src/js/tcmd/tui/app.js +140 -0
  79. package/src/js/tcmd/tui/catalogue.js +300 -0
  80. package/src/js/tcmd/tui/frame.js +269 -0
  81. package/src/js/tcmd/tui/help.js +32 -0
  82. package/src/js/tcmd/tui/index.js +104 -0
  83. package/src/js/tcmd/tui/theme.js +177 -0
  84. package/src/js/tcmd/vendor/fzy.js +243 -0
  85. package/src/js/tcmd/web/app.js +154 -0
  86. package/src/js/tcmd/web/catalogue.js +159 -0
  87. package/src/js/tcmd/web/header.html +1 -0
  88. package/src/js/tcmd/web/header.js +38 -0
  89. package/src/js/tcmd/web/http.js +79 -0
  90. package/src/js/tcmd/web/index.js +22 -0
  91. package/src/js/tcmd/web/main.js +151 -0
  92. package/src/js/tcmd/web/opfs.js +282 -0
  93. package/src/js/tcmd/web/seed.js +90 -0
  94. package/src/js/tcmd/web/server.js +191 -0
  95. package/src/js/tcmd/web/theme.js +23 -0
@@ -0,0 +1,300 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-09-26
5
+
6
+ // Module: tcmd/tui/catalogue
7
+ // Binds the shared component logic in `tcmd/components` to the TUI (`.xml`)
8
+ // presentations and registers them with the select.tui catalogue. Tags in
9
+ // templates are matched case-insensitively by select.tui.
10
+ //
11
+ // List rows are rendered with the same `Component.map` primitive as the web:
12
+ // each row component (`.xml`) is bound here and referenced by its parent's
13
+ // `rows` environment. Components sharing a presentation bind the same source
14
+ // (`Breadcrumbs` uses `RowList.xml`).
15
+ //
16
+ // TUI-only components (`Help`) live in `tui/` and are registered here only.
17
+
18
+ import { extend, ui } from "select.tui"
19
+ import { unwrap } from "select/state/cells"
20
+ import bookmarkListXml from "../components/BookmarkList.xml" with { type: "text" }
21
+ import bookmarkRowXml from "../components/BookmarkRow.xml" with { type: "text" }
22
+ import commandBarXml from "../components/CommandBar.xml" with { type: "text" }
23
+ import commandArgRowXml from "../components/CommandArgRow.xml" with { type: "text" }
24
+ import commandItemXml from "../components/CommandItem.xml" with { type: "text" }
25
+ import commandPanelXml from "../components/CommandPanel.xml" with { type: "text" }
26
+ import commandPaletteXml from "../components/CommandPalette.xml" with { type: "text" }
27
+ import confirmXml from "../components/Confirm.xml" with { type: "text" }
28
+ import crumbXml from "../components/Crumb.xml" with { type: "text" }
29
+ import entryDetailXml from "../components/EntryDetail.xml" with { type: "text" }
30
+ import fileListXml from "../components/FileList.xml" with { type: "text" }
31
+ import filePreviewXml from "../components/FilePreview.xml" with { type: "text" }
32
+ import fileRowXml from "../components/FileRow.xml" with { type: "text" }
33
+ import filterBarXml from "../components/FilterBar.xml" with { type: "text" }
34
+ import helpXml from "../components/Help.xml" with { type: "text" }
35
+ import paletteRowXml from "../components/PaletteRow.xml" with { type: "text" }
36
+ import rowListXml from "../components/RowList.xml" with { type: "text" }
37
+ import summaryXml from "../components/Summary.xml" with { type: "text" }
38
+
39
+ import {
40
+ bind,
41
+ bookmarkListBehaviors,
42
+ bookmarkRowBehaviors,
43
+ bookmarkRows,
44
+ breadcrumbsBehaviors,
45
+ commandBarBehaviors,
46
+ commandArgRowBehaviors,
47
+ commandItemBehaviors,
48
+ commandItemRows,
49
+ commandBarMarksItem,
50
+ commandPanelBehaviors,
51
+ commandPaletteBehaviors,
52
+ confirmBehaviors,
53
+ crumbBehaviors,
54
+ crumbRows,
55
+ entryDetailBehaviors,
56
+ fileListBehaviors,
57
+ filePreviewBehaviors,
58
+ fileRowBehaviors,
59
+ fileRows,
60
+ fileWindow,
61
+ filterBarBehaviors,
62
+ iconsEnabled,
63
+ paletteRowBehaviors,
64
+ paletteRows,
65
+ panelArgRows,
66
+ panelOpRows,
67
+ summaryBehaviors,
68
+ } from "../components/index.js"
69
+ import { createBehaviors as helpBehaviors } from "./help.js"
70
+ import { INSPECTOR_INLINE_WIDTH } from "./frame.js"
71
+ import { tuiTheme } from "./theme.js"
72
+
73
+ let registered = false
74
+
75
+ // Function: tuiEnv
76
+ // The TUI renderer environment: OpenTUI colors plus TUI-only hooks.
77
+ function tuiEnv() {
78
+ return {
79
+ accentBg: tuiTheme.accentBg,
80
+ accentFg: tuiTheme.accentFg,
81
+ accentSecondaryBg: tuiTheme.accentSecondaryBg,
82
+ accentSecondaryFg: tuiTheme.accentSecondaryFg,
83
+ borderPrimaryActiveFg: tuiTheme.borderPrimaryActiveFg,
84
+ borderPrimaryFg: tuiTheme.borderPrimaryFg,
85
+ borderSecondaryActiveFg: tuiTheme.borderSecondaryActiveFg,
86
+ borderSecondaryFg: tuiTheme.borderSecondaryFg,
87
+ cursorBg: tuiTheme.cursorBg,
88
+ cursorInactiveBg: tuiTheme.cursorInactiveBg,
89
+ devFg: tuiTheme.devFg,
90
+ dirFg: tuiTheme.dirFg,
91
+ errorBg: tuiTheme.errorBg,
92
+ errorFg: tuiTheme.errorFg,
93
+ fileFg: tuiTheme.fileFg,
94
+ // Nerd Font icons in rows, on unless `TCMD_ICONS` opts out.
95
+ icons: iconsEnabled(process.env),
96
+ lineFg: tuiTheme.textSecondaryFg,
97
+ linkFg: tuiTheme.linkFg,
98
+ mutedBg: tuiTheme.mutedBg,
99
+ overlayBg: tuiTheme.overlayBg,
100
+ selectionBg: tuiTheme.selectionBg,
101
+ surfaceBg: tuiTheme.surfaceBg,
102
+ textFg: tuiTheme.textFg,
103
+ textHeadingFg: tuiTheme.textHeadingFg,
104
+ textSecondaryFg: tuiTheme.textSecondaryFg,
105
+ tipBg: tuiTheme.tipBg,
106
+ tipFg: tuiTheme.tipFg,
107
+ warningBg: tuiTheme.warningBg,
108
+ warningFg: tuiTheme.warningFg,
109
+ }
110
+ }
111
+
112
+ // Function: measure
113
+ // Display width of a string, counting wide CJK/emoji cells as two.
114
+ function measure(text) {
115
+ return globalThis.Bun?.stringWidth ? globalThis.Bun.stringWidth(text) : `${text}`.length
116
+ }
117
+
118
+ // Function: elide
119
+ // Truncate `text` to `width` columns with a trailing ellipsis.
120
+ function elide(text, width) {
121
+ if (width < 12 || measure(text) <= width) return text
122
+ return `${text.slice(0, Math.max(0, width - 1))}…`
123
+ }
124
+
125
+ // Function: paneWidth
126
+ // Content width of a pane from the instance box, falling back to a viewport
127
+ // estimate (panes share the width, minus the inspector when inline) because
128
+ // the box geometry is unresolved when a behavior first runs. The next render
129
+ // uses the real width.
130
+ function paneWidth(self) {
131
+ const own = Math.floor(self.ref.box?.width ?? 0)
132
+ if (own > 0) return own
133
+ const total = Math.floor(self.ctx?.width ?? 0)
134
+ if (total <= 0) return 0
135
+ const columns = total >= INSPECTOR_INLINE_WIDTH ? 3 : 2
136
+ return Math.max(12, Math.floor(total / columns) - 6)
137
+ }
138
+
139
+ // Edge fractions of the viewport kept as a scroll margin: the view nudges one
140
+ // line once the cursor enters the bottom or top quarter.
141
+ const SCROLL_LOW = 0.25
142
+ const SCROLL_HIGH = 0.75
143
+
144
+ // Keep the given row index inside the scrollbox viewport. A jump (PageUp/Down,
145
+ // Home/End, click) first clamps the view so the cursor stays visible; then, for
146
+ // line-by-line movement, the view scrolls one line at a time once the cursor is
147
+ // more than 75% down or less than 25% up, when more lines remain off-screen.
148
+ // `total` is the row count used to tell whether the view can still scroll.
149
+ function keepVisible(box, index, total) {
150
+ if (process.env?.TCMD_SCROLL_DEBUG && box && box.height) {
151
+ require("node:fs").appendFileSync(
152
+ "/tmp/opencode/tcmd-scroll.log",
153
+ `index=${index} total=${total} view=${Math.floor(box.height)} scrollTop=${box.scrollTop}\n`,
154
+ )
155
+ }
156
+ if (!box || !box.height) return box?.scrollTop ?? 0
157
+ const view = Math.max(1, Math.floor(box.height))
158
+ const content = total ?? box.scrollHeight ?? view
159
+ const maxTop = Math.max(0, content - view)
160
+ let top = Math.max(0, Math.min(box.scrollTop ?? 0, maxTop))
161
+ if (index < top) top = index
162
+ else if (index >= top + view) top = index - view + 1
163
+ const pos = index - top
164
+ if (pos > SCROLL_HIGH * view && top < maxTop) top += 1
165
+ else if (pos < SCROLL_LOW * view && top > 0) top -= 1
166
+ return top
167
+ }
168
+
169
+ // Keep the file-list cursor row inside the viewport. When the list is windowed
170
+ // (a `viewport` height is threaded down) the scrollbox holds only the window,
171
+ // so it scrolls to the cursor's index inside that window against the window
172
+ // length; otherwise it scrolls the full listing.
173
+ function fileListScrollTop(self, data) {
174
+ const viewport = unwrap(data.viewport)
175
+ if (viewport > 0) {
176
+ const { local, len } = fileWindow(data, viewport)
177
+ return keepVisible(self.ref.list, local, len)
178
+ }
179
+ return keepVisible(self.ref.list, unwrap(data.cursor) ?? 0, unwrap(data.entries)?.length ?? 0)
180
+ }// Keep the palette selection inside its viewport.
181
+ function paletteScrollTop(self, data) {
182
+ return keepVisible(self.ref.list, unwrap(data.selected) ?? 0, unwrap(data.items)?.length ?? 0)
183
+ }
184
+
185
+ // Keep the bookmark picker selection inside its viewport.
186
+ function bookmarkScrollTop(self, data) {
187
+ return keepVisible(self.ref.list, unwrap(data.selected) ?? 0, unwrap(data.items)?.length ?? 0)
188
+ }
189
+
190
+ // Keep the command panel's argument cursor inside its viewport.
191
+ function panelArgScrollTop(self, data) {
192
+ return keepVisible(self.ref.args, unwrap(data.argCursor) ?? 0, unwrap(data.rows)?.length ?? 0)
193
+ }
194
+
195
+ // Function: commandBarRows
196
+ // Appends the active pane's marked count as a trailing dimmed entry, then
197
+ // maps the items to rows. The entries wrap onto as many rows as the width
198
+ // demands; nothing is trimmed.
199
+ function commandBarRows(commandItem) {
200
+ const base = commandItemRows(commandItem)
201
+ return (self, data) => {
202
+ const items = unwrap(data.items) ?? []
203
+ const marks = commandBarMarksItem(data)
204
+ return base(self, { ...data, items: marks ? [...items, marks] : items })
205
+ }
206
+ }
207
+
208
+ // Function: createComponents
209
+ // Binds every shared component to its `.xml` presentation.
210
+ function createComponents() {
211
+ const env = tuiEnv()
212
+ const commandItem = bind(ui, "CommandItem", commandItemXml, commandItemBehaviors(env))
213
+ const commandArgRow = bind(ui, "CommandArgRow", commandArgRowXml, commandArgRowBehaviors(env))
214
+ const crumb = bind(ui, "Crumb", crumbXml, crumbBehaviors(env))
215
+ const fileRow = bind(ui, "FileRow", fileRowXml, fileRowBehaviors(env))
216
+ const filterBar = bind(ui, "FilterBar", filterBarXml, filterBarBehaviors(env))
217
+ const paletteRow = bind(ui, "PaletteRow", paletteRowXml, paletteRowBehaviors(env))
218
+ const bookmarkRow = bind(ui, "BookmarkRow", bookmarkRowXml, bookmarkRowBehaviors(env))
219
+ return {
220
+ BookmarkRow: bookmarkRow,
221
+ CommandArgRow: commandArgRow,
222
+ CommandItem: commandItem,
223
+ Crumb: crumb,
224
+ FileRow: fileRow,
225
+ PaletteRow: paletteRow,
226
+ Breadcrumbs: bind(ui, "Breadcrumbs", rowListXml, breadcrumbsBehaviors({
227
+ ...env,
228
+ rows: crumbRows(crumb),
229
+ })),
230
+ BookmarkList: bind(ui, "BookmarkList", bookmarkListXml, bookmarkListBehaviors({
231
+ ...env,
232
+ rows: bookmarkRows(bookmarkRow),
233
+ scrollTop: bookmarkScrollTop,
234
+ })),
235
+ CommandBar: bind(ui, "CommandBar", commandBarXml, commandBarBehaviors({
236
+ ...env,
237
+ rows: commandBarRows(commandItem),
238
+ })),
239
+ CommandPanel: bind(ui, "CommandPanel", commandPanelXml, commandPanelBehaviors({
240
+ ...env,
241
+ opRows: panelOpRows(commandArgRow),
242
+ argRows: panelArgRows(commandArgRow),
243
+ argScrollTop: panelArgScrollTop,
244
+ })),
245
+ CommandPalette: bind(ui, "CommandPalette", commandPaletteXml, commandPaletteBehaviors({
246
+ ...env,
247
+ rows: paletteRows(paletteRow),
248
+ scrollTop: paletteScrollTop,
249
+ })),
250
+ Confirm: bind(ui, "Confirm", confirmXml, confirmBehaviors(env)),
251
+ EntryDetail: bind(ui, "EntryDetail", entryDetailXml, entryDetailBehaviors({
252
+ ...env,
253
+ width: paneWidth,
254
+ measure,
255
+ elide,
256
+ })),
257
+ FileList: bind(ui, "FileList", fileListXml, fileListBehaviors({
258
+ ...env,
259
+ rows: fileRows(fileRow),
260
+ scrollTop: fileListScrollTop,
261
+ })),
262
+ FilePreview: bind(ui, "FilePreview", filePreviewXml, filePreviewBehaviors(env)),
263
+ FilterBar: filterBar,
264
+ Help: bind(ui, "Help", helpXml, helpBehaviors({ theme: tuiTheme })),
265
+ Summary: bind(ui, "Summary", summaryXml, summaryBehaviors({
266
+ ...env,
267
+ width: paneWidth,
268
+ measure,
269
+ elide,
270
+ })),
271
+ }
272
+ }
273
+
274
+ const components = createComponents()
275
+
276
+ // Function: registerComponents
277
+ // Idempotent registration of the parent components as select.tui host tags.
278
+ function registerComponents() {
279
+ if (registered) return components
280
+ registered = true
281
+ extend({
282
+ bookmarklist: components.BookmarkList,
283
+ breadcrumbs: components.Breadcrumbs,
284
+ commandbar: components.CommandBar,
285
+ commandpanel: components.CommandPanel,
286
+ commandpalette: components.CommandPalette,
287
+ confirm: components.Confirm,
288
+ entrydetail: components.EntryDetail,
289
+ filelist: components.FileList,
290
+ filepreview: components.FilePreview,
291
+ filterbar: components.FilterBar,
292
+ help: components.Help,
293
+ summary: components.Summary,
294
+ })
295
+ return components
296
+ }
297
+
298
+ export { components, fileListScrollTop, keepVisible, registerComponents }
299
+
300
+ // EOF
@@ -0,0 +1,269 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-10-02
5
+
6
+ // Module: tcmd/tui/frame
7
+ // TUI-only presentation behaviors for the workspace chrome: the responsive
8
+ // breakpoints driven by the `uiSize` cell, the pane borders, header rules,
9
+ // the command-bar geometry and the help overlay state. Everything here is
10
+ // injected into the App binding; none of it lives in the shared
11
+ // `components/` logic.
12
+
13
+ import { unwrap } from "select/state/cells"
14
+ import { DEFAULT_KEYMAP } from "../core/keymap.js"
15
+ import { getCommands } from "../core/commands.js"
16
+ import { commandBarMarksItem, commandBarRowCount, commandBarStatus } from "../components/CommandBar.js"
17
+
18
+ // Below this the two-pane workspace is replaced by a resize hint.
19
+ const MIN_WIDTH = 64
20
+ const MIN_HEIGHT = 16
21
+ // At or above this the inspector can sit beside the panes; below it becomes a
22
+ // fullscreen overlay.
23
+ const INSPECTOR_INLINE_WIDTH = 100
24
+
25
+ // Function: sizeOf
26
+ // Reads the viewport size from the App data, falling back to the renderer's
27
+ // own dimensions so the gates work even when no `uiSize` cell is injected
28
+ // (tests, web). Returns `null` when neither is known.
29
+ function sizeOf(data, self) {
30
+ const size = unwrap(data?.uiSize)
31
+ if (size && (size.width || size.height)) return size
32
+ const width = Math.floor(self?.ctx?.width ?? 0)
33
+ const height = Math.floor(self?.ctx?.height ?? 0)
34
+ if (!width && !height) return null
35
+ return { width, height }
36
+ }
37
+
38
+ // Function: isNarrow
39
+ // True when the viewport is known and too narrow for the inline inspector.
40
+ function isNarrow(data, self) {
41
+ const size = sizeOf(data, self)
42
+ return Boolean(size && (size.width ?? 0) > 0 && size.width < INSPECTOR_INLINE_WIDTH)
43
+ }
44
+
45
+ // Function: commandBarRowsFor
46
+ // The entries the bar currently shows: the context hints or the `bar`
47
+ // commands, plus the active pane's marked count as a trailing entry.
48
+ function commandBarRowsFor(data) {
49
+ const items = unwrap(data?.commandBarItems) ?? []
50
+ const marks = commandBarMarksItem(data)
51
+ return marks ? [...items, marks] : items
52
+ }
53
+
54
+ // Function: commandBarLines
55
+ // Rows the wrapped command bar occupies at the current viewport (the bar
56
+ // is inset one column from the viewport edge).
57
+ function commandBarLines(data, self) {
58
+ const width = Math.floor(sizeOf(data, self)?.width ?? 0)
59
+ if (width <= 0) return 1
60
+ return commandBarRowCount(commandBarRowsFor(data), width - 1)
61
+ }
62
+
63
+ // Function: paneListHeight
64
+ // Rows available to a pane's listing: the viewport height minus the identity
65
+ // bar (1), the pane's own border (2), the pane header + rule (2), the entry
66
+ // detail (2), the summary (1) and the wrapped command bar — eight chrome rows
67
+ // plus the bar. Fed to the windowed `filelist` so a large directory mounts
68
+ // only the visible slice. `0` means "unknown — render everything to measure".
69
+ function paneListHeight(data, self) {
70
+ const height = Math.floor(sizeOf(data, self)?.height ?? 0)
71
+ if (height <= 0) return 0
72
+ // A notice or error swaps the keys for a one-line status.
73
+ const bar = commandBarStatus(data) ? 1 : commandBarLines(data, self)
74
+ return Math.max(1, height - 8 - bar)
75
+ }
76
+
77
+ // Function: mixHex
78
+ // Linear interpolation between two `#rrggbb` colours at `t` in [0, 1]. A
79
+ // missing colour (the mono theme) yields `undefined` so the whole run falls
80
+ // back to the terminal default.
81
+ function mixHex(from, to, t) {
82
+ if (!from || !to) return undefined
83
+ const channel = (index) => {
84
+ const a = Number.parseInt(from.slice(index, index + 2), 16)
85
+ const b = Number.parseInt(to.slice(index, index + 2), 16)
86
+ return Math.round(a + (b - a) * t)
87
+ .toString(16)
88
+ .padStart(2, "0")
89
+ }
90
+ return `#${channel(1)}${channel(3)}${channel(5)}`
91
+ }
92
+
93
+ // Function: shineOf
94
+ // One `{ letter, fg }` per letter of a brand word, interpolating `from` → `to`
95
+ // across it so the word reads as a gradient; a missing colour (mono theme)
96
+ // leaves every `fg` undefined and the run falls back to the terminal default.
97
+ function shineOf(letters, from, to) {
98
+ return `${letters}`.split("").map((letter, index, all) => ({
99
+ letter,
100
+ fg: mixHex(from, to, index / (all.length - 1)),
101
+ }))
102
+ }
103
+
104
+ // Function: createFrameBehaviors
105
+ // Behaviors for the App root and its chrome.
106
+ function createFrameBehaviors(env) {
107
+ const tiny = shineOf("Tiny", env.theme.brandTinyFrom, env.theme.brandTinyTo)
108
+ const cmd = shineOf("Cmd", env.theme.brandCmdFrom, env.theme.brandCmdTo)
109
+ return {
110
+ // Identity bar: "Tiny" shimmers along the brand blues, "Cmd" along a
111
+ // brighter orange than the mark accent, and the host trail follows in
112
+ // the heading colour. Everything else on screen belongs to the user's
113
+ // files; this one row is the app's own.
114
+ brandT: () => tiny[0].letter,
115
+ brandTFg: () => tiny[0].fg,
116
+ brandI: () => tiny[1].letter,
117
+ brandIFg: () => tiny[1].fg,
118
+ brandN: () => tiny[2].letter,
119
+ brandNFg: () => tiny[2].fg,
120
+ brandY: () => tiny[3].letter,
121
+ brandYFg: () => tiny[3].fg,
122
+ brandC: () => cmd[0].letter,
123
+ brandCFg: () => cmd[0].fg,
124
+ brandM: () => cmd[1].letter,
125
+ brandMFg: () => cmd[1].fg,
126
+ brandD: () => cmd[2].letter,
127
+ brandDFg: () => cmd[2].fg,
128
+ topBar: (_self, data) => {
129
+ const host = `${unwrap(data.appHost) ?? ""}`.trim()
130
+ return host ? ` - ${host} ` : " "
131
+ },
132
+ topBarFg: () => env.theme.textHeadingFg,
133
+
134
+ // Responsive gates. `uiSize` is absent in tests and the web, so the
135
+ // workspace renders normally there.
136
+ tooSmall: (self, data) => {
137
+ const size = sizeOf(data, self)
138
+ if (!size) return false
139
+ return (size.width ?? MIN_WIDTH) < MIN_WIDTH || (size.height ?? MIN_HEIGHT) < MIN_HEIGHT
140
+ },
141
+ minSizeTitle: () => "Terminal too small",
142
+ minSizeHint: (self, data) => {
143
+ const size = sizeOf(data, self) ?? { width: 0, height: 0 }
144
+ return `need ${MIN_WIDTH}x${MIN_HEIGHT}, have ${size.width ?? 0}x${size.height ?? 0}`
145
+ },
146
+ // The min-size gate text uses the heading/secondary text tokens.
147
+ titleFg: () => env.theme.textHeadingFg,
148
+ dimFg: () => env.theme.textSecondaryFg,
149
+
150
+ // Each pane is a complete box. Both draw all four edges (the middle
151
+ // shows two adjacent rules) so each pane's border colour can track its
152
+ // own focus independently — a shared edge would be owned by one pane
153
+ // and never highlight when the other is active.
154
+ leftBorder: () => ["top", "left", "bottom", "right"],
155
+ rightBorder: () => ["top", "left", "bottom", "right"],
156
+ // The header rule separates the pane header row from the listing body.
157
+ headRule: () => ["top"],
158
+
159
+ // The breadcrumb yields the pane header row to the filter while a
160
+ // filter is being edited or applied.
161
+ leftFilterOn: (_self, data) => Boolean(unwrap(data.leftFilter)),
162
+ rightFilterOn: (_self, data) => Boolean(unwrap(data.rightFilter)),
163
+
164
+ // The inline inspector shows when the preview is on and the viewport is
165
+ // wide enough; otherwise `v` opens the fullscreen viewer.
166
+ inspectorNarrow: (self, data) => isNarrow(data, self),
167
+ showInspectorInline: (self, data) => Boolean(unwrap(data.showPreview)) && !isNarrow(data, self),
168
+ showViewer: (self, data) => Boolean(unwrap(data.viewerOpen)) && isNarrow(data, self),
169
+
170
+ // Help overlay visibility; the content lives in `tui/Help.js` so the
171
+ // overlay has a single source of truth.
172
+ helpOpen: (_self, data) => Boolean(unwrap(data.helpOpen)),
173
+
174
+ // The windowed listing height for both panes (they share the row).
175
+ listViewport: (self, data) => paneListHeight(data, self),
176
+
177
+ // The command bar's height: one line for a status, otherwise as many
178
+ // rows as the wrapped bracket shorthands need.
179
+ barHeight: (self, data) => (commandBarStatus(data) ? 1 : commandBarLines(data, self)),
180
+ }
181
+ }
182
+
183
+ // Function: keyGlyph
184
+ // Human-readable glyph for a keymap chord ("↑", "⇧Tab", "^W", "PgUp").
185
+ function keyGlyph(chord) {
186
+ const name = chord.key
187
+ const named = {
188
+ up: "↑",
189
+ down: "↓",
190
+ left: "←",
191
+ right: "→",
192
+ return: "↵",
193
+ enter: "↵",
194
+ space: "Space",
195
+ tab: "Tab",
196
+ pageup: "PgUp",
197
+ pagedown: "PgDn",
198
+ escape: "Esc",
199
+ backspace: "Bksp",
200
+ home: "Home",
201
+ end: "End",
202
+ }
203
+ let base = named[name] ?? (name.length === 1 ? name.toUpperCase() : `${name}`)
204
+ if (chord.ctrl) base = `^${base}`
205
+ if (chord.meta) base = `⌘${base}`
206
+ if (chord.shift && name !== "tab") base = `⇧${base}`
207
+ return base
208
+ }
209
+
210
+ // Constant: GROUP_ORDER / GROUP_TITLES
211
+ // Help sections, in display order.
212
+ const GROUP_ORDER = ["nav", "pane", "list", "bookmark", "view", "file", "panel", "palette", "filter", "app"]
213
+ const GROUP_TITLES = {
214
+ app: "Session",
215
+ bookmark: "Bookmarks",
216
+ file: "Files",
217
+ filter: "Filter",
218
+ list: "Selection",
219
+ nav: "Navigation",
220
+ palette: "Commands",
221
+ panel: "Command panel",
222
+ pane: "Panes",
223
+ view: "View",
224
+ }
225
+
226
+ // Function: helpLines
227
+ // Builds the categorized key reference from the keymap and the command
228
+ // registry, so it can never drift from the actual bindings.
229
+ function helpLines() {
230
+ const byId = new Map(getCommands().map((command) => [command.id, command]))
231
+ const groups = new Map()
232
+ const seen = new Set()
233
+ for (const binding of DEFAULT_KEYMAP) {
234
+ const command = byId.get(binding.command)
235
+ if (!command) continue
236
+ const group = binding.command.split(".")[0]
237
+ if (!groups.has(group)) groups.set(group, new Map())
238
+ const rows = groups.get(group)
239
+ if (!rows.has(binding.command)) rows.set(binding.command, { keys: [], title: command.title })
240
+ const row = rows.get(binding.command)
241
+ const glyph = keyGlyph(binding)
242
+ if (!row.keys.includes(glyph) && !seen.has(`${binding.command}:${glyph}`)) {
243
+ row.keys.push(glyph)
244
+ seen.add(`${binding.command}:${glyph}`)
245
+ }
246
+ }
247
+ const lines = []
248
+ for (const group of GROUP_ORDER) {
249
+ const rows = groups.get(group)
250
+ if (!rows) continue
251
+ lines.push(GROUP_TITLES[group] ?? group)
252
+ for (const row of rows.values()) {
253
+ lines.push(` ${row.keys.join("/").padEnd(9)} ${row.title}`)
254
+ }
255
+ lines.push("")
256
+ }
257
+ return lines
258
+ }
259
+
260
+ export {
261
+ commandBarLines,
262
+ createFrameBehaviors,
263
+ helpLines,
264
+ INSPECTOR_INLINE_WIDTH,
265
+ isNarrow,
266
+ keyGlyph,
267
+ sizeOf,
268
+ }
269
+ // EOF
@@ -0,0 +1,32 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-10-02
5
+
6
+ // Module: tcmd/tui/help
7
+ // TUI-only behavior for the `?` key reference overlay. The content is built
8
+ // from the live keymap and command registry (see `tui/frame.js`), so it
9
+ // cannot drift from the actual bindings.
10
+
11
+ import { unwrap } from "select/state/cells"
12
+ import { helpLines } from "./frame.js"
13
+
14
+ // Function: createBehaviors
15
+ // Builds the Help behavior map for the given TUI environment.
16
+ function createBehaviors(env) {
17
+ return {
18
+ bg: () => env.theme.surfaceBg,
19
+ borderFg: () => env.theme.borderPrimaryActiveFg,
20
+ dimFg: () => env.theme.textSecondaryFg,
21
+ hint: () => " Esc / ? close ",
22
+ open: (_self, data) => Boolean(unwrap(data.open)),
23
+ text: () => helpLines().join("\n"),
24
+ textFg: () => env.theme.textFg,
25
+ title: () => " Keys ",
26
+ titleFg: () => env.theme.textHeadingFg,
27
+ }
28
+ }
29
+
30
+ export { createBehaviors }
31
+
32
+ // EOF