@ssebastien/tcmd 0.1.2 → 0.1.4

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 (79) hide show
  1. package/README.md +121 -38
  2. package/bin/tcmd +11 -3
  3. package/bin/tcmd.js +15 -0
  4. package/package.json +4 -3
  5. package/public/index.html +14 -0
  6. package/public/main.js +17501 -0
  7. package/public/style.css +901 -0
  8. package/scripts/serve-web.js +61 -0
  9. package/src/js/tcmd/components/App.html +28 -12
  10. package/src/js/tcmd/components/App.js +135 -85
  11. package/src/js/tcmd/components/App.xml +64 -15
  12. package/src/js/tcmd/components/BookmarkList.html +3 -2
  13. package/src/js/tcmd/components/BookmarkList.js +1 -1
  14. package/src/js/tcmd/components/Breadcrumbs.js +1 -1
  15. package/src/js/tcmd/components/CommandArgRow.html +17 -0
  16. package/src/js/tcmd/components/CommandArgRow.js +86 -26
  17. package/src/js/tcmd/components/CommandArgRow.xml +31 -4
  18. package/src/js/tcmd/components/CommandBar.html +1 -1
  19. package/src/js/tcmd/components/CommandBar.js +3 -3
  20. package/src/js/tcmd/components/CommandItem.html +1 -1
  21. package/src/js/tcmd/components/CommandItem.js +1 -0
  22. package/src/js/tcmd/components/CommandPanel.html +29 -0
  23. package/src/js/tcmd/components/CommandPanel.js +54 -52
  24. package/src/js/tcmd/components/CommandPanel.xml +38 -24
  25. package/src/js/tcmd/components/Confirm.html +1 -1
  26. package/src/js/tcmd/components/EntryDetail.html +7 -7
  27. package/src/js/tcmd/components/EntryDetail.js +1 -1
  28. package/src/js/tcmd/components/FileList.html +5 -1
  29. package/src/js/tcmd/components/FileList.js +2 -1
  30. package/src/js/tcmd/components/FilePreview.html +14 -3
  31. package/src/js/tcmd/components/FilePreview.js +73 -10
  32. package/src/js/tcmd/components/FilePreview.xml +19 -6
  33. package/src/js/tcmd/components/FileRow.html +1 -1
  34. package/src/js/tcmd/components/FileRow.js +10 -3
  35. package/src/js/tcmd/components/FilterBar.html +1 -1
  36. package/src/js/tcmd/components/FilterBar.js +14 -4
  37. package/src/js/tcmd/components/FilterBar.xml +1 -1
  38. package/src/js/tcmd/{tui/frame.js → components/Frame.js} +120 -91
  39. package/src/js/tcmd/components/Help.html +7 -0
  40. package/src/js/tcmd/components/Help.js +109 -0
  41. package/src/js/tcmd/components/Summary.html +1 -1
  42. package/src/js/tcmd/components/Summary.js +1 -6
  43. package/src/js/tcmd/components/icons.js +39 -5
  44. package/src/js/tcmd/components/index.js +3 -4
  45. package/src/js/tcmd/components/rows.js +37 -33
  46. package/src/js/tcmd/core/commands.js +422 -180
  47. package/src/js/tcmd/core/directory.js +145 -95
  48. package/src/js/tcmd/core/fs.js +29 -2
  49. package/src/js/tcmd/core/history.js +58 -0
  50. package/src/js/tcmd/core/index.js +3 -9
  51. package/src/js/tcmd/core/keymap.js +3 -3
  52. package/src/js/tcmd/core/operations.js +108 -15
  53. package/src/js/tcmd/core/pane.js +31 -8
  54. package/src/js/tcmd/core/panel.js +172 -82
  55. package/src/js/tcmd/core/script.js +7 -95
  56. package/src/js/tcmd/core/selection.js +9 -1
  57. package/src/js/tcmd/core/session.js +280 -152
  58. package/src/js/tcmd/index.js +0 -1
  59. package/src/js/tcmd/tui/app.js +26 -5
  60. package/src/js/tcmd/tui/catalogue.js +6 -19
  61. package/src/js/tcmd/tui/index.js +1 -16
  62. package/src/js/tcmd/tui/theme.js +5 -0
  63. package/src/js/tcmd/web/app.js +158 -32
  64. package/src/js/tcmd/web/bookmarks.js +36 -0
  65. package/src/js/tcmd/web/catalogue.js +45 -39
  66. package/src/js/tcmd/web/effects.js +143 -0
  67. package/src/js/tcmd/web/header.html +1 -1
  68. package/src/js/tcmd/web/header.js +15 -11
  69. package/src/js/tcmd/web/main.js +6 -66
  70. package/src/js/tcmd/web/seed.js +3 -3
  71. package/src/js/tcmd/web/server.js +6 -4
  72. package/src/js/tcmd/web/theme.js +1 -0
  73. package/src/js/tcmd/components/CommandPalette.html +0 -7
  74. package/src/js/tcmd/components/CommandPalette.js +0 -58
  75. package/src/js/tcmd/components/CommandPalette.xml +0 -16
  76. package/src/js/tcmd/components/PaletteRow.html +0 -3
  77. package/src/js/tcmd/components/PaletteRow.js +0 -43
  78. package/src/js/tcmd/components/PaletteRow.xml +0 -4
  79. package/src/js/tcmd/tui/help.js +0 -32
@@ -8,10 +8,20 @@
8
8
  // their leading content; other kinds show a metadata card. Renderer-agnostic
9
9
  // logic; `FilePreview.xml` / `FilePreview.html` are the presentations.
10
10
  //
11
- // Environment: `textFg`, `textSecondaryFg`, `textHeadingFg`.
11
+ // A directory is the exception: it renders the *directory column* — its own
12
+ // breadcrumbs over a cursorable listing — so a previewed directory looks and
13
+ // behaves like a pane in every view mode (click a row to focus it, Enter to
14
+ // descend). The listing is the inspector pane's (`session.panes.preview`),
15
+ // threaded down as the `dir*` inputs; `paneId` is what rows, crumbs and the
16
+ // column body publish back to the App.
17
+ //
18
+ // Environment: `textFg`, `textSecondaryFg`, `textHeadingFg`, and the optional
19
+ // `actions` footer text (default: the TUI's `[Open O] [Edit E]` hints; the web
20
+ // substitutes a note that those keys do not exist in a browser).
12
21
 
13
22
  import { unwrap } from "select/state/cells"
14
23
  import { formatSize } from "./format.js"
24
+ import { previewSource } from "../core/session.js"
15
25
 
16
26
  const KIND_LABELS = {
17
27
  directory: "directory",
@@ -57,7 +67,9 @@ function previewTitle(preview) {
57
67
 
58
68
  // Function: entryCard
59
69
  // Metadata card for the entry the session did not preview: a directory, or a
60
- // file whose kind has no inline preview.
70
+ // file whose kind has no inline preview. Directories normally take the
71
+ // directory column instead (see `isDirectoryPane`); the branch stays for a
72
+ // `stat` payload and for hosts that render no listing.
61
73
  function entryCard(entry) {
62
74
  if (!entry) return ""
63
75
  const lines = []
@@ -69,42 +81,93 @@ function entryCard(entry) {
69
81
  return lines.join("\n")
70
82
  }
71
83
 
84
+ // Function: isDirectoryPane
85
+ // True when the inspector previews a plain directory: no `stat` card was
86
+ // pinned on it, so the directory column takes the body.
87
+ function isDirectoryPane(preview, entry) {
88
+ return Boolean(entry?.isDir) && !preview
89
+ }
90
+
72
91
  // Function: headerTitle
73
- // The inspector title: the preview's own title, else the cursor entry.
92
+ // The inspector title: the preview's own title, else the cursor entry — whose
93
+ // symlink target and resolved extension `previewSource` supplies, so the
94
+ // header names the traversal before the async read lands.
74
95
  function headerTitle(preview, entry) {
75
96
  if (preview) return previewTitle(preview)
76
97
  if (entry) {
77
- const badge = entry.isDir ? "DIR" : (entry.ext || "file").toUpperCase()
78
- return `${entry.name}${entry.isDir ? "/" : ""} <${badge}>`
98
+ const source = previewSource(entry)
99
+ const badge = entry.isDir ? "DIR" : (source.ext || "file").toUpperCase()
100
+ const name = entry.isDir ? `${entry.name}/` : entry.name
101
+ const target = entry.isSymlink && entry.target ? ` -> ${entry.target}` : ""
102
+ return `${name}${target} <${badge}>`
79
103
  }
80
104
  return "inspector"
81
105
  }
82
106
 
83
107
  // Function: createBehaviors
84
108
  // Builds the FilePreview behavior map for the given renderer environment.
109
+ // `env.actions` overrides the footer line so a presentation can carry its own
110
+ // copy (the browser has no Open/Edit); the default advertises the TUI keys.
85
111
  function createBehaviors(env) {
112
+ const actions = env.actions ?? (() => " [Open O] [Edit E]")
113
+ const directory = (_self, data) =>
114
+ isDirectoryPane(unwrap(data.preview), unwrap(data.entry))
115
+ const body = (_self, data) => !directory(_self, data)
86
116
  return {
87
- actions: () => " [Open O] [Edit E]",
117
+ actions,
88
118
  actionsFg: () => env.textSecondaryFg,
89
119
  cardText: (_self, data) => cardText(unwrap(data.preview)),
120
+ // The directory column: breadcrumbs over the inspector pane's listing,
121
+ // shaped like a pane header (a rule separates it from the body).
122
+ dirCrumbsView: (_self, data) => ({ segments: data.dirSegments, paneId: data.paneId }),
123
+ dirListView: (_self, data) => ({
124
+ entries: data.dirEntries,
125
+ cursor: data.dirCursor,
126
+ marked: data.dirMarked,
127
+ active: data.dirActive,
128
+ loading: data.dirLoading,
129
+ paneId: data.paneId,
130
+ }),
90
131
  dimFg: () => env.textSecondaryFg,
91
132
  emptyText: () => " No file selected\n Move the cursor or press v",
92
133
  entryText: (_self, data) => entryCard(unwrap(data.entry)),
134
+ // The rule between the contents and the shortcut footer.
135
+ footRule: () => ["top"],
136
+ footRuleFg: () => env.textSecondaryFg,
137
+ headRule: () => ["top"],
138
+ headRuleFg: () => env.textSecondaryFg,
93
139
  hasCard: (_self, data) => hasCard(unwrap(data.preview)),
94
- // The metadata card fills in whenever there is a cursor entry but no
95
- // preview payload (a directory, or a non-text file).
96
- hasEntryCard: (_self, data) => !unwrap(data.preview) && Boolean(unwrap(data.entry)),
97
140
  hasText: (_self, data) => hasText(unwrap(data.preview)),
98
141
  headerTitle: (_self, data) => headerTitle(unwrap(data.preview), unwrap(data.entry)),
99
142
  isEmpty: (_self, data) => !unwrap(data.preview) && !unwrap(data.entry),
143
+ isDirPane: directory,
100
144
  previewText: (_self, data) => unwrap(data.preview)?.text ?? "",
101
145
  previewTitle: (_self, data) => previewTitle(unwrap(data.preview)),
102
146
  previewTitleColor: () => env.textHeadingFg,
103
147
  show: (_self, data) => Boolean(unwrap(data.show)),
148
+ // One body at a time: the directory column, else the card/text/empty
149
+ // states, with the title row yielding to the breadcrumb header.
150
+ // `showEntryCard` fills in whenever there is a cursor entry but no
151
+ // preview payload — except a directory, which gets the listing.
152
+ showBody: body,
153
+ showCard: (_self, data) => body(_self, data) && hasCard(unwrap(data.preview)),
154
+ showEmpty: (_self, data) => body(_self, data) && !unwrap(data.preview) && !unwrap(data.entry),
155
+ showEntryCard: (_self, data) =>
156
+ body(_self, data) && !unwrap(data.preview) && Boolean(unwrap(data.entry)),
157
+ showText: (_self, data) => body(_self, data) && hasText(unwrap(data.preview)),
104
158
  textFg: () => env.textFg,
105
159
  }
106
160
  }
107
161
 
108
- export { cardText, createBehaviors, entryCard, hasCard, hasText, headerTitle, previewTitle }
162
+ export {
163
+ cardText,
164
+ createBehaviors,
165
+ entryCard,
166
+ hasCard,
167
+ hasText,
168
+ headerTitle,
169
+ isDirectoryPane,
170
+ previewTitle,
171
+ }
109
172
 
110
173
  // EOF
@@ -1,10 +1,23 @@
1
+ <!-- The inspector: a card/text body for a file, and a directory column
2
+ (breadcrumbs + cursorable listing) for a directory, so a previewed
3
+ directory reads as a pane. The body and the column share the border and
4
+ the footer, and the whole column is clickable to take focus. -->
1
5
  <box when="show" out:title="headerTitle" out:titleColor="previewTitleColor" border borderStyle="rounded"
2
- flexDirection="column" flexGrow="1" overflow="hidden">
3
- <scrollbox flexGrow="1">
4
- <text when="hasText" out="previewText" out:fg="textFg"></text>
5
- <text when="hasCard" out="cardText" out:fg="dimFg"></text>
6
- <text when="hasEntryCard" out="entryText" out:fg="dimFg"></text>
7
- <text when="isEmpty" out="emptyText" out:fg="dimFg"></text>
6
+ flexDirection="column" flexGrow="1" overflow="hidden" on:click="paneId!pane:activate">
7
+ <box when="isDirPane" flexDirection="row" height="1" paddingLeft="1" overflow="hidden">
8
+ <box flexGrow="1" flexShrink="1" minWidth="0" overflow="hidden">
9
+ <breadcrumbs in:segments="dirSegments" in:paneId="paneId" height="1"></breadcrumbs>
10
+ </box>
11
+ </box>
12
+ <box when="isDirPane" height="1" out:border="headRule" out:borderColor="headRuleFg"></box>
13
+ <filelist when="isDirPane" in:entries="dirEntries" in:cursor="dirCursor" in:marked="dirMarked"
14
+ in:active="dirActive" in:paneId="paneId" in:loading="dirLoading"></filelist>
15
+ <scrollbox when="showBody" flexGrow="1">
16
+ <text when="showText" out="previewText" out:fg="textFg"></text>
17
+ <text when="showCard" out="cardText" out:fg="dimFg"></text>
18
+ <text when="showEntryCard" out="entryText" out:fg="dimFg"></text>
19
+ <text when="showEmpty" out="emptyText" out:fg="dimFg"></text>
8
20
  </scrollbox>
21
+ <box height="1" out:border="footRule" out:borderColor="footRuleFg"></box>
9
22
  <box height="1"><text out="actions" out:fg="actionsFg"></text></box>
10
23
  </box>
@@ -1,4 +1,4 @@
1
- <li class="file-row" out:class="rowClass" on:click="paneId:paneId,index:index!list:select">
1
+ <li class="file-row" out:class="rowClass" out:data-marked="rowMarked" out:data-active="rowActive" out:data-kind="rowKind" on:click="paneId:paneId,index:index!list:select">
2
2
  <span class="marker" out="rowMarker"></span>
3
3
  <span class="name" out:class="rowNameClass" out="rowName"></span>
4
4
  <span class="size" out="rowSize"></span>
@@ -8,7 +8,8 @@
8
8
  // markedRow, active, paneId }`, the same bag shape in both engines.
9
9
  // `currentRow`/`markedRow` are plain per-row booleans snapshotted by the
10
10
  // `fileRows` builder (see `rows.js`); `active` is the pane-focus cell, read
11
- // with `unwrap` at the point of use.
11
+ // with `unwrap` at the point of use. A symlink row carries its target in the
12
+ // name (`notes -> ../real/notes`, see `rowName`).
12
13
  //
13
14
  // Environment: `cursorBg`, `cursorInactiveBg`, `currentClass`,
14
15
  // `accentSecondaryFg`, `accentFg`, `linkFg`, `dirFg`, `devFg`, `fileFg`,
@@ -20,10 +21,13 @@ import { iconFor } from "./icons.js"
20
21
 
21
22
  // Function: rowName
22
23
  // The entry name with the directory slash (the icon is its own slot, so it
23
- // can be dimmer than the name — see `rowIcon`).
24
+ // can be dimmer than the name — see `rowIcon`), and, for a symlink, the
25
+ // `-> target` tail `ls -l` prints. The name cell truncates, so a narrow pane
26
+ // drops the target before the name itself.
24
27
  function rowName(entry) {
25
28
  if (!entry) return ""
26
- return entry.isDir ? `${entry.name}/` : entry.name
29
+ const name = entry.isDir ? `${entry.name}/` : entry.name
30
+ return entry.target ? `${name} -> ${entry.target}` : name
27
31
  }
28
32
 
29
33
  // Function: rowIcon
@@ -115,6 +119,9 @@ function createBehaviors(env) {
115
119
  return undefined
116
120
  },
117
121
  rowClass: (_self, data) => (rowCurrent(data) ? env.currentClass : null),
122
+ rowMarked: (_self, data) => `${rowIsMarked(data)}`,
123
+ rowActive: (_self, data) => `${unwrap(data.active) !== false}`,
124
+ rowKind: (_self, data) => data.item?.isSymlink ? "link" : data.item?.isDir ? "dir" : data.item?.isDevice ? "dev" : "file",
118
125
  // The icon is its own slot so it can sit dimmer than the name; it
119
126
  // collapses when the environment keeps rows icon-free.
120
127
  hasIcon: () => Boolean(env.icons),
@@ -1,6 +1,6 @@
1
1
  <div class="filter-bar" when="visible">
2
2
  <span class="filter-prompt" out="prompt"></span>
3
- <input when="editing" ref="query" inout="query" class="filter-input" />
3
+ <input when="editing" ref="query" inout="query" on:input="onFilter" class="filter-input" />
4
4
  <span when="applied" class="filter-label" out="label"></span>
5
5
  <span class="filter-alert" out="alert"></span>
6
6
  </div>
@@ -7,10 +7,12 @@
7
7
  // FilterBar: the fuzzy filter indicator, folded into the pane header so it
8
8
  // never shifts the listing. While the pane owns the filter input
9
9
  // (`filtering === paneId`) it shows a compact focused input bound to the pane's
10
- // `query`; once accepted it degrades to the same `/ query` indication, so the
11
- // pane is navigable again and the filter can still be cleared with Esc. When
12
- // the query matches no entry it shows a `no match` alert. The key hints live in
13
- // the command bar, not here.
10
+ // `query`; each change also routes through the pane's `setFilter` (passed as
11
+ // `setFilter`), so the cursor re-anchors with no directory watcher. Once
12
+ // accepted it degrades to the same `/ query` indication, so the pane is
13
+ // navigable again and the filter can still be cleared with Esc. When the query
14
+ // matches no entry it shows a `no match` alert. The key hints live in the
15
+ // command bar, not here.
14
16
  //
15
17
  // Environment: `accentFg` (the applied label), `textSecondaryFg` (the `/`
16
18
  // prompt), `warningFg` (the no-match alert).
@@ -33,6 +35,14 @@ function createBehaviors(env) {
33
35
  applied: (self, data) => !editing(self, data) && activeQuery(self, data).length > 0,
34
36
  editing,
35
37
  focused: editing,
38
+ // The input both displays and captures through `inout`; `on:input`
39
+ // additionally routes the change through the pane's `setFilter` so the
40
+ // cursor re-anchors on the first match. The TUI delivers the input
41
+ // value directly, the DOM an event carrying it.
42
+ onFilter: (_self, data, event) => {
43
+ const value = typeof event === "string" ? event : `${event?.target?.value ?? ""}`
44
+ data.setFilter?.(value)
45
+ },
36
46
  // The bare query (shared with the web presentation).
37
47
  label: (self, data) => queryText(self, data),
38
48
  labelFg: () => env.accentFg,
@@ -1,6 +1,6 @@
1
1
  <box when="visible" flexDirection="row" height="1" flexShrink="0">
2
2
  <text out="prompt" out:fg="promptFg"></text>
3
- <input when="editing" ref="query" inout="query" out:focused="focused" width="14" height="1"></input>
3
+ <input when="editing" ref="query" inout="query" on:input="onFilter" out:focused="focused" width="14" height="1"></input>
4
4
  <text when="applied" out="labelText" out:fg="labelFg" wrapMode="none" truncate="true"></text>
5
5
  <text out="alert" out:fg="alertFg" wrapMode="none"></text>
6
6
  </box>
@@ -3,17 +3,52 @@
3
3
  // License: BSD
4
4
  // Created: 2026-10-02
5
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.
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`.
12
13
 
13
14
  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"
15
+ import { commandBarMarksItem, commandBarRowCount, 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
+ }
17
52
 
18
53
  // Below this the two-pane workspace is replaced by a resize hint.
19
54
  const MIN_WIDTH = 64
@@ -42,6 +77,21 @@ function isNarrow(data, self) {
42
77
  return Boolean(size && (size.width ?? 0) > 0 && size.width < INSPECTOR_INLINE_WIDTH)
43
78
  }
44
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
+
45
95
  // Function: commandBarRowsFor
46
96
  // The entries the bar currently shows: the context hints or the `bar`
47
97
  // commands, plus the active pane's marked count as a trailing entry.
@@ -106,6 +156,31 @@ function shineOf(letters, from, to) {
106
156
  function createFrameBehaviors(env) {
107
157
  const tiny = shineOf("Tiny", env.theme.brandTinyFrom, env.theme.brandTinyTo)
108
158
  const cmd = shineOf("Cmd", env.theme.brandCmdFrom, env.theme.brandCmdTo)
159
+ // The mode word, letter by letter: `modeL<i>` is the letter, `modeFg<i>`
160
+ // its gradient colour — the same per-letter trick the `TinyCmd` brand
161
+ // uses, since a terminal has no gradient to draw. The run is cached per
162
+ // word (there are only two), and both behaviors read the live `viewMode`,
163
+ // so flipping `t` redraws it in the other brand's colours.
164
+ const modeRuns = new Map()
165
+ const modeRun = (data) => {
166
+ const viewMode = unwrap(data?.viewMode)
167
+ const word = modeWord(viewMode)
168
+ let run = modeRuns.get(word)
169
+ if (!run) {
170
+ run = shineOf(word, ...modeGradient(env, viewMode))
171
+ modeRuns.set(word, run)
172
+ }
173
+ return run
174
+ }
175
+ const modeSlots = {}
176
+ for (let i = 0; i < MODE_SLOTS; i++) {
177
+ // The `when` gate matters: an empty text node still claims a column in
178
+ // the row, so the slots past the end of a shorter word are dropped
179
+ // from the layout instead of padding it.
180
+ modeSlots[`modeHas${i}`] = (_self, data) => modeRun(data)[i] !== undefined
181
+ modeSlots[`modeL${i}`] = (_self, data) => modeRun(data)[i]?.letter ?? ""
182
+ modeSlots[`modeFg${i}`] = (_self, data) => modeRun(data)[i]?.fg
183
+ }
109
184
  return {
110
185
  // Identity bar: "Tiny" shimmers along the brand blues, "Cmd" along a
111
186
  // brighter orange than the mark accent, and the host trail follows in
@@ -130,6 +205,12 @@ function createFrameBehaviors(env) {
130
205
  return host ? ` - ${host} ` : " "
131
206
  },
132
207
  topBarFg: () => env.theme.textHeadingFg,
208
+ // Right-aligned trail: the version, following the gradiented mode word
209
+ // (`modeL*`) slot by slot. Dimmer than the brand, so the row still
210
+ // reads left to right.
211
+ topBarStatus: () => versionTrail(),
212
+ topBarStatusFg: () => env.theme.textSecondaryFg,
213
+ ...modeSlots,
133
214
 
134
215
  // Responsive gates. `uiSize` is absent in tests and the web, so the
135
216
  // workspace renders normally there.
@@ -160,14 +241,32 @@ function createFrameBehaviors(env) {
160
241
  // filter is being edited or applied.
161
242
  leftFilterOn: (_self, data) => Boolean(unwrap(data.leftFilter)),
162
243
  rightFilterOn: (_self, data) => Boolean(unwrap(data.rightFilter)),
244
+ parentFilterOn: (_self, data) => Boolean(unwrap(data.parentFilter)),
245
+
246
+ // Which columns the workspace draws. Commander tiles source and
247
+ // destination; explorer drops destination for the parent of the
248
+ // current directory, which leads the row so the current pane stays in
249
+ // the middle. The inspector column follows `showInspectorInline` and
250
+ // completes the three columns in either mode. Maximizing the inspector
251
+ // (`⇧V`) hides every column so the preview can take the row.
252
+ showParentPane: (_self, data) => isExplorer(data) && !unwrap(data?.previewFull),
253
+ showLeftPane: (_self, data) =>
254
+ (!isExplorer(data) || currentColumnId(data) === "left") && !unwrap(data?.previewFull),
255
+ showRightPane: (_self, data) =>
256
+ (!isExplorer(data) || currentColumnId(data) === "right") && !unwrap(data?.previewFull),
163
257
 
164
258
  // The inline inspector shows when the preview is on and the viewport is
165
- // wide enough; otherwise `v` opens the fullscreen viewer.
259
+ // wide enough; otherwise `v` opens the fullscreen viewer. Maximizing
260
+ // (`⇧V`) replaces the column with `showPreviewFull` below.
166
261
  inspectorNarrow: (self, data) => isNarrow(data, self),
167
- showInspectorInline: (self, data) => Boolean(unwrap(data.showPreview)) && !isNarrow(data, self),
262
+ showInspectorInline: (self, data) =>
263
+ Boolean(unwrap(data.showPreview)) && !isNarrow(data, self) && !unwrap(data?.previewFull),
168
264
  showViewer: (self, data) => Boolean(unwrap(data.viewerOpen)) && isNarrow(data, self),
265
+ // The maximized inspector: the preview fills the pane row in either
266
+ // view mode, pushed by `view.maximize` (`⇧V`).
267
+ showPreviewFull: (_self, data) => Boolean(unwrap(data?.previewFull)),
169
268
 
170
- // Help overlay visibility; the content lives in `tui/Help.js` so the
269
+ // Help overlay visibility; the content lives in `Help.js` so the
171
270
  // overlay has a single source of truth.
172
271
  helpOpen: (_self, data) => Boolean(unwrap(data.helpOpen)),
173
272
 
@@ -180,90 +279,20 @@ function createFrameBehaviors(env) {
180
279
  }
181
280
  }
182
281
 
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
282
  export {
261
283
  commandBarLines,
262
284
  createFrameBehaviors,
263
- helpLines,
285
+ currentColumnId,
264
286
  INSPECTOR_INLINE_WIDTH,
287
+ isExplorer,
265
288
  isNarrow,
266
- keyGlyph,
289
+ MIN_HEIGHT,
290
+ MIN_WIDTH,
291
+ MODE_SLOTS,
292
+ modeGradient,
293
+ modeWord,
267
294
  sizeOf,
295
+ versionTrail,
296
+ VERSION,
268
297
  }
269
298
  // EOF
@@ -0,0 +1,7 @@
1
+ <section class="help" when="open" role="dialog" aria-modal="true" aria-label="Key reference" tabindex="-1">
2
+ <div class="help-dialog">
3
+ <header out="title"></header>
4
+ <pre out="text"></pre>
5
+ <footer out="hint"></footer>
6
+ </div>
7
+ </section>
@@ -0,0 +1,109 @@
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 text
8
+ // itself (`helpLines`). The content is built from the live keymap and command
9
+ // registry, so it cannot drift from the actual bindings. Both presentations
10
+ // (`Help.xml` / `Help.html`) render the same lines.
11
+
12
+ import { unwrap } from "select/state/cells"
13
+ import { DEFAULT_KEYMAP } from "../core/keymap.js"
14
+ import { getCommands } from "../core/commands.js"
15
+
16
+ // Function: keyGlyph
17
+ // Human-readable glyph for a keymap chord ("↑", "⇧Tab", "^W", "PgUp").
18
+ function keyGlyph(chord) {
19
+ const name = chord.key
20
+ const named = {
21
+ up: "↑",
22
+ down: "↓",
23
+ left: "←",
24
+ right: "→",
25
+ return: "↵",
26
+ enter: "↵",
27
+ space: "Space",
28
+ tab: "Tab",
29
+ pageup: "PgUp",
30
+ pagedown: "PgDn",
31
+ escape: "Esc",
32
+ backspace: "Bksp",
33
+ home: "Home",
34
+ end: "End",
35
+ }
36
+ let base = named[name] ?? (name.length === 1 ? name.toUpperCase() : `${name}`)
37
+ if (chord.ctrl) base = `^${base}`
38
+ if (chord.meta) base = `⌘${base}`
39
+ if (chord.shift && name !== "tab") base = `⇧${base}`
40
+ return base
41
+ }
42
+
43
+ // Constant: GROUP_ORDER / GROUP_TITLES
44
+ // Help sections, in display order.
45
+ const GROUP_ORDER = ["nav", "pane", "list", "bookmark", "view", "file", "panel", "filter", "app"]
46
+ const GROUP_TITLES = {
47
+ app: "Session",
48
+ bookmark: "Bookmarks",
49
+ file: "Files",
50
+ filter: "Filter",
51
+ list: "Selection",
52
+ nav: "Navigation",
53
+ panel: "Commands",
54
+ pane: "Panes",
55
+ view: "View",
56
+ }
57
+
58
+ // Function: helpLines
59
+ // The categorized key reference, one line per heading or binding.
60
+ function helpLines() {
61
+ const byId = new Map(getCommands().map((command) => [command.id, command]))
62
+ const groups = new Map()
63
+ const seen = new Set()
64
+ for (const binding of DEFAULT_KEYMAP) {
65
+ const command = byId.get(binding.command)
66
+ if (!command) continue
67
+ const group = binding.command.split(".")[0]
68
+ if (!groups.has(group)) groups.set(group, new Map())
69
+ const rows = groups.get(group)
70
+ if (!rows.has(binding.command)) rows.set(binding.command, { keys: [], title: command.title })
71
+ const row = rows.get(binding.command)
72
+ const glyph = keyGlyph(binding)
73
+ if (!row.keys.includes(glyph) && !seen.has(`${binding.command}:${glyph}`)) {
74
+ row.keys.push(glyph)
75
+ seen.add(`${binding.command}:${glyph}`)
76
+ }
77
+ }
78
+ const lines = []
79
+ for (const group of GROUP_ORDER) {
80
+ const rows = groups.get(group)
81
+ if (!rows) continue
82
+ lines.push(GROUP_TITLES[group] ?? group)
83
+ for (const row of rows.values()) {
84
+ lines.push(` ${row.keys.join("/").padEnd(9)} ${row.title}`)
85
+ }
86
+ lines.push("")
87
+ }
88
+ return lines
89
+ }
90
+
91
+ // Function: createBehaviors
92
+ // Builds the Help behavior map for the given renderer environment.
93
+ function createBehaviors(env) {
94
+ return {
95
+ bg: () => env.theme.surfaceBg,
96
+ borderFg: () => env.theme.borderPrimaryActiveFg,
97
+ dimFg: () => env.theme.textSecondaryFg,
98
+ hint: () => " Esc / ? close ",
99
+ open: (_self, data) => Boolean(unwrap(data.open)),
100
+ text: () => helpLines().join("\n"),
101
+ textFg: () => env.theme.textFg,
102
+ title: () => " Keys ",
103
+ titleFg: () => env.theme.textHeadingFg,
104
+ }
105
+ }
106
+
107
+ export { createBehaviors, helpLines, keyGlyph }
108
+
109
+ // EOF
@@ -1,3 +1,3 @@
1
1
  <div class="summary" ref="box">
2
- <span out="line" class="summary-line"></span>
2
+ <span out="priorityLine" class="summary-line"></span>
3
3
  </div>