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,216 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "reline"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
class TerminalUI
|
|
7
|
+
# Prepended to Reline::LineEditor: while a Screen is attached, Reline
|
|
8
|
+
# keeps doing all the input work (keys, history, completion dialogs,
|
|
9
|
+
# multiline, paste) but draws nothing itself. Its rendered rows go to the
|
|
10
|
+
# Screen's editor slot, and a finished prompt goes to scrollback through
|
|
11
|
+
# the Screen. With no Screen attached every method is Reline's own.
|
|
12
|
+
#
|
|
13
|
+
# This overrides private methods of reline 0.6.x (the Gemfile pins it).
|
|
14
|
+
# .supported? checks that they are all still there; without them attached
|
|
15
|
+
# mode falls back to plain output. What each override relies on:
|
|
16
|
+
# - render_differential(lines, cursor_x, cursor_y): called by #render
|
|
17
|
+
# with the rows it laid out; the seam hands them to the Screen and keeps
|
|
18
|
+
# @rendered_screen as if Reline had drawn them.
|
|
19
|
+
# - reset: asks the terminal for the cursor row (base_y), which decides
|
|
20
|
+
# whether a dialog opens below the prompt. The region's editor rows
|
|
21
|
+
# start at 0.
|
|
22
|
+
# - screen_height: the rows Reline may draw in, cut to the Screen's
|
|
23
|
+
# editor budget so the region fits (Reline scrolls the input inside it).
|
|
24
|
+
# Overriding the method, not @screen_size, survives reset and resizes.
|
|
25
|
+
# - update(key): every key a read takes, for the key handler.
|
|
26
|
+
# - render_finished (Enter), handle_interrupted (Ctrl-C), finalize (every
|
|
27
|
+
# read, including one dropped by Thread#raise), ed_clear_screen and its
|
|
28
|
+
# alias clear_screen (Ctrl-L), handle_resized (SIGWINCH, SIGCONT).
|
|
29
|
+
module RelineSeam
|
|
30
|
+
# Reline's methods the seam overrides or calls, with their arities.
|
|
31
|
+
METHODS = { reset: -1, finalize: 0, render_finished: 0, screen_height: 0, screen_width: 0,
|
|
32
|
+
prompt_list: 0, modified_lines: 0, clear_dialogs: 0, scroll_into_view: 0, render: 0,
|
|
33
|
+
render_differential: 3, handle_interrupted: 0, handle_resized: 0,
|
|
34
|
+
clear_rendered_screen_cache: 0, ed_clear_screen: 1, clear_screen: 1,
|
|
35
|
+
split_line_by_width: -3, update: 1 }.freeze
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
# @return [Screen, nil] where Reline draws, nil for Reline's own drawing
|
|
39
|
+
attr_reader :screen
|
|
40
|
+
|
|
41
|
+
# Asked first on Ctrl-C during a read, with or without a screen: a
|
|
42
|
+
# truthy answer means it handled the key (the REPL cancelled a running
|
|
43
|
+
# turn) and the read goes on with the typed text as it is.
|
|
44
|
+
# @return [#call, nil]
|
|
45
|
+
attr_accessor :interrupt_handler
|
|
46
|
+
|
|
47
|
+
# Called with no arguments for each key a read takes (typing is
|
|
48
|
+
# activity for the REPL's idle clock).
|
|
49
|
+
# @return [#call, nil]
|
|
50
|
+
attr_accessor :key_handler
|
|
51
|
+
|
|
52
|
+
# @return [Boolean] a Reline read is open (it owns stdin)
|
|
53
|
+
def reading? = @reading == true
|
|
54
|
+
|
|
55
|
+
# Run a read (on this thread) whose submitted line leaves nothing in
|
|
56
|
+
# the scrollback: the answer to a question, which commits its own
|
|
57
|
+
# summary line instead.
|
|
58
|
+
def without_echo
|
|
59
|
+
was = Thread.current[:samagotchi_reline_no_echo]
|
|
60
|
+
Thread.current[:samagotchi_reline_no_echo] = true
|
|
61
|
+
yield
|
|
62
|
+
ensure
|
|
63
|
+
Thread.current[:samagotchi_reline_no_echo] = was
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @api private
|
|
67
|
+
attr_writer :reading
|
|
68
|
+
|
|
69
|
+
# @return [Boolean] this Reline has every method the seam relies on
|
|
70
|
+
def supported?
|
|
71
|
+
defined?(Reline::LineEditor::RenderedScreen) &&
|
|
72
|
+
(%i[base_y lines cursor_y] - Reline::LineEditor::RenderedScreen.members).empty? &&
|
|
73
|
+
METHODS.all? { |name, arity| reline_method(name)&.arity == arity }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Reline draws into +screen+ from its next render on.
|
|
77
|
+
def attach(screen)
|
|
78
|
+
# Measuring a character like … or │ makes Reline print one and ask
|
|
79
|
+
# the terminal where the cursor went, the first time only. Do it
|
|
80
|
+
# now, before the Screen measures rows from other threads while a
|
|
81
|
+
# read owns stdin.
|
|
82
|
+
Reline.ambiguous_width
|
|
83
|
+
install
|
|
84
|
+
@screen = screen
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Put the seam in Reline without a screen: Reline draws as usual, and
|
|
88
|
+
# reading? and the interrupt handler work.
|
|
89
|
+
def install
|
|
90
|
+
Reline::LineEditor.prepend(self) unless Reline::LineEditor.include?(self)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def detach(screen)
|
|
94
|
+
@screen = nil if @screen.equal?(screen)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
# Reline's own implementation, past this module once it is prepended.
|
|
100
|
+
def reline_method(name)
|
|
101
|
+
method = Reline::LineEditor.instance_method(name)
|
|
102
|
+
method = method.super_method while method&.owner == self
|
|
103
|
+
method
|
|
104
|
+
rescue NameError
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def reset(...)
|
|
110
|
+
super
|
|
111
|
+
RelineSeam.reading = true
|
|
112
|
+
@rendered_screen.base_y = 0 if RelineSeam.screen
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def update(key)
|
|
116
|
+
RelineSeam.key_handler&.call
|
|
117
|
+
super
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def screen_height
|
|
121
|
+
screen = RelineSeam.screen
|
|
122
|
+
screen ? [super, screen.editor_budget].min : super
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def render_finished
|
|
126
|
+
screen = RelineSeam.screen
|
|
127
|
+
return super unless screen
|
|
128
|
+
|
|
129
|
+
screen.finish_editor(Thread.current[:samagotchi_reline_no_echo] ? nil : seam_final_lines)
|
|
130
|
+
clear_rendered_screen_cache
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# A read that ends without render_finished (dropped by Thread#raise,
|
|
134
|
+
# or an I/O error) leaves its prompt in the region: take it out.
|
|
135
|
+
def finalize
|
|
136
|
+
screen = RelineSeam.screen
|
|
137
|
+
if screen && !@rendered_screen.lines.empty?
|
|
138
|
+
screen.finish_editor
|
|
139
|
+
clear_rendered_screen_cache
|
|
140
|
+
end
|
|
141
|
+
RelineSeam.reading = false
|
|
142
|
+
super
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
def render_differential(new_lines, cursor_x, cursor_y)
|
|
148
|
+
screen = RelineSeam.screen
|
|
149
|
+
return super unless screen
|
|
150
|
+
|
|
151
|
+
cursor_y = cursor_y.clamp(0, [screen_height - 1, 0].max)
|
|
152
|
+
screen.draw_editor(new_lines, cursor_x, cursor_y)
|
|
153
|
+
@rendered_screen.lines = new_lines
|
|
154
|
+
@rendered_screen.cursor_y = cursor_y
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Ctrl-C: the interrupt handler's if it takes it. Otherwise the typed
|
|
158
|
+
# text stays in scrollback with ^C, then Reline's own handling of the
|
|
159
|
+
# trap it replaced (raise Interrupt by default).
|
|
160
|
+
def handle_interrupted
|
|
161
|
+
return unless @interrupted
|
|
162
|
+
|
|
163
|
+
if RelineSeam.interrupt_handler&.call
|
|
164
|
+
@interrupted = false
|
|
165
|
+
return
|
|
166
|
+
end
|
|
167
|
+
screen = RelineSeam.screen
|
|
168
|
+
return super unless screen
|
|
169
|
+
|
|
170
|
+
@interrupted = false
|
|
171
|
+
clear_dialogs
|
|
172
|
+
screen.finish_editor(seam_final_lines.tap { |lines| lines[-1] = "#{lines[-1]}^C" })
|
|
173
|
+
clear_rendered_screen_cache
|
|
174
|
+
case @old_trap
|
|
175
|
+
when "DEFAULT", "SYSTEM_DEFAULT" then raise Interrupt
|
|
176
|
+
when "IGNORE" then nil
|
|
177
|
+
when "EXIT" then exit
|
|
178
|
+
else @old_trap.call if @old_trap.respond_to?(:call)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def handle_resized
|
|
183
|
+
screen = RelineSeam.screen
|
|
184
|
+
return super unless screen
|
|
185
|
+
return unless @resized
|
|
186
|
+
|
|
187
|
+
@screen_size = Reline::IOGate.get_screen_size
|
|
188
|
+
@resized = false
|
|
189
|
+
scroll_into_view
|
|
190
|
+
clear_rendered_screen_cache
|
|
191
|
+
render
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def ed_clear_screen(key)
|
|
195
|
+
screen = RelineSeam.screen
|
|
196
|
+
return super unless screen
|
|
197
|
+
|
|
198
|
+
screen.clear_screen
|
|
199
|
+
@screen_size = Reline::IOGate.get_screen_size
|
|
200
|
+
clear_rendered_screen_cache
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Reline's alias still points at its own ed_clear_screen.
|
|
204
|
+
def clear_screen(key) = ed_clear_screen(key)
|
|
205
|
+
|
|
206
|
+
# The prompt and the text as render_finished writes them: one line per
|
|
207
|
+
# input line, with a trailing space when a line fills the width exactly.
|
|
208
|
+
def seam_final_lines
|
|
209
|
+
@buffer_of_lines.size.times.map do |i|
|
|
210
|
+
line = Reline::Unicode.strip_non_printing_start_end(prompt_list[i]) + modified_lines[i]
|
|
211
|
+
split_line_by_width(line, screen_width).last.empty? ? "#{line} " : line
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
require_relative "line_reader"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
class TerminalUI
|
|
8
|
+
# The REPL's input on a terminal: one LineReader keeps a prompt open for
|
|
9
|
+
# the whole session, turns included, and this routes what it reads. A
|
|
10
|
+
# line goes to a question waiting at the prompt (asked at ? ), to
|
|
11
|
+
# the running turn when its handler takes it (steering), else to the
|
|
12
|
+
# inbox the REPL loop takes its next line from.
|
|
13
|
+
class ReplInput
|
|
14
|
+
# @param prompt [#call] -> the prompt when no question waits
|
|
15
|
+
# @param read [#call] (prompt, prefill) -> line (nil = Ctrl-D)
|
|
16
|
+
# @param surface [Surface] the editor slot a dropped read leaves is
|
|
17
|
+
# cleared under its lock
|
|
18
|
+
def initialize(prompt:, read:, surface:)
|
|
19
|
+
@prompt = prompt
|
|
20
|
+
@read = read
|
|
21
|
+
@surface = surface
|
|
22
|
+
@inbox = Thread::Queue.new
|
|
23
|
+
# Lines a turn left over: they come before the inbox.
|
|
24
|
+
@front = []
|
|
25
|
+
@turn = nil
|
|
26
|
+
@lock = Monitor.new
|
|
27
|
+
@answers = nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def start(prefill: nil)
|
|
31
|
+
@reader = LineReader.new(self, prompt: method(:prompt_text), read: @read, prefill: prefill).start
|
|
32
|
+
self
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def open? = @reader&.alive? || false
|
|
36
|
+
|
|
37
|
+
def stop
|
|
38
|
+
@reader&.stop
|
|
39
|
+
@surface.clear_slot(:editor)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# From the reader thread: [:line, text] or [:interrupt, info].
|
|
43
|
+
def <<(item)
|
|
44
|
+
@lock.synchronize do
|
|
45
|
+
next @answers << item if @answers
|
|
46
|
+
taken = @turn && item.first == :line && @turn.call(item.last)
|
|
47
|
+
# Not now: the line goes back into the prompt.
|
|
48
|
+
next @reader&.prefill_next(item.last) if taken == :back
|
|
49
|
+
next if taken
|
|
50
|
+
|
|
51
|
+
@inbox << item
|
|
52
|
+
end
|
|
53
|
+
self
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @return [Array, nil] the next item, nil after +timeout+ seconds
|
|
57
|
+
def pop(timeout:)
|
|
58
|
+
@lock.synchronize { return @front.shift unless @front.empty? }
|
|
59
|
+
@inbox.pop(timeout: timeout)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# While a turn runs, +handler+ (line -> truthy when it took the line,
|
|
63
|
+
# :back to put it back into the prompt) sees each line first. Once it ends, +leftovers+ (-> lines it took that the
|
|
64
|
+
# turn never merged) come next, in order, before anything in the inbox.
|
|
65
|
+
def during_turn(handler, leftovers:)
|
|
66
|
+
@lock.synchronize { @turn = handler }
|
|
67
|
+
yield
|
|
68
|
+
ensure
|
|
69
|
+
@lock.synchronize do
|
|
70
|
+
@turn = nil
|
|
71
|
+
@front.concat(Array(leftovers.call).map { |line| [:line, line] })
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def prompt_text
|
|
76
|
+
@lock.synchronize { @answers ? @choice_prompt : @prompt.call }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Start the open read again when its prompt changed (a continue offer
|
|
80
|
+
# came or went); what was typed is kept.
|
|
81
|
+
def sync_prompt
|
|
82
|
+
return unless open?
|
|
83
|
+
|
|
84
|
+
with_surface_lock do
|
|
85
|
+
next if @reader.current == prompt_text
|
|
86
|
+
|
|
87
|
+
@surface.clear_slot(:editor)
|
|
88
|
+
@reader.reprompt(keep_text: true)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Put +text+ into the open prompt, unless something is typed there.
|
|
93
|
+
# @return [Boolean] whether it went in
|
|
94
|
+
def prefill(text) = open? && @reader.prefill(text)
|
|
95
|
+
|
|
96
|
+
# A question answered at the open prompt: the lines submitted from now
|
|
97
|
+
# on go to it, at the ? prompt. What was typed at the prompt is put aside
|
|
98
|
+
# and comes back once the question closes.
|
|
99
|
+
# @yield [Thread::Queue] the answers ([:line, text] or [:interrupt, info])
|
|
100
|
+
# A prompt closed by Ctrl-D mid-turn (the REPL exits after the turn)
|
|
101
|
+
# opens for the question and closes again after it.
|
|
102
|
+
def ask(choice_prompt)
|
|
103
|
+
was_open = open?
|
|
104
|
+
typed = was_open ? @reader.typed_text : nil
|
|
105
|
+
answers = Thread::Queue.new
|
|
106
|
+
@lock.synchronize do
|
|
107
|
+
@answers = answers
|
|
108
|
+
@choice_prompt = choice_prompt
|
|
109
|
+
end
|
|
110
|
+
was_open ? reprompt : start
|
|
111
|
+
yield answers
|
|
112
|
+
ensure
|
|
113
|
+
@lock.synchronize { @answers = nil }
|
|
114
|
+
if !was_open
|
|
115
|
+
stop
|
|
116
|
+
elsif open?
|
|
117
|
+
reprompt(prefill: typed)
|
|
118
|
+
else
|
|
119
|
+
# Ctrl-D at the question ended the reader: start it again.
|
|
120
|
+
start(prefill: typed)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
def reprompt(prefill: nil)
|
|
127
|
+
with_surface_lock do
|
|
128
|
+
@surface.clear_slot(:editor)
|
|
129
|
+
@reader.reprompt(prefill: prefill)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def with_surface_lock(&block)
|
|
134
|
+
@surface.respond_to?(:synchronize) ? @surface.synchronize(&block) : yield
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
require "monitor"
|
|
5
|
+
require "reline"
|
|
6
|
+
require "stringio"
|
|
7
|
+
require_relative "surface"
|
|
8
|
+
|
|
9
|
+
module Samagotchi
|
|
10
|
+
class TerminalUI
|
|
11
|
+
# The only writer to the terminal while a live region is on (attached
|
|
12
|
+
# mode). The bottom of the screen is the live region, its slots top down:
|
|
13
|
+
# activity (spinner, thinking tail), editor (the Reline prompt and its
|
|
14
|
+
# completion dialog, fed by RelineSeam), then status, notes and hints.
|
|
15
|
+
# Everything above it is normal scrollback: no alternate screen, so
|
|
16
|
+
# scrolling and copy/paste stay the terminal's own.
|
|
17
|
+
#
|
|
18
|
+
# Every change is one frame under the lock: move up to the top of the
|
|
19
|
+
# region, clear to the end of the screen (ESC[J), print any committed
|
|
20
|
+
# text, and draw the region again with the cursor back in the editor.
|
|
21
|
+
# Committed text may wrap; the region's rows are clipped to the width,
|
|
22
|
+
# so the move up is exact and the region can't drift.
|
|
23
|
+
class Screen
|
|
24
|
+
include Surface
|
|
25
|
+
|
|
26
|
+
# The slots below the editor, and the order they give way in when
|
|
27
|
+
# the terminal is short: hints first, then notes, then status.
|
|
28
|
+
BELOW = %i[status notes hints].freeze
|
|
29
|
+
SYNC_BEGIN = "\e[?2026h\e[?25l"
|
|
30
|
+
SYNC_END = "\e[?25h\e[?2026l"
|
|
31
|
+
|
|
32
|
+
# @param out [IO]
|
|
33
|
+
# @param size [#call] -> [rows, columns] of the terminal
|
|
34
|
+
def initialize(out:, size: -> { IO.console&.winsize || [24, 80] })
|
|
35
|
+
@out = out
|
|
36
|
+
@size = size
|
|
37
|
+
@lock = Monitor.new
|
|
38
|
+
@slots = {}
|
|
39
|
+
@editor = []
|
|
40
|
+
@editor_cursor = [0, 0]
|
|
41
|
+
@cursor_row = 0
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def synchronize(&) = @lock.synchronize(&)
|
|
45
|
+
|
|
46
|
+
# @return [Integer] terminal width in columns
|
|
47
|
+
def columns = [@size.call.last.to_i, 1].max
|
|
48
|
+
|
|
49
|
+
# @return [Integer] terminal height in rows
|
|
50
|
+
def rows = [@size.call.first.to_i, 1].max
|
|
51
|
+
|
|
52
|
+
# Print permanent output (may hold several lines) above the region.
|
|
53
|
+
def commit(text)
|
|
54
|
+
lines = text.to_s.split("\n", -1)
|
|
55
|
+
synchronize { frame { |buffer| lines.each { |line| buffer << line << "\r\n" } } }
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The editor slot is Reline's: RelineSeam feeds it with #draw_editor.
|
|
60
|
+
def set_slot(name, rows) = set_slots(name => rows)
|
|
61
|
+
|
|
62
|
+
# One frame for all the slots given, none when nothing changed.
|
|
63
|
+
def set_slots(**rows_by_slot)
|
|
64
|
+
rows_by_slot = rows_by_slot.to_h do |name, rows|
|
|
65
|
+
check_slot!(name)
|
|
66
|
+
raise ArgumentError, "Reline draws the editor slot" if name == :editor
|
|
67
|
+
|
|
68
|
+
# Fitted content is laid out at each frame (#region).
|
|
69
|
+
[name, rows.respond_to?(:fit) ? rows : Array(rows).map { |row| one_row(row) }]
|
|
70
|
+
end
|
|
71
|
+
synchronize do
|
|
72
|
+
next if rows_by_slot.all? { |name, rows| slot(name).equal?(rows) || slot(name) == rows }
|
|
73
|
+
|
|
74
|
+
rows_by_slot.each do |name, rows|
|
|
75
|
+
rows.respond_to?(:empty?) && rows.empty? ? @slots.delete(name) : (@slots[name] = rows)
|
|
76
|
+
end
|
|
77
|
+
frame
|
|
78
|
+
end
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Clearing the editor slot drops the prompt from the region (its read
|
|
83
|
+
# is about to be dropped and started again).
|
|
84
|
+
# @return [Boolean] whether the slot showed anything
|
|
85
|
+
def clear_slot(name)
|
|
86
|
+
check_slot!(name)
|
|
87
|
+
synchronize do
|
|
88
|
+
next finish_editor if name == :editor
|
|
89
|
+
next false unless @slots.key?(name)
|
|
90
|
+
|
|
91
|
+
@slots.delete(name)
|
|
92
|
+
frame
|
|
93
|
+
true
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Rows the editor may take (its prompt, wrapped input and completion
|
|
98
|
+
# dialog), so that the region fits on the screen with the activity and
|
|
99
|
+
# status rows. Notes and hints give way to the editor instead.
|
|
100
|
+
def editor_budget
|
|
101
|
+
synchronize { [rows - 1 - fitted(:activity, columns).size - fitted(:status, columns).size, 1].max }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Reline rendered: +lines+ are its rows of [x, width, content] layers
|
|
105
|
+
# (prompt, input, dialog), the cursor is at (+cursor_x+, +cursor_y+)
|
|
106
|
+
# inside them.
|
|
107
|
+
def draw_editor(lines, cursor_x, cursor_y)
|
|
108
|
+
synchronize do
|
|
109
|
+
@editor = lines.map { |layers| compose(layers) }
|
|
110
|
+
@editor_cursor = [cursor_x, cursor_y]
|
|
111
|
+
frame
|
|
112
|
+
end
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The prompt ended. +lines+ (the submitted text, or the text with ^C)
|
|
117
|
+
# go to scrollback; without them the prompt just disappears.
|
|
118
|
+
# @return [Boolean] whether a prompt was shown
|
|
119
|
+
def finish_editor(lines = nil)
|
|
120
|
+
synchronize do
|
|
121
|
+
shown = !@editor.empty?
|
|
122
|
+
next false unless shown || lines
|
|
123
|
+
|
|
124
|
+
@editor = []
|
|
125
|
+
@editor_cursor = [0, 0]
|
|
126
|
+
frame { |buffer| lines&.each { |line| buffer << line << "\r\n" } }
|
|
127
|
+
shown
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Ctrl-L: clear the screen and draw the region at the top.
|
|
132
|
+
def clear_screen
|
|
133
|
+
synchronize do
|
|
134
|
+
@cursor_row = 0
|
|
135
|
+
frame(clear: true)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Draw the region again (after a resize).
|
|
140
|
+
def redraw
|
|
141
|
+
synchronize { frame }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Take over what else writes to the terminal while the screen is on:
|
|
145
|
+
# $stderr (warnings from background threads would cross the region)
|
|
146
|
+
# and SIGWINCH outside a read (Reline traps it only during one, and
|
|
147
|
+
# chains to this trap then). #close gives both back.
|
|
148
|
+
# @return [self]
|
|
149
|
+
def start
|
|
150
|
+
@stderr_was = $stderr
|
|
151
|
+
$stderr = ErrorOutput.new(self)
|
|
152
|
+
# A trap handler can't take the lock: redraw from a thread.
|
|
153
|
+
@winch_was = Signal.trap("WINCH") { Thread.new { redraw } }
|
|
154
|
+
self
|
|
155
|
+
rescue ArgumentError
|
|
156
|
+
self # no SIGWINCH on this platform
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Erase the region and leave the cursor where it began; put back what
|
|
160
|
+
# #start took over.
|
|
161
|
+
def close
|
|
162
|
+
if @stderr_was
|
|
163
|
+
$stderr.flush
|
|
164
|
+
$stderr = @stderr_was
|
|
165
|
+
@stderr_was = nil
|
|
166
|
+
end
|
|
167
|
+
Signal.trap("WINCH", @winch_was) if @winch_was
|
|
168
|
+
@winch_was = nil
|
|
169
|
+
synchronize do
|
|
170
|
+
@slots.clear
|
|
171
|
+
@editor = []
|
|
172
|
+
frame
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# $stderr while a Screen is on: each line written goes above the region
|
|
177
|
+
# as committed output. A line without its newline waits for the rest.
|
|
178
|
+
class ErrorOutput
|
|
179
|
+
def initialize(screen)
|
|
180
|
+
@screen = screen
|
|
181
|
+
@pending = +""
|
|
182
|
+
@lock = Mutex.new
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def write(*parts)
|
|
186
|
+
text = parts.join
|
|
187
|
+
lines = @lock.synchronize do
|
|
188
|
+
@pending << text
|
|
189
|
+
*done, @pending = @pending.split("\n", -1)
|
|
190
|
+
@pending = +@pending.to_s
|
|
191
|
+
done
|
|
192
|
+
end
|
|
193
|
+
lines.each { |line| @screen.commit(line) }
|
|
194
|
+
text.bytesize
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def print(*parts)
|
|
198
|
+
write(*parts)
|
|
199
|
+
nil
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def puts(*items)
|
|
203
|
+
io = StringIO.new
|
|
204
|
+
io.puts(*items)
|
|
205
|
+
write(io.string)
|
|
206
|
+
nil
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def <<(item)
|
|
210
|
+
write(item)
|
|
211
|
+
self
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Commit a line still waiting for its newline.
|
|
215
|
+
def flush
|
|
216
|
+
line = @lock.synchronize { @pending.empty? ? nil : @pending.dup.tap { @pending.clear } }
|
|
217
|
+
@screen.commit(line) if line
|
|
218
|
+
self
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def sync = true
|
|
222
|
+
def sync=(_value); end
|
|
223
|
+
def tty? = false
|
|
224
|
+
alias isatty tty?
|
|
225
|
+
def fileno = nil
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
private
|
|
229
|
+
|
|
230
|
+
def slot(name) = @slots.fetch(name, [])
|
|
231
|
+
|
|
232
|
+
# A slot's rows at +width+; fitted content gets at most +height+ rows.
|
|
233
|
+
def fitted(name, width, height = nil)
|
|
234
|
+
content = slot(name)
|
|
235
|
+
return content unless content.respond_to?(:fit)
|
|
236
|
+
|
|
237
|
+
lay_out(content, width: width, height: height && [height, 0].max).map { |row| one_row(row) }
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# One frame: erase the region, let the block add scrollback text, draw
|
|
241
|
+
# the region. Written in one piece and never cut short by
|
|
242
|
+
# Thread#raise (LineReader drops a read that way).
|
|
243
|
+
def frame(clear: false)
|
|
244
|
+
Thread.handle_interrupt(Object => :never) do
|
|
245
|
+
buffer = +SYNC_BEGIN
|
|
246
|
+
buffer << "\e[2J\e[H" if clear
|
|
247
|
+
buffer << "\r"
|
|
248
|
+
buffer << "\e[#{@cursor_row}A" if @cursor_row.positive?
|
|
249
|
+
buffer << "\e[J"
|
|
250
|
+
@cursor_row = 0
|
|
251
|
+
yield buffer if block_given?
|
|
252
|
+
buffer << region
|
|
253
|
+
buffer << SYNC_END
|
|
254
|
+
@out.write(buffer)
|
|
255
|
+
@out.flush
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# The region's rows, leaving the cursor in the editor (or on the row
|
|
260
|
+
# below the region when no prompt is open). Sets @cursor_row.
|
|
261
|
+
def region
|
|
262
|
+
width = columns
|
|
263
|
+
above = fitted(:activity, width)
|
|
264
|
+
spare = [rows - 1 - above.size - @editor.size, 0].max
|
|
265
|
+
# Each slot below gets the rows the ones above it left.
|
|
266
|
+
below = BELOW.each_with_object([]) do |name, taken|
|
|
267
|
+
taken.concat(fitted(name, width, spare - taken.size).first([spare - taken.size, 0].max))
|
|
268
|
+
end
|
|
269
|
+
above = above.last([rows - 1 - @editor.size, 0].max)
|
|
270
|
+
all = above + @editor + below
|
|
271
|
+
return +"" if all.empty?
|
|
272
|
+
|
|
273
|
+
text = all.map { |row| clip(row, width) }.join("\r\n")
|
|
274
|
+
last = all.size - 1
|
|
275
|
+
if @editor.empty?
|
|
276
|
+
text << "\r\n"
|
|
277
|
+
@cursor_row = last + 1
|
|
278
|
+
else
|
|
279
|
+
target = above.size + @editor_cursor[1].clamp(0, @editor.size - 1)
|
|
280
|
+
text << "\e[#{last - target}A" if last > target
|
|
281
|
+
text << "\r"
|
|
282
|
+
text << "\e[#{@editor_cursor[0]}C" if @editor_cursor[0].positive?
|
|
283
|
+
@cursor_row = target
|
|
284
|
+
end
|
|
285
|
+
text
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# A slot row is one line: no line breaks or tabs.
|
|
289
|
+
def one_row(row) = row.to_s.tr("\r\n\t", " ")
|
|
290
|
+
|
|
291
|
+
# Cut a row to the terminal width, so it takes exactly one row.
|
|
292
|
+
def clip(row, width)
|
|
293
|
+
return row if Reline::Unicode.calculate_width(row, true) <= width
|
|
294
|
+
|
|
295
|
+
"#{Reline::Unicode.take_mbchar_range(row, 0, width, padding: false).first}\e[0m"
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Lay Reline's layers (prompt, input, dialog rows) over each other into
|
|
299
|
+
# one row, as Reline's own render_line_differential would draw them.
|
|
300
|
+
def compose(layers)
|
|
301
|
+
base = +""
|
|
302
|
+
layers.each do |layer|
|
|
303
|
+
next unless layer
|
|
304
|
+
|
|
305
|
+
x, w, content = layer
|
|
306
|
+
base_width = Reline::Unicode.calculate_width(base, true)
|
|
307
|
+
base << (" " * (x - base_width)) if base_width < x
|
|
308
|
+
head, = Reline::Unicode.take_mbchar_range(base, 0, x, padding: true)
|
|
309
|
+
tail, = Reline::Unicode.take_mbchar_range(base, x + w, [base_width - x - w, 0].max, padding: true)
|
|
310
|
+
base = "#{head}\e[0m#{content}\e[0m#{tail}"
|
|
311
|
+
end
|
|
312
|
+
base
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|