samagotchi 0.5.0 → 0.6.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 +84 -1
- data/bin/chi +32 -891
- data/docs/architecture.md +8 -1
- data/docs/cli.md +12 -13
- data/docs/configuration.md +4 -1
- data/docs/desktop.md +3 -1
- data/docs/hooks.md +69 -4
- data/docs/plugins.md +98 -3
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bridge/card_store.rb +30 -5
- data/lib/samagotchi/bridge/turn_accumulator.rb +7 -4
- data/lib/samagotchi/bridge/turn_notice.rb +37 -0
- data/lib/samagotchi/bundle_command.rb +652 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +4 -4
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +214 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -0
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/cancellation_controller.rb +42 -1
- data/lib/samagotchi/config.rb +2 -2
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +20 -5
- data/lib/samagotchi/desktop/macos/Panel.swift +31 -3
- data/lib/samagotchi/engine.rb +138 -618
- data/lib/samagotchi/guardrail_wiring.rb +238 -0
- data/lib/samagotchi/hooks/registry.rb +11 -3
- data/lib/samagotchi/hooks/stream_watch.rb +93 -0
- data/lib/samagotchi/hooks.rb +3 -0
- data/lib/samagotchi/kernel_loop.rb +65 -6
- data/lib/samagotchi/llm/chat_loop.rb +73 -11
- data/lib/samagotchi/llm/openai_chat.rb +16 -4
- data/lib/samagotchi/log_subscriber.rb +10 -4
- data/lib/samagotchi/plugin/api.rb +8 -0
- data/lib/samagotchi/plugin/context.rb +12 -3
- data/lib/samagotchi/plugin_tasks.rb +229 -0
- data/lib/samagotchi/question_desk.rb +280 -0
- data/lib/samagotchi/recap_setup.rb +128 -0
- data/lib/samagotchi/reply_wait.rb +9 -0
- data/lib/samagotchi/send_command.rb +3 -6
- data/lib/samagotchi/session.rb +9 -1
- data/lib/samagotchi/sessions_command.rb +255 -0
- data/lib/samagotchi/steer.rb +19 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -5
- data/lib/samagotchi/terminal_ui/formatting.rb +4 -2
- data/lib/samagotchi/tool_declarations.rb +2 -2
- data/lib/samagotchi/tools/delegate.rb +6 -2
- data/lib/samagotchi/tools/delegate_wait.rb +26 -8
- data/lib/samagotchi/turn_note.rb +20 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +28 -10
- data/lib/samagotchi/web/public/app.js +94 -52
- data/lib/samagotchi/web/public/card.js +8 -1
- data/lib/samagotchi/web/public/format.js +1 -1
- data/lib/samagotchi/web/public/index.html +2 -5
- data/lib/samagotchi/web/public/stage_model.js +44 -0
- data/lib/samagotchi/web/public/stage_view.js +26 -17
- data/lib/samagotchi/web/public/timing.js +9 -3
- data/lib/samagotchi/web/public/turn_events.js +18 -5
- data/lib/samagotchi/web/public/turn_view.js +80 -16
- data/lib/samagotchi/web/server.rb +4 -3
- metadata +9 -2
- data/lib/samagotchi/web/public/chat_view.js +0 -376
|
@@ -9,10 +9,12 @@ module Samagotchi
|
|
|
9
9
|
module Tools
|
|
10
10
|
# Waiting for a delegated session's next reply, shared by delegate and
|
|
11
11
|
# delegate_result: ReplyWait in the tools' words, with a cursor per
|
|
12
|
-
# child (the newest reply this parent was already given)
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
12
|
+
# child (the newest reply this parent was already given) and the child
|
|
13
|
+
# as it was before the message went in (ReplyWait's baseline), so a turn
|
|
14
|
+
# that ends before the wait's first look (a fast failure) still ends it.
|
|
15
|
+
# Both live in this process (a worker runs one session), keyed by parent
|
|
16
|
+
# and child; a worker respawn loses them, which only repeats the newest
|
|
17
|
+
# reply once or waits for a later turn.
|
|
16
18
|
module DelegateWait
|
|
17
19
|
TIMEOUT_DEFAULT = 600
|
|
18
20
|
POLL_INTERVAL = 0.5
|
|
@@ -28,6 +30,11 @@ module Samagotchi
|
|
|
28
30
|
@seen ||= {}
|
|
29
31
|
end
|
|
30
32
|
|
|
33
|
+
# @return [Hash{Array(String, String) => Hash}] [parent id, child id] → ReplyWait baseline taken before sending
|
|
34
|
+
def baselines
|
|
35
|
+
@baselines ||= {}
|
|
36
|
+
end
|
|
37
|
+
|
|
31
38
|
# @param child_id [String] the child's full id
|
|
32
39
|
# @param peers [Peers] the parent (session_id, state_dir, cancelled?)
|
|
33
40
|
# @param timeout [Integer] seconds
|
|
@@ -38,7 +45,9 @@ module Samagotchi
|
|
|
38
45
|
key = [peers.session_id, child_id]
|
|
39
46
|
cancelled = -> { peers.respond_to?(:cancelled?) && peers.cancelled? }
|
|
40
47
|
wait = ReplyWait.call(child_id, state_dir: sd, cursor: seen[key], timeout: timeout.to_i,
|
|
41
|
-
poll_interval: poll_interval, cancelled: cancelled)
|
|
48
|
+
poll_interval: poll_interval, cancelled: cancelled, baseline: baselines[key])
|
|
49
|
+
# The turn sent to is handed over: a later wait looks for a later one.
|
|
50
|
+
baselines.delete(key) if %i[done no_reply].include?(wait.status)
|
|
42
51
|
case wait.status
|
|
43
52
|
when :done
|
|
44
53
|
seen[key] = wait.file
|
|
@@ -61,9 +70,18 @@ module Samagotchi
|
|
|
61
70
|
end
|
|
62
71
|
|
|
63
72
|
# Point the cursor at the newest reply now, so only a later one counts
|
|
64
|
-
# (a follow-up sent to a child that already answered)
|
|
65
|
-
|
|
66
|
-
|
|
73
|
+
# (a follow-up sent to a child that already answered), and take the
|
|
74
|
+
# child's baseline before the follow-up goes in.
|
|
75
|
+
# @param child [Session] the child as loaded before sending
|
|
76
|
+
def mark_seen(parent_id, child, state_dir:)
|
|
77
|
+
seen[[parent_id, child.id]] = ReplyWait.newest_reply(child.id, state_dir: state_dir)
|
|
78
|
+
baselines[[parent_id, child.id]] = ReplyWait.baseline_of(child)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# A new child's baseline: as spawned, before its first turn ran.
|
|
82
|
+
# @param child [Session] the session spawn_session returned
|
|
83
|
+
def mark_started(parent_id, child)
|
|
84
|
+
baselines[[parent_id, child.id]] = ReplyWait.baseline_of(child, question_id: nil)
|
|
67
85
|
end
|
|
68
86
|
|
|
69
87
|
def reply_result(child_id, text)
|
data/lib/samagotchi/turn_note.rb
CHANGED
|
@@ -17,6 +17,7 @@ module Samagotchi
|
|
|
17
17
|
RESTORED = "The message went back to the user, who may send it again."
|
|
18
18
|
TASKS_LISTED = 5
|
|
19
19
|
TASK_COMMAND_CHARS = 60
|
|
20
|
+
RETRY_NUDGE = :retry_nudge
|
|
20
21
|
|
|
21
22
|
module_function
|
|
22
23
|
|
|
@@ -64,7 +65,25 @@ module Samagotchi
|
|
|
64
65
|
# The hidden nudge before a retry of an empty answer (EmptyAnswerRetry):
|
|
65
66
|
# the model never sees its empty generation, so it is told what happened.
|
|
66
67
|
def empty_retry
|
|
67
|
-
message("your last reply had no visible answer. Answer the user's last message now, briefly.")
|
|
68
|
+
retry_nudge(message("your last reply had no visible answer. Answer the user's last message now, briefly."))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The hidden nudge before a retry of a generation a plugin cut
|
|
72
|
+
# (stop_generation): +by+ is the bundle, +reason+ what it said.
|
|
73
|
+
def cut_retry(by, reason)
|
|
74
|
+
why = reason.to_s.strip.empty? ? "" : ": #{one_line(reason)}"
|
|
75
|
+
retry_nudge(message("your last reply was cut off by #{by || "a plugin"}#{why}. Don't start the same reasoning " \
|
|
76
|
+
"again; answer the user's last message now, briefly."))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# A retry nudge is marked, so the spent one is found by the mark
|
|
80
|
+
# whatever its words (KernelLoop drops it when the retry fails too).
|
|
81
|
+
def retry_nudge(note)
|
|
82
|
+
note.merge(RETRY_NUDGE => true)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def retry_nudge?(entry)
|
|
86
|
+
entry.respond_to?(:[]) && (entry[RETRY_NUDGE] || entry[RETRY_NUDGE.to_s]) == true
|
|
68
87
|
end
|
|
69
88
|
|
|
70
89
|
def note?(entry)
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "cgi/escape" # CGI.escapeHTML; Ruby 4.0 ships only cgi/escape
|
|
4
|
+
require "digest"
|
|
4
5
|
require "fileutils"
|
|
5
6
|
require "json"
|
|
6
7
|
require "time"
|
|
@@ -70,8 +71,8 @@ module Samagotchi
|
|
|
70
71
|
|
|
71
72
|
# @param bridge_wait_timeout [Float] bounded seconds to wait for a
|
|
72
73
|
# freshly-spawned worker's bridge before answering POST /api/sessions.
|
|
73
|
-
# @param view ["
|
|
74
|
-
#
|
|
74
|
+
# @param view ["stage", "turn"] the page's view of a turn (web.view);
|
|
75
|
+
# ?view=stage|turn overrides it for one page load
|
|
75
76
|
# @param annotate_presets [String, Array] the quick replies next to
|
|
76
77
|
# Annotate (web.annotate_presets, "|"-separated); "" shows none. The
|
|
77
78
|
# page parses them (annotate_presets.js).
|
|
@@ -86,7 +87,7 @@ module Samagotchi
|
|
|
86
87
|
# String, or a Web::Token::Source that follows the file); nil:
|
|
87
88
|
# loopback only, no token
|
|
88
89
|
def initialize(manager: nil, session_class: nil, state_dir: nil, public_dir: nil,
|
|
89
|
-
bridge_wait_timeout: BRIDGE_WAIT_TIMEOUT, markdown: false, view: "
|
|
90
|
+
bridge_wait_timeout: BRIDGE_WAIT_TIMEOUT, markdown: false, view: Config::BY_KEY["web.view"].default, hub: nil,
|
|
90
91
|
annotate_presets: Config::BY_KEY["web.annotate_presets"].default,
|
|
91
92
|
events_heartbeat: EVENTS_HEARTBEAT, events_queue: EVENTS_QUEUE,
|
|
92
93
|
registry: nil, models_wait_timeout: MODELS_WAIT_TIMEOUT, lan: nil)
|
|
@@ -1161,13 +1162,26 @@ module Samagotchi
|
|
|
1161
1162
|
full = File.join(@public_dir, rel)
|
|
1162
1163
|
if File.file?(full) && full.start_with?(@public_dir)
|
|
1163
1164
|
body = File.binread(full)
|
|
1164
|
-
|
|
1165
|
-
|
|
1165
|
+
# no-cache + a content ETag: the browser revalidates every asset
|
|
1166
|
+
# (the ES-module imports too) and gets a cheap 304 while it is
|
|
1167
|
+
# unchanged, so a gem upgrade + chi web restart takes effect on the
|
|
1168
|
+
# next load instead of after max-age runs out.
|
|
1169
|
+
etag = %("#{Digest::SHA256.hexdigest(body)[0, 32]}")
|
|
1170
|
+
headers = { "Cache-Control" => "no-cache", "ETag" => etag }
|
|
1171
|
+
return [304, headers, []] if etag_match?(req.get_header("HTTP_IF_NONE_MATCH"), etag)
|
|
1172
|
+
|
|
1173
|
+
[200, headers.merge("Content-Type" => mime_type(full), "Content-Length" => body.bytesize.to_s), [body]]
|
|
1166
1174
|
else
|
|
1167
1175
|
not_found(path: req.path_info)
|
|
1168
1176
|
end
|
|
1169
1177
|
end
|
|
1170
1178
|
|
|
1179
|
+
def etag_match?(if_none_match, etag)
|
|
1180
|
+
return false if if_none_match.nil?
|
|
1181
|
+
|
|
1182
|
+
if_none_match.split(",").map { |t| t.strip.delete_prefix("W/") }.any? { |t| t == "*" || t == etag }
|
|
1183
|
+
end
|
|
1184
|
+
|
|
1171
1185
|
def mime_type(path)
|
|
1172
1186
|
case File.extname(path).downcase
|
|
1173
1187
|
when ".html" then "text/html; charset=utf-8"
|
|
@@ -1268,16 +1282,17 @@ module Samagotchi
|
|
|
1268
1282
|
return nil unless saved
|
|
1269
1283
|
|
|
1270
1284
|
messages = Array(session.messages)
|
|
1271
|
-
since = messages.drop(saved[:covered].to_i).count { |m| Samagotchi::Steer.
|
|
1285
|
+
since = messages.drop(saved[:covered].to_i).count { |m| Samagotchi::Steer.turn_prompt?(m) }
|
|
1272
1286
|
{ text: saved[:text], turns_since: since }
|
|
1273
1287
|
rescue StandardError
|
|
1274
1288
|
nil
|
|
1275
1289
|
end
|
|
1276
1290
|
|
|
1277
|
-
# The step (iteration) that answered the steer
|
|
1278
|
-
# the model messages between it and the
|
|
1291
|
+
# The step (iteration) that answered the steer or merged input at
|
|
1292
|
+
# +index+: one past the model messages between it and the turn's
|
|
1293
|
+
# prompt.
|
|
1279
1294
|
def steer_step(list, index)
|
|
1280
|
-
list[0...index].reverse_each.take_while { |m| !Samagotchi::Steer.
|
|
1295
|
+
list[0...index].reverse_each.take_while { |m| !Samagotchi::Steer.turn_prompt?(m) }
|
|
1281
1296
|
.count { |m| %w[model assistant].include?((m[:role] || m["role"]).to_s) } + 1
|
|
1282
1297
|
end
|
|
1283
1298
|
|
|
@@ -1326,6 +1341,9 @@ module Samagotchi
|
|
|
1326
1341
|
next unless %w[user assistant].include?(norm_role)
|
|
1327
1342
|
|
|
1328
1343
|
message = { role: norm_role, content: stripped }
|
|
1344
|
+
# Merged into the running turn (Steer::INPUT_KIND): part of it,
|
|
1345
|
+
# read by step +step+.
|
|
1346
|
+
message.merge!(merged: true, step: steer_step(list, index)) if norm_role == "user" && Samagotchi::Steer.input?(m)
|
|
1329
1347
|
images = m[:images] || m["images"]
|
|
1330
1348
|
message[:images] = Array(images).map { |ref| ImageStore.symbolize(ref).slice(:file, :name, :width, :height) } if norm_role == "user" && images.is_a?(Array) && !images.empty?
|
|
1331
1349
|
if norm_role == "assistant"
|
|
@@ -1440,7 +1458,7 @@ module Samagotchi
|
|
|
1440
1458
|
|
|
1441
1459
|
VIEWS = Config::BY_KEY["web.view"].enum_values
|
|
1442
1460
|
|
|
1443
|
-
# The view for this page load: ?view=
|
|
1461
|
+
# The view for this page load: ?view=stage|turn wins, anything
|
|
1444
1462
|
# else leaves the config's choice (web.view).
|
|
1445
1463
|
def view_name(param)
|
|
1446
1464
|
VIEWS.include?(param) ? param : @view
|
|
@@ -21,7 +21,6 @@ import { MODEL_KEY } from "./model_pick.js";
|
|
|
21
21
|
import { createModelPicker } from "./model_picker.js";
|
|
22
22
|
import { chipName, chipsHtml, imageFiles, restoredChips, thumbsHtml, turnRefs, turnText } from "./images.js";
|
|
23
23
|
import { escapeHtml, messageBodyHtml, noteHtml, normalize, steerRowHtml, userBodyHtml, ownerBadge, previewForCard, relativeTime, firstMessageOf, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy } from "./format.js";
|
|
24
|
-
import { chatHistoryHtml, createChatView } from "./chat_view.js";
|
|
25
24
|
import { createTurnView, turnHistoryHtml } from "./turn_view.js";
|
|
26
25
|
import { createStageView } from "./stage_view.js";
|
|
27
26
|
import { annotateBarLeft, annotationSource, appendQuote, quoteBlock } from "./annotations.js";
|
|
@@ -31,7 +30,7 @@ import { routeChunk } from "./chunk_router.js";
|
|
|
31
30
|
import { extractCtxPct, savedCtxPct, cardCtxText } from "./ctx.js";
|
|
32
31
|
import { diffHtml } from "./diff_view.js";
|
|
33
32
|
import { approvalAllowed, approvalView, isApproval, nextCardAction, resultText, summaryText } from "./question_card.js";
|
|
34
|
-
import { cardClass, cardInnerHtml, cardPlace, splitSnapshotCards, turnNoticePlace } from "./card.js";
|
|
33
|
+
import { cardClass, cardInnerHtml, cardPlace, isCard, splitSnapshotCards, turnNoticePlace } from "./card.js";
|
|
35
34
|
import { gripFloor, promptHeight } from "./composer_size.js";
|
|
36
35
|
import { commandMatches, commandRow, moveSelection, pickCommand } from "./command_complete.js";
|
|
37
36
|
import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
|
|
@@ -44,7 +43,7 @@ import {
|
|
|
44
43
|
appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, normalizeTiming, turnTimingText,
|
|
45
44
|
} from "./timing.js";
|
|
46
45
|
import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
|
|
47
|
-
import { clientLabel, commandView, continueLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isCommandLine, isOwn, keepEarlyRestore, newClientId, promptOps, reminderText, restoreAction, restoreOnAck, retryStatusLine, snapshotEvents, turnOutput, webLocalReply, workerGoneText } from "./turn_events.js";
|
|
46
|
+
import { clientLabel, commandView, continueLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isCommandLine, isOwn, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, restoreOnAck, retryStatusLine, snapshotEvents, turnOutput, webLocalReply, workerGoneText } from "./turn_events.js";
|
|
48
47
|
|
|
49
48
|
const $ = (s) => document.querySelector(s);
|
|
50
49
|
const topStripEl = $("#topStrip");
|
|
@@ -73,14 +72,12 @@ let resyncsInFlight = 0;
|
|
|
73
72
|
let resyncAgain = false;
|
|
74
73
|
let allSessions = [];
|
|
75
74
|
let seenContent = new Set();
|
|
76
|
-
// The page's view of a turn (web.view, or ?view=
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
const view = ["
|
|
80
|
-
// The turn
|
|
81
|
-
|
|
82
|
-
// The running turn's DOM: the streaming bubbles, the thinking block and the
|
|
83
|
-
// activity panel, the turn block, or the stage (the turn block in its cloud).
|
|
75
|
+
// The page's view of a turn (web.view, or ?view=stage|turn): the running
|
|
76
|
+
// turn pinned above the composer (the stage, stage_view.js), or one block
|
|
77
|
+
// per turn with its steps in the history (turn_view.js).
|
|
78
|
+
const view = ["stage", "turn"].includes(document.body.dataset.view) ? document.body.dataset.view : "stage";
|
|
79
|
+
// The running turn's DOM: the turn block, or the stage (the turn block in
|
|
80
|
+
// its cloud).
|
|
84
81
|
const viewArgs = {
|
|
85
82
|
historyEl,
|
|
86
83
|
appendToHistory: (el) => appendToHistory(el),
|
|
@@ -90,7 +87,7 @@ const viewArgs = {
|
|
|
90
87
|
};
|
|
91
88
|
const turnDom = view === "stage"
|
|
92
89
|
? createStageView({ ...viewArgs, isNearBottom: () => atHistoryEnd(), dockEl: $("#dock"), centerEl: $("#center"), composerEl })
|
|
93
|
-
:
|
|
90
|
+
: createTurnView(viewArgs);
|
|
94
91
|
// Where a running turn's rows can be: the history, and the stage while it
|
|
95
92
|
// holds a turn (lookups by id, copy, annotate, card actions).
|
|
96
93
|
function turnRoots() {
|
|
@@ -683,7 +680,7 @@ async function resync({ endText = null } = {}) {
|
|
|
683
680
|
let data;
|
|
684
681
|
try {
|
|
685
682
|
try {
|
|
686
|
-
data = await getSession(id, { parts:
|
|
683
|
+
data = await getSession(id, { parts: true });
|
|
687
684
|
} catch (e) {
|
|
688
685
|
if (e.status === 404) return leaveGoneSession(id, "not_found");
|
|
689
686
|
if (id === selected) historyEl.innerHTML = `<div class="hint">Error: ${escapeHtml(e.message)}</div>`;
|
|
@@ -749,9 +746,11 @@ function renderSessionView(data) {
|
|
|
749
746
|
seedSeenFromDom();
|
|
750
747
|
seedInitTasks(initTasks, data.init_tasks);
|
|
751
748
|
renderInitRow();
|
|
752
|
-
// A guardrail file that failed to load, announced before this page
|
|
753
|
-
|
|
754
|
-
|
|
749
|
+
// A guardrail file that failed to load, announced before this page
|
|
750
|
+
// joined: where it came (the snapshot's cards), else at the end.
|
|
751
|
+
const loadWarned = (label) => (data.cards || []).some((c) => c?.type === "guardrail_warning" && (c.label || "guardrails") === label);
|
|
752
|
+
if (!loadWarned("guardrails")) showGuardrailWarning(data.guardrail_warning);
|
|
753
|
+
if (!loadWarned("plugins")) showGuardrailWarning(data.plugin_warning, "plugins");
|
|
755
754
|
// The recap saved with the session (also a stopped one's), else a live
|
|
756
755
|
// worker's from before saved_recap existed.
|
|
757
756
|
if (data.saved_recap) showRecap(data.saved_recap.text, data.saved_recap.turns_since);
|
|
@@ -789,10 +788,8 @@ function renderHistory(items) {
|
|
|
789
788
|
historyEl.innerHTML = `<div class="hint">No output yet. Send a message.</div>`;
|
|
790
789
|
return;
|
|
791
790
|
}
|
|
792
|
-
if (typeof items[0] === "object" && items[0].role
|
|
791
|
+
if (typeof items[0] === "object" && items[0].role) {
|
|
793
792
|
historyEl.innerHTML = turnHistoryHtml(items, currentTiming, { thumbs: (images) => thumbsHtml(selected, images) });
|
|
794
|
-
} else if (typeof items[0] === "object" && items[0].role) {
|
|
795
|
-
historyEl.innerHTML = chatHistoryHtml(items, currentTiming, { thumbs: (images) => thumbsHtml(selected, images) });
|
|
796
793
|
} else {
|
|
797
794
|
historyEl.innerHTML = items.map((c) => `<div class="bubble output">${escapeHtml(c)}</div>`).join("");
|
|
798
795
|
}
|
|
@@ -905,7 +902,7 @@ function addEndBubble(text) {
|
|
|
905
902
|
function endTurnView(kind, endText = null) {
|
|
906
903
|
setTurnRunning(false);
|
|
907
904
|
const kept = turnDom.turnEnded({ kind }) || [];
|
|
908
|
-
finishLiveTurnTiming();
|
|
905
|
+
finishLiveTurnTiming(null, { canceled: kind === "canceled" });
|
|
909
906
|
if (endText) addEndBubble(endText);
|
|
910
907
|
if (kept.length) {
|
|
911
908
|
kept.forEach((el) => appendToHistory(el));
|
|
@@ -1034,7 +1031,7 @@ function showRecap(text, turnsSince = 0) {
|
|
|
1034
1031
|
details.appendChild(body);
|
|
1035
1032
|
// A stale one sits where it was written, above the turns it doesn't cover.
|
|
1036
1033
|
// Never in the stage: a recap is between turns.
|
|
1037
|
-
const before = recapPlace(
|
|
1034
|
+
const before = recapPlace(turnPromptBubbles(), turnsSince);
|
|
1038
1035
|
keepFollowing(historyEl, () => (before ? historyEl.insertBefore(details, before) : appendAboveLiveTiming(historyEl, details, liveTurnTimingEl)));
|
|
1039
1036
|
}
|
|
1040
1037
|
|
|
@@ -1049,21 +1046,23 @@ function markRecapStale() {
|
|
|
1049
1046
|
|
|
1050
1047
|
// A guardrail hook or rule file that failed to load (once per worker), or
|
|
1051
1048
|
// a bundle's plugin (+label+ "plugins").
|
|
1052
|
-
|
|
1049
|
+
// +before+: the element it goes above (a snapshot's), else the end.
|
|
1050
|
+
function showGuardrailWarning(text, label = "guardrails", { before = null } = {}) {
|
|
1053
1051
|
if (!text) return;
|
|
1054
1052
|
const el = document.createElement("div");
|
|
1055
1053
|
el.className = "bubble guardrail-warning";
|
|
1056
1054
|
el.textContent = `${label}: ${text}`;
|
|
1057
1055
|
const follow = isNearBottom();
|
|
1058
|
-
|
|
1056
|
+
if (before) historyEl.insertBefore(el, before);
|
|
1057
|
+
else appendToHistory(el);
|
|
1059
1058
|
if (follow) historyEl.scrollTop = historyEl.scrollHeight;
|
|
1060
1059
|
}
|
|
1061
1060
|
|
|
1062
1061
|
// A hook's line to the user (event[:notify]): "<bundle>: text", in the
|
|
1063
1062
|
// warning colour for level warn.
|
|
1064
1063
|
// +before+: the element it goes above (a snapshot's notice), else the end.
|
|
1065
|
-
// A plugin's steers (pending_input_merged's steers):
|
|
1066
|
-
//
|
|
1064
|
+
// A plugin's steers (pending_input_merged's steers): a row of the running
|
|
1065
|
+
// turn's step, else (no turn runs) a bubble.
|
|
1067
1066
|
function showSteers(steers) {
|
|
1068
1067
|
for (const steer of steers || []) {
|
|
1069
1068
|
if (turnDom.steer(steer)) continue;
|
|
@@ -1080,7 +1079,7 @@ function showHookNotice(data, { before = null } = {}) {
|
|
|
1080
1079
|
removeHintIfEmpty();
|
|
1081
1080
|
const el = document.createElement("div");
|
|
1082
1081
|
el.className = "bubble hook-notice" + (data.level === "warn" ? " warn" : "");
|
|
1083
|
-
el.textContent = `${hookNoticeLabel(data.hook)}: ${data.text || ""}`;
|
|
1082
|
+
el.textContent = data.line || `${hookNoticeLabel(data.hook)}: ${data.text || ""}`;
|
|
1084
1083
|
const follow = isNearBottom();
|
|
1085
1084
|
if (before) historyEl.insertBefore(el, before);
|
|
1086
1085
|
else appendToHistory(el);
|
|
@@ -1101,6 +1100,9 @@ function cardElement(id) {
|
|
|
1101
1100
|
function drawCard(el, card) {
|
|
1102
1101
|
el.className = cardClass(card);
|
|
1103
1102
|
el.dataset.cardId = card.id;
|
|
1103
|
+
// A running turn's own card (the stage's status waits on one that asks).
|
|
1104
|
+
if (card.in_turn) el.dataset.inTurn = "1";
|
|
1105
|
+
else delete el.dataset.inTurn;
|
|
1104
1106
|
if (card.body_html === undefined && el.dataset.body === String(card.body ?? "") && el.querySelector(".card-body")) {
|
|
1105
1107
|
// The same body again from the stream: keep the rendered one.
|
|
1106
1108
|
const body = el.querySelector(".card-body").innerHTML;
|
|
@@ -1139,41 +1141,35 @@ function upsertCard(card, { before = null } = {}) {
|
|
|
1139
1141
|
// A snapshot's cards and notices, each above the first turn after it; a
|
|
1140
1142
|
// turn's own notice where it showed live (placeTurnNotice).
|
|
1141
1143
|
function placeSnapshotCards(entries) {
|
|
1142
|
-
const users =
|
|
1144
|
+
const users = turnPromptBubbles();
|
|
1143
1145
|
for (const entry of entries) {
|
|
1144
1146
|
const before = cardPlace(users, entry);
|
|
1145
|
-
if (entry
|
|
1147
|
+
if (isCard(entry)) upsertCard(entry, { before });
|
|
1148
|
+
else if (entry.type === "question") placeResolvedQuestion(entry, before);
|
|
1149
|
+
else if (entry.type === "guardrail_warning") showGuardrailWarning(entry.message, entry.label || "guardrails", { before });
|
|
1150
|
+
else if (entry.in_turn) placeTurnNotice(entry, before);
|
|
1146
1151
|
else if (entry.type === "hook_notice") showHookNotice(entry, { before });
|
|
1147
|
-
else upsertCard(entry, { before });
|
|
1148
1152
|
}
|
|
1149
1153
|
}
|
|
1150
1154
|
|
|
1151
|
-
// A finished turn's hook notice
|
|
1152
|
-
//
|
|
1153
|
-
// the call it came before, as live; else (
|
|
1154
|
-
// step the history doesn't have) a bubble before the
|
|
1155
|
-
// before its timing line when it has none.
|
|
1155
|
+
// A finished turn's row, a hook's notice or the loop's asking again
|
|
1156
|
+
// (+before+: the next turn's prompt, or null for the last turn): a row of
|
|
1157
|
+
// its step, above the row of the call it came before, as live; else (a
|
|
1158
|
+
// plain answer, a step the history doesn't have) a bubble before the
|
|
1159
|
+
// turn's answer, or before its timing line when it has none.
|
|
1156
1160
|
function placeTurnNotice(entry, before) {
|
|
1157
|
-
const
|
|
1158
|
-
|
|
1159
|
-
turnEls.unshift(el);
|
|
1160
|
-
if (el.matches(".bubble.user")) break;
|
|
1161
|
-
}
|
|
1161
|
+
const line = noticeLine(entry);
|
|
1162
|
+
const turnEls = reloadedTurnEls(before);
|
|
1162
1163
|
const place = turnNoticePlace(entry);
|
|
1163
1164
|
const block = turnEls.find((el) => el.matches(".turn-work"));
|
|
1164
1165
|
const step = place && block ? block.querySelectorAll(":scope > .gen")[place.step] : null;
|
|
1165
1166
|
if (!step) {
|
|
1166
|
-
|
|
1167
|
-
// partial narration the notice came after.
|
|
1168
|
-
const canceled = !turnView && turnEls.some((el) => el.matches(".bubble.cancel"));
|
|
1169
|
-
const answer = canceled ? null : turnEls.filter((el) => el.matches(".bubble.output")).pop();
|
|
1170
|
-
const timing = turnEls.find((el) => el.matches(".turn-timing"));
|
|
1171
|
-
showHookNotice(entry, { before: answer || timing || before });
|
|
1167
|
+
showHookNotice({ ...entry, line }, { before: turnAnswerEl(turnEls) || before });
|
|
1172
1168
|
return;
|
|
1173
1169
|
}
|
|
1174
1170
|
const el = document.createElement("div");
|
|
1175
1171
|
el.className = "hook-notice" + (entry.level === "warn" ? " warn" : "");
|
|
1176
|
-
el.textContent =
|
|
1172
|
+
el.textContent = line;
|
|
1177
1173
|
let body = step.querySelector(":scope > .activity-body");
|
|
1178
1174
|
if (!body) {
|
|
1179
1175
|
body = document.createElement("div");
|
|
@@ -1184,6 +1180,49 @@ function placeTurnNotice(entry, before) {
|
|
|
1184
1180
|
body.insertBefore(el, row || null);
|
|
1185
1181
|
}
|
|
1186
1182
|
|
|
1183
|
+
// A prompt that started a turn: not a line steered into a running one.
|
|
1184
|
+
const TURN_PROMPT = ".bubble.user:not(.steered)";
|
|
1185
|
+
|
|
1186
|
+
// The history's turn prompts, oldest first (what turns_since counts).
|
|
1187
|
+
function turnPromptBubbles() {
|
|
1188
|
+
return [...historyEl.querySelectorAll(`:scope > ${TURN_PROMPT}`)];
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// The elements of the reloaded turn that ends before +before+ (the next
|
|
1192
|
+
// turn's prompt, or null for the last turn), its prompt first.
|
|
1193
|
+
function reloadedTurnEls(before) {
|
|
1194
|
+
const turnEls = [];
|
|
1195
|
+
for (let el = before ? before.previousElementSibling : historyEl.lastElementChild; el; el = el.previousElementSibling) {
|
|
1196
|
+
turnEls.unshift(el);
|
|
1197
|
+
if (el.matches(TURN_PROMPT)) break;
|
|
1198
|
+
}
|
|
1199
|
+
return turnEls;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// What goes after a reloaded turn's own rows and cards: its answer, else
|
|
1203
|
+
// its timing line; null when it has neither.
|
|
1204
|
+
function turnAnswerEl(turnEls) {
|
|
1205
|
+
return turnEls.filter((el) => el.matches(".bubble.output")).pop() || turnEls.find((el) => el.matches(".turn-timing")) || null;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// A finished turn's question (the snapshot's cards keep it with its
|
|
1209
|
+
// answer): the card as it was left live, resolved, before the lines the
|
|
1210
|
+
// user sent into the turn after it (a steered bubble's data-step: the step
|
|
1211
|
+
// that read it) and the turn's answer. A pending one is the running turn's
|
|
1212
|
+
// (pending_question draws it).
|
|
1213
|
+
function placeResolvedQuestion(entry, before) {
|
|
1214
|
+
const pq = entry.pending_question;
|
|
1215
|
+
if (!pq?.id || !(entry.answer || entry.cancelled)) return;
|
|
1216
|
+
renderQuestionCard(pq);
|
|
1217
|
+
const card = questionCardEl;
|
|
1218
|
+
if (!card || card.dataset.qid !== String(pq.id)) return;
|
|
1219
|
+
resolveQuestionCard(pq.id, { answer: entry.answer || null, cancelled: !!entry.cancelled, reason: entry.reason || "" });
|
|
1220
|
+
const turnEls = reloadedTurnEls(before);
|
|
1221
|
+
const asked = Number(entry.iteration) || 1;
|
|
1222
|
+
const later = turnEls.find((el) => el.matches(".bubble.user.steered") && Number(el.dataset.step) > asked);
|
|
1223
|
+
historyEl.insertBefore(card, later || turnAnswerEl(turnEls) || before);
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1187
1226
|
// A live card's body comes as text; the worker's snapshot (through the
|
|
1188
1227
|
// server) has it rendered. One re-read for a burst of cards.
|
|
1189
1228
|
let cardBodiesTimer = null;
|
|
@@ -1337,7 +1376,9 @@ function setLiveTurnNote(text) {
|
|
|
1337
1376
|
if (currentTiming.activeTurn) updateLiveTiming();
|
|
1338
1377
|
}
|
|
1339
1378
|
|
|
1340
|
-
|
|
1379
|
+
// A canceled turn's line says so (+canceled+, or its record's status), as
|
|
1380
|
+
// a reload's does.
|
|
1381
|
+
function finishLiveTurnTiming(record = null, { canceled = false } = {}) {
|
|
1341
1382
|
const duration = Number.isFinite(Number(record?.duration_ms))
|
|
1342
1383
|
? Number(record.duration_ms)
|
|
1343
1384
|
: elapsedSince(currentTiming.activeTurn?.started_at);
|
|
@@ -1345,7 +1386,8 @@ function finishLiveTurnTiming(record = null) {
|
|
|
1345
1386
|
liveTurnNote = "";
|
|
1346
1387
|
liveTurnTimingEl.classList.remove("live");
|
|
1347
1388
|
const at = record ? currentTiming.turnRecords.indexOf(record) : -1;
|
|
1348
|
-
liveTurnTimingEl.textContent = turnTimingText(at >= 0 ? at + 1 : liveTurnNumber, duration
|
|
1389
|
+
liveTurnTimingEl.textContent = turnTimingText(at >= 0 ? at + 1 : liveTurnNumber, duration,
|
|
1390
|
+
{ canceled: canceled || record?.status === "canceled" });
|
|
1349
1391
|
}
|
|
1350
1392
|
currentTiming.activeTurn = null;
|
|
1351
1393
|
clearTimingInterval();
|
|
@@ -1496,7 +1538,7 @@ const streamHandlers = {
|
|
|
1496
1538
|
// that the rendered markdown is in it (or the re-read failed: then the
|
|
1497
1539
|
// plain text pops), and the display if one was coming, so the links
|
|
1498
1540
|
// never swap in after the pop. The hold reveals by itself after
|
|
1499
|
-
// HOLD_MS whatever comes. The
|
|
1541
|
+
// HOLD_MS whatever comes. The stage has no hold.
|
|
1500
1542
|
if (display) await display;
|
|
1501
1543
|
turnDom.answerReady?.();
|
|
1502
1544
|
},
|
|
@@ -1611,10 +1653,10 @@ const streamHandlers = {
|
|
|
1611
1653
|
if (!turnRunning) showRecap(data.recap);
|
|
1612
1654
|
},
|
|
1613
1655
|
guardrail_warning: (data) => showGuardrailWarning(data.message, data.label || "guardrails"),
|
|
1614
|
-
//
|
|
1656
|
+
// A notice during a turn is a row of its current step.
|
|
1615
1657
|
hook_notice: (data) => { if (!turnDom.notice(data)) showHookNotice(data); },
|
|
1616
|
-
// The loop asks again after an empty answer: a row of the empty step
|
|
1617
|
-
//
|
|
1658
|
+
// The loop asks again after an empty answer: a row of the empty step
|
|
1659
|
+
// (a reload puts it back from the snapshot's cards).
|
|
1618
1660
|
empty_answer_retry: (data) => { turnDom.notice({ line: emptyRetryLine(data) }); },
|
|
1619
1661
|
// The provider is asked again after an error, or the turn waits for
|
|
1620
1662
|
// plugins' setup: a note on the live timing line until it streams.
|
|
@@ -53,15 +53,22 @@ export function cardPlace(userBubbles, card) {
|
|
|
53
53
|
// (a row of its current step once the turn is drawn), and the ones that
|
|
54
54
|
// came during the running turn but aren't its own (an anytime command's,
|
|
55
55
|
// such as /btw: after the running turn once it is drawn, as they came).
|
|
56
|
+
// The running turn's own rows (a hook's notice, a retry) are left out:
|
|
57
|
+
// they come with the turn's parts (snapshotEvents).
|
|
56
58
|
export function splitSnapshotCards(cards) {
|
|
57
59
|
const list = Array.isArray(cards) ? cards : [];
|
|
58
60
|
return {
|
|
59
61
|
placed: list.filter((c) => !c.current && !c.during),
|
|
60
|
-
current: list.filter((c) => c.current),
|
|
62
|
+
current: list.filter((c) => c.current && isCard(c)),
|
|
61
63
|
during: list.filter((c) => !c.current && c.during),
|
|
62
64
|
};
|
|
63
65
|
}
|
|
64
66
|
|
|
67
|
+
// An entry of the snapshot's cards that is a card (not a notice or a row).
|
|
68
|
+
export function isCard(entry) {
|
|
69
|
+
return (entry?.type ?? "card") === "card";
|
|
70
|
+
}
|
|
71
|
+
|
|
65
72
|
// Where a snapshot's turn notice (a hook's notice during a turn, in_turn)
|
|
66
73
|
// goes in its reloaded turn's block: the step of its iteration (the
|
|
67
74
|
// iteration-th .gen, 0-based index here) and the key of the tool row it
|
|
@@ -25,7 +25,7 @@ export function noteHtml(note) {
|
|
|
25
25
|
|
|
26
26
|
// A plugin's steer (a nudge put into the running turn): one line that
|
|
27
27
|
// names who nudged the model, its text collapsed under it. +bubble+: on its
|
|
28
|
-
// own between bubbles (
|
|
28
|
+
// own between bubbles (no step holds it), not a row of a step.
|
|
29
29
|
export function steerRowHtml({ source, text } = {}, { bubble = false } = {}) {
|
|
30
30
|
const who = source ? `${source} nudged the model` : "nudged the model";
|
|
31
31
|
return `<details class="${bubble ? "bubble " : ""}steer-row"><summary>${escapeHtml(who)}</summary>` +
|
|
@@ -104,11 +104,6 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
|
|
|
104
104
|
.bubble.recap summary{cursor:pointer;color:var(--think-text);font-size:12px}
|
|
105
105
|
.bubble.recap .recap-body{white-space:pre-wrap;margin-top:6px}
|
|
106
106
|
.bubble.recap.stale{opacity:.7;border-left-style:dashed}
|
|
107
|
-
.bubble.activity{border-left:3px solid var(--accent);background:var(--bg-panel);padding:8px 12px}
|
|
108
|
-
.bubble.activity>summary{cursor:pointer;color:var(--text-muted);font-size:13px;font-weight:600;user-select:none;list-style:none}
|
|
109
|
-
.bubble.activity>summary::-webkit-details-marker{display:none}
|
|
110
|
-
.bubble.activity>summary::before{content:"▸ ";color:var(--accent)}
|
|
111
|
-
.bubble.activity[open]>summary::before{content:"▾ "}
|
|
112
107
|
.activity-body{margin-top:6px}
|
|
113
108
|
.activity-row{display:flex;gap:8px;align-items:baseline;font-size:13px;color:var(--text-2);padding:5px 2px;border-top:1px solid var(--border)}
|
|
114
109
|
.activity-row:first-child{border-top:none}
|
|
@@ -561,6 +556,8 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
|
|
|
561
556
|
#composeHint{display:none}
|
|
562
557
|
#infoBar{flex-wrap:nowrap;align-items:center;gap:6px;padding:6px 10px 8px}
|
|
563
558
|
#infoBar .preview-line{display:none}
|
|
559
|
+
/* No terminal on a phone to attach from. */
|
|
560
|
+
#infoBar .meta .copy-attach{display:none}
|
|
564
561
|
#infoBar .meta{flex-wrap:nowrap;overflow-x:auto;scrollbar-width:none;white-space:nowrap}
|
|
565
562
|
#infoBar .meta::-webkit-scrollbar{display:none}
|
|
566
563
|
#infoBar .meta>*{flex-shrink:0}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// history, and in what order its pieces go there. No DOM, so `node --test`
|
|
4
4
|
// covers the rules; stage_view.js draws them.
|
|
5
5
|
import { toolName } from "./activity.js";
|
|
6
|
+
import { splitSentences } from "./sentences.js";
|
|
6
7
|
|
|
7
8
|
const READ_LIKE = new Set(["read", "memory_read", "web_fetch", "task_get", "task_list", "list_sessions"]);
|
|
8
9
|
const EDITS = new Set(["edit", "write", "memory_write"]);
|
|
@@ -14,6 +15,49 @@ export const QUIET_MS = 1500;
|
|
|
14
15
|
// A touch, wheel or scroll counts as use this long (phones have no hover).
|
|
15
16
|
export const TOUCH_MS = 4000;
|
|
16
17
|
|
|
18
|
+
// One headline sentence as plain text: the markdown a streaming answer
|
|
19
|
+
// carries (headings, quotes, bullets, table pipes, emphasis, code ticks,
|
|
20
|
+
// links) goes, and a line that is only syntax (a code fence, a table's
|
|
21
|
+
// separator row, a rule) is empty. A small pass, no renderer: a live line
|
|
22
|
+
// only needs to read well, and an unclosed "**" mid-stream goes too.
|
|
23
|
+
export function plainHeadline(line) {
|
|
24
|
+
let s = String(line ?? "").trim();
|
|
25
|
+
if (/^(```|~~~)/.test(s)) return "";
|
|
26
|
+
if (/^\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?$/.test(s)) return "";
|
|
27
|
+
if (/^([-*_])(\s*\1){2,}$/.test(s)) return "";
|
|
28
|
+
s = s.replace(/^(>\s*)+/, "").replace(/^#{1,6}\s+/, "").replace(/^[-*+]\s+(\[[ xX]\]\s+)?/, "");
|
|
29
|
+
// A table row: a pipe at either end (a pipe mid-prose, "a || b", stays).
|
|
30
|
+
if (/^\||\|$/.test(s)) {
|
|
31
|
+
s = s.replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim()).filter(Boolean).join(" · ");
|
|
32
|
+
}
|
|
33
|
+
// Code spans keep their text as is; an unclosed tick just goes.
|
|
34
|
+
const code = [];
|
|
35
|
+
s = s.replace(/`+([^`]+)`+/g, (_, c) => `\u0000${code.push(c) - 1}\u0000`);
|
|
36
|
+
s = s
|
|
37
|
+
.replace(/!\[([^\]]*)\]\([^)]*\)?/g, "$1")
|
|
38
|
+
.replace(/\[([^\]]*)\]\([^)]*\)?/g, "$1")
|
|
39
|
+
.replace(/<(https?:\/\/[^>\s]+)>/g, "$1")
|
|
40
|
+
.replace(/`+/g, "")
|
|
41
|
+
.replace(/(^|[^\w])__(?=\S)(.*?\S)__(?=$|[^\w])/g, "$1$2")
|
|
42
|
+
.replace(/(\*\*|~~)/g, "")
|
|
43
|
+
.replace(/(^|[^\w*])\*(?=\S)([^*]*?\S)\*(?=$|[^\w*])/g, "$1$2")
|
|
44
|
+
.replace(/(^|[^\w])_(?=\S)([^_]*?\S)_(?=$|[^\w])/g, "$1$2")
|
|
45
|
+
.replace(/\u0000(\d+)\u0000/g, (_, i) => code[Number(i)]);
|
|
46
|
+
return s.replace(/\s+/g, " ").trim();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The step's newest narration sentence, as plain text: the end of a
|
|
50
|
+
// finished text is a boundary (its last line may have no period), a live
|
|
51
|
+
// one's not yet. A sentence that is only markdown syntax is skipped.
|
|
52
|
+
export function headlineOf(text, done) {
|
|
53
|
+
const { sentences, rest } = splitSentences(text, { atEnd: true });
|
|
54
|
+
const fragment = plainHeadline(rest);
|
|
55
|
+
if (done && fragment) return fragment;
|
|
56
|
+
const plain = sentences.map(plainHeadline).filter(Boolean);
|
|
57
|
+
if (plain.length) return plain[plain.length - 1];
|
|
58
|
+
return fragment.length > 160 ? fragment : "";
|
|
59
|
+
}
|
|
60
|
+
|
|
17
61
|
// A tool's tick colour: read-like, edit, exec or other.
|
|
18
62
|
export function toolKind(name) {
|
|
19
63
|
if (READ_LIKE.has(name)) return "read";
|