kward 0.84.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 +34 -0
- data/Gemfile.lock +2 -2
- data/README.md +2 -2
- data/doc/agent-tools.md +13 -1
- data/doc/api.md +17 -2
- data/doc/composer.md +1 -1
- data/doc/configuration.md +27 -4
- data/doc/extensibility.md +2 -1
- data/doc/git.md +3 -1
- data/doc/pan.md +6 -0
- data/doc/permissions.md +4 -4
- data/doc/plugins.md +464 -15
- data/doc/rpc.md +154 -16
- data/doc/sandboxing.md +11 -5
- data/doc/security.md +7 -1
- data/doc/session-management.md +5 -4
- data/doc/tabs.md +6 -2
- data/doc/transports.md +15 -0
- data/doc/usage.md +4 -1
- 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/lib/kward/agent.rb +24 -11
- data/lib/kward/cli/compaction.rb +9 -3
- data/lib/kward/cli/plugins.rb +81 -12
- data/lib/kward/cli/prompt_interface.rb +25 -5
- data/lib/kward/cli/rendering.rb +3 -0
- data/lib/kward/cli/runtime_helpers.rb +121 -27
- 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 +45 -4
- data/lib/kward/cli/tabs.rb +167 -27
- data/lib/kward/{cli_transcript_formatter.rb → cli/transcript_formatter.rb} +3 -3
- data/lib/kward/cli/worktrees.rb +65 -2
- data/lib/kward/cli.rb +26 -24
- 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 -1008
- data/lib/kward/conversation.rb +31 -2
- data/lib/kward/image_attachments.rb +1 -1
- data/lib/kward/model/client.rb +2 -2
- 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/typesafe_client.rb +78 -0
- data/lib/kward/pan/server.rb +10 -7
- 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 +1 -1
- data/lib/kward/prompt_interface/composer_state.rb +1 -1
- data/lib/kward/prompt_interface/editor/controller.rb +1 -1
- data/lib/kward/prompt_interface/editor/runner.rb +1 -1
- data/lib/kward/prompt_interface/editor/state.rb +1 -1
- data/lib/kward/prompt_interface/layout.rb +1 -1
- data/lib/kward/prompt_interface/overlay_renderer.rb +1 -1
- data/lib/kward/prompt_interface/plugin_ui_requests.rb +82 -0
- data/lib/kward/prompt_interface/runtime_state.rb +6 -1
- data/lib/kward/prompt_interface/screen.rb +9 -2
- data/lib/kward/prompt_interface.rb +60 -11
- data/lib/kward/prompts/commands.rb +2 -1
- data/lib/kward/{adaptive_pty_output_sink.rb → pty/adaptive_output_sink.rb} +1 -1
- data/lib/kward/{interactive_pty_runner.rb → pty/interactive_runner.rb} +2 -2
- 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} +9 -4
- 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/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/{kwsh.rb → shell/kwsh.rb} +3 -3
- data/lib/kward/{persistent_shell_session.rb → shell/persistent_session.rb} +3 -3
- 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/{clipboard.rb → terminal/clipboard.rb} +1 -1
- data/lib/kward/{terminal_image_support.rb → terminal/image_support.rb} +1 -1
- 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/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 +96 -13
- 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 +2 -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/{git_worktree_manager.rb → workspace/git_worktree_manager.rb} +28 -0
- data/lib/kward/{workspace.rb → workspace/workspace.rb} +3 -3
- data/templates/default/fulldoc/html/css/kward.css +125 -0
- data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
- data/templates/default/fulldoc/html/setup.rb +1 -1
- data/templates/default/layout/html/layout.erb +16 -4
- metadata +67 -48
- data/lib/kward/tab_driver.rb +0 -90
- data/templates/default/fulldoc/html/images/kward_workflow.svg +0 -52
- /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/{markdown_code_block.rb → prompt_interface/editor/markdown_code_block.rb} +0 -0
- /data/lib/kward/{scratchpad_languages.rb → prompt_interface/editor/scratchpad_languages.rb} +0 -0
- /data/lib/kward/{scratchpad_runner.rb → prompt_interface/editor/scratchpad_runner.rb} +0 -0
- /data/lib/kward/{detached_run.rb → pty/detached_run.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/{kwshrc.rb → shell/kwshrc.rb} +0 -0
- /data/lib/kward/{shell_prompt.rb → shell/prompt.rb} +0 -0
- /data/lib/kward/{ansi.rb → terminal/ansi.rb} +0 -0
- /data/lib/kward/{terminal_keys.rb → terminal/keys.rb} +0 -0
- /data/lib/kward/{terminal_sequences.rb → terminal/sequences.rb} +0 -0
- /data/lib/kward/{terminal_text.rb → terminal/text.rb} +0 -0
- /data/lib/kward/{transcript_export.rb → transcripts/transcript_export.rb} +0 -0
- /data/lib/kward/{project_files.rb → workspace/files.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
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
class PromptInterface
|
|
4
|
+
# Terminal implementations of the frontend-neutral plugin UI requests.
|
|
5
|
+
module PluginUIRequests
|
|
6
|
+
def request_plugin_ui(kind, payload, cancellation: nil)
|
|
7
|
+
cancellation&.raise_if_cancelled!
|
|
8
|
+
result = case kind.to_sym
|
|
9
|
+
when :question then ask_user_question(payload.fetch(:questions))
|
|
10
|
+
when :select then request_plugin_select(payload)
|
|
11
|
+
when :confirm then request_plugin_confirmation(payload)
|
|
12
|
+
when :input then request_plugin_input(payload)
|
|
13
|
+
else raise ArgumentError, "unsupported plugin UI request: #{kind}"
|
|
14
|
+
end
|
|
15
|
+
cancellation&.raise_if_cancelled!
|
|
16
|
+
result
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def request_plugin_select(payload)
|
|
22
|
+
options = payload.fetch(:options)
|
|
23
|
+
labels = options.map { |option| plugin_option_text(option) }
|
|
24
|
+
selected = with_plugin_ui_modal_state do
|
|
25
|
+
select(payload[:message] || "Choose an option", labels, title: payload.fetch(:title))
|
|
26
|
+
end
|
|
27
|
+
return nil if selected.nil?
|
|
28
|
+
|
|
29
|
+
option = options[labels.index(selected)]
|
|
30
|
+
option && option.fetch(:value)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def request_plugin_confirmation(payload)
|
|
34
|
+
yes = { label: "Yes", description: "Continue." }
|
|
35
|
+
no = { label: "No", description: "Cancel." }
|
|
36
|
+
answers = ask_user_question([
|
|
37
|
+
{
|
|
38
|
+
header: payload.fetch(:title),
|
|
39
|
+
question: payload.fetch(:message),
|
|
40
|
+
options: payload[:default] ? [yes, no] : [no, yes]
|
|
41
|
+
}
|
|
42
|
+
])
|
|
43
|
+
return false unless answers
|
|
44
|
+
|
|
45
|
+
answers.first[:answer].to_s.casecmp?("yes")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def request_plugin_input(payload)
|
|
49
|
+
with_plugin_ui_modal_state do
|
|
50
|
+
@mutex.synchronize do
|
|
51
|
+
self.composer_input = payload[:default].to_s
|
|
52
|
+
self.composer_cursor = composer_input.length
|
|
53
|
+
@composer.prefill_input = nil
|
|
54
|
+
@composer.clear_attachments
|
|
55
|
+
@pending_keys.clear
|
|
56
|
+
@busy = false
|
|
57
|
+
@asking = true
|
|
58
|
+
end
|
|
59
|
+
label = payload[:placeholder].to_s.empty? ? payload.fetch(:title) : "#{payload.fetch(:title)} · #{payload[:placeholder]}"
|
|
60
|
+
answer = ask("#{label}>")
|
|
61
|
+
answer.is_a?(String) ? answer : nil
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def with_plugin_ui_modal_state
|
|
66
|
+
saved_state = @mutex.synchronize do
|
|
67
|
+
state = begin_question_prompt_state
|
|
68
|
+
@question_prompt_active = true
|
|
69
|
+
state
|
|
70
|
+
end
|
|
71
|
+
yield
|
|
72
|
+
ensure
|
|
73
|
+
finish_question_prompt(saved_state) if saved_state
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def plugin_option_text(option)
|
|
77
|
+
description = option[:description].to_s
|
|
78
|
+
description.empty? ? option.fetch(:label) : "#{option.fetch(:label)} — #{description}"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -108,13 +108,18 @@ module Kward
|
|
|
108
108
|
end
|
|
109
109
|
|
|
110
110
|
def refresh_footer_text_locked
|
|
111
|
-
@cached_footer_text =
|
|
111
|
+
@cached_footer_text = call_footer([screen_width - 4, 1].max).to_s.gsub(/\s+/, " ").strip
|
|
112
112
|
rescue StandardError
|
|
113
113
|
@cached_footer_text = ""
|
|
114
114
|
ensure
|
|
115
115
|
@last_footer_refresh = monotonic_now
|
|
116
116
|
end
|
|
117
117
|
|
|
118
|
+
def call_footer(max_width)
|
|
119
|
+
arity = @footer.respond_to?(:arity) ? @footer.arity : @footer.method(:call).arity
|
|
120
|
+
arity.zero? ? @footer.call : @footer.call(max_width)
|
|
121
|
+
end
|
|
122
|
+
|
|
118
123
|
def cached_composer_status_text
|
|
119
124
|
return nil unless @composer_status
|
|
120
125
|
|
|
@@ -118,10 +118,16 @@ module Kward
|
|
|
118
118
|
old_top = [height - old_reserved_rows + 1, 1].max
|
|
119
119
|
@reserved_rows = new_reserved_rows
|
|
120
120
|
new_top = composer_top_row(height)
|
|
121
|
-
|
|
121
|
+
new_bottom = transcript_bottom_row(height)
|
|
122
|
+
print_output_locked(TerminalSequences.scroll_region(1, new_bottom))
|
|
122
123
|
clear_screen_rows_locked(old_top, new_top - 1) if new_top > old_top
|
|
124
|
+
clear_screen_rows_locked(new_bottom + 1, new_top - 1) if old_reserved_rows.positive? && new_bottom < new_top - 1
|
|
123
125
|
@last_composer_rows = []
|
|
124
|
-
|
|
126
|
+
if redraw_transcript && @stream_state.block && new_reserved_rows > old_reserved_rows
|
|
127
|
+
@transcript_viewport_rows = [@transcript_viewport_rows, new_bottom].min
|
|
128
|
+
end
|
|
129
|
+
should_redraw_transcript = redraw_transcript && (new_reserved_rows < old_reserved_rows || @stream_state.block)
|
|
130
|
+
redraw_transcript_locked(width: width, height: height) if should_redraw_transcript && new_reserved_rows != old_reserved_rows
|
|
125
131
|
end
|
|
126
132
|
|
|
127
133
|
def handle_resize_locked
|
|
@@ -139,6 +145,7 @@ module Kward
|
|
|
139
145
|
@reserved_rows = 0
|
|
140
146
|
@last_width = current_width
|
|
141
147
|
@last_height = current_height
|
|
148
|
+
@last_footer_refresh = nil if current_width != old_width
|
|
142
149
|
if interactive_active_locked?
|
|
143
150
|
@interactive_state[:controller].resize(width: interactive_canvas_width(current_width))
|
|
144
151
|
end
|