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
@@ -1,4 +1,4 @@
1
- require_relative "../../editor_mode"
1
+ require_relative "editor_mode"
2
2
  require_relative "../../text_boundary"
3
3
  require_relative "buffer"
4
4
  require_relative "file_marker"
@@ -180,6 +180,15 @@ module Kward
180
180
  sync_vibe_state
181
181
  end
182
182
 
183
+ def vibe_command_range
184
+ @vibe_state.command_range
185
+ end
186
+
187
+ def vibe_command_range=(value)
188
+ @vibe_state.command_range = value
189
+ sync_vibe_state
190
+ end
191
+
183
192
  def vibe_last_change
184
193
  @vibe_state.last_change
185
194
  end
@@ -207,15 +216,6 @@ module Kward
207
216
  sync_vibe_state
208
217
  end
209
218
 
210
- def vibe_visual_block_insert
211
- @vibe_state.visual_block_insert
212
- end
213
-
214
- def vibe_visual_block_insert=(value)
215
- @vibe_state.visual_block_insert = value
216
- sync_vibe_state
217
- end
218
-
219
219
  def vibe_marks
220
220
  @vibe_state.marks
221
221
  end
@@ -1289,10 +1289,10 @@ module Kward
1289
1289
  @vibe_mode = @vibe_state.mode
1290
1290
  @vibe_pending = @vibe_state.pending
1291
1291
  @vibe_command = @vibe_state.command
1292
+ @vibe_command_range = @vibe_state.command_range
1292
1293
  @vibe_last_change = @vibe_state.last_change
1293
1294
  @vibe_last_find = @vibe_state.last_find
1294
1295
  @vibe_last_visual_selection = @vibe_state.last_visual_selection
1295
- @vibe_visual_block_insert = @vibe_state.visual_block_insert
1296
1296
  @vibe_marks = @vibe_state.marks
1297
1297
  @vibe_registers = @vibe_state.registers
1298
1298
  @vibe_register_types = @vibe_state.register_types
@@ -53,6 +53,7 @@ module Kward
53
53
  keywords: %w[begin if while for try let quote function macro module baremodule struct mutable abstract primitive type do end else elseif catch finally true false nothing]
54
54
  },
55
55
  makefile: {
56
+ extensions: %w[.mk],
56
57
  filenames: %w[Makefile makefile GNUmakefile],
57
58
  line_comment: "#",
58
59
  keywords: %w[ifeq ifneq ifdef ifndef else endif include define endef export unexport override]
@@ -125,11 +126,17 @@ module Kward
125
126
  def editor_highlight_line(line, line_index = nil)
126
127
  return line.to_s unless @color_enabled
127
128
 
128
- case editor_syntax_language
129
+ if editor_syntax_language == :markdown
130
+ editor_highlight_markdown(line, line_index)
131
+ else
132
+ editor_highlight_language_line(line, editor_syntax_language, line_index)
133
+ end
134
+ end
135
+
136
+ def editor_highlight_language_line(line, language, line_index = nil)
137
+ case language
129
138
  when :ruby
130
139
  editor_highlight_ruby(line, line_index)
131
- when :markdown
132
- editor_highlight_markdown(line)
133
140
  when :json
134
141
  editor_highlight_json(line)
135
142
  when :yaml
@@ -143,7 +150,7 @@ module Kward
143
150
  when :sql
144
151
  editor_highlight_sql(line)
145
152
  else
146
- editor_highlight_generic(line, editor_syntax_language, line_index)
153
+ editor_highlight_generic(line, language, line_index)
147
154
  end
148
155
  end
149
156
 
@@ -395,16 +402,71 @@ module Kward
395
402
  nil
396
403
  end
397
404
 
398
- def editor_highlight_markdown(line)
405
+ def editor_highlight_markdown(line, line_index = nil)
399
406
  text = line.to_s
407
+ fence_context = editor_markdown_fence_context(line_index)
408
+ return editor_highlight_markdown_fence(text) if fence_context == :marker
409
+ if fence_context.is_a?(Array)
410
+ language, nested_line_index = fence_context
411
+ return editor_highlight_language_line(text, language, nested_line_index) if language
412
+
413
+ return text
414
+ end
415
+
400
416
  return editor_highlight_markdown_heading(text) if text.match?(/\A\s{0,3}[#]{1,6}\s/)
401
- return editor_highlight_markdown_fence(text) if text.match?(/\A\s*```/)
402
417
  return editor_highlight_markdown_blockquote(text) if text.match?(/\A\s*>/)
403
418
  return editor_highlight_markdown_list(text) if text.match?(/\A\s*(?:[-*+]\s+|\d+\.\s+)/)
404
419
 
405
420
  editor_highlight_markdown_inline(text)
406
421
  end
407
422
 
423
+ def editor_markdown_fence_context(line_index)
424
+ return nil unless line_index && @editor_state
425
+
426
+ inside_fence = false
427
+ language = nil
428
+ opening_index = nil
429
+ @editor_state.lines.first(line_index.to_i + 1).each_with_index do |line, index|
430
+ match = line.to_s.match(/\A\s*```([^`]*)\s*\z/)
431
+ if match
432
+ if inside_fence
433
+ inside_fence = false
434
+ language = nil
435
+ opening_index = nil
436
+ else
437
+ inside_fence = true
438
+ language = editor_markdown_fence_language(match[1])
439
+ opening_index = index
440
+ end
441
+ return :marker if index == line_index
442
+ elsif inside_fence && index == line_index
443
+ return [language, index, opening_index]
444
+ end
445
+ end
446
+
447
+ nil
448
+ end
449
+
450
+ def editor_effective_syntax_language(line_index = nil)
451
+ language = editor_syntax_language
452
+ return language unless language == :markdown
453
+
454
+ context = editor_markdown_fence_context(line_index)
455
+ context.is_a?(Array) ? context.first || :markdown : :markdown
456
+ end
457
+
458
+ def editor_syntax_context_start(line_index = nil)
459
+ return nil unless editor_syntax_language == :markdown
460
+
461
+ context = editor_markdown_fence_context(line_index)
462
+ context.is_a?(Array) ? context[2] : nil
463
+ end
464
+
465
+ def editor_markdown_fence_language(info)
466
+ name = info.to_s.strip.split(/\s/, 2).first.to_s.downcase
467
+ ScratchpadLanguages::ALIASES[name]
468
+ end
469
+
408
470
  def editor_highlight_markdown_heading(line)
409
471
  line.sub(/\A(\s{0,3}[#]{1,6}\s+)(.*)\z/) do
410
472
  "#{colored(Regexp.last_match(1), :cyan)}#{colored(Regexp.last_match(2), :bold)}"
@@ -5,7 +5,7 @@ module Kward
5
5
  # Modal Vibe editor state for one editor buffer.
6
6
  class VibeEditorState
7
7
  attr_accessor :mode, :pending, :command, :last_change, :last_find
8
- attr_accessor :last_visual_selection, :visual_block_insert
8
+ attr_accessor :command_range, :last_visual_selection
9
9
  attr_accessor :marks, :registers, :register_types, :macros, :recording_macro, :last_macro
10
10
  attr_accessor :kill_linewise, :previous_change_cursor, :jump_back_list, :jump_forward_list
11
11
 
@@ -13,10 +13,10 @@ module Kward
13
13
  @mode = editor_mode == "vibe" ? "normal" : nil
14
14
  @pending = ""
15
15
  @command = ""
16
+ @command_range = nil
16
17
  @last_change = nil
17
18
  @last_find = nil
18
19
  @last_visual_selection = nil
19
- @visual_block_insert = nil
20
20
  @marks = {}
21
21
  @registers = {}
22
22
  @register_types = {}
@@ -34,10 +34,10 @@ module Kward
34
34
  state.mode = other.mode&.dup
35
35
  state.pending = other.pending.dup
36
36
  state.command = other.command.dup
37
+ state.command_range = other.command_range&.dup
37
38
  state.last_change = other.last_change&.dup
38
39
  state.last_find = other.last_find&.dup
39
40
  state.last_visual_selection = other.last_visual_selection&.dup
40
- state.visual_block_insert = other.visual_block_insert&.dup
41
41
  state.marks = other.marks.transform_values(&:dup)
42
42
  state.registers = other.registers.transform_values(&:dup)
43
43
  state.register_types = other.register_types.transform_values(&:dup)
@@ -1,3 +1,5 @@
1
+ require_relative "../text_matcher"
2
+
1
3
  # Namespace for the Kward CLI agent runtime.
2
4
  module Kward
3
5
  # File-mention completion overlay behavior.
@@ -86,32 +88,78 @@ module Kward
86
88
  def file_overlay_matches
87
89
  token = active_file_open || active_file_mention
88
90
  return [] unless token
91
+ unless @file_mention_paths
92
+ start_file_mention_discovery_locked
93
+ return []
94
+ end
89
95
 
90
96
  query = token[:query].downcase
97
+ entries = project_file_path_entries
98
+ cache = @file_overlay_match_cache
99
+ return cache[:matches] if cache && cache[:query] == query && cache[:entries].equal?(entries)
100
+
101
+ pattern = TextMatcher.subsequence_pattern(query)
91
102
  matches = []
92
- project_file_path_entries.each do |entry|
93
- next unless file_mention_match?(entry[:downcase], query)
103
+ entries.each do |entry|
104
+ next unless TextMatcher.subsequence?(entry[:downcase], query, pattern)
94
105
 
95
106
  matches << entry[:path]
96
107
  break if matches.length >= FILE_MENTION_RESULT_LIMIT
97
108
  end
109
+ @file_overlay_match_cache = { query: query, entries: entries, matches: matches }
98
110
  matches
99
111
  end
100
112
 
101
- def file_mention_match?(path, query)
102
- return true if query.empty?
103
- return true if path.include?(query)
104
-
105
- query_chars = query.chars
106
- query_chars.all? do |char|
107
- index = path.index(char)
108
- if index
109
- path = path[(index + 1)..].to_s
110
- true
111
- else
112
- false
113
+ def start_file_mention_discovery_locked
114
+ root = prompt_workspace_root
115
+ active_thread = @file_mention_discovery_thread
116
+ return if active_thread&.alive? && @file_mention_discovery_root == root
117
+
118
+ token = Object.new
119
+ start = Queue.new
120
+ @file_mention_discovery_root = root
121
+ @file_mention_discovery_token = token
122
+ worker = Thread.new do
123
+ start.pop
124
+ paths = discover_file_mention_paths(root)
125
+ entries = build_file_path_entries(paths)
126
+ @mutex.synchronize do
127
+ next unless @file_mention_discovery_token.equal?(token)
128
+
129
+ @file_mention_discovery_thread = nil
130
+ @file_mention_discovery_root = nil
131
+ @file_mention_discovery_token = nil
132
+ next unless root == prompt_workspace_root
133
+
134
+ @file_mention_paths = paths
135
+ @file_mention_path_entries_paths = paths
136
+ @file_mention_path_entries = entries
137
+ @file_overlay_match_cache = nil
138
+ render_prompt_locked if @started && @asking && file_overlay_visible?
113
139
  end
114
140
  end
141
+ worker.report_on_exception = false
142
+ @file_mention_discovery_thread = worker
143
+ start << true
144
+ end
145
+
146
+ def discover_file_mention_paths(root)
147
+ ProjectFiles.list(root: root)
148
+ end
149
+
150
+ def finish_file_mention_discovery_locked
151
+ root = prompt_workspace_root
152
+ @file_mention_discovery_thread = nil
153
+ @file_mention_discovery_root = nil
154
+ @file_mention_discovery_token = nil
155
+ @file_mention_paths = discover_file_mention_paths(root)
156
+ @file_mention_path_entries_paths = @file_mention_paths
157
+ @file_mention_path_entries = build_file_path_entries(@file_mention_paths)
158
+ @file_overlay_match_cache = nil
159
+ end
160
+
161
+ def file_mention_discovery_loading?
162
+ @file_mention_discovery_thread&.alive? && @file_mention_discovery_root == prompt_workspace_root
115
163
  end
116
164
 
117
165
  def project_file_paths(include_ignored: false)
@@ -127,7 +175,11 @@ module Kward
127
175
  return @file_mention_path_entries if @file_mention_path_entries_paths.equal?(paths) && @file_mention_path_entries
128
176
 
129
177
  @file_mention_path_entries_paths = paths
130
- @file_mention_path_entries = paths.map { |path| { path: path, downcase: path.downcase } }
178
+ @file_mention_path_entries = build_file_path_entries(paths)
179
+ end
180
+
181
+ def build_file_path_entries(paths)
182
+ paths.map { |path| { path: path, downcase: path.downcase } }
131
183
  end
132
184
 
133
185
  def discover_project_file_paths(include_ignored: false)
@@ -165,6 +217,7 @@ module Kward
165
217
  def selected_file_overlay_path
166
218
  return nil unless file_overlay_visible?
167
219
 
220
+ finish_file_mention_discovery_locked unless @file_mention_paths
168
221
  matches = file_overlay_matches
169
222
  return nil if matches.empty?
170
223
 
@@ -200,6 +253,9 @@ module Kward
200
253
  return [] unless file_overlay_visible?
201
254
 
202
255
  matches = file_overlay_matches
256
+ if file_mention_discovery_loading?
257
+ return overlay_card_rows("Files", [overlay_text_line("Loading project files…", :muted)], width)
258
+ end
203
259
  if matches.empty?
204
260
  return overlay_card_rows("Files", [overlay_text_line("No matching files", :muted)], width)
205
261
  end
@@ -6,6 +6,73 @@ module Kward
6
6
  module KeyHandler
7
7
  private
8
8
 
9
+ def filter_terminal_handoff_input_locked(chunk)
10
+ @handoff_input_buffer << chunk.to_s.b
11
+ forwarded = +"".b
12
+ tab_action = nil
13
+
14
+ until @handoff_input_buffer.empty?
15
+ if @handoff_input_buffer.start_with?(TerminalKeys::CTRL_T)
16
+ action = handle_tab_key_binding(TerminalKeys::CTRL_T)
17
+ if action != false
18
+ remainder = @handoff_input_buffer.byteslice(TerminalKeys::CTRL_T.bytesize..).to_s
19
+ queue_pending_keys(remainder) unless remainder.empty?
20
+ @handoff_input_buffer.clear
21
+ tab_action = action
22
+ break
23
+ end
24
+ forwarded << @handoff_input_buffer.slice!(0)
25
+ next
26
+ end
27
+
28
+ if @handoff_input_buffer.start_with?("\e")
29
+ complete = TerminalKeys::TAB_ACTION_SEQUENCES.find { |sequence| @handoff_input_buffer.start_with?(sequence) }
30
+ partial = TerminalKeys::TAB_ACTION_SEQUENCES.any? { |sequence| sequence.start_with?(@handoff_input_buffer) }
31
+ break if partial && !complete
32
+
33
+ if complete
34
+ action = handle_tab_key_binding(complete)
35
+ if action != false
36
+ remainder = @handoff_input_buffer.byteslice(complete.bytesize..).to_s
37
+ queue_pending_keys(remainder) unless remainder.empty?
38
+ @handoff_input_buffer.clear
39
+ tab_action = action
40
+ break
41
+ end
42
+ forwarded << complete
43
+ @handoff_input_buffer = @handoff_input_buffer.byteslice(complete.bytesize..).to_s.b
44
+ next
45
+ end
46
+
47
+ if (sequence = parse_csi_u_key(@handoff_input_buffer)) && @handoff_input_buffer.start_with?(sequence[:sequence])
48
+ legacy_key = legacy_handoff_key(sequence)
49
+ unless legacy_key.nil?
50
+ forwarded << legacy_key
51
+ @handoff_input_buffer = @handoff_input_buffer.byteslice(sequence[:sequence].bytesize..).to_s.b
52
+ next
53
+ end
54
+ end
55
+ end
56
+
57
+ forwarded << @handoff_input_buffer.slice!(0)
58
+ end
59
+
60
+ { input: forwarded, tab_action: tab_action }
61
+ end
62
+
63
+ def legacy_handoff_key(sequence)
64
+ event = csi_u_key_event(sequence)
65
+ return event[:text] if event[:type] == :printable
66
+ return "\r" if event[:type] == :enter
67
+ return "\b" if event[:type] == :backspace
68
+ return "\t" if event[:type] == :tab
69
+
70
+ return unless event[:type] == :modified && ctrl_modifier?(event[:modifier])
71
+ return unless event[:code].between?(32, 126)
72
+
73
+ (event[:code] & 0x1f).chr
74
+ end
75
+
9
76
  def read_key(nonblock: false)
10
77
  pending = @pending_keys.shift unless @pending_keys.empty?
11
78
  return pending if pending
@@ -19,7 +19,7 @@ module Kward
19
19
  end
20
20
 
21
21
  def transcript_bottom_row(height = screen_height)
22
- [height - @reserved_rows, 1].max
22
+ [height - @reserved_rows - TRANSCRIPT_COMPOSER_GAP_ROWS, 1].max
23
23
  end
24
24
 
25
25
  end
@@ -1,3 +1,5 @@
1
+ require_relative "../terminal/text"
2
+
1
3
  # Namespace for the Kward CLI agent runtime.
2
4
  module Kward
3
5
  # Shared overlay drawing helpers for prompt UI popups.
@@ -63,7 +65,7 @@ module Kward
63
65
 
64
66
  def overlay_top_border(title, card_width)
65
67
  title = visible_truncate(title.to_s, [card_width - 4, 1].max)
66
- plain_length = ANSI.strip(title).length
68
+ plain_length = TerminalText.width(ANSI.strip(title))
67
69
  colored("╭", :primary_green) + " #{colored(title, :primary_green, :bold)} " + colored("─" * [card_width - plain_length - 4, 0].max, :primary_green) + colored("╮", :primary_green)
68
70
  end
69
71
 
@@ -98,7 +100,7 @@ module Kward
98
100
  end
99
101
 
100
102
  def align_overlay_row(row, width)
101
- plain_length = ANSI.strip(row).length
103
+ plain_length = TerminalText.width(ANSI.strip(row))
102
104
  padding = [width - plain_length, 0].max
103
105
  left = overlay_left_padding(width, plain_length)
104
106
  right = padding - left
@@ -152,14 +154,14 @@ module Kward
152
154
  end
153
155
 
154
156
  def visible_ljust(text, width)
155
- text.to_s + (" " * [width - ANSI.strip(text.to_s).length, 0].max)
157
+ text.to_s + (" " * [width - TerminalText.width(ANSI.strip(text.to_s)), 0].max)
156
158
  end
157
159
 
158
160
  def visible_truncate(text, width)
159
161
  plain = ANSI.strip(text.to_s)
160
- return text.to_s if plain.length <= width
162
+ return text.to_s if TerminalText.width(plain) <= width
161
163
 
162
- plain[0, width]
164
+ TerminalText.truncate(plain, width)
163
165
  end
164
166
 
165
167
  end
@@ -0,0 +1,82 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ class PromptInterface
4
+ # Terminal implementations of the frontend-neutral plugin UI requests.
5
+ module PluginUIRequests
6
+ def request_plugin_ui(kind, payload, cancellation: nil)
7
+ cancellation&.raise_if_cancelled!
8
+ result = case kind.to_sym
9
+ when :question then ask_user_question(payload.fetch(:questions))
10
+ when :select then request_plugin_select(payload)
11
+ when :confirm then request_plugin_confirmation(payload)
12
+ when :input then request_plugin_input(payload)
13
+ else raise ArgumentError, "unsupported plugin UI request: #{kind}"
14
+ end
15
+ cancellation&.raise_if_cancelled!
16
+ result
17
+ end
18
+
19
+ private
20
+
21
+ def request_plugin_select(payload)
22
+ options = payload.fetch(:options)
23
+ labels = options.map { |option| plugin_option_text(option) }
24
+ selected = with_plugin_ui_modal_state do
25
+ select(payload[:message] || "Choose an option", labels, title: payload.fetch(:title))
26
+ end
27
+ return nil if selected.nil?
28
+
29
+ option = options[labels.index(selected)]
30
+ option && option.fetch(:value)
31
+ end
32
+
33
+ def request_plugin_confirmation(payload)
34
+ yes = { label: "Yes", description: "Continue." }
35
+ no = { label: "No", description: "Cancel." }
36
+ answers = ask_user_question([
37
+ {
38
+ header: payload.fetch(:title),
39
+ question: payload.fetch(:message),
40
+ options: payload[:default] ? [yes, no] : [no, yes]
41
+ }
42
+ ])
43
+ return false unless answers
44
+
45
+ answers.first[:answer].to_s.casecmp?("yes")
46
+ end
47
+
48
+ def request_plugin_input(payload)
49
+ with_plugin_ui_modal_state do
50
+ @mutex.synchronize do
51
+ self.composer_input = payload[:default].to_s
52
+ self.composer_cursor = composer_input.length
53
+ @composer.prefill_input = nil
54
+ @composer.clear_attachments
55
+ @pending_keys.clear
56
+ @busy = false
57
+ @asking = true
58
+ end
59
+ label = payload[:placeholder].to_s.empty? ? payload.fetch(:title) : "#{payload.fetch(:title)} · #{payload[:placeholder]}"
60
+ answer = ask("#{label}>")
61
+ answer.is_a?(String) ? answer : nil
62
+ end
63
+ end
64
+
65
+ def with_plugin_ui_modal_state
66
+ saved_state = @mutex.synchronize do
67
+ state = begin_question_prompt_state
68
+ @question_prompt_active = true
69
+ state
70
+ end
71
+ yield
72
+ ensure
73
+ finish_question_prompt(saved_state) if saved_state
74
+ end
75
+
76
+ def plugin_option_text(option)
77
+ description = option[:description].to_s
78
+ description.empty? ? option.fetch(:label) : "#{option.fetch(:label)} — #{description}"
79
+ end
80
+ end
81
+ end
82
+ end