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_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
+ # - :prefix_newline [Boolean] Whether to add newline before message (for system messages)
17
+ # @return [String] Rendered message
18
+ def render(data)
19
+ role = data[:role]
20
+ content = data[:content]
21
+ timestamp = data[:timestamp]
22
+ images = data[:images] || []
23
+ prefix_newline = data.fetch(:prefix_newline, true)
24
+
25
+ case role
26
+ when "user"
27
+ render_user_message(content, timestamp, images)
28
+ when "assistant"
29
+ render_assistant_message(content, timestamp)
30
+ else
31
+ render_system_message(content, timestamp, prefix_newline)
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ # Render user message
38
+ # @param content [String] Message content
39
+ # @param timestamp [Time, nil] Optional timestamp
40
+ # @param images [Array<String>] Optional image paths
41
+ # @return [String] Rendered message
42
+ def render_user_message(content, timestamp = nil, images = [])
43
+ symbol = format_symbol(:user)
44
+ text = format_text(content, :user)
45
+ time_str = timestamp ? @pastel.dim("[#{format_timestamp(timestamp)}]") : ""
46
+
47
+ "\n#{symbol} #{text} #{time_str}".rstrip
48
+ end
49
+
50
+ # Render assistant message
51
+ # @param content [String] Message content
52
+ # @param timestamp [Time, nil] Optional timestamp
53
+ # @return [String] Rendered message
54
+ def render_assistant_message(content, timestamp = nil)
55
+ return "" if content.nil? || content.empty?
56
+
57
+ symbol = format_symbol(:assistant)
58
+ text = format_text(content, :assistant)
59
+ time_str = timestamp ? @pastel.dim("[#{format_timestamp(timestamp)}]") : ""
60
+
61
+ "\n#{symbol} #{text} #{time_str}".rstrip
62
+ end
63
+
64
+ # Render system message
65
+ # @param content [String] Message content
66
+ # @param timestamp [Time, nil] Optional timestamp
67
+ # @param prefix_newline [Boolean] Whether to add newline before message
68
+ # @return [String] Rendered message
69
+ private def render_system_message(content, timestamp = nil, prefix_newline = true)
70
+ symbol = format_symbol(:info)
71
+ text = format_text(content, :info)
72
+ time_str = timestamp ? @pastel.dim("[#{format_timestamp(timestamp)}]") : ""
73
+
74
+ prefix = prefix_newline ? "\n" : ""
75
+ "#{prefix}#{symbol} #{text} #{time_str}".rstrip
76
+ end
77
+ end
78
+ end
79
+ end
80
+ 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?
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,130 @@
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 = 3 # Show current + next 2 tasks
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
+ # Calculate height: 0 if no pending, otherwise 1 line per task (up to MAX_DISPLAY_TASKS)
31
+ if @pending_todos.empty?
32
+ @height = 0
33
+ else
34
+ @height = [@pending_todos.size, MAX_DISPLAY_TASKS].min
35
+ end
36
+ end
37
+
38
+ # Check if there are todos to display
39
+ def visible?
40
+ @height > 0
41
+ end
42
+
43
+ # Render todos area
44
+ # @param start_row [Integer] Screen row to start rendering
45
+ def render(start_row:)
46
+ return unless visible?
47
+
48
+ update_width
49
+
50
+ # Render each task on separate line
51
+ tasks_to_show = @pending_todos.take(MAX_DISPLAY_TASKS)
52
+
53
+ tasks_to_show.each_with_index do |task, index|
54
+ move_cursor(start_row + index, 0)
55
+
56
+ # Build the line content
57
+ line_content = if index == 0
58
+ # First line: Task [2/4]: #3 - Current task description
59
+ progress = "#{@completed_count}/#{@total_count}"
60
+ prefix = "Task [#{progress}]: "
61
+ task_text = "##{task[:id]} - #{task[:task]}"
62
+ available_width = @width - prefix.length - 2
63
+ truncated_task = truncate_text(task_text, available_width)
64
+
65
+ "#{@pastel.cyan(prefix)}#{truncated_task}"
66
+ else
67
+ # Subsequent lines: -> Next: #4 - Next task description
68
+ label = index == 1 ? "Next" : "After"
69
+ prefix = "-> #{label}: "
70
+ task_text = "##{task[:id]} - #{task[:task]}"
71
+ available_width = @width - prefix.length - 2
72
+ truncated_task = truncate_text(task_text, available_width)
73
+
74
+ "#{@pastel.dim(prefix)}#{@pastel.dim(truncated_task)}"
75
+ end
76
+
77
+ # Use carriage return and print content directly (overwrite existing content)
78
+ print "\r#{line_content}"
79
+ # Clear any remaining characters from previous render if line is shorter
80
+ clear_to_end_of_line
81
+ end
82
+
83
+ flush
84
+ end
85
+
86
+ # Clear the area
87
+ def clear
88
+ @todos = []
89
+ @pending_todos = []
90
+ @completed_count = 0
91
+ @total_count = 0
92
+ @height = 0
93
+ end
94
+
95
+ private
96
+
97
+ # Truncate text to fit width
98
+ def truncate_text(text, max_width)
99
+ return "" if text.nil?
100
+
101
+ if text.length > max_width
102
+ text[0...(max_width - 3)] + "..."
103
+ else
104
+ text
105
+ end
106
+ end
107
+
108
+ # Update width on resize
109
+ def update_width
110
+ @width = TTY::Screen.width
111
+ end
112
+
113
+ # Move cursor to position
114
+ def move_cursor(row, col)
115
+ print "\e[#{row + 1};#{col + 1}H"
116
+ end
117
+
118
+ # Clear from cursor to end of line
119
+ def clear_to_end_of_line
120
+ print "\e[0K"
121
+ end
122
+
123
+ # Flush output
124
+ def flush
125
+ $stdout.flush
126
+ end
127
+ end
128
+ end
129
+ end
130
+ 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,103 @@
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 only the logo (ASCII art)
33
+ # @return [String] Formatted logo only
34
+ def render_logo
35
+ lines = []
36
+ lines << ""
37
+ lines << @pastel.bright_green(LOGO)
38
+ lines << ""
39
+ lines.join("\n")
40
+ end
41
+
42
+ # Render startup banner
43
+ # @return [String] Formatted startup banner
44
+ def render_startup
45
+ lines = []
46
+ lines << ""
47
+ lines << @pastel.bright_green(LOGO)
48
+ lines << ""
49
+ lines << @pastel.bright_cyan(TAGLINE)
50
+ lines << ""
51
+ TIPS.each do |tip|
52
+ lines << @pastel.dim(tip)
53
+ end
54
+ lines << ""
55
+ lines.join("\n")
56
+ end
57
+
58
+ # Render agent welcome section
59
+ # @param working_dir [String] Working directory
60
+ # @param mode [String] Permission mode
61
+ # @return [String] Formatted agent welcome section
62
+ def render_agent_welcome(working_dir:, mode:)
63
+ lines = []
64
+ lines << ""
65
+ lines << separator("=")
66
+ lines << @pastel.bright_green("[+] AGENT MODE INITIALIZED")
67
+ lines << separator("=")
68
+ lines << ""
69
+ lines << info_line("Working Directory", working_dir)
70
+ lines << info_line("Permission Mode", mode)
71
+ lines << ""
72
+ lines << @pastel.dim("[!] Type 'exit' or 'quit' to terminate session")
73
+ lines << separator("-")
74
+ lines << ""
75
+ lines.join("\n")
76
+ end
77
+
78
+ # Render full welcome (startup + agent info)
79
+ # @param working_dir [String] Working directory
80
+ # @param mode [String] Permission mode
81
+ # @return [String] Full welcome content
82
+ def render_full(working_dir:, mode:)
83
+ render_startup + render_agent_welcome(
84
+ working_dir: working_dir,
85
+ mode: mode
86
+ )
87
+ end
88
+
89
+ private
90
+
91
+ def info_line(label, value)
92
+ label_text = @pastel.cyan("[#{label}]")
93
+ value_text = @pastel.white(value)
94
+ " #{label_text} #{value_text}"
95
+ end
96
+
97
+ def separator(char = "-")
98
+ @pastel.dim(char * 80)
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end