openclacky 0.5.6 → 0.6.1

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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +71 -0
  3. data/docs/ui2-architecture.md +124 -0
  4. data/lib/clacky/agent.rb +376 -346
  5. data/lib/clacky/agent_config.rb +1 -7
  6. data/lib/clacky/cli.rb +167 -398
  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 +66 -10
  12. data/lib/clacky/tools/grep.rb +6 -122
  13. data/lib/clacky/tools/run_project.rb +10 -5
  14. data/lib/clacky/tools/safe_shell.rb +149 -20
  15. data/lib/clacky/tools/shell.rb +3 -51
  16. data/lib/clacky/tools/todo_manager.rb +50 -3
  17. data/lib/clacky/tools/trash_manager.rb +1 -1
  18. data/lib/clacky/tools/web_fetch.rb +4 -4
  19. data/lib/clacky/tools/web_search.rb +40 -28
  20. data/lib/clacky/ui2/README.md +214 -0
  21. data/lib/clacky/ui2/components/base_component.rb +163 -0
  22. data/lib/clacky/ui2/components/common_component.rb +98 -0
  23. data/lib/clacky/ui2/components/inline_input.rb +187 -0
  24. data/lib/clacky/ui2/components/input_area.rb +1124 -0
  25. data/lib/clacky/ui2/components/message_component.rb +80 -0
  26. data/lib/clacky/ui2/components/output_area.rb +112 -0
  27. data/lib/clacky/ui2/components/todo_area.rb +130 -0
  28. data/lib/clacky/ui2/components/tool_component.rb +106 -0
  29. data/lib/clacky/ui2/components/welcome_banner.rb +103 -0
  30. data/lib/clacky/ui2/layout_manager.rb +437 -0
  31. data/lib/clacky/ui2/line_editor.rb +201 -0
  32. data/lib/clacky/ui2/markdown_renderer.rb +80 -0
  33. data/lib/clacky/ui2/screen_buffer.rb +257 -0
  34. data/lib/clacky/ui2/theme_manager.rb +68 -0
  35. data/lib/clacky/ui2/themes/base_theme.rb +85 -0
  36. data/lib/clacky/ui2/themes/hacker_theme.rb +58 -0
  37. data/lib/clacky/ui2/themes/minimal_theme.rb +52 -0
  38. data/lib/clacky/ui2/ui_controller.rb +778 -0
  39. data/lib/clacky/ui2/view_renderer.rb +177 -0
  40. data/lib/clacky/ui2.rb +37 -0
  41. data/lib/clacky/utils/file_ignore_helper.rb +126 -0
  42. data/lib/clacky/version.rb +1 -1
  43. data/lib/clacky.rb +1 -6
  44. metadata +53 -6
  45. data/lib/clacky/ui/banner.rb +0 -155
  46. data/lib/clacky/ui/enhanced_prompt.rb +0 -786
  47. data/lib/clacky/ui/formatter.rb +0 -209
  48. data/lib/clacky/ui/statusbar.rb +0 -96
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-markdown"
4
+ require_relative "theme_manager"
5
+
6
+ module Clacky
7
+ module UI2
8
+ # MarkdownRenderer handles rendering Markdown content with syntax highlighting
9
+ module MarkdownRenderer
10
+ class << self
11
+ # Render markdown content with theme-aware colors
12
+ # @param content [String] Markdown content to render
13
+ # @return [String] Rendered content with ANSI colors
14
+ def render(content)
15
+ return content if content.nil? || content.empty?
16
+
17
+ # Get current theme colors
18
+ theme = ThemeManager.current_theme
19
+
20
+ # Configure tty-markdown colors based on current theme
21
+ # tty-markdown uses Pastel internally, we can configure symbols
22
+ parsed = TTY::Markdown.parse(content,
23
+ colors: theme_colors,
24
+ width: TTY::Screen.width - 4 # Leave some margin
25
+ )
26
+
27
+ parsed
28
+ rescue StandardError => e
29
+ # Fallback to plain content if rendering fails
30
+ content
31
+ end
32
+
33
+ # Check if content looks like markdown
34
+ # @param content [String] Content to check
35
+ # @return [Boolean] true if content appears to be markdown
36
+ def markdown?(content)
37
+ return false if content.nil? || content.empty?
38
+
39
+ # Check for common markdown patterns
40
+ content.match?(/^#+ /) || # Headers
41
+ content.match?(/```/) || # Code blocks
42
+ content.match?(/^\s*[-*+] /) || # Unordered lists
43
+ content.match?(/^\s*\d+\. /) || # Ordered lists
44
+ content.match?(/\[.+\]\(.+\)/) || # Links
45
+ content.match?(/^\s*> /) || # Blockquotes
46
+ content.match?(/\*\*.+\*\*/) || # Bold
47
+ content.match?(/`.+`/) || # Inline code
48
+ content.match?(/^\s*\|.+\|/) || # Tables
49
+ content.match?(/^---+$/) # Horizontal rules
50
+ end
51
+
52
+ private
53
+
54
+ # Get theme-aware colors for markdown rendering
55
+ # @return [Hash] Color configuration for tty-markdown
56
+ def theme_colors
57
+ theme = ThemeManager.current_theme
58
+
59
+ # Map our theme colors to tty-markdown's expected format
60
+ {
61
+ # Headers use info color (cyan/blue)
62
+ header: theme.colors[:info],
63
+ # Code blocks use dim color
64
+ code: theme.colors[:thinking],
65
+ # Links use success color (green)
66
+ link: theme.colors[:success],
67
+ # Lists use default text color
68
+ list: :bright_white,
69
+ # Strong/bold use bright white
70
+ strong: :bright_white,
71
+ # Emphasis/italic use white
72
+ em: :white,
73
+ # Note/blockquote use dim color
74
+ note: theme.colors[:thinking],
75
+ }
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-screen"
4
+ require "io/console"
5
+
6
+ module Clacky
7
+ module UI2
8
+ # ScreenBuffer manages terminal screen state and provides low-level rendering primitives
9
+ class ScreenBuffer
10
+ attr_reader :width, :height
11
+
12
+ def initialize
13
+ @width = TTY::Screen.width
14
+ @height = TTY::Screen.height
15
+ @buffer = []
16
+ @last_input_time = nil
17
+ @rapid_input_threshold = 0.01 # 10ms threshold for detecting paste-like rapid input
18
+ setup_resize_handler
19
+ end
20
+
21
+ # Move cursor to specific position (0-indexed)
22
+ # @param row [Integer] Row position
23
+ # @param col [Integer] Column position
24
+ def move_cursor(row, col)
25
+ print "\e[#{row + 1};#{col + 1}H"
26
+ end
27
+
28
+ # Clear entire screen
29
+ def clear_screen
30
+ print "\e[2J"
31
+ move_cursor(0, 0)
32
+ end
33
+
34
+ # Clear current line
35
+ def clear_line
36
+ print "\e[2K"
37
+ end
38
+
39
+ # Clear from cursor to end of line
40
+ def clear_to_eol
41
+ print "\e[K"
42
+ end
43
+
44
+ # Hide cursor
45
+ def hide_cursor
46
+ print "\e[?25l"
47
+ end
48
+
49
+ # Show cursor
50
+ def show_cursor
51
+ print "\e[?25h"
52
+ end
53
+
54
+ # Save cursor position
55
+ def save_cursor
56
+ print "\e[s"
57
+ end
58
+
59
+ # Restore cursor position
60
+ def restore_cursor
61
+ print "\e[u"
62
+ end
63
+
64
+ # Enable alternative screen buffer (like vim/less)
65
+ def enable_alt_screen
66
+ print "\e[?1049h"
67
+ end
68
+
69
+ # Disable alternative screen buffer
70
+ def disable_alt_screen
71
+ print "\e[?1049l"
72
+ end
73
+
74
+ # Set scroll region (DECSTBM - DEC Set Top and Bottom Margins)
75
+ # Content written in this region will scroll, content outside will stay fixed
76
+ # @param top [Integer] Top row (1-indexed)
77
+ # @param bottom [Integer] Bottom row (1-indexed)
78
+ def set_scroll_region(top, bottom)
79
+ print "\e[#{top};#{bottom}r"
80
+ end
81
+
82
+ # Reset scroll region to full screen
83
+ def reset_scroll_region
84
+ print "\e[r"
85
+ end
86
+
87
+ # Scroll the scroll region up by n lines
88
+ # @param n [Integer] Number of lines to scroll
89
+ def scroll_up(n = 1)
90
+ print "\e[#{n}S"
91
+ end
92
+
93
+ # Scroll the scroll region down by n lines
94
+ # @param n [Integer] Number of lines to scroll
95
+ def scroll_down(n = 1)
96
+ print "\e[#{n}T"
97
+ end
98
+
99
+ # Get current screen dimensions
100
+ def update_dimensions
101
+ @width = TTY::Screen.width
102
+ @height = TTY::Screen.height
103
+ end
104
+
105
+ # Enable raw mode (disable line buffering)
106
+ def enable_raw_mode
107
+ $stdin.raw!
108
+ end
109
+
110
+ # Disable raw mode
111
+ def disable_raw_mode
112
+ $stdin.cooked!
113
+ end
114
+
115
+ # Read a single character without echo
116
+ # @param timeout [Float] Timeout in seconds (nil for blocking)
117
+ # @return [String, nil] Character or nil if timeout
118
+ def read_char(timeout: nil)
119
+ if timeout
120
+ return nil unless IO.select([$stdin], nil, nil, timeout)
121
+ end
122
+
123
+ $stdin.getc
124
+ end
125
+
126
+ # Read a key including special keys (arrows, etc.)
127
+ # @param timeout [Float] Timeout in seconds
128
+ # @return [Symbol, String, Hash, nil] Key symbol, character, or { type: :rapid_input, text: String }
129
+ def read_key(timeout: nil)
130
+ $stdin.set_encoding('UTF-8')
131
+
132
+ current_time = Time.now.to_f
133
+ is_rapid_input = @last_input_time && (current_time - @last_input_time) < @rapid_input_threshold
134
+ @last_input_time = current_time
135
+
136
+ char = read_char(timeout: timeout)
137
+ return nil unless char
138
+
139
+ # Ensure character is UTF-8 encoded
140
+ char = char.force_encoding('UTF-8') if char.is_a?(String) && char.encoding != Encoding::UTF_8
141
+
142
+ # Handle escape sequences for special keys
143
+ if char == "\e"
144
+ # Non-blocking read for escape sequence
145
+ char2 = read_char(timeout: 0.01)
146
+ return :escape unless char2
147
+
148
+ if char2 == "["
149
+ char3 = read_char(timeout: 0.01)
150
+ case char3
151
+ when "A" then return :up_arrow
152
+ when "B" then return :down_arrow
153
+ when "C" then return :right_arrow
154
+ when "D" then return :left_arrow
155
+ when "H" then return :home
156
+ when "F" then return :end
157
+ when "Z" then return :shift_tab
158
+ when "3"
159
+ char4 = read_char(timeout: 0.01)
160
+ return :delete if char4 == "~"
161
+ end
162
+ end
163
+ end
164
+
165
+ # Check if there are more characters available (for rapid input detection)
166
+ has_more_input = IO.select([$stdin], nil, nil, 0)
167
+
168
+ # If this is rapid input or there are more characters available
169
+ if is_rapid_input || has_more_input
170
+ buffer = char.to_s.dup
171
+ buffer.force_encoding('UTF-8')
172
+
173
+ # Keep reading available characters
174
+ loop_count = 0
175
+ empty_checks = 0
176
+
177
+ loop do
178
+ # Check if there's data available immediately
179
+ has_data = IO.select([$stdin], nil, nil, 0)
180
+
181
+ if has_data
182
+ next_char = $stdin.getc
183
+ break unless next_char
184
+
185
+ next_char = next_char.force_encoding('UTF-8') if next_char.encoding != Encoding::UTF_8
186
+ buffer << next_char
187
+ loop_count += 1
188
+ empty_checks = 0 # Reset empty check counter
189
+ else
190
+ # No immediate data, but wait a bit to see if more is coming
191
+ # This handles the case where paste data arrives in chunks
192
+ empty_checks += 1
193
+ if empty_checks == 1
194
+ # First empty check - wait 10ms for more data
195
+ sleep 0.01
196
+ else
197
+ # Second empty check - really no more data
198
+ break
199
+ end
200
+ end
201
+ end
202
+
203
+ # If we buffered multiple characters or newlines, treat as rapid input (paste)
204
+ if buffer.length > 1 || buffer.include?("\n") || buffer.include?("\r")
205
+ # Remove any trailing \r or \n from rapid input buffer
206
+ cleaned_buffer = buffer.gsub(/[\r\n]+\z/, '')
207
+ return { type: :rapid_input, text: cleaned_buffer } if cleaned_buffer.length > 0
208
+ end
209
+
210
+ # Single character, continue to normal handling
211
+ char = buffer[0] if buffer.length == 1
212
+ end
213
+
214
+ # Handle control characters
215
+ case char
216
+ when "\r" then :enter
217
+ when "\n" then :newline # Shift+Enter sends \n
218
+ when "\u007F", "\b" then :backspace
219
+ when "\u0001" then :ctrl_a
220
+ when "\u0002" then :ctrl_b
221
+ when "\u0003" then :ctrl_c
222
+ when "\u0004" then :ctrl_d
223
+ when "\u0005" then :ctrl_e
224
+ when "\u0006" then :ctrl_f
225
+ when "\u000B" then :ctrl_k
226
+ when "\u000C" then :ctrl_l
227
+ when "\u0012" then :ctrl_r
228
+ when "\u0015" then :ctrl_u
229
+ when "\u0016" then :ctrl_v
230
+ when "\u0017" then :ctrl_w
231
+ else char
232
+ end
233
+ end
234
+
235
+ # Flush output
236
+ def flush
237
+ $stdout.flush
238
+ end
239
+
240
+ private
241
+
242
+ # Setup handler for terminal resize (SIGWINCH)
243
+ def setup_resize_handler
244
+ Signal.trap("WINCH") do
245
+ update_dimensions
246
+ @resize_callback&.call(@width, @height)
247
+ end
248
+ end
249
+
250
+ # Register callback for resize events
251
+ # @param block [Proc] Callback to execute on resize
252
+ def on_resize(&block)
253
+ @resize_callback = block
254
+ end
255
+ end
256
+ end
257
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "themes/base_theme"
4
+ require_relative "themes/hacker_theme"
5
+ require_relative "themes/minimal_theme"
6
+
7
+ module Clacky
8
+ module UI2
9
+ # ThemeManager handles theme registration and switching
10
+ class ThemeManager
11
+ class << self
12
+ def instance
13
+ @instance ||= new
14
+ end
15
+
16
+ # Delegate methods to instance
17
+ def current_theme
18
+ instance.current_theme
19
+ end
20
+
21
+ def set_theme(name)
22
+ instance.set_theme(name)
23
+ end
24
+
25
+ def available_themes
26
+ instance.available_themes
27
+ end
28
+
29
+ def register_theme(name, theme_class)
30
+ instance.register_theme(name, theme_class)
31
+ end
32
+ end
33
+
34
+ def initialize
35
+ @themes = {}
36
+ @current_theme = nil
37
+ register_default_themes
38
+ set_theme(:hacker)
39
+ end
40
+
41
+ def current_theme
42
+ @current_theme
43
+ end
44
+
45
+ def set_theme(name)
46
+ name = name.to_sym
47
+ raise ArgumentError, "Unknown theme: #{name}" unless @themes.key?(name)
48
+
49
+ @current_theme = @themes[name].new
50
+ end
51
+
52
+ def available_themes
53
+ @themes.keys
54
+ end
55
+
56
+ def register_theme(name, theme_class)
57
+ @themes[name.to_sym] = theme_class
58
+ end
59
+
60
+ private
61
+
62
+ def register_default_themes
63
+ register_theme(:hacker, Themes::HackerTheme)
64
+ register_theme(:minimal, Themes::MinimalTheme)
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+
5
+ module Clacky
6
+ module UI2
7
+ module Themes
8
+ # BaseTheme defines the abstract interface for all themes
9
+ # Subclasses MUST define SYMBOLS and COLORS constants
10
+ class BaseTheme
11
+ def initialize
12
+ @pastel = Pastel.new
13
+ validate_theme_definition!
14
+ end
15
+
16
+ # Get all symbols defined by this theme
17
+ # @return [Hash] Symbol definitions
18
+ def symbols
19
+ self.class::SYMBOLS
20
+ end
21
+
22
+ # Get all colors defined by this theme
23
+ # @return [Hash] Color definitions
24
+ def colors
25
+ self.class::COLORS
26
+ end
27
+
28
+ # Get symbol for a specific key
29
+ # @param key [Symbol] Symbol key
30
+ # @return [String] Symbol string
31
+ def symbol(key)
32
+ symbols[key] || "[??]"
33
+ end
34
+
35
+ # Get symbol color for a specific key
36
+ # @param key [Symbol] Color key
37
+ # @return [Symbol] Pastel color method name
38
+ def symbol_color(key)
39
+ colors.dig(key, 0) || :white
40
+ end
41
+
42
+ # Get text color for a specific key
43
+ # @param key [Symbol] Color key
44
+ # @return [Symbol] Pastel color method name
45
+ def text_color(key)
46
+ colors.dig(key, 1) || :white
47
+ end
48
+
49
+ # Format symbol with its color
50
+ # @param key [Symbol] Symbol key (e.g., :user, :assistant)
51
+ # @return [String] Colored symbol
52
+ def format_symbol(key)
53
+ @pastel.public_send(symbol_color(key), symbol(key))
54
+ end
55
+
56
+ # Format text with color for given key
57
+ # @param text [String] Text to format
58
+ # @param key [Symbol] Color key (e.g., :user, :assistant)
59
+ # @return [String] Colored text
60
+ def format_text(text, key)
61
+ @pastel.public_send(text_color(key), text)
62
+ end
63
+
64
+ # Theme name for display (subclasses should override)
65
+ # @return [String] Theme name
66
+ def name
67
+ raise NotImplementedError, "Subclass must implement #name method"
68
+ end
69
+
70
+ private
71
+
72
+ # Validate that subclass has defined required constants
73
+ def validate_theme_definition!
74
+ unless self.class.const_defined?(:SYMBOLS)
75
+ raise NotImplementedError, "Theme #{self.class.name} must define SYMBOLS constant"
76
+ end
77
+
78
+ unless self.class.const_defined?(:COLORS)
79
+ raise NotImplementedError, "Theme #{self.class.name} must define COLORS constant"
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_theme"
4
+
5
+ module Clacky
6
+ module UI2
7
+ module Themes
8
+ # HackerTheme - Matrix/hacker-style with bracket symbols
9
+ class HackerTheme < BaseTheme
10
+ SYMBOLS = {
11
+ user: "[>>]",
12
+ assistant: "[<<]",
13
+ tool_call: "[=>]",
14
+ tool_result: "[<=]",
15
+ tool_denied: "[!!]",
16
+ tool_planned: "[??]",
17
+ tool_error: "[XX]",
18
+ thinking: "[..]",
19
+ working: "[..]",
20
+ success: "[OK]",
21
+ error: "[ER]",
22
+ warning: "[!!]",
23
+ info: "[--]",
24
+ task: "[##]",
25
+ progress: "[>>]",
26
+ file: "[F]",
27
+ command: "[C]",
28
+ cached: "[*]"
29
+ }.freeze
30
+
31
+ COLORS = {
32
+ user: [:bright_blue, :blue],
33
+ assistant: [:bright_green, :white],
34
+ tool_call: [:bright_cyan, :cyan],
35
+ tool_result: [:cyan, :white],
36
+ tool_denied: [:bright_yellow, :yellow],
37
+ tool_planned: [:bright_blue, :blue],
38
+ tool_error: [:bright_red, :red],
39
+ thinking: [:dim, :dim],
40
+ working: [:bright_yellow, :yellow],
41
+ success: [:bright_green, :green],
42
+ error: [:bright_red, :red],
43
+ warning: [:bright_yellow, :yellow],
44
+ info: [:bright_white, :white],
45
+ task: [:bright_yellow, :white],
46
+ progress: [:bright_cyan, :cyan],
47
+ file: [:cyan, :white],
48
+ command: [:cyan, :white],
49
+ cached: [:cyan, :cyan]
50
+ }.freeze
51
+
52
+ def name
53
+ "hacker"
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_theme"
4
+
5
+ module Clacky
6
+ module UI2
7
+ module Themes
8
+ # MinimalTheme - Clean, simple symbols
9
+ class MinimalTheme < BaseTheme
10
+ SYMBOLS = {
11
+ user: ">",
12
+ assistant: "<",
13
+ tool_call: "*",
14
+ tool_result: "-",
15
+ tool_denied: "!",
16
+ tool_planned: "?",
17
+ tool_error: "x",
18
+ thinking: ".",
19
+ working: ".",
20
+ success: "+",
21
+ error: "x",
22
+ warning: "!",
23
+ info: "-",
24
+ task: "#",
25
+ progress: ">"
26
+ }.freeze
27
+
28
+ COLORS = {
29
+ user: [:blue, :blue],
30
+ assistant: [:green, :white],
31
+ tool_call: [:cyan, :cyan],
32
+ tool_result: [:white, :white],
33
+ tool_denied: [:yellow, :yellow],
34
+ tool_planned: [:blue, :blue],
35
+ tool_error: [:red, :red],
36
+ thinking: [:dim, :dim],
37
+ working: [:bright_yellow, :yellow],
38
+ success: [:green, :green],
39
+ error: [:red, :red],
40
+ warning: [:yellow, :yellow],
41
+ info: [:white, :white],
42
+ task: [:yellow, :white],
43
+ progress: [:cyan, :cyan]
44
+ }.freeze
45
+
46
+ def name
47
+ "minimal"
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end