kward 0.71.0 → 0.73.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/ci.yml +30 -0
  3. data/CHANGELOG.md +93 -0
  4. data/Gemfile.lock +2 -2
  5. data/README.md +4 -0
  6. data/doc/agent-tools.md +15 -6
  7. data/doc/authentication.md +22 -1
  8. data/doc/code-search.md +42 -2
  9. data/doc/configuration.md +106 -3
  10. data/doc/context-budgeting.md +136 -0
  11. data/doc/context-tools.md +16 -3
  12. data/doc/editor.md +415 -0
  13. data/doc/extensibility.md +16 -7
  14. data/doc/files.md +100 -0
  15. data/doc/getting-started.md +25 -18
  16. data/doc/git.md +123 -0
  17. data/doc/memory.md +24 -4
  18. data/doc/personas.md +34 -5
  19. data/doc/plugins.md +72 -1
  20. data/doc/releasing.md +37 -9
  21. data/doc/rpc.md +75 -5
  22. data/doc/session-management.md +35 -1
  23. data/doc/shell.md +332 -0
  24. data/doc/tabs.md +122 -0
  25. data/doc/troubleshooting.md +77 -1
  26. data/doc/usage.md +79 -7
  27. data/doc/web-search.md +12 -4
  28. data/doc/workspace-tools.md +51 -12
  29. data/examples/plugins/space_invaders.rb +377 -0
  30. data/lib/kward/agent.rb +1 -1
  31. data/lib/kward/ansi.rb +62 -23
  32. data/lib/kward/cli/commands.rb +33 -2
  33. data/lib/kward/cli/git.rb +150 -0
  34. data/lib/kward/cli/interactive_turn.rb +73 -9
  35. data/lib/kward/cli/plugins.rb +54 -4
  36. data/lib/kward/cli/prompt_interface.rb +32 -1
  37. data/lib/kward/cli/rendering.rb +4 -1
  38. data/lib/kward/cli/runtime_helpers.rb +268 -4
  39. data/lib/kward/cli/sessions.rb +2 -2
  40. data/lib/kward/cli/settings.rb +217 -9
  41. data/lib/kward/cli/slash_commands.rb +628 -2
  42. data/lib/kward/cli/tabs.rb +725 -0
  43. data/lib/kward/cli/tool_summaries.rb +6 -0
  44. data/lib/kward/cli.rb +150 -26
  45. data/lib/kward/clipboard.rb +2 -3
  46. data/lib/kward/compactor.rb +7 -19
  47. data/lib/kward/config_files.rb +145 -1
  48. data/lib/kward/context_budget_meter.rb +44 -0
  49. data/lib/kward/conversation.rb +12 -4
  50. data/lib/kward/editor_mode.rb +25 -0
  51. data/lib/kward/ekwsh.rb +559 -0
  52. data/lib/kward/image_attachments.rb +3 -1
  53. data/lib/kward/interactive_pty_runner.rb +151 -0
  54. data/lib/kward/local_command_runner.rb +155 -0
  55. data/lib/kward/local_pty_command_runner.rb +171 -0
  56. data/lib/kward/model/context_usage.rb +2 -2
  57. data/lib/kward/model/payloads.rb +2 -5
  58. data/lib/kward/plugin_registry.rb +61 -0
  59. data/lib/kward/project_files.rb +52 -0
  60. data/lib/kward/prompt_history.rb +84 -0
  61. data/lib/kward/prompt_interface/composer_controller.rb +69 -1
  62. data/lib/kward/prompt_interface/composer_renderer.rb +109 -13
  63. data/lib/kward/prompt_interface/composer_state.rb +96 -27
  64. data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
  65. data/lib/kward/prompt_interface/editor/auto_indent.rb +510 -0
  66. data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
  67. data/lib/kward/prompt_interface/editor/controller.rb +1218 -0
  68. data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
  69. data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
  70. data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
  71. data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
  72. data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
  73. data/lib/kward/prompt_interface/editor/modes/modern.rb +354 -0
  74. data/lib/kward/prompt_interface/editor/modes/vibe.rb +1812 -0
  75. data/lib/kward/prompt_interface/editor/modes/vibe_insert_readline.rb +166 -0
  76. data/lib/kward/prompt_interface/editor/renderer.rb +244 -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 +1271 -0
  80. data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
  81. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +422 -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 +288 -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 +451 -57
  90. data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
  91. data/lib/kward/prompt_interface/project_browser.rb +524 -0
  92. data/lib/kward/prompt_interface/question_prompt.rb +99 -56
  93. data/lib/kward/prompt_interface/runtime_state.rb +43 -0
  94. data/lib/kward/prompt_interface/screen.rb +19 -3
  95. data/lib/kward/prompt_interface/selection_prompt.rb +10 -19
  96. data/lib/kward/prompt_interface/slash_overlay.rb +2 -0
  97. data/lib/kward/prompt_interface/stream_state.rb +7 -0
  98. data/lib/kward/prompt_interface/transcript_buffer.rb +6 -0
  99. data/lib/kward/prompt_interface.rb +366 -222
  100. data/lib/kward/prompts/commands.rb +9 -0
  101. data/lib/kward/prompts.rb +2 -0
  102. data/lib/kward/rpc/memory_methods.rb +83 -0
  103. data/lib/kward/rpc/server.rb +169 -83
  104. data/lib/kward/rpc/session_manager.rb +45 -121
  105. data/lib/kward/rpc/session_tree_rows.rb +9 -115
  106. data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
  107. data/lib/kward/rpc/tool_metadata.rb +11 -0
  108. data/lib/kward/rpc/transcript_normalizer.rb +4 -39
  109. data/lib/kward/scratchpad_runner.rb +56 -0
  110. data/lib/kward/session_diff.rb +20 -3
  111. data/lib/kward/session_naming.rb +11 -0
  112. data/lib/kward/session_store.rb +44 -0
  113. data/lib/kward/session_tree_nodes.rb +136 -0
  114. data/lib/kward/session_tree_renderer.rb +9 -131
  115. data/lib/kward/tab_store.rb +47 -0
  116. data/lib/kward/terminal_keys.rb +84 -0
  117. data/lib/kward/terminal_sequences.rb +42 -0
  118. data/lib/kward/text_boundary.rb +25 -0
  119. data/lib/kward/tools/context_budget_stats.rb +54 -0
  120. data/lib/kward/tools/context_for_task.rb +204 -0
  121. data/lib/kward/tools/read_file.rb +8 -4
  122. data/lib/kward/tools/registry.rb +62 -16
  123. data/lib/kward/tools/tool_call.rb +10 -0
  124. data/lib/kward/version.rb +1 -1
  125. data/lib/kward/workers/git_guard.rb +93 -0
  126. data/lib/kward/workers/job.rb +99 -0
  127. data/lib/kward/workers/live_view.rb +49 -0
  128. data/lib/kward/workers/manager.rb +288 -0
  129. data/lib/kward/workers/queue_runner.rb +166 -0
  130. data/lib/kward/workers/queue_store.rb +112 -0
  131. data/lib/kward/workers/store.rb +72 -0
  132. data/lib/kward/workers/tool_policy.rb +23 -0
  133. data/lib/kward/workers/worker.rb +82 -0
  134. data/lib/kward/workers/write_lock.rb +38 -0
  135. data/lib/kward/workers.rb +10 -0
  136. data/lib/kward/workspace.rb +125 -87
  137. data/templates/default/fulldoc/html/css/kward.css +140 -36
  138. data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
  139. data/templates/default/fulldoc/html/setup.rb +1 -0
  140. data/templates/default/kward_navigation.rb +12 -1
  141. data/templates/default/layout/html/layout.erb +23 -34
  142. data/templates/default/layout/html/setup.rb +6 -0
  143. metadata +67 -1
@@ -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
  #
data/lib/kward/ansi.rb CHANGED
@@ -1,11 +1,13 @@
1
1
  # Namespace for the Kward CLI agent runtime.
2
2
  module Kward
3
- # ANSI color and terminal capability helpers.
3
+ # ANSI SGR styling and terminal-text helpers.
4
+ #
5
+ # Terminal control output sequences live in `TerminalSequences`, and input key
6
+ # sequences live in `TerminalKeys`. This module owns text-level concerns:
7
+ # colorizing strings, stripping/sanitizing escape sequences, visible wrapping,
8
+ # and lightweight Markdown rendering for terminal output.
4
9
  module ANSI
5
- ESCAPE_PATTERN = /\e\[[0-9;?]*[ -\/]*[@-~]/.freeze
6
10
  SGR_PATTERN = /\e\[[0-9;:]*m/.freeze
7
- OSC_PATTERN = /\e\][^\a]*(?:\a|\e\\)/m.freeze
8
- STRING_ESCAPE_PATTERN = /\e[P_X^][\s\S]*?\e\\/m.freeze
9
11
  STYLES = {
10
12
  reset: 0,
11
13
  bold: 1,
@@ -52,13 +54,24 @@ module Kward
52
54
  end
53
55
 
54
56
  def strip(text)
55
- text.to_s.gsub(ESCAPE_PATTERN, "")
57
+ strip_control_sequences(text)
56
58
  end
57
59
 
60
+ # Removes terminal escape/control sequences while preserving visible text.
61
+ def strip_control_sequences(text)
62
+ scan_escape_tokens(text).each_with_object(+"") do |token, stripped|
63
+ stripped << token[:text] unless token[:escape]
64
+ end
65
+ end
66
+
67
+ # Drops unsafe terminal controls from transcript text while preserving SGR color.
58
68
  def sanitize_transcript(text)
59
- string = text.to_s.gsub(OSC_PATTERN, "").gsub(STRING_ESCAPE_PATTERN, "")
60
- string.gsub(/\e(?:\[[0-9;:?]*[ -\/]*[@-~]|.)/m) do |sequence|
61
- sequence.match?(SGR_PATTERN) ? sequence : ""
69
+ scan_escape_tokens(text).each_with_object(+"") do |token, sanitized|
70
+ if token[:escape]
71
+ sanitized << token[:text] if token[:text].match?(SGR_PATTERN)
72
+ else
73
+ sanitized << token[:text]
74
+ end
62
75
  end
63
76
  end
64
77
 
@@ -67,28 +80,27 @@ module Kward
67
80
  rows = []
68
81
  current = +""
69
82
  visible_width = 0
70
- string = text.to_s
71
- index = 0
72
83
 
73
- while index < string.length
74
- if string[index] == "\e" && (match = string[index..].match(/\A\e\[[0-9;:]*m/))
84
+ scan_escape_tokens(text).each do |token|
85
+ if token[:escape]
86
+ next unless token[:text].match?(SGR_PATTERN)
87
+
75
88
  if current.empty? && rows.any?
76
- rows[-1] << match[0]
89
+ rows[-1] << token[:text]
77
90
  else
78
- current << match[0]
91
+ current << token[:text]
79
92
  end
80
- index += match[0].length
81
93
  next
82
94
  end
83
95
 
84
- char = string[index]
85
- current << char
86
- visible_width += 1
87
- index += 1
88
- if visible_width >= line_width
89
- rows << current
90
- current = +""
91
- visible_width = 0
96
+ token[:text].each_char do |char|
97
+ current << char
98
+ visible_width += 1
99
+ if visible_width >= line_width
100
+ rows << current
101
+ current = +""
102
+ visible_width = 0
103
+ end
92
104
  end
93
105
  end
94
106
 
@@ -96,6 +108,33 @@ module Kward
96
108
  rows
97
109
  end
98
110
 
111
+ # Splits text into visible chunks and terminal escape sequence chunks.
112
+ def scan_escape_tokens(text)
113
+ string = text.to_s
114
+ tokens = []
115
+ index = 0
116
+ while index < string.length
117
+ if string[index] == "\e" && (escape = escape_sequence_at(string, index))
118
+ tokens << { text: escape, escape: true }
119
+ index += escape.length
120
+ next
121
+ end
122
+
123
+ next_escape = string.index("\e", index) || string.length
124
+ tokens << { text: string[index...next_escape], escape: false } if next_escape > index
125
+ index = next_escape
126
+ end
127
+ tokens
128
+ end
129
+
130
+ def escape_sequence_at(string, index)
131
+ chunk = string[index..]
132
+ chunk.match(/\A\e\][^\a]*(?:\a|\e\\)/m)&.[](0) ||
133
+ chunk.match(/\A\e[P_X^][\s\S]*?\e\\/m)&.[](0) ||
134
+ chunk.match(/\A\e\[[0-9;:?]*[ -\/]*[@-~]/)&.[](0) ||
135
+ chunk[0, 2]
136
+ end
137
+
99
138
  def markdown(text, enabled: enabled?)
100
139
  string = text.to_s
101
140
  lines = string.lines(chomp: true)
@@ -48,10 +48,12 @@ 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
56
58
  #{command.call("kward openrouter refresh")} Refresh cached OpenRouter models
57
59
  #{command.call("kward pan")} Start Pan mode web UI
@@ -64,6 +66,7 @@ module Kward
64
66
  #{command.call("auth status|logout")} Show or clear saved credentials
65
67
  #{command.call("init")} Install starter prompts and PRINCIPLES.md
66
68
  #{command.call("doctor")} Check local Kward setup
69
+ #{command.call("edit")} #{option.call("<filename>")} Open a file in the integrated editor
67
70
  #{command.call("sysprompt")} [--raw] Inspect the effective system prompt
68
71
  #{command.call("stats tokens")} [range] [options] Export local token telemetry as CSV
69
72
  #{command.call("openrouter refresh|list")} Refresh or list cached OpenRouter models
@@ -72,14 +75,18 @@ module Kward
72
75
 
73
76
  #{heading.call("Options")}
74
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
75
80
  #{option.call("--help")}, #{option.call("-h")} Show this help
76
81
  #{option.call("--version")}, #{option.call("-v")} Show the installed version
77
82
 
78
83
  #{heading.call("Examples")}
79
84
  #{command.call("kward")}
80
- #{command.call("kward")} #{option.call('"Review this diff"')}
81
- #{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"')}
82
88
  #{command.call("kward login openrouter")}
89
+ #{command.call("kward edit lib/main.rb")}
83
90
  #{command.call("kward openrouter refresh")}
84
91
  #{command.call("kward stats tokens today --bucket hour")}
85
92
 
@@ -119,6 +126,11 @@ module Kward
119
126
  description: "Check local Kward configuration, workspace, auth hints, and writable directories.",
120
127
  examples: ["kward doctor", "kward --working-directory ~/code/project doctor"]
121
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
+ },
122
134
  "sysprompt" => {
123
135
  usage: "kward sysprompt [--raw]",
124
136
  description: "Inspect the effective system prompt for a new conversation in the current workspace.",
@@ -200,6 +212,17 @@ module Kward
200
212
  @prompt_delimited = true
201
213
  remaining.concat(arguments[(index + 1)..] || [])
202
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))
203
226
  when "--working-directory"
204
227
  index += 1
205
228
  raise ArgumentError, "Missing value for --working-directory" if index >= arguments.length
@@ -215,6 +238,14 @@ module Kward
215
238
  remaining
216
239
  end
217
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
+
218
249
  def expanded_working_directory(path)
219
250
  value = path.to_s.strip
220
251
  raise ArgumentError, "Missing value for --working-directory" if value.empty?