samagotchi 0.3.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 (121) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +162 -1
  3. data/README.md +29 -2
  4. data/bin/chi +60 -69
  5. data/docs/cli.md +211 -77
  6. data/docs/configuration.md +118 -21
  7. data/docs/desktop.md +39 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +89 -7
  10. data/docs/memory.md +40 -0
  11. data/docs/plugins.md +50 -0
  12. data/docs/releasing.md +15 -12
  13. data/docs/sessions.md +20 -18
  14. data/lib/samagotchi/bootstrap/config_writer.rb +1 -2
  15. data/lib/samagotchi/bridge/sse_writer.rb +0 -3
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
  17. data/lib/samagotchi/bridge.rb +20 -12
  18. data/lib/samagotchi/bundles/skills/manifest.yml +10 -0
  19. data/lib/samagotchi/bundles/skills/plugin.rb +419 -0
  20. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +178 -5
  21. data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
  22. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  23. data/lib/samagotchi/bundles/system/config_modification_protocol.md +9 -10
  24. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  25. data/lib/samagotchi/bundles/system/identity.md +5 -0
  26. data/lib/samagotchi/bundles/system/manifest.yml +6 -6
  27. data/lib/samagotchi/bundles/system/memory_guide.md +26 -0
  28. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  29. data/lib/samagotchi/client.rb +25 -26
  30. data/lib/samagotchi/commands/registry.rb +8 -0
  31. data/lib/samagotchi/config.rb +97 -113
  32. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  33. data/lib/samagotchi/desktop/macos/ChiRunner.swift +4 -2
  34. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  35. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  36. data/lib/samagotchi/desktop/macos/Panel.swift +112 -9
  37. data/lib/samagotchi/desktop/macos.rb +59 -8
  38. data/lib/samagotchi/desktop_command.rb +6 -3
  39. data/lib/samagotchi/edit_preview.rb +82 -0
  40. data/lib/samagotchi/engine.rb +236 -443
  41. data/lib/samagotchi/gem_update.rb +89 -0
  42. data/lib/samagotchi/guardrails/approval.rb +26 -4
  43. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  44. data/lib/samagotchi/host_registry.rb +8 -12
  45. data/lib/samagotchi/idle_client.rb +24 -15
  46. data/lib/samagotchi/idle_reminders.rb +2 -2
  47. data/lib/samagotchi/image_store.rb +10 -6
  48. data/lib/samagotchi/kernel_loop.rb +59 -123
  49. data/lib/samagotchi/live_versions.rb +65 -0
  50. data/lib/samagotchi/llm/api_key.rb +41 -0
  51. data/lib/samagotchi/llm/chat_loop.rb +77 -13
  52. data/lib/samagotchi/llm/errors.rb +38 -7
  53. data/lib/samagotchi/llm/http.rb +19 -22
  54. data/lib/samagotchi/llm/openai_chat.rb +22 -26
  55. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  56. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  57. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  58. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  59. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  60. data/lib/samagotchi/model_profile.rb +27 -10
  61. data/lib/samagotchi/note_command.rb +2 -1
  62. data/lib/samagotchi/prompt.rb +4 -2
  63. data/lib/samagotchi/reminder_store.rb +1 -9
  64. data/lib/samagotchi/reply_wait.rb +48 -4
  65. data/lib/samagotchi/self_report.rb +37 -5
  66. data/lib/samagotchi/send_command.rb +190 -17
  67. data/lib/samagotchi/session.rb +4 -2
  68. data/lib/samagotchi/session_commands.rb +38 -8
  69. data/lib/samagotchi/session_manager.rb +19 -53
  70. data/lib/samagotchi/system_prompt.rb +403 -0
  71. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -3
  72. data/lib/samagotchi/terminal_ui/attached_loop.rb +141 -108
  73. data/lib/samagotchi/terminal_ui/attached_view.rb +27 -12
  74. data/lib/samagotchi/terminal_ui/event_renderer.rb +29 -8
  75. data/lib/samagotchi/terminal_ui/formatting.rb +41 -22
  76. data/lib/samagotchi/terminal_ui/input_support.rb +7 -23
  77. data/lib/samagotchi/terminal_ui/plain_surface.rb +13 -7
  78. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  79. data/lib/samagotchi/terminal_ui/status_row.rb +81 -0
  80. data/lib/samagotchi/terminal_ui/surface.rb +1 -1
  81. data/lib/samagotchi/terminal_ui.rb +142 -923
  82. data/lib/samagotchi/text_diff.rb +181 -0
  83. data/lib/samagotchi/thinking.rb +126 -0
  84. data/lib/samagotchi/tool_activity.rb +52 -2
  85. data/lib/samagotchi/tool_runner.rb +37 -1
  86. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  87. data/lib/samagotchi/tools/edit.rb +23 -9
  88. data/lib/samagotchi/tools/execute.rb +3 -3
  89. data/lib/samagotchi/tools/output_guardrails.rb +8 -7
  90. data/lib/samagotchi/tools/read.rb +4 -4
  91. data/lib/samagotchi/tools/write.rb +4 -0
  92. data/lib/samagotchi/turn_flow.rb +12 -2
  93. data/lib/samagotchi/update_command.rb +309 -0
  94. data/lib/samagotchi/update_hint.rb +59 -0
  95. data/lib/samagotchi/version.rb +1 -1
  96. data/lib/samagotchi/vision_support.rb +6 -4
  97. data/lib/samagotchi/web/app.rb +173 -38
  98. data/lib/samagotchi/web/lan.rb +99 -0
  99. data/lib/samagotchi/web/message_parts.rb +19 -10
  100. data/lib/samagotchi/web/public/activity.js +10 -0
  101. data/lib/samagotchi/web/public/app.js +135 -78
  102. data/lib/samagotchi/web/public/chat_view.js +8 -1
  103. data/lib/samagotchi/web/public/data.js +2 -0
  104. data/lib/samagotchi/web/public/diff_view.js +58 -0
  105. data/lib/samagotchi/web/public/index.html +185 -18
  106. data/lib/samagotchi/web/public/model_pick.js +136 -0
  107. data/lib/samagotchi/web/public/model_picker.js +224 -0
  108. data/lib/samagotchi/web/public/notify.js +10 -0
  109. data/lib/samagotchi/web/public/question_card.js +3 -1
  110. data/lib/samagotchi/web/public/stage_model.js +110 -0
  111. data/lib/samagotchi/web/public/stage_view.js +580 -0
  112. data/lib/samagotchi/web/public/timing.js +6 -2
  113. data/lib/samagotchi/web/public/turn_events.js +38 -10
  114. data/lib/samagotchi/web/public/turn_model.js +11 -3
  115. data/lib/samagotchi/web/public/turn_view.js +76 -20
  116. data/lib/samagotchi/web/qr.rb +40 -0
  117. data/lib/samagotchi/web/server.rb +101 -11
  118. data/lib/samagotchi/web/token.rb +97 -0
  119. data/lib/samagotchi/worker.rb +5 -4
  120. metadata +38 -3
  121. 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
@@ -47,47 +44,10 @@ module Samagotchi
47
44
  include Formatting
48
45
  include InputSupport
49
46
 
50
- AGENT_DESCRIPTION_FILE = "AGENT.md"
51
- SKIP_AGENT_DESCRIPTION_ENV = "SAMAGOTCHI_SKIP_AGENT_MD"
52
- STATS_COMMAND = "/stats"
53
- # /exit --delete: delete the session on the way out.
54
- EXIT_DELETE_FLAG = "--delete"
55
- # Leave, then archive the session (hidden from the lists, kept for good).
56
- ARCHIVE_COMMAND = "/archive"
57
47
  SCRATCH_ARCHIVE_REFUSED = "a scratch session is deleted when you leave; nothing to archive"
58
- RECAP_COMMAND = "/recap"
59
48
  # The prompt while a question waits for its answer (its choices are in
60
49
  # the notes slot).
61
50
  QUESTION_PROMPT = "? "
62
- THINKING_UI_ENV = "SAMAGOTCHI_THINKING_UI"
63
- THINKING_UI_SPINNER = "spinner"
64
- THINKING_UI_OFF = "off"
65
- THINKING_SPINNER_FRAMES = ["|", "/", "-", "\\"].freeze
66
- MEMORY_SPINNER_COLOR = "38;5;208"
67
- TOOL_SPINNER_COLOR = 32
68
- NETWORK_RETRY_SPINNER_COLOR = 31
69
- MEMORY_SPINNER_PREVIEW_LIMIT = 3
70
- MEMORY_STICKY_PREVIEW_LIMIT = 8
71
- THINKING_PREVIEW_WIDTH = 120
72
- # Columns a sentence needs on the spinner row; with less, it reads "thinking...".
73
- MIN_SENTENCE_ROOM = 12
74
- THINKING_TOOL_PREVIEW_LIMIT = 56
75
- THINKING_RENDER_MIN_INTERVAL = 0.08
76
- # The spinner also turns with time: a ticker redraws it when no chunk
77
- # did for this long, and after THINKING_WAIT_NOTICE_AFTER seconds with no
78
- # chunk it says how long the first token has taken.
79
- THINKING_TICK_INTERVAL = 0.25
80
- THINKING_WAIT_NOTICE_AFTER = 2.0
81
- THINKING_RENDER_INTERVAL_ENV = "SAMAGOTCHI_THINKING_RENDER_INTERVAL"
82
- STATUS_LINE_ENV = "SAMAGOTCHI_STATUS_LINE"
83
- STATUS_LINE_ON = "on"
84
- STATUS_LINE_OFF = "off"
85
- STATUS_WIDTH_MODE_ENV = "SAMAGOTCHI_STATUS_WIDTH_MODE"
86
- STATUS_WIDTH_MODE_TERMINAL_CAP = "terminal_cap"
87
- STATUS_WIDTH_MODE_FIXED = "fixed"
88
- STATUS_FIXED_WIDTH_ENV = "SAMAGOTCHI_STATUS_FIXED_WIDTH"
89
- STATUS_MAX_WIDTH_ENV = "SAMAGOTCHI_STATUS_MAX_WIDTH"
90
- STATUS_MAX_WIDTH_DEFAULT = 160
91
51
  REMINDER_PENDING_POLL_INTERVAL = 0.5
92
52
  # What a command sent during a turn gets, as from a worker (Worker::BUSY_OUTPUT).
93
53
  COMMAND_BUSY = "busy: wait for the turn to end"
@@ -109,14 +69,9 @@ module Samagotchi
109
69
  # @param scratch [Boolean] `chi scratch`: a new session that is deleted
110
70
  # however the REPL ends, saves no memories and writes no recap
111
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,
112
- spinner_tick_interval: THINKING_TICK_INTERVAL, scratch: false)
72
+ spinner_tick_interval: AttachedView::TICK_INTERVAL, spinner_clock: nil, scratch: false)
113
73
  @mode = mode.to_sym
114
74
  @scratch = scratch
115
- # nil: no ticker thread (specs that compare exact frames).
116
- @spinner_tick_interval = spinner_tick_interval
117
- @spinner_lock = Monitor.new
118
- @turn_tally = TurnTally.new
119
- @thinking_line = ThinkingLine.new(clock: -> { monotonic_time })
120
75
  @prompt = prompt
121
76
  @default_model_name = ModelProfile.required_model_name(nil)
122
77
  aliased_model_name = model_name.to_s.strip.empty? ? nil : ConfigFile.resolve_model_alias(model_name)
@@ -145,6 +100,9 @@ module Samagotchi
145
100
  # Re-resolve client after resume may change effective model
146
101
  @client = @host_registry.resolve(@effective_model_name).client
147
102
  end
103
+ # The model it starts on names a configured host (a bad default.model
104
+ # doesn't matter when --model or the session picks another).
105
+ ModelProfile.check_host!(@effective_model_name, hosts: @host_registry.entries)
148
106
  # Only a caller's profile goes in; otherwise the Engine resolves one
149
107
  # for the effective model and hands it to the kernel.
150
108
  @kernel = KernelLoop.new(client: @client, profile: profile, no_interrupt: no_interrupt, reminder_store: Samagotchi::ReminderStore.new)
@@ -167,9 +125,17 @@ module Samagotchi
167
125
  # Every terminal write goes through the surface. The REPL swaps in a
168
126
  # live region when the terminal can show one (#assist_loop), unless it
169
127
  # was given a surface to draw on.
170
- @surface = surface || LegacySurface.new
128
+ @surface = surface || PlainSurface.new
171
129
  @surface_given = !surface.nil?
172
- @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)
173
139
  @render_event = ->(event) { handle_stream_event(event) }
174
140
  @engine = Engine.new(
175
141
  mode: :assist,
@@ -178,7 +144,9 @@ module Samagotchi
178
144
  profile: profile,
179
145
  session_id: session_id,
180
146
  no_interrupt: no_interrupt,
181
- model_name: @default_model_name,
147
+ # nil: the config default, unchecked here (the model the REPL starts
148
+ # on is checked above, and --model may make the default moot).
149
+ model_name: nil,
182
150
  memories: @requested_memories,
183
151
  muted_memories: @muted_memory_names,
184
152
  kernel: @kernel,
@@ -235,6 +203,8 @@ module Samagotchi
235
203
  # (auto-executing it, then saving), then either exits when there is no
236
204
  # follow-up REPL (--non-interactive) or drops into the REPL carrying the
237
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)
238
208
  def run
239
209
  unless @mode == :assist
240
210
  raise ArgumentError, "Unknown mode '#{@mode}'. Use: assist"
@@ -296,6 +266,8 @@ module Samagotchi
296
266
  else
297
267
  keep_after_exit(session)
298
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
299
271
  ensure
300
272
  # However it ends (the early returns too; the Engine was built in
301
273
  # #initialize): the anytime commands finish, the plugins' services
@@ -426,26 +398,8 @@ module Samagotchi
426
398
  messages
427
399
  end
428
400
 
429
- # Public entrypoint for background session workers.
430
- # Keeps worker call sites out of TerminalUI private API details.
431
- def process_background_prompt(session:, prompt:)
432
- @engine.process_background_prompt(session: session, prompt: prompt)
433
- end
434
-
435
401
  private
436
402
 
437
- # Generate profile-aware tool calling hint.
438
- # Kept as a one-line delegator to Engine (single source of truth).
439
- def tool_call_hint
440
- return @engine.tool_call_hint
441
- end
442
-
443
- # Assist system prompt — delegates wholesale to Engine so the two can
444
- # never drift apart. This single delegator fixes the 4-line regression.
445
- def assist_system_prompt
446
- return @engine.assist_system_prompt
447
- end
448
-
449
403
  # Interactive REPL loop. Session seed + messages are built by #run and
450
404
  # threaded in here (so --prompt / --resume share one code path). The
451
405
  # working session is persisted at the end of every turn.
@@ -508,15 +462,24 @@ module Samagotchi
508
462
  return yield unless screen
509
463
 
510
464
  plain = @surface
511
- @surface = screen
465
+ use_surface(screen)
512
466
  begin
513
467
  yield
514
468
  ensure
515
- @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
516
473
  LiveRegion.close(screen)
517
474
  end
518
475
  end
519
476
 
477
+ def use_surface(surface)
478
+ @surface = surface
479
+ @view.surface = surface
480
+ @status_row.surface = surface
481
+ end
482
+
520
483
  # Interactive REPL loop. Session seed + messages are built by #run and
521
484
  # threaded in here (so --prompt / --resume share one code path). The
522
485
  # session's conversation is the single working copy: turns go through
@@ -559,21 +522,22 @@ module Samagotchi
559
522
  end
560
523
  end
561
524
  break if input.nil?
562
- if exit_command?(input)
563
- @delete_on_exit ||= delete_on_exit?(input)
525
+ local = local_command(input)
526
+ if local == :exit
527
+ @delete_on_exit ||= SessionCommands.delete_on_exit?(input)
564
528
  break
565
529
  end
566
- if archive_command?(input)
530
+ if local == :archive
567
531
  next @surface.commit(SCRATCH_ARCHIVE_REFUSED) if @scratch
568
532
 
569
533
  @archive_on_exit = true
570
534
  break
571
535
  end
572
536
  # Not an answer to a continue offer either.
573
- next detach_note if detach_command?(input)
537
+ next detach_note if local == :detach
574
538
 
575
539
  # /stats and /recap run; the offer stays open.
576
- if @turn_flow.awaiting_continue? && !stats_command?(input) && !recap_command?(input)
540
+ if @turn_flow.awaiting_continue? && !%i[stats recap].include?(local)
577
541
  answer_continue_offer(session, input)
578
542
  else
579
543
  # The ? read left no echo: show what ran.
@@ -602,8 +566,9 @@ module Samagotchi
602
566
  @turn_flow.before_continue_turn
603
567
  begin
604
568
  result = run_engine_turn(session, nil, continue: true)
605
- rescue LLM::ProviderError => e
606
- @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"))
607
572
  return
608
573
  end
609
574
  finish_turn(session, result, continue: true)
@@ -626,20 +591,17 @@ module Samagotchi
626
591
  persist_recent_history(input) if command.shell
627
592
  return
628
593
  end
629
- return @surface.commit("\nmodel> session stats:\n#{format_session_metrics(@engine.stats_snapshot)}") if stats_command?(input)
630
- return @surface.commit("\nmodel> #{handle_recap_command}") if recap_command?(input)
594
+ return if show_local_command(input)
631
595
 
632
596
  @turn_flow.before_prompt_turn
633
597
  persist_recent_history(input)
634
598
  begin
635
599
  # Engine#run_turn injects due reminders as a tail message, appends
636
600
  # the prompt, and renders through @renderer via on_event.
637
- result = run_engine_turn(session, normalize_model_input(input), images: ImageInput.extract(input))
601
+ result = run_engine_turn(session, input, images: ImageInput.extract(input))
638
602
  rescue LLM::ProviderError, ImageStore::Error => e
639
- # Engine closed the turn (:turn_failed); show its duration.
640
- # Retries were already tallied via generation_retrying events.
603
+ # Engine closed the turn (:turn_failed), which the renderer showed.
641
604
  # An @path image that can't be used fails the turn the same way.
642
- emit_interactive_turn_duration(canceled: false)
643
605
  summary = e.respond_to?(:summary) ? e.summary : e.message
644
606
  # The model reads why on its next turn. An image that couldn't be
645
607
  # used, or that the host refused, never reached it: no note for that
@@ -648,7 +610,14 @@ module Samagotchi
648
610
  @turn_flow.prompt_turn_failed(note: note)
649
611
  # The Engine saved the failed turn; the file follows the rollback.
650
612
  save_session(session) if note
651
- @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
652
621
  return
653
622
  end
654
623
  finish_turn(session, result, continue: false)
@@ -666,10 +635,9 @@ module Samagotchi
666
635
  # and the partial assistant reply (marked [interrupted]) into
667
636
  # result.conversation, so progress is preserved by default — the
668
637
  # user's next message continues from it. !rollback restores the
669
- # pre-turn checkpoint for an explicit full discard.
670
- 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).
671
640
  save_session(session) if result.respond_to?(:conversation) && result.conversation.is_a?(Array)
672
- @surface.commit("\nmodel> turn cancelled; partial progress kept in context; use !rollback immediately after cancellation to restore the pre-turn checkpoint")
673
641
  return
674
642
  end
675
643
 
@@ -702,191 +670,11 @@ module Samagotchi
702
670
  @default_model_name = @commands.default_model
703
671
  end
704
672
 
705
- def status_server_segment
706
- # Show per-host info when multi-host is configured
707
- if @host_registry && @host_registry.entries.size > 1
708
- active = @host_registry.resolve(@effective_model_name).entry rescue nil
709
- if active
710
- host = active.host
711
- port = active.port
712
- total = @host_registry.entries.size
713
- return "" if ["localhost", "127.0.0.1"].include?(host) && total == 1
714
- # When multiple hosts, always show active + count
715
- return "server=#{host}:#{port} (#{total} hosts)"
716
- end
717
- end
718
- host = Samagotchi::Config.get("server.host")
719
- return "" if ["localhost", "127.0.0.1"].include?(host)
720
-
721
- port = Samagotchi::Config.get("server.port")
722
- "server=#{host}:#{port}"
723
- end
724
-
725
- # Engine's built-once system prompt (the one run_turn sends), plus the
726
- # --memory activations it recorded for the sticky status line.
727
- def seed_system_prompt
728
- prompt = @engine.system_prompt
729
- sync_engine_activated_memories
730
- prompt
731
- end
732
-
733
- # Appends the current memory index to the base system prompt so the agent
734
- # is always aware of stored memories without needing to call a tool first.
735
- # Delegates wholesale to Engine (single source of truth).
736
- def system_prompt_with_index(base)
737
- result = @engine.system_prompt_with_index(base)
738
- # Mirror any --memory activations the Engine performed so the sticky
739
- # status line can surface them. The prompt body injection moved into
740
- # Engine; echoing the activated names here is purely a UI concern.
741
- sync_engine_activated_memories
742
- result
743
- end
744
-
745
- # Engine owns the system prompt (including --memory activation), but the
746
- # sticky status line is a UI concern. Mirror the activated names so they
747
- # appear in the status line.
748
- def sync_engine_activated_memories
749
- @engine.activated_memory_names.each do |name|
750
- add_unique_memory_name(:@session_memory_names, name)
751
- end
752
- end
753
-
754
- # Render a result from a turn that bypassed Engine#run_turn (continue,
755
- # reminder turns) exactly as a :turn_completed would be rendered.
756
- def emit_result(result)
757
- @renderer.render_turn_summary(@engine.turn_summary(result))
758
- end
759
-
760
- # ── Turn view: the drawing surface EventRenderer calls ──────────────────
761
- public
762
-
763
- def emit_active_memories_line
764
- lines = sticky_status_lines(width: status_effective_width)
765
- return if lines.empty?
766
-
767
- lines.each { |line| @surface.commit(line) }
768
- end
769
-
770
- def print_line(text)
771
- @surface.commit(text)
772
- end
773
-
774
- def reset_turn_feedback
775
- @spinner_lock.synchronize { @turn_tally.reset }
776
- clear_retry_spinner_status
777
- reset_thinking_memory_notification
778
- reset_thinking_memory_names
779
- reset_thinking_tool_notification
780
- end
781
-
782
- # A turn waits for plugins' slow setup (chi.init) before its first
783
- # request: the spinner says what for, until the model starts.
784
- def init_wait_feedback(event)
785
- labels = Array(event[:tasks]).map { |task| "#{task[:bundle]}: #{task[:label]}" }
786
- @spinner_lock.synchronize do
787
- @init_wait_status = "chi> waiting for #{labels.join(" · ")}..."
788
- start_thinking_spinner
789
- refresh_thinking_spinner_status
790
- end
791
- end
792
-
793
- def generation_feedback_started(event = {})
794
- @init_wait_status = nil
795
- @context_window_tokens = event[:context_window_tokens] if event[:context_window_tokens]
796
- clear_retry_spinner_status
797
- @latest_server_context_status = nil
798
- @spinner_lock.synchronize do
799
- @thinking_line.reset
800
- start_thinking_spinner
801
- end
802
- end
803
-
804
- def generation_feedback_retrying(event)
805
- @spinner_lock.synchronize do
806
- # The retry streams from the start: wait for its first token again.
807
- @thinking_waiting_since = monotonic_time if @thinking_spinner_active
808
- set_retry_spinner_status(event)
809
- refresh_thinking_spinner_status
810
- end
811
- end
812
-
813
- def generation_feedback_chunk(event)
814
- @spinner_lock.synchronize do
815
- @thinking_waiting_since = nil
816
- clear_retry_spinner_status if retry_spinner_status_active?
817
- capture_server_context_status_from_payload(event[:payload])
818
- # A new sentence shows at once (the line changes at most once a dwell).
819
- next refresh_thinking_spinner_status if @thinking_line.chunk(event)
820
-
821
- tick_thinking_spinner
822
- end
823
- end
824
-
825
- def tool_call_feedback_started(event)
826
- @spinner_lock.synchronize do
827
- @thinking_waiting_since = nil
828
- clear_retry_spinner_status
829
- @turn_tally.started(key: tally_key(event), tool: event[:tool], params: event[:params])
830
- memory_loaded = capture_memory_tool_call(event)
831
- capture_thinking_tool_call(event) if memory_loaded
832
- refresh_thinking_spinner_status
833
- end
834
- end
835
-
836
- def tool_call_feedback_completed(event)
837
- @spinner_lock.synchronize do
838
- @turn_tally.completed(key: tally_key(event), tool: event[:tool],
839
- status: event.dig(:activity, :status), params: event.dig(:activity, :params))
840
- end
841
- end
842
-
843
- def clear_generation_retry
844
- @spinner_lock.synchronize { clear_retry_spinner_status }
845
- end
846
-
847
- def generation_feedback_finished
848
- @spinner_lock.synchronize do
849
- clear_retry_spinner_status
850
- @thinking_line.reset
851
- finish_thinking_spinner
852
- end
853
- end
854
-
855
- # The kernel reports the last emitted context status on the result; keep
856
- # the previous one when a turn reports none.
857
- def capture_context_status(status)
858
- return unless status
859
-
860
- @latest_context_status = {
861
- est_pct: status[:est_pct],
862
- bucket: status[:bucket]
863
- }
864
- end
865
-
866
- private
867
-
868
- # The last turn's line: completed, canceled or failed, as the Engine
869
- # closed it (a provider error closes it with :turn_failed).
870
- def emit_interactive_turn_duration(canceled:)
871
- record = Array(@engine.metrics.snapshot[:turn_records]).last
872
- return unless record && record[:duration_ms]
873
-
874
- state = record[:status] == "failed" ? "failed" : (canceled ? "canceled" : "completed")
875
- @surface.commit("#{paint('chi>', 36)} turn #{state} (#{format_elapsed_duration(record[:duration_ms])})")
876
- end
877
-
878
- def split_memory_scope(raw)
879
- value = raw.to_s.strip
880
- if value.include?("/")
881
- scope, name = value.split("/", 2)
882
- return [scope, name] if Tools::VALID_SCOPES.include?(scope)
883
- end
884
-
885
- [nil, value]
886
- end
673
+ # Engine's built-once system prompt (the one run_turn sends).
674
+ def seed_system_prompt = @engine.system_prompt
887
675
 
888
676
  def read_input(awaiting_continue:)
889
- emit_idle_status_line
677
+ refresh_status_row
890
678
 
891
679
  if awaiting_continue
892
680
  sync_continue_slot(true)
@@ -910,20 +698,24 @@ module Samagotchi
910
698
  # empty synthetic turn then (it duplicated output).
911
699
  return unless @engine.reminders_due?
912
700
 
701
+ # A pending continue offer goes, as for a new prompt (the worker's rule).
702
+ if @turn_flow.before_reminder_turn
703
+ sync_continue_slot(false)
704
+ @surface.commit(QuestionSlot.continue_summary("(dropped: a reminder ran)", paint: method(:paint)))
705
+ end
706
+ @surface.commit("reminder: #{names.join(", ")}")
913
707
  hint = "reminder: #{names.join(", ")} · Ctrl-C cancels it"
914
708
  @surface.set_slot(:hints, [color_output? ? paint(hint, 90) : hint])
915
709
  result = nil
916
710
  begin
917
711
  result = run_engine_turn(session, nil, continue: true)
918
712
  @prompt = nil
919
- rescue LLM::ProviderError => e
920
- # like other failed turns: back to the prompt
921
- @surface.commit("\nmodel> #{e.summary}")
713
+ rescue LLM::ProviderError
714
+ nil # the renderer showed the failure; back to the prompt
922
715
  ensure
923
716
  @surface.clear_slot(:hints)
924
717
  end
925
718
  canceled = result.respond_to?(:canceled?) && result.canceled?
926
- emit_interactive_turn_duration(canceled: canceled)
927
719
  if result && !canceled
928
720
  session.messages = result.conversation if result.respond_to?(:conversation) && result.conversation.is_a?(Array)
929
721
  session.model_name = @effective_model_name
@@ -949,7 +741,7 @@ module Samagotchi
949
741
 
950
742
  if @idle_status_due
951
743
  @idle_status_due = false
952
- emit_idle_status_line
744
+ refresh_status_row
953
745
  end
954
746
  sync_continue_slot(awaiting_continue)
955
747
  @repl_input.sync_prompt
@@ -972,7 +764,7 @@ module Samagotchi
972
764
  return unless STDIN.tty? && $stdin.tty?
973
765
 
974
766
  @idle_status_due = false
975
- emit_idle_status_line
767
+ refresh_status_row
976
768
  @repl_input = ReplInput.new(prompt: method(:repl_prompt_text), read: method(:read_repl_line), surface: @surface).start
977
769
  end
978
770
 
@@ -1013,6 +805,10 @@ module Samagotchi
1013
805
  prompt == paint(QUESTION_PROMPT, 33) ? RelineSeam.without_echo(&read) : read.call
1014
806
  end
1015
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
+
1016
812
  # A failed prompt goes back into the input for a retry.
1017
813
  # @return [String] what the failed-turn line says about it
1018
814
  def restore_prompt_for_retry(input)
@@ -1023,27 +819,20 @@ module Samagotchi
1023
819
  @repl_input.prefill(input) ? "prompt restored for retry" : "the failed prompt is in the input history (↑)"
1024
820
  end
1025
821
 
1026
- def shell_bang_command?(input)
1027
- input.to_s.match?(/\A!\s*\S/)
1028
- end
1029
-
1030
- def stats_command?(input)
1031
- input.to_s.strip == STATS_COMMAND
1032
- end
1033
-
1034
- def recap_command?(input)
1035
- input.to_s.strip == RECAP_COMMAND
1036
- end
1037
-
1038
- # SessionCommands runs /model; the REPL mirrors the result.
1039
- def handle_model_command(input)
1040
- result = @commands.run(input)
1041
- sync_model_mirrors
1042
- result.output
1043
- end
822
+ # The id of the terminal's own command +input+ is (:exit, :archive,
823
+ # :detach, :stats, :recap; SessionCommands registers them), or nil.
824
+ def local_command(input) = command_registry.lookup_local(input)&.id
1044
825
 
1045
- def handle_models_command
1046
- @commands.run(SessionCommands::MODELS_COMMAND).output
826
+ # /stats and /recap, which show and change nothing (at the prompt, a
827
+ # continue offer's ? prompt, or during a turn).
828
+ # @return [Boolean] whether +input+ was one
829
+ def show_local_command(input)
830
+ case local_command(input)
831
+ when :stats then @surface.commit("\nmodel> session stats:\n#{format_session_metrics(@engine.stats_snapshot)}")
832
+ when :recap then @surface.commit("\nmodel> #{handle_recap_command}")
833
+ else return false
834
+ end
835
+ true
1047
836
  end
1048
837
 
1049
838
  # /recap: the saved recap, and a new one asked for at once when the
@@ -1060,31 +849,10 @@ module Samagotchi
1060
849
  # A resumed session doesn't get the default input either.
1061
850
  def default_input_wanted? = !@resume_session && !@no_default_input
1062
851
 
1063
- # exit or /exit, with --delete to delete the session on the way out.
1064
- def exit_command?(input)
1065
- words = input.to_s.strip.downcase.split
1066
- %w[exit /exit].include?(words.first) && (words.size == 1 || words == [words.first, EXIT_DELETE_FLAG])
1067
- end
1068
-
1069
- def delete_on_exit?(input) = input.to_s.strip.downcase.split.last == EXIT_DELETE_FLAG
1070
-
1071
- def detach_command?(input) = input.to_s.strip.casecmp?("/detach")
1072
-
1073
- def archive_command?(input) = input.to_s.strip.casecmp?(ARCHIVE_COMMAND)
1074
-
1075
852
  def clone_messages(messages)
1076
853
  Array(messages).map(&:dup)
1077
854
  end
1078
855
 
1079
- def skip_agent_description?
1080
- value = ENV[SKIP_AGENT_DESCRIPTION_ENV]
1081
- value == "1" || value&.casecmp?("true")
1082
- end
1083
-
1084
- def rg_available?
1085
- system("command -v rg", out: File::NULL, err: File::NULL)
1086
- end
1087
-
1088
856
  # Run one REPL turn (a prompt, or a continue/reminder turn with nil) through
1089
857
  # Engine#run_turn, rendering via @renderer.
1090
858
  # @param images [Array<Hash>] the prompt's `@path` images ({path:})
@@ -1092,12 +860,11 @@ module Samagotchi
1092
860
  cancellation_controller = CancellationController.new
1093
861
  @active_cancel_controller = cancellation_controller
1094
862
  @renderer.begin_turn
1095
- # Build the (memoized) prompt now so --memory activations show in this
1096
- # turn's status lines, including after /model rebuilt it.
1097
- seed_system_prompt
863
+ # The row shows the turn's model (a -p turn runs before any read).
864
+ refresh_status_row
1098
865
  # A prompt the user typed brings an archived session back to the lists.
1099
866
  ArchiveStore.user_input(session.id, state_dir: Session.default_state_dir) if prompt && !continue
1100
- result = with_steering do
867
+ with_steering do
1101
868
  @engine.run_turn(
1102
869
  session,
1103
870
  prompt,
@@ -1109,17 +876,14 @@ module Samagotchi
1109
876
  images: images
1110
877
  )
1111
878
  end
1112
- emit_cancellation_notice(result)
1113
- result
1114
879
  rescue Interrupt
1115
- # 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).
1116
882
  cancellation_controller&.cancel!(:ctrl_c)
1117
- result = cancelled_result_from(session.messages, reason: :ctrl_c)
1118
- emit_cancellation_notice(result)
1119
- result
883
+ cancelled_result_from(session.messages, reason: :ctrl_c)
1120
884
  ensure
1121
885
  @active_cancel_controller = nil
1122
- finish_thinking_spinner
886
+ @view.finish_thinking_spinner
1123
887
  end
1124
888
 
1125
889
  # While a turn runs, a line submitted at the open prompt steers it: it
@@ -1139,11 +903,13 @@ module Samagotchi
1139
903
  # in the prompt.
1140
904
  # @return [Boolean, :back] whether the turn took it, :back to put it back
1141
905
  def steer_line(line)
1142
- return exit_after_turn(delete: delete_on_exit?(line)) if line.nil? || exit_command?(line)
1143
- return detach_note if detach_command?(line)
906
+ local = local_command(line) unless line.nil?
907
+ return exit_after_turn(delete: SessionCommands.delete_on_exit?(line)) if line.nil? || local == :exit
908
+ return detach_note if local == :detach
1144
909
  return false if @active_cancel_controller&.cancelled?
1145
910
  return start_anytime_command(line) if command_registry.lookup(line)&.anytime
1146
- return command_during_turn(line) if command_line?(line)
911
+ # A command, never steering text (/archive waits, back in the prompt).
912
+ return command_during_turn(line) if local || command_registry.command?(line)
1147
913
  return true if line.strip.empty?
1148
914
  # Steering merges text only: a line with images runs as the next turn.
1149
915
  return image_line_waits if ImageInput.extract(line).any?
@@ -1177,15 +943,10 @@ module Samagotchi
1177
943
 
1178
944
  # @return [true, :back]
1179
945
  def command_during_turn(line)
1180
- if stats_command?(line)
1181
- @surface.commit("\nmodel> session stats:\n#{format_session_metrics(@engine.stats_snapshot)}")
1182
- elsif recap_command?(line)
1183
- @surface.commit("\nmodel> #{handle_recap_command}")
1184
- else
1185
- @surface.commit(COMMAND_BUSY)
1186
- return :back
1187
- end
1188
- true
946
+ return true if show_local_command(line)
947
+
948
+ @surface.commit(COMMAND_BUSY)
949
+ :back
1189
950
  end
1190
951
 
1191
952
  # D8: an anytime command runs on its own thread while the turn goes on
@@ -1205,35 +966,32 @@ module Samagotchi
1205
966
  true
1206
967
  end
1207
968
 
1208
- def command_line?(line)
1209
- command_registry.command?(line) || stats_command?(line) || recap_command?(line) || exit_command?(line)
1210
- end
1211
-
1212
- # The kernel's drain at an iteration boundary: queued lines, #memory
1213
- # shorthand made words as for a prompt, and saved in the history.
969
+ # The kernel's drain at an iteration boundary: queued lines, sent as
970
+ # typed and saved in the history.
1214
971
  def drain_steering
1215
972
  @pending_input_queue.drain.map do |line|
1216
973
  persist_recent_history(line)
1217
- normalize_model_input(line)
974
+ line
1218
975
  end
1219
976
  end
1220
977
 
1221
- # The REPL's on_event sink for Engine#run_turn: render one event. Engine
1222
- # 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.
1223
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
1224
990
  @renderer.call(event)
1225
991
  rescue StandardError => e
1226
992
  Log.error(:repl, "render_failed", echo: "[render] #{event[:type]}: #{e.class}: #{e.message}", event_type: event[:type].to_s, error: e.class.name)
1227
993
  end
1228
994
 
1229
- def emit_cancellation_notice(result)
1230
- return unless result.respond_to?(:canceled?) && result.canceled?
1231
-
1232
- reason = result.respond_to?(:cancellation_reason) ? result.cancellation_reason : nil
1233
- label = cancellation_reason_label(reason)
1234
- @surface.commit("\nmodel> request cancelled#{label.empty? ? "" : " (#{label})"}")
1235
- end
1236
-
1237
995
  def cancelled_result_from(messages, reason:)
1238
996
  KernelLoop::Result.new(
1239
997
  output: "",
@@ -1246,291 +1004,16 @@ module Samagotchi
1246
1004
  )
1247
1005
  end
1248
1006
 
1249
- def cancellation_reason_label(reason)
1250
- return "" if reason.nil?
1251
-
1252
- case reason.to_sym
1253
- when :ctrl_c
1254
- "ctrl-c"
1255
- else
1256
- reason.to_s
1257
- end
1258
- end
1259
-
1260
- def start_thinking_spinner
1261
- return unless thinking_spinner_enabled?
1262
-
1263
- @thinking_spinner_active = true
1264
- @thinking_spinner_index = 0 if @thinking_spinner_index.nil?
1265
- @thinking_spinner_last_render_at = nil
1266
- @thinking_waiting_since = monotonic_time
1267
- render_thinking_spinner
1268
- start_thinking_ticker
1269
- end
1270
-
1271
- # One thread per spinner; it ends when the spinner does.
1272
- def start_thinking_ticker
1273
- return unless @spinner_tick_interval
1274
- return if @thinking_ticker&.alive?
1275
-
1276
- @thinking_ticker = Thread.new do
1277
- loop do
1278
- sleep(@spinner_tick_interval)
1279
- break unless tick_thinking_spinner_on_timer
1280
- end
1281
- rescue StandardError
1282
- nil
1283
- end
1284
- @thinking_ticker.report_on_exception = false
1285
- end
1286
-
1287
- # Turn the spinner when no chunk did for a tick.
1288
- # @return [Boolean] whether the spinner is still shown
1289
- def tick_thinking_spinner_on_timer
1290
- @spinner_lock.synchronize do
1291
- return false unless @thinking_spinner_active
1292
-
1293
- last = @thinking_spinner_last_render_at
1294
- if last.nil? || (monotonic_time - last) >= @spinner_tick_interval
1295
- @thinking_spinner_index = (@thinking_spinner_index + 1) % THINKING_SPINNER_FRAMES.length
1296
- render_thinking_spinner
1297
- end
1298
- true
1299
- end
1300
- end
1301
-
1302
- def tick_thinking_spinner
1303
- return unless @thinking_spinner_active
1304
-
1305
- @thinking_spinner_index = (@thinking_spinner_index + 1) % THINKING_SPINNER_FRAMES.length
1306
- render_thinking_spinner_if_due
1307
- end
1308
-
1309
- def refresh_thinking_spinner_status
1310
- return unless @thinking_spinner_active
1311
-
1312
- render_thinking_spinner
1313
- end
1314
-
1315
- def render_thinking_spinner_if_due
1316
- last = @thinking_spinner_last_render_at
1317
- return render_thinking_spinner if last.nil?
1318
- return if (monotonic_time - last) < thinking_render_min_interval
1319
-
1320
- render_thinking_spinner
1321
- end
1322
-
1323
- public
1324
-
1325
- # Erase the spinner rows; nothing to do when none are shown.
1326
- def finish_thinking_spinner
1327
- @spinner_lock.synchronize do
1328
- # Off even when the rows are gone already, so the ticker ends.
1329
- @thinking_spinner_active = false
1330
- @init_wait_status = nil
1331
- @thinking_waiting_since = nil
1332
- next unless @surface.clear_slot(:activity)
1333
-
1334
- @thinking_spinner_last_render_at = nil
1335
- end
1336
- end
1337
-
1338
- private
1339
-
1340
- def retry_spinner_status_line(frame, width)
1341
- data = @retry_spinner_status || {}
1342
- attempt = data[:attempt].to_i
1343
- max_retries = data[:max_retries].to_i
1344
- total_attempts = max_retries + 1
1345
- delay = format("%.1f", data[:next_delay].to_f)
1346
- error_class = data[:error_class].to_s
1347
- message = "model> network error: retrying (#{attempt}/#{total_attempts} in #{delay}s) #{frame}"
1348
- message += " #{error_class}" unless error_class.empty?
1349
- capped = cap_preview_line(message, width)
1350
- color_output? ? paint(capped, NETWORK_RETRY_SPINNER_COLOR) : capped
1351
- end
1352
-
1353
- def cap_preview_line(text, width)
1354
- cap_preview_text(text, thinking_preview_width(width))
1355
- end
1356
-
1357
- def thinking_preview_width(width)
1358
- return THINKING_PREVIEW_WIDTH if width <= 0
1359
-
1360
- [width, THINKING_PREVIEW_WIDTH].min
1361
- end
1362
-
1363
-
1364
- def thinking_render_min_interval
1365
- value = ENV.fetch(THINKING_RENDER_INTERVAL_ENV, THINKING_RENDER_MIN_INTERVAL.to_s).to_f
1366
- return THINKING_RENDER_MIN_INTERVAL unless value.positive?
1367
-
1368
- value
1369
- end
1370
-
1371
- def monotonic_time
1372
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
1373
- end
1374
-
1375
- def capture_memory_tool_call(event)
1376
- call = event[:call].is_a?(Hash) ? event[:call] : {}
1377
- memory_name = memory_name_from_tool_call(call)
1378
- return false if memory_name.nil? || memory_name.empty?
1379
-
1380
- added_to_thinking = add_unique_memory_name(:@thinking_memory_names, memory_name)
1381
- add_unique_memory_name(:@session_memory_names, memory_name)
1382
- @thinking_recent_memory_loaded = memory_name if added_to_thinking
1383
- added_to_thinking
1384
- end
1385
-
1386
- def capture_thinking_tool_call(event)
1387
- call = event[:call].is_a?(Hash) ? event[:call] : {}
1388
- name = call[:name].to_s.strip
1389
- return if name.empty?
1390
-
1391
- params = thinking_tool_params_preview(call, event[:params])
1392
- text = params.empty? ? name : "#{name}(#{params})"
1393
- @thinking_recent_tool_call = cap_preview_text(text, THINKING_TOOL_PREVIEW_LIMIT)
1394
- end
1395
-
1396
- def thinking_tool_params_preview(call, raw_params)
1397
- compact = raw_params.to_s.gsub(/\s+/, " ").strip
1398
- return compact unless compact.empty?
1399
-
1400
- tool_name = call[:name].to_s
1401
- case tool_name
1402
- when Tools::Execute::NAME
1403
- "command=#{preview_value_for_spinner(call[:content])}"
1404
- when Tools::Read::NAME
1405
- "path=#{preview_value_for_spinner(call[:content])}"
1406
- when Tools::Write::NAME, Tools::Edit::NAME
1407
- "path=#{preview_value_for_spinner(call[:path])}"
1408
- when Tools::MemoryRead::NAME
1409
- parts = []
1410
- name = call[:content].to_s.strip
1411
- parts << "name=#{preview_value_for_spinner(name)}" unless name.empty?
1412
- scope = call[:scope].to_s.strip
1413
- parts << "scope=#{preview_value_for_spinner(scope)}" unless scope.empty?
1414
- parts.join(" ")
1415
- when Tools::MemoryWrite::NAME
1416
- parts = []
1417
- path = call[:path].to_s.strip
1418
- parts << "name=#{preview_value_for_spinner(path)}" unless path.empty?
1419
- scope = call[:scope].to_s.strip
1420
- parts << "scope=#{preview_value_for_spinner(scope)}" unless scope.empty?
1421
- parts.join(" ")
1422
- else
1423
- ""
1424
- end
1425
- end
1426
-
1427
- def preview_value_for_spinner(value)
1428
- text = value.to_s.gsub(/\s+/, " ").strip
1429
- return '""' if text.empty?
1430
-
1431
- text.inspect
1432
- end
1433
-
1434
- def add_unique_memory_name(ivar_name, value)
1435
- names = instance_variable_get(ivar_name) || []
1436
- return false if names.include?(value)
1437
-
1438
- names << value
1439
- instance_variable_set(ivar_name, names)
1440
- true
1441
- end
1442
-
1443
- def memory_name_from_tool_call(call)
1444
- tool_name = call[:name].to_s
1445
- case tool_name
1446
- when Tools::MemoryRead::NAME
1447
- normalize_memory_name(call[:content])
1448
- when Tools::Read::NAME
1449
- memory_name_from_read_path(call[:content])
1450
- else
1451
- nil
1452
- end
1453
- end
1454
-
1455
- def normalize_memory_name(raw)
1456
- value = raw.to_s.strip
1457
- return nil if value.empty?
1458
-
1459
- File.basename(value, ".md")
1460
- end
1461
-
1462
- def memory_name_from_read_path(raw_path)
1463
- path = raw_path.to_s.strip.tr("\\", "/")
1464
- return nil if path.empty?
1465
- return nil unless path.match?(/memories[\/].+\.md\z/)
1466
-
1467
- normalize_memory_name(path)
1468
- end
1469
-
1470
- def memory_spinner_segment
1471
- segment = memory_spinner_segment_plain
1472
- return "" if segment.empty?
1473
-
1474
- color_output? ? paint(segment, MEMORY_SPINNER_COLOR) : segment
1475
- end
1476
-
1477
- def memory_spinner_segment_plain
1478
- names = Array(@thinking_memory_names)
1479
- return "" if names.empty?
1480
-
1481
- visible = names.first(MEMORY_SPINNER_PREVIEW_LIMIT)
1482
- suffix = names.length > visible.length ? ", +#{names.length - visible.length}" : ""
1483
- " mem: #{visible.join(', ')}#{suffix}"
1484
- end
1485
-
1486
- def memory_sticky_line
1487
- names = Array(@session_memory_names)
1488
- muted = @engine.muted_memory_names
1489
- return "" if names.empty? && muted.empty?
1490
-
1491
- parts = []
1492
- parts << "active this session: #{preview_names(names)}" unless names.empty?
1493
- parts << "muted: #{preview_names(muted)}" unless muted.empty?
1494
- body = "memories> #{parts.join(' · ')}"
1495
- color_output? ? paint(body, MEMORY_SPINNER_COLOR) : body
1496
- end
1497
-
1498
- def preview_names(names, limit: MEMORY_STICKY_PREVIEW_LIMIT)
1499
- visible = names.first(limit)
1500
- suffix = names.length > visible.length ? ", +#{names.length - visible.length}" : ""
1501
- "#{visible.join(', ')}#{suffix}"
1502
- end
1503
-
1504
- def reset_thinking_memory_names
1505
- @thinking_memory_names = []
1506
- end
1507
-
1508
- def reset_thinking_memory_notification
1509
- @thinking_recent_memory_loaded = nil
1510
- end
1511
-
1512
- def reset_thinking_tool_notification
1513
- @thinking_recent_tool_call = nil
1514
- end
1515
-
1516
- def capture_server_context_status_from_payload(payload)
1517
- # The window the kernel resolved for this generation (see
1518
- # :generation_started); the configured one before the first generation.
1519
- window_tokens = @context_window_tokens || ContextWindow.configured.tokens
1520
- normalized = ContextUsage.normalize(payload, window_tokens: window_tokens)
1521
- return unless normalized
1522
-
1523
- @latest_server_context_status = normalized
1524
- end
1525
-
1526
-
1527
- def emit_idle_status_line
1528
- return unless status_line_enabled?
1529
-
1530
- lines = idle_status_lines(width: status_effective_width)
1531
- return if lines.empty?
1532
-
1533
- @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)
1534
1017
  end
1535
1018
 
1536
1019
  # ── Idle session recap ───────────────────────────────────────────────────
@@ -1567,13 +1050,17 @@ module Samagotchi
1567
1050
  # main thread (the command runs at the prompt) or beside a running turn
1568
1051
  # (above the live region), else at the next flush.
1569
1052
  #
1570
- # A plugin's init task (chi.init) prints a line as it starts and one
1571
- # when it is done; a load warning announced before the first turn, one.
1572
- # Beside a running turn they print at once; a --non-interactive run
1573
- # 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.
1574
1058
  def handle_card_event(event)
1575
1059
  if INIT_EVENTS.include?(event[:type])
1576
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
1577
1064
  return show_pending_item(event) if @engine.turn_running?
1578
1065
 
1579
1066
  return @pending_cards << event
@@ -1601,13 +1088,13 @@ module Samagotchi
1601
1088
  INIT_EVENTS = %i[plugin_init_started plugin_init_finished guardrail_warning].freeze
1602
1089
 
1603
1090
  # A card, a notice, an anytime command's output (:command_output), a
1604
- # plugin init task's line or a load warning.
1091
+ # plugin init task's done line or a load warning.
1605
1092
  def show_pending_item(item)
1606
1093
  case item[:type]
1607
1094
  when :card then @renderer.render_card(item)
1608
1095
  when :command_output then @surface.commit(item[:text])
1609
1096
  when :guardrail_warning then @surface.commit(EventRenderer.load_warning_line(item))
1610
- when :plugin_init_started, :plugin_init_finished
1097
+ when :plugin_init_finished
1611
1098
  line = EventRenderer.init_line(item)
1612
1099
  @surface.commit(line) if line
1613
1100
  else @surface.commit(EventRenderer.hook_notice_line(item))
@@ -1622,188 +1109,6 @@ module Samagotchi
1622
1109
  ConfigFile.recap_config
1623
1110
  end
1624
1111
 
1625
- def bare_model_for(full_ref)
1626
- @host_registry.bare_name(full_ref)
1627
- end
1628
-
1629
- def spinner_status_line
1630
- return "" unless status_line_enabled?
1631
-
1632
- lines = spinner_status_lines
1633
- lines.empty? ? "" : lines.first
1634
- end
1635
-
1636
- def spinner_status_lines(width: status_effective_width)
1637
- return [] unless status_line_enabled?
1638
-
1639
- build_status_lines(scope: :spinner, width: width)
1640
- end
1641
-
1642
- def sticky_status_line
1643
- return "" unless status_line_enabled?
1644
-
1645
- lines = sticky_status_lines
1646
- lines.empty? ? "" : lines.first
1647
- end
1648
-
1649
- def sticky_status_lines(width: status_effective_width)
1650
- return [] unless status_line_enabled?
1651
-
1652
- build_status_lines(scope: :sticky, width: width)
1653
- end
1654
-
1655
- def idle_status_line
1656
- return "" unless status_line_enabled?
1657
-
1658
- lines = idle_status_lines
1659
- lines.empty? ? "" : lines.first
1660
- end
1661
-
1662
- def idle_status_lines(width: status_effective_width)
1663
- return [] unless status_line_enabled?
1664
-
1665
- build_status_lines(scope: :idle, width: width)
1666
- end
1667
-
1668
- def build_status_line(scope:)
1669
- lines = build_status_lines(scope: scope)
1670
- lines.empty? ? "" : lines.first
1671
- end
1672
-
1673
- # The status rows for +scope+ (:spinner, :sticky or :idle), cut to +width+.
1674
- def build_status_lines(scope:, width: status_effective_width)
1675
- status_rows(status_segments(scope), width)
1676
- end
1677
-
1678
-
1679
- def status_width_mode
1680
- mode = ENV.fetch(STATUS_WIDTH_MODE_ENV, STATUS_WIDTH_MODE_TERMINAL_CAP).to_s.strip.downcase
1681
- return STATUS_WIDTH_MODE_FIXED if mode == STATUS_WIDTH_MODE_FIXED
1682
-
1683
- STATUS_WIDTH_MODE_TERMINAL_CAP
1684
- end
1685
-
1686
- def status_effective_width
1687
- mode = status_width_mode
1688
- width = if mode == STATUS_WIDTH_MODE_FIXED
1689
- status_fixed_width
1690
- else
1691
- [terminal_columns, status_max_width].min
1692
- end
1693
- width = status_fixed_width unless width.positive?
1694
- width
1695
- end
1696
-
1697
- def status_fixed_width
1698
- env_positive_int(STATUS_FIXED_WIDTH_ENV, THINKING_PREVIEW_WIDTH)
1699
- end
1700
-
1701
- def status_max_width
1702
- env_positive_int(STATUS_MAX_WIDTH_ENV, STATUS_MAX_WIDTH_DEFAULT)
1703
- end
1704
-
1705
- def terminal_columns
1706
- columns = begin
1707
- io = IO.console
1708
- io&.winsize&.[](1).to_i
1709
- rescue StandardError
1710
- 0
1711
- end
1712
- return columns if columns.positive?
1713
-
1714
- env_positive_int("COLUMNS", status_max_width)
1715
- end
1716
-
1717
- def env_positive_int(key, default)
1718
- value = ENV.fetch(key, default.to_s).to_i
1719
- value.positive? ? value : default
1720
- end
1721
-
1722
- def status_segments(scope)
1723
- segments = [status_model_segment, status_server_segment].reject(&:empty?)
1724
- context_segment = status_context_segment
1725
- memory_segment = status_memory_segment(scope)
1726
- segments << context_segment unless context_segment.empty?
1727
- segments << memory_segment unless memory_segment.empty?
1728
- # The session's --mute list, on the sticky and idle rows (not the spinner).
1729
- muted_segment = scope == :spinner ? "" : status_memory_text(@engine.muted_memory_names, MEMORY_STICKY_PREVIEW_LIMIT, label: "muted")
1730
- segments << muted_segment unless muted_segment.empty?
1731
- segments
1732
- end
1733
-
1734
- def status_model_segment
1735
- served, served_for = @engine.respond_to?(:served_model) ? @engine.served_model(probe: false) : nil
1736
- status_model_text(@effective_model_name, @default_model_name, served: served, served_for: served_for)
1737
- end
1738
-
1739
- def status_context_segment
1740
- status_context_text(server: @latest_server_context_status, estimate: @latest_context_status)
1741
- end
1742
-
1743
- def status_memory_segment(scope)
1744
- names, limit = case scope
1745
- when :spinner
1746
- [Array(@thinking_memory_names), MEMORY_SPINNER_PREVIEW_LIMIT]
1747
- else
1748
- [Array(@session_memory_names), MEMORY_STICKY_PREVIEW_LIMIT]
1749
- end
1750
- status_memory_text(names, limit)
1751
- end
1752
-
1753
- def thinking_memory_notification_suffix
1754
- memory_name = @thinking_recent_memory_loaded.to_s.strip
1755
- return "" if memory_name.empty?
1756
-
1757
- " memory_loaded: #{memory_name}"
1758
- end
1759
-
1760
- def thinking_tool_notification_suffix
1761
- tool_call = @thinking_recent_tool_call.to_s.strip
1762
- return "" if tool_call.empty?
1763
-
1764
- " last_tool: #{tool_call}"
1765
- end
1766
-
1767
- def thinking_notification_segments(width)
1768
- return ["", ""] if width <= 0
1769
-
1770
- memory_suffix = cap_preview_text(thinking_memory_notification_suffix, width)
1771
- remaining = [width - memory_suffix.length, 0].max
1772
- tool_suffix = cap_preview_text(thinking_tool_notification_suffix, remaining)
1773
- [memory_suffix, tool_suffix]
1774
- end
1775
-
1776
- def paint_if_present(text, code)
1777
- return "" if text.to_s.empty?
1778
-
1779
- paint(text, code)
1780
- end
1781
-
1782
- def thinking_spinner_enabled?
1783
- return false unless $stdout.tty?
1784
-
1785
- mode = ENV.fetch(THINKING_UI_ENV, THINKING_UI_SPINNER).to_s.strip.downcase
1786
- return false if mode.empty? || mode == THINKING_UI_OFF || mode == "false" || mode == "0"
1787
-
1788
- mode == THINKING_UI_SPINNER && ENV.fetch("TERM", "") != "dumb"
1789
- end
1790
-
1791
- def set_retry_spinner_status(event)
1792
- @retry_spinner_status = {
1793
- attempt: event[:attempt],
1794
- max_retries: event[:max_retries],
1795
- next_delay: event[:next_delay],
1796
- error_class: event[:error_class]
1797
- }
1798
- end
1799
-
1800
- def clear_retry_spinner_status
1801
- @retry_spinner_status = nil
1802
- end
1803
-
1804
- def retry_spinner_status_active?
1805
- @retry_spinner_status.is_a?(Hash)
1806
- end
1807
1112
 
1808
1113
  # Reset the Engine's shared inactivity clock when a prompt opens (a
1809
1114
  # Reline.pre_input_hook) and on each key typed (RelineSeam.key_handler). This is the
@@ -1825,79 +1130,6 @@ module Samagotchi
1825
1130
  RelineSeam.key_handler = previous_key_handler
1826
1131
  end
1827
1132
 
1828
- def render_thinking_spinner
1829
- frame = THINKING_SPINNER_FRAMES[@thinking_spinner_index % THINKING_SPINNER_FRAMES.length]
1830
- width = status_effective_width
1831
- @thinking_line.tick
1832
- # The spinner goes above the prompt, the status rows below it.
1833
- @surface.set_slots(activity: thinking_spinner_status_lines(frame, width: width), status: spinner_status_lines(width: width))
1834
- @thinking_spinner_last_render_at = monotonic_time
1835
- end
1836
-
1837
- # "model> waiting for the first token... 5s |" once a generation has
1838
- # shown nothing for THINKING_WAIT_NOTICE_AFTER seconds, else nil.
1839
- def first_token_wait_status(frame)
1840
- return nil unless @thinking_waiting_since
1841
-
1842
- waited = monotonic_time - @thinking_waiting_since
1843
- return nil if waited < THINKING_WAIT_NOTICE_AFTER
1844
-
1845
- "model> waiting for the first token... #{waited.floor}s #{frame}"
1846
- end
1847
-
1848
- def thinking_spinner_status_line(frame)
1849
- lines = thinking_spinner_status_lines(frame)
1850
- lines.empty? ? "" : lines.first
1851
- end
1852
-
1853
- # The spinner row, then (from a turn's 3rd tool call) its tool tally.
1854
- # The spinner stops while tools run, so the tally shows while the model
1855
- # generates between tool rounds.
1856
- def thinking_spinner_status_lines(frame, width: status_effective_width)
1857
- spinner_row_lines(frame, width: width) + tally_status_lines(width)
1858
- end
1859
-
1860
- def tally_status_lines(width)
1861
- tally = @turn_tally.text(width: width)
1862
- return [] unless tally
1863
-
1864
- [color_output? ? paint(tally, 90) : tally]
1865
- end
1866
-
1867
- def tally_key(event)
1868
- [event[:iteration].to_i, event[:call_index].to_i]
1869
- end
1870
-
1871
- def spinner_row_lines(frame, width:)
1872
- if retry_spinner_status_active?
1873
- return [retry_spinner_status_line(frame, width)]
1874
- end
1875
-
1876
- return ["#{@init_wait_status} #{frame}"[0, width]] if @init_wait_status
1877
-
1878
- base = first_token_wait_status(frame) || "model> thinking... #{frame}"
1879
- available_for_notification = [width - base.length, 0].max
1880
- memory_notification, tool_notification = thinking_notification_segments(available_for_notification)
1881
- notification = "#{memory_notification}#{tool_notification}"
1882
- base = thinking_sentence_base(frame, width - notification.length) || base
1883
-
1884
- return ["#{base}#{notification}"] unless color_output?
1885
-
1886
- ["#{paint(base, 90)}#{paint_if_present(memory_notification, MEMORY_SPINNER_COLOR)}#{paint_if_present(tool_notification, TOOL_SPINNER_COLOR)}"]
1887
- end
1888
-
1889
- # "model> thinking · <the newest sentence> |" within +room+ columns (the
1890
- # web's thinking ticker, ThinkingLine), or nil before the first sentence.
1891
- def thinking_sentence_base(frame, room)
1892
- return nil if @thinking_waiting_since || @thinking_line.empty?
1893
-
1894
- prefix = "model> #{@thinking_line.label} · "
1895
- room -= prefix.length + frame.length + 1
1896
- return nil if room < MIN_SENTENCE_ROOM
1897
-
1898
- "#{prefix}#{@thinking_line.fit(room)} #{frame}"
1899
- end
1900
-
1901
1133
  # ── Ask-user-question adapter (generic TUI renderer) ─────────────────────
1902
1134
 
1903
1135
  # Non-blocking observer: the turn thread emits :question_requested; we stash
@@ -1917,10 +1149,6 @@ module Samagotchi
1917
1149
  nil
1918
1150
  end
1919
1151
 
1920
- def pending_question_event?
1921
- !!@pending_question_event
1922
- end
1923
-
1924
1152
  # Called from REPL thread (run_assist_loop top) — renders widget, blocks
1925
1153
  # until user selects, then answers via Engine#answer_question which wakes
1926
1154
  # the parked turn thread.
@@ -1960,8 +1188,11 @@ module Samagotchi
1960
1188
  def render_question_widget(pending)
1961
1189
  prompt = QuestionPrompt.new(pending)
1962
1190
 
1963
- # Ensure spinner cleared and terminal in known state (same as reminder mute handling)
1964
- finish_thinking_spinner rescue nil
1191
+ # The activity row goes while the question waits.
1192
+ @view.finish_thinking_spinner
1193
+ # An edit's diff goes above the slot, into the scrollback.
1194
+ preview = prompt.preview_lines(paint: method(:paint))
1195
+ @surface.commit(preview.join("\n")) unless preview.empty?
1965
1196
  choices = prompt.slot(paint: method(:paint))
1966
1197
 
1967
1198
  question_prompt = paint(QUESTION_PROMPT, 33)
@@ -2049,17 +1280,5 @@ module Samagotchi
2049
1280
  return :canceled if controller&.cancelled?
2050
1281
  end
2051
1282
  end
2052
-
2053
- # Process a prompt through the kernel loop and return the model response.
2054
- # Delegates to the internal Engine instance.
2055
- def process_prompt_through_kernel(session, prompt)
2056
- @engine.process_prompt_through_kernel(session, prompt)
2057
- end
2058
-
2059
- # Write an agent response to the session output directory.
2060
- def write_session_output(output_dir, response)
2061
- timestamp = Time.now.strftime("%Y%m%d%H%M%S%9N")
2062
- File.write(File.join(output_dir, "#{timestamp}.txt"), response.to_s)
2063
- end
2064
1283
  end
2065
1284
  end