@ssebastien/tcmd 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +234 -2
  2. package/bin/tcmd +17 -0
  3. package/package.json +37 -5
  4. package/src/js/tcmd/components/App.html +28 -0
  5. package/src/js/tcmd/components/App.js +355 -0
  6. package/src/js/tcmd/components/App.xml +98 -0
  7. package/src/js/tcmd/components/BookmarkList.html +12 -0
  8. package/src/js/tcmd/components/BookmarkList.js +73 -0
  9. package/src/js/tcmd/components/BookmarkList.xml +18 -0
  10. package/src/js/tcmd/components/BookmarkRow.html +5 -0
  11. package/src/js/tcmd/components/BookmarkRow.js +40 -0
  12. package/src/js/tcmd/components/BookmarkRow.xml +10 -0
  13. package/src/js/tcmd/components/Breadcrumbs.js +27 -0
  14. package/src/js/tcmd/components/CommandArgRow.js +90 -0
  15. package/src/js/tcmd/components/CommandArgRow.xml +5 -0
  16. package/src/js/tcmd/components/CommandBar.html +4 -0
  17. package/src/js/tcmd/components/CommandBar.js +126 -0
  18. package/src/js/tcmd/components/CommandBar.xml +4 -0
  19. package/src/js/tcmd/components/CommandItem.html +3 -0
  20. package/src/js/tcmd/components/CommandItem.js +78 -0
  21. package/src/js/tcmd/components/CommandItem.xml +6 -0
  22. package/src/js/tcmd/components/CommandPalette.html +7 -0
  23. package/src/js/tcmd/components/CommandPalette.js +58 -0
  24. package/src/js/tcmd/components/CommandPalette.xml +16 -0
  25. package/src/js/tcmd/components/CommandPanel.js +98 -0
  26. package/src/js/tcmd/components/CommandPanel.xml +35 -0
  27. package/src/js/tcmd/components/Confirm.html +3 -0
  28. package/src/js/tcmd/components/Confirm.js +36 -0
  29. package/src/js/tcmd/components/Confirm.xml +7 -0
  30. package/src/js/tcmd/components/Crumb.html +4 -0
  31. package/src/js/tcmd/components/Crumb.js +48 -0
  32. package/src/js/tcmd/components/Crumb.xml +4 -0
  33. package/src/js/tcmd/components/EntryDetail.html +15 -0
  34. package/src/js/tcmd/components/EntryDetail.js +194 -0
  35. package/src/js/tcmd/components/EntryDetail.xml +16 -0
  36. package/src/js/tcmd/components/FileList.html +1 -0
  37. package/src/js/tcmd/components/FileList.js +41 -0
  38. package/src/js/tcmd/components/FileList.xml +7 -0
  39. package/src/js/tcmd/components/FilePreview.html +5 -0
  40. package/src/js/tcmd/components/FilePreview.js +110 -0
  41. package/src/js/tcmd/components/FilePreview.xml +10 -0
  42. package/src/js/tcmd/components/FileRow.html +5 -0
  43. package/src/js/tcmd/components/FileRow.js +135 -0
  44. package/src/js/tcmd/components/FileRow.xml +10 -0
  45. package/src/js/tcmd/components/FilterBar.html +6 -0
  46. package/src/js/tcmd/components/FilterBar.js +50 -0
  47. package/src/js/tcmd/components/FilterBar.xml +6 -0
  48. package/src/js/tcmd/components/Help.xml +11 -0
  49. package/src/js/tcmd/components/PaletteRow.html +3 -0
  50. package/src/js/tcmd/components/PaletteRow.js +43 -0
  51. package/src/js/tcmd/components/PaletteRow.xml +4 -0
  52. package/src/js/tcmd/components/RowList.html +1 -0
  53. package/src/js/tcmd/components/RowList.xml +1 -0
  54. package/src/js/tcmd/components/Summary.html +3 -0
  55. package/src/js/tcmd/components/Summary.js +77 -0
  56. package/src/js/tcmd/components/Summary.xml +3 -0
  57. package/src/js/tcmd/components/bind.js +20 -0
  58. package/src/js/tcmd/components/format.js +155 -0
  59. package/src/js/tcmd/components/icons.js +185 -0
  60. package/src/js/tcmd/components/index.js +41 -0
  61. package/src/js/tcmd/components/rows.js +212 -0
  62. package/src/js/tcmd/core/bookmarks.js +175 -0
  63. package/src/js/tcmd/core/commands.js +930 -0
  64. package/src/js/tcmd/core/directory.js +194 -0
  65. package/src/js/tcmd/core/entries.js +88 -0
  66. package/src/js/tcmd/core/fs.js +266 -0
  67. package/src/js/tcmd/core/index.js +39 -0
  68. package/src/js/tcmd/core/keymap.js +60 -0
  69. package/src/js/tcmd/core/operations.js +453 -0
  70. package/src/js/tcmd/core/os.js +128 -0
  71. package/src/js/tcmd/core/pane.js +74 -0
  72. package/src/js/tcmd/core/panel.js +383 -0
  73. package/src/js/tcmd/core/path.js +57 -0
  74. package/src/js/tcmd/core/script.js +186 -0
  75. package/src/js/tcmd/core/selection.js +43 -0
  76. package/src/js/tcmd/core/session.js +405 -0
  77. package/src/js/tcmd/index.js +53 -0
  78. package/src/js/tcmd/tui/app.js +140 -0
  79. package/src/js/tcmd/tui/catalogue.js +300 -0
  80. package/src/js/tcmd/tui/frame.js +269 -0
  81. package/src/js/tcmd/tui/help.js +32 -0
  82. package/src/js/tcmd/tui/index.js +104 -0
  83. package/src/js/tcmd/tui/theme.js +177 -0
  84. package/src/js/tcmd/vendor/fzy.js +243 -0
  85. package/src/js/tcmd/web/app.js +154 -0
  86. package/src/js/tcmd/web/catalogue.js +159 -0
  87. package/src/js/tcmd/web/header.html +1 -0
  88. package/src/js/tcmd/web/header.js +38 -0
  89. package/src/js/tcmd/web/http.js +79 -0
  90. package/src/js/tcmd/web/index.js +22 -0
  91. package/src/js/tcmd/web/main.js +151 -0
  92. package/src/js/tcmd/web/opfs.js +282 -0
  93. package/src/js/tcmd/web/seed.js +90 -0
  94. package/src/js/tcmd/web/server.js +191 -0
  95. package/src/js/tcmd/web/theme.js +23 -0
@@ -0,0 +1,930 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-09-26
5
+
6
+ // Module: tcmd/core/commands
7
+ // Command registry and dispatch. Commands are pure operations over the
8
+ // session model: `run(session, payload)`. Views never mutate state directly —
9
+ // they call `session.dispatch(id, payload)`, keeping the model the single
10
+ // source of truth for every presentation layer.
11
+ //
12
+ // Commands carry presentation metadata:
13
+ // - `kind`: "ui" for UI-level actions; the command palette lists UI actions by
14
+ // default (scripting operations live in core/operations.js).
15
+ // - `bar`: true when the command appears in the command bar's key hints.
16
+ // - `hidden`: true for internal commands that never show in the palette.
17
+
18
+ import { basename, parent as parentOf } from "./path.js"
19
+ import { PANEL_FOCUS, argvFor, bindingAt, missingArg, rowAt, withToggledItem } from "./panel.js"
20
+ import { clampCursor, moveCursor } from "./selection.js"
21
+ import { runLine } from "./script.js"
22
+ import { getOperation, runOperation } from "./operations.js"
23
+
24
+ // Constant: PAGE / JUMP
25
+ // Cursor movement steps: a page for PgUp/PgDn, a shorter jump for the
26
+ // ctrl-arrow accelerators.
27
+ const PAGE = 10
28
+ const JUMP = 5
29
+
30
+ // Function: PALETTE_KEYS
31
+ // Command-bar hints shown while the command palette is open.
32
+ const PALETTE_KEYS = [
33
+ { id: null, title: "Navigate", keys: "↑/↓" },
34
+ { id: null, title: "Complete", keys: "Tab" },
35
+ { id: null, title: "Run", keys: "Enter", primary: true },
36
+ { id: null, title: "Close", keys: "Esc" },
37
+ ]
38
+
39
+ // Constant: SCRIPT_KEYS
40
+ // Command-bar hints for scripting mode (no UI navigation, only the palette
41
+ // itself).
42
+ const SCRIPT_KEYS = [
43
+ { id: null, title: "Complete", keys: "Tab" },
44
+ { id: null, title: "Run", keys: "Enter", primary: true },
45
+ { id: null, title: "Close", keys: "Esc" },
46
+ ]
47
+
48
+ // Constant: FILTER_KEYS
49
+ // Command-bar hints shown while a pane filter is capturing input.
50
+ const FILTER_KEYS = [
51
+ { id: null, title: "Navigate", keys: "↑/↓" },
52
+ { id: null, title: "Done", keys: "Enter", primary: true },
53
+ { id: null, title: "Clear", keys: "Esc" },
54
+ ]
55
+
56
+ // Constant: BOOKMARK_NAME_KEYS
57
+ // Command-bar hints while naming a bookmark.
58
+ const BOOKMARK_NAME_KEYS = [
59
+ { id: null, title: "Save", keys: "Enter", primary: true },
60
+ { id: null, title: "Cancel", keys: "Esc" },
61
+ ]
62
+
63
+ // Constant: BOOKMARK_LIST_KEYS
64
+ // Command-bar hints while picking a bookmark.
65
+ const BOOKMARK_LIST_KEYS = [
66
+ { id: null, title: "Navigate", keys: "↑/↓" },
67
+ { id: null, title: "Jump", keys: "Enter", primary: true },
68
+ { id: null, title: "Close", keys: "Esc" },
69
+ ]
70
+
71
+ // Constant: PANEL_HINT_KEYS
72
+ // Command-bar hints while the command panel is open.
73
+ const PANEL_HINT_KEYS = [
74
+ { id: null, title: "Command", keys: "↑/↓" },
75
+ { id: null, title: "Arg", keys: "⇥" },
76
+ { id: null, title: "Toggle", keys: "Spc" },
77
+ { id: null, title: "Run", keys: "↵", primary: true },
78
+ { id: null, title: "Close", keys: "Esc" },
79
+ ]
80
+
81
+ const commands = [
82
+ {
83
+ id: "nav.up",
84
+ title: "Cursor up",
85
+ keys: "↑",
86
+ kind: "ui",
87
+ run(session) {
88
+ bump(session, -1)
89
+ },
90
+ },
91
+ {
92
+ id: "nav.down",
93
+ title: "Cursor down",
94
+ keys: "↓",
95
+ kind: "ui",
96
+ run(session) {
97
+ bump(session, 1)
98
+ },
99
+ },
100
+ {
101
+ id: "nav.pageUp",
102
+ title: "Page up",
103
+ keys: "PgUp",
104
+ kind: "ui",
105
+ run(session) {
106
+ bump(session, -PAGE)
107
+ },
108
+ },
109
+ {
110
+ id: "nav.pageDown",
111
+ title: "Page down",
112
+ keys: "PgDn",
113
+ kind: "ui",
114
+ run(session) {
115
+ bump(session, PAGE)
116
+ },
117
+ },
118
+ {
119
+ id: "nav.jumpUp",
120
+ title: "Jump up",
121
+ keys: "^↑",
122
+ kind: "ui",
123
+ run(session) {
124
+ bump(session, -JUMP)
125
+ },
126
+ },
127
+ {
128
+ id: "nav.jumpDown",
129
+ title: "Jump down",
130
+ keys: "^↓",
131
+ kind: "ui",
132
+ run(session) {
133
+ bump(session, JUMP)
134
+ },
135
+ },
136
+ {
137
+ id: "nav.home",
138
+ title: "First entry",
139
+ keys: "Home",
140
+ kind: "ui",
141
+ run(session) {
142
+ pane(session).cursor.set(0)
143
+ },
144
+ },
145
+ {
146
+ id: "nav.end",
147
+ title: "Last entry",
148
+ keys: "End",
149
+ kind: "ui",
150
+ run(session) {
151
+ pane(session).cursor.set(Math.max(0, pane(session).entries.value.length - 1))
152
+ },
153
+ },
154
+ {
155
+ id: "pane.enter",
156
+ title: "Open",
157
+ keys: "Enter",
158
+ kind: "ui",
159
+ bar: true,
160
+ primary: true,
161
+ run(session) {
162
+ return pane(session).open()
163
+ },
164
+ },
165
+ {
166
+ id: "pane.up",
167
+ title: "Parent",
168
+ keys: "←",
169
+ kind: "ui",
170
+ bar: true,
171
+ run(session) {
172
+ return pane(session).up()
173
+ },
174
+ },
175
+ {
176
+ id: "pane.back",
177
+ title: "Back",
178
+ keys: "Esc",
179
+ kind: "ui",
180
+ run(session) {
181
+ return pane(session).back()
182
+ },
183
+ },
184
+ {
185
+ id: "pane.forward",
186
+ title: "Forward",
187
+ keys: "⇧Esc",
188
+ kind: "ui",
189
+ run(session) {
190
+ return pane(session).forward()
191
+ },
192
+ },
193
+ {
194
+ id: "pane.reload",
195
+ title: "Reload",
196
+ keys: "^R",
197
+ kind: "ui",
198
+ bar: true,
199
+ run(session) {
200
+ return pane(session).refresh()
201
+ },
202
+ },
203
+ {
204
+ id: "pane.toggle",
205
+ title: "Switch",
206
+ keys: "Tab",
207
+ kind: "ui",
208
+ bar: true,
209
+ run(session) {
210
+ session.active.set(session.active.value === "left" ? "right" : "left")
211
+ },
212
+ },
213
+ {
214
+ id: "list.mark",
215
+ title: "Mark",
216
+ keys: "Spc",
217
+ kind: "ui",
218
+ bar: true,
219
+ run(session) {
220
+ pane(session).toggleMark()
221
+ },
222
+ },
223
+ {
224
+ id: "bookmark.record",
225
+ title: "Bookmark",
226
+ keys: "b",
227
+ kind: "ui",
228
+ bar: true,
229
+ run(session) {
230
+ // Record the entry under the cursor; fall back to the pane's
231
+ // directory when the listing is empty.
232
+ const entry = pane(session).currentEntry()
233
+ const path = entry?.path ?? pane(session).path.value
234
+ const isDir = entry ? Boolean(entry.isDir) : true
235
+ const existing = session.bookmarks.find(path)
236
+ if (existing) {
237
+ // Already bookmarked: no need to re-name it.
238
+ session.notice.set({ kind: "tip", text: `bookmark ${existing.name}` })
239
+ return
240
+ }
241
+ // The default name is the path's basename.
242
+ session.bookmarkTarget.set({ path, isDir })
243
+ session.bookmarkQuery.set(basename(path))
244
+ session.bookmarkMode.set("name")
245
+ session.bookmarkOpen.set(true)
246
+ },
247
+ },
248
+ {
249
+ id: "bookmark.jump",
250
+ title: "Bookmarks",
251
+ keys: "g",
252
+ kind: "ui",
253
+ bar: true,
254
+ run(session) {
255
+ session.bookmarkTarget.set(null)
256
+ session.bookmarkQuery.set("")
257
+ session.bookmarkMode.set("list")
258
+ session.bookmarkOpen.set(true)
259
+ },
260
+ },
261
+ {
262
+ id: "bookmark.up",
263
+ title: "Previous bookmark",
264
+ kind: "ui",
265
+ hidden: true,
266
+ run(session) {
267
+ moveBookmark(session, -1)
268
+ },
269
+ },
270
+ {
271
+ id: "bookmark.down",
272
+ title: "Next bookmark",
273
+ kind: "ui",
274
+ hidden: true,
275
+ run(session) {
276
+ moveBookmark(session, 1)
277
+ },
278
+ },
279
+ {
280
+ id: "bookmark.save",
281
+ title: "Save bookmark",
282
+ kind: "ui",
283
+ hidden: true,
284
+ run(session) {
285
+ const name = `${session.bookmarkQuery.value ?? ""}`.trim()
286
+ const target = session.bookmarkTarget.value
287
+ if (!name) {
288
+ session.notice.set({ kind: "warning", text: "bookmark needs a name" })
289
+ return
290
+ }
291
+ if (!target) {
292
+ closeBookmark(session)
293
+ return
294
+ }
295
+ // Close synchronously: the global key handler and the input's
296
+ // `on:enter` can both fire, and a second save while the overlay is
297
+ // still open would re-record and flash the notice twice. A rejected
298
+ // `record` (rejected name) still surfaces through the dispatch's
299
+ // error notice.
300
+ closeBookmark(session)
301
+ return session.bookmarks.record(name, target.path, target.isDir).then(() => {
302
+ session.notice.set({ kind: "tip", text: `bookmarked ${name}` })
303
+ })
304
+ },
305
+ },
306
+ {
307
+ id: "bookmark.setIndex",
308
+ title: "Highlight bookmark",
309
+ kind: "ui",
310
+ hidden: true,
311
+ run(session, payload) {
312
+ session.bookmarkIndex.set(Math.max(0, payload?.index ?? 0))
313
+ },
314
+ },
315
+ {
316
+ id: "bookmark.select",
317
+ title: "Jump to bookmark",
318
+ kind: "ui",
319
+ hidden: true,
320
+ async run(session) {
321
+ const items = session.bookmarkItems.value
322
+ const item = items[clampCursor(session.bookmarkIndex.value, items.length)]
323
+ if (!item) return
324
+ const target = session.panes[session.active.value]
325
+ const path = item.path
326
+ // Resolve the kind when it is unknown (legacy path-only bookmark).
327
+ let isDir = item.isDir
328
+ if (isDir == null) isDir = Boolean((await session.fs.stat(path))?.isDir)
329
+ closeBookmark(session)
330
+ // A directory: open it (show its contents).
331
+ if (isDir) return target.navigate(path)
332
+ // A file: select it in its directory and preview it (a path at the
333
+ // filesystem root has no parent: stay at the root).
334
+ return target.navigate(parentOf(path) ?? "/").then(() => {
335
+ const entries = target.entries.value
336
+ const index = entries.findIndex((entry) => entry.path === path)
337
+ if (index < 0) return
338
+ target.cursor.set(index)
339
+ // Pin the preview so it shows even if the effect lags the cursor.
340
+ session.previewTarget.set(target.currentEntry() ?? null)
341
+ session.showPreview.set(true)
342
+ })
343
+ },
344
+ },
345
+ {
346
+ id: "bookmark.cancel",
347
+ title: "Cancel bookmark",
348
+ kind: "ui",
349
+ hidden: true,
350
+ run(session) {
351
+ closeBookmark(session)
352
+ },
353
+ },
354
+ {
355
+ id: "list.cursor",
356
+ title: "Set cursor",
357
+ kind: "ui",
358
+ hidden: true,
359
+ run(session, payload) {
360
+ const target = payload?.paneId ? session.panes[payload.paneId] : pane(session)
361
+ activate(session, payload?.paneId)
362
+ target.cursor.set(clampCursor(payload?.index ?? 0, target.entries.value.length))
363
+ },
364
+ },
365
+ {
366
+ id: "pane.activate",
367
+ title: "Activate pane",
368
+ kind: "ui",
369
+ hidden: true,
370
+ run(session, payload) {
371
+ activate(session, payload?.paneId)
372
+ },
373
+ },
374
+ {
375
+ id: "pane.setPath",
376
+ title: "Go to path",
377
+ kind: "ui",
378
+ hidden: true,
379
+ run(session, payload) {
380
+ const target = payload?.paneId ? session.panes[payload.paneId] : pane(session)
381
+ activate(session, payload?.paneId)
382
+ return target.navigate(payload?.path)
383
+ },
384
+ },
385
+ {
386
+ id: "view.togglePreview",
387
+ title: "Preview",
388
+ keys: "v",
389
+ kind: "ui",
390
+ bar: true,
391
+ run(session) {
392
+ session.showPreview.set(!session.showPreview.value)
393
+ },
394
+ },
395
+ {
396
+ id: "file.open",
397
+ title: "Open file",
398
+ keys: "O",
399
+ kind: "ui",
400
+ run(session) {
401
+ const entry = session.previewEntry.value
402
+ if (!entry) return
403
+ return session.os.open(entry.path)
404
+ },
405
+ },
406
+ {
407
+ id: "file.edit",
408
+ title: "Edit file",
409
+ keys: "E",
410
+ kind: "ui",
411
+ run(session) {
412
+ const entry = session.previewEntry.value
413
+ if (!entry || entry.isDir) {
414
+ session.notice.set({ kind: "warning", text: "no file to edit" })
415
+ return
416
+ }
417
+ return session.os.edit(entry.path).then(() => session.panes[session.active.value].refresh())
418
+ },
419
+ },
420
+ // Command panel (`:`/`m`): the structured Commando view over the
421
+ // operations. Lifecycle, then navigation, then draft editing, then run.
422
+ {
423
+ id: "panel.toggle",
424
+ title: "Commands",
425
+ keys: "m",
426
+ kind: "ui",
427
+ bar: true,
428
+ run(session) {
429
+ togglePanel(session)
430
+ },
431
+ },
432
+ {
433
+ id: "panel.open",
434
+ title: "Commands",
435
+ keys: ":",
436
+ kind: "ui",
437
+ hidden: true,
438
+ run(session) {
439
+ // `:` is the natural "command" key: open the structured panel on
440
+ // renderers that have it, and fall back to the flat script palette
441
+ // on those that do not (the web view).
442
+ if (!session.panelEnabled?.value) {
443
+ openPalette(session, "script", "")
444
+ return
445
+ }
446
+ openPanel(session)
447
+ },
448
+ },
449
+ {
450
+ id: "panel.up",
451
+ title: "Previous operation",
452
+ kind: "ui",
453
+ hidden: true,
454
+ run(session) {
455
+ const length = session.panelFiltered.value.length
456
+ session.panelOpIndex.set(moveCursor(session.panelOpIndex.value, -1, length))
457
+ },
458
+ },
459
+ {
460
+ id: "panel.down",
461
+ title: "Next operation",
462
+ kind: "ui",
463
+ hidden: true,
464
+ run(session) {
465
+ const length = session.panelFiltered.value.length
466
+ session.panelOpIndex.set(moveCursor(session.panelOpIndex.value, 1, length))
467
+ },
468
+ },
469
+ {
470
+ id: "panel.prevArg",
471
+ title: "Previous argument",
472
+ kind: "ui",
473
+ hidden: true,
474
+ run(session) {
475
+ const length = session.panelRows.value.length
476
+ session.panelRowIndex.set(moveCursor(session.panelRowIndex.value, -1, length))
477
+ },
478
+ },
479
+ {
480
+ id: "panel.nextArg",
481
+ title: "Next argument",
482
+ kind: "ui",
483
+ hidden: true,
484
+ run(session) {
485
+ const length = session.panelRows.value.length
486
+ session.panelRowIndex.set(moveCursor(session.panelRowIndex.value, 1, length))
487
+ },
488
+ },
489
+ {
490
+ id: "panel.tab",
491
+ title: "Next argument or command",
492
+ kind: "ui",
493
+ hidden: true,
494
+ run(session, payload) {
495
+ // Tab cycles the focus ring: the command (query) then each argument
496
+ // header, wrapping at both ends. Shift-Tab walks it backwards.
497
+ const delta = payload?.delta === -1 ? -1 : 1
498
+ const rows = session.panelRows.value
499
+ const headers = []
500
+ for (let i = 0; i < rows.length; i++) if (rows[i].type === "arg") headers.push(i)
501
+ if (!headers.length) return
502
+ if (session.panelFocus.value === PANEL_FOCUS.OPS) {
503
+ session.panelRowIndex.set(delta > 0 ? headers[0] : headers[headers.length - 1])
504
+ session.panelFocus.set(PANEL_FOCUS.ARGS)
505
+ return
506
+ }
507
+ const at = headers.indexOf(session.panelRowIndex.value)
508
+ if (delta > 0) {
509
+ if (at === -1 || at >= headers.length - 1) session.panelFocus.set(PANEL_FOCUS.OPS)
510
+ else session.panelRowIndex.set(headers[at + 1])
511
+ } else if (at <= 0) {
512
+ session.panelFocus.set(PANEL_FOCUS.OPS)
513
+ } else {
514
+ session.panelRowIndex.set(headers[at - 1])
515
+ }
516
+ },
517
+ },
518
+ {
519
+ id: "panel.setQuery",
520
+ title: "Filter commands",
521
+ kind: "ui",
522
+ hidden: true,
523
+ run(session, payload) {
524
+ session.panelQuery.set(`${payload?.query ?? ""}`)
525
+ },
526
+ },
527
+ {
528
+ id: "panel.type",
529
+ title: "Type into the command filter",
530
+ kind: "ui",
531
+ hidden: true,
532
+ run(session, payload) {
533
+ // A key pressed while a non-textual argument owns the cursor jumps
534
+ // back to the command column and edits the filter.
535
+ const query = `${session.panelQuery.value ?? ""}`
536
+ session.panelQuery.set(payload?.backspace ? query.slice(0, -1) : `${query}${payload?.char ?? ""}`)
537
+ session.panelFocus.set(PANEL_FOCUS.OPS)
538
+ },
539
+ },
540
+ {
541
+ id: "panel.setRow",
542
+ title: "Highlight argument",
543
+ kind: "ui",
544
+ hidden: true,
545
+ run(session, payload) {
546
+ if (payload?.index !== undefined) session.panelRowIndex.set(Math.max(0, payload.index))
547
+ },
548
+ },
549
+ {
550
+ id: "panel.setOp",
551
+ title: "Select operation",
552
+ kind: "ui",
553
+ hidden: true,
554
+ run(session, payload) {
555
+ // Selecting an operation (a click) moves the cursor without
556
+ // stealing focus from the command filter.
557
+ if (payload?.index !== undefined) session.panelOpIndex.set(Math.max(0, payload.index))
558
+ },
559
+ },
560
+ {
561
+ id: "panel.focusArg",
562
+ title: "Focus arguments",
563
+ kind: "ui",
564
+ hidden: true,
565
+ run(session) {
566
+ // A click on an argument focuses the argument column.
567
+ session.panelFocus.set(PANEL_FOCUS.ARGS)
568
+ },
569
+ },
570
+ {
571
+ id: "panel.toggleItem",
572
+ title: "Toggle argument item",
573
+ kind: "ui",
574
+ hidden: true,
575
+ run(session) {
576
+ const row = rowAt(session.panelRows.value, session.panelRowIndex.value)
577
+ if (!row) return
578
+ const draft = session.panelDraft.value
579
+ if (row.type === "item") {
580
+ session.panelDraft.set(withToggledItem(draft, row.argIndex, row.itemIndex))
581
+ return
582
+ }
583
+ // An argument header toggles its whole selection: select all when
584
+ // some are off, clear when all are on.
585
+ const binding = bindingAt(draft, row.argIndex)
586
+ if (!binding || binding.kind !== "selection") return
587
+ const all = (binding.items ?? []).length > 0 && (binding.items ?? []).every((item) => item.selected)
588
+ const items = (binding.items ?? []).map((item) => ({ ...item, selected: !all }))
589
+ session.panelDraft.set(draft.map((b) => (b.index === row.argIndex ? { ...b, items } : b)))
590
+ },
591
+ },
592
+ {
593
+ id: "panel.run",
594
+ title: "Run command",
595
+ kind: "ui",
596
+ hidden: true,
597
+ async run(session) {
598
+ const op = session.panelOperation.value
599
+ if (!op) return
600
+ // An unrunnable draft is refused with the missing argument's name —
601
+ // the `«NAME»` the preview already flags — instead of the generic
602
+ // usage error `runOperation` would produce.
603
+ const missing = missingArg(session.panelDraft.value)
604
+ if (missing) {
605
+ session.notice.set({ kind: "error", text: `${op.name}: fill in «${missing}»` })
606
+ return
607
+ }
608
+ const status = await runOperation(session, op, argvFor(session.panelDraft.value))
609
+ // A confirm keeps the panel open until the user answers; a run
610
+ // closes it. Errors stay open so the draft can be fixed.
611
+ if (status === "ran") closePanel(session)
612
+ },
613
+ },
614
+ {
615
+ id: "panel.cancel",
616
+ title: "Close command panel",
617
+ kind: "ui",
618
+ hidden: true,
619
+ run(session) {
620
+ closePanel(session)
621
+ },
622
+ },
623
+ {
624
+ id: "palette.toggle",
625
+ title: "Palette",
626
+ keys: "^P",
627
+ kind: "ui",
628
+ bar: true,
629
+ run(session) {
630
+ if (session.paletteOpen.value) {
631
+ closePalette(session)
632
+ return
633
+ }
634
+ openPalette(session, "ui", "")
635
+ },
636
+ },
637
+ {
638
+ id: "palette.script",
639
+ title: "Script",
640
+ kind: "ui",
641
+ run(session) {
642
+ openPalette(session, "script", "")
643
+ },
644
+ },
645
+ {
646
+ id: "palette.run",
647
+ title: "Run command",
648
+ keys: "!",
649
+ kind: "ui",
650
+ run(session) {
651
+ openPalette(session, "script", "!")
652
+ },
653
+ },
654
+ {
655
+ id: "filter.start",
656
+ title: "Filter",
657
+ keys: "/",
658
+ kind: "ui",
659
+ bar: true,
660
+ run(session) {
661
+ session.filtering.set(session.active.value)
662
+ },
663
+ },
664
+ {
665
+ id: "filter.accept",
666
+ title: "Keep filter",
667
+ kind: "ui",
668
+ hidden: true,
669
+ run(session) {
670
+ session.filtering.set(null)
671
+ },
672
+ },
673
+ {
674
+ id: "filter.clear",
675
+ title: "Clear filter",
676
+ kind: "ui",
677
+ hidden: true,
678
+ run(session) {
679
+ pane(session).clearFilter()
680
+ session.filtering.set(null)
681
+ },
682
+ },
683
+ {
684
+ id: "palette.up",
685
+ title: "Previous command",
686
+ kind: "ui",
687
+ hidden: true,
688
+ run(session) {
689
+ movePalette(session, -1)
690
+ },
691
+ },
692
+ {
693
+ id: "palette.down",
694
+ title: "Next command",
695
+ kind: "ui",
696
+ hidden: true,
697
+ run(session) {
698
+ movePalette(session, 1)
699
+ },
700
+ },
701
+ {
702
+ id: "palette.complete",
703
+ title: "Complete",
704
+ kind: "ui",
705
+ hidden: true,
706
+ run(session) {
707
+ const items = session.paletteItems.value
708
+ const item = items[clampCursor(session.paletteIndex.value, items.length)]
709
+ if (item?.completion) session.paletteQuery.set(item.completion)
710
+ },
711
+ },
712
+ {
713
+ id: "palette.setQuery",
714
+ title: "Set palette query",
715
+ kind: "ui",
716
+ hidden: true,
717
+ run(session, payload) {
718
+ session.paletteQuery.set(`${payload?.query ?? ""}`)
719
+ },
720
+ },
721
+ {
722
+ id: "palette.submit",
723
+ title: "Submit palette",
724
+ kind: "ui",
725
+ hidden: true,
726
+ run(session) {
727
+ const query = `${session.paletteQuery.value ?? ""}`.trim()
728
+ if (!query) {
729
+ // Nothing to run: complete the highlighted row instead.
730
+ const items = session.paletteItems.value
731
+ const item = items[clampCursor(session.paletteIndex.value, items.length)]
732
+ if (item?.completion) session.paletteQuery.set(item.completion)
733
+ return
734
+ }
735
+ return runLine(session, query).then((status) => {
736
+ if (status !== "empty") closePalette(session)
737
+ return status
738
+ })
739
+ },
740
+ },
741
+ {
742
+ id: "confirm.yes",
743
+ title: "Confirm",
744
+ kind: "ui",
745
+ hidden: true,
746
+ run(session) {
747
+ const pending = session.confirm.value
748
+ if (!pending) return
749
+ session.confirm.set(null)
750
+ if (pending.op) {
751
+ const op = getOperation(pending.op)
752
+ if (!op) return null
753
+ // A panel-originated confirm re-runs its draft and, on success,
754
+ // closes the panel that was waiting on it.
755
+ return runOperation(session, op, pending.args, { confirmed: true }).then((status) => {
756
+ if (status === "ran" && session.panelOpen?.value) closePanel(session)
757
+ return status
758
+ })
759
+ }
760
+ return runLine(session, pending.line, { confirmed: true })
761
+ },
762
+ },
763
+ {
764
+ id: "confirm.no",
765
+ title: "Cancel",
766
+ kind: "ui",
767
+ hidden: true,
768
+ run(session) {
769
+ session.confirm.set(null)
770
+ },
771
+ },
772
+ // Quit: presentation-only. The key path intercepts `app.quit` before
773
+ // dispatch, and the command-bar click does the same (the renderer owns
774
+ // exiting); the registry entry exists so the bar and the `?` reference
775
+ // can advertise it. Hidden from the palette, where it could only no-op.
776
+ {
777
+ id: "app.quit",
778
+ title: "Quit",
779
+ keys: "q",
780
+ kind: "ui",
781
+ bar: true,
782
+ hidden: true,
783
+ run() {},
784
+ },
785
+ ]
786
+
787
+ // Function: getCommands
788
+ // The full command registry.
789
+ function getCommands() {
790
+ return commands
791
+ }
792
+
793
+ // Function: getCommand
794
+ // Looks up a command by id.
795
+ function getCommand(id) {
796
+ for (let i = 0; i < commands.length; i++) {
797
+ if (commands[i].id === id) return commands[i]
798
+ }
799
+ return null
800
+ }
801
+
802
+ // Function: getPaletteCommands
803
+ // Commands the command palette lists in UI mode: user-visible UI actions.
804
+ function getPaletteCommands() {
805
+ return commands.filter((command) => !command.hidden && (command.kind ?? "ui") === "ui")
806
+ }
807
+
808
+ // Function: getCommandBarItems
809
+ // Command-bar rows for the current context: the palette's key hints while it
810
+ // is open (scripting mode has its own), the filter hints while a pane filter is
811
+ // capturing input, the `bar`-flagged UI commands otherwise (keymap order).
812
+ function getCommandBarItems(paletteOpen, paletteMode, filtering, bookmarkOpen, bookmarkMode, panelOpen) {
813
+ if (panelOpen) return PANEL_HINT_KEYS
814
+ if (bookmarkOpen) return bookmarkMode === "name" ? BOOKMARK_NAME_KEYS : BOOKMARK_LIST_KEYS
815
+ if (paletteOpen) return paletteMode === "script" ? SCRIPT_KEYS : PALETTE_KEYS
816
+ if (filtering) return FILTER_KEYS
817
+ return commands.filter((command) => command.bar).map(commandRow)
818
+ }
819
+
820
+ // Function: commandRow
821
+ // Projects a command to the small row object views render.
822
+ function commandRow(command) {
823
+ return {
824
+ id: command.id,
825
+ title: command.title,
826
+ keys: command.keys,
827
+ kind: command.kind ?? "ui",
828
+ primary: command.primary === true,
829
+ completion: command.completion ?? command.title,
830
+ }
831
+ }
832
+
833
+ // Function: createDispatch
834
+ // Returns `dispatch(id, payload)` bound to the session. Unknown and `app.*`
835
+ // (presentation-only) commands are ignored here. Failures (sync throws and
836
+ // rejections) land on the active pane's error cell and the session notice.
837
+ function createDispatch(session) {
838
+ return (id, payload) => {
839
+ const command = getCommand(id)
840
+ if (!command) return null
841
+ session.notice?.set(null)
842
+ try {
843
+ return Promise.resolve(command.run(session, payload)).catch((error) => fail(session, error))
844
+ } catch (error) {
845
+ return fail(session, error)
846
+ }
847
+ }
848
+ }
849
+
850
+ function fail(session, error) {
851
+ const message = `${error?.message ?? error}`
852
+ pane(session).error.set(message)
853
+ session.notice?.set({ kind: "error", text: message })
854
+ return null
855
+ }
856
+
857
+ function pane(session) {
858
+ return session.panes[session.active.value]
859
+ }
860
+
861
+ // Function: activate
862
+ // Makes `paneId` the active pane (no-op for unknown ids or when already
863
+ // active). Clicks on rows, crumbs, tabs and pane bodies all route here.
864
+ function activate(session, paneId) {
865
+ if (!paneId || !session.panes[paneId]) return
866
+ if (session.active.value !== paneId) session.active.set(paneId)
867
+ }
868
+
869
+ function bump(session, delta) {
870
+ const p = pane(session)
871
+ p.cursor.set(moveCursor(p.cursor.value, delta, p.entries.value.length))
872
+ }
873
+
874
+ function movePalette(session, delta) {
875
+ const length = session.paletteItems.value.length
876
+ session.paletteIndex.set(moveCursor(session.paletteIndex.value, delta, length))
877
+ }
878
+
879
+ function moveBookmark(session, delta) {
880
+ const length = session.bookmarkItems.value.length
881
+ session.bookmarkIndex.set(moveCursor(session.bookmarkIndex.value, delta, length))
882
+ }
883
+
884
+ function closeBookmark(session) {
885
+ session.bookmarkOpen.set(false)
886
+ }
887
+
888
+ function openPanel(session) {
889
+ session.panelQuery.set("")
890
+ session.panelOpIndex.set(0)
891
+ session.panelRowIndex.set(0)
892
+ session.panelFocus.set(PANEL_FOCUS.OPS)
893
+ session.panelOpen.set(true)
894
+ }
895
+
896
+ // Function: togglePanel
897
+ // Opens the command panel, or closes it when already open. Disabled (TUI-only)
898
+ // renderers ignore it.
899
+ function togglePanel(session) {
900
+ if (!session.panelEnabled?.value) return
901
+ if (session.panelOpen.value) {
902
+ closePanel(session)
903
+ return
904
+ }
905
+ openPanel(session)
906
+ }
907
+
908
+ function closePanel(session) {
909
+ session.panelOpen.set(false)
910
+ }
911
+
912
+ function openPalette(session, mode, query) {
913
+ session.paletteMode.set(mode)
914
+ session.paletteQuery.set(query)
915
+ session.paletteIndex.set(0)
916
+ session.paletteOpen.set(true)
917
+ }
918
+
919
+ function closePalette(session) {
920
+ session.paletteOpen.set(false)
921
+ }
922
+
923
+ export {
924
+ commandRow,
925
+ createDispatch,
926
+ getCommand,
927
+ getCommandBarItems,
928
+ getCommands,
929
+ getPaletteCommands,
930
+ }