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,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_component"
4
+
5
+ module Clacky
6
+ module UI2
7
+ module Components
8
+ # MessageComponent renders user and assistant messages
9
+ class MessageComponent < BaseComponent
10
+ # Render a message
11
+ # @param data [Hash] Message data
12
+ # - :role [String] "user" or "assistant"
13
+ # - :content [String] Message content
14
+ # - :timestamp [Time, nil] Optional timestamp
15
+ # - :images [Array<String>] Optional image paths (for user messages)
16
+ # @return [String] Rendered message
17
+ def render(data)
18
+ role = data[:role]
19
+ content = data[:content]
20
+ timestamp = data[:timestamp]
21
+ images = data[:images] || []
22
+
23
+ case role
24
+ when "user"
25
+ render_user_message(content, timestamp, images)
26
+ when "assistant"
27
+ render_assistant_message(content, timestamp)
28
+ else
29
+ render_system_message(content, timestamp)
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ # Render user message
36
+ # @param content [String] Message content
37
+ # @param timestamp [Time, nil] Optional timestamp
38
+ # @param images [Array<String>] Optional image paths
39
+ # @return [String] Rendered message
40
+ def render_user_message(content, timestamp = nil, images = [])
41
+ symbol = format_symbol(:user)
42
+ text = format_text(content, :user)
43
+ time_str = timestamp ? @pastel.dim("[#{format_timestamp(timestamp)}]") : ""
44
+
45
+ "\n#{symbol} #{text} #{time_str}".rstrip
46
+ end
47
+
48
+ # Render assistant message
49
+ # @param content [String] Message content
50
+ # @param timestamp [Time, nil] Optional timestamp
51
+ # @return [String] Rendered message
52
+ def render_assistant_message(content, timestamp = nil)
53
+ return "" if content.nil? || content.empty?
54
+
55
+ symbol = format_symbol(:assistant)
56
+ text = format_text(content, :assistant)
57
+ time_str = timestamp ? @pastel.dim("[#{format_timestamp(timestamp)}]") : ""
58
+
59
+ "\n#{symbol} #{text} #{time_str}".rstrip
60
+ end
61
+
62
+ # Render system message
63
+ # @param content [String] Message content
64
+ # @param timestamp [Time, nil] Optional timestamp
65
+ # @return [String] Rendered message
66
+ def render_system_message(content, timestamp = nil)
67
+ symbol = format_symbol(:info)
68
+ text = format_text(content, :info)
69
+ time_str = timestamp ? @pastel.dim("[#{format_timestamp(timestamp)}]") : ""
70
+
71
+ "\n#{symbol} #{text} #{time_str}".rstrip
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+
5
+ module Clacky
6
+ module UI2
7
+ module Components
8
+ # OutputArea writes content directly to terminal
9
+ # Terminal handles scrolling natively via scrollback buffer
10
+ class OutputArea
11
+ attr_accessor :height
12
+
13
+ def initialize(height:)
14
+ @height = height
15
+ @pastel = Pastel.new
16
+ @width = TTY::Screen.width
17
+ @last_line_row = nil # Track last line position for updates
18
+ end
19
+
20
+ # Append single line content directly to terminal (no newline)
21
+ # Multi-line handling is done by LayoutManager
22
+ # @param content [String] Single line content to append
23
+ def append(content)
24
+ return if content.nil? || content.empty?
25
+
26
+ update_width
27
+ print wrap_line(content)
28
+ flush
29
+ end
30
+
31
+ # Initial render - no-op, output flows naturally
32
+ # @param start_row [Integer] Screen row (ignored)
33
+ def render(start_row:)
34
+ # No-op - output flows naturally from current position
35
+ end
36
+
37
+ # Update the last line (for progress indicator)
38
+ # Uses carriage return to overwrite current line
39
+ # @param content [String] New content for last line
40
+ def update_last_line(content)
41
+ print "\r"
42
+ clear_line
43
+ print truncate_line(content)
44
+ flush
45
+ end
46
+
47
+ # Remove the last line from output
48
+ def remove_last_line
49
+ print "\r"
50
+ clear_line
51
+ flush
52
+ end
53
+
54
+ # Clear - no-op for natural scroll mode
55
+ def clear
56
+ # No-op
57
+ end
58
+
59
+ # Legacy scroll methods (no-op, terminal handles scrolling)
60
+ def scroll_up(lines = 1); end
61
+ def scroll_down(lines = 1); end
62
+ def scroll_to_top; end
63
+ def scroll_to_bottom; end
64
+ def at_bottom?; true; end
65
+ def scroll_percentage; 0.0; end
66
+
67
+ def visible_range
68
+ { start: 1, end: @height, total: @height }
69
+ end
70
+
71
+ private
72
+
73
+ # Wrap line to fit screen width (auto line wrap)
74
+ def wrap_line(line)
75
+ return "" if line.nil?
76
+ # Let terminal handle line wrapping naturally
77
+ line
78
+ end
79
+
80
+ # Truncate line to fit screen width
81
+ def truncate_line(line)
82
+ return "" if line.nil?
83
+
84
+ visible_length = line.gsub(/\e\[[0-9;]*m/, "").length
85
+
86
+ if visible_length > @width
87
+ truncated = line[0...(@width - 3)]
88
+ truncated + @pastel.dim("...")
89
+ else
90
+ line
91
+ end
92
+ end
93
+
94
+ def update_width
95
+ @width = TTY::Screen.width
96
+ end
97
+
98
+ def move_cursor(row, col)
99
+ print "\e[#{row + 1};#{col + 1}H"
100
+ end
101
+
102
+ def clear_line
103
+ print "\e[2K"
104
+ end
105
+
106
+ def flush
107
+ $stdout.flush
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+
5
+ module Clacky
6
+ module UI2
7
+ module Components
8
+ # TodoArea displays active todos above the separator line
9
+ class TodoArea
10
+ attr_accessor :height
11
+ attr_reader :todos
12
+
13
+ MAX_DISPLAY_TASKS = 2 # Show at most 2 tasks (Next + After)
14
+
15
+ def initialize
16
+ @todos = []
17
+ @pastel = Pastel.new
18
+ @width = TTY::Screen.width
19
+ @height = 0 # Dynamic height based on todos
20
+ end
21
+
22
+ # Update todos list
23
+ # @param todos [Array<Hash>] Array of todo items
24
+ def update(todos)
25
+ @todos = todos || []
26
+ @pending_todos = @todos.select { |t| t[:status] == "pending" }
27
+ @completed_count = @todos.count { |t| t[:status] == "completed" }
28
+ @total_count = @todos.size
29
+
30
+ # Height: 1 line for header + min(pending_count, MAX_DISPLAY_TASKS) lines for tasks
31
+ if @pending_todos.empty? && @completed_count == 0
32
+ @height = 0
33
+ else
34
+ display_count = [@pending_todos.size, MAX_DISPLAY_TASKS].min
35
+ @height = 1 + display_count
36
+ end
37
+ end
38
+
39
+ # Check if there are todos to display
40
+ def visible?
41
+ @height > 0
42
+ end
43
+
44
+ # Render todos area
45
+ # @param start_row [Integer] Screen row to start rendering
46
+ def render(start_row:)
47
+ return unless visible?
48
+
49
+ update_width
50
+
51
+ # Render header: [##] Tasks [0/4]: ████
52
+ move_cursor(start_row, 0)
53
+ clear_line
54
+ header = render_header
55
+ print header
56
+
57
+ # Render tasks (Next and After)
58
+ @pending_todos.take(MAX_DISPLAY_TASKS).each_with_index do |todo, i|
59
+ move_cursor(start_row + i + 1, 0)
60
+ clear_line
61
+
62
+ label = i == 0 ? "Next" : "After"
63
+ task_text = truncate_text("##{todo[:id]} - #{todo[:task]}", @width - 12)
64
+ line = " #{@pastel.dim("->")} #{@pastel.yellow(label)}: #{task_text}"
65
+ print line
66
+ end
67
+
68
+ flush
69
+ end
70
+
71
+ # Clear the area
72
+ def clear
73
+ @todos = []
74
+ @pending_todos = []
75
+ @completed_count = 0
76
+ @total_count = 0
77
+ @height = 0
78
+ end
79
+
80
+ private
81
+
82
+ # Render header line with progress bar
83
+ def render_header
84
+ progress = "#{@completed_count}/#{@total_count}"
85
+ progress_bar = render_progress_bar(@completed_count, @total_count)
86
+
87
+ "#{@pastel.cyan("[##]")} Tasks [#{progress}]: #{progress_bar}"
88
+ end
89
+
90
+ # Render a simple progress bar
91
+ def render_progress_bar(completed, total)
92
+ return "" if total == 0
93
+
94
+ bar_width = 10
95
+ filled = total > 0 ? (completed.to_f / total * bar_width).round : 0
96
+ empty = bar_width - filled
97
+
98
+ filled_bar = @pastel.green("█" * filled)
99
+ empty_bar = @pastel.dim("░" * empty)
100
+
101
+ "#{filled_bar}#{empty_bar}"
102
+ end
103
+
104
+ # Truncate text to fit width
105
+ def truncate_text(text, max_width)
106
+ return "" if text.nil?
107
+
108
+ if text.length > max_width
109
+ text[0...(max_width - 3)] + "..."
110
+ else
111
+ text
112
+ end
113
+ end
114
+
115
+ # Update width on resize
116
+ def update_width
117
+ @width = TTY::Screen.width
118
+ end
119
+
120
+ # Move cursor to position
121
+ def move_cursor(row, col)
122
+ print "\e[#{row + 1};#{col + 1}H"
123
+ end
124
+
125
+ # Clear current line
126
+ def clear_line
127
+ print "\e[2K"
128
+ end
129
+
130
+ # Flush output
131
+ def flush
132
+ $stdout.flush
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_component"
4
+
5
+ module Clacky
6
+ module UI2
7
+ module Components
8
+ # ToolComponent renders tool calls and results
9
+ class ToolComponent < BaseComponent
10
+ # Render a tool event
11
+ # @param data [Hash] Tool event data
12
+ # - :type [Symbol] :call, :result, :error, :denied, :planned
13
+ # - :tool_name [String] Name of the tool
14
+ # - :formatted_call [String] Formatted tool call description
15
+ # - :result [String] Tool result (for :result type)
16
+ # - :error [String] Error message (for :error type)
17
+ # @return [String] Rendered tool event
18
+ def render(data)
19
+ type = data[:type]
20
+
21
+ case type
22
+ when :call
23
+ render_tool_call(data)
24
+ when :result
25
+ render_tool_result(data)
26
+ when :error
27
+ render_tool_error(data)
28
+ when :denied
29
+ render_tool_denied(data)
30
+ when :planned
31
+ render_tool_planned(data)
32
+ else
33
+ render_unknown_tool_event(data)
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ # Render tool call
40
+ # @param data [Hash] Tool call data
41
+ # @return [String] Rendered tool call
42
+ def render_tool_call(data)
43
+ symbol = format_symbol(:tool_call)
44
+ formatted_call = data[:formatted_call] || "#{data[:tool_name]}(...)"
45
+ text = format_text(formatted_call, :tool_call)
46
+
47
+ "\n#{symbol} #{text}"
48
+ end
49
+
50
+ # Render tool result
51
+ # @param data [Hash] Tool result data
52
+ # @return [String] Rendered tool result
53
+ def render_tool_result(data)
54
+ symbol = format_symbol(:tool_result)
55
+ result = data[:result] || data[:summary] || "completed"
56
+ text = format_text(truncate(result, 200), :tool_result)
57
+
58
+ "#{symbol} #{text}"
59
+ end
60
+
61
+ # Render tool error
62
+ # @param data [Hash] Tool error data
63
+ # @return [String] Rendered tool error
64
+ def render_tool_error(data)
65
+ symbol = format_symbol(:tool_error)
66
+ error_msg = data[:error] || "Unknown error"
67
+ text = format_text("Error: #{error_msg}", :tool_error)
68
+
69
+ "\n#{symbol} #{text}"
70
+ end
71
+
72
+ # Render tool denied
73
+ # @param data [Hash] Tool denied data
74
+ # @return [String] Rendered tool denied
75
+ def render_tool_denied(data)
76
+ symbol = format_symbol(:tool_denied)
77
+ tool_name = data[:tool_name] || "unknown"
78
+ text = format_text("Tool denied: #{tool_name}", :tool_denied)
79
+
80
+ "\n#{symbol} #{text}"
81
+ end
82
+
83
+ # Render tool planned
84
+ # @param data [Hash] Tool planned data
85
+ # @return [String] Rendered tool planned
86
+ def render_tool_planned(data)
87
+ symbol = format_symbol(:tool_planned)
88
+ tool_name = data[:tool_name] || "unknown"
89
+ text = format_text("Planned: #{tool_name}", :tool_planned)
90
+
91
+ "\n#{symbol} #{text}"
92
+ end
93
+
94
+ # Render unknown tool event
95
+ # @param data [Hash] Tool event data
96
+ # @return [String] Rendered unknown event
97
+ def render_unknown_tool_event(data)
98
+ symbol = format_symbol(:info)
99
+ text = format_text("Tool event: #{data.inspect}", :info)
100
+
101
+ "#{symbol} #{text}"
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+
5
+ module Clacky
6
+ module UI2
7
+ module Components
8
+ # WelcomeBanner displays the startup screen with ASCII logo, tagline, tips, and agent info
9
+ class WelcomeBanner
10
+ LOGO = <<~'LOGO'
11
+ ██████╗ ██████╗ ███████╗███╗ ██╗ ██████╗██╗ █████╗ ██████╗██╗ ██╗██╗ ██╗
12
+ ██╔═══██╗██╔══██╗██╔════╝████╗ ██║██╔════╝██║ ██╔══██╗██╔════╝██║ ██╔╝╚██╗ ██╔╝
13
+ ██║ ██║██████╔╝█████╗ ██╔██╗ ██║██║ ██║ ███████║██║ █████╔╝ ╚████╔╝
14
+ ██║ ██║██╔═══╝ ██╔══╝ ██║╚██╗██║██║ ██║ ██╔══██║██║ ██╔═██╗ ╚██╔╝
15
+ ╚██████╔╝██║ ███████╗██║ ╚████║╚██████╗███████╗██║ ██║╚██████╗██║ ██╗ ██║
16
+ ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝ ╚═╝
17
+ LOGO
18
+
19
+ TAGLINE = "[>] AI Coding Assistant & Technical Co-founder"
20
+
21
+ TIPS = [
22
+ "[*] Ask questions, edit files, or run commands",
23
+ "[*] Be specific for the best results",
24
+ "[*] Create .clackyrules to customize interactions",
25
+ "[*] Type /help for more commands"
26
+ ].freeze
27
+
28
+ def initialize
29
+ @pastel = Pastel.new
30
+ end
31
+
32
+ # Render startup banner
33
+ # @return [String] Formatted startup banner
34
+ def render_startup
35
+ lines = []
36
+ lines << ""
37
+ lines << @pastel.bright_green(LOGO)
38
+ lines << ""
39
+ lines << @pastel.bright_cyan(TAGLINE)
40
+ lines << ""
41
+ TIPS.each do |tip|
42
+ lines << @pastel.dim(tip)
43
+ end
44
+ lines << ""
45
+ lines.join("\n")
46
+ end
47
+
48
+ # Render agent welcome section
49
+ # @param working_dir [String] Working directory
50
+ # @param mode [String] Permission mode
51
+ # @return [String] Formatted agent welcome section
52
+ def render_agent_welcome(working_dir:, mode:)
53
+ lines = []
54
+ lines << ""
55
+ lines << separator("=")
56
+ lines << @pastel.bright_green("[+] AGENT MODE INITIALIZED")
57
+ lines << separator("=")
58
+ lines << ""
59
+ lines << info_line("Working Directory", working_dir)
60
+ lines << info_line("Permission Mode", mode)
61
+ lines << ""
62
+ lines << @pastel.dim("[!] Type 'exit' or 'quit' to terminate session")
63
+ lines << separator("-")
64
+ lines << ""
65
+ lines.join("\n")
66
+ end
67
+
68
+ # Render full welcome (startup + agent info)
69
+ # @param working_dir [String] Working directory
70
+ # @param mode [String] Permission mode
71
+ # @return [String] Full welcome content
72
+ def render_full(working_dir:, mode:)
73
+ render_startup + render_agent_welcome(
74
+ working_dir: working_dir,
75
+ mode: mode
76
+ )
77
+ end
78
+
79
+ private
80
+
81
+ def info_line(label, value)
82
+ label_text = @pastel.cyan("[#{label}]")
83
+ value_text = @pastel.white(value)
84
+ " #{label_text} #{value_text}"
85
+ end
86
+
87
+ def separator(char = "-")
88
+ @pastel.dim(char * 80)
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end