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.
- checksums.yaml +4 -4
- data/.github/workflows/pages.yml +1 -1
- data/CHANGELOG.md +89 -3
- data/Gemfile +2 -0
- data/Gemfile.lock +90 -2
- data/README.md +34 -6
- data/Rakefile +96 -0
- data/doc/agent-tools.md +52 -0
- data/doc/api.md +92 -0
- data/doc/authentication.md +58 -23
- data/doc/code-search.md +42 -2
- data/doc/configuration.md +102 -13
- data/doc/context-budgeting.md +136 -0
- data/doc/context-tools.md +83 -0
- data/doc/editor.md +394 -0
- data/doc/extensibility.md +16 -7
- data/doc/files.md +100 -0
- data/doc/getting-started.md +25 -18
- data/doc/git.md +122 -0
- data/doc/memory.md +24 -4
- data/doc/personas.md +34 -5
- data/doc/plugins.md +74 -3
- data/doc/releasing.md +45 -8
- data/doc/rpc.md +77 -15
- data/doc/session-management.md +254 -0
- data/doc/shell.md +286 -0
- data/doc/tabs.md +122 -0
- data/doc/troubleshooting.md +77 -1
- data/doc/usage.md +60 -15
- data/doc/web-search.md +12 -4
- data/doc/workspace-tools.md +144 -0
- data/examples/plugins/space_invaders.rb +377 -0
- data/lib/kward/agent.rb +1 -1
- data/lib/kward/cli/commands.rb +41 -2
- data/lib/kward/cli/git.rb +150 -0
- data/lib/kward/cli/interactive_turn.rb +73 -9
- data/lib/kward/cli/openrouter_commands.rb +55 -0
- data/lib/kward/cli/plugins.rb +54 -4
- data/lib/kward/cli/prompt_interface.rb +111 -6
- data/lib/kward/cli/rendering.rb +11 -6
- data/lib/kward/cli/runtime_helpers.rb +133 -3
- data/lib/kward/cli/sessions.rb +262 -13
- data/lib/kward/cli/settings.rb +216 -37
- data/lib/kward/cli/slash_commands.rb +439 -8
- data/lib/kward/cli/tabs.rb +695 -0
- data/lib/kward/cli.rb +171 -26
- data/lib/kward/compactor.rb +4 -1
- data/lib/kward/config_files.rb +125 -5
- data/lib/kward/context_budget_meter.rb +44 -0
- data/lib/kward/conversation.rb +59 -22
- data/lib/kward/editor_mode.rb +25 -0
- data/lib/kward/ekwsh.rb +362 -0
- data/lib/kward/model/client.rb +37 -50
- data/lib/kward/model/context_usage.rb +13 -6
- data/lib/kward/model/model_info.rb +92 -16
- data/lib/kward/model/payloads.rb +2 -0
- data/lib/kward/openrouter_model_cache.rb +120 -0
- data/lib/kward/plugin_registry.rb +108 -1
- data/lib/kward/project_files.rb +52 -0
- data/lib/kward/prompt_history.rb +82 -0
- data/lib/kward/prompt_interface/banner.rb +16 -51
- data/lib/kward/prompt_interface/composer_controller.rb +124 -83
- data/lib/kward/prompt_interface/composer_renderer.rb +116 -14
- 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 +509 -0
- data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
- data/lib/kward/prompt_interface/editor/controller.rb +1018 -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 +353 -0
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +1962 -0
- data/lib/kward/prompt_interface/editor/renderer.rb +243 -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 +1249 -0
- data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +420 -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 +299 -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 +416 -43
- data/lib/kward/prompt_interface/layout.rb +2 -2
- 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/prompt_renderer.rb +32 -13
- data/lib/kward/prompt_interface/question_prompt.rb +122 -82
- data/lib/kward/prompt_interface/runtime_state.rb +49 -1
- data/lib/kward/prompt_interface/screen.rb +17 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +511 -58
- data/lib/kward/prompt_interface/stream_state.rb +7 -0
- data/lib/kward/prompt_interface/transcript_buffer.rb +13 -16
- data/lib/kward/prompt_interface/transcript_renderer.rb +3 -3
- data/lib/kward/prompt_interface.rb +307 -35
- data/lib/kward/prompts/commands.rb +7 -1
- data/lib/kward/prompts.rb +4 -2
- data/lib/kward/rpc/server.rb +45 -11
- data/lib/kward/rpc/session_manager.rb +52 -53
- data/lib/kward/rpc/session_tree_rows.rb +9 -115
- data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
- data/lib/kward/session_store.rb +67 -4
- 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/telemetry/logger.rb +5 -3
- data/lib/kward/text_boundary.rb +25 -0
- data/lib/kward/tool_output_compactor.rb +127 -0
- data/lib/kward/tools/base.rb +8 -2
- data/lib/kward/tools/context_budget_stats.rb +54 -0
- data/lib/kward/tools/context_for_task.rb +202 -0
- data/lib/kward/tools/read_file.rb +8 -4
- data/lib/kward/tools/registry.rb +92 -15
- data/lib/kward/tools/retrieve_tool_output.rb +71 -0
- data/lib/kward/tools/search/web.rb +2 -2
- data/lib/kward/tools/summarize_file_structure.rb +29 -0
- data/lib/kward/tools/tool_call.rb +12 -0
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workers/git_guard.rb +68 -0
- data/lib/kward/workers/live_view.rb +49 -0
- data/lib/kward/workers/manager.rb +288 -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 +7 -0
- data/lib/kward/workspace.rb +154 -12
- data/templates/default/fulldoc/html/css/kward.css +362 -42
- data/templates/default/fulldoc/html/full_list.erb +107 -0
- data/templates/default/fulldoc/html/js/kward.js +161 -2
- data/templates/default/fulldoc/html/setup.rb +8 -0
- data/templates/default/kward_navigation.rb +102 -0
- data/templates/default/layout/html/layout.erb +43 -10
- data/templates/default/layout/html/setup.rb +39 -38
- metadata +65 -3
- data/lib/kward/resources/avatar_kward_logo.rb +0 -50
- 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
data/lib/kward/cli/commands.rb
CHANGED
|
@@ -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('"
|
|
79
|
-
#{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"')}
|
|
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
|