samagotchi 0.56.0 → 0.58.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -1
  3. data/README.md +1 -1
  4. data/docs/cli.md +1 -1
  5. data/docs/configuration.md +5 -3
  6. data/docs/hooks.md +2 -2
  7. data/docs/memory.md +1 -1
  8. data/docs/plugins.md +63 -0
  9. data/docs/sessions.md +2 -2
  10. data/lib/samagotchi/bootstrap/config_writer.rb +2 -0
  11. data/lib/samagotchi/bootstrap/probe.rb +3 -0
  12. data/lib/samagotchi/bootstrap_command.rb +2 -0
  13. data/lib/samagotchi/broadcast_command.rb +2 -0
  14. data/lib/samagotchi/bundles/core/manifest.yml +2 -1
  15. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/mcp/plugin.rb +15 -1
  17. data/lib/samagotchi/bundles/slow-commands/manifest.yml +11 -0
  18. data/lib/samagotchi/bundles/slow-commands/plugin.rb +320 -0
  19. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  21. data/lib/samagotchi/context_command.rb +2 -0
  22. data/lib/samagotchi/desktop/macos.rb +3 -0
  23. data/lib/samagotchi/engine.rb +2 -2
  24. data/lib/samagotchi/gem_update.rb +3 -0
  25. data/lib/samagotchi/guardrails/shell_lex.rb +8 -0
  26. data/lib/samagotchi/guardrails/shell_paths.rb +3 -0
  27. data/lib/samagotchi/host_registry.rb +15 -0
  28. data/lib/samagotchi/installed_versions.rb +3 -0
  29. data/lib/samagotchi/model_profile.rb +14 -3
  30. data/lib/samagotchi/plugin/context.rb +5 -0
  31. data/lib/samagotchi/redacted_inspect.rb +43 -0
  32. data/lib/samagotchi/session_commands.rb +5 -4
  33. data/lib/samagotchi/terminal_ui/attached_loop.rb +13 -8
  34. data/lib/samagotchi/terminal_ui.rb +14 -5
  35. data/lib/samagotchi/tool_runner.rb +14 -3
  36. data/lib/samagotchi/tools/execute.rb +3 -2
  37. data/lib/samagotchi/version.rb +1 -1
  38. data/lib/samagotchi/web/app.rb +14 -6
  39. data/lib/samagotchi/web/public/app.js +31 -14
  40. data/lib/samagotchi/web/public/index.html +32 -5
  41. data/lib/samagotchi/web/public/question_card.js +5 -3
  42. data/lib/samagotchi/web/public/question_cards.js +21 -1
  43. data/lib/samagotchi/web/public/read_view.js +74 -0
  44. data/lib/samagotchi/web/public/ref_menu.js +188 -0
  45. data/lib/samagotchi/web/public/ref_scan.js +216 -0
  46. data/lib/samagotchi/web/public/refs.js +283 -54
  47. data/lib/samagotchi/web/public/stage_view.js +21 -0
  48. data/lib/samagotchi/web/public/turn_html.js +4 -2
  49. data/lib/samagotchi/web/public/turn_view.js +5 -3
  50. data/lib/samagotchi/web/token.rb +3 -0
  51. data/lib/samagotchi/worker.rb +2 -2
  52. metadata +7 -1
@@ -98,7 +98,7 @@ module Samagotchi
98
98
  @thinking_word = thinking
99
99
  @prompt = prompt
100
100
  @llm_context_words = llm_context
101
- @default_model_name = ModelProfile.required_model_name(nil)
101
+ @default_model_name = ModelProfile.default_model_name
102
102
  # As typed: the Engine applies an alias where it resolves the model.
103
103
  flag_model_name = model_name.to_s.strip.empty? ? nil : ModelProfile.required_model_name(model_name)
104
104
  @effective_model_name = flag_model_name || @default_model_name
@@ -127,6 +127,12 @@ module Samagotchi
127
127
  # Re-resolve client after resume may change effective model
128
128
  @client = @host_registry.resolve(@effective_model_name).client
129
129
  end
130
+ # No --model, no session model and no default: nothing to run.
131
+ raise ModelProfile::MissingModel, ModelProfile.missing_model_message if @effective_model_name.to_s.strip.empty?
132
+
133
+ # With no config default the Engine starts on the model it runs.
134
+ no_default = @default_model_name.nil?
135
+
130
136
  # The model it starts on names a configured host (a bad default.model
131
137
  # doesn't matter when --model or the session picks another).
132
138
  ModelProfile.check_host!(@effective_model_name, hosts: @host_registry.entries)
@@ -166,8 +172,10 @@ module Samagotchi
166
172
  session_id: session_id,
167
173
  no_interrupt: no_interrupt,
168
174
  # nil: the config default, unchecked here (the model the REPL starts
169
- # on is checked above, and --model may make the default moot).
170
- model_name: nil,
175
+ # on is checked above, and --model may make the default moot). With
176
+ # no default, the model it runs (the switch below is then skipped).
177
+ model_name: no_default ? @effective_model_name : nil,
178
+ model_typed: no_default ? @resumed_model_typed : nil,
171
179
  memories: @requested_memories,
172
180
  muted_memories: @muted_memory_names,
173
181
  recap: scratch ? nil : recap_config,
@@ -199,8 +207,9 @@ module Samagotchi
199
207
  @commands = SessionCommands.new(engine: @engine, turn_flow: @turn_flow, default_model: @default_model_name,
200
208
  registry: @engine.command_registry)
201
209
  # Runtime --model flag or resumed session: switch the Engine (client,
202
- # kernel profile) without persisting the default.
203
- @engine.switch_model!(@effective_model_name, typed: @resumed_model_typed) if @effective_model_name != @default_model_name
210
+ # kernel profile) without persisting the default. Skipped when there's
211
+ # no config default (the Engine was built on the session model).
212
+ @engine.switch_model!(@effective_model_name, typed: @resumed_model_typed) if !no_default && @effective_model_name != @default_model_name
204
213
  # Render an idle session-recap via the cursor-safe background writer; the
205
214
  # detector itself is Engine-owned (see Engine#recap) and opt-in.
206
215
  @recap_handle = @engine.subscribe(observer: ->(event) { @between_turns.take_recap(event) })
@@ -28,7 +28,8 @@ module Samagotchi
28
28
 
29
29
  # @param kernel [KernelLoop] read lazily: Engine sets its hooks after
30
30
  # the kernel is built.
31
- # @param clock [#call] monotonic seconds, for the approval wait
31
+ # @param clock [#call] monotonic seconds, for the approval wait and the
32
+ # dispatch time (after_tool_call's elapsed_ms)
32
33
  def initialize(kernel, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
33
34
  @kernel = kernel
34
35
  @clock = clock
@@ -71,7 +72,14 @@ module Samagotchi
71
72
  end
72
73
  before = verdict.deny? ? NOT_AN_EDIT : file_before(call)
73
74
  index_before = before.equal?(NOT_AN_EDIT) ? nil : memory_index_size(call)
74
- result = verdict.deny? ? denied(call, verdict) : dispatch(call)
75
+ elapsed_ms = nil
76
+ if verdict.deny?
77
+ result = denied(call, verdict)
78
+ else
79
+ dispatched_at = @clock.call
80
+ result = dispatch(call)
81
+ elapsed_ms = ((@clock.call - dispatched_at) * 1000).round
82
+ end
75
83
  diff = file_change(call, before)
76
84
  if diff && refresh_memory_index(call)
77
85
  result = with_index_note(result, index_before, call)
@@ -80,6 +88,9 @@ module Samagotchi
80
88
  result, images = attach_images(call, result) if result[:images]
81
89
 
82
90
  output = scrub(result[:output].to_s)
91
+ # The tool's own text, for after_tool_call: no "[tool]" lead, no
92
+ # ran-as line, no cap.
93
+ tool_text = output.sub(/\A\[#{Regexp.escape(call[:name].to_s)}\](?:\n| )/, "")
83
94
  # The gate may have replaced the call (known-names corrects a near
84
95
  # miss): one leading line tells the model what actually ran.
85
96
  if (changed = changed_call_line(original, call))
@@ -104,7 +115,7 @@ module Samagotchi
104
115
  # After the completed event: a hook's card (check-in, skills) prints
105
116
  # under the call's tool row, not before it.
106
117
  fire(:after_tool_call, { type: :after_tool_call, iteration: iteration, tool: call[:name], output: capped,
107
- status: status || "error" })
118
+ status: status || "error", call: call.dup, elapsed_ms: elapsed_ms, result: tool_text })
108
119
 
109
120
  run = { output: output, capped_output: capped, truncated: truncated, activity: result[:activity] }
110
121
  run[:images] = images if images&.any?
@@ -24,6 +24,8 @@ module Samagotchi
24
24
  NOT_RUN_ON_STOP = "Error: not run, the user stopped the turn"
25
25
  # The first words of a command the user's Stop killed.
26
26
  STOPPED_BY_USER = "Error: command stopped by the user"
27
+ # The first words of a command killed at the limit (hooks match on it).
28
+ TIMEOUT_PREFIX = "Error: command timed out after"
27
29
  TIMEOUT_HINT = "(killed at the limit; for a long command use task_create, then task_wait)"
28
30
  # How long the output may stay open once the shell has exited: a
29
31
  # `server &` child keeps the pipes, and the read would never end.
@@ -68,7 +70,7 @@ module Samagotchi
68
70
  rescue CommandTimedOut => e
69
71
  # Keep the "Error:" first line (callers classify on it), say how to run
70
72
  # a long command, and return whatever it printed before it was killed.
71
- stopped_result("Error: command timed out after #{timeout_sec}s\n#{TIMEOUT_HINT}", e)
73
+ stopped_result("#{TIMEOUT_PREFIX} #{timeout_sec}s\n#{TIMEOUT_HINT}", e)
72
74
  rescue CommandCancelled => e
73
75
  stopped_result("#{STOPPED_BY_USER} after #{e.elapsed.round}s (killed; rerun it if still needed)", e)
74
76
  rescue StandardError => e
@@ -189,7 +191,6 @@ module Samagotchi
189
191
  def self.timeout_seconds
190
192
  OutputGuardrails.config_positive_int("execute.timeout_sec", TIMEOUT_SEC)
191
193
  end
192
- private_class_method :timeout_seconds
193
194
 
194
195
  # Resolves the working directory for the child process. An empty/absent
195
196
  # cwd defaults to the project root (Dir.pwd), mirroring task_runtime.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.56.0"
4
+ VERSION = "0.58.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.
@@ -706,10 +710,12 @@ module Samagotchi
706
710
 
707
711
  # POST /api/sessions with continues: <id, prefix or last:id>: the next
708
712
  # link of that session's chain (SessionManager.continue_session), in its
709
- # folder, on its model and with its llm_context and thinking level, so
710
- # dir, model, llm_context and thinking are refused beside it. 409 continued (with next_id, the
711
- # link that continues it already: the page opens that one),
712
- # open_children (ids), folder_gone, busy, scratch or owned_by_tui.
713
+ # folder, on its model and with its own setup (SessionSetup: llm_context,
714
+ # thinking and reasoning_history levels), so dir, model, llm_context and
715
+ # thinking are refused beside it (a create takes no reasoning_history).
716
+ # 409 continued (with next_id, the link that continues it already: the
717
+ # page opens that one), open_children (ids), folder_gone, busy, scratch
718
+ # or owned_by_tui.
713
719
  def handle_continue(body, prompt, idle)
714
720
  given = body["continues"]
715
721
  unless given.is_a?(String) && !given.strip.empty?
@@ -2128,10 +2134,12 @@ module Samagotchi
2128
2134
  # before returning, and a read blocked on a quiet worker would hang
2129
2135
  # Ctrl-C of chi web. The bytes go through unchanged.
2130
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)
2131
2139
  def initialize(host:, port:, session_id:, query:, headers:, server_running: -> { true })
2132
2140
  @client = BridgeClient.new(session_id: session_id, port: port, host: host)
2133
2141
  @query = query
2134
- @headers = headers
2142
+ @last_event_id = headers["HTTP_LAST_EVENT_ID"]
2135
2143
  @server_running = server_running
2136
2144
  end
2137
2145
 
@@ -2140,7 +2148,7 @@ module Samagotchi
2140
2148
  # Last-Event-ID header over ?from_seq, and the reconnect URL carries a
2141
2149
  # stale initial cursor — without this the bridge would replay content
2142
2150
  # already delivered (duplicate bubbles).
2143
- @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, &)
2144
2152
  end
2145
2153
  end
2146
2154
  end
@@ -65,7 +65,8 @@ 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
+ import { linkRefs, touchedFiles } from "./ref_scan.js";
69
70
  import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
70
71
  import { isReportClient, hookNoticeLabel, llmContextNotice, newClientId, noticeLine, promptOps, snapshotEvents, workerGoneText } from "./turn_events.js";
71
72
  import { applyConfirm, drawCommandBubble, keptCommandBubbles, restoreCommandBubbles } from "./commands.js";
@@ -837,6 +838,7 @@ function renderHistory(items) {
837
838
  }
838
839
  if (typeof items[0] === "object" && items[0].role) {
839
840
  historyEl.replaceChildren(turnHistoryNodes(items, current.timing, { thumbs: (images) => thumbsHtml(selected, images) }));
841
+ linkTextRefs();
840
842
  } else {
841
843
  historyEl.innerHTML = items.map((c) => `<div class="bubble output">${escapeHtml(c)}</div>`).join("");
842
844
  }
@@ -853,13 +855,11 @@ function setMarkdownWarning(warning) {
853
855
  markdownWarningEl.hidden = false;
854
856
  }
855
857
 
856
- // Both from one payload, together; the body's has-editor and refs-<kind>
857
- // classes follow (the refs' hover styling keys on refs-<kind>).
858
+ // Both from one payload, together; the body's has-editor class follows.
858
859
  function setCapabilities(capabilities, editorUrl) {
859
860
  pageCapabilities = capabilities && typeof capabilities === "object" ? capabilities : {};
860
861
  pageEditorUrl = pageCapabilities.editor === true && typeof editorUrl === "string" && editorUrl ? editorUrl : null;
861
862
  document.body.classList.toggle("has-editor", pageEditorUrl !== null);
862
- setRefKinds(document.body, pageViewer());
863
863
  }
864
864
 
865
865
  // What this viewer can do with a ref (refs.js REF_ACTIONS).
@@ -867,8 +867,11 @@ function pageViewer() {
867
867
  return { editor: pageCapabilities.editor === true, editorUrl: pageEditorUrl };
868
868
  }
869
869
 
870
- // Refs anywhere on the page (a tool row's file, its diff lines): one listener.
871
- 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
+ });
872
875
 
873
876
  async function readTurnEnd() {
874
877
  if (!selected) return;
@@ -901,20 +904,34 @@ async function readTurnEnd() {
901
904
  } catch(_){}
902
905
  }
903
906
 
907
+ // The turn's answer as its rendered markdown, once it is final (the
908
+ // turn-end read, an answer_display re-read); then its refs.
904
909
  function applyFinalMarkdown(messages) {
905
910
  const message = [...messages].reverse().find(
906
911
  (item) => item?.role === "assistant" && typeof item.html === "string",
907
912
  );
908
- if (!message) return;
909
-
910
- const bubble = findInTurnRoots(".bubble.output")
913
+ const bubble = message && findInTurnRoots(".bubble.output")
911
914
  .reverse()
912
915
  .find((item) => showsAnswer(item, message, normalize));
913
- if (!bubble) return;
914
-
915
- bubble.classList.add("markdown");
916
- bubble.setAttribute(COPY_SOURCE_ATTR, copySource(message));
917
- bubble.innerHTML = message.html;
916
+ if (bubble) {
917
+ bubble.classList.add("markdown");
918
+ bubble.setAttribute(COPY_SOURCE_ATTR, copySource(message));
919
+ bubble.innerHTML = message.html;
920
+ }
921
+ linkTextRefs();
922
+ }
923
+
924
+ // The `path:line` refs in the answers' and closed steps' texts that name a
925
+ // file the session's rows (and cards) touched, on this page (ref_scan.js).
926
+ // Not mid-stream: at a turn's end and after a reload's render. A ref
927
+ // already made stays as it is.
928
+ function linkTextRefs() {
929
+ const roots = turnRoots();
930
+ const touched = touchedFiles(roots);
931
+ if (!touched.size) return;
932
+ for (const root of roots) {
933
+ for (const el of root.querySelectorAll(".bubble.output, .gen:not(.live) > .gen-text")) linkRefs(el, touched, current.dir);
934
+ }
918
935
  }
919
936
 
920
937
  function seedSeenFromDom() {
@@ -178,6 +178,17 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
178
178
  .activity-diff>summary{cursor:pointer;font-size:12px;color:var(--text-muted);width:max-content;font-variant-numeric:tabular-nums}
179
179
  .activity-diff>summary:hover{color:var(--text-2)}
180
180
  .activity-diff[open]>summary{margin-bottom:4px}
181
+ /* A read of the file as-is: its numbered lines, closed under the preview (read_view.js); open, they stand for it. */
182
+ .activity-read{flex:1 1 100%;min-width:0;margin:2px 0 2px var(--row-gutter)}
183
+ .activity-read>summary{cursor:pointer;font-size:12px;color:var(--text-muted);width:max-content;font-variant-numeric:tabular-nums}
184
+ .activity-read>summary:hover{color:var(--text-2)}
185
+ .activity-read[open]>summary{margin-bottom:4px}
186
+ .activity-row:has(>.activity-read[open])>.activity-output{display:none}
187
+ .read-lines{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}
188
+ .read-lines code{display:block;min-width:max-content}
189
+ .read-line{display:block;padding:0 10px 0 0;white-space:pre;color:var(--text-2)}
190
+ .read-line::before{content:attr(data-line);display:inline-block;width:5ch;margin-right:10px;padding-right:6px;text-align:right;color:var(--text-faint);user-select:none;font-variant-numeric:tabular-nums}
191
+ .read-meta{color:var(--text-faint)}
181
192
  /* An execute/task_create's full command, above its output and lined up with it: as written, its own scroll. */
182
193
  .activity-row>.activity-command{flex:1 1 100%;min-width:0;margin:4px 0 2px var(--row-gutter)}
183
194
  .activity-command pre{margin:0;max-height:160px;overflow:auto;white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;line-height:1.45;color:var(--text-2);background:var(--bg-inset);border:1px solid var(--border);border-radius:6px;padding:6px 34px 6px 8px}
@@ -215,6 +226,7 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
215
226
  .activity-params{flex:1 1 0}
216
227
  .activity-output{margin-left:0}
217
228
  .activity-diff{margin-left:0}
229
+ .activity-read{margin-left:0}
218
230
  .activity-edit{margin-left:0}
219
231
  .activity-row>.activity-command{margin-left:0}
220
232
  }
@@ -368,11 +380,26 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
368
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}
369
381
  .diff code{display:block;min-width:max-content}
370
382
  .diff-line{display:block;padding:0 10px;white-space:pre;color:var(--text-2)}
371
- /* Refs (refs.js): text until hovered, and only for a kind this viewer has an action for (body.refs-file). */
372
- .refs-file .ref[data-ref-kind="file"]:hover{color:var(--accent);text-decoration:underline dotted;text-underline-offset:3px;cursor:pointer}
373
- .refs-file .ref[data-ref-kind="file"]:hover::after{content:"\2197";margin-left:.3em;color:var(--text-faint);display:inline-block}
374
- .refs-file .activity-row:has(.ref[data-ref-kind="file"]) .diff-line[data-line]:hover{cursor:pointer;box-shadow:inset 2px 0 var(--accent)}
375
- .refs-file .activity-row: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)}
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}}
376
403
  .diff-add{background:var(--diff-add-bg);color:var(--diff-add-text)}
377
404
  .diff-del{background:var(--diff-del-bg);color:var(--diff-del-text)}
378
405
  .diff-hunk{color:var(--text-faint)}
@@ -49,9 +49,10 @@ export function relayView(pq) {
49
49
  };
50
50
  }
51
51
 
52
- // @return {null | {tool, what, isCommand, where, why, preview?, delegate?}};
53
- // preview is an edit/write's dry-run diff (diff_view.js draws it); delegate
54
- // is relayView's, for a delegate's approval
52
+ // @return {null | {tool, what, isCommand, where, why, paths?, preview?, delegate?}};
53
+ // paths are what +what+ joins when it shows paths (the card draws a ref
54
+ // for each); preview is an edit/write's dry-run diff (diff_view.js draws
55
+ // it); delegate is relayView's, for a delegate's approval
55
56
  export function approvalView(pq) {
56
57
  if (!isApproval(pq)) return null;
57
58
  const a = pq.approval || {};
@@ -74,6 +75,7 @@ export function approvalView(pq) {
74
75
  // A tool without a command or path (an MCP tool): its compact args.
75
76
  what: command || paths.join("\n") || a.args || "",
76
77
  isCommand: !!command,
78
+ ...(!command && paths.length ? { paths } : {}),
77
79
  where,
78
80
  why: `${reason} (${who})`,
79
81
  ...(a.preview && typeof a.preview === "object" ? { preview: a.preview } : {}),
@@ -254,7 +254,8 @@ export function createQuestionCards({
254
254
  const what = doc.createElement("pre");
255
255
  what.className = view.isCommand ? "approval-what command" : "approval-what paths";
256
256
  const code = doc.createElement("code");
257
- code.textContent = view.what;
257
+ if (view.paths) appendPathRefs(code, view.paths);
258
+ else code.textContent = view.what;
258
259
  what.appendChild(code);
259
260
  box.appendChild(what);
260
261
  }
@@ -279,6 +280,25 @@ export function createQuestionCards({
279
280
  return box;
280
281
  }
281
282
 
283
+ // One file ref per path, a line each (refs.js: the card's path opens
284
+ // the current file at the dry run's first change). A directory (a
285
+ // plugin's path ending in /) stays plain text.
286
+ function appendPathRefs(code, paths) {
287
+ paths.forEach((path, i) => {
288
+ if (i) code.appendChild(doc.createTextNode("\n"));
289
+ if (String(path).endsWith("/")) {
290
+ code.appendChild(doc.createTextNode(String(path)));
291
+ return;
292
+ }
293
+ const ref = doc.createElement("span");
294
+ ref.className = "ref";
295
+ ref.dataset.refKind = "file";
296
+ ref.dataset.refPath = String(path);
297
+ ref.textContent = String(path);
298
+ code.appendChild(ref);
299
+ });
300
+ }
301
+
282
302
  // "Waiting for approval in parent ab12: answering here works too", or
283
303
  // an empty (hidden) line while the question isn't relayed.
284
304
  function renderRelayedTo(pq) {
@@ -0,0 +1,74 @@
1
+ // A read row's output as the file's lines, numbered, apart from the DOM.
2
+ // Only when the output is the file text as-is: each numbered line is a
3
+ // ref's line (refs.js opens the row's file there). The row's preview
4
+ // (turn_html.js) strips any "[tag] " and cuts at 300 chars; numbering
5
+ // needs the text exactly as read, so it is drawn on its own, closed.
6
+
7
+ import { escapeHtml } from "./format.js";
8
+
9
+ // What follows one of these isn't the file's next line: a ToolRunner cap
10
+ // ("[cut: …]"), a head/tail preview's gap ("[... omitted …]") or the note
11
+ // of a range clamped at the end of the file ("[read: end_line …]"). They
12
+ // are appended at the end (a clamp note, then a cut), so only the last
13
+ // MARKER_LINES lines can be one: such a line earlier is the file's own.
14
+ const MARKER_LINES = 2;
15
+ const MARKER = /^(?:\[cut: |\[\.\.\. omitted |\[read: end_line \d+ exceeds )/;
16
+
17
+ // An image's line instead of its text: attached, or why not (ToolRunner).
18
+ const IMAGE = /\((?:\d+×\d+ )?[A-Z]+\)(?: attached\.| is an image; | is not attached: )/;
19
+
20
+ // The file text of a read row's output, or null when it isn't the file
21
+ // as-is: an error, the head/tail preview of a big file, an image's line.
22
+ function fileText(row) {
23
+ if (row?.tool !== "read" || row.status !== "ok" || row.ref?.kind !== "file" || row.images?.length) return null;
24
+ let text = String(row.output || "");
25
+ // The call the gate replaced says so on a line before the tag.
26
+ if (text.startsWith("ran as: ")) text = text.slice(text.indexOf("\n") + 1);
27
+ if (!text.startsWith("[read]\n")) return null;
28
+ text = text.slice("[read]\n".length);
29
+ if (!text || text.startsWith("Error: ") || text.startsWith("truncated=true\n")) return null;
30
+ if (!text.includes("\n") && IMAGE.test(text)) return null;
31
+ return text;
32
+ }
33
+
34
+ // @return {null | Array<{text, line}>} the output's lines, line null for
35
+ // one that isn't the file's: a marker and all after it, and the "…" of a
36
+ // reload's capped output (output_truncated; a cut line already says so
37
+ // for a live one). The first line is the range's start_line (the ref's
38
+ // line), else 1.
39
+ export function readLines(row) {
40
+ const text = fileText(row);
41
+ if (text === null) return null;
42
+ const lines = text.split("\n");
43
+ // The file's last newline ends a line, it doesn't start one.
44
+ if (lines.at(-1) === "") lines.pop();
45
+ let next = Number(row.ref.line) || 1;
46
+ let numbering = true;
47
+ const out = [];
48
+ lines.forEach((line, i) => {
49
+ if (numbering && i >= lines.length - MARKER_LINES && MARKER.test(line)) {
50
+ numbering = false;
51
+ // The newline before a marker is the marker's: the blank it leaves
52
+ // isn't a line of the file.
53
+ if (i > 0 && lines[i - 1] === "" && out.at(-1)?.line !== null) out.pop();
54
+ }
55
+ out.push({ text: line, line: numbering ? next++ : null });
56
+ });
57
+ if (row.output_truncated && numbering) out.push({ text: "…", line: null });
58
+ return out;
59
+ }
60
+
61
+ // A read row's numbered lines behind a closed toggle ("lines 40–60"); ""
62
+ // when its output isn't the file as-is.
63
+ export function readLinesHtml(row) {
64
+ const lines = readLines(row);
65
+ const numbered = lines?.filter((l) => l.line !== null) || [];
66
+ if (!numbered.length) return "";
67
+ const first = numbered[0].line;
68
+ const last = numbered.at(-1).line;
69
+ const label = first === last ? `line ${first}` : `lines ${first}–${last}`;
70
+ const spans = lines.map(({ text, line }) => line === null
71
+ ? `<span class="read-line read-meta">${escapeHtml(text)}</span>`
72
+ : `<span class="read-line" data-line="${line}">${escapeHtml(text) || " "}</span>`).join("");
73
+ return `<details class="activity-read"><summary>${label}</summary><pre class="read-lines"><code>${spans}</code></pre></details>`;
74
+ }
@@ -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
+ }