kward 0.71.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 (116) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -1
  3. data/Gemfile.lock +2 -2
  4. data/README.md +4 -0
  5. data/doc/agent-tools.md +15 -6
  6. data/doc/authentication.md +22 -1
  7. data/doc/code-search.md +42 -2
  8. data/doc/configuration.md +106 -3
  9. data/doc/context-budgeting.md +136 -0
  10. data/doc/context-tools.md +16 -3
  11. data/doc/editor.md +394 -0
  12. data/doc/extensibility.md +16 -7
  13. data/doc/files.md +100 -0
  14. data/doc/getting-started.md +25 -18
  15. data/doc/git.md +122 -0
  16. data/doc/memory.md +24 -4
  17. data/doc/personas.md +34 -5
  18. data/doc/plugins.md +72 -1
  19. data/doc/releasing.md +37 -9
  20. data/doc/rpc.md +74 -4
  21. data/doc/session-management.md +35 -1
  22. data/doc/shell.md +286 -0
  23. data/doc/tabs.md +122 -0
  24. data/doc/troubleshooting.md +77 -1
  25. data/doc/usage.md +53 -7
  26. data/doc/web-search.md +12 -4
  27. data/doc/workspace-tools.md +51 -12
  28. data/examples/plugins/space_invaders.rb +377 -0
  29. data/lib/kward/agent.rb +1 -1
  30. data/lib/kward/cli/commands.rb +33 -2
  31. data/lib/kward/cli/git.rb +150 -0
  32. data/lib/kward/cli/interactive_turn.rb +73 -9
  33. data/lib/kward/cli/plugins.rb +54 -4
  34. data/lib/kward/cli/prompt_interface.rb +32 -1
  35. data/lib/kward/cli/runtime_helpers.rb +133 -3
  36. data/lib/kward/cli/sessions.rb +2 -2
  37. data/lib/kward/cli/settings.rb +218 -9
  38. data/lib/kward/cli/slash_commands.rb +415 -2
  39. data/lib/kward/cli/tabs.rb +695 -0
  40. data/lib/kward/cli.rb +158 -26
  41. data/lib/kward/config_files.rb +123 -1
  42. data/lib/kward/context_budget_meter.rb +44 -0
  43. data/lib/kward/conversation.rb +12 -4
  44. data/lib/kward/editor_mode.rb +25 -0
  45. data/lib/kward/ekwsh.rb +362 -0
  46. data/lib/kward/plugin_registry.rb +61 -0
  47. data/lib/kward/project_files.rb +52 -0
  48. data/lib/kward/prompt_history.rb +82 -0
  49. data/lib/kward/prompt_interface/composer_controller.rb +69 -1
  50. data/lib/kward/prompt_interface/composer_renderer.rb +109 -13
  51. data/lib/kward/prompt_interface/composer_state.rb +96 -27
  52. data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
  53. data/lib/kward/prompt_interface/editor/auto_indent.rb +509 -0
  54. data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
  55. data/lib/kward/prompt_interface/editor/controller.rb +1018 -0
  56. data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
  57. data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
  58. data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
  59. data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
  60. data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
  61. data/lib/kward/prompt_interface/editor/modes/modern.rb +353 -0
  62. data/lib/kward/prompt_interface/editor/modes/vibe.rb +1962 -0
  63. data/lib/kward/prompt_interface/editor/renderer.rb +243 -0
  64. data/lib/kward/prompt_interface/editor/search.rb +76 -0
  65. data/lib/kward/prompt_interface/editor/selections.rb +120 -0
  66. data/lib/kward/prompt_interface/editor/state.rb +1249 -0
  67. data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
  68. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +420 -0
  69. data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
  70. data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
  71. data/lib/kward/prompt_interface/file_overlay.rb +211 -0
  72. data/lib/kward/prompt_interface/git_prompt.rb +299 -0
  73. data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
  74. data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
  75. data/lib/kward/prompt_interface/interactive/state.rb +62 -0
  76. data/lib/kward/prompt_interface/key_handler.rb +387 -35
  77. data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
  78. data/lib/kward/prompt_interface/project_browser.rb +524 -0
  79. data/lib/kward/prompt_interface/question_prompt.rb +98 -50
  80. data/lib/kward/prompt_interface/runtime_state.rb +43 -0
  81. data/lib/kward/prompt_interface/screen.rb +16 -0
  82. data/lib/kward/prompt_interface/selection_prompt.rb +7 -13
  83. data/lib/kward/prompt_interface/stream_state.rb +7 -0
  84. data/lib/kward/prompt_interface/transcript_buffer.rb +6 -0
  85. data/lib/kward/prompt_interface.rb +286 -8
  86. data/lib/kward/prompts/commands.rb +5 -0
  87. data/lib/kward/prompts.rb +2 -0
  88. data/lib/kward/rpc/server.rb +42 -3
  89. data/lib/kward/rpc/session_manager.rb +35 -47
  90. data/lib/kward/rpc/session_tree_rows.rb +9 -115
  91. data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
  92. data/lib/kward/session_store.rb +44 -0
  93. data/lib/kward/session_tree_nodes.rb +136 -0
  94. data/lib/kward/session_tree_renderer.rb +9 -131
  95. data/lib/kward/tab_store.rb +47 -0
  96. data/lib/kward/text_boundary.rb +25 -0
  97. data/lib/kward/tools/context_budget_stats.rb +54 -0
  98. data/lib/kward/tools/context_for_task.rb +202 -0
  99. data/lib/kward/tools/read_file.rb +8 -4
  100. data/lib/kward/tools/registry.rb +62 -16
  101. data/lib/kward/tools/tool_call.rb +10 -0
  102. data/lib/kward/version.rb +1 -1
  103. data/lib/kward/workers/git_guard.rb +68 -0
  104. data/lib/kward/workers/live_view.rb +49 -0
  105. data/lib/kward/workers/manager.rb +288 -0
  106. data/lib/kward/workers/store.rb +72 -0
  107. data/lib/kward/workers/tool_policy.rb +23 -0
  108. data/lib/kward/workers/worker.rb +82 -0
  109. data/lib/kward/workers/write_lock.rb +38 -0
  110. data/lib/kward/workers.rb +7 -0
  111. data/lib/kward/workspace.rb +110 -24
  112. data/templates/default/fulldoc/html/css/kward.css +107 -36
  113. data/templates/default/kward_navigation.rb +12 -1
  114. data/templates/default/layout/html/layout.erb +4 -2
  115. data/templates/default/layout/html/setup.rb +6 -0
  116. metadata +53 -1
@@ -0,0 +1,211 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # File-mention completion overlay behavior.
4
+ class PromptInterface
5
+ # Composer @file mention overlay support.
6
+ module FileOverlay
7
+ private
8
+
9
+ FILE_MENTION_RESULT_LIMIT = 200
10
+
11
+ def reset_file_selection
12
+ @file_selection_index = 0
13
+ end
14
+
15
+ def dismiss_file_overlay
16
+ return false unless file_overlay_visible?
17
+
18
+ if file_open_overlay_visible?
19
+ @file_open_dismissed_token = active_file_open_token
20
+ @file_editor_open_status = nil
21
+ else
22
+ @file_overlay_dismissed_token = active_file_mention_token
23
+ end
24
+ reset_file_selection
25
+ true
26
+ end
27
+
28
+ def file_overlay_visible?
29
+ file_open_overlay_visible? || file_mention_overlay_visible?
30
+ end
31
+
32
+ def file_mention_overlay_visible?
33
+ token = active_file_mention_token
34
+ return false unless token
35
+ return false if @file_overlay_dismissed_token == token
36
+
37
+ true
38
+ end
39
+
40
+ def file_open_overlay_visible?
41
+ token = active_file_open_token
42
+ return false unless token
43
+ return false if @file_open_dismissed_token == token
44
+
45
+ true
46
+ end
47
+
48
+ def active_file_mention_token
49
+ mention = active_file_mention
50
+ return nil unless mention
51
+
52
+ mention[:token]
53
+ end
54
+
55
+ def active_file_mention
56
+ active_file_token("@")
57
+ end
58
+
59
+ def active_file_open_token
60
+ open = active_file_open
61
+ return nil unless open
62
+
63
+ open[:token]
64
+ end
65
+
66
+ def active_file_open
67
+ token = active_file_token("$")
68
+ return nil unless token && token[:start].zero?
69
+
70
+ token
71
+ end
72
+
73
+ def active_file_token(prefix)
74
+ input = composer_input.to_s
75
+ cursor = composer_cursor
76
+ return nil if cursor.negative? || cursor > input.length
77
+
78
+ before_cursor = input[0...cursor].to_s
79
+ prefix_index = before_cursor.rindex(prefix)
80
+ return nil unless prefix_index
81
+ return nil if before_cursor[prefix_index...cursor].to_s.match?(/\s/)
82
+
83
+ { start: prefix_index, finish: cursor, query: before_cursor[(prefix_index + 1)...cursor].to_s, token: before_cursor[prefix_index...cursor].to_s }
84
+ end
85
+
86
+ def file_overlay_matches
87
+ token = active_file_open || active_file_mention
88
+ return [] unless token
89
+
90
+ query = token[:query].downcase
91
+ matches = []
92
+ project_file_path_entries.each do |entry|
93
+ next unless file_mention_match?(entry[:downcase], query)
94
+
95
+ matches << entry[:path]
96
+ break if matches.length >= FILE_MENTION_RESULT_LIMIT
97
+ end
98
+ matches
99
+ end
100
+
101
+ def file_mention_match?(path, query)
102
+ return true if query.empty?
103
+ return true if path.include?(query)
104
+
105
+ query_chars = query.chars
106
+ query_chars.all? do |char|
107
+ index = path.index(char)
108
+ if index
109
+ path = path[(index + 1)..].to_s
110
+ true
111
+ else
112
+ false
113
+ end
114
+ end
115
+ end
116
+
117
+ def project_file_paths
118
+ @file_mention_paths ||= discover_project_file_paths
119
+ end
120
+
121
+ def project_file_path_entries
122
+ paths = project_file_paths
123
+ return @file_mention_path_entries if @file_mention_path_entries_paths.equal?(paths) && @file_mention_path_entries
124
+
125
+ @file_mention_path_entries_paths = paths
126
+ @file_mention_path_entries = paths.map { |path| { path: path, downcase: path.downcase } }
127
+ end
128
+
129
+ def discover_project_file_paths
130
+ paths = git_project_file_paths
131
+ paths = scanned_project_file_paths if paths.empty?
132
+ paths.reject { |path| path.empty? || path.end_with?("/") }.uniq.sort
133
+ end
134
+
135
+ def git_project_file_paths
136
+ ProjectFiles.git_paths(Dir.pwd)
137
+ end
138
+
139
+ def scanned_project_file_paths
140
+ ProjectFiles.scanned_paths(Dir.pwd)
141
+ end
142
+
143
+ def selected_file_mention_path
144
+ selected_file_overlay_path if file_mention_overlay_visible?
145
+ end
146
+
147
+ def selected_file_open_path
148
+ selected_file_overlay_path if file_open_overlay_visible?
149
+ end
150
+
151
+ def selected_file_overlay_path
152
+ return nil unless file_overlay_visible?
153
+
154
+ matches = file_overlay_matches
155
+ return nil if matches.empty?
156
+
157
+ matches[[@file_selection_index, matches.length - 1].min]
158
+ end
159
+
160
+ def select_previous_file_mention
161
+ matches = file_overlay_matches
162
+ return if matches.empty?
163
+
164
+ @file_selection_index = previous_list_selection_index(@file_selection_index, matches.length)
165
+ end
166
+
167
+ def select_next_file_mention
168
+ matches = file_overlay_matches
169
+ return if matches.empty?
170
+
171
+ @file_selection_index = next_list_selection_index(@file_selection_index, matches.length)
172
+ end
173
+
174
+ def complete_selected_file_mention
175
+ mention = active_file_mention
176
+ path = selected_file_mention_path
177
+ return false unless mention && path
178
+
179
+ self.composer_input = composer_input[0...mention[:start]].to_s + "@#{path}" + composer_input[mention[:finish]..].to_s
180
+ self.composer_cursor = mention[:start] + path.length + 1
181
+ reset_file_selection
182
+ true
183
+ end
184
+
185
+ def file_overlay_rows(width, height: screen_height)
186
+ return [] unless file_overlay_visible?
187
+
188
+ matches = file_overlay_matches
189
+ if matches.empty?
190
+ return overlay_card_rows("Files", [overlay_text_line("No matching files", :muted)], width)
191
+ end
192
+
193
+ visible = visible_file_overlay_matches(matches, height: height)
194
+ start_index = visible[:start]
195
+ lines = []
196
+ lines << overlay_text_line(@file_editor_open_status, :muted) if @file_editor_open_status && file_open_overlay_visible?
197
+ lines.concat(visible[:paths].each_with_index.map do |path, offset|
198
+ index = start_index + offset
199
+ overlay_choice_line(path, selected: index == @file_selection_index)
200
+ end)
201
+ overlay_card_rows(file_open_overlay_visible? ? "Open file" : "Files", lines, width)
202
+ end
203
+
204
+ def visible_file_overlay_matches(matches, height: screen_height)
205
+ max_rows = max_overlay_list_rows(height)
206
+ start = centered_list_window_start(@file_selection_index, matches.length, max_rows)
207
+ { start: start, paths: matches[start, max_rows] || [] }
208
+ end
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,299 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Interactive terminal UI used by the CLI frontend.
4
+ class PromptInterface
5
+ # Git status/commit modal overlay support.
6
+ module GitPrompt
7
+ def git_commit_message(status_lines)
8
+ start
9
+ @mutex.synchronize do
10
+ prepare_modal_input_locked("Git>", clear_attachments: true)
11
+ @git_state = git_state_for(status_lines)
12
+ render_prompt_locked
13
+ end
14
+
15
+ loop do
16
+ key = read_key(nonblock: true)
17
+ result = nil
18
+ @mutex.synchronize do
19
+ if key.nil?
20
+ resized = handle_resize_locked
21
+ footer_refreshed = tick_footer_locked
22
+ render_prompt_locked if resized || footer_refreshed
23
+ else
24
+ result = handle_git_key(key)
25
+ render_prompt_locked unless result.is_a?(String) || result == SELECT_CANCEL || git_action?(result)
26
+ end
27
+ end
28
+
29
+ if git_action?(result)
30
+ action_result = block_given? ? yield(result) : status_lines
31
+ refreshed_status = git_action_status_lines(action_result)
32
+ open_git_diff_viewer(action_result[:diff]) if action_result.is_a?(Hash) && action_result[:diff]
33
+ @mutex.synchronize do
34
+ selected_index = @git_state ? @git_state[:selected_index].to_i : 0
35
+ @git_state = git_state_for(refreshed_status, selected_index: selected_index)
36
+ @prompt_label = "Git>"
37
+ render_prompt_locked
38
+ end
39
+ elsif result.is_a?(String) || result == SELECT_CANCEL
40
+ finish_git_prompt
41
+ return result == SELECT_CANCEL ? nil : result
42
+ end
43
+
44
+ sleep 0.02 if key.nil?
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def handle_git_key(key)
51
+ return git_submit_message if key.nil?
52
+ return if handle_git_bracketed_paste_key(key)
53
+ return if git_composing? && handle_shift_enter_key(key)
54
+
55
+ csi_result = handle_git_csi_u_key(key)
56
+ return csi_result unless csi_result == false
57
+
58
+ return true if handle_bundled_key(key) { |token| handle_git_key(token) }
59
+
60
+ case key
61
+ when "\n", "\r"
62
+ return git_submit_message if git_composing?
63
+ return git_open_selected_file_diff
64
+ when "\t"
65
+ return git_composing? ? git_return_to_overlay : git_begin_message
66
+ when "\b", "\x7F"
67
+ return delete_before_cursor if git_composing?
68
+ when "\e"
69
+ return SELECT_CANCEL
70
+ end
71
+
72
+ key_name = key_name_for(key)
73
+ named_result = handle_git_named_key(key_name) if key_name
74
+ return named_result unless named_result == false || named_result.nil?
75
+
76
+ binding_result = handle_composer_key_binding(key) if git_composing?
77
+ return binding_result unless binding_result == false || binding_result.nil?
78
+
79
+ return git_toggle_selected_file if key == "s" && !git_composing?
80
+
81
+ insert_key(key) if git_composing?
82
+ end
83
+
84
+ def handle_git_csi_u_key(key)
85
+ sequence = parse_csi_u_key(key)
86
+ return false unless sequence
87
+
88
+ code = sequence[:code]
89
+ modifier = sequence[:modifier]
90
+ queue_pending_keys(sequence[:remaining]) if sequence[:remaining] && !sequence[:remaining].empty?
91
+
92
+ case code
93
+ when 9
94
+ git_composing? ? git_return_to_overlay : git_begin_message
95
+ when 13
96
+ git_composing? ? git_submit_message : git_open_selected_file_diff
97
+ when 27
98
+ SELECT_CANCEL
99
+ when 8, 127
100
+ git_composing? && alt_modifier?(modifier) ? delete_word_before_cursor : delete_before_cursor if git_composing?
101
+ nil
102
+ when 4
103
+ delete_at_cursor if git_composing?
104
+ nil
105
+ else
106
+ if !git_composing? && code == "s".ord && (sequence[:modifiers].to_s.empty? || sequence[:modifiers].to_s == "1")
107
+ return git_toggle_selected_file
108
+ end
109
+ return false unless git_composing?
110
+
111
+ modified_result = handle_modified_csi_u_key(code, modifier)
112
+ return modified_result unless modified_result == false
113
+
114
+ insert_csi_u_text(sequence)
115
+ end
116
+ end
117
+
118
+ def handle_git_bracketed_paste_key(key)
119
+ paste = read_bracketed_paste(key)
120
+ return false unless paste
121
+
122
+ insert_string(normalize_paste(paste[:content])) if git_composing?
123
+ queue_pending_keys(paste[:remaining]) if paste[:remaining] && !paste[:remaining].empty?
124
+ true
125
+ end
126
+
127
+ def handle_git_named_key(key_name)
128
+ case key_name
129
+ when :return, :enter
130
+ git_composing? ? git_submit_message : git_open_selected_file_diff
131
+ when :backspace
132
+ delete_before_cursor if git_composing?
133
+ when :delete
134
+ delete_at_cursor if git_composing?
135
+ when :left
136
+ move_cursor_left if git_composing?
137
+ when :right
138
+ move_cursor_right if git_composing?
139
+ when :up
140
+ git_composing? ? false : git_move_selection(-1)
141
+ when :down
142
+ git_composing? ? false : git_move_selection(1)
143
+ when :home
144
+ move_to_start_of_line if git_composing?
145
+ when :end
146
+ move_to_end_of_line if git_composing?
147
+ else
148
+ false
149
+ end
150
+ end
151
+
152
+ def handle_git_escape_sequence
153
+ pending_sequence = read_pending_escape_sequence
154
+ return SELECT_CANCEL if pending_sequence.empty?
155
+
156
+ full_sequence = "\e#{pending_sequence}"
157
+ sequence = next_key_token(full_sequence)
158
+ queue_pending_keys(full_sequence[sequence.length..]) if full_sequence.length > sequence.length
159
+ return SELECT_CANCEL if sequence == "\e"
160
+
161
+ handle_git_named_key(key_name_for(sequence))
162
+ end
163
+
164
+ def git_state_for(status_lines, selected_index: 0)
165
+ lines = Array(status_lines).map(&:to_s)
166
+ selected_index = [[selected_index.to_i, 0].max, [lines.length - 1, 0].max].min
167
+ { status_lines: lines, composing: false, selected_index: selected_index, message_draft: "", message_cursor: 0 }
168
+ end
169
+
170
+ def git_action?(result)
171
+ result.is_a?(Hash) && result[:action]
172
+ end
173
+
174
+ def git_move_selection(delta)
175
+ return false unless @git_state
176
+
177
+ count = @git_state[:status_lines].length
178
+ return true if count.zero?
179
+
180
+ @git_state[:selected_index] = [[@git_state[:selected_index].to_i + delta, 0].max, count - 1].min
181
+ true
182
+ end
183
+
184
+ def git_toggle_selected_file
185
+ return true unless @git_state
186
+ return true if @git_state[:status_lines].empty?
187
+
188
+ { action: :toggle_stage, index: @git_state[:selected_index].to_i }
189
+ end
190
+
191
+ def git_open_selected_file_diff
192
+ return true unless @git_state
193
+ return true if @git_state[:status_lines].empty?
194
+
195
+ { action: :open_diff, index: @git_state[:selected_index].to_i }
196
+ end
197
+
198
+ def git_action_status_lines(action_result)
199
+ return action_result[:status_lines] if action_result.is_a?(Hash) && action_result.key?(:status_lines)
200
+
201
+ action_result
202
+ end
203
+
204
+ def open_git_diff_viewer(diff)
205
+ return unless diff.respond_to?(:[])
206
+
207
+ @mutex.synchronize do
208
+ open_diff_viewer(diff[:path].to_s, diff[:content].to_s)
209
+ render_prompt_locked
210
+ end
211
+ read_editor_until_closed
212
+ end
213
+
214
+ def read_editor_until_closed
215
+ while editor_active?
216
+ key = read_key(nonblock: true)
217
+ @mutex.synchronize do
218
+ if key.nil?
219
+ resized = handle_resize_locked
220
+ footer_refreshed = tick_footer_locked
221
+ render_prompt_locked if resized || footer_refreshed
222
+ else
223
+ handle_editor_key(key)
224
+ render_prompt_locked if editor_active?
225
+ end
226
+ end
227
+ sleep 0.02 if key.nil?
228
+ end
229
+ end
230
+
231
+ def git_begin_message
232
+ return true if git_composing?
233
+
234
+ @git_state[:composing] = true if @git_state
235
+ @prompt_label = "Commit>"
236
+ self.composer_input = @git_state.fetch(:message_draft, "")
237
+ self.composer_cursor = [[@git_state.fetch(:message_cursor, composer_input.length).to_i, 0].max, composer_input.length].min
238
+ true
239
+ end
240
+
241
+ def git_return_to_overlay
242
+ return true unless git_composing?
243
+
244
+ @git_state[:message_draft] = composer_input.dup
245
+ @git_state[:message_cursor] = composer_cursor
246
+ @git_state[:composing] = false
247
+ @prompt_label = "Git>"
248
+ self.composer_input = ""
249
+ self.composer_cursor = 0
250
+ true
251
+ end
252
+
253
+ def git_submit_message
254
+ return false unless git_composing?
255
+
256
+ value = composer_input.dup
257
+ add_history(composer_input)
258
+ value
259
+ end
260
+
261
+ def git_composing?
262
+ @git_state && @git_state[:composing]
263
+ end
264
+
265
+ def finish_git_prompt
266
+ @mutex.synchronize do
267
+ @git_state = nil
268
+ self.composer_input = ""
269
+ self.composer_cursor = 0
270
+ @asking = true
271
+ render_prompt_locked
272
+ @output_io.flush
273
+ end
274
+ end
275
+
276
+ def git_overlay_rows(width, height: screen_height)
277
+ return [] unless @git_state
278
+
279
+ help = git_composing? ? "Type commit message · Enter commit · Tab overlay · Esc cancel" : "↑/↓ select · Enter diff · s stage/unstage · Tab message · Esc cancel"
280
+ lines = [overlay_text_line(help, :muted), overlay_blank_line]
281
+ status_lines = @git_state[:status_lines]
282
+ status_lines = ["No uncommitted changes."] if status_lines.empty?
283
+ max_status_rows = [max_overlay_list_rows(height), 1].max
284
+ selected_index = @git_state[:selected_index].to_i
285
+ start_index = centered_list_window_start(selected_index, status_lines.length, max_status_rows)
286
+ visible_status_lines = status_lines[start_index, max_status_rows] || []
287
+ lines << overlay_text_line("… #{start_index} above", :muted) if start_index.positive?
288
+ visible_status_lines.each_with_index do |line, offset|
289
+ index = start_index + offset
290
+ marker = index == selected_index ? "› " : " "
291
+ lines << overlay_text_line("#{marker}#{line}")
292
+ end
293
+ hidden_below = status_lines.length - start_index - visible_status_lines.length
294
+ lines << overlay_text_line("… #{hidden_below} more", :muted) if hidden_below.positive?
295
+ overlay_card_rows("Git", lines, width)
296
+ end
297
+ end
298
+ end
299
+ end
@@ -0,0 +1,186 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Interactive terminal UI used by the CLI frontend.
4
+ class PromptInterface
5
+ # Controller object passed to interactive plugin commands. Owns the canvas
6
+ # buffer and exposes the plugin-facing API for drawing colored cells,
7
+ # reading keys, and controlling the loop lifecycle.
8
+ #
9
+ # Kward manages the actual terminal rendering: the plugin fills the canvas
10
+ # via {#put} and {#clear_frame}, then calls {#render} to mark it dirty.
11
+ # Kward flushes the canvas to the composer region on the next frame write.
12
+ class InteractiveController
13
+ # Creates a controller with the given canvas dimensions and frame rate.
14
+ #
15
+ # @param width [Integer] canvas width in terminal columns
16
+ # @param height [Integer] canvas height in terminal rows
17
+ # @param fps [Numeric] target frame rate for tick callbacks
18
+ def initialize(width:, height:, fps:)
19
+ @width = [width.to_i, 1].max
20
+ @height = [height.to_i, 1].max
21
+ @fps = [[fps.to_f, 1].max, 120].min
22
+ @cells = Array.new(@height) { Array.new(@width) { blank_cell } }
23
+ @dirty = true
24
+ @keys = []
25
+ @exited = false
26
+ @on_tick = nil
27
+ end
28
+
29
+ # @return [Integer] canvas width in terminal columns
30
+ attr_reader :width
31
+
32
+ # @return [Integer] canvas height in terminal rows
33
+ attr_reader :height
34
+
35
+ # @return [Numeric] target frame rate
36
+ attr_reader :fps
37
+
38
+ # Sets the tick callback invoked by Kward on each frame. The block
39
+ # receives this controller. Returning `:exit` or calling {#exit}
40
+ # ends the loop.
41
+ #
42
+ # @yieldparam ui [InteractiveController] self
43
+ # @return [void]
44
+ def on_tick(&block)
45
+ @on_tick = block
46
+ end
47
+
48
+ # Places a character at the given canvas position with optional color.
49
+ #
50
+ # @param row [Integer] zero-based row
51
+ # @param col [Integer] zero-based column
52
+ # @param char [String] single character to display
53
+ # @param color [Symbol, String, nil] ANSI style name or raw SGR code
54
+ # @return [void]
55
+ def put(row, col, char, *colors)
56
+ row = row.to_i
57
+ col = col.to_i
58
+ return if row.negative? || row >= @height
59
+ return if col.negative? || col >= @width
60
+
61
+ @cells[row][col] = { char: char.to_s[0] || " ", colors: colors.flatten }
62
+ @dirty = true
63
+ end
64
+
65
+ # Clears all canvas cells to blank.
66
+ #
67
+ # @return [void]
68
+ def clear_frame
69
+ @cells = Array.new(@height) { Array.new(@width) { blank_cell } }
70
+ @dirty = true
71
+ end
72
+
73
+ # Marks the canvas as ready for Kward to render. Called after the plugin
74
+ # has finished drawing a frame via {#put} and {#clear_frame}.
75
+ #
76
+ # @return [void]
77
+ def render
78
+ @dirty = true
79
+ end
80
+
81
+ # Whether the canvas has changes pending render. Kward checks this to
82
+ # decide whether to write cells to the terminal.
83
+ #
84
+ # @return [Boolean]
85
+ def dirty?
86
+ @dirty
87
+ end
88
+
89
+ # Returns the canvas cells as a 2D array of `{ char:, colors: }` hashes.
90
+ # Kward calls this to render the frame. Resets the dirty flag.
91
+ #
92
+ # @return [Array<Array<Hash>>]
93
+ def cells
94
+ @dirty = false
95
+ @cells
96
+ end
97
+
98
+ # Returns the next pending key, or nil if none. Keys are routed by
99
+ # Kward's main input loop via {#push_key}. Non-blocking.
100
+ #
101
+ # @return [String, Symbol, nil]
102
+ def poll_key
103
+ @keys.shift
104
+ end
105
+
106
+ # Requests that the interactive loop exit. Kward detects this and tears
107
+ # down the canvas, restoring the prior composer state.
108
+ #
109
+ # @return [void]
110
+ def exit
111
+ @exited = true
112
+ end
113
+
114
+ # Whether exit has been requested by the plugin or forced by Kward.
115
+ #
116
+ # @return [Boolean]
117
+ def exited?
118
+ @exited
119
+ end
120
+
121
+ # Whether a tick callback has been registered.
122
+ #
123
+ # @return [Boolean]
124
+ def tickable?
125
+ !@on_tick.nil?
126
+ end
127
+
128
+ # Invokes the registered tick callback. Kward calls this on each frame.
129
+ # Returns `:exit` if the callback requests exit.
130
+ #
131
+ # @return [Object, :exit, nil]
132
+ def invoke_tick
133
+ return nil unless @on_tick
134
+
135
+ result = @on_tick.call(self)
136
+ result == :exit ? :exit : nil
137
+ end
138
+
139
+ # Resizes the canvas dimensions. Called by Kward when the terminal
140
+ # resizes during interactive mode.
141
+ #
142
+ # @param width [Integer] new canvas width
143
+ # @param height [Integer] new canvas height (kept at original row count)
144
+ # @return [void]
145
+ def resize(width:, height: @height)
146
+ @width = [width.to_i, 1].max
147
+ new_height = [height.to_i, 1].max
148
+ if new_height != @height
149
+ @height = new_height
150
+ clear_frame
151
+ return
152
+ end
153
+
154
+ @cells.each do |row|
155
+ if row.length < @width
156
+ row.fill(blank_cell, row.length...@width)
157
+ else
158
+ row.slice!(@width..)
159
+ end
160
+ end
161
+ @dirty = true
162
+ end
163
+
164
+ # Pushes a key into the internal queue. Called by Kward's input loop.
165
+ #
166
+ # @param key [String, Symbol] key to enqueue
167
+ # @return [void]
168
+ def push_key(key)
169
+ @keys << key
170
+ end
171
+
172
+ # Marks the controller as exited. Called by Kward on forced exit.
173
+ #
174
+ # @return [void]
175
+ def force_exit
176
+ @exited = true
177
+ end
178
+
179
+ private
180
+
181
+ def blank_cell
182
+ { char: " ", colors: [] }
183
+ end
184
+ end
185
+ end
186
+ end