openclacky 0.7.2 → 0.7.4
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/.clacky/skills/commit/SKILL.md +252 -74
- data/CHANGELOG.md +38 -0
- data/bin/openclacky +2 -0
- data/lib/clacky/agent/message_compressor_helper.rb +1 -1
- data/lib/clacky/agent/system_prompt_builder.rb +9 -8
- data/lib/clacky/agent/tool_executor.rb +4 -13
- data/lib/clacky/agent.rb +28 -7
- data/lib/clacky/cli.rb +22 -5
- data/lib/clacky/default_skills/new/SKILL.md +61 -30
- data/lib/clacky/default_skills/new/scripts/create_rails_project.sh +176 -0
- data/lib/clacky/default_skills/new/scripts/rails_env_checker.sh +389 -0
- data/lib/clacky/default_skills/skill-add/SKILL.md +251 -34
- data/lib/clacky/default_skills/skill-add/scripts/install_from_github.rb +189 -0
- data/lib/clacky/providers.rb +13 -11
- data/lib/clacky/tools/invoke_skill.rb +5 -1
- data/lib/clacky/tools/safe_shell.rb +2 -2
- data/lib/clacky/tools/shell.rb +48 -20
- data/lib/clacky/ui2/components/input_area.rb +27 -9
- data/lib/clacky/ui2/components/modal_component.rb +22 -2
- data/lib/clacky/ui2/components/welcome_banner.rb +33 -10
- data/lib/clacky/ui2/layout_manager.rb +107 -26
- data/lib/clacky/ui2/line_editor.rb +6 -5
- data/lib/clacky/ui2/screen_buffer.rb +0 -15
- data/lib/clacky/ui2/terminal_detector.rb +119 -0
- data/lib/clacky/ui2/theme_manager.rb +18 -0
- data/lib/clacky/ui2/themes/base_theme.rb +22 -1
- data/lib/clacky/ui2/themes/hacker_theme.rb +22 -18
- data/lib/clacky/ui2/themes/minimal_theme.rb +19 -15
- data/lib/clacky/ui2/ui_controller.rb +66 -15
- data/lib/clacky/ui2.rb +1 -0
- data/lib/clacky/utils/limit_stack.rb +5 -0
- data/lib/clacky/version.rb +1 -1
- metadata +5 -1
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'tmpdir'
|
|
6
|
+
require 'uri'
|
|
7
|
+
require 'find'
|
|
8
|
+
|
|
9
|
+
# Install skills from a GitHub repository
|
|
10
|
+
# Usage: ruby install_from_github.rb <github_url>
|
|
11
|
+
class SkillInstaller
|
|
12
|
+
GITHUB_URL_PATTERNS = [
|
|
13
|
+
%r{^https?://github\.com/[\w-]+/[\w.-]+(?:\.git)?$},
|
|
14
|
+
%r{^git@github\.com:[\w-]+/[\w.-]+\.git$}
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
def initialize(repo_url, target_dir: nil)
|
|
18
|
+
@repo_url = repo_url
|
|
19
|
+
@target_dir = target_dir || File.join(Dir.pwd, '.clacky', 'skills')
|
|
20
|
+
@installed_skills = []
|
|
21
|
+
@errors = []
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Main installation process
|
|
25
|
+
def install
|
|
26
|
+
unless valid_github_url?
|
|
27
|
+
raise ArgumentError, "Invalid GitHub URL: #{@repo_url}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
Dir.mktmpdir('clacky-skills-') do |tmpdir|
|
|
31
|
+
clone_repository(tmpdir)
|
|
32
|
+
discover_and_install_skills(tmpdir)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
report_results
|
|
36
|
+
rescue StandardError => e
|
|
37
|
+
puts "❌ Installation failed: #{e.message}"
|
|
38
|
+
exit 1
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Validate GitHub URL format
|
|
42
|
+
private def valid_github_url?
|
|
43
|
+
GITHUB_URL_PATTERNS.any? { |pattern| @repo_url.match?(pattern) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Clone the repository to temporary directory
|
|
47
|
+
private def clone_repository(tmpdir)
|
|
48
|
+
puts "📦 Cloning repository..."
|
|
49
|
+
puts " #{@repo_url}"
|
|
50
|
+
|
|
51
|
+
clone_path = File.join(tmpdir, 'repo')
|
|
52
|
+
|
|
53
|
+
# Use git clone with depth=1 for faster cloning
|
|
54
|
+
system('git', 'clone', '--depth', '1', @repo_url, clone_path,
|
|
55
|
+
out: File::NULL, err: File::NULL)
|
|
56
|
+
|
|
57
|
+
unless $?.success?
|
|
58
|
+
raise "Failed to clone repository. Please check the URL and your network connection."
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
@clone_path = clone_path
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Discover all skills directories and install them
|
|
65
|
+
private def discover_and_install_skills(tmpdir)
|
|
66
|
+
skills_found = false
|
|
67
|
+
|
|
68
|
+
# Search for any directory named 'skills' containing SKILL.md files
|
|
69
|
+
Find.find(@clone_path) do |path|
|
|
70
|
+
next unless File.directory?(path)
|
|
71
|
+
next unless File.basename(path) == 'skills'
|
|
72
|
+
|
|
73
|
+
# Check if this skills directory contains subdirectories with SKILL.md
|
|
74
|
+
skill_dirs = Dir.glob(File.join(path, '*/SKILL.md')).map { |f| File.dirname(f) }
|
|
75
|
+
|
|
76
|
+
next if skill_dirs.empty?
|
|
77
|
+
|
|
78
|
+
skills_found = true
|
|
79
|
+
install_skills_from_directory(path, skill_dirs)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
unless skills_found
|
|
83
|
+
raise "No skills found in repository. Looking for directories named 'skills/' containing SKILL.md files."
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Install skills from a specific skills directory
|
|
88
|
+
private def install_skills_from_directory(skills_dir, skill_dirs)
|
|
89
|
+
skill_dirs.each do |skill_dir|
|
|
90
|
+
skill_name = File.basename(skill_dir)
|
|
91
|
+
target_path = File.join(@target_dir, skill_name)
|
|
92
|
+
|
|
93
|
+
begin
|
|
94
|
+
# Create target directory
|
|
95
|
+
FileUtils.mkdir_p(@target_dir)
|
|
96
|
+
|
|
97
|
+
# Check if skill already exists
|
|
98
|
+
if File.exist?(target_path)
|
|
99
|
+
puts "⚠️ Skill '#{skill_name}' already exists, skipping..."
|
|
100
|
+
@errors << "Skill '#{skill_name}' already exists at #{target_path}"
|
|
101
|
+
next
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Copy skill directory
|
|
105
|
+
FileUtils.cp_r(skill_dir, target_path)
|
|
106
|
+
|
|
107
|
+
# Read skill description from SKILL.md
|
|
108
|
+
description = extract_description(File.join(target_path, 'SKILL.md'))
|
|
109
|
+
|
|
110
|
+
@installed_skills << {
|
|
111
|
+
name: skill_name,
|
|
112
|
+
path: target_path,
|
|
113
|
+
description: description
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
rescue StandardError => e
|
|
117
|
+
@errors << "Failed to install '#{skill_name}': #{e.message}"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Extract description from SKILL.md frontmatter
|
|
123
|
+
private def extract_description(skill_file)
|
|
124
|
+
return "No description" unless File.exist?(skill_file)
|
|
125
|
+
|
|
126
|
+
content = File.read(skill_file)
|
|
127
|
+
|
|
128
|
+
# Parse YAML frontmatter
|
|
129
|
+
if content =~ /\A---\s*\n(.*?)\n---/m
|
|
130
|
+
frontmatter = $1
|
|
131
|
+
if frontmatter =~ /^description:\s*(.+)$/
|
|
132
|
+
return $1.strip
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
"No description"
|
|
137
|
+
rescue StandardError
|
|
138
|
+
"No description"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Report installation results
|
|
142
|
+
private def report_results
|
|
143
|
+
puts "\n" + "=" * 60
|
|
144
|
+
|
|
145
|
+
if @installed_skills.empty?
|
|
146
|
+
puts "❌ No skills were installed."
|
|
147
|
+
|
|
148
|
+
if @errors.any?
|
|
149
|
+
puts "\nErrors encountered:"
|
|
150
|
+
@errors.each { |err| puts " • #{err}" }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
exit 1
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
puts "✅ Installation complete!"
|
|
157
|
+
puts "\nInstalled #{@installed_skills.size} skill(s):\n\n"
|
|
158
|
+
|
|
159
|
+
@installed_skills.each do |skill|
|
|
160
|
+
puts " ✓ #{skill[:name]}"
|
|
161
|
+
puts " #{skill[:description]}"
|
|
162
|
+
puts " → #{skill[:path]}"
|
|
163
|
+
puts
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
if @errors.any?
|
|
167
|
+
puts "⚠️ Warnings:"
|
|
168
|
+
@errors.each { |err| puts " • #{err}" }
|
|
169
|
+
puts
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
puts "You can now use these skills with /skill-name"
|
|
173
|
+
puts "=" * 60
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Run installer if called directly
|
|
178
|
+
if __FILE__ == $0
|
|
179
|
+
if ARGV.empty?
|
|
180
|
+
puts "Usage: ruby install_from_github.rb <github_url>"
|
|
181
|
+
puts "\nExamples:"
|
|
182
|
+
puts " ruby install_from_github.rb https://github.com/username/repo"
|
|
183
|
+
puts " ruby install_from_github.rb https://github.com/username/repo.git"
|
|
184
|
+
exit 1
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
installer = SkillInstaller.new(ARGV[0])
|
|
188
|
+
installer.install
|
|
189
|
+
end
|
data/lib/clacky/providers.rb
CHANGED
|
@@ -11,28 +11,21 @@ module Clacky
|
|
|
11
11
|
# - api: API type (anthropic-messages, openai-responses, openai-completions)
|
|
12
12
|
# - default_model: Recommended default model
|
|
13
13
|
PRESETS = {
|
|
14
|
-
"anthropic" => {
|
|
15
|
-
"name" => "Anthropic (Claude)",
|
|
16
|
-
"base_url" => "https://api.anthropic.com",
|
|
17
|
-
"api" => "anthropic-messages",
|
|
18
|
-
"default_model" => "claude-sonnet-4-6",
|
|
19
|
-
"models" => ["claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4"]
|
|
20
|
-
}.freeze,
|
|
21
14
|
|
|
22
15
|
"openrouter" => {
|
|
23
16
|
"name" => "OpenRouter",
|
|
24
17
|
"base_url" => "https://openrouter.ai/api/v1",
|
|
25
18
|
"api" => "openai-responses",
|
|
26
|
-
"default_model" => "anthropic/claude-sonnet-4-
|
|
19
|
+
"default_model" => "anthropic/claude-sonnet-4-6",
|
|
27
20
|
"models" => [] # Dynamic - fetched from API
|
|
28
21
|
}.freeze,
|
|
29
22
|
|
|
30
23
|
"minimax" => {
|
|
31
24
|
"name" => "Minimax",
|
|
32
|
-
"base_url" => "https://api.
|
|
25
|
+
"base_url" => "https://api.minimaxi.com/v1",
|
|
33
26
|
"api" => "openai-completions",
|
|
34
|
-
"default_model" => "MiniMax-
|
|
35
|
-
"models" => ["MiniMax-
|
|
27
|
+
"default_model" => "MiniMax-M2.5",
|
|
28
|
+
"models" => ["MiniMax-M2.1", "MiniMax-M2.5"]
|
|
36
29
|
}.freeze,
|
|
37
30
|
|
|
38
31
|
"kimi" => {
|
|
@@ -41,7 +34,16 @@ module Clacky
|
|
|
41
34
|
"api" => "openai-completions",
|
|
42
35
|
"default_model" => "kimi-k2.5",
|
|
43
36
|
"models" => ["kimi-k2.5"]
|
|
37
|
+
}.freeze,
|
|
38
|
+
|
|
39
|
+
"anthropic" => {
|
|
40
|
+
"name" => "Anthropic (Claude)",
|
|
41
|
+
"base_url" => "https://api.anthropic.com",
|
|
42
|
+
"api" => "anthropic-messages",
|
|
43
|
+
"default_model" => "claude-sonnet-4.6",
|
|
44
|
+
"models" => ["claude-opus-4-6", "claude-sonnet-4.6", "claude-haiku-4.5"]
|
|
44
45
|
}.freeze
|
|
46
|
+
|
|
45
47
|
}.freeze
|
|
46
48
|
|
|
47
49
|
class << self
|
|
@@ -55,9 +55,13 @@ module Clacky
|
|
|
55
55
|
else
|
|
56
56
|
# Expand skill content inline
|
|
57
57
|
expanded = skill.process_content(task)
|
|
58
|
+
|
|
59
|
+
# Add skill directory path information for script execution
|
|
60
|
+
skill_dir_info = "\n\n---\n**Skill Directory:** `#{skill.directory}`\n\nWhen executing scripts from Supporting Files, use the full path:\n`#{skill.directory}/scripts/script_name`\n---\n"
|
|
61
|
+
|
|
58
62
|
{
|
|
59
63
|
message: "Skill '#{skill_name}' content expanded",
|
|
60
|
-
content: expanded,
|
|
64
|
+
content: expanded + skill_dir_info,
|
|
61
65
|
skill_type: "inline",
|
|
62
66
|
note: "The expanded content has been added to the conversation. Continue following its instructions."
|
|
63
67
|
}
|
|
@@ -32,7 +32,7 @@ module Clacky
|
|
|
32
32
|
required: ["command"]
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
def execute(command:, timeout: nil, max_output_lines: 1000, skip_safety_check: false)
|
|
35
|
+
def execute(command:, timeout: nil, max_output_lines: 1000, skip_safety_check: false, output_buffer: nil)
|
|
36
36
|
# Get project root directory
|
|
37
37
|
project_root = Dir.pwd
|
|
38
38
|
|
|
@@ -58,7 +58,7 @@ module Clacky
|
|
|
58
58
|
hard_timeout = calculate_hard_timeout(command, timeout)
|
|
59
59
|
|
|
60
60
|
# 4. Call parent class execution method
|
|
61
|
-
result = super(command: safe_command, soft_timeout: soft_timeout, hard_timeout: hard_timeout, max_output_lines: max_output_lines)
|
|
61
|
+
result = super(command: safe_command, soft_timeout: soft_timeout, hard_timeout: hard_timeout, max_output_lines: max_output_lines, output_buffer: output_buffer)
|
|
62
62
|
|
|
63
63
|
# 5. Enhance result information
|
|
64
64
|
enhance_result(result, command, safe_command, safety_replacer)
|
data/lib/clacky/tools/shell.rb
CHANGED
|
@@ -57,7 +57,7 @@ module Clacky
|
|
|
57
57
|
'go build'
|
|
58
58
|
].freeze
|
|
59
59
|
|
|
60
|
-
def execute(command:, soft_timeout: nil, hard_timeout: nil, max_output_lines: 1000)
|
|
60
|
+
def execute(command:, soft_timeout: nil, hard_timeout: nil, max_output_lines: 1000, output_buffer: nil)
|
|
61
61
|
require "open3"
|
|
62
62
|
require "stringio"
|
|
63
63
|
|
|
@@ -67,6 +67,15 @@ module Clacky
|
|
|
67
67
|
stderr_buffer = StringIO.new
|
|
68
68
|
soft_timeout_triggered = false
|
|
69
69
|
process_pid = nil
|
|
70
|
+
|
|
71
|
+
# Store output buffer reference for real-time access (use LimitStack for memory efficiency)
|
|
72
|
+
@output_buffer = output_buffer
|
|
73
|
+
if @output_buffer
|
|
74
|
+
@output_buffer[:stdout_lines] = Utils::LimitStack.new(max_size: 1000)
|
|
75
|
+
@output_buffer[:stderr_lines] = Utils::LimitStack.new(max_size: 200)
|
|
76
|
+
end
|
|
77
|
+
@stdout_buffer = stdout_buffer
|
|
78
|
+
@stderr_buffer = stderr_buffer
|
|
70
79
|
|
|
71
80
|
begin
|
|
72
81
|
Open3.popen3(command) do |stdin, stdout, stderr, wait_thr|
|
|
@@ -126,6 +135,8 @@ module Clacky
|
|
|
126
135
|
else
|
|
127
136
|
stderr_buffer.write(data)
|
|
128
137
|
end
|
|
138
|
+
# Update shared output buffer for real-time access
|
|
139
|
+
update_output_buffer
|
|
129
140
|
rescue IO::WaitReadable, EOFError
|
|
130
141
|
end
|
|
131
142
|
end
|
|
@@ -306,7 +317,7 @@ module Clacky
|
|
|
306
317
|
|
|
307
318
|
# Format result for LLM consumption - limit output size to save tokens
|
|
308
319
|
# Maximum characters to include in LLM output
|
|
309
|
-
MAX_LLM_OUTPUT_CHARS =
|
|
320
|
+
MAX_LLM_OUTPUT_CHARS = 4000
|
|
310
321
|
|
|
311
322
|
def format_result_for_llm(result)
|
|
312
323
|
# Return error info as-is if command failed or timed out
|
|
@@ -367,45 +378,62 @@ module Clacky
|
|
|
367
378
|
# Write full output to temp file
|
|
368
379
|
File.write(temp_file, output)
|
|
369
380
|
|
|
370
|
-
# For LLM display: show first
|
|
381
|
+
# For LLM display: show first N lines to preserve most useful information
|
|
371
382
|
lines = output.lines
|
|
372
383
|
return { content: output, temp_file: nil } if lines.length <= 2
|
|
373
384
|
|
|
374
|
-
# Reserve
|
|
375
|
-
|
|
385
|
+
# Reserve space for truncation notice (including temp file path)
|
|
386
|
+
notice_overhead = 200
|
|
387
|
+
available_chars = max_chars - notice_overhead
|
|
376
388
|
|
|
377
|
-
#
|
|
389
|
+
# Prioritize first lines as they usually contain the most important information
|
|
378
390
|
first_part = []
|
|
379
391
|
accumulated = 0
|
|
380
392
|
lines.each do |line|
|
|
381
|
-
break if accumulated + line.length > available_chars
|
|
393
|
+
break if accumulated + line.length > available_chars
|
|
382
394
|
first_part << line
|
|
383
395
|
accumulated += line.length
|
|
384
396
|
end
|
|
385
397
|
|
|
386
|
-
# Take last few lines
|
|
387
|
-
last_part = []
|
|
388
|
-
accumulated = 0
|
|
389
|
-
lines.reverse_each do |line|
|
|
390
|
-
break if accumulated + line.length > available_chars / 2
|
|
391
|
-
last_part.unshift(line)
|
|
392
|
-
accumulated += line.length
|
|
393
|
-
end
|
|
394
|
-
|
|
395
398
|
total_lines = lines.length
|
|
399
|
+
shown_lines = first_part.length
|
|
396
400
|
|
|
397
|
-
# Create notice message
|
|
401
|
+
# Create prominent notice message with temp file path
|
|
398
402
|
if label == "stderr"
|
|
399
|
-
notice =
|
|
403
|
+
notice = <<~NOTICE
|
|
404
|
+
|
|
405
|
+
... [Error output truncated for LLM: showing #{shown_lines} of #{total_lines} lines, full content: #{temp_file} (use grep to search)] ...
|
|
406
|
+
NOTICE
|
|
400
407
|
else
|
|
401
|
-
notice =
|
|
408
|
+
notice = <<~NOTICE
|
|
409
|
+
|
|
410
|
+
... [Output truncated for LLM: showing #{shown_lines} of #{total_lines} lines, full content: #{temp_file} (use grep to search)] ...
|
|
411
|
+
NOTICE
|
|
402
412
|
end
|
|
403
413
|
|
|
404
414
|
# Combine with compact notice
|
|
405
|
-
content = first_part.join +
|
|
415
|
+
content = first_part.join + notice
|
|
406
416
|
|
|
407
417
|
{ content: content, temp_file: temp_file }
|
|
408
418
|
end
|
|
419
|
+
|
|
420
|
+
# Update shared output buffer for real-time access
|
|
421
|
+
# Uses LimitStack to automatically manage memory and keep only recent output
|
|
422
|
+
private def update_output_buffer
|
|
423
|
+
return unless @output_buffer
|
|
424
|
+
|
|
425
|
+
# Push new lines to LimitStack (automatically handles size limit)
|
|
426
|
+
stdout_lines = @stdout_buffer.string.lines
|
|
427
|
+
stderr_lines = @stderr_buffer.string.lines
|
|
428
|
+
|
|
429
|
+
@output_buffer[:stdout_lines].clear
|
|
430
|
+
@output_buffer[:stdout_lines].push_lines(stdout_lines)
|
|
431
|
+
|
|
432
|
+
@output_buffer[:stderr_lines].clear
|
|
433
|
+
@output_buffer[:stderr_lines].push_lines(stderr_lines)
|
|
434
|
+
|
|
435
|
+
@output_buffer[:timestamp] = Time.now
|
|
436
|
+
end
|
|
409
437
|
end
|
|
410
438
|
end
|
|
411
439
|
end
|
|
@@ -1058,15 +1058,33 @@ module Clacky
|
|
|
1058
1058
|
end
|
|
1059
1059
|
|
|
1060
1060
|
# Render a segment of a line with cursor if cursor is in this segment
|
|
1061
|
+
# Applies theme colors to the text
|
|
1061
1062
|
# @param line [String] Full line text
|
|
1062
1063
|
# @param segment_start [Integer] Start position of segment in line (char index)
|
|
1063
1064
|
# @param segment_end [Integer] End position of segment in line (char index)
|
|
1064
|
-
# @return [String] Rendered segment with cursor
|
|
1065
|
+
# @return [String] Rendered segment with cursor and theme colors applied
|
|
1065
1066
|
def render_line_segment_with_cursor(line, segment_start, segment_end)
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1067
|
+
chars = line.chars
|
|
1068
|
+
segment_chars = chars[segment_start...segment_end]
|
|
1069
|
+
|
|
1070
|
+
# Check if cursor is in this segment
|
|
1071
|
+
if @cursor_position >= segment_start && @cursor_position < segment_end
|
|
1072
|
+
# Cursor is in this segment
|
|
1073
|
+
cursor_pos_in_segment = @cursor_position - segment_start
|
|
1074
|
+
before_cursor = segment_chars[0...cursor_pos_in_segment].join
|
|
1075
|
+
cursor_char = segment_chars[cursor_pos_in_segment] || " "
|
|
1076
|
+
after_cursor = segment_chars[(cursor_pos_in_segment + 1)..-1]&.join || ""
|
|
1077
|
+
|
|
1078
|
+
# Apply theme color to text parts, keep cursor highlight as is
|
|
1079
|
+
"#{theme.format_text(before_cursor, :user)}#{@pastel.on_white(@pastel.black(cursor_char))}#{theme.format_text(after_cursor, :user)}"
|
|
1080
|
+
elsif @cursor_position == segment_end && segment_end == line.length
|
|
1081
|
+
# Cursor is at the very end of the line, show it in last segment
|
|
1082
|
+
segment_text = segment_chars.join
|
|
1083
|
+
"#{theme.format_text(segment_text, :user)}#{@pastel.on_white(@pastel.black(' '))}"
|
|
1084
|
+
else
|
|
1085
|
+
# Cursor is not in this segment, apply theme color
|
|
1086
|
+
theme.format_text(segment_chars.join, :user)
|
|
1087
|
+
end
|
|
1070
1088
|
end
|
|
1071
1089
|
|
|
1072
1090
|
# Render a separator line (ensures it doesn't exceed screen width)
|
|
@@ -1122,7 +1140,7 @@ module Clacky
|
|
|
1122
1140
|
# Working directory (shortened if too long)
|
|
1123
1141
|
if @sessionbar_info[:working_dir]
|
|
1124
1142
|
dir_display = shorten_path(@sessionbar_info[:working_dir])
|
|
1125
|
-
parts <<
|
|
1143
|
+
parts << theme.format_text(dir_display, :statusbar_path)
|
|
1126
1144
|
end
|
|
1127
1145
|
|
|
1128
1146
|
# Permission mode
|
|
@@ -1133,15 +1151,15 @@ module Clacky
|
|
|
1133
1151
|
|
|
1134
1152
|
# Model
|
|
1135
1153
|
if @sessionbar_info[:model]
|
|
1136
|
-
parts <<
|
|
1154
|
+
parts << theme.format_text(@sessionbar_info[:model], :statusbar_secondary)
|
|
1137
1155
|
end
|
|
1138
1156
|
|
|
1139
1157
|
# Tasks count
|
|
1140
|
-
parts <<
|
|
1158
|
+
parts << theme.format_text("#{@sessionbar_info[:tasks]} tasks", :statusbar_secondary)
|
|
1141
1159
|
|
|
1142
1160
|
# Cost
|
|
1143
1161
|
cost_display = format("$%.1f", @sessionbar_info[:cost])
|
|
1144
|
-
parts <<
|
|
1162
|
+
parts << theme.format_text(cost_display, :statusbar_secondary)
|
|
1145
1163
|
|
|
1146
1164
|
" " + parts.join(separator)
|
|
1147
1165
|
end
|
|
@@ -30,8 +30,9 @@ module Clacky
|
|
|
30
30
|
# Example: [{ name: "Option 1", value: :opt1 }, { name: "---", disabled: true }]
|
|
31
31
|
# @param validator [Proc, nil] Optional validation callback that receives values hash
|
|
32
32
|
# Should return { success: true } or { success: false, error: "message" }
|
|
33
|
+
# @param on_close [Proc, nil] Optional callback to execute when modal closes (e.g., to re-render screen)
|
|
33
34
|
# @return [Hash, nil] Hash of field values or selected value, or nil if cancelled
|
|
34
|
-
def show(title:, fields: nil, choices: nil, validator: nil)
|
|
35
|
+
def show(title:, fields: nil, choices: nil, validator: nil, on_close: nil)
|
|
35
36
|
@title = title
|
|
36
37
|
@mode = choices ? :menu : :form
|
|
37
38
|
@fields = fields || []
|
|
@@ -45,10 +46,16 @@ module Clacky
|
|
|
45
46
|
@selected_index = @choices.index { |c| !c[:disabled] } || 0
|
|
46
47
|
end
|
|
47
48
|
|
|
48
|
-
# Adjust height
|
|
49
|
+
# Adjust height based on mode
|
|
49
50
|
if @mode == :menu
|
|
50
51
|
visible_items = [@choices.length, 15].min
|
|
51
52
|
@height = visible_items + 4 # +4 for title, borders, and instructions
|
|
53
|
+
else
|
|
54
|
+
# Form mode - adjust height based on number of fields
|
|
55
|
+
# Each field takes 2 rows (label + input)
|
|
56
|
+
# +3 for title and top border
|
|
57
|
+
# +5 for error message area, buttons, and bottom border
|
|
58
|
+
@height = (@fields.length * 2) + 3 + 5
|
|
52
59
|
end
|
|
53
60
|
|
|
54
61
|
# Get terminal size
|
|
@@ -69,6 +76,8 @@ module Clacky
|
|
|
69
76
|
ensure
|
|
70
77
|
# Clear modal area
|
|
71
78
|
clear_modal(start_row, start_col)
|
|
79
|
+
# Call on_close callback if provided (e.g., to re-render screen)
|
|
80
|
+
on_close&.call
|
|
72
81
|
end
|
|
73
82
|
end
|
|
74
83
|
|
|
@@ -167,6 +176,17 @@ module Clacky
|
|
|
167
176
|
# Clear testing messages
|
|
168
177
|
print "\e[#{testing_row};#{testing_col}H\e[K"
|
|
169
178
|
print "\e[#{testing_row + 1};#{testing_col}H\e[K"
|
|
179
|
+
|
|
180
|
+
if validation_result[:success]
|
|
181
|
+
# Validation passed - hide cursor and return values
|
|
182
|
+
print "\e[?25l"
|
|
183
|
+
return @values
|
|
184
|
+
else
|
|
185
|
+
# Validation failed - show error and loop again to let user correct input
|
|
186
|
+
@error_message = validation_result[:error] || "Validation failed"
|
|
187
|
+
# Don't clear modal - just loop again to redraw with error message
|
|
188
|
+
# This prevents the modal from flickering
|
|
189
|
+
end
|
|
170
190
|
else
|
|
171
191
|
# No validator - return immediately
|
|
172
192
|
print "\e[?25l"
|
|
@@ -26,26 +26,48 @@ module Clacky
|
|
|
26
26
|
"[*] Type /help for more commands"
|
|
27
27
|
].freeze
|
|
28
28
|
|
|
29
|
+
# Minimum terminal width required for full logo display
|
|
30
|
+
MIN_WIDTH_FOR_LOGO = 90
|
|
31
|
+
|
|
29
32
|
def initialize
|
|
30
33
|
@pastel = Pastel.new
|
|
31
34
|
end
|
|
32
35
|
|
|
33
|
-
#
|
|
36
|
+
# Get current theme from ThemeManager
|
|
37
|
+
def theme
|
|
38
|
+
UI2::ThemeManager.current_theme
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Render only the logo (ASCII art or simple text based on terminal width)
|
|
42
|
+
# @param width [Integer] Terminal width
|
|
34
43
|
# @return [String] Formatted logo only
|
|
35
|
-
def render_logo
|
|
44
|
+
def render_logo(width:)
|
|
36
45
|
lines = []
|
|
37
46
|
lines << ""
|
|
38
|
-
|
|
47
|
+
|
|
48
|
+
if width >= MIN_WIDTH_FOR_LOGO
|
|
49
|
+
lines << @pastel.bright_green(LOGO)
|
|
50
|
+
else
|
|
51
|
+
lines << @pastel.bright_green("Welcome, OpenClacky is here")
|
|
52
|
+
end
|
|
53
|
+
|
|
39
54
|
lines << ""
|
|
40
55
|
lines.join("\n")
|
|
41
56
|
end
|
|
42
57
|
|
|
43
58
|
# Render startup banner
|
|
59
|
+
# @param width [Integer] Terminal width
|
|
44
60
|
# @return [String] Formatted startup banner
|
|
45
|
-
def render_startup
|
|
61
|
+
def render_startup(width:)
|
|
46
62
|
lines = []
|
|
47
63
|
lines << ""
|
|
48
|
-
|
|
64
|
+
|
|
65
|
+
if width >= MIN_WIDTH_FOR_LOGO
|
|
66
|
+
lines << @pastel.bright_green(LOGO)
|
|
67
|
+
else
|
|
68
|
+
lines << @pastel.bright_green("Welcome, OpenClacky is here")
|
|
69
|
+
end
|
|
70
|
+
|
|
49
71
|
lines << ""
|
|
50
72
|
lines << @pastel.bright_cyan(TAGLINE)
|
|
51
73
|
lines << @pastel.dim(" Version #{Clacky::VERSION}")
|
|
@@ -71,7 +93,7 @@ module Clacky
|
|
|
71
93
|
lines << info_line("Working Directory", working_dir)
|
|
72
94
|
lines << info_line("Permission Mode", mode)
|
|
73
95
|
lines << ""
|
|
74
|
-
lines <<
|
|
96
|
+
lines << theme.format_text("[!] Type 'exit' or 'quit' to terminate session", :thinking)
|
|
75
97
|
lines << separator("-")
|
|
76
98
|
lines << ""
|
|
77
99
|
lines.join("\n")
|
|
@@ -80,9 +102,10 @@ module Clacky
|
|
|
80
102
|
# Render full welcome (startup + agent info)
|
|
81
103
|
# @param working_dir [String] Working directory
|
|
82
104
|
# @param mode [String] Permission mode
|
|
105
|
+
# @param width [Integer] Terminal width
|
|
83
106
|
# @return [String] Full welcome content
|
|
84
|
-
def render_full(working_dir:, mode:)
|
|
85
|
-
render_startup + render_agent_welcome(
|
|
107
|
+
def render_full(working_dir:, mode:, width:)
|
|
108
|
+
render_startup(width: width) + render_agent_welcome(
|
|
86
109
|
working_dir: working_dir,
|
|
87
110
|
mode: mode
|
|
88
111
|
)
|
|
@@ -92,12 +115,12 @@ module Clacky
|
|
|
92
115
|
|
|
93
116
|
def info_line(label, value)
|
|
94
117
|
label_text = @pastel.cyan("[#{label}]")
|
|
95
|
-
value_text =
|
|
118
|
+
value_text = theme.format_text(value, :info)
|
|
96
119
|
" #{label_text} #{value_text}"
|
|
97
120
|
end
|
|
98
121
|
|
|
99
122
|
def separator(char = "-")
|
|
100
|
-
|
|
123
|
+
theme.format_text(char * 80, :thinking) # Use :thinking for subtle separator
|
|
101
124
|
end
|
|
102
125
|
end
|
|
103
126
|
end
|