kward 0.71.0 → 0.72.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 (116) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -1
  3. data/Gemfile.lock +2 -2
  4. data/README.md +4 -0
  5. data/doc/agent-tools.md +15 -6
  6. data/doc/authentication.md +22 -1
  7. data/doc/code-search.md +42 -2
  8. data/doc/configuration.md +106 -3
  9. data/doc/context-budgeting.md +136 -0
  10. data/doc/context-tools.md +16 -3
  11. data/doc/editor.md +394 -0
  12. data/doc/extensibility.md +16 -7
  13. data/doc/files.md +100 -0
  14. data/doc/getting-started.md +25 -18
  15. data/doc/git.md +122 -0
  16. data/doc/memory.md +24 -4
  17. data/doc/personas.md +34 -5
  18. data/doc/plugins.md +72 -1
  19. data/doc/releasing.md +37 -9
  20. data/doc/rpc.md +74 -4
  21. data/doc/session-management.md +35 -1
  22. data/doc/shell.md +286 -0
  23. data/doc/tabs.md +122 -0
  24. data/doc/troubleshooting.md +77 -1
  25. data/doc/usage.md +53 -7
  26. data/doc/web-search.md +12 -4
  27. data/doc/workspace-tools.md +51 -12
  28. data/examples/plugins/space_invaders.rb +377 -0
  29. data/lib/kward/agent.rb +1 -1
  30. data/lib/kward/cli/commands.rb +33 -2
  31. data/lib/kward/cli/git.rb +150 -0
  32. data/lib/kward/cli/interactive_turn.rb +73 -9
  33. data/lib/kward/cli/plugins.rb +54 -4
  34. data/lib/kward/cli/prompt_interface.rb +32 -1
  35. data/lib/kward/cli/runtime_helpers.rb +133 -3
  36. data/lib/kward/cli/sessions.rb +2 -2
  37. data/lib/kward/cli/settings.rb +218 -9
  38. data/lib/kward/cli/slash_commands.rb +415 -2
  39. data/lib/kward/cli/tabs.rb +695 -0
  40. data/lib/kward/cli.rb +158 -26
  41. data/lib/kward/config_files.rb +123 -1
  42. data/lib/kward/context_budget_meter.rb +44 -0
  43. data/lib/kward/conversation.rb +12 -4
  44. data/lib/kward/editor_mode.rb +25 -0
  45. data/lib/kward/ekwsh.rb +362 -0
  46. data/lib/kward/plugin_registry.rb +61 -0
  47. data/lib/kward/project_files.rb +52 -0
  48. data/lib/kward/prompt_history.rb +82 -0
  49. data/lib/kward/prompt_interface/composer_controller.rb +69 -1
  50. data/lib/kward/prompt_interface/composer_renderer.rb +109 -13
  51. data/lib/kward/prompt_interface/composer_state.rb +96 -27
  52. data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
  53. data/lib/kward/prompt_interface/editor/auto_indent.rb +509 -0
  54. data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
  55. data/lib/kward/prompt_interface/editor/controller.rb +1018 -0
  56. data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
  57. data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
  58. data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
  59. data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
  60. data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
  61. data/lib/kward/prompt_interface/editor/modes/modern.rb +353 -0
  62. data/lib/kward/prompt_interface/editor/modes/vibe.rb +1962 -0
  63. data/lib/kward/prompt_interface/editor/renderer.rb +243 -0
  64. data/lib/kward/prompt_interface/editor/search.rb +76 -0
  65. data/lib/kward/prompt_interface/editor/selections.rb +120 -0
  66. data/lib/kward/prompt_interface/editor/state.rb +1249 -0
  67. data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
  68. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +420 -0
  69. data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
  70. data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
  71. data/lib/kward/prompt_interface/file_overlay.rb +211 -0
  72. data/lib/kward/prompt_interface/git_prompt.rb +299 -0
  73. data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
  74. data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
  75. data/lib/kward/prompt_interface/interactive/state.rb +62 -0
  76. data/lib/kward/prompt_interface/key_handler.rb +387 -35
  77. data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
  78. data/lib/kward/prompt_interface/project_browser.rb +524 -0
  79. data/lib/kward/prompt_interface/question_prompt.rb +98 -50
  80. data/lib/kward/prompt_interface/runtime_state.rb +43 -0
  81. data/lib/kward/prompt_interface/screen.rb +16 -0
  82. data/lib/kward/prompt_interface/selection_prompt.rb +7 -13
  83. data/lib/kward/prompt_interface/stream_state.rb +7 -0
  84. data/lib/kward/prompt_interface/transcript_buffer.rb +6 -0
  85. data/lib/kward/prompt_interface.rb +286 -8
  86. data/lib/kward/prompts/commands.rb +5 -0
  87. data/lib/kward/prompts.rb +2 -0
  88. data/lib/kward/rpc/server.rb +42 -3
  89. data/lib/kward/rpc/session_manager.rb +35 -47
  90. data/lib/kward/rpc/session_tree_rows.rb +9 -115
  91. data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
  92. data/lib/kward/session_store.rb +44 -0
  93. data/lib/kward/session_tree_nodes.rb +136 -0
  94. data/lib/kward/session_tree_renderer.rb +9 -131
  95. data/lib/kward/tab_store.rb +47 -0
  96. data/lib/kward/text_boundary.rb +25 -0
  97. data/lib/kward/tools/context_budget_stats.rb +54 -0
  98. data/lib/kward/tools/context_for_task.rb +202 -0
  99. data/lib/kward/tools/read_file.rb +8 -4
  100. data/lib/kward/tools/registry.rb +62 -16
  101. data/lib/kward/tools/tool_call.rb +10 -0
  102. data/lib/kward/version.rb +1 -1
  103. data/lib/kward/workers/git_guard.rb +68 -0
  104. data/lib/kward/workers/live_view.rb +49 -0
  105. data/lib/kward/workers/manager.rb +288 -0
  106. data/lib/kward/workers/store.rb +72 -0
  107. data/lib/kward/workers/tool_policy.rb +23 -0
  108. data/lib/kward/workers/worker.rb +82 -0
  109. data/lib/kward/workers/write_lock.rb +38 -0
  110. data/lib/kward/workers.rb +7 -0
  111. data/lib/kward/workspace.rb +110 -24
  112. data/templates/default/fulldoc/html/css/kward.css +107 -36
  113. data/templates/default/kward_navigation.rb +12 -1
  114. data/templates/default/layout/html/layout.erb +4 -2
  115. data/templates/default/layout/html/setup.rb +6 -0
  116. metadata +53 -1
@@ -0,0 +1,362 @@
1
+ require "open3"
2
+ require "shellwords"
3
+
4
+ # Namespace for the Kward CLI agent runtime.
5
+ module Kward
6
+ # Kward-native embedded shell command runner.
7
+ class Ekwsh
8
+ Result = Struct.new(:output, :exit_status, :exit_shell, :clear, :open_editor_path, keyword_init: true)
9
+ Completion = Struct.new(:range, :replacement, :candidates, keyword_init: true)
10
+ BUILTINS = %w[alias cd pwd export unset clear exit logout].freeze
11
+
12
+ attr_reader :cwd
13
+
14
+ def initialize(cwd: Dir.pwd, env: ENV.to_h, shell: ENV["SHELL"], configured_env: {}, aliases: {})
15
+ @cwd = File.expand_path(cwd.to_s.empty? ? Dir.pwd : cwd.to_s)
16
+ @previous_cwd = nil
17
+ @env = env.to_h.transform_keys(&:to_s).transform_values(&:to_s)
18
+ @env.merge!(configured_env.to_h.transform_keys(&:to_s).transform_values(&:to_s))
19
+ @env["PWD"] = @cwd
20
+ configure_rbenv_environment
21
+ configure_color_environment
22
+ @aliases = aliases.to_h.transform_keys(&:to_s).transform_values(&:to_s)
23
+ @shell = shell.to_s.empty? ? "/bin/sh" : shell.to_s
24
+ end
25
+
26
+ def prompt_label
27
+ "Shell #{display_cwd} $"
28
+ end
29
+
30
+ def run(input)
31
+ command = input.to_s.strip
32
+ return Result.new(output: "", exit_status: 0) if command.empty?
33
+ return Result.new(output: command_echo(command), exit_status: 0, exit_shell: true) if exit_command?(command)
34
+
35
+ builtin_result(command) || run_expanded_command(command)
36
+ end
37
+
38
+ def complete(input, cursor)
39
+ token = completion_token(input.to_s, cursor.to_i)
40
+ return nil if token[:command] && token[:text].empty?
41
+
42
+ candidates = if token[:command] && !path_like_token?(token[:text])
43
+ command_candidates(token[:text])
44
+ else
45
+ path_candidates(token[:text], directories_only: cd_completion?(input, token))
46
+ end
47
+ return nil if candidates.empty?
48
+
49
+ replacement = completion_replacement(token[:text], candidates)
50
+ Completion.new(range: token[:range], replacement: replacement, candidates: candidates)
51
+ end
52
+
53
+ private
54
+
55
+ def configure_rbenv_environment
56
+ root = @env["RBENV_ROOT"].to_s
57
+ root = File.expand_path("~/.rbenv") if root.empty?
58
+ root = File.expand_path(root)
59
+ paths = [File.join(root, "shims"), File.join(root, "bin")].select { |path| Dir.exist?(path) }
60
+ return if paths.empty?
61
+
62
+ @env["RBENV_ROOT"] = root
63
+ @env["PATH"] = prepend_path_entries(@env["PATH"], paths)
64
+ rescue ArgumentError
65
+ nil
66
+ end
67
+
68
+ def prepend_path_entries(path, entries)
69
+ current = path.to_s.split(File::PATH_SEPARATOR)
70
+ (entries + current).uniq.join(File::PATH_SEPARATOR)
71
+ end
72
+
73
+ def configure_color_environment
74
+ @env["CLICOLOR"] ||= "1"
75
+ @env["COLORTERM"] ||= "truecolor"
76
+ @env["TERM"] = "xterm-256color" if @env["TERM"].to_s.empty? || @env["TERM"] == "dumb"
77
+ end
78
+
79
+ def completion_token(input, cursor)
80
+ cursor = [[cursor, 0].max, input.length].min
81
+ start_index = cursor
82
+ start_index -= 1 while start_index.positive? && token_character?(input, start_index - 1)
83
+ text = input[start_index...cursor].to_s
84
+ before = input[0...start_index].to_s
85
+ { range: (start_index...cursor), text: text, command: before.strip.empty? }
86
+ end
87
+
88
+ def token_character?(input, index)
89
+ return true unless input[index].match?(/\s/)
90
+
91
+ escaped_character?(input, index)
92
+ end
93
+
94
+ def escaped_character?(input, index)
95
+ backslashes = 0
96
+ cursor = index - 1
97
+ while cursor >= 0 && input[cursor] == "\\"
98
+ backslashes += 1
99
+ cursor -= 1
100
+ end
101
+ backslashes.odd?
102
+ end
103
+
104
+ def cd_completion?(input, token)
105
+ input[0...token[:range].begin].to_s.strip == "cd"
106
+ end
107
+
108
+ def path_like_token?(text)
109
+ text.to_s.include?("/")
110
+ end
111
+
112
+ def command_candidates(prefix)
113
+ (BUILTINS + @aliases.keys + path_executables).uniq.grep(/\A#{Regexp.escape(prefix)}/).sort
114
+ end
115
+
116
+ def path_executables
117
+ @env.fetch("PATH", "").split(File::PATH_SEPARATOR).flat_map do |path|
118
+ next [] unless File.directory?(path)
119
+
120
+ Dir.children(path).filter_map do |entry|
121
+ full_path = File.join(path, entry)
122
+ entry if File.file?(full_path) && File.executable?(full_path)
123
+ end
124
+ rescue SystemCallError
125
+ []
126
+ end
127
+ end
128
+
129
+ def path_candidates(prefix, directories_only: false)
130
+ raw_dir, raw_base = split_path_prefix(prefix)
131
+ dir = File.expand_path(unescape_path(raw_dir.empty? ? "." : raw_dir), @cwd)
132
+ return [] unless File.directory?(dir)
133
+
134
+ Dir.children(dir).filter_map do |entry|
135
+ next unless entry.start_with?(unescape_path(raw_base))
136
+
137
+ path = File.join(dir, entry)
138
+ directory = File.directory?(path)
139
+ next if directories_only && !directory
140
+
141
+ completed = "#{raw_dir}#{Shellwords.escape(entry)}"
142
+ completed = "#{completed}/" if directory
143
+ completed
144
+ end.sort
145
+ rescue SystemCallError
146
+ []
147
+ end
148
+
149
+ def split_path_prefix(prefix)
150
+ index = prefix.rindex("/")
151
+ return ["", prefix] unless index
152
+
153
+ [prefix[0..index], prefix[(index + 1)..].to_s]
154
+ end
155
+
156
+ def unescape_path(value)
157
+ value.to_s.gsub(/\\(.)/, "\\1")
158
+ end
159
+
160
+ def completion_replacement(prefix, candidates)
161
+ return add_completion_suffix(candidates.first) if candidates.length == 1
162
+
163
+ common = common_prefix(candidates)
164
+ common.length > prefix.length ? common : prefix
165
+ end
166
+
167
+ def add_completion_suffix(candidate)
168
+ candidate.end_with?("/") ? candidate : "#{candidate} "
169
+ end
170
+
171
+ def common_prefix(values)
172
+ first = values.first.to_s
173
+ values.drop(1).reduce(first) do |prefix, value|
174
+ prefix = prefix[0...-1] until value.start_with?(prefix) || prefix.empty?
175
+ prefix
176
+ end
177
+ end
178
+
179
+ def display_cwd
180
+ home = Dir.home.to_s
181
+ return "~" if @cwd == home
182
+ return "~#{@cwd.delete_prefix(home)}" if !home.empty? && @cwd.start_with?("#{home}/")
183
+
184
+ @cwd
185
+ rescue ArgumentError
186
+ @cwd
187
+ end
188
+
189
+ def command_echo(command)
190
+ "$ #{command}\n"
191
+ end
192
+
193
+ def exit_command?(command)
194
+ ["exit", "logout"].include?(command)
195
+ end
196
+
197
+ def builtin_result(command)
198
+ words = shell_words(command)
199
+ return nil if words.empty?
200
+
201
+ case words.first
202
+ when "alias"
203
+ list_aliases(command, words)
204
+ when "cd"
205
+ change_directory(command, words)
206
+ when "pwd"
207
+ Result.new(output: "#{command_echo(command)}#{@cwd}\n", exit_status: 0)
208
+ when "export"
209
+ export_variables(command, words)
210
+ when "unset"
211
+ unset_variables(command, words)
212
+ when "clear"
213
+ Result.new(output: "", exit_status: 0, clear: true)
214
+ else
215
+ nil
216
+ end
217
+ rescue ArgumentError => e
218
+ Result.new(output: "#{command_echo(command)}ekwsh: #{e.message}\n", exit_status: 2)
219
+ end
220
+
221
+ def shell_words(command)
222
+ Shellwords.shellsplit(command)
223
+ end
224
+
225
+ def list_aliases(command, words)
226
+ assignments, names = words.drop(1).partition { |word| word.include?("=") }
227
+ invalid = []
228
+ assignments.each do |assignment|
229
+ name, value = assignment.split("=", 2)
230
+ if valid_alias_name?(name)
231
+ @aliases[name] = value.to_s
232
+ else
233
+ invalid << name
234
+ end
235
+ end
236
+ return Result.new(output: "#{command_echo(command)}ekwsh: alias: invalid name: #{invalid.join(" ")}\n", exit_status: 2) unless invalid.empty?
237
+
238
+ names = @aliases.keys.sort if names.empty? && assignments.empty?
239
+ lines = names.filter_map { |name| @aliases[name] ? "#{name}=#{Shellwords.escape(@aliases.fetch(name))}" : nil }
240
+ suffix = lines.empty? ? "" : "#{lines.join("\n")}\n"
241
+ Result.new(output: "#{command_echo(command)}#{suffix}", exit_status: 0)
242
+ end
243
+
244
+ def valid_alias_name?(name)
245
+ name.to_s.match?(/\A[A-Za-z_][A-Za-z0-9_-]*\z/) && !BUILTINS.include?(name.to_s)
246
+ end
247
+
248
+ def expand_alias(command)
249
+ words = shell_words(command)
250
+ return command if words.empty? || BUILTINS.include?(words.first)
251
+ return command unless @aliases[words.first]
252
+
253
+ rest = command.sub(/\A\s*#{Regexp.escape(words.first)}\b\s*/, "")
254
+ [@aliases.fetch(words.first), rest].reject(&:empty?).join(" ")
255
+ rescue ArgumentError
256
+ command
257
+ end
258
+
259
+ def run_expanded_command(command)
260
+ expanded_command = expand_alias(command)
261
+ kward_result = kward_command_result(expanded_command, display_command: command)
262
+ return kward_result if kward_result
263
+
264
+ execute(expanded_command, display_command: command)
265
+ end
266
+
267
+ def kward_command_result(command, display_command: command)
268
+ words = shell_words(command)
269
+ return nil unless kward_edit_command?(words)
270
+
271
+ unless words.length == 3
272
+ return Result.new(output: "#{command_echo(display_command)}Usage: kward edit <filename>\n", exit_status: 2)
273
+ end
274
+
275
+ path = File.expand_path(words[2], @cwd)
276
+ Result.new(output: command_echo(display_command), exit_status: 0, open_editor_path: path)
277
+ rescue ArgumentError => e
278
+ Result.new(output: "#{command_echo(display_command)}ekwsh: #{e.message}\n", exit_status: 2)
279
+ end
280
+
281
+ def kward_edit_command?(words)
282
+ return false unless words[1] == "edit"
283
+
284
+ File.basename(words[0].to_s) == "kward"
285
+ end
286
+
287
+ def change_directory(command, words)
288
+ target = words[1]
289
+ target = Dir.home if target.nil? || target.empty?
290
+ target = @previous_cwd || @cwd if target == "-"
291
+ path = File.expand_path(target, @cwd)
292
+ unless File.directory?(path)
293
+ return Result.new(output: "#{command_echo(command)}ekwsh: cd: no such directory: #{target}\n", exit_status: 1)
294
+ end
295
+
296
+ @previous_cwd = @cwd
297
+ @cwd = path
298
+ @env["OLDPWD"] = @previous_cwd
299
+ @env["PWD"] = @cwd
300
+ output = command_echo(command)
301
+ output << "#{@cwd}\n" if words[1] == "-"
302
+ Result.new(output: output, exit_status: 0)
303
+ end
304
+
305
+ def export_variables(command, words)
306
+ if words.length == 1
307
+ lines = @env.keys.sort.map { |key| "export #{key}=#{Shellwords.escape(@env.fetch(key))}" }
308
+ return Result.new(output: "#{command_echo(command)}#{lines.join("\n")}\n", exit_status: 0)
309
+ end
310
+
311
+ invalid = []
312
+ words.drop(1).each do |assignment|
313
+ key, value = assignment.split("=", 2)
314
+ if value.nil? || !valid_env_key?(key)
315
+ invalid << assignment
316
+ else
317
+ @env[key] = value
318
+ end
319
+ end
320
+
321
+ if invalid.empty?
322
+ Result.new(output: command_echo(command), exit_status: 0)
323
+ else
324
+ Result.new(output: "#{command_echo(command)}ekwsh: export: invalid assignment: #{invalid.join(" ")}\n", exit_status: 2)
325
+ end
326
+ end
327
+
328
+ def unset_variables(command, words)
329
+ invalid = words.drop(1).reject { |key| valid_env_key?(key) }
330
+ words.drop(1).each { |key| @env.delete(key) if valid_env_key?(key) }
331
+
332
+ if invalid.empty?
333
+ Result.new(output: command_echo(command), exit_status: 0)
334
+ else
335
+ Result.new(output: "#{command_echo(command)}ekwsh: unset: invalid name: #{invalid.join(" ")}\n", exit_status: 2)
336
+ end
337
+ end
338
+
339
+ def valid_env_key?(key)
340
+ key.to_s.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
341
+ end
342
+
343
+ def execute(command, display_command: command)
344
+ stdout, stderr, status = Open3.capture3(@env, @shell, "-c", command, chdir: @cwd)
345
+ exit_status = status.exitstatus || 1
346
+ output = command_echo(display_command)
347
+ output << clean_output(stdout)
348
+ output << clean_output(stderr)
349
+ output << "Exit status: #{exit_status}\n" unless exit_status.zero?
350
+ Result.new(output: output, exit_status: exit_status)
351
+ rescue Errno::ENOENT => e
352
+ Result.new(output: "#{command_echo(display_command)}ekwsh: #{e.message}\n", exit_status: 127)
353
+ end
354
+
355
+ def clean_output(value)
356
+ text = value.to_s.dup
357
+ text.force_encoding(Encoding::UTF_8)
358
+ text = text.valid_encoding? ? text : text.scrub
359
+ text.end_with?("\n") || text.empty? ? text : "#{text}\n"
360
+ end
361
+ end
362
+ end
@@ -23,6 +23,15 @@ module Kward
23
23
  end
24
24
  end
25
25
 
26
+ # Registered interactive command that takes over the composer region with a
27
+ # Kward-driven render and input loop. Like a slash command but with canvas
28
+ # rendering capabilities for games, dashboards, viewers, and similar uses.
29
+ InteractiveCommand = Struct.new(:name, :description, :argument_hint, :rows, :fps, :path, :handler, keyword_init: true) do
30
+ def entry
31
+ { name: name, description: description, argument_hint: argument_hint }
32
+ end
33
+ end
34
+
26
35
  # Read-only event passed to plugin transcript observers.
27
36
  TranscriptEvent = Struct.new(:type, :payload, keyword_init: true) do
28
37
  def to_h
@@ -167,6 +176,24 @@ module Kward
167
176
  def prompt_context(&block)
168
177
  @registry.register_prompt_context(path: @path, &block)
169
178
  end
179
+
180
+ # Registers an interactive command that takes over the composer region with
181
+ # a Kward-driven render and input loop. The handler receives an
182
+ # interactive controller object with a canvas API for drawing colored
183
+ # cells and reading keys. Useful for games, dashboards, and viewers.
184
+ #
185
+ # @param name [String, #to_s] command name without the leading slash
186
+ # @param rows [Integer] fixed canvas height in terminal rows
187
+ # @param fps [Numeric] frame rate for tick callbacks (1-120, default 30)
188
+ # @param description [String] short text shown in command listings
189
+ # @param argument_hint [String] optional usage hint for arguments
190
+ # @yieldparam ui [Object] interactive controller with canvas and key API
191
+ # @yieldparam ctx [Context] plugin execution context
192
+ # @return [void]
193
+ # @api public
194
+ def interactive_command(name, rows:, fps: 30, description: "", argument_hint: "", &block)
195
+ @registry.register_interactive_command(name, rows: rows, fps: fps, description: description, argument_hint: argument_hint, path: @path, &block)
196
+ end
170
197
  end
171
198
 
172
199
  # Mutable singleton guard used while loading trusted plugin files.
@@ -207,6 +234,7 @@ module Kward
207
234
  def initialize(reserved_commands: [])
208
235
  @reserved_commands = reserved_commands.map(&:to_s)
209
236
  @commands = {}
237
+ @interactive_commands = {}
210
238
  @footer = nil
211
239
  @footer_path = nil
212
240
  @transcript_event_handlers = []
@@ -228,6 +256,14 @@ module Kward
228
256
  @commands[name.to_s]
229
257
  end
230
258
 
259
+ def interactive_commands
260
+ @interactive_commands.values
261
+ end
262
+
263
+ def interactive_command_for(name)
264
+ @interactive_commands[name.to_s]
265
+ end
266
+
231
267
  def footer_renderer
232
268
  @footer
233
269
  end
@@ -306,6 +342,31 @@ module Kward
306
342
  )
307
343
  end
308
344
 
345
+ def register_interactive_command(name, rows:, fps: 30, description: "", argument_hint: "", path: nil, &handler)
346
+ name = name.to_s
347
+ raise "Interactive command name is invalid: #{name}" unless name.match?(COMMAND_NAME_PATTERN)
348
+ raise "Interactive command /#{name} requires a handler" unless handler
349
+
350
+ if @reserved_commands.include?(name) || @commands.key?(name)
351
+ warn "Warning: skipping Kward interactive command /#{name}: reserved command"
352
+ return nil
353
+ end
354
+ if @interactive_commands.key?(name)
355
+ warn "Warning: skipping duplicate Kward interactive command /#{name}: #{path}"
356
+ return nil
357
+ end
358
+
359
+ @interactive_commands[name] = InteractiveCommand.new(
360
+ name: name,
361
+ description: description.to_s,
362
+ argument_hint: argument_hint.to_s,
363
+ rows: [[rows.to_i, 1].max, 1].max,
364
+ fps: [[fps.to_f, 1].max, 120].min,
365
+ path: path,
366
+ handler: handler
367
+ )
368
+ end
369
+
309
370
  def register_footer(path: nil, &renderer)
310
371
  raise "Plugin footer requires a renderer" unless renderer
311
372
 
@@ -0,0 +1,52 @@
1
+ require "find"
2
+ require "open3"
3
+ require "pathname"
4
+
5
+ # Namespace for the Kward CLI agent runtime.
6
+ module Kward
7
+ # Discovers project files for prompt UI features.
8
+ module ProjectFiles
9
+ module_function
10
+
11
+ def list(root: Dir.pwd)
12
+ paths = git_paths(root)
13
+ paths = scanned_paths(root) if paths.empty?
14
+ paths.reject { |path| path.empty? || path.end_with?("/") }.uniq.sort
15
+ end
16
+
17
+ def git_paths(root)
18
+ output, status = Open3.capture2("git", "ls-files", "--cached", "--others", "--exclude-standard", chdir: root)
19
+ return [] unless status.success?
20
+
21
+ output.lines.map(&:chomp).reject(&:empty?)
22
+ rescue StandardError
23
+ []
24
+ end
25
+
26
+ def scanned_paths(root)
27
+ root_path = Pathname.new(root)
28
+ paths = []
29
+ Find.find(root_path.to_s) do |path|
30
+ relative = Pathname.new(path).relative_path_from(root_path).to_s
31
+ if File.directory?(path)
32
+ Find.prune if ignored_directory?(relative)
33
+ next
34
+ end
35
+
36
+ paths << relative unless ignored_file?(relative)
37
+ end
38
+ paths
39
+ rescue StandardError
40
+ []
41
+ end
42
+
43
+ def ignored_directory?(relative)
44
+ ignored_directories = %w[.git .yardoc _yardoc node_modules rdoc tmp vendor/bundle]
45
+ ignored_directories.include?(relative) || relative.start_with?(".git/")
46
+ end
47
+
48
+ def ignored_file?(relative)
49
+ relative.start_with?(".git/")
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,82 @@
1
+ require "fileutils"
2
+ require "json"
3
+ require "time"
4
+ require_relative "config_files"
5
+
6
+ # Namespace for the Kward CLI agent runtime.
7
+ module Kward
8
+ # Workspace-scoped JSONL persistence for terminal prompt history.
9
+ class PromptHistory
10
+ DEFAULT_LIMIT = 1_000
11
+
12
+ Entry = Struct.new(:value, :timestamp, keyword_init: true)
13
+
14
+ def initialize(config_dir: ConfigFiles.config_dir, cwd: Dir.pwd, limit: DEFAULT_LIMIT)
15
+ @config_dir = config_dir
16
+ @cwd = ConfigFiles.canonical_workspace_root(cwd)
17
+ @limit = limit.to_i.positive? ? limit.to_i : DEFAULT_LIMIT
18
+ end
19
+
20
+ attr_reader :cwd, :limit
21
+
22
+ def values
23
+ entries.map(&:value)
24
+ end
25
+
26
+ def append(value)
27
+ text = value.to_s
28
+ return false if text.strip.empty?
29
+
30
+ existing = entries
31
+ return false if existing.last&.value == text
32
+
33
+ write_entries((existing + [Entry.new(value: text, timestamp: Time.now.utc.iso8601(3))]).last(limit))
34
+ true
35
+ end
36
+
37
+ def path
38
+ ConfigFiles.prompt_history_path(@cwd, config_dir: @config_dir)
39
+ end
40
+
41
+ private
42
+
43
+ def entries
44
+ return [] unless File.file?(path)
45
+
46
+ File.readlines(path, chomp: true).filter_map do |line|
47
+ record = JSON.parse(line)
48
+ value = record["value"].to_s
49
+ next if value.strip.empty?
50
+
51
+ Entry.new(value: value, timestamp: record["timestamp"].to_s)
52
+ rescue JSON::ParserError
53
+ nil
54
+ end.last(limit)
55
+ rescue Errno::ENOENT
56
+ []
57
+ end
58
+
59
+ def write_entries(entries)
60
+ FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
61
+ File.open(path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |file|
62
+ file.write(JSON.generate(history_header))
63
+ file.write("\n")
64
+ entries.each do |entry|
65
+ file.write(JSON.generate({ type: "prompt_history", version: 1, timestamp: entry.timestamp || Time.now.utc.iso8601(3), value: entry.value }))
66
+ file.write("\n")
67
+ end
68
+ end
69
+ File.chmod(0o600, path)
70
+ end
71
+
72
+ def history_header
73
+ {
74
+ type: "prompt_history_header",
75
+ version: 1,
76
+ workspace: @cwd,
77
+ workspaceHash: File.basename(path, ".jsonl"),
78
+ limit: limit
79
+ }
80
+ end
81
+ end
82
+ end