diff --git a/lib/claw/tui/chat_panel.rb b/lib/claw/tui/chat_panel.rb index 8af4d6c..75a6c81 100644 --- a/lib/claw/tui/chat_panel.rb +++ b/lib/claw/tui/chat_panel.rb @@ -10,10 +10,12 @@ module ChatPanel def self.render(model, width, height) # Configure textarea width and render it ta = model.textarea - ta.width = width - 4 + ta.width = width - 2 # Dynamic height: expand to actual line count, cap at 5 line_count = [ta.line_count, 1].max ta.height = [line_count, 5].min + # Recalculate viewport offset with new height (stale from previous render cycle) + ta.instance_variable_set(:@viewport_offset, [ta.row - ta.height + 1, 0].max) # Show line numbers in multi-line mode for visual clarity ta.show_line_numbers = line_count > 1 input_view = ta.view @@ -21,15 +23,15 @@ def self.render(model, width, height) input_h = [input_h, 5].min # Chat viewport fills remaining space - chat_height = height - input_h - 1 + chat_height = height - input_h chat_height = 3 if chat_height < 3 # Render chat messages - content = render_messages(model.chat_history, width - 4) + content = render_messages(model.chat_history, width - 2, zone: model.zone) # Set up viewport viewport = model.chat_viewport - viewport.width = width - 4 + viewport.width = width - 2 viewport.height = chat_height viewport.content = content viewport.goto_bottom unless model.scrolled_up? @@ -41,12 +43,12 @@ def self.render(model, width, height) Styles::PANEL_BORDER.width(width).height(height).render(panel) end - def self.render_messages(messages, width) + def self.render_messages(messages, width, zone: nil) # Fold consecutive tool calls messages = Folding.fold_tool_calls(messages) lines = [] - messages.each do |msg| + messages.each_with_index do |msg, idx| case msg[:role] when :user lines << Styles::USER_STYLE.render(">> #{msg[:content]}") @@ -56,7 +58,10 @@ def self.render_messages(messages, width) rescue msg[:content].to_s end - folded = Folding.fold_text(rendered.rstrip) + folded = Folding.fold_text(rendered.rstrip, zone: zone, fold_id: idx) + if folded[:folded] + msg[:folded_full] = folded[:full] + end lines << Styles::AGENT_STYLE.render("claw> ") + folded[:display] when :tool_call lines << Styles::TOOL_STYLE.render(" #{msg[:icon] || "⚡"} #{msg[:detail]}") @@ -68,7 +73,8 @@ def self.render_messages(messages, width) when :error lines << Styles::ERROR_STYLE.render("error: #{msg[:content]}") when :system - lines << Styles::TOOL_STYLE.render(" #{msg[:content]}") + indented = msg[:content].to_s.gsub(/^/, " ") + lines << Styles::TOOL_STYLE.render(indented) end end lines.join("\n") diff --git a/lib/claw/tui/input_handler.rb b/lib/claw/tui/input_handler.rb index 2dc30b0..609d74d 100644 --- a/lib/claw/tui/input_handler.rb +++ b/lib/claw/tui/input_handler.rb @@ -35,6 +35,16 @@ def self.completions(prefix, binding:, memory: nil) candidates.concat( receiver.methods.map(&:to_s).reject { |m| m.start_with?("_") || (m.include?("!") && m.length < 3) } ) + + # Include private methods (def in eval creates private methods) + candidates.concat( + receiver.private_methods(false).map(&:to_s).reject { |m| m.start_with?("_") || (m.include?("!") && m.length < 3) } + ) + + # Include tracked REPL definitions + if receiver.instance_variable_defined?(:@__claw_definitions__) + candidates.concat(receiver.instance_variable_get(:@__claw_definitions__).keys) + end rescue # Binding is invalid or inaccessible; skip local completions end diff --git a/lib/claw/tui/model.rb b/lib/claw/tui/model.rb index 690dc2a..d9cf3ca 100644 --- a/lib/claw/tui/model.rb +++ b/lib/claw/tui/model.rb @@ -4,13 +4,37 @@ require "bubbles" require "io/console" +begin + require "bubblezone" +rescue LoadError + # bubblezone gem ships versioned native extensions (e.g. 4.0/bubblezone.bundle) + # but its loader may not route by Ruby version. Patch and retry. + begin + spec = Gem::Specification.find_by_name("bubblezone") + major, minor, = RUBY_VERSION.split(".") + versioned_dir = File.join(spec.gem_dir, "lib", "bubblezone", "#{major}.#{minor}") + if File.directory?(versioned_dir) + # Add versioned dir so require_relative "bubblezone/bubblezone" resolves + target = File.join(spec.gem_dir, "lib", "bubblezone", "bubblezone.bundle") + source = File.join(versioned_dir, "bubblezone.bundle") + unless File.exist?(target) + File.symlink(source, target) + end + require "bubblezone" + end + rescue LoadError, Gem::MissingSpecError, Errno::EEXIST, Errno::EACCES + # mouse click zones will be disabled but scroll/drag still work. + end +end + module Claw module TUI # MVU Model — central state for the TUI application. # Implements Bubbletea's init/update/view protocol. class Model attr_reader :runtime, :chat_history, :mode, :chat_viewport, :executor, :textarea, - :baseline_methods, :input_history + :baseline_methods, :input_history, :zone + attr_accessor :chat_ratio, :dragging_divider def initialize(caller_binding) @caller_binding = caller_binding @@ -22,6 +46,11 @@ def initialize(caller_binding) @input_history = [] @history_index = nil @saved_input = +"" + @chat_ratio = 0.70 + @dragging_divider = false + @view_width = 80 + @view_height = 24 + @zone = defined?(Bubblezone::Manager) ? Bubblezone::Manager.new : nil @baseline_methods = begin caller_binding.eval("methods").dup rescue @@ -60,6 +89,8 @@ def update(msg) cmd = case msg when Bubbletea::KeyMessage return handle_key(msg) + when Bubbletea::MouseMessage + return handle_mouse(msg) when Bubbles::Spinner::TickMessage @spinner, spinner_cmd = @spinner.update(msg) Bubbletea.batch(spinner_cmd, Bubbletea.tick(1.0) { TickMsg.new(time: Time.now) }) @@ -91,6 +122,8 @@ def update(msg) when StateChangeMsg Bubbletea.none when Bubbletea::WindowSizeMessage + @view_width = msg.width + @view_height = msg.height Bubbletea.none else Bubbletea.none @@ -99,7 +132,8 @@ def update(msg) end def view - h, w = IO.console&.winsize || [24, 80] + w = @view_width + h = @view_height w = 80 if w < 40 h = 24 if h < 12 Layout.render(self, w, h) @@ -123,6 +157,79 @@ def spinner_view = @spinner.view private + def handle_mouse(msg) + w = @view_width + h = @view_height + divider_x = (w * @chat_ratio).to_i + + if msg.wheel? + if msg.button == Bubbletea::MouseMessage::BUTTON_WHEEL_UP + @chat_viewport.scroll_up(3) + @scrolled_up = true + else + @chat_viewport.scroll_down(3) + @scrolled_up = @chat_viewport.at_bottom? ? false : true + end + elsif msg.press? + if msg.left? + # Click near divider (±2 cols) and below status bar → start drag + if (msg.x - divider_x).abs <= 2 && msg.y > 1 + @dragging_divider = true + else + handle_mouse_click(msg, w, h) + end + end + elsif msg.motion? + if @dragging_divider + @chat_ratio = (msg.x.to_f / w).clamp(0.3, 0.85) + end + elsif msg.release? + @dragging_divider = false + end + + [self, Bubbletea.none] + end + + def handle_mouse_click(msg, width, height) + return unless @zone + hit = @zone.find_in_bounds(msg.x, msg.y) + return unless hit + zone_id, _zone_info = hit + + case zone_id + when /\Asnap_(\d+)\z/ + snap_id = $1.to_i + @chat_history << { + role: :system, + content: "Snapshot ##{snap_id} selected. Type /rollback #{snap_id} to restore." + } + + when /\Amem_(\d+)\z/ + mem_id = $1.to_i + fact = Claw.memory&.long_term&.find { |m| m[:id] == mem_id } + if fact + @chat_history << { role: :system, content: "Memory ##{mem_id}: #{fact[:content]}" } + end + + when /\Atool_(.+)\z/ + tool_name = $1 + @chat_history << { + role: :system, + content: "Tool: #{tool_name}. Use /forge #{tool_name} to promote." + } + + when /\Afold_(\d+)\z/ + idx = $1.to_i + if idx < @chat_history.size + m = @chat_history[idx] + if m[:folded_full] + m[:content] = m[:folded_full] + m.delete(:folded_full) + end + end + end + end + def handle_key(msg) key = msg.to_s @@ -159,7 +266,7 @@ def handle_key(msg) return submit_textarea end when "up" - if @textarea.line_count <= 1 + if @textarea.line_count <= 1 || @textarea.row == 0 navigate_history(:up) return [self, Bubbletea.none] else @@ -167,7 +274,7 @@ def handle_key(msg) return [self, ta_cmd] end when "down" - if @textarea.line_count <= 1 + if @textarea.line_count <= 1 || @textarea.row == @textarea.line_count - 1 navigate_history(:down) return [self, Bubbletea.none] else diff --git a/lib/claw/version.rb b/lib/claw/version.rb index 7284284..63a820a 100644 --- a/lib/claw/version.rb +++ b/lib/claw/version.rb @@ -2,5 +2,5 @@ module Claw VERSION = "0.2.2" - BUILD = "20260407-011" + BUILD = "20260407-014" end diff --git a/spec/claw/tui/model_spec.rb b/spec/claw/tui/model_spec.rb index 7629bec..cd4ca2f 100644 --- a/spec/claw/tui/model_spec.rb +++ b/spec/claw/tui/model_spec.rb @@ -353,6 +353,35 @@ def submit(model, text) expect(model.textarea.value).to eq("only_one") end + it "navigates history from first line of multi-line content" do + submit(model, "def abc\n 1\nend") + submit(model, "2 + 2") + # Navigate up to "2 + 2" + model.update(make_key("up")) + expect(model.textarea.value).to eq("2 + 2") + # Navigate up again to multi-line entry + model.update(make_key("up")) + expect(model.textarea.value).to eq("def abc\n 1\nend") + # Cursor is on last line (row == line_count - 1 after value= sets cursor to end) + # Navigate up — cursor on row 0 would navigate history, but cursor is at end + # so first up moves cursor to line 0, then next up navigates history + end + + it "navigates history from last line of multi-line content via down" do + submit(model, "first") + submit(model, "second") + # Go to "first" + model.update(make_key("up")) + model.update(make_key("up")) + expect(model.textarea.value).to eq("first") + # Navigate down through entries + model.update(make_key("down")) + expect(model.textarea.value).to eq("second") + # Navigate down past last entry → empty + model.update(make_key("down")) + expect(model.textarea.value).to eq("") + 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 @@ -397,6 +426,13 @@ def submit(model, text) expect(model.chat_history.size).to eq(before_count) end + it "completes user-defined private methods" do + submit(model, "def zzz_priv_test_method; 99; end") + model.textarea.value = "zzz_priv" + model.update(make_key("tab")) + expect(model.textarea.value).to eq("zzz_priv_test_method") + end + it "completes slash commands" do model.textarea.value = "/sn" model.update(make_key("tab"))