@ssebastien/tcmd 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +234 -2
  2. package/bin/tcmd +7 -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,110 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-09-26
5
+
6
+ // Module: tcmd/components/FilePreview
7
+ // FilePreview: inline preview of the entry under the cursor. Text files show
8
+ // their leading content; other kinds show a metadata card. Renderer-agnostic
9
+ // logic; `FilePreview.xml` / `FilePreview.html` are the presentations.
10
+ //
11
+ // Environment: `textFg`, `textSecondaryFg`, `textHeadingFg`.
12
+
13
+ import { unwrap } from "select/state/cells"
14
+ import { formatSize } from "./format.js"
15
+
16
+ const KIND_LABELS = {
17
+ directory: "directory",
18
+ image: "image",
19
+ media: "audio/video",
20
+ text: "text",
21
+ }
22
+
23
+ // Function: cardText
24
+ // Metadata card for non-text previews; `preview.details` (set by `stat`) adds
25
+ // one indented line each.
26
+ function cardText(preview) {
27
+ if (!preview) return ""
28
+ const lines = []
29
+ lines.push("")
30
+ lines.push(` ${KIND_LABELS[preview.kind] ?? "file"}: ${preview.title}`)
31
+ lines.push(` size: ${formatSize(preview.size)}`)
32
+ for (const detail of preview.details ?? []) lines.push(` ${detail}`)
33
+ lines.push("")
34
+ return lines.join("\n")
35
+ }
36
+
37
+ // Function: hasCard
38
+ // True when the preview is a metadata card (no text body).
39
+ function hasCard(preview) {
40
+ return Boolean(preview && preview.text === null)
41
+ }
42
+
43
+ // Function: hasText
44
+ // True when the preview carries a text body.
45
+ function hasText(preview) {
46
+ return Boolean(preview?.text)
47
+ }
48
+
49
+ // Function: previewTitle
50
+ // "Filename <EXT>" style title; falls back to the kind label when the file has
51
+ // no extension.
52
+ function previewTitle(preview) {
53
+ if (!preview) return "preview"
54
+ const badge = (preview.ext || KIND_LABELS[preview.kind] || "file").toUpperCase()
55
+ return `${preview.title} <${badge}>`
56
+ }
57
+
58
+ // Function: entryCard
59
+ // Metadata card for the entry the session did not preview: a directory, or a
60
+ // file whose kind has no inline preview.
61
+ function entryCard(entry) {
62
+ if (!entry) return ""
63
+ const lines = []
64
+ lines.push("")
65
+ lines.push(` ${entry.isDir ? "directory" : "file"}: ${entry.name}${entry.isDir ? "/" : ""}`)
66
+ if (entry.isDir) lines.push(" open it to list its contents")
67
+ else lines.push(` size: ${formatSize(entry.size)}`)
68
+ lines.push("")
69
+ return lines.join("\n")
70
+ }
71
+
72
+ // Function: headerTitle
73
+ // The inspector title: the preview's own title, else the cursor entry.
74
+ function headerTitle(preview, entry) {
75
+ if (preview) return previewTitle(preview)
76
+ if (entry) {
77
+ const badge = entry.isDir ? "DIR" : (entry.ext || "file").toUpperCase()
78
+ return `${entry.name}${entry.isDir ? "/" : ""} <${badge}>`
79
+ }
80
+ return "inspector"
81
+ }
82
+
83
+ // Function: createBehaviors
84
+ // Builds the FilePreview behavior map for the given renderer environment.
85
+ function createBehaviors(env) {
86
+ return {
87
+ actions: () => " [Open O] [Edit E]",
88
+ actionsFg: () => env.textSecondaryFg,
89
+ cardText: (_self, data) => cardText(unwrap(data.preview)),
90
+ dimFg: () => env.textSecondaryFg,
91
+ emptyText: () => " No file selected\n Move the cursor or press v",
92
+ entryText: (_self, data) => entryCard(unwrap(data.entry)),
93
+ 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
+ hasText: (_self, data) => hasText(unwrap(data.preview)),
98
+ headerTitle: (_self, data) => headerTitle(unwrap(data.preview), unwrap(data.entry)),
99
+ isEmpty: (_self, data) => !unwrap(data.preview) && !unwrap(data.entry),
100
+ previewText: (_self, data) => unwrap(data.preview)?.text ?? "",
101
+ previewTitle: (_self, data) => previewTitle(unwrap(data.preview)),
102
+ previewTitleColor: () => env.textHeadingFg,
103
+ show: (_self, data) => Boolean(unwrap(data.show)),
104
+ textFg: () => env.textFg,
105
+ }
106
+ }
107
+
108
+ export { cardText, createBehaviors, entryCard, hasCard, hasText, headerTitle, previewTitle }
109
+
110
+ // EOF
@@ -0,0 +1,10 @@
1
+ <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>
8
+ </scrollbox>
9
+ <box height="1"><text out="actions" out:fg="actionsFg"></text></box>
10
+ </box>
@@ -0,0 +1,5 @@
1
+ <li class="file-row" out:class="rowClass" on:click="paneId:paneId,index:index!list:select">
2
+ <span class="marker" out="rowMarker"></span>
3
+ <span class="name" out:class="rowNameClass" out="rowName"></span>
4
+ <span class="size" out="rowSize"></span>
5
+ </li>
@@ -0,0 +1,135 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-09-26
5
+
6
+ // Module: tcmd/components/FileRow
7
+ // File listing row behaviors. Read `{ item: entry, index, currentRow,
8
+ // markedRow, active, paneId }`, the same bag shape in both engines.
9
+ // `currentRow`/`markedRow` are plain per-row booleans snapshotted by the
10
+ // `fileRows` builder (see `rows.js`); `active` is the pane-focus cell, read
11
+ // with `unwrap` at the point of use.
12
+ //
13
+ // Environment: `cursorBg`, `cursorInactiveBg`, `currentClass`,
14
+ // `accentSecondaryFg`, `accentFg`, `linkFg`, `dirFg`, `devFg`, `fileFg`,
15
+ // `textSecondaryFg`, `dirClass`, `icons` (show Nerd Font icons).
16
+
17
+ import { unwrap } from "select/state/cells"
18
+ import { formatSize } from "./format.js"
19
+ import { iconFor } from "./icons.js"
20
+
21
+ // Function: rowName
22
+ // 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
+ function rowName(entry) {
25
+ if (!entry) return ""
26
+ return entry.isDir ? `${entry.name}/` : entry.name
27
+ }
28
+
29
+ // Function: rowIcon
30
+ // The entry's Nerd Font icon, empty when the renderer environment keeps the
31
+ // rows icon-free.
32
+ function rowIcon(entry, icons) {
33
+ return icons ? iconFor(entry) : ""
34
+ }
35
+
36
+ // Function: rowSize
37
+ function rowSize(entry) {
38
+ if (!entry) return ""
39
+ return entry.isDir ? "<dir>" : formatSize(entry.size)
40
+ }
41
+
42
+ // Function: isCurrent
43
+ function isCurrent(index, cursor) {
44
+ return index === cursor
45
+ }
46
+
47
+ // Function: isMarked
48
+ function isMarked(entry, marked) {
49
+ return Boolean(entry && marked?.has?.(entry.path))
50
+ }
51
+
52
+ // Function: rowMarker
53
+ // The 2-char row marker: "*" for a marked row, ">" for the cursor row — marks
54
+ // win, so a marked-and-current row still reads as marked.
55
+ function rowMarker(data) {
56
+ return rowIsMarked(data) ? "* " : rowCurrent(data) ? "> " : " "
57
+ }
58
+
59
+ // Function: rowCurrent
60
+ // Whether the row is the pane cursor. The plain `currentRow` flag (snapshotted
61
+ // by the row builder) wins; otherwise fall back to comparing the row index
62
+ // against the shared cursor cell (an unknown index never reads as current).
63
+ // Because the flag is plain, a cursor move rewrites only the two changed row
64
+ // bags instead of re-rendering every row.
65
+ function rowCurrent(data) {
66
+ if (data.currentRow !== undefined) return data.currentRow === true
67
+ if (data.index === undefined) return false
68
+ return isCurrent(data.index, unwrap(data.cursor))
69
+ }
70
+
71
+ // Function: rowIsMarked
72
+ // Whether the row's entry is marked. `bag.markedRow` (a plain per-row boolean)
73
+ // wins; otherwise compare against the shared marked set.
74
+ function rowIsMarked(data) {
75
+ if (data.markedRow !== undefined) return data.markedRow === true
76
+ return isMarked(data.item, unwrap(data.marked))
77
+ }
78
+
79
+ // Function: nameFg
80
+ // The listing color for an entry: a symlink reads as a link, a directory as a
81
+ // directory, a device node as a device, otherwise the plain file color.
82
+ // Symlinks win over directories, matching `dircolors` (a link to a directory
83
+ // renders as a link).
84
+ function nameFg(item, env) {
85
+ if (item?.isSymlink) return env.linkFg
86
+ if (item?.isDir) return env.dirFg
87
+ if (item?.isDevice) return env.devFg
88
+ return env.fileFg
89
+ }
90
+
91
+ // Function: rowNameFg
92
+ // The name color for a row: a marked row reads orange (the selection signal),
93
+ // the cursor row the bright heading color, otherwise the entry's kind color.
94
+ // Marks win over the cursor so a marked-and-current row still reads as
95
+ // marked, the same precedence the row marker follows.
96
+ function rowNameFg(data, env) {
97
+ if (rowIsMarked(data)) return env.accentSecondaryFg
98
+ if (rowCurrent(data)) return env.textHeadingFg
99
+ return nameFg(data.item, env)
100
+ }
101
+
102
+ // Function: createBehaviors
103
+ // Builds the FileRow behavior map for the given renderer environment.
104
+ function createBehaviors(env) {
105
+ return {
106
+ rowBg: (_self, data) => {
107
+ if (rowCurrent(data)) {
108
+ // The active pane's cursor is the strong accent; the inactive
109
+ // pane keeps a muted cursor so focus is visible from the row
110
+ // alone. A missing `active` (web, single-pane hosts) reads as
111
+ // active.
112
+ return unwrap(data.active) === false ? env.cursorInactiveBg : env.cursorBg
113
+ }
114
+ if (rowIsMarked(data)) return env.accentSecondaryBg
115
+ return undefined
116
+ },
117
+ rowClass: (_self, data) => (rowCurrent(data) ? env.currentClass : null),
118
+ // The icon is its own slot so it can sit dimmer than the name; it
119
+ // collapses when the environment keeps rows icon-free.
120
+ hasIcon: () => Boolean(env.icons),
121
+ rowIcon: (_self, data) => rowIcon(data.item, Boolean(env.icons)),
122
+ rowIconFg: () => env.textSecondaryFg,
123
+ rowMarker: (_self, data) => rowMarker(data),
124
+ rowMarkerFg: (_self, data) => (rowIsMarked(data) ? env.accentSecondaryFg : env.accentFg),
125
+ rowName: (_self, data) => rowName(data.item),
126
+ rowNameClass: (_self, data) => (data.item?.isDir ? env.dirClass : null),
127
+ rowNameFg: (_self, data) => rowNameFg(data, env),
128
+ rowSize: (_self, data) => rowSize(data.item),
129
+ rowSizeFg: () => env.textSecondaryFg,
130
+ }
131
+ }
132
+
133
+ export { createBehaviors, isMarked, nameFg, rowIcon, rowName, rowNameFg, rowSize }
134
+
135
+ // EOF
@@ -0,0 +1,10 @@
1
+ <box flexDirection="row" height="1" columnGap="1" out:backgroundColor="rowBg" on:click="paneId:paneId,index:index!list:select">
2
+ <text out="rowMarker" width="2" out:fg="rowMarkerFg"></text>
3
+ <box flexGrow="1" minWidth="0" overflow="hidden" flexDirection="row" columnGap="1">
4
+ <text out="rowIcon" out:fg="rowIconFg" wrapMode="none" when="hasIcon"></text>
5
+ <text out="rowName" out:fg="rowNameFg" wrapMode="none" truncate="true"></text>
6
+ </box>
7
+ <box width="9" justifyContent="flex-end">
8
+ <text out="rowSize" out:fg="rowSizeFg" wrapMode="none"></text>
9
+ </box>
10
+ </box>
@@ -0,0 +1,6 @@
1
+ <div class="filter-bar" when="visible">
2
+ <span class="filter-prompt" out="prompt"></span>
3
+ <input when="editing" ref="query" inout="query" class="filter-input" />
4
+ <span when="applied" class="filter-label" out="label"></span>
5
+ <span class="filter-alert" out="alert"></span>
6
+ </div>
@@ -0,0 +1,50 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-09-30
5
+
6
+ // Module: tcmd/components/FilterBar
7
+ // FilterBar: the fuzzy filter indicator, folded into the pane header so it
8
+ // never shifts the listing. While the pane owns the filter input
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.
14
+ //
15
+ // Environment: `accentFg` (the applied label), `textSecondaryFg` (the `/`
16
+ // prompt), `warningFg` (the no-match alert).
17
+
18
+ import { unwrap } from "select/state/cells"
19
+
20
+ // Function: createBehaviors
21
+ // Builds the FilterBar behavior map for the given renderer environment.
22
+ function createBehaviors(env) {
23
+ // The pane that owns the input is the one typing into it.
24
+ const editing = (_self, data) => unwrap(data.filtering) === unwrap(data.paneId)
25
+ const queryText = (_self, data) => `${unwrap(data.query) ?? ""}`
26
+ const activeQuery = (self, data) => queryText(self, data).trim()
27
+ const matchCount = (_self, data) => (unwrap(data.entries) ?? []).length
28
+ return {
29
+ // The row exists while editing or once a non-empty filter is applied.
30
+ alert: (self, data) =>
31
+ activeQuery(self, data).length > 0 && matchCount(self, data) === 0 ? "no match" : "",
32
+ alertFg: () => env.warningFg,
33
+ applied: (self, data) => !editing(self, data) && activeQuery(self, data).length > 0,
34
+ editing,
35
+ focused: editing,
36
+ // The bare query (shared with the web presentation).
37
+ label: (self, data) => queryText(self, data),
38
+ labelFg: () => env.accentFg,
39
+ // TUI: the static label renders the full `/ query`, so the editing
40
+ // input and the accepted state read the same.
41
+ labelText: (self, data) => `/ ${queryText(self, data)}`,
42
+ prompt: () => "/ ",
43
+ promptFg: () => env.textSecondaryFg,
44
+ visible: (self, data) => editing(self, data) || activeQuery(self, data).length > 0,
45
+ }
46
+ }
47
+
48
+ export { createBehaviors }
49
+
50
+ // EOF
@@ -0,0 +1,6 @@
1
+ <box when="visible" flexDirection="row" height="1" flexShrink="0">
2
+ <text out="prompt" out:fg="promptFg"></text>
3
+ <input when="editing" ref="query" inout="query" out:focused="focused" width="14" height="1"></input>
4
+ <text when="applied" out="labelText" out:fg="labelFg" wrapMode="none" truncate="true"></text>
5
+ <text out="alert" out:fg="alertFg" wrapMode="none"></text>
6
+ </box>
@@ -0,0 +1,11 @@
1
+ <box when="open" width="100%" height="100%" alignItems="center" justifyContent="center">
2
+ <box width="72%" height="82%" border borderStyle="rounded"
3
+ out:title="title" out:titleColor="titleFg" out:borderColor="borderFg"
4
+ out:backgroundColor="bg" flexDirection="column"
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>
10
+ </box>
11
+ </box>
@@ -0,0 +1,3 @@
1
+ <li class="palette-item" out:class="rowClass" on:click="item.id!palette:run">
2
+ <span out="paletteLabel"></span>
3
+ </li>
@@ -0,0 +1,43 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-09-26
5
+
6
+ // Module: tcmd/components/PaletteRow
7
+ // Command-palette row behaviors. Read `{ item: command, index, selected }`, the
8
+ // same bag shape in both engines. Selection is snapshotted by the row builder
9
+ // into a plain `selectedRow` boolean and read through the shared `isSelected`
10
+ // predicate, with the `selected` cell as fallback for hand-built bags. Script
11
+ // rows carry a `detail` (operation signature, fileset count) appended to the
12
+ // label.
13
+ //
14
+ // Environment: `accentFg`, `textFg`, `selectionBg` (TUI row background),
15
+ // `selectedClass` (web).
16
+
17
+ import { isSelected } from "./rows.js"
18
+
19
+ // Function: paletteLabel
20
+ // "Switch [Tab]" for commands; script rows append their detail.
21
+ function paletteLabel(command) {
22
+ if (!command) return ""
23
+ const keys = command.keys ? ` [${command.keys}]` : ""
24
+ const detail = command.detail ? ` ${command.detail}` : ""
25
+ return `${command.title}${keys}${detail}`
26
+ }
27
+
28
+ // Function: createBehaviors
29
+ // Builds the PaletteRow behavior map for the given renderer environment.
30
+ function createBehaviors(env) {
31
+ return {
32
+ paletteLabel: (_self, data) => paletteLabel(data.item),
33
+ rowBg: (_self, data) => (isSelected(data) ? env.selectionBg : undefined),
34
+ rowClass: (_self, data) => (isSelected(data) ? env.selectedClass : null),
35
+ rowFg: (_self, data) => (isSelected(data) ? env.accentFg : env.textFg),
36
+ // TUI: a marker so the selection is not color-only.
37
+ rowMarker: (_self, data) => (isSelected(data) ? ">" : " "),
38
+ }
39
+ }
40
+
41
+ export { createBehaviors, paletteLabel }
42
+
43
+ // EOF
@@ -0,0 +1,4 @@
1
+ <box flexDirection="row" height="1" out:backgroundColor="rowBg" on:click="item.id!palette:run">
2
+ <text out="rowMarker" width="2" out:fg="rowFg"></text>
3
+ <text out="paletteLabel" flexGrow="1" out:fg="rowFg" wrapMode="none" truncate="true"></text>
4
+ </box>
@@ -0,0 +1 @@
1
+ <nav out:class="listClass" out="rows"></nav>
@@ -0,0 +1 @@
1
+ <box out="rows" flexDirection="row" height="1"></box>
@@ -0,0 +1,3 @@
1
+ <div class="summary" ref="box">
2
+ <span out="line" class="summary-line"></span>
3
+ </div>
@@ -0,0 +1,77 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-09-26
5
+
6
+ // Module: tcmd/components/Summary
7
+ // PathSummary: one compact line with entry counts and disk availability.
8
+ // Renderer-agnostic logic; `Summary.xml` / `Summary.html` provide the TUI and
9
+ // web presentations.
10
+ //
11
+ // Environment (injected by the renderer binding):
12
+ // - `lineFg`: color/token for the summary text (bound by the TUI presentation)
13
+ // - `width(self)`: pane width in cells (optional; defaults to 0)
14
+ // - `elide(text, width)`: truncate to the pane width (optional; defaults to
15
+ // the full text)
16
+ // - `measure(text)`: display width of `text` (optional; defaults to
17
+ // `text.length`)
18
+
19
+ import { unwrap } from "select/state/cells"
20
+ import { formatCounts, formatDisk, formatSize } from "./format.js"
21
+
22
+ // Constant: INDENT
23
+ // Leading inset so the summary aligns with the detail line above it.
24
+ const INDENT = " "
25
+
26
+ // Function: summaryText
27
+ // The full, untruncated summary line for `entries` and `disk`.
28
+ function summaryText(entries, disk) {
29
+ let text = formatCounts(entries)
30
+ const diskText = formatDisk(disk)
31
+ if (diskText) text += ` · ${diskText}`
32
+ return `${INDENT}${text}`
33
+ }
34
+
35
+ // Function: summaryCounts
36
+ // Just the counts part, the first thing a summary drops information for.
37
+ function summaryCounts(entries) {
38
+ return formatCounts(entries)
39
+ }
40
+
41
+ // Function: summaryShortDisk
42
+ // Compact disk availability, e.g. "12G free".
43
+ function summaryShortDisk(disk) {
44
+ return disk ? `${formatSize(disk.free)} free` : ""
45
+ }
46
+
47
+ // Function: createBehaviors
48
+ // Builds the Summary behavior map for the given renderer environment.
49
+ function createBehaviors(env) {
50
+ const measure = env.measure ?? ((text) => `${text}`.length)
51
+ return {
52
+ line: (self, data) => {
53
+ const text = summaryText(unwrap(data.entries), unwrap(data.disk))
54
+ const width = env.width ? env.width(self) : 0
55
+ return env.elide ? env.elide(text, width) : text
56
+ },
57
+ lineFg: () => env.lineFg,
58
+ // TUI: degrade by information value instead of chopping mid-token:
59
+ // full line -> short disk -> counts only -> elided counts.
60
+ priorityLine: (self, data) => {
61
+ const entries = unwrap(data.entries)
62
+ const disk = unwrap(data.disk)
63
+ const width = env.width ? env.width(self) : 0
64
+ const full = summaryText(entries, disk)
65
+ if (!width || measure(full) <= width) return full
66
+ const counts = `${INDENT}${summaryCounts(entries)}`
67
+ const short = disk ? `${counts} · ${summaryShortDisk(disk)}` : counts
68
+ if (measure(short) <= width) return short
69
+ if (measure(counts) <= width) return counts
70
+ return env.elide ? env.elide(counts, width) : counts
71
+ },
72
+ }
73
+ }
74
+
75
+ export { createBehaviors, summaryCounts, summaryShortDisk, summaryText }
76
+
77
+ // EOF
@@ -0,0 +1,3 @@
1
+ <box ref="box" flexDirection="row" height="1" overflow="hidden">
2
+ <text out="priorityLine" out:fg="lineFg" wrapMode="none" truncate="true"></text>
3
+ </box>
@@ -0,0 +1,20 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-09-27
5
+
6
+ // Module: tcmd/components/bind
7
+ // Binds shared component logic to a renderer template. Both select.js and
8
+ // select.tui implement the same `ui.component(name).does(behaviors).using(source)`
9
+ // contract, so the catalogues share this one binding idiom and only differ in
10
+ // the `ui` runtime they pass in.
11
+
12
+ // Function: bind
13
+ // Returns the component `name` bound to `source` with `behaviors`.
14
+ function bind(ui, name, source, behaviors) {
15
+ return ui.component(name).does(behaviors).using(source)
16
+ }
17
+
18
+ export { bind }
19
+
20
+ // EOF