@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.
- package/README.md +234 -2
- package/bin/tcmd +17 -0
- package/package.json +37 -5
- package/src/js/tcmd/components/App.html +28 -0
- package/src/js/tcmd/components/App.js +355 -0
- package/src/js/tcmd/components/App.xml +98 -0
- package/src/js/tcmd/components/BookmarkList.html +12 -0
- package/src/js/tcmd/components/BookmarkList.js +73 -0
- package/src/js/tcmd/components/BookmarkList.xml +18 -0
- package/src/js/tcmd/components/BookmarkRow.html +5 -0
- package/src/js/tcmd/components/BookmarkRow.js +40 -0
- package/src/js/tcmd/components/BookmarkRow.xml +10 -0
- package/src/js/tcmd/components/Breadcrumbs.js +27 -0
- package/src/js/tcmd/components/CommandArgRow.js +90 -0
- package/src/js/tcmd/components/CommandArgRow.xml +5 -0
- package/src/js/tcmd/components/CommandBar.html +4 -0
- package/src/js/tcmd/components/CommandBar.js +126 -0
- package/src/js/tcmd/components/CommandBar.xml +4 -0
- package/src/js/tcmd/components/CommandItem.html +3 -0
- package/src/js/tcmd/components/CommandItem.js +78 -0
- package/src/js/tcmd/components/CommandItem.xml +6 -0
- package/src/js/tcmd/components/CommandPalette.html +7 -0
- package/src/js/tcmd/components/CommandPalette.js +58 -0
- package/src/js/tcmd/components/CommandPalette.xml +16 -0
- package/src/js/tcmd/components/CommandPanel.js +98 -0
- package/src/js/tcmd/components/CommandPanel.xml +35 -0
- package/src/js/tcmd/components/Confirm.html +3 -0
- package/src/js/tcmd/components/Confirm.js +36 -0
- package/src/js/tcmd/components/Confirm.xml +7 -0
- package/src/js/tcmd/components/Crumb.html +4 -0
- package/src/js/tcmd/components/Crumb.js +48 -0
- package/src/js/tcmd/components/Crumb.xml +4 -0
- package/src/js/tcmd/components/EntryDetail.html +15 -0
- package/src/js/tcmd/components/EntryDetail.js +194 -0
- package/src/js/tcmd/components/EntryDetail.xml +16 -0
- package/src/js/tcmd/components/FileList.html +1 -0
- package/src/js/tcmd/components/FileList.js +41 -0
- package/src/js/tcmd/components/FileList.xml +7 -0
- package/src/js/tcmd/components/FilePreview.html +5 -0
- package/src/js/tcmd/components/FilePreview.js +110 -0
- package/src/js/tcmd/components/FilePreview.xml +10 -0
- package/src/js/tcmd/components/FileRow.html +5 -0
- package/src/js/tcmd/components/FileRow.js +135 -0
- package/src/js/tcmd/components/FileRow.xml +10 -0
- package/src/js/tcmd/components/FilterBar.html +6 -0
- package/src/js/tcmd/components/FilterBar.js +50 -0
- package/src/js/tcmd/components/FilterBar.xml +6 -0
- package/src/js/tcmd/components/Help.xml +11 -0
- package/src/js/tcmd/components/PaletteRow.html +3 -0
- package/src/js/tcmd/components/PaletteRow.js +43 -0
- package/src/js/tcmd/components/PaletteRow.xml +4 -0
- package/src/js/tcmd/components/RowList.html +1 -0
- package/src/js/tcmd/components/RowList.xml +1 -0
- package/src/js/tcmd/components/Summary.html +3 -0
- package/src/js/tcmd/components/Summary.js +77 -0
- package/src/js/tcmd/components/Summary.xml +3 -0
- package/src/js/tcmd/components/bind.js +20 -0
- package/src/js/tcmd/components/format.js +155 -0
- package/src/js/tcmd/components/icons.js +185 -0
- package/src/js/tcmd/components/index.js +41 -0
- package/src/js/tcmd/components/rows.js +212 -0
- package/src/js/tcmd/core/bookmarks.js +175 -0
- package/src/js/tcmd/core/commands.js +930 -0
- package/src/js/tcmd/core/directory.js +194 -0
- package/src/js/tcmd/core/entries.js +88 -0
- package/src/js/tcmd/core/fs.js +266 -0
- package/src/js/tcmd/core/index.js +39 -0
- package/src/js/tcmd/core/keymap.js +60 -0
- package/src/js/tcmd/core/operations.js +453 -0
- package/src/js/tcmd/core/os.js +128 -0
- package/src/js/tcmd/core/pane.js +74 -0
- package/src/js/tcmd/core/panel.js +383 -0
- package/src/js/tcmd/core/path.js +57 -0
- package/src/js/tcmd/core/script.js +186 -0
- package/src/js/tcmd/core/selection.js +43 -0
- package/src/js/tcmd/core/session.js +405 -0
- package/src/js/tcmd/index.js +53 -0
- package/src/js/tcmd/tui/app.js +140 -0
- package/src/js/tcmd/tui/catalogue.js +300 -0
- package/src/js/tcmd/tui/frame.js +269 -0
- package/src/js/tcmd/tui/help.js +32 -0
- package/src/js/tcmd/tui/index.js +104 -0
- package/src/js/tcmd/tui/theme.js +177 -0
- package/src/js/tcmd/vendor/fzy.js +243 -0
- package/src/js/tcmd/web/app.js +154 -0
- package/src/js/tcmd/web/catalogue.js +159 -0
- package/src/js/tcmd/web/header.html +1 -0
- package/src/js/tcmd/web/header.js +38 -0
- package/src/js/tcmd/web/http.js +79 -0
- package/src/js/tcmd/web/index.js +22 -0
- package/src/js/tcmd/web/main.js +151 -0
- package/src/js/tcmd/web/opfs.js +282 -0
- package/src/js/tcmd/web/seed.js +90 -0
- package/src/js/tcmd/web/server.js +191 -0
- 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
|