openclacky 0.5.1 → 0.5.2
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.
- checksums.yaml +4 -4
- data/.clackyrules +2 -0
- data/README.md +1 -0
- data/lib/clacky/agent.rb +100 -6
- data/lib/clacky/agent_config.rb +4 -1
- data/lib/clacky/cli.rb +104 -88
- data/lib/clacky/client.rb +56 -7
- data/lib/clacky/hook_manager.rb +2 -1
- data/lib/clacky/progress_indicator.rb +12 -10
- data/lib/clacky/tools/safe_shell.rb +7 -2
- data/lib/clacky/tools/trash_manager.rb +1 -1
- data/lib/clacky/ui/banner.rb +144 -0
- data/lib/clacky/ui/formatter.rb +209 -0
- data/lib/clacky/ui/prompt.rb +70 -0
- data/lib/clacky/ui/statusbar.rb +98 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky.rb +6 -0
- metadata +33 -1
data/lib/clacky/hook_manager.rb
CHANGED
|
@@ -14,7 +14,9 @@ module Clacky
|
|
|
14
14
|
def start
|
|
15
15
|
@start_time = Time.now
|
|
16
16
|
@running = true
|
|
17
|
-
|
|
17
|
+
# Save cursor position after the [..] symbol
|
|
18
|
+
print "\e[s" # Save cursor position
|
|
19
|
+
print_thinking_status("#{@thinking_verb}… (ctrl+c to interrupt)")
|
|
18
20
|
|
|
19
21
|
# Start background thread to update elapsed time
|
|
20
22
|
@update_thread = Thread.new do
|
|
@@ -29,24 +31,24 @@ module Clacky
|
|
|
29
31
|
return unless @start_time
|
|
30
32
|
|
|
31
33
|
elapsed = (Time.now - @start_time).to_i
|
|
32
|
-
|
|
34
|
+
print_thinking_status("#{@thinking_verb}… (ctrl+c to interrupt · #{elapsed}s)")
|
|
33
35
|
end
|
|
34
36
|
|
|
35
37
|
def finish
|
|
36
38
|
@running = false
|
|
37
39
|
@update_thread&.join
|
|
38
|
-
|
|
40
|
+
# Restore cursor and clear to end of line
|
|
41
|
+
print "\e[u" # Restore cursor position
|
|
42
|
+
print "\e[K" # Clear to end of line
|
|
43
|
+
puts "" # Add newline after finishing
|
|
39
44
|
end
|
|
40
45
|
|
|
41
46
|
private
|
|
42
47
|
|
|
43
|
-
def
|
|
44
|
-
print "\
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def clear_line
|
|
49
|
-
print "\r\033[K" # Clear the entire line
|
|
48
|
+
def print_thinking_status(text)
|
|
49
|
+
print "\e[u" # Restore cursor position (to after [..] symbol)
|
|
50
|
+
print "\e[K" # Clear to end of line from cursor
|
|
51
|
+
print text
|
|
50
52
|
$stdout.flush
|
|
51
53
|
end
|
|
52
54
|
end
|
|
@@ -147,6 +147,12 @@ module Clacky
|
|
|
147
147
|
trash_directory = Clacky::TrashDirectory.new(@project_root)
|
|
148
148
|
@trash_dir = trash_directory.trash_dir
|
|
149
149
|
@backup_dir = trash_directory.backup_dir
|
|
150
|
+
|
|
151
|
+
# Setup safety log directory under ~/.clacky/safety_logs/
|
|
152
|
+
@project_hash = trash_directory.generate_project_hash(@project_root)
|
|
153
|
+
@safety_log_dir = File.join(Dir.home, ".clacky", "safety_logs", @project_hash)
|
|
154
|
+
FileUtils.mkdir_p(@safety_log_dir) unless Dir.exist?(@safety_log_dir)
|
|
155
|
+
@safety_log_file = File.join(@safety_log_dir, "safety.log")
|
|
150
156
|
end
|
|
151
157
|
|
|
152
158
|
def make_command_safe(command)
|
|
@@ -384,8 +390,7 @@ module Clacky
|
|
|
384
390
|
end
|
|
385
391
|
|
|
386
392
|
def write_log(log_entry)
|
|
387
|
-
|
|
388
|
-
File.open(log_file, 'a') do |f|
|
|
393
|
+
File.open(@safety_log_file, 'a') do |f|
|
|
389
394
|
f.puts JSON.generate(log_entry)
|
|
390
395
|
end
|
|
391
396
|
rescue StandardError
|
|
@@ -260,7 +260,7 @@ module Clacky
|
|
|
260
260
|
- Use 'list' to see what files are in trash
|
|
261
261
|
- Use 'restore' to get back accidentally deleted files
|
|
262
262
|
- Use 'empty' periodically to free up disk space
|
|
263
|
-
- All deletions by SafeShell are logged in
|
|
263
|
+
- All deletions by SafeShell are logged in ~/.clacky/safety_logs/
|
|
264
264
|
HELP
|
|
265
265
|
|
|
266
266
|
{
|
|
@@ -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,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "readline"
|
|
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
|
+
end
|
|
14
|
+
|
|
15
|
+
# Read user input with enhanced prompt box
|
|
16
|
+
# @param prefix [String] Prompt prefix (default: "You:")
|
|
17
|
+
# @param placeholder [String] Placeholder text (not shown when using Readline)
|
|
18
|
+
# @return [String, nil] User input or nil on EOF
|
|
19
|
+
def read_input(prefix: "You:", placeholder: nil)
|
|
20
|
+
width = [TTY::Screen.width - 5, 70].min
|
|
21
|
+
|
|
22
|
+
# Display complete box frame first
|
|
23
|
+
puts @pastel.dim("╭" + "─" * width + "╮")
|
|
24
|
+
|
|
25
|
+
# Empty input line with borders (width - 2 for left/right padding)
|
|
26
|
+
padding = " " * (width - 2)
|
|
27
|
+
puts @pastel.dim("│ #{padding} │")
|
|
28
|
+
|
|
29
|
+
# Bottom border
|
|
30
|
+
puts @pastel.dim("╰" + "─" * width + "╯")
|
|
31
|
+
|
|
32
|
+
# Move cursor back up to input line (2 lines up)
|
|
33
|
+
print "\e[2A" # Move up 2 lines
|
|
34
|
+
print "\r" # Move to beginning of line
|
|
35
|
+
print "\e[2C" # Move right 2 chars to after "│ "
|
|
36
|
+
|
|
37
|
+
# Read input with Readline
|
|
38
|
+
prompt_text = @pastel.bright_blue("#{prefix} ")
|
|
39
|
+
input = read_with_readline(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_readline(prompt)
|
|
63
|
+
Readline.readline(prompt, true)
|
|
64
|
+
rescue Interrupt
|
|
65
|
+
puts
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
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
|
data/lib/clacky/version.rb
CHANGED
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.
|
|
4
|
+
version: 0.5.2
|
|
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
|