kward 0.71.0 → 0.73.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +30 -0
- data/CHANGELOG.md +93 -0
- data/Gemfile.lock +2 -2
- data/README.md +4 -0
- data/doc/agent-tools.md +15 -6
- data/doc/authentication.md +22 -1
- data/doc/code-search.md +42 -2
- data/doc/configuration.md +106 -3
- data/doc/context-budgeting.md +136 -0
- data/doc/context-tools.md +16 -3
- data/doc/editor.md +415 -0
- data/doc/extensibility.md +16 -7
- data/doc/files.md +100 -0
- data/doc/getting-started.md +25 -18
- data/doc/git.md +123 -0
- data/doc/memory.md +24 -4
- data/doc/personas.md +34 -5
- data/doc/plugins.md +72 -1
- data/doc/releasing.md +37 -9
- data/doc/rpc.md +75 -5
- data/doc/session-management.md +35 -1
- data/doc/shell.md +332 -0
- data/doc/tabs.md +122 -0
- data/doc/troubleshooting.md +77 -1
- data/doc/usage.md +79 -7
- data/doc/web-search.md +12 -4
- data/doc/workspace-tools.md +51 -12
- data/examples/plugins/space_invaders.rb +377 -0
- data/lib/kward/agent.rb +1 -1
- data/lib/kward/ansi.rb +62 -23
- data/lib/kward/cli/commands.rb +33 -2
- data/lib/kward/cli/git.rb +150 -0
- data/lib/kward/cli/interactive_turn.rb +73 -9
- data/lib/kward/cli/plugins.rb +54 -4
- data/lib/kward/cli/prompt_interface.rb +32 -1
- data/lib/kward/cli/rendering.rb +4 -1
- data/lib/kward/cli/runtime_helpers.rb +268 -4
- data/lib/kward/cli/sessions.rb +2 -2
- data/lib/kward/cli/settings.rb +217 -9
- data/lib/kward/cli/slash_commands.rb +628 -2
- data/lib/kward/cli/tabs.rb +725 -0
- data/lib/kward/cli/tool_summaries.rb +6 -0
- data/lib/kward/cli.rb +150 -26
- data/lib/kward/clipboard.rb +2 -3
- data/lib/kward/compactor.rb +7 -19
- data/lib/kward/config_files.rb +145 -1
- data/lib/kward/context_budget_meter.rb +44 -0
- data/lib/kward/conversation.rb +12 -4
- data/lib/kward/editor_mode.rb +25 -0
- data/lib/kward/ekwsh.rb +559 -0
- data/lib/kward/image_attachments.rb +3 -1
- data/lib/kward/interactive_pty_runner.rb +151 -0
- data/lib/kward/local_command_runner.rb +155 -0
- data/lib/kward/local_pty_command_runner.rb +171 -0
- data/lib/kward/model/context_usage.rb +2 -2
- data/lib/kward/model/payloads.rb +2 -5
- data/lib/kward/plugin_registry.rb +61 -0
- data/lib/kward/project_files.rb +52 -0
- data/lib/kward/prompt_history.rb +84 -0
- data/lib/kward/prompt_interface/composer_controller.rb +69 -1
- data/lib/kward/prompt_interface/composer_renderer.rb +109 -13
- data/lib/kward/prompt_interface/composer_state.rb +96 -27
- data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
- data/lib/kward/prompt_interface/editor/auto_indent.rb +510 -0
- data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
- data/lib/kward/prompt_interface/editor/controller.rb +1218 -0
- data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
- data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
- data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
- data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
- data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
- data/lib/kward/prompt_interface/editor/modes/modern.rb +354 -0
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +1812 -0
- data/lib/kward/prompt_interface/editor/modes/vibe_insert_readline.rb +166 -0
- data/lib/kward/prompt_interface/editor/renderer.rb +244 -0
- data/lib/kward/prompt_interface/editor/search.rb +76 -0
- data/lib/kward/prompt_interface/editor/selections.rb +120 -0
- data/lib/kward/prompt_interface/editor/state.rb +1271 -0
- data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +422 -0
- data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
- data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
- data/lib/kward/prompt_interface/file_overlay.rb +211 -0
- data/lib/kward/prompt_interface/git_prompt.rb +288 -0
- data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
- data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
- data/lib/kward/prompt_interface/interactive/state.rb +62 -0
- data/lib/kward/prompt_interface/key_handler.rb +451 -57
- data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
- data/lib/kward/prompt_interface/project_browser.rb +524 -0
- data/lib/kward/prompt_interface/question_prompt.rb +99 -56
- data/lib/kward/prompt_interface/runtime_state.rb +43 -0
- data/lib/kward/prompt_interface/screen.rb +19 -3
- data/lib/kward/prompt_interface/selection_prompt.rb +10 -19
- data/lib/kward/prompt_interface/slash_overlay.rb +2 -0
- data/lib/kward/prompt_interface/stream_state.rb +7 -0
- data/lib/kward/prompt_interface/transcript_buffer.rb +6 -0
- data/lib/kward/prompt_interface.rb +366 -222
- data/lib/kward/prompts/commands.rb +9 -0
- data/lib/kward/prompts.rb +2 -0
- data/lib/kward/rpc/memory_methods.rb +83 -0
- data/lib/kward/rpc/server.rb +169 -83
- data/lib/kward/rpc/session_manager.rb +45 -121
- data/lib/kward/rpc/session_tree_rows.rb +9 -115
- data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
- data/lib/kward/rpc/tool_metadata.rb +11 -0
- data/lib/kward/rpc/transcript_normalizer.rb +4 -39
- data/lib/kward/scratchpad_runner.rb +56 -0
- data/lib/kward/session_diff.rb +20 -3
- data/lib/kward/session_naming.rb +11 -0
- data/lib/kward/session_store.rb +44 -0
- data/lib/kward/session_tree_nodes.rb +136 -0
- data/lib/kward/session_tree_renderer.rb +9 -131
- data/lib/kward/tab_store.rb +47 -0
- data/lib/kward/terminal_keys.rb +84 -0
- data/lib/kward/terminal_sequences.rb +42 -0
- data/lib/kward/text_boundary.rb +25 -0
- data/lib/kward/tools/context_budget_stats.rb +54 -0
- data/lib/kward/tools/context_for_task.rb +204 -0
- data/lib/kward/tools/read_file.rb +8 -4
- data/lib/kward/tools/registry.rb +62 -16
- data/lib/kward/tools/tool_call.rb +10 -0
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workers/git_guard.rb +93 -0
- data/lib/kward/workers/job.rb +99 -0
- data/lib/kward/workers/live_view.rb +49 -0
- data/lib/kward/workers/manager.rb +288 -0
- data/lib/kward/workers/queue_runner.rb +166 -0
- data/lib/kward/workers/queue_store.rb +112 -0
- data/lib/kward/workers/store.rb +72 -0
- data/lib/kward/workers/tool_policy.rb +23 -0
- data/lib/kward/workers/worker.rb +82 -0
- data/lib/kward/workers/write_lock.rb +38 -0
- data/lib/kward/workers.rb +10 -0
- data/lib/kward/workspace.rb +125 -87
- data/templates/default/fulldoc/html/css/kward.css +140 -36
- data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
- data/templates/default/fulldoc/html/setup.rb +1 -0
- data/templates/default/kward_navigation.rb +12 -1
- data/templates/default/layout/html/layout.erb +23 -34
- data/templates/default/layout/html/setup.rb +6 -0
- metadata +67 -1
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Interactive terminal UI used by the CLI frontend.
|
|
4
|
+
class PromptInterface
|
|
5
|
+
# Readline-style insert-mode bindings for the Vibe editor mode.
|
|
6
|
+
module VibeInsertReadline
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def handle_vibe_insert_readline_key(key)
|
|
10
|
+
csi_result = handle_vibe_insert_readline_csi_u_key(key)
|
|
11
|
+
return csi_result unless csi_result == false
|
|
12
|
+
|
|
13
|
+
handle_vibe_insert_readline_ansi_key(key)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def handle_vibe_insert_readline_csi_u_key(key)
|
|
17
|
+
sequence = parse_csi_u_key(key)
|
|
18
|
+
return false unless sequence
|
|
19
|
+
|
|
20
|
+
queue_pending_keys(sequence[:remaining]) if sequence[:remaining] && !sequence[:remaining].empty?
|
|
21
|
+
modifier = sequence[:modifier]
|
|
22
|
+
normalized_code = sequence[:code].to_i.chr.downcase.ord rescue sequence[:code]
|
|
23
|
+
if ctrl_modifier?(modifier)
|
|
24
|
+
return handle_vibe_insert_readline_ctrl_key(normalized_code)
|
|
25
|
+
elsif alt_modifier?(modifier)
|
|
26
|
+
return handle_vibe_insert_readline_alt_key(normalized_code)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def handle_vibe_insert_readline_ansi_key(key)
|
|
33
|
+
case key
|
|
34
|
+
when TerminalKeys::CTRL_A
|
|
35
|
+
@editor_state.move_line_start
|
|
36
|
+
when TerminalKeys::CTRL_B
|
|
37
|
+
@editor_state.move_left
|
|
38
|
+
when TerminalKeys::CTRL_D
|
|
39
|
+
vibe_record_undo { @editor_state.delete_at_cursor }
|
|
40
|
+
when TerminalKeys::CTRL_E
|
|
41
|
+
@editor_state.move_line_end
|
|
42
|
+
when TerminalKeys::CTRL_F
|
|
43
|
+
@editor_state.move_right
|
|
44
|
+
when TerminalKeys::CTRL_K
|
|
45
|
+
vibe_record_undo { @editor_state.kill_line_after_cursor }
|
|
46
|
+
when TerminalKeys::CTRL_U
|
|
47
|
+
vibe_record_undo { @editor_state.kill_line_before_cursor }
|
|
48
|
+
when TerminalKeys::CTRL_W
|
|
49
|
+
vibe_record_undo { @editor_state.delete_word_before_cursor }
|
|
50
|
+
when TerminalKeys::CTRL_Y
|
|
51
|
+
vibe_record_undo { @editor_state.yank_kill_buffer }
|
|
52
|
+
when *TerminalKeys::LEFT
|
|
53
|
+
@editor_state.move_left
|
|
54
|
+
when *TerminalKeys::RIGHT
|
|
55
|
+
@editor_state.move_right
|
|
56
|
+
when *TerminalKeys::HOME
|
|
57
|
+
@editor_state.move_line_start
|
|
58
|
+
when *TerminalKeys::END_KEY
|
|
59
|
+
@editor_state.move_line_end
|
|
60
|
+
when *TerminalKeys::DELETE
|
|
61
|
+
vibe_record_undo { @editor_state.delete_at_cursor }
|
|
62
|
+
when "\eb", "\eB"
|
|
63
|
+
@editor_state.move_to_previous_word
|
|
64
|
+
when "\ef", "\eF"
|
|
65
|
+
@editor_state.move_to_next_word
|
|
66
|
+
when "\ed", "\eD"
|
|
67
|
+
vibe_record_undo { @editor_state.delete_word_after_cursor }
|
|
68
|
+
when "\e\b", "\e\x7F"
|
|
69
|
+
vibe_record_undo { @editor_state.delete_word_before_cursor }
|
|
70
|
+
else
|
|
71
|
+
handle_vibe_insert_modified_ansi_key(key)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def handle_vibe_insert_readline_ctrl_key(normalized_code)
|
|
76
|
+
case normalized_code
|
|
77
|
+
when 97
|
|
78
|
+
@editor_state.move_line_start
|
|
79
|
+
when 98
|
|
80
|
+
@editor_state.move_left
|
|
81
|
+
when 100
|
|
82
|
+
vibe_record_undo { @editor_state.delete_at_cursor }
|
|
83
|
+
when 101
|
|
84
|
+
@editor_state.move_line_end
|
|
85
|
+
when 102
|
|
86
|
+
@editor_state.move_right
|
|
87
|
+
when 107
|
|
88
|
+
vibe_record_undo { @editor_state.kill_line_after_cursor }
|
|
89
|
+
when 117
|
|
90
|
+
vibe_record_undo { @editor_state.kill_line_before_cursor }
|
|
91
|
+
when 119
|
|
92
|
+
vibe_record_undo { @editor_state.delete_word_before_cursor }
|
|
93
|
+
when 121
|
|
94
|
+
vibe_record_undo { @editor_state.yank_kill_buffer }
|
|
95
|
+
else
|
|
96
|
+
false
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def handle_vibe_insert_readline_alt_key(normalized_code)
|
|
101
|
+
case normalized_code
|
|
102
|
+
when 98
|
|
103
|
+
@editor_state.move_to_previous_word
|
|
104
|
+
when 100
|
|
105
|
+
vibe_record_undo { @editor_state.delete_word_after_cursor }
|
|
106
|
+
when 102
|
|
107
|
+
@editor_state.move_to_next_word
|
|
108
|
+
else
|
|
109
|
+
false
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def handle_vibe_insert_modified_ansi_key(key)
|
|
114
|
+
sequence = parse_modified_ansi_key(key)
|
|
115
|
+
return false unless sequence
|
|
116
|
+
|
|
117
|
+
case sequence[:type]
|
|
118
|
+
when :cursor
|
|
119
|
+
return false unless alt_modifier?(sequence[:modifier])
|
|
120
|
+
|
|
121
|
+
case sequence[:final]
|
|
122
|
+
when "C"
|
|
123
|
+
@editor_state.move_to_next_word
|
|
124
|
+
when "D"
|
|
125
|
+
@editor_state.move_to_previous_word
|
|
126
|
+
when "F"
|
|
127
|
+
@editor_state.move_line_end
|
|
128
|
+
when "H"
|
|
129
|
+
@editor_state.move_line_start
|
|
130
|
+
else
|
|
131
|
+
false
|
|
132
|
+
end
|
|
133
|
+
when :delete
|
|
134
|
+
return false unless alt_modifier?(sequence[:modifier])
|
|
135
|
+
|
|
136
|
+
vibe_record_undo { @editor_state.delete_word_after_cursor }
|
|
137
|
+
else
|
|
138
|
+
false
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def handle_vibe_insert_named_key(key_name)
|
|
143
|
+
case key_name
|
|
144
|
+
when :escape
|
|
145
|
+
vibe_return_to_normal
|
|
146
|
+
when :return, :enter
|
|
147
|
+
vibe_record_undo { editor_insert_newline }
|
|
148
|
+
when :backspace
|
|
149
|
+
vibe_record_undo { editor_delete_before_cursor }
|
|
150
|
+
when :delete
|
|
151
|
+
vibe_record_undo { @editor_state.delete_at_cursor }
|
|
152
|
+
when :left
|
|
153
|
+
@editor_state.move_left
|
|
154
|
+
when :right
|
|
155
|
+
@editor_state.move_right
|
|
156
|
+
when :up
|
|
157
|
+
editor_move_up
|
|
158
|
+
when :down
|
|
159
|
+
editor_move_down
|
|
160
|
+
else
|
|
161
|
+
false
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Interactive terminal UI used by the CLI frontend.
|
|
4
|
+
class PromptInterface
|
|
5
|
+
# Renderer for the built-in composer file editor.
|
|
6
|
+
module EditorRenderer
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def editor_layout(width, height = screen_height)
|
|
10
|
+
content_width = [width - 4, 1].max
|
|
11
|
+
visible_count = editor_visible_line_count(height: height, width: width)
|
|
12
|
+
line_index, column = @editor_state.cursor_line_and_column
|
|
13
|
+
gutter_width = editor_line_number_gutter_width
|
|
14
|
+
text_width = editor_text_width(content_width, gutter_width)
|
|
15
|
+
sync_editor_wrap_state(text_width)
|
|
16
|
+
|
|
17
|
+
if current_editor_soft_wrap?
|
|
18
|
+
editor_wrapped_layout(width, content_width, visible_count, line_index, column, text_width)
|
|
19
|
+
else
|
|
20
|
+
editor_unwrapped_layout(width, content_width, visible_count, line_index, column, text_width)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def editor_unwrapped_layout(width, content_width, visible_count, line_index, column, text_width)
|
|
25
|
+
@editor_state.viewport_row = [[@editor_state.viewport_row, line_index - visible_count + 1].max, line_index].min
|
|
26
|
+
@editor_state.viewport_row = [@editor_state.viewport_row, 0].max
|
|
27
|
+
@editor_state.viewport_column = [[@editor_state.viewport_column.to_i, column - text_width + 1].max, column].min
|
|
28
|
+
@editor_state.viewport_column = [@editor_state.viewport_column, 0].max
|
|
29
|
+
editor_lines = @editor_state.lines
|
|
30
|
+
visible_lines = editor_lines[@editor_state.viewport_row, visible_count] || []
|
|
31
|
+
actual_visible_count = visible_lines.length
|
|
32
|
+
visible_lines << "" while visible_lines.length < visible_count
|
|
33
|
+
gutter_width = editor_line_number_gutter_width
|
|
34
|
+
rows = [editor_top_border(width)]
|
|
35
|
+
rows.concat(visible_lines.each_with_index.map do |line, index|
|
|
36
|
+
gutter = if index < actual_visible_count
|
|
37
|
+
editor_line_number_gutter(@editor_state.viewport_row + index)
|
|
38
|
+
else
|
|
39
|
+
editor_blank_line_number_gutter
|
|
40
|
+
end
|
|
41
|
+
rendered_line = editor_render_line(line, @editor_state.viewport_row + index, text_width, column_offset: @editor_state.viewport_column)
|
|
42
|
+
row = gutter + rendered_line
|
|
43
|
+
box_content_row(row, content_width)
|
|
44
|
+
end)
|
|
45
|
+
rows << footer_row(content_width, editor_status_text)
|
|
46
|
+
rows.concat(editor_bottom_rows(width))
|
|
47
|
+
cursor_row = 1 + line_index - @editor_state.viewport_row
|
|
48
|
+
cursor_col = 2 + gutter_width + [[column - @editor_state.viewport_column, 0].max, text_width - 1].min
|
|
49
|
+
[rows, cursor_row, cursor_col]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def editor_wrapped_layout(width, content_width, visible_count, line_index, column, text_width)
|
|
53
|
+
visual_rows = editor_visual_rows(text_width)
|
|
54
|
+
cursor_visual_row = editor_visual_row_for(line_index, column, text_width)
|
|
55
|
+
@editor_state.viewport_row = [[@editor_state.viewport_row, cursor_visual_row - visible_count + 1].max, cursor_visual_row].min
|
|
56
|
+
@editor_state.viewport_row = [@editor_state.viewport_row, 0].max
|
|
57
|
+
visible_rows = visual_rows[@editor_state.viewport_row, visible_count] || []
|
|
58
|
+
visible_rows << nil while visible_rows.length < visible_count
|
|
59
|
+
rows = [editor_top_border(width)]
|
|
60
|
+
rows.concat(visible_rows.map do |visual_row|
|
|
61
|
+
if visual_row
|
|
62
|
+
gutter = visual_row[:continuation] ? editor_blank_line_number_gutter : editor_line_number_gutter(visual_row[:line_index])
|
|
63
|
+
rendered_line = editor_render_line(visual_row[:line], visual_row[:line_index], text_width, column_offset: visual_row[:column_offset])
|
|
64
|
+
box_content_row(gutter + rendered_line, content_width)
|
|
65
|
+
else
|
|
66
|
+
box_content_row(editor_blank_line_number_gutter, content_width)
|
|
67
|
+
end
|
|
68
|
+
end)
|
|
69
|
+
rows << footer_row(content_width, editor_status_text)
|
|
70
|
+
rows.concat(editor_bottom_rows(width))
|
|
71
|
+
line_start = editor_visual_row_start_column(line_index, column, text_width)
|
|
72
|
+
cursor_row = 1 + cursor_visual_row - @editor_state.viewport_row
|
|
73
|
+
cursor_col = 2 + editor_line_number_gutter_width + [[column - line_start, 0].max, text_width - 1].min
|
|
74
|
+
[rows, cursor_row, cursor_col]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def editor_visible_line_count(height: screen_height, width: screen_width)
|
|
78
|
+
visible_count = [[height - 3 - editor_bottom_rows(width).length, 1].max, 1].max
|
|
79
|
+
visible_count = [visible_count, height - 4].min if height > 4
|
|
80
|
+
[visible_count, 1].max
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def editor_bottom_rows(width)
|
|
84
|
+
@tabs.empty? ? [bottom_border(width)] : tab_border_rows(width)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def editor_render_line(line, line_index, text_width, column_offset: 0)
|
|
88
|
+
visible = line.to_s[column_offset.to_i, text_width].to_s
|
|
89
|
+
rendered = editor_render_visible_line(visible, line_index)
|
|
90
|
+
line_start = @editor_state.line_start_offset(line_index)
|
|
91
|
+
rendered = editor_overlay_line_selections(rendered, line_start, column_offset, visible.length)
|
|
92
|
+
editor_overlay_secondary_cursors(rendered, line_start, column_offset, visible.length, text_width)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def editor_overlay_line_selections(rendered, line_start, column_offset, visible_length)
|
|
96
|
+
ranges = @editor_state.selection_ranges
|
|
97
|
+
return rendered if ranges.empty?
|
|
98
|
+
|
|
99
|
+
selection_ranges = ranges.filter_map do |range|
|
|
100
|
+
selection_start = [range[0] - line_start - column_offset.to_i, 0].max
|
|
101
|
+
selection_end = [range[1] - line_start - column_offset.to_i, visible_length].min
|
|
102
|
+
[selection_start, selection_end] if selection_start < selection_end
|
|
103
|
+
end
|
|
104
|
+
return rendered if selection_ranges.empty?
|
|
105
|
+
|
|
106
|
+
editor_overlay_selection(rendered, selection_ranges)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def editor_overlay_secondary_cursors(rendered, line_start, column_offset, visible_length, text_width)
|
|
110
|
+
return rendered unless @color_enabled
|
|
111
|
+
|
|
112
|
+
cursor_columns = @editor_state.secondary_cursor_offsets.filter_map do |offset|
|
|
113
|
+
column = offset - line_start - column_offset.to_i
|
|
114
|
+
column if column >= 0 && column <= visible_length
|
|
115
|
+
end
|
|
116
|
+
return rendered if cursor_columns.empty?
|
|
117
|
+
|
|
118
|
+
if cursor_columns.include?(visible_length) && visible_length < text_width
|
|
119
|
+
rendered += " "
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
editor_overlay_selection(rendered, cursor_columns.map { |column| [column, column + 1] })
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def editor_overlay_selection(rendered, selection_ranges)
|
|
126
|
+
return rendered unless @color_enabled
|
|
127
|
+
|
|
128
|
+
output = +""
|
|
129
|
+
selected = false
|
|
130
|
+
visible_index = 0
|
|
131
|
+
index = 0
|
|
132
|
+
while index < rendered.length
|
|
133
|
+
if rendered[index] == "\e" && (match = rendered[index..].match(/\A\e\[[0-9;:]*m/))
|
|
134
|
+
output << match[0]
|
|
135
|
+
output << TerminalSequences::SGR_INVERSE if selected && match[0] == "\e[0m"
|
|
136
|
+
index += match[0].length
|
|
137
|
+
next
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
should_select = selection_ranges.any? { |range| visible_index >= range[0] && visible_index < range[1] }
|
|
141
|
+
if should_select != selected
|
|
142
|
+
output << (should_select ? TerminalSequences::SGR_INVERSE : TerminalSequences::SGR_INVERSE_OFF)
|
|
143
|
+
selected = should_select
|
|
144
|
+
end
|
|
145
|
+
output << rendered[index]
|
|
146
|
+
visible_index += 1
|
|
147
|
+
index += 1
|
|
148
|
+
end
|
|
149
|
+
output << TerminalSequences::SGR_INVERSE_OFF if selected
|
|
150
|
+
output
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def editor_render_visible_line(line, line_index)
|
|
154
|
+
return editor_render_diff_line(line) if @editor_state.diff_view?
|
|
155
|
+
|
|
156
|
+
editor_highlight_line(line, line_index)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def editor_render_diff_line(line)
|
|
160
|
+
text = line.to_s
|
|
161
|
+
return colored(text, :green) if text.start_with?("+") && !text.start_with?("+++")
|
|
162
|
+
return colored(text, :red) if text.start_with?("-") && !text.start_with?("---")
|
|
163
|
+
return colored(text, :cyan) if text.start_with?("@@")
|
|
164
|
+
|
|
165
|
+
text
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def editor_line_number_gutter_width
|
|
169
|
+
[[@editor_state.lines.length.to_s.length, 4].max + 3, 1].max
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def editor_text_width(content_width, gutter_width = editor_line_number_gutter_width)
|
|
173
|
+
[content_width - gutter_width, 1].max
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def editor_visual_rows(text_width)
|
|
177
|
+
@editor_state.lines.each_with_index.flat_map do |line, line_index|
|
|
178
|
+
count = editor_visual_row_count(line, text_width)
|
|
179
|
+
count.times.map do |index|
|
|
180
|
+
column_offset = index * text_width
|
|
181
|
+
{ line_index: line_index, column_offset: column_offset, line: line, continuation: index.positive? }
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def editor_visual_row_count(line, text_width)
|
|
187
|
+
length = line.to_s.length
|
|
188
|
+
return 1 if length.zero?
|
|
189
|
+
|
|
190
|
+
((length - 1) / text_width) + 1
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def editor_visual_row_for(line_index, column, text_width)
|
|
194
|
+
before = @editor_state.lines.first(line_index).sum { |line| editor_visual_row_count(line, text_width) }
|
|
195
|
+
before + (editor_visual_row_start_column(line_index, column, text_width) / text_width)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def editor_visual_row_start_column(line_index, column, text_width)
|
|
199
|
+
line = @editor_state.lines[line_index].to_s
|
|
200
|
+
return 0 if column.to_i.zero?
|
|
201
|
+
return column.to_i - text_width if column.to_i == line.length && (column.to_i % text_width).zero?
|
|
202
|
+
|
|
203
|
+
(column.to_i / text_width) * text_width
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def editor_line_number_gutter(line_index)
|
|
207
|
+
number = editor_display_line_number(line_index).to_s.rjust(editor_line_number_gutter_width - 3)
|
|
208
|
+
colored("#{number} │ ", :dark_forest_green)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def editor_display_line_number(line_index)
|
|
212
|
+
return line_index + 1 unless current_editor_line_numbers == "relative"
|
|
213
|
+
return line_index + 1 if @editor_state.readonly?
|
|
214
|
+
|
|
215
|
+
cursor_line, = @editor_state.cursor_line_and_column
|
|
216
|
+
line_index == cursor_line ? line_index + 1 : (line_index - cursor_line).abs
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def editor_blank_line_number_gutter
|
|
220
|
+
colored(" " * editor_line_number_gutter_width, :dark_forest_green)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def editor_top_border(width)
|
|
224
|
+
title_prefix = @editor_state.diff_view? ? "Diff" : "Edit"
|
|
225
|
+
dirty_marker = @editor_state.dirty? && !@editor_state.readonly? ? " *" : ""
|
|
226
|
+
title = visible_truncate("#{title_prefix} #{editor_display_path}#{dirty_marker}", [width - 4, 1].max)
|
|
227
|
+
plain_title = ANSI.strip(title)
|
|
228
|
+
"#{colored("╭", :primary_green)} #{title} #{colored("─" * [width - plain_title.length - 4, 0].max, :primary_green)}#{colored("╮", :primary_green)}"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def editor_display_path
|
|
232
|
+
path = @editor_state.path || @editor_state.display_path
|
|
233
|
+
Pathname.new(path).relative_path_from(Pathname.new(Dir.pwd)).to_s
|
|
234
|
+
rescue StandardError
|
|
235
|
+
@editor_state.display_path || @editor_state.path
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def editor_status_text
|
|
239
|
+
text = @editor_state.search_active ? "#{@editor_state.search_direction == :backward ? "Search backward" : "Search"}: #{@editor_state.search_query}" : @editor_state.status
|
|
240
|
+
visible_truncate(text, [screen_width - 4, 1].max)
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Interactive terminal UI used by the CLI frontend.
|
|
4
|
+
class PromptInterface
|
|
5
|
+
# Incremental search state and operations for editor buffers.
|
|
6
|
+
class EditorSearch
|
|
7
|
+
attr_reader :query, :direction
|
|
8
|
+
|
|
9
|
+
def initialize(direction: :forward)
|
|
10
|
+
@active = false
|
|
11
|
+
@query = +""
|
|
12
|
+
@direction = direction
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def active?
|
|
16
|
+
@active == true
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def begin(direction = :forward)
|
|
20
|
+
@active = true
|
|
21
|
+
@direction = direction
|
|
22
|
+
@query = +""
|
|
23
|
+
status_prefix
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def cancel
|
|
27
|
+
@active = false
|
|
28
|
+
"Search cancelled"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def append(text)
|
|
32
|
+
@query << text.to_s
|
|
33
|
+
"#{status_prefix} #{@query}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def delete_character
|
|
37
|
+
@query = @query[0...-1].to_s
|
|
38
|
+
"#{status_prefix} #{@query}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def confirm(buffer:, cursor:)
|
|
42
|
+
confirmed_query = @query.to_s
|
|
43
|
+
@active = false
|
|
44
|
+
return { status: "Search cancelled", found: false } if confirmed_query.empty?
|
|
45
|
+
|
|
46
|
+
repeat(buffer: buffer, cursor: cursor, direction: @direction, query: confirmed_query)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def repeat(buffer:, cursor:, direction: @direction, query: @query)
|
|
50
|
+
query = query.to_s
|
|
51
|
+
return { status: "No previous search", found: false } if query.empty?
|
|
52
|
+
|
|
53
|
+
@query = query
|
|
54
|
+
@direction = direction
|
|
55
|
+
index = if direction == :backward
|
|
56
|
+
search_from = cursor.positive? ? cursor - 1 : buffer.length
|
|
57
|
+
buffer.rindex(query, search_from) || buffer.rindex(query)
|
|
58
|
+
else
|
|
59
|
+
buffer.index(query, cursor + 1) || buffer.index(query)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
if index
|
|
63
|
+
{ cursor: index, status: "Found: #{query}", found: true }
|
|
64
|
+
else
|
|
65
|
+
{ status: "No match: #{query}", found: false }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def status_prefix
|
|
72
|
+
@direction == :backward ? "Search backward:" : "Search:"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Interactive terminal UI used by the CLI frontend.
|
|
4
|
+
class PromptInterface
|
|
5
|
+
# Primary and secondary selection/cursor storage for editor buffers.
|
|
6
|
+
class EditorSelections
|
|
7
|
+
attr_reader :anchor, :secondary
|
|
8
|
+
|
|
9
|
+
def initialize(cursor:, buffer_length:, anchor: nil, secondary: [])
|
|
10
|
+
@cursor = cursor
|
|
11
|
+
@buffer_length = buffer_length
|
|
12
|
+
@anchor = anchor.nil? ? nil : clamp_offset(anchor)
|
|
13
|
+
@secondary = secondary.map { |selection| normalized_selection(selection) }
|
|
14
|
+
normalize
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def cursor=(value)
|
|
18
|
+
@cursor = value
|
|
19
|
+
normalize
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def buffer_length=(value)
|
|
23
|
+
@buffer_length = value.to_i
|
|
24
|
+
@anchor = clamp_offset(@anchor) unless @anchor.nil?
|
|
25
|
+
@secondary = @secondary.map { |selection| normalized_selection(selection) }
|
|
26
|
+
normalize
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def anchor=(value)
|
|
30
|
+
@anchor = value.nil? ? nil : clamp_offset(value)
|
|
31
|
+
normalize
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def all
|
|
35
|
+
normalize
|
|
36
|
+
[primary] + @secondary.map(&:dup)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def multi_cursor?
|
|
40
|
+
normalize
|
|
41
|
+
@secondary.any?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def set(values)
|
|
45
|
+
first, *rest = values.to_a
|
|
46
|
+
if first
|
|
47
|
+
@anchor = first[:anchor]
|
|
48
|
+
@cursor = first[:cursor]
|
|
49
|
+
else
|
|
50
|
+
@anchor = nil
|
|
51
|
+
@cursor = 0
|
|
52
|
+
end
|
|
53
|
+
@secondary = rest.map { |selection| normalized_selection(selection) }
|
|
54
|
+
normalize
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def add(anchor, cursor = anchor)
|
|
58
|
+
@secondary << normalized_selection(anchor: anchor, cursor: cursor)
|
|
59
|
+
normalize
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def clear
|
|
63
|
+
@anchor = nil
|
|
64
|
+
@secondary = []
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def collapse_to_primary
|
|
68
|
+
@secondary = []
|
|
69
|
+
@anchor = nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def secondary_cursor_offsets
|
|
73
|
+
normalize
|
|
74
|
+
@secondary.filter_map do |selection|
|
|
75
|
+
selection[:cursor] if selection[:anchor] == selection[:cursor]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def primary
|
|
80
|
+
{ anchor: @anchor || @cursor, cursor: @cursor }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def primary_active?(vibe_visual: false)
|
|
84
|
+
return false if @anchor.nil?
|
|
85
|
+
return true if vibe_visual
|
|
86
|
+
|
|
87
|
+
@anchor != @cursor
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def range_for(selection)
|
|
91
|
+
[selection[:anchor], selection[:cursor]].minmax
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def normalize
|
|
97
|
+
seen = { [primary[:anchor], primary[:cursor]] => true }
|
|
98
|
+
@secondary = @secondary.filter_map do |selection|
|
|
99
|
+
normalized = normalized_selection(selection)
|
|
100
|
+
key = [normalized[:anchor], normalized[:cursor]]
|
|
101
|
+
next if seen[key]
|
|
102
|
+
|
|
103
|
+
seen[key] = true
|
|
104
|
+
normalized
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def normalized_selection(selection)
|
|
109
|
+
{
|
|
110
|
+
anchor: clamp_offset(selection[:anchor]),
|
|
111
|
+
cursor: clamp_offset(selection[:cursor])
|
|
112
|
+
}
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def clamp_offset(value)
|
|
116
|
+
[[value.to_i, 0].max, @buffer_length].min
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|