@ssebastien/tcmd 0.1.4 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README.md +169 -58
  2. package/desktop/dist/index.html +13 -0
  3. package/desktop/dist/main.js +20298 -0
  4. package/desktop/dist/style.css +1515 -0
  5. package/desktop/frontend/index.html +13 -0
  6. package/package.json +17 -5
  7. package/public/fonts/OFL.txt +93 -0
  8. package/public/licenses/LUCIDE.txt +43 -0
  9. package/public/main.js +5066 -1888
  10. package/public/style.css +660 -46
  11. package/scripts/build-desktop.js +31 -0
  12. package/src/js/tcmd/components/App.html +56 -6
  13. package/src/js/tcmd/components/App.js +233 -58
  14. package/src/js/tcmd/components/App.xml +27 -22
  15. package/src/js/tcmd/components/BookmarkList.html +4 -5
  16. package/src/js/tcmd/components/BookmarkList.js +14 -40
  17. package/src/js/tcmd/components/BookmarkList.xml +1 -2
  18. package/src/js/tcmd/components/BookmarkRow.html +2 -1
  19. package/src/js/tcmd/components/BookmarkRow.js +14 -9
  20. package/src/js/tcmd/components/BookmarkRow.xml +1 -1
  21. package/src/js/tcmd/components/CommandArgRow.html +18 -7
  22. package/src/js/tcmd/components/CommandArgRow.js +100 -44
  23. package/src/js/tcmd/components/CommandArgRow.xml +39 -15
  24. package/src/js/tcmd/components/CommandBar.html +1 -1
  25. package/src/js/tcmd/components/CommandBar.js +31 -25
  26. package/src/js/tcmd/components/CommandBar.xml +2 -2
  27. package/src/js/tcmd/components/CommandItem.js +24 -25
  28. package/src/js/tcmd/components/CommandItem.xml +2 -2
  29. package/src/js/tcmd/components/CommandPanel.html +13 -13
  30. package/src/js/tcmd/components/CommandPanel.js +57 -33
  31. package/src/js/tcmd/components/CommandPanel.xml +30 -20
  32. package/src/js/tcmd/components/Confirm.html +2 -2
  33. package/src/js/tcmd/components/EntryDetail.js +21 -7
  34. package/src/js/tcmd/components/FilePicker.html +10 -0
  35. package/src/js/tcmd/components/FilePicker.js +43 -0
  36. package/src/js/tcmd/components/FilePicker.xml +13 -0
  37. package/src/js/tcmd/components/FilePickerRow.html +6 -0
  38. package/src/js/tcmd/components/FilePickerRow.js +27 -0
  39. package/src/js/tcmd/components/FilePickerRow.xml +10 -0
  40. package/src/js/tcmd/components/FilePreview.html +1 -2
  41. package/src/js/tcmd/components/FilePreview.js +40 -4
  42. package/src/js/tcmd/components/FilePreview.xml +17 -10
  43. package/src/js/tcmd/components/FileRow.html +1 -0
  44. package/src/js/tcmd/components/FileRow.js +60 -9
  45. package/src/js/tcmd/components/Frame.js +57 -31
  46. package/src/js/tcmd/components/Help.html +12 -4
  47. package/src/js/tcmd/components/Help.js +198 -47
  48. package/src/js/tcmd/components/Help.xml +13 -4
  49. package/src/js/tcmd/components/HelpRow.html +8 -0
  50. package/src/js/tcmd/components/HelpRow.xml +7 -0
  51. package/src/js/tcmd/components/OverlayList.html +8 -0
  52. package/src/js/tcmd/components/OverlayList.js +69 -0
  53. package/src/js/tcmd/components/OverlayList.xml +13 -0
  54. package/src/js/tcmd/components/OverlayRow.html +5 -0
  55. package/src/js/tcmd/components/OverlayRow.js +47 -0
  56. package/src/js/tcmd/components/OverlayRow.xml +9 -0
  57. package/src/js/tcmd/components/format.js +4 -23
  58. package/src/js/tcmd/components/icons.js +137 -0
  59. package/src/js/tcmd/components/index.js +8 -3
  60. package/src/js/tcmd/components/rows.js +149 -25
  61. package/src/js/tcmd/core/base64.js +64 -0
  62. package/src/js/tcmd/core/bookmarks.js +45 -8
  63. package/src/js/tcmd/core/commands.js +612 -136
  64. package/src/js/tcmd/core/concurrency.js +59 -0
  65. package/src/js/tcmd/core/decompress.js +117 -0
  66. package/src/js/tcmd/core/directory.js +81 -5
  67. package/src/js/tcmd/core/format.js +36 -0
  68. package/src/js/tcmd/core/fs.js +410 -53
  69. package/src/js/tcmd/core/journal.js +93 -0
  70. package/src/js/tcmd/core/keymap.js +39 -3
  71. package/src/js/tcmd/core/operations.js +735 -63
  72. package/src/js/tcmd/core/os.js +29 -3
  73. package/src/js/tcmd/core/pane.js +4 -2
  74. package/src/js/tcmd/core/panel.js +179 -93
  75. package/src/js/tcmd/core/path.js +30 -3
  76. package/src/js/tcmd/core/permissions.js +107 -0
  77. package/src/js/tcmd/core/picker.js +156 -0
  78. package/src/js/tcmd/core/preview.js +102 -0
  79. package/src/js/tcmd/core/session.js +286 -116
  80. package/src/js/tcmd/core/watch.js +153 -0
  81. package/src/js/tcmd/desktop/backend/api.js +165 -0
  82. package/src/js/tcmd/desktop/backend/fs.js +626 -0
  83. package/src/js/tcmd/desktop/backend/main.ts +9 -0
  84. package/src/js/tcmd/desktop/bookmarks.js +23 -0
  85. package/src/js/tcmd/desktop/fs.js +91 -0
  86. package/src/js/tcmd/desktop/main.js +194 -0
  87. package/src/js/tcmd/desktop/os.js +33 -0
  88. package/src/js/tcmd/tui/app.js +111 -74
  89. package/src/js/tcmd/tui/catalogue.js +232 -23
  90. package/src/js/tcmd/tui/index.js +18 -4
  91. package/src/js/tcmd/tui/opentui.js +33 -0
  92. package/src/js/tcmd/tui/syntax.js +91 -0
  93. package/src/js/tcmd/tui/theme.js +27 -2
  94. package/src/js/tcmd/vendor/bzip2.js +276 -0
  95. package/src/js/tcmd/web/app.js +114 -56
  96. package/src/js/tcmd/web/catalogue.js +41 -4
  97. package/src/js/tcmd/web/effects.js +94 -14
  98. package/src/js/tcmd/web/header.html +1 -1
  99. package/src/js/tcmd/web/http.js +13 -0
  100. package/src/js/tcmd/web/index.js +2 -2
  101. package/src/js/tcmd/web/opfs.js +297 -30
  102. package/src/js/tcmd/web/seed.js +1 -1
  103. package/src/js/tcmd/web/server.js +118 -17
  104. package/src/js/tcmd/web/theme.js +3 -0
  105. package/tinyjs.json +16 -0
@@ -17,8 +17,8 @@
17
17
  // shared logic bound here to their `.xml` presentations; the browser view
18
18
  // binds the same modules to `.html`.
19
19
 
20
- import { extend, ui } from "select.tui"
21
- import { unwrap } from "select/state/cells"
20
+ import { extend, getTags, ui } from "select.tui"
21
+ import { effect, unwrap } from "select/state/cells"
22
22
  import bookmarkListXml from "../components/BookmarkList.xml" with { type: "text" }
23
23
  import bookmarkRowXml from "../components/BookmarkRow.xml" with { type: "text" }
24
24
  import commandBarXml from "../components/CommandBar.xml" with { type: "text" }
@@ -29,10 +29,15 @@ import confirmXml from "../components/Confirm.xml" with { type: "text" }
29
29
  import crumbXml from "../components/Crumb.xml" with { type: "text" }
30
30
  import entryDetailXml from "../components/EntryDetail.xml" with { type: "text" }
31
31
  import fileListXml from "../components/FileList.xml" with { type: "text" }
32
+ import filePickerXml from "../components/FilePicker.xml" with { type: "text" }
33
+ import filePickerRowXml from "../components/FilePickerRow.xml" with { type: "text" }
32
34
  import filePreviewXml from "../components/FilePreview.xml" with { type: "text" }
33
35
  import fileRowXml from "../components/FileRow.xml" with { type: "text" }
34
36
  import filterBarXml from "../components/FilterBar.xml" with { type: "text" }
35
37
  import helpXml from "../components/Help.xml" with { type: "text" }
38
+ import helpRowXml from "../components/HelpRow.xml" with { type: "text" }
39
+ import overlayListXml from "../components/OverlayList.xml" with { type: "text" }
40
+ import overlayRowXml from "../components/OverlayRow.xml" with { type: "text" }
36
41
  import rowListXml from "../components/RowList.xml" with { type: "text" }
37
42
  import summaryXml from "../components/Summary.xml" with { type: "text" }
38
43
 
@@ -43,32 +48,58 @@ import {
43
48
  bookmarkRows,
44
49
  breadcrumbsBehaviors,
45
50
  commandBarBehaviors,
51
+ commandBarFit,
52
+ commandBarMarksItem,
46
53
  commandArgRowBehaviors,
47
54
  commandItemBehaviors,
48
55
  commandItemRows,
49
- commandBarMarksItem,
50
56
  commandPanelBehaviors,
51
57
  confirmBehaviors,
52
58
  crumbBehaviors,
53
59
  crumbRows,
54
60
  entryDetailBehaviors,
55
61
  fileListBehaviors,
62
+ filePickerBehaviors,
63
+ filePickerRowBehaviors,
64
+ filePickerRows,
56
65
  filePreviewBehaviors,
57
66
  fileRowBehaviors,
58
67
  fileRows,
59
- fileWindow,
60
68
  filterBarBehaviors,
69
+ helpRowBehaviors,
70
+ helpRows,
61
71
  iconsEnabled,
72
+ listingWindow,
73
+ overlayListBehaviors,
74
+ overlayRowBehaviors,
75
+ overlayRows,
62
76
  panelArgRows,
63
77
  panelOpRows,
78
+ panelRunRows,
64
79
  summaryBehaviors,
65
80
  } from "../components/index.js"
66
81
  import { createBehaviors as helpBehaviors } from "../components/Help.js"
67
82
  import { INSPECTOR_INLINE_WIDTH } from "../components/Frame.js"
83
+ import { CodeRenderable, ImageRenderable, pathToFiletype, createTextAttributes } from "./opentui.js"
84
+ import { syntaxStyle } from "./syntax.js"
68
85
  import { tuiTheme } from "./theme.js"
69
86
 
70
87
  let registered = false
71
88
 
89
+ // Class: CodeView
90
+ // The `code` host tag: OpenTUI's tree-sitter `CodeRenderable` with the shared
91
+ // `SyntaxStyle` injected, because the renderable reads its style at
92
+ // construction time and a template attribute can only carry a string. Content
93
+ // and `filetype` still arrive through the node's own `out:` bindings.
94
+ class CodeView extends CodeRenderable {
95
+ constructor(ctx, options) {
96
+ super(ctx, {
97
+ ...options,
98
+ syntaxStyle: options.syntaxStyle ?? syntaxStyle(ctx.nativeScene),
99
+ })
100
+ }
101
+ }
102
+
72
103
  // Function: tuiEnv
73
104
  // The TUI renderer environment: OpenTUI colors plus TUI-only hooks.
74
105
  function tuiEnv() {
@@ -87,14 +118,23 @@ function tuiEnv() {
87
118
  dirFg: tuiTheme.dirFg,
88
119
  errorBg: tuiTheme.errorBg,
89
120
  errorFg: tuiTheme.errorFg,
121
+ execFg: tuiTheme.execFg,
90
122
  fieldBg: tuiTheme.fieldBg,
91
123
  fileFg: tuiTheme.fileFg,
124
+ groupAttributes: createTextAttributes({ bold: true }),
125
+ // Resolves an entry path to a tree-sitter filetype (undefined when no
126
+ // bundled parser matches, and the preview then draws plain text).
127
+ filetype: (path) => pathToFiletype(path),
128
+ // Raster formats the `image` renderable decodes. `bmp`/`svg` are not
129
+ // among them, so those previews keep the metadata card.
130
+ imageExts: ["gif", "jpeg", "jpg", "png", "webp"],
92
131
  // Nerd Font icons in rows, on unless `TCMD_ICONS` opts out.
93
132
  icons: iconsEnabled(process.env),
94
133
  lineFg: tuiTheme.textSecondaryFg,
95
134
  linkFg: tuiTheme.linkFg,
96
135
  mutedBg: tuiTheme.mutedBg,
97
136
  overlayBg: tuiTheme.overlayBg,
137
+ readonlyFg: tuiTheme.readonlyFg,
98
138
  selectionBg: tuiTheme.selectionBg,
99
139
  surfaceBg: tuiTheme.surfaceBg,
100
140
  textFg: tuiTheme.textFg,
@@ -164,17 +204,23 @@ function keepVisible(box, index, total) {
164
204
  return top
165
205
  }
166
206
 
167
- // Keep the file-list cursor row inside the viewport. When the list is windowed
168
- // (a `viewport` height is threaded down) the scrollbox holds only the window,
169
- // so it scrolls to the cursor's index inside that window against the window
170
- // length; otherwise it scrolls the full listing.
207
+ // Keep the file-list cursor row inside the viewport. The window comes from
208
+ // `listingWindow`, the same helper the row builder uses, so the scrollbox
209
+ // content and the scroll keeper never disagree (a windowed pane scrolls the
210
+ // cursor inside its window; an inspector without a threaded viewport mounts
211
+ // the full listing and scrolls it by absolute index).
171
212
  function fileListScrollTop(self, data) {
172
- const viewport = unwrap(data.viewport)
173
- if (viewport > 0) {
174
- const { local, len } = fileWindow(data, viewport)
175
- return keepVisible(self.ref.list, local, len)
176
- }
177
- return keepVisible(self.ref.list, unwrap(data.cursor) ?? 0, unwrap(data.entries)?.length ?? 0)
213
+ const { local, len } = listingWindow(data, unwrap(data.viewport) ?? 0)
214
+ return keepVisible(self.ref.list, local, len)
215
+ }
216
+
217
+ // Keep the file picker's cursor row inside its viewport, using the same
218
+ // bounded window as the row builder.
219
+ function filePickerScrollTop(self, data) {
220
+ const rows = unwrap(data.rows) ?? []
221
+ const cursor = unwrap(data.cursor) ?? 0
222
+ const { local, len } = listingWindow({ entries: rows, cursor }, 0)
223
+ return keepVisible(self.ref.list, local, len)
178
224
  }
179
225
 
180
226
  // Keep the bookmark picker selection inside its viewport.
@@ -182,21 +228,101 @@ function bookmarkScrollTop(self, data) {
182
228
  return keepVisible(self.ref.list, unwrap(data.selected) ?? 0, unwrap(data.items)?.length ?? 0)
183
229
  }
184
230
 
185
- // Keep the command panel's argument cursor inside its viewport.
231
+ // Keep the shared list overlay's cursor inside its viewport.
232
+ function overlayScrollTop(self, data) {
233
+ return keepVisible(self.ref.list, unwrap(data.cursor) ?? 0, unwrap(data.items)?.length ?? 0)
234
+ }
235
+
236
+ // Keep the selected command visible in the command panel's scrolling list.
237
+ function panelOpScrollTop(self, data) {
238
+ const items = unwrap(data.items) ?? []
239
+ const cursor = unwrap(data.opCursor) ?? 0
240
+ const index = items.findIndex((item) => item.type === "op" && item.entryIndex === cursor)
241
+ const box = self.ref.ops
242
+ // OpenTUI's measured scrollbox height includes two clipped edge cells in a
243
+ // modal, so scroll against the visible content height rather than the frame.
244
+ const visibleBox = box?.height
245
+ ? { height: Math.max(1, Math.floor(box.height) - 2), scrollTop: box.scrollTop, scrollHeight: box.scrollHeight }
246
+ : box
247
+ return keepVisible(visibleBox, Math.max(0, index), items.length)
248
+ }
249
+
250
+ // Keep enough vertical room for the trailing Run row on compact terminals while
251
+ // retaining the usual shorter panel on a full-sized viewport.
252
+ function panelDialogHeight(self) {
253
+ const height = Math.floor(self.ctx?.height ?? 0)
254
+ if (!height) return "72%"
255
+ return Math.floor(height * (height <= 20 ? 0.85 : 0.72))
256
+ }
257
+
258
+ // Keep the command panel's argument cursor inside its viewport. Argument
259
+ // headers are two lines tall and a selection's item run gains a border row at
260
+ // each end, so the offset is measured in rendered lines rather than row
261
+ // indices.
186
262
  function panelArgScrollTop(self, data) {
187
- return keepVisible(self.ref.args, unwrap(data.argCursor) ?? 0, unwrap(data.rows)?.length ?? 0)
263
+ // Arguments, the preflight preview and Run share the argument scroller;
264
+ // the mapping must count the preview and Run's leading blank line so the
265
+ // cursor can still be kept in view at the bottom.
266
+ const rows = unwrap(data.rows) ?? []
267
+ const box = self.ref.args
268
+ if (!rows.length) return box?.scrollTop ?? 0
269
+ const cursor = Math.min(unwrap(data.argCursor) ?? 0, rows.length - 1)
270
+ const previewHeight = unwrap(data.preview) ? (self.ref.previewBox?.height ?? 0) : 0
271
+ let offset = 0
272
+ let cursorTop = 0
273
+ let cursorHeight = 1
274
+ for (let i = 0; i < rows.length; i++) {
275
+ const row = rows[i]
276
+ // Run is preceded by the preview (when there is one) and by its own
277
+ // one-line separation from the last field.
278
+ if (row.type === "run") offset += 1 + previewHeight
279
+ const firstItem = row.type === "item" && (i === 0 || rows[i - 1].type !== "item")
280
+ const lastItem = row.type === "item" && (i === rows.length - 1 || rows[i + 1].type !== "item")
281
+ if (firstItem) offset += 1
282
+ const height = row.type === "arg" ? 2 : 1
283
+ if (i === cursor) {
284
+ cursorTop = offset
285
+ cursorHeight = height
286
+ }
287
+ offset += height
288
+ if (lastItem) offset += 1
289
+ }
290
+ return keepRangeVisible(box, cursorTop, cursorHeight, offset)
291
+ }
292
+
293
+ // Function: keepRangeVisible
294
+ // The line-based counterpart of `keepVisible`: keeps the `[top, top + height)`
295
+ // run inside the scrollbox, then nudges one line at the same edge margins.
296
+ function keepRangeVisible(box, top, height, total) {
297
+ if (!box || !box.height) return box?.scrollTop ?? 0
298
+ const view = Math.max(1, Math.floor(box.height))
299
+ const maxTop = Math.max(0, total - view)
300
+ let scroll = Math.max(0, Math.min(box.scrollTop ?? 0, maxTop))
301
+ if (top < scroll) scroll = top
302
+ else if (top + height > scroll + view) scroll = top + height - view
303
+ const position = top - scroll
304
+ if (position > SCROLL_HIGH * view && scroll < maxTop) scroll += 1
305
+ else if (position < SCROLL_LOW * view && scroll > 0) scroll -= 1
306
+ return scroll
188
307
  }
189
308
 
190
309
  // Function: commandBarRows
191
- // Appends the active pane's marked count as a trailing dimmed entry, then
192
- // maps the items to rows. The entries wrap onto as many rows as the width
193
- // demands; nothing is trimmed.
310
+ // Appends the active pane's marked count as a trailing dimmed entry, fits the
311
+ // entries on one line (dropping what does not fit behind an ellipsis), then
312
+ // maps them to rows.
194
313
  function commandBarRows(commandItem) {
195
314
  const base = commandItemRows(commandItem)
196
315
  return (self, data) => {
316
+ const viewport = unwrap(data.uiSize)
317
+ const viewportWidth = Math.floor(viewport?.width ?? 0)
318
+ // Prefer the injected viewport: the box geometry still reports the
319
+ // previous width on the frame a resize arrives.
320
+ const own = Math.floor(self.ref.box?.width ?? 0)
321
+ const width = viewportWidth > 0 ? viewportWidth - 1 : own
197
322
  const items = unwrap(data.items) ?? []
198
323
  const marks = commandBarMarksItem(data)
199
- return base(self, { ...data, items: marks ? [...items, marks] : items })
324
+ const entries = marks ? [...items, marks] : items
325
+ return base(self, { ...data, items: commandBarFit(entries, width) })
200
326
  }
201
327
  }
202
328
 
@@ -208,8 +334,11 @@ function createComponents() {
208
334
  const commandArgRow = bind(ui, "CommandArgRow", commandArgRowXml, commandArgRowBehaviors(env))
209
335
  const crumb = bind(ui, "Crumb", crumbXml, crumbBehaviors(env))
210
336
  const fileRow = bind(ui, "FileRow", fileRowXml, fileRowBehaviors(env))
337
+ const filePickerRow = bind(ui, "FilePickerRow", filePickerRowXml, filePickerRowBehaviors(env))
211
338
  const filterBar = bind(ui, "FilterBar", filterBarXml, filterBarBehaviors(env))
212
339
  const bookmarkRow = bind(ui, "BookmarkRow", bookmarkRowXml, bookmarkRowBehaviors(env))
340
+ const overlayRow = bind(ui, "OverlayRow", overlayRowXml, overlayRowBehaviors(env))
341
+ const helpRow = bind(ui, "HelpRow", helpRowXml, helpRowBehaviors({ theme: tuiTheme }))
213
342
  return {
214
343
  BookmarkRow: bookmarkRow,
215
344
  CommandArgRow: commandArgRow,
@@ -231,8 +360,11 @@ function createComponents() {
231
360
  })),
232
361
  CommandPanel: bind(ui, "CommandPanel", commandPanelXml, commandPanelBehaviors({
233
362
  ...env,
363
+ panelHeight: panelDialogHeight,
234
364
  opRows: panelOpRows(commandArgRow),
235
365
  argRows: panelArgRows(commandArgRow),
366
+ runRows: panelRunRows(commandArgRow),
367
+ opScrollTop: panelOpScrollTop,
236
368
  argScrollTop: panelArgScrollTop,
237
369
  })),
238
370
  Confirm: bind(ui, "Confirm", confirmXml, confirmBehaviors(env)),
@@ -247,9 +379,37 @@ function createComponents() {
247
379
  rows: fileRows(fileRow),
248
380
  scrollTop: fileListScrollTop,
249
381
  })),
250
- FilePreview: bind(ui, "FilePreview", filePreviewXml, filePreviewBehaviors(env)),
382
+ FilePicker: bind(ui, "FilePicker", filePickerXml, filePickerBehaviors({
383
+ ...env,
384
+ rows: filePickerRows(filePickerRow),
385
+ scrollTop: filePickerScrollTop,
386
+ })),
387
+ FilePreview: bind(ui, "FilePreview", filePreviewXml, filePreviewBehaviors(env)).init((self, data) => {
388
+ // A new preview target starts at the top of the body. `entry` is the
389
+ // reactive cell the App threads down; the effect fires after mount,
390
+ // when `ref.body` exists, and the following render swaps the text.
391
+ if (data?.entry?.isReactive) {
392
+ self.effect(() => effect([data.entry], () => {
393
+ if (self.ref.body) self.ref.body.scrollTop = 0
394
+ }))
395
+ }
396
+ // A full-preview scroll request (App key routing) moves the body by
397
+ // one line; OpenTUI clamps the range, so the edges need no guard.
398
+ if (data?.scroll?.isReactive) {
399
+ self.effect(() => effect([data.scroll], ([request]) => {
400
+ const body = self.ref.body
401
+ if (!body || !request?.delta) return
402
+ body.scrollTop = Math.max(0, body.scrollTop + request.delta)
403
+ }))
404
+ }
405
+ }),
251
406
  FilterBar: filterBar,
252
- Help: bind(ui, "Help", helpXml, helpBehaviors({ theme: tuiTheme })),
407
+ Help: bind(ui, "Help", helpXml, helpBehaviors({ theme: tuiTheme, rows: helpRows(helpRow) })),
408
+ OverlayList: bind(ui, "OverlayList", overlayListXml, overlayListBehaviors({
409
+ ...env,
410
+ rows: overlayRows(overlayRow),
411
+ scrollTop: overlayScrollTop,
412
+ })),
253
413
  Summary: bind(ui, "Summary", summaryXml, summaryBehaviors({
254
414
  ...env,
255
415
  width: paneWidth,
@@ -261,27 +421,76 @@ function createComponents() {
261
421
 
262
422
  const components = createComponents()
263
423
 
424
+ // Function: showScrollBar
425
+ // select.tui hides scrollbars by default because cursor-aware lists keep the
426
+ // cursor on screen themselves. Restore automatic overflow visibility for every
427
+ // scrollbox; the wrapper width fix lets the vertical bar fit beside its content.
428
+ function showScrollBar(node) {
429
+ // The wrapper's content declares a 100% minimum width, which otherwise
430
+ // starves the 1-column bar and drops it over the first column. The wrapper
431
+ // is re-measured once the content is mounted, so apply it again on the next
432
+ // tick to land on the overlay's first frame.
433
+ const fix = () => {
434
+ if (node.wrapper) node.wrapper.minWidth = 0
435
+ node.requestRender?.()
436
+ }
437
+ fix()
438
+ queueMicrotask(fix)
439
+ const bar = node.verticalScrollBar
440
+ if (bar) {
441
+ // Override select.tui's hidden default and let overflow drive the bar
442
+ // again. OpenTUI 0.8 pins `visible = false`, so the reset is required.
443
+ bar.resetVisibilityControl?.()
444
+ bar.width = 1
445
+ bar.flexShrink = 0
446
+ // The default track/thumb are greys; the theme keeps the scrollbar part
447
+ // of the night-blue surfaces (mono leaves the defaults). OpenTUI 0.8
448
+ // paints the bar natively from `trackOptions`; older versions expose a
449
+ // child slider renderable.
450
+ if (bar.slider) {
451
+ if (tuiTheme.scrollbarThumbBg) {
452
+ bar.slider.backgroundColor = tuiTheme.scrollbarTrackBg
453
+ bar.slider.foregroundColor = tuiTheme.scrollbarThumbBg
454
+ }
455
+ } else if (tuiTheme.scrollbarThumbBg) {
456
+ bar.trackOptions = {
457
+ foregroundColor: tuiTheme.scrollbarThumbBg,
458
+ backgroundColor: tuiTheme.scrollbarTrackBg,
459
+ }
460
+ }
461
+ }
462
+ }
463
+
264
464
  // Function: registerComponents
265
465
  // Idempotent registration of the parent components as select.tui host tags.
266
466
  function registerComponents() {
267
467
  if (registered) return components
268
468
  registered = true
269
469
  extend({
470
+ // Preserve OpenTUI's scrollbox while re-enabling its overflow-aware bars.
471
+ scrollbox: { Class: getTags().scrollbox.Class, configure: showScrollBar },
472
+ helpscroll: { Class: getTags().scrollbox.Class, configure: showScrollBar },
270
473
  bookmarklist: components.BookmarkList,
271
474
  breadcrumbs: components.Breadcrumbs,
475
+ // Tree-sitter code preview (see `CodeView`) and the Kitty/sixel/blocks
476
+ // image renderable; `filepreview` binds both.
477
+ code: { Class: CodeView, contentProp: "content" },
478
+ image: { Class: ImageRenderable },
272
479
  commandbar: components.CommandBar,
273
480
  commandpanel: components.CommandPanel,
274
481
  confirm: components.Confirm,
275
482
  entrydetail: components.EntryDetail,
276
483
  filelist: components.FileList,
484
+ filepicker: components.FilePicker,
277
485
  filepreview: components.FilePreview,
278
486
  filterbar: components.FilterBar,
279
487
  help: components.Help,
488
+ overlaylist: components.OverlayList,
280
489
  summary: components.Summary,
281
490
  })
282
491
  return components
283
492
  }
284
493
 
285
- export { components, fileListScrollTop, keepVisible, registerComponents }
494
+ export { components, fileListScrollTop, keepVisible, panelDialogHeight, panelOpScrollTop, registerComponents }
286
495
 
287
496
  // EOF
@@ -44,16 +44,27 @@ function bookmarkFile() {
44
44
  return nodePath.join(configHome, "tcmd", "bookmarks.json")
45
45
  }
46
46
 
47
+ // Function: startupPath
48
+ // Resolves a CLI path argument against the working directory. Relative paths
49
+ // (`../src`, `.`) would otherwise reach the panes verbatim and be rewritten
50
+ // from the filesystem root by the POSIX path helpers, corrupting breadcrumbs,
51
+ // history and the explorer's parent column.
52
+ function startupPath(value, fallback) {
53
+ return value ? nodePath.resolve(value) : fallback
54
+ }
55
+
47
56
  // Function: main
48
57
  // Runs the TUI. `paths` are optional initial directories [left, right].
49
58
  async function main(paths = []) {
50
59
  const fs = createNodeFs()
51
- const left = createPane({ fs, path: paths[0] || fs.cwd() })
52
- const right = createPane({ fs, path: paths[1] || fs.home() })
60
+ const left = createPane({ fs, path: startupPath(paths[0], fs.cwd()) })
61
+ const right = createPane({ fs, path: startupPath(paths[1], fs.home()) })
53
62
  let instance = null
54
63
  const os = createNodeOs({
55
64
  suspend: () => instance?._ownedRenderer?.suspend?.(),
56
65
  resume: () => instance?._ownedRenderer?.resume?.(),
66
+ // OSC 52: the renderer knows whether the terminal supports it.
67
+ copy: (text) => instance?._ownedRenderer?.copyToClipboardOSC52?.(text) ?? false,
57
68
  })
58
69
  const bookmarks = createBookmarks({
59
70
  store: createFileBookmarkStore({
@@ -68,7 +79,10 @@ async function main(paths = []) {
68
79
  const size = cell({ width: 0, height: 0 })
69
80
  const help = cell(false)
70
81
  const App = createAppComponent(session, { size, help })
71
- instance = await render(App, { useMouse: true, exitOnCtrlC: false })
82
+ instance = await render(App, {
83
+ useMouse: true,
84
+ exitOnCtrlC: false,
85
+ })
72
86
  const renderer = instance?.ctx ?? instance?.renderer
73
87
  const current = renderer?.width || renderer?.height
74
88
  ? { width: renderer.width ?? 0, height: renderer.height ?? 0 }
@@ -84,6 +98,6 @@ async function main(paths = []) {
84
98
  return session
85
99
  }
86
100
 
87
- export { main, readSize }
101
+ export { main, readSize, startupPath }
88
102
 
89
103
  // EOF
@@ -0,0 +1,33 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-10-18
5
+
6
+ // Module: tcmd/tui/opentui
7
+ // The OpenTUI classes the TUI registers as host tags (`code`, `image`) — and
8
+ // the `SyntaxStyle`/filetype helpers they need — resolved from the *same*
9
+ // OpenTUI copy `select.tui` itself binds its renderables to.
10
+ //
11
+ // A plain `import … from "@opentui/core"` can land on a different physical
12
+ // copy: in a source checkout `select.tui` is symlinked with its own
13
+ // `node_modules`, so the repo and the renderer each get an `@opentui/core` and
14
+ // their renderable base classes stop matching (a code renderable from one copy
15
+ // cannot be mounted in the other's tree — OpenTUI throws on the first
16
+ // destroy). Resolving `@opentui/core` from wherever `select.tui` resolves its
17
+ // own dependencies keeps a single instance. In a normal install both resolve
18
+ // to the one hoisted copy, so this is a no-op there.
19
+
20
+ import { createRequire } from "node:module"
21
+ import { pathToFileURL } from "node:url"
22
+
23
+ const requireHere = createRequire(import.meta.url)
24
+ const requireFromSelectTui = createRequire(requireHere.resolve("select.tui"))
25
+ const coreUrl = pathToFileURL(requireFromSelectTui.resolve("@opentui/core")).href
26
+
27
+ const core = await import(coreUrl)
28
+
29
+ const { CodeRenderable, ImageRenderable, pathToFiletype, SyntaxStyle, createTextAttributes } = core
30
+
31
+ export { CodeRenderable, ImageRenderable, pathToFiletype, SyntaxStyle, createTextAttributes }
32
+
33
+ // EOF
@@ -0,0 +1,91 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-10-18
5
+
6
+ // Module: tcmd/tui/syntax
7
+ // The TUI's syntax-highlighting palette: one `SyntaxStyle` (OpenTUI's
8
+ // tree-sitter style table) per native scene, built from the active `tuiTheme`,
9
+ // so code previews read in the same colours as the rest of the workspace.
10
+ // Since OpenTUI 0.6, every style is tied to the scene that owns its native
11
+ // handle, so a renderer cannot share a style with another renderer. The
12
+ // TUI-only half of the preview — the web renders plain text and never imports
13
+ // OpenTUI.
14
+ //
15
+ // The scope names are the standard tree-sitter captures the bundled queries
16
+ // emit (`keyword.return`, `function.method`, `punctuation.bracket`…).
17
+ // `SyntaxStyle#getStyleId` falls back from a dotted capture to its base name,
18
+ // so registering the bases here covers every variant without enumerating them.
19
+ // In the mono theme every colour token is `undefined`, so a scope keeps only
20
+ // its attributes (comment stays italic, headings stay bold) and the terminal's
21
+ // own foreground carries the rest.
22
+
23
+ import { SyntaxStyle } from "./opentui.js"
24
+ import { tuiTheme } from "./theme.js"
25
+
26
+ // Function: scopeStyles
27
+ // Maps each base capture to a style drawn from `theme`. Content colours reuse
28
+ // the listing palette (comments dimmer than plain text, strings the executable
29
+ // green, types the directory blue, literals the device purple), so a preview
30
+ // and a pane never disagree about what a colour means.
31
+ function scopeStyles(theme) {
32
+ return {
33
+ attribute: { fg: theme.tipFg },
34
+ boolean: { fg: theme.tipFg },
35
+ comment: { fg: theme.readonlyFg, italic: true },
36
+ constant: { fg: theme.tipFg },
37
+ constructor: { fg: theme.dirFg },
38
+ embedded: { fg: theme.textFg },
39
+ field: { fg: theme.devFg },
40
+ function: { fg: theme.linkFg },
41
+ import: { fg: theme.accentFg },
42
+ keyword: { fg: theme.accentSecondaryFg, bold: true },
43
+ label: { fg: theme.accentSecondaryFg },
44
+ method: { fg: theme.linkFg },
45
+ module: { fg: theme.accentFg },
46
+ namespace: { fg: theme.accentFg },
47
+ number: { fg: theme.tipFg },
48
+ operator: { fg: theme.textSecondaryFg },
49
+ parameter: { fg: theme.textFg },
50
+ property: { fg: theme.devFg },
51
+ punctuation: { fg: theme.textSecondaryFg },
52
+ string: { fg: theme.execFg },
53
+ tag: { fg: theme.accentSecondaryFg },
54
+ type: { fg: theme.dirFg },
55
+ variable: { fg: theme.textFg },
56
+ "markup.heading": { fg: theme.textHeadingFg, bold: true },
57
+ "markup.italic": { italic: true },
58
+ "markup.link": { fg: theme.linkFg, underline: true },
59
+ "markup.link.label": { fg: theme.linkFg },
60
+ "markup.link.url": { fg: theme.linkFg, underline: true },
61
+ "markup.list": { fg: theme.accentSecondaryFg },
62
+ "markup.quote": { fg: theme.readonlyFg, italic: true },
63
+ "markup.raw": { fg: theme.execFg },
64
+ "markup.strong": { bold: true },
65
+ }
66
+ }
67
+
68
+ // Function: createSyntaxStyle
69
+ // Builds a `SyntaxStyle` for `theme`, owned by `scene`.
70
+ function createSyntaxStyle(scene, theme = tuiTheme) {
71
+ return SyntaxStyle.fromStyles(scopeStyles(theme), scene)
72
+ }
73
+
74
+ // The style is shared per scene: the theme resolves once per process, and the
75
+ // code renderables of one renderer share one native handle.
76
+ const sharedStyles = new WeakMap()
77
+
78
+ // Function: syntaxStyle
79
+ // The shared `SyntaxStyle` for `scene` and the active theme, built on first use.
80
+ function syntaxStyle(scene, theme = tuiTheme) {
81
+ let style = sharedStyles.get(scene)
82
+ if (!style) {
83
+ style = createSyntaxStyle(scene, theme)
84
+ sharedStyles.set(scene, style)
85
+ }
86
+ return style
87
+ }
88
+
89
+ export { syntaxStyle }
90
+
91
+ // EOF
@@ -23,11 +23,16 @@
23
23
  // from the cursor alone.
24
24
  // - Text: primary, secondary (hints) and heading (titles/breadcrumbs).
25
25
  // - Borders: secondary (pane box borders, header rules, active/inactive) and
26
- // primary (the frame and the modal overlays).
26
+ // primary (the frame and the modal overlays). The active pane's
27
+ // border and head rule follow the view mode: the Tiny blue in
28
+ // explorer, the gold accent in commander.
27
29
  // - Accent: primary (cursor, active pane label) and secondary (marks).
28
30
  // - Semantic: error, warning and tip, each with a background + foreground
29
31
  // for filled badges (the command bar status, the confirm dialog).
30
- // - Content: the file-listing colors (file/link/device/directory).
32
+ // - Content: the file-listing colors (file/link/device/directory plus the
33
+ // executable and read-only file states).
34
+ // - Scrollbar: the track and thumb of every scrollable surface (the TUI's
35
+ // slider, mirrored by the web's CSS variables).
31
36
  // - Brand: the identity-bar gradient endpoints — "Tiny" stays blue, "Cmd"
32
37
  // runs gold to saturated orange.
33
38
 
@@ -52,6 +57,8 @@ const DARK = {
52
57
  // Borders
53
58
  borderSecondaryFg: "#33508f",
54
59
  borderSecondaryActiveFg: "#f0b429",
60
+ // The active pane's border/head rule in explorer mode: the Tiny blue.
61
+ borderActiveExplorerFg: "#4088ff",
55
62
  borderPrimaryFg: "#3d5590",
56
63
  borderPrimaryActiveFg: "#ffc233",
57
64
  // Accent
@@ -71,6 +78,13 @@ const DARK = {
71
78
  linkFg: "#4fc3ff",
72
79
  devFg: "#b48eff",
73
80
  dirFg: "#5c8cff",
81
+ // Executable files go green, read-only files dimmer than a plain file
82
+ // (`ls --color` / Midnight Commander habits).
83
+ execFg: "#5fd67a",
84
+ readonlyFg: "#7d8ab8",
85
+ // Scrollbar
86
+ scrollbarTrackBg: "transparent",
87
+ scrollbarThumbBg: "#22345e",
74
88
  // Brand
75
89
  brandTinyFrom: "#4088ff",
76
90
  brandTinyTo: "#7cd0ff",
@@ -99,6 +113,7 @@ const LIGHT = {
99
113
  // Borders
100
114
  borderSecondaryFg: "#9aabd6",
101
115
  borderSecondaryActiveFg: "#926000",
116
+ borderActiveExplorerFg: "#1d4ed8",
102
117
  borderPrimaryFg: "#8c9cc9",
103
118
  borderPrimaryActiveFg: "#a06a00",
104
119
  // Accent
@@ -118,6 +133,11 @@ const LIGHT = {
118
133
  linkFg: "#0369a1",
119
134
  devFg: "#7c3aed",
120
135
  dirFg: "#1d4ed8",
136
+ execFg: "#0f7a3d",
137
+ readonlyFg: "#6b7280",
138
+ // Scrollbar
139
+ scrollbarTrackBg: "transparent",
140
+ scrollbarThumbBg: "#34548f",
121
141
  // Brand
122
142
  brandTinyFrom: "#1d4ed8",
123
143
  brandTinyTo: "#3b82f6",
@@ -141,6 +161,7 @@ const MONO = {
141
161
  textHeadingFg: undefined,
142
162
  borderSecondaryFg: undefined,
143
163
  borderSecondaryActiveFg: undefined,
164
+ borderActiveExplorerFg: undefined,
144
165
  borderPrimaryFg: undefined,
145
166
  borderPrimaryActiveFg: undefined,
146
167
  accentFg: undefined,
@@ -157,6 +178,10 @@ const MONO = {
157
178
  linkFg: undefined,
158
179
  devFg: undefined,
159
180
  dirFg: undefined,
181
+ execFg: undefined,
182
+ readonlyFg: undefined,
183
+ scrollbarTrackBg: undefined,
184
+ scrollbarThumbBg: undefined,
160
185
  brandTinyFrom: undefined,
161
186
  brandTinyTo: undefined,
162
187
  brandCmdFrom: undefined,