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,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,288 @@
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
+ def open_modal_diff_viewer(path, content)
49
+ @mutex.synchronize do
50
+ open_diff_viewer(path.to_s, content.to_s)
51
+ render_prompt_locked
52
+ end
53
+ read_editor_until_closed
54
+ end
55
+
56
+ private
57
+
58
+ def handle_git_key(key)
59
+ return git_submit_message if key.nil?
60
+ return if handle_git_bracketed_paste_key(key)
61
+ return if git_composing? && handle_shift_enter_key(key)
62
+
63
+ csi_result = handle_git_csi_u_key(key)
64
+ return csi_result unless csi_result == false
65
+
66
+ return true if handle_bundled_key(key) { |token| handle_git_key(token) }
67
+
68
+ case key
69
+ when "\n", "\r"
70
+ return git_submit_message if git_composing?
71
+ return git_open_selected_file_diff
72
+ when "\t"
73
+ return git_composing? ? git_return_to_overlay : git_begin_message
74
+ when "\b", "\x7F"
75
+ return delete_before_cursor if git_composing?
76
+ when "\e"
77
+ return SELECT_CANCEL
78
+ end
79
+
80
+ key_name = key_name_for(key)
81
+ named_result = handle_git_named_key(key_name) if key_name
82
+ return named_result unless named_result == false || named_result.nil?
83
+
84
+ binding_result = handle_composer_key_binding(key) if git_composing?
85
+ return binding_result unless binding_result == false || binding_result.nil?
86
+
87
+ return git_toggle_selected_file if key == "s" && !git_composing?
88
+
89
+ insert_key(key) if git_composing?
90
+ end
91
+
92
+ def handle_git_csi_u_key(key)
93
+ sequence = parse_csi_u_key(key)
94
+ return false unless sequence
95
+
96
+ code = sequence[:code]
97
+ modifier = sequence[:modifier]
98
+ queue_pending_keys(sequence[:remaining]) if sequence[:remaining] && !sequence[:remaining].empty?
99
+
100
+ case code
101
+ when 9
102
+ git_composing? ? git_return_to_overlay : git_begin_message
103
+ when 13
104
+ git_composing? ? git_submit_message : git_open_selected_file_diff
105
+ when 27
106
+ SELECT_CANCEL
107
+ when 8, 127
108
+ git_composing? && alt_modifier?(modifier) ? delete_word_before_cursor : delete_before_cursor if git_composing?
109
+ nil
110
+ when 4
111
+ delete_at_cursor if git_composing?
112
+ nil
113
+ else
114
+ if !git_composing? && code == "s".ord && (sequence[:modifiers].to_s.empty? || sequence[:modifiers].to_s == "1")
115
+ return git_toggle_selected_file
116
+ end
117
+ return false unless git_composing?
118
+
119
+ modified_result = handle_modified_csi_u_key(code, modifier)
120
+ return modified_result unless modified_result == false
121
+
122
+ insert_csi_u_text(sequence)
123
+ end
124
+ end
125
+
126
+ def handle_git_bracketed_paste_key(key)
127
+ handle_bracketed_paste(key) do |content|
128
+ insert_string(content) if git_composing?
129
+ end
130
+ end
131
+
132
+ def handle_git_named_key(key_name)
133
+ case key_name
134
+ when :return, :enter
135
+ git_composing? ? git_submit_message : git_open_selected_file_diff
136
+ when :backspace
137
+ delete_before_cursor if git_composing?
138
+ when :delete
139
+ delete_at_cursor if git_composing?
140
+ when :left
141
+ move_cursor_left if git_composing?
142
+ when :right
143
+ move_cursor_right if git_composing?
144
+ when :up
145
+ git_composing? ? false : git_move_selection(-1)
146
+ when :down
147
+ git_composing? ? false : git_move_selection(1)
148
+ when :home
149
+ move_to_start_of_line if git_composing?
150
+ when :end
151
+ move_to_end_of_line if git_composing?
152
+ else
153
+ false
154
+ end
155
+ end
156
+
157
+ def git_state_for(status_lines, selected_index: 0)
158
+ lines = Array(status_lines).map(&:to_s)
159
+ selected_index = [[selected_index.to_i, 0].max, [lines.length - 1, 0].max].min
160
+ { status_lines: lines, composing: false, selected_index: selected_index, message_draft: "", message_cursor: 0 }
161
+ end
162
+
163
+ def git_action?(result)
164
+ result.is_a?(Hash) && result[:action]
165
+ end
166
+
167
+ def git_move_selection(delta)
168
+ return false unless @git_state
169
+
170
+ count = @git_state[:status_lines].length
171
+ return true if count.zero?
172
+
173
+ @git_state[:selected_index] = [[@git_state[:selected_index].to_i + delta, 0].max, count - 1].min
174
+ true
175
+ end
176
+
177
+ def git_toggle_selected_file
178
+ return true unless @git_state
179
+ return true if @git_state[:status_lines].empty?
180
+
181
+ { action: :toggle_stage, index: @git_state[:selected_index].to_i }
182
+ end
183
+
184
+ def git_open_selected_file_diff
185
+ return true unless @git_state
186
+ return true if @git_state[:status_lines].empty?
187
+
188
+ { action: :open_diff, index: @git_state[:selected_index].to_i }
189
+ end
190
+
191
+ def git_action_status_lines(action_result)
192
+ return action_result[:status_lines] if action_result.is_a?(Hash) && action_result.key?(:status_lines)
193
+
194
+ action_result
195
+ end
196
+
197
+ def open_git_diff_viewer(diff)
198
+ return unless diff.respond_to?(:[])
199
+
200
+ open_modal_diff_viewer(diff[:path], diff[:content])
201
+ end
202
+
203
+ def read_editor_until_closed
204
+ while editor_active?
205
+ key = read_key(nonblock: true)
206
+ @mutex.synchronize do
207
+ if key.nil?
208
+ resized = handle_resize_locked
209
+ footer_refreshed = tick_footer_locked
210
+ render_prompt_locked if resized || footer_refreshed
211
+ else
212
+ handle_editor_key(key)
213
+ render_prompt_locked if editor_active?
214
+ end
215
+ end
216
+ sleep 0.02 if key.nil?
217
+ end
218
+ end
219
+
220
+ def git_begin_message
221
+ return true if git_composing?
222
+
223
+ @git_state[:composing] = true if @git_state
224
+ @prompt_label = "Commit>"
225
+ self.composer_input = @git_state.fetch(:message_draft, "")
226
+ self.composer_cursor = [[@git_state.fetch(:message_cursor, composer_input.length).to_i, 0].max, composer_input.length].min
227
+ true
228
+ end
229
+
230
+ def git_return_to_overlay
231
+ return true unless git_composing?
232
+
233
+ @git_state[:message_draft] = composer_input.dup
234
+ @git_state[:message_cursor] = composer_cursor
235
+ @git_state[:composing] = false
236
+ @prompt_label = "Git>"
237
+ self.composer_input = ""
238
+ self.composer_cursor = 0
239
+ true
240
+ end
241
+
242
+ def git_submit_message
243
+ return false unless git_composing?
244
+
245
+ value = composer_input.dup
246
+ add_history(composer_input)
247
+ value
248
+ end
249
+
250
+ def git_composing?
251
+ @git_state && @git_state[:composing]
252
+ end
253
+
254
+ def finish_git_prompt
255
+ @mutex.synchronize do
256
+ @git_state = nil
257
+ self.composer_input = ""
258
+ self.composer_cursor = 0
259
+ @asking = true
260
+ render_prompt_locked
261
+ @output_io.flush
262
+ end
263
+ end
264
+
265
+ def git_overlay_rows(width, height: screen_height)
266
+ return [] unless @git_state
267
+
268
+ help = git_composing? ? "Type commit message · Enter commit · Tab overlay · Esc cancel" : "↑/↓ select · Enter diff · s stage/unstage · Tab message · Esc cancel"
269
+ lines = [overlay_text_line(help, :muted), overlay_blank_line]
270
+ status_lines = @git_state[:status_lines]
271
+ status_lines = ["No uncommitted changes."] if status_lines.empty?
272
+ max_status_rows = [max_overlay_list_rows(height), 1].max
273
+ selected_index = @git_state[:selected_index].to_i
274
+ start_index = centered_list_window_start(selected_index, status_lines.length, max_status_rows)
275
+ visible_status_lines = status_lines[start_index, max_status_rows] || []
276
+ lines << overlay_text_line("… #{start_index} above", :muted) if start_index.positive?
277
+ visible_status_lines.each_with_index do |line, offset|
278
+ index = start_index + offset
279
+ marker = index == selected_index ? "› " : " "
280
+ lines << overlay_text_line("#{marker}#{line}")
281
+ end
282
+ hidden_below = status_lines.length - start_index - visible_status_lines.length
283
+ lines << overlay_text_line("… #{hidden_below} more", :muted) if hidden_below.positive?
284
+ overlay_card_rows("Git", lines, width)
285
+ end
286
+ end
287
+ end
288
+ 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 colors [Array<Symbol, String>] ANSI style names or raw SGR codes
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