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,362 @@
1
+ require_relative "scratchpad_runner"
2
+ require_relative "runner_state"
3
+
4
+ # Namespace for the Kward CLI agent runtime.
5
+ module Kward
6
+ # Coordinates execution and output viewing for editor buffers.
7
+ class PromptInterface
8
+ module EditorRunner
9
+ private
10
+
11
+ def editor_runner_config
12
+ config = @editor_runners_source.respond_to?(:call) ? @editor_runners_source.call : {}
13
+ config.is_a?(Hash) ? config : {}
14
+ end
15
+
16
+ def editor_runner_output_visible?
17
+ @editor_runner_output_visible && @editor_runner_state
18
+ end
19
+
20
+ def editor_runner_running?
21
+ @editor_runner_state&.running?
22
+ end
23
+
24
+ def run_editor_buffer(source: nil, language: nil, source_path: nil, output_visible: true, completion: nil)
25
+ return false unless @editor_state
26
+ return false if @editor_state.readonly?
27
+ if editor_runner_running?
28
+ @editor_runner_output_visible = true
29
+ @editor_state.status = "Runner already active · Ctrl+C cancel"
30
+ return false
31
+ end
32
+
33
+ language ||= @editor_state.language || editor_syntax_language
34
+ unless ScratchpadLanguages.runnable?(language)
35
+ @editor_state.status = "No runner available for #{language || "text"}"
36
+ return false
37
+ end
38
+
39
+ source = source.nil? ? @editor_state.buffer.dup : source.to_s
40
+ source_path ||= @editor_state.path
41
+ workspace_root = prompt_workspace_root
42
+ state = EditorRunnerState.new(language: language, source: source)
43
+ @editor_runner_state = state
44
+ @editor_runner_completion = completion
45
+ @editor_runner_output_visible = output_visible
46
+ clear_editor_runner_selection
47
+ @editor_state.status = "Running #{language} · Ctrl+C cancel"
48
+ Thread.new do
49
+ Thread.current.report_on_exception = false
50
+ result = ScratchpadRunner.run(
51
+ language,
52
+ source,
53
+ cancelled: state.method(:cancel_requested?),
54
+ cwd: workspace_root,
55
+ source_path: source_path,
56
+ runner_config: editor_runner_config
57
+ )
58
+ finish_editor_runner(state, result)
59
+ rescue StandardError => e
60
+ fail_editor_runner(state, e)
61
+ end
62
+ true
63
+ end
64
+
65
+ def finish_editor_runner(state, result)
66
+ @mutex.synchronize do
67
+ return unless @editor_runner_state.equal?(state) && @editor_state
68
+
69
+ state.complete(result)
70
+ completion = @editor_runner_completion
71
+ @editor_runner_completion = nil
72
+ @editor_runner_output_visible = false if completion
73
+ completion ? completion.call(result, nil) : @editor_state.status = editor_runner_result_status(result)
74
+ render_prompt_locked if @started && @asking
75
+ end
76
+ end
77
+
78
+ def fail_editor_runner(state, error)
79
+ @mutex.synchronize do
80
+ return unless @editor_runner_state.equal?(state) && @editor_state
81
+
82
+ state.fail(error)
83
+ completion = @editor_runner_completion
84
+ @editor_runner_completion = nil
85
+ @editor_runner_output_visible = false if completion
86
+ if completion
87
+ completion.call(nil, error)
88
+ else
89
+ @editor_state.status = "Run failed: #{error.message}"
90
+ end
91
+ render_prompt_locked if @started && @asking
92
+ end
93
+ end
94
+
95
+ def editor_runner_result_status(result)
96
+ return "Canceled · Esc close · Ctrl+R rerun" if result.cancelled
97
+
98
+ "Exit #{result.exit_status} · #{format_runner_duration(result.duration)} · Esc close · Ctrl+R rerun"
99
+ end
100
+
101
+ def format_runner_duration(duration)
102
+ format("%.2fs", duration.to_f)
103
+ end
104
+
105
+ def cancel_editor_runner
106
+ return false unless editor_runner_running?
107
+
108
+ @editor_runner_state.cancel
109
+ @editor_state.status = "Canceling #{@editor_runner_state.language} · please wait"
110
+ true
111
+ end
112
+
113
+ def close_editor_runner_output
114
+ @editor_runner_output_visible = false
115
+ clear_editor_runner_selection
116
+ true
117
+ end
118
+
119
+ def handle_editor_runner_output_key(key)
120
+ mouse_result = handle_editor_runner_mouse_key(key)
121
+ return mouse_result unless mouse_result == false
122
+ return true if handle_bundled_key(key) { |token| handle_editor_runner_output_key(token) }
123
+
124
+ if (sequence = parse_csi_u_key(key))
125
+ queue_pending_keys(sequence[:remaining]) if sequence[:remaining] && !sequence[:remaining].empty?
126
+ if sequence[:code] == 121 && !ctrl_modifier?(sequence[:modifier]) && @editor_state&.vibe? && editor_runner_selection_active?
127
+ return copy_editor_runner_selection
128
+ end
129
+ normalized_code = ctrl_code(sequence[:code])
130
+ if (ctrl_modifier?(sequence[:modifier]) || super_modifier?(sequence[:modifier])) && normalized_code == 99
131
+ return copy_editor_runner_selection if editor_runner_selection_active?
132
+ return cancel_editor_runner if ctrl_modifier?(sequence[:modifier]) && editor_runner_running?
133
+ end
134
+ if ctrl_modifier?(sequence[:modifier])
135
+ return run_editor_buffer if normalized_code == 114
136
+ if normalized_code == 113
137
+ close_editor_runner_output
138
+ return quit_editor
139
+ end
140
+ end
141
+ return close_editor_runner_output if sequence[:code] == 27
142
+ end
143
+
144
+ key_name = key_name_for(key)
145
+ case key_name
146
+ when :up
147
+ scroll_editor_runner_output(-1)
148
+ when :down
149
+ scroll_editor_runner_output(1)
150
+ when :pageup
151
+ scroll_editor_runner_output(-editor_runner_output_page_size)
152
+ when :pagedown
153
+ scroll_editor_runner_output(editor_runner_output_page_size)
154
+ when :ctrl_q
155
+ close_editor_runner_output
156
+ quit_editor
157
+ when :ctrl_r
158
+ run_editor_buffer
159
+ when :ctrl_c
160
+ if editor_runner_selection_active?
161
+ copy_editor_runner_selection
162
+ else
163
+ editor_runner_running? ? cancel_editor_runner : close_editor_runner_output
164
+ end
165
+ else
166
+ case key
167
+ when "\e", "q", "Q"
168
+ close_editor_runner_output
169
+ when "y"
170
+ copy_editor_runner_selection if @editor_state&.vibe? && editor_runner_selection_active?
171
+ when TerminalKeys::CTRL_Q
172
+ close_editor_runner_output
173
+ quit_editor
174
+ when TerminalKeys::CTRL_R
175
+ run_editor_buffer
176
+ when TerminalKeys::CTRL_C
177
+ if editor_runner_selection_active?
178
+ copy_editor_runner_selection
179
+ else
180
+ editor_runner_running? ? cancel_editor_runner : close_editor_runner_output
181
+ end
182
+ end
183
+ end
184
+ true
185
+ end
186
+
187
+ def handle_editor_runner_mouse_key(key)
188
+ event = parse_sgr_mouse_event(key)
189
+ return false unless event
190
+
191
+ queue_pending_keys(event[:remaining]) unless event[:remaining].empty?
192
+ case event[:code]
193
+ when 64
194
+ scroll_editor_runner_output(-1)
195
+ when 65
196
+ scroll_editor_runner_output(1)
197
+ else
198
+ if event[:drag]
199
+ update_editor_runner_selection(editor_runner_position_for_mouse_event(event))
200
+ elsif event[:button].zero?
201
+ event[:release] ? finish_editor_runner_selection : begin_editor_runner_selection(event)
202
+ end
203
+ end
204
+ true
205
+ end
206
+
207
+ def begin_editor_runner_selection(event)
208
+ position = editor_runner_position_for_mouse_event(event)
209
+ return true unless position
210
+
211
+ click_count = editor_runner_mouse_click_count(event)
212
+ case click_count
213
+ when 3..Float::INFINITY
214
+ @editor_runner_selection_anchor = [position[0], 0]
215
+ @editor_runner_selection_cursor = [position[0], editor_runner_line(position[0]).length]
216
+ when 2
217
+ range = editor_runner_word_range_at(position)
218
+ if range
219
+ @editor_runner_selection_anchor = [position[0], range[0]]
220
+ @editor_runner_selection_cursor = [position[0], range[1]]
221
+ else
222
+ @editor_runner_selection_anchor = position
223
+ @editor_runner_selection_cursor = position
224
+ end
225
+ else
226
+ @editor_runner_selection_anchor = position
227
+ @editor_runner_selection_cursor = position
228
+ end
229
+ @editor_runner_mouse_dragging = true
230
+ true
231
+ end
232
+
233
+ def update_editor_runner_selection(position)
234
+ return true unless @editor_runner_mouse_dragging && position
235
+
236
+ @editor_runner_selection_cursor = position
237
+ true
238
+ end
239
+
240
+ def finish_editor_runner_selection
241
+ @editor_runner_mouse_dragging = false
242
+ true
243
+ end
244
+
245
+ def editor_runner_mouse_click_count(event)
246
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
247
+ count = if @editor_runner_last_click && now - @editor_runner_last_click[:time] <= 0.5 &&
248
+ (@editor_runner_last_click[:column] - event[:column]).abs <= 1 &&
249
+ (@editor_runner_last_click[:row] - event[:row]).abs <= 1
250
+ @editor_runner_last_click[:count] + 1
251
+ else
252
+ 1
253
+ end
254
+ @editor_runner_last_click = { time: now, column: event[:column], row: event[:row], count: count }
255
+ count
256
+ end
257
+
258
+ def editor_runner_position_for_mouse_event(event)
259
+ row_offset = event[:row] - editor_runner_output_content_top_row
260
+ return nil if row_offset.negative? || row_offset >= editor_runner_output_visible_count
261
+
262
+ line_index = @editor_runner_state.scroll_row + row_offset
263
+ line = editor_runner_line(line_index)
264
+ return nil unless line
265
+
266
+ column = [[event[:column] - 3, 0].max, line.length].min
267
+ [line_index, column]
268
+ end
269
+
270
+ def editor_runner_output_content_top_row
271
+ height = screen_height
272
+ source_count, = editor_split_visible_counts(height: height, width: screen_width)
273
+ composer_top_row(height) + source_count + 3
274
+ end
275
+
276
+ def editor_runner_line(line_index)
277
+ lines = editor_runner_output_lines([screen_width - 4, 1].max)
278
+ line = lines[line_index]
279
+ line && ANSI.strip(line)
280
+ end
281
+
282
+ def editor_runner_selection_active?
283
+ @editor_runner_selection_anchor && @editor_runner_selection_cursor &&
284
+ @editor_runner_selection_anchor != @editor_runner_selection_cursor
285
+ end
286
+
287
+ def clear_editor_runner_selection
288
+ @editor_runner_selection_anchor = nil
289
+ @editor_runner_selection_cursor = nil
290
+ @editor_runner_mouse_dragging = false
291
+ @editor_runner_last_click = nil
292
+ end
293
+
294
+ def editor_runner_selection_bounds
295
+ return nil unless editor_runner_selection_active?
296
+
297
+ [@editor_runner_selection_anchor, @editor_runner_selection_cursor].minmax_by { |position| position }
298
+ end
299
+
300
+ def editor_runner_selection_range_for(line_index, line_length)
301
+ bounds = editor_runner_selection_bounds
302
+ return nil unless bounds
303
+
304
+ first, last = bounds
305
+ return nil if line_index < first[0] || line_index > last[0]
306
+
307
+ start_column = line_index == first[0] ? first[1] : 0
308
+ end_column = line_index == last[0] ? last[1] : line_length
309
+ [start_column, end_column] if start_column < end_column
310
+ end
311
+
312
+ def editor_runner_word_range_at(position)
313
+ line = editor_runner_line(position[0]).to_s
314
+ return nil if line.empty? || TextBoundary.word_separator?(line[[position[1], line.length - 1].min])
315
+
316
+ index = [position[1], line.length - 1].min
317
+ start_column = index
318
+ start_column -= 1 while start_column.positive? && !TextBoundary.word_separator?(line[start_column - 1])
319
+ end_column = index + 1
320
+ end_column += 1 while end_column < line.length && !TextBoundary.word_separator?(line[end_column])
321
+ [start_column, end_column]
322
+ end
323
+
324
+ def editor_runner_selected_text
325
+ bounds = editor_runner_selection_bounds
326
+ return "" unless bounds
327
+
328
+ first, last = bounds
329
+ lines = (first[0]..last[0]).map { |line_index| editor_runner_line(line_index).to_s }
330
+ if lines.length == 1
331
+ lines[0] = lines[0][first[1]...last[1]] || ""
332
+ else
333
+ lines[0] = lines[0][first[1]..] || ""
334
+ lines[-1] = lines[-1][...last[1]] || ""
335
+ end
336
+ lines.join("\n")
337
+ end
338
+
339
+ def copy_editor_runner_selection
340
+ text = editor_runner_selected_text
341
+ return false if text.empty?
342
+
343
+ print_output_locked(TerminalSequences.osc52(text))
344
+ flush_output_locked if @output_io.respond_to?(:flush)
345
+ clear_editor_runner_selection
346
+ @editor_state.status = "Copied selection"
347
+ true
348
+ end
349
+
350
+ def scroll_editor_runner_output(delta)
351
+ return false unless @editor_runner_state
352
+
353
+ @editor_runner_state.scroll_by(delta, maximum: editor_runner_output_scroll_max)
354
+ true
355
+ end
356
+
357
+ def editor_runner_output_page_size
358
+ [editor_runner_output_visible_count, 1].max
359
+ end
360
+ end
361
+ end
362
+ end
@@ -0,0 +1,78 @@
1
+ require "digest"
2
+
3
+ # Namespace for the Kward CLI agent runtime.
4
+ module Kward
5
+ # Ephemeral state for the output of an editor runner.
6
+ class PromptInterface
7
+ class EditorRunnerState
8
+ attr_reader :language, :source_digest
9
+
10
+ def initialize(language:, source:)
11
+ @language = language.to_sym
12
+ @source_digest = Digest::SHA256.hexdigest(source.to_s)
13
+ @mutex = Mutex.new
14
+ @running = true
15
+ @cancel_requested = false
16
+ @result = nil
17
+ @error = nil
18
+ @scroll_row = 0
19
+ end
20
+
21
+ def running?
22
+ @mutex.synchronize { @running }
23
+ end
24
+
25
+ def cancel_requested?
26
+ @mutex.synchronize { @cancel_requested }
27
+ end
28
+
29
+ def cancel
30
+ @mutex.synchronize { @cancel_requested = true }
31
+ end
32
+
33
+ def complete(result)
34
+ @mutex.synchronize do
35
+ @result = result
36
+ @running = false
37
+ end
38
+ end
39
+
40
+ def fail(error)
41
+ @mutex.synchronize do
42
+ @error = error
43
+ @running = false
44
+ end
45
+ end
46
+
47
+ def result
48
+ @mutex.synchronize { @result }
49
+ end
50
+
51
+ def error
52
+ @mutex.synchronize { @error }
53
+ end
54
+
55
+ def output_text
56
+ @mutex.synchronize do
57
+ return @error.message.to_s if @error
58
+
59
+ @result&.output.to_s
60
+ end
61
+ end
62
+
63
+ def scroll_row
64
+ @mutex.synchronize { @scroll_row }
65
+ end
66
+
67
+ def set_scroll_row(row, maximum:)
68
+ @mutex.synchronize do
69
+ @scroll_row = [[row.to_i, 0].max, maximum.to_i].min
70
+ end
71
+ end
72
+
73
+ def scroll_by(delta, maximum:)
74
+ set_scroll_row(scroll_row + delta.to_i, maximum: maximum)
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,74 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Names, aliases, and display paths accepted by the scratchpad command.
4
+ module ScratchpadLanguages
5
+ LANGUAGES = {
6
+ text: { aliases: %w[text txt], display_path: "scratchpad.txt" },
7
+ ruby: { aliases: %w[ruby rb], display_path: "scratchpad.rb", runner: :ruby },
8
+ erb: { aliases: %w[erb], display_path: "scratchpad.erb" },
9
+ crystal: { aliases: %w[crystal cr], display_path: "scratchpad.cr", runner: :crystal },
10
+ elixir: { aliases: %w[elixir ex exs], display_path: "scratchpad.ex", runner: :elixir },
11
+ julia: { aliases: %w[julia jl], display_path: "scratchpad.jl", runner: :julia },
12
+ javascript: { aliases: %w[javascript js jsx mjs cjs], display_path: "scratchpad.js", runner: :node },
13
+ typescript: { aliases: %w[typescript ts tsx], display_path: "scratchpad.ts", runner: :node },
14
+ json: { aliases: %w[json], display_path: "scratchpad.json" },
15
+ markdown: { aliases: %w[markdown md], display_path: "scratchpad.md" },
16
+ yaml: { aliases: %w[yaml yml], display_path: "scratchpad.yml" },
17
+ shell: { aliases: %w[shell sh bash zsh fish], display_path: "scratchpad.sh", runner: :shell },
18
+ makefile: { aliases: %w[makefile make mk], display_path: "scratchpad.mk" },
19
+ html: { aliases: %w[html htm], display_path: "scratchpad.html" },
20
+ css: { aliases: %w[css], display_path: "scratchpad.css" },
21
+ scss: { aliases: %w[scss], display_path: "scratchpad.scss" },
22
+ python: { aliases: %w[python py pyw], display_path: "scratchpad.py", runner: :python },
23
+ go: { aliases: %w[go], display_path: "scratchpad.go", runner: :go },
24
+ rust: { aliases: %w[rust rs], display_path: "scratchpad.rs" },
25
+ java: { aliases: %w[java], display_path: "scratchpad.java" },
26
+ csharp: { aliases: %w[csharp cs c#], display_path: "scratchpad.cs" },
27
+ c: { aliases: %w[c], display_path: "scratchpad.c" },
28
+ cpp: { aliases: %w[cpp cc cxx hpp hh hxx c++], display_path: "scratchpad.cpp" },
29
+ swift: { aliases: %w[swift], display_path: "scratchpad.swift", runner: :swift },
30
+ kotlin: { aliases: %w[kotlin kt kts], display_path: "scratchpad.kt" },
31
+ lua: { aliases: %w[lua], display_path: "scratchpad.lua", runner: :lua },
32
+ sql: { aliases: %w[sql], display_path: "scratchpad.sql" }
33
+ }.freeze
34
+
35
+ ALIASES = LANGUAGES.each_with_object({}) do |(language, definition), aliases|
36
+ ([language.to_s] + definition.fetch(:aliases)).each do |name|
37
+ aliases[name] = language
38
+ end
39
+ end.freeze
40
+
41
+ module_function
42
+
43
+ def normalize(language)
44
+ value = language.to_s.strip.downcase
45
+ return :text if value.empty?
46
+
47
+ ALIASES.fetch(value) do
48
+ raise ArgumentError, "Unknown scratchpad language #{language.inspect}. Use /scratchpad help for supported languages."
49
+ end
50
+ end
51
+
52
+ def display_path(language)
53
+ LANGUAGES.fetch(normalize(language)).fetch(:display_path)
54
+ end
55
+
56
+ def runner(language)
57
+ LANGUAGES.fetch(normalize(language))[:runner]
58
+ end
59
+
60
+ def runnable?(language)
61
+ !runner(language).nil?
62
+ end
63
+
64
+ def help_text
65
+ lines = ["Supported scratchpad languages:"]
66
+ LANGUAGES.each do |language, definition|
67
+ aliases = definition.fetch(:aliases).reject { |name| name == language.to_s }
68
+ suffix = aliases.empty? ? "" : " (#{aliases.join(", ")})"
69
+ lines << " #{language}#{suffix}"
70
+ end
71
+ lines.join("\n")
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,182 @@
1
+ require "open3"
2
+ require "rbconfig"
3
+ require "tempfile"
4
+ require_relative "scratchpad_languages"
5
+
6
+ # Namespace for the Kward CLI agent runtime.
7
+ module Kward
8
+ # Executes editor buffers and returns captured runner output.
9
+ module ScratchpadRunner
10
+ OUTPUT_READ_SIZE = 16 * 1024
11
+ MAX_OUTPUT_BYTES = 1_000_000
12
+ POLL_INTERVAL = 0.05
13
+
14
+ DEFAULT_BINARIES = {
15
+ ruby: -> { RbConfig.ruby },
16
+ node: "node",
17
+ python: -> { windows_host? ? "python" : "python3" },
18
+ shell: -> { File.executable?("/bin/sh") ? "/bin/sh" : "sh" },
19
+ lua: "lua",
20
+ julia: "julia",
21
+ elixir: "elixir",
22
+ crystal: "crystal",
23
+ swift: "swift",
24
+ go: "go"
25
+ }.freeze
26
+ RUNNER_ARGUMENTS = {
27
+ crystal: ["run"],
28
+ go: ["run"]
29
+ }.freeze
30
+
31
+ Result = Struct.new(:language, :output, :exit_status, :duration, :command, :cancelled, :truncated, keyword_init: true)
32
+
33
+ module_function
34
+
35
+ def run(language, content, cancelled: nil, cwd: Dir.pwd, source_path: nil, runner_config: {})
36
+ language = ScratchpadLanguages.normalize(language)
37
+ runner = ScratchpadLanguages.runner(language)
38
+ raise ArgumentError, "Scratchpad language #{language.inspect} is not runnable" unless runner
39
+
40
+ cwd = File.expand_path(cwd.to_s.empty? ? Dir.pwd : cwd)
41
+ binary = configured_binary(runner, runner_config, cwd)
42
+ arguments = RUNNER_ARGUMENTS.fetch(runner, [])
43
+ default_display_path = ScratchpadLanguages.display_path(language)
44
+ display_path = source_path.to_s.empty? ? default_display_path : File.basename(source_path.to_s)
45
+ extension = File.extname(display_path)
46
+ extension = File.extname(default_display_path) if extension.empty?
47
+ started_at = monotonic_now
48
+ output, status, was_cancelled, truncated = capture_source(
49
+ content,
50
+ extension: extension,
51
+ source_path: source_path,
52
+ cwd: cwd,
53
+ command: [binary, *arguments],
54
+ cancelled: cancelled
55
+ )
56
+ Result.new(
57
+ language: language,
58
+ output: output,
59
+ exit_status: was_cancelled ? 130 : status.exitstatus,
60
+ duration: monotonic_now - started_at,
61
+ command: [binary, *arguments, "<#{display_path}>"],
62
+ cancelled: was_cancelled,
63
+ truncated: truncated
64
+ )
65
+ rescue Errno::ENOENT
66
+ raise ArgumentError, "Editor runner #{runner.inspect} binary #{binary.inspect} was not found"
67
+ rescue Errno::EACCES
68
+ raise ArgumentError, "Editor runner #{runner.inspect} binary #{binary.inspect} is not executable"
69
+ end
70
+
71
+ def capture_source(content, extension:, source_path:, cwd:, command:, cancelled: nil)
72
+ Tempfile.create(["kward-scratchpad", extension], dir: temporary_directory(cwd, source_path)) do |file|
73
+ file.write(content.to_s)
74
+ file.flush
75
+ capture_process([*command, file.path], cancelled: cancelled, chdir: cwd)
76
+ end
77
+ end
78
+
79
+ def configured_binary(runner, runner_config, cwd)
80
+ settings = runner_config.is_a?(Hash) ? (runner_config[runner.to_s] || runner_config[runner]) : nil
81
+ configured = settings.is_a?(Hash) ? (settings["binary"] || settings[:binary]) : settings
82
+ binary = configured.nil? ? default_binary(runner) : configured
83
+ unless binary.is_a?(String) && !binary.strip.empty?
84
+ raise ArgumentError, "Editor runner #{runner.inspect} binary must be a non-empty string"
85
+ end
86
+
87
+ binary = binary.strip
88
+ path_like?(binary) ? File.expand_path(binary, cwd) : binary
89
+ end
90
+
91
+ def default_binary(runner)
92
+ value = DEFAULT_BINARIES[runner]
93
+ value.respond_to?(:call) ? value.call : value
94
+ end
95
+
96
+ def temporary_directory(cwd, source_path)
97
+ return cwd if source_path.to_s.empty?
98
+
99
+ directory = File.dirname(File.expand_path(source_path, cwd))
100
+ return directory if Dir.exist?(directory) && File.writable?(directory)
101
+
102
+ cwd
103
+ end
104
+
105
+ def path_like?(value)
106
+ value.include?("/") || value.include?("\\") || value.start_with?("~")
107
+ end
108
+
109
+ def windows_host?
110
+ RbConfig::CONFIG["host_os"].to_s.match?(/mswin|mingw|cygwin/i)
111
+ end
112
+
113
+ def capture_process(command, cancelled: nil, chdir: nil)
114
+ options = chdir ? { chdir: chdir } : {}
115
+ input, output, wait_thread = Open3.popen2e(*command, **options)
116
+ input.close
117
+ chunks = []
118
+ output_size = 0
119
+ was_cancelled = false
120
+
121
+ loop do
122
+ if !was_cancelled && cancelled&.call
123
+ terminate_process(wait_thread.pid)
124
+ was_cancelled = true
125
+ end
126
+
127
+ output_size = read_available_output(output, chunks, output_size)
128
+ break if wait_thread.join(0)
129
+
130
+ sleep POLL_INTERVAL
131
+ end
132
+
133
+ output_size = read_remaining_output(output, chunks, output_size)
134
+ [chunks.join, wait_thread.value, was_cancelled, output_size >= MAX_OUTPUT_BYTES]
135
+ ensure
136
+ if wait_thread&.alive?
137
+ terminate_process(wait_thread.pid)
138
+ wait_thread.join
139
+ end
140
+ input&.close unless input&.closed?
141
+ output&.close unless output&.closed?
142
+ end
143
+
144
+ def read_available_output(output, chunks, output_size)
145
+ ready = IO.select([output], nil, nil, 0)
146
+ return output_size unless ready
147
+
148
+ loop do
149
+ output_size = append_output(chunks, output.read_nonblock(OUTPUT_READ_SIZE), output_size)
150
+ rescue IO::WaitReadable, EOFError
151
+ break
152
+ end
153
+ output_size
154
+ end
155
+
156
+ def read_remaining_output(output, chunks, output_size)
157
+ loop do
158
+ output_size = append_output(chunks, output.read_nonblock(OUTPUT_READ_SIZE), output_size)
159
+ end
160
+ rescue IO::WaitReadable, IOError, EOFError
161
+ output_size
162
+ end
163
+
164
+ def append_output(chunks, chunk, output_size)
165
+ return output_size if output_size >= MAX_OUTPUT_BYTES
166
+
167
+ remaining = MAX_OUTPUT_BYTES - output_size
168
+ chunks << chunk.byteslice(0, remaining)
169
+ output_size + [chunk.bytesize, remaining].min
170
+ end
171
+
172
+ def terminate_process(pid)
173
+ Process.kill("TERM", pid)
174
+ rescue Errno::ESRCH
175
+ nil
176
+ end
177
+
178
+ def monotonic_now
179
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
180
+ end
181
+ end
182
+ end