kward 0.70.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 (143) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/pages.yml +1 -1
  3. data/CHANGELOG.md +89 -3
  4. data/Gemfile +2 -0
  5. data/Gemfile.lock +90 -2
  6. data/README.md +34 -6
  7. data/Rakefile +96 -0
  8. data/doc/agent-tools.md +52 -0
  9. data/doc/api.md +92 -0
  10. data/doc/authentication.md +58 -23
  11. data/doc/code-search.md +42 -2
  12. data/doc/configuration.md +102 -13
  13. data/doc/context-budgeting.md +136 -0
  14. data/doc/context-tools.md +83 -0
  15. data/doc/editor.md +394 -0
  16. data/doc/extensibility.md +16 -7
  17. data/doc/files.md +100 -0
  18. data/doc/getting-started.md +25 -18
  19. data/doc/git.md +122 -0
  20. data/doc/memory.md +24 -4
  21. data/doc/personas.md +34 -5
  22. data/doc/plugins.md +74 -3
  23. data/doc/releasing.md +45 -8
  24. data/doc/rpc.md +77 -15
  25. data/doc/session-management.md +254 -0
  26. data/doc/shell.md +286 -0
  27. data/doc/tabs.md +122 -0
  28. data/doc/troubleshooting.md +77 -1
  29. data/doc/usage.md +60 -15
  30. data/doc/web-search.md +12 -4
  31. data/doc/workspace-tools.md +144 -0
  32. data/examples/plugins/space_invaders.rb +377 -0
  33. data/lib/kward/agent.rb +1 -1
  34. data/lib/kward/cli/commands.rb +41 -2
  35. data/lib/kward/cli/git.rb +150 -0
  36. data/lib/kward/cli/interactive_turn.rb +73 -9
  37. data/lib/kward/cli/openrouter_commands.rb +55 -0
  38. data/lib/kward/cli/plugins.rb +54 -4
  39. data/lib/kward/cli/prompt_interface.rb +111 -6
  40. data/lib/kward/cli/rendering.rb +11 -6
  41. data/lib/kward/cli/runtime_helpers.rb +133 -3
  42. data/lib/kward/cli/sessions.rb +262 -13
  43. data/lib/kward/cli/settings.rb +216 -37
  44. data/lib/kward/cli/slash_commands.rb +439 -8
  45. data/lib/kward/cli/tabs.rb +695 -0
  46. data/lib/kward/cli.rb +171 -26
  47. data/lib/kward/compactor.rb +4 -1
  48. data/lib/kward/config_files.rb +125 -5
  49. data/lib/kward/context_budget_meter.rb +44 -0
  50. data/lib/kward/conversation.rb +59 -22
  51. data/lib/kward/editor_mode.rb +25 -0
  52. data/lib/kward/ekwsh.rb +362 -0
  53. data/lib/kward/model/client.rb +37 -50
  54. data/lib/kward/model/context_usage.rb +13 -6
  55. data/lib/kward/model/model_info.rb +92 -16
  56. data/lib/kward/model/payloads.rb +2 -0
  57. data/lib/kward/openrouter_model_cache.rb +120 -0
  58. data/lib/kward/plugin_registry.rb +108 -1
  59. data/lib/kward/project_files.rb +52 -0
  60. data/lib/kward/prompt_history.rb +82 -0
  61. data/lib/kward/prompt_interface/banner.rb +16 -51
  62. data/lib/kward/prompt_interface/composer_controller.rb +124 -83
  63. data/lib/kward/prompt_interface/composer_renderer.rb +116 -14
  64. data/lib/kward/prompt_interface/composer_state.rb +96 -27
  65. data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
  66. data/lib/kward/prompt_interface/editor/auto_indent.rb +509 -0
  67. data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
  68. data/lib/kward/prompt_interface/editor/controller.rb +1018 -0
  69. data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
  70. data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
  71. data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
  72. data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
  73. data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
  74. data/lib/kward/prompt_interface/editor/modes/modern.rb +353 -0
  75. data/lib/kward/prompt_interface/editor/modes/vibe.rb +1962 -0
  76. data/lib/kward/prompt_interface/editor/renderer.rb +243 -0
  77. data/lib/kward/prompt_interface/editor/search.rb +76 -0
  78. data/lib/kward/prompt_interface/editor/selections.rb +120 -0
  79. data/lib/kward/prompt_interface/editor/state.rb +1249 -0
  80. data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
  81. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +420 -0
  82. data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
  83. data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
  84. data/lib/kward/prompt_interface/file_overlay.rb +211 -0
  85. data/lib/kward/prompt_interface/git_prompt.rb +299 -0
  86. data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
  87. data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
  88. data/lib/kward/prompt_interface/interactive/state.rb +62 -0
  89. data/lib/kward/prompt_interface/key_handler.rb +416 -43
  90. data/lib/kward/prompt_interface/layout.rb +2 -2
  91. data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
  92. data/lib/kward/prompt_interface/project_browser.rb +524 -0
  93. data/lib/kward/prompt_interface/prompt_renderer.rb +32 -13
  94. data/lib/kward/prompt_interface/question_prompt.rb +122 -82
  95. data/lib/kward/prompt_interface/runtime_state.rb +49 -1
  96. data/lib/kward/prompt_interface/screen.rb +17 -0
  97. data/lib/kward/prompt_interface/selection_prompt.rb +511 -58
  98. data/lib/kward/prompt_interface/stream_state.rb +7 -0
  99. data/lib/kward/prompt_interface/transcript_buffer.rb +13 -16
  100. data/lib/kward/prompt_interface/transcript_renderer.rb +3 -3
  101. data/lib/kward/prompt_interface.rb +307 -35
  102. data/lib/kward/prompts/commands.rb +7 -1
  103. data/lib/kward/prompts.rb +4 -2
  104. data/lib/kward/rpc/server.rb +45 -11
  105. data/lib/kward/rpc/session_manager.rb +52 -53
  106. data/lib/kward/rpc/session_tree_rows.rb +9 -115
  107. data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
  108. data/lib/kward/session_store.rb +67 -4
  109. data/lib/kward/session_tree_nodes.rb +136 -0
  110. data/lib/kward/session_tree_renderer.rb +9 -131
  111. data/lib/kward/tab_store.rb +47 -0
  112. data/lib/kward/telemetry/logger.rb +5 -3
  113. data/lib/kward/text_boundary.rb +25 -0
  114. data/lib/kward/tool_output_compactor.rb +127 -0
  115. data/lib/kward/tools/base.rb +8 -2
  116. data/lib/kward/tools/context_budget_stats.rb +54 -0
  117. data/lib/kward/tools/context_for_task.rb +202 -0
  118. data/lib/kward/tools/read_file.rb +8 -4
  119. data/lib/kward/tools/registry.rb +92 -15
  120. data/lib/kward/tools/retrieve_tool_output.rb +71 -0
  121. data/lib/kward/tools/search/web.rb +2 -2
  122. data/lib/kward/tools/summarize_file_structure.rb +29 -0
  123. data/lib/kward/tools/tool_call.rb +12 -0
  124. data/lib/kward/version.rb +1 -1
  125. data/lib/kward/workers/git_guard.rb +68 -0
  126. data/lib/kward/workers/live_view.rb +49 -0
  127. data/lib/kward/workers/manager.rb +288 -0
  128. data/lib/kward/workers/store.rb +72 -0
  129. data/lib/kward/workers/tool_policy.rb +23 -0
  130. data/lib/kward/workers/worker.rb +82 -0
  131. data/lib/kward/workers/write_lock.rb +38 -0
  132. data/lib/kward/workers.rb +7 -0
  133. data/lib/kward/workspace.rb +154 -12
  134. data/templates/default/fulldoc/html/css/kward.css +362 -42
  135. data/templates/default/fulldoc/html/full_list.erb +107 -0
  136. data/templates/default/fulldoc/html/js/kward.js +161 -2
  137. data/templates/default/fulldoc/html/setup.rb +8 -0
  138. data/templates/default/kward_navigation.rb +102 -0
  139. data/templates/default/layout/html/layout.erb +43 -10
  140. data/templates/default/layout/html/setup.rb +39 -38
  141. metadata +65 -3
  142. data/lib/kward/resources/avatar_kward_logo.rb +0 -50
  143. data/lib/kward/resources/pixel_logo.rb +0 -232
@@ -0,0 +1,377 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Space Invaders — a full classic arcade game built as a Kward interactive
4
+ # plugin command.
5
+ #
6
+ # Install: copy this file to ~/.kward/plugins/ and run `/invaders` in the
7
+ # Kward TUI.
8
+ #
9
+ # Controls:
10
+ # ←/→ Move
11
+ # Space Fire
12
+ # Q / Esc Quit
13
+ # Ctrl+C Force quit (handled by Kward)
14
+ #
15
+ # The game renders colored sprites and particle-burst explosions inside the
16
+ # composer canvas region using the interactive mode API.
17
+
18
+ Kward.plugin do |plugin|
19
+ plugin.interactive_command "invaders", rows: 18, fps: 30, description: "Space Invaders arcade game" do |ui, ctx|
20
+ game = SpaceInvadersGame.new(width: ui.width, height: ui.height)
21
+ ui.on_tick { |ui| game.tick(ui) }
22
+ end
23
+ end
24
+
25
+ # rubocop:disable Metrics/ClassLength, Metrics/MethodLength
26
+ class SpaceInvadersGame
27
+ PLAYER_CHAR = "▲"
28
+ PLAYER_COLOR = :cyan
29
+ BULLET_CHAR = "│"
30
+ BULLET_COLOR = :yellow
31
+ BOMB_CHAR = "V"
32
+ BOMB_COLOR = :magenta
33
+ SCORE_LABEL = "Score"
34
+ LIVES_LABEL = "Lives"
35
+
36
+ INVADER_ROWS = [
37
+ { char: "M", color: :red, points: 30 },
38
+ { char: "M", color: :red, points: 30 },
39
+ { char: "W", color: :yellow, points: 20 },
40
+ { char: "W", color: :yellow, points: 20 },
41
+ { char: "A", color: :green, points: 10 }
42
+ ].freeze
43
+
44
+ PARTICLE_CHARS = %w[* + . o].freeze
45
+ PARTICLE_COLORS = %i[red yellow].freeze
46
+ PARTICLE_LIFE = 6
47
+
48
+ MOVE_EVERY = 12
49
+ BULLET_SPEED = 2
50
+ BOMB_SPEED = 1
51
+ MAX_BULLETS = 3
52
+ FIRE_COOLDOWN = 8
53
+ BOMB_CHANCE = 0.015
54
+
55
+ def initialize(width:, height:)
56
+ @width = width
57
+ @height = height
58
+ @phase = :playing
59
+ @tick_count = 0
60
+ @score = 0
61
+ @lives = 3
62
+ @wave = 1
63
+ @fire_cooldown = 0
64
+ @invader_dir = 1
65
+ @invaders = []
66
+ @bullets = []
67
+ @bombs = []
68
+ @explosions = []
69
+ @message_timer = 0
70
+ spawn_invaders
71
+ place_player
72
+ end
73
+
74
+ def tick(ui)
75
+ drain_keys(ui)
76
+ case @phase
77
+ when :playing
78
+ update_playing
79
+ render_playing(ui)
80
+ when :game_over, :won
81
+ update_message_phase
82
+ render_message(ui)
83
+ end
84
+ ui.render
85
+ end
86
+
87
+ private
88
+
89
+ def drain_keys(ui)
90
+ while (key = ui.poll_key)
91
+ case key
92
+ when :escape, "q", "Q"
93
+ ui.exit
94
+ when :left
95
+ @player_col -= 1 if @phase == :playing
96
+ when :right
97
+ @player_col += 1 if @phase == :playing
98
+ when :space
99
+ fire_bullet if @phase == :playing
100
+ when :return, :enter
101
+ restart if @phase != :playing
102
+ end
103
+ end
104
+ end
105
+
106
+ def place_player
107
+ @player_col = @width / 2
108
+ @player_row = @height - 2
109
+ end
110
+
111
+ # ---- Invader spawning ----
112
+
113
+ def spawn_invaders
114
+ @invaders = []
115
+ rows = INVADER_ROWS.length
116
+ cols = [(@width - 4) / 4, 6].min
117
+ cols = [cols, 3].max
118
+ start_row = 2
119
+ start_col = (@width - cols * 4) / 2
120
+ start_col = [start_col, 1].max
121
+
122
+ rows.times do |row|
123
+ config = INVADER_ROWS[row]
124
+ cols.times do |col|
125
+ @invaders << {
126
+ row: start_row + row,
127
+ col: start_col + col * 4,
128
+ char: config[:char],
129
+ color: config[:color],
130
+ points: config[:points],
131
+ alive: true
132
+ }
133
+ end
134
+ end
135
+ @invader_dir = 1
136
+ end
137
+
138
+ # ---- Playing phase updates ----
139
+
140
+ def update_playing
141
+ @tick_count += 1
142
+ @fire_cooldown = [@fire_cooldown - 1, 0].max if @fire_cooldown.positive?
143
+
144
+ move_invaders if (@tick_count % move_interval).zero?
145
+ move_bullets
146
+ move_bombs
147
+ maybe_drop_bomb
148
+ update_explosions
149
+ check_collisions
150
+ check_win_lose
151
+ end
152
+
153
+ def move_interval
154
+ alive = @invaders.count { |inv| inv[:alive] }
155
+ total = @invaders.length
156
+ return MOVE_EVERY if total.zero?
157
+
158
+ speedup = ((total - alive) * MOVE_EVERY) / (total * 2)
159
+ [MOVE_EVERY - speedup, 2].max
160
+ end
161
+
162
+ def move_invaders
163
+ living = @invaders.select { |inv| inv[:alive] }
164
+ return if living.empty?
165
+
166
+ min_col = living.map { |inv| inv[:col] }.min
167
+ max_col = living.map { |inv| inv[:col] }.max
168
+
169
+ if @invader_dir.positive? && max_col >= @width - 2
170
+ @invader_dir = -1
171
+ @invaders.each { |inv| inv[:row] += 1 if inv[:alive] }
172
+ elsif @invader_dir.negative? && min_col <= 1
173
+ @invader_dir = 1
174
+ @invaders.each { |inv| inv[:row] += 1 if inv[:alive] }
175
+ else
176
+ @invaders.each { |inv| inv[:col] += @invader_dir if inv[:alive] }
177
+ end
178
+ end
179
+
180
+ def fire_bullet
181
+ return if @bullets.length >= MAX_BULLETS
182
+ return if @fire_cooldown.positive?
183
+
184
+ @bullets << { row: @player_row - 1, col: @player_col }
185
+ @fire_cooldown = FIRE_COOLDOWN
186
+ end
187
+
188
+ def move_bullets
189
+ @bullets.each { |b| b[:row] -= BULLET_SPEED }
190
+ @bullets.reject! { |b| b[:row] < 0 }
191
+ end
192
+
193
+ def move_bombs
194
+ @bombs.each { |b| b[:row] += BOMB_SPEED }
195
+ @bombs.reject! { |b| b[:row] >= @height }
196
+ end
197
+
198
+ def maybe_drop_bomb
199
+ return if rand > BOMB_CHANCE
200
+
201
+ living = @invaders.select { |inv| inv[:alive] }
202
+ return if living.empty?
203
+
204
+ # Drop from a random bottom-most invader per column
205
+ columns = living.group_by { |inv| inv[:col] }
206
+ col, invaders = columns.min_by { rand }
207
+ return unless invaders
208
+
209
+ bottom = invaders.max_by { |inv| inv[:row] }
210
+ @bombs << { row: bottom[:row] + 1, col: bottom[:col] } if bottom
211
+ end
212
+
213
+ # ---- Explosions ----
214
+
215
+ def spawn_explosion(row, col, color = :red)
216
+ PARTICLE_LIFE.times do |i|
217
+ angle = (i * 360 / PARTICLE_LIFE) * Math::PI / 180
218
+ dx = (Math.cos(angle) * (i + 1)).round
219
+ dy = (Math.sin(angle) * (i + 1)).round
220
+ @explosions << {
221
+ row: row + dy,
222
+ col: col + dx,
223
+ char: PARTICLE_CHARS[i % PARTICLE_CHARS.length],
224
+ color: PARTICLE_COLORS[i % PARTICLE_COLORS.length],
225
+ life: PARTICLE_LIFE
226
+ }
227
+ end
228
+ end
229
+
230
+ def update_explosions
231
+ @explosions.each { |e| e[:life] -= 1 }
232
+ @explosions.reject! { |e| e[:life] <= 0 }
233
+ end
234
+
235
+ # ---- Collisions ----
236
+
237
+ def check_collisions
238
+ check_bullet_invader_hits
239
+ check_bomb_player_hits
240
+ end
241
+
242
+ def check_bullet_invader_hits
243
+ @bullets.reject! do |bullet|
244
+ hit = @invaders.find { |inv| inv[:alive] && inv[:row] == bullet[:row] && inv[:col] == bullet[:col] }
245
+ next false unless hit
246
+
247
+ hit[:alive] = false
248
+ @score += hit[:points]
249
+ spawn_explosion(hit[:row], hit[:col], hit[:color])
250
+ true
251
+ end
252
+ end
253
+
254
+ def check_bomb_player_hits
255
+ @bombs.reject! do |bomb|
256
+ hit = bomb[:row] == @player_row && bomb[:col] == @player_col
257
+ next false unless hit
258
+
259
+ @lives -= 1
260
+ spawn_explosion(@player_row, @player_col, :cyan)
261
+ @phase = :game_over if @lives <= 0
262
+ true
263
+ end
264
+ end
265
+
266
+ def check_win_lose
267
+ return unless @phase == :playing
268
+
269
+ @phase = :won if @invaders.none? { |inv| inv[:alive] }
270
+
271
+ return unless @invaders.any? { |inv| inv[:alive] && inv[:row] >= @player_row }
272
+
273
+ @phase = :game_over
274
+ spawn_explosion(@player_row, @player_col, :cyan)
275
+ end
276
+
277
+ # ---- Message phase ----
278
+
279
+ def update_message_phase
280
+ @message_timer += 1
281
+ end
282
+
283
+ def restart
284
+ @score = 0
285
+ @lives = 3
286
+ @wave = 1
287
+ @tick_count = 0
288
+ @bullets.clear
289
+ @bombs.clear
290
+ @explosions.clear
291
+ @fire_cooldown = 0
292
+ @invader_dir = 1
293
+ spawn_invaders
294
+ place_player
295
+ @phase = :playing
296
+ @message_timer = 0
297
+ end
298
+
299
+ # ---- Rendering ----
300
+
301
+ def render_playing(ui)
302
+ ui.clear_frame
303
+
304
+ render_hud(ui)
305
+ render_invaders(ui)
306
+ render_bullets(ui)
307
+ render_bombs(ui)
308
+ render_explosions(ui)
309
+ ui.put(@player_row, @player_col, PLAYER_CHAR, PLAYER_COLOR)
310
+ end
311
+
312
+ def render_hud(ui)
313
+ score_text = "#{SCORE_LABEL}: #{@score}"
314
+ lives_text = "#{LIVES_LABEL}: #{@lives}"
315
+ wave_text = "Wave: #{@wave}"
316
+
317
+ score_text.chars.each_with_index do |char, i|
318
+ ui.put(0, i, char, :green)
319
+ end
320
+ lives_col = @width - lives_text.length
321
+ lives_text.chars.each_with_index do |char, i|
322
+ ui.put(0, lives_col + i, char, :cyan)
323
+ end
324
+ wave_col = (@width - wave_text.length) / 2
325
+ wave_text.chars.each_with_index do |char, i|
326
+ ui.put(0, wave_col + i, char, :gray)
327
+ end
328
+ end
329
+
330
+ def render_invaders(ui)
331
+ @invaders.each do |inv|
332
+ next unless inv[:alive]
333
+
334
+ ui.put(inv[:row], inv[:col], inv[:char], inv[:color])
335
+ end
336
+ end
337
+
338
+ def render_bullets(ui)
339
+ @bullets.each { |b| ui.put(b[:row], b[:col], BULLET_CHAR, BULLET_COLOR) }
340
+ end
341
+
342
+ def render_bombs(ui)
343
+ @bombs.each { |b| ui.put(b[:row], b[:col], BOMB_CHAR, BOMB_COLOR) }
344
+ end
345
+
346
+ def render_explosions(ui)
347
+ @explosions.each do |e|
348
+ ui.put(e[:row], e[:col], e[:char], e[:color])
349
+ end
350
+ end
351
+
352
+ def render_message(ui)
353
+ ui.clear_frame
354
+ render_invaders(ui)
355
+ render_explosions(ui)
356
+
357
+ if @phase == :won
358
+ message = "YOU WIN!"
359
+ color = :green
360
+ else
361
+ message = "GAME OVER"
362
+ color = :red
363
+ end
364
+
365
+ center_text(ui, @height / 2, message, color)
366
+ center_text(ui, @height / 2 + 2, "Score: #{@score}", :yellow)
367
+ center_text(ui, @height / 2 + 4, "Enter: restart Q: quit", :gray)
368
+ end
369
+
370
+ def center_text(ui, row, text, color)
371
+ col = (@width - text.length) / 2
372
+ text.chars.each_with_index do |char, i|
373
+ ui.put(row, col + i, char, color)
374
+ end
375
+ end
376
+ end
377
+ # rubocop:enable Metrics/ClassLength, Metrics/MethodLength
data/lib/kward/agent.rb CHANGED
@@ -31,7 +31,7 @@ module Kward
31
31
  @telemetry_logger = telemetry_logger
32
32
  end
33
33
 
34
- attr_reader :conversation
34
+ attr_reader :conversation, :tool_registry
35
35
 
36
36
  # Adds a user message, compacts context when needed, and runs the turn.
37
37
  #
@@ -48,11 +48,14 @@ module Kward
48
48
  #{heading.call("Usage")}
49
49
  #{command.call("kward")} Start an interactive chat
50
50
  #{command.call("kward")} #{option.call('"Explain this project"')} Run a one-shot prompt
51
+ #{command.call("kward --filter")} #{option.call('"Translate"')} Filter stdin with an instruction
51
52
  #{command.call("kward login")} Sign in or save provider credentials
52
53
  #{command.call("kward auth status")} Show saved credential status
53
54
  #{command.call("kward init")} Install starter prompts and PRINCIPLES.md
54
55
  #{command.call("kward doctor")} Check local Kward setup
56
+ #{command.call("kward edit")} #{option.call("<filename>")} Open a file in the integrated editor
55
57
  #{command.call("kward sysprompt")} Inspect the effective system prompt
58
+ #{command.call("kward openrouter refresh")} Refresh cached OpenRouter models
56
59
  #{command.call("kward pan")} Start Pan mode web UI
57
60
  #{command.call("kward rpc")} Start the experimental JSON-RPC backend
58
61
 
@@ -63,21 +66,28 @@ module Kward
63
66
  #{command.call("auth status|logout")} Show or clear saved credentials
64
67
  #{command.call("init")} Install starter prompts and PRINCIPLES.md
65
68
  #{command.call("doctor")} Check local Kward setup
69
+ #{command.call("edit")} #{option.call("<filename>")} Open a file in the integrated editor
66
70
  #{command.call("sysprompt")} [--raw] Inspect the effective system prompt
67
71
  #{command.call("stats tokens")} [range] [options] Export local token telemetry as CSV
72
+ #{command.call("openrouter refresh|list")} Refresh or list cached OpenRouter models
68
73
  #{command.call("pan")} Start Pan mode web UI
69
74
  #{command.call("rpc")} Run the JSON-RPC backend for UI clients
70
75
 
71
76
  #{heading.call("Options")}
72
77
  #{option.call("--working-directory=PATH")} Run Kward from PATH
78
+ #{option.call("--mode=MODE")} Execution mode: auto, chat, oneshot, filter
79
+ #{option.call("--filter")} Shortcut for --mode filter
73
80
  #{option.call("--help")}, #{option.call("-h")} Show this help
74
81
  #{option.call("--version")}, #{option.call("-v")} Show the installed version
75
82
 
76
83
  #{heading.call("Examples")}
77
84
  #{command.call("kward")}
78
- #{command.call("kward")} #{option.call('"Review this diff"')}
79
- #{command.call("git diff | kward")} #{option.call('"Review this diff"')}
85
+ #{command.call("kward")} #{option.call('"Explain this project"')}
86
+ #{command.call("git diff | kward")} #{option.call('"Summarize the main changes"')}
87
+ #{command.call("echo Hello | kward --filter")} #{option.call('"Translate to German"')}
80
88
  #{command.call("kward login openrouter")}
89
+ #{command.call("kward edit lib/main.rb")}
90
+ #{command.call("kward openrouter refresh")}
81
91
  #{command.call("kward stats tokens today --bucket hour")}
82
92
 
83
93
  Command names take precedence. Anything else is sent as a one-shot prompt.
@@ -116,6 +126,11 @@ module Kward
116
126
  description: "Check local Kward configuration, workspace, auth hints, and writable directories.",
117
127
  examples: ["kward doctor", "kward --working-directory ~/code/project doctor"]
118
128
  },
129
+ "edit" => {
130
+ usage: "kward edit <filename>",
131
+ description: "Open a file in the integrated editor.",
132
+ examples: ["kward edit lib/main.rb", "kward edit ~/notes/todo.md"]
133
+ },
119
134
  "sysprompt" => {
120
135
  usage: "kward sysprompt [--raw]",
121
136
  description: "Inspect the effective system prompt for a new conversation in the current workspace.",
@@ -126,6 +141,11 @@ module Kward
126
141
  description: "Export local token telemetry as CSV.",
127
142
  examples: ["kward stats tokens today", "kward stats tokens today --bucket hour", "kward stats tokens week --output tokens.csv"]
128
143
  },
144
+ "openrouter" => {
145
+ usage: "kward openrouter refresh|list",
146
+ description: "Refresh or list cached text-capable OpenRouter models available to your API key.",
147
+ examples: ["kward openrouter refresh", "kward openrouter --refresh", "kward openrouter list"]
148
+ },
129
149
  "pan" => {
130
150
  usage: "kward pan",
131
151
  description: "Start Pan mode, a minimal LAN web UI with a prompt textarea and transcript.",
@@ -192,6 +212,17 @@ module Kward
192
212
  @prompt_delimited = true
193
213
  remaining.concat(arguments[(index + 1)..] || [])
194
214
  break
215
+ when "--experimental-workers"
216
+ @experimental_workers = true
217
+ when "--filter"
218
+ @requested_mode = "filter"
219
+ when "--mode"
220
+ index += 1
221
+ raise ArgumentError, "Missing value for --mode" if index >= arguments.length
222
+
223
+ @requested_mode = normalized_execution_mode(arguments[index])
224
+ when /\A--mode=(.*)\z/
225
+ @requested_mode = normalized_execution_mode(Regexp.last_match(1))
195
226
  when "--working-directory"
196
227
  index += 1
197
228
  raise ArgumentError, "Missing value for --working-directory" if index >= arguments.length
@@ -207,6 +238,14 @@ module Kward
207
238
  remaining
208
239
  end
209
240
 
241
+ def normalized_execution_mode(value)
242
+ mode = value.to_s.strip.downcase
243
+ modes = ["auto", "chat", "oneshot", "filter"]
244
+ raise ArgumentError, "Unknown mode: #{value}. Expected one of: #{modes.join(", ")}" unless modes.include?(mode)
245
+
246
+ mode
247
+ end
248
+
210
249
  def expanded_working_directory(path)
211
250
  value = path.to_s.strip
212
251
  raise ArgumentError, "Missing value for --working-directory" if value.empty?
@@ -0,0 +1,150 @@
1
+ require "open3"
2
+
3
+ # Namespace for the Kward CLI agent runtime.
4
+ module Kward
5
+ # Command-line frontend that coordinates terminal interaction, sessions, tools, and model turns.
6
+ class CLI
7
+ # Interactive Git status and commit helpers.
8
+ module GitCommands
9
+ private
10
+
11
+ def handle_git_command(agent)
12
+ unless @prompt.respond_to?(:git_commit_message)
13
+ runtime_output("/git is available in the interactive overlay only.")
14
+ return
15
+ end
16
+
17
+ root = interactive_workspace_root(agent)
18
+ git_root = git_repository_root(root)
19
+ if git_root.empty?
20
+ runtime_output("Not a Git repository: #{root}")
21
+ return
22
+ end
23
+
24
+ status = git_status_lines(git_root)
25
+ message = @prompt.git_commit_message(status) do |action|
26
+ result = handle_git_prompt_action(git_root, status, action)
27
+ status = result.is_a?(Hash) && result.key?(:status_lines) ? result[:status_lines] : result
28
+ result
29
+ end
30
+ return if message.nil?
31
+
32
+ result = run_busy_local_command_and_requeue(activity: "committing") do
33
+ git_commit(git_root, message)
34
+ end
35
+ print_git_commit_result(result)
36
+ end
37
+
38
+ def git_repository_root(root)
39
+ output, status = Open3.capture2e("git", "rev-parse", "--show-toplevel", chdir: root.to_s)
40
+ return "" unless status.success?
41
+
42
+ output.lines.first.to_s.strip
43
+ rescue StandardError
44
+ ""
45
+ end
46
+
47
+ def git_status_lines(root)
48
+ output, status = Open3.capture2e("git", "status", "--short", "--untracked-files=normal", chdir: root.to_s)
49
+ return ["Unable to read Git status: #{output.strip}"] unless status.success?
50
+
51
+ output.lines.map(&:chomp)
52
+ rescue StandardError => e
53
+ ["Unable to read Git status: #{e.message}"]
54
+ end
55
+
56
+ def handle_git_prompt_action(root, current_status, action)
57
+ case action[:action]
58
+ when :toggle_stage
59
+ toggle_git_stage(root, current_status[action[:index].to_i])
60
+ git_status_lines(root)
61
+ when :open_diff
62
+ status_line = current_status[action[:index].to_i]
63
+ { status_lines: git_status_lines(root), diff: git_diff_view(root, status_line) }
64
+ else
65
+ git_status_lines(root)
66
+ end
67
+ end
68
+
69
+ def git_diff_view(root, status_line)
70
+ entry = parse_git_status_line(status_line)
71
+ return { path: "Git diff", content: "Unable to read Git status entry.\n" } if entry.nil?
72
+
73
+ output = entry[:untracked] ? git_untracked_file_diff(root, entry[:path]) : git_tracked_file_diff(root, entry[:path])
74
+ { path: entry[:path], content: output.empty? ? "No diff for #{entry[:path]}\n" : output }
75
+ end
76
+
77
+ def git_tracked_file_diff(root, path)
78
+ output, status = Open3.capture2e("git", "diff", "HEAD", "--", path, chdir: root.to_s)
79
+ status.success? ? output : "Unable to read diff for #{path}:\n#{output}"
80
+ rescue StandardError => e
81
+ "Unable to read diff for #{path}: #{e.message}\n"
82
+ end
83
+
84
+ def git_untracked_file_diff(root, path)
85
+ full_path = File.expand_path(path, root.to_s)
86
+ content = File.file?(full_path) ? File.read(full_path) : ""
87
+ lines = ["diff --git a/#{path} b/#{path}", "new file mode 100644", "--- /dev/null", "+++ b/#{path}", "@@ -0,0 +1,#{content.lines.length} @@"]
88
+ lines.concat(content.lines(chomp: true).map { |line| "+#{line}" })
89
+ lines << "\" if !content.empty? && !content.end_with?("\n")
90
+ lines.join("\n") + "\n"
91
+ rescue StandardError => e
92
+ "Unable to read diff for #{path}: #{e.message}\n"
93
+ end
94
+
95
+ def toggle_git_stage(root, status_line)
96
+ entry = parse_git_status_line(status_line)
97
+ return if entry.nil?
98
+
99
+ command = entry[:staged] ? ["restore", "--staged", "--", entry[:path]] : ["add", "--", entry[:path]]
100
+ Open3.capture2e("git", *command, chdir: root.to_s)
101
+ rescue StandardError
102
+ nil
103
+ end
104
+
105
+ def parse_git_status_line(line)
106
+ text = line.to_s
107
+ return nil if text.length < 4
108
+
109
+ status = text[0, 2]
110
+ path = text[3..].to_s
111
+ path = path.split(" -> ", 2).last if status.include?("R") || status.include?("C")
112
+ return nil if path.empty?
113
+
114
+ { path: path, staged: status[0] != " " && status[0] != "?", untracked: status == "??" }
115
+ end
116
+
117
+ def git_commit(root, message)
118
+ return git_commit_staged(root, message) if git_staged_changes?(root)
119
+
120
+ add_output, add_status = Open3.capture2e("git", "add", "--all", chdir: root.to_s)
121
+ return { success: false, output: add_output } unless add_status.success?
122
+
123
+ git_commit_staged(root, message)
124
+ rescue StandardError => e
125
+ { success: false, output: e.message }
126
+ end
127
+
128
+ def git_staged_changes?(root)
129
+ _output, status = Open3.capture2e("git", "diff", "--cached", "--quiet", chdir: root.to_s)
130
+ !status.success?
131
+ rescue StandardError
132
+ false
133
+ end
134
+
135
+ def git_commit_staged(root, message)
136
+ commit_output, commit_status = Open3.capture2e("git", "commit", "-m", message.to_s, chdir: root.to_s)
137
+ { success: commit_status.success?, output: commit_output }
138
+ rescue StandardError => e
139
+ { success: false, output: e.message }
140
+ end
141
+
142
+ def print_git_commit_result(result)
143
+ output = result[:output].to_s.strip
144
+ output = result[:success] ? "Commit created." : "Git commit failed." if output.empty?
145
+ status = result[:success] ? "Git commit succeeded" : "Git commit failed"
146
+ runtime_output("#{status}\n#{output}")
147
+ end
148
+ end
149
+ end
150
+ end