acstack 0.49.0 → 0.49.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 (34) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/assets/terminal-workspace/helpers/acstack-bridge +185 -0
  3. package/assets/terminal-workspace/helpers/acstack-clip +88 -0
  4. package/assets/terminal-workspace/helpers/acstack-dash +25 -0
  5. package/assets/terminal-workspace/helpers/acstack-db +86 -0
  6. package/assets/terminal-workspace/helpers/acstack-diff +268 -0
  7. package/assets/terminal-workspace/helpers/acstack-edit +25 -0
  8. package/assets/terminal-workspace/helpers/acstack-gh +47 -0
  9. package/assets/terminal-workspace/helpers/acstack-herdr-renumber +93 -0
  10. package/assets/terminal-workspace/helpers/acstack-herdr-tab +49 -0
  11. package/assets/terminal-workspace/helpers/acstack-md +401 -0
  12. package/assets/terminal-workspace/helpers/acstack-md-lib +219 -0
  13. package/assets/terminal-workspace/helpers/acstack-open +217 -0
  14. package/assets/terminal-workspace/helpers/acstack-popup +13 -0
  15. package/assets/terminal-workspace/helpers/acstack-view +33 -0
  16. package/assets/terminal-workspace/helpers/xclip +31 -0
  17. package/assets/terminal-workspace/lib/apply-ghdash.sh +78 -0
  18. package/assets/terminal-workspace/lib/apply-herdr-numbers.sh +49 -0
  19. package/assets/terminal-workspace/lib/apply-herdr.sh +308 -0
  20. package/assets/terminal-workspace/lib/apply-tools.sh +293 -0
  21. package/assets/terminal-workspace/lib/apply-tuicr.sh +133 -0
  22. package/assets/terminal-workspace/lib/apply-yazi.sh +389 -0
  23. package/assets/terminal-workspace/lib/commands.sh +296 -0
  24. package/assets/terminal-workspace/lib/core.sh +184 -0
  25. package/assets/terminal-workspace/lib/helper-install.sh +35 -0
  26. package/assets/terminal-workspace/lib/pi-packages.sh +331 -0
  27. package/assets/terminal-workspace/lib/pi-state.sh +479 -0
  28. package/assets/terminal-workspace/lib/versions.sh +254 -0
  29. package/assets/terminal-workspace/setup.sh +24 -4637
  30. package/cli/package.json +1 -1
  31. package/package.json +1 -1
  32. package/skills/handoff/SKILL.md +14 -14
  33. package/skills/handoff/launch-herdr.md +15 -4
  34. package/skills/implement/SKILL.md +5 -0
@@ -0,0 +1,133 @@
1
+ build_tuicr_fork() {
2
+ # TEMPORARY path. Builds the two unmerged upstream pull requests the config
3
+ # comments name. Once both land, set build_from_fork: false and this whole
4
+ # function stops being reachable.
5
+ # `have cargo` is not enough: a rustup shim on PATH with no default
6
+ # toolchain installed exits non-zero on every invocation.
7
+ if ! cargo --version >/dev/null 2>&1; then
8
+ say "tuicr fork build needs a working cargo (see rustup.rs), skipping"
9
+ cargo --version 2>&1 | head -2 | sed 's/^/ /'
10
+ return 1
11
+ fi
12
+ have git || { say "tuicr fork build needs git, skipping"; return 1; }
13
+ local url branch dir
14
+ url=$(cfg_get tuicr.fork "https://github.com/ac8318740/tuicr")
15
+ branch=$(cfg_get tuicr.fork_branch "local/daily")
16
+ dir="${ACSTACK_TUICR_SRC:-$HOME/tuicr}"
17
+ if [ -d "$dir/.git" ]; then
18
+ git -C "$dir" fetch --quiet --all 2>/dev/null
19
+ git -C "$dir" checkout --quiet "$branch" 2>/dev/null || { say "tuicr: no branch $branch in $dir"; return 1; }
20
+ git -C "$dir" pull --quiet --ff-only 2>/dev/null
21
+ else
22
+ git clone --quiet -b "$branch" "$url" "$dir" || { say "tuicr: clone failed"; return 1; }
23
+ fi
24
+ say "building tuicr from $branch (a few minutes on a cold cache)"
25
+ local log; log=$(mktemp)
26
+ if ! ( cd "$dir" && cargo build --release ) >"$log" 2>&1; then
27
+ say "tuicr: build failed in $dir"
28
+ # Swallowing cargo's output here made a missing toolchain look identical
29
+ # to a compile error.
30
+ grep -E '^(error|warning: unused)' "$log" | head -5 | sed 's/^/ /'
31
+ tail -3 "$log" | sed 's/^/ /'
32
+ rm -f "$log"; return 1
33
+ fi
34
+ rm -f "$log"
35
+ mkdir -p "$BIN"
36
+ # mv, not cp: cp fails with "Text file busy" when tuicr is running.
37
+ cp "$dir/target/release/tuicr" "$BIN/tuicr.new" && mv -f "$BIN/tuicr.new" "$BIN/tuicr"
38
+ say "tuicr built from $branch and installed to $BIN/tuicr"
39
+ }
40
+
41
+ apply_tuicr() {
42
+ local from_fork; from_fork=$(cfg_get tuicr.build_from_fork false)
43
+ if ! have tuicr; then
44
+ if [ "$from_fork" = "true" ]; then
45
+ build_tuicr_fork || return 0
46
+ else
47
+ install_binary tuicr agavra/tuicr x86_64-unknown-linux-gnu || return 0
48
+ fi
49
+ fi
50
+ have tuicr || { say "tuicr not installed, skipping config"; return 0; }
51
+
52
+ mkdir -p "$HOME/.config/tuicr"
53
+ # show_file_line_stats and file_list_width only exist in the fork build.
54
+ # Writing them against a stock release makes tuicr warn on every start.
55
+ ACSTACK_ARGS="$from_fork|$(cfg_get tuicr.file_list_width 30)|$(cfg_get tuicr.show_file_line_stats true)|$(cfg_get tuicr.appearance "")|$BEGIN|$END" \
56
+ py "$HOME/.config/tuicr/config.toml" <<'PY'
57
+ import os, re, sys
58
+ path = sys.argv[1]
59
+ from_fork, width, stats, appearance, begin, end = os.environ["ACSTACK_ARGS"].split("|")
60
+ text = open(path).read() if os.path.isfile(path) else ""
61
+ text = re.sub(re.escape(begin) + r".*?" + re.escape(end) + r"\n?", "", text, flags=re.S)
62
+
63
+ block = [begin]
64
+ managed = set()
65
+ if from_fork == "true":
66
+ block += [
67
+ "# These two keys exist only in the fork build. See the tuicr section",
68
+ "# of ~/.config/acstack/terminal-workspace.yaml.",
69
+ f"show_file_line_stats = {stats}",
70
+ f"file_list_width = {width}",
71
+ "# A local build must not be replaced by a stock release.",
72
+ "no_update_check = true",
73
+ ]
74
+ managed |= {"show_file_line_stats", "file_list_width", "no_update_check"}
75
+ else:
76
+ block += ["# Stock release: the fork-only keys are omitted so tuicr does not",
77
+ "# warn about unknown config keys at startup."]
78
+ # `system` asks the terminal for its background colour over OSC 11. Under
79
+ # `herdr --remote` nothing answers, so tuicr falls back to a desktop setting
80
+ # that a headless machine reports as light, and paints dark text on a black
81
+ # terminal. Pinning the value sidesteps the whole detection path.
82
+ if appearance:
83
+ block += ["# Pinned rather than detected. See the tuicr section of",
84
+ "# ~/.config/acstack/terminal-workspace.yaml.",
85
+ f'appearance = "{appearance}"']
86
+ managed.add("appearance")
87
+ block.append(end)
88
+
89
+ # TOML forbids a duplicate key, so a value the user set by hand before this
90
+ # script managed it would make the merged file unparseable and tuicr would
91
+ # reject the lot. Drop only the keys this run writes, and the comment block
92
+ # that introduced each one: the setting alone leaves a heading over nothing.
93
+ kept, pending, cut = [], [], None
94
+ for ln in text.splitlines():
95
+ stripped = ln.strip()
96
+ if stripped.startswith("#") or not stripped:
97
+ pending.append(ln)
98
+ continue
99
+ # Every key this script writes is top level, so the first table header ends
100
+ # the search. A key of the same name inside a table is a different key.
101
+ if cut is None and stripped.startswith("["):
102
+ # Before the comments introducing that header, not after them.
103
+ cut = len(kept)
104
+ if cut is None and "=" in ln and ln.split("=", 1)[0].strip().strip('"') in managed:
105
+ pending = []
106
+ continue
107
+ kept.extend(pending)
108
+ pending = []
109
+ kept.append(ln)
110
+ kept.extend(pending)
111
+
112
+ # The block holds top-level keys, so it goes above the first table header
113
+ # rather than at the end of the file. Appended after a `[export]` or
114
+ # `[forge]` section, every key in it would belong to that section instead:
115
+ # tuicr would warn about an unknown key there and never see the setting, and
116
+ # a name the section already uses makes the file invalid outright.
117
+ if cut is None:
118
+ text = "\n".join(kept).rstrip("\n")
119
+ text = (text + "\n\n" if text else "") + "\n".join(block) + "\n"
120
+ else:
121
+ head = "\n".join(kept[:cut]).rstrip("\n")
122
+ text = ((head + "\n\n" if head else "") + "\n".join(block) + "\n\n"
123
+ + "\n".join(kept[cut:]).strip("\n") + "\n")
124
+ open(path, "w").write(text)
125
+ PY
126
+ say "tuicr config written"
127
+ }
128
+
129
+ # The sidebar draws a workspace's position in herdr's stored list, but
130
+ # prefix+1..9 targets its row in the grouped sidebar. Creating or removing a
131
+ # worktree moves one and not the other, so the numbers you read stop being the
132
+ # numbers you press. This links a tiny herdr plugin that reruns the alignment
133
+ # on every event that can move a row, so it never needs remembering.
@@ -0,0 +1,389 @@
1
+ apply_yazi() {
2
+ [ "$(cfg_get yazi.enabled true)" = "true" ] || return 0
3
+ if ! have yazi; then
4
+ install_binary yazi sxyazi/yazi x86_64-unknown-linux-gnu || return 0
5
+ install_binary ya sxyazi/yazi x86_64-unknown-linux-gnu || true
6
+ fi
7
+ have yazi || { say "yazi not installed, skipping config"; return 0; }
8
+
9
+ # piper turns any shell command into a previewer, which is how acstack-md
10
+ # gets to draw markdown in the preview pane.
11
+ if have ya && ! ya pkg list 2>/dev/null | grep -q "plugins:piper"; then
12
+ ya pkg add yazi-rs/plugins:piper >/dev/null 2>&1 \
13
+ && say "yazi piper plugin installed" \
14
+ || say "piper install failed; markdown will preview as plain text"
15
+ fi
16
+
17
+ mkdir -p "$HOME/.config/yazi"
18
+ ACSTACK_ARGS="$(cfg_get yazi.show_hidden true)|$BEGIN|$END" \
19
+ py "$HOME/.config/yazi/yazi.toml" <<'PY'
20
+ import os, re, sys
21
+ path = sys.argv[1]
22
+ hidden, begin, end = os.environ["ACSTACK_ARGS"].split("|")
23
+ text = open(path).read() if os.path.isfile(path) else ""
24
+ # Drop any previous managed region, both to stay idempotent and so what is
25
+ # left to inspect below is exactly the config the user wrote.
26
+ text = re.sub(re.escape(begin) + r".*?" + re.escape(end) + r"\n?", "", text, flags=re.S)
27
+
28
+ # Every namespace the block writes into. TOML forbids declaring any of them
29
+ # twice, and yazi answers one duplicate by throwing the entire config away and
30
+ # falling back to presets. So a namespace the user already occupies is theirs:
31
+ # the block gives that piece up rather than cost them every other setting they
32
+ # have. Deciding which are taken is a question about the parsed document, not
33
+ # about the text: [ mgr ], [ "mgr" ], [ 'mgr' ] and mgr.show_hidden = false are
34
+ # all the same table, and only a parser knows that.
35
+ NAMESPACES = ("mgr", "opener.markdown", "plugin.prepend_previewers", "open.prepend_rules")
36
+
37
+ try:
38
+ import tomllib
39
+ except ImportError: # tomllib is 3.11 and newer
40
+ tomllib = None
41
+
42
+
43
+ def claimed(cfg):
44
+ """Which of NAMESPACES the user's own config already occupies."""
45
+ if tomllib is None:
46
+ # No parser to reason with, so read the text conservatively: name the
47
+ # top-level table wherever TOML could open one, as a header or as a
48
+ # key of its own. That concedes namespaces which would have been safe
49
+ # to write, and each of those costs one setting; guessing the other
50
+ # way costs the file. One thing text cannot see is that the config is
51
+ # broken already, so unlike the parsed path below this branch does not
52
+ # concede everything for a file that never parsed.
53
+ found = set()
54
+ for ns in NAMESPACES:
55
+ root = re.escape(ns.split(".")[0])
56
+ # A key may be bare, a basic string or a literal string, and all
57
+ # three name the same table: [mgr], ["mgr"] and ['mgr'] are one.
58
+ name = r"""(?:%s|"%s"|'%s')""" % (root, root, root)
59
+ header = r'\[\[?\s*%s\s*[.\]]' % name
60
+ key = r'%s\s*[.=]' % name
61
+ if re.search(r"(?m)^\s*(?:%s|%s)" % (header, key), cfg):
62
+ found.add(ns)
63
+ return found
64
+ try:
65
+ tomllib.loads(cfg)
66
+ except tomllib.TOMLDecodeError:
67
+ # Their config does not parse as it stands. We cannot tell what it
68
+ # claims, and repairing it is not ours to do, so concede everything:
69
+ # the file ends up exactly as broken as it arrived, never more.
70
+ return set(NAMESPACES)
71
+ found = set()
72
+ for ns in NAMESPACES:
73
+ # Whether a namespace is free is not "is the key there". TOML also
74
+ # refuses to reopen an inline table and to overwrite a scalar, so
75
+ # opener = { text = [...] } and opener = "nope" both have no
76
+ # opener.markdown key and are both fatal to write under. tomllib
77
+ # hands an inline table back as an ordinary dict, so no walk over the
78
+ # parsed document can tell those apart from a table that is still
79
+ # open. Put the question to the parser instead, in the exact form the
80
+ # block would write it: append that header to their config and see
81
+ # whether the whole thing still parses. Whatever it refuses is theirs.
82
+ probe = "[mgr]" if ns == "mgr" else "[[%s]]" % ns
83
+ try:
84
+ tomllib.loads(cfg + "\n" + probe + "\n")
85
+ except tomllib.TOMLDecodeError:
86
+ found.add(ns)
87
+ return found
88
+
89
+
90
+ taken = claimed(text)
91
+ parts = []
92
+
93
+ if "plugin.prepend_previewers" not in taken:
94
+ parts.append("""
95
+ [[plugin.prepend_previewers]]
96
+ url = "*.md"
97
+ run = 'piper -- COLUMNS=$w acstack-md --preview "$1"'
98
+
99
+ [[plugin.prepend_previewers]]
100
+ url = "*.markdown"
101
+ run = 'piper -- COLUMNS=$w acstack-md --preview "$1"'
102
+ """)
103
+
104
+ if "opener.markdown" not in taken:
105
+ # An array of tables rather than a markdown key under [opener]: a second
106
+ # [opener] table would be a duplicate, while a subtable of one the user
107
+ # has already declared is legal.
108
+ parts.append("""
109
+ # The preview pane is narrow, so a wide diagram shows its placeholder there.
110
+ # Enter opens the same renderer full width, where more of them fit.
111
+ #
112
+ # %s is what yazi substitutes for the files being opened, already quoted. Not
113
+ # "$@": an opener template is run as `sh -c '<run>'` with nothing after it, so
114
+ # $0 is "sh" and $@ is empty - measured on yazi 26.8.15, where "$@" left
115
+ # acstack-md with no file at all and Enter did nothing but print its usage.
116
+ [[opener.markdown]]
117
+ run = 'acstack-md %s'
118
+ block = true
119
+ desc = "Read (acstack-md)"
120
+
121
+ # Rendered markdown has no line number to quote back in a review, so the same
122
+ # file is also readable as its own source. Second, not first: it answers a
123
+ # narrower question than reading does, and still a readier one than editing.
124
+ # Enter takes the first entry, so O is the key that offers this one.
125
+ [[opener.markdown]]
126
+ run = 'acstack-md --numbered %s'
127
+ block = true
128
+ desc = "Read with line numbers"
129
+
130
+ [[opener.markdown]]
131
+ run = '${EDITOR:-vi} %s'
132
+ block = true
133
+ desc = "Edit"
134
+ """)
135
+
136
+ if "open.prepend_rules" not in taken:
137
+ # Worth writing even when the opener above was skipped: the rules point
138
+ # markdown files at whichever opener.markdown ends up in the file.
139
+ parts.append("""
140
+ [[open.prepend_rules]]
141
+ url = "*.md"
142
+ use = "markdown"
143
+
144
+ [[open.prepend_rules]]
145
+ url = "*.markdown"
146
+ use = "markdown"
147
+ """)
148
+
149
+ if "mgr" not in taken:
150
+ parts.append(f"[mgr]\nshow_hidden = {hidden}")
151
+
152
+ # The markers go down even when every piece was conceded, so the shell below
153
+ # can still read the region back and see what is missing from it.
154
+ block = begin + "\n" + "\n\n".join(p.strip("\n") for p in parts) + "\n" + end
155
+ text = text.rstrip("\n")
156
+ open(path, "w").write((text + "\n\n" if text else "") + block + "\n")
157
+ PY
158
+ # A setting the block gave up to the user's own config is simply absent from
159
+ # what was written, so read the region back rather than let it go unsaid.
160
+ local written; written="$(sed -n "/$BEGIN/,/$END/p" "$HOME/.config/yazi/yazi.toml")"
161
+ case "$written" in *"[[plugin.prepend_previewers]]"*) ;; *)
162
+ say "yazi: your yazi.toml already sets plugin.prepend_previewers, so the"
163
+ say " markdown previewer was left alone. Add a *.md entry there to"
164
+ say " preview markdown with acstack-md." ;;
165
+ esac
166
+ case "$written" in *"[[opener.markdown]]"*) ;; *)
167
+ say "yazi: your yazi.toml already opens markdown its own way, so it was left"
168
+ say " alone. Add acstack-md to that opener to read markdown with it." ;;
169
+ esac
170
+ case "$written" in *"[[open.prepend_rules]]"*) ;; *)
171
+ say "yazi: your yazi.toml already sets open.prepend_rules, so the markdown"
172
+ say " rules were left alone. Add *.md and *.markdown entries there"
173
+ say " pointing at the markdown opener." ;;
174
+ esac
175
+ case "$written" in *show_hidden*) ;; *)
176
+ say "yazi: your yazi.toml already claims mgr itself, so show_hidden was left"
177
+ say " alone. Set it there yourself to show hidden files." ;;
178
+ esac
179
+ # The keymap is its own file, and prepend_keymap has two spellings: an array
180
+ # of tables, which is additive and safe to extend, and an inline array under
181
+ # [mgr], which is one key that TOML forbids declaring twice. Only the second
182
+ # collides with the entry below, and telling them apart takes the text - both
183
+ # spellings parse to the same list.
184
+ ACSTACK_ARGS="$(cfg_get yazi.browser_key "b")|$(cfg_get yazi.line_numbers_key "#")|$(cfg_get yazi.download_key "D")|$(cfg_get yazi.download_target "")|$(cfg_get yazi.edit_key "e")|$BEGIN|$END" \
185
+ py "$HOME/.config/yazi/keymap.toml" <<'PY'
186
+ import os, re, sys
187
+
188
+ path = sys.argv[1]
189
+ key, numkey, dlkey, dltarget, editkey, begin, end = os.environ["ACSTACK_ARGS"].split("|")
190
+ text = open(path).read() if os.path.isfile(path) else ""
191
+ # Drop any previous managed region, both to stay idempotent and so what is
192
+ # left to inspect below is exactly the keymap the user wrote.
193
+ text = re.sub(re.escape(begin) + r".*?" + re.escape(end) + r"\n?", "", text, flags=re.S)
194
+
195
+ try:
196
+ import tomllib
197
+ except ImportError: # tomllib is 3.11 and newer
198
+ tomllib = None
199
+
200
+
201
+ def claimed(t):
202
+ """True when the user's keymap declares prepend_keymap as an inline array
203
+ under [mgr] - the one spelling our own entry cannot sit beside."""
204
+ if not re.search(r"^\s*prepend_keymap\s*=", t, flags=re.M):
205
+ return False
206
+ if tomllib is None:
207
+ # No parser to say which table that assignment sits in, so assume the
208
+ # worst. A conceded binding costs one key; a collision costs the whole
209
+ # keymap, because yazi answers invalid TOML by falling back to presets.
210
+ return True
211
+ try:
212
+ return "prepend_keymap" in (tomllib.loads(t).get("mgr") or {})
213
+ except Exception:
214
+ return True
215
+
216
+
217
+ CLAIMED = {k for k in (key, numkey, editkey) if k}
218
+ if dltarget and dlkey:
219
+ CLAIMED.add(dlkey)
220
+
221
+
222
+ def drop_claimed(t):
223
+ """Remove any prepend_keymap block the user wrote on a key this script is
224
+ about to bind.
225
+
226
+ yazi accepts both, lists both in its help, and runs one of them. So a
227
+ binding written by hand before this script managed the key leaves two
228
+ entries saying different things and no way to tell which one fires. Both
229
+ table spellings, because `manager` is what yazi called `mgr` before the
230
+ rename and an older keymap still says it.
231
+ """
232
+ out, block = [], None
233
+
234
+ def bound_key(lines):
235
+ """The single key this block binds, or None.
236
+
237
+ yazi spells `on` four ways: a double-quoted scalar, a single-quoted
238
+ one, a one-element array, and any of those with a trailing comment.
239
+ Reading only the first spelling leaves the other three in place, which
240
+ is the state this function exists to prevent. A longer array is a
241
+ multi-key sequence and never collides with a single key.
242
+ """
243
+ body = "\n".join(lines[1:])
244
+ if tomllib is not None:
245
+ try:
246
+ on = tomllib.loads(body).get("on")
247
+ except Exception:
248
+ on = ... # unparseable, so fall through to the regex
249
+ if on is not ...:
250
+ if isinstance(on, str):
251
+ return on
252
+ if isinstance(on, list) and len(on) == 1 and isinstance(on[0], str):
253
+ return on[0]
254
+ return None
255
+ m = re.search(r"""(?m)^\s*on\s*=\s*\[?\s*(?:"([^"]*)"|'([^']*)')""", body)
256
+ return (m.group(1) if m.group(1) is not None else m.group(2)) if m else None
257
+
258
+ def close():
259
+ nonlocal block
260
+ if block is None:
261
+ return
262
+ # Trailing blank lines belong to whatever comes next, not to the
263
+ # binding, so they stay even when the binding goes.
264
+ tail = []
265
+ while block and not block[-1].strip():
266
+ tail.insert(0, block.pop())
267
+ if bound_key(block) not in CLAIMED:
268
+ out.extend(block)
269
+ out.extend(tail)
270
+ block = None
271
+
272
+ for ln in t.splitlines():
273
+ if re.match(r"^\s*\[\[\s*(?:mgr|manager)\.prepend_keymap\s*\]\]\s*$", ln):
274
+ close()
275
+ block = [ln]
276
+ continue
277
+ if block is not None:
278
+ # Any other table header ends this binding and belongs to what
279
+ # follows, so it is never dropped with the block.
280
+ if ln.lstrip().startswith("["):
281
+ close()
282
+ out.append(ln)
283
+ continue
284
+ block.append(ln)
285
+ continue
286
+ out.append(ln)
287
+ close()
288
+ return "\n".join(out)
289
+
290
+
291
+ parts = []
292
+ if not claimed(text):
293
+ # A binding conceded below is one this script never writes, so the user's
294
+ # own must survive. Only strip when the bindings are actually going in.
295
+ text = drop_claimed(text)
296
+ # %h is the hovered file. $0 and $@ are not: yazi runs the template through
297
+ # a shell, where $0 names the shell itself and $@ is empty - both measured,
298
+ # and both would open the browser on nothing at all.
299
+ #
300
+ # %h stays bare, here and in every binding below. yazi shell-quotes it
301
+ # itself, so quotes around it end up inside the path and nothing is found.
302
+ #
303
+ # --block because the delivery has something to say. On the bridge it is a
304
+ # line of confirmation; on every other route it is a link, and a served
305
+ # page that has to stay up. Detached, all of that goes nowhere.
306
+ parts.append(
307
+ "[[mgr.prepend_keymap]]\n"
308
+ f'on = "{key}"\n'
309
+ """run = 'shell --block -- acstack-md --browser %h'\n"""
310
+ 'desc = "Open in the browser you are sitting at"'
311
+ )
312
+ # Flipping the flag changes nothing a reader can see: the pane already
313
+ # holds the view it drew before the key was pressed. So the binding is two
314
+ # actions, and peek redraws it.
315
+ #
316
+ # --block, though the toggle has nothing to say. Detached, yazi spawns it
317
+ # and runs peek immediately, and the pane is redrawn while the flag is
318
+ # still whatever it was - a race the reader settles by pressing the key
319
+ # twice. Blocking costs a frame and wins it outright.
320
+ parts.append(
321
+ "[[mgr.prepend_keymap]]\n"
322
+ f'on = "{numkey}"\n'
323
+ "run = [ 'shell --block -- acstack-md --toggle-line-numbers', 'peek --force' ]\n"
324
+ 'desc = "Preview markdown as source with line numbers"'
325
+ )
326
+ # `e` edits, everywhere. tuicr's diff and file tree both put the editor on
327
+ # `e`, so yazi matching it means one key means edit across the workspace.
328
+ # `o` is left alone: it opens by rule, which for markdown is the reader.
329
+ #
330
+ # ${EDITOR:-vi} rather than a fixed editor, the same expansion the opener
331
+ # in yazi.toml uses, so both paths land in whatever the user actually runs.
332
+ parts.append(
333
+ "[[mgr.prepend_keymap]]\n"
334
+ f'on = "{editkey}"\n'
335
+ """run = 'shell --block -- ${EDITOR:-vi} %h'\n"""
336
+ 'desc = "Edit in $EDITOR"'
337
+ )
338
+ # Taildrop is Tailscale's file send. It is the one route off a headless
339
+ # machine that needs nothing installed on the machine the user sits at,
340
+ # and no inbound port there either.
341
+ #
342
+ # %h, the hovered file, for the same reason the browser key uses it. %* is
343
+ # not a keybinding placeholder at all - yazi passes it through untouched,
344
+ # and tailscale then reports `open %*: no such file or directory`.
345
+ # Measured on yazi 26.8.15.
346
+ #
347
+ # --block because a send reports progress, and a refused one reports why.
348
+ if dltarget:
349
+ parts.append(
350
+ "[[mgr.prepend_keymap]]\n"
351
+ f'on = "{dlkey}"\n'
352
+ f"""run = 'shell --block -- tailscale file cp %h {dltarget}:'\n"""
353
+ f'desc = "Send this file to {dltarget} over Taildrop"'
354
+ )
355
+
356
+ # The markers go down even when the binding was conceded, so the shell below
357
+ # can read the region back and see what is missing from it.
358
+ block = begin + "\n" + "\n\n".join(parts) + ("\n" if parts else "") + end
359
+ text = text.rstrip("\n")
360
+ open(path, "w").write((text + "\n\n" if text else "") + block + "\n")
361
+ PY
362
+ local kmwritten; kmwritten="$(sed -n "/$BEGIN/,/$END/p" "$HOME/.config/yazi/keymap.toml")"
363
+ case "$kmwritten" in *prepend_keymap*) ;; *)
364
+ say "yazi: your keymap.toml already sets mgr.prepend_keymap as an inline"
365
+ say " array, which these bindings cannot sit beside. Add them there"
366
+ say " yourself: shell --block -- acstack-md --browser %h"
367
+ say " and: [ 'shell --block -- acstack-md --toggle-line-numbers', 'peek --force' ]"
368
+ say " and: shell --block -- \${EDITOR:-vi} %h" ;;
369
+ esac
370
+ # The download key is written from the config alone, so it can be wrong in
371
+ # three ways the config cannot see. Name whichever one holds, rather than
372
+ # leaving the user to read `status code: 1` out of a yazi popup.
373
+ local dltarget; dltarget="$(cfg_get yazi.download_target "")"
374
+ if [ -n "$dltarget" ]; then
375
+ if ! have tailscale; then
376
+ say "yazi: download_target names $dltarget, but this machine has no"
377
+ say " tailscale. Install it, or clear the key to drop the binding."
378
+ elif ! tailscale file cp --targets >/dev/null 2>&1; then
379
+ say "yazi: tailscale refuses file access to this user account. Run once:"
380
+ say " sudo tailscale set --operator=\$USER"
381
+ elif ! tailscale file cp --targets 2>/dev/null \
382
+ | awk -F'\t' -v t="$dltarget" '$2 == t { f = 1 } END { exit !f }'; then
383
+ say "yazi: $dltarget is not a Taildrop target of this machine. Taildrop"
384
+ say " only sends between devices one Tailscale account owns on one"
385
+ say " tailnet. Check both ends with: tailscale file cp --targets"
386
+ fi
387
+ fi
388
+ say "yazi config written"
389
+ }