Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion lib/claw/tui/agent_executor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,13 @@ def execute(input, binding, &on_event)
# @param code [String] Ruby code to eval
# @param binding [Binding] caller's binding
# @return [Hash] { success: bool, result: Any, error: Exception? }
# Evaluate Ruby code in the given binding, catching all eval errors.
# Note: SyntaxError/ScriptError inherit from Exception (not StandardError),
# and SystemExit can be triggered by user code calling `exit`.
def eval_ruby(code, binding)
result = binding.eval(code)
{ success: true, result: result }
rescue StandardError, SyntaxError, ScriptError => e
rescue StandardError, ScriptError, SystemExit => e
{ success: false, error: e }
end
end
Expand Down
28 changes: 18 additions & 10 deletions lib/claw/tui/input_handler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,24 +26,32 @@ def self.highlight(code)
def self.completions(prefix, binding:, memory: nil)
candidates = []

# Local variables
candidates.concat(binding.local_variables.map(&:to_s))
begin
# Local variables
candidates.concat(binding.local_variables.map(&:to_s))

# Receiver methods (filtered)
receiver = binding.eval("self")
candidates.concat(
receiver.methods.map(&:to_s).reject { |m| m.start_with?("_") || m.include?("!") && m.length < 3 }
)
# Receiver methods (filtered)
receiver = binding.eval("self")
candidates.concat(
receiver.methods.map(&:to_s).reject { |m| m.start_with?("_") || (m.include?("!") && m.length < 3) }
)
rescue
# Binding is invalid or inaccessible; skip local completions
end

# Slash commands
candidates.concat(Claw::Commands::COMMANDS.map { |c| "/#{c}" })
candidates.concat(%w[/plan /role /cd /source /doc /find /shell /memory /forget /help /ask /new])

# Memory keywords
if memory
memory.long_term.each do |m|
words = m[:content].to_s.split(/\s+/).select { |w| w.length > 3 }
candidates.concat(words)
begin
memory.long_term.each do |m|
words = m[:content].to_s.split(/\s+/).select { |w| w.length > 3 }
candidates.concat(words)
end
rescue
# Memory access failed; skip
end
end

Expand Down
18 changes: 13 additions & 5 deletions lib/claw/tui/model.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ def initialize(caller_binding)
@text_buffer = +"" # accumulates streaming text
@input_history = []
@history_index = nil
@baseline_methods = caller_binding.eval("methods").dup
@saved_input = +""
@baseline_methods = begin
caller_binding.eval("methods").dup
rescue
[]
end

# Bubbles components
@chat_viewport = Bubbles::Viewport.new(width: 80, height: 20)
Expand Down Expand Up @@ -284,7 +289,7 @@ def handle_slash(text)
result = ObjectExplorer.source(arg.to_s, @caller_binding)
if result[:type] == :data
@chat_history << { role: :system, content: "#{result[:data][:file]}:#{result[:data][:line]}\n#{result[:data][:source]}" }
elsif result[:type] == :error && result[:message]&.include?("not found")
elsif result[:type] == :error
# Try to find REPL-defined source from tracked definitions
receiver = @caller_binding.eval("self")
defs = receiver.instance_variable_defined?(:@__claw_definitions__) ?
Expand Down Expand Up @@ -345,15 +350,15 @@ def handle_smart_input(text)
if (err.is_a?(NameError) || err.is_a?(NoMethodError)) &&
(text.include?(" ") || text.match?(/[^\x00-\x7F]/))
# Multi-word or non-ASCII that failed as Ruby → fallback to AI
handle_llm(text)
return handle_llm(text)
else
@chat_history << { role: :error, content: "#{err.class}: #{err.message}" }
@runtime&.resources&.dig("binding")&.scan_binding
[self, Bubbletea.none]
return [self, Bubbletea.none]
end
else
# Not valid Ruby syntax → send to AI directly
handle_llm(text)
return handle_llm(text)
end
end

Expand Down Expand Up @@ -423,6 +428,9 @@ def handle_tab_completion
display += " ..." if candidates.size > 20
@chat_history << { role: :system, content: display }
end
rescue => e
# Tab completion should never crash the TUI
nil
end


Expand Down
4 changes: 2 additions & 2 deletions lib/claw/tui/status_bar.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ module TUI
# Dynamically drops lower-priority items when viewport is too narrow.
module StatusBar
def self.render(model, width)
right = build_right(model, width)
right = build_right(model)

# Left items in priority order (last dropped first)
left_items = [
Expand All @@ -28,7 +28,7 @@ def self.render(model, width)
Styles::STATUS_BAR.width(width).render(text)
end

def self.build_right(model, width)
def self.build_right(model)
parts = []
parts << "↑pgup ↓pgdn" if model.scrolled_up?
parts << "mode: #{model.mode}" if model.mode != :normal
Expand Down
2 changes: 1 addition & 1 deletion lib/claw/version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@

module Claw
VERSION = "0.2.2"
BUILD = "20260407-007"
BUILD = "20260407-008"
end
117 changes: 111 additions & 6 deletions spec/claw/tui/model_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -279,12 +279,36 @@ def submit(model, text)
end

it "sends non-Ruby syntax to AI (e.g. Chinese text)" do
# "你好" is not valid Ruby syntax, so it should be routed to AI
# We can't fully test AI execution, but verify it doesn't crash and doesn't produce :ruby msg
submit(model, "你好世界")
ruby_msgs = model.chat_history.select { |m| m[:role] == :ruby }
expect(ruby_msgs).to be_empty
end

it "shows error for single-word NameError (no AI fallback)" do
submit(model, "undefined_xyz_var")
errors = model.chat_history.select { |m| m[:role] == :error }
expect(errors.size).to eq(1)
expect(errors.first[:content]).to include("NameError")
end

it "evaluates string literals as Ruby" do
submit(model, '"hello world"')
ruby_msgs = model.chat_history.select { |m| m[:role] == :ruby }
expect(ruby_msgs.size).to eq(1)
expect(ruby_msgs.first[:content]).to include("hello world")
end

it "evaluates array literals as Ruby" do
submit(model, "[1, 2, 3]")
ruby_msgs = model.chat_history.select { |m| m[:role] == :ruby }
expect(ruby_msgs.size).to eq(1)
expect(ruby_msgs.first[:content]).to include("1")
end

it "returns MVU tuple from handle_smart_input" do
result = submit(model, "42")
expect_mvu_tuple(result)
end
end

describe "input history (#6)" do
Expand Down Expand Up @@ -316,13 +340,36 @@ def submit(model, text)
model.update(make_key("down"))
expect(model.textarea.value).to eq("")
end

it "does nothing when history is empty" do
model.update(make_key("up"))
expect(model.textarea.value).to eq("")
end

it "does not go past oldest entry" do
submit(model, "only_one")
model.update(make_key("up"))
model.update(make_key("up")) # should stay at oldest
expect(model.textarea.value).to eq("only_one")
end

it "preserves saved input after up/down cycle" do
model.textarea.value = "partial"
model.update(make_key("a")) # type 'a' to get "partiala" in textarea
submit(model, "1 + 1")
# Type something, then navigate up and back down
model.textarea.value = "typing..."
model.update(make_key("up"))
expect(model.textarea.value).to eq("1 + 1")
model.update(make_key("down"))
expect(model.textarea.value).to eq("typing...")
end
end

describe "tab completion (#7)" do
before { model.init }

it "completes single candidate" do
# Define a unique method to have something to complete
submit(model, "def zzz_unique_test_method; end")
model.textarea.value = "zzz_unique"
model.update(make_key("tab"))
Expand All @@ -332,10 +379,28 @@ def submit(model, text)
it "shows multiple candidates in chat" do
model.textarea.value = "/h"
model.update(make_key("tab"))
# Should show candidates like /help, /history in chat
system_msgs = model.chat_history.select { |m| m[:role] == :system }
# At minimum the init message; may have candidates too
expect(model.chat_history.size).to be >= 1
expect(system_msgs.size).to be >= 2 # init msg + candidates
end

it "does nothing on empty prefix" do
model.textarea.value = ""
before_count = model.chat_history.size
model.update(make_key("tab"))
expect(model.chat_history.size).to eq(before_count)
end

it "does nothing when no matches" do
model.textarea.value = "zzz_no_match_xyz_999"
before_count = model.chat_history.size
model.update(make_key("tab"))
expect(model.chat_history.size).to eq(before_count)
end

it "completes slash commands" do
model.textarea.value = "/sn"
model.update(make_key("tab"))
expect(model.textarea.value).to eq("/snapshot")
end
end

Expand All @@ -357,11 +422,51 @@ def submit(model, text)
end
end

describe "/source REPL fallback (#14)" do
before { model.init }

it "shows tracked REPL definition for /source" do
submit(model, "def zzz_src_test; 42; end")
submit(model, "/source zzz_src_test")
system_msgs = model.chat_history.select { |m| m[:role] == :system && m[:content]&.include?("zzz_src_test") }
expect(system_msgs).not_to be_empty
end
end

describe "eval_ruby error handling" do
it "catches SyntaxError in eval_ruby" do
result = model.executor.eval_ruby("def end", model.send(:instance_variable_get, :@caller_binding))
expect(result[:success]).to be false
expect(result[:error]).to be_a(SyntaxError)
end

it "catches NameError in eval_ruby" do
result = model.executor.eval_ruby("undefined_var_xyz", model.send(:instance_variable_get, :@caller_binding))
expect(result[:success]).to be false
expect(result[:error]).to be_a(NameError)
end

it "returns success for valid Ruby" do
result = model.executor.eval_ruby("1 + 1", model.send(:instance_variable_get, :@caller_binding))
expect(result[:success]).to be true
expect(result[:result]).to eq(2)
end
end

describe "baseline methods (#9)" do
it "records baseline methods at init" do
expect(model.baseline_methods).to be_an(Array)
expect(model.baseline_methods).to include(:inspect)
end

it "detects new methods after definition" do
model.init
submit(model, "def zzz_baseline_test; end")
binding_obj = model.send(:instance_variable_get, :@caller_binding)
current = binding_obj.eval("methods")
new_methods = current - model.baseline_methods
expect(new_methods).to include(:zzz_baseline_test)
end
end

describe "WindowSizeMessage" do
Expand Down
46 changes: 46 additions & 0 deletions spec/claw/tui/snapshot_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -152,4 +152,50 @@ def render_plain(model, width: 72, height: 23)
expect(plain).to include("def foo")
end
end

describe "status bar model name (#1)" do
it "shows full model name from config" do
model = build_model
plain = render_plain(model)
model_name = Mana.config.model.to_s
# Model name should appear (or be truncated by responsive logic)
expect(plain).to include("claw v#{Claw::VERSION}")
end
end

describe "help alignment (#10)" do
it "has consistent indentation in /help output" do
model = build_model
submit(model, "/help")
help_msg = model.chat_history.find { |m| m[:role] == :system && m[:content]&.include?("/ask") }
lines = help_msg[:content].split("\n").select { |l| l.include?("—") }
# All command lines should have same indentation
indents = lines.map { |l| l.match(/\A(\s*)/)[1].length }
expect(indents.uniq.size).to eq(1), "Expected uniform indentation, got #{indents.uniq}"
end

it "does not include /ls or /whereami" do
model = build_model
submit(model, "/help")
help_msg = model.chat_history.find { |m| m[:role] == :system && m[:content]&.include?("/ask") }
expect(help_msg[:content]).not_to include("/ls")
expect(help_msg[:content]).not_to include("/whereami")
end

it "includes history and tab completion hint" do
model = build_model
submit(model, "/help")
help_msg = model.chat_history.find { |m| m[:role] == :system && m[:content]&.include?("/ask") }
expect(help_msg[:content]).to include("history")
expect(help_msg[:content]).to include("tab completion")
end
end

describe "scroll hint (#11)" do
it "does not show scroll hint when not scrolled" do
model = build_model
plain = render_plain(model)
expect(plain).not_to include("pgup")
end
end
end
Loading