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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +54 -1
- data/README.md +1 -1
- data/docs/cli.md +1 -1
- data/docs/configuration.md +5 -3
- data/docs/hooks.md +2 -2
- data/docs/memory.md +1 -1
- data/docs/plugins.md +63 -0
- data/docs/sessions.md +2 -2
- data/lib/samagotchi/bootstrap/config_writer.rb +2 -0
- data/lib/samagotchi/bootstrap/probe.rb +3 -0
- data/lib/samagotchi/bootstrap_command.rb +2 -0
- data/lib/samagotchi/broadcast_command.rb +2 -0
- data/lib/samagotchi/bundles/core/manifest.yml +2 -1
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +15 -1
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +11 -0
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +320 -0
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/context_command.rb +2 -0
- data/lib/samagotchi/desktop/macos.rb +3 -0
- data/lib/samagotchi/engine.rb +2 -2
- data/lib/samagotchi/gem_update.rb +3 -0
- data/lib/samagotchi/guardrails/shell_lex.rb +8 -0
- data/lib/samagotchi/guardrails/shell_paths.rb +3 -0
- data/lib/samagotchi/host_registry.rb +15 -0
- data/lib/samagotchi/installed_versions.rb +3 -0
- data/lib/samagotchi/model_profile.rb +14 -3
- data/lib/samagotchi/plugin/context.rb +5 -0
- data/lib/samagotchi/redacted_inspect.rb +43 -0
- data/lib/samagotchi/session_commands.rb +5 -4
- data/lib/samagotchi/terminal_ui/attached_loop.rb +13 -8
- data/lib/samagotchi/terminal_ui.rb +14 -5
- data/lib/samagotchi/tool_runner.rb +14 -3
- data/lib/samagotchi/tools/execute.rb +3 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +14 -6
- data/lib/samagotchi/web/public/app.js +31 -14
- data/lib/samagotchi/web/public/index.html +32 -5
- data/lib/samagotchi/web/public/question_card.js +5 -3
- data/lib/samagotchi/web/public/question_cards.js +21 -1
- data/lib/samagotchi/web/public/read_view.js +74 -0
- data/lib/samagotchi/web/public/ref_menu.js +188 -0
- data/lib/samagotchi/web/public/ref_scan.js +216 -0
- data/lib/samagotchi/web/public/refs.js +283 -54
- data/lib/samagotchi/web/public/stage_view.js +21 -0
- data/lib/samagotchi/web/public/turn_html.js +4 -2
- data/lib/samagotchi/web/public/turn_view.js +5 -3
- data/lib/samagotchi/web/token.rb +3 -0
- data/lib/samagotchi/worker.rb +2 -2
- 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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
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.
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -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
|
|
710
|
-
# dir, model, llm_context and
|
|
711
|
-
#
|
|
712
|
-
#
|
|
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
|
-
@
|
|
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: @
|
|
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
|
|
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
|
|
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
|
|
871
|
-
|
|
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
|
-
|
|
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 (
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
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
|
|
372
|
-
.
|
|
373
|
-
.
|
|
374
|
-
.
|
|
375
|
-
.
|
|
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
|
-
//
|
|
54
|
-
// is
|
|
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
|
-
|
|
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
|
+
}
|