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,194 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "logger"
|
|
3
|
+
require_relative "../config_files"
|
|
4
|
+
require_relative "../deep_copy"
|
|
5
|
+
require_relative "../plugins/chat_contract"
|
|
6
|
+
require_relative "../plugins/host"
|
|
7
|
+
require_relative "../plugins/resources"
|
|
8
|
+
|
|
9
|
+
# Namespace for the Kward CLI agent runtime.
|
|
10
|
+
module Kward
|
|
11
|
+
# Adapts a session-backed agent to the tab runtime interface. Plugin tab
|
|
12
|
+
# drivers implement the same small surface without becoming Kward sessions.
|
|
13
|
+
class SessionTabDriver
|
|
14
|
+
attr_reader :session, :agent, :worktree
|
|
15
|
+
|
|
16
|
+
def initialize(session:, agent:, worktree: nil)
|
|
17
|
+
@session = session
|
|
18
|
+
@agent = agent
|
|
19
|
+
@worktree = worktree
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def messages
|
|
23
|
+
agent.conversation.messages
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def conversation
|
|
27
|
+
agent.conversation
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def submit(input, display_input:, cancellation:, steering: nil, &block)
|
|
31
|
+
options = { cancellation: cancellation }
|
|
32
|
+
options[:display_input] = display_input unless display_input.nil?
|
|
33
|
+
options[:steering] = steering if steering
|
|
34
|
+
agent.ask(input, **options, &block)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def descriptor
|
|
38
|
+
descriptor = { "kind" => "session", "session_path" => session.path }
|
|
39
|
+
descriptor["worktree"] = worktree.descriptor if worktree
|
|
40
|
+
descriptor
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def session?
|
|
44
|
+
true
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def supports_steering?
|
|
48
|
+
true
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def assistant_label
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Represents a persisted plugin tab whose provider plugin is unavailable.
|
|
57
|
+
# Its descriptor is retained so reinstalling the plugin restores the tab.
|
|
58
|
+
class UnavailableTabDriver
|
|
59
|
+
attr_reader :descriptor
|
|
60
|
+
|
|
61
|
+
def initialize(descriptor:, message:)
|
|
62
|
+
@descriptor = descriptor
|
|
63
|
+
@message = message
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def messages
|
|
67
|
+
[{ role: "assistant", content: @message }]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def submit(*)
|
|
71
|
+
raise @message
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def session?
|
|
75
|
+
false
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def supports_steering?
|
|
79
|
+
false
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def assistant_label
|
|
83
|
+
"Plugin"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Dependencies and instance-scoped services made available to a plugin chat
|
|
88
|
+
# factory without exposing CLI or workspace-session internals.
|
|
89
|
+
class PluginTabHost
|
|
90
|
+
SURFACES = %i[local rpc transport shared].freeze
|
|
91
|
+
|
|
92
|
+
class LogDevice
|
|
93
|
+
def write(message)
|
|
94
|
+
ConfigFiles.emit_warning(message.to_s.chomp)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def close
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
private_constant :LogDevice
|
|
102
|
+
|
|
103
|
+
attr_reader :client, :workspace_root, :plugin_id, :type_id, :surface, :scope_key,
|
|
104
|
+
:capabilities, :config, :storage, :logger
|
|
105
|
+
|
|
106
|
+
def initialize(client:, workspace_root:, plugin_host: nil, type_id: nil, surface: :local, scope_key: "default", capabilities: nil, config: nil, storage: nil, logger: nil, env: ENV)
|
|
107
|
+
@client = client
|
|
108
|
+
@workspace_root = workspace_root
|
|
109
|
+
@plugin_id = plugin_host&.id
|
|
110
|
+
@type_id = required_value(type_id || @plugin_id || "anonymous", "plugin chat type id").freeze
|
|
111
|
+
@surface = normalize_surface(surface)
|
|
112
|
+
@scope_key = required_value(scope_key, "plugin chat scope key").freeze
|
|
113
|
+
@capabilities = capabilities || PluginChatCapabilities.legacy
|
|
114
|
+
@config = freeze_config(config || plugin_host&.config || ConfigFiles.plugin_config(storage_owner_id))
|
|
115
|
+
@storage = PluginScopedStore.new(storage || plugin_host&.storage || default_storage, storage_namespace)
|
|
116
|
+
@logger = logger || plugin_host&.logger || default_logger
|
|
117
|
+
@env = env
|
|
118
|
+
@resources = PluginResources.new(name: "Kward plugin chat #{@type_id}", warning_sink: ConfigFiles.warning_sink).tap(&:activate!)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Starts cooperative background work owned by this chat instance.
|
|
122
|
+
def background(name: nil, cancellation: nil, &block)
|
|
123
|
+
@resources.background(name: name, cancellation: cancellation, &block)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Registers cleanup that runs when the chat instance closes.
|
|
127
|
+
def on_cleanup(resource = nil, &block)
|
|
128
|
+
@resources.on_cleanup(resource, &block)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
alias manage on_cleanup
|
|
132
|
+
|
|
133
|
+
# Reads a secret from private plugin config, an explicit environment
|
|
134
|
+
# variable, or the plugin's conventional KWARD_PLUGIN_* variable.
|
|
135
|
+
def secret(key, env: nil)
|
|
136
|
+
key = required_value(key, "secret key")
|
|
137
|
+
value = config[key]
|
|
138
|
+
value = @env[env.to_s] if value.nil? && env
|
|
139
|
+
value = @env[default_secret_env_name(key)] if value.nil?
|
|
140
|
+
value.to_s unless value.nil?
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Cancels chat-owned work and invokes cleanup callbacks.
|
|
144
|
+
# @api private
|
|
145
|
+
def shutdown(timeout: PluginResources::DEFAULT_SHUTDOWN_TIMEOUT)
|
|
146
|
+
@resources.shutdown(timeout: timeout)
|
|
147
|
+
self
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
def storage_owner_id
|
|
153
|
+
plugin_id || type_id
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def storage_namespace
|
|
157
|
+
"plugin_chat:#{type_id}:#{scope_key}"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def default_storage
|
|
161
|
+
safe_id = "chat.#{Digest::SHA256.hexdigest(storage_owner_id)[0, 24]}"
|
|
162
|
+
PluginStore.new(safe_id)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def freeze_config(value)
|
|
166
|
+
raise ArgumentError, "Kward plugin config for #{storage_owner_id} must be an object" unless value.is_a?(Hash)
|
|
167
|
+
|
|
168
|
+
DeepCopy.freeze(DeepCopy.dup(value))
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def normalize_surface(value)
|
|
172
|
+
surface = value.to_sym
|
|
173
|
+
raise ArgumentError, "Unknown plugin chat surface: #{value}" unless SURFACES.include?(surface)
|
|
174
|
+
|
|
175
|
+
surface
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def required_value(value, name)
|
|
179
|
+
value = value.to_s
|
|
180
|
+
raise ArgumentError, "#{name} is required" if value.empty?
|
|
181
|
+
|
|
182
|
+
value
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def default_secret_env_name(key)
|
|
186
|
+
parts = [storage_owner_id, key].map { |part| part.gsub(/[^A-Za-z0-9]/, "_").upcase }
|
|
187
|
+
"KWARD_PLUGIN_#{parts.join("_")}"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def default_logger
|
|
191
|
+
Logger.new(LogDevice.new).tap { |logger| logger.progname = "Kward plugin chat #{type_id}" }
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -23,6 +23,12 @@ module Kward
|
|
|
23
23
|
gray: 90,
|
|
24
24
|
grey: 90,
|
|
25
25
|
white: 97,
|
|
26
|
+
activity: 36,
|
|
27
|
+
success: 32,
|
|
28
|
+
caution: 33,
|
|
29
|
+
failure: 31,
|
|
30
|
+
tool: 35,
|
|
31
|
+
metadata: 90,
|
|
26
32
|
primary_green: "38;2;138;160;106",
|
|
27
33
|
bright_accent_green: "38;2;155;255;0",
|
|
28
34
|
augen: "38;2;155;255;0",
|
|
@@ -181,23 +187,30 @@ module Kward
|
|
|
181
187
|
@enabled = enabled
|
|
182
188
|
@pending = +""
|
|
183
189
|
@in_fence = false
|
|
190
|
+
@line_streaming = false
|
|
184
191
|
end
|
|
185
192
|
|
|
186
193
|
def render(delta, final: false)
|
|
187
|
-
|
|
188
|
-
return ANSI.markdown(text, enabled: @enabled) if fast_markdown?(text, final)
|
|
189
|
-
|
|
190
|
-
@pending << text
|
|
194
|
+
@pending << delta.to_s
|
|
191
195
|
rendered = +""
|
|
192
196
|
while (match = @pending.match(/\r\n|\r|\n/))
|
|
193
197
|
line = @pending[0...match.begin(0)]
|
|
194
198
|
@pending = @pending[(match.end(0))..] || +""
|
|
195
|
-
rendered <<
|
|
199
|
+
rendered << render_pending_line(line) << "\n"
|
|
196
200
|
end
|
|
197
201
|
|
|
198
|
-
if final
|
|
199
|
-
|
|
200
|
-
|
|
202
|
+
if final
|
|
203
|
+
unless @pending.empty?
|
|
204
|
+
rendered << render_pending_line(@pending)
|
|
205
|
+
@pending.clear
|
|
206
|
+
end
|
|
207
|
+
else
|
|
208
|
+
streamable = streamable_inline_prefix
|
|
209
|
+
unless streamable.empty?
|
|
210
|
+
rendered << ANSI.inline_markdown(streamable, enabled: @enabled)
|
|
211
|
+
@pending = @pending[streamable.length..] || +""
|
|
212
|
+
@line_streaming = true
|
|
213
|
+
end
|
|
201
214
|
end
|
|
202
215
|
|
|
203
216
|
if final && @in_fence
|
|
@@ -211,8 +224,95 @@ module Kward
|
|
|
211
224
|
|
|
212
225
|
private
|
|
213
226
|
|
|
214
|
-
def
|
|
215
|
-
|
|
227
|
+
def render_pending_line(line)
|
|
228
|
+
if @line_streaming
|
|
229
|
+
@line_streaming = false
|
|
230
|
+
ANSI.inline_markdown(line, enabled: @enabled)
|
|
231
|
+
else
|
|
232
|
+
render_line(line)
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def streamable_inline_prefix
|
|
237
|
+
return "" if @pending.empty? || block_line_pending?
|
|
238
|
+
|
|
239
|
+
boundary = [
|
|
240
|
+
unmatched_marker_index("`"),
|
|
241
|
+
unmatched_marker_index("**"),
|
|
242
|
+
unmatched_marker_index("~~"),
|
|
243
|
+
unmatched_emphasis_index("*"),
|
|
244
|
+
unmatched_emphasis_index("_"),
|
|
245
|
+
incomplete_marker_prefix_index,
|
|
246
|
+
incomplete_link_index
|
|
247
|
+
].compact.min
|
|
248
|
+
boundary ? @pending[0...boundary] : @pending.dup
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def unmatched_marker_index(marker)
|
|
252
|
+
unmatched = nil
|
|
253
|
+
offset = 0
|
|
254
|
+
while (index = @pending.index(marker, offset))
|
|
255
|
+
unmatched = unmatched ? nil : index
|
|
256
|
+
offset = index + marker.length
|
|
257
|
+
end
|
|
258
|
+
unmatched
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def unmatched_emphasis_index(marker)
|
|
262
|
+
unmatched = nil
|
|
263
|
+
offset = 0
|
|
264
|
+
while (index = @pending.index(marker, offset))
|
|
265
|
+
previous = index.positive? ? @pending[index - 1] : nil
|
|
266
|
+
following = @pending[index + 1]
|
|
267
|
+
offset = index + 1
|
|
268
|
+
next if previous == marker || following == marker
|
|
269
|
+
|
|
270
|
+
if unmatched
|
|
271
|
+
unmatched = nil if following.nil? || following.match?(/[\s\)\]},.!?:;]/)
|
|
272
|
+
elsif previous.nil? || previous.match?(/[\s\(\[{]/)
|
|
273
|
+
unmatched = index
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
unmatched
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def incomplete_marker_prefix_index
|
|
280
|
+
trailing = @pending[/~+\z/]
|
|
281
|
+
return nil unless trailing&.length&.odd?
|
|
282
|
+
|
|
283
|
+
@pending.length - 1
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def incomplete_link_index
|
|
287
|
+
offset = 0
|
|
288
|
+
while (opening = @pending.index("[", offset))
|
|
289
|
+
closing = @pending.index("]", opening + 1)
|
|
290
|
+
return opening unless closing
|
|
291
|
+
return opening if closing == @pending.length - 1
|
|
292
|
+
|
|
293
|
+
unless @pending[closing + 1] == "("
|
|
294
|
+
offset = closing + 1
|
|
295
|
+
next
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
closing_parenthesis = @pending.index(")", closing + 2)
|
|
299
|
+
return opening unless closing_parenthesis
|
|
300
|
+
|
|
301
|
+
offset = closing_parenthesis + 1
|
|
302
|
+
end
|
|
303
|
+
nil
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def block_line_pending?
|
|
307
|
+
return true if @in_fence
|
|
308
|
+
return false if @line_streaming
|
|
309
|
+
|
|
310
|
+
line = @pending.lstrip
|
|
311
|
+
line.start_with?("```") || line.match?(/\A`{1,2}\z/) ||
|
|
312
|
+
line.match?(/\A\#{1,6}(?:\s|\z)/) ||
|
|
313
|
+
line.start_with?(">") ||
|
|
314
|
+
line.match?(/\A[-*]\s+\[[ xX]\](?:\s|\z)/) ||
|
|
315
|
+
line.match?(/\A[-*](?:\s*\z|\s+\[[ xX]?\z)/)
|
|
216
316
|
end
|
|
217
317
|
|
|
218
318
|
def render_line(line)
|
|
@@ -9,6 +9,8 @@ module Kward
|
|
|
9
9
|
CTRL_D = "\x04".freeze
|
|
10
10
|
CTRL_E = "\x05".freeze
|
|
11
11
|
CTRL_F = "\x06".freeze
|
|
12
|
+
CTRL_H = "\x08".freeze
|
|
13
|
+
CTRL_J = "\x0A".freeze
|
|
12
14
|
CTRL_K = "\x0B".freeze
|
|
13
15
|
CTRL_L = "\x0C".freeze
|
|
14
16
|
CTRL_N = "\x0E".freeze
|
|
@@ -71,6 +73,16 @@ module Kward
|
|
|
71
73
|
CTRL_T_CSI_U = "\e[116;5u".freeze
|
|
72
74
|
CTRL_W_CSI_U = "\e[119;5u".freeze
|
|
73
75
|
CTRL_NUMBER_TAB_PATTERN = /\A\e\[((?:49)|(?:5[0-7]));5u\z/.freeze
|
|
76
|
+
TAB_ACTION_SEQUENCES = (
|
|
77
|
+
CTRL_TAB +
|
|
78
|
+
CTRL_SHIFT_TAB +
|
|
79
|
+
[CTRL_T, CTRL_T_CSI_U, CTRL_W_CSI_U] +
|
|
80
|
+
(49..57).map { |code| "\e[#{code};5u" } +
|
|
81
|
+
["\et", "\eT"] +
|
|
82
|
+
ALT_RIGHT +
|
|
83
|
+
ALT_LEFT +
|
|
84
|
+
(1..9).map { |number| "\e#{number}" }
|
|
85
|
+
).freeze
|
|
74
86
|
|
|
75
87
|
CSI_U_PATTERN = /\A\e\[(\d+)((?:;[\d:]*)*)u/.freeze
|
|
76
88
|
MODIFIED_CURSOR_PATTERN = /\A\e\[(\d+);(\d+)([CDFH])\z/.freeze
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
require "unicode/display_width"
|
|
2
|
+
|
|
3
|
+
# Namespace for the Kward CLI agent runtime.
|
|
4
|
+
module Kward
|
|
5
|
+
# Terminal-cell and grapheme helpers for cursor-aware text layout.
|
|
6
|
+
module TerminalText
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def width(text)
|
|
10
|
+
Unicode::DisplayWidth.of(text.to_s, ambiguous: 1, emoji: :all)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def truncate(text, max_width)
|
|
14
|
+
string = text.to_s
|
|
15
|
+
limit = [max_width.to_i, 0].max
|
|
16
|
+
return string[0, limit].to_s if string.ascii_only?
|
|
17
|
+
|
|
18
|
+
remaining = limit
|
|
19
|
+
truncated = +""
|
|
20
|
+
string.each_grapheme_cluster do |grapheme|
|
|
21
|
+
grapheme_width = width(grapheme)
|
|
22
|
+
break if grapheme_width > remaining
|
|
23
|
+
|
|
24
|
+
truncated << grapheme
|
|
25
|
+
remaining -= grapheme_width
|
|
26
|
+
end
|
|
27
|
+
truncated
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def wrap(text, width:, cursor: nil)
|
|
31
|
+
string = text.to_s
|
|
32
|
+
line_width = [width.to_i, 1].max
|
|
33
|
+
return wrap_ascii(string, line_width, cursor) if string.ascii_only?
|
|
34
|
+
|
|
35
|
+
rows = [+""]
|
|
36
|
+
row_widths = [0]
|
|
37
|
+
cursor_row = nil
|
|
38
|
+
cursor_col = nil
|
|
39
|
+
offset = 0
|
|
40
|
+
|
|
41
|
+
string.each_grapheme_cluster do |grapheme|
|
|
42
|
+
if !cursor.nil? && cursor_row.nil? && cursor <= offset
|
|
43
|
+
append_empty_row(rows, row_widths) if row_widths.last >= line_width
|
|
44
|
+
cursor_row = rows.length - 1
|
|
45
|
+
cursor_col = row_widths.last
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
grapheme_width = width(grapheme)
|
|
49
|
+
append_empty_row(rows, row_widths) if row_widths.last.positive? && row_widths.last + grapheme_width > line_width
|
|
50
|
+
rows.last << grapheme
|
|
51
|
+
row_widths[-1] += grapheme_width
|
|
52
|
+
offset += grapheme.length
|
|
53
|
+
|
|
54
|
+
if !cursor.nil? && cursor_row.nil? && cursor <= offset
|
|
55
|
+
cursor_row = rows.length - 1
|
|
56
|
+
cursor_col = row_widths.last
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
if !cursor.nil? && cursor_row.nil?
|
|
61
|
+
append_empty_row(rows, row_widths) if row_widths.last >= line_width
|
|
62
|
+
cursor_row = rows.length - 1
|
|
63
|
+
cursor_col = row_widths.last
|
|
64
|
+
elsif !cursor.nil? && cursor >= offset && row_widths.last >= line_width
|
|
65
|
+
append_empty_row(rows, row_widths)
|
|
66
|
+
cursor_row = rows.length - 1
|
|
67
|
+
cursor_col = 0
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
{ rows: rows, cursor_row: cursor_row, cursor_col: cursor_col }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def previous_grapheme_boundary(text, index)
|
|
74
|
+
string = text.to_s
|
|
75
|
+
cursor = [[index.to_i, 0].max, string.length].min
|
|
76
|
+
return [cursor - 1, 0].max if string.ascii_only?
|
|
77
|
+
|
|
78
|
+
previous = 0
|
|
79
|
+
offset = 0
|
|
80
|
+
string.each_grapheme_cluster do |grapheme|
|
|
81
|
+
offset += grapheme.length
|
|
82
|
+
return previous if offset >= cursor
|
|
83
|
+
|
|
84
|
+
previous = offset
|
|
85
|
+
end
|
|
86
|
+
previous
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def next_grapheme_boundary(text, index)
|
|
90
|
+
string = text.to_s
|
|
91
|
+
cursor = [[index.to_i, 0].max, string.length].min
|
|
92
|
+
return [cursor + 1, string.length].min if string.ascii_only?
|
|
93
|
+
|
|
94
|
+
offset = 0
|
|
95
|
+
string.each_grapheme_cluster do |grapheme|
|
|
96
|
+
offset += grapheme.length
|
|
97
|
+
return offset if offset > cursor
|
|
98
|
+
end
|
|
99
|
+
string.length
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def wrap_ascii(text, line_width, cursor)
|
|
103
|
+
rows = text.scan(/.{1,#{line_width}}/m)
|
|
104
|
+
rows = [""] if rows.empty?
|
|
105
|
+
return { rows: rows, cursor_row: nil, cursor_col: nil } if cursor.nil?
|
|
106
|
+
|
|
107
|
+
cursor = [[cursor.to_i, 0].max, text.length].min
|
|
108
|
+
if cursor == text.length && text.length.positive? && (text.length % line_width).zero?
|
|
109
|
+
rows << ""
|
|
110
|
+
end
|
|
111
|
+
{ rows: rows, cursor_row: cursor / line_width, cursor_col: cursor % line_width }
|
|
112
|
+
end
|
|
113
|
+
private_class_method :wrap_ascii
|
|
114
|
+
|
|
115
|
+
def append_empty_row(rows, row_widths)
|
|
116
|
+
rows << +""
|
|
117
|
+
row_widths << 0
|
|
118
|
+
end
|
|
119
|
+
private_class_method :append_empty_row
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Allocation-conscious text matching helpers shared by interactive search UI.
|
|
4
|
+
module TextMatcher
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def subsequence_pattern(query)
|
|
8
|
+
source = query.to_s.each_char.map { |character| Regexp.escape(character) }.join(".*")
|
|
9
|
+
Regexp.new(source, Regexp::MULTILINE)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def subsequence?(value, query, pattern = subsequence_pattern(query))
|
|
13
|
+
text = value.to_s
|
|
14
|
+
needle = query.to_s
|
|
15
|
+
needle.empty? || text.match?(pattern)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
data/lib/kward/tools/base.rb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require_relative "workspace_targets"
|
|
2
|
+
|
|
1
3
|
# Namespace for the Kward CLI agent runtime.
|
|
2
4
|
module Kward
|
|
3
5
|
# Model-callable tool wrappers and their argument schemas.
|
|
@@ -54,6 +56,22 @@ module Kward
|
|
|
54
56
|
end
|
|
55
57
|
end
|
|
56
58
|
|
|
59
|
+
# Configures the active workspace and any additional host-scoped targets.
|
|
60
|
+
def configure_workspace_targets(workspace, workspace_targets)
|
|
61
|
+
@workspace = workspace
|
|
62
|
+
@workspace_targets = workspace_targets || WorkspaceTargets.new(active: workspace)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Adds the optional target selector when additional workspaces are available.
|
|
66
|
+
def targeted_properties(properties)
|
|
67
|
+
@workspace_targets.properties(properties)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Resolves the requested role through the trusted target map.
|
|
71
|
+
def workspace_for(args)
|
|
72
|
+
@workspace_targets.workspace_for(args)
|
|
73
|
+
end
|
|
74
|
+
|
|
57
75
|
# Reads a tool argument while accepting symbol or string keys from restored calls.
|
|
58
76
|
def argument(args, key, default = nil)
|
|
59
77
|
return args[key] if args.key?(key)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
require "pathname"
|
|
2
|
-
require_relative "../workspace"
|
|
2
|
+
require_relative "../workspace/workspace"
|
|
3
3
|
require_relative "base"
|
|
4
4
|
|
|
5
5
|
# Namespace for the Kward CLI agent runtime.
|
|
@@ -16,23 +16,32 @@ module Kward
|
|
|
16
16
|
SKIP_DIRECTORIES = %w[.git .yardoc _yardoc node_modules vendor tmp log coverage dist build .bundle].freeze
|
|
17
17
|
|
|
18
18
|
# Builds the tool schema and stores the execution dependency.
|
|
19
|
-
def initialize(workspace:)
|
|
20
|
-
|
|
19
|
+
def initialize(workspace:, workspace_targets: nil)
|
|
20
|
+
configure_workspace_targets(workspace, workspace_targets)
|
|
21
21
|
super(
|
|
22
22
|
"context_for_task",
|
|
23
23
|
"Build focused workspace context for a task from outlines and matching excerpts within a byte budget.",
|
|
24
|
-
properties:
|
|
24
|
+
properties: targeted_properties(
|
|
25
25
|
budget: { type: "integer", description: "Approximate byte budget for the returned context. Default 4000, maximum 20000." },
|
|
26
26
|
paths: { type: "array", items: { type: "string" }, description: "Optional workspace-relative files or directories to focus." },
|
|
27
27
|
task: { type: "string", description: "Task or question to gather context for." }
|
|
28
|
-
|
|
28
|
+
),
|
|
29
29
|
required: ["task"]
|
|
30
30
|
)
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
# Executes focused context retrieval.
|
|
34
|
-
def call(args,
|
|
34
|
+
def call(args, conversation, cancellation: nil)
|
|
35
35
|
cancellation&.raise_if_cancelled!
|
|
36
|
+
selected_workspace = workspace_for(args)
|
|
37
|
+
unless selected_workspace.equal?(@workspace)
|
|
38
|
+
return self.class.new(workspace: selected_workspace).call(
|
|
39
|
+
@workspace_targets.without_target(args),
|
|
40
|
+
conversation,
|
|
41
|
+
cancellation: cancellation
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
36
45
|
task = argument(args, :task, "").to_s.strip
|
|
37
46
|
return "Error: task is required" if task.empty?
|
|
38
47
|
|
|
@@ -7,12 +7,12 @@ module Kward
|
|
|
7
7
|
# Tool wrapper for exact block replacement edits.
|
|
8
8
|
class EditFile < Base
|
|
9
9
|
# Builds the tool schema and stores the execution dependency.
|
|
10
|
-
def initialize(workspace:)
|
|
11
|
-
|
|
10
|
+
def initialize(workspace:, workspace_targets: nil)
|
|
11
|
+
configure_workspace_targets(workspace, workspace_targets)
|
|
12
12
|
super(
|
|
13
13
|
"edit_file",
|
|
14
14
|
"Edit a read workspace file by exact replacements. Each old_text must match once; edits must not overlap.",
|
|
15
|
-
properties:
|
|
15
|
+
properties: targeted_properties(
|
|
16
16
|
path: { type: "string", description: "Workspace-relative path." },
|
|
17
17
|
edits: {
|
|
18
18
|
type: "array",
|
|
@@ -27,7 +27,7 @@ module Kward
|
|
|
27
27
|
additionalProperties: false
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
-
|
|
30
|
+
),
|
|
31
31
|
required: ["path", "edits"]
|
|
32
32
|
)
|
|
33
33
|
end
|
|
@@ -37,8 +37,11 @@ module Kward
|
|
|
37
37
|
path = argument(args, :path, "")
|
|
38
38
|
edits = argument(args, :edits, [])
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
workspace = workspace_for(args)
|
|
41
|
+
result = workspace.edit_file(path, edits, read_paths: conversation.read_paths)
|
|
42
|
+
if workspace.equal?(@workspace) && agents_file_changed?(workspace, path, result)
|
|
43
|
+
conversation.refresh_system_message!
|
|
44
|
+
end
|
|
42
45
|
result
|
|
43
46
|
end
|
|
44
47
|
end
|
|
@@ -5,15 +5,18 @@ module Kward
|
|
|
5
5
|
module Tools
|
|
6
6
|
# Tool wrapper for the trusted host-side Git commit operation.
|
|
7
7
|
class GitCommit < Base
|
|
8
|
-
def initialize(committer:)
|
|
8
|
+
def initialize(committer:, workspace_targets: nil)
|
|
9
9
|
@committer = committer
|
|
10
|
+
@workspace_targets = workspace_targets
|
|
11
|
+
properties = {
|
|
12
|
+
message: { type: "string", description: "Commit message." },
|
|
13
|
+
paths: { type: "array", items: { type: "string" }, description: "Optional workspace-relative paths to include." }
|
|
14
|
+
}
|
|
15
|
+
properties = workspace_targets.properties(properties) if workspace_targets
|
|
10
16
|
super(
|
|
11
17
|
"git_commit",
|
|
12
|
-
"Stage and commit changes in the active
|
|
13
|
-
properties:
|
|
14
|
-
message: { type: "string", description: "Commit message." },
|
|
15
|
-
paths: { type: "array", items: { type: "string" }, description: "Optional workspace-relative paths to include." }
|
|
16
|
-
},
|
|
18
|
+
"Stage and commit changes in the selected worktree (active by default). Omit paths to include all current changes.",
|
|
19
|
+
properties: properties,
|
|
17
20
|
required: ["message"]
|
|
18
21
|
)
|
|
19
22
|
end
|
|
@@ -27,7 +30,10 @@ module Kward
|
|
|
27
30
|
return "Error: paths must contain strings" if paths && paths.any? { |path| !path.is_a?(String) }
|
|
28
31
|
|
|
29
32
|
paths = nil if paths&.empty?
|
|
30
|
-
|
|
33
|
+
target = @workspace_targets&.target_name(args)
|
|
34
|
+
options = { message: message, paths: paths }
|
|
35
|
+
options[:target] = target if target && target != WorkspaceTargets::ACTIVE
|
|
36
|
+
result = @committer.call(**options)
|
|
31
37
|
output = result[:output].to_s.strip
|
|
32
38
|
return "Git commit succeeded\n#{output}" if result[:success]
|
|
33
39
|
|