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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +41 -1
- 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 +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 +72 -1
- data/doc/releasing.md +37 -9
- data/doc/rpc.md +74 -4
- data/doc/session-management.md +35 -1
- data/doc/shell.md +286 -0
- data/doc/tabs.md +122 -0
- data/doc/troubleshooting.md +77 -1
- data/doc/usage.md +53 -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/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/runtime_helpers.rb +133 -3
- data/lib/kward/cli/sessions.rb +2 -2
- data/lib/kward/cli/settings.rb +218 -9
- data/lib/kward/cli/slash_commands.rb +415 -2
- data/lib/kward/cli/tabs.rb +695 -0
- data/lib/kward/cli.rb +158 -26
- data/lib/kward/config_files.rb +123 -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 +362 -0
- data/lib/kward/plugin_registry.rb +61 -0
- data/lib/kward/project_files.rb +52 -0
- data/lib/kward/prompt_history.rb +82 -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 +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 +387 -35
- 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 +98 -50
- data/lib/kward/prompt_interface/runtime_state.rb +43 -0
- data/lib/kward/prompt_interface/screen.rb +16 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +7 -13
- 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 +286 -8
- data/lib/kward/prompts/commands.rb +5 -0
- data/lib/kward/prompts.rb +2 -0
- data/lib/kward/rpc/server.rb +42 -3
- data/lib/kward/rpc/session_manager.rb +35 -47
- 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 +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/text_boundary.rb +25 -0
- 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 +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 +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 +110 -24
- data/templates/default/fulldoc/html/css/kward.css +107 -36
- data/templates/default/kward_navigation.rb +12 -1
- data/templates/default/layout/html/layout.erb +4 -2
- data/templates/default/layout/html/setup.rb +6 -0
- 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
|