openclacky 0.5.6 → 0.6.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +43 -0
  3. data/docs/ui2-architecture.md +124 -0
  4. data/lib/clacky/agent.rb +245 -340
  5. data/lib/clacky/agent_config.rb +1 -7
  6. data/lib/clacky/cli.rb +156 -397
  7. data/lib/clacky/client.rb +68 -36
  8. data/lib/clacky/gitignore_parser.rb +26 -12
  9. data/lib/clacky/model_pricing.rb +6 -2
  10. data/lib/clacky/session_manager.rb +6 -2
  11. data/lib/clacky/tools/glob.rb +65 -9
  12. data/lib/clacky/tools/grep.rb +4 -120
  13. data/lib/clacky/tools/run_project.rb +5 -0
  14. data/lib/clacky/tools/safe_shell.rb +49 -13
  15. data/lib/clacky/tools/shell.rb +1 -49
  16. data/lib/clacky/tools/web_fetch.rb +2 -2
  17. data/lib/clacky/tools/web_search.rb +38 -26
  18. data/lib/clacky/ui2/README.md +214 -0
  19. data/lib/clacky/ui2/components/base_component.rb +163 -0
  20. data/lib/clacky/ui2/components/common_component.rb +89 -0
  21. data/lib/clacky/ui2/components/inline_input.rb +187 -0
  22. data/lib/clacky/ui2/components/input_area.rb +1029 -0
  23. data/lib/clacky/ui2/components/message_component.rb +76 -0
  24. data/lib/clacky/ui2/components/output_area.rb +112 -0
  25. data/lib/clacky/ui2/components/todo_area.rb +137 -0
  26. data/lib/clacky/ui2/components/tool_component.rb +106 -0
  27. data/lib/clacky/ui2/components/welcome_banner.rb +93 -0
  28. data/lib/clacky/ui2/layout_manager.rb +331 -0
  29. data/lib/clacky/ui2/line_editor.rb +201 -0
  30. data/lib/clacky/ui2/screen_buffer.rb +238 -0
  31. data/lib/clacky/ui2/theme_manager.rb +68 -0
  32. data/lib/clacky/ui2/themes/base_theme.rb +99 -0
  33. data/lib/clacky/ui2/themes/hacker_theme.rb +56 -0
  34. data/lib/clacky/ui2/themes/minimal_theme.rb +50 -0
  35. data/lib/clacky/ui2/ui_controller.rb +720 -0
  36. data/lib/clacky/ui2/view_renderer.rb +160 -0
  37. data/lib/clacky/ui2.rb +37 -0
  38. data/lib/clacky/utils/file_ignore_helper.rb +126 -0
  39. data/lib/clacky/version.rb +1 -1
  40. data/lib/clacky.rb +1 -6
  41. metadata +38 -6
  42. data/lib/clacky/ui/banner.rb +0 -155
  43. data/lib/clacky/ui/enhanced_prompt.rb +0 -786
  44. data/lib/clacky/ui/formatter.rb +0 -209
  45. data/lib/clacky/ui/statusbar.rb +0 -96
@@ -0,0 +1,331 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "screen_buffer"
4
+
5
+ module Clacky
6
+ module UI2
7
+ # LayoutManager manages screen layout with split areas (output area on top, input area on bottom)
8
+ class LayoutManager
9
+ attr_reader :screen, :output_area, :input_area, :todo_area
10
+
11
+ def initialize(output_area:, input_area:, todo_area: nil)
12
+ @screen = ScreenBuffer.new
13
+ @output_area = output_area
14
+ @input_area = input_area
15
+ @todo_area = todo_area
16
+ @render_mutex = Mutex.new
17
+ @output_row = 0 # Track current output row position
18
+
19
+ calculate_layout
20
+ setup_resize_handler
21
+ end
22
+
23
+ # Calculate layout dimensions based on screen size and component heights
24
+ def calculate_layout
25
+ todo_height = @todo_area&.height || 0
26
+ input_height = @input_area.required_height
27
+ gap_height = 1 # Blank line between output and input
28
+
29
+ # Layout: output -> gap -> todo -> input (with its own separators and status)
30
+ @output_height = screen.height - gap_height - todo_height - input_height
31
+ @output_height = [1, @output_height].max # Minimum 1 line for output
32
+
33
+ @gap_row = @output_height
34
+ @todo_row = @gap_row + gap_height
35
+ @input_row = @todo_row + todo_height
36
+
37
+ # Update component dimensions
38
+ @output_area.height = @output_height
39
+ @input_area.row = @input_row
40
+ end
41
+
42
+ # Recalculate layout (called when input height changes)
43
+ def recalculate_layout
44
+ @render_mutex.synchronize do
45
+ # Save old layout values before recalculating
46
+ old_gap_row = @gap_row # This is the old fixed_area_start
47
+ old_input_row = @input_row
48
+
49
+ calculate_layout
50
+
51
+ # If layout changed, clear old fixed area and re-render at new position
52
+ if @input_row != old_input_row
53
+ # Clear old fixed area lines (from old gap_row to screen bottom)
54
+ ([old_gap_row, 0].max...screen.height).each do |row|
55
+ screen.move_cursor(row, 0)
56
+ screen.clear_line
57
+ end
58
+
59
+ # Re-render fixed areas at new position
60
+ render_fixed_areas
61
+ screen.flush
62
+ end
63
+ end
64
+ end
65
+
66
+ # Render all layout areas
67
+ def render_all
68
+ @render_mutex.synchronize do
69
+ render_all_internal
70
+ end
71
+ end
72
+
73
+ # Render output area - with native scroll, just ensure input stays in place
74
+ def render_output
75
+ @render_mutex.synchronize do
76
+ # Output is written directly, just need to re-render fixed areas
77
+ render_fixed_areas
78
+ screen.flush
79
+ end
80
+ end
81
+
82
+ # Render just the input area
83
+ def render_input
84
+ @render_mutex.synchronize do
85
+ # Clear and re-render entire fixed area to ensure consistency
86
+ render_fixed_areas
87
+ screen.flush
88
+ end
89
+ end
90
+
91
+ # Position cursor for inline input in output area
92
+ # @param inline_input [Components::InlineInput] InlineInput component
93
+ def position_inline_input_cursor(inline_input)
94
+ return unless inline_input
95
+
96
+ # InlineInput renders its own visual cursor via render_line_with_cursor
97
+ # (white background on cursor character), so we don't need terminal cursor.
98
+ # Just hide the terminal cursor to avoid showing two cursors.
99
+ screen.hide_cursor
100
+ screen.flush
101
+ end
102
+
103
+ # Update todos and re-render
104
+ # @param todos [Array<Hash>] Array of todo items
105
+ def update_todos(todos)
106
+ return unless @todo_area
107
+
108
+ @render_mutex.synchronize do
109
+ old_height = @todo_area.height
110
+ old_gap_row = @gap_row
111
+
112
+ @todo_area.update(todos)
113
+ new_height = @todo_area.height
114
+
115
+ # Recalculate layout if height changed
116
+ if old_height != new_height
117
+ calculate_layout
118
+
119
+ # Clear old fixed area lines (from old gap_row to screen bottom)
120
+ ([old_gap_row, 0].max...screen.height).each do |row|
121
+ screen.move_cursor(row, 0)
122
+ screen.clear_line
123
+ end
124
+ end
125
+
126
+ # Render fixed areas at new position
127
+ render_fixed_areas
128
+ screen.flush
129
+ end
130
+ end
131
+
132
+ # Initialize the screen (render initial content)
133
+ def initialize_screen
134
+ screen.clear_screen
135
+ screen.hide_cursor
136
+ @output_row = 0
137
+ render_all
138
+ end
139
+
140
+ # Cleanup the screen (restore cursor)
141
+ def cleanup_screen
142
+ @render_mutex.synchronize do
143
+ # Clear fixed areas (gap + todo + input)
144
+ fixed_start = fixed_area_start_row
145
+ (fixed_start...screen.height).each do |row|
146
+ screen.move_cursor(row, 0)
147
+ screen.clear_line
148
+ end
149
+
150
+ # Move cursor to start of a new line after last output
151
+ # Use \r to ensure we're at column 0, then move down
152
+ screen.move_cursor([@output_row, 0].max, 0)
153
+ print "\r" # Carriage return to column 0
154
+ screen.show_cursor
155
+ screen.flush
156
+ end
157
+ end
158
+
159
+ # Clear output area (for /clear command)
160
+ def clear_output
161
+ @render_mutex.synchronize do
162
+ # Clear all lines in output area (from 0 to fixed_area_start - 1)
163
+ max_output_row = fixed_area_start_row
164
+ (0...max_output_row).each do |row|
165
+ screen.move_cursor(row, 0)
166
+ screen.clear_line
167
+ end
168
+ # Reset output row position to start
169
+ @output_row = 0
170
+ # Re-render fixed areas to ensure they stay in place
171
+ render_fixed_areas
172
+ screen.flush
173
+ end
174
+ end
175
+
176
+ # Append content to output area
177
+ # Track current row, scroll when reaching fixed area
178
+ # @param content [String] Content to append
179
+ def append_output(content)
180
+ return if content.nil? || content.empty?
181
+
182
+ @render_mutex.synchronize do
183
+ max_output_row = fixed_area_start_row - 1
184
+
185
+ content.split("\n").each do |line|
186
+ # If at max row, need to scroll before outputting
187
+ if @output_row > max_output_row
188
+ # Move to bottom of screen and print newline to trigger scroll
189
+ screen.move_cursor(screen.height - 1, 0)
190
+ print "\n"
191
+ # Stay at max_output_row for next output
192
+ @output_row = max_output_row
193
+ end
194
+
195
+ # Output line at current position
196
+ screen.move_cursor(@output_row, 0)
197
+ screen.clear_line
198
+ output_area.append(line)
199
+ @output_row += 1
200
+ end
201
+
202
+ # Re-render fixed areas at screen bottom
203
+ render_fixed_areas
204
+ screen.flush
205
+ end
206
+ end
207
+
208
+ # Update the last line in output area (for progress indicator)
209
+ # @param content [String] Content to update
210
+ def update_last_line(content)
211
+ @render_mutex.synchronize do
212
+ # Last output line is at @output_row - 1
213
+ last_row = [@output_row - 1, 0].max
214
+ screen.move_cursor(last_row, 0)
215
+ screen.clear_line
216
+ output_area.append(content)
217
+ render_fixed_areas
218
+ screen.flush
219
+ end
220
+ end
221
+
222
+ # Remove the last line from output area
223
+ def remove_last_line
224
+ @render_mutex.synchronize do
225
+ last_row = [@output_row - 1, 0].max
226
+ screen.move_cursor(last_row, 0)
227
+ screen.clear_line
228
+ @output_row = last_row if @output_row > 0
229
+ render_fixed_areas
230
+ screen.flush
231
+ end
232
+ end
233
+
234
+ # Scroll output area up
235
+ # @param lines [Integer] Number of lines to scroll
236
+ def scroll_output_up(lines = 1)
237
+ output_area.scroll_up(lines)
238
+ render_output
239
+ end
240
+
241
+ # Scroll output area down
242
+ # @param lines [Integer] Number of lines to scroll
243
+ def scroll_output_down(lines = 1)
244
+ output_area.scroll_down(lines)
245
+ render_output
246
+ end
247
+
248
+ # Handle window resize
249
+ def handle_resize
250
+ old_gap_row = @gap_row
251
+
252
+ screen.update_dimensions
253
+ calculate_layout
254
+
255
+ # Adjust output_row if it exceeds new max
256
+ max_row = fixed_area_start_row - 1
257
+ @output_row = [@output_row, max_row].min
258
+
259
+ # Clear old fixed area lines
260
+ ([old_gap_row, 0].max...screen.height).each do |row|
261
+ screen.move_cursor(row, 0)
262
+ screen.clear_line
263
+ end
264
+
265
+ render_fixed_areas
266
+ screen.flush
267
+ end
268
+
269
+ private
270
+
271
+ # Calculate fixed area height (gap + todo + input)
272
+ def fixed_area_height
273
+ todo_height = @todo_area&.height || 0
274
+ input_height = @input_area.required_height
275
+ 1 + todo_height + input_height # gap + todo + input
276
+ end
277
+
278
+ # Calculate the starting row for fixed areas (from screen bottom)
279
+ def fixed_area_start_row
280
+ screen.height - fixed_area_height
281
+ end
282
+
283
+ # Render fixed areas (gap, todo, input) at screen bottom
284
+ def render_fixed_areas
285
+ # When input is paused (InlineInput active), don't render fixed areas
286
+ # The InlineInput is rendered inline with output
287
+ return if input_area.paused?
288
+
289
+ start_row = fixed_area_start_row
290
+ gap_row = start_row
291
+ todo_row = gap_row + 1
292
+ input_row = todo_row + (@todo_area&.height || 0)
293
+
294
+ # Render gap line
295
+ screen.move_cursor(gap_row, 0)
296
+ screen.clear_line
297
+
298
+ # Render todo
299
+ if @todo_area&.visible?
300
+ @todo_area.render(start_row: todo_row)
301
+ end
302
+
303
+ # Render input (InputArea renders its own visual cursor via render_line_with_cursor)
304
+ input_area.render(start_row: input_row, width: screen.width)
305
+ end
306
+
307
+ # Internal render all (without mutex)
308
+ def render_all_internal
309
+ output_area.render(start_row: 0)
310
+ render_fixed_areas
311
+ screen.flush
312
+ end
313
+
314
+ # Restore cursor to input area
315
+ def restore_cursor_to_input
316
+ input_row = fixed_area_start_row + 1 + (@todo_area&.height || 0)
317
+ input_area.position_cursor(input_row)
318
+ screen.show_cursor
319
+ end
320
+
321
+ # Setup handler for window resize
322
+ def setup_resize_handler
323
+ Signal.trap("WINCH") do
324
+ handle_resize
325
+ end
326
+ rescue ArgumentError
327
+ # Signal already trapped, ignore
328
+ end
329
+ end
330
+ end
331
+ end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+
5
+ module Clacky
6
+ module UI2
7
+ # LineEditor module provides single-line text editing functionality
8
+ # Shared by InputArea and InlineInput components
9
+ module LineEditor
10
+ attr_reader :cursor_position
11
+
12
+ def initialize_line_editor
13
+ @line = ""
14
+ @cursor_position = 0
15
+ @pastel = Pastel.new
16
+ end
17
+
18
+ # Get current line content
19
+ def current_line
20
+ @line
21
+ end
22
+
23
+ # Set line content
24
+ def set_line(text)
25
+ @line = text
26
+ @cursor_position = [@cursor_position, @line.chars.length].min
27
+ end
28
+
29
+ # Clear line
30
+ def clear_line_content
31
+ @line = ""
32
+ @cursor_position = 0
33
+ end
34
+
35
+ # Insert character at cursor position
36
+ def insert_char(char)
37
+ chars = @line.chars
38
+ chars.insert(@cursor_position, char)
39
+ @line = chars.join
40
+ @cursor_position += 1
41
+ end
42
+
43
+ # Backspace - delete character before cursor
44
+ def backspace
45
+ return if @cursor_position == 0
46
+ chars = @line.chars
47
+ chars.delete_at(@cursor_position - 1)
48
+ @line = chars.join
49
+ @cursor_position -= 1
50
+ end
51
+
52
+ # Delete character at cursor position
53
+ def delete_char
54
+ chars = @line.chars
55
+ return if @cursor_position >= chars.length
56
+ chars.delete_at(@cursor_position)
57
+ @line = chars.join
58
+ end
59
+
60
+ # Move cursor left
61
+ def cursor_left
62
+ @cursor_position = [@cursor_position - 1, 0].max
63
+ end
64
+
65
+ # Move cursor right
66
+ def cursor_right
67
+ @cursor_position = [@cursor_position + 1, @line.chars.length].min
68
+ end
69
+
70
+ # Move cursor to start of line
71
+ def cursor_home
72
+ @cursor_position = 0
73
+ end
74
+
75
+ # Move cursor to end of line
76
+ def cursor_end
77
+ @cursor_position = @line.chars.length
78
+ end
79
+
80
+ # Kill from cursor to end of line (Ctrl+K)
81
+ def kill_to_end
82
+ chars = @line.chars
83
+ @line = chars[0...@cursor_position].join
84
+ end
85
+
86
+ # Kill from start to cursor (Ctrl+U)
87
+ def kill_to_start
88
+ chars = @line.chars
89
+ @line = chars[@cursor_position..-1]&.join || ""
90
+ @cursor_position = 0
91
+ end
92
+
93
+ # Kill word before cursor (Ctrl+W)
94
+ def kill_word
95
+ chars = @line.chars
96
+ pos = @cursor_position - 1
97
+
98
+ # Skip whitespace
99
+ while pos >= 0 && chars[pos] =~ /\s/
100
+ pos -= 1
101
+ end
102
+ # Delete word characters
103
+ while pos >= 0 && chars[pos] =~ /\S/
104
+ pos -= 1
105
+ end
106
+
107
+ delete_start = pos + 1
108
+ chars.slice!(delete_start...@cursor_position)
109
+ @line = chars.join
110
+ @cursor_position = delete_start
111
+ end
112
+
113
+ # Insert text at cursor position
114
+ def insert_text(text)
115
+ return if text.nil? || text.empty?
116
+ chars = @line.chars
117
+ text.chars.each_with_index do |c, i|
118
+ chars.insert(@cursor_position + i, c)
119
+ end
120
+ @line = chars.join
121
+ @cursor_position += text.length
122
+ end
123
+
124
+ # Expand placeholders and normalize line endings
125
+ def expand_placeholders(text, placeholders)
126
+ result = text.dup
127
+ placeholders.each do |placeholder, actual_content|
128
+ # Normalize line endings to \n
129
+ normalized_content = actual_content.gsub(/\r\n|\r/, "\n")
130
+ result.gsub!(placeholder, normalized_content)
131
+ end
132
+ result
133
+ end
134
+
135
+ # Render line with cursor highlight
136
+ # @return [String] Rendered line with cursor
137
+ def render_line_with_cursor
138
+ chars = @line.chars
139
+ before_cursor = chars[0...@cursor_position].join
140
+ cursor_char = chars[@cursor_position] || " "
141
+ after_cursor = chars[(@cursor_position + 1)..-1]&.join || ""
142
+
143
+ "#{@pastel.white(before_cursor)}#{@pastel.on_white(@pastel.black(cursor_char))}#{@pastel.white(after_cursor)}"
144
+ end
145
+
146
+ # Calculate display width of a string, considering multi-byte characters
147
+ # East Asian Wide and Fullwidth characters (like Chinese) take 2 columns
148
+ # @param text [String] UTF-8 encoded text
149
+ # @return [Integer] Display width in terminal columns
150
+ def calculate_display_width(text)
151
+ width = 0
152
+ text.each_char do |char|
153
+ code = char.ord
154
+ # East Asian Wide and Fullwidth characters
155
+ # See: https://www.unicode.org/reports/tr11/
156
+ if (code >= 0x1100 && code <= 0x115F) || # Hangul Jamo
157
+ (code >= 0x2329 && code <= 0x232A) || # Left/Right-Pointing Angle Brackets
158
+ (code >= 0x2E80 && code <= 0x303E) || # CJK Radicals Supplement .. CJK Symbols and Punctuation
159
+ (code >= 0x3040 && code <= 0xA4CF) || # Hiragana .. Yi Radicals
160
+ (code >= 0xAC00 && code <= 0xD7A3) || # Hangul Syllables
161
+ (code >= 0xF900 && code <= 0xFAFF) || # CJK Compatibility Ideographs
162
+ (code >= 0xFE10 && code <= 0xFE19) || # Vertical Forms
163
+ (code >= 0xFE30 && code <= 0xFE6F) || # CJK Compatibility Forms .. Small Form Variants
164
+ (code >= 0xFF00 && code <= 0xFF60) || # Fullwidth Forms
165
+ (code >= 0xFFE0 && code <= 0xFFE6) || # Fullwidth Forms
166
+ (code >= 0x1F300 && code <= 0x1F9FF) || # Emoticons, Symbols, etc.
167
+ (code >= 0x20000 && code <= 0x2FFFD) || # CJK Unified Ideographs Extension B..F
168
+ (code >= 0x30000 && code <= 0x3FFFD) # CJK Unified Ideographs Extension G
169
+ width += 2
170
+ else
171
+ width += 1
172
+ end
173
+ end
174
+ width
175
+ end
176
+
177
+ # Strip ANSI escape codes from a string
178
+ # @param text [String] Text with ANSI codes
179
+ # @return [String] Text without ANSI codes
180
+ def strip_ansi_codes(text)
181
+ text.gsub(/\e\[[0-9;]*m/, '')
182
+ end
183
+
184
+ # Get cursor column position (considering multi-byte characters)
185
+ # @param prompt [String] Prompt string before the line (may contain ANSI codes)
186
+ # @return [Integer] Column position for cursor
187
+ def cursor_column(prompt = "")
188
+ # Strip ANSI codes from prompt to get actual display width
189
+ visible_prompt = strip_ansi_codes(prompt)
190
+ prompt_display_width = calculate_display_width(visible_prompt)
191
+
192
+ # Calculate display width of text before cursor
193
+ chars = @line.chars
194
+ text_before_cursor = chars[0...@cursor_position].join
195
+ text_display_width = calculate_display_width(text_before_cursor)
196
+
197
+ prompt_display_width + text_display_width
198
+ end
199
+ end
200
+ end
201
+ end