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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +30 -0
- data/CHANGELOG.md +93 -0
- data/Gemfile.lock +2 -2
- data/README.md +4 -0
- data/doc/agent-tools.md +15 -6
- data/doc/authentication.md +22 -1
- data/doc/code-search.md +42 -2
- data/doc/configuration.md +106 -3
- data/doc/context-budgeting.md +136 -0
- data/doc/context-tools.md +16 -3
- data/doc/editor.md +415 -0
- data/doc/extensibility.md +16 -7
- data/doc/files.md +100 -0
- data/doc/getting-started.md +25 -18
- data/doc/git.md +123 -0
- data/doc/memory.md +24 -4
- data/doc/personas.md +34 -5
- data/doc/plugins.md +72 -1
- data/doc/releasing.md +37 -9
- data/doc/rpc.md +75 -5
- data/doc/session-management.md +35 -1
- data/doc/shell.md +332 -0
- data/doc/tabs.md +122 -0
- data/doc/troubleshooting.md +77 -1
- data/doc/usage.md +79 -7
- data/doc/web-search.md +12 -4
- data/doc/workspace-tools.md +51 -12
- data/examples/plugins/space_invaders.rb +377 -0
- data/lib/kward/agent.rb +1 -1
- data/lib/kward/ansi.rb +62 -23
- data/lib/kward/cli/commands.rb +33 -2
- data/lib/kward/cli/git.rb +150 -0
- data/lib/kward/cli/interactive_turn.rb +73 -9
- data/lib/kward/cli/plugins.rb +54 -4
- data/lib/kward/cli/prompt_interface.rb +32 -1
- data/lib/kward/cli/rendering.rb +4 -1
- data/lib/kward/cli/runtime_helpers.rb +268 -4
- data/lib/kward/cli/sessions.rb +2 -2
- data/lib/kward/cli/settings.rb +217 -9
- data/lib/kward/cli/slash_commands.rb +628 -2
- data/lib/kward/cli/tabs.rb +725 -0
- data/lib/kward/cli/tool_summaries.rb +6 -0
- data/lib/kward/cli.rb +150 -26
- data/lib/kward/clipboard.rb +2 -3
- data/lib/kward/compactor.rb +7 -19
- data/lib/kward/config_files.rb +145 -1
- data/lib/kward/context_budget_meter.rb +44 -0
- data/lib/kward/conversation.rb +12 -4
- data/lib/kward/editor_mode.rb +25 -0
- data/lib/kward/ekwsh.rb +559 -0
- data/lib/kward/image_attachments.rb +3 -1
- data/lib/kward/interactive_pty_runner.rb +151 -0
- data/lib/kward/local_command_runner.rb +155 -0
- data/lib/kward/local_pty_command_runner.rb +171 -0
- data/lib/kward/model/context_usage.rb +2 -2
- data/lib/kward/model/payloads.rb +2 -5
- data/lib/kward/plugin_registry.rb +61 -0
- data/lib/kward/project_files.rb +52 -0
- data/lib/kward/prompt_history.rb +84 -0
- data/lib/kward/prompt_interface/composer_controller.rb +69 -1
- data/lib/kward/prompt_interface/composer_renderer.rb +109 -13
- data/lib/kward/prompt_interface/composer_state.rb +96 -27
- data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
- data/lib/kward/prompt_interface/editor/auto_indent.rb +510 -0
- data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
- data/lib/kward/prompt_interface/editor/controller.rb +1218 -0
- data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
- data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
- data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
- data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
- data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
- data/lib/kward/prompt_interface/editor/modes/modern.rb +354 -0
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +1812 -0
- data/lib/kward/prompt_interface/editor/modes/vibe_insert_readline.rb +166 -0
- data/lib/kward/prompt_interface/editor/renderer.rb +244 -0
- data/lib/kward/prompt_interface/editor/search.rb +76 -0
- data/lib/kward/prompt_interface/editor/selections.rb +120 -0
- data/lib/kward/prompt_interface/editor/state.rb +1271 -0
- data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +422 -0
- data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
- data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
- data/lib/kward/prompt_interface/file_overlay.rb +211 -0
- data/lib/kward/prompt_interface/git_prompt.rb +288 -0
- data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
- data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
- data/lib/kward/prompt_interface/interactive/state.rb +62 -0
- data/lib/kward/prompt_interface/key_handler.rb +451 -57
- data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
- data/lib/kward/prompt_interface/project_browser.rb +524 -0
- data/lib/kward/prompt_interface/question_prompt.rb +99 -56
- data/lib/kward/prompt_interface/runtime_state.rb +43 -0
- data/lib/kward/prompt_interface/screen.rb +19 -3
- data/lib/kward/prompt_interface/selection_prompt.rb +10 -19
- data/lib/kward/prompt_interface/slash_overlay.rb +2 -0
- data/lib/kward/prompt_interface/stream_state.rb +7 -0
- data/lib/kward/prompt_interface/transcript_buffer.rb +6 -0
- data/lib/kward/prompt_interface.rb +366 -222
- data/lib/kward/prompts/commands.rb +9 -0
- data/lib/kward/prompts.rb +2 -0
- data/lib/kward/rpc/memory_methods.rb +83 -0
- data/lib/kward/rpc/server.rb +169 -83
- data/lib/kward/rpc/session_manager.rb +45 -121
- data/lib/kward/rpc/session_tree_rows.rb +9 -115
- data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
- data/lib/kward/rpc/tool_metadata.rb +11 -0
- data/lib/kward/rpc/transcript_normalizer.rb +4 -39
- data/lib/kward/scratchpad_runner.rb +56 -0
- data/lib/kward/session_diff.rb +20 -3
- data/lib/kward/session_naming.rb +11 -0
- data/lib/kward/session_store.rb +44 -0
- data/lib/kward/session_tree_nodes.rb +136 -0
- data/lib/kward/session_tree_renderer.rb +9 -131
- data/lib/kward/tab_store.rb +47 -0
- data/lib/kward/terminal_keys.rb +84 -0
- data/lib/kward/terminal_sequences.rb +42 -0
- data/lib/kward/text_boundary.rb +25 -0
- data/lib/kward/tools/context_budget_stats.rb +54 -0
- data/lib/kward/tools/context_for_task.rb +204 -0
- data/lib/kward/tools/read_file.rb +8 -4
- data/lib/kward/tools/registry.rb +62 -16
- data/lib/kward/tools/tool_call.rb +10 -0
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workers/git_guard.rb +93 -0
- data/lib/kward/workers/job.rb +99 -0
- data/lib/kward/workers/live_view.rb +49 -0
- data/lib/kward/workers/manager.rb +288 -0
- data/lib/kward/workers/queue_runner.rb +166 -0
- data/lib/kward/workers/queue_store.rb +112 -0
- data/lib/kward/workers/store.rb +72 -0
- data/lib/kward/workers/tool_policy.rb +23 -0
- data/lib/kward/workers/worker.rb +82 -0
- data/lib/kward/workers/write_lock.rb +38 -0
- data/lib/kward/workers.rb +10 -0
- data/lib/kward/workspace.rb +125 -87
- data/templates/default/fulldoc/html/css/kward.css +140 -36
- data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
- data/templates/default/fulldoc/html/setup.rb +1 -0
- data/templates/default/kward_navigation.rb +12 -1
- data/templates/default/layout/html/layout.erb +23 -34
- data/templates/default/layout/html/setup.rb +6 -0
- 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
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
|
|
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
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
74
|
-
if
|
|
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] <<
|
|
89
|
+
rows[-1] << token[:text]
|
|
77
90
|
else
|
|
78
|
-
current <<
|
|
91
|
+
current << token[:text]
|
|
79
92
|
end
|
|
80
|
-
index += match[0].length
|
|
81
93
|
next
|
|
82
94
|
end
|
|
83
95
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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)
|
data/lib/kward/cli/commands.rb
CHANGED
|
@@ -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('"
|
|
81
|
-
#{command.call("git diff | kward")} #{option.call('"
|
|
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?
|