kward 0.83.0 → 0.85.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 +4 -4
- data/CHANGELOG.md +102 -15
- data/CONTRIBUTING.md +74 -0
- data/Gemfile.lock +8 -2
- data/README.md +21 -1
- data/Rakefile +46 -2
- data/SECURITY.md +31 -0
- data/doc/agent-tools.md +13 -1
- data/doc/api.md +21 -2
- data/doc/composer.md +2 -2
- data/doc/configuration.md +95 -25
- data/doc/editor.md +28 -13
- data/doc/extensibility.md +2 -1
- data/doc/files.md +8 -4
- data/doc/getting-started.md +3 -0
- data/doc/git.md +3 -1
- data/doc/pan.md +25 -15
- data/doc/permissions.md +4 -4
- data/doc/platform-support.md +48 -0
- data/doc/plugins.md +464 -15
- data/doc/rpc.md +154 -16
- data/doc/sandboxing.md +11 -5
- data/doc/security.md +10 -3
- data/doc/session-management.md +5 -4
- data/doc/shell.md +62 -45
- data/doc/tabs.md +6 -2
- data/doc/transports.md +15 -0
- data/doc/troubleshooting.md +12 -2
- data/doc/usage.md +9 -6
- data/doc/workspace-tools.md +9 -0
- data/examples/plugins/space_invaders.rb +1 -1
- data/examples/plugins/stardate_footer.rb +2 -2
- data/examples/plugins/telegram/plugin.rb +1 -1
- data/kward.gemspec +5 -4
- data/lib/kward/agent.rb +30 -14
- data/lib/kward/cli/auth_commands.rb +34 -13
- data/lib/kward/cli/commands.rb +83 -62
- data/lib/kward/cli/compaction.rb +9 -3
- data/lib/kward/cli/doctor.rb +39 -17
- data/lib/kward/cli/hook_commands.rb +22 -12
- data/lib/kward/cli/interactive_turn.rb +48 -7
- data/lib/kward/cli/plugins.rb +81 -12
- data/lib/kward/cli/project_skills_commands.rb +8 -4
- data/lib/kward/cli/prompt_interface.rb +52 -5
- data/lib/kward/cli/rendering.rb +18 -9
- data/lib/kward/cli/runtime_helpers.rb +228 -71
- data/lib/kward/cli/sessions.rb +9 -5
- data/lib/kward/cli/settings/menus.rb +745 -0
- data/lib/kward/cli/settings/model.rb +327 -0
- data/lib/kward/cli/settings.rb +6 -1055
- data/lib/kward/cli/slash_commands.rb +56 -17
- data/lib/kward/cli/tabs.rb +244 -33
- data/lib/kward/cli/tool_summaries.rb +14 -0
- data/lib/kward/{cli_transcript_formatter.rb → cli/transcript_formatter.rb} +14 -7
- data/lib/kward/cli/worktrees.rb +65 -2
- data/lib/kward/cli.rb +70 -30
- data/lib/kward/compactor.rb +18 -7
- data/lib/kward/config/core.rb +389 -0
- data/lib/kward/config/extensions.rb +96 -0
- data/lib/kward/config/prompts.rb +313 -0
- data/lib/kward/config/settings.rb +250 -0
- data/lib/kward/config_files.rb +14 -994
- data/lib/kward/conversation.rb +31 -2
- data/lib/kward/image_attachments.rb +1 -1
- data/lib/kward/model/client.rb +36 -24
- data/lib/kward/model/copilot_models.rb +2 -2
- data/lib/kward/model/model_info.rb +20 -3
- data/lib/kward/{openrouter_model_cache.rb → model/openrouter_model_cache.rb} +3 -3
- data/lib/kward/model/payloads.rb +12 -3
- data/lib/kward/model/provider_catalog.rb +5 -0
- data/lib/kward/model/stream_parser.rb +20 -4
- data/lib/kward/model/typesafe_client.rb +78 -0
- data/lib/kward/pan/index.html.erb +3 -3
- data/lib/kward/pan/server.rb +33 -10
- data/lib/kward/permissions/policy.rb +6 -2
- data/lib/kward/plugin_registry.rb +2 -659
- data/lib/kward/plugins/actions.rb +453 -0
- data/lib/kward/plugins/chat_contract.rb +121 -0
- data/lib/kward/{plugin_chat_runtime.rb → plugins/chat_runtime.rb} +62 -18
- data/lib/kward/plugins/host.rb +232 -0
- data/lib/kward/plugins/registry.rb +1190 -0
- data/lib/kward/plugins/resources.rb +206 -0
- data/lib/kward/plugins/turn_request.rb +36 -0
- data/lib/kward/plugins/ui.rb +219 -0
- data/lib/kward/prompt_interface/composer_renderer.rb +44 -40
- data/lib/kward/prompt_interface/composer_state.rb +33 -24
- data/lib/kward/prompt_interface/editor/auto_indent.rb +24 -22
- data/lib/kward/prompt_interface/editor/controller.rb +30 -33
- data/lib/kward/prompt_interface/editor/endwise.rb +13 -4
- data/lib/kward/prompt_interface/editor/markdown_code_block.rb +136 -0
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +289 -44
- data/lib/kward/prompt_interface/editor/renderer.rb +108 -6
- data/lib/kward/prompt_interface/editor/runner.rb +362 -0
- data/lib/kward/prompt_interface/editor/runner_state.rb +78 -0
- data/lib/kward/prompt_interface/editor/scratchpad_languages.rb +74 -0
- data/lib/kward/prompt_interface/editor/scratchpad_runner.rb +182 -0
- data/lib/kward/prompt_interface/editor/state.rb +11 -11
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +68 -6
- data/lib/kward/prompt_interface/editor/vibe_state.rb +3 -3
- data/lib/kward/prompt_interface/file_overlay.rb +71 -15
- data/lib/kward/prompt_interface/key_handler.rb +67 -0
- data/lib/kward/prompt_interface/layout.rb +1 -1
- data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
- data/lib/kward/prompt_interface/plugin_ui_requests.rb +82 -0
- data/lib/kward/prompt_interface/project_browser.rb +415 -14
- data/lib/kward/prompt_interface/runtime_state.rb +56 -2
- data/lib/kward/prompt_interface/screen.rb +11 -4
- data/lib/kward/prompt_interface/selection_prompt.rb +3 -1
- data/lib/kward/prompt_interface/slash_overlay.rb +19 -4
- data/lib/kward/prompt_interface/transcript_renderer.rb +12 -7
- data/lib/kward/prompt_interface.rb +151 -27
- data/lib/kward/prompts/commands.rb +3 -2
- data/lib/kward/prompts.rb +1 -1
- data/lib/kward/{adaptive_pty_output_sink.rb → pty/adaptive_output_sink.rb} +1 -1
- data/lib/kward/pty/detached_run.rb +44 -0
- data/lib/kward/{interactive_pty_runner.rb → pty/interactive_runner.rb} +104 -30
- data/lib/kward/{local_command_runner.rb → pty/local_command_runner.rb} +1 -1
- data/lib/kward/{local_pty_command_runner.rb → pty/local_pty_runner.rb} +3 -9
- data/lib/kward/{pty_output_sink.rb → pty/output_sink.rb} +52 -0
- data/lib/kward/{pty_transcript_normalizer.rb → pty/transcript_normalizer.rb} +1 -1
- data/lib/kward/rpc/plugin_chat_manager.rb +30 -10
- data/lib/kward/rpc/prompt_bridge.rb +25 -0
- data/lib/kward/rpc/server.rb +91 -12
- data/lib/kward/rpc/session_manager.rb +147 -44
- data/lib/kward/rpc/session_tree_rows.rb +2 -2
- data/lib/kward/rpc/tool_metadata.rb +1 -1
- data/lib/kward/rpc/transcript_normalizer.rb +7 -3
- data/lib/kward/sandbox/command_runner.rb +1 -1
- data/lib/kward/{session_catalog.rb → sessions/catalog.rb} +1 -1
- data/lib/kward/{session_store.rb → sessions/store.rb} +9 -9
- data/lib/kward/{session_tree_nodes.rb → sessions/tree_nodes.rb} +3 -3
- data/lib/kward/{session_tree_renderer.rb → sessions/tree_renderer.rb} +4 -4
- data/lib/kward/{session_tree_tool_display.rb → sessions/tree_tool_display.rb} +1 -1
- data/lib/kward/{ekwsh.rb → shell/kwsh.rb} +38 -19
- data/lib/kward/shell/kwshrc.rb +233 -0
- data/lib/kward/{persistent_shell_session.rb → shell/persistent_session.rb} +121 -28
- data/lib/kward/{shell_prompt.rb → shell/prompt.rb} +2 -0
- data/lib/kward/{shell_prompt_session.rb → shell/prompt_session.rb} +1 -1
- data/lib/kward/skills/trust_store.rb +1 -1
- data/lib/kward/tabs/driver.rb +194 -0
- data/lib/kward/{tab_store.rb → tabs/store.rb} +2 -2
- data/lib/kward/{ansi.rb → terminal/ansi.rb} +110 -10
- data/lib/kward/{clipboard.rb → terminal/clipboard.rb} +1 -1
- data/lib/kward/{terminal_image_support.rb → terminal/image_support.rb} +1 -1
- data/lib/kward/{terminal_keys.rb → terminal/keys.rb} +12 -0
- data/lib/kward/terminal/text.rb +121 -0
- data/lib/kward/text_matcher.rb +18 -0
- data/lib/kward/tools/base.rb +18 -0
- data/lib/kward/tools/context_for_task.rb +15 -6
- data/lib/kward/tools/edit_file.rb +9 -6
- data/lib/kward/tools/git_commit.rb +13 -7
- data/lib/kward/tools/list_directory.rb +4 -4
- data/lib/kward/tools/open_editor.rb +41 -0
- data/lib/kward/tools/plugin_tool.rb +41 -0
- data/lib/kward/tools/prepare_shell_command.rb +1 -1
- data/lib/kward/tools/read_file.rb +7 -6
- data/lib/kward/tools/registry.rb +109 -17
- data/lib/kward/tools/run_shell_command.rb +10 -8
- data/lib/kward/tools/search/code.rb +1 -1
- data/lib/kward/tools/summarize_file_structure.rb +5 -5
- data/lib/kward/tools/tool_call.rb +3 -1
- data/lib/kward/tools/typesafe_evaluate.rb +81 -0
- data/lib/kward/tools/workspace_targets.rb +58 -0
- data/lib/kward/tools/write_file.rb +9 -6
- data/lib/kward/{export_path.rb → transcripts/export_path.rb} +1 -1
- data/lib/kward/{markdown_transcript.rb → transcripts/markdown_transcript.rb} +2 -2
- data/lib/kward/transport/contracts.rb +200 -0
- data/lib/kward/transport/gateway.rb +79 -35
- data/lib/kward/transport/plugin_chat_gateway.rb +3 -2
- data/lib/kward/transport.rb +1 -200
- data/lib/kward/version.rb +1 -1
- data/lib/kward/{workspace_factory.rb → workspace/factory.rb} +2 -2
- data/lib/kward/{project_files.rb → workspace/files.rb} +2 -2
- data/lib/kward/{git_worktree_manager.rb → workspace/git_worktree_manager.rb} +28 -0
- data/lib/kward/{workspace.rb → workspace/workspace.rb} +3 -3
- data/templates/default/kward_navigation.rb +1 -0
- data/templates/default/layout/html/footer.erb +10 -0
- data/templates/default/layout/html/headers.erb +23 -0
- data/templates/default/layout/html/layout.erb +2 -2
- data/templates/default/layout/html/setup.rb +41 -2
- metadata +94 -47
- data/lib/kward/scratchpad_runner.rb +0 -56
- data/lib/kward/tab_driver.rb +0 -90
- /data/lib/kward/{editor_prompt.rb → cli/editor_prompt.rb} +0 -0
- /data/lib/kward/{editor_prompt_session.rb → cli/editor_prompt_session.rb} +0 -0
- /data/lib/kward/{diff_view_mode.rb → prompt_interface/editor/diff_view_mode.rb} +0 -0
- /data/lib/kward/{editor_mode.rb → prompt_interface/editor/editor_mode.rb} +0 -0
- /data/lib/kward/{session_diff.rb → sessions/diff.rb} +0 -0
- /data/lib/kward/{session_naming.rb → sessions/naming.rb} +0 -0
- /data/lib/kward/{session_trash.rb → sessions/trash.rb} +0 -0
- /data/lib/kward/{terminal_sequences.rb → terminal/sequences.rb} +0 -0
- /data/lib/kward/{transcript_export.rb → transcripts/transcript_export.rb} +0 -0
- /data/lib/kward/{path_guard.rb → workspace/path_guard.rb} +0 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
require "thread"
|
|
2
|
+
require_relative "../cancellation"
|
|
3
|
+
|
|
4
|
+
# Namespace for the Kward CLI agent runtime.
|
|
5
|
+
module Kward
|
|
6
|
+
# Idempotent cleanup registration owned by a plugin runtime.
|
|
7
|
+
class PluginDisposable
|
|
8
|
+
def initialize(resource = nil, &cleanup)
|
|
9
|
+
raise ArgumentError, "plugin cleanup requires a block or closeable resource" unless cleanup || resource&.respond_to?(:close)
|
|
10
|
+
|
|
11
|
+
@resource = resource
|
|
12
|
+
@cleanup = cleanup
|
|
13
|
+
@mutex = Mutex.new
|
|
14
|
+
@disposed = false
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def disposed?
|
|
18
|
+
@mutex.synchronize { @disposed }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def dispose
|
|
22
|
+
cleanup = @mutex.synchronize do
|
|
23
|
+
return false if @disposed
|
|
24
|
+
|
|
25
|
+
@disposed = true
|
|
26
|
+
@cleanup
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
if cleanup
|
|
30
|
+
cleanup.arity.zero? ? cleanup.call : cleanup.call(@resource)
|
|
31
|
+
else
|
|
32
|
+
@resource.close
|
|
33
|
+
end
|
|
34
|
+
true
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Cooperative background task owned by a plugin runtime.
|
|
39
|
+
class PluginTask
|
|
40
|
+
attr_reader :name, :cancellation
|
|
41
|
+
|
|
42
|
+
def initialize(name:, cancellation:, &work)
|
|
43
|
+
@name = name.to_s.freeze
|
|
44
|
+
@cancellation = cancellation
|
|
45
|
+
@work = work
|
|
46
|
+
@mutex = Mutex.new
|
|
47
|
+
@thread = nil
|
|
48
|
+
@result = nil
|
|
49
|
+
@error = nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def start(&finished)
|
|
53
|
+
@mutex.synchronize do
|
|
54
|
+
raise "Plugin task #{name.inspect} has already started" if @thread
|
|
55
|
+
|
|
56
|
+
@thread = Thread.new do
|
|
57
|
+
begin
|
|
58
|
+
cancellation.raise_if_cancelled!
|
|
59
|
+
result = @work.arity.zero? ? @work.call : @work.call(cancellation)
|
|
60
|
+
@mutex.synchronize { @result = result }
|
|
61
|
+
rescue Cancellation::CancelledError
|
|
62
|
+
nil
|
|
63
|
+
rescue StandardError => e
|
|
64
|
+
@mutex.synchronize { @error = e }
|
|
65
|
+
ensure
|
|
66
|
+
finished&.call(self)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
@thread.report_on_exception = false
|
|
70
|
+
end
|
|
71
|
+
self
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def cancel!
|
|
75
|
+
cancellation.cancel!
|
|
76
|
+
self
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def cancelled?
|
|
80
|
+
cancellation.cancelled?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def alive?
|
|
84
|
+
thread = @mutex.synchronize { @thread }
|
|
85
|
+
thread&.alive? == true
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def complete?
|
|
89
|
+
!alive?
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def join(timeout = nil)
|
|
93
|
+
thread = @mutex.synchronize { @thread }
|
|
94
|
+
thread&.join(timeout)
|
|
95
|
+
self
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def result
|
|
99
|
+
join
|
|
100
|
+
@mutex.synchronize { @result }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def error
|
|
104
|
+
@mutex.synchronize { @error }
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Owns cleanup callbacks and cooperative background tasks for a plugin or tab.
|
|
109
|
+
class PluginResources
|
|
110
|
+
DEFAULT_SHUTDOWN_TIMEOUT = 1.0
|
|
111
|
+
|
|
112
|
+
def initialize(name:, warning_sink: nil)
|
|
113
|
+
@name = name.to_s
|
|
114
|
+
@warning_sink = warning_sink
|
|
115
|
+
@mutex = Mutex.new
|
|
116
|
+
@state = :initialized
|
|
117
|
+
@tasks = []
|
|
118
|
+
@disposables = []
|
|
119
|
+
@next_task_id = 0
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def activate!
|
|
123
|
+
@mutex.synchronize do
|
|
124
|
+
raise "#{@name} resources have already been stopped" if @state == :stopped
|
|
125
|
+
|
|
126
|
+
@state = :active
|
|
127
|
+
end
|
|
128
|
+
self
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def background(name: nil, cancellation: nil, &work)
|
|
132
|
+
raise ArgumentError, "plugin background task requires a block" unless work
|
|
133
|
+
|
|
134
|
+
task = @mutex.synchronize do
|
|
135
|
+
raise "#{@name} background work cannot start before plugin activation" unless @state == :active
|
|
136
|
+
|
|
137
|
+
@next_task_id += 1
|
|
138
|
+
task_name = name.to_s.empty? ? "task-#{@next_task_id}" : name.to_s
|
|
139
|
+
task_cancellation = Cancellation.new
|
|
140
|
+
cancellation&.on_cancel { task_cancellation.cancel! }
|
|
141
|
+
PluginTask.new(name: task_name, cancellation: task_cancellation, &work).tap do |created|
|
|
142
|
+
@tasks << created
|
|
143
|
+
created.start { |finished| finish_task(finished) }
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
task
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def on_cleanup(resource = nil, &cleanup)
|
|
150
|
+
disposable = PluginDisposable.new(resource, &cleanup)
|
|
151
|
+
@mutex.synchronize do
|
|
152
|
+
raise "#{@name} resources have already been stopped" if @state == :stopped
|
|
153
|
+
|
|
154
|
+
@disposables << disposable
|
|
155
|
+
end
|
|
156
|
+
disposable
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
alias manage on_cleanup
|
|
160
|
+
|
|
161
|
+
def shutdown(timeout: DEFAULT_SHUTDOWN_TIMEOUT)
|
|
162
|
+
tasks, disposables = @mutex.synchronize do
|
|
163
|
+
return self if @state == :stopped
|
|
164
|
+
|
|
165
|
+
@state = :stopped
|
|
166
|
+
[@tasks.dup, @disposables.reverse]
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
tasks.each(&:cancel!)
|
|
170
|
+
disposables.each { |disposable| dispose(disposable) }
|
|
171
|
+
join_tasks(tasks, timeout)
|
|
172
|
+
self
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
private
|
|
176
|
+
|
|
177
|
+
def finish_task(task)
|
|
178
|
+
error = task.error
|
|
179
|
+
@mutex.synchronize { @tasks.delete(task) }
|
|
180
|
+
warn("#{@name} background task #{task.name.inspect} failed: #{error.message}") if error
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def dispose(disposable)
|
|
184
|
+
disposable.dispose
|
|
185
|
+
rescue StandardError => e
|
|
186
|
+
warn("#{@name} cleanup failed: #{e.message}")
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def join_tasks(tasks, timeout)
|
|
190
|
+
deadline = monotonic_time + [timeout.to_f, 0].max
|
|
191
|
+
tasks.each do |task|
|
|
192
|
+
remaining = deadline - monotonic_time
|
|
193
|
+
task.join(remaining) if remaining.positive?
|
|
194
|
+
warn("#{@name} background task #{task.name.inspect} did not stop before shutdown") if task.alive?
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def monotonic_time
|
|
199
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def warn(message)
|
|
203
|
+
@warning_sink ? @warning_sink.call("Warning: #{message}") : Kernel.warn("Warning: #{message}")
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
|
|
3
|
+
module Kward
|
|
4
|
+
# Immutable, host-dispatched system instructions for one session turn.
|
|
5
|
+
# Historical records retain the instructions for display, not model replay.
|
|
6
|
+
class PluginTurnRequest
|
|
7
|
+
MAX_SYSTEM_BYTES = 65_536
|
|
8
|
+
|
|
9
|
+
attr_reader :system, :command, :plugin_id, :id
|
|
10
|
+
|
|
11
|
+
def initialize(system:, command:, plugin_id: nil, id: SecureRandom.uuid)
|
|
12
|
+
unless system.is_a?(String) && system.valid_encoding? && !system.strip.empty? && system.bytesize <= MAX_SYSTEM_BYTES
|
|
13
|
+
raise ArgumentError, "System instructions must be nonblank text of at most #{MAX_SYSTEM_BYTES} bytes"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
@system = system.dup.freeze
|
|
17
|
+
@command = command.to_s.dup.freeze
|
|
18
|
+
@plugin_id = plugin_id&.to_s&.dup&.freeze
|
|
19
|
+
@id = id.to_s.dup.freeze
|
|
20
|
+
freeze
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_s
|
|
24
|
+
"/#{command} #{system}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def history_message
|
|
28
|
+
{
|
|
29
|
+
role: "user",
|
|
30
|
+
content: "A plugin requested a system-instruction turn. Those instructions are not active in later turns.",
|
|
31
|
+
display_content: to_s,
|
|
32
|
+
plugin_system_turn: { id: id, command: command, plugin_id: plugin_id, system: system, scope: "turn" }
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
require_relative "../deep_copy"
|
|
2
|
+
require_relative "../question_contract"
|
|
3
|
+
|
|
4
|
+
# Namespace for the Kward CLI agent runtime.
|
|
5
|
+
module Kward
|
|
6
|
+
# Frontend-neutral structured UI exposed to trusted plugin handlers.
|
|
7
|
+
#
|
|
8
|
+
# Frontends provide a small backend that handles blocking requests and
|
|
9
|
+
# non-blocking events. Unsupported requests fail closed: selection, question,
|
|
10
|
+
# and input return nil, while confirmation returns false.
|
|
11
|
+
class PluginUI
|
|
12
|
+
FEATURES = %i[question select confirm input progress notify].freeze
|
|
13
|
+
NOTIFICATION_LEVELS = %i[info success warning error].freeze
|
|
14
|
+
MAX_TEXT_BYTES = 16_384
|
|
15
|
+
MAX_OPTIONS = 100
|
|
16
|
+
MAX_ID_BYTES = 128
|
|
17
|
+
|
|
18
|
+
# Adapts frontend callbacks to the structured plugin UI contract.
|
|
19
|
+
class Backend
|
|
20
|
+
def initialize(capabilities: {}, requester: nil, emitter: nil)
|
|
21
|
+
@capabilities = FEATURES.to_h { |feature| [feature, capability_enabled?(capabilities, feature)] }.freeze
|
|
22
|
+
@requester = requester
|
|
23
|
+
@emitter = emitter
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
attr_reader :capabilities
|
|
27
|
+
|
|
28
|
+
def supported?(feature)
|
|
29
|
+
@capabilities.fetch(feature.to_sym, false)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def request(kind, payload, cancellation: nil)
|
|
33
|
+
return nil unless supported?(kind) && @requester
|
|
34
|
+
|
|
35
|
+
@requester.call(kind.to_sym, payload, cancellation: cancellation)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def emit(kind, payload)
|
|
39
|
+
return false unless supported?(kind) && @emitter
|
|
40
|
+
|
|
41
|
+
@emitter.call(kind.to_sym, payload)
|
|
42
|
+
true
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def capability_enabled?(capabilities, feature)
|
|
48
|
+
value = capabilities[feature] || capabilities[feature.to_s]
|
|
49
|
+
value.is_a?(Hash) ? value.fetch(:supported, value.fetch("supported", false)) == true : value == true
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def initialize(backend: nil, say_callback: nil, cancellation: nil)
|
|
54
|
+
@backend = backend || Backend.new
|
|
55
|
+
@say_callback = say_callback
|
|
56
|
+
@cancellation = cancellation
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def capabilities
|
|
60
|
+
@backend.capabilities.dup.freeze
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def supported?(feature)
|
|
64
|
+
@backend.supported?(feature)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def question(questions)
|
|
68
|
+
normalized = QuestionContract.normalize_questions(questions)
|
|
69
|
+
request(:question, questions: normalized)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def select(title, options, message: nil)
|
|
73
|
+
normalized = normalize_options(options)
|
|
74
|
+
answer = request(
|
|
75
|
+
:select,
|
|
76
|
+
title: required_text(title, "select title"),
|
|
77
|
+
message: optional_text(message),
|
|
78
|
+
options: normalized
|
|
79
|
+
)
|
|
80
|
+
return nil if answer.nil?
|
|
81
|
+
|
|
82
|
+
selected = normalized.find { |option| option[:value] == answer.to_s || option[:label] == answer.to_s }
|
|
83
|
+
raise ArgumentError, "plugin UI returned an unknown selection" unless selected
|
|
84
|
+
|
|
85
|
+
selected[:value]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def confirm(title, message = nil, default: false)
|
|
89
|
+
if message.nil?
|
|
90
|
+
message = title
|
|
91
|
+
title = "Confirm"
|
|
92
|
+
end
|
|
93
|
+
answer = request(
|
|
94
|
+
:confirm,
|
|
95
|
+
title: required_text(title, "confirm title"),
|
|
96
|
+
message: required_text(message, "confirm message"),
|
|
97
|
+
default: default == true
|
|
98
|
+
)
|
|
99
|
+
answer == true
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def input(title, placeholder = nil, default: nil)
|
|
103
|
+
answer = request(
|
|
104
|
+
:input,
|
|
105
|
+
title: required_text(title, "input title"),
|
|
106
|
+
placeholder: optional_text(placeholder),
|
|
107
|
+
default: optional_text(default)
|
|
108
|
+
)
|
|
109
|
+
return nil if answer.nil?
|
|
110
|
+
raise ArgumentError, "plugin UI input answer must be a string" unless answer.is_a?(String)
|
|
111
|
+
|
|
112
|
+
bounded_text(answer, "input answer")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def notify(message, level = :info, **options)
|
|
116
|
+
level = options[:level] if options.key?(:level)
|
|
117
|
+
level = level.to_sym
|
|
118
|
+
raise ArgumentError, "unsupported plugin notification level: #{level}" unless NOTIFICATION_LEVELS.include?(level)
|
|
119
|
+
|
|
120
|
+
payload = { message: required_text(message, "notification message"), level: level }
|
|
121
|
+
@say_callback&.call(payload[:message]) unless @backend.emit(:notify, immutable(payload))
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def progress(id = nil, message = nil, percent: nil, done: false, **values)
|
|
126
|
+
id ||= values[:id]
|
|
127
|
+
message ||= values[:message]
|
|
128
|
+
payload = {
|
|
129
|
+
id: required_text(id, "progress id", max_bytes: MAX_ID_BYTES),
|
|
130
|
+
message: required_text(message, "progress message"),
|
|
131
|
+
percent: normalize_percent(percent),
|
|
132
|
+
done: done == true
|
|
133
|
+
}.compact
|
|
134
|
+
unless @backend.emit(:progress, immutable(payload))
|
|
135
|
+
fallback = payload[:percent] ? "#{payload[:message]} (#{payload[:percent]}%)" : payload[:message]
|
|
136
|
+
@say_callback&.call(fallback)
|
|
137
|
+
end
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def with_cancellation(cancellation)
|
|
142
|
+
self.class.new(backend: @backend, say_callback: @say_callback, cancellation: cancellation)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
def request(kind, payload)
|
|
148
|
+
return false if kind == :confirm && !supported?(kind)
|
|
149
|
+
return nil unless supported?(kind)
|
|
150
|
+
|
|
151
|
+
@cancellation&.raise_if_cancelled!
|
|
152
|
+
answer = @backend.request(kind, immutable(payload), cancellation: @cancellation)
|
|
153
|
+
@cancellation&.raise_if_cancelled!
|
|
154
|
+
answer
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def normalize_options(options)
|
|
158
|
+
raise ArgumentError, "select options must be an array" unless options.is_a?(Array)
|
|
159
|
+
unless options.length.between?(1, MAX_OPTIONS)
|
|
160
|
+
raise ArgumentError, "select requires 1-#{MAX_OPTIONS} options"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
normalized = options.map.with_index(1) do |option, index|
|
|
164
|
+
if option.is_a?(Hash)
|
|
165
|
+
label = option[:label] || option["label"]
|
|
166
|
+
value = option.key?(:value) ? option[:value] : option.fetch("value", label)
|
|
167
|
+
description = option[:description] || option["description"]
|
|
168
|
+
{
|
|
169
|
+
label: required_text(label, "select option #{index} label"),
|
|
170
|
+
value: required_text(value, "select option #{index} value"),
|
|
171
|
+
description: optional_text(description)
|
|
172
|
+
}.compact
|
|
173
|
+
else
|
|
174
|
+
text = required_text(option, "select option #{index}")
|
|
175
|
+
{ label: text, value: text }
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
labels = normalized.map { |option| option[:label] }
|
|
179
|
+
values = normalized.map { |option| option[:value] }
|
|
180
|
+
raise ArgumentError, "select option labels must be unique" unless labels.uniq.length == labels.length
|
|
181
|
+
raise ArgumentError, "select option values must be unique" unless values.uniq.length == values.length
|
|
182
|
+
|
|
183
|
+
normalized
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def normalize_percent(percent)
|
|
187
|
+
return nil if percent.nil?
|
|
188
|
+
|
|
189
|
+
number = Float(percent)
|
|
190
|
+
raise ArgumentError, "progress percent must be between 0 and 100" unless number.between?(0, 100)
|
|
191
|
+
|
|
192
|
+
number % 1 == 0 ? number.to_i : number
|
|
193
|
+
rescue TypeError, ArgumentError
|
|
194
|
+
raise ArgumentError, "progress percent must be between 0 and 100"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def required_text(value, name, max_bytes: MAX_TEXT_BYTES)
|
|
198
|
+
text = bounded_text(value, name, max_bytes: max_bytes)
|
|
199
|
+
raise ArgumentError, "#{name} is required" if text.strip.empty?
|
|
200
|
+
|
|
201
|
+
text
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def optional_text(value)
|
|
205
|
+
value.nil? ? nil : bounded_text(value, "text")
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def bounded_text(value, name, max_bytes: MAX_TEXT_BYTES)
|
|
209
|
+
text = value.to_s
|
|
210
|
+
raise ArgumentError, "#{name} exceeds #{max_bytes} bytes" if text.bytesize > max_bytes
|
|
211
|
+
|
|
212
|
+
text.freeze
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def immutable(value)
|
|
216
|
+
DeepCopy.freeze(DeepCopy.dup(value))
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require_relative "../terminal/text"
|
|
2
|
+
|
|
1
3
|
# Namespace for the Kward CLI agent runtime.
|
|
2
4
|
module Kward
|
|
3
5
|
# Renderer for the editable composer text area.
|
|
@@ -35,45 +37,42 @@ module Kward
|
|
|
35
37
|
end
|
|
36
38
|
|
|
37
39
|
def compact_composer_layout(width)
|
|
38
|
-
cursor_line, cursor_col =
|
|
40
|
+
lines, cursor_line, cursor_col = @composer.lines_and_cursor
|
|
39
41
|
prefix = "#{@prompt_label} "
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
prefix_width = TerminalText.width(prefix)
|
|
43
|
+
line = lines[cursor_line] || ""
|
|
44
|
+
input_width = [width - prefix_width, 1].max
|
|
45
|
+
layout = TerminalText.wrap(line, width: input_width, cursor: cursor_col)
|
|
46
|
+
visible = layout[:rows][layout[:cursor_row]].to_s
|
|
47
|
+
row = visible_ljust(TerminalText.truncate("#{prefix}#{visible}", width), width)
|
|
48
|
+
[[row], 0, [prefix_width + layout[:cursor_col], width - 1].min]
|
|
46
49
|
end
|
|
47
50
|
|
|
48
51
|
def input_layout(content_width)
|
|
49
|
-
cursor_line, cursor_col =
|
|
52
|
+
lines, cursor_line, cursor_col = @composer.lines_and_cursor
|
|
50
53
|
rows = []
|
|
51
54
|
cursor_row = 0
|
|
55
|
+
cursor_col_in_row = 0
|
|
52
56
|
rendered_row_offset = 0
|
|
53
57
|
|
|
54
|
-
|
|
58
|
+
lines.each_with_index do |line, index|
|
|
55
59
|
prefix = input_prefix(index)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if index == cursor_line && cursor_col == line.length && line.length.positive? && (line.length % available).zero?
|
|
61
|
-
chunks << ""
|
|
62
|
-
end
|
|
60
|
+
prefix_width = TerminalText.width(prefix)
|
|
61
|
+
continuation_prefix = " " * prefix_width
|
|
62
|
+
available = [content_width - prefix_width, 1].max
|
|
63
|
+
layout = TerminalText.wrap(line, width: available, cursor: index == cursor_line ? cursor_col : nil)
|
|
63
64
|
|
|
64
65
|
if index == cursor_line
|
|
65
|
-
cursor_row = rendered_row_offset +
|
|
66
|
+
cursor_row = rendered_row_offset + layout[:cursor_row]
|
|
67
|
+
cursor_col_in_row = prefix_width + layout[:cursor_col]
|
|
66
68
|
end
|
|
67
69
|
|
|
68
|
-
|
|
70
|
+
layout[:rows].each_with_index do |chunk, chunk_index|
|
|
69
71
|
rows << "#{chunk_index.zero? ? prefix : continuation_prefix}#{chunk}"
|
|
70
72
|
end
|
|
71
|
-
rendered_row_offset +=
|
|
73
|
+
rendered_row_offset += layout[:rows].length
|
|
72
74
|
end
|
|
73
75
|
|
|
74
|
-
prefix = input_prefix(cursor_line)
|
|
75
|
-
available = [content_width - prefix.length, 1].max
|
|
76
|
-
cursor_col_in_row = prefix.length + (cursor_col % available)
|
|
77
76
|
[rows, cursor_row, cursor_col_in_row]
|
|
78
77
|
end
|
|
79
78
|
|
|
@@ -81,23 +80,28 @@ module Kward
|
|
|
81
80
|
title = composer_title
|
|
82
81
|
status = cached_composer_status_text
|
|
83
82
|
if status
|
|
84
|
-
gap = width - 2 - ANSI.strip(title)
|
|
83
|
+
gap = width - 2 - TerminalText.width(ANSI.strip(title)) - TerminalText.width(ANSI.strip(status))
|
|
85
84
|
if gap >= 0
|
|
86
85
|
return colored("╭", :primary_green) + title + colored("─" * gap, :primary_green) + status + colored("╮", :primary_green)
|
|
87
86
|
end
|
|
88
87
|
end
|
|
89
88
|
plain_title = ANSI.strip(title)
|
|
90
|
-
"#{colored("╭", :primary_green)}#{title}#{colored("─" * [width - plain_title
|
|
89
|
+
"#{colored("╭", :primary_green)}#{title}#{colored("─" * [width - TerminalText.width(plain_title) - 2, 0].max, :primary_green)}#{colored("╮", :primary_green)}"
|
|
91
90
|
end
|
|
92
91
|
|
|
93
92
|
def composer_title
|
|
94
93
|
label = composer_title_label
|
|
95
94
|
if @busy && @queued_count.positive?
|
|
96
|
-
status_composer_text(busy_title("#{label} · #{@queued_count} queued"))
|
|
95
|
+
status_composer_text(busy_title("#{label} · #{colored("#{@queued_count} queued", :metadata)}"))
|
|
97
96
|
elsif @busy && @steered_count.to_i.positive?
|
|
98
|
-
status_composer_text(busy_title("#{label} · #{spinner_frame} steering"))
|
|
97
|
+
status_composer_text(busy_title("#{label} · #{colored(spinner_frame, :activity, :bold)} steering"))
|
|
98
|
+
elsif @busy && response_arriving?
|
|
99
|
+
status_composer_text(busy_title("#{label} · #{colored("✦", :activity, :bold)} responding"))
|
|
99
100
|
elsif @busy
|
|
100
|
-
status_composer_text(busy_title("#{label} · #{spinner_frame} #{@busy_activity}"))
|
|
101
|
+
status_composer_text(busy_title("#{label} · #{colored(spinner_frame, :activity, :bold)} #{@busy_activity}"))
|
|
102
|
+
elsif @completion_status
|
|
103
|
+
completion = colored(@completion_status[:text], @completion_status[:style], :bold)
|
|
104
|
+
status_composer_text("#{label} · #{completion}")
|
|
101
105
|
else
|
|
102
106
|
status_composer_text(label)
|
|
103
107
|
end
|
|
@@ -110,6 +114,8 @@ module Kward
|
|
|
110
114
|
end
|
|
111
115
|
|
|
112
116
|
def busy_title(text)
|
|
117
|
+
elapsed = busy_elapsed_text
|
|
118
|
+
text = "#{text} · #{colored(elapsed, :metadata)}" if elapsed
|
|
113
119
|
@busy_help ? "#{text} · #{BUSY_HELP_TEXT}" : text
|
|
114
120
|
end
|
|
115
121
|
|
|
@@ -140,7 +146,7 @@ module Kward
|
|
|
140
146
|
label_left = 4
|
|
141
147
|
@tabs.each_with_index.map do |label, index|
|
|
142
148
|
text = tab_label(label, index)
|
|
143
|
-
width = ANSI.strip(text)
|
|
149
|
+
width = TerminalText.width(ANSI.strip(text))
|
|
144
150
|
slot = {
|
|
145
151
|
left: label_left - 2,
|
|
146
152
|
label_left: label_left,
|
|
@@ -185,18 +191,25 @@ module Kward
|
|
|
185
191
|
|
|
186
192
|
visible_offset = 0
|
|
187
193
|
last_index = nil
|
|
188
|
-
text.to_s.scan(/\e\[[0-9;:]*m
|
|
194
|
+
text.to_s.scan(/\e\[[0-9;:]*m|\X/m).each do |part|
|
|
189
195
|
if part.start_with?("\e")
|
|
190
196
|
index = visible_offset.positive? ? last_index : left
|
|
191
197
|
row[index] = row[index].to_s + part if index&.between?(0, row.length - 1)
|
|
192
198
|
next
|
|
193
199
|
end
|
|
194
200
|
|
|
201
|
+
part_width = TerminalText.width(part)
|
|
202
|
+
if part_width.zero?
|
|
203
|
+
row[last_index] = row[last_index].to_s + part if last_index
|
|
204
|
+
next
|
|
205
|
+
end
|
|
206
|
+
|
|
195
207
|
index = left + visible_offset
|
|
196
208
|
break if index >= row.length
|
|
197
209
|
row[index] = row[index].to_s.sub(/\A /, "") + part unless index.negative?
|
|
210
|
+
1.upto(part_width - 1) { |offset| row[index + offset] = "" if (index + offset).between?(0, row.length - 1) }
|
|
198
211
|
last_index = index
|
|
199
|
-
visible_offset +=
|
|
212
|
+
visible_offset += part_width
|
|
200
213
|
end
|
|
201
214
|
end
|
|
202
215
|
|
|
@@ -252,19 +265,10 @@ module Kward
|
|
|
252
265
|
[[input_cap, height - 3 - overlay_count - footer_count - attachment_count].min, 1].max
|
|
253
266
|
end
|
|
254
267
|
|
|
255
|
-
def input_lines
|
|
256
|
-
lines = composer_input.split("\n", -1)
|
|
257
|
-
lines.empty? ? [""] : lines
|
|
258
|
-
end
|
|
259
|
-
|
|
260
268
|
def input_prefix(_index)
|
|
261
269
|
""
|
|
262
270
|
end
|
|
263
271
|
|
|
264
|
-
def cursor_logical_position
|
|
265
|
-
@composer.cursor_logical_position
|
|
266
|
-
end
|
|
267
|
-
|
|
268
272
|
def image_viewer_preview_rows(height, width: screen_width)
|
|
269
273
|
available_rows = max_project_browser_rows(height, width: width)
|
|
270
274
|
desired_rows = [(IMAGE_VIEWER_MAX_ROWS * image_viewer_zoom).round, 1].max
|
|
@@ -276,7 +280,7 @@ module Kward
|
|
|
276
280
|
preview_rows = image_viewer_preview_rows(height, width: width)
|
|
277
281
|
title = visible_truncate(" Image: #{@image_viewer_state[:display_path]} ", content_width)
|
|
278
282
|
rows = [
|
|
279
|
-
colored("╭", :primary_green) + title + colored("─" * [width - ANSI.strip(title)
|
|
283
|
+
colored("╭", :primary_green) + title + colored("─" * [width - TerminalText.width(ANSI.strip(title)) - 2, 0].max, :primary_green) + colored("╮", :primary_green),
|
|
280
284
|
image_viewer_graphic_row(width, image_viewer_sequence(width, height))
|
|
281
285
|
]
|
|
282
286
|
rows.concat(Array.new(preview_rows - 1) { box_content_row("", content_width) })
|