openclacky 0.6.0 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +28 -0
- data/lib/clacky/agent.rb +137 -12
- data/lib/clacky/cli.rb +15 -5
- data/lib/clacky/tools/glob.rb +2 -2
- data/lib/clacky/tools/grep.rb +2 -2
- data/lib/clacky/tools/run_project.rb +5 -5
- data/lib/clacky/tools/safe_shell.rb +101 -8
- data/lib/clacky/tools/shell.rb +2 -2
- data/lib/clacky/tools/todo_manager.rb +50 -3
- data/lib/clacky/tools/trash_manager.rb +1 -1
- data/lib/clacky/tools/web_fetch.rb +2 -2
- data/lib/clacky/tools/web_search.rb +2 -2
- data/lib/clacky/ui2/components/common_component.rb +14 -5
- data/lib/clacky/ui2/components/input_area.rb +124 -29
- data/lib/clacky/ui2/components/message_component.rb +7 -3
- data/lib/clacky/ui2/components/output_area.rb +1 -1
- data/lib/clacky/ui2/components/todo_area.rb +38 -45
- data/lib/clacky/ui2/components/welcome_banner.rb +10 -0
- data/lib/clacky/ui2/layout_manager.rb +120 -14
- data/lib/clacky/ui2/markdown_renderer.rb +80 -0
- data/lib/clacky/ui2/screen_buffer.rb +26 -7
- data/lib/clacky/ui2/themes/base_theme.rb +32 -46
- data/lib/clacky/ui2/themes/hacker_theme.rb +2 -0
- data/lib/clacky/ui2/themes/minimal_theme.rb +2 -0
- data/lib/clacky/ui2/ui_controller.rb +70 -12
- data/lib/clacky/ui2/view_renderer.rb +21 -4
- data/lib/clacky/version.rb +1 -1
- metadata +16 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9c052d03a1c67251ddb1310bd82b4d1bbee0129716f4e0f2ec664dfdf3b52ced
|
|
4
|
+
data.tar.gz: 1d04af0f03f0e03e5ef41a312b2074a8b53a57d2fc9b38f95e73fe3baed3ffd4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6a78384406082c30a41e3546b145230276532567664e2fcf2d6de579a7765966d9ce837629c24b1e35e27f5b9a60b7e1337d6f95981665f74cc99cdee52773ef
|
|
7
|
+
data.tar.gz: 6da58385006861fcb511c56febf1a0c81d5f30825354e3610201ea2b4c53a29f0d65fd41cf89447c9347c396de4dadb77f7139e4a69d19d0f5debcc986e5536f
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.6.1] - 2026-01-29
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- User tips for better guidance and feedback
|
|
14
|
+
- Batch TODO operations for improved task management
|
|
15
|
+
- Markdown output support for better formatted responses
|
|
16
|
+
- Text style customization options
|
|
17
|
+
|
|
18
|
+
### Improved
|
|
19
|
+
- Tool execution with slow progress indicators for long-running operations
|
|
20
|
+
- Progress UI refinements for better visual feedback
|
|
21
|
+
- Session restore now shows recent messages for context
|
|
22
|
+
- TODO area UI enhancements with auto-hide when all tasks completed
|
|
23
|
+
- Work status bar styling improvements
|
|
24
|
+
- Text wrapping when moving input to output area
|
|
25
|
+
- Safe shell output improvements for better readability
|
|
26
|
+
- Task info display optimization (only show essential information)
|
|
27
|
+
- TODO list cleanup and organization
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
- Double paste bug causing duplicate input
|
|
31
|
+
- Double error message display issue
|
|
32
|
+
- TODO clear functionality
|
|
33
|
+
- RSpec test hanging issues
|
|
34
|
+
|
|
35
|
+
### Removed
|
|
36
|
+
- Tool emoji from output for cleaner display
|
|
37
|
+
|
|
10
38
|
## [0.6.0] - 2026-01-28
|
|
11
39
|
|
|
12
40
|
### Added
|
data/lib/clacky/agent.rb
CHANGED
|
@@ -129,6 +129,33 @@ module Clacky
|
|
|
129
129
|
end
|
|
130
130
|
end
|
|
131
131
|
|
|
132
|
+
# Get recent user messages from conversation history
|
|
133
|
+
# @param limit [Integer] Number of recent user messages to retrieve (default: 5)
|
|
134
|
+
# @return [Array<String>] Array of recent user message contents
|
|
135
|
+
def get_recent_user_messages(limit: 5)
|
|
136
|
+
# Filter messages to only include real user messages (exclude system-injected ones)
|
|
137
|
+
user_messages = @messages.select do |m|
|
|
138
|
+
m[:role] == "user" && !m[:system_injected]
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Extract text content from the last N user messages
|
|
142
|
+
user_messages.last(limit).map do |msg|
|
|
143
|
+
extract_text_from_content(msg[:content])
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private def extract_text_from_content(content)
|
|
148
|
+
if content.is_a?(String)
|
|
149
|
+
content
|
|
150
|
+
elsif content.is_a?(Array)
|
|
151
|
+
# Extract text from content array (may contain text and images)
|
|
152
|
+
text_parts = content.select { |c| c.is_a?(Hash) && c[:type] == "text" }
|
|
153
|
+
text_parts.map { |c| c[:text] }.join("\n")
|
|
154
|
+
else
|
|
155
|
+
content.to_s
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
132
159
|
def add_hook(event, &block)
|
|
133
160
|
@hooks.add(event, &block)
|
|
134
161
|
end
|
|
@@ -137,6 +164,16 @@ module Clacky
|
|
|
137
164
|
@start_time = Time.now
|
|
138
165
|
@task_cost_source = :estimated # Reset for new task
|
|
139
166
|
@previous_total_tokens = 0 # Reset token tracking for new task
|
|
167
|
+
@task_start_iterations = @iterations # Track starting iterations for this task
|
|
168
|
+
@task_start_cost = @total_cost # Track starting cost for this task
|
|
169
|
+
|
|
170
|
+
# Track cache stats for current task
|
|
171
|
+
@task_cache_stats = {
|
|
172
|
+
cache_creation_input_tokens: 0,
|
|
173
|
+
cache_read_input_tokens: 0,
|
|
174
|
+
total_requests: 0,
|
|
175
|
+
cache_hit_requests: 0
|
|
176
|
+
}
|
|
140
177
|
|
|
141
178
|
# Add system prompt as the first message if this is the first run
|
|
142
179
|
if @messages.empty?
|
|
@@ -193,16 +230,17 @@ module Clacky
|
|
|
193
230
|
if action_result[:denied]
|
|
194
231
|
# If user provided feedback, treat it as a user question/instruction
|
|
195
232
|
if action_result[:feedback] && !action_result[:feedback].empty?
|
|
196
|
-
# Add user feedback as a new user message
|
|
233
|
+
# Add user feedback as a new user message with system_injected marker
|
|
197
234
|
@messages << {
|
|
198
235
|
role: "user",
|
|
199
|
-
content: "STOP. The user has a question/feedback for you: #{action_result[:feedback]}\n\nPlease respond to the user's question/feedback before continuing with any actions."
|
|
236
|
+
content: "STOP. The user has a question/feedback for you: #{action_result[:feedback]}\n\nPlease respond to the user's question/feedback before continuing with any actions.",
|
|
237
|
+
system_injected: true # Mark as system-injected message for filtering
|
|
200
238
|
}
|
|
201
239
|
# Continue loop to let agent respond to feedback
|
|
202
240
|
next
|
|
203
241
|
else
|
|
204
242
|
# User just said "no" without feedback - stop and wait
|
|
205
|
-
@ui&.show_assistant_message("Tool execution was denied. Please
|
|
243
|
+
@ui&.show_assistant_message("Tool execution was denied. Please give more instructions...")
|
|
206
244
|
break
|
|
207
245
|
end
|
|
208
246
|
end
|
|
@@ -221,8 +259,8 @@ module Clacky
|
|
|
221
259
|
# Let CLI handle the interrupt message
|
|
222
260
|
raise
|
|
223
261
|
rescue StandardError => e
|
|
262
|
+
# Build error result for session data, but let CLI handle error display
|
|
224
263
|
result = build_result(:error, error: e.message)
|
|
225
|
-
@ui&.show_error("Error: #{e.message}")
|
|
226
264
|
raise
|
|
227
265
|
end
|
|
228
266
|
end
|
|
@@ -405,8 +443,8 @@ module Clacky
|
|
|
405
443
|
end
|
|
406
444
|
end
|
|
407
445
|
|
|
408
|
-
#
|
|
409
|
-
@ui&.
|
|
446
|
+
# Clear progress indicator (change to gray and show final time)
|
|
447
|
+
@ui&.clear_progress
|
|
410
448
|
|
|
411
449
|
track_cost(response[:usage], raw_api_usage: response[:raw_api_usage])
|
|
412
450
|
|
|
@@ -494,7 +532,13 @@ module Clacky
|
|
|
494
532
|
|
|
495
533
|
confirmation = confirm_tool_use?(call)
|
|
496
534
|
unless confirmation[:approved]
|
|
497
|
-
|
|
535
|
+
# Show denial warning with user feedback if provided
|
|
536
|
+
denial_message = "Tool #{call[:name]} denied"
|
|
537
|
+
if confirmation[:feedback] && !confirmation[:feedback].empty?
|
|
538
|
+
denial_message += ": #{confirmation[:feedback]}"
|
|
539
|
+
end
|
|
540
|
+
@ui&.show_warning(denial_message)
|
|
541
|
+
|
|
498
542
|
denied = true
|
|
499
543
|
user_feedback = confirmation[:feedback]
|
|
500
544
|
feedback = user_feedback if user_feedback
|
|
@@ -526,8 +570,17 @@ module Clacky
|
|
|
526
570
|
args[:todos_storage] = @todos
|
|
527
571
|
end
|
|
528
572
|
|
|
573
|
+
# Show progress for potentially slow tools (no prefix newline)
|
|
574
|
+
if potentially_slow_tool?(call[:name], args)
|
|
575
|
+
progress_message = build_tool_progress_message(call[:name], args)
|
|
576
|
+
@ui&.show_progress(progress_message, prefix_newline: false)
|
|
577
|
+
end
|
|
578
|
+
|
|
529
579
|
result = tool.execute(**args)
|
|
530
580
|
|
|
581
|
+
# Clear progress if shown
|
|
582
|
+
@ui&.clear_progress if potentially_slow_tool?(call[:name], args)
|
|
583
|
+
|
|
531
584
|
# Hook: after_tool_use
|
|
532
585
|
@hooks.trigger(:after_tool_use, call, result)
|
|
533
586
|
|
|
@@ -606,6 +659,53 @@ module Clacky
|
|
|
606
659
|
false
|
|
607
660
|
end
|
|
608
661
|
|
|
662
|
+
# Check if a tool is potentially slow and should show progress
|
|
663
|
+
private def potentially_slow_tool?(tool_name, args)
|
|
664
|
+
case tool_name.to_s.downcase
|
|
665
|
+
when 'shell', 'safe_shell'
|
|
666
|
+
# Check if the command is a slow command
|
|
667
|
+
command = args[:command] || args['command']
|
|
668
|
+
return false unless command
|
|
669
|
+
|
|
670
|
+
# List of slow command patterns
|
|
671
|
+
slow_patterns = [
|
|
672
|
+
/bundle\s+(install|exec\s+rspec|exec\s+rake)/,
|
|
673
|
+
/npm\s+(install|run\s+test|run\s+build)/,
|
|
674
|
+
/yarn\s+(install|test|build)/,
|
|
675
|
+
/pnpm\s+install/,
|
|
676
|
+
/cargo\s+(build|test)/,
|
|
677
|
+
/go\s+(build|test)/,
|
|
678
|
+
/make\s+(test|build)/,
|
|
679
|
+
/pytest/,
|
|
680
|
+
/jest/
|
|
681
|
+
]
|
|
682
|
+
|
|
683
|
+
slow_patterns.any? { |pattern| command.match?(pattern) }
|
|
684
|
+
when 'web_fetch', 'web_search'
|
|
685
|
+
true # Network operations can be slow
|
|
686
|
+
else
|
|
687
|
+
false # Most file operations are fast
|
|
688
|
+
end
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
# Build progress message for tool execution
|
|
692
|
+
private def build_tool_progress_message(tool_name, args)
|
|
693
|
+
case tool_name.to_s.downcase
|
|
694
|
+
when 'shell', 'safe_shell'
|
|
695
|
+
command = args[:command] || args['command']
|
|
696
|
+
# Extract the main command for display
|
|
697
|
+
cmd_parts = command.to_s.split
|
|
698
|
+
main_cmd = cmd_parts.first(2).join(' ')
|
|
699
|
+
"Running #{main_cmd}"
|
|
700
|
+
when 'web_fetch'
|
|
701
|
+
"Fetching web page"
|
|
702
|
+
when 'web_search'
|
|
703
|
+
"Searching web"
|
|
704
|
+
else
|
|
705
|
+
"Executing #{tool_name}"
|
|
706
|
+
end
|
|
707
|
+
end
|
|
708
|
+
|
|
609
709
|
def track_cost(usage, raw_api_usage: nil)
|
|
610
710
|
# Priority 1: Use API-provided cost if available (OpenRouter, LiteLLM, etc.)
|
|
611
711
|
iteration_cost = nil
|
|
@@ -637,7 +737,7 @@ module Clacky
|
|
|
637
737
|
# Display token usage statistics for this iteration
|
|
638
738
|
display_iteration_tokens(usage, iteration_cost)
|
|
639
739
|
|
|
640
|
-
# Track cache usage statistics
|
|
740
|
+
# Track cache usage statistics (global)
|
|
641
741
|
@cache_stats[:total_requests] += 1
|
|
642
742
|
|
|
643
743
|
if usage[:cache_creation_input_tokens]
|
|
@@ -655,6 +755,20 @@ module Clacky
|
|
|
655
755
|
@cache_stats[:raw_api_usage_samples] << raw_api_usage
|
|
656
756
|
@cache_stats[:raw_api_usage_samples] = @cache_stats[:raw_api_usage_samples].last(3)
|
|
657
757
|
end
|
|
758
|
+
|
|
759
|
+
# Track cache usage for current task
|
|
760
|
+
if @task_cache_stats
|
|
761
|
+
@task_cache_stats[:total_requests] += 1
|
|
762
|
+
|
|
763
|
+
if usage[:cache_creation_input_tokens]
|
|
764
|
+
@task_cache_stats[:cache_creation_input_tokens] += usage[:cache_creation_input_tokens]
|
|
765
|
+
end
|
|
766
|
+
|
|
767
|
+
if usage[:cache_read_input_tokens]
|
|
768
|
+
@task_cache_stats[:cache_read_input_tokens] += usage[:cache_read_input_tokens]
|
|
769
|
+
@task_cache_stats[:cache_hit_requests] += 1
|
|
770
|
+
end
|
|
771
|
+
end
|
|
658
772
|
end
|
|
659
773
|
|
|
660
774
|
# Display token usage for current iteration
|
|
@@ -867,9 +981,14 @@ module Clacky
|
|
|
867
981
|
when true
|
|
868
982
|
{ approved: true, feedback: nil }
|
|
869
983
|
when false, nil
|
|
984
|
+
# User denied - add visual marker based on tool type
|
|
985
|
+
tool_name_capitalized = call[:name].capitalize
|
|
986
|
+
@ui&.show_info(" ↳ #{tool_name_capitalized} cancelled", prefix_newline: false)
|
|
870
987
|
{ approved: false, feedback: nil }
|
|
871
988
|
else
|
|
872
|
-
# String feedback
|
|
989
|
+
# String feedback - also add visual marker
|
|
990
|
+
tool_name_capitalized = call[:name].capitalize
|
|
991
|
+
@ui&.show_info(" ↳ #{tool_name_capitalized} cancelled", prefix_newline: false)
|
|
873
992
|
{ approved: false, feedback: result.to_s }
|
|
874
993
|
end
|
|
875
994
|
else
|
|
@@ -1079,14 +1198,20 @@ module Clacky
|
|
|
1079
1198
|
end
|
|
1080
1199
|
|
|
1081
1200
|
def build_result(status, error: nil)
|
|
1201
|
+
# Calculate iterations for current task only
|
|
1202
|
+
task_iterations = @iterations - (@task_start_iterations || 0)
|
|
1203
|
+
|
|
1204
|
+
# Calculate cost for current task only
|
|
1205
|
+
task_cost = @total_cost - (@task_start_cost || 0)
|
|
1206
|
+
|
|
1082
1207
|
{
|
|
1083
1208
|
status: status,
|
|
1084
1209
|
session_id: @session_id,
|
|
1085
|
-
iterations:
|
|
1210
|
+
iterations: task_iterations, # Show only current task iterations
|
|
1086
1211
|
duration_seconds: Time.now - @start_time,
|
|
1087
|
-
total_cost_usd:
|
|
1212
|
+
total_cost_usd: task_cost.round(4), # Show only current task cost
|
|
1088
1213
|
cost_source: @task_cost_source, # Add cost source for this task
|
|
1089
|
-
cache_stats: @cache_stats,
|
|
1214
|
+
cache_stats: @task_cache_stats || @cache_stats, # Use task cache stats if available
|
|
1090
1215
|
messages: @messages,
|
|
1091
1216
|
error: error
|
|
1092
1217
|
}
|
data/lib/clacky/cli.rb
CHANGED
|
@@ -82,11 +82,14 @@ module Clacky
|
|
|
82
82
|
# Handle session loading/continuation
|
|
83
83
|
session_manager = Clacky::SessionManager.new
|
|
84
84
|
agent = nil
|
|
85
|
+
is_session_load = false
|
|
85
86
|
|
|
86
87
|
if options[:continue]
|
|
87
88
|
agent = load_latest_session(client, agent_config, session_manager, working_dir)
|
|
89
|
+
is_session_load = !agent.nil?
|
|
88
90
|
elsif options[:attach]
|
|
89
91
|
agent = load_session_by_number(client, agent_config, session_manager, working_dir, options[:attach])
|
|
92
|
+
is_session_load = !agent.nil?
|
|
90
93
|
end
|
|
91
94
|
|
|
92
95
|
# Create new agent if no session loaded
|
|
@@ -98,7 +101,7 @@ module Clacky
|
|
|
98
101
|
Dir.chdir(working_dir) if should_chdir
|
|
99
102
|
|
|
100
103
|
begin
|
|
101
|
-
run_agent_with_ui2(agent, working_dir, agent_config, message, session_manager, client)
|
|
104
|
+
run_agent_with_ui2(agent, working_dir, agent_config, message, session_manager, client, is_session_load: is_session_load)
|
|
102
105
|
rescue StandardError => e
|
|
103
106
|
# Save session on error
|
|
104
107
|
if session_manager
|
|
@@ -231,7 +234,7 @@ module Clacky
|
|
|
231
234
|
return nil
|
|
232
235
|
end
|
|
233
236
|
|
|
234
|
-
|
|
237
|
+
# Don't print message here - will be shown by UI after banner
|
|
235
238
|
Clacky::Agent.from_session(client, agent_config, session_data)
|
|
236
239
|
end
|
|
237
240
|
|
|
@@ -276,7 +279,7 @@ module Clacky
|
|
|
276
279
|
end
|
|
277
280
|
end
|
|
278
281
|
|
|
279
|
-
|
|
282
|
+
# Don't print message here - will be shown by UI after banner
|
|
280
283
|
Clacky::Agent.from_session(client, agent_config, session_data)
|
|
281
284
|
end
|
|
282
285
|
|
|
@@ -296,7 +299,7 @@ module Clacky
|
|
|
296
299
|
end
|
|
297
300
|
|
|
298
301
|
# Run agent with UI2 split-screen interface
|
|
299
|
-
def run_agent_with_ui2(agent, working_dir, agent_config, initial_message = nil, session_manager = nil, client = nil)
|
|
302
|
+
def run_agent_with_ui2(agent, working_dir, agent_config, initial_message = nil, session_manager = nil, client = nil, is_session_load: false)
|
|
300
303
|
# Create UI2 controller with configuration
|
|
301
304
|
ui_controller = UI2::UIController.new(
|
|
302
305
|
working_dir: working_dir,
|
|
@@ -359,6 +362,8 @@ module Clacky
|
|
|
359
362
|
ui_controller.show_info("Session cleared. Starting fresh.")
|
|
360
363
|
# Update session bar with reset values
|
|
361
364
|
ui_controller.update_sessionbar(tasks: agent.total_tasks, cost: agent.total_cost)
|
|
365
|
+
# Clear todo area display
|
|
366
|
+
ui_controller.update_todos([])
|
|
362
367
|
next
|
|
363
368
|
when "/exit", "/quit"
|
|
364
369
|
ui_controller.stop
|
|
@@ -400,7 +405,12 @@ module Clacky
|
|
|
400
405
|
end
|
|
401
406
|
|
|
402
407
|
# Initialize UI screen first
|
|
403
|
-
|
|
408
|
+
if is_session_load
|
|
409
|
+
recent_user_messages = agent.get_recent_user_messages(limit: 5)
|
|
410
|
+
ui_controller.initialize_and_show_banner(recent_user_messages: recent_user_messages)
|
|
411
|
+
else
|
|
412
|
+
ui_controller.initialize_and_show_banner
|
|
413
|
+
end
|
|
404
414
|
|
|
405
415
|
# If there's an initial message, process it
|
|
406
416
|
if initial_message && !initial_message.strip.empty?
|
data/lib/clacky/tools/glob.rb
CHANGED
|
@@ -122,13 +122,13 @@ module Clacky
|
|
|
122
122
|
|
|
123
123
|
def format_result(result)
|
|
124
124
|
if result[:error]
|
|
125
|
-
"
|
|
125
|
+
"[Error] #{result[:error]}"
|
|
126
126
|
else
|
|
127
127
|
count = result[:returned] || 0
|
|
128
128
|
total = result[:total_matches] || 0
|
|
129
129
|
truncated = result[:truncated] ? " (truncated)" : ""
|
|
130
130
|
|
|
131
|
-
msg = "
|
|
131
|
+
msg = "[OK] Found #{count}/#{total} files#{truncated}"
|
|
132
132
|
|
|
133
133
|
# Add skipped files info if present
|
|
134
134
|
if result[:skipped_files]
|
data/lib/clacky/tools/grep.rb
CHANGED
|
@@ -212,11 +212,11 @@ module Clacky
|
|
|
212
212
|
|
|
213
213
|
def format_result(result)
|
|
214
214
|
if result[:error]
|
|
215
|
-
"
|
|
215
|
+
"[Error] #{result[:error]}"
|
|
216
216
|
else
|
|
217
217
|
matches = result[:total_matches] || 0
|
|
218
218
|
files = result[:files_with_matches] || 0
|
|
219
|
-
msg = "
|
|
219
|
+
msg = "[OK] Found #{matches} matches in #{files} files"
|
|
220
220
|
|
|
221
221
|
# Add truncation info if present
|
|
222
222
|
if result[:truncated] && result[:truncation_reason]
|
|
@@ -74,22 +74,22 @@ module Clacky
|
|
|
74
74
|
|
|
75
75
|
def format_result(result)
|
|
76
76
|
if result[:error]
|
|
77
|
-
"
|
|
77
|
+
"[Error] #{result[:error]}"
|
|
78
78
|
elsif result[:status]
|
|
79
79
|
case result[:status]
|
|
80
80
|
when 'started'
|
|
81
81
|
cmd_preview = result[:command] ? result[:command][0..50] : ''
|
|
82
82
|
output_preview = result[:output]&.lines&.first(2)&.join&.strip
|
|
83
|
-
msg = "
|
|
83
|
+
msg = "[OK] Started (PID: #{result[:pid]}, cmd: #{cmd_preview})"
|
|
84
84
|
msg += "\n #{output_preview}" if output_preview && !output_preview.empty?
|
|
85
85
|
msg
|
|
86
86
|
when 'stopped'
|
|
87
|
-
"
|
|
87
|
+
"[OK] Stopped"
|
|
88
88
|
when 'running'
|
|
89
89
|
uptime = result[:uptime] ? "#{result[:uptime].round(1)}s" : "unknown"
|
|
90
|
-
"Running
|
|
90
|
+
"[Running] #{uptime}, PID: #{result[:pid]}"
|
|
91
91
|
when 'not_running'
|
|
92
|
-
"Not
|
|
92
|
+
"[Not Running]"
|
|
93
93
|
else
|
|
94
94
|
result[:status].to_s
|
|
95
95
|
end
|
|
@@ -62,7 +62,7 @@ module Clacky
|
|
|
62
62
|
{
|
|
63
63
|
command: command,
|
|
64
64
|
stdout: "",
|
|
65
|
-
stderr: "
|
|
65
|
+
stderr: "[Security Protection] #{e.message}",
|
|
66
66
|
exit_code: 126,
|
|
67
67
|
success: false,
|
|
68
68
|
security_blocked: true
|
|
@@ -72,7 +72,10 @@ module Clacky
|
|
|
72
72
|
|
|
73
73
|
private def extract_timeout_from_command(command)
|
|
74
74
|
# Match patterns: "timeout 30 ...", "timeout 30s ...", etc.
|
|
75
|
+
# Also supports: "cd xxx && timeout 30 command", "export X=Y && timeout 30 command"
|
|
75
76
|
# Supports: timeout N command, timeout Ns command, timeout -s SIGNAL N command
|
|
77
|
+
|
|
78
|
+
# Try to match timeout at the beginning of command
|
|
76
79
|
match = command.match(/^timeout\s+(?:-s\s+\w+\s+)?(\d+)s?\s+(.+)$/i)
|
|
77
80
|
|
|
78
81
|
if match
|
|
@@ -81,7 +84,22 @@ module Clacky
|
|
|
81
84
|
return [actual_command, timeout_value]
|
|
82
85
|
end
|
|
83
86
|
|
|
84
|
-
#
|
|
87
|
+
# Try to match timeout after && or ;
|
|
88
|
+
# Pattern: "prefix && timeout 30 command" or "prefix; timeout 30 command"
|
|
89
|
+
match = command.match(/^(.+?)\s*(&&|;)\s*timeout\s+(?:-s\s+\w+\s+)?(\d+)s?\s+(.+)$/i)
|
|
90
|
+
|
|
91
|
+
if match
|
|
92
|
+
prefix = match[1] # e.g., "cd /tmp"
|
|
93
|
+
separator = match[2] # && or ;
|
|
94
|
+
timeout_value = match[3].to_i
|
|
95
|
+
main_command = match[4] # e.g., "bundle exec rspec"
|
|
96
|
+
|
|
97
|
+
# Reconstruct command without timeout prefix
|
|
98
|
+
actual_command = "#{prefix} #{separator} #{main_command}"
|
|
99
|
+
return [actual_command, timeout_value]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# No timeout found, return original command
|
|
85
103
|
[command, nil]
|
|
86
104
|
end
|
|
87
105
|
|
|
@@ -135,7 +153,7 @@ module Clacky
|
|
|
135
153
|
result[:safe_command] = safe_command
|
|
136
154
|
|
|
137
155
|
# Add security note to stdout
|
|
138
|
-
security_note = "
|
|
156
|
+
security_note = "[Safe] Command was automatically made safe\n"
|
|
139
157
|
result[:stdout] = security_note + (result[:stdout] || "")
|
|
140
158
|
end
|
|
141
159
|
|
|
@@ -160,18 +178,93 @@ module Clacky
|
|
|
160
178
|
stderr = result[:stderr] || result['stderr'] || ""
|
|
161
179
|
|
|
162
180
|
if result[:security_blocked]
|
|
163
|
-
"
|
|
181
|
+
"[Blocked] Security protection"
|
|
164
182
|
elsif result[:security_enhanced]
|
|
165
183
|
lines = stdout.lines.size
|
|
166
|
-
"
|
|
184
|
+
"[Safe] Completed#{lines > 0 ? " (#{lines} lines)" : ''}"
|
|
167
185
|
elsif exit_code == 0
|
|
168
186
|
lines = stdout.lines.size
|
|
169
|
-
"
|
|
187
|
+
"[OK] Completed#{lines > 0 ? " (#{lines} lines)" : ''}"
|
|
170
188
|
else
|
|
171
|
-
|
|
172
|
-
"✗ Exit #{exit_code}: #{error_msg[0..50]}"
|
|
189
|
+
format_non_zero_exit(exit_code, stdout, stderr)
|
|
173
190
|
end
|
|
174
191
|
end
|
|
192
|
+
|
|
193
|
+
private def format_non_zero_exit(exit_code, stdout, stderr)
|
|
194
|
+
stdout_lines = stdout.lines.size
|
|
195
|
+
has_output = stdout_lines > 0
|
|
196
|
+
has_error = !stderr.empty?
|
|
197
|
+
|
|
198
|
+
if has_error
|
|
199
|
+
# Real error: show error summary
|
|
200
|
+
error_summary = extract_error_summary(stderr)
|
|
201
|
+
"[Exit #{exit_code}] #{error_summary}"
|
|
202
|
+
elsif has_output
|
|
203
|
+
# Command produced output but exited with non-zero code
|
|
204
|
+
# This is common in commands like "ls; exit 1" or grep with no matches
|
|
205
|
+
"[Exit #{exit_code}] #{stdout_lines} lines output"
|
|
206
|
+
else
|
|
207
|
+
# No output, no error message - just show exit code
|
|
208
|
+
"[Exit #{exit_code}] No output"
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
private def extract_error_summary(stderr)
|
|
213
|
+
return "No error message" if stderr.empty?
|
|
214
|
+
|
|
215
|
+
# Try to extract the most meaningful error line
|
|
216
|
+
lines = stderr.lines.map(&:strip).reject(&:empty?)
|
|
217
|
+
|
|
218
|
+
# Common error patterns with priority
|
|
219
|
+
patterns = [
|
|
220
|
+
# Ruby/Python exceptions with error type
|
|
221
|
+
{ regex: /(\w+(?:Error|Exception)):\s*(.+)$/, format: ->(m) { "#{m[1]}: #{m[2]}" } },
|
|
222
|
+
# File not found patterns
|
|
223
|
+
{ regex: /cannot load such file.*--\s*(.+)$/, format: ->(m) { "Cannot load file: #{m[1]}" } },
|
|
224
|
+
{ regex: /No such file or directory.*[@\-]\s*(.+)$/, format: ->(m) { "File not found: #{m[1]}" } },
|
|
225
|
+
# Undefined method/variable
|
|
226
|
+
{ regex: /undefined (?:local variable or )?method [`'](\w+)'/, format: ->(m) { "Undefined method: #{m[1]}" } },
|
|
227
|
+
# Syntax errors
|
|
228
|
+
{ regex: /syntax error,?\s*(.+)$/i, format: ->(m) { "Syntax error: #{m[1]}" } }
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
# Try each pattern on each line
|
|
232
|
+
patterns.each do |pattern|
|
|
233
|
+
lines.each do |line|
|
|
234
|
+
match = line.match(pattern[:regex])
|
|
235
|
+
if match
|
|
236
|
+
result = pattern[:format].call(match)
|
|
237
|
+
return truncate_error(clean_path(result), 80)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Fallback: find the most informative line
|
|
243
|
+
informative_line = lines.find do |line|
|
|
244
|
+
!line.start_with?('from', 'Did you mean?', '#', 'Showing full backtrace') &&
|
|
245
|
+
line.length > 10 &&
|
|
246
|
+
(line.include?(':') || line.match?(/error|failed|cannot|invalid/i))
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
if informative_line
|
|
250
|
+
return truncate_error(clean_path(informative_line), 80)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Last resort: use first meaningful line
|
|
254
|
+
first_line = lines.first || "Unknown error"
|
|
255
|
+
truncate_error(clean_path(first_line), 80)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
private def clean_path(text)
|
|
259
|
+
# Remove long absolute paths, keep only filename
|
|
260
|
+
text.gsub(/\/(?:Users|home)\/[^\/]+\/[\w\/\.\-]+\/([^:\/\s]+)/, '')
|
|
261
|
+
.gsub(/\/[\w\/\.\-]{30,}\/([^:\/\s]+)/, '...')
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
private def truncate_error(text, max_length)
|
|
265
|
+
return text if text.length <= max_length
|
|
266
|
+
"#{text[0...max_length-3]}..."
|
|
267
|
+
end
|
|
175
268
|
end
|
|
176
269
|
|
|
177
270
|
class CommandSafetyReplacer
|
data/lib/clacky/tools/shell.rb
CHANGED
|
@@ -284,10 +284,10 @@ module Clacky
|
|
|
284
284
|
|
|
285
285
|
if exit_code == 0
|
|
286
286
|
lines = stdout.lines.size
|
|
287
|
-
"
|
|
287
|
+
"[OK] Completed#{lines > 0 ? " (#{lines} lines)" : ''}"
|
|
288
288
|
else
|
|
289
289
|
error_msg = stderr.lines.first&.strip || "Failed"
|
|
290
|
-
"
|
|
290
|
+
"[Exit #{exit_code}] #{error_msg[0..50]}"
|
|
291
291
|
end
|
|
292
292
|
end
|
|
293
293
|
end
|
|
@@ -29,12 +29,17 @@ module Clacky
|
|
|
29
29
|
id: {
|
|
30
30
|
type: "integer",
|
|
31
31
|
description: "The task ID (required for 'complete' and 'remove' actions)"
|
|
32
|
+
},
|
|
33
|
+
ids: {
|
|
34
|
+
type: "array",
|
|
35
|
+
items: { type: "integer" },
|
|
36
|
+
description: "Array of task IDs for batch removal (for 'remove' action). Example: [1, 3, 5]"
|
|
32
37
|
}
|
|
33
38
|
},
|
|
34
39
|
required: ["action"]
|
|
35
40
|
}
|
|
36
41
|
|
|
37
|
-
def execute(action:, task: nil, tasks: nil, id: nil, todos_storage: nil)
|
|
42
|
+
def execute(action:, task: nil, tasks: nil, id: nil, ids: nil, todos_storage: nil)
|
|
38
43
|
# todos_storage is injected by Agent, stores todos in memory
|
|
39
44
|
@todos = todos_storage || []
|
|
40
45
|
|
|
@@ -46,7 +51,12 @@ module Clacky
|
|
|
46
51
|
when "complete"
|
|
47
52
|
complete_todo(id)
|
|
48
53
|
when "remove"
|
|
49
|
-
|
|
54
|
+
# Support both single ID and batch IDs
|
|
55
|
+
if ids && ids.is_a?(Array)
|
|
56
|
+
remove_todos(ids)
|
|
57
|
+
else
|
|
58
|
+
remove_todo(id)
|
|
59
|
+
end
|
|
50
60
|
when "clear"
|
|
51
61
|
clear_todos
|
|
52
62
|
else
|
|
@@ -65,7 +75,12 @@ module Clacky
|
|
|
65
75
|
when 'list'
|
|
66
76
|
"TodoManager(list)"
|
|
67
77
|
when 'remove'
|
|
68
|
-
|
|
78
|
+
ids = args[:ids] || args['ids']
|
|
79
|
+
if ids && ids.is_a?(Array) && !ids.empty?
|
|
80
|
+
"TodoManager(remove #{ids.size} tasks: #{ids.join(', ')})"
|
|
81
|
+
else
|
|
82
|
+
"TodoManager(remove ##{args[:id] || args['id']})"
|
|
83
|
+
end
|
|
69
84
|
when 'clear'
|
|
70
85
|
"TodoManager(clear all)"
|
|
71
86
|
else
|
|
@@ -223,6 +238,38 @@ module Clacky
|
|
|
223
238
|
cleared_count: count
|
|
224
239
|
}
|
|
225
240
|
end
|
|
241
|
+
|
|
242
|
+
def remove_todos(ids)
|
|
243
|
+
return { error: "Task IDs array is required" } if ids.nil? || ids.empty?
|
|
244
|
+
|
|
245
|
+
todos = load_todos
|
|
246
|
+
removed_todos = []
|
|
247
|
+
not_found_ids = []
|
|
248
|
+
|
|
249
|
+
ids.each do |id|
|
|
250
|
+
todo = todos.find { |t| t[:id] == id }
|
|
251
|
+
if todo
|
|
252
|
+
removed_todos << todo
|
|
253
|
+
else
|
|
254
|
+
not_found_ids << id
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Remove all found todos
|
|
259
|
+
todos.reject! { |t| ids.include?(t[:id]) }
|
|
260
|
+
save_todos(todos)
|
|
261
|
+
|
|
262
|
+
result = {
|
|
263
|
+
message: "#{removed_todos.size} task(s) removed",
|
|
264
|
+
removed: removed_todos,
|
|
265
|
+
remaining: todos.size
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
# Add warning about not found IDs
|
|
269
|
+
result[:not_found] = not_found_ids unless not_found_ids.empty?
|
|
270
|
+
|
|
271
|
+
result
|
|
272
|
+
end
|
|
226
273
|
end
|
|
227
274
|
end
|
|
228
275
|
end
|