samagotchi 0.4.0 → 0.5.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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -1
  3. data/README.md +13 -2
  4. data/bin/chi +29 -39
  5. data/docs/cli.md +135 -73
  6. data/docs/configuration.md +15 -20
  7. data/docs/hooks.md +1 -1
  8. data/docs/memory.md +40 -0
  9. data/docs/plugins.md +50 -0
  10. data/docs/releasing.md +9 -6
  11. data/docs/sessions.md +3 -3
  12. data/lib/samagotchi/bootstrap/config_writer.rb +1 -2
  13. data/lib/samagotchi/bridge/sse_writer.rb +0 -3
  14. data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
  15. data/lib/samagotchi/bridge.rb +16 -11
  16. data/lib/samagotchi/bundles/skills/manifest.yml +10 -0
  17. data/lib/samagotchi/bundles/skills/plugin.rb +419 -0
  18. data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -8
  19. data/lib/samagotchi/bundles/system/identity.md +5 -0
  20. data/lib/samagotchi/bundles/system/manifest.yml +5 -5
  21. data/lib/samagotchi/bundles/system/memory_guide.md +26 -0
  22. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  23. data/lib/samagotchi/client.rb +16 -20
  24. data/lib/samagotchi/config.rb +40 -100
  25. data/lib/samagotchi/engine.rb +50 -354
  26. data/lib/samagotchi/kernel_loop.rb +33 -44
  27. data/lib/samagotchi/live_versions.rb +7 -1
  28. data/lib/samagotchi/llm/errors.rb +17 -0
  29. data/lib/samagotchi/llm/http.rb +4 -18
  30. data/lib/samagotchi/llm/openai_chat.rb +17 -0
  31. data/lib/samagotchi/model_profile.rb +4 -10
  32. data/lib/samagotchi/note_command.rb +2 -1
  33. data/lib/samagotchi/reply_wait.rb +48 -4
  34. data/lib/samagotchi/self_report.rb +20 -2
  35. data/lib/samagotchi/send_command.rb +84 -6
  36. data/lib/samagotchi/session.rb +4 -2
  37. data/lib/samagotchi/session_manager.rb +18 -37
  38. data/lib/samagotchi/system_prompt.rb +403 -0
  39. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -3
  40. data/lib/samagotchi/terminal_ui/attached_loop.rb +120 -83
  41. data/lib/samagotchi/terminal_ui/attached_view.rb +27 -12
  42. data/lib/samagotchi/terminal_ui/event_renderer.rb +23 -7
  43. data/lib/samagotchi/terminal_ui/formatting.rb +32 -22
  44. data/lib/samagotchi/terminal_ui/input_support.rb +3 -4
  45. data/lib/samagotchi/terminal_ui/plain_surface.rb +13 -7
  46. data/lib/samagotchi/terminal_ui/status_row.rb +81 -0
  47. data/lib/samagotchi/terminal_ui/surface.rb +1 -1
  48. data/lib/samagotchi/terminal_ui.rb +95 -690
  49. data/lib/samagotchi/thinking.rb +11 -0
  50. data/lib/samagotchi/tool_activity.rb +52 -2
  51. data/lib/samagotchi/tool_runner.rb +3 -0
  52. data/lib/samagotchi/tools/execute.rb +3 -3
  53. data/lib/samagotchi/tools/output_guardrails.rb +8 -7
  54. data/lib/samagotchi/tools/read.rb +4 -4
  55. data/lib/samagotchi/update_command.rb +2 -1
  56. data/lib/samagotchi/version.rb +1 -1
  57. data/lib/samagotchi/web/app.rb +170 -35
  58. data/lib/samagotchi/web/lan.rb +99 -0
  59. data/lib/samagotchi/web/message_parts.rb +15 -11
  60. data/lib/samagotchi/web/public/activity.js +7 -0
  61. data/lib/samagotchi/web/public/app.js +99 -54
  62. data/lib/samagotchi/web/public/chat_view.js +5 -1
  63. data/lib/samagotchi/web/public/index.html +163 -17
  64. data/lib/samagotchi/web/public/model_pick.js +136 -0
  65. data/lib/samagotchi/web/public/model_picker.js +224 -0
  66. data/lib/samagotchi/web/public/notify.js +10 -0
  67. data/lib/samagotchi/web/public/stage_model.js +110 -0
  68. data/lib/samagotchi/web/public/stage_view.js +580 -0
  69. data/lib/samagotchi/web/public/timing.js +6 -2
  70. data/lib/samagotchi/web/public/turn_events.js +9 -5
  71. data/lib/samagotchi/web/public/turn_model.js +11 -3
  72. data/lib/samagotchi/web/public/turn_view.js +74 -19
  73. data/lib/samagotchi/web/qr.rb +40 -0
  74. data/lib/samagotchi/web/server.rb +101 -11
  75. data/lib/samagotchi/web/token.rb +97 -0
  76. metadata +27 -3
  77. data/lib/samagotchi/terminal_ui/legacy_surface.rb +0 -111
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "monitor"
4
3
  require "json"
5
4
  require "fileutils"
6
5
  require "io/console"
@@ -13,8 +12,6 @@ require_relative "context_note"
13
12
  require_relative "cancellation_controller"
14
13
  require_relative "llm/errors"
15
14
  require_relative "host_registry"
16
- require_relative "context_usage"
17
- require_relative "context_window"
18
15
  require_relative "kernel_loop"
19
16
  require_relative "session"
20
17
  require_relative "owner_lock"
@@ -22,17 +19,17 @@ require_relative "engine"
22
19
  require_relative "tools/memory"
23
20
  require_relative "output_formatter"
24
21
  require_relative "turn_flow"
25
- require_relative "turn_tally"
26
22
  require_relative "session_commands"
23
+ require_relative "terminal_ui/attached_view"
27
24
  require_relative "terminal_ui/event_renderer"
28
25
  require_relative "terminal_ui/formatting"
29
26
  require_relative "terminal_ui/input_support"
30
27
  require_relative "terminal_ui/image_input"
31
- require_relative "terminal_ui/legacy_surface"
28
+ require_relative "terminal_ui/plain_surface"
32
29
  require_relative "terminal_ui/live_region"
33
30
  require_relative "terminal_ui/question_prompt"
34
31
  require_relative "terminal_ui/repl_input"
35
- require_relative "terminal_ui/thinking_line"
32
+ require_relative "terminal_ui/status_row"
36
33
  require_relative "log"
37
34
 
38
35
  module Samagotchi
@@ -51,35 +48,6 @@ module Samagotchi
51
48
  # The prompt while a question waits for its answer (its choices are in
52
49
  # the notes slot).
53
50
  QUESTION_PROMPT = "? "
54
- THINKING_UI_ENV = "SAMAGOTCHI_THINKING_UI"
55
- THINKING_UI_SPINNER = "spinner"
56
- THINKING_UI_OFF = "off"
57
- THINKING_SPINNER_FRAMES = ["|", "/", "-", "\\"].freeze
58
- MEMORY_SPINNER_COLOR = "38;5;208"
59
- TOOL_SPINNER_COLOR = 32
60
- NETWORK_RETRY_SPINNER_COLOR = 31
61
- MEMORY_SPINNER_PREVIEW_LIMIT = 3
62
- MEMORY_STICKY_PREVIEW_LIMIT = 8
63
- THINKING_PREVIEW_WIDTH = 120
64
- # Columns a sentence needs on the spinner row; with less, it reads "thinking...".
65
- MIN_SENTENCE_ROOM = 12
66
- THINKING_TOOL_PREVIEW_LIMIT = 56
67
- THINKING_RENDER_MIN_INTERVAL = 0.08
68
- # The spinner also turns with time: a ticker redraws it when no chunk
69
- # did for this long, and after THINKING_WAIT_NOTICE_AFTER seconds with no
70
- # chunk it says how long the first token has taken.
71
- THINKING_TICK_INTERVAL = 0.25
72
- THINKING_WAIT_NOTICE_AFTER = 2.0
73
- THINKING_RENDER_INTERVAL_ENV = "SAMAGOTCHI_THINKING_RENDER_INTERVAL"
74
- STATUS_LINE_ENV = "SAMAGOTCHI_STATUS_LINE"
75
- STATUS_LINE_ON = "on"
76
- STATUS_LINE_OFF = "off"
77
- STATUS_WIDTH_MODE_ENV = "SAMAGOTCHI_STATUS_WIDTH_MODE"
78
- STATUS_WIDTH_MODE_TERMINAL_CAP = "terminal_cap"
79
- STATUS_WIDTH_MODE_FIXED = "fixed"
80
- STATUS_FIXED_WIDTH_ENV = "SAMAGOTCHI_STATUS_FIXED_WIDTH"
81
- STATUS_MAX_WIDTH_ENV = "SAMAGOTCHI_STATUS_MAX_WIDTH"
82
- STATUS_MAX_WIDTH_DEFAULT = 160
83
51
  REMINDER_PENDING_POLL_INTERVAL = 0.5
84
52
  # What a command sent during a turn gets, as from a worker (Worker::BUSY_OUTPUT).
85
53
  COMMAND_BUSY = "busy: wait for the turn to end"
@@ -101,14 +69,9 @@ module Samagotchi
101
69
  # @param scratch [Boolean] `chi scratch`: a new session that is deleted
102
70
  # however the REPL ends, saves no memories and writes no recap
103
71
  def initialize(mode: :assist, prompt: nil, client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, no_default_input: false, model_name: nil, memories: [], muted_memories: [], non_interactive: false, surface: nil,
104
- spinner_tick_interval: THINKING_TICK_INTERVAL, scratch: false)
72
+ spinner_tick_interval: AttachedView::TICK_INTERVAL, spinner_clock: nil, scratch: false)
105
73
  @mode = mode.to_sym
106
74
  @scratch = scratch
107
- # nil: no ticker thread (specs that compare exact frames).
108
- @spinner_tick_interval = spinner_tick_interval
109
- @spinner_lock = Monitor.new
110
- @turn_tally = TurnTally.new
111
- @thinking_line = ThinkingLine.new(clock: -> { monotonic_time })
112
75
  @prompt = prompt
113
76
  @default_model_name = ModelProfile.required_model_name(nil)
114
77
  aliased_model_name = model_name.to_s.strip.empty? ? nil : ConfigFile.resolve_model_alias(model_name)
@@ -162,9 +125,17 @@ module Samagotchi
162
125
  # Every terminal write goes through the surface. The REPL swaps in a
163
126
  # live region when the terminal can show one (#assist_loop), unless it
164
127
  # was given a surface to draw on.
165
- @surface = surface || LegacySurface.new
128
+ @surface = surface || PlainSurface.new
166
129
  @surface_given = !surface.nil?
167
- @renderer = EventRenderer.new(self)
130
+ # The turn view is the attached TUI's, and so is the status row; both
131
+ # move to the live region with the surface (#use_surface). A
132
+ # spinner_tick_interval of nil: no ticker thread, and a spinner_clock
133
+ # fixes the frames (specs that compare exact frames).
134
+ view_options = { tick_interval: spinner_tick_interval }
135
+ view_options[:clock] = spinner_clock if spinner_clock
136
+ @view = AttachedView.new(@surface, **view_options)
137
+ @status_row = StatusRow.new(@surface)
138
+ @renderer = EventRenderer.new(@view)
168
139
  @render_event = ->(event) { handle_stream_event(event) }
169
140
  @engine = Engine.new(
170
141
  mode: :assist,
@@ -232,6 +203,8 @@ module Samagotchi
232
203
  # (auto-executing it, then saving), then either exits when there is no
233
204
  # follow-up REPL (--non-interactive) or drops into the REPL carrying the
234
205
  # post-turn conversation.
206
+ # @return [Symbol, nil] :turn_failed when lines came from a pipe and a
207
+ # turn failed (bin/chi exits 1)
235
208
  def run
236
209
  unless @mode == :assist
237
210
  raise ArgumentError, "Unknown mode '#{@mode}'. Use: assist"
@@ -293,6 +266,8 @@ module Samagotchi
293
266
  else
294
267
  keep_after_exit(session)
295
268
  end
269
+ # Lines from a pipe (`chi -p X </dev/null`): a failed turn fails the run.
270
+ :turn_failed if @piped_turn_failed
296
271
  ensure
297
272
  # However it ends (the early returns too; the Engine was built in
298
273
  # #initialize): the anytime commands finish, the plugins' services
@@ -487,15 +462,24 @@ module Samagotchi
487
462
  return yield unless screen
488
463
 
489
464
  plain = @surface
490
- @surface = screen
465
+ use_surface(screen)
491
466
  begin
492
467
  yield
493
468
  ensure
494
- @surface = plain
469
+ # Nothing may draw on the screen once it is closed: the view moves
470
+ # off it, then its ticker ends.
471
+ use_surface(plain)
472
+ @view.stop
495
473
  LiveRegion.close(screen)
496
474
  end
497
475
  end
498
476
 
477
+ def use_surface(surface)
478
+ @surface = surface
479
+ @view.surface = surface
480
+ @status_row.surface = surface
481
+ end
482
+
499
483
  # Interactive REPL loop. Session seed + messages are built by #run and
500
484
  # threaded in here (so --prompt / --resume share one code path). The
501
485
  # session's conversation is the single working copy: turns go through
@@ -582,8 +566,9 @@ module Samagotchi
582
566
  @turn_flow.before_continue_turn
583
567
  begin
584
568
  result = run_engine_turn(session, nil, continue: true)
585
- rescue LLM::ProviderError => e
586
- @surface.commit("\nmodel> #{e.summary}; continue prompt preserved")
569
+ rescue LLM::ProviderError
570
+ # The renderer showed the failure (:turn_failed).
571
+ @surface.commit(turn_end_hint("continue prompt preserved"))
587
572
  return
588
573
  end
589
574
  finish_turn(session, result, continue: true)
@@ -615,10 +600,8 @@ module Samagotchi
615
600
  # the prompt, and renders through @renderer via on_event.
616
601
  result = run_engine_turn(session, input, images: ImageInput.extract(input))
617
602
  rescue LLM::ProviderError, ImageStore::Error => e
618
- # Engine closed the turn (:turn_failed); show its duration.
619
- # Retries were already tallied via generation_retrying events.
603
+ # Engine closed the turn (:turn_failed), which the renderer showed.
620
604
  # An @path image that can't be used fails the turn the same way.
621
- emit_interactive_turn_duration(canceled: false)
622
605
  summary = e.respond_to?(:summary) ? e.summary : e.message
623
606
  # The model reads why on its next turn. An image that couldn't be
624
607
  # used, or that the host refused, never reached it: no note for that
@@ -627,7 +610,14 @@ module Samagotchi
627
610
  @turn_flow.prompt_turn_failed(note: note)
628
611
  # The Engine saved the failed turn; the file follows the rollback.
629
612
  save_session(session) if note
630
- @surface.commit("\nmodel> #{summary}; #{restore_prompt_for_retry(input)}")
613
+ if piped_input?
614
+ # Nobody can edit a restored prompt, and Reline would hand it back
615
+ # as the next line: the turn would run again and again. It failed;
616
+ # the exit status says so.
617
+ @piped_turn_failed = true
618
+ else
619
+ @surface.commit(turn_end_hint(restore_prompt_for_retry(input)))
620
+ end
631
621
  return
632
622
  end
633
623
  finish_turn(session, result, continue: false)
@@ -645,10 +635,9 @@ module Samagotchi
645
635
  # and the partial assistant reply (marked [interrupted]) into
646
636
  # result.conversation, so progress is preserved by default — the
647
637
  # user's next message continues from it. !rollback restores the
648
- # pre-turn checkpoint for an explicit full discard.
649
- emit_interactive_turn_duration(canceled: true)
638
+ # pre-turn checkpoint for an explicit full discard (the renderer's
639
+ # hint under :turn_canceled says so).
650
640
  save_session(session) if result.respond_to?(:conversation) && result.conversation.is_a?(Array)
651
- @surface.commit("\nmodel> turn cancelled; partial progress kept in context; use !rollback immediately after cancellation to restore the pre-turn checkpoint")
652
641
  return
653
642
  end
654
643
 
@@ -681,176 +670,11 @@ module Samagotchi
681
670
  @default_model_name = @commands.default_model
682
671
  end
683
672
 
684
- def status_server_segment
685
- # Show per-host info when multi-host is configured
686
- if @host_registry && @host_registry.entries.size > 1
687
- active = @host_registry.resolve(@effective_model_name).entry rescue nil
688
- if active
689
- host = active.host
690
- port = active.port
691
- total = @host_registry.entries.size
692
- return "" if ["localhost", "127.0.0.1"].include?(host) && total == 1
693
- # When multiple hosts, always show active + count
694
- return "server=#{host}:#{port} (#{total} hosts)"
695
- end
696
- end
697
- host = Samagotchi::Config.get("server.host")
698
- return "" if ["localhost", "127.0.0.1"].include?(host)
699
-
700
- port = Samagotchi::Config.get("server.port")
701
- "server=#{host}:#{port}"
702
- end
703
-
704
- # Engine's built-once system prompt (the one run_turn sends), plus the
705
- # --memory activations it recorded for the sticky status line.
706
- def seed_system_prompt
707
- prompt = @engine.system_prompt
708
- sync_engine_activated_memories
709
- prompt
710
- end
711
-
712
- # Engine owns the system prompt (including --memory activation), but the
713
- # sticky status line is a UI concern. Mirror the activated names so they
714
- # appear in the status line.
715
- def sync_engine_activated_memories
716
- @engine.activated_memory_names.each do |name|
717
- add_unique_memory_name(:@session_memory_names, name)
718
- end
719
- end
720
-
721
- # ── Turn view: the drawing surface EventRenderer calls ──────────────────
722
- public
723
-
724
- def emit_active_memories_line
725
- lines = sticky_status_lines(width: status_effective_width)
726
- return if lines.empty?
727
-
728
- lines.each { |line| @surface.commit(line) }
729
- end
730
-
731
- def print_line(text)
732
- @surface.commit(text)
733
- end
734
-
735
- def reset_turn_feedback
736
- @spinner_lock.synchronize { @turn_tally.reset }
737
- clear_retry_spinner_status
738
- reset_thinking_memory_notification
739
- reset_thinking_memory_names
740
- reset_thinking_tool_notification
741
- end
742
-
743
- # A turn waits for plugins' slow setup (chi.init) before its first
744
- # request: the spinner says what for, until the model starts.
745
- def init_wait_feedback(event)
746
- labels = Array(event[:tasks]).map { |task| "#{task[:bundle]}: #{task[:label]}" }
747
- @spinner_lock.synchronize do
748
- @init_wait_status = "chi> waiting for #{labels.join(" · ")}..."
749
- start_thinking_spinner
750
- refresh_thinking_spinner_status
751
- end
752
- end
753
-
754
- def generation_feedback_started(event = {})
755
- @init_wait_status = nil
756
- @context_window_tokens = event[:context_window_tokens] if event[:context_window_tokens]
757
- clear_retry_spinner_status
758
- @latest_server_context_status = nil
759
- @spinner_lock.synchronize do
760
- @thinking_line.reset
761
- start_thinking_spinner
762
- end
763
- end
764
-
765
- def generation_feedback_retrying(event)
766
- @spinner_lock.synchronize do
767
- # The retry streams from the start: wait for its first token again.
768
- @thinking_waiting_since = monotonic_time if @thinking_spinner_active
769
- set_retry_spinner_status(event)
770
- refresh_thinking_spinner_status
771
- end
772
- end
773
-
774
- def generation_feedback_chunk(event)
775
- @spinner_lock.synchronize do
776
- @thinking_waiting_since = nil
777
- clear_retry_spinner_status if retry_spinner_status_active?
778
- capture_server_context_status_from_payload(event[:payload])
779
- # A new sentence shows at once (the line changes at most once a dwell).
780
- next refresh_thinking_spinner_status if @thinking_line.chunk(event)
781
-
782
- tick_thinking_spinner
783
- end
784
- end
785
-
786
- def tool_call_feedback_started(event)
787
- @spinner_lock.synchronize do
788
- @thinking_waiting_since = nil
789
- clear_retry_spinner_status
790
- @turn_tally.started(key: tally_key(event), tool: event[:tool], params: event[:params])
791
- @last_tool_call_event = event
792
- refresh_thinking_spinner_status
793
- end
794
- end
795
-
796
- # The Engine's memory list: after a memory read (read_names, right after
797
- # its tool_call_started) and at the end of a turn (preloads).
798
- def used_memories_updated(event)
799
- @spinner_lock.synchronize do
800
- Array(event[:used_memory_names]).each { |name| add_unique_memory_name(:@session_memory_names, name) }
801
- added = Array(event[:read_names]).select { |name| add_unique_memory_name(:@thinking_memory_names, name) }
802
- next if added.empty?
803
-
804
- @thinking_recent_memory_loaded = added.last
805
- capture_thinking_tool_call(@last_tool_call_event) if @last_tool_call_event
806
- refresh_thinking_spinner_status
807
- end
808
- end
809
-
810
- def tool_call_feedback_completed(event)
811
- @spinner_lock.synchronize do
812
- @turn_tally.completed(key: tally_key(event), tool: event[:tool],
813
- status: event.dig(:activity, :status), params: event.dig(:activity, :params))
814
- end
815
- end
816
-
817
- def clear_generation_retry
818
- @spinner_lock.synchronize { clear_retry_spinner_status }
819
- end
820
-
821
- def generation_feedback_finished
822
- @spinner_lock.synchronize do
823
- clear_retry_spinner_status
824
- @thinking_line.reset
825
- finish_thinking_spinner
826
- end
827
- end
828
-
829
- # The kernel reports the last emitted context status on the result; keep
830
- # the previous one when a turn reports none.
831
- def capture_context_status(status)
832
- return unless status
833
-
834
- @latest_context_status = {
835
- est_pct: status[:est_pct],
836
- bucket: status[:bucket]
837
- }
838
- end
839
-
840
- private
841
-
842
- # The last turn's line: completed, canceled or failed, as the Engine
843
- # closed it (a provider error closes it with :turn_failed).
844
- def emit_interactive_turn_duration(canceled:)
845
- record = Array(@engine.metrics.snapshot[:turn_records]).last
846
- return unless record && record[:duration_ms]
847
-
848
- state = record[:status] == "failed" ? "failed" : (canceled ? "canceled" : "completed")
849
- @surface.commit("#{paint('chi>', 36)} turn #{state} (#{format_elapsed_duration(record[:duration_ms])})")
850
- end
673
+ # Engine's built-once system prompt (the one run_turn sends).
674
+ def seed_system_prompt = @engine.system_prompt
851
675
 
852
676
  def read_input(awaiting_continue:)
853
- emit_idle_status_line
677
+ refresh_status_row
854
678
 
855
679
  if awaiting_continue
856
680
  sync_continue_slot(true)
@@ -879,20 +703,19 @@ module Samagotchi
879
703
  sync_continue_slot(false)
880
704
  @surface.commit(QuestionSlot.continue_summary("(dropped: a reminder ran)", paint: method(:paint)))
881
705
  end
706
+ @surface.commit("reminder: #{names.join(", ")}")
882
707
  hint = "reminder: #{names.join(", ")} · Ctrl-C cancels it"
883
708
  @surface.set_slot(:hints, [color_output? ? paint(hint, 90) : hint])
884
709
  result = nil
885
710
  begin
886
711
  result = run_engine_turn(session, nil, continue: true)
887
712
  @prompt = nil
888
- rescue LLM::ProviderError => e
889
- # like other failed turns: back to the prompt
890
- @surface.commit("\nmodel> #{e.summary}")
713
+ rescue LLM::ProviderError
714
+ nil # the renderer showed the failure; back to the prompt
891
715
  ensure
892
716
  @surface.clear_slot(:hints)
893
717
  end
894
718
  canceled = result.respond_to?(:canceled?) && result.canceled?
895
- emit_interactive_turn_duration(canceled: canceled)
896
719
  if result && !canceled
897
720
  session.messages = result.conversation if result.respond_to?(:conversation) && result.conversation.is_a?(Array)
898
721
  session.model_name = @effective_model_name
@@ -918,7 +741,7 @@ module Samagotchi
918
741
 
919
742
  if @idle_status_due
920
743
  @idle_status_due = false
921
- emit_idle_status_line
744
+ refresh_status_row
922
745
  end
923
746
  sync_continue_slot(awaiting_continue)
924
747
  @repl_input.sync_prompt
@@ -941,7 +764,7 @@ module Samagotchi
941
764
  return unless STDIN.tty? && $stdin.tty?
942
765
 
943
766
  @idle_status_due = false
944
- emit_idle_status_line
767
+ refresh_status_row
945
768
  @repl_input = ReplInput.new(prompt: method(:repl_prompt_text), read: method(:read_repl_line), surface: @surface).start
946
769
  end
947
770
 
@@ -982,6 +805,10 @@ module Samagotchi
982
805
  prompt == paint(QUESTION_PROMPT, 33) ? RelineSeam.without_echo(&read) : read.call
983
806
  end
984
807
 
808
+ # Lines come from a pipe or a file (`chi -p X </dev/null`, `echo … | chi`),
809
+ # not a terminal: no prompt is restored for a retry.
810
+ def piped_input? = !$stdin.tty?
811
+
985
812
  # A failed prompt goes back into the input for a retry.
986
813
  # @return [String] what the failed-turn line says about it
987
814
  def restore_prompt_for_retry(input)
@@ -1033,12 +860,11 @@ module Samagotchi
1033
860
  cancellation_controller = CancellationController.new
1034
861
  @active_cancel_controller = cancellation_controller
1035
862
  @renderer.begin_turn
1036
- # Build the (memoized) prompt now so --memory activations show in this
1037
- # turn's status lines, including after /model rebuilt it.
1038
- seed_system_prompt
863
+ # The row shows the turn's model (a -p turn runs before any read).
864
+ refresh_status_row
1039
865
  # A prompt the user typed brings an archived session back to the lists.
1040
866
  ArchiveStore.user_input(session.id, state_dir: Session.default_state_dir) if prompt && !continue
1041
- result = with_steering do
867
+ with_steering do
1042
868
  @engine.run_turn(
1043
869
  session,
1044
870
  prompt,
@@ -1050,17 +876,14 @@ module Samagotchi
1050
876
  images: images
1051
877
  )
1052
878
  end
1053
- emit_cancellation_notice(result)
1054
- result
1055
879
  rescue Interrupt
1056
- # Engine kept the prompt in the session and emitted :turn_canceled.
880
+ # Engine kept the prompt in the session and emitted :turn_canceled
881
+ # (the renderer's line).
1057
882
  cancellation_controller&.cancel!(:ctrl_c)
1058
- result = cancelled_result_from(session.messages, reason: :ctrl_c)
1059
- emit_cancellation_notice(result)
1060
- result
883
+ cancelled_result_from(session.messages, reason: :ctrl_c)
1061
884
  ensure
1062
885
  @active_cancel_controller = nil
1063
- finish_thinking_spinner
886
+ @view.finish_thinking_spinner
1064
887
  end
1065
888
 
1066
889
  # While a turn runs, a line submitted at the open prompt steers it: it
@@ -1152,22 +975,23 @@ module Samagotchi
1152
975
  end
1153
976
  end
1154
977
 
1155
- # The REPL's on_event sink for Engine#run_turn: render one event. Engine
1156
- # swallows on_event errors to protect the turn, so log ours instead.
978
+ # The REPL's on_event sink for Engine#run_turn: render one event, after
979
+ # taking what the status row shows from it (as AttachedLoop does from the
980
+ # Bridge's). Engine swallows on_event errors to protect the turn, so log
981
+ # ours instead.
1157
982
  def handle_stream_event(event)
983
+ case event[:type]
984
+ when :context_status
985
+ @status_row.update(context: { est_pct: event.dig(:usage, :estimated_pct), bucket: event[:bucket] })
986
+ when :used_memories_updated then @status_row.update(used_memories: Array(event[:used_memory_names]))
987
+ when :generation_completed
988
+ @status_row.update(served: [event[:served_model], event[:requested_model]]) if event[:served_model]
989
+ end
1158
990
  @renderer.call(event)
1159
991
  rescue StandardError => e
1160
992
  Log.error(:repl, "render_failed", echo: "[render] #{event[:type]}: #{e.class}: #{e.message}", event_type: event[:type].to_s, error: e.class.name)
1161
993
  end
1162
994
 
1163
- def emit_cancellation_notice(result)
1164
- return unless result.respond_to?(:canceled?) && result.canceled?
1165
-
1166
- reason = result.respond_to?(:cancellation_reason) ? result.cancellation_reason : nil
1167
- label = cancellation_reason_label(reason)
1168
- @surface.commit("\nmodel> request cancelled#{label.empty? ? "" : " (#{label})"}")
1169
- end
1170
-
1171
995
  def cancelled_result_from(messages, reason:)
1172
996
  KernelLoop::Result.new(
1173
997
  output: "",
@@ -1180,219 +1004,16 @@ module Samagotchi
1180
1004
  )
1181
1005
  end
1182
1006
 
1183
- def cancellation_reason_label(reason)
1184
- return "" if reason.nil?
1185
-
1186
- case reason.to_sym
1187
- when :ctrl_c
1188
- "ctrl-c"
1189
- else
1190
- reason.to_s
1191
- end
1192
- end
1193
-
1194
- def start_thinking_spinner
1195
- return unless thinking_spinner_enabled?
1196
-
1197
- @thinking_spinner_active = true
1198
- @thinking_spinner_index = 0 if @thinking_spinner_index.nil?
1199
- @thinking_spinner_last_render_at = nil
1200
- @thinking_waiting_since = monotonic_time
1201
- render_thinking_spinner
1202
- start_thinking_ticker
1203
- end
1204
-
1205
- # One thread per spinner; it ends when the spinner does.
1206
- def start_thinking_ticker
1207
- return unless @spinner_tick_interval
1208
- return if @thinking_ticker&.alive?
1209
-
1210
- @thinking_ticker = Thread.new do
1211
- loop do
1212
- sleep(@spinner_tick_interval)
1213
- break unless tick_thinking_spinner_on_timer
1214
- end
1215
- rescue StandardError
1216
- nil
1217
- end
1218
- @thinking_ticker.report_on_exception = false
1219
- end
1220
-
1221
- # Turn the spinner when no chunk did for a tick.
1222
- # @return [Boolean] whether the spinner is still shown
1223
- def tick_thinking_spinner_on_timer
1224
- @spinner_lock.synchronize do
1225
- return false unless @thinking_spinner_active
1226
-
1227
- last = @thinking_spinner_last_render_at
1228
- if last.nil? || (monotonic_time - last) >= @spinner_tick_interval
1229
- @thinking_spinner_index = (@thinking_spinner_index + 1) % THINKING_SPINNER_FRAMES.length
1230
- render_thinking_spinner
1231
- end
1232
- true
1233
- end
1234
- end
1235
-
1236
- def tick_thinking_spinner
1237
- return unless @thinking_spinner_active
1238
-
1239
- @thinking_spinner_index = (@thinking_spinner_index + 1) % THINKING_SPINNER_FRAMES.length
1240
- render_thinking_spinner_if_due
1241
- end
1242
-
1243
- def refresh_thinking_spinner_status
1244
- return unless @thinking_spinner_active
1245
-
1246
- render_thinking_spinner
1247
- end
1248
-
1249
- def render_thinking_spinner_if_due
1250
- last = @thinking_spinner_last_render_at
1251
- return render_thinking_spinner if last.nil?
1252
- return if (monotonic_time - last) < thinking_render_min_interval
1253
-
1254
- render_thinking_spinner
1255
- end
1256
-
1257
- public
1258
-
1259
- # Erase the spinner rows; nothing to do when none are shown.
1260
- def finish_thinking_spinner
1261
- @spinner_lock.synchronize do
1262
- # Off even when the rows are gone already, so the ticker ends.
1263
- @thinking_spinner_active = false
1264
- @init_wait_status = nil
1265
- @thinking_waiting_since = nil
1266
- next unless @surface.clear_slot(:activity)
1267
-
1268
- @thinking_spinner_last_render_at = nil
1269
- end
1270
- end
1271
-
1272
- private
1273
-
1274
- def retry_spinner_status_line(frame, width)
1275
- data = @retry_spinner_status || {}
1276
- attempt = data[:attempt].to_i
1277
- max_retries = data[:max_retries].to_i
1278
- total_attempts = max_retries + 1
1279
- delay = format("%.1f", data[:next_delay].to_f)
1280
- error_class = data[:error_class].to_s
1281
- message = "model> network error: retrying (#{attempt}/#{total_attempts} in #{delay}s) #{frame}"
1282
- message += " #{error_class}" unless error_class.empty?
1283
- capped = cap_preview_line(message, width)
1284
- color_output? ? paint(capped, NETWORK_RETRY_SPINNER_COLOR) : capped
1285
- end
1286
-
1287
- def cap_preview_line(text, width)
1288
- cap_preview_text(text, thinking_preview_width(width))
1289
- end
1290
-
1291
- def thinking_preview_width(width)
1292
- return THINKING_PREVIEW_WIDTH if width <= 0
1293
-
1294
- [width, THINKING_PREVIEW_WIDTH].min
1295
- end
1296
-
1297
-
1298
- def thinking_render_min_interval
1299
- value = ENV.fetch(THINKING_RENDER_INTERVAL_ENV, THINKING_RENDER_MIN_INTERVAL.to_s).to_f
1300
- return THINKING_RENDER_MIN_INTERVAL unless value.positive?
1301
-
1302
- value
1303
- end
1304
-
1305
- def monotonic_time
1306
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
1307
- end
1308
-
1309
- def capture_thinking_tool_call(event)
1310
- call = event[:call].is_a?(Hash) ? event[:call] : {}
1311
- name = call[:name].to_s.strip
1312
- return if name.empty?
1313
-
1314
- params = thinking_tool_params_preview(call, event[:params])
1315
- text = params.empty? ? name : "#{name}(#{params})"
1316
- @thinking_recent_tool_call = cap_preview_text(text, THINKING_TOOL_PREVIEW_LIMIT)
1317
- end
1318
-
1319
- def thinking_tool_params_preview(call, raw_params)
1320
- compact = raw_params.to_s.gsub(/\s+/, " ").strip
1321
- return compact unless compact.empty?
1322
-
1323
- tool_name = call[:name].to_s
1324
- case tool_name
1325
- when Tools::Execute::NAME
1326
- "command=#{preview_value_for_spinner(call[:content])}"
1327
- when Tools::Read::NAME
1328
- "path=#{preview_value_for_spinner(call[:content])}"
1329
- when Tools::Write::NAME, Tools::Edit::NAME
1330
- "path=#{preview_value_for_spinner(call[:path])}"
1331
- when Tools::MemoryRead::NAME
1332
- parts = []
1333
- name = call[:content].to_s.strip
1334
- parts << "name=#{preview_value_for_spinner(name)}" unless name.empty?
1335
- scope = call[:scope].to_s.strip
1336
- parts << "scope=#{preview_value_for_spinner(scope)}" unless scope.empty?
1337
- parts.join(" ")
1338
- when Tools::MemoryWrite::NAME
1339
- parts = []
1340
- path = call[:path].to_s.strip
1341
- parts << "name=#{preview_value_for_spinner(path)}" unless path.empty?
1342
- scope = call[:scope].to_s.strip
1343
- parts << "scope=#{preview_value_for_spinner(scope)}" unless scope.empty?
1344
- parts.join(" ")
1345
- else
1346
- ""
1347
- end
1348
- end
1349
-
1350
- def preview_value_for_spinner(value)
1351
- text = value.to_s.gsub(/\s+/, " ").strip
1352
- return '""' if text.empty?
1353
-
1354
- text.inspect
1355
- end
1356
-
1357
- def add_unique_memory_name(ivar_name, value)
1358
- names = instance_variable_get(ivar_name) || []
1359
- return false if names.include?(value)
1360
-
1361
- names << value
1362
- instance_variable_set(ivar_name, names)
1363
- true
1364
- end
1365
-
1366
- def reset_thinking_memory_names
1367
- @thinking_memory_names = []
1368
- end
1369
-
1370
- def reset_thinking_memory_notification
1371
- @thinking_recent_memory_loaded = nil
1372
- end
1373
-
1374
- def reset_thinking_tool_notification
1375
- @thinking_recent_tool_call = nil
1376
- end
1377
-
1378
- def capture_server_context_status_from_payload(payload)
1379
- # The window the kernel resolved for this generation (see
1380
- # :generation_started); the configured one before the first generation.
1381
- window_tokens = @context_window_tokens || ContextWindow.configured.tokens
1382
- normalized = ContextUsage.normalize(payload, window_tokens: window_tokens)
1383
- return unless normalized
1384
-
1385
- @latest_server_context_status = normalized
1386
- end
1387
-
1388
-
1389
- def emit_idle_status_line
1390
- return unless status_line_enabled?
1391
-
1392
- lines = idle_status_lines(width: status_effective_width)
1393
- return if lines.empty?
1394
-
1395
- @surface.set_slot(:status, lines)
1007
+ # The status row under the prompt, from the Engine's state (what a
1008
+ # worker hands an attached TUI when it joins): drawn when it changed.
1009
+ def refresh_status_row
1010
+ state = @engine.session_state_snapshot
1011
+ served = state[:served_model] ? [state[:served_model], state[:served_model_for]] : nil
1012
+ fields = { model: @effective_model_name, default_model: @default_model_name, served: served,
1013
+ parent_id: state[:parent_id], used_memories: Array(state[:used_memory_names]),
1014
+ preloaded: Array(state[:preloaded_memory_names]), muted: Array(state[:muted_memory_names]) }
1015
+ fields[:context] = state[:context_status] if state[:context_status].is_a?(Hash)
1016
+ @status_row.update(**fields)
1396
1017
  end
1397
1018
 
1398
1019
  # ── Idle session recap ───────────────────────────────────────────────────
@@ -1429,13 +1050,17 @@ module Samagotchi
1429
1050
  # main thread (the command runs at the prompt) or beside a running turn
1430
1051
  # (above the live region), else at the next flush.
1431
1052
  #
1432
- # A plugin's init task (chi.init) prints a line as it starts and one
1433
- # when it is done; a load warning announced before the first turn, one.
1434
- # Beside a running turn they print at once; a --non-interactive run
1435
- # prints only its answer.
1053
+ # A plugin's init task (chi.init) turns the activity row while it runs
1054
+ # (between turns too, as in attached mode) and prints a line when it is
1055
+ # done; a load warning announced before the first turn, one. Beside a
1056
+ # running turn they print at once; a --non-interactive run prints only
1057
+ # its answer.
1436
1058
  def handle_card_event(event)
1437
1059
  if INIT_EVENTS.include?(event[:type])
1438
1060
  return if @non_interactive
1061
+ return @view.init_started(event) if event[:type] == :plugin_init_started
1062
+
1063
+ @view.init_finished(event) if event[:type] == :plugin_init_finished
1439
1064
  return show_pending_item(event) if @engine.turn_running?
1440
1065
 
1441
1066
  return @pending_cards << event
@@ -1463,13 +1088,13 @@ module Samagotchi
1463
1088
  INIT_EVENTS = %i[plugin_init_started plugin_init_finished guardrail_warning].freeze
1464
1089
 
1465
1090
  # A card, a notice, an anytime command's output (:command_output), a
1466
- # plugin init task's line or a load warning.
1091
+ # plugin init task's done line or a load warning.
1467
1092
  def show_pending_item(item)
1468
1093
  case item[:type]
1469
1094
  when :card then @renderer.render_card(item)
1470
1095
  when :command_output then @surface.commit(item[:text])
1471
1096
  when :guardrail_warning then @surface.commit(EventRenderer.load_warning_line(item))
1472
- when :plugin_init_started, :plugin_init_finished
1097
+ when :plugin_init_finished
1473
1098
  line = EventRenderer.init_line(item)
1474
1099
  @surface.commit(line) if line
1475
1100
  else @surface.commit(EventRenderer.hook_notice_line(item))
@@ -1484,158 +1109,6 @@ module Samagotchi
1484
1109
  ConfigFile.recap_config
1485
1110
  end
1486
1111
 
1487
- def spinner_status_lines(width: status_effective_width)
1488
- return [] unless status_line_enabled?
1489
-
1490
- build_status_lines(scope: :spinner, width: width)
1491
- end
1492
-
1493
- def sticky_status_lines(width: status_effective_width)
1494
- return [] unless status_line_enabled?
1495
-
1496
- build_status_lines(scope: :sticky, width: width)
1497
- end
1498
-
1499
- def idle_status_lines(width: status_effective_width)
1500
- return [] unless status_line_enabled?
1501
-
1502
- build_status_lines(scope: :idle, width: width)
1503
- end
1504
-
1505
- # The status rows for +scope+ (:spinner, :sticky or :idle), cut to +width+.
1506
- def build_status_lines(scope:, width: status_effective_width)
1507
- status_rows(status_segments(scope), width)
1508
- end
1509
-
1510
-
1511
- def status_width_mode
1512
- mode = ENV.fetch(STATUS_WIDTH_MODE_ENV, STATUS_WIDTH_MODE_TERMINAL_CAP).to_s.strip.downcase
1513
- return STATUS_WIDTH_MODE_FIXED if mode == STATUS_WIDTH_MODE_FIXED
1514
-
1515
- STATUS_WIDTH_MODE_TERMINAL_CAP
1516
- end
1517
-
1518
- def status_effective_width
1519
- mode = status_width_mode
1520
- width = if mode == STATUS_WIDTH_MODE_FIXED
1521
- status_fixed_width
1522
- else
1523
- [terminal_columns, status_max_width].min
1524
- end
1525
- width = status_fixed_width unless width.positive?
1526
- width
1527
- end
1528
-
1529
- def status_fixed_width
1530
- env_positive_int(STATUS_FIXED_WIDTH_ENV, THINKING_PREVIEW_WIDTH)
1531
- end
1532
-
1533
- def status_max_width
1534
- env_positive_int(STATUS_MAX_WIDTH_ENV, STATUS_MAX_WIDTH_DEFAULT)
1535
- end
1536
-
1537
- def terminal_columns
1538
- columns = begin
1539
- io = IO.console
1540
- io&.winsize&.[](1).to_i
1541
- rescue StandardError
1542
- 0
1543
- end
1544
- return columns if columns.positive?
1545
-
1546
- env_positive_int("COLUMNS", status_max_width)
1547
- end
1548
-
1549
- def env_positive_int(key, default)
1550
- value = ENV.fetch(key, default.to_s).to_i
1551
- value.positive? ? value : default
1552
- end
1553
-
1554
- def status_segments(scope)
1555
- segments = [status_model_segment, status_server_segment].reject(&:empty?)
1556
- context_segment = status_context_segment
1557
- memory_segment = status_memory_segment(scope)
1558
- segments << context_segment unless context_segment.empty?
1559
- segments << memory_segment unless memory_segment.empty?
1560
- # The session's --mute list, on the sticky and idle rows (not the spinner).
1561
- muted_segment = scope == :spinner ? "" : status_memory_text(@engine.muted_memory_names, MEMORY_STICKY_PREVIEW_LIMIT, label: "muted")
1562
- segments << muted_segment unless muted_segment.empty?
1563
- segments
1564
- end
1565
-
1566
- def status_model_segment
1567
- served, served_for = @engine.respond_to?(:served_model) ? @engine.served_model(probe: false) : nil
1568
- status_model_text(@effective_model_name, @default_model_name, served: served, served_for: served_for)
1569
- end
1570
-
1571
- def status_context_segment
1572
- status_context_text(server: @latest_server_context_status, estimate: @latest_context_status)
1573
- end
1574
-
1575
- def status_memory_segment(scope)
1576
- names, limit = case scope
1577
- when :spinner
1578
- [Array(@thinking_memory_names), MEMORY_SPINNER_PREVIEW_LIMIT]
1579
- else
1580
- [Array(@session_memory_names), MEMORY_STICKY_PREVIEW_LIMIT]
1581
- end
1582
- status_memory_text(names, limit)
1583
- end
1584
-
1585
- def thinking_memory_notification_suffix
1586
- memory_name = @thinking_recent_memory_loaded.to_s.strip
1587
- return "" if memory_name.empty?
1588
-
1589
- " memory_loaded: #{memory_name}"
1590
- end
1591
-
1592
- def thinking_tool_notification_suffix
1593
- tool_call = @thinking_recent_tool_call.to_s.strip
1594
- return "" if tool_call.empty?
1595
-
1596
- " last_tool: #{tool_call}"
1597
- end
1598
-
1599
- def thinking_notification_segments(width)
1600
- return ["", ""] if width <= 0
1601
-
1602
- memory_suffix = cap_preview_text(thinking_memory_notification_suffix, width)
1603
- remaining = [width - memory_suffix.length, 0].max
1604
- tool_suffix = cap_preview_text(thinking_tool_notification_suffix, remaining)
1605
- [memory_suffix, tool_suffix]
1606
- end
1607
-
1608
- def paint_if_present(text, code)
1609
- return "" if text.to_s.empty?
1610
-
1611
- paint(text, code)
1612
- end
1613
-
1614
- def thinking_spinner_enabled?
1615
- return false unless $stdout.tty?
1616
-
1617
- mode = ENV.fetch(THINKING_UI_ENV, THINKING_UI_SPINNER).to_s.strip.downcase
1618
- return false if mode.empty? || mode == THINKING_UI_OFF || mode == "false" || mode == "0"
1619
-
1620
- mode == THINKING_UI_SPINNER && ENV.fetch("TERM", "") != "dumb"
1621
- end
1622
-
1623
- def set_retry_spinner_status(event)
1624
- @retry_spinner_status = {
1625
- attempt: event[:attempt],
1626
- max_retries: event[:max_retries],
1627
- next_delay: event[:next_delay],
1628
- error_class: event[:error_class]
1629
- }
1630
- end
1631
-
1632
- def clear_retry_spinner_status
1633
- @retry_spinner_status = nil
1634
- end
1635
-
1636
- def retry_spinner_status_active?
1637
- @retry_spinner_status.is_a?(Hash)
1638
- end
1639
1112
 
1640
1113
  # Reset the Engine's shared inactivity clock when a prompt opens (a
1641
1114
  # Reline.pre_input_hook) and on each key typed (RelineSeam.key_handler). This is the
@@ -1657,74 +1130,6 @@ module Samagotchi
1657
1130
  RelineSeam.key_handler = previous_key_handler
1658
1131
  end
1659
1132
 
1660
- def render_thinking_spinner
1661
- frame = THINKING_SPINNER_FRAMES[@thinking_spinner_index % THINKING_SPINNER_FRAMES.length]
1662
- width = status_effective_width
1663
- @thinking_line.tick
1664
- # The spinner goes above the prompt, the status rows below it.
1665
- @surface.set_slots(activity: thinking_spinner_status_lines(frame, width: width), status: spinner_status_lines(width: width))
1666
- @thinking_spinner_last_render_at = monotonic_time
1667
- end
1668
-
1669
- # "model> waiting for the first token... 5s |" once a generation has
1670
- # shown nothing for THINKING_WAIT_NOTICE_AFTER seconds, else nil.
1671
- def first_token_wait_status(frame)
1672
- return nil unless @thinking_waiting_since
1673
-
1674
- waited = monotonic_time - @thinking_waiting_since
1675
- return nil if waited < THINKING_WAIT_NOTICE_AFTER
1676
-
1677
- "model> waiting for the first token... #{waited.floor}s #{frame}"
1678
- end
1679
-
1680
- # The spinner row, then (from a turn's 3rd tool call) its tool tally.
1681
- # The spinner stops while tools run, so the tally shows while the model
1682
- # generates between tool rounds.
1683
- def thinking_spinner_status_lines(frame, width: status_effective_width)
1684
- spinner_row_lines(frame, width: width) + tally_status_lines(width)
1685
- end
1686
-
1687
- def tally_status_lines(width)
1688
- tally = @turn_tally.text(width: width)
1689
- return [] unless tally
1690
-
1691
- [color_output? ? paint(tally, 90) : tally]
1692
- end
1693
-
1694
- def tally_key(event)
1695
- [event[:iteration].to_i, event[:call_index].to_i]
1696
- end
1697
-
1698
- def spinner_row_lines(frame, width:)
1699
- if retry_spinner_status_active?
1700
- return [retry_spinner_status_line(frame, width)]
1701
- end
1702
-
1703
- return ["#{@init_wait_status} #{frame}"[0, width]] if @init_wait_status
1704
-
1705
- base = first_token_wait_status(frame) || "model> thinking... #{frame}"
1706
- available_for_notification = [width - base.length, 0].max
1707
- memory_notification, tool_notification = thinking_notification_segments(available_for_notification)
1708
- notification = "#{memory_notification}#{tool_notification}"
1709
- base = thinking_sentence_base(frame, width - notification.length) || base
1710
-
1711
- return ["#{base}#{notification}"] unless color_output?
1712
-
1713
- ["#{paint(base, 90)}#{paint_if_present(memory_notification, MEMORY_SPINNER_COLOR)}#{paint_if_present(tool_notification, TOOL_SPINNER_COLOR)}"]
1714
- end
1715
-
1716
- # "model> thinking · <the newest sentence> |" within +room+ columns (the
1717
- # web's thinking ticker, ThinkingLine), or nil before the first sentence.
1718
- def thinking_sentence_base(frame, room)
1719
- return nil if @thinking_waiting_since || @thinking_line.empty?
1720
-
1721
- prefix = "model> #{@thinking_line.label} · "
1722
- room -= prefix.length + frame.length + 1
1723
- return nil if room < MIN_SENTENCE_ROOM
1724
-
1725
- "#{prefix}#{@thinking_line.fit(room)} #{frame}"
1726
- end
1727
-
1728
1133
  # ── Ask-user-question adapter (generic TUI renderer) ─────────────────────
1729
1134
 
1730
1135
  # Non-blocking observer: the turn thread emits :question_requested; we stash
@@ -1783,8 +1188,8 @@ module Samagotchi
1783
1188
  def render_question_widget(pending)
1784
1189
  prompt = QuestionPrompt.new(pending)
1785
1190
 
1786
- # Ensure spinner cleared and terminal in known state (same as reminder mute handling)
1787
- finish_thinking_spinner rescue nil
1191
+ # The activity row goes while the question waits.
1192
+ @view.finish_thinking_spinner
1788
1193
  # An edit's diff goes above the slot, into the scrollback.
1789
1194
  preview = prompt.preview_lines(paint: method(:paint))
1790
1195
  @surface.commit(preview.join("\n")) unless preview.empty?