openclacky 0.5.1 → 0.5.3

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.
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+
5
+ module Clacky
6
+ module UI
7
+ # ASCII art banner and startup screen with Matrix/hacker aesthetic
8
+ class Banner
9
+ LOGO = <<~'LOGO'
10
+ ██████╗ ██████╗ ███████╗███╗ ██╗ ██████╗██╗ █████╗ ██████╗██╗ ██╗██╗ ██╗
11
+ ██╔═══██╗██╔══██╗██╔════╝████╗ ██║██╔════╝██║ ██╔══██╗██╔════╝██║ ██╔╝╚██╗ ██╔╝
12
+ ██║ ██║██████╔╝█████╗ ██╔██╗ ██║██║ ██║ ███████║██║ █████╔╝ ╚████╔╝
13
+ ██║ ██║██╔═══╝ ██╔══╝ ██║╚██╗██║██║ ██║ ██╔══██║██║ ██╔═██╗ ╚██╔╝
14
+ ╚██████╔╝██║ ███████╗██║ ╚████║╚██████╗███████╗██║ ██║╚██████╗██║ ██╗ ██║
15
+ ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝ ╚═╝
16
+ LOGO
17
+
18
+ TAGLINE = "[>] AI Coding Assistant & Technical Co-founder"
19
+
20
+ TIPS = [
21
+ "[*] Ask questions, edit files, or run commands",
22
+ "[*] Be specific for the best results",
23
+ "[*] Create .clackyrules to customize interactions",
24
+ "[*] Type /help for more commands"
25
+ ]
26
+
27
+ def initialize
28
+ @pastel = Pastel.new
29
+ end
30
+
31
+ # Display full startup banner
32
+ def display_startup
33
+ puts
34
+ puts @pastel.bright_green(LOGO)
35
+ puts
36
+ puts @pastel.bright_cyan(TAGLINE)
37
+ puts
38
+ display_tips
39
+ puts
40
+ end
41
+
42
+ # Display welcome message for agent mode
43
+ def display_agent_welcome(working_dir:, mode:, max_iterations:, max_cost:)
44
+ puts
45
+ puts separator("=")
46
+ puts @pastel.bright_green("[+] AGENT MODE INITIALIZED")
47
+ puts separator("=")
48
+ puts
49
+ puts info_line("Working Directory", working_dir)
50
+ puts info_line("Permission Mode", mode)
51
+ puts info_line("Max Iterations", max_iterations)
52
+ puts info_line("Max Cost", "$#{max_cost}")
53
+ puts
54
+ puts @pastel.dim("[!] Type 'exit' or 'quit' to terminate session")
55
+ puts separator("-")
56
+ puts
57
+ end
58
+
59
+ # Display session continuation info
60
+ def display_session_continue(session_id:, created_at:, tasks:, cost:)
61
+ puts
62
+ puts separator("=")
63
+ puts @pastel.bright_yellow("[~] RESUMING SESSION")
64
+ puts separator("=")
65
+ puts
66
+ puts info_line("Session ID", session_id)
67
+ puts info_line("Started", created_at)
68
+ puts info_line("Tasks Completed", tasks)
69
+ puts info_line("Total Cost", "$#{cost}")
70
+ puts separator("-")
71
+ puts
72
+ end
73
+
74
+ # Display task completion summary
75
+ def display_task_complete(iterations:, cost:, total_tasks:, total_cost:, cache_stats: {})
76
+ puts
77
+ puts separator("-")
78
+ puts @pastel.bright_green("[✓] TASK COMPLETED")
79
+ puts info_line("Iterations", iterations)
80
+ puts info_line("Cost", "$#{cost}")
81
+ puts info_line("Session Total", "#{total_tasks} tasks, $#{total_cost}")
82
+
83
+ # Display cache statistics if available
84
+ if cache_stats[:total_requests] && cache_stats[:total_requests] > 0
85
+ puts
86
+ puts @pastel.cyan(" [Prompt Caching]")
87
+ puts info_line(" Cache Writes", "#{cache_stats[:cache_creation_input_tokens]} tokens")
88
+ puts info_line(" Cache Reads", "#{cache_stats[:cache_read_input_tokens]} tokens")
89
+
90
+ hit_rate = (cache_stats[:cache_hit_requests].to_f / cache_stats[:total_requests] * 100).round(1)
91
+ puts info_line(" Cache Hit Rate", "#{hit_rate}% (#{cache_stats[:cache_hit_requests]}/#{cache_stats[:total_requests]} requests)")
92
+ end
93
+
94
+ puts separator("-")
95
+ puts
96
+ end
97
+
98
+ # Display error message
99
+ def display_error(message, details: nil)
100
+ puts
101
+ puts separator("-")
102
+ puts @pastel.bright_red("[✗] ERROR")
103
+ puts @pastel.red(" #{message}")
104
+ if details
105
+ puts @pastel.dim(" #{details}")
106
+ end
107
+ puts separator("-")
108
+ puts
109
+ end
110
+
111
+ # Display session end
112
+ def display_goodbye(total_tasks:, total_cost:)
113
+ puts
114
+ puts separator("=")
115
+ puts @pastel.bright_cyan("[#] SESSION TERMINATED")
116
+ puts separator("=")
117
+ puts
118
+ puts info_line("Total Tasks", total_tasks)
119
+ puts info_line("Total Cost", "$#{total_cost}")
120
+ puts
121
+ puts @pastel.dim("[*] All session data has been saved")
122
+ puts
123
+ end
124
+
125
+ private
126
+
127
+ def display_tips
128
+ TIPS.each do |tip|
129
+ puts @pastel.dim(tip)
130
+ end
131
+ end
132
+
133
+ def info_line(label, value)
134
+ label_text = @pastel.cyan("[#{label}]")
135
+ value_text = @pastel.white(value)
136
+ " #{label_text} #{value_text}"
137
+ end
138
+
139
+ def separator(char = "-")
140
+ @pastel.dim(char * 80)
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+
5
+ module Clacky
6
+ module UI
7
+ # Matrix/hacker-style output formatter
8
+ class Formatter
9
+ # Hacker-style symbols (no emoji)
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
+ success: "[OK]",
20
+ error: "[ER]",
21
+ warning: "[!!]",
22
+ info: "[--]",
23
+ task: "[##]",
24
+ progress: "[>>]"
25
+ }.freeze
26
+
27
+ def initialize
28
+ @pastel = Pastel.new
29
+ end
30
+
31
+ # Format user message
32
+ def user_message(content)
33
+ symbol = @pastel.bright_blue(SYMBOLS[:user])
34
+ text = @pastel.blue(content)
35
+ puts "\n#{symbol} #{text}"
36
+ end
37
+
38
+ # Format assistant message
39
+ def assistant_message(content)
40
+ return if content.nil? || content.empty?
41
+
42
+ symbol = @pastel.bright_green(SYMBOLS[:assistant])
43
+ text = @pastel.white(content)
44
+ puts "\n#{symbol} #{text}"
45
+ end
46
+
47
+ # Format tool call
48
+ def tool_call(formatted_call)
49
+ symbol = @pastel.bright_cyan(SYMBOLS[:tool_call])
50
+ text = @pastel.cyan(formatted_call)
51
+ puts "\n#{symbol} #{text}"
52
+ end
53
+
54
+ # Format tool result
55
+ def tool_result(summary)
56
+ symbol = @pastel.cyan(SYMBOLS[:tool_result])
57
+ text = @pastel.white(summary)
58
+ puts "#{symbol} #{text}"
59
+ end
60
+
61
+ # Format tool denied
62
+ def tool_denied(tool_name)
63
+ symbol = @pastel.bright_yellow(SYMBOLS[:tool_denied])
64
+ text = @pastel.yellow("Tool denied: #{tool_name}")
65
+ puts "\n#{symbol} #{text}"
66
+ end
67
+
68
+ # Format tool planned
69
+ def tool_planned(tool_name)
70
+ symbol = @pastel.bright_blue(SYMBOLS[:tool_planned])
71
+ text = @pastel.blue("Planned: #{tool_name}")
72
+ puts "\n#{symbol} #{text}"
73
+ end
74
+
75
+ # Format tool error
76
+ def tool_error(error_message)
77
+ symbol = @pastel.bright_red(SYMBOLS[:tool_error])
78
+ text = @pastel.red("Error: #{error_message}")
79
+ puts "\n#{symbol} #{text}"
80
+ end
81
+
82
+ # Format thinking indicator
83
+ def thinking
84
+ symbol = @pastel.dim(SYMBOLS[:thinking])
85
+ # Output symbol on the same line, progress indicator will follow
86
+ print "\n#{symbol} "
87
+ end
88
+
89
+ # Format success message
90
+ def success(message)
91
+ symbol = @pastel.bright_green(SYMBOLS[:success])
92
+ text = @pastel.green(message)
93
+ puts "#{symbol} #{text}"
94
+ end
95
+
96
+ # Format error message
97
+ def error(message)
98
+ symbol = @pastel.bright_red(SYMBOLS[:error])
99
+ text = @pastel.red(message)
100
+ puts "#{symbol} #{text}"
101
+ end
102
+
103
+ # Format warning message
104
+ def warning(message)
105
+ symbol = @pastel.bright_yellow(SYMBOLS[:warning])
106
+ text = @pastel.yellow(message)
107
+ puts "#{symbol} #{text}"
108
+ end
109
+
110
+ # Format info message
111
+ def info(message)
112
+ symbol = @pastel.bright_white(SYMBOLS[:info])
113
+ text = @pastel.white(message)
114
+ puts "#{symbol} #{text}"
115
+ end
116
+
117
+ # Format TODO status with progress bar
118
+ def todo_status(todos)
119
+ return if todos.empty?
120
+
121
+ completed = todos.count { |t| t[:status] == "completed" }
122
+ total = todos.size
123
+
124
+ # Build progress bar with hacker style
125
+ progress_bar = todos.map { |t|
126
+ t[:status] == "completed" ? @pastel.green("█") : @pastel.dim("░")
127
+ }.join
128
+
129
+ # Check if all completed
130
+ if completed == total
131
+ symbol = @pastel.bright_green(SYMBOLS[:success])
132
+ puts "\n#{symbol} Tasks [#{completed}/#{total}]: #{progress_bar} #{@pastel.bright_green('COMPLETE')}"
133
+ return
134
+ end
135
+
136
+ # Find current and next tasks
137
+ current_task = todos.find { |t| t[:status] == "pending" }
138
+ next_task_index = todos.index(current_task)
139
+ next_task = next_task_index && todos[next_task_index + 1]
140
+
141
+ symbol = @pastel.bright_yellow(SYMBOLS[:task])
142
+ puts "\n#{symbol} Tasks [#{completed}/#{total}]: #{progress_bar}"
143
+
144
+ if current_task
145
+ puts " #{@pastel.cyan('→')} Next: ##{current_task[:id]} - #{current_task[:task]}"
146
+ end
147
+
148
+ if next_task && next_task[:status] == "pending"
149
+ puts " #{@pastel.dim('⇢')} After: ##{next_task[:id]} - #{next_task[:task]}"
150
+ end
151
+ end
152
+
153
+ # Format iteration indicator
154
+ def iteration(number)
155
+ symbol = @pastel.dim(SYMBOLS[:progress])
156
+ text = @pastel.dim("Iteration #{number}")
157
+ puts "\n#{symbol} #{text}"
158
+ end
159
+
160
+ # Format separator
161
+ def separator(char = "─", width: 80)
162
+ puts @pastel.dim(char * width)
163
+ end
164
+
165
+ # Format section header
166
+ def section_header(title)
167
+ puts
168
+ separator("═")
169
+ puts @pastel.bright_white(title.center(80))
170
+ separator("═")
171
+ puts
172
+ end
173
+
174
+ # Format confirmation prompt for tool use
175
+ def tool_confirmation_prompt(formatted_call)
176
+ symbol = @pastel.bright_yellow("[??]")
177
+ puts "\n#{symbol} #{@pastel.yellow(formatted_call)}"
178
+ end
179
+
180
+ # Format conversation history message
181
+ def history_message(role, content, index, total)
182
+ case role
183
+ when "user"
184
+ symbol = @pastel.blue(SYMBOLS[:user])
185
+ text = @pastel.white(truncate(content, 150))
186
+ puts "#{symbol} You: #{text}"
187
+ when "assistant"
188
+ symbol = @pastel.green(SYMBOLS[:assistant])
189
+ text = @pastel.white(truncate(content, 200))
190
+ puts "#{symbol} Assistant: #{text}"
191
+ end
192
+ end
193
+
194
+ private
195
+
196
+ def truncate(content, max_length)
197
+ return "" if content.nil? || content.empty?
198
+
199
+ cleaned = content.strip.gsub(/\s+/, ' ')
200
+
201
+ if cleaned.length > max_length
202
+ cleaned[0...max_length] + "..."
203
+ else
204
+ cleaned
205
+ end
206
+ end
207
+ end
208
+ end
209
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-prompt"
4
+ require "pastel"
5
+ require "tty-screen"
6
+
7
+ module Clacky
8
+ module UI
9
+ # Enhanced input prompt with box drawing and status info
10
+ class Prompt
11
+ def initialize
12
+ @pastel = Pastel.new
13
+ @tty_prompt = TTY::Prompt.new(interrupt: :exit)
14
+ end
15
+
16
+ # Read user input with enhanced prompt box
17
+ # @param prefix [String] Prompt prefix (default: "You:")
18
+ # @param placeholder [String] Placeholder text (not shown when using TTY::Prompt)
19
+ # @return [String, nil] User input or nil on EOF
20
+ def read_input(prefix: "You:", placeholder: nil)
21
+ width = [TTY::Screen.width - 5, 70].min
22
+
23
+ # Display complete box frame first
24
+ puts @pastel.dim("╭" + "─" * width + "╮")
25
+
26
+ # Empty input line - NO left border, just spaces and right border
27
+ padding = " " * width
28
+ puts @pastel.dim("#{padding} │")
29
+
30
+ # Bottom border
31
+ puts @pastel.dim("╰" + "─" * width + "╯")
32
+
33
+ # Move cursor back up to input line (2 lines up)
34
+ print "\e[2A" # Move up 2 lines
35
+ print "\r" # Move to beginning of line
36
+
37
+ # Read input with TTY::Prompt
38
+ prompt_text = @pastel.bright_blue("#{prefix}")
39
+ input = read_with_tty_prompt(prompt_text)
40
+
41
+ # After input, clear the input box completely
42
+ # Move cursor up 2 lines to the top of the box
43
+ print "\e[2A"
44
+ print "\r"
45
+
46
+ # Clear all 3 lines of the box
47
+ 3.times do
48
+ print "\e[2K" # Clear entire line
49
+ print "\e[1B" # Move down 1 line
50
+ print "\r" # Move to beginning of line
51
+ end
52
+
53
+ # Move cursor back up to where the box started
54
+ print "\e[3A"
55
+ print "\r"
56
+
57
+ input
58
+ end
59
+
60
+ private
61
+
62
+ def read_with_tty_prompt(prompt)
63
+ @tty_prompt.ask(prompt, required: false, echo: true) do |q|
64
+ q.modify :strip
65
+ end
66
+ rescue TTY::Reader::InputInterrupt
67
+ puts
68
+ nil
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+ require "tty-screen"
5
+
6
+ module Clacky
7
+ module UI
8
+ # Status bar showing session information
9
+ class StatusBar
10
+ def initialize
11
+ @pastel = Pastel.new
12
+ end
13
+
14
+ # Display session status bar
15
+ # @param working_dir [String] Current working directory
16
+ # @param mode [String] Permission mode
17
+ # @param model [String] AI model name
18
+ # @param tasks [Integer] Number of completed tasks (optional)
19
+ # @param cost [Float] Total cost (optional)
20
+ def display(working_dir:, mode:, model:, tasks: nil, cost: nil)
21
+ parts = []
22
+
23
+ # Working directory (shortened if too long)
24
+ dir_display = shorten_path(working_dir)
25
+ parts << @pastel.bright_cyan(dir_display)
26
+
27
+ # Permission mode
28
+ mode_color = mode_color_for(mode)
29
+ parts << @pastel.public_send(mode_color, mode)
30
+
31
+ # Model
32
+ parts << @pastel.bright_white(model)
33
+
34
+ # Optional: tasks and cost
35
+ if tasks
36
+ parts << @pastel.yellow("#{tasks} tasks")
37
+ end
38
+
39
+ if cost
40
+ parts << @pastel.yellow("$#{cost.round(4)}")
41
+ end
42
+
43
+ # Join with separator
44
+ separator = @pastel.dim(" │ ")
45
+ status_line = " " + parts.join(separator)
46
+
47
+ puts status_line
48
+ puts @pastel.dim("─" * [TTY::Screen.width, 80].min)
49
+ end
50
+
51
+ # Display minimal status for non-interactive mode
52
+ def display_minimal(working_dir:, mode:)
53
+ dir_display = shorten_path(working_dir)
54
+ puts " #{@pastel.bright_cyan(dir_display)} #{@pastel.dim('│')} #{@pastel.yellow(mode)}"
55
+ puts @pastel.dim("─" * [TTY::Screen.width, 80].min)
56
+ end
57
+
58
+ private
59
+
60
+ def shorten_path(path)
61
+ return path if path.length <= 40
62
+
63
+ # Replace home directory with ~
64
+ home = ENV['HOME']
65
+ if home && path.start_with?(home)
66
+ path = path.sub(home, '~')
67
+ end
68
+
69
+ # If still too long, show last parts
70
+ if path.length > 40
71
+ parts = path.split('/')
72
+ if parts.length > 3
73
+ ".../" + parts[-3..-1].join('/')
74
+ else
75
+ path[0..40] + "..."
76
+ end
77
+ else
78
+ path
79
+ end
80
+ end
81
+
82
+ def mode_color_for(mode)
83
+ case mode.to_s
84
+ when /auto_approve/
85
+ :bright_red
86
+ when /confirm_safes/
87
+ :bright_yellow
88
+ when /confirm_edits/
89
+ :bright_green
90
+ when /plan_only/
91
+ :bright_blue
92
+ else
93
+ :white
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "0.5.1"
4
+ VERSION = "0.5.3"
5
5
  end
data/lib/clacky.rb CHANGED
@@ -30,6 +30,12 @@ require_relative "clacky/tools/safe_shell"
30
30
  require_relative "clacky/tools/trash_manager"
31
31
  require_relative "clacky/agent"
32
32
 
33
+ # UI components
34
+ require_relative "clacky/ui/banner"
35
+ require_relative "clacky/ui/prompt"
36
+ require_relative "clacky/ui/statusbar"
37
+ require_relative "clacky/ui/formatter"
38
+
33
39
  require_relative "clacky/cli"
34
40
 
35
41
  module Clacky
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openclacky
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.5.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - windy
@@ -79,6 +79,34 @@ dependencies:
79
79
  - - "~>"
80
80
  - !ruby/object:Gem::Version
81
81
  version: '3.4'
82
+ - !ruby/object:Gem::Dependency
83
+ name: pastel
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '0.8'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '0.8'
96
+ - !ruby/object:Gem::Dependency
97
+ name: tty-screen
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '0.8'
103
+ type: :runtime
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '0.8'
82
110
  description: OpenClacky is a Ruby CLI tool for interacting with AI models via OpenAI-compatible
83
111
  APIs. It provides chat functionality and autonomous AI agent capabilities with tool
84
112
  use.
@@ -128,6 +156,10 @@ files:
128
156
  - lib/clacky/tools/web_search.rb
129
157
  - lib/clacky/tools/write.rb
130
158
  - lib/clacky/trash_directory.rb
159
+ - lib/clacky/ui/banner.rb
160
+ - lib/clacky/ui/formatter.rb
161
+ - lib/clacky/ui/prompt.rb
162
+ - lib/clacky/ui/statusbar.rb
131
163
  - lib/clacky/utils/arguments_parser.rb
132
164
  - lib/clacky/utils/limit_stack.rb
133
165
  - lib/clacky/utils/path_helper.rb