samagotchi 0.57.0 → 0.59.0

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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -1
  3. data/docs/architecture.md +3 -3
  4. data/docs/cli.md +5 -4
  5. data/docs/configuration.md +40 -23
  6. data/docs/hooks.md +2 -2
  7. data/docs/internals/background-tasks.md +7 -0
  8. data/docs/internals/engine.md +5 -5
  9. data/docs/internals/prompt-caching.md +2 -2
  10. data/docs/internals/tool-guardrails.md +2 -1
  11. data/docs/plugins.md +26 -6
  12. data/docs/sessions.md +3 -3
  13. data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
  14. data/lib/samagotchi/bridge.rb +6 -2
  15. data/lib/samagotchi/bundles/slow-commands/manifest.yml +5 -4
  16. data/lib/samagotchi/bundles/slow-commands/plugin.rb +169 -16
  17. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  18. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  19. data/lib/samagotchi/config.rb +11 -5
  20. data/lib/samagotchi/engine.rb +28 -3
  21. data/lib/samagotchi/host_config.rb +3 -1
  22. data/lib/samagotchi/host_registry.rb +2 -1
  23. data/lib/samagotchi/model_profile.rb +14 -3
  24. data/lib/samagotchi/model_settings.rb +4 -2
  25. data/lib/samagotchi/process_group.rb +76 -3
  26. data/lib/samagotchi/redacted_inspect.rb +21 -6
  27. data/lib/samagotchi/send_command.rb +1 -1
  28. data/lib/samagotchi/session_commands.rb +19 -6
  29. data/lib/samagotchi/steer_cut.rb +12 -8
  30. data/lib/samagotchi/steer_cut_after.rb +69 -0
  31. data/lib/samagotchi/system_prompt.rb +5 -5
  32. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -4
  33. data/lib/samagotchi/terminal_ui/formatting.rb +9 -5
  34. data/lib/samagotchi/terminal_ui.rb +15 -6
  35. data/lib/samagotchi/tool_activity.rb +18 -1
  36. data/lib/samagotchi/tool_declarations.rb +1 -1
  37. data/lib/samagotchi/tools/execute.rb +318 -70
  38. data/lib/samagotchi/tools/task_get.rb +2 -0
  39. data/lib/samagotchi/tools/task_runtime.rb +96 -13
  40. data/lib/samagotchi/tools/task_wait.rb +23 -5
  41. data/lib/samagotchi/utf8_default.rb +1 -1
  42. data/lib/samagotchi/version.rb +1 -1
  43. data/lib/samagotchi/web/app.rb +8 -2
  44. data/lib/samagotchi/web/public/app.js +7 -6
  45. data/lib/samagotchi/web/public/index.html +20 -7
  46. data/lib/samagotchi/web/public/read_view.js +3 -1
  47. data/lib/samagotchi/web/public/ref_menu.js +188 -0
  48. data/lib/samagotchi/web/public/ref_scan.js +11 -1
  49. data/lib/samagotchi/web/public/refs.js +271 -51
  50. data/lib/samagotchi/web/public/stage_view.js +21 -0
  51. data/lib/samagotchi/web/public/task_stop.js +25 -7
  52. data/lib/samagotchi/worker.rb +2 -2
  53. metadata +3 -1
@@ -10,6 +10,9 @@ module Samagotchi
10
10
  TAIL_LINES_DEFAULT = 10
11
11
  TAIL_LINES_MAX = 100
12
12
  POLL_INTERVAL = 0.5
13
+ # A handed-over execute keeps stderr in a log of its own: at most this
14
+ # many of its last lines follow the output tail.
15
+ STDERR_TAIL_LINES_MAX = 10
13
16
 
14
17
  def self.name = NAME
15
18
 
@@ -41,7 +44,7 @@ module Samagotchi
41
44
 
42
45
  if completion_pattern
43
46
  output_tail = output_tail(record, tail_lines)
44
- return format_response(record, wait_result: "pattern_matched", output_tail: output_tail) if completion_pattern.match?(output_tail)
47
+ return format_response(record, wait_result: "pattern_matched", output_tail: output_tail, tail_lines: tail_lines) if completion_pattern.match?(output_tail)
45
48
  end
46
49
 
47
50
  return cancelled_response(task_id, tail_lines, started) if cancelled.call
@@ -55,7 +58,7 @@ module Samagotchi
55
58
  refreshed, = TaskRuntime.get_record(task_id)
56
59
  return finished_response(refreshed, tail_lines) unless refreshed.fetch("status") == "running"
57
60
 
58
- format_response(refreshed, wait_result: "timeout", output_tail: output_tail(refreshed, tail_lines))
61
+ format_response(refreshed, wait_result: "timeout", output_tail: output_tail(refreshed, tail_lines), tail_lines: tail_lines)
59
62
  rescue StandardError => e
60
63
  "Error: #{e.message}"
61
64
  end
@@ -72,7 +75,8 @@ module Samagotchi
72
75
  " To keep waiting: task_wait #{task_id}. If it is stuck or no longer needed: task_stop #{task_id}.",
73
76
  "waited: #{(monotonic_time - started).round}s"
74
77
  ]
75
- format_response(record, wait_result: "canceled", output_tail: output_tail(record, tail_lines), extra: note)
78
+ format_response(record, wait_result: "canceled", output_tail: output_tail(record, tail_lines), tail_lines: tail_lines,
79
+ extra: note)
76
80
  end
77
81
  private_class_method :cancelled_response
78
82
 
@@ -81,7 +85,8 @@ module Samagotchi
81
85
  def self.finished_response(record, tail_lines)
82
86
  return format_response(record) unless TaskRuntime.stopped_by_user?(record)
83
87
 
84
- format_response(record, output_tail: output_tail(record, tail_lines), extra: [TaskRuntime::USER_STOP_NOTE])
88
+ format_response(record, output_tail: output_tail(record, tail_lines), tail_lines: tail_lines,
89
+ extra: [TaskRuntime::USER_STOP_NOTE])
85
90
  end
86
91
  private_class_method :finished_response
87
92
 
@@ -90,7 +95,17 @@ module Samagotchi
90
95
  end
91
96
  private_class_method :output_tail
92
97
 
93
- def self.format_response(record, wait_result: nil, output_tail: nil, extra: [])
98
+ # The last lines of a handed-over execute's stderr log (stderr_path),
99
+ # "" for a task_create task (one log) or an empty stderr.
100
+ def self.stderr_tail(record, tail_lines)
101
+ path = record["stderr_path"]
102
+ return "" if path.to_s.empty? || tail_lines.nil?
103
+
104
+ TaskRuntime.output_tail_lines(path, [tail_lines, STDERR_TAIL_LINES_MAX].min)
105
+ end
106
+ private_class_method :stderr_tail
107
+
108
+ def self.format_response(record, wait_result: nil, output_tail: nil, tail_lines: nil, extra: [])
94
109
  lines = [
95
110
  "task_id: #{record.fetch("id")}",
96
111
  "status: #{record.fetch("status")}",
@@ -98,12 +113,15 @@ module Samagotchi
98
113
  "stop_reason: #{record["stop_reason"]}",
99
114
  "output_path: #{record.fetch("output_path")}"
100
115
  ]
116
+ lines << "stderr_path: #{record["stderr_path"]}" if record["stderr_path"]
101
117
  return lines.join("\n") unless output_tail
102
118
 
103
119
  lines << "wait_result: #{wait_result}" if wait_result
104
120
  lines.concat(extra)
105
121
  lines << "output_tail:"
106
122
  lines << output_tail.rstrip unless output_tail.empty?
123
+ stderr_tail = stderr_tail(record, tail_lines)
124
+ lines.concat(["stderr_tail:", stderr_tail.rstrip]) unless stderr_tail.empty?
107
125
  lines.join("\n")
108
126
  end
109
127
  private_class_method :format_response
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- # chi's files (memories, AGENT.md, sessions, config.yml) are UTF-8,
4
+ # chi's files (memories, AGENTS.md, sessions, config.yml) are UTF-8,
5
5
  # whatever the locale. Under LC_ALL=C (or with no locale at all: an app
6
6
  # started from Finder or launchd, `env -i`) Ruby reads files as US-ASCII,
7
7
  # and a UTF-8 memory index then breaks the system prompt
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.57.0"
4
+ VERSION = "0.59.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -45,6 +45,7 @@ require_relative "editor"
45
45
  require_relative "message_parts"
46
46
  require_relative "session_summary"
47
47
  require_relative "../log"
48
+ require_relative "../redacted_inspect"
48
49
 
49
50
  module Samagotchi
50
51
  module Web
@@ -56,6 +57,9 @@ module Samagotchi
56
57
  # (the single live client transport); history for any session — including
57
58
  # dead ones — is served by GET /api/sessions/:id/output from output/ files.
58
59
  class App
60
+ # @lan holds the LAN access token.
61
+ include RedactedInspect
62
+
59
63
  DEFAULT_HOST = BridgeClient::HOST
60
64
  BRIDGE_WAIT_TIMEOUT = 10.0
61
65
  # An uploaded image may be this big before it is downscaled.
@@ -2130,10 +2134,12 @@ module Samagotchi
2130
2134
  # before returning, and a read blocked on a quiet worker would hang
2131
2135
  # Ctrl-C of chi web. The bytes go through unchanged.
2132
2136
  class ProxyStreamBody
2137
+ # @param headers [Hash] the request's Rack env; only its Last-Event-ID
2138
+ # is kept (the env carries the token cookie, never held or inspected)
2133
2139
  def initialize(host:, port:, session_id:, query:, headers:, server_running: -> { true })
2134
2140
  @client = BridgeClient.new(session_id: session_id, port: port, host: host)
2135
2141
  @query = query
2136
- @headers = headers
2142
+ @last_event_id = headers["HTTP_LAST_EVENT_ID"]
2137
2143
  @server_running = server_running
2138
2144
  end
2139
2145
 
@@ -2142,7 +2148,7 @@ module Samagotchi
2142
2148
  # Last-Event-ID header over ?from_seq, and the reconnect URL carries a
2143
2149
  # stale initial cursor — without this the bridge would replay content
2144
2150
  # already delivered (duplicate bubbles).
2145
- @client.stream(query: @query, last_event_id: @headers["HTTP_LAST_EVENT_ID"], running: @server_running, &)
2151
+ @client.stream(query: @query, last_event_id: @last_event_id, running: @server_running, &)
2146
2152
  end
2147
2153
  end
2148
2154
  end
@@ -65,7 +65,7 @@ import {
65
65
  appendAboveLiveTiming, formatDuration, mergeTiming, normalizeTiming, sessionDurationNow, STOPPED_LINE_CLASS,
66
66
  } from "./timing.js";
67
67
  import { createLiveTurn } from "./live_turn.js";
68
- import { installRefClicks, setRefKinds } from "./refs.js";
68
+ import { installRefClicks } from "./refs.js";
69
69
  import { linkRefs, touchedFiles } from "./ref_scan.js";
70
70
  import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
71
71
  import { isReportClient, hookNoticeLabel, llmContextNotice, newClientId, noticeLine, promptOps, snapshotEvents, workerGoneText } from "./turn_events.js";
@@ -855,13 +855,11 @@ function setMarkdownWarning(warning) {
855
855
  markdownWarningEl.hidden = false;
856
856
  }
857
857
 
858
- // Both from one payload, together; the body's has-editor and refs-<kind>
859
- // classes follow (the refs' hover styling keys on refs-<kind>).
858
+ // Both from one payload, together; the body's has-editor class follows.
860
859
  function setCapabilities(capabilities, editorUrl) {
861
860
  pageCapabilities = capabilities && typeof capabilities === "object" ? capabilities : {};
862
861
  pageEditorUrl = pageCapabilities.editor === true && typeof editorUrl === "string" && editorUrl ? editorUrl : null;
863
862
  document.body.classList.toggle("has-editor", pageEditorUrl !== null);
864
- setRefKinds(document.body, pageViewer());
865
863
  }
866
864
 
867
865
  // What this viewer can do with a ref (refs.js REF_ACTIONS).
@@ -869,8 +867,11 @@ function pageViewer() {
869
867
  return { editor: pageCapabilities.editor === true, editorUrl: pageEditorUrl };
870
868
  }
871
869
 
872
- // Refs anywhere on the page (a tool row's file, its diff lines): one listener.
873
- installRefClicks(document.body, pageViewer);
870
+ // Refs anywhere on the page (a tool row's file, its diff lines, an
871
+ // answer's path:line): one listener set; "Copy path" says so in a toast.
872
+ installRefClicks(document.body, pageViewer, {
873
+ copy: async (text) => showToast((await copyText(text)) ? "Path copied" : "Couldn't copy the path"),
874
+ });
874
875
 
875
876
  async function readTurnEnd() {
876
877
  if (!selected) return;
@@ -380,13 +380,26 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
380
380
  .diff{margin:0;padding:4px 0;background:var(--bg-inset);border:1px solid var(--border);border-radius:6px;font-size:12px;line-height:1.45;overflow:auto;max-height:420px}
381
381
  .diff code{display:block;min-width:max-content}
382
382
  .diff-line{display:block;padding:0 10px;white-space:pre;color:var(--text-2)}
383
- /* Refs (refs.js): text until hovered, and only for a kind this viewer has an action for (body.refs-file). */
384
- .refs-file .ref[data-ref-kind="file"]:hover{color:var(--accent);text-decoration:underline dotted;text-underline-offset:3px;cursor:pointer}
385
- .refs-file .ref[data-ref-kind="file"]:hover::after{content:"\2197";margin-left:.3em;color:var(--text-faint);display:inline-block}
386
- .refs-file :is(.activity-row,.bubble.approval):has(.ref[data-ref-kind="file"]) .diff-line[data-line]:hover{cursor:pointer;box-shadow:inset 2px 0 var(--accent)}
387
- .refs-file :is(.activity-row,.bubble.approval):has(.ref[data-ref-kind="file"]) .diff-line[data-line]:hover::after{content:"\2197 " attr(data-line);float:right;padding-left:12px;color:var(--text-faint)}
388
- .refs-file .activity-row:has(.ref[data-ref-kind="file"]) .read-line[data-line]:hover{cursor:pointer;box-shadow:inset 2px 0 var(--accent)}
389
- .refs-file .activity-row:has(.ref[data-ref-kind="file"]) .read-line[data-line]:hover::before{color:var(--accent)}
383
+ /* Refs (refs.js): text until hovered or focused, and only with a target for this viewer (refs.js marks it: data-ref-main, the main one). A link ref keeps its link look. */
384
+ .ref[data-ref-main]:not(a):is(:hover,:focus-visible){color:var(--accent);text-decoration:underline dotted;text-underline-offset:3px;cursor:pointer}
385
+ .ref[data-ref-main="editor"]:not(a):is(:hover,:focus-visible)::after{content:"\2197";margin-left:.3em;color:var(--text-faint);display:inline-block}
386
+ .ref[data-ref-kind]:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:3px}
387
+ :is(.activity-row,.bubble.approval) .diff-line[data-ref-main]:hover{cursor:pointer;box-shadow:inset 2px 0 var(--accent)}
388
+ :is(.activity-row,.bubble.approval) .diff-line[data-ref-main]:hover::after{content:"\2197 " attr(data-line);float:right;padding-left:12px;color:var(--text-faint)}
389
+ .activity-row .read-line[data-ref-main]:hover{cursor:pointer;box-shadow:inset 2px 0 var(--accent)}
390
+ .activity-row .read-line[data-ref-main]:hover::before{color:var(--accent)}
391
+ /* A ref's ▾ and its menu (ref_menu.js): one each, placed next to the ref. */
392
+ .ref-more{position:fixed;z-index:60;display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;padding:0;border:1px solid var(--border-strong);border-radius:5px;background:var(--bg-panel);color:var(--text-muted);font-size:12px;line-height:1;cursor:pointer;box-shadow:0 1px 3px var(--shadow-strong)}
393
+ .ref-more[hidden]{display:none}
394
+ .ref-more:hover{color:var(--accent);border-color:var(--accent)}
395
+ .ref-menu{position:fixed;z-index:61;min-width:160px;max-width:calc(100vw - 16px);padding:4px;border:1px solid var(--border-strong);border-radius:8px;background:var(--bg-panel);box-shadow:var(--toast-shadow);animation:fadeIn .12s ease-out}
396
+ .ref-menu [role="menuitem"]{display:block;width:100%;padding:6px 10px;border:0;border-radius:5px;background:none;color:var(--text);font:inherit;font-size:13px;text-align:left;cursor:pointer;white-space:nowrap}
397
+ .ref-menu [role="menuitem"]:is(:hover,:focus-visible){background:var(--accent-bg);color:var(--text-bright);outline:none}
398
+ @media (pointer:coarse){.ref-menu [role="menuitem"]{padding:11px 14px;font-size:15px}}
399
+ /* Show in turn: the row (and its line) flash once. */
400
+ @keyframes ref-flash{0%,35%{background:color-mix(in srgb,var(--accent) 22%,transparent)}100%{background:transparent}}
401
+ .ref-flash{animation:ref-flash 1.6s ease-out}
402
+ @media (prefers-reduced-motion:reduce){.ref-menu{animation:none}.ref-flash{animation:none;outline:2px solid var(--accent);outline-offset:2px}}
390
403
  .diff-add{background:var(--diff-add-bg);color:var(--diff-add-text)}
391
404
  .diff-del{background:var(--diff-del-bg);color:var(--diff-del-text)}
392
405
  .diff-hunk{color:var(--text-faint)}
@@ -67,8 +67,10 @@ export function readLinesHtml(row) {
67
67
  const first = numbered[0].line;
68
68
  const last = numbered.at(-1).line;
69
69
  const label = first === last ? `line ${first}` : `lines ${first}–${last}`;
70
+ // A blank line of the file holds no text, only a <br>: an empty block
71
+ // drops out of a copied selection, a space would be copied as one.
70
72
  const spans = lines.map(({ text, line }) => line === null
71
73
  ? `<span class="read-line read-meta">${escapeHtml(text)}</span>`
72
- : `<span class="read-line" data-line="${line}">${escapeHtml(text) || " "}</span>`).join("");
74
+ : `<span class="read-line" data-line="${line}">${escapeHtml(text) || "<br>"}</span>`).join("");
73
75
  return `<details class="activity-read"><summary>${label}</summary><pre class="read-lines"><code>${spans}</code></pre></details>`;
74
76
  }
@@ -0,0 +1,188 @@
1
+ // A ref's menu (refs.js): the small ▾ that shows next to a hovered or
2
+ // focused ref with more than one target, and the menu of its targets it
3
+ // opens (a tap and ↓ open it too). One of each per page, placed next to
4
+ // the ref (position: fixed); a scroll, a resize, a click elsewhere, Esc or
5
+ // Tab closes them. Keyboard: ↑/↓ (Home/End) move, Enter picks, Esc closes
6
+ // back to the ref.
7
+
8
+ // How long the ▾ stays after the pointer leaves the ref: long enough to
9
+ // reach it.
10
+ const HIDE_MS = 300;
11
+ // Kept from the viewport's edges (a phone's width).
12
+ const EDGE = 8;
13
+
14
+ // +doc+'s ▾ and menu. open(el, items, {focus}) shows +items+ ({id, label,
15
+ // run}) under +el+; showMore(el, onOpen) puts the ▾ after +el+, whose click
16
+ // calls +onOpen+; hideMoreSoon(el) takes it away unless the pointer reaches
17
+ // it; close({refocus}) closes the menu.
18
+ export function createRefMenu(doc = globalThis.document) {
19
+ let more = null;
20
+ let menu = null;
21
+ let moreFor = null;
22
+ let menuFor = null;
23
+ let onMore = null;
24
+ let hideTimer = null;
25
+ const win = doc.defaultView || globalThis;
26
+
27
+ function moreButton() {
28
+ if (more) return more;
29
+ more = doc.createElement("button");
30
+ more.type = "button";
31
+ more.className = "ref-more";
32
+ more.textContent = "▾";
33
+ more.title = "More for this ref";
34
+ more.setAttribute("aria-label", "More for this ref");
35
+ more.setAttribute("aria-haspopup", "menu");
36
+ more.tabIndex = -1;
37
+ more.hidden = true;
38
+ more.addEventListener("mouseenter", () => clearTimeout(hideTimer));
39
+ more.addEventListener("mouseleave", () => hideMoreSoon(moreFor));
40
+ more.addEventListener("mousedown", (e) => e.preventDefault()); // keeps the ref's focus
41
+ more.addEventListener("click", (e) => {
42
+ e.preventDefault();
43
+ e.stopPropagation();
44
+ onMore?.();
45
+ });
46
+ doc.body.appendChild(more);
47
+ return more;
48
+ }
49
+
50
+ function showMore(el, onOpen) {
51
+ clearTimeout(hideTimer);
52
+ const button = moreButton();
53
+ moreFor = el;
54
+ onMore = onOpen;
55
+ button.hidden = false;
56
+ const rect = lastRect(el);
57
+ const top = rect.top + (rect.height - (button.offsetHeight || rect.height)) / 2;
58
+ button.style.left = `${Math.min(rect.right + 2, win.innerWidth - (button.offsetWidth || 16) - 2)}px`;
59
+ button.style.top = `${top}px`;
60
+ }
61
+
62
+ function hideMore() {
63
+ clearTimeout(hideTimer);
64
+ if (more) more.hidden = true;
65
+ moreFor = null;
66
+ }
67
+
68
+ function hideMoreSoon(el) {
69
+ if (!more || (el && el !== moreFor)) return;
70
+ clearTimeout(hideTimer);
71
+ hideTimer = setTimeout(() => {
72
+ if (menu && menuFor === moreFor) return;
73
+ if (doc.activeElement && doc.activeElement === moreFor) return;
74
+ hideMore();
75
+ }, HIDE_MS);
76
+ }
77
+
78
+ function open(el, items, { focus = true } = {}) {
79
+ close();
80
+ menuFor = el;
81
+ menu = doc.createElement("div");
82
+ menu.className = "ref-menu";
83
+ menu.setAttribute("role", "menu");
84
+ for (const item of items) {
85
+ const button = doc.createElement("button");
86
+ button.type = "button";
87
+ button.setAttribute("role", "menuitem");
88
+ button.tabIndex = -1;
89
+ button.dataset.target = item.id;
90
+ button.textContent = item.label;
91
+ button.addEventListener("click", (e) => {
92
+ e.preventDefault();
93
+ e.stopPropagation();
94
+ close({ refocus: false });
95
+ hideMore();
96
+ item.run();
97
+ });
98
+ menu.appendChild(button);
99
+ }
100
+ menu.addEventListener("keydown", onMenuKey);
101
+ menu.addEventListener("focusout", (e) => {
102
+ if (menu && !menu.contains(e.relatedTarget) && e.relatedTarget) close({ refocus: false });
103
+ });
104
+ doc.body.appendChild(menu);
105
+ place(menu, el);
106
+ el.setAttribute("aria-expanded", "true");
107
+ doc.addEventListener("pointerdown", onOutside, true);
108
+ win.addEventListener("scroll", onScroll, true);
109
+ win.addEventListener("resize", onScroll);
110
+ if (focus) menuItems()[0]?.focus();
111
+ return menu;
112
+ }
113
+
114
+ function close({ refocus = false } = {}) {
115
+ if (!menu) return;
116
+ const el = menuFor;
117
+ menu.remove();
118
+ menu = null;
119
+ menuFor = null;
120
+ el?.removeAttribute("aria-expanded");
121
+ doc.removeEventListener("pointerdown", onOutside, true);
122
+ win.removeEventListener("scroll", onScroll, true);
123
+ win.removeEventListener("resize", onScroll);
124
+ if (refocus) el?.focus?.();
125
+ }
126
+
127
+ function menuItems() {
128
+ return menu ? [...menu.querySelectorAll('[role="menuitem"]')] : [];
129
+ }
130
+
131
+ function onMenuKey(e) {
132
+ const items = menuItems();
133
+ const at = items.indexOf(doc.activeElement);
134
+ const go = (index) => items[(index + items.length) % items.length]?.focus();
135
+ if (e.key === "ArrowDown") go(at + 1);
136
+ else if (e.key === "ArrowUp") go(at < 0 ? -1 : at - 1);
137
+ else if (e.key === "Home") go(0);
138
+ else if (e.key === "End") go(-1);
139
+ else if (e.key === "Escape") close({ refocus: true });
140
+ else if (e.key === "Tab") { close({ refocus: true }); return; }
141
+ else return;
142
+ e.preventDefault();
143
+ e.stopPropagation();
144
+ }
145
+
146
+ function onOutside(e) {
147
+ if (menu?.contains(e.target) || more?.contains(e.target)) return;
148
+ close();
149
+ }
150
+
151
+ function onScroll(e) {
152
+ if (menu?.contains(e?.target)) return;
153
+ close();
154
+ hideMore();
155
+ }
156
+
157
+ // Under the ref's last line, flipped above it when there is no room
158
+ // below, kept inside the viewport.
159
+ function place(box, el) {
160
+ const rect = lastRect(el);
161
+ const first = el.getClientRects?.()[0] || rect;
162
+ const width = box.offsetWidth;
163
+ const height = box.offsetHeight;
164
+ const left = Math.max(EDGE, Math.min(rect.left, win.innerWidth - width - EDGE));
165
+ const below = rect.bottom + 4;
166
+ const top = below + height > win.innerHeight - EDGE && first.top - height - 4 >= EDGE ? first.top - height - 4 : below;
167
+ box.style.left = `${left}px`;
168
+ box.style.top = `${top}px`;
169
+ }
170
+
171
+ return {
172
+ open,
173
+ close,
174
+ showMore,
175
+ hideMore,
176
+ hideMoreSoon,
177
+ get isOpen() { return !!menu; },
178
+ get element() { return menu; },
179
+ get moreButton() { return more; },
180
+ };
181
+ }
182
+
183
+ // The ref's last line box (a ref wrapped over two lines ends on the
184
+ // second); its whole box when it has none.
185
+ function lastRect(el) {
186
+ const rects = el.getClientRects?.() || [];
187
+ return rects.length ? rects[rects.length - 1] : el.getBoundingClientRect();
188
+ }
@@ -118,10 +118,20 @@ export function touchedFiles(roots) {
118
118
  // Wraps the refs in +el+'s text in span.ref (refs.js clicks and
119
119
  // hover them as it does a row title). Text inside pre, a link or a ref is
120
120
  // left alone; an inline code element is wrapped whole when it is a ref
121
- // alone. Runs again over the same element without wrapping twice.
121
+ // alone. A link whose text is a ref alone (github-pr's PR diff link) is
122
+ // that ref itself: its href stays the plain click's, the ref's other
123
+ // targets join it in the menu (refs.js). Runs again over the same element
124
+ // without wrapping twice.
122
125
  export function linkRefs(el, touched, cwd) {
123
126
  if (!el || !touched?.size) return;
124
127
  const doc = el.ownerDocument;
128
+ for (const link of [...el.querySelectorAll("a[href]:not([data-ref-kind])")]) {
129
+ if (link.parentElement?.closest("pre, a, [data-ref-kind]")) continue;
130
+ const ref = aloneRef(link.textContent, touched, cwd);
131
+ if (!ref) continue;
132
+ link.classList.add("ref");
133
+ for (const [name, value] of Object.entries(refSpan(doc, ref).dataset)) link.dataset[name] = value;
134
+ }
125
135
  for (const code of [...el.querySelectorAll("code")]) {
126
136
  if (code.closest(SKIP)) continue;
127
137
  const ref = aloneRef(code.textContent, touched, cwd);