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
22 changes: 14 additions & 8 deletions lib/claw/tui/chat_panel.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,28 @@ 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
_, input_h = Lipgloss.size(input_view)
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?
Expand All @@ -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]}")
Expand All @@ -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
Comment on lines +61 to +64
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 验证 Folding.fold_text 方法签名
ast-grep --pattern $'def self.fold_text($$$) {
  $$$
}'

# 搜索完整的 fold_text 方法定义
rg -n "def self\.fold_text" --type ruby -A 5

Repository: twokidsCarl/ruby-claw

Length of output: 439


🏁 Script executed:

# 查看完整的 folding.rb 文件
cat lib/claw/tui/folding.rb

# 查看 chat_panel.rb 的相关行
sed -n '55,70p' lib/claw/tui/chat_panel.rb

Repository: twokidsCarl/ruby-claw

Length of output: 4104


🏁 Script executed:

# 搜索所有 fold_text 定义
rg -n "def.*fold_text" lib/claw/tui/ -A 3

# 查看 chat_panel.rb 第55-70行的完整上下文
sed -n '55,70p' lib/claw/tui/chat_panel.rb

# 确认这是否是实际存在的问题
rg -n "fold_text.*zone:" lib/claw/tui/

Repository: twokidsCarl/ruby-claw

Length of output: 1131


Folding.fold_text 方法签名不匹配 — 将导致运行时 ArgumentError

根据 lib/claw/tui/folding.rb:15Folding.fold_text 方法签名为:

def self.fold_text(text, threshold: 10)

但在 lib/claw/tui/chat_panel.rb:61 处调用时传递了 zone:fold_id: 参数,这些参数在方法定义中不存在:

folded = Folding.fold_text(rendered.rstrip, zone: zone, fold_id: idx)

会导致运行时 ArgumentError: unknown keywords: zone, fold_id。请移除这两个参数或更新 fold_text 方法签名以接受它们。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/claw/tui/chat_panel.rb` around lines 61 - 64, The call to
Folding.fold_text in chat_panel.rb passes unknown keyword args (zone: and
fold_id:) that don't exist on Folding.fold_text (defined as def
self.fold_text(text, threshold: 10) in folding.rb), causing an ArgumentError;
fix by either removing the extra keywords from the call (change folded =
Folding.fold_text(rendered.rstrip, zone: zone, fold_id: idx) to folded =
Folding.fold_text(rendered.rstrip) and keep using folded[:folded] /
folded[:full] as before) or by updating the Folding.fold_text signature to
accept zone: and fold_id: (e.g., def self.fold_text(text, threshold: 10, zone:
nil, fold_id: nil)) and handle them inside the method; reference
Folding.fold_text, the call site in chat_panel.rb, and the zone / idx variables
when making the change.

lines << Styles::AGENT_STYLE.render("claw> ") + folded[:display]
when :tool_call
lines << Styles::TOOL_STYLE.render(" #{msg[:icon] || "⚡"} #{msg[:detail]}")
Expand All @@ -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")
Expand Down
10 changes: 10 additions & 0 deletions lib/claw/tui/input_handler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
115 changes: 111 additions & 4 deletions lib/claw/tui/model.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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) })
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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
Comment on lines +221 to +230
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 搜索 Folding 模块的完整定义
rg -n "module Folding" --type ruby -A 50

Repository: twokidsCarl/ruby-claw

Length of output: 3431


🏁 Script executed:

# Search for all calls to Folding.fold_text
rg -n "fold_text" --type ruby -B 2 -A 2

Repository: twokidsCarl/ruby-claw

Length of output: 814


🏁 Script executed:

# Check chat_panel.rb around lines 61-64
fd "chat_panel.rb" --type f | head -1 | xargs wc -l

Repository: twokidsCarl/ruby-claw

Length of output: 94


🏁 Script executed:

# Once we know file size, examine chat_panel.rb lines around 61-64
fd "chat_panel.rb" --type f | head -1 | xargs cat -n | sed -n '55,75p'

Repository: twokidsCarl/ruby-claw

Length of output: 1152


🏁 Script executed:

# Also check model.rb around lines 221-230 to see full context
fd "model.rb" --path "*/tui/*" --type f | head -1 | xargs cat -n | sed -n '215,240p'

Repository: twokidsCarl/ruby-claw

Length of output: 280


🏁 Script executed:

# Get model.rb file and show lines around 221-230
rg -n "fold_" lib/claw/tui/model.rb -B 3 -A 8 | head -50

Repository: twokidsCarl/ruby-claw

Length of output: 473


🏁 Script executed:

# Check if there are multiple fold_text method definitions
rg -n "def.*fold_text" --type ruby

Repository: twokidsCarl/ruby-claw

Length of output: 137


Folding.fold_text 方法签名与调用不匹配。

chat_panel.rb:61 调用 Folding.fold_text(rendered.rstrip, zone: zone, fold_id: idx) 传递了 zone:fold_id: 参数,但 folding.rb:15 中的方法定义只接受 textthreshold: 10。这将导致运行时错误。

需要更新 Folding.fold_text 的方法签名以支持这些参数,或者修改调用代码以移除这些参数。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/claw/tui/model.rb` around lines 221 - 230, Folding.fold_text currently
only accepts (text, threshold: 10) but callers (e.g., the code that calls
Folding.fold_text(rendered.rstrip, zone: zone, fold_id: idx)) pass keyword args
zone: and fold_id:, causing a mismatch; update the Folding.fold_text method
signature to accept those keywords (e.g., add zone: nil, fold_id: nil) and
ensure the implementation uses or safely ignores them (and maintain threshold:
default) so existing calls succeed without changing callers.

end

def handle_key(msg)
key = msg.to_s

Expand Down Expand Up @@ -159,15 +266,15 @@ 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
@textarea, ta_cmd = @textarea.update(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
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-011"
BUILD = "20260407-014"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

BUILD 日期已过时。

根据编码指南,BUILD 常量应使用 YYYYMMDD-NNN 格式,其中日期应为代码更改时的当前日期。当前 BUILD 为 20260407-014(4月7日),但今天是4月8日。应更新为 20260408-001

🔧 建议修复
-  BUILD = "20260407-014"
+  BUILD = "20260408-001"

根据编码指南:Increment the BUILD constant in lib/claw/version.rb every time you change code, using format YYYYMMDD-NNN where NNN is a sequential number starting at 001 each day

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
BUILD = "20260407-014"
BUILD = "20260408-001"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/claw/version.rb` at line 5, Update the BUILD constant in version.rb to
reflect today's date and reset the daily counter: change the BUILD constant
value from "20260407-014" to "20260408-001" (format YYYYMMDD-NNN,
increment/reset per coding guideline) so the BUILD constant matches the current
change date.

end
36 changes: 36 additions & 0 deletions spec/claw/tui/model_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"))
Expand Down
Loading