samagotchi 0.43.0 → 0.45.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 +89 -1
- data/docs/cli.md +29 -7
- data/docs/configuration.md +18 -0
- data/docs/context.md +2 -1
- data/docs/internals/context-telemetry.md +4 -1
- data/docs/internals/engine.md +1 -0
- data/docs/plugins.md +4 -2
- data/docs/sessions.md +8 -4
- data/lib/samagotchi/bridge/card_store.rb +20 -9
- data/lib/samagotchi/bridge/turn_accumulator.rb +14 -0
- data/lib/samagotchi/bridge/turn_notice.rb +3 -1
- data/lib/samagotchi/bridge.rb +22 -10
- data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
- data/lib/samagotchi/bundles/github-pr/plugin.rb +22 -4
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/commands/registry.rb +54 -9
- data/lib/samagotchi/context_status.rb +18 -5
- data/lib/samagotchi/engine.rb +8 -3
- data/lib/samagotchi/host_registry.rb +28 -3
- data/lib/samagotchi/kernel_loop.rb +24 -9
- data/lib/samagotchi/llm/chat_loop.rb +4 -3
- data/lib/samagotchi/llm_context_forget.rb +6 -2
- data/lib/samagotchi/llm_context_notice.rb +96 -0
- data/lib/samagotchi/llm_context_strategy.rb +37 -0
- data/lib/samagotchi/model_notes.rb +8 -6
- data/lib/samagotchi/model_overlay.rb +3 -2
- data/lib/samagotchi/model_profile.rb +71 -2
- data/lib/samagotchi/plugin/context.rb +19 -7
- data/lib/samagotchi/self_report.rb +31 -2
- data/lib/samagotchi/session_commands.rb +24 -7
- data/lib/samagotchi/session_manager.rb +19 -1
- data/lib/samagotchi/session_metrics.rb +39 -6
- data/lib/samagotchi/sessions_command.rb +15 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +87 -4
- data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +26 -1
- data/lib/samagotchi/terminal_ui/one_shot_sink.rb +1 -0
- data/lib/samagotchi/terminal_ui.rb +84 -12
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +14 -13
- data/lib/samagotchi/web/message_parts.rb +90 -11
- data/lib/samagotchi/web/public/app.js +38 -19
- data/lib/samagotchi/web/public/command_complete.js +3 -1
- data/lib/samagotchi/web/public/ctx.js +42 -15
- data/lib/samagotchi/web/public/index.html +7 -1
- data/lib/samagotchi/web/public/llm_context_chip.js +2 -4
- data/lib/samagotchi/web/public/stage_model.js +18 -4
- data/lib/samagotchi/web/public/stage_view.js +3 -3
- data/lib/samagotchi/web/public/turn_events.js +50 -4
- data/lib/samagotchi/web/public/turn_html.js +33 -2
- data/lib/samagotchi/web/public/turn_view.js +4 -2
- data/lib/samagotchi/web/session_hub.rb +9 -1
- data/lib/samagotchi/web/session_summary.rb +19 -2
- data/lib/samagotchi/worker.rb +156 -20
- data/lib/samagotchi/worker_inbound.rb +6 -5
- metadata +2 -1
|
@@ -83,6 +83,13 @@ module Samagotchi
|
|
|
83
83
|
paint("↪ cut in for #{STEER_CUT_FOR.fetch(event[:source].to_s, "your message")}", 90)
|
|
84
84
|
end
|
|
85
85
|
|
|
86
|
+
# "✂ forgot 2 outputs, stubbed 1 stale read · frees ~4.1k tokens (paid
|
|
87
|
+
# off)": a batch of LLM context edits went in (LLMContextNotice builds
|
|
88
|
+
# the line; the web prints the same), dim like the retry row.
|
|
89
|
+
def format_llm_context_line(event)
|
|
90
|
+
paint(event[:text].to_s.empty? ? "✂ LLM context edited" : event[:text].to_s, 90)
|
|
91
|
+
end
|
|
92
|
+
|
|
86
93
|
# "no answer: the model returned nothing (after 1 retry)", dim like the
|
|
87
94
|
# retry row: a turn that ended with no answer (TurnNote.empty_answer_line).
|
|
88
95
|
def format_empty_answer_line(retries)
|
|
@@ -479,7 +486,7 @@ module Samagotchi
|
|
|
479
486
|
lines << "output cap hits: #{snapshot[:capped].to_i}"
|
|
480
487
|
context = snapshot[:context] || {}
|
|
481
488
|
if context[:used_tokens]
|
|
482
|
-
pct = context
|
|
489
|
+
pct = context_pct_text(context, snapshot[:llm_context])
|
|
483
490
|
lines << "context used: #{context[:used_tokens]} tokens#{pct}"
|
|
484
491
|
end
|
|
485
492
|
lines << "context window: #{context[:window_tokens]} tokens (#{context[:window_source]})" if context[:window_tokens]
|
|
@@ -499,6 +506,24 @@ module Samagotchi
|
|
|
499
506
|
lines.join("\n")
|
|
500
507
|
end
|
|
501
508
|
|
|
509
|
+
# " (25.0% of the 64000 budget)" when the session's llm_context budget
|
|
510
|
+
# is set and smaller than the window, else " (12.5%)": the live meter
|
|
511
|
+
# counts against the smaller of the two (ContextStatus#counted_against),
|
|
512
|
+
# and so does this. The summary is Explained#summary (symbol or string
|
|
513
|
+
# keys: the attached TUI's come as JSON); "" when there is no window.
|
|
514
|
+
def context_pct_text(context, llm_context)
|
|
515
|
+
window = context[:window_tokens].to_i
|
|
516
|
+
return "" unless window.positive?
|
|
517
|
+
|
|
518
|
+
summary = llm_context.is_a?(Hash) ? llm_context : {}
|
|
519
|
+
budget = summary[:budget_tokens] || summary["budget_tokens"]
|
|
520
|
+
budget = nil unless budget.is_a?(Numeric) && budget.positive?
|
|
521
|
+
used = context[:used_tokens] * 100.0
|
|
522
|
+
return format(" (%.1f%%)", used / window) unless budget && budget < window
|
|
523
|
+
|
|
524
|
+
format(" (%.1f%% of the %d budget)", used / budget, budget)
|
|
525
|
+
end
|
|
526
|
+
|
|
502
527
|
# "stale,forget (the session); apply turn_end (models: x); budget
|
|
503
528
|
# 64000 (the session)" for /stats (Explained#summary; symbol or
|
|
504
529
|
# string keys, the attached TUI's come as JSON); nil without one.
|
|
@@ -30,6 +30,7 @@ module Samagotchi
|
|
|
30
30
|
case event[:type]
|
|
31
31
|
when :empty_answer_retry then line(format_empty_retry_line(event))
|
|
32
32
|
when :steer_cut then line(format_steer_cut_line(event))
|
|
33
|
+
when :llm_context_edited then line(format_llm_context_line(event))
|
|
33
34
|
when :generation_retrying then line(format_generation_retry_line(event))
|
|
34
35
|
when :turn_completed, :turn_canceled then @turn_over = true
|
|
35
36
|
when :hook_notice
|
|
@@ -60,6 +60,9 @@ module Samagotchi
|
|
|
60
60
|
REMINDER_PENDING_POLL_INTERVAL = 0.5
|
|
61
61
|
# What a command sent during a turn gets, as from a worker (Worker::BUSY_OUTPUT).
|
|
62
62
|
COMMAND_BUSY = "busy: wait for the turn to end"
|
|
63
|
+
ROLLBACK_BUSY = "busy: Ctrl-C the turn first, then !rollback"
|
|
64
|
+
# A command typed during a turn that runs after it (/model X, !cmd).
|
|
65
|
+
COMMAND_QUEUED = "(queued: runs after this turn)"
|
|
63
66
|
# /detach is the attached terminal's; the REPL owns its session.
|
|
64
67
|
REPL_DETACH_NOTE = "(not attached: this session runs in this terminal; /exit ends it)"
|
|
65
68
|
IMAGE_LINE_WAITS = "(a line with images runs as the next turn)"
|
|
@@ -569,8 +572,10 @@ module Samagotchi
|
|
|
569
572
|
# Not an answer to a continue offer either.
|
|
570
573
|
next detach_note if local == :detach
|
|
571
574
|
|
|
572
|
-
# /stats and /recap run; the offer stays open.
|
|
573
|
-
|
|
575
|
+
# /stats and /recap run; the offer stays open. So does a session
|
|
576
|
+
# command (one queued during the turn: /model X, !cmd); /continue
|
|
577
|
+
# answers it.
|
|
578
|
+
if @turn_flow.awaiting_continue? && !%i[stats recap].include?(local) && !offer_leaves_open?(input)
|
|
574
579
|
answer_continue_offer(session, input)
|
|
575
580
|
else
|
|
576
581
|
# The ? read left no echo: show what ran.
|
|
@@ -714,6 +719,13 @@ module Samagotchi
|
|
|
714
719
|
|
|
715
720
|
# An answer at the ? prompt of a continue offer. A valid one closes the
|
|
716
721
|
# offer's choices and leaves one line; an invalid one gets its error.
|
|
722
|
+
# Whether +input+ is a session command that runs past a continue offer,
|
|
723
|
+
# leaving it open: any but /continue (the worker's S2).
|
|
724
|
+
def offer_leaves_open?(input)
|
|
725
|
+
entry = command_registry.lookup(input)
|
|
726
|
+
!entry.nil? && entry.id != :continue
|
|
727
|
+
end
|
|
728
|
+
|
|
717
729
|
def answer_continue_offer(session, input)
|
|
718
730
|
answer = @commands.continue_answer(input)
|
|
719
731
|
if answer.decision == :invalid
|
|
@@ -743,7 +755,7 @@ module Samagotchi
|
|
|
743
755
|
# /model, /models, !rollback, !cmd, /continue (shared with workers);
|
|
744
756
|
# an anytime command's cards print as it shows them (btw's
|
|
745
757
|
# "thinking…" before the answer).
|
|
746
|
-
command = if command_registry.
|
|
758
|
+
command = if command_registry.anytime?(input)
|
|
747
759
|
@engine.running_anytime { @commands.run(input) }
|
|
748
760
|
else
|
|
749
761
|
@commands.run(input)
|
|
@@ -1031,26 +1043,58 @@ module Samagotchi
|
|
|
1031
1043
|
# While a turn runs, a line submitted at the open prompt steers it: it
|
|
1032
1044
|
# merges at the next iteration boundary (Kernel), and one that comes after
|
|
1033
1045
|
# the last runs as the next turn. Reminder turns too.
|
|
1046
|
+
#
|
|
1047
|
+
# A command that waits for the turn's end (/model X, !cmd) and every line
|
|
1048
|
+
# after it wait in @after_turn, in the order typed (D1), with the image
|
|
1049
|
+
# lines (which never steer): once the turn ends they come back after the
|
|
1050
|
+
# steering it didn't merge (typed before them), ahead of anything sent
|
|
1051
|
+
# after Ctrl-C.
|
|
1034
1052
|
def with_steering(&)
|
|
1035
1053
|
return yield unless @repl_input
|
|
1036
1054
|
|
|
1037
|
-
@
|
|
1055
|
+
@after_turn = []
|
|
1056
|
+
@command_waits = false
|
|
1057
|
+
@repl_input.during_turn(method(:steer_line), leftovers: -> { @pending_input_queue.drain + lines_after_turn }, &)
|
|
1058
|
+
end
|
|
1059
|
+
|
|
1060
|
+
# The lines that waited for the turn's end, taken from @after_turn. After
|
|
1061
|
+
# a canceled or failed turn (Ctrl-C raises through here; a failure too)
|
|
1062
|
+
# a !cmd among them is dropped (D4): running it would close the
|
|
1063
|
+
# !rollback the turn left.
|
|
1064
|
+
def lines_after_turn
|
|
1065
|
+
lines = @after_turn.slice!(0..)
|
|
1066
|
+
ended = if @active_cancel_controller&.cancelled? || $!.is_a?(Interrupt) then "canceled"
|
|
1067
|
+
elsif $! then "failed"
|
|
1068
|
+
end
|
|
1069
|
+
return lines unless ended
|
|
1070
|
+
|
|
1071
|
+
lines.reject do |line|
|
|
1072
|
+
next false unless command_registry.lookup(line)&.id == :shell
|
|
1073
|
+
|
|
1074
|
+
@surface.commit("turn #{ended}: #{line.strip} not run; send it again")
|
|
1075
|
+
true
|
|
1076
|
+
end
|
|
1038
1077
|
end
|
|
1039
1078
|
|
|
1040
1079
|
# On the reader thread, from ReplInput: takes a line for the running turn.
|
|
1041
1080
|
# A line sent after Ctrl-C waits for the next turn (the kernel would not
|
|
1042
1081
|
# merge it into the cancelled one). Ctrl-D or exit ends the REPL after
|
|
1043
1082
|
# the turn. /stats and /recap run now, an anytime command (/help, a
|
|
1044
|
-
# plugin's /btw) starts now on its own thread;
|
|
1045
|
-
# in the
|
|
1083
|
+
# plugin's /btw, /model alone) starts now on its own thread; one that
|
|
1084
|
+
# runs after the turn (/model X, !cmd) waits in the queue, in arrival
|
|
1085
|
+
# order with the other lines that wait; others go back in the prompt.
|
|
1046
1086
|
# @return [Boolean, :back] whether the turn took it, :back to put it back
|
|
1047
1087
|
def steer_line(line)
|
|
1048
1088
|
local = local_command(line) unless line.nil?
|
|
1049
1089
|
return exit_after_turn(delete: SessionCommands.delete_on_exit?(line)) if line.nil? || local == :exit
|
|
1090
|
+
return archive_after_turn if local == :archive
|
|
1050
1091
|
return detach_note if local == :detach
|
|
1051
1092
|
return false if @active_cancel_controller&.cancelled?
|
|
1052
|
-
|
|
1053
|
-
|
|
1093
|
+
|
|
1094
|
+
policy = command_registry.mid_turn(line)
|
|
1095
|
+
return start_anytime_command(line) if policy == :anytime
|
|
1096
|
+
return command_queued(line) if policy == :queue
|
|
1097
|
+
# A command, never steering text.
|
|
1054
1098
|
return command_during_turn(line) if local || command_registry.command?(line)
|
|
1055
1099
|
return true if line.strip.empty?
|
|
1056
1100
|
|
|
@@ -1060,7 +1104,9 @@ module Samagotchi
|
|
|
1060
1104
|
return true
|
|
1061
1105
|
end
|
|
1062
1106
|
# Steering merges text only: a line with images runs as the next turn.
|
|
1063
|
-
return image_line_waits if ImageInput.extract(line).any?
|
|
1107
|
+
return image_line_waits(line) if ImageInput.extract(line).any?
|
|
1108
|
+
# Behind a command that waits for the turn's end, a line waits too.
|
|
1109
|
+
return wait_for_turn_end(line) if @command_waits
|
|
1064
1110
|
|
|
1065
1111
|
epoch = @engine.input_epoch
|
|
1066
1112
|
@pending_input_queue.push(line.strip)
|
|
@@ -1071,9 +1117,16 @@ module Samagotchi
|
|
|
1071
1117
|
true
|
|
1072
1118
|
end
|
|
1073
1119
|
|
|
1074
|
-
def image_line_waits
|
|
1120
|
+
def image_line_waits(line)
|
|
1075
1121
|
@surface.commit(IMAGE_LINE_WAITS)
|
|
1076
|
-
|
|
1122
|
+
wait_for_turn_end(line)
|
|
1123
|
+
end
|
|
1124
|
+
|
|
1125
|
+
# +line+ runs once the turn has ended, in the order typed (#with_steering).
|
|
1126
|
+
# @return [true] the turn took it
|
|
1127
|
+
def wait_for_turn_end(line)
|
|
1128
|
+
@after_turn << line
|
|
1129
|
+
true
|
|
1077
1130
|
end
|
|
1078
1131
|
|
|
1079
1132
|
def detach_note
|
|
@@ -1081,6 +1134,18 @@ module Samagotchi
|
|
|
1081
1134
|
true
|
|
1082
1135
|
end
|
|
1083
1136
|
|
|
1137
|
+
# /archive during a turn (D2): the REPL leaves and archives once the
|
|
1138
|
+
# turn and the lines queued behind it (S5) ran, as /exit does.
|
|
1139
|
+
# @return [true]
|
|
1140
|
+
def archive_after_turn
|
|
1141
|
+
return @surface.commit(SCRATCH_ARCHIVE_REFUSED).then { true } if @scratch
|
|
1142
|
+
|
|
1143
|
+
@exit_after_turn = true
|
|
1144
|
+
@archive_on_exit = true
|
|
1145
|
+
@surface.commit("(archives after this turn; Ctrl-C cancels it)")
|
|
1146
|
+
true
|
|
1147
|
+
end
|
|
1148
|
+
|
|
1084
1149
|
def exit_after_turn(delete: false)
|
|
1085
1150
|
@exit_after_turn = true
|
|
1086
1151
|
@delete_on_exit = true if delete
|
|
@@ -1098,10 +1163,17 @@ module Samagotchi
|
|
|
1098
1163
|
def command_during_turn(line)
|
|
1099
1164
|
return true if show_local_command(line)
|
|
1100
1165
|
|
|
1101
|
-
@surface.commit(COMMAND_BUSY)
|
|
1166
|
+
@surface.commit(command_registry.lookup(line)&.id == :rollback ? ROLLBACK_BUSY : COMMAND_BUSY)
|
|
1102
1167
|
:back
|
|
1103
1168
|
end
|
|
1104
1169
|
|
|
1170
|
+
# @return [true] it waits for the turn's end (#wait_for_turn_end)
|
|
1171
|
+
def command_queued(line)
|
|
1172
|
+
@surface.commit(COMMAND_QUEUED)
|
|
1173
|
+
@command_waits = true
|
|
1174
|
+
wait_for_turn_end(line)
|
|
1175
|
+
end
|
|
1176
|
+
|
|
1105
1177
|
# D8: an anytime command runs on its own thread while the turn goes on
|
|
1106
1178
|
# (it reads copies, and shows things through its ctx). What it prints
|
|
1107
1179
|
# while the turn runs goes above the live region now, its cards as it
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -488,15 +488,10 @@ module Samagotchi
|
|
|
488
488
|
# A session's LLM context strategy, apply rule and budget, each with
|
|
489
489
|
# where it came from (LLMContextStrategy::Explained#summary), as its
|
|
490
490
|
# worker would resolve them; nil when they can't be.
|
|
491
|
+
# (LLMContextStrategy.for_session, shared with the ctx % the session
|
|
492
|
+
# lists count.)
|
|
491
493
|
def llm_context_for(session)
|
|
492
|
-
registry
|
|
493
|
-
name = session.model_name.to_s.strip.empty? ? ModelProfile.required_model_name(nil) : session.model_name
|
|
494
|
-
target = registry.resolve(name)
|
|
495
|
-
names = registry.lookup_names(session.model_typed || name, resolved: name, target: target)
|
|
496
|
-
own = session.llm_context
|
|
497
|
-
LLMContextStrategy.explain(target, names: names, **(own&.resolve_args || {})).summary(own)
|
|
498
|
-
rescue StandardError
|
|
499
|
-
nil
|
|
494
|
+
LLMContextStrategy.for_session(session, registry: host_registry)
|
|
500
495
|
end
|
|
501
496
|
|
|
502
497
|
# Built again when config.yml's hosts: changed (compared as read: the
|
|
@@ -679,6 +674,8 @@ module Samagotchi
|
|
|
679
674
|
failed_turn: failed_turn_for(session, raw_messages),
|
|
680
675
|
current_turn: current_turn,
|
|
681
676
|
queued: turn_snapshot ? Array(turn_snapshot["queued"]) : [],
|
|
677
|
+
# Commands waiting for the running turn's end (their bubbles).
|
|
678
|
+
queued_commands: turn_snapshot ? Array(turn_snapshot["queued_commands"]) : [],
|
|
682
679
|
recap: turn_snapshot && turn_snapshot["recap"],
|
|
683
680
|
saved_recap: saved_recap_for(id, session, turn_snapshot),
|
|
684
681
|
continue_offer: turn_snapshot && turn_snapshot["continue_offer"],
|
|
@@ -1496,7 +1493,8 @@ module Samagotchi
|
|
|
1496
1493
|
# @param owner [OwnerLock::Owner, nil] #session_owner; its kind is shown as `owner`
|
|
1497
1494
|
def session_to_json(s, status: s.status, owner: nil)
|
|
1498
1495
|
SessionSummary.build(s, status: status, owner: owner,
|
|
1499
|
-
session_dir: @session_class.session_dir(s.id, state_dir: default_state_dir)
|
|
1496
|
+
session_dir: @session_class.session_dir(s.id, state_dir: default_state_dir),
|
|
1497
|
+
registry: host_registry)
|
|
1500
1498
|
end
|
|
1501
1499
|
|
|
1502
1500
|
# The whole timing: analytics.json's records merged with a live
|
|
@@ -1658,6 +1656,8 @@ module Samagotchi
|
|
|
1658
1656
|
def messages_for_display(msgs, parts: false, cwd: nil)
|
|
1659
1657
|
filtered = []
|
|
1660
1658
|
list = Array(msgs)
|
|
1659
|
+
# The outputs' ✂ marks (LLM context edits), by id, for the parts.
|
|
1660
|
+
marks = parts ? MessageParts.edit_marks(list) : {}
|
|
1661
1661
|
list.each_with_index do |m, index|
|
|
1662
1662
|
role = (m[:role] || m["role"]).to_s
|
|
1663
1663
|
content = (m[:content] || m["content"]).to_s
|
|
@@ -1689,7 +1689,7 @@ module Samagotchi
|
|
|
1689
1689
|
next if role == "tool_response"
|
|
1690
1690
|
|
|
1691
1691
|
stripped = Samagotchi::OutputFormatter.strip_markup(content)
|
|
1692
|
-
did = parts && %w[model assistant].include?(role) ? message_parts(list, index, cwd) : nil
|
|
1692
|
+
did = parts && %w[model assistant].include?(role) ? message_parts(list, index, cwd, marks) : nil
|
|
1693
1693
|
next if stripped.empty? && did.nil?
|
|
1694
1694
|
|
|
1695
1695
|
norm_role = role == "model" ? "assistant" : role
|
|
@@ -1757,10 +1757,11 @@ module Samagotchi
|
|
|
1757
1757
|
end
|
|
1758
1758
|
|
|
1759
1759
|
# The parts of the assistant message at +index+, with the tool_response
|
|
1760
|
-
# messages right after it
|
|
1761
|
-
|
|
1760
|
+
# messages right after it; +marks+: the session's ✂ marks
|
|
1761
|
+
# (MessageParts.edit_marks).
|
|
1762
|
+
def message_parts(list, index, cwd = nil, marks = {})
|
|
1762
1763
|
responses = list.drop(index + 1).take_while { |r| (r[:role] || r["role"]).to_s == "tool_response" }
|
|
1763
|
-
MessageParts.for_message(list[index], responses, cwd: cwd)
|
|
1764
|
+
MessageParts.for_message(list[index], responses, cwd: cwd, marks: marks)
|
|
1764
1765
|
end
|
|
1765
1766
|
|
|
1766
1767
|
# The last message messages_for_display shows as an answer
|
|
@@ -7,6 +7,8 @@ require_relative "../tool_view"
|
|
|
7
7
|
require_relative "../llm/native_tool_normalizer"
|
|
8
8
|
require_relative "../tools/builtins"
|
|
9
9
|
require_relative "../image_store"
|
|
10
|
+
require_relative "../llm_context_edit"
|
|
11
|
+
require_relative "../token_usage"
|
|
10
12
|
|
|
11
13
|
module Samagotchi
|
|
12
14
|
module Web
|
|
@@ -28,6 +30,10 @@ module Samagotchi
|
|
|
28
30
|
# by the saved image_counts (older sessions without them: only a lone
|
|
29
31
|
# call gets them).
|
|
30
32
|
#
|
|
33
|
+
# A call's chi-owned id (ToolIds, saved as tool_ids) is the part's
|
|
34
|
+
# tool_id:, and an LLM context edit saved on its output (LLMContextEdit,
|
|
35
|
+
# under edits) the part's edit: (.edit_marks), the row's ✂ mark.
|
|
36
|
+
#
|
|
31
37
|
# A plugin tool's params are what its live row showed (its preview),
|
|
32
38
|
# saved with the result as tool_params (native: one per call, chat: one
|
|
33
39
|
# per result); its label ("chrome: screenshot") likewise as tool_labels,
|
|
@@ -59,15 +65,17 @@ module Samagotchi
|
|
|
59
65
|
# @param registry [Tools::Registry] the session's tools
|
|
60
66
|
# @param cwd [String, nil] the session's working directory (a file
|
|
61
67
|
# tool's title is relative to it)
|
|
68
|
+
# @param marks [Hash{String => Hash}] the session's edit marks by
|
|
69
|
+
# output id (.edit_marks)
|
|
62
70
|
# @return [Hash, nil] { thinking:, tools: [{ tool:, params:, title:,
|
|
63
|
-
# output:, output_truncated:, images: }] } with only what it found; nil for nothing
|
|
64
|
-
def for_message(message, responses, registry: Tools::Builtins.default, cwd: nil)
|
|
71
|
+
# output:, output_truncated:, images:, tool_id:, edit: }] } with only what it found; nil for nothing
|
|
72
|
+
def for_message(message, responses, registry: Tools::Builtins.default, cwd: nil, marks: {})
|
|
65
73
|
content = field(message, :content).to_s
|
|
66
74
|
calls = field(message, :tool_calls)
|
|
67
75
|
tools = if calls.is_a?(Array) && !calls.empty?
|
|
68
|
-
native_tools(calls, responses, registry, cwd)
|
|
76
|
+
native_tools(calls, responses, registry, cwd, marks)
|
|
69
77
|
else
|
|
70
|
-
markup_tools(content, responses, registry, cwd)
|
|
78
|
+
markup_tools(content, responses, registry, cwd, marks)
|
|
71
79
|
end
|
|
72
80
|
parts = {}
|
|
73
81
|
thinking = [field(message, :thinking).to_s.strip, thinking_of(content)].reject(&:empty?).join("\n\n")
|
|
@@ -87,7 +95,7 @@ module Samagotchi
|
|
|
87
95
|
blocks.map(&:strip).reject(&:empty?).join("\n\n")
|
|
88
96
|
end
|
|
89
97
|
|
|
90
|
-
def markup_tools(content, responses, registry, cwd = nil)
|
|
98
|
+
def markup_tools(content, responses, registry, cwd = nil, marks = {})
|
|
91
99
|
calls = []
|
|
92
100
|
calls.concat(ToolCallParser::Qwen.new(ModelProfile.qwen36).parse(content)) if content.include?("<tool_call>")
|
|
93
101
|
calls.concat(ToolCallParser::Gemma.new(ModelProfile.gemma4).parse(content)) if content.include?("<|tool_call>")
|
|
@@ -98,9 +106,11 @@ module Samagotchi
|
|
|
98
106
|
shown = saved_list(responses, :tool_params, calls.length)
|
|
99
107
|
labels = saved_list(responses, :tool_labels, calls.length)
|
|
100
108
|
diffs = saved_list(responses, :tool_diffs, calls.length)
|
|
109
|
+
ids = saved_list(responses, :tool_ids, calls.length)
|
|
101
110
|
images = split_images(responses, calls.length)
|
|
102
111
|
calls.each_with_index.map do |call, i|
|
|
103
|
-
tool_part(call, outputs[i], registry, shown[i], images[i], label: labels[i], diff: diffs[i], cwd: cwd
|
|
112
|
+
tool_part(call, outputs[i], registry, shown[i], images[i], label: labels[i], diff: diffs[i], cwd: cwd,
|
|
113
|
+
tool_id: ids[i], marks: marks)
|
|
104
114
|
end
|
|
105
115
|
end
|
|
106
116
|
|
|
@@ -122,16 +132,78 @@ module Samagotchi
|
|
|
122
132
|
counts.map { |n| images.shift(n) }
|
|
123
133
|
end
|
|
124
134
|
|
|
125
|
-
def native_tools(calls, responses, registry, cwd = nil)
|
|
126
|
-
|
|
135
|
+
def native_tools(calls, responses, registry, cwd = nil, marks = {})
|
|
136
|
+
# By id only for an id that names one response: a server may omit
|
|
137
|
+
# it (nil), send "" or repeat it; those pair by position.
|
|
138
|
+
ids = responses.map { |r| field(r, :tool_call_id).to_s }
|
|
139
|
+
unique = ->(id) { !id.empty? && ids.count(id) == 1 }
|
|
140
|
+
by_id = responses.each_with_index.to_h { |r, i| [ids[i], r] }.select { |id, _| unique.call(id) }
|
|
127
141
|
calls.each_with_index.map do |raw, i|
|
|
128
142
|
ref = CallRef.new(field(raw, :name).to_s, field(raw, :arguments))
|
|
129
143
|
call = LLM::NativeToolNormalizer.normalize(ref) || { name: ref.name }
|
|
130
|
-
response = by_id[field(raw, :id)] || (
|
|
144
|
+
response = by_id[field(raw, :id).to_s] || (responses[i] && !unique.call(ids[i]) ? responses[i] : nil)
|
|
131
145
|
tool_part(call, response && field(response, :content).to_s, registry, field(response, :tool_params),
|
|
132
146
|
response && field(response, :images), label: field(response, :tool_labels),
|
|
133
|
-
diff: field(response, :tool_diffs), cwd: cwd
|
|
147
|
+
diff: field(response, :tool_diffs), cwd: cwd,
|
|
148
|
+
tool_id: Array(field(response, :tool_ids)).first, marks: marks)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# The ✂ mark of each output an LLM context edit is saved on, by its
|
|
153
|
+
# id, from +messages+ (a session's, symbol or string keys): {kind:
|
|
154
|
+
# "stale" | "forget", staged: true while a forget waits for the
|
|
155
|
+
# turn's end, note: (a stale stub's reason, "superseded by a later
|
|
156
|
+
# read"; the first output of a forget_outputs call's, its note), with:
|
|
157
|
+
# (the others of that call's: the first one's id), kept: ("12-40, 50",
|
|
158
|
+
# a forget's kept lines)}. A forget call's outputs share the note,
|
|
159
|
+
# author and stamp (LLMContextView's key); the first in the session's
|
|
160
|
+
# order carries the note.
|
|
161
|
+
# @return [Hash{String => Hash}]
|
|
162
|
+
def edit_marks(messages)
|
|
163
|
+
first = {}
|
|
164
|
+
Array(messages).each_with_object({}) do |entry, marks|
|
|
165
|
+
next unless field(entry, :role).to_s == "tool_response"
|
|
166
|
+
|
|
167
|
+
saved = field(entry, :edits)
|
|
168
|
+
next unless saved.is_a?(Hash)
|
|
169
|
+
|
|
170
|
+
edits = saved.filter_map { |id, hash| LLMContextEdit.from_h(id, hash) }.to_h { |edit| [edit.id, edit] }
|
|
171
|
+
order = Array(field(entry, :tool_ids)).map(&:to_s)
|
|
172
|
+
edits.sort_by { |id, _| order.index(id) || order.size }.each do |id, edit|
|
|
173
|
+
marks[id] = edit_mark(edit, first)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
rescue StandardError
|
|
177
|
+
{}
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def edit_mark(edit, first)
|
|
181
|
+
mark = { kind: edit.kind.to_s }
|
|
182
|
+
mark[:staged] = true unless edit.applied?
|
|
183
|
+
if edit.kind == :stale
|
|
184
|
+
mark[:note] = edit.note[/superseded by .*\z/] || edit.note
|
|
185
|
+
else
|
|
186
|
+
key = [edit.note, edit.by, edit.staged_at]
|
|
187
|
+
if first.key?(key)
|
|
188
|
+
mark[:with] = first[key]
|
|
189
|
+
else
|
|
190
|
+
first[key] = edit.id
|
|
191
|
+
mark[:note] = edit.note
|
|
192
|
+
end
|
|
193
|
+
kept = edit.keep.map { |from, to| from == to ? from.to_s : "#{from}-#{to}" }
|
|
194
|
+
mark[:kept] = kept.join(", ") unless kept.empty?
|
|
134
195
|
end
|
|
196
|
+
mark
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# +output+'s ✂ mark (+marks+ by +id+); an applied stale stub says
|
|
200
|
+
# what it frees: its output, ~chars/4.
|
|
201
|
+
def mark_of(id, marks, output)
|
|
202
|
+
mark = marks[id]
|
|
203
|
+
return nil unless mark
|
|
204
|
+
return mark unless mark[:kind] == "stale" && !mark[:staged] && output
|
|
205
|
+
|
|
206
|
+
mark.merge(tokens: (output.length / TokenUsage::CHARS_PER_TOKEN).ceil)
|
|
135
207
|
end
|
|
136
208
|
|
|
137
209
|
# +count+ outputs from a joined tool_response: the "[tool]"-tagged
|
|
@@ -153,7 +225,9 @@ module Samagotchi
|
|
|
153
225
|
# +shown+ is the saved params line, when it is a String.
|
|
154
226
|
# +diff+ is what an edit/write changed (EditPreview.change), saved as
|
|
155
227
|
# tool_diffs. +cwd+: the session's working directory, for the title.
|
|
156
|
-
|
|
228
|
+
# +tool_id+: its output's ToolIds id, with its ✂ mark in +marks+.
|
|
229
|
+
def tool_part(call, output, registry, shown = nil, images = nil, label: nil, diff: nil, cwd: nil, tool_id: nil,
|
|
230
|
+
marks: {})
|
|
157
231
|
name = call[:name].to_s
|
|
158
232
|
params = shown.is_a?(String) ? shown : ToolActivity.tool_activity_params(name, call, registry: registry)
|
|
159
233
|
part = { tool: name, params: params.to_s }
|
|
@@ -171,6 +245,11 @@ module Samagotchi
|
|
|
171
245
|
refs = Array(images).select { |ref| ref.is_a?(Hash) }
|
|
172
246
|
part[:images] = refs.map { |ref| ImageStore.symbolize(ref).slice(:file, :name, :width, :height) } unless refs.empty?
|
|
173
247
|
part[:diff] = diff if diff.is_a?(Hash)
|
|
248
|
+
if tool_id
|
|
249
|
+
part[:tool_id] = tool_id.to_s
|
|
250
|
+
mark = mark_of(tool_id.to_s, marks, output)
|
|
251
|
+
part[:edit] = mark if mark
|
|
252
|
+
end
|
|
174
253
|
part
|
|
175
254
|
end
|
|
176
255
|
|
|
@@ -60,7 +60,7 @@ import {
|
|
|
60
60
|
} from "./timing.js";
|
|
61
61
|
import { createLiveTurn } from "./live_turn.js";
|
|
62
62
|
import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
|
|
63
|
-
import { clientLabel, commandView, isReportClient, displayWait, dropEarlyRestores, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, replaysDrawnTurn, retryStatusLine, snapshotEvents, steerCutLine, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
|
|
63
|
+
import { clientLabel, commandView, isReportClient, displayWait, dropEarlyRestores, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, llmContextHover, llmContextLine, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, replaysDrawnTurn, retryStatusLine, snapshotEvents, steerCutLine, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
|
|
64
64
|
|
|
65
65
|
const $ = (s) => document.querySelector(s);
|
|
66
66
|
const topStripEl = $("#topStrip");
|
|
@@ -447,16 +447,20 @@ function fitStripColumns() {
|
|
|
447
447
|
// The order a list draws its cards in: +next+, what waits on the user
|
|
448
448
|
// first (waitingFirst), back to its place once answered. Never under the
|
|
449
449
|
// pointer, though: while it is over the list the order last drawn holds,
|
|
450
|
-
// and its leaving draws the list again.
|
|
451
|
-
//
|
|
452
|
-
//
|
|
450
|
+
// and its leaving draws the list again. A hidden list holds nothing: the
|
|
451
|
+
// strip keeps :hover for a moment after a click on its All sessions tile
|
|
452
|
+
// hid it, and an update held then brought it back from the all view in an
|
|
453
|
+
// order nobody saw (a waiting family behind). +idOf+ names a card: a
|
|
454
|
+
// session's id, or familyId (a card per family: its head's id). drawnOrder
|
|
455
|
+
// is the cards drawn, which select mode picks from.
|
|
453
456
|
const drawnOrder = new Map(); // list element → card ids in the order last drawn
|
|
454
457
|
const heldLists = new Set(); // lists drawn in a held order
|
|
455
458
|
const canHover = globalThis.matchMedia?.("(hover: hover)").matches ?? false;
|
|
456
459
|
function listOrder(el, next, idOf = (s) => s.id) {
|
|
457
460
|
// The strip's popover holds it too (the pointer leaves the strip for it,
|
|
458
461
|
// and a touch screen has no hover at all).
|
|
459
|
-
const holding =
|
|
462
|
+
const holding = el.getClientRects().length > 0
|
|
463
|
+
&& ((canHover && el.matches(":hover")) || (el === topStripEl && familyPop.isOpen()));
|
|
460
464
|
const held = holding ? drawnOrder.get(el) : null;
|
|
461
465
|
const ordered = held ? heldOrder(held, next, idOf) : next;
|
|
462
466
|
if (held && ordered.some((x, i) => idOf(x) !== idOf(next[i]))) heldLists.add(el);
|
|
@@ -1160,7 +1164,7 @@ function renderSessionView(data) {
|
|
|
1160
1164
|
const activeTurnId = current.timing.activeTurn?.id || null;
|
|
1161
1165
|
// The saved fill, so a reloaded or stopped session shows it before its
|
|
1162
1166
|
// next turn (a running turn's replay below streams over it).
|
|
1163
|
-
const savedPct = savedCtxPct(data.timing?.context);
|
|
1167
|
+
const savedPct = savedCtxPct(data.timing?.context, s.llm_context?.budget_tokens);
|
|
1164
1168
|
if (savedPct !== null) current.ctxPct = savedPct;
|
|
1165
1169
|
if (data.timing?.context?.window_tokens) {
|
|
1166
1170
|
current.ctxWindow = data.timing.context.window_tokens;
|
|
@@ -1211,6 +1215,7 @@ function renderSessionView(data) {
|
|
|
1211
1215
|
const replay = snapshotEvents({
|
|
1212
1216
|
current_turn: data.current_turn,
|
|
1213
1217
|
queued: data.queued,
|
|
1218
|
+
queued_commands: data.queued_commands,
|
|
1214
1219
|
started_at: current.timing.activeTurn?.started_at,
|
|
1215
1220
|
});
|
|
1216
1221
|
// Marked replayed: a call's duration isn't timed from the replay's clock.
|
|
@@ -1527,11 +1532,17 @@ function showSteers(steers) {
|
|
|
1527
1532
|
}
|
|
1528
1533
|
}
|
|
1529
1534
|
|
|
1535
|
+
// A ✂ row's notice data (turnDom.notice, placeTurnNotice).
|
|
1536
|
+
function llmContextNotice(data) {
|
|
1537
|
+
return { line: llmContextLine(data), cls: "llm-context", title: llmContextHover(data) };
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1530
1540
|
function showHookNotice(data, { before = null } = {}) {
|
|
1531
1541
|
removeHintIfEmpty();
|
|
1532
1542
|
const el = document.createElement("div");
|
|
1533
|
-
el.className = "bubble hook-notice" + (data.level === "warn" ? " warn" : "");
|
|
1543
|
+
el.className = "bubble hook-notice" + (data.level === "warn" ? " warn" : "") + (data.cls ? ` ${data.cls}` : "");
|
|
1534
1544
|
el.textContent = data.line || `${hookNoticeLabel(data.hook)}: ${data.text || ""}`;
|
|
1545
|
+
if (data.title) el.title = data.title;
|
|
1535
1546
|
const follow = isNearBottom();
|
|
1536
1547
|
if (before) historyEl.insertBefore(el, before);
|
|
1537
1548
|
else appendToHistory(el);
|
|
@@ -1623,18 +1634,20 @@ function placeSnapshotCards(entries) {
|
|
|
1623
1634
|
// plain answer, a step the history doesn't have) a bubble before the
|
|
1624
1635
|
// turn's answer, or before its timing line when it has none.
|
|
1625
1636
|
function placeTurnNotice(entry, before) {
|
|
1637
|
+
const extra = entry.type === "llm_context_edited" ? llmContextNotice(entry) : {};
|
|
1626
1638
|
const line = noticeLine(entry);
|
|
1627
1639
|
const turnEls = reloadedTurnEls(before);
|
|
1628
1640
|
const place = turnNoticePlace(entry);
|
|
1629
1641
|
const block = turnEls.find((el) => el.matches(".turn-work"));
|
|
1630
1642
|
const step = place && block ? block.querySelectorAll(":scope > .gen")[place.step] : null;
|
|
1631
1643
|
if (!step) {
|
|
1632
|
-
showHookNotice({ ...entry, line }, { before: turnAnswerEl(turnEls) || before });
|
|
1644
|
+
showHookNotice({ ...entry, ...extra, line }, { before: turnAnswerEl(turnEls) || before });
|
|
1633
1645
|
return;
|
|
1634
1646
|
}
|
|
1635
1647
|
const el = document.createElement("div");
|
|
1636
|
-
el.className = "hook-notice" + (entry.level === "warn" ? " warn" : "");
|
|
1648
|
+
el.className = "hook-notice" + (entry.level === "warn" ? " warn" : "") + (extra.cls ? ` ${extra.cls}` : "");
|
|
1637
1649
|
el.textContent = line;
|
|
1650
|
+
if (extra.title) el.title = extra.title;
|
|
1638
1651
|
let body = step.querySelector(":scope > .activity-body");
|
|
1639
1652
|
if (!body) {
|
|
1640
1653
|
body = document.createElement("div");
|
|
@@ -1813,7 +1826,8 @@ function updateInfoBar() {
|
|
|
1813
1826
|
const ctxText = ctxBarText(current.ctxPct, current.ctxWindowSource);
|
|
1814
1827
|
const tokens = current.timing.tokens;
|
|
1815
1828
|
const ctxTip = tokensTipText(tokens, undefined, current.timing.memoryIndex,
|
|
1816
|
-
{ tokens: current.ctxWindow, source: current.ctxWindowSource, pct: current.ctxPct
|
|
1829
|
+
{ tokens: current.ctxWindow, source: current.ctxWindowSource, pct: current.ctxPct,
|
|
1830
|
+
budget: current.llmContext?.budget_tokens });
|
|
1817
1831
|
const speedTextNow = lastSpeedText(tokens);
|
|
1818
1832
|
const avgSpeed = speedText(tokens?.avg_decode_tps, tokens?.tps_source);
|
|
1819
1833
|
const speedTip = `Decode speed of the last generation${avgSpeed ? `; session average ${avgSpeed}` : ""}`;
|
|
@@ -1976,7 +1990,7 @@ const streamHandlers = {
|
|
|
1976
1990
|
const { text, thinking } = routeChunk(data);
|
|
1977
1991
|
liveTurn.timing.setNote("");
|
|
1978
1992
|
turnDom.chunk({ text, thinking, iteration: data.iteration ?? null });
|
|
1979
|
-
const pct = extractCtxPct(data, current.ctxWindow);
|
|
1993
|
+
const pct = extractCtxPct(data, current.ctxWindow, current.llmContext?.budget_tokens);
|
|
1980
1994
|
if (pct !== null && !Number.isNaN(pct)) {
|
|
1981
1995
|
current.ctxPct = pct;
|
|
1982
1996
|
updateInfoBar();
|
|
@@ -2125,17 +2139,19 @@ const streamHandlers = {
|
|
|
2125
2139
|
}
|
|
2126
2140
|
if (view.resync) await resync();
|
|
2127
2141
|
// A card's action that isn't anytime (the step-limit question's
|
|
2128
|
-
// /continue) has no bubble: nothing to remove.
|
|
2129
|
-
|
|
2142
|
+
// /continue) has no bubble: nothing to remove. An anytime or queued
|
|
2143
|
+
// one's bubble was drawn at its command_queued: filled in here.
|
|
2144
|
+
const queued = view.anytime || view.queued ? commandBubble(view.commandId) : null;
|
|
2130
2145
|
if (view.hidden) queued?.remove();
|
|
2131
2146
|
else if (queued) drawCommandBubble(queued, view);
|
|
2132
2147
|
else addCommandBubble(view);
|
|
2133
2148
|
},
|
|
2134
2149
|
// An anytime command (/help, a plugin's /btw) runs at once, beside a
|
|
2135
|
-
// turn: its bubble goes here, so the cards it shows come after it.
|
|
2150
|
+
// turn: its bubble goes here, so the cards it shows come after it. One
|
|
2151
|
+
// queued for after the turn (/model X mid-turn) shows waiting here.
|
|
2136
2152
|
command_queued: (data) => {
|
|
2137
2153
|
// A card's action waits for its command_ran: shown only if it answers.
|
|
2138
|
-
if (data.anytime && !data.card && !commandBubble(data.command_id)) addCommandBubble(
|
|
2154
|
+
if ((data.anytime || data.waits) && !data.card && !commandBubble(data.command_id)) addCommandBubble(commandView(data, clientId));
|
|
2139
2155
|
},
|
|
2140
2156
|
// Due reminders went into the turn (a reminder turn has no prompt bubble).
|
|
2141
2157
|
reminder_injected: (data) => addCommandBubble({ label: null, line: reminderText(data), text: "" }),
|
|
@@ -2154,7 +2170,7 @@ const streamHandlers = {
|
|
|
2154
2170
|
if (Array.isArray(data.used_memory_names)) setUsedMemories(data.used_memory_names);
|
|
2155
2171
|
},
|
|
2156
2172
|
context_status: (data) => {
|
|
2157
|
-
const pct = extractCtxPct(data, current.ctxWindow);
|
|
2173
|
+
const pct = extractCtxPct(data, current.ctxWindow, current.llmContext?.budget_tokens);
|
|
2158
2174
|
if (pct !== null) {
|
|
2159
2175
|
current.ctxPct = pct;
|
|
2160
2176
|
updateInfoBar();
|
|
@@ -2190,7 +2206,10 @@ const streamHandlers = {
|
|
|
2190
2206
|
// The loop asks again after an empty answer: a row of the empty step
|
|
2191
2207
|
// (a reload puts it back from the snapshot's cards).
|
|
2192
2208
|
empty_answer_retry: (data) => { turnDom.notice({ line: emptyRetryLine(data) }); },
|
|
2193
|
-
steer_cut: (data) => { turnDom.notice({ line: steerCutLine(data) }); },
|
|
2209
|
+
steer_cut: (data) => { turnDom.notice({ line: steerCutLine(data), cls: "steer-cut" }); },
|
|
2210
|
+
// A batch of LLM context edits the server applied: a ✂ row of the
|
|
2211
|
+
// current step, its hover listing the outputs.
|
|
2212
|
+
llm_context_edited: (data) => { turnDom.notice(llmContextNotice(data)); },
|
|
2194
2213
|
// The provider is asked again after an error, or the turn waits for
|
|
2195
2214
|
// plugins' setup: a note on the live timing line until it streams.
|
|
2196
2215
|
generation_retrying: (data) => liveTurn.timing.setNote(retryStatusLine(data)),
|
|
@@ -2423,8 +2442,8 @@ function addCommandBubble(view) {
|
|
|
2423
2442
|
|
|
2424
2443
|
// A command bubble's line and output (again, when its command_ran fills in
|
|
2425
2444
|
// an anytime command's bubble in place).
|
|
2426
|
-
function drawCommandBubble(div, { label, line, text, busy = false, failed = false, commandId = null }) {
|
|
2427
|
-
div.className = "bubble command" + (busy ? " busy" : "") + (failed ? " failed" : "");
|
|
2445
|
+
function drawCommandBubble(div, { label, line, text, busy = false, failed = false, waiting = false, commandId = null }) {
|
|
2446
|
+
div.className = "bubble command" + (busy ? " busy" : "") + (failed ? " failed" : "") + (waiting ? " queued" : "");
|
|
2428
2447
|
if (commandId) div.dataset.commandId = commandId;
|
|
2429
2448
|
div.replaceChildren();
|
|
2430
2449
|
const head = document.createElement("div");
|