ruby-claw 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +94 -0
  3. data/README.md +214 -10
  4. data/exe/claw +42 -1
  5. data/lib/claw/auto_forge.rb +66 -0
  6. data/lib/claw/benchmark/benchmark.rb +79 -0
  7. data/lib/claw/benchmark/diff.rb +69 -0
  8. data/lib/claw/benchmark/report.rb +87 -0
  9. data/lib/claw/benchmark/runner.rb +91 -0
  10. data/lib/claw/benchmark/scorer.rb +69 -0
  11. data/lib/claw/benchmark/task.rb +63 -0
  12. data/lib/claw/benchmark/tasks/claw_remember.rb +20 -0
  13. data/lib/claw/benchmark/tasks/claw_session.rb +18 -0
  14. data/lib/claw/benchmark/tasks/evolution_trace.rb +18 -0
  15. data/lib/claw/benchmark/tasks/mana_call_func.rb +21 -0
  16. data/lib/claw/benchmark/tasks/mana_eval.rb +18 -0
  17. data/lib/claw/benchmark/tasks/mana_knowledge.rb +19 -0
  18. data/lib/claw/benchmark/tasks/mana_var_readwrite.rb +18 -0
  19. data/lib/claw/benchmark/tasks/runtime_fork.rb +18 -0
  20. data/lib/claw/benchmark/tasks/runtime_snapshot.rb +18 -0
  21. data/lib/claw/benchmark/trigger.rb +68 -0
  22. data/lib/claw/chat.rb +119 -6
  23. data/lib/claw/child_runtime.rb +196 -0
  24. data/lib/claw/cli.rb +177 -0
  25. data/lib/claw/commands.rb +131 -0
  26. data/lib/claw/config.rb +5 -1
  27. data/lib/claw/console/event_logger.rb +69 -0
  28. data/lib/claw/console/public/app.js +264 -0
  29. data/lib/claw/console/public/style.css +330 -0
  30. data/lib/claw/console/server.rb +253 -0
  31. data/lib/claw/console/sse.rb +28 -0
  32. data/lib/claw/console/views/experiments.erb +8 -0
  33. data/lib/claw/console/views/index.erb +27 -0
  34. data/lib/claw/console/views/layout.erb +29 -0
  35. data/lib/claw/console/views/memory.erb +13 -0
  36. data/lib/claw/console/views/monitor.erb +15 -0
  37. data/lib/claw/console/views/prompt.erb +15 -0
  38. data/lib/claw/console/views/snapshots.erb +12 -0
  39. data/lib/claw/console/views/tools.erb +13 -0
  40. data/lib/claw/console/views/traces.erb +9 -0
  41. data/lib/claw/console.rb +5 -0
  42. data/lib/claw/evolution.rb +227 -0
  43. data/lib/claw/forge.rb +144 -0
  44. data/lib/claw/hub.rb +67 -0
  45. data/lib/claw/init.rb +199 -0
  46. data/lib/claw/knowledge.rb +36 -2
  47. data/lib/claw/memory_store.rb +2 -2
  48. data/lib/claw/plan_mode.rb +110 -0
  49. data/lib/claw/resource.rb +35 -0
  50. data/lib/claw/resources/binding_resource.rb +128 -0
  51. data/lib/claw/resources/context_resource.rb +73 -0
  52. data/lib/claw/resources/filesystem_resource.rb +107 -0
  53. data/lib/claw/resources/memory_resource.rb +74 -0
  54. data/lib/claw/resources/worktree_resource.rb +133 -0
  55. data/lib/claw/roles.rb +56 -0
  56. data/lib/claw/runtime.rb +189 -0
  57. data/lib/claw/serializer.rb +10 -7
  58. data/lib/claw/tool.rb +99 -0
  59. data/lib/claw/tool_index.rb +84 -0
  60. data/lib/claw/tool_registry.rb +100 -0
  61. data/lib/claw/trace.rb +86 -0
  62. data/lib/claw/tui/agent_executor.rb +92 -0
  63. data/lib/claw/tui/chat_panel.rb +81 -0
  64. data/lib/claw/tui/command_bar.rb +22 -0
  65. data/lib/claw/tui/file_card.rb +88 -0
  66. data/lib/claw/tui/folding.rb +80 -0
  67. data/lib/claw/tui/input_handler.rb +73 -0
  68. data/lib/claw/tui/layout.rb +34 -0
  69. data/lib/claw/tui/messages.rb +31 -0
  70. data/lib/claw/tui/model.rb +411 -0
  71. data/lib/claw/tui/object_explorer.rb +136 -0
  72. data/lib/claw/tui/status_bar.rb +30 -0
  73. data/lib/claw/tui/status_panel.rb +133 -0
  74. data/lib/claw/tui/styles.rb +58 -0
  75. data/lib/claw/tui/tui.rb +54 -0
  76. data/lib/claw/version.rb +1 -1
  77. data/lib/claw.rb +99 -1
  78. metadata +223 -7
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "glamour"
4
+
5
+ module Claw
6
+ module TUI
7
+ # Left panel: chat history + input box.
8
+ # Uses Bubbles::Viewport for scrollable content and Glamour for markdown rendering.
9
+ module ChatPanel
10
+ def self.render(model, width, height)
11
+ # Reserve 3 lines for input box
12
+ chat_height = height - 3
13
+
14
+ # Render chat messages
15
+ content = render_messages(model.chat_history, width - 4)
16
+
17
+ # Set up viewport
18
+ viewport = model.chat_viewport
19
+ viewport.width = width - 4
20
+ viewport.height = chat_height
21
+ viewport.content = content
22
+ viewport.goto_bottom unless model.scrolled_up?
23
+
24
+ # Input box
25
+ input_line = render_input(model, width - 4)
26
+
27
+ # Compose with border
28
+ chat_view = viewport.view
29
+ panel = "#{chat_view}\n#{input_line}"
30
+
31
+ Styles::PANEL_BORDER.width(width).height(height).render(panel)
32
+ end
33
+
34
+ def self.render_messages(messages, width)
35
+ # Fold consecutive tool calls
36
+ messages = Folding.fold_tool_calls(messages)
37
+
38
+ lines = []
39
+ messages.each do |msg|
40
+ case msg[:role]
41
+ when :user
42
+ lines << Styles::USER_STYLE.render("you> #{msg[:content]}")
43
+ when :agent
44
+ rendered = begin
45
+ Glamour.render(msg[:content].to_s)
46
+ rescue
47
+ msg[:content].to_s
48
+ end
49
+ folded = Folding.fold_text(rendered.rstrip)
50
+ lines << Styles::AGENT_STYLE.render("claw> ") + folded[:display]
51
+ when :tool_call
52
+ lines << Styles::TOOL_STYLE.render(" #{msg[:icon] || "⚡"} #{msg[:detail]}")
53
+ when :tool_result
54
+ lines << Styles::RESULT_STYLE.render(" ↩ #{truncate(msg[:result].to_s, width - 6)}")
55
+ when :ruby
56
+ highlighted = InputHandler.highlight(msg[:content].to_s)
57
+ lines << Styles::RUBY_STYLE.render("=> #{highlighted}")
58
+ when :error
59
+ lines << Styles::ERROR_STYLE.render("error: #{msg[:content]}")
60
+ when :system
61
+ lines << Styles::TOOL_STYLE.render(" #{msg[:content]}")
62
+ end
63
+ end
64
+ lines.join("\n")
65
+ end
66
+
67
+ def self.render_input(model, width)
68
+ prompt = model.mode == :plan ? "plan> " : "claw> "
69
+ cursor = model.input_focused? ? "█" : ""
70
+ text = "#{prompt}#{model.input_text}#{cursor}"
71
+ Lipgloss::Style.new.foreground(Styles::CYAN).width(width).render(text)
72
+ end
73
+
74
+ def self.truncate(str, max)
75
+ str.length > max ? "#{str[0, max - 3]}..." : str
76
+ end
77
+
78
+ private_class_method :render_messages, :render_input, :truncate
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Claw
4
+ module TUI
5
+ # Bottom bar: slash command hints + keyboard shortcuts.
6
+ module CommandBar
7
+ HINTS = %w[/snapshot /rollback /diff /history /status /evolve /plan /role].freeze
8
+
9
+ def self.render(model, width)
10
+ left = HINTS.join(" ")
11
+ right = "ctrl+c quit"
12
+ left_w, _ = Lipgloss.size(left)
13
+ right_w, _ = Lipgloss.size(right)
14
+ spacing = width - left_w - right_w - 2
15
+ spacing = 1 if spacing < 1
16
+
17
+ text = "#{left}#{" " * spacing}#{right}"
18
+ Styles::COMMAND_BAR.width(width).render(text)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Claw
4
+ module TUI
5
+ # Detect @filename references in user input and render file cards.
6
+ module FileCard
7
+ # Pattern to match @filename references (supports glob patterns).
8
+ FILE_REF_PATTERN = /@([\w.*\/\-]+(?:\.\w+)?)/
9
+
10
+ # Extract file references from input text.
11
+ #
12
+ # @param input [String] user input
13
+ # @return [Array<String>] list of file reference patterns
14
+ def self.extract_refs(input)
15
+ input.scan(FILE_REF_PATTERN).flatten
16
+ end
17
+
18
+ # Resolve a file reference to actual file paths.
19
+ # Supports glob patterns like @*.rb.
20
+ #
21
+ # @param ref [String] file reference (e.g., "user.rb" or "*.rb")
22
+ # @return [Array<String>] resolved file paths
23
+ def self.resolve(ref)
24
+ if ref.include?("*")
25
+ Dir.glob(ref)
26
+ elsif File.exist?(ref)
27
+ [ref]
28
+ else
29
+ []
30
+ end
31
+ end
32
+
33
+ # Render a compact file card for display.
34
+ #
35
+ # @param path [String] file path
36
+ # @return [String] rendered card
37
+ def self.render_card(path)
38
+ return " (file not found: #{path})" unless File.exist?(path)
39
+
40
+ stat = File.stat(path)
41
+ ext = File.extname(path).delete(".")
42
+ lines = File.readlines(path).size rescue 0
43
+ size = format_size(stat.size)
44
+ lang = language_for(ext)
45
+
46
+ card = Lipgloss::Style.new
47
+ .border(:rounded)
48
+ .border_foreground(Styles::DIM_GRAY)
49
+ .padding(0, 1)
50
+ .render("#{path} | #{lines} lines | #{lang} | #{size}")
51
+
52
+ card
53
+ end
54
+
55
+ # Read file content for injection into LLM context.
56
+ #
57
+ # @param path [String] file path
58
+ # @return [String] file content (truncated if large)
59
+ def self.read_for_context(path)
60
+ return "" unless File.exist?(path)
61
+
62
+ content = File.read(path, 50_001) || ""
63
+ if content.length > 50_000
64
+ content = content[0, 50_000] + "\n... (truncated)"
65
+ end
66
+ "# File: #{path}\n```\n#{content}\n```"
67
+ end
68
+
69
+ # --- Helpers ---
70
+
71
+ def self.format_size(bytes)
72
+ return "#{bytes}B" if bytes < 1024
73
+ return "#{(bytes / 1024.0).round(1)}KB" if bytes < 1024 * 1024
74
+ "#{(bytes / (1024.0 * 1024)).round(1)}MB"
75
+ end
76
+
77
+ def self.language_for(ext)
78
+ { "rb" => "Ruby", "py" => "Python", "js" => "JavaScript", "ts" => "TypeScript",
79
+ "rs" => "Rust", "go" => "Go", "java" => "Java", "c" => "C", "cpp" => "C++",
80
+ "md" => "Markdown", "json" => "JSON", "yml" => "YAML", "yaml" => "YAML",
81
+ "sh" => "Shell", "sql" => "SQL", "html" => "HTML", "css" => "CSS"
82
+ }.fetch(ext, ext.upcase)
83
+ end
84
+
85
+ private_class_method :format_size, :language_for
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Claw
4
+ module TUI
5
+ # Content folding for Claude Code-style UX:
6
+ # - Large text blocks collapsed
7
+ # - Consecutive same-type tool calls collapsed
8
+ # - Colored diff rendering
9
+ module Folding
10
+ # Fold text that exceeds a line threshold.
11
+ #
12
+ # @param text [String] the text to potentially fold
13
+ # @param threshold [Integer] max lines before folding (default 10)
14
+ # @return [Hash] { folded: bool, display: String, full: String }
15
+ def self.fold_text(text, threshold: 10)
16
+ lines = text.lines
17
+ if lines.size <= threshold
18
+ { folded: false, display: text, full: text }
19
+ else
20
+ preview = lines[0, 3].join
21
+ summary = "[+#{lines.size - 3} more lines · Ctrl+E to expand]"
22
+ display = "#{preview}#{Styles::TOOL_STYLE.render(summary)}"
23
+ { folded: true, display: display, full: text }
24
+ end
25
+ end
26
+
27
+ # Fold consecutive same-type tool calls into a summary.
28
+ #
29
+ # @param calls [Array<Hash>] tool call messages ({ role: :tool_call, ... })
30
+ # @return [Array<Hash>] folded messages (may be shorter)
31
+ def self.fold_tool_calls(calls)
32
+ return calls if calls.size <= 2
33
+
34
+ groups = calls.chunk { |c| c[:detail]&.split("(")&.first || c[:detail] }
35
+ groups.flat_map do |tool_name, group|
36
+ if group.size > 2
37
+ targets = group.map { |c| c[:detail].to_s.split("(").last&.tr(")", "") || "" }
38
+ summary = "#{group.size}x #{tool_name} (#{targets.first(3).join(', ')}#{targets.size > 3 ? ', ...' : ''})"
39
+ [{ role: :tool_call, icon: "⚡", detail: summary,
40
+ folded: true, children: group }]
41
+ else
42
+ group
43
+ end
44
+ end
45
+ end
46
+
47
+ # Render a diff hash with colors (green for additions, red for removals).
48
+ #
49
+ # @param diff_text [String] unified diff text
50
+ # @return [String] colored diff
51
+ def self.render_diff(diff_text)
52
+ diff_text.lines.map do |line|
53
+ case line[0]
54
+ when "+"
55
+ Lipgloss::Style.new.foreground("#32CD32").render(line.rstrip)
56
+ when "-"
57
+ Lipgloss::Style.new.foreground("#FF4444").render(line.rstrip)
58
+ when "~"
59
+ Lipgloss::Style.new.foreground("#FFD700").render(line.rstrip)
60
+ else
61
+ line.rstrip
62
+ end
63
+ end.join("\n")
64
+ end
65
+
66
+ # Render a resource diff hash from Runtime#diff.
67
+ #
68
+ # @param diffs [Hash] { resource_name => diff_string }
69
+ # @return [String] colored output
70
+ def self.render_resource_diff(diffs)
71
+ sections = diffs.map do |name, diff_text|
72
+ header = Lipgloss::Style.new.bold(true).render("#{name}:")
73
+ colored = render_diff(diff_text)
74
+ "#{header}\n#{colored}"
75
+ end
76
+ sections.join("\n\n")
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Claw
4
+ module TUI
5
+ # Enhanced input handling: syntax highlighting, tab completion, auto-indent.
6
+ module InputHandler
7
+ # Syntax highlight Ruby code using basic token patterns.
8
+ # Returns ANSI-colored string.
9
+ def self.highlight(code)
10
+ code
11
+ .gsub(/\b(def|end|class|module|if|else|elsif|unless|do|while|until|for|begin|rescue|ensure|return|yield|raise|require|require_relative|include|extend|attr_\w+)\b/) { "\e[35m#{$&}\e[0m" }
12
+ .gsub(/\b(true|false|nil|self)\b/) { "\e[36m#{$&}\e[0m" }
13
+ .gsub(/(#.*)$/) { "\e[2m#{$1}\e[0m" }
14
+ .gsub(/(:[\w!?]+)/) { "\e[33m#{$1}\e[0m" }
15
+ .gsub(/"([^"]*)"/) { "\e[32m\"#{$1}\"\e[0m" }
16
+ .gsub(/'([^']*)'/) { "\e[32m'#{$1}'\e[0m" }
17
+ .gsub(/\b(\d+\.?\d*)\b/) { "\e[34m#{$1}\e[0m" }
18
+ end
19
+
20
+ # Generate tab completion candidates from binding, memory, and commands.
21
+ #
22
+ # @param prefix [String] current input prefix
23
+ # @param binding [Binding] caller's binding
24
+ # @param memory [Claw::Memory, nil] memory for fact keywords
25
+ # @return [Array<String>] completion candidates
26
+ def self.completions(prefix, binding:, memory: nil)
27
+ candidates = []
28
+
29
+ # Local variables
30
+ candidates.concat(binding.local_variables.map(&:to_s))
31
+
32
+ # Receiver methods (filtered)
33
+ receiver = binding.eval("self")
34
+ candidates.concat(
35
+ receiver.methods.map(&:to_s).reject { |m| m.start_with?("_") || m.include?("!") && m.length < 3 }
36
+ )
37
+
38
+ # Slash commands
39
+ candidates.concat(Claw::Commands::COMMANDS.map { |c| "/#{c}" })
40
+ candidates.concat(%w[/plan /role /ls /cd /source /doc /find /whereami /shell /memory /forget])
41
+
42
+ # Memory keywords
43
+ if memory
44
+ memory.long_term.each do |m|
45
+ words = m[:content].to_s.split(/\s+/).select { |w| w.length > 3 }
46
+ candidates.concat(words)
47
+ end
48
+ end
49
+
50
+ candidates.uniq.select { |c| c.start_with?(prefix) }.sort
51
+ end
52
+
53
+ # Check if code has unclosed blocks (for multi-line continuation).
54
+ def self.incomplete?(code)
55
+ RubyVM::InstructionSequence.compile(code)
56
+ false
57
+ rescue SyntaxError => e
58
+ e.message.include?("unexpected end-of-input") ||
59
+ e.message.include?("unterminated")
60
+ end
61
+
62
+ # Calculate auto-indent level based on code structure.
63
+ #
64
+ # @param code [String] current multi-line buffer
65
+ # @return [Integer] number of spaces to indent
66
+ def self.indent_level(code)
67
+ opens = code.scan(/\b(def|class|module|if|unless|while|until|for|do|begin|case)\b/).size
68
+ closes = code.scan(/\bend\b/).size
69
+ [(opens - closes) * 2, 0].max
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Claw
4
+ module TUI
5
+ # Composes the 4-zone TUI layout: top status bar, left chat, right status, bottom commands.
6
+ module Layout
7
+ CHAT_RATIO = 0.65
8
+
9
+ def self.render(model, width, height)
10
+ # Top status bar: 1 line
11
+ top = StatusBar.render(model, width)
12
+ _, top_h = Lipgloss.size(top)
13
+
14
+ # Bottom command bar: 1 line
15
+ bottom = CommandBar.render(model, width)
16
+ _, bottom_h = Lipgloss.size(bottom)
17
+
18
+ # Middle area
19
+ middle_h = height - top_h - bottom_h
20
+ middle_h = 6 if middle_h < 6
21
+
22
+ left_w = (width * CHAT_RATIO).to_i
23
+ right_w = width - left_w
24
+
25
+ left = ChatPanel.render(model, left_w, middle_h)
26
+ right = StatusPanel.render(model, right_w, middle_h)
27
+
28
+ middle = Lipgloss.join_horizontal(Lipgloss::TOP, left, right)
29
+
30
+ Lipgloss.join_vertical(Lipgloss::LEFT, top, middle, bottom)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Claw
4
+ module TUI
5
+ # MVU message types sent between components and the model.
6
+
7
+ # Agent emitted a text chunk (streaming).
8
+ AgentTextMsg = Struct.new(:text, keyword_init: true)
9
+
10
+ # Agent started a tool call.
11
+ ToolCallMsg = Struct.new(:name, :input, keyword_init: true)
12
+
13
+ # Agent finished a tool call.
14
+ ToolResultMsg = Struct.new(:name, :result, keyword_init: true)
15
+
16
+ # Agent execution completed.
17
+ ExecutionDoneMsg = Struct.new(:result, :trace, keyword_init: true)
18
+
19
+ # Agent execution failed.
20
+ ExecutionErrorMsg = Struct.new(:error, keyword_init: true)
21
+
22
+ # Runtime state changed (idle/thinking/executing_tool/failed).
23
+ StateChangeMsg = Struct.new(:old_state, :new_state, :step, keyword_init: true)
24
+
25
+ # Tick for spinner/progress animations.
26
+ TickMsg = Struct.new(:time, keyword_init: true)
27
+
28
+ # Command result from a slash command.
29
+ CommandResultMsg = Struct.new(:result, :cmd, keyword_init: true)
30
+ end
31
+ end