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.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +93 -1
  3. data/docs/architecture.md +16 -9
  4. data/docs/cli.md +10 -4
  5. data/docs/configuration.md +30 -13
  6. data/docs/hooks.md +21 -7
  7. data/docs/internals/background-tasks.md +1 -0
  8. data/docs/internals/prompt-caching.md +94 -0
  9. data/docs/plugins.md +17 -3
  10. data/docs/sub-agent.md +4 -3
  11. data/docs/testing.md +12 -0
  12. data/lib/samagotchi/bridge/card_store.rb +4 -3
  13. data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
  14. data/lib/samagotchi/bridge/turn_notice.rb +1 -0
  15. data/lib/samagotchi/bridge.rb +7 -0
  16. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  17. data/lib/samagotchi/bundles/check-in/plugin.rb +2 -1
  18. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  19. data/lib/samagotchi/bundles/loop-guard/plugin.rb +43 -7
  20. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  21. data/lib/samagotchi/command_steps.rb +233 -0
  22. data/lib/samagotchi/config.rb +9 -2
  23. data/lib/samagotchi/empty_answer_retry.rb +2 -2
  24. data/lib/samagotchi/engine.rb +103 -5
  25. data/lib/samagotchi/generation_phase.rb +83 -0
  26. data/lib/samagotchi/guardrails/shell_lex.rb +158 -6
  27. data/lib/samagotchi/guardrails/shell_paths.rb +1 -1
  28. data/lib/samagotchi/idle_client.rb +14 -2
  29. data/lib/samagotchi/idle_recap.rb +4 -2
  30. data/lib/samagotchi/kernel_loop.rb +40 -12
  31. data/lib/samagotchi/llm/chat_loop.rb +37 -13
  32. data/lib/samagotchi/llm/errors.rb +26 -1
  33. data/lib/samagotchi/llm/openai_chat.rb +8 -4
  34. data/lib/samagotchi/llm/prompt_cache.rb +34 -9
  35. data/lib/samagotchi/llm/usage.rb +18 -3
  36. data/lib/samagotchi/log_subscriber.rb +5 -3
  37. data/lib/samagotchi/plugin/api.rb +3 -2
  38. data/lib/samagotchi/prompt.rb +4 -1
  39. data/lib/samagotchi/session_metrics.rb +38 -4
  40. data/lib/samagotchi/steer.rb +103 -8
  41. data/lib/samagotchi/system_prompt.rb +30 -9
  42. data/lib/samagotchi/terminal_ui/attached_loop.rb +6 -3
  43. data/lib/samagotchi/terminal_ui/attached_view.rb +6 -2
  44. data/lib/samagotchi/terminal_ui/event_renderer.rb +17 -2
  45. data/lib/samagotchi/terminal_ui/formatting.rb +40 -9
  46. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +1 -0
  47. data/lib/samagotchi/terminal_ui.rb +5 -0
  48. data/lib/samagotchi/thinking_tails.rb +103 -0
  49. data/lib/samagotchi/thought_stream_splitter.rb +9 -3
  50. data/lib/samagotchi/token_usage.rb +18 -6
  51. data/lib/samagotchi/tool_activity.rb +37 -3
  52. data/lib/samagotchi/tool_declarations.rb +14 -0
  53. data/lib/samagotchi/tool_runner.rb +7 -0
  54. data/lib/samagotchi/tool_view.rb +76 -0
  55. data/lib/samagotchi/tools/builtins.rb +4 -1
  56. data/lib/samagotchi/tools/execute.rb +2 -1
  57. data/lib/samagotchi/tools/registry.rb +11 -4
  58. data/lib/samagotchi/tools/shell.rb +32 -0
  59. data/lib/samagotchi/tools/task_runtime.rb +3 -3
  60. data/lib/samagotchi/turn_tally.rb +17 -6
  61. data/lib/samagotchi/version.rb +1 -1
  62. data/lib/samagotchi/waiting_steer.rb +64 -0
  63. data/lib/samagotchi/web/message_parts.rb +5 -0
  64. data/lib/samagotchi/web/public/activity.js +5 -0
  65. data/lib/samagotchi/web/public/app.js +2 -1
  66. data/lib/samagotchi/web/public/command_view.js +96 -0
  67. data/lib/samagotchi/web/public/copy.js +7 -0
  68. data/lib/samagotchi/web/public/format.js +11 -1
  69. data/lib/samagotchi/web/public/index.html +33 -0
  70. data/lib/samagotchi/web/public/stage_model.js +16 -5
  71. data/lib/samagotchi/web/public/stage_view.js +5 -2
  72. data/lib/samagotchi/web/public/turn_events.js +15 -1
  73. data/lib/samagotchi/web/public/turn_view.js +15 -6
  74. data/lib/samagotchi/worker.rb +4 -1
  75. 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), and later the ones bundles add. The
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 = @entries.keys
50
+ def names = entries.map(&:name)
47
51
 
48
52
  # @return [Array<Entry>] in declaration order
49
- def entries = @entries.values
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 = @entries.values.map(&:schema)
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 (matches Execute): a login shell re-sources profile
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
- "/bin/sh", "-c", wrapped_command,
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
- def started(key:, tool:, params: nil)
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
- started(key: key, tool: part[:tool], params: part[:params])
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
- params = call[:params].to_s.gsub(/\s+/, " ").strip
89
- fields << "last: #{[call[:tool], params].reject(&:empty?).join(" ")}"
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
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.23.0"
4
+ VERSION = "0.25.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -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 who = source ? `${source} nudged the model` : "nudged the model";
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}) or null;
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") return { kind: "steer", text: data.source ? `${data.source} nudged the model` : "nudged the model" };
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.title = tool.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
- events.push({ type: "tool_call_started", ...call, params: part.params, ...title });
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;