samagotchi 0.2.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +43 -0
- data/LICENSE +21 -0
- data/README.md +126 -0
- data/bin/chi +1140 -0
- data/docs/architecture.md +299 -0
- data/docs/cli.md +490 -0
- data/docs/configuration.md +494 -0
- data/docs/desktop.md +97 -0
- data/docs/guardrails.md +218 -0
- data/docs/hooks.md +309 -0
- data/docs/internals/background-tasks.md +26 -0
- data/docs/internals/context-telemetry.md +36 -0
- data/docs/internals/gemma4-contract.md +23 -0
- data/docs/internals/tool-guardrails.md +45 -0
- data/docs/memory.md +85 -0
- data/docs/plugins.md +819 -0
- data/docs/releasing.md +135 -0
- data/docs/sessions.md +155 -0
- data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
- data/lib/samagotchi/bridge/card_store.rb +126 -0
- data/lib/samagotchi/bridge/event_id.rb +25 -0
- data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
- data/lib/samagotchi/bridge/sse_writer.rb +248 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
- data/lib/samagotchi/bridge.rb +993 -0
- data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
- data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
- data/lib/samagotchi/bridge_client.rb +330 -0
- data/lib/samagotchi/bundle_needs.rb +97 -0
- data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
- data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
- data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
- data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
- data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
- data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
- data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
- data/lib/samagotchi/bundles/system/delegated.md +10 -0
- data/lib/samagotchi/bundles/system/identity.md +7 -0
- data/lib/samagotchi/bundles/system/manifest.yml +11 -0
- data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
- data/lib/samagotchi/bundles/system/self_map.md +55 -0
- data/lib/samagotchi/cancellation_controller.rb +78 -0
- data/lib/samagotchi/client.rb +429 -0
- data/lib/samagotchi/commands/registry.rb +112 -0
- data/lib/samagotchi/config.rb +910 -0
- data/lib/samagotchi/context_note.rb +77 -0
- data/lib/samagotchi/context_quote.rb +21 -0
- data/lib/samagotchi/context_usage.rb +66 -0
- data/lib/samagotchi/context_window.rb +76 -0
- data/lib/samagotchi/debug_log.rb +110 -0
- data/lib/samagotchi/desktop/macos/App.swift +102 -0
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
- data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
- data/lib/samagotchi/desktop/macos.rb +255 -0
- data/lib/samagotchi/desktop.rb +21 -0
- data/lib/samagotchi/desktop_command.rb +143 -0
- data/lib/samagotchi/engine.rb +2807 -0
- data/lib/samagotchi/guardrails/approval.rb +125 -0
- data/lib/samagotchi/guardrails/approvals.rb +177 -0
- data/lib/samagotchi/guardrails/context.rb +71 -0
- data/lib/samagotchi/guardrails/gate.rb +125 -0
- data/lib/samagotchi/guardrails/load_failures.rb +46 -0
- data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
- data/lib/samagotchi/guardrails/rules.rb +199 -0
- data/lib/samagotchi/guardrails/targets.rb +119 -0
- data/lib/samagotchi/guardrails/verdict.rb +134 -0
- data/lib/samagotchi/guardrails.rb +18 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
- data/lib/samagotchi/hooks/loader.rb +162 -0
- data/lib/samagotchi/hooks/registry.rb +261 -0
- data/lib/samagotchi/hooks.rb +30 -0
- data/lib/samagotchi/host_registry.rb +315 -0
- data/lib/samagotchi/idle_client.rb +147 -0
- data/lib/samagotchi/idle_recap.rb +549 -0
- data/lib/samagotchi/idle_reminders.rb +101 -0
- data/lib/samagotchi/idle_scheduler.rb +76 -0
- data/lib/samagotchi/image_store.rb +393 -0
- data/lib/samagotchi/installed_gem.rb +38 -0
- data/lib/samagotchi/kernel_loop.rb +1017 -0
- data/lib/samagotchi/launch_mode.rb +34 -0
- data/lib/samagotchi/llm/backend.rb +28 -0
- data/lib/samagotchi/llm/chat_loop.rb +450 -0
- data/lib/samagotchi/llm/errors.rb +329 -0
- data/lib/samagotchi/llm/http.rb +412 -0
- data/lib/samagotchi/llm/model_result.rb +72 -0
- data/lib/samagotchi/llm/native_backend.rb +50 -0
- data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
- data/lib/samagotchi/llm/openai_chat.rb +403 -0
- data/lib/samagotchi/llm/usage.rb +79 -0
- data/lib/samagotchi/log.rb +200 -0
- data/lib/samagotchi/log_line.rb +127 -0
- data/lib/samagotchi/log_path.rb +31 -0
- data/lib/samagotchi/log_subscriber.rb +163 -0
- data/lib/samagotchi/memory_bundle/builder.rb +364 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
- data/lib/samagotchi/memory_bundle/installer.rb +528 -0
- data/lib/samagotchi/memory_bundle/listing.rb +72 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
- data/lib/samagotchi/memory_bundle/merger.rb +52 -0
- data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
- data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
- data/lib/samagotchi/memory_bundle/source.rb +153 -0
- data/lib/samagotchi/memory_bundle/status.rb +107 -0
- data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
- data/lib/samagotchi/memory_bundle.rb +17 -0
- data/lib/samagotchi/memory_paths.rb +101 -0
- data/lib/samagotchi/model_overlay.rb +53 -0
- data/lib/samagotchi/model_profile.rb +309 -0
- data/lib/samagotchi/muted_memories.rb +66 -0
- data/lib/samagotchi/note_command.rb +163 -0
- data/lib/samagotchi/output_formatter.rb +100 -0
- data/lib/samagotchi/owner_lock.rb +110 -0
- data/lib/samagotchi/pending_input_queue.rb +48 -0
- data/lib/samagotchi/plugin/api.rb +362 -0
- data/lib/samagotchi/plugin/context.rb +193 -0
- data/lib/samagotchi/plugin/loader.rb +126 -0
- data/lib/samagotchi/plugin/service.rb +117 -0
- data/lib/samagotchi/plugin/sessions.rb +150 -0
- data/lib/samagotchi/plugin/side_question.rb +60 -0
- data/lib/samagotchi/plugin/tool_result.rb +24 -0
- data/lib/samagotchi/project_scope.rb +25 -0
- data/lib/samagotchi/prompt.rb +119 -0
- data/lib/samagotchi/prompt_literal_guard.rb +70 -0
- data/lib/samagotchi/recap_store.rb +92 -0
- data/lib/samagotchi/reminder_store.rb +165 -0
- data/lib/samagotchi/self_report.rb +195 -0
- data/lib/samagotchi/send_command.rb +170 -0
- data/lib/samagotchi/served_model.rb +32 -0
- data/lib/samagotchi/session.rb +508 -0
- data/lib/samagotchi/session_commands.rb +527 -0
- data/lib/samagotchi/session_delete_command.rb +105 -0
- data/lib/samagotchi/session_manager.rb +1049 -0
- data/lib/samagotchi/session_metrics.rb +466 -0
- data/lib/samagotchi/session_observer.rb +117 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
- data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
- data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
- data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
- data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
- data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
- data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
- data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
- data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
- data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
- data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
- data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
- data/lib/samagotchi/terminal_ui/screen.rb +316 -0
- data/lib/samagotchi/terminal_ui/surface.rb +47 -0
- data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
- data/lib/samagotchi/terminal_ui.rb +1992 -0
- data/lib/samagotchi/thinking_ticker.rb +110 -0
- data/lib/samagotchi/thought_stream_splitter.rb +149 -0
- data/lib/samagotchi/token_usage.rb +88 -0
- data/lib/samagotchi/tool_activity.rb +216 -0
- data/lib/samagotchi/tool_call_parser.rb +637 -0
- data/lib/samagotchi/tool_declarations.rb +561 -0
- data/lib/samagotchi/tool_runner.rb +211 -0
- data/lib/samagotchi/tools/args.rb +259 -0
- data/lib/samagotchi/tools/ask_user_question.rb +152 -0
- data/lib/samagotchi/tools/builtins.rb +122 -0
- data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
- data/lib/samagotchi/tools/delegate.rb +167 -0
- data/lib/samagotchi/tools/delegate_result.rb +53 -0
- data/lib/samagotchi/tools/delegate_wait.rb +153 -0
- data/lib/samagotchi/tools/edit.rb +155 -0
- data/lib/samagotchi/tools/execute.rb +214 -0
- data/lib/samagotchi/tools/list_reminders.rb +20 -0
- data/lib/samagotchi/tools/list_sessions.rb +74 -0
- data/lib/samagotchi/tools/memory.rb +256 -0
- data/lib/samagotchi/tools/output_guardrails.rb +93 -0
- data/lib/samagotchi/tools/peers.rb +18 -0
- data/lib/samagotchi/tools/read.rb +182 -0
- data/lib/samagotchi/tools/register_reminder.rb +53 -0
- data/lib/samagotchi/tools/registry.rb +60 -0
- data/lib/samagotchi/tools/send_note.rb +49 -0
- data/lib/samagotchi/tools/task_create.rb +29 -0
- data/lib/samagotchi/tools/task_get.rb +39 -0
- data/lib/samagotchi/tools/task_list.rb +43 -0
- data/lib/samagotchi/tools/task_runtime.rb +311 -0
- data/lib/samagotchi/tools/task_stop.rb +29 -0
- data/lib/samagotchi/tools/task_wait.rb +104 -0
- data/lib/samagotchi/tools/tool_path.rb +18 -0
- data/lib/samagotchi/tools/web_fetch.rb +163 -0
- data/lib/samagotchi/tools/write.rb +26 -0
- data/lib/samagotchi/turn_flow.rb +242 -0
- data/lib/samagotchi/turn_note.rb +76 -0
- data/lib/samagotchi/turn_tally.rb +101 -0
- data/lib/samagotchi/version.rb +7 -0
- data/lib/samagotchi/vision_context.rb +132 -0
- data/lib/samagotchi/vision_support.rb +109 -0
- data/lib/samagotchi/web/app.rb +1349 -0
- data/lib/samagotchi/web/markdown_renderer.rb +107 -0
- data/lib/samagotchi/web/message_parts.rb +169 -0
- data/lib/samagotchi/web/public/activity.js +100 -0
- data/lib/samagotchi/web/public/annotations.js +67 -0
- data/lib/samagotchi/web/public/app.js +2382 -0
- data/lib/samagotchi/web/public/card.js +74 -0
- data/lib/samagotchi/web/public/chat_view.js +360 -0
- data/lib/samagotchi/web/public/chunk_router.js +25 -0
- data/lib/samagotchi/web/public/command_complete.js +39 -0
- data/lib/samagotchi/web/public/composer_size.js +19 -0
- data/lib/samagotchi/web/public/copy.js +142 -0
- data/lib/samagotchi/web/public/ctx.js +35 -0
- data/lib/samagotchi/web/public/data.js +256 -0
- data/lib/samagotchi/web/public/format.js +232 -0
- data/lib/samagotchi/web/public/hold.js +78 -0
- data/lib/samagotchi/web/public/images.js +77 -0
- data/lib/samagotchi/web/public/index.html +568 -0
- data/lib/samagotchi/web/public/init_row.js +60 -0
- data/lib/samagotchi/web/public/model_pick.js +23 -0
- data/lib/samagotchi/web/public/question_card.js +100 -0
- data/lib/samagotchi/web/public/route.js +17 -0
- data/lib/samagotchi/web/public/scope.js +36 -0
- data/lib/samagotchi/web/public/scroll.js +24 -0
- data/lib/samagotchi/web/public/sentences.js +88 -0
- data/lib/samagotchi/web/public/sessions_list.js +60 -0
- data/lib/samagotchi/web/public/strip.js +25 -0
- data/lib/samagotchi/web/public/tally.js +37 -0
- data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
- data/lib/samagotchi/web/public/timing.js +185 -0
- data/lib/samagotchi/web/public/turn_events.js +209 -0
- data/lib/samagotchi/web/public/turn_model.js +204 -0
- data/lib/samagotchi/web/public/turn_view.js +587 -0
- data/lib/samagotchi/web/server.rb +183 -0
- data/lib/samagotchi/web/session_hub.rb +329 -0
- data/lib/samagotchi/web/session_summary.rb +85 -0
- data/lib/samagotchi/worker.rb +635 -0
- data/lib/samagotchi/worker_idle_exit.rb +87 -0
- data/lib/samagotchi.rb +12 -0
- metadata +374 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
require_relative "surface"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
class TerminalUI
|
|
8
|
+
# The REPL's Surface before the live region: plain output where the
|
|
9
|
+
# cursor is. Only the activity slot is live. It redraws in place by
|
|
10
|
+
# moving back up over the rows it drew last (ESC[nA), which works only
|
|
11
|
+
# while nothing else writes below it. The editor slot prints a prompt
|
|
12
|
+
# for a plain read, and clearing it erases the line Reline drew. The other
|
|
13
|
+
# slots just print their rows, except status rows set together with the
|
|
14
|
+
# activity rows (#set_slots): those are the activity block's last rows.
|
|
15
|
+
class LegacySurface
|
|
16
|
+
include Surface
|
|
17
|
+
|
|
18
|
+
# @param out [IO, nil] nil writes to $stdout as it is at each write
|
|
19
|
+
# (specs swap it after the UI is built)
|
|
20
|
+
def initialize(out: nil)
|
|
21
|
+
@out = out
|
|
22
|
+
@activity_shown = false
|
|
23
|
+
@activity_rows = 0
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def commit(text)
|
|
27
|
+
out.puts(text)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def set_slot(name, rows)
|
|
31
|
+
check_slot!(name)
|
|
32
|
+
case name
|
|
33
|
+
when :activity then redraw_activity(rows)
|
|
34
|
+
when :editor
|
|
35
|
+
out.print(rows.join("\n"))
|
|
36
|
+
out.flush
|
|
37
|
+
else lay_out(rows, width: columns).each { |row| out.puts(row) }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def set_slots(**rows_by_slot)
|
|
42
|
+
return super unless rows_by_slot.key?(:activity)
|
|
43
|
+
|
|
44
|
+
activity = rows_by_slot.delete(:activity)
|
|
45
|
+
status = rows_by_slot.delete(:status)
|
|
46
|
+
set_slot(:activity, Array(activity) + Array(status))
|
|
47
|
+
super(**rows_by_slot)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def clear_slot(name)
|
|
51
|
+
check_slot!(name)
|
|
52
|
+
case name
|
|
53
|
+
when :activity then erase_activity
|
|
54
|
+
when :editor
|
|
55
|
+
out.print("\r\e[2K")
|
|
56
|
+
out.flush
|
|
57
|
+
true
|
|
58
|
+
else false
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def out = @out || $stdout
|
|
65
|
+
|
|
66
|
+
def columns
|
|
67
|
+
IO.console&.winsize&.last || 80
|
|
68
|
+
rescue StandardError
|
|
69
|
+
80
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Pad to the taller of the old and new heights, so rows left over from
|
|
73
|
+
# a taller frame are blanked.
|
|
74
|
+
def redraw_activity(rows)
|
|
75
|
+
move_to_activity_origin
|
|
76
|
+
row_count = [@activity_rows, rows.length].max
|
|
77
|
+
padded = rows + Array.new(row_count - rows.length, "")
|
|
78
|
+
out.print(padded.map { |text| "#{text}\e[0K" }.join("\n"))
|
|
79
|
+
out.flush
|
|
80
|
+
@activity_shown = true
|
|
81
|
+
@activity_rows = row_count
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def move_to_activity_origin
|
|
85
|
+
return unless @activity_shown
|
|
86
|
+
|
|
87
|
+
out.print("\e[#{@activity_rows - 1}A") if @activity_rows > 1
|
|
88
|
+
out.print("\r")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def erase_activity
|
|
92
|
+
return false unless @activity_shown
|
|
93
|
+
|
|
94
|
+
row_count = @activity_rows
|
|
95
|
+
row_count = 1 if row_count <= 0
|
|
96
|
+
out.print("\e[#{row_count - 1}A") if row_count > 1
|
|
97
|
+
out.print("\r")
|
|
98
|
+
row_count.times do |index|
|
|
99
|
+
out.print("\e[0K")
|
|
100
|
+
out.print("\n") if index < row_count - 1
|
|
101
|
+
end
|
|
102
|
+
out.print("\e[#{row_count - 1}A") if row_count > 1
|
|
103
|
+
out.print("\r")
|
|
104
|
+
out.flush
|
|
105
|
+
@activity_shown = false
|
|
106
|
+
@activity_rows = 0
|
|
107
|
+
true
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "reline"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
class TerminalUI
|
|
7
|
+
# Reads input lines on its own thread, so output keeps rendering while
|
|
8
|
+
# the user types, and hands each line (nil = Ctrl-D) or Ctrl-C to a sink
|
|
9
|
+
# (anything with #<<, such as a Thread::Queue); the attached loop and the
|
|
10
|
+
# REPL both read this way. #reprompt makes it drop the open read and start
|
|
11
|
+
# again with the current prompt (a question opened or closed); #prefill
|
|
12
|
+
# starts it again with text already typed in (a failed prompt).
|
|
13
|
+
class LineReader
|
|
14
|
+
# Raised into the reader thread; only lands inside a read.
|
|
15
|
+
class Reprompt < StandardError; end
|
|
16
|
+
# Ends the reader from inside its read, so Reline restores the terminal.
|
|
17
|
+
class Stop < StandardError; end
|
|
18
|
+
|
|
19
|
+
# @param prompt [#call] -> the prompt for the next read
|
|
20
|
+
# @param read [#call] (prompt, prefill) -> line (nil = Ctrl-D)
|
|
21
|
+
# @param prefill [String, nil] typed into the first read
|
|
22
|
+
def initialize(sink, prompt:, read:, prefill: nil)
|
|
23
|
+
@sink = sink
|
|
24
|
+
@prompt = prompt
|
|
25
|
+
@read = read
|
|
26
|
+
@current = nil
|
|
27
|
+
@prefill = prefill
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @return [String, nil] the prompt of the read in progress
|
|
31
|
+
attr_reader :current
|
|
32
|
+
|
|
33
|
+
def start
|
|
34
|
+
# Created masked (threads inherit the mask), so a Reprompt raised
|
|
35
|
+
# before #run is entered waits for the first read too.
|
|
36
|
+
Thread.handle_interrupt(Reprompt => :never, Stop => :never) { @thread = Thread.new { run } }
|
|
37
|
+
@thread.report_on_exception = false
|
|
38
|
+
self
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def alive? = @thread&.alive? || false
|
|
42
|
+
|
|
43
|
+
# Drop the open read and start it again with the current prompt.
|
|
44
|
+
# @param keep_text [Boolean] with what is typed there now
|
|
45
|
+
# @param prefill [String, nil] with this text instead
|
|
46
|
+
def reprompt(keep_text: false, prefill: nil)
|
|
47
|
+
text = keep_text ? typed_text : prefill
|
|
48
|
+
@prefill = text unless text.to_s.empty?
|
|
49
|
+
@thread&.raise(Reprompt)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Type +text+ into the next read (from the sink, between two reads).
|
|
53
|
+
def prefill_next(text)
|
|
54
|
+
@prefill = text
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @return [String, nil] what the open read holds
|
|
58
|
+
def typed_text
|
|
59
|
+
return nil unless $stdin.tty?
|
|
60
|
+
|
|
61
|
+
Reline.line_buffer.to_s
|
|
62
|
+
rescue StandardError
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Start the read again with +text+ in it, unless something is typed
|
|
67
|
+
# there already (that would be lost).
|
|
68
|
+
# @return [Boolean] whether the text went in
|
|
69
|
+
def prefill(text)
|
|
70
|
+
return false unless line_empty?
|
|
71
|
+
|
|
72
|
+
@prefill = text
|
|
73
|
+
reprompt
|
|
74
|
+
true
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def stop
|
|
78
|
+
return unless @thread&.alive?
|
|
79
|
+
|
|
80
|
+
@thread.raise(Stop)
|
|
81
|
+
@thread.join(0.5) || (@thread.kill && @thread.join(0.5))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def run
|
|
87
|
+
# A Reprompt may only interrupt the read itself; one that comes
|
|
88
|
+
# while a line is being handed over waits for the next read (and
|
|
89
|
+
# just restarts it). The mask is also inherited from #start.
|
|
90
|
+
Thread.handle_interrupt(Reprompt => :never, Stop => :never) do
|
|
91
|
+
loop do
|
|
92
|
+
@current = @prompt.call
|
|
93
|
+
prefill = @prefill
|
|
94
|
+
@prefill = nil
|
|
95
|
+
line = Thread.handle_interrupt(Reprompt => :immediate, Stop => :immediate) { @read.call(@current, prefill) }
|
|
96
|
+
@sink << [:line, line]
|
|
97
|
+
break if line.nil?
|
|
98
|
+
rescue Reprompt
|
|
99
|
+
next
|
|
100
|
+
rescue Interrupt
|
|
101
|
+
# What the line held at the press (AttachedLoop#note_interrupted_line).
|
|
102
|
+
@sink << [:interrupt, Thread.current[:interrupted_line]]
|
|
103
|
+
Thread.current[:interrupted_line] = nil
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
rescue Stop
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def line_empty? = typed_text.to_s.strip.empty?
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "screen"
|
|
4
|
+
require_relative "reline_seam"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
class TerminalUI
|
|
8
|
+
# Opens and closes a live region: a Screen with Reline drawing into it
|
|
9
|
+
# through RelineSeam. The REPL and attached mode both use one when the
|
|
10
|
+
# terminal can show it, and fall back to their own plain output otherwise.
|
|
11
|
+
module LiveRegion
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# @return [Screen, nil] a started Screen with Reline attached, or nil
|
|
15
|
+
# when the terminal can't show a live region (output or input not a
|
|
16
|
+
# terminal, TERM=dumb, or a Reline the seam doesn't support)
|
|
17
|
+
def open(out: $stdout, input: $stdin, env: ENV)
|
|
18
|
+
return unless available?(out: out, input: input, env: env)
|
|
19
|
+
|
|
20
|
+
screen = Screen.new(out: out).start
|
|
21
|
+
RelineSeam.attach(screen)
|
|
22
|
+
screen
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Hand Reline its own drawing back, then close the surface.
|
|
26
|
+
def close(surface)
|
|
27
|
+
RelineSeam.detach(surface)
|
|
28
|
+
surface.close
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def available?(out:, input:, env:)
|
|
32
|
+
out.tty? && input.tty? && env["TERM"] != "dumb" && RelineSeam.supported?
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
require "monitor"
|
|
5
|
+
require_relative "surface"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
class TerminalUI
|
|
9
|
+
# Attached mode's Surface when a live region can't be drawn (output or
|
|
10
|
+
# input not a terminal, TERM=dumb, or a Reline the seam doesn't support):
|
|
11
|
+
# append-only output. Committed text and the notes, status and hints rows
|
|
12
|
+
# print as lines. The activity slot is dropped, because redrawing it in
|
|
13
|
+
# place needs cursor movement (the turn's lines still print as they end).
|
|
14
|
+
# The prompt is Reline's (or a plain read's) own, so output may cross it.
|
|
15
|
+
class PlainSurface
|
|
16
|
+
include Surface
|
|
17
|
+
|
|
18
|
+
# @param out [IO]
|
|
19
|
+
def initialize(out:)
|
|
20
|
+
@out = out
|
|
21
|
+
@lock = Monitor.new
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def synchronize(&) = @lock.synchronize(&)
|
|
25
|
+
|
|
26
|
+
# @return [Integer] terminal width in columns
|
|
27
|
+
def columns = IO.console&.winsize&.last || 80
|
|
28
|
+
|
|
29
|
+
def commit(text)
|
|
30
|
+
synchronize { @out.puts(text) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def set_slot(name, rows)
|
|
34
|
+
check_slot!(name)
|
|
35
|
+
raise ArgumentError, "Reline draws the editor slot" if name == :editor
|
|
36
|
+
return if name == :activity
|
|
37
|
+
|
|
38
|
+
synchronize { lay_out(rows, width: columns).each { |row| @out.puts(row) } }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Nothing printed can be taken back.
|
|
42
|
+
# @return [false]
|
|
43
|
+
def clear_slot(name)
|
|
44
|
+
check_slot!(name)
|
|
45
|
+
false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def close; end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "question_slot"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
class TerminalUI
|
|
7
|
+
# An ask_user_question prompt as the terminal shows it: the widget as
|
|
8
|
+
# slot content (#slot), the line it leaves (#summary) and the parsing of
|
|
9
|
+
# a typed answer ("2", "1,3", "1 3; text", option labels). No I/O:
|
|
10
|
+
# callers draw the slot and print the messages, and
|
|
11
|
+
# record the answer (the REPL on its Engine, an attached UI over the
|
|
12
|
+
# Bridge). An approval (kind "approval") takes only a number, an exact
|
|
13
|
+
# label, y (the first option, Allow once) or n (the last, Deny), plus
|
|
14
|
+
# an optional "; reason"; a substring would pick the wrong one ("y" is
|
|
15
|
+
# in "Deny").
|
|
16
|
+
class QuestionPrompt
|
|
17
|
+
# A parsed answer. +error+ set: re-ask. +note+: print, then accept.
|
|
18
|
+
Answer = Struct.new(:selected, :freeform, :error, :note, keyword_init: true) do
|
|
19
|
+
def ok? = error.nil?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
attr_reader :id, :question, :options, :header
|
|
23
|
+
|
|
24
|
+
# @param pending [Hash] Engine#pending_question (symbol or string keys)
|
|
25
|
+
def initialize(pending)
|
|
26
|
+
field = ->(key) { pending[key] || pending[key.to_s] }
|
|
27
|
+
@id = field.(:id).to_s
|
|
28
|
+
@question = field.(:question).to_s
|
|
29
|
+
@options = Array(field.(:options)).map { |v| v.to_s.strip }.reject(&:empty?)
|
|
30
|
+
header = field.(:header).to_s.strip
|
|
31
|
+
@header = header.empty? ? nil : header
|
|
32
|
+
@multi = !!field.(:multi_select)
|
|
33
|
+
@free = !!field.(:allow_freeform)
|
|
34
|
+
@approval = field.(:kind).to_s == "approval"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def multi? = @multi
|
|
38
|
+
def free? = @free
|
|
39
|
+
def approval? = @approval
|
|
40
|
+
|
|
41
|
+
# The widget as slot content, fitted to the rows it gets (QuestionSlot).
|
|
42
|
+
# @return [QuestionSlot]
|
|
43
|
+
def slot(paint: ->(text, _code) { text })
|
|
44
|
+
first, *rest = question.lines.map(&:chomp)
|
|
45
|
+
keys = options.each_index.map { |idx| (idx + 1).to_s }
|
|
46
|
+
QuestionSlot.new(header: approval? ? (header || "Approve tool call?") : header,
|
|
47
|
+
question: first.to_s, mark: mark, details: rest,
|
|
48
|
+
options: keys.zip(options).map { |key, label| QuestionSlot::Option.new(key, label) },
|
|
49
|
+
hint: slot_hint, question_code: approval? ? 33 : 94, paint: paint)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The one line that stays in the scrollback once the question closes:
|
|
53
|
+
# the question and what became of it.
|
|
54
|
+
# @param outcome [String] the answer (#answer_text) or what closed it
|
|
55
|
+
def summary(outcome, paint: ->(text, _code) { text })
|
|
56
|
+
"#{paint.("#{mark}#{question.lines.first.to_s.chomp}", approval? ? 33 : 94)} → #{outcome}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @param answer [Answer] an accepted one
|
|
60
|
+
# @return [String] "Banana", "Apple, Cherry; ripe ones", "Deny: use a PR"
|
|
61
|
+
def answer_text(answer)
|
|
62
|
+
picked = answer.selected.to_a
|
|
63
|
+
if approval?
|
|
64
|
+
choice = picked.first || options.last
|
|
65
|
+
return answer.freeform ? "#{choice}: #{answer.freeform}" : choice
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
[picked.join(", "), answer.freeform].reject { |part| part.to_s.empty? }.join("; ")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @param raw [String] the typed line, not empty
|
|
72
|
+
# @return [Answer]
|
|
73
|
+
def parse(raw)
|
|
74
|
+
return parse_approval(raw) if approval?
|
|
75
|
+
|
|
76
|
+
raw = raw.to_s.strip
|
|
77
|
+
# "1,3; my text": the first ';' separates the selection from freeform text.
|
|
78
|
+
sel_part, free_part = raw.include?(";") ? raw.split(";", 2).map(&:strip) : [raw, nil]
|
|
79
|
+
free_part = nil if free_part&.empty?
|
|
80
|
+
# Models forget to flag freeform: accept it anyway, with a note.
|
|
81
|
+
note = free_part && !free? ? "(note: freeform not flagged but accepting '#{free_part}')" : nil
|
|
82
|
+
|
|
83
|
+
labels = []
|
|
84
|
+
sel_part.split(/[,\s]+/).reject(&:empty?).each do |tok|
|
|
85
|
+
label, error = option_for(tok)
|
|
86
|
+
return Answer.new(error: error, note: note) if error
|
|
87
|
+
|
|
88
|
+
labels << label
|
|
89
|
+
end
|
|
90
|
+
labels.uniq!
|
|
91
|
+
|
|
92
|
+
return Answer.new(error: "No selection. Try again.", note: note) if labels.empty? && free_part.nil?
|
|
93
|
+
return Answer.new(error: "This is single-select (pick one). Try again.", note: note) if !multi? && labels.size > 1
|
|
94
|
+
|
|
95
|
+
Answer.new(selected: labels, freeform: free_part, note: note)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def mark = approval? ? "! " : "? "
|
|
101
|
+
|
|
102
|
+
def slot_hint
|
|
103
|
+
if approval?
|
|
104
|
+
return "1-#{options.size}, y = #{options.first}, n = #{options.last}; add '; reason' to tell the model why; " \
|
|
105
|
+
"Enter alone denies"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
hint = [multi? ? "Select one or more (e.g. 1,3)" : "Select one (e.g. 2)"]
|
|
109
|
+
hint << "add '; text' for your own answer" if free?
|
|
110
|
+
hint << "Enter alone cancels"
|
|
111
|
+
hint.join("; ")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def parse_approval(raw)
|
|
115
|
+
sel_part, free_part = raw.to_s.strip.split(";", 2).map { |part| part.to_s.strip }
|
|
116
|
+
free_part = nil if free_part.to_s.empty?
|
|
117
|
+
return Answer.new(selected: [], freeform: free_part) if sel_part.to_s.empty? && free_part
|
|
118
|
+
|
|
119
|
+
label = approval_option(sel_part.to_s)
|
|
120
|
+
unless label
|
|
121
|
+
return Answer.new(error: "Answer with 1-#{options.size}, y (#{options.first}) or n (#{options.last}).")
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
Answer.new(selected: [label], freeform: free_part)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def approval_option(token)
|
|
128
|
+
down = token.downcase
|
|
129
|
+
return options.first if %w[y yes].include?(down)
|
|
130
|
+
return options.last if %w[n no].include?(down)
|
|
131
|
+
|
|
132
|
+
if token.match?(/\A\d+\z/)
|
|
133
|
+
idx = token.to_i - 1
|
|
134
|
+
return idx.between?(0, options.size - 1) ? options[idx] : nil
|
|
135
|
+
end
|
|
136
|
+
options.find { |o| o.downcase == down }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# A number, or an option label (exact or substring, any case).
|
|
140
|
+
def option_for(tok)
|
|
141
|
+
if tok.match?(/\A\d+\z/)
|
|
142
|
+
idx = tok.to_i - 1
|
|
143
|
+
return [nil, "Invalid choice '#{tok}': pick 1-#{options.size}"] if idx.negative? || idx >= options.size
|
|
144
|
+
|
|
145
|
+
return [options[idx], nil]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
found = options.find { |o| o.downcase == tok.downcase || o.downcase.include?(tok.downcase) }
|
|
149
|
+
found ? [found, nil] : [nil, "Unknown option '#{tok}'. Use numbers 1-#{options.size} or exact labels."]
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "reline"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
class TerminalUI
|
|
7
|
+
# A question's rows as slot content (Surface): laid out afresh for the
|
|
8
|
+
# rows it gets. With room it shows everything, top down: a header, the
|
|
9
|
+
# question (wrapped), detail lines, one row per option and a hint row.
|
|
10
|
+
# When short, rows give way in this order: the hint, the details (and
|
|
11
|
+
# the question's wrapped rows), the header; then the options fold into
|
|
12
|
+
# as few packed rows as the width allows. The question's first row and
|
|
13
|
+
# the options stay to the last.
|
|
14
|
+
class QuestionSlot
|
|
15
|
+
# A numbered option shows as "1) Apple", a word as "yes: continue".
|
|
16
|
+
Option = Struct.new(:key, :label) do
|
|
17
|
+
def text = key.match?(/\A\d+\z/) ? "#{key}) #{label}" : "#{key}: #{label}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
CONTINUE_QUESTION = "The turn ran out of iterations. Continue it?"
|
|
21
|
+
|
|
22
|
+
# A continue offer (TurnFlow): what the answers at the ? prompt do.
|
|
23
|
+
# @param context [Hash, nil] the offer's context (last_model_intent)
|
|
24
|
+
def self.continue_offer(context = nil, paint: ->(text, _code) { text })
|
|
25
|
+
intent = (context || {})[:last_model_intent] || (context || {})["last_model_intent"]
|
|
26
|
+
details = intent.to_s.strip.empty? ? [] : [" last step: #{intent.to_s.strip.tr("\n", " ")}"]
|
|
27
|
+
new(question: CONTINUE_QUESTION, mark: "? ", details: details, question_code: 33, paint: paint,
|
|
28
|
+
options: [Option.new("yes", "continue (Enter alone too)"), Option.new("no", "stop here"),
|
|
29
|
+
Option.new("no, <reason>", "stop and tell the model why")])
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The line a continue offer leaves in the scrollback.
|
|
33
|
+
def self.continue_summary(outcome, paint: ->(text, _code) { text })
|
|
34
|
+
"#{paint.("? #{CONTINUE_QUESTION}", 33)} → #{outcome}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @param question [String] the first row's text, wrapped when there is room
|
|
38
|
+
# @param mark [String] its prefix ("? ", "! ")
|
|
39
|
+
# @param options [Array<Option>] key ("1", "yes") and label
|
|
40
|
+
# @param paint [#call] (text, code) -> coloured text
|
|
41
|
+
def initialize(question:, mark:, options:, header: nil, details: [], hint: nil, question_code: 94,
|
|
42
|
+
paint: ->(text, _code) { text })
|
|
43
|
+
@header = header
|
|
44
|
+
@question = question.to_s
|
|
45
|
+
@mark = mark
|
|
46
|
+
@details = details
|
|
47
|
+
@options = options
|
|
48
|
+
@hint = hint
|
|
49
|
+
@question_code = question_code
|
|
50
|
+
@paint = paint
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @param width [Integer]
|
|
54
|
+
# @param height [Integer, nil] nil: no limit
|
|
55
|
+
# @return [Array<String>]
|
|
56
|
+
def fit(width:, height:)
|
|
57
|
+
width = [width.to_i, 10].max
|
|
58
|
+
full = layout(width, hint: true, details: true, header: true, folded: false)
|
|
59
|
+
return full if height.nil? || full.size <= height
|
|
60
|
+
|
|
61
|
+
[
|
|
62
|
+
{ hint: false, details: true, header: true, folded: false },
|
|
63
|
+
{ hint: false, details: false, header: true, folded: false },
|
|
64
|
+
{ hint: false, details: false, header: false, folded: false },
|
|
65
|
+
{ hint: false, details: false, header: false, folded: true }
|
|
66
|
+
].each do |level|
|
|
67
|
+
rows = layout(width, **level)
|
|
68
|
+
return rows if rows.size <= height
|
|
69
|
+
end
|
|
70
|
+
layout(width, hint: false, details: false, header: false, folded: true).first([height, 0].max)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def layout(width, hint:, details:, header:, folded:)
|
|
76
|
+
rows = []
|
|
77
|
+
rows << @paint.(@header, 1) if header && @header
|
|
78
|
+
question_rows = wrap(@question, width, first: @mark, indent: " " * @mark.size)
|
|
79
|
+
question_rows = question_rows.first(1) unless details
|
|
80
|
+
rows.concat(question_rows.map { |row| @paint.(row, @question_code) })
|
|
81
|
+
rows.concat(@details.map { |row| @paint.(row, @question_code) }) if details
|
|
82
|
+
rows.concat(folded ? folded_options(width) : @options.map { |o| @paint.(" #{o.text}", 92) })
|
|
83
|
+
rows << @paint.(" [#{@hint}]", 90) if hint && @hint
|
|
84
|
+
rows
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The options packed into rows of +width+: " 1) Allow once · 2) Deny".
|
|
88
|
+
def folded_options(width)
|
|
89
|
+
rows = []
|
|
90
|
+
line = +""
|
|
91
|
+
@options.each do |option|
|
|
92
|
+
item = option.text
|
|
93
|
+
candidate = line.empty? ? " #{item}" : "#{line} · #{item}"
|
|
94
|
+
if !line.empty? && display_width(candidate) > width
|
|
95
|
+
rows << line
|
|
96
|
+
line = +" #{item}"
|
|
97
|
+
else
|
|
98
|
+
line = +candidate
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
rows << line unless line.empty?
|
|
102
|
+
rows.map { |row| @paint.(row, 92) }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Word wrap at +width+ columns, the first row after +first+, the others
|
|
106
|
+
# after +indent+; a word longer than a row is cut.
|
|
107
|
+
def wrap(text, width, first:, indent:)
|
|
108
|
+
rows = []
|
|
109
|
+
line = first.dup
|
|
110
|
+
words = false
|
|
111
|
+
text.split(/(?<= )/).each do |word|
|
|
112
|
+
if words && display_width(line + word.rstrip) > width
|
|
113
|
+
rows << line.rstrip
|
|
114
|
+
line = indent.dup
|
|
115
|
+
end
|
|
116
|
+
line << word
|
|
117
|
+
words = true
|
|
118
|
+
while display_width(line.rstrip) > width
|
|
119
|
+
head, = Reline::Unicode.take_mbchar_range(line, 0, width, padding: false)
|
|
120
|
+
rows << head
|
|
121
|
+
line = +"#{indent}#{line[head.size..]}"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
rows << line.rstrip unless line.strip.empty? && !rows.empty?
|
|
125
|
+
rows
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def display_width(text) = Reline::Unicode.calculate_width(text, true)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|