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,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