kward 0.76.0 → 0.78.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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +8 -1
  3. data/CHANGELOG.md +62 -0
  4. data/Gemfile.lock +2 -2
  5. data/README.md +13 -4
  6. data/doc/agent-tools.md +0 -3
  7. data/doc/api.md +4 -0
  8. data/doc/code-search.md +9 -6
  9. data/doc/composer.md +219 -0
  10. data/doc/configuration.md +114 -27
  11. data/doc/editor.md +18 -2
  12. data/doc/files.md +1 -1
  13. data/doc/getting-started.md +4 -0
  14. data/doc/git.md +2 -2
  15. data/doc/lifecycle-hooks.md +2 -14
  16. data/doc/pan.md +167 -0
  17. data/doc/permissions.md +179 -0
  18. data/doc/plugins.md +58 -0
  19. data/doc/rpc.md +61 -22
  20. data/doc/security.md +184 -0
  21. data/doc/tabs.md +4 -3
  22. data/doc/usage.md +15 -32
  23. data/doc/web-search.md +4 -2
  24. data/lib/kward/ansi.rb +2 -2
  25. data/lib/kward/auth/anthropic_oauth.rb +2 -2
  26. data/lib/kward/auth/github_oauth.rb +3 -3
  27. data/lib/kward/auth/oauth_helpers.rb +4 -2
  28. data/lib/kward/auth/openai_oauth.rb +2 -1
  29. data/lib/kward/cancellation.rb +2 -2
  30. data/lib/kward/cli/commands.rb +1 -3
  31. data/lib/kward/cli/interactive_turn.rb +0 -17
  32. data/lib/kward/cli/plugins.rb +24 -7
  33. data/lib/kward/cli/prompt_interface.rb +1 -0
  34. data/lib/kward/cli/rendering.rb +7 -1
  35. data/lib/kward/cli/runtime_helpers.rb +11 -35
  36. data/lib/kward/cli/sessions.rb +14 -5
  37. data/lib/kward/cli/settings.rb +22 -0
  38. data/lib/kward/cli/slash_commands.rb +1 -561
  39. data/lib/kward/cli/tabs.rb +154 -36
  40. data/lib/kward/cli.rb +30 -26
  41. data/lib/kward/compactor.rb +3 -3
  42. data/lib/kward/config_files.rb +28 -0
  43. data/lib/kward/ekwsh.rb +0 -4
  44. data/lib/kward/hooks/catalog.rb +0 -5
  45. data/lib/kward/hooks/http_handler.rb +2 -1
  46. data/lib/kward/http.rb +18 -0
  47. data/lib/kward/image_attachments.rb +1 -1
  48. data/lib/kward/model/client.rb +33 -11
  49. data/lib/kward/model/payloads.rb +6 -1
  50. data/lib/kward/openrouter_model_cache.rb +2 -1
  51. data/lib/kward/pan/index.html.erb +459 -64
  52. data/lib/kward/pan/kward_logo.png +0 -0
  53. data/lib/kward/pan/server.rb +248 -23
  54. data/lib/kward/permissions/policy.rb +171 -0
  55. data/lib/kward/plugin_registry.rb +71 -1
  56. data/lib/kward/prompt_interface/approval_prompt.rb +62 -0
  57. data/lib/kward/prompt_interface/editor/auto_indent.rb +31 -23
  58. data/lib/kward/prompt_interface/editor/modes/vibe.rb +72 -3
  59. data/lib/kward/prompt_interface/layout.rb +0 -4
  60. data/lib/kward/prompt_interface/project_browser.rb +40 -1
  61. data/lib/kward/prompt_interface/question_prompt.rb +12 -3
  62. data/lib/kward/prompt_interface/runtime_state.rb +4 -0
  63. data/lib/kward/prompt_interface/selection_prompt.rb +0 -1
  64. data/lib/kward/prompt_interface/transcript_renderer.rb +0 -12
  65. data/lib/kward/prompt_interface.rb +20 -1
  66. data/lib/kward/prompts/commands.rb +1 -3
  67. data/lib/kward/prompts.rb +20 -0
  68. data/lib/kward/rpc/plugin_chat_manager.rb +299 -0
  69. data/lib/kward/rpc/server.rb +48 -41
  70. data/lib/kward/rpc/session_manager.rb +76 -25
  71. data/lib/kward/rpc/tool_event_normalizer.rb +2 -0
  72. data/lib/kward/rpc/tool_metadata.rb +23 -0
  73. data/lib/kward/rpc/transcript_normalizer.rb +18 -5
  74. data/lib/kward/session_store.rb +55 -16
  75. data/lib/kward/skills/registry.rb +21 -1
  76. data/lib/kward/starter_pack_installer.rb +3 -1
  77. data/lib/kward/tab_driver.rb +87 -0
  78. data/lib/kward/tab_store.rb +74 -12
  79. data/lib/kward/tools/base.rb +14 -0
  80. data/lib/kward/tools/code_search.rb +8 -2
  81. data/lib/kward/tools/fetch_content.rb +4 -2
  82. data/lib/kward/tools/fetch_raw.rb +3 -1
  83. data/lib/kward/tools/registry.rb +45 -17
  84. data/lib/kward/tools/search/code.rb +47 -13
  85. data/lib/kward/tools/search/web.rb +61 -18
  86. data/lib/kward/tools/search/web_fetch.rb +206 -38
  87. data/lib/kward/tools/tool_call.rb +0 -4
  88. data/lib/kward/tools/web_search.rb +3 -1
  89. data/lib/kward/transcript_export.rb +1 -1
  90. data/lib/kward/update_check.rb +2 -1
  91. data/lib/kward/version.rb +1 -1
  92. data/templates/default/kward_navigation.rb +7 -2
  93. data/templates/default/layout/html/layout.erb +2 -2
  94. data/templates/default/layout/html/setup.rb +1 -0
  95. metadata +11 -12
  96. data/lib/kward/workers/git_guard.rb +0 -93
  97. data/lib/kward/workers/job.rb +0 -99
  98. data/lib/kward/workers/live_view.rb +0 -49
  99. data/lib/kward/workers/manager.rb +0 -328
  100. data/lib/kward/workers/queue_runner.rb +0 -166
  101. data/lib/kward/workers/queue_store.rb +0 -112
  102. data/lib/kward/workers/store.rb +0 -72
  103. data/lib/kward/workers/tool_policy.rb +0 -23
  104. data/lib/kward/workers/worker.rb +0 -82
  105. data/lib/kward/workers/write_lock.rb +0 -38
  106. data/lib/kward/workers.rb +0 -10
@@ -34,6 +34,10 @@ module Kward
34
34
  end
35
35
  end
36
36
 
37
+ # Registered plugin-owned tab runtime. Its factory receives a
38
+ # `PluginTabHost` and its persisted descriptor, then returns a driver.
39
+ TabType = Struct.new(:id, :name, :title, :singleton, :rpc, :transcript_events, :path, :handler, keyword_init: true)
40
+
37
41
  # Read-only event passed to plugin transcript observers.
38
42
  TranscriptEvent = Struct.new(:type, :payload, keyword_init: true) do
39
43
  def to_h
@@ -87,14 +91,17 @@ module Kward
87
91
  nil
88
92
  end
89
93
 
94
+ # @return [String, nil] active session identifier
90
95
  def session_id
91
96
  @session&.id
92
97
  end
93
98
 
99
+ # @return [String, nil] human-readable active session name
94
100
  def session_name
95
101
  @session&.name
96
102
  end
97
103
 
104
+ # @return [String, nil] saved active session path
98
105
  def session_path
99
106
  @session&.path
100
107
  end
@@ -108,30 +115,45 @@ module Kward
108
115
  nil
109
116
  end
110
117
 
118
+ # Allows the current lifecycle event to continue.
119
+ # @return [Hooks::Decision]
111
120
  def allow(message = nil, metadata: nil)
112
121
  Hooks::Decision.allow(message, metadata: metadata)
113
122
  end
114
123
 
124
+ # Denies the current lifecycle event.
125
+ # @return [Hooks::Decision]
115
126
  def deny(message = nil, metadata: nil)
116
127
  Hooks::Decision.deny(message, metadata: metadata)
117
128
  end
118
129
 
130
+ # Requests frontend approval for the current lifecycle event.
131
+ # @return [Hooks::Decision]
119
132
  def ask(message = nil, metadata: nil)
120
133
  Hooks::Decision.ask(message, metadata: metadata)
121
134
  end
122
135
 
136
+ # Continues with an event-specific payload replacement.
137
+ # @param payload [Hash] replacement fields supported by the event
138
+ # @return [Hooks::Decision]
123
139
  def modify(payload, message: nil, metadata: nil)
124
140
  Hooks::Decision.modify(payload, message: message, metadata: metadata)
125
141
  end
126
142
 
143
+ # Allows the event while recording a warning.
144
+ # @return [Hooks::Decision]
127
145
  def warn(message = nil, metadata: nil)
128
146
  Hooks::Decision.warn(message, metadata: metadata)
129
147
  end
130
148
 
149
+ # Requests a retry when the current event supports it.
150
+ # @return [Hooks::Decision]
131
151
  def retry(message = nil, payload: nil, metadata: nil)
132
152
  Hooks::Decision.retry(message, payload: payload, metadata: metadata)
133
153
  end
134
154
 
155
+ # Defers the event when the current workflow supports it.
156
+ # @return [Hooks::Decision]
135
157
  def defer(message = nil, payload: nil, metadata: nil)
136
158
  Hooks::Decision.defer(message, payload: payload, metadata: metadata)
137
159
  end
@@ -246,6 +268,23 @@ module Kward
246
268
  def interactive_command(name, rows:, fps: 30, description: "", argument_hint: "", &block)
247
269
  @registry.register_interactive_command(name, rows: rows, fps: fps, description: description, argument_hint: argument_hint, path: @path, &block)
248
270
  end
271
+
272
+ # Registers a plugin-owned tab type for the interactive CLI. `id` is a
273
+ # durable identifier used in persisted tab layouts and must not change.
274
+ # The factory receives a `PluginTabHost` and a descriptor hash.
275
+ #
276
+ # @param name [String] command name used by `/tab open <name>`
277
+ # @param id [String] stable persisted tab type identifier
278
+ # @param title [String] default tab label
279
+ # @param singleton [Symbol] `:global` for one shared plugin runtime
280
+ # @param transcript_events [Boolean] allow global transcript observers to receive this tab's events
281
+ # @yieldparam host [PluginTabHost] supported host dependencies
282
+ # @yieldparam descriptor [Hash] persisted tab descriptor
283
+ # @return [void]
284
+ # @api public
285
+ def tab_type(name, id:, title: nil, singleton: nil, rpc: false, transcript_events: false, &block)
286
+ @registry.register_tab_type(name, id: id, title: title, singleton: singleton, rpc: rpc, transcript_events: transcript_events, path: @path, &block)
287
+ end
249
288
  end
250
289
 
251
290
  # Mutable singleton guard used while loading trusted plugin files.
@@ -264,6 +303,8 @@ module Kward
264
303
  @reserved_commands = reserved_commands.map(&:to_s)
265
304
  @commands = {}
266
305
  @interactive_commands = {}
306
+ @tab_types = {}
307
+ @tab_types_by_id = {}
267
308
  @footer = nil
268
309
  @footer_path = nil
269
310
  @transcript_event_handlers = []
@@ -294,6 +335,18 @@ module Kward
294
335
  @interactive_commands[name.to_s]
295
336
  end
296
337
 
338
+ def tab_types
339
+ @tab_types.values
340
+ end
341
+
342
+ def tab_type_for(name)
343
+ @tab_types[name.to_s]
344
+ end
345
+
346
+ def tab_type_for_id(id)
347
+ @tab_types_by_id[id.to_s]
348
+ end
349
+
297
350
  def footer_renderer
298
351
  @footer
299
352
  end
@@ -411,6 +464,23 @@ module Kward
411
464
  )
412
465
  end
413
466
 
467
+ def register_tab_type(name, id:, title: nil, singleton: nil, rpc: false, transcript_events: false, path: nil, &handler)
468
+ name = name.to_s
469
+ id = id.to_s
470
+ raise "Plugin tab type name is invalid: #{name}" unless name.match?(COMMAND_NAME_PATTERN)
471
+ raise "Plugin tab type id is required" if id.empty?
472
+ raise "Plugin tab type #{name} requires a handler" unless handler
473
+
474
+ if @tab_types.key?(name) || @tab_types_by_id.key?(id)
475
+ warn "Warning: skipping duplicate Kward plugin tab type #{id}: #{path}"
476
+ return nil
477
+ end
478
+
479
+ tab_type = TabType.new(id: id, name: name, title: title.to_s.empty? ? name.capitalize : title.to_s, singleton: singleton&.to_sym, rpc: rpc == true, transcript_events: transcript_events == true, path: path, handler: handler)
480
+ @tab_types[name] = tab_type
481
+ @tab_types_by_id[id] = tab_type
482
+ end
483
+
414
484
  def register_footer(path: nil, &renderer)
415
485
  raise "Plugin footer requires a renderer" unless renderer
416
486
 
@@ -482,7 +552,7 @@ module Kward
482
552
  end
483
553
  end
484
554
 
485
- def transcript_event(type, payload)
555
+ def transcript_event(type, payload = {})
486
556
  TranscriptEvent.new(
487
557
  type: type,
488
558
  payload: DeepCopy.freeze(DeepCopy.dup(payload))
@@ -0,0 +1,62 @@
1
+ require "json"
2
+
3
+ # Namespace for the Kward CLI agent runtime.
4
+ module Kward
5
+ class PromptInterface
6
+ # Tool-approval overlay built on the structured question modal.
7
+ module ApprovalPrompt
8
+ # Asks the captain to approve a model-requested tool call. Cancellation is
9
+ # intentionally a denial so a closed or interrupted overlay never permits
10
+ # a side effect.
11
+ def ask_tool_approval(tool_name:, args:, reason: nil)
12
+ summary, details = tool_approval_details(tool_name, args)
13
+ question = (["The agent wants to #{summary}."] + Array(details) + [reason.to_s].reject(&:empty?)).join("\n")
14
+ answers = ask_user_question([
15
+ {
16
+ header: "Approval required · #{tool_approval_title(tool_name)}",
17
+ question: question,
18
+ options: [
19
+ { label: "Allow once", description: "Run this tool call." },
20
+ { label: "Allow this tool for this session", description: "Run this call and future calls to #{tool_name}." },
21
+ { label: "Deny", description: "Do not run this tool call." }
22
+ ]
23
+ }
24
+ ])
25
+ answer = answers&.first
26
+ return { denied_message: answer[:answer] } if answer&.fetch(:custom, false) && !answer[:answer].to_s.empty?
27
+
28
+ case answer&.fetch(:answer, nil)
29
+ when "Allow once" then true
30
+ when "Allow this tool for this session" then :allow_for_session
31
+ else false
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def tool_approval_title(tool_name)
38
+ case tool_name.to_s
39
+ when "run_shell_command" then "Shell command"
40
+ when "write_file" then "Write file"
41
+ when "edit_file" then "Edit file"
42
+ when "fetch_content", "fetch_raw" then "Network request"
43
+ when "web_search" then "Web search"
44
+ else tool_name.to_s.tr("_", " ").capitalize
45
+ end
46
+ end
47
+
48
+ def tool_approval_details(tool_name, args)
49
+ action = case tool_name.to_s
50
+ when "run_shell_command" then "run this shell command"
51
+ when "write_file" then "write this file"
52
+ when "edit_file" then "edit this file"
53
+ when "read_file", "read_skill" then "read these resources"
54
+ when "fetch_content", "fetch_raw" then "make this network request"
55
+ when "web_search" then "search the web"
56
+ else "use #{tool_name}"
57
+ end
58
+ [action, ["Arguments:\n#{JSON.pretty_generate(args.to_h)}"]]
59
+ end
60
+ end
61
+ end
62
+ end
@@ -177,7 +177,7 @@ module Kward
177
177
  def editor_expected_indent_for_line(line_index)
178
178
  line = @editor_state.lines[line_index].to_s
179
179
  code = editor_indent_code(line, editor_syntax_language).strip
180
- matching_indent = editor_matching_indent_for_line(code)
180
+ matching_indent = editor_matching_indent_for_line(code, line_index)
181
181
  return matching_indent if matching_indent
182
182
 
183
183
  previous_line = previous_non_blank_editor_line(line_index)
@@ -188,11 +188,11 @@ module Kward
188
188
  indent
189
189
  end
190
190
 
191
- def editor_matching_indent_for_line(code)
191
+ def editor_matching_indent_for_line(code, line_index = nil)
192
192
  return nil if code.empty?
193
- return editor_matching_punctuation_indent(code[0]) if editor_closing_punctuation?(code[0])
193
+ return editor_matching_punctuation_indent(code[0], line_index) if editor_closing_punctuation?(code[0])
194
194
 
195
- editor_matching_word_indent if editor_completed_word_closer?(code, editor_syntax_language)
195
+ editor_matching_word_indent(line_index) if editor_completed_word_closer?(code, editor_syntax_language)
196
196
  end
197
197
 
198
198
  def previous_non_blank_editor_line(line_index)
@@ -371,7 +371,8 @@ module Kward
371
371
  def editor_reindent_for_closing_punctuation(text)
372
372
  return unless editor_cursor_in_leading_indent?
373
373
 
374
- indent = editor_matching_punctuation_indent(text)
374
+ line_index, = @editor_state.cursor_line_and_column
375
+ indent = editor_matching_punctuation_indent(text, line_index)
375
376
  editor_reindent_current_line(indent) if indent
376
377
  end
377
378
 
@@ -381,7 +382,7 @@ module Kward
381
382
  before_cursor = line[0...column].to_s
382
383
  return unless editor_completed_word_closer?(before_cursor, editor_syntax_language)
383
384
 
384
- indent = editor_matching_word_indent
385
+ indent = editor_matching_word_indent(line_index)
385
386
  editor_reindent_current_line(indent) if indent
386
387
  end
387
388
 
@@ -394,14 +395,21 @@ module Kward
394
395
  def editor_reindent_current_line(indent)
395
396
  line_index, column = @editor_state.cursor_line_and_column
396
397
  line_start = @editor_state.line_start_offset(line_index)
397
- line = @editor_state.lines[line_index].to_s
398
- old_indent = line[/\A[ \t]*/].to_s
398
+ old_indent = @editor_state.lines[line_index].to_s[/\A[ \t]*/].to_s
399
+ return false unless editor_reindent_line(line_index, indent)
400
+
401
+ content_column = [column - old_indent.length, 0].max
402
+ @editor_state.cursor = line_start + indent.to_s.length + content_column
403
+ true
404
+ end
405
+
406
+ def editor_reindent_line(line_index, indent)
407
+ line_start = @editor_state.line_start_offset(line_index)
408
+ old_indent = @editor_state.lines[line_index].to_s[/\A[ \t]*/].to_s
399
409
  new_indent = indent.to_s
400
410
  return false if old_indent == new_indent
401
411
 
402
- content_column = [column - old_indent.length, 0].max
403
412
  @editor_state.replace_range(line_start, line_start + old_indent.length, new_indent)
404
- @editor_state.cursor = line_start + new_indent.length + content_column
405
413
  true
406
414
  end
407
415
 
@@ -421,23 +429,23 @@ module Kward
421
429
  end
422
430
  end
423
431
 
424
- def editor_matching_word_indent
432
+ def editor_matching_word_indent(line_index = nil)
425
433
  case editor_syntax_language
426
434
  when :ruby
427
- editor_matching_keyword_indent("end", %w[end])
435
+ editor_matching_keyword_indent("end", %w[end], line_index)
428
436
  when :lua
429
- editor_matching_keyword_indent("end", %w[end until])
437
+ editor_matching_keyword_indent("end", %w[end until], line_index)
430
438
  when :shell
431
- editor_matching_keyword_indent(nil, SHELL_DEDENT_KEYWORDS)
439
+ editor_matching_keyword_indent(nil, SHELL_DEDENT_KEYWORDS, line_index)
432
440
  when :html
433
- editor_matching_html_indent
441
+ editor_matching_html_indent(line_index)
434
442
  end
435
443
  end
436
444
 
437
- def editor_matching_punctuation_indent(text)
445
+ def editor_matching_punctuation_indent(text, line_index = nil)
438
446
  opener = PUNCTUATION_PAIRS[text]
439
447
  stack = []
440
- editor_previous_code_lines.each do |line|
448
+ editor_previous_code_lines(line_index).each do |line|
441
449
  editor_scan_punctuation_tokens(line[:code]).each do |token|
442
450
  if token == opener
443
451
  stack << line[:indent]
@@ -449,9 +457,9 @@ module Kward
449
457
  stack.last || ""
450
458
  end
451
459
 
452
- def editor_matching_keyword_indent(opener = nil, closers = [])
460
+ def editor_matching_keyword_indent(opener = nil, closers = [], line_index = nil)
453
461
  stack = []
454
- editor_previous_code_lines.each do |line|
462
+ editor_previous_code_lines(line_index).each do |line|
455
463
  code = line[:code].strip
456
464
  next if code.empty?
457
465
 
@@ -461,9 +469,9 @@ module Kward
461
469
  stack.last || ""
462
470
  end
463
471
 
464
- def editor_matching_html_indent
472
+ def editor_matching_html_indent(line_index = nil)
465
473
  stack = []
466
- editor_previous_code_lines.each do |line|
474
+ editor_previous_code_lines(line_index).each do |line|
467
475
  code = line[:code].strip
468
476
  next if code.empty?
469
477
 
@@ -473,8 +481,8 @@ module Kward
473
481
  stack.last || ""
474
482
  end
475
483
 
476
- def editor_previous_code_lines
477
- line_index, = @editor_state.cursor_line_and_column
484
+ def editor_previous_code_lines(line_index = nil)
485
+ line_index ||= @editor_state.cursor_line_and_column.first
478
486
  @editor_state.lines.first(line_index).filter_map do |line|
479
487
  code = editor_indent_code(line, editor_syntax_language).rstrip
480
488
  next if code.strip.empty?
@@ -452,9 +452,10 @@ module Kward
452
452
  return true if command.match?(/\A\d*g\z/)
453
453
  return true if command.match?(/\A\d*g[uU~]\z/)
454
454
  return true if command.match?(/\A\d*z\z/)
455
- return true if command.match?(/\A\d*[cdy]\d*\z/)
456
- return true if command.match?(/\A\d*[cdy]\d*[ai]\z/)
457
- return true if command.match?(/\A\d*[cdy]\d*[fFtT]\z/)
455
+ return true if command.match?(/\A\d*[=cdy](?:[1-9]\d*)?\z/)
456
+ return true if command.match?(/\A\d*[=cdy]\d*g\z/)
457
+ return true if command.match?(/\A\d*[=cdy]\d*[ai]\z/)
458
+ return true if command.match?(/\A\d*[=cdy]\d*[fFtT]\z/)
458
459
  return true if command.match?(/\A\d*[fFtT]\z/)
459
460
  return true if command.match?(/\A\d*r\z/)
460
461
  return true if command.match?(/\Am\z/)
@@ -635,6 +636,10 @@ module Kward
635
636
  when "<<"
636
637
  vibe_indent_lines(count, :left)
637
638
  vibe_remember_change(command)
639
+ when "=="
640
+ vibe_reindent_lines(count, command)
641
+ when /\A=(.+)\z/
642
+ vibe_reindent_operator(Regexp.last_match(1), count, command)
638
643
  when "p"
639
644
  vibe_active_register_linewise? ? vibe_paste_line(:below, original_command) : vibe_paste_after(original_command)
640
645
  when "P"
@@ -704,6 +709,8 @@ module Kward
704
709
  vibe_indent_visual_selection(:right)
705
710
  when "<"
706
711
  vibe_indent_visual_selection(:left)
712
+ when "="
713
+ vibe_reindent_visual_selection
707
714
  when "J"
708
715
  vibe_join_visual_selection
709
716
  when "~"
@@ -1077,6 +1084,18 @@ module Kward
1077
1084
  vibe_cancel_visual_mode
1078
1085
  end
1079
1086
 
1087
+ def vibe_reindent_visual_selection
1088
+ ranges = @editor_state.selection_ranges
1089
+ return false if ranges.empty?
1090
+
1091
+ start_index = ranges.map(&:first).min
1092
+ end_index = ranges.map(&:last).max
1093
+ start_line, = @editor_state.cursor_line_and_column_for(start_index)
1094
+ end_line, = @editor_state.cursor_line_and_column_for([end_index - 1, start_index].max)
1095
+ vibe_reindent_line_range(start_line, end_line)
1096
+ vibe_cancel_visual_mode
1097
+ end
1098
+
1080
1099
  def vibe_count_and_body(command)
1081
1100
  return [0, "0"] if command == "0"
1082
1101
 
@@ -1284,6 +1303,56 @@ module Kward
1284
1303
  true
1285
1304
  end
1286
1305
 
1306
+ def vibe_reindent_lines(count, command = nil)
1307
+ line, = @editor_state.cursor_line_and_column
1308
+ vibe_reindent_line_range(line, line + count - 1, command)
1309
+ end
1310
+
1311
+ def vibe_reindent_operator(motion, count, command = nil)
1312
+ motion_count, motion = vibe_count_and_body(motion)
1313
+ return vibe_reindent_to_line(motion_count, command) if motion == "G"
1314
+ return vibe_reindent_to_line(motion_count.positive? ? motion_count : 1, command) if motion == "gg"
1315
+ return vibe_reindent_lines(count, command) if motion == "=" || motion == "g_"
1316
+
1317
+ count *= motion_count if motion_count.positive?
1318
+ target = vibe_operator_target(motion, count)
1319
+ return false unless target
1320
+
1321
+ vibe_reindent_target(target, motion, command)
1322
+ end
1323
+
1324
+ def vibe_reindent_to_line(line_count, command)
1325
+ line, = @editor_state.cursor_line_and_column
1326
+ target_line = line_count.positive? ? line_count - 1 : @editor_state.lines.length - 1
1327
+ vibe_reindent_line_range(line, target_line, command)
1328
+ end
1329
+
1330
+ def vibe_reindent_target(target, motion, command)
1331
+ start_line, = @editor_state.cursor_line_and_column_for(target.start_index)
1332
+ end_line = if motion.match?(/\A[ai].\z/)
1333
+ @editor_state.cursor_line_and_column_for([target.end_index - 1, target.start_index].max).first
1334
+ else
1335
+ @editor_state.cursor_line_and_column.first
1336
+ end
1337
+ vibe_reindent_line_range(start_line, end_line, command)
1338
+ end
1339
+
1340
+ def vibe_reindent_line_range(first_line, last_line, command = nil)
1341
+ start_line, end_line = [first_line, last_line].minmax
1342
+ end_line = [end_line, @editor_state.lines.length - 1].min
1343
+ vibe_record_undo do
1344
+ (start_line..end_line).each do |line_index|
1345
+ next if @editor_state.lines[line_index].to_s.strip.empty?
1346
+
1347
+ editor_reindent_line(line_index, editor_expected_indent_for_line(line_index))
1348
+ end
1349
+ end
1350
+ @editor_state.move_to_line_first_non_blank(end_line)
1351
+ @editor_state.status = "Reindented #{end_line - start_line + 1} line#{end_line == start_line ? "" : "s"}"
1352
+ vibe_remember_change(command) if command
1353
+ true
1354
+ end
1355
+
1287
1356
  def vibe_change_lines(count, command = nil)
1288
1357
  start_index, end_index = vibe_linewise_change_range(count)
1289
1358
  @editor_state.copy_range(start_index, end_index)
@@ -10,10 +10,6 @@ module Kward
10
10
  @banner.rows(width, message: message)
11
11
  end
12
12
 
13
- def banner_logo_rows
14
- @banner.logo_rows(screen_width)
15
- end
16
-
17
13
  def transcript_redraw_row_count(height = screen_height)
18
14
  [[@transcript_viewport_rows, transcript_bottom_row(height)].max, height].min
19
15
  end
@@ -12,6 +12,28 @@ module Kward
12
12
  PROJECT_BROWSER_ROOT = "".freeze
13
13
  PROJECT_BROWSER_RESULT_LIMIT = 200
14
14
  PROJECT_BROWSER_STATE_VERSION = 1
15
+ PROJECT_BROWSER_FILENAME_ICONS = {
16
+ ".gitignore" => "",
17
+ "Gemfile" => "",
18
+ "Rakefile" => "",
19
+ "README" => "",
20
+ "README.md" => "",
21
+ "package.json" => ""
22
+ }.freeze
23
+ PROJECT_BROWSER_EXTENSION_ICONS = {
24
+ "css" => "",
25
+ "html" => "",
26
+ "js" => "",
27
+ "json" => "",
28
+ "md" => "",
29
+ "rb" => "",
30
+ "sh" => "",
31
+ "ts" => "",
32
+ "yaml" => "",
33
+ "yml" => ""
34
+ }.freeze
35
+ PROJECT_BROWSER_DIRECTORY_ICON = ""
36
+ PROJECT_BROWSER_FILE_ICON = ""
15
37
 
16
38
  def open_project_browser
17
39
  @mutex.synchronize do
@@ -399,8 +421,25 @@ module Kward
399
421
  else
400
422
  " "
401
423
  end
424
+ icon = project_browser_icon(row)
402
425
  suffix = row[:directory] ? "/" : ""
403
- "#{indent}#{marker}#{row[:name]}#{suffix}"
426
+ "#{indent}#{marker}#{icon}#{row[:name]}#{suffix}"
427
+ end
428
+
429
+ def project_browser_icon(row)
430
+ return "" unless @project_browser_icon_theme == "nerd-font"
431
+
432
+ icon = if row[:directory]
433
+ PROJECT_BROWSER_DIRECTORY_ICON
434
+ else
435
+ project_browser_file_icon(row[:path])
436
+ end
437
+ "#{icon} "
438
+ end
439
+
440
+ def project_browser_file_icon(path)
441
+ name = File.basename(path.to_s)
442
+ PROJECT_BROWSER_FILENAME_ICONS[name] || PROJECT_BROWSER_EXTENSION_ICONS[File.extname(name).delete_prefix(".").downcase] || PROJECT_BROWSER_FILE_ICON
404
443
  end
405
444
 
406
445
  def saved_project_browser_state
@@ -337,11 +337,12 @@ module Kward
337
337
 
338
338
  def question_overlay_rows(width)
339
339
  title = "Question #{@question_state[:index]}/#{@question_state[:total]} · #{@question_state[:header]}"
340
- lines = [
341
- overlay_text_line(@question_state[:question].to_s, :bold),
340
+ content_width = [overlay_card_width(width) - 4, 1].max
341
+ lines = question_text_rows(@question_state[:question], content_width, :bold)
342
+ lines.concat([
342
343
  overlay_text_line("↑/↓ select · Enter choose · Esc cancel", :muted),
343
344
  overlay_blank_line
344
- ]
345
+ ])
345
346
  question_choices.each_with_index do |choice, index|
346
347
  selected = index == question_selection_index
347
348
  lines << overlay_choice_line(choice_text(choice, selected: selected), selected: selected)
@@ -349,6 +350,14 @@ module Kward
349
350
  overlay_card_rows(title, lines, width)
350
351
  end
351
352
 
353
+ def question_text_rows(text, width, style)
354
+ text.to_s.split("\n", -1).flat_map do |line|
355
+ wrapped = ANSI.wrap_visible(line, width)
356
+ wrapped = [""] if wrapped.empty?
357
+ wrapped.map { |row| overlay_text_line(row, style) }
358
+ end
359
+ end
360
+
352
361
  def choice_text(choice, selected: false)
353
362
  if choice[:custom]
354
363
  selected ? "Type a custom answer below." : "Type something."
@@ -74,6 +74,10 @@ module Kward
74
74
  ANSI.colorize(text, *styles, enabled: @color_enabled)
75
75
  end
76
76
 
77
+ def normalize_project_browser_icon_theme(value)
78
+ value.to_s == "nerd-font" ? "nerd-font" : "off"
79
+ end
80
+
77
81
  def normalize_tab_keybindings(value)
78
82
  text = value.to_s.downcase
79
83
  return "ctrl" if text == "ctrl"
@@ -68,7 +68,6 @@ module Kward
68
68
  return false unless sequence
69
69
 
70
70
  code = sequence[:code]
71
- modifiers = sequence[:modifiers]
72
71
  modifier = sequence[:modifier]
73
72
  queue_pending_keys(sequence[:remaining]) if sequence[:remaining] && !sequence[:remaining].empty?
74
73
 
@@ -40,10 +40,6 @@ module Kward
40
40
  @transcript_buffer.append(text.to_s)
41
41
  end
42
42
 
43
- def invalidate_transcript_display_rows_cache
44
- @transcript_buffer.invalidate_display_rows_cache
45
- end
46
-
47
43
  def ensure_transcript_block_separator_locked
48
44
  return if @transcript_buffer.empty? || @transcript_buffer.end_with?("\n\n")
49
45
 
@@ -65,10 +61,6 @@ module Kward
65
61
  @output_io.print(terminal_newlines(rows.join("\n")))
66
62
  end
67
63
 
68
- def transcript_viewport_text(row_count, width)
69
- transcript_viewport_rows(row_count, width).join("\n")
70
- end
71
-
72
64
  def transcript_viewport_rows(row_count, width)
73
65
  return [] unless row_count.positive?
74
66
 
@@ -89,10 +81,6 @@ module Kward
89
81
  @transcript_buffer.display_rows(width)
90
82
  end
91
83
 
92
- def transcript_text_display_rows(width)
93
- @transcript_buffer.text_display_rows(width)
94
- end
95
-
96
84
  def reset_stream_position_from_transcript_locked(width = screen_width)
97
85
  @stream_state.reset_position_from_rows(transcript_display_rows(width), width)
98
86
  end
@@ -25,6 +25,7 @@ require_relative "prompt_interface/file_overlay"
25
25
  require_relative "prompt_interface/project_browser"
26
26
  require_relative "prompt_interface/selection_prompt"
27
27
  require_relative "prompt_interface/question_prompt"
28
+ require_relative "prompt_interface/approval_prompt"
28
29
  require_relative "prompt_interface/git_prompt"
29
30
  require_relative "prompt_interface/overlay_renderer"
30
31
  require_relative "prompt_interface/editor/renderer"
@@ -75,6 +76,7 @@ module Kward
75
76
  include ProjectBrowser
76
77
  include SelectionPrompt
77
78
  include QuestionPrompt
79
+ include ApprovalPrompt
78
80
  include GitPrompt
79
81
  include OverlayRenderer
80
82
  include EditorRenderer
@@ -126,7 +128,7 @@ module Kward
126
128
  end
127
129
  end
128
130
 
129
- def initialize(input: $stdin, output: $stdout, slash_commands: [], overlay_settings: nil, footer: nil, composer_status: nil, busy_help: true, attachment_badges: nil, attachment_parser: nil, banner_message: nil, tab_keybindings: nil, prompt_history: nil, editor_mode: nil, editor_mode_source: nil, editor_auto_indent: true, editor_auto_indent_source: nil, editor_auto_close_pairs: true, editor_auto_close_pairs_source: nil, editor_soft_wrap: true, editor_soft_wrap_source: nil, editor_bar_cursor: true, editor_bar_cursor_source: nil, editor_line_numbers: "absolute", editor_line_numbers_source: nil, diff_view: "auto", diff_view_source: nil)
131
+ def initialize(input: $stdin, output: $stdout, slash_commands: [], overlay_settings: nil, project_browser_icon_theme: "off", footer: nil, composer_status: nil, busy_help: true, attachment_badges: nil, attachment_parser: nil, banner_message: nil, tab_keybindings: nil, prompt_history: nil, editor_mode: nil, editor_mode_source: nil, editor_auto_indent: true, editor_auto_indent_source: nil, editor_auto_close_pairs: true, editor_auto_close_pairs_source: nil, editor_soft_wrap: true, editor_soft_wrap_source: nil, editor_bar_cursor: true, editor_bar_cursor_source: nil, editor_line_numbers: "absolute", editor_line_numbers_source: nil, diff_view: "auto", diff_view_source: nil)
130
132
  @input_io = input
131
133
  @output_io = output
132
134
  @reader = TTY::Reader.new(input: input, output: output, interrupt: :error)
@@ -186,6 +188,7 @@ module Kward
186
188
  @editor_bar_cursor_active = false
187
189
  @synchronized_output_depth = 0
188
190
  @overlay_settings = normalize_overlay_settings(overlay_settings)
191
+ @project_browser_icon_theme = normalize_project_browser_icon_theme(project_browser_icon_theme)
189
192
  @footer = footer
190
193
  @composer_status = composer_status
191
194
  @busy_help = busy_help
@@ -211,6 +214,15 @@ module Kward
211
214
  @diff_view_source = diff_view_source
212
215
  end
213
216
 
217
+ def update_slash_commands(commands)
218
+ @mutex.synchronize do
219
+ @slash_commands = normalize_slash_commands(commands)
220
+ reset_slash_selection
221
+ @slash_overlay_dismissed_input = nil
222
+ render_prompt_locked if @started
223
+ end
224
+ end
225
+
214
226
  def start(render: true)
215
227
  @mutex.synchronize do
216
228
  return if @started
@@ -661,6 +673,13 @@ module Kward
661
673
  end
662
674
  end
663
675
 
676
+ def update_project_browser_icon_theme(theme)
677
+ @mutex.synchronize do
678
+ @project_browser_icon_theme = normalize_project_browser_icon_theme(theme)
679
+ render_prompt_locked if @started && @asking && project_browser_visible?
680
+ end
681
+ end
682
+
664
683
  def begin_busy_input(message = "You>", activity: "streaming")
665
684
  start
666
685
  @mutex.synchronize do