@ssebastien/tcmd 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +121 -38
  2. package/bin/tcmd +10 -2
  3. package/bin/tcmd.js +13 -2
  4. package/package.json +4 -3
  5. package/public/index.html +14 -0
  6. package/public/main.js +17501 -0
  7. package/public/style.css +901 -0
  8. package/scripts/serve-web.js +61 -0
  9. package/src/js/tcmd/components/App.html +28 -12
  10. package/src/js/tcmd/components/App.js +135 -85
  11. package/src/js/tcmd/components/App.xml +64 -15
  12. package/src/js/tcmd/components/BookmarkList.html +3 -2
  13. package/src/js/tcmd/components/BookmarkList.js +1 -1
  14. package/src/js/tcmd/components/Breadcrumbs.js +1 -1
  15. package/src/js/tcmd/components/CommandArgRow.html +17 -0
  16. package/src/js/tcmd/components/CommandArgRow.js +86 -26
  17. package/src/js/tcmd/components/CommandArgRow.xml +31 -4
  18. package/src/js/tcmd/components/CommandBar.html +1 -1
  19. package/src/js/tcmd/components/CommandBar.js +3 -3
  20. package/src/js/tcmd/components/CommandItem.html +1 -1
  21. package/src/js/tcmd/components/CommandItem.js +1 -0
  22. package/src/js/tcmd/components/CommandPanel.html +29 -0
  23. package/src/js/tcmd/components/CommandPanel.js +54 -52
  24. package/src/js/tcmd/components/CommandPanel.xml +38 -24
  25. package/src/js/tcmd/components/Confirm.html +1 -1
  26. package/src/js/tcmd/components/EntryDetail.html +7 -7
  27. package/src/js/tcmd/components/EntryDetail.js +1 -1
  28. package/src/js/tcmd/components/FileList.html +5 -1
  29. package/src/js/tcmd/components/FileList.js +2 -1
  30. package/src/js/tcmd/components/FilePreview.html +14 -3
  31. package/src/js/tcmd/components/FilePreview.js +73 -10
  32. package/src/js/tcmd/components/FilePreview.xml +19 -6
  33. package/src/js/tcmd/components/FileRow.html +1 -1
  34. package/src/js/tcmd/components/FileRow.js +10 -3
  35. package/src/js/tcmd/components/FilterBar.html +1 -1
  36. package/src/js/tcmd/components/FilterBar.js +14 -4
  37. package/src/js/tcmd/components/FilterBar.xml +1 -1
  38. package/src/js/tcmd/{tui/frame.js → components/Frame.js} +120 -91
  39. package/src/js/tcmd/components/Help.html +7 -0
  40. package/src/js/tcmd/components/Help.js +109 -0
  41. package/src/js/tcmd/components/Summary.html +1 -1
  42. package/src/js/tcmd/components/Summary.js +1 -6
  43. package/src/js/tcmd/components/icons.js +39 -5
  44. package/src/js/tcmd/components/index.js +3 -4
  45. package/src/js/tcmd/components/rows.js +37 -33
  46. package/src/js/tcmd/core/commands.js +422 -180
  47. package/src/js/tcmd/core/directory.js +145 -95
  48. package/src/js/tcmd/core/fs.js +29 -2
  49. package/src/js/tcmd/core/history.js +58 -0
  50. package/src/js/tcmd/core/index.js +3 -9
  51. package/src/js/tcmd/core/keymap.js +3 -3
  52. package/src/js/tcmd/core/operations.js +108 -15
  53. package/src/js/tcmd/core/pane.js +31 -8
  54. package/src/js/tcmd/core/panel.js +172 -82
  55. package/src/js/tcmd/core/script.js +7 -95
  56. package/src/js/tcmd/core/selection.js +9 -1
  57. package/src/js/tcmd/core/session.js +280 -152
  58. package/src/js/tcmd/index.js +0 -1
  59. package/src/js/tcmd/tui/app.js +26 -5
  60. package/src/js/tcmd/tui/catalogue.js +6 -19
  61. package/src/js/tcmd/tui/index.js +1 -16
  62. package/src/js/tcmd/tui/theme.js +5 -0
  63. package/src/js/tcmd/web/app.js +158 -32
  64. package/src/js/tcmd/web/bookmarks.js +36 -0
  65. package/src/js/tcmd/web/catalogue.js +45 -39
  66. package/src/js/tcmd/web/effects.js +143 -0
  67. package/src/js/tcmd/web/header.html +1 -1
  68. package/src/js/tcmd/web/header.js +15 -11
  69. package/src/js/tcmd/web/main.js +6 -66
  70. package/src/js/tcmd/web/seed.js +3 -3
  71. package/src/js/tcmd/web/server.js +6 -4
  72. package/src/js/tcmd/web/theme.js +1 -0
  73. package/src/js/tcmd/components/CommandPalette.html +0 -7
  74. package/src/js/tcmd/components/CommandPalette.js +0 -58
  75. package/src/js/tcmd/components/CommandPalette.xml +0 -16
  76. package/src/js/tcmd/components/PaletteRow.html +0 -3
  77. package/src/js/tcmd/components/PaletteRow.js +0 -43
  78. package/src/js/tcmd/components/PaletteRow.xml +0 -4
  79. package/src/js/tcmd/tui/help.js +0 -32
@@ -6,8 +6,11 @@
6
6
  // Module: tcmd/core/pane
7
7
  // A Pane is one column with a single directory: it owns that directory and
8
8
  // proxies its cells (path, entries, cursor, marks, disk, …) as pane-level
9
- // cells, so views and commands address the pane directly. Navigation is async
10
- // and updates the directory's `rawEntries` in place.
9
+ // cells, so views and commands address the pane directly. Navigation reads the
10
+ // target first and commits atomically (see `core/directory#read`), so the pane
11
+ // keeps showing its current listing until the replacement is ready. The pane
12
+ // also owns its navigation history (back/forward snapshots of the view), which
13
+ // the session drives to restore position, selection and focus.
11
14
 
12
15
  import { cell } from "select/state/cells"
13
16
  import { createDirectory } from "./directory.js"
@@ -22,6 +25,23 @@ const PANE_BUFFERS = { left: "A", right: "B" }
22
25
  // destination (DST).
23
26
  const PANE_LABELS = { left: "SRC", right: "DST" }
24
27
 
28
+ // Function: currentPaneId
29
+ // The id of the pane that owns the current directory: the focused pane while
30
+ // focus is on source/destination, the anchor pane otherwise.
31
+ function currentPaneId(session) {
32
+ return session.currentId?.value ?? session.active?.value ?? "left"
33
+ }
34
+
35
+ // Function: currentPane
36
+ // The pane that owns the current directory: the focused pane while focus is
37
+ // on source/destination, the anchor pane while it is on an auxiliary column
38
+ // (see `core/session`). Path-changing commands — open, up, history,
39
+ // breadcrumbs, relative arguments — target it; only cursor movement follows
40
+ // the focused column. `core/commands` keeps `pane()` for the latter.
41
+ function currentPane(session) {
42
+ return session.panes[currentPaneId(session)] ?? session.panes.left
43
+ }
44
+
25
45
  // Function: createPane
26
46
  // Creates a pane bound to a filesystem adapter at `path`.
27
47
  //
@@ -31,10 +51,10 @@ const PANE_LABELS = { left: "SRC", right: "DST" }
31
51
  // await pane.refresh()
32
52
  // pane.entries.value // sorted, filtered FileEntry[]
33
53
  // ```
34
- function createPane({ fs, path: initialPath } = {}) {
54
+ function createPane({ fs, path: initialPath, context } = {}) {
35
55
  const pane = {}
36
56
  pane.fs = fs
37
- pane.directory = createDirectory({ fs, path: initialPath })
57
+ pane.directory = createDirectory({ fs, path: initialPath, context })
38
58
 
39
59
  // Proxy cells mirror the directory, so views address the pane uniformly.
40
60
  const dir = pane.directory
@@ -46,29 +66,32 @@ function createPane({ fs, path: initialPath } = {}) {
46
66
  pane.sort = dir.sort
47
67
  pane.entries = dir.entries
48
68
  pane.cursor = dir.cursor
49
- pane.cursorStat = dir.cursorStat
50
69
  pane.marked = dir.marked
51
70
  pane.disk = dir.disk
52
71
  pane.segments = dir.segments
72
+ pane.history = dir.history
53
73
 
54
74
  // Function: current
55
75
  // The pane's directory.
56
76
  pane.current = () => pane.directory
57
77
 
58
78
  // Function: refresh / navigate / up / open / back / forward /
59
- // currentEntry / toggleMark / clearFilter — delegate to the directory.
79
+ // currentEntry / toggleMark / setFilter / clearFilter — delegate to the
80
+ // directory.
60
81
  pane.refresh = () => pane.directory.refresh()
61
- pane.navigate = (path) => pane.directory.navigate(path)
82
+ pane.navigate = (path, options) => pane.directory.navigate(path, options)
62
83
  pane.up = () => pane.directory.up()
63
84
  pane.open = () => pane.directory.open()
64
85
  pane.back = () => pane.directory.back()
65
86
  pane.forward = () => pane.directory.forward()
66
87
  pane.currentEntry = () => pane.directory.currentEntry()
67
88
  pane.toggleMark = () => pane.directory.toggleMark()
89
+ pane.setFilter = (query) => pane.directory.setFilter(query)
68
90
  pane.clearFilter = () => pane.directory.clearFilter()
91
+ pane.setContext = (context) => pane.directory.setContext(context)
69
92
  pane.dispose = () => pane.directory.dispose()
70
93
 
71
94
  return pane
72
95
  }
73
96
 
74
- export { PANE_BUFFERS, PANE_LABELS, createPane }
97
+ export { PANE_BUFFERS, PANE_LABELS, createPane, currentPane, currentPaneId }
@@ -4,14 +4,17 @@
4
4
  // Created: 2026-10-09
5
5
 
6
6
  // Module: tcmd/core/panel
7
- // The command panel (Commando): a structured view over the operation registry.
8
- // It lists the operations alphabetically — ranked by match importance once a
9
- // query filters them (see `filterPanelOperations`) — and, for the selected
10
- // one, its arguments bound from
11
- // the live context — a selection argument shows the SRC/DST pane's marked paths
12
- // (falling back to the cursor entry) and lets the user toggle individual items;
13
- // a destination argument resolves to the DST pane's directory; textual
14
- // arguments (a name, a buffer label, a shell command) are typed in place.
7
+ // The command panel (Commando): the one entry point for the CLI operations
8
+ // (`copy`, `move`, `rename`, `delete`, `run` — the `core/operations` registry).
9
+ // They are listed flat in one column, ranked by match importance once a query
10
+ // filters them (see `filterPanelEntries`). The internal TinyCmd actions are not
11
+ // listed here — they live on their own keys.
12
+ //
13
+ // For the selected operation, the panel binds its arguments from the live
14
+ // context — a selection argument shows the SRC/DST pane's marked paths (falling
15
+ // back to the cursor entry) and lets the user toggle individual items; a
16
+ // destination argument resolves to the DST pane's directory; textual arguments
17
+ // (a name, a buffer label, a shell command) are typed in place.
15
18
  //
16
19
  // The selection the user tweaks is *transient*: it lives in the draft bindings
17
20
  // (`session.panelDraft`), never in the real pane marks. When the draft runs,
@@ -23,71 +26,100 @@
23
26
  // `core/commands` assemble and run.
24
27
 
25
28
  import { hasMatch, score } from "../vendor/fzy.js"
26
- import { PANE_BUFFERS, PANE_LABELS } from "./pane.js"
29
+ import { currentPaneId, PANE_BUFFERS, PANE_LABELS } from "./pane.js"
27
30
  import { basename, parent } from "./path.js"
28
31
  import { getOperations, pathToken } from "./operations.js"
29
32
 
30
33
  // Constant: PANEL_FOCUS
31
- // The two columns the panel's focus ring moves through: "ops" (the operation
32
- // list, where typing filters) and "args" (an argument row, where Space toggles
33
- // and a textual argument owns the keyboard).
34
+ // The two regions the panel's focus ring moves through: "ops" (the filter and
35
+ // the command list on the left) and "args" (an argument row or item in the
36
+ // right pane, where a textual argument owns the keyboard and Space toggles a
37
+ // selection item).
34
38
  const PANEL_FOCUS = { OPS: "ops", ARGS: "args" }
35
39
 
36
- // Function: panelOperations
37
- // The operations the panel lists: every visible registry entry that carries a
38
- // structured `args` signature (the ones that take arguments).
39
- function panelOperations() {
40
- return getOperations().filter((op) => !op.hidden && Array.isArray(op.args))
40
+ // Function: panelEntries
41
+ // The entries the panel lists: the CLI operations. They are the only namespace
42
+ // — the internal TinyCmd actions are reached by their own keys, not the panel.
43
+ function panelEntries() {
44
+ return getOperations()
45
+ .filter((op) => !op.hidden)
46
+ .map((op) => ({
47
+ id: `op:${op.name}`,
48
+ name: op.name,
49
+ summary: `${op.signature ?? ""}`.replace(/^\S+\s*/, ""),
50
+ signature: op.signature ?? "",
51
+ risk: op.risk ?? null,
52
+ matchNames: [op.name, ...(op.aliases ?? [])],
53
+ op,
54
+ }))
41
55
  }
42
56
 
43
- // Function: compareOperations
44
- // Alphabetical order by name, the panel's default ordering.
45
- function compareOperations(a, b) {
57
+ // Function: compareEntries
58
+ // The panel's default ordering: alphabetically by name.
59
+ function compareEntries(a, b) {
46
60
  return `${a?.name ?? ""}`.localeCompare(`${b?.name ?? ""}`)
47
61
  }
48
62
 
49
- // Function: operationScore
50
- // The match importance of an operation for a query: the best fzy score across
51
- // its name and aliases, or `-Infinity` when neither matches (only the
52
- // signature does). Signature hits stay listable but rank below any name
53
- // match.
54
- function operationScore(op, query) {
63
+ // Function: entryScore
64
+ // The match importance of an entry for a query: the best fzy score across its
65
+ // names, or `-Infinity` when none matches (only the signature does).
66
+ function entryScore(entry, query) {
55
67
  let best = -Infinity
56
- for (const name of [op.name, ...(op.aliases ?? [])]) {
57
- if (hasMatch(query, name)) best = Math.max(best, score(query, name))
68
+ for (const name of entry.matchNames ?? []) {
69
+ const haystack = `${name}`.toLowerCase()
70
+ if (hasMatch(query, haystack)) best = Math.max(best, score(query, haystack))
58
71
  }
59
72
  return best
60
73
  }
61
74
 
62
- // Function: filterPanelOperations
63
- // Narrows the operation list by a typed query and orders it: no query lists
64
- // every operation alphabetically; a query ranks the matches by importance
65
- // (the fzy score of the best name/alias match, so `open` outranks `copy` for
66
- // `op`), ties broken alphabetically. A query that only matches an operation's
67
- // signature (`buf` -> `sel [+|-][BUF] PATH...`) keeps it listable, at the
68
- // bottom.
69
- function filterPanelOperations(operations, query) {
70
- const list = operations ?? []
75
+ // Function: filterPanelEntries
76
+ // Narrows the entry list by a typed query and orders it: no query lists the
77
+ // operations alphabetically; a query ranks the matches by importance (the fzy
78
+ // score of the best name match, so `open` outranks `copy` for `op`), ties
79
+ // alphabetical. A query that only matches an operation's signature (`buf` ->
80
+ // `sel [+|-][BUF] PATH...`) keeps it listable, at the bottom.
81
+ function filterPanelEntries(entries, query) {
82
+ const list = entries ?? []
71
83
  const q = `${query ?? ""}`.trim().toLowerCase()
72
- if (!q) return [...list].sort(compareOperations)
84
+ if (!q) return [...list].sort(compareEntries)
73
85
  const ranked = []
74
- for (const op of list) {
75
- const rank = operationScore(op, q)
76
- const signatureMatch = `${op.signature ?? ""}`.toLowerCase().includes(q)
86
+ for (const entry of list) {
87
+ const rank = entryScore(entry, q)
88
+ const signatureMatch = `${entry.signature ?? ""}`.toLowerCase().includes(q)
77
89
  if (rank === -Infinity && !signatureMatch) continue
78
- ranked.push({ op, score: rank })
90
+ ranked.push({ entry, score: rank })
79
91
  }
80
- ranked.sort((a, b) => (a.score !== b.score ? b.score - a.score : compareOperations(a.op, b.op)))
81
- return ranked.map((match) => match.op)
92
+ ranked.sort((a, b) => (a.score !== b.score ? b.score - a.score : compareEntries(a.entry, b.entry)))
93
+ return ranked.map((match) => match.entry)
94
+ }
95
+
96
+ // Function: panelEntryRows
97
+ // Projects the filtered entries into the flat list the command-list column
98
+ // renders: one `op` row per entry, tagged with its index in the *entries* list
99
+ // (what the cursor `panelOpIndex` addresses).
100
+ function panelEntryRows(entries) {
101
+ return (entries ?? []).map((entry, index) => ({
102
+ type: "op",
103
+ id: entry.id,
104
+ entryIndex: index,
105
+ name: entry.name,
106
+ summary: entry.summary,
107
+ risk: entry.risk,
108
+ }))
82
109
  }
83
110
 
84
111
  // Function: resolvePaneId
85
- // Maps an argument role to a pane id: "source" -> SRC (left), "dest" -> DST
86
- // (right), anything else -> the active pane.
112
+ // Maps an argument role to a pane id: "source" -> the working pane (SRC in
113
+ // commander; the pane the explorer is actually browsing, which may be either
114
+ // side), "dest" -> DST (right), anything else -> the pane holding the current
115
+ // directory (never an auxiliary column: operations run against the directory
116
+ // being browsed).
87
117
  function resolvePaneId(session, role) {
88
- if (role === "source") return "left"
89
118
  if (role === "dest") return "right"
90
- return session.active.value
119
+ if (role === "source") {
120
+ return session.viewMode?.value === "explorer" ? currentPaneId(session) : "left"
121
+ }
122
+ return currentPaneId(session)
91
123
  }
92
124
 
93
125
  // Function: paneSelectionPaths
@@ -136,7 +168,8 @@ function bindArg(session, arg, index) {
136
168
  role,
137
169
  optional: arg.optional === true,
138
170
  variadic: arg.variadic === true,
139
- textual: arg.kind === "literal" || arg.kind === "raw" || arg.kind === "buffer",
171
+ textual:
172
+ arg.kind === "literal" || arg.kind === "raw" || arg.kind === "buffer" || arg.kind === "path",
140
173
  }
141
174
  if (arg.kind === "selection") {
142
175
  const paneId = resolvePaneId(session, role)
@@ -145,26 +178,37 @@ function bindArg(session, arg, index) {
145
178
  return {
146
179
  ...common,
147
180
  paneId,
181
+ // A variadic selection accepts many marked paths (checkboxes); a
182
+ // single-path argument accepts one (a radio list).
183
+ multi: arg.variadic === true,
148
184
  items: paneSelectionPaths(pane).map((path) => itemFor(entries, path)),
149
185
  value: "",
150
186
  }
151
187
  }
152
- if (arg.kind === "dest") {
153
- const pane = session.panes.right
154
- const path = pane?.path?.value ?? ""
155
- // The destination is editable in place, like a literal (no toggles).
156
- return { ...common, paneId: "right", textual: true, items: [], value: path }
188
+ if (arg.kind === "dest" || arg.kind === "path") {
189
+ // A destination or a path argument is typeable in place, like a
190
+ // literal, and additionally browsable through the panel's file picker
191
+ // (`picker: true`, opened with `panel.browse`). In commander mode the
192
+ // DST pane is the natural destination, so a directory argument
193
+ // pre-fills from it; explorer mode has no destination pane, so it opens
194
+ // empty for the picker (or the user) to fill.
195
+ const mode = arg.kind === "dest" ? "dir" : arg.mode ?? "any"
196
+ const commander = (session.viewMode?.value ?? "commander") !== "explorer"
197
+ const value = mode === "dir" && commander ? session.panes?.right?.path?.value ?? "" : ""
198
+ return { ...common, textual: true, picker: true, mode, items: [], value }
157
199
  }
158
200
  if (arg.kind === "buffer") {
159
- return { ...common, items: [], value: `+${PANE_BUFFERS[session.active.value] ?? "A"}` }
201
+ const label = PANE_BUFFERS[session.currentId?.value ?? session.active.value] ?? "A"
202
+ return { ...common, items: [], value: `+${label}` }
160
203
  }
161
204
  return { ...common, items: [], value: "" }
162
205
  }
163
206
 
164
207
  // Function: bindArgs
165
- // Binds every argument of `op` to the live context.
166
- function bindArgs(session, op) {
167
- return (op?.args ?? []).map((arg, index) => bindArg(session, arg, index))
208
+ // Binds every argument of the selected entry to the live context. Every
209
+ // operation carries an `args` signature.
210
+ function bindArgs(session, entry) {
211
+ return (entry?.op?.args ?? []).map((arg, index) => bindArg(session, arg, index))
168
212
  }
169
213
 
170
214
  // Function: selectedPaths
@@ -200,23 +244,31 @@ function argvFor(bindings) {
200
244
  return args
201
245
  }
202
246
 
203
- // Function: missingArg
204
- // The name of the first argument that is not runnable: a required argument left
205
- // blank, an optional one before a filled one (a positional gap would shift the
206
- // following tokens), or a selection with nothing selected. Returns null when the
207
- // argv is runnable.
208
- function missingArg(bindings) {
247
+ // Function: missingBinding
248
+ // The first binding that is not runnable: a required argument left blank, an
249
+ // optional one before a filled one (a positional gap would shift the following
250
+ // tokens), or a selection with nothing selected. Returns null when the argv is
251
+ // runnable. The panel uses it both to flag an unrunnable draft and to focus the
252
+ // next thing to fill when Enter lands on an incomplete command.
253
+ function missingBinding(bindings) {
209
254
  const list = bindings ?? []
210
255
  const args = argTokens(list)
211
256
  for (let i = 0; i < args.length - 1; i++) {
212
- if (args[i] === "") return list[i].name
257
+ if (args[i] === "") return list[i]
213
258
  }
214
259
  // A trailing required argument must still be filled.
215
260
  const trailing = list[list.length - 1]
216
- if (trailing && args[args.length - 1] === "" && trailing.optional !== true) return trailing.name
261
+ if (trailing && args[args.length - 1] === "" && trailing.optional !== true) return trailing
217
262
  return null
218
263
  }
219
264
 
265
+ // Function: missingArg
266
+ // The name of the first argument that is not runnable (see `missingBinding`),
267
+ // or null when the argv is runnable.
268
+ function missingArg(bindings) {
269
+ return missingBinding(bindings)?.name ?? null
270
+ }
271
+
220
272
  // Function: shellQuote
221
273
  // Quotes a token for display as it would appear in a shell: bare when it is
222
274
  // already safe (`/home/a`), single-quoted when it contains spaces or shell
@@ -269,41 +321,56 @@ function panelSummary(binding) {
269
321
  const count = selectedPaths(binding).length
270
322
  return `${pane} · ${count} selected`
271
323
  }
272
- if (binding.kind === "dest") return binding.value || "(none)"
324
+ if (binding.kind === "dest" || binding.kind === "path") return binding.value || "(none)"
273
325
  if (binding.kind === "buffer") return binding.value || "(active)"
274
326
  return binding.value ? binding.value : "(empty)"
275
327
  }
276
328
 
277
329
  // Function: panelRowList
278
- // Flattens the draft into the rows the panel's argument column renders: one
279
- // header row per argument, followed by one item row per item of a
280
- // selection/destination argument (so individual paths can be toggled). Row
281
- // focus is assigned by the view (comparing the row index to the cursor), the
282
- // same way list rows are; this stays pure and index-stable.
330
+ // Flattens the draft into the rows the panel's right-hand argument pane
331
+ // renders: one header row per argument (its label and value/summary),
332
+ // followed by one item row per selected path of a selection argument (so
333
+ // individual paths can be toggled). A variadic selection shows checkboxes
334
+ // (`[x]`/`[ ]`); a single-path one shows radios (`(•)`/`( )`). Row focus is
335
+ // assigned by the view (comparing the row index to the cursor), the same way
336
+ // list rows are; this stays pure and index-stable.
283
337
  function panelRowList(bindings) {
284
338
  const rows = []
285
339
  for (const binding of bindings ?? []) {
340
+ const multi = binding.multi === true
341
+ const items = binding.items ?? []
342
+ // A selection that resolves to a single path is not a list: it renders
343
+ // as a pre-filled, read-only path (no checkbox/radio to pick from).
344
+ const single = items.length === 1
286
345
  rows.push({
287
346
  type: "arg",
288
347
  key: `arg:${binding.index}`,
348
+ rowIndex: rows.length,
289
349
  argIndex: binding.index,
290
350
  itemIndex: -1,
291
351
  name: binding.name,
292
- summary: panelSummary(binding),
352
+ summary: single ? items[0].path : panelSummary(binding),
293
353
  kind: binding.kind,
294
354
  role: binding.role,
295
355
  optional: binding.optional,
296
356
  variadic: binding.variadic,
297
357
  textual: binding.textual,
358
+ picker: binding.picker === true,
359
+ mode: binding.mode ?? null,
360
+ multi,
361
+ readonly: single,
298
362
  value: binding.value,
299
- count: (binding.items ?? []).length,
363
+ count: items.length,
300
364
  })
301
- for (let i = 0; i < (binding.items ?? []).length; i++) {
302
- const item = binding.items[i]
365
+ if (single) continue
366
+ for (let i = 0; i < items.length; i++) {
367
+ const item = items[i]
303
368
  const selected = item.selected
369
+ const marker = multi ? (selected ? "[x]" : "[ ]") : selected ? "(•)" : "( )"
304
370
  rows.push({
305
371
  type: "item",
306
372
  key: `item:${binding.index}:${item.path}`,
373
+ rowIndex: rows.length,
307
374
  argIndex: binding.index,
308
375
  itemIndex: i,
309
376
  name: item.name,
@@ -311,7 +378,8 @@ function panelRowList(bindings) {
311
378
  parent: item.parent ?? "",
312
379
  isDir: item.isDir,
313
380
  selected,
314
- marker: selected ? "[x]" : "[ ]",
381
+ multi,
382
+ marker,
315
383
  })
316
384
  }
317
385
  }
@@ -324,6 +392,23 @@ function rowAt(rows, index) {
324
392
  return (rows ?? [])[index] ?? null
325
393
  }
326
394
 
395
+ // Function: pickerRowList
396
+ // Flattens the picker's visible listing into the rows the panel's argument
397
+ // column renders while a path is being picked. The shape mirrors a
398
+ // file-listing row (`name`, `path`, `isDir`) so the shared row component can
399
+ // render both; a symlink carries its raw target for the summary.
400
+ function pickerRowList(entries) {
401
+ return (entries ?? []).map((entry) => ({
402
+ type: "entry",
403
+ key: entry.path,
404
+ name: entry.name,
405
+ path: entry.path,
406
+ isDir: entry.isDir === true,
407
+ isSymlink: entry.isSymlink === true,
408
+ target: entry.target ?? null,
409
+ }))
410
+ }
411
+
327
412
  // Function: isTextualRow
328
413
  // True when the cursor row is an argument header that takes free text, so the
329
414
  // panel focuses its edit field.
@@ -333,12 +418,13 @@ function isTextualRow(row) {
333
418
 
334
419
  // Function: withToggledItem
335
420
  // Returns a new draft with item `itemIndex` of argument `argIndex` toggled,
336
- // leaving the other bindings untouched.
337
- function withToggledItem(draft, argIndex, itemIndex) {
421
+ // leaving the other bindings untouched. A single-path argument (`multi` false)
422
+ // is a radio list: choosing one selects it alone.
423
+ function withToggledItem(draft, argIndex, itemIndex, multi = true) {
338
424
  return (draft ?? []).map((binding) => {
339
425
  if (binding.index !== argIndex) return binding
340
426
  const items = (binding.items ?? []).map((item, i) =>
341
- i === itemIndex ? { ...item, selected: !item.selected } : item,
427
+ multi ? (i === itemIndex ? { ...item, selected: !item.selected } : item) : { ...item, selected: i === itemIndex },
342
428
  )
343
429
  return { ...binding, items }
344
430
  })
@@ -365,17 +451,21 @@ export { decodePathToken, pathToken } from "./operations.js"
365
451
  export {
366
452
  PANEL_FOCUS,
367
453
  argvFor,
454
+ bindArg,
368
455
  bindArgs,
369
456
  bindingAt,
370
- filterPanelOperations,
457
+ filterPanelEntries,
371
458
  isTextualRow,
372
459
  missingArg,
460
+ missingBinding,
373
461
  panelCommand,
374
- panelOperations,
462
+ panelEntries,
463
+ panelEntryRows,
375
464
  panelRowList,
376
465
  panelRunnable,
377
466
  panelSummary,
378
467
  paneSelectionPaths,
468
+ pickerRowList,
379
469
  rowAt,
380
470
  selectedPaths,
381
471
  withArgValue,
@@ -4,13 +4,13 @@
4
4
  // Created: 2026-09-27
5
5
 
6
6
  // Module: tcmd/core/script
7
- // The scripting language behind the command palette's scripting mode (`:`):
8
- // line tokenizer, operation parser and runner, plus the completion rows the
9
- // palette lists. A line starts with an operation name (or the `!` alias for
10
- // `run`); arguments may be filesets ($A, $B, $FILE), quoted paths, `~` or
11
- // paths relative to the active pane.
7
+ // The TinyCmd scripting language: line tokenizer, operation parser and runner.
8
+ // A line starts with an operation name (or the `!` alias for `run`); arguments
9
+ // may be filesets ($A, $B, $FILE), quoted paths, `~` or paths relative to the
10
+ // active pane. The command panel runs operations structurally and delegates to
11
+ // the same `runLine` through `runOperation`.
12
12
 
13
- import { getOperation, getOperations, humanSize, runOperation } from "./operations.js"
13
+ import { getOperation, runOperation } from "./operations.js"
14
14
 
15
15
  // Function: tokenize
16
16
  // Whitespace tokenizer with single/double quotes and backslash escapes.
@@ -93,94 +93,6 @@ async function runLine(session, line, options = {}) {
93
93
  return runOperation(session, parsed.op, parsed.args, { ...options, line })
94
94
  }
95
95
 
96
- // Function: scriptItems
97
- // Palette rows for scripting mode: the session's available filesets first,
98
- // then every operation, then matching entries of the active pane (path
99
- // completion). Rows carry the full query replacement `completion` for Tab.
100
- function scriptItems(query, filesets, context = {}) {
101
- const text = `${query ?? ""}`
102
- const q = text.trim().toLowerCase()
103
- const head = q.split(/\s+/)[0] ?? ""
104
- const rows = []
105
- const typingFileset = lastToken(q).startsWith("$")
106
- for (const set of filesets ?? []) {
107
- const id = set.id.toLowerCase()
108
- if (head && !id.includes(head) && !q.includes(id) && !typingFileset) continue
109
- rows.push({
110
- id: set.id,
111
- title: set.id,
112
- kind: "fileset",
113
- detail: `${set.count} file${set.count === 1 ? "" : "s"}`,
114
- completion: completeFileset(text, set.id),
115
- })
116
- }
117
- for (const op of getOperations()) {
118
- if (op.hidden) continue
119
- const names = [op.name, ...(op.aliases ?? [])]
120
- const matchesName = !head || names.some((name) => name.toLowerCase().includes(head))
121
- if (!matchesName && !op.signature.toLowerCase().includes(q)) continue
122
- rows.push({
123
- id: `op:${op.name}`,
124
- title: op.name,
125
- kind: "op",
126
- detail: op.signature,
127
- description: op.description,
128
- completion: q.includes(" ") ? text.replace(/^\S+/, op.name) : `${op.name} `,
129
- })
130
- }
131
- const last = lastToken(q)
132
- const token = lastToken(text)
133
- if (token && !token.startsWith("$")) {
134
- const at = token.lastIndexOf("/")
135
- const dirPart = at >= 0 ? token.slice(0, at + 1) : ""
136
- const prefix = (at >= 0 ? token.slice(at + 1) : token).toLowerCase()
137
- for (const entry of context.entries ?? []) {
138
- if (!entry.name.toLowerCase().startsWith(prefix)) continue
139
- const name = entry.isDir ? `${entry.name}/` : entry.name
140
- rows.push({
141
- id: `path:${entry.path}`,
142
- title: name,
143
- kind: "path",
144
- detail: entry.isDir ? "dir" : humanSize(entry.size),
145
- completion: replaceLastToken(text, `${dirPart}${name}`),
146
- })
147
- }
148
- }
149
- return rows
150
- }
151
-
152
- // Function: lastToken
153
- // The last whitespace-separated token, case preserved.
154
- function lastToken(text) {
155
- const match = /(\S+)\s*$/.exec(`${text ?? ""}`)
156
- return match ? match[1] : ""
157
- }
158
-
159
- // Function: completionToken
160
- // For a query whose last token contains "/", the directory part to read and
161
- // the name prefix being completed; null when the token is not a path.
162
- function completionToken(query) {
163
- const token = lastToken(query)
164
- if (!token || token.startsWith("$")) return null
165
- const at = token.lastIndexOf("/")
166
- if (at < 0) return null
167
- return { dir: token.slice(0, at) || "/", prefix: token.slice(at + 1) }
168
- }
169
-
170
- // Function: replaceLastToken
171
- // Replaces the (possibly empty) last whitespace-separated token.
172
- function replaceLastToken(text, replacement) {
173
- const match = /(\S*)\s*$/.exec(text)
174
- return `${text.slice(0, match.index)}${replacement}`
175
- }
176
-
177
- // Function: completeFileset
178
- // Replaces the `$…` token being typed, or appends the fileset.
179
- function completeFileset(text, id) {
180
- if (/\$\w*$/.test(text.trimEnd())) return replaceLastToken(text, id)
181
- return `${text} ${id}`.trim()
182
- }
183
-
184
- export { completionToken, parseLine, runLine, scriptItems, tokenize }
96
+ export { parseLine, runLine, tokenize }
185
97
 
186
98
  // EOF
@@ -26,6 +26,14 @@ function currentEntry(entries, cursor) {
26
26
  return entries[cursor]
27
27
  }
28
28
 
29
+ // Function: indexOfPath
30
+ // The index of the entry whose path is `path`, or -1. Navigations that must
31
+ // land on a known element use it: the directory `up` stepped out of, the
32
+ // current directory in a mirror's listing.
33
+ function indexOfPath(entries, path) {
34
+ return entries.findIndex((entry) => entry.path === path)
35
+ }
36
+
29
37
  // Function: toggleMark
30
38
  // Returns a new Set with `path` toggled in `marks`.
31
39
  function toggleMark(marks, path) {
@@ -38,6 +46,6 @@ function toggleMark(marks, path) {
38
46
  return next
39
47
  }
40
48
 
41
- export { clampCursor, currentEntry, moveCursor, toggleMark }
49
+ export { clampCursor, currentEntry, indexOfPath, moveCursor, toggleMark }
42
50
 
43
51
  // EOF