samagotchi 0.23.0 → 0.25.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 +93 -1
- data/docs/architecture.md +16 -9
- data/docs/cli.md +10 -4
- data/docs/configuration.md +30 -13
- data/docs/hooks.md +21 -7
- data/docs/internals/background-tasks.md +1 -0
- data/docs/internals/prompt-caching.md +94 -0
- data/docs/plugins.md +17 -3
- data/docs/sub-agent.md +4 -3
- data/docs/testing.md +12 -0
- data/lib/samagotchi/bridge/card_store.rb +4 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
- data/lib/samagotchi/bridge/turn_notice.rb +1 -0
- data/lib/samagotchi/bridge.rb +7 -0
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +2 -1
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +43 -7
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/command_steps.rb +233 -0
- data/lib/samagotchi/config.rb +9 -2
- data/lib/samagotchi/empty_answer_retry.rb +2 -2
- data/lib/samagotchi/engine.rb +103 -5
- data/lib/samagotchi/generation_phase.rb +83 -0
- data/lib/samagotchi/guardrails/shell_lex.rb +158 -6
- data/lib/samagotchi/guardrails/shell_paths.rb +1 -1
- data/lib/samagotchi/idle_client.rb +14 -2
- data/lib/samagotchi/idle_recap.rb +4 -2
- data/lib/samagotchi/kernel_loop.rb +40 -12
- data/lib/samagotchi/llm/chat_loop.rb +37 -13
- data/lib/samagotchi/llm/errors.rb +26 -1
- data/lib/samagotchi/llm/openai_chat.rb +8 -4
- data/lib/samagotchi/llm/prompt_cache.rb +34 -9
- data/lib/samagotchi/llm/usage.rb +18 -3
- data/lib/samagotchi/log_subscriber.rb +5 -3
- data/lib/samagotchi/plugin/api.rb +3 -2
- data/lib/samagotchi/prompt.rb +4 -1
- data/lib/samagotchi/session_metrics.rb +38 -4
- data/lib/samagotchi/steer.rb +103 -8
- data/lib/samagotchi/system_prompt.rb +30 -9
- data/lib/samagotchi/terminal_ui/attached_loop.rb +6 -3
- data/lib/samagotchi/terminal_ui/attached_view.rb +6 -2
- data/lib/samagotchi/terminal_ui/event_renderer.rb +17 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +40 -9
- data/lib/samagotchi/terminal_ui/one_shot_sink.rb +1 -0
- data/lib/samagotchi/terminal_ui.rb +5 -0
- data/lib/samagotchi/thinking_tails.rb +103 -0
- data/lib/samagotchi/thought_stream_splitter.rb +9 -3
- data/lib/samagotchi/token_usage.rb +18 -6
- data/lib/samagotchi/tool_activity.rb +37 -3
- data/lib/samagotchi/tool_declarations.rb +14 -0
- data/lib/samagotchi/tool_runner.rb +7 -0
- data/lib/samagotchi/tool_view.rb +76 -0
- data/lib/samagotchi/tools/builtins.rb +4 -1
- data/lib/samagotchi/tools/execute.rb +2 -1
- data/lib/samagotchi/tools/registry.rb +11 -4
- data/lib/samagotchi/tools/shell.rb +32 -0
- data/lib/samagotchi/tools/task_runtime.rb +3 -3
- data/lib/samagotchi/turn_tally.rb +17 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/waiting_steer.rb +64 -0
- data/lib/samagotchi/web/message_parts.rb +5 -0
- data/lib/samagotchi/web/public/activity.js +5 -0
- data/lib/samagotchi/web/public/app.js +2 -1
- data/lib/samagotchi/web/public/command_view.js +96 -0
- data/lib/samagotchi/web/public/copy.js +7 -0
- data/lib/samagotchi/web/public/format.js +11 -1
- data/lib/samagotchi/web/public/index.html +33 -0
- data/lib/samagotchi/web/public/stage_model.js +16 -5
- data/lib/samagotchi/web/public/stage_view.js +5 -2
- data/lib/samagotchi/web/public/turn_events.js +15 -1
- data/lib/samagotchi/web/public/turn_view.js +15 -6
- data/lib/samagotchi/worker.rb +4 -1
- metadata +9 -1
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "open3"
|
|
4
4
|
require_relative "output_guardrails"
|
|
5
|
+
require_relative "shell"
|
|
5
6
|
|
|
6
7
|
module Samagotchi
|
|
7
8
|
module Tools
|
|
@@ -90,7 +91,7 @@ module Samagotchi
|
|
|
90
91
|
started = monotonic_time
|
|
91
92
|
deadline = started + timeout_sec
|
|
92
93
|
|
|
93
|
-
Open3.popen3(env || {}, command, chdir: cwd, pgroup: true) do |stdin, stdout, stderr, wait_thr|
|
|
94
|
+
Open3.popen3(env || {}, *Shell.argv(command), chdir: cwd, pgroup: true) do |stdin, stdout, stderr, wait_thr|
|
|
94
95
|
stdin.close
|
|
95
96
|
readers = [OutputReader.new(stdout), OutputReader.new(stderr)]
|
|
96
97
|
|
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
module Samagotchi
|
|
4
4
|
module Tools
|
|
5
5
|
# The tools a session offers the model, in declaration order: the
|
|
6
|
-
# built-ins (Tools::Builtins),
|
|
6
|
+
# built-ins (Tools::Builtins) as registered, then the ones bundles add,
|
|
7
|
+
# by bundle and name. That order doesn't depend on which plugin
|
|
8
|
+
# finished its init first or re-registered a changed tool, so the
|
|
9
|
+
# tool list (part of the prompt's cached prefix) is the same in every
|
|
10
|
+
# session with the same tools. The
|
|
7
11
|
# native prompts and the chat path's tools: are rendered from #schemas,
|
|
8
12
|
# and KernelLoop#dispatch runs a call through its entry's handler.
|
|
9
13
|
class Registry
|
|
@@ -43,13 +47,16 @@ module Samagotchi
|
|
|
43
47
|
def key?(name) = @entries.key?(name.to_s)
|
|
44
48
|
|
|
45
49
|
# @return [Array<String>] in declaration order
|
|
46
|
-
def names =
|
|
50
|
+
def names = entries.map(&:name)
|
|
47
51
|
|
|
48
52
|
# @return [Array<Entry>] in declaration order
|
|
49
|
-
def entries
|
|
53
|
+
def entries
|
|
54
|
+
core, added = @entries.values.partition(&:core?)
|
|
55
|
+
core + added.sort_by { |entry| [entry.source.to_s, entry.name] }
|
|
56
|
+
end
|
|
50
57
|
|
|
51
58
|
# @return [Array<Hash>] the schemas, in declaration order
|
|
52
|
-
def schemas =
|
|
59
|
+
def schemas = entries.map(&:schema)
|
|
53
60
|
|
|
54
61
|
def freeze
|
|
55
62
|
@entries.freeze
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
module Tools
|
|
7
|
+
# The shell a model's (or the user's `!`) command runs in: execute,
|
|
8
|
+
# task_create and the shell bang. Non-login (a login shell re-sources
|
|
9
|
+
# profile files, which can rebuild PATH and shadow the inherited toolchain).
|
|
10
|
+
#
|
|
11
|
+
# macOS's /bin/sh is bash 3.2, which can't parse an apostrophe in a
|
|
12
|
+
# heredoc inside "$( )" -- the usual `git commit -m "$(cat <<'EOF' ...`.
|
|
13
|
+
# There, zsh in sh emulation runs instead (it reads no rc files in that
|
|
14
|
+
# mode), with the options that keep bash habits working: `{1..3}` brace
|
|
15
|
+
# expansion, unquoted `( )` in `[[ =~ ]]`, BASH_REMATCH, and echo
|
|
16
|
+
# expanding `\t` as macOS's sh does. Elsewhere (Linux) /bin/sh as before.
|
|
17
|
+
module Shell
|
|
18
|
+
ZSH = "/bin/zsh"
|
|
19
|
+
ZSH_SH_MODE = [ZSH, "--emulate", "sh", "+o", "ignore_braces", "+o", "sh_glob", "-o", "bash_rematch",
|
|
20
|
+
"+o", "bsd_echo"].freeze
|
|
21
|
+
SH = ["/bin/sh"].freeze
|
|
22
|
+
|
|
23
|
+
# @return [Array<String>] the argv that runs +command+
|
|
24
|
+
def self.argv(command) = [*program, "-c", command]
|
|
25
|
+
|
|
26
|
+
# @return [Array<String>] the shell and its options, without -c
|
|
27
|
+
def self.program(host_os: RbConfig::CONFIG["host_os"], zsh: File.executable?(ZSH))
|
|
28
|
+
host_os.to_s.include?("darwin") && zsh ? ZSH_SH_MODE : SH
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -6,6 +6,7 @@ require "securerandom"
|
|
|
6
6
|
require "shellwords"
|
|
7
7
|
require "time"
|
|
8
8
|
require_relative "../atomic_file"
|
|
9
|
+
require_relative "shell"
|
|
9
10
|
|
|
10
11
|
module Samagotchi
|
|
11
12
|
module Tools
|
|
@@ -49,11 +50,10 @@ module Samagotchi
|
|
|
49
50
|
output_io.sync = true
|
|
50
51
|
|
|
51
52
|
wrapped_command = wrapped_shell_command(normalized_command, exit_code_path)
|
|
52
|
-
# Non-login shell
|
|
53
|
-
# files, which can rebuild PATH and shadow the inherited toolchain.
|
|
53
|
+
# Non-login, the shell Execute uses too (Shell).
|
|
54
54
|
pid = Process.spawn(
|
|
55
55
|
spawn_env,
|
|
56
|
-
|
|
56
|
+
*Shell.argv(wrapped_command),
|
|
57
57
|
chdir: resolved_cwd,
|
|
58
58
|
out: output_io,
|
|
59
59
|
err: output_io,
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "tool_view"
|
|
4
|
+
|
|
3
5
|
module Samagotchi
|
|
4
6
|
# A running count of one turn's tool calls, for the one-line tally a long,
|
|
5
7
|
# tool-heavy turn shows under its activity row:
|
|
@@ -32,10 +34,13 @@ module Samagotchi
|
|
|
32
34
|
|
|
33
35
|
# @param key [Object] one call's identity within the turn (the event's
|
|
34
36
|
# [iteration, call_index])
|
|
35
|
-
|
|
37
|
+
# @param description [String, nil] a command's description, which the
|
|
38
|
+
# last call shows in place of its params
|
|
39
|
+
def started(key:, tool:, params: nil, description: nil)
|
|
36
40
|
call = (@calls[key] ||= { tool: tool.to_s, params: "", status: "running" })
|
|
37
41
|
call[:tool] = tool.to_s unless tool.to_s.empty?
|
|
38
42
|
call[:params] = params.to_s unless params.nil?
|
|
43
|
+
call[:description] = description if description
|
|
39
44
|
call[:status] = "running"
|
|
40
45
|
call
|
|
41
46
|
end
|
|
@@ -43,9 +48,10 @@ module Samagotchi
|
|
|
43
48
|
# A completion with no start seen still counts as a call (as the web's
|
|
44
49
|
# activity rows do). Only "error" is a failure: a call a guardrail or an
|
|
45
50
|
# approval blocked still counts as a call, not as a failed one.
|
|
46
|
-
def completed(key:, tool:, status:, params: nil)
|
|
47
|
-
call = @calls[key] || started(key: key, tool: tool, params: params)
|
|
51
|
+
def completed(key:, tool:, status:, params: nil, description: nil)
|
|
52
|
+
call = @calls[key] || started(key: key, tool: tool, params: params, description: description)
|
|
48
53
|
call[:params] = params.to_s if params
|
|
54
|
+
call[:description] = description if description
|
|
49
55
|
call[:status] = status.to_s == "error" ? "error" : "ok"
|
|
50
56
|
call
|
|
51
57
|
end
|
|
@@ -58,7 +64,8 @@ module Samagotchi
|
|
|
58
64
|
next unless part[:kind].to_s == "tool"
|
|
59
65
|
|
|
60
66
|
key = [part[:iteration].to_i, part[:call_index].to_i]
|
|
61
|
-
|
|
67
|
+
view = part[:view].is_a?(Hash) ? part[:view].transform_keys(&:to_sym) : {}
|
|
68
|
+
started(key: key, tool: part[:tool], params: part[:params], description: ToolView.description_title(view))
|
|
62
69
|
completed(key: key, tool: part[:tool], status: part[:status]) unless part[:status].to_s == "running"
|
|
63
70
|
end
|
|
64
71
|
self
|
|
@@ -85,8 +92,12 @@ module Samagotchi
|
|
|
85
92
|
fields = [head] + top.map { |(tool, n), _| "#{tool} ×#{n}" }
|
|
86
93
|
if last
|
|
87
94
|
call = calls.last
|
|
88
|
-
|
|
89
|
-
|
|
95
|
+
shown = if call[:description]
|
|
96
|
+
"#{call[:tool]}: #{call[:description]}"
|
|
97
|
+
else
|
|
98
|
+
[call[:tool], call[:params].to_s.gsub(/\s+/, " ").strip].reject(&:empty?).join(" ")
|
|
99
|
+
end
|
|
100
|
+
fields << "last: #{shown}"
|
|
90
101
|
end
|
|
91
102
|
cut(fields.join(SEPARATOR), width)
|
|
92
103
|
end
|
data/lib/samagotchi/version.rb
CHANGED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# A message for the running turn that may cut its generation but came too
|
|
5
|
+
# early (the generation hadn't streamed only thinking for long enough):
|
|
6
|
+
# it waits here until the thinking passes steer.cut_after (the Engine
|
|
7
|
+
# checks on each thinking chunk), or until a drain hands the message to
|
|
8
|
+
# the model at a boundary, or the turn ends. One cut covers every waiting
|
|
9
|
+
# message: the first one's source names it.
|
|
10
|
+
#
|
|
11
|
+
# The epoch counts the drains that took input. A caller reads it before
|
|
12
|
+
# it queues its message; a drain after that may have taken the message
|
|
13
|
+
# already, so the message doesn't wait (it may be in the model's prompt).
|
|
14
|
+
# Written from the UIs' threads and the turn's; its own Mutex.
|
|
15
|
+
class WaitingSteer
|
|
16
|
+
def initialize
|
|
17
|
+
@mutex = Mutex.new
|
|
18
|
+
@source = nil
|
|
19
|
+
@epoch = 0
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @return [Integer] the drains that took input so far
|
|
23
|
+
def epoch
|
|
24
|
+
@mutex.synchronize { @epoch }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# +source+'s message waits for a cut, unless a drain took input since
|
|
28
|
+
# +epoch+. Returns whether it waits.
|
|
29
|
+
def wait!(source, epoch: nil)
|
|
30
|
+
@mutex.synchronize do
|
|
31
|
+
next false if epoch && epoch != @epoch
|
|
32
|
+
|
|
33
|
+
@source ||= source.to_s
|
|
34
|
+
true
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def waiting?
|
|
39
|
+
@mutex.synchronize { !@source.nil? }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The waiting source, taken (nil when none waits).
|
|
43
|
+
def take
|
|
44
|
+
@mutex.synchronize do
|
|
45
|
+
source = @source
|
|
46
|
+
@source = nil
|
|
47
|
+
source
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# A drain took input: what waited is delivered.
|
|
52
|
+
def delivered!
|
|
53
|
+
@mutex.synchronize do
|
|
54
|
+
@epoch += 1
|
|
55
|
+
@source = nil
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The turn ended, or a cut happened: nothing waits.
|
|
60
|
+
def clear!
|
|
61
|
+
@mutex.synchronize { @source = nil }
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "../model_profile"
|
|
4
4
|
require_relative "../tool_call_parser"
|
|
5
5
|
require_relative "../tool_activity"
|
|
6
|
+
require_relative "../tool_view"
|
|
6
7
|
require_relative "../llm/native_tool_normalizer"
|
|
7
8
|
require_relative "../tools/builtins"
|
|
8
9
|
require_relative "../image_store"
|
|
@@ -158,6 +159,10 @@ module Samagotchi
|
|
|
158
159
|
part = { tool: name, params: params.to_s }
|
|
159
160
|
title = ToolActivity.tool_title(name, call, cwd: cwd)
|
|
160
161
|
part[:title] = title if title
|
|
162
|
+
# Built from the saved call (never saved itself), so older sessions
|
|
163
|
+
# get it too.
|
|
164
|
+
view = ToolView.for(name, call)&.to_h
|
|
165
|
+
part[:view] = view if view
|
|
161
166
|
part[:label] = label if label.is_a?(String) && !label.empty?
|
|
162
167
|
unless output.nil?
|
|
163
168
|
part[:output] = output.length > OUTPUT_MAX ? output[0, OUTPUT_MAX] : output
|
|
@@ -50,6 +50,7 @@ export function addStarted(model, event = {}) {
|
|
|
50
50
|
row.label = event.label || row.label;
|
|
51
51
|
row.params = event.params || row.params;
|
|
52
52
|
row.title = event.title || row.title;
|
|
53
|
+
row.view = event.view || row.view;
|
|
53
54
|
return { row, created: false };
|
|
54
55
|
}
|
|
55
56
|
row = {
|
|
@@ -63,6 +64,9 @@ export function addStarted(model, event = {}) {
|
|
|
63
64
|
// project-relative path, a command without its "cd … &&"); rows show it
|
|
64
65
|
// in place of the params when there is one.
|
|
65
66
|
title: event.title || null,
|
|
67
|
+
// The call's full arguments for the expanded row and the hovers (the
|
|
68
|
+
// server's ToolView: an execute/task_create's command, its cwd).
|
|
69
|
+
view: event.view || null,
|
|
66
70
|
status: ROW_STATUS.RUNNING,
|
|
67
71
|
output: "",
|
|
68
72
|
output_truncated: false,
|
|
@@ -87,6 +91,7 @@ export function addCompleted(model, event = {}) {
|
|
|
87
91
|
label: event.label,
|
|
88
92
|
params: event.activity?.params || "",
|
|
89
93
|
title: event.activity?.title,
|
|
94
|
+
view: event.view,
|
|
90
95
|
});
|
|
91
96
|
row = synthesized.row;
|
|
92
97
|
}
|
|
@@ -45,7 +45,7 @@ import {
|
|
|
45
45
|
appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, mergeTiming, normalizeTiming, turnTimingText,
|
|
46
46
|
} from "./timing.js";
|
|
47
47
|
import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
|
|
48
|
-
import { clientLabel, commandView, displayWait, dropEarlyRestores, emptyAnswerLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, replaysDrawnTurn, retryStatusLine, snapshotEvents, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
|
|
48
|
+
import { clientLabel, commandView, displayWait, dropEarlyRestores, emptyAnswerLine, 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";
|
|
49
49
|
|
|
50
50
|
const $ = (s) => document.querySelector(s);
|
|
51
51
|
const topStripEl = $("#topStrip");
|
|
@@ -1861,6 +1861,7 @@ const streamHandlers = {
|
|
|
1861
1861
|
// The loop asks again after an empty answer: a row of the empty step
|
|
1862
1862
|
// (a reload puts it back from the snapshot's cards).
|
|
1863
1863
|
empty_answer_retry: (data) => { turnDom.notice({ line: emptyRetryLine(data) }); },
|
|
1864
|
+
steer_cut: (data) => { turnDom.notice({ line: steerCutLine(data) }); },
|
|
1864
1865
|
// The provider is asked again after an error, or the turn waits for
|
|
1865
1866
|
// plugins' setup: a note on the live timing line until it streams.
|
|
1866
1867
|
generation_retrying: (data) => setLiveTurnNote(retryStatusLine(data)),
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// A tool call's full command in a richer UI than the TUI's params line:
|
|
2
|
+
// the server's view (ToolView: an execute/task_create's command uncut up to
|
|
3
|
+
// 8,000 chars, the call's cwd: argument). Pure HTML builders, shared by the
|
|
4
|
+
// live row (turn_view.js fillActivityRow) and the reloaded one
|
|
5
|
+
// (reloadRowHtml), so the two can't differ.
|
|
6
|
+
import { copyButtonHtml } from "./copy.js";
|
|
7
|
+
import { escapeHtml } from "./format.js";
|
|
8
|
+
|
|
9
|
+
// What a row's title hovers: the full command when the call has one,
|
|
10
|
+
// else the params line when the row shows a title in its place.
|
|
11
|
+
export function rowHover(row) {
|
|
12
|
+
if (row?.view?.command) return row.view.command;
|
|
13
|
+
return row?.title && row?.params ? row.params : "";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// The expanded row's command block: "in <cwd>" when the call gave one (as
|
|
17
|
+
// given: execute's may be relative to the project root), the command as
|
|
18
|
+
// written (whitespace kept, its own scroll), a copy button, and a line
|
|
19
|
+
// saying how much a cut command left out. "" for a call without a view.
|
|
20
|
+
//
|
|
21
|
+
// A view with steps (CommandSteps) shows them in the command's place, its
|
|
22
|
+
// leading cd as one more "in" tag, and a "raw" toggle (a checkbox, CSS
|
|
23
|
+
// only, so a live and a reloaded row behave alike) that swaps the steps
|
|
24
|
+
// for the command as written. The copy button copies the raw command
|
|
25
|
+
// either way. A view without steps (the parser's fallback) is the raw
|
|
26
|
+
// block alone: nothing hides the command.
|
|
27
|
+
//
|
|
28
|
+
// +title+ is the row's: the model's description of the command, when it
|
|
29
|
+
// gave one, cut to fit. The block starts with the whole description when
|
|
30
|
+
// the title is cut, never in place of the command.
|
|
31
|
+
export function commandBlockHtml(view, title = null) {
|
|
32
|
+
const command = view?.command;
|
|
33
|
+
if (!command) return "";
|
|
34
|
+
const desc = view.description && view.description !== title
|
|
35
|
+
? `<div class="activity-command-desc">${escapeHtml(view.description)}</div>`
|
|
36
|
+
: "";
|
|
37
|
+
const steps = Array.isArray(view.steps) && view.steps.length ? view.steps : null;
|
|
38
|
+
const dirs = [view.cwd, steps && view.cd].filter(Boolean);
|
|
39
|
+
const where = dirs.length
|
|
40
|
+
? `in ${dirs.map((d) => `<span>${escapeHtml(d)}</span>`).join(" <span class=\"activity-command-sep\">›</span> ")}`
|
|
41
|
+
: "";
|
|
42
|
+
const cut = view.truncated
|
|
43
|
+
? `<div class="activity-command-cut">… (${command.length.toLocaleString("en-US")} of ${Number(view.chars || 0).toLocaleString("en-US")} chars)</div>`
|
|
44
|
+
: "";
|
|
45
|
+
const raw = `<pre><code>${escapeHtml(command)}</code></pre>`;
|
|
46
|
+
if (!steps) {
|
|
47
|
+
const cwd = where ? `<div class="activity-command-cwd">${where}</div>` : "";
|
|
48
|
+
return `<div class="activity-command code-wrap">${desc}${cwd}${raw}${copyButtonHtml("code")}${cut}</div>`;
|
|
49
|
+
}
|
|
50
|
+
const head = `<div class="activity-command-head"><span class="activity-command-cwd">${where}</span>` +
|
|
51
|
+
`<label class="activity-command-raw" title="Show the command as written"><input type="checkbox">raw</label></div>`;
|
|
52
|
+
return `<div class="activity-command code-wrap has-steps">${desc}${head}${stepsHtml(steps)}${raw}${copyButtonHtml("code")}${cut}</div>`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// What an op between two steps reads as: an arrow for "then" (&&, ;, a
|
|
56
|
+
// new line, a pipe), "else" for ||, "&" for one put in the background.
|
|
57
|
+
const OP_GLYPHS = { "&&": "→", ";": "→", "\n": "→", "|": "→", "|&": "→", "||": "else", "&": "&" };
|
|
58
|
+
const OP_NAMES = { "\n": "new line", "&": "after starting the step before in the background" };
|
|
59
|
+
|
|
60
|
+
function opHtml(op) {
|
|
61
|
+
if (!op) return `<span class="step-op"></span>`;
|
|
62
|
+
const glyph = OP_GLYPHS[op] || op;
|
|
63
|
+
const name = OP_NAMES[op] || op;
|
|
64
|
+
return `<span class="step-op${op === "||" ? " else" : ""}" title="${escapeHtml(name)}">${escapeHtml(glyph)}</span>`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// A step's redirections to or from nowhere (2>&1, 2>/dev/null, </dev/null),
|
|
68
|
+
// dimmed: plumbing, not what the step does.
|
|
69
|
+
const PLUMBING = /(^|\s)(\d*>&\d+|&>\/dev\/null|\d*>>?\s*\/dev\/null|<\s*\/dev\/null)(?=\s|$)/g;
|
|
70
|
+
|
|
71
|
+
export function stepTextHtml(text) {
|
|
72
|
+
let out = "";
|
|
73
|
+
let at = 0;
|
|
74
|
+
for (const m of String(text).matchAll(PLUMBING)) {
|
|
75
|
+
const start = m.index + m[1].length;
|
|
76
|
+
out += `${escapeHtml(text.slice(at, start))}<span class="step-plumbing">${escapeHtml(m[2])}</span>`;
|
|
77
|
+
at = start + m[2].length;
|
|
78
|
+
}
|
|
79
|
+
return out + escapeHtml(String(text).slice(at));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The steps as a list: a label as a small heading above its step, the op
|
|
83
|
+
// that links a step to the one before, its text, its limit and heredoc
|
|
84
|
+
// as chips (beside the text, below it when the row is narrow).
|
|
85
|
+
export function stepsHtml(steps) {
|
|
86
|
+
const items = steps.map((step) => {
|
|
87
|
+
const label = step.label ? `<li class="step-label">${escapeHtml(step.label)}</li>` : "";
|
|
88
|
+
const limit = step.limit ? `<span class="step-chip" title="output limited">${escapeHtml(step.limit)}</span>` : "";
|
|
89
|
+
const heredoc = step.heredoc
|
|
90
|
+
? `<span class="step-chip heredoc" title="a heredoc: its text in the raw command">${escapeHtml(step.heredoc.tag)} · ` +
|
|
91
|
+
`${Number(step.heredoc.lines)} ${step.heredoc.lines === 1 ? "line" : "lines"}</span>`
|
|
92
|
+
: "";
|
|
93
|
+
return `${label}<li class="activity-step">${opHtml(step.op)}<span class="step-body"><code>${stepTextHtml(step.text)}</code>${limit}${heredoc}</span></li>`;
|
|
94
|
+
});
|
|
95
|
+
return `<ol class="activity-steps">${items.join("")}</ol>`;
|
|
96
|
+
}
|
|
@@ -88,6 +88,13 @@ const ICON = '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"
|
|
|
88
88
|
const BUBBLES = ".bubble.output:not(.streaming), .bubble.user";
|
|
89
89
|
const CODE = ".bubble.output pre";
|
|
90
90
|
|
|
91
|
+
// The button as markup, for a builder that returns HTML (a tool row's
|
|
92
|
+
// command block, command_view.js); the same click handler answers it.
|
|
93
|
+
export function copyButtonHtml(kind) {
|
|
94
|
+
const title = kind === "code" ? "Copy code" : "Copy";
|
|
95
|
+
return `<button type="button" class="copy-btn copy-${kind}" title="${title}" aria-label="${title}">${ICON}</button>`;
|
|
96
|
+
}
|
|
97
|
+
|
|
91
98
|
function makeButton(doc, kind) {
|
|
92
99
|
const btn = doc.createElement("button");
|
|
93
100
|
btn.type = "button";
|
|
@@ -23,11 +23,21 @@ export function noteHtml(note) {
|
|
|
23
23
|
return `<div class="note-line">${escapeHtml(from)}</div><div class="note-text">${escapeHtml(note?.content ?? "")}</div>`;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// Who a steer names as its sender: a plugin's own label, chi's sender ids
|
|
27
|
+
// in words (the TUI's Formatting#steer_sender agrees;
|
|
28
|
+
// spec/shared/labels_matrix.json). "": no one named.
|
|
29
|
+
const STEER_SENDERS = { parent_agent: "parent agent", chi_send: "chi send", plugin_send: "plugin" };
|
|
30
|
+
export function steerSender(source) {
|
|
31
|
+
const id = String(source ?? "");
|
|
32
|
+
return STEER_SENDERS[id] || id;
|
|
33
|
+
}
|
|
34
|
+
|
|
26
35
|
// A plugin's steer (a nudge put into the running turn): one line that
|
|
27
36
|
// names who nudged the model, its text collapsed under it. +bubble+: on its
|
|
28
37
|
// own between bubbles (no step holds it), not a row of a step.
|
|
29
38
|
export function steerRowHtml({ source, text } = {}, { bubble = false } = {}) {
|
|
30
|
-
const
|
|
39
|
+
const sender = steerSender(source);
|
|
40
|
+
const who = sender ? `${sender} nudged the model` : "nudged the model";
|
|
31
41
|
return `<details class="${bubble ? "bubble " : ""}steer-row"><summary>${escapeHtml(who)}</summary>` +
|
|
32
42
|
`<div class="steer-text">${escapeHtml(text ?? "")}</div></details>`;
|
|
33
43
|
}
|
|
@@ -134,10 +134,43 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
|
|
|
134
134
|
.activity-diff>summary{cursor:pointer;font-size:12px;color:var(--text-muted);width:max-content;font-variant-numeric:tabular-nums}
|
|
135
135
|
.activity-diff>summary:hover{color:var(--text-2)}
|
|
136
136
|
.activity-diff[open]>summary{margin-bottom:4px}
|
|
137
|
+
/* An execute/task_create's full command, above its output and lined up with it: as written, its own scroll. */
|
|
138
|
+
.activity-row>.activity-command{flex:1 1 100%;min-width:0;margin:4px 0 2px 60px}
|
|
139
|
+
.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}
|
|
140
|
+
.activity-command-cwd,.activity-command-cut{font-size:11px;color:var(--text-faint)}
|
|
141
|
+
.activity-command-cwd{margin-bottom:2px}
|
|
142
|
+
.activity-command-cwd>span{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--text-muted)}
|
|
143
|
+
.activity-command-cut{margin-top:2px}
|
|
144
|
+
.activity-command-desc{font-size:12px;line-height:1.4;color:var(--text-2);margin-bottom:3px;overflow-wrap:anywhere}
|
|
145
|
+
/* The command as its steps (CommandSteps): the op that links each to the one before, labels as small headings, limits and heredocs as chips; "raw" swaps in the command as written (CSS only). */
|
|
146
|
+
.activity-command.has-steps{background:var(--bg-inset);border:1px solid var(--border);border-radius:6px;padding:4px 8px 5px}
|
|
147
|
+
.activity-command-head{display:flex;align-items:center;gap:8px;min-height:20px;padding-right:30px}
|
|
148
|
+
.has-steps .activity-command-cwd{flex:1 1 auto;min-width:0;margin:0;overflow-wrap:anywhere}
|
|
149
|
+
.activity-command-cwd>.activity-command-sep{font-family:inherit;color:var(--text-faint)}
|
|
150
|
+
.activity-command-raw{flex:none;display:inline-flex;align-items:center;height:18px;padding:0 7px;border:1px solid var(--border-strong);border-radius:9px;font-size:11px;color:var(--text-muted);cursor:pointer;user-select:none;-webkit-user-select:none}
|
|
151
|
+
.activity-command-raw:hover{color:var(--text-2)}
|
|
152
|
+
.activity-command-raw input{position:absolute;opacity:0;width:0;height:0;margin:0}
|
|
153
|
+
.activity-command-raw:has(input:focus-visible){outline:2px solid var(--accent);outline-offset:1px}
|
|
154
|
+
.activity-command-raw:has(input:checked){color:var(--accent);border-color:var(--accent-border);background:var(--accent-tint)}
|
|
155
|
+
.activity-steps{list-style:none;margin:2px 0 0;padding:0;max-height:220px;overflow:auto;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;line-height:1.5;color:var(--text-2)}
|
|
156
|
+
.activity-step{display:flex;align-items:baseline;gap:6px;min-width:0}
|
|
157
|
+
.step-body{flex:1 1 auto;min-width:0;display:flex;flex-wrap:wrap;align-items:baseline;gap:2px 6px}
|
|
158
|
+
.step-body>code{min-width:0;font:inherit;white-space:pre-wrap;overflow-wrap:anywhere}
|
|
159
|
+
.step-op{flex:none;width:2.4em;text-align:right;font-family:ui-sans-serif,system-ui,sans-serif;font-size:11px;color:var(--text-faint)}
|
|
160
|
+
.step-op.else{color:var(--warn-text)}
|
|
161
|
+
.step-label{margin:5px 0 0 calc(2.4em + 6px);font-family:ui-sans-serif,system-ui,sans-serif;font-size:11px;font-weight:600;color:var(--text-muted)}
|
|
162
|
+
.step-label:first-child{margin-top:1px}
|
|
163
|
+
.step-chip{flex:none;align-self:center;padding:0 6px;border:1px solid var(--border-strong);border-radius:8px;font-family:ui-sans-serif,system-ui,sans-serif;font-size:10.5px;line-height:15px;color:var(--text-muted);white-space:nowrap}
|
|
164
|
+
.step-chip.heredoc{border-style:dashed}
|
|
165
|
+
.step-plumbing{color:var(--text-faint)}
|
|
166
|
+
.activity-command.has-steps>pre{display:none;max-height:220px;margin-top:2px;padding:0 4px;background:none;border:0}
|
|
167
|
+
.has-steps:has(.activity-command-raw input:checked)>pre{display:block}
|
|
168
|
+
.has-steps:has(.activity-command-raw input:checked)>.activity-steps{display:none}
|
|
137
169
|
@media(max-width:600px){
|
|
138
170
|
.activity-params{flex:1 1 0}
|
|
139
171
|
.activity-output{margin-left:0}
|
|
140
172
|
.activity-diff{margin-left:0}
|
|
173
|
+
.activity-row>.activity-command{margin-left:0}
|
|
141
174
|
}
|
|
142
175
|
.bubble.thinking{border-left:3px solid var(--think);background:color-mix(in srgb,var(--think) 6%,var(--bg-panel));padding:8px 12px}
|
|
143
176
|
.bubble.thinking:not([open]),.bubble.activity:not([open]){padding:3px 10px;background:transparent;border-color:transparent;align-self:flex-start;border-radius:6px;transition:background .12s ease}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// covers the rules; stage_view.js draws them.
|
|
5
5
|
import { toolName } from "./activity.js";
|
|
6
6
|
import { splitSentences } from "./sentences.js";
|
|
7
|
+
import { steerSender } from "./format.js";
|
|
7
8
|
import { hookNoticeLabel } from "./turn_events.js";
|
|
8
9
|
|
|
9
10
|
const READ_LIKE = new Set(["read", "memory_read", "web_fetch", "task_get", "task_list", "list_sessions"]);
|
|
@@ -78,6 +79,12 @@ function rowTitle(row) {
|
|
|
78
79
|
return row.title || row.params || "";
|
|
79
80
|
}
|
|
80
81
|
|
|
82
|
+
// A slot's full command (the row's view), for its hover; none for a tool
|
|
83
|
+
// without one.
|
|
84
|
+
function withCommand(slot, row) {
|
|
85
|
+
return row.view?.command ? { ...slot, command: row.view.command } : slot;
|
|
86
|
+
}
|
|
87
|
+
|
|
81
88
|
// While the first step has no thinking and no tool rows the turn is plain:
|
|
82
89
|
// its text streams in the stage's answer slot, as the turn view's
|
|
83
90
|
// chrome-less block streams like a bubble.
|
|
@@ -98,9 +105,10 @@ const NO_ANSWER = "no answer";
|
|
|
98
105
|
// headline the newest narration sentence (+narration+, the view's sentence
|
|
99
106
|
// feed), else the newest thinking one (+thinking+, its ticker),
|
|
100
107
|
// else "…";
|
|
101
|
-
// tool the running call ({name, title, kind}
|
|
108
|
+
// tool the running call ({name, title, kind}, and command: its full
|
|
109
|
+
// command when it has one) or null;
|
|
102
110
|
// trail the last three finished calls, newest last (a file's title
|
|
103
|
-
// without its dir);
|
|
111
|
+
// without its dir; command as for tool);
|
|
104
112
|
// ticks one per call: {kind, status, step (the gen's index)}.
|
|
105
113
|
// +pending+: a card waits for the user; +ended+: the turn's end kind;
|
|
106
114
|
// +emptyAnswer+: a completed turn ended with no answer
|
|
@@ -123,13 +131,13 @@ export function liveSlots(turn, { narration = "", thinking = "", pending = false
|
|
|
123
131
|
else if (thinking) headline = { text: thinking, thinking: true };
|
|
124
132
|
const trail = rows.filter(({ row }) => row.status !== "running").slice(-TRAIL).map(({ row }) => {
|
|
125
133
|
const title = rowTitle(row);
|
|
126
|
-
return { name: toolName(row), title: FILE_TOOLS.has(row.tool) ? title.split("/").pop() : title, status: row.status };
|
|
134
|
+
return withCommand({ name: toolName(row), title: FILE_TOOLS.has(row.tool) ? title.split("/").pop() : title, status: row.status }, row);
|
|
127
135
|
});
|
|
128
136
|
return {
|
|
129
137
|
phase,
|
|
130
138
|
step: ended ? gens.filter((g) => !g.answer || g.thinking || g.tools.length).length : gens.length,
|
|
131
139
|
headline,
|
|
132
|
-
tool: running ? { name: toolName(running), title: rowTitle(running), kind: toolKind(running.tool) } : null,
|
|
140
|
+
tool: running ? withCommand({ name: toolName(running), title: rowTitle(running), kind: toolKind(running.tool) }, running) : null,
|
|
133
141
|
trail,
|
|
134
142
|
ticks: rows.map(({ row, step }) => ({ kind: toolKind(row.tool), status: row.status, step })),
|
|
135
143
|
};
|
|
@@ -167,7 +175,10 @@ export function handOffOrder({ prompt = null, block = null, extras = [], answer
|
|
|
167
175
|
// chi's own asking-again row (a notice with a line and no hook) does not:
|
|
168
176
|
// the status line tells that already. null: nothing flashes.
|
|
169
177
|
export function flashOf(what, data = {}) {
|
|
170
|
-
if (what === "steer")
|
|
178
|
+
if (what === "steer") {
|
|
179
|
+
const sender = steerSender(data.source);
|
|
180
|
+
return { kind: "steer", text: sender ? `${sender} nudged the model` : "nudged the model" };
|
|
181
|
+
}
|
|
171
182
|
if (what !== "notice" || !data.hook) return null;
|
|
172
183
|
return { kind: data.level === "warn" ? "warn" : "info", text: data.line || `${hookNoticeLabel(data.hook)}: ${data.text || ""}` };
|
|
173
184
|
}
|
|
@@ -195,6 +195,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
195
195
|
const elapsed = endKind ? formatDuration(endedAt - startedAt) : "";
|
|
196
196
|
chipLabel.textContent = `${slots.step} step${slots.step === 1 ? "" : "s"} · ${calls} tool call${calls === 1 ? "" : "s"}${elapsed ? ` · ${elapsed}` : ""}`;
|
|
197
197
|
miniTool.textContent = collapsed && slots.tool ? `${slots.tool.name} ${slots.tool.title}` : "";
|
|
198
|
+
miniTool.title = (collapsed && slots.tool?.command) || "";
|
|
198
199
|
|
|
199
200
|
live.hidden = plain || !!endKind;
|
|
200
201
|
cloud.hidden = !blockEl || blockEl.parentNode !== cloudBody;
|
|
@@ -222,7 +223,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
222
223
|
|
|
223
224
|
function drawNow(tool, gen) {
|
|
224
225
|
const quiet = gen?.text && !gen.textDone ? "writing…" : "thinking…";
|
|
225
|
-
const key = tool ? `run|${tool.name}|${tool.title}` : `quiet|${quiet}`;
|
|
226
|
+
const key = tool ? `run|${tool.name}|${tool.title}|${tool.command || ""}` : `quiet|${quiet}`;
|
|
226
227
|
if (shown.now === key) return;
|
|
227
228
|
shown.now = key;
|
|
228
229
|
now.replaceChildren();
|
|
@@ -233,7 +234,8 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
233
234
|
name.textContent = tool.name;
|
|
234
235
|
const title = node("span", "ts-title", now);
|
|
235
236
|
title.textContent = tool.title;
|
|
236
|
-
title
|
|
237
|
+
// The full command when the call has one (the title is its first step +N).
|
|
238
|
+
title.title = tool.command || tool.title;
|
|
237
239
|
} else {
|
|
238
240
|
const word = node("span", "ts-title quiet", now);
|
|
239
241
|
word.textContent = quiet;
|
|
@@ -251,6 +253,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
251
253
|
const failed = item.status === "error";
|
|
252
254
|
const el = node("span", `ts-trail-item${failed ? " error" : ""}`, trail);
|
|
253
255
|
el.textContent = `${failed ? "✕" : "✓"} ${item.name} ${item.title}`.trim();
|
|
256
|
+
if (item.command) el.title = item.command;
|
|
254
257
|
}
|
|
255
258
|
if (!flashing && !items.length) node("span", "ts-trail-item none", trail).textContent = "no tool calls yet";
|
|
256
259
|
}
|
|
@@ -123,6 +123,17 @@ export function emptyRetryLine({ attempt, of, stopped_by: stoppedBy } = {}) {
|
|
|
123
123
|
return `↻ ${what}, asking again (${attempt}/${of})`;
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
// The row a step gets when a message for the running turn cut a generation
|
|
127
|
+
// that was only thinking (steer_cut; source "" is the user's, as is an
|
|
128
|
+
// unknown one). The TUI words it the same (spec/shared/labels_matrix.json).
|
|
129
|
+
const STEER_CUT_FOR = {
|
|
130
|
+
chi_send: "a message sent with chi send",
|
|
131
|
+
parent_agent: "the parent agent's message",
|
|
132
|
+
};
|
|
133
|
+
export function steerCutLine({ source } = {}) {
|
|
134
|
+
return `↪ cut in for ${STEER_CUT_FOR[source] || "your message"}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
126
137
|
// The one muted line for a turn that ended with no answer (turn_completed's
|
|
127
138
|
// turn_summary.empty_answer, or a reloaded turn's marker): the TUI words
|
|
128
139
|
// it the same (TurnNote.empty_answer_line; spec/shared/labels_matrix.json).
|
|
@@ -136,6 +147,7 @@ export function emptyAnswerLine({ retries = 0 } = {}) {
|
|
|
136
147
|
// notice as "<label>: text", the loop's asking-again row as its line.
|
|
137
148
|
export function noticeLine(entry = {}) {
|
|
138
149
|
if (entry.type === "empty_answer_retry") return emptyRetryLine(entry);
|
|
150
|
+
if (entry.type === "steer_cut") return steerCutLine(entry);
|
|
139
151
|
return `${hookNoticeLabel(entry.hook)}: ${entry.text || ""}`;
|
|
140
152
|
}
|
|
141
153
|
|
|
@@ -451,7 +463,8 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], started
|
|
|
451
463
|
const call = { iteration: part.iteration, call_index: part.call_index, tool: part.tool };
|
|
452
464
|
if (part.label) call.label = part.label;
|
|
453
465
|
const title = part.title ? { title: part.title } : {};
|
|
454
|
-
|
|
466
|
+
const view = part.view ? { view: part.view } : {};
|
|
467
|
+
events.push({ type: "tool_call_started", ...call, params: part.params, ...title, ...view });
|
|
455
468
|
if (part.status !== "running") {
|
|
456
469
|
// The activity as the live event had it; action and duration_ms
|
|
457
470
|
// only in snapshots from a worker that keeps them (the TUI's
|
|
@@ -463,6 +476,7 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], started
|
|
|
463
476
|
};
|
|
464
477
|
if (part.duration_ms != null) completed.duration_ms = part.duration_ms;
|
|
465
478
|
if (part.diff) completed.diff = part.diff;
|
|
479
|
+
if (part.view) completed.view = part.view;
|
|
466
480
|
events.push(withImages(completed, part.images));
|
|
467
481
|
}
|
|
468
482
|
break;
|