samagotchi 0.124.0 → 0.126.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -1
  3. data/docs/architecture.md +4 -2
  4. data/docs/cli.md +20 -8
  5. data/docs/guardrails.md +22 -0
  6. data/docs/internals/participants.md +96 -3
  7. data/docs/internals/remote-sessions.md +2 -0
  8. data/docs/internals/web-push.md +10 -6
  9. data/docs/sessions.md +5 -2
  10. data/lib/samagotchi/bridge.rb +4 -3
  11. data/lib/samagotchi/bridge_client.rb +8 -1
  12. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +25 -4
  13. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  14. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  15. data/lib/samagotchi/child_reports.rb +3 -0
  16. data/lib/samagotchi/children_status.rb +6 -1
  17. data/lib/samagotchi/engine.rb +43 -7
  18. data/lib/samagotchi/guardrail_wiring.rb +10 -1
  19. data/lib/samagotchi/llm/chat_loop.rb +1 -0
  20. data/lib/samagotchi/process_group.rb +10 -1
  21. data/lib/samagotchi/reminder_carry.rb +92 -0
  22. data/lib/samagotchi/reminder_store.rb +49 -0
  23. data/lib/samagotchi/session.rb +40 -3
  24. data/lib/samagotchi/session_inbox.rb +14 -8
  25. data/lib/samagotchi/session_manager.rb +39 -8
  26. data/lib/samagotchi/session_participants.rb +10 -2
  27. data/lib/samagotchi/sessions_command.rb +1 -1
  28. data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +225 -0
  29. data/lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb +138 -0
  30. data/lib/samagotchi/terminal_ui/attached_loop/questions.rb +225 -0
  31. data/lib/samagotchi/terminal_ui/attached_loop/queueing.rb +154 -0
  32. data/lib/samagotchi/terminal_ui/attached_loop/sending.rb +302 -0
  33. data/lib/samagotchi/terminal_ui/attached_loop.rb +16 -1253
  34. data/lib/samagotchi/terminal_ui/exit_lines.rb +141 -0
  35. data/lib/samagotchi/terminal_ui/join_replay.rb +235 -0
  36. data/lib/samagotchi/verdict.rb +20 -0
  37. data/lib/samagotchi/verify_anchor.rb +28 -0
  38. data/lib/samagotchi/verify_fresh.rb +275 -0
  39. data/lib/samagotchi/verify_fresh_origin.rb +71 -0
  40. data/lib/samagotchi/version.rb +1 -1
  41. data/lib/samagotchi/web/app.rb +10 -3
  42. data/lib/samagotchi/web/participant_access.rb +15 -4
  43. data/lib/samagotchi/web/participant_review.rb +7 -1
  44. data/lib/samagotchi/web/participant_view.rb +46 -5
  45. data/lib/samagotchi/web/participants_admin.rb +15 -4
  46. data/lib/samagotchi/web/public/annotate_bar.css +8 -4
  47. data/lib/samagotchi/web/public/annotate_bar.js +43 -7
  48. data/lib/samagotchi/web/public/app.js +134 -8
  49. data/lib/samagotchi/web/public/claim_link.js +347 -0
  50. data/lib/samagotchi/web/public/data.js +2 -1
  51. data/lib/samagotchi/web/public/index.html +1 -0
  52. data/lib/samagotchi/web/public/notify.js +13 -2
  53. data/lib/samagotchi/web/public/participant.html +3 -1
  54. data/lib/samagotchi/web/public/participant.js +113 -9
  55. data/lib/samagotchi/web/public/participants_panel.js +15 -0
  56. data/lib/samagotchi/web/public/route.js +3 -2
  57. data/lib/samagotchi/web/public/send_turn.js +6 -1
  58. data/lib/samagotchi/web/public/session_state.js +5 -0
  59. data/lib/samagotchi/web/public/stream_handlers.js +3 -0
  60. data/lib/samagotchi/web/public/tokens.css +2 -1
  61. data/lib/samagotchi/web/public/verdict_card.js +65 -0
  62. data/lib/samagotchi/web/public/verdict_fresh.js +120 -0
  63. data/lib/samagotchi/web/public/verify_card.css +17 -0
  64. data/lib/samagotchi/web/public/verify_preset.js +18 -0
  65. data/lib/samagotchi/web/push/attention.rb +25 -5
  66. data/lib/samagotchi/web/push/notifier.rb +4 -3
  67. data/lib/samagotchi/web/session_control.rb +2 -0
  68. data/lib/samagotchi/web/session_summary.rb +25 -1
  69. data/lib/samagotchi/web/verify_fresh_routes.rb +110 -0
  70. data/lib/samagotchi/worker.rb +125 -12
  71. data/lib/samagotchi/worker_idle_exit.rb +7 -6
  72. data/lib/samagotchi/worker_inbound.rb +4 -4
  73. metadata +19 -1
@@ -22,8 +22,10 @@ module Samagotchi
22
22
  end
23
23
 
24
24
  # Reads +session+'s sidecar again.
25
+ # A Verify fresh child reads its parent's (Session#participants_source_id):
26
+ # its requester's grant is there.
25
27
  def refresh!(session)
26
- @session_id = session&.id
28
+ @session_id = source_id(session)
27
29
  @entries = read
28
30
  self
29
31
  end
@@ -44,7 +46,7 @@ module Samagotchi
44
46
  def entry_for(author, session: nil)
45
47
  return nil unless Steer.participant_author?(author)
46
48
 
47
- refresh!(session) if session && session.id != @session_id
49
+ refresh!(session) if session && source_id(session) != @session_id
48
50
  id = Steer.participant_id(author)
49
51
  @entries.find { |e| e.id == id } || (@entries = read).find { |e| e.id == id }
50
52
  end
@@ -62,6 +64,12 @@ module Samagotchi
62
64
 
63
65
  private
64
66
 
67
+ def source_id(session)
68
+ return nil unless session
69
+
70
+ session.respond_to?(:participants_source_id) ? session.participants_source_id : session.id
71
+ end
72
+
65
73
  def read
66
74
  return [] if @session_id.to_s.empty?
67
75
 
@@ -34,7 +34,7 @@ module Samagotchi
34
34
  [--scope=all]: every project's sessions; by default only this git project's (all outside a repo)
35
35
  --archived: archived sessions too (marked [archived]; json: archived: true)
36
36
  stop ID... # stop each session's worker (IDs or unique prefixes); chi --resume ID then starts a fresh one
37
- restart ID... # hand each session to a new worker on the newest chi installed; refused while a turn, question or reminder would be lost
37
+ restart ID... # hand each session to a new worker on the newest chi installed; keeps its reminders; refused while a turn or question would be lost
38
38
  archive ID... # hide sessions (and their delegates) from every list and keep them for good; unarchive ID... brings them back
39
39
  delete [--force] ID... # delete sessions for good (IDs or unique prefixes); --force stops a live worker first
40
40
  prune [--dry-run] [--days N] [--keep N] [--keep-status running,...] [--test-only]
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../composer_route"
4
+ require_relative "../../prompt_history"
5
+ require_relative "../../session_commands"
6
+ require_relative "../exit_lines"
7
+ require_relative "../reline_seam"
8
+
9
+ module Samagotchi
10
+ class TerminalUI
11
+ class AttachedLoop
12
+ # Input composition and Ctrl-C handling for the attached loop.
13
+ # Reads @client, @clock, @command_registry, @continue_offer, @exit_ask,
14
+ # @gaps, @link, @prefilled, @question, @queue, @queued_ids, @reader,
15
+ # @running, @screen; writes @prefilled, @last_idle_interrupt_at.
16
+ # Calls the loop helpers #answer_continue, #answer_exit_ask, #answer_question,
17
+ # #archive_after_turn, #command_registry, #detach, #echo_answer, #exit_and_archive,
18
+ # #exit_and_delete, #exit_worker, #gap_divider, #participant_commands, #persist_recent_history,
19
+ # #queue_input_prefill, #read_prompt_line, #send_command, #send_prompt,
20
+ # #show_recap, #show_stats, #submit_delivery, #unqueue_newest, #via, and
21
+ # #worker_down.
22
+ module Composer
23
+ private
24
+
25
+ # A divider above a typed line's echo (RelineSeam.echo_above, on the
26
+ # reader thread): a prompt typed after a quiet gap, at an idle prompt.
27
+ # It marks the activity, so the turn's start marks no second one.
28
+ def gap_above_echo(text)
29
+ return nil if @running
30
+
31
+ route = ComposerRoute.call(text.to_s.strip, running: false, registry: command_registry)
32
+ return nil unless %i[prompt deliver].include?(route.kind)
33
+
34
+ line = @gaps.divider
35
+ line && gap_divider(line)
36
+ end
37
+
38
+ # @return [Symbol, nil] :detach to end the loop
39
+ def submit(line)
40
+ if @exit_ask
41
+ # The exit ask is open: this line answers it. Ctrl-D and /detach
42
+ # detach without stopping anything, the way its 1 does.
43
+ return detach(ExitLines.kept_line(@exit_ask, via: via, session_id: @client.session_id)) if line.nil? ||
44
+ command_registry.lookup_local(line.strip)&.id == :detach
45
+
46
+ return answer_exit_ask(line)
47
+ end
48
+
49
+ return detach("Detached; the session keeps running. Re-attach with: #{via.attach(@client.session_id)}") if line.nil?
50
+
51
+ text = line.strip
52
+ if @question
53
+ # /detach leaves the question open, as Ctrl-D does; any other
54
+ # line (/exit too) is read as the answer.
55
+ return submit(nil) if command_registry.lookup_local(text)&.id == :detach
56
+
57
+ return answer_question(text)
58
+ end
59
+
60
+ text, kept = split_prefilled(text)
61
+ submitted = submit_line(text)
62
+ # The removed text the command was typed after, back in the input
63
+ # (unless an /unqueue put its own there, or the loop ends). Set
64
+ # before the read restarts with it: the read's echo reads it.
65
+ if kept && submitted != :detach && @prefilled.nil?
66
+ @prefilled = kept
67
+ @prefilled = nil unless @reader&.prefill(kept)
68
+ end
69
+ submitted
70
+ end
71
+
72
+ # A line typed after an /unqueue's text (#unqueued) without clearing
73
+ # it, the text untouched and then a command ("fix it/unqueue"): the
74
+ # command alone, and the text to put back. Any other line as is.
75
+ # @return [Array(String, String|nil)]
76
+ def split_prefilled(text)
77
+ kept = @prefilled
78
+ @prefilled = nil
79
+ rest = command_after(text, kept)
80
+ rest ? [rest, kept] : [text, nil]
81
+ end
82
+
83
+ # The command typed after +kept+ (/unqueue's text) in +text+, or nil.
84
+ # @return [String, nil]
85
+ def command_after(text, kept)
86
+ return nil unless kept && text.length > kept.length && text.start_with?(kept)
87
+
88
+ rest = text[kept.length..].strip
89
+ route = ComposerRoute.call(rest, running: @running, registry: command_registry)
90
+ rest if route.choice || %i[local command usage].include?(route.kind)
91
+ end
92
+
93
+ # The read's echo (RelineSeam.echo_as, on the reader thread): the
94
+ # command alone when one is typed after /unqueue's text, as
95
+ # #split_prefilled runs it; nil echoes the line as typed.
96
+ def prefilled_echo(typed) = command_after(typed.strip, @prefilled)
97
+
98
+ # @return [Symbol, nil] :detach to end the loop
99
+ def submit_line(text)
100
+ # /cut TEXT and /queue TEXT are this UI's own deliveries, read before
101
+ # every command check (ComposerRoute; SessionCommands registers them
102
+ # for Tab and /help only). A plain line sends no delivery: it goes in
103
+ # at the running turn's next step, and never cuts.
104
+ route = ComposerRoute.call(text, running: @running, registry: command_registry)
105
+ return submit_delivery(route) if route.choice
106
+
107
+ # The terminal's own commands (SessionCommands registers them, the
108
+ # REPL reads the same words). Before the continue offer: the
109
+ # leaving ones are no answer to it.
110
+ local = route.local
111
+ return submit(nil) if local == :detach
112
+ return SessionCommands.delete_on_exit?(text) ? exit_and_delete : exit_worker if local == :exit
113
+ return archive_after_turn if local == :archive && (@running || !@queued_ids.empty?)
114
+ return exit_and_archive if local == :archive
115
+
116
+ if @continue_offer
117
+ return answer_continue(text) unless local || route.kind == :command
118
+
119
+ # Not an answer: the read left no echo, so show what ran.
120
+ echo_answer(text)
121
+ end
122
+ return if route.kind == :empty
123
+
124
+ if local == :stats
125
+ show_stats
126
+ elsif local == :recap
127
+ show_recap
128
+ elsif local == :unqueue
129
+ unqueue_newest
130
+ elsif %i[share participants].include?(local)
131
+ @screen.commit(participant_commands.run(text))
132
+ elsif route.kind == :command
133
+ # The history keeps !cmds, as the REPL's does (prompts: #send_prompt).
134
+ persist_recent_history(text) if PromptHistory.shell_line?(text)
135
+ send_command(text)
136
+ elsif route.kind == :typo
137
+ # A typo of a command (/modle) is not a prompt: say so, send nothing.
138
+ @screen.commit(route.reply)
139
+ else
140
+ send_prompt(text)
141
+ end
142
+ nil
143
+ end
144
+
145
+ # One read on the reader thread: the REPL's multiline read with Tab
146
+ # completion at the main prompt, a plain line for a question's choice
147
+ # or the continue offer's answer.
148
+ def read_input_line(prompt, prefill)
149
+ return $stdin.gets&.chomp unless $stdin.tty?
150
+ # A question's answer leaves only its summary line.
151
+ return RelineSeam.without_echo { Reline.readline(prompt, true) } if @question || @continue_offer
152
+
153
+ queue_input_prefill(prefill) if prefill
154
+ read_prompt_line(prompt)
155
+ end
156
+
157
+ # The attached TUI's commands, for Tab.
158
+ def slash_commands = command_registry.completions(:attached)
159
+
160
+ # @path and #memory candidates, as the link offers them
161
+ # (AttachLink#completions).
162
+ def build_project_path_completion_candidates(path_fragment)
163
+ @link.completions.paths ? super : []
164
+ end
165
+
166
+ def build_memory_completion_candidates(memory_fragment)
167
+ @link.completions.memories ? super : []
168
+ end
169
+
170
+ # The session's commands as its worker's snapshot names them (its
171
+ # plugins' too); the built-ins until one arrives, or from a worker too
172
+ # old to name them. An unknown /foo is a prompt, as in the REPL.
173
+ def command_registry = @command_registry || SessionCommands.builtin_registry
174
+
175
+ # Ctrl-C cancels the running turn (whoever started it). At an idle
176
+ # prompt it clears the line; on an empty one it says how to detach, and
177
+ # a second press within DETACH_WINDOW detaches (D4).
178
+ # @param pressed [Hash, nil] {text:, at:} from the read the press ended
179
+ # (nil: pressed with no read open)
180
+ # @return [Symbol, nil] :detach
181
+ def interrupt(pressed = nil)
182
+ typed = pressed&.fetch(:text, nil).to_s
183
+ if @running
184
+ begin
185
+ @client.cancel(reason: "ctrl_c")
186
+ rescue SystemCallError, IOError => e
187
+ @screen.commit("could not cancel the turn (#{worker_down(e)})")
188
+ end
189
+ return nil
190
+ end
191
+ unless typed.strip.empty?
192
+ @last_idle_interrupt_at = nil
193
+ return nil
194
+ end
195
+
196
+ now = pressed&.fetch(:at, nil) || @clock.call
197
+ return submit(nil) if @last_idle_interrupt_at && now - @last_idle_interrupt_at <= DETACH_WINDOW
198
+
199
+ @last_idle_interrupt_at = now
200
+ @screen.commit(DETACH_HINT)
201
+ nil
202
+ end
203
+
204
+ # On the reader thread, from Reline's Ctrl-C: what the line held, and
205
+ # when. During a turn the press goes to the loop (it cancels the turn)
206
+ # and the read goes on with the typed text still in it, as in the REPL;
207
+ # otherwise it is noted for the Interrupt the LineReader queues next.
208
+ # @return [Boolean] true: handled, the read goes on; false: Reline ends
209
+ # the read with Interrupt
210
+ def note_interrupted_line
211
+ pressed = { text: Reline.line_buffer.to_s, at: @clock.call }
212
+ if @running
213
+ @queue << [:interrupt, pressed]
214
+ return true
215
+ end
216
+
217
+ Thread.current[:interrupted_line] = pressed
218
+ false
219
+ rescue StandardError
220
+ false
221
+ end
222
+ end
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../exit_lines"
4
+
5
+ module Samagotchi
6
+ class TerminalUI
7
+ class AttachedLoop
8
+ # Exit and archive commands for the attached loop.
9
+ # Reads @client, @client_id, @exit_ask, @link, @queued_ids, @running,
10
+ # @screen, @view; writes @archive_after_turn and @exit_ask.
11
+ # Calls the loop helpers #via and #detach.
12
+ module ExitCommands
13
+ private
14
+
15
+ # @return [Symbol] :detach
16
+ def detach(line)
17
+ @view.finish_thinking_spinner
18
+ @screen.commit(line)
19
+ :detach
20
+ end
21
+
22
+ # Detach, and ask the worker to exit now. The worker decides: it stays
23
+ # up while anything still needs it (a turn, queued input, a continue
24
+ # offer, another UI, reminders), and the line says which. When the only
25
+ # holds are ones the user can stop, it asks what to do with them instead
26
+ # of detaching (#answer_exit_ask).
27
+ # @return [Symbol, nil] :detach, or nil to stay at the prompt
28
+ def exit_worker
29
+ reply = @client.request_exit(client_id: @client_id)
30
+ holds = reply.status == 409 ? reply.json&.fetch("holds", nil) : nil
31
+ return detach(ExitLines.exit_line(reply, via: via, session_id: @client.session_id)) unless holds.is_a?(Array) && !holds.empty?
32
+
33
+ @exit_ask = holds
34
+ @screen.commit(ExitLines.ask_lines(holds))
35
+ nil
36
+ rescue SystemCallError, IOError => e
37
+ detach(ExitLines.exit_failed_line(e.message, via: via, session_id: @client.session_id))
38
+ end
39
+
40
+ # The answer to the exit ask.
41
+ # @return [Symbol, nil] :detach for 1 and 2, nil for 3 or a line the
42
+ # ask doesn't understand
43
+ def answer_exit_ask(line)
44
+ choice = ExitLines::EXIT_ANSWERS[line.to_s.strip.downcase]
45
+ unless choice
46
+ @screen.commit("Say 1 (keep running), 2 (stop them and exit) or 3 (back to the session).")
47
+ return nil
48
+ end
49
+
50
+ holds = @exit_ask
51
+ @exit_ask = nil
52
+ case choice
53
+ when :keep then detach(ExitLines.kept_line(holds, via: via, session_id: @client.session_id))
54
+ when :stop then stop_and_exit
55
+ else
56
+ @screen.commit("(nothing stopped; the worker keeps running)")
57
+ nil
58
+ end
59
+ end
60
+
61
+ # 2: ask the worker to stop them all and exit.
62
+ # @return [Symbol] :detach
63
+ def stop_and_exit
64
+ reply = @client.request_exit(client_id: @client_id, stop: true)
65
+ detach(ExitLines.exit_line(reply, via: via, session_id: @client.session_id))
66
+ rescue SystemCallError, IOError => e
67
+ detach(ExitLines.exit_failed_line(e.message, via: via, session_id: @client.session_id))
68
+ end
69
+
70
+ # /exit --delete: the worker exits as for /exit, then the session is
71
+ # deleted. When the worker stays up (another UI, queued input, ...),
72
+ # nothing is deleted.
73
+ def exit_and_delete
74
+ reply = @client.request_exit(client_id: @client_id, delete: true)
75
+ id = @client.session_id
76
+ unless reply.status == 200
77
+ line = ExitLines.exit_line(reply, via: via, session_id: id)
78
+ return detach(line.sub("Detached; the session keeps running", "Detached; not deleted: the session keeps running")) if reply.status == 409
79
+
80
+ return detach("#{line} Not deleted.")
81
+ end
82
+ # An empty session: the worker deletes it as it leaves.
83
+ return detach(ExitLines::EMPTY_EXIT) if ExitLines.discards?(reply)
84
+
85
+ @link.delete_session
86
+ detach("Detached; deleted session #{id}.")
87
+ rescue SystemCallError, IOError => e
88
+ detach("#{ExitLines.exit_failed_line(e.message, via: via, session_id: id)} Not deleted.")
89
+ rescue SessionManager::DeleteRefused, SessionManager::OwnedByTUI, ArgumentError => e
90
+ detach("Detached; the worker is stopping, but session #{id} was not deleted (#{e.message}): " \
91
+ "#{via.command("sessions delete #{id}")}")
92
+ end
93
+
94
+ # /archive during a turn (D2): it waits for the turn's end and the
95
+ # commands queued behind it (#archive_due?), then runs.
96
+ # @return [nil]
97
+ def archive_after_turn
98
+ @archive_after_turn = true
99
+ @screen.commit("(archives after this turn)")
100
+ nil
101
+ end
102
+
103
+ def archive_due? = @archive_after_turn && !@running && @queued_ids.empty?
104
+
105
+ # @return [Symbol] :detached
106
+ def archive_now
107
+ @archive_after_turn = false
108
+ exit_and_archive
109
+ :detached
110
+ end
111
+
112
+ # /archive: the worker is asked to exit as for /exit, then the session
113
+ # is archived. A worker staying up for another UI (or reminders) is
114
+ # stopped by the archive, as the web's archive stops it; a turn
115
+ # running, a queued prompt or an open continue offer refuses it
116
+ # (SessionManager.archive_session). An empty session: the worker
117
+ # deletes it as it leaves.
118
+ def exit_and_archive
119
+ # /archive can't ask about what still runs: a child the archive will
120
+ # stop is not worth a question here.
121
+ reply = @client.request_exit(client_id: @client_id, ask: false)
122
+ id = @client.session_id
123
+ return detach(ExitLines::EMPTY_EXIT) if reply.status == 200 && ExitLines.discards?(reply)
124
+ return detach("#{ExitLines.exit_line(reply, via: via, session_id: id)} Not archived.") unless [200, 409].include?(reply.status)
125
+
126
+ result = @link.archive_session
127
+ return detach("Detached; the session was empty, so it is discarded.") if result[:discarded].include?(id)
128
+
129
+ detach("Detached; archived session #{id}. #{via.command("sessions list --archived")} finds it.")
130
+ rescue SystemCallError, IOError => e
131
+ detach("#{ExitLines.exit_failed_line(e.message, via: via, session_id: id)} Not archived.")
132
+ rescue SessionManager::ArchiveRefused, SessionManager::OwnedByTUI, ArgumentError => e
133
+ detach("Detached; session #{id} was not archived (#{e.message}). Re-attach with: #{via.attach(id)}")
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../client_id"
4
+ require_relative "../exit_lines"
5
+ require_relative "../../session_commands"
6
+ require_relative "../question_prompt"
7
+
8
+ module Samagotchi
9
+ class TerminalUI
10
+ class AttachedLoop
11
+ # Question and continue prompts for the attached loop.
12
+ # Reads @as_parent, @answered_ids, @client, @continue_offer, @offer_asked,
13
+ # @question, @reader, @screen, @set_aside, @view; writes @continue_answer,
14
+ # @continue_offer, @offer_asked, @previewed_id, @question, @question_pending,
15
+ # @set_aside, @unanswered.
16
+ # Calls the loop helpers #client_label, #detach, #own?, #paint, #send_command,
17
+ # #stale_worker, #sync_prompt, #via, and #worker_down.
18
+ module Questions
19
+ private
20
+
21
+ # The worker says what became of it (:continue_resolved); an invalid
22
+ # answer gets its error and the offer stays.
23
+ def answer_continue(text)
24
+ echo_answer(text) if TurnFlow.continue_decision(text).first == :invalid
25
+ @continue_answer = text.empty? ? "yes" : text
26
+ send_command(continue_line(text))
27
+ nil
28
+ end
29
+
30
+ # A bare answer at the continue prompt, as the REPL reads it (an empty
31
+ # one is yes).
32
+ def continue_line(text)
33
+ text.empty? ? SessionCommands::CONTINUE_COMMAND : "#{SessionCommands::CONTINUE_COMMAND} #{text}"
34
+ end
35
+
36
+ def offer_continue(event)
37
+ @continue_offer = { context: event[:context], no_interrupt: event[:no_interrupt] }
38
+ sync_prompt
39
+ sync_continue_slot
40
+ end
41
+
42
+ CONTINUE_DECISIONS = { "resume" => "yes", "abort" => "no", "abort_with_reason" => "no, with a reason",
43
+ "refused" => "not continued: the participant may no longer run turns" }.freeze
44
+
45
+ # The offer's choices go; one line says what became of it. Another
46
+ # UI's answer shows as its command line (web> /continue yes) instead.
47
+ def continue_resolved(event)
48
+ @continue_offer = nil
49
+ # Asked as the step-limit question: its line says what became of it.
50
+ asked = @offer_asked
51
+ @offer_asked = false
52
+ sync_continue_slot
53
+ who = client_label(event[:client_id], "another UI")
54
+ outcome = if event[:decision] == "dropped"
55
+ "(dropped: #{own?(event[:client_id]) ? "you" : who} sent a new prompt)"
56
+ elsif event[:decision] == "refused"
57
+ CONTINUE_DECISIONS["refused"]
58
+ elsif own?(event[:client_id])
59
+ @continue_answer || CONTINUE_DECISIONS.fetch(event[:decision].to_s, event[:decision].to_s)
60
+ end
61
+ @continue_answer = nil
62
+ @screen.commit(QuestionSlot.continue_summary(outcome, paint: method(:paint))) if outcome && !asked
63
+ # Back to the > prompt: with the text a question set aside meanwhile
64
+ # (#close_question), else what the ? read holds.
65
+ set_aside = @set_aside
66
+ @set_aside = nil
67
+ set_aside ? sync_prompt(keep_text: false, prefill: set_aside) : sync_prompt
68
+ end
69
+
70
+ # The notes slot shows the offer's choices while it waits (a question
71
+ # has the slot while it is open).
72
+ def sync_continue_slot
73
+ return if @question || @offer_asked
74
+
75
+ if @continue_offer
76
+ @screen.set_slot(:notes, QuestionSlot.continue_offer(@continue_offer[:context], paint: method(:paint)))
77
+ else
78
+ @screen.clear_slot(:notes)
79
+ end
80
+ end
81
+
82
+ def unanswered_question(pending)
83
+ @unanswered = pending
84
+ detach("A question waits for an answer: #{via.attach(@client.session_id)}")
85
+ :unanswered
86
+ end
87
+
88
+ def prompt_text
89
+ return paint(QUESTION_PROMPT, 33) if @question || @continue_offer
90
+
91
+ paint(PROMPT, 92)
92
+ end
93
+
94
+ # Switch the open prompt to ? for the answer and show the choices in
95
+ # the notes slot, fitted to the terminal. Once the question closes,
96
+ # one line (the question and what became of it) stays.
97
+ def ask(pending)
98
+ return unless pending
99
+
100
+ @view.finish_thinking_spinner
101
+ # What was typed at the prompt waits for the question to close.
102
+ # Not over text already set aside (a question that closed while a
103
+ # continue offer waits): the ? read holds an answer then, if anything.
104
+ @set_aside = @reader&.typed_text.to_s.then { |typed| typed.empty? ? nil : typed } unless @question || @set_aside
105
+ @question = QuestionPrompt.new(pending)
106
+ @question_pending = pending
107
+ # The step-limit question stands for the continue offer's own slot
108
+ # until the offer is resolved.
109
+ @offer_asked = true if @question.continue?
110
+ # An edit's diff goes above, into the scrollback, once per question
111
+ # (a join gets it from the snapshot and may get the event too).
112
+ unless @previewed_id == @question.id
113
+ @previewed_id = @question.id
114
+ preview = @question.preview_lines(paint: method(:paint))
115
+ @screen.commit(preview.join("\n")) unless preview.empty?
116
+ end
117
+ # The prompt first, so the choices never show under the typed text.
118
+ sync_prompt(keep_text: false)
119
+ @screen.set_slot(:notes, @question.slot(paint: method(:paint)))
120
+ end
121
+
122
+ def answer_question(text)
123
+ # Enter alone at the step-limit question continues (it can't be
124
+ # dismissed), as at the continue prompt.
125
+ return dismiss_question if text.empty? && !@question.continue?
126
+
127
+ answer = @question.parse(text)
128
+ @screen.commit(answer.note) if answer.note
129
+ unless answer.ok?
130
+ echo_answer(text)
131
+ return @screen.commit(answer.error)
132
+ end
133
+
134
+ parent = @as_parent ? { client_id: ClientId::CLI_ANSWER } : {}
135
+ reply = @client.answer(id: @question.id, selected: answer.selected, freeform: answer.freeform, **parent)
136
+ case reply.status
137
+ when 200
138
+ @answered_ids << @question.id
139
+ close_question(@question.answer_text(answer))
140
+ when 409 then close_question("(already answered in another UI)")
141
+ when 408 then @screen.commit("could not answer (#{ExitLines::LATE})")
142
+ else
143
+ detail = reply.json&.fetch("detail", nil) || reply.json&.fetch("error", nil)
144
+ @screen.commit("could not answer (#{[reply.status, detail].compact.join(" ")})")
145
+ end
146
+ nil
147
+ rescue SystemCallError, IOError => e
148
+ @screen.commit("could not answer (#{worker_down(e)})")
149
+ nil
150
+ end
151
+
152
+ # An empty answer dismisses the question, as in the REPL: the tool
153
+ # returns unanswered (an approval: denied) and the turn goes on. The
154
+ # :question_cancelled every UI gets finds it already closed here.
155
+ def dismiss_question
156
+ reply = @client.dismiss_question(id: @question.id)
157
+ case reply.status
158
+ when 200 then close_question(@question.approval? ? "(denied)" : "(cancelled)")
159
+ when 409 then close_question("(question already closed in another UI)")
160
+ when 404 then @screen.commit("#{stale_worker("dismiss questions")}; Ctrl-C cancels the turn")
161
+ when 408 then @screen.commit("could not dismiss the question (#{ExitLines::LATE}); Ctrl-C cancels the turn")
162
+ else
163
+ # The question stays open (after a 404 too).
164
+ detail = reply.json&.fetch("error", nil)
165
+ @screen.commit("could not dismiss the question (#{[reply.status, detail].compact.join(" ")}); Ctrl-C cancels the turn")
166
+ end
167
+ nil
168
+ rescue SystemCallError, IOError => e
169
+ @screen.commit("could not dismiss the question (#{worker_down(e)}); Ctrl-C cancels the turn")
170
+ nil
171
+ end
172
+
173
+ # A line read at ? that isn't a (valid) answer: the read left no echo,
174
+ # so it shows above what it got.
175
+ def echo_answer(text) = @screen.commit("#{paint(QUESTION_PROMPT, 33)}#{text}")
176
+
177
+ def question_relayed(event)
178
+ return unless @question && @question.id == event[:id].to_s
179
+
180
+ @question.relayed_to = event[:relayed_to]
181
+ @screen.set_slot(:notes, @question.slot(paint: method(:paint)))
182
+ end
183
+
184
+ def question_answered(event)
185
+ return unless @question
186
+ return close_question(nil) if @answered_ids.include?(event[:id])
187
+
188
+ answer = event[:answer] || {}
189
+ picked = [*Array(answer[:selected]), answer[:freeform]].compact.join(", ")
190
+ close_question("#{picked.empty? ? "(answered)" : picked} (in another UI)")
191
+ end
192
+
193
+ # @param outcome [String, nil] what became of it, for the line that
194
+ # stays in the scrollback (nil: none, it was closed already)
195
+ def close_question(outcome)
196
+ @screen.clear_slot(:notes)
197
+ @screen.commit(@question.summary(outcome, paint: method(:paint))) if outcome
198
+ @question = nil
199
+ # A continue offer still waiting keeps the ? prompt (the step-limit
200
+ # question closes before its offer resolves): the text set aside
201
+ # waits for the > prompt (#continue_resolved).
202
+ return sync_continue_slot if @continue_offer
203
+
204
+ set_aside = @set_aside
205
+ @set_aside = nil
206
+ sync_prompt(keep_text: false, prefill: set_aside)
207
+ end
208
+
209
+ # Restart the open read when its prompt no longer fits (a question or a
210
+ # continue offer opened or closed), first erasing the prompt Reline drew.
211
+ # What is typed there goes along (+keep_text+), or +prefill+ goes in.
212
+ def sync_prompt(keep_text: true, prefill: nil)
213
+ return unless @reader
214
+
215
+ @screen.synchronize do
216
+ next if @reader.current == prompt_text
217
+
218
+ @screen.clear_slot(:editor)
219
+ @reader.reprompt(keep_text: keep_text, prefill: prefill)
220
+ end
221
+ end
222
+ end
223
+ end
224
+ end
225
+ end