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.
Files changed (193) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +102 -15
  3. data/CONTRIBUTING.md +74 -0
  4. data/Gemfile.lock +8 -2
  5. data/README.md +21 -1
  6. data/Rakefile +46 -2
  7. data/SECURITY.md +31 -0
  8. data/doc/agent-tools.md +13 -1
  9. data/doc/api.md +21 -2
  10. data/doc/composer.md +2 -2
  11. data/doc/configuration.md +95 -25
  12. data/doc/editor.md +28 -13
  13. data/doc/extensibility.md +2 -1
  14. data/doc/files.md +8 -4
  15. data/doc/getting-started.md +3 -0
  16. data/doc/git.md +3 -1
  17. data/doc/pan.md +25 -15
  18. data/doc/permissions.md +4 -4
  19. data/doc/platform-support.md +48 -0
  20. data/doc/plugins.md +464 -15
  21. data/doc/rpc.md +154 -16
  22. data/doc/sandboxing.md +11 -5
  23. data/doc/security.md +10 -3
  24. data/doc/session-management.md +5 -4
  25. data/doc/shell.md +62 -45
  26. data/doc/tabs.md +6 -2
  27. data/doc/transports.md +15 -0
  28. data/doc/troubleshooting.md +12 -2
  29. data/doc/usage.md +9 -6
  30. data/doc/workspace-tools.md +9 -0
  31. data/examples/plugins/space_invaders.rb +1 -1
  32. data/examples/plugins/stardate_footer.rb +2 -2
  33. data/examples/plugins/telegram/plugin.rb +1 -1
  34. data/kward.gemspec +5 -4
  35. data/lib/kward/agent.rb +30 -14
  36. data/lib/kward/cli/auth_commands.rb +34 -13
  37. data/lib/kward/cli/commands.rb +83 -62
  38. data/lib/kward/cli/compaction.rb +9 -3
  39. data/lib/kward/cli/doctor.rb +39 -17
  40. data/lib/kward/cli/hook_commands.rb +22 -12
  41. data/lib/kward/cli/interactive_turn.rb +48 -7
  42. data/lib/kward/cli/plugins.rb +81 -12
  43. data/lib/kward/cli/project_skills_commands.rb +8 -4
  44. data/lib/kward/cli/prompt_interface.rb +52 -5
  45. data/lib/kward/cli/rendering.rb +18 -9
  46. data/lib/kward/cli/runtime_helpers.rb +228 -71
  47. data/lib/kward/cli/sessions.rb +9 -5
  48. data/lib/kward/cli/settings/menus.rb +745 -0
  49. data/lib/kward/cli/settings/model.rb +327 -0
  50. data/lib/kward/cli/settings.rb +6 -1055
  51. data/lib/kward/cli/slash_commands.rb +56 -17
  52. data/lib/kward/cli/tabs.rb +244 -33
  53. data/lib/kward/cli/tool_summaries.rb +14 -0
  54. data/lib/kward/{cli_transcript_formatter.rb → cli/transcript_formatter.rb} +14 -7
  55. data/lib/kward/cli/worktrees.rb +65 -2
  56. data/lib/kward/cli.rb +70 -30
  57. data/lib/kward/compactor.rb +18 -7
  58. data/lib/kward/config/core.rb +389 -0
  59. data/lib/kward/config/extensions.rb +96 -0
  60. data/lib/kward/config/prompts.rb +313 -0
  61. data/lib/kward/config/settings.rb +250 -0
  62. data/lib/kward/config_files.rb +14 -994
  63. data/lib/kward/conversation.rb +31 -2
  64. data/lib/kward/image_attachments.rb +1 -1
  65. data/lib/kward/model/client.rb +36 -24
  66. data/lib/kward/model/copilot_models.rb +2 -2
  67. data/lib/kward/model/model_info.rb +20 -3
  68. data/lib/kward/{openrouter_model_cache.rb → model/openrouter_model_cache.rb} +3 -3
  69. data/lib/kward/model/payloads.rb +12 -3
  70. data/lib/kward/model/provider_catalog.rb +5 -0
  71. data/lib/kward/model/stream_parser.rb +20 -4
  72. data/lib/kward/model/typesafe_client.rb +78 -0
  73. data/lib/kward/pan/index.html.erb +3 -3
  74. data/lib/kward/pan/server.rb +33 -10
  75. data/lib/kward/permissions/policy.rb +6 -2
  76. data/lib/kward/plugin_registry.rb +2 -659
  77. data/lib/kward/plugins/actions.rb +453 -0
  78. data/lib/kward/plugins/chat_contract.rb +121 -0
  79. data/lib/kward/{plugin_chat_runtime.rb → plugins/chat_runtime.rb} +62 -18
  80. data/lib/kward/plugins/host.rb +232 -0
  81. data/lib/kward/plugins/registry.rb +1190 -0
  82. data/lib/kward/plugins/resources.rb +206 -0
  83. data/lib/kward/plugins/turn_request.rb +36 -0
  84. data/lib/kward/plugins/ui.rb +219 -0
  85. data/lib/kward/prompt_interface/composer_renderer.rb +44 -40
  86. data/lib/kward/prompt_interface/composer_state.rb +33 -24
  87. data/lib/kward/prompt_interface/editor/auto_indent.rb +24 -22
  88. data/lib/kward/prompt_interface/editor/controller.rb +30 -33
  89. data/lib/kward/prompt_interface/editor/endwise.rb +13 -4
  90. data/lib/kward/prompt_interface/editor/markdown_code_block.rb +136 -0
  91. data/lib/kward/prompt_interface/editor/modes/vibe.rb +289 -44
  92. data/lib/kward/prompt_interface/editor/renderer.rb +108 -6
  93. data/lib/kward/prompt_interface/editor/runner.rb +362 -0
  94. data/lib/kward/prompt_interface/editor/runner_state.rb +78 -0
  95. data/lib/kward/prompt_interface/editor/scratchpad_languages.rb +74 -0
  96. data/lib/kward/prompt_interface/editor/scratchpad_runner.rb +182 -0
  97. data/lib/kward/prompt_interface/editor/state.rb +11 -11
  98. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +68 -6
  99. data/lib/kward/prompt_interface/editor/vibe_state.rb +3 -3
  100. data/lib/kward/prompt_interface/file_overlay.rb +71 -15
  101. data/lib/kward/prompt_interface/key_handler.rb +67 -0
  102. data/lib/kward/prompt_interface/layout.rb +1 -1
  103. data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
  104. data/lib/kward/prompt_interface/plugin_ui_requests.rb +82 -0
  105. data/lib/kward/prompt_interface/project_browser.rb +415 -14
  106. data/lib/kward/prompt_interface/runtime_state.rb +56 -2
  107. data/lib/kward/prompt_interface/screen.rb +11 -4
  108. data/lib/kward/prompt_interface/selection_prompt.rb +3 -1
  109. data/lib/kward/prompt_interface/slash_overlay.rb +19 -4
  110. data/lib/kward/prompt_interface/transcript_renderer.rb +12 -7
  111. data/lib/kward/prompt_interface.rb +151 -27
  112. data/lib/kward/prompts/commands.rb +3 -2
  113. data/lib/kward/prompts.rb +1 -1
  114. data/lib/kward/{adaptive_pty_output_sink.rb → pty/adaptive_output_sink.rb} +1 -1
  115. data/lib/kward/pty/detached_run.rb +44 -0
  116. data/lib/kward/{interactive_pty_runner.rb → pty/interactive_runner.rb} +104 -30
  117. data/lib/kward/{local_command_runner.rb → pty/local_command_runner.rb} +1 -1
  118. data/lib/kward/{local_pty_command_runner.rb → pty/local_pty_runner.rb} +3 -9
  119. data/lib/kward/{pty_output_sink.rb → pty/output_sink.rb} +52 -0
  120. data/lib/kward/{pty_transcript_normalizer.rb → pty/transcript_normalizer.rb} +1 -1
  121. data/lib/kward/rpc/plugin_chat_manager.rb +30 -10
  122. data/lib/kward/rpc/prompt_bridge.rb +25 -0
  123. data/lib/kward/rpc/server.rb +91 -12
  124. data/lib/kward/rpc/session_manager.rb +147 -44
  125. data/lib/kward/rpc/session_tree_rows.rb +2 -2
  126. data/lib/kward/rpc/tool_metadata.rb +1 -1
  127. data/lib/kward/rpc/transcript_normalizer.rb +7 -3
  128. data/lib/kward/sandbox/command_runner.rb +1 -1
  129. data/lib/kward/{session_catalog.rb → sessions/catalog.rb} +1 -1
  130. data/lib/kward/{session_store.rb → sessions/store.rb} +9 -9
  131. data/lib/kward/{session_tree_nodes.rb → sessions/tree_nodes.rb} +3 -3
  132. data/lib/kward/{session_tree_renderer.rb → sessions/tree_renderer.rb} +4 -4
  133. data/lib/kward/{session_tree_tool_display.rb → sessions/tree_tool_display.rb} +1 -1
  134. data/lib/kward/{ekwsh.rb → shell/kwsh.rb} +38 -19
  135. data/lib/kward/shell/kwshrc.rb +233 -0
  136. data/lib/kward/{persistent_shell_session.rb → shell/persistent_session.rb} +121 -28
  137. data/lib/kward/{shell_prompt.rb → shell/prompt.rb} +2 -0
  138. data/lib/kward/{shell_prompt_session.rb → shell/prompt_session.rb} +1 -1
  139. data/lib/kward/skills/trust_store.rb +1 -1
  140. data/lib/kward/tabs/driver.rb +194 -0
  141. data/lib/kward/{tab_store.rb → tabs/store.rb} +2 -2
  142. data/lib/kward/{ansi.rb → terminal/ansi.rb} +110 -10
  143. data/lib/kward/{clipboard.rb → terminal/clipboard.rb} +1 -1
  144. data/lib/kward/{terminal_image_support.rb → terminal/image_support.rb} +1 -1
  145. data/lib/kward/{terminal_keys.rb → terminal/keys.rb} +12 -0
  146. data/lib/kward/terminal/text.rb +121 -0
  147. data/lib/kward/text_matcher.rb +18 -0
  148. data/lib/kward/tools/base.rb +18 -0
  149. data/lib/kward/tools/context_for_task.rb +15 -6
  150. data/lib/kward/tools/edit_file.rb +9 -6
  151. data/lib/kward/tools/git_commit.rb +13 -7
  152. data/lib/kward/tools/list_directory.rb +4 -4
  153. data/lib/kward/tools/open_editor.rb +41 -0
  154. data/lib/kward/tools/plugin_tool.rb +41 -0
  155. data/lib/kward/tools/prepare_shell_command.rb +1 -1
  156. data/lib/kward/tools/read_file.rb +7 -6
  157. data/lib/kward/tools/registry.rb +109 -17
  158. data/lib/kward/tools/run_shell_command.rb +10 -8
  159. data/lib/kward/tools/search/code.rb +1 -1
  160. data/lib/kward/tools/summarize_file_structure.rb +5 -5
  161. data/lib/kward/tools/tool_call.rb +3 -1
  162. data/lib/kward/tools/typesafe_evaluate.rb +81 -0
  163. data/lib/kward/tools/workspace_targets.rb +58 -0
  164. data/lib/kward/tools/write_file.rb +9 -6
  165. data/lib/kward/{export_path.rb → transcripts/export_path.rb} +1 -1
  166. data/lib/kward/{markdown_transcript.rb → transcripts/markdown_transcript.rb} +2 -2
  167. data/lib/kward/transport/contracts.rb +200 -0
  168. data/lib/kward/transport/gateway.rb +79 -35
  169. data/lib/kward/transport/plugin_chat_gateway.rb +3 -2
  170. data/lib/kward/transport.rb +1 -200
  171. data/lib/kward/version.rb +1 -1
  172. data/lib/kward/{workspace_factory.rb → workspace/factory.rb} +2 -2
  173. data/lib/kward/{project_files.rb → workspace/files.rb} +2 -2
  174. data/lib/kward/{git_worktree_manager.rb → workspace/git_worktree_manager.rb} +28 -0
  175. data/lib/kward/{workspace.rb → workspace/workspace.rb} +3 -3
  176. data/templates/default/kward_navigation.rb +1 -0
  177. data/templates/default/layout/html/footer.erb +10 -0
  178. data/templates/default/layout/html/headers.erb +23 -0
  179. data/templates/default/layout/html/layout.erb +2 -2
  180. data/templates/default/layout/html/setup.rb +41 -2
  181. metadata +94 -47
  182. data/lib/kward/scratchpad_runner.rb +0 -56
  183. data/lib/kward/tab_driver.rb +0 -90
  184. /data/lib/kward/{editor_prompt.rb → cli/editor_prompt.rb} +0 -0
  185. /data/lib/kward/{editor_prompt_session.rb → cli/editor_prompt_session.rb} +0 -0
  186. /data/lib/kward/{diff_view_mode.rb → prompt_interface/editor/diff_view_mode.rb} +0 -0
  187. /data/lib/kward/{editor_mode.rb → prompt_interface/editor/editor_mode.rb} +0 -0
  188. /data/lib/kward/{session_diff.rb → sessions/diff.rb} +0 -0
  189. /data/lib/kward/{session_naming.rb → sessions/naming.rb} +0 -0
  190. /data/lib/kward/{session_trash.rb → sessions/trash.rb} +0 -0
  191. /data/lib/kward/{terminal_sequences.rb → terminal/sequences.rb} +0 -0
  192. /data/lib/kward/{transcript_export.rb → transcripts/transcript_export.rb} +0 -0
  193. /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
@@ -1,7 +1,7 @@
1
1
  require "digest"
2
2
  require "json"
3
- require_relative "config_files"
4
- require_relative "private_file"
3
+ require_relative "../config_files"
4
+ require_relative "../private_file"
5
5
 
6
6
  # Namespace for the Kward CLI agent runtime.
7
7
  module Kward
@@ -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
- text = delta.to_s
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 << render_line(line) << "\n"
199
+ rendered << render_pending_line(line) << "\n"
196
200
  end
197
201
 
198
- if final && !@pending.empty?
199
- rendered << render_line(@pending)
200
- @pending.clear
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 fast_markdown?(text, final)
215
- !final && !@in_fence && @pending.empty? && !text.match?(/[`*~_\[\]>]/)
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)
@@ -1,5 +1,5 @@
1
1
  require "open3"
2
- require_relative "terminal_sequences"
2
+ require_relative "sequences"
3
3
  require "rbconfig"
4
4
 
5
5
  # Namespace for the Kward CLI agent runtime.
@@ -1,4 +1,4 @@
1
- require_relative "terminal_sequences"
1
+ require_relative "sequences"
2
2
 
3
3
  # Namespace for the Kward CLI agent runtime.
4
4
  module Kward
@@ -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
@@ -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
- @workspace = workspace
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, _conversation, cancellation: nil)
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
- @workspace = workspace
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
- result = @workspace.edit_file(path, edits, read_paths: conversation.read_paths)
41
- conversation.refresh_system_message! if agents_file_changed?(@workspace, path, result)
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 worktree. Omit paths to include all current changes.",
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
- result = @committer.call(message: message, paths: paths)
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