samagotchi 0.79.0 → 0.80.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -1
  3. data/README.md +7 -5
  4. data/docs/cli.md +24 -7
  5. data/docs/configuration.md +7 -4
  6. data/docs/internals/anthropic.md +3 -1
  7. data/docs/internals/bundles.md +4 -0
  8. data/docs/internals/engine.md +13 -3
  9. data/docs/plugins.md +144 -3
  10. data/docs/releasing.md +2 -2
  11. data/docs/sessions.md +1 -1
  12. data/docs/testing.md +32 -2
  13. data/lib/samagotchi/bootstrap/config_writer.rb +13 -3
  14. data/lib/samagotchi/bootstrap/probe.rb +153 -31
  15. data/lib/samagotchi/bootstrap_command.rb +92 -15
  16. data/lib/samagotchi/bridge.rb +1 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  19. data/lib/samagotchi/continue_offer.rb +5 -5
  20. data/lib/samagotchi/engine.rb +40 -37
  21. data/lib/samagotchi/guardrails/approval.rb +6 -4
  22. data/lib/samagotchi/guardrails/gate.rb +7 -3
  23. data/lib/samagotchi/hooks/bundle_loader.rb +31 -16
  24. data/lib/samagotchi/hooks/registry.rb +44 -3
  25. data/lib/samagotchi/host_registry.rb +12 -4
  26. data/lib/samagotchi/llm/adapter_common.rb +4 -2
  27. data/lib/samagotchi/llm/anthropic_binding_recovery.rb +3 -0
  28. data/lib/samagotchi/llm/anthropic_messages.rb +2 -1
  29. data/lib/samagotchi/llm/http.rb +12 -7
  30. data/lib/samagotchi/plugin/card.rb +44 -0
  31. data/lib/samagotchi/plugin/context.rb +53 -8
  32. data/lib/samagotchi/plugin/http.rb +154 -0
  33. data/lib/samagotchi/plugin/http_transport.rb +77 -0
  34. data/lib/samagotchi/plugin/loader.rb +29 -10
  35. data/lib/samagotchi/question_answer.rb +29 -0
  36. data/lib/samagotchi/question_desk.rb +20 -19
  37. data/lib/samagotchi/relay_verifier.rb +1 -1
  38. data/lib/samagotchi/testing/boundary.rb +156 -0
  39. data/lib/samagotchi/testing/bundle.rb +349 -0
  40. data/lib/samagotchi/testing/fake_executable.rb +42 -0
  41. data/lib/samagotchi/testing/fake_http.rb +137 -0
  42. data/lib/samagotchi/testing/helpers.rb +56 -0
  43. data/lib/samagotchi/testing/host.rb +76 -0
  44. data/lib/samagotchi/testing/minitest.rb +31 -0
  45. data/lib/samagotchi/testing/recorder.rb +138 -0
  46. data/lib/samagotchi/testing/recording_api.rb +27 -0
  47. data/lib/samagotchi/testing/rspec.rb +31 -0
  48. data/lib/samagotchi/testing/sandbox.rb +163 -0
  49. data/lib/samagotchi/testing/sessions.rb +77 -0
  50. data/lib/samagotchi/testing/verdict.rb +28 -0
  51. data/lib/samagotchi/testing/waiting.rb +36 -0
  52. data/lib/samagotchi/testing.rb +26 -0
  53. data/lib/samagotchi/tools/delegate_relay.rb +7 -6
  54. data/lib/samagotchi/tools/tool_index.rb +3 -0
  55. data/lib/samagotchi/turn_state.rb +28 -1
  56. data/lib/samagotchi/version.rb +1 -1
  57. data/lib/samagotchi/web/public/app.js +47 -23
  58. data/lib/samagotchi/web/public/images.js +33 -0
  59. data/lib/samagotchi/web/public/index.html +71 -38
  60. data/lib/samagotchi/web/public/level_chip.js +11 -11
  61. data/lib/samagotchi/web/public/lists_view.js +19 -14
  62. data/lib/samagotchi/web/public/llm_context_chip.js +15 -15
  63. data/lib/samagotchi/web/public/model_picker.js +1 -1
  64. data/lib/samagotchi/web/public/scope.js +3 -3
  65. data/lib/samagotchi/web/public/strip.js +35 -9
  66. data/lib/samagotchi/web/public/strip_grip.js +76 -0
  67. data/lib/samagotchi/web/public/thinking_chip.js +5 -5
  68. metadata +21 -1
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../plugin/context"
4
+
5
+ module Samagotchi
6
+ module Testing
7
+ # One ctx.sessions call: the method's name and its arguments.
8
+ SessionCall = Data.define(:name, :args, :options)
9
+
10
+ # ctx.sessions recorded (Plugin::Sessions' methods, same signatures:
11
+ # the contract spec checks): each call lands in #calls and returns a
12
+ # scripted answer (#on), or by default: fork a new id ("fork-1"…), send
13
+ # and stop the id they got, children [], read raises
14
+ # Plugin::Sessions::Error (no such session).
15
+ #
16
+ # bundle.sessions.on(:children) { |all:| [{ id: "c1", status: "running" }] }
17
+ # bundle.sessions.on(:send) { |id, text, delivery:| raise Samagotchi::Plugin::Sessions::Error, "busy" }
18
+ class Sessions
19
+ METHODS = %i[fork send read children stop].freeze
20
+
21
+ def initialize
22
+ @calls = []
23
+ @scripts = {}
24
+ @forks = 0
25
+ @mutex = Mutex.new
26
+ end
27
+
28
+ # @return [Array<SessionCall>]
29
+ def calls = @mutex.synchronize { @calls.dup }
30
+
31
+ # The calls of one method.
32
+ def calls_to(method) = calls.select { |call| call.name == method }
33
+
34
+ # Answer +method+'s calls with the block (it gets the call's
35
+ # arguments; a raise is the call's).
36
+ def on(method, &block)
37
+ raise ArgumentError, "ctx.sessions has no #{method} (#{METHODS.join(", ")})" unless METHODS.include?(method)
38
+ raise ArgumentError, "on(#{method}) needs a block" unless block
39
+
40
+ @mutex.synchronize { @scripts[method] = block }
41
+ self
42
+ end
43
+
44
+ def fork(messages:, title: nil, prompt: nil)
45
+ answer(:fork, [], { messages: messages, title: title, prompt: prompt }) do
46
+ "fork-#{@mutex.synchronize { @forks += 1 }}"
47
+ end
48
+ end
49
+
50
+ def send(id, text, delivery: nil)
51
+ answer(:send, [id, text], { delivery: delivery }) { id }
52
+ end
53
+
54
+ def read(id)
55
+ answer(:read, [id], {}) { raise Plugin::Sessions::Error, "no session #{id}" }
56
+ end
57
+
58
+ def children(all: false)
59
+ answer(:children, [], { all: all }) { [] }
60
+ end
61
+
62
+ def stop(id)
63
+ answer(:stop, [id], {}) { id }
64
+ end
65
+
66
+ private
67
+
68
+ def answer(method, args, options)
69
+ script = @mutex.synchronize do
70
+ @calls << SessionCall.new(name: method, args: args.freeze, options: options.freeze)
71
+ @scripts[method]
72
+ end
73
+ script ? script.call(*args, **options) : yield
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../guardrails/verdict"
4
+
5
+ module Samagotchi
6
+ module Testing
7
+ # One deny! or ask! a voter cast, kept or not.
8
+ Vote = Data.define(:decision, :reason, :options)
9
+
10
+ # A real Guardrails::Verdict that also records every deny! and ask!
11
+ # (#votes), for tests that count votes or check their keywords
12
+ # (advice:, rule:), even the ones a stricter earlier vote ignored.
13
+ class Verdict < Guardrails::Verdict
14
+ # @return [Array<Vote>]
15
+ def votes = (@votes ||= [])
16
+
17
+ def deny!(reason, **options)
18
+ votes << Vote.new(decision: :deny, reason: reason, options: options)
19
+ super
20
+ end
21
+
22
+ def ask!(reason, **options)
23
+ votes << Vote.new(decision: :ask, reason: reason, options: options)
24
+ super
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module Testing
5
+ # Waiting with a deadline, for a test that waits on a thread or a
6
+ # process (Helpers has them).
7
+ module Waiting
8
+ # The block's value once truthy, polled every +interval+ s; after
9
+ # +timeout+ s its last (falsy) value.
10
+ def wait_until(timeout: 5, interval: 0.01)
11
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
12
+ until (value = yield)
13
+ return yield if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
14
+
15
+ sleep(interval)
16
+ end
17
+ value
18
+ end
19
+
20
+ # #wait_until, raising "timed out after N s waiting for <what>" when
21
+ # the time runs out: for a wait no check follows.
22
+ def wait_until!(what, timeout: 5, interval: 0.01, &)
23
+ wait_until(timeout: timeout, interval: interval, &) || raise("timed out after #{timeout} s waiting for #{what}")
24
+ end
25
+
26
+ # Whether no process +pid+ is left (a killed grandchild is reaped a
27
+ # moment later: wait_until { process_gone?(pid) }).
28
+ def process_gone?(pid)
29
+ Process.kill(0, pid)
30
+ false
31
+ rescue Errno::ESRCH
32
+ true
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The bundle test kit (docs/plugins.md, "Testing a bundle"): load a bundle's
4
+ # plugin and hook files through chi's own loaders, registries, Context and
5
+ # guardrail Gate, with only the edges recorded (the session's Host, the hook
6
+ # runtime, ctx.sessions, init tasks), in a sandboxed environment.
7
+ #
8
+ # EXPERIMENTAL: it may change in any release before 1.0.0; it is not part of
9
+ # "Stable for bundles".
10
+ #
11
+ # require "samagotchi/testing" # plain Ruby
12
+ # require "samagotchi/testing/rspec" # RSpec helpers
13
+ # require "samagotchi/testing/minitest" # Samagotchi::Testing::Case
14
+ require_relative "version"
15
+ require_relative "testing/sandbox"
16
+ require_relative "testing/recorder"
17
+ require_relative "testing/host"
18
+ require_relative "testing/sessions"
19
+ require_relative "testing/fake_http"
20
+ require_relative "testing/verdict"
21
+ require_relative "testing/recording_api"
22
+ require_relative "testing/waiting"
23
+ require_relative "testing/fake_executable"
24
+ require_relative "testing/bundle"
25
+ require_relative "testing/helpers"
26
+ require_relative "testing/boundary"
@@ -5,6 +5,7 @@ require_relative "../bridge_client"
5
5
  require_relative "../relay"
6
6
  require_relative "../guardrails/approval"
7
7
  require_relative "../log"
8
+ require_relative "../question_answer"
8
9
 
9
10
  module Samagotchi
10
11
  # Loaded on first use (a require cycle otherwise; see delegate.rb).
@@ -84,10 +85,10 @@ module Samagotchi
84
85
  return closed(answer[:reason].to_s, what, client: client, relay_id: relay_id, qid: qid)
85
86
  end
86
87
 
87
- dismissed = !(answer.is_a?(Hash) && answer[:selected])
88
- by = answer.is_a?(Hash) && answer[:by] == "parent_agent" ? "parent_agent" : "user"
89
- desk.record(relay_id, selected_indices: dismissed ? [] : Array(answer[:selected_indices]),
90
- freeform: dismissed ? nil : answer[:freeform], dismissed: dismissed, by: by)
88
+ dismissed = !answer.is_a?(QuestionAnswer)
89
+ by = answer.is_a?(QuestionAnswer) && answer.parent_agent? ? "parent_agent" : "user"
90
+ desk.record(relay_id, selected_indices: dismissed ? [] : answer.selected_indices,
91
+ freeform: dismissed ? nil : answer.freeform, dismissed: dismissed, by: by)
91
92
  response = post(client, "answered", relay_id, qid)
92
93
  Outcome.new(line: line(what, delivered(response, answer, fields, dismissed)), stopped: false)
93
94
  end
@@ -118,7 +119,7 @@ module Samagotchi
118
119
  # allowed once / allowed for its session / denied ("why")
119
120
  def verdict(answer, fields)
120
121
  scopes = Array(fields.dig(:approval, :scopes))
121
- index = Array(answer[:selected_indices]).first
122
+ index = answer.selected_indices.first
122
123
  if index && index < scopes.size
123
124
  return case scopes[index].to_s
124
125
  when "once" then "allowed once"
@@ -129,7 +130,7 @@ module Samagotchi
129
130
  end
130
131
  end
131
132
 
132
- why = answer[:freeform].to_s.strip
133
+ why = answer.freeform.to_s.strip
133
134
  why.empty? ? "denied" : "denied (#{why.inspect})"
134
135
  end
135
136
 
@@ -194,6 +194,9 @@ module Samagotchi
194
194
  nil
195
195
  end
196
196
 
197
+ # @return [Array<Source>] the sources added, in order
198
+ def sources = @sources.dup
199
+
197
200
  # Every group, now: the index's own (#registry_groups), then the
198
201
  # sources'. A source group with a reserved name, or one an earlier
199
202
  # group has, is left out of search, lookup and the description (said
@@ -22,6 +22,8 @@ module Samagotchi
22
22
  @running = false
23
23
  @controller = nil
24
24
  @sink = nil
25
+ @thread = nil
26
+ @ended = false
25
27
  @steers = []
26
28
  @carried_steers = []
27
29
  end
@@ -64,12 +66,15 @@ module Samagotchi
64
66
  @lock.synchronize { @running }
65
67
  end
66
68
 
67
- # A turn begins: running, with its controller and sink, in one step.
69
+ # A turn begins, on the calling thread (the turn's own): running, with
70
+ # its controller and sink, in one step.
68
71
  def begin!(controller:, sink:)
69
72
  @lock.synchronize do
70
73
  @running = true
71
74
  @controller = controller
72
75
  @sink = sink
76
+ @thread = Thread.current
77
+ @ended = false
73
78
  @steers.concat(@carried_steers)
74
79
  @carried_steers = []
75
80
  end
@@ -82,15 +87,37 @@ module Samagotchi
82
87
  @running = false
83
88
  @controller = nil
84
89
  @sink = nil
90
+ @thread = nil
91
+ @ended = false
85
92
  @steers.tap { @steers = [] }
86
93
  end
87
94
  end
88
95
 
96
+ # The turn's end is decided (its end event is out): what runs after,
97
+ # its after_turn / session_end hooks, is post-turn work, up to #finish!.
98
+ def ended!
99
+ @lock.synchronize { @ended = true }
100
+ end
101
+
89
102
  # @return [CancellationController, nil] the running turn's controller
90
103
  def controller
91
104
  @lock.synchronize { @controller }
92
105
  end
93
106
 
107
+ # The controller for work done for the turn itself (a plugin's HTTP
108
+ # call): the running turn's, on the turn's thread or +handler+ (inside a
109
+ # plugin's event handler, whatever thread fires it), until the turn has
110
+ # ended; nil for any other thread (an anytime command, a service) and
111
+ # outside a turn.
112
+ # @return [CancellationController, nil]
113
+ def own_controller(handler: false)
114
+ @lock.synchronize do
115
+ return nil unless @running && !@ended
116
+
117
+ handler || @thread.equal?(Thread.current) ? @controller : nil
118
+ end
119
+ end
120
+
94
121
  # Cancel the running turn, if any (outside the lock).
95
122
  # @return [Boolean] whether a cancellation was triggered
96
123
  def cancel!(reason)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.79.0"
4
+ VERSION = "0.80.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -24,7 +24,7 @@ import {
24
24
  } from "./data.js";
25
25
  import { createContextChips } from "./context_chips.js";
26
26
  import {
27
- chipModel as llmContextChipModel, choiceWords, createLlmContextControl, NEW_CHAT,
27
+ chipModel as llmContextChipModel, choiceWords, createLlmContextControl, NEW_SESSION,
28
28
  withChoice,
29
29
  } from "./llm_context_chip.js";
30
30
  import {
@@ -38,7 +38,7 @@ import { createShownTexts } from "./shown_texts.js";
38
38
  import { MODEL_KEY } from "./model_pick.js";
39
39
  import { emptySessionState, sessionStateFrom } from "./session_state.js";
40
40
  import { createModelPicker } from "./model_picker.js";
41
- import { chipName, chipsTemplate, imageFiles, restoredChips, thumbsTemplate, turnRefs, turnText, uploadChips } from "./images.js";
41
+ import { admitImages, chipName, chipsTemplate, imageFiles, restoredChips, thumbsTemplate, turnRefs, turnText, uploadChips } from "./images.js";
42
42
  import { normalize, firstPreview, failedTurnText, deleteConfirmText, goneSessionNotice, cantText, withLiveStatus, withoutWorker } from "./format.js";
43
43
  import { createTurnView, turnHistoryNodes } from "./turn_view.js";
44
44
  import { elementOf, emptyAnswerTemplate } from "./turn_html.js";
@@ -66,6 +66,7 @@ import { scopeDir, scopeTemplate } from "./scope.js";
66
66
  import { html, nothing, render } from "./vendor/lit-html.js";
67
67
  import { easeScrollTo, FOLLOW_MS, shouldFollowScroll } from "./scroll.js";
68
68
  import { SHORT_WINDOW_QUERY, stripAutoHidden } from "./strip.js";
69
+ import { createStripGrip } from "./strip_grip.js";
69
70
  import { waitingOn } from "./notify.js";
70
71
  import { applySessionEvent, inTree, sortedByUpdated } from "./sessions_list.js";
71
72
  import { createNotifications } from "./notifications.js";
@@ -121,7 +122,7 @@ const llmContextControl = createLlmContextControl({
121
122
  run: (line) => runSessionCommand(line),
122
123
  });
123
124
  // The start page's LLM context chip, beside the model picker: the picked
124
- // model's values, and a choice for the next new chat only (sent with its
125
+ // model's values, and a choice for the next new session only (sent with its
125
126
  // create, then dropped; never remembered: a reload or opening a session
126
127
  // drops it too, a failed create keeps it).
127
128
  const llmContextNewEl = $("#llmContextNew");
@@ -130,7 +131,7 @@ const newChatLlmSummary = () => (selected || modelPickEl.hidden ? null : modelPi
130
131
  const llmContextNewControl = createLlmContextControl({
131
132
  el: llmContextNewEl,
132
133
  summary: newChatLlmSummary,
133
- kind: NEW_CHAT,
134
+ kind: NEW_SESSION,
134
135
  choice: () => newChatLlmContext,
135
136
  choose: (next) => {
136
137
  newChatLlmContext = next;
@@ -159,14 +160,14 @@ const reasoningHistoryControl = createReasoningHistoryControl({
159
160
  run: (line) => runSessionCommand(line),
160
161
  });
161
162
  // The start page's thinking chip: the picked model's level, and a level
162
- // for the next new chat only (sent with its create, then dropped).
163
+ // for the next new session only (sent with its create, then dropped).
163
164
  const thinkingNewEl = $("#thinkingNew");
164
165
  let newChatThinking = null;
165
166
  const newChatThinkingSummary = () => (selected || modelPickEl.hidden ? null : modelPicker.chosenRow()?.thinking || null);
166
167
  const thinkingNewControl = createThinkingControl({
167
168
  el: thinkingNewEl,
168
169
  summary: newChatThinkingSummary,
169
- kind: NEW_CHAT,
170
+ kind: NEW_SESSION,
170
171
  choice: () => newChatThinking,
171
172
  choose: (next) => {
172
173
  newChatThinking = next;
@@ -177,7 +178,7 @@ const thinkingNewControl = createThinkingControl({
177
178
  anchor: "#thinkingNew",
178
179
  });
179
180
  function renderNewChatThinking() {
180
- const chip = thinkingChipModel(withThinkingChoice(newChatThinkingSummary(), newChatThinking), { kind: NEW_CHAT });
181
+ const chip = thinkingChipModel(withThinkingChoice(newChatThinkingSummary(), newChatThinking), { kind: NEW_SESSION });
181
182
  thinkingNewEl.hidden = !chip;
182
183
  if (!chip) return thinkingNewControl.close();
183
184
  thinkingNewEl.textContent = chip.text;
@@ -186,7 +187,7 @@ function renderNewChatThinking() {
186
187
  }
187
188
  // Hidden without the model's values (the list not in, an older server).
188
189
  function renderNewChatLlmContext() {
189
- const chip = llmContextChipModel(withChoice(newChatLlmSummary(), newChatLlmContext), { kind: NEW_CHAT });
190
+ const chip = llmContextChipModel(withChoice(newChatLlmSummary(), newChatLlmContext), { kind: NEW_SESSION });
190
191
  llmContextNewEl.hidden = !chip;
191
192
  if (!chip) return llmContextNewControl.close();
192
193
  llmContextNewEl.textContent = chip.text;
@@ -205,6 +206,8 @@ let pageEditorUrl = null;
205
206
  const promptEl = $("#prompt");
206
207
  const actionBtn = $("#actionBtn");
207
208
  const cancelBtn = $("#cancelBtn");
209
+ const attachBtn = $("#attachBtn");
210
+ const attachInput = $("#attachInput");
208
211
  const infoCancelBtn = $("#infoCancelBtn");
209
212
  const modelPickEl = $("#modelPick");
210
213
  const composeHintEl = $("#composeHint");
@@ -357,7 +360,7 @@ const sentIds = new Set();
357
360
  const earlyRestores = new Map();
358
361
 
359
362
  // The page's scope (scope.js): ?dir= is the folder a project view came
360
- // from (new chats start there); the server names its project, if any.
363
+ // from (new sessions start there); the server names its project, if any.
361
364
  const scope = {
362
365
  dir: scopeDir(location.search),
363
366
  projectName: document.body.dataset.projectName || null,
@@ -467,7 +470,7 @@ function followSelectedSession(sess) {
467
470
 
468
471
  // The header's scope chip: "<project> · all" in a project view (all links
469
472
  // to the same place without ?dir); "all projects" otherwise, with where a
470
- // new chat starts on the start page.
473
+ // new session starts on the start page.
471
474
  function renderScope() {
472
475
  const el = $("#scope");
473
476
  render(scopeTemplate(scope, { error: scopeError, where: () => ({ hash: location.hash, search: location.search }) }), el);
@@ -500,7 +503,6 @@ const lists = createListsView({
500
503
  allViewOpen: () => allViewOpen(),
501
504
  openAllView: () => openAllView(),
502
505
  leaveAllView: (hash) => leaveAllView(hash),
503
- setStripHidden: (hidden) => setStripHidden(hidden),
504
506
  });
505
507
 
506
508
  // Delete a session (the info bar's button, an all-sessions card's ✕) after
@@ -544,7 +546,7 @@ function closeAllView() {
544
546
  leaveAllView(sessionHash(selected));
545
547
  }
546
548
  }
547
- // Leave all sessions for a place of our own (a picked session, a new chat):
549
+ // Leave all sessions for a place of our own (a picked session, a new session):
548
550
  // replace its history entry, since history.back() would come back later
549
551
  // with the session the view was opened from.
550
552
  function leaveAllView(hash) {
@@ -620,7 +622,7 @@ function updateComposerMode() {
620
622
  syncModelPick();
621
623
  if (selected) {
622
624
  llmContextNewControl.close();
623
- // The start page's choice was for a new chat: opening a session drops it.
625
+ // The start page's choice was for a new session: opening a session drops it.
624
626
  if (newChatLlmContext) {
625
627
  newChatLlmContext = null;
626
628
  renderNewChatLlmContext();
@@ -633,6 +635,9 @@ function updateComposerMode() {
633
635
  }
634
636
  const running = liveTurn.running && !!selected;
635
637
  cancelBtn.style.display = running ? "" : "none";
638
+ // No images for a session a terminal owns (it takes no message from here);
639
+ // on a phone none while a turn runs either: Stop turn needs the row's room.
640
+ attachBtn.hidden = (!!selected && current.owner === "tui") || (running && narrowScreen.matches);
636
641
  syncDeliveryLine();
637
642
  if (running) {
638
643
  setPlaceholder("Running… a message goes in at the next step · Enter to send · Alt+Enter queues", "Steer the turn…");
@@ -1535,7 +1540,7 @@ async function handleCancel() {
1535
1540
 
1536
1541
  // The chi logo: back to the empty start, where the composer makes a new
1537
1542
  // session (from all sessions too).
1538
- // The typed draft stays in the composer: paste in a session, then + new chat
1543
+ // The typed draft stays in the composer: paste in a session, then + new session
1539
1544
  // starts from it (with the model picker). clearSelection leaves #prompt alone.
1540
1545
  $("#newBtn").addEventListener("click", () => {
1541
1546
  if (allViewOpen()) leaveAllView("");
@@ -1553,7 +1558,7 @@ $("#newBtn").addEventListener("click", () => {
1553
1558
  // tooltip; a route that fails leaves the picker hidden and the server's
1554
1559
  // default in charge. The search list itself is model_picker.js'; a pick
1555
1560
  // sends the focus to the message box.
1556
- const START_PICK_TITLE = "The model the new chat starts on";
1561
+ const START_PICK_TITLE = "The model the new session starts on";
1557
1562
  let startPickTitle = START_PICK_TITLE;
1558
1563
  // The fresh link the picker shows (null: the start page, or a session it
1559
1564
  // is hidden in), and a link pick's /model still in flight (a send waits).
@@ -1624,7 +1629,7 @@ function syncModelPick() {
1624
1629
  modelPickEl.setAttribute("aria-label", "Model this link starts on");
1625
1630
  } else {
1626
1631
  modelPickEl.title = startPickTitle;
1627
- modelPickEl.setAttribute("aria-label", "Model for the new chat");
1632
+ modelPickEl.setAttribute("aria-label", "Model for the new session");
1628
1633
  }
1629
1634
  }
1630
1635
  // Leaving a session: the start page's remembered model again (no fetch).
@@ -1680,9 +1685,14 @@ function clearChips() {
1680
1685
  setChips([]);
1681
1686
  }
1682
1687
 
1688
+ // A paste's, a drop's or the image button's files: the images the message
1689
+ // may still take become chips (images.js admitImages); a toast says why the
1690
+ // rest stayed out. Whether any went in.
1683
1691
  function addImageFiles(files) {
1684
- if (!files.length) return false;
1685
- for (const file of files) chips.push({ file, name: chipName(file, chips.length), src: URL.createObjectURL(file) });
1692
+ const { take, refused } = admitImages(files, chips.length);
1693
+ if (refused) showToast(refused);
1694
+ if (!take.length) return false;
1695
+ for (const file of take) chips.push({ file, name: chipName(file, chips.length), src: URL.createObjectURL(file) });
1686
1696
  renderChips();
1687
1697
  focusPrompt();
1688
1698
  return true;
@@ -1695,6 +1705,13 @@ chipsEl.addEventListener("click", (e) => {
1695
1705
  if (chip?.file && chip.src) URL.revokeObjectURL(chip.src);
1696
1706
  renderChips();
1697
1707
  });
1708
+ // The image button: the file picker's images join the chips. Its value is
1709
+ // cleared after each pick, so the same file can be picked again.
1710
+ attachBtn.addEventListener("click", () => attachInput.click());
1711
+ attachInput.addEventListener("change", () => {
1712
+ addImageFiles([...attachInput.files]);
1713
+ attachInput.value = "";
1714
+ });
1698
1715
  // ── Composer height: grows with the text; the top-edge grip sets a floor ───
1699
1716
  let promptFloor = Number(readStored(GRIP_KEY)) || null;
1700
1717
 
@@ -2093,15 +2110,24 @@ function stripHiddenSaved() {
2093
2110
  return saved === null ? readStored("chi_zen") === "1" : saved === "1";
2094
2111
  }
2095
2112
  // A short window hides the strip by itself, so the stage keeps room for a
2096
- // card that waits (its question and choices); the sessions pill brings it
2097
- // back until the window is tall again. Not saved: the saved choice is the
2098
- // user's own hide ▴ / show.
2113
+ // card that waits (its question and choices); the grip brings it back
2114
+ // until the window is tall again. Not saved: the saved choice is the
2115
+ // user's own hide or show.
2099
2116
  const shortWindow = window.matchMedia(SHORT_WINDOW_QUERY);
2100
2117
  let stripShownWhileShort = false;
2118
+ // The strip's grip (strip_grip.js): the handle on the edge between the strip
2119
+ // and the chat. It is given the hidden state and the toggle; it speaks its own
2120
+ // title and aria-expanded, refreshed below after every change.
2121
+ const stripGripRefresh = createStripGrip({
2122
+ gripEl: $("#stripGrip"),
2123
+ isHidden: () => document.body.classList.contains("strip-hidden"),
2124
+ setHidden: (hidden) => setStripHidden(hidden),
2125
+ });
2101
2126
  function applyStripHidden() {
2102
2127
  document.body.classList.toggle("strip-hidden", stripAutoHidden({
2103
2128
  saved: stripHiddenSaved(), short: shortWindow.matches, shownWhileShort: stripShownWhileShort,
2104
2129
  }));
2130
+ stripGripRefresh();
2105
2131
  updateSessionTitle();
2106
2132
  }
2107
2133
  function setStripHidden(hidden) {
@@ -2115,8 +2141,6 @@ shortWindow.addEventListener("change", () => {
2115
2141
  if (!shortWindow.matches) stripShownWhileShort = false;
2116
2142
  applyStripHidden();
2117
2143
  });
2118
- $("#stripShow").addEventListener("click", () => setStripHidden(false));
2119
- $("#stripHideTop").addEventListener("click", () => setStripHidden(true));
2120
2144
 
2121
2145
  // Esc leaves select mode first, then the all-sessions view.
2122
2146
  document.addEventListener("keydown", (e) => {
@@ -15,6 +15,39 @@ export function imageFiles(dataTransfer) {
15
15
  return files.filter((f) => /^image\//.test(f?.type || ""));
16
16
  }
17
17
 
18
+ // The server's caps, mirrored so the composer refuses at once what a send
19
+ // would (spec/web/images_spec.rb pins both): ImageStore::MAX_TURN_REFS
20
+ // images a message, Web::App::MAX_IMAGE_UPLOAD_BYTES a file.
21
+ export const MAX_TURN_IMAGES = 20;
22
+ export const MAX_IMAGE_MB = 20;
23
+ const MAX_IMAGE_BYTES = MAX_IMAGE_MB * 1024 * 1024;
24
+
25
+ function filesText(n) {
26
+ return n === 1 ? "1 file" : `${n} files`;
27
+ }
28
+
29
+ // What the composer takes of +files+ (a paste's, a drop's or the image
30
+ // button's picks) while it holds +held+ chips: images only, none over
31
+ // MAX_IMAGE_MB, and no more than MAX_TURN_IMAGES in all. +refused+ says
32
+ // why the rest stayed out, for a toast ("" when none did).
33
+ export function admitImages(files, held = 0) {
34
+ const take = [];
35
+ let notImage = 0;
36
+ let tooBig = 0;
37
+ let overCap = 0;
38
+ for (const file of files || []) {
39
+ if (!/^image\//.test(file?.type || "")) notImage += 1;
40
+ else if ((file.size || 0) > MAX_IMAGE_BYTES) tooBig += 1;
41
+ else if (held + take.length >= MAX_TURN_IMAGES) overCap += 1;
42
+ else take.push(file);
43
+ }
44
+ const why = [];
45
+ if (overCap) why.push(`${filesText(overCap)} not added: at most ${MAX_TURN_IMAGES} images a message`);
46
+ if (tooBig) why.push(`${filesText(tooBig)} not added: over ${MAX_IMAGE_MB} MB`);
47
+ if (notImage) why.push(`${filesText(notImage)} not added: not an image`);
48
+ return { take, refused: why.join(" · ") };
49
+ }
50
+
18
51
  // A pasted screenshot is often called "image.png": number it so the chips
19
52
  // (and what the model reads) tell them apart.
20
53
  export function chipName(file, index) {