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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +94 -0
- data/README.md +214 -10
- data/exe/claw +42 -1
- data/lib/claw/auto_forge.rb +66 -0
- data/lib/claw/benchmark/benchmark.rb +79 -0
- data/lib/claw/benchmark/diff.rb +69 -0
- data/lib/claw/benchmark/report.rb +87 -0
- data/lib/claw/benchmark/runner.rb +91 -0
- data/lib/claw/benchmark/scorer.rb +69 -0
- data/lib/claw/benchmark/task.rb +63 -0
- data/lib/claw/benchmark/tasks/claw_remember.rb +20 -0
- data/lib/claw/benchmark/tasks/claw_session.rb +18 -0
- data/lib/claw/benchmark/tasks/evolution_trace.rb +18 -0
- data/lib/claw/benchmark/tasks/mana_call_func.rb +21 -0
- data/lib/claw/benchmark/tasks/mana_eval.rb +18 -0
- data/lib/claw/benchmark/tasks/mana_knowledge.rb +19 -0
- data/lib/claw/benchmark/tasks/mana_var_readwrite.rb +18 -0
- data/lib/claw/benchmark/tasks/runtime_fork.rb +18 -0
- data/lib/claw/benchmark/tasks/runtime_snapshot.rb +18 -0
- data/lib/claw/benchmark/trigger.rb +68 -0
- data/lib/claw/chat.rb +119 -6
- data/lib/claw/child_runtime.rb +196 -0
- data/lib/claw/cli.rb +177 -0
- data/lib/claw/commands.rb +131 -0
- data/lib/claw/config.rb +5 -1
- data/lib/claw/console/event_logger.rb +69 -0
- data/lib/claw/console/public/app.js +264 -0
- data/lib/claw/console/public/style.css +330 -0
- data/lib/claw/console/server.rb +253 -0
- data/lib/claw/console/sse.rb +28 -0
- data/lib/claw/console/views/experiments.erb +8 -0
- data/lib/claw/console/views/index.erb +27 -0
- data/lib/claw/console/views/layout.erb +29 -0
- data/lib/claw/console/views/memory.erb +13 -0
- data/lib/claw/console/views/monitor.erb +15 -0
- data/lib/claw/console/views/prompt.erb +15 -0
- data/lib/claw/console/views/snapshots.erb +12 -0
- data/lib/claw/console/views/tools.erb +13 -0
- data/lib/claw/console/views/traces.erb +9 -0
- data/lib/claw/console.rb +5 -0
- data/lib/claw/evolution.rb +227 -0
- data/lib/claw/forge.rb +144 -0
- data/lib/claw/hub.rb +67 -0
- data/lib/claw/init.rb +199 -0
- data/lib/claw/knowledge.rb +36 -2
- data/lib/claw/memory_store.rb +2 -2
- data/lib/claw/plan_mode.rb +110 -0
- data/lib/claw/resource.rb +35 -0
- data/lib/claw/resources/binding_resource.rb +128 -0
- data/lib/claw/resources/context_resource.rb +73 -0
- data/lib/claw/resources/filesystem_resource.rb +107 -0
- data/lib/claw/resources/memory_resource.rb +74 -0
- data/lib/claw/resources/worktree_resource.rb +133 -0
- data/lib/claw/roles.rb +56 -0
- data/lib/claw/runtime.rb +189 -0
- data/lib/claw/serializer.rb +10 -7
- data/lib/claw/tool.rb +99 -0
- data/lib/claw/tool_index.rb +84 -0
- data/lib/claw/tool_registry.rb +100 -0
- data/lib/claw/trace.rb +86 -0
- data/lib/claw/tui/agent_executor.rb +92 -0
- data/lib/claw/tui/chat_panel.rb +81 -0
- data/lib/claw/tui/command_bar.rb +22 -0
- data/lib/claw/tui/file_card.rb +88 -0
- data/lib/claw/tui/folding.rb +80 -0
- data/lib/claw/tui/input_handler.rb +73 -0
- data/lib/claw/tui/layout.rb +34 -0
- data/lib/claw/tui/messages.rb +31 -0
- data/lib/claw/tui/model.rb +411 -0
- data/lib/claw/tui/object_explorer.rb +136 -0
- data/lib/claw/tui/status_bar.rb +30 -0
- data/lib/claw/tui/status_panel.rb +133 -0
- data/lib/claw/tui/styles.rb +58 -0
- data/lib/claw/tui/tui.rb +54 -0
- data/lib/claw/version.rb +1 -1
- data/lib/claw.rb +99 -1
- metadata +223 -7
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bubbletea"
|
|
4
|
+
require "bubbles"
|
|
5
|
+
require "io/console"
|
|
6
|
+
|
|
7
|
+
module Claw
|
|
8
|
+
module TUI
|
|
9
|
+
# MVU Model — central state for the TUI application.
|
|
10
|
+
# Implements Bubbletea's init/update/view protocol.
|
|
11
|
+
class Model
|
|
12
|
+
attr_reader :runtime, :chat_history, :mode, :chat_viewport, :executor
|
|
13
|
+
attr_accessor :input_text
|
|
14
|
+
|
|
15
|
+
def initialize(caller_binding)
|
|
16
|
+
@caller_binding = caller_binding
|
|
17
|
+
@runtime = init_runtime(caller_binding)
|
|
18
|
+
@chat_history = []
|
|
19
|
+
@mode = :normal # :normal | :plan
|
|
20
|
+
@input_text = ""
|
|
21
|
+
@input_focused = true
|
|
22
|
+
@scrolled_up = false
|
|
23
|
+
@text_buffer = +"" # accumulates streaming text
|
|
24
|
+
|
|
25
|
+
# Bubbles components
|
|
26
|
+
@chat_viewport = Bubbles::Viewport.new(width: 80, height: 20)
|
|
27
|
+
@spinner = Bubbles::Spinner.new
|
|
28
|
+
@spinner.style = Styles::SPINNER_STYLE
|
|
29
|
+
|
|
30
|
+
# Agent executor
|
|
31
|
+
@executor = AgentExecutor.new(@runtime)
|
|
32
|
+
|
|
33
|
+
# Runtime state change observer → deliver MVU messages
|
|
34
|
+
@runtime&.on_state_change do |old_state, new_state, step|
|
|
35
|
+
Bubbletea.send_message(StateChangeMsg.new(
|
|
36
|
+
old_state: old_state, new_state: new_state, step: step
|
|
37
|
+
))
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def init
|
|
42
|
+
@chat_history << { role: :system, content: "Claw agent ready · type 'exit' to quit" }
|
|
43
|
+
cmd = @spinner.init
|
|
44
|
+
Bubbletea.batch(cmd, Bubbletea.tick(1.0) { TickMsg.new(time: Time.now) })
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def update(msg)
|
|
48
|
+
case msg
|
|
49
|
+
when Bubbletea::KeyMessage
|
|
50
|
+
handle_key(msg)
|
|
51
|
+
when TickMsg
|
|
52
|
+
cmd = @spinner.update(@spinner.tick)
|
|
53
|
+
Bubbletea.batch(cmd, Bubbletea.tick(1.0) { TickMsg.new(time: Time.now) })
|
|
54
|
+
when AgentTextMsg
|
|
55
|
+
@text_buffer << msg.text
|
|
56
|
+
Bubbletea.none
|
|
57
|
+
when ToolCallMsg
|
|
58
|
+
flush_text_buffer
|
|
59
|
+
detail = format_tool_detail(msg.name, msg.input)
|
|
60
|
+
@chat_history << { role: :tool_call, icon: "⚡", detail: detail }
|
|
61
|
+
Bubbletea.none
|
|
62
|
+
when ToolResultMsg
|
|
63
|
+
@chat_history << { role: :tool_result, result: msg.result } unless msg.result.to_s.start_with?("ok:")
|
|
64
|
+
Bubbletea.none
|
|
65
|
+
when ExecutionDoneMsg
|
|
66
|
+
flush_text_buffer
|
|
67
|
+
write_trace(msg.trace)
|
|
68
|
+
Claw.memory&.schedule_compaction
|
|
69
|
+
Bubbletea.none
|
|
70
|
+
when ExecutionErrorMsg
|
|
71
|
+
flush_text_buffer
|
|
72
|
+
@chat_history << { role: :error, content: msg.error.message }
|
|
73
|
+
Bubbletea.none
|
|
74
|
+
when CommandResultMsg
|
|
75
|
+
handle_command_result(msg)
|
|
76
|
+
Bubbletea.none
|
|
77
|
+
when StateChangeMsg
|
|
78
|
+
# Runtime state changed — triggers re-render via Bubbletea
|
|
79
|
+
Bubbletea.none
|
|
80
|
+
else
|
|
81
|
+
Bubbletea.none
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def view
|
|
86
|
+
h, w = IO.console&.winsize || [24, 80]
|
|
87
|
+
w = 80 if w < 40
|
|
88
|
+
h = 24 if h < 12
|
|
89
|
+
Layout.render(self, w, h)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# --- Query methods for panels ---
|
|
93
|
+
|
|
94
|
+
def last_snapshot_id
|
|
95
|
+
@runtime&.snapshots&.last&.id || 0
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def token_display
|
|
99
|
+
ctx = Mana::Context.current
|
|
100
|
+
used = ctx.token_count
|
|
101
|
+
limit = Mana.config.context_window
|
|
102
|
+
"#{format_tokens(used)}/#{format_tokens(limit)}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def input_focused? = @input_focused
|
|
106
|
+
def scrolled_up? = @scrolled_up
|
|
107
|
+
def spinner_view = @spinner.view
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def handle_key(msg)
|
|
112
|
+
key = msg.to_s
|
|
113
|
+
|
|
114
|
+
case key
|
|
115
|
+
when "ctrl+c", "ctrl+d"
|
|
116
|
+
save_state
|
|
117
|
+
return Bubbletea.quit
|
|
118
|
+
when "enter"
|
|
119
|
+
return submit_input
|
|
120
|
+
when "pgup"
|
|
121
|
+
@chat_viewport.page_up
|
|
122
|
+
@scrolled_up = true
|
|
123
|
+
return Bubbletea.none
|
|
124
|
+
when "pgdown"
|
|
125
|
+
@chat_viewport.page_down
|
|
126
|
+
@scrolled_up = @chat_viewport.at_bottom? ? false : true
|
|
127
|
+
return Bubbletea.none
|
|
128
|
+
when "backspace"
|
|
129
|
+
@input_text = @input_text[0..-2] if @input_text.length > 0
|
|
130
|
+
return Bubbletea.none
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Regular character input
|
|
134
|
+
if key.length == 1 && key.ord >= 32
|
|
135
|
+
@input_text << key
|
|
136
|
+
end
|
|
137
|
+
Bubbletea.none
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def submit_input
|
|
141
|
+
text = @input_text.strip
|
|
142
|
+
@input_text = ""
|
|
143
|
+
return Bubbletea.none if text.empty?
|
|
144
|
+
|
|
145
|
+
# Busy guard — prevent concurrent LLM executions
|
|
146
|
+
if @executor.running? && !text.start_with?("/") && !text.start_with?("!") && !text.match?(/\A(exit|quit|bye|q)\z/i)
|
|
147
|
+
@chat_history << { role: :system, content: "Agent is busy — please wait for the current execution to finish." }
|
|
148
|
+
return Bubbletea.none
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Exit
|
|
152
|
+
if text.match?(/\A(exit|quit|bye|q)\z/i)
|
|
153
|
+
save_state
|
|
154
|
+
return Bubbletea.quit
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
@chat_history << { role: :user, content: text }
|
|
158
|
+
@scrolled_up = false
|
|
159
|
+
|
|
160
|
+
if text.start_with?("/")
|
|
161
|
+
handle_slash(text)
|
|
162
|
+
elsif text.start_with?("!")
|
|
163
|
+
handle_ruby(text[1..].strip)
|
|
164
|
+
elsif ruby_syntax?(text)
|
|
165
|
+
handle_ruby_or_llm(text)
|
|
166
|
+
else
|
|
167
|
+
handle_llm(text)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def handle_slash(text)
|
|
172
|
+
cmd, *args = text.sub(/\A\//, "").split(" ", 2)
|
|
173
|
+
arg = args.first
|
|
174
|
+
|
|
175
|
+
if cmd == "plan"
|
|
176
|
+
@mode = @mode == :plan ? :normal : :plan
|
|
177
|
+
@chat_history << { role: :system, content: "mode: #{@mode}" }
|
|
178
|
+
return Bubbletea.none
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Object explorer commands
|
|
182
|
+
case cmd
|
|
183
|
+
when "ls"
|
|
184
|
+
result = ObjectExplorer.ls(@caller_binding)
|
|
185
|
+
if result[:type] == :data
|
|
186
|
+
lines = result[:data].flat_map { |section, items| ["#{section}:", *items] }
|
|
187
|
+
@chat_history << { role: :system, content: lines.join("\n") }
|
|
188
|
+
end
|
|
189
|
+
return Bubbletea.none
|
|
190
|
+
when "cd"
|
|
191
|
+
@nav_stack ||= []
|
|
192
|
+
result = ObjectExplorer.cd(arg || "..", @caller_binding, @nav_stack)
|
|
193
|
+
if result[:type] == :success
|
|
194
|
+
@caller_binding = result[:data][:binding]
|
|
195
|
+
@chat_history << { role: :system, content: "cd → #{result[:data][:label]}" }
|
|
196
|
+
else
|
|
197
|
+
@chat_history << { role: :error, content: result[:message] }
|
|
198
|
+
end
|
|
199
|
+
return Bubbletea.none
|
|
200
|
+
when "source"
|
|
201
|
+
result = ObjectExplorer.source(arg.to_s, @caller_binding)
|
|
202
|
+
if result[:type] == :data
|
|
203
|
+
@chat_history << { role: :system, content: "#{result[:data][:file]}:#{result[:data][:line]}\n#{result[:data][:source]}" }
|
|
204
|
+
else
|
|
205
|
+
@chat_history << { role: :error, content: result[:message] }
|
|
206
|
+
end
|
|
207
|
+
return Bubbletea.none
|
|
208
|
+
when "doc"
|
|
209
|
+
result = ObjectExplorer.doc(arg.to_s, @caller_binding)
|
|
210
|
+
@chat_history << { role: :system, content: result[:data][:doc].to_s }
|
|
211
|
+
return Bubbletea.none
|
|
212
|
+
when "find"
|
|
213
|
+
result = ObjectExplorer.find(arg.to_s, @caller_binding)
|
|
214
|
+
if result[:type] == :data
|
|
215
|
+
@chat_history << { role: :system, content: result[:data][:matches].join(", ") }
|
|
216
|
+
else
|
|
217
|
+
@chat_history << { role: :system, content: result[:message] }
|
|
218
|
+
end
|
|
219
|
+
return Bubbletea.none
|
|
220
|
+
when "whereami"
|
|
221
|
+
result = ObjectExplorer.whereami(@caller_binding)
|
|
222
|
+
d = result[:data]
|
|
223
|
+
@chat_history << { role: :system, content: "#{d[:file]}:#{d[:line]} (#{d[:receiver]})" }
|
|
224
|
+
return Bubbletea.none
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
result = Claw::Commands.dispatch(cmd, arg, runtime: @runtime)
|
|
228
|
+
handle_command_result(CommandResultMsg.new(result: result, cmd: cmd))
|
|
229
|
+
Bubbletea.none
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def handle_ruby(code)
|
|
233
|
+
eval_result = @executor.eval_ruby(code, @caller_binding)
|
|
234
|
+
if eval_result[:success]
|
|
235
|
+
@chat_history << { role: :ruby, content: eval_result[:result].inspect }
|
|
236
|
+
# Track method definitions for session persistence
|
|
237
|
+
if eval_result[:result].is_a?(Symbol) && code.strip.match?(/\Adef\s/)
|
|
238
|
+
track_definition(@caller_binding, code, eval_result[:result])
|
|
239
|
+
end
|
|
240
|
+
else
|
|
241
|
+
@chat_history << { role: :error, content: "#{eval_result[:error].class}: #{eval_result[:error].message}" }
|
|
242
|
+
end
|
|
243
|
+
@runtime&.resources&.dig("binding")&.scan_binding
|
|
244
|
+
Bubbletea.none
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def handle_ruby_or_llm(text)
|
|
248
|
+
eval_result = @executor.eval_ruby(text, @caller_binding)
|
|
249
|
+
if eval_result[:success]
|
|
250
|
+
@chat_history << { role: :ruby, content: eval_result[:result].inspect }
|
|
251
|
+
@runtime&.resources&.dig("binding")&.scan_binding
|
|
252
|
+
Bubbletea.none
|
|
253
|
+
elsif eval_result[:error].is_a?(NameError) && (text.include?(" ") || text.match?(/[^\x00-\x7F]/))
|
|
254
|
+
handle_llm(text)
|
|
255
|
+
else
|
|
256
|
+
@chat_history << { role: :error, content: "#{eval_result[:error].class}: #{eval_result[:error].message}" }
|
|
257
|
+
Bubbletea.none
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def handle_llm(text)
|
|
262
|
+
# Extract @file references and inject file context
|
|
263
|
+
refs = FileCard.extract_refs(text)
|
|
264
|
+
unless refs.empty?
|
|
265
|
+
refs.each do |ref|
|
|
266
|
+
paths = FileCard.resolve(ref)
|
|
267
|
+
paths.each do |path|
|
|
268
|
+
@chat_history << { role: :system, content: FileCard.render_card(path) }
|
|
269
|
+
text = "#{text}\n\n#{FileCard.read_for_context(path)}"
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
@executor.execute(text, @caller_binding) do |event|
|
|
275
|
+
Bubbletea.send_message(event)
|
|
276
|
+
end
|
|
277
|
+
Bubbletea.none
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def handle_command_result(msg)
|
|
281
|
+
result = msg.result
|
|
282
|
+
case result[:type]
|
|
283
|
+
when :success
|
|
284
|
+
@chat_history << { role: :system, content: "✓ #{result[:message]}" }
|
|
285
|
+
when :error
|
|
286
|
+
@chat_history << { role: :error, content: result[:message] }
|
|
287
|
+
when :info
|
|
288
|
+
@chat_history << { role: :system, content: result[:message] }
|
|
289
|
+
when :data
|
|
290
|
+
case msg.cmd
|
|
291
|
+
when "diff"
|
|
292
|
+
data = result[:data]
|
|
293
|
+
lines = ["Diff ##{data[:from]} → ##{data[:to]}:"]
|
|
294
|
+
data[:diffs].each do |name, d|
|
|
295
|
+
lines << " #{name}:"
|
|
296
|
+
d.each_line { |l| lines << " #{l.rstrip}" }
|
|
297
|
+
end
|
|
298
|
+
@chat_history << { role: :system, content: lines.join("\n") }
|
|
299
|
+
when "history"
|
|
300
|
+
lines = result[:data][:snapshots].map { |s| " ##{s[:id]} #{s[:label]} — #{s[:timestamp]}" }
|
|
301
|
+
@chat_history << { role: :system, content: lines.join("\n") }
|
|
302
|
+
when "status"
|
|
303
|
+
@chat_history << { role: :system, content: result[:data][:markdown] }
|
|
304
|
+
when "evolve"
|
|
305
|
+
evo = result[:data]
|
|
306
|
+
msg_text = case evo[:status]
|
|
307
|
+
when :accept then "✓ accepted: #{evo[:proposal]}"
|
|
308
|
+
when :reject then "✗ rejected: #{evo[:proposal] || 'n/a'}"
|
|
309
|
+
when :skip then "· skipped: #{evo[:reason]}"
|
|
310
|
+
else result[:message]
|
|
311
|
+
end
|
|
312
|
+
@chat_history << { role: :system, content: msg_text }
|
|
313
|
+
else
|
|
314
|
+
@chat_history << { role: :system, content: result[:message] }
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def flush_text_buffer
|
|
320
|
+
return if @text_buffer.empty?
|
|
321
|
+
@chat_history << { role: :agent, content: @text_buffer.dup }
|
|
322
|
+
@text_buffer.clear
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def format_tool_detail(name, input)
|
|
326
|
+
input ||= {}
|
|
327
|
+
case name
|
|
328
|
+
when "call_func"
|
|
329
|
+
func = input[:name] || input["name"]
|
|
330
|
+
args = input[:args] || input["args"] || []
|
|
331
|
+
desc = func.to_s
|
|
332
|
+
desc += "(#{args.map(&:inspect).join(', ')})" if args.any?
|
|
333
|
+
desc
|
|
334
|
+
when "read_var", "write_var"
|
|
335
|
+
var = input[:name] || input["name"]
|
|
336
|
+
val = input[:value] || input["value"]
|
|
337
|
+
val ? "#{var} = #{val.inspect[0, 60]}" : var.to_s
|
|
338
|
+
when "read_attr", "write_attr"
|
|
339
|
+
obj = input[:obj] || input["obj"]
|
|
340
|
+
attr = input[:attr] || input["attr"]
|
|
341
|
+
"#{obj}.#{attr}"
|
|
342
|
+
when "remember"
|
|
343
|
+
"remember: #{(input[:content] || input["content"]).to_s[0, 60]}"
|
|
344
|
+
when "knowledge"
|
|
345
|
+
topic = input[:topic] || input["topic"]
|
|
346
|
+
"knowledge(#{topic})"
|
|
347
|
+
else
|
|
348
|
+
name.to_s
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def ruby_syntax?(input)
|
|
353
|
+
RubyVM::InstructionSequence.compile(input)
|
|
354
|
+
true
|
|
355
|
+
rescue SyntaxError
|
|
356
|
+
false
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def write_trace(trace_data)
|
|
360
|
+
return unless trace_data
|
|
361
|
+
claw_dir = File.join(Dir.pwd, ".ruby-claw")
|
|
362
|
+
return unless File.directory?(claw_dir)
|
|
363
|
+
Claw::Trace.write(trace_data, claw_dir)
|
|
364
|
+
rescue
|
|
365
|
+
# ignore trace write failures
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def save_state
|
|
369
|
+
Claw::Serializer.save(@caller_binding, File.join(Dir.pwd, ".ruby-claw")) if Claw.config.persist_session
|
|
370
|
+
Claw.memory&.save_session
|
|
371
|
+
rescue
|
|
372
|
+
# ignore save failures
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def track_definition(caller_binding, code, method_name)
|
|
376
|
+
receiver = caller_binding.receiver
|
|
377
|
+
defs = receiver.instance_variable_defined?(:@__claw_definitions__) ?
|
|
378
|
+
receiver.instance_variable_get(:@__claw_definitions__) : {}
|
|
379
|
+
defs[method_name.to_s] = code
|
|
380
|
+
receiver.instance_variable_set(:@__claw_definitions__, defs)
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def format_tokens(n)
|
|
384
|
+
n < 1000 ? n.to_s : "#{(n / 1000.0).round(1)}k"
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def init_runtime(caller_binding)
|
|
388
|
+
runtime = Claw::Runtime.new
|
|
389
|
+
|
|
390
|
+
context = Mana::Context.current
|
|
391
|
+
runtime.register("context", Claw::Resources::ContextResource.new(context))
|
|
392
|
+
|
|
393
|
+
memory = Claw.memory
|
|
394
|
+
runtime.register("memory", Claw::Resources::MemoryResource.new(memory)) if memory
|
|
395
|
+
|
|
396
|
+
runtime.register("binding", Claw::Resources::BindingResource.new(caller_binding))
|
|
397
|
+
|
|
398
|
+
claw_dir = File.join(Dir.pwd, ".ruby-claw")
|
|
399
|
+
if File.directory?(claw_dir)
|
|
400
|
+
runtime.register("filesystem", Claw::Resources::FilesystemResource.new(claw_dir))
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
runtime.snapshot!(label: "session_start")
|
|
404
|
+
runtime
|
|
405
|
+
rescue => e
|
|
406
|
+
$stderr.puts " ⚠ runtime init failed: #{e.message}" if Mana.config.verbose
|
|
407
|
+
nil
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Claw
|
|
4
|
+
module TUI
|
|
5
|
+
# Object exploration commands (pry-style): /ls, /cd, /source, /doc, /find, /whereami.
|
|
6
|
+
module ObjectExplorer
|
|
7
|
+
# @param binding [Binding]
|
|
8
|
+
# @return [Hash] { type:, data: }
|
|
9
|
+
def self.ls(binding)
|
|
10
|
+
receiver = binding.eval("self")
|
|
11
|
+
sections = {}
|
|
12
|
+
|
|
13
|
+
# Local variables
|
|
14
|
+
locals = binding.local_variables.map do |sym|
|
|
15
|
+
val = binding.local_variable_get(sym)
|
|
16
|
+
" #{sym}: #{val.class} = #{safe_inspect(val)}"
|
|
17
|
+
end
|
|
18
|
+
sections["Local Variables"] = locals unless locals.empty?
|
|
19
|
+
|
|
20
|
+
# Instance variables
|
|
21
|
+
ivars = receiver.instance_variables.map do |ivar|
|
|
22
|
+
val = receiver.instance_variable_get(ivar)
|
|
23
|
+
" #{ivar}: #{val.class} = #{safe_inspect(val)}"
|
|
24
|
+
end
|
|
25
|
+
sections["Instance Variables"] = ivars unless ivars.empty?
|
|
26
|
+
|
|
27
|
+
# Public methods (own, not inherited from Object)
|
|
28
|
+
own_methods = (receiver.methods - Object.instance_methods).sort
|
|
29
|
+
sections["Methods"] = own_methods.map { |m| " #{m}" } unless own_methods.empty?
|
|
30
|
+
|
|
31
|
+
{ type: :data, data: sections }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Navigate into an object's context.
|
|
35
|
+
# Returns [new_binding, prompt_label] or error.
|
|
36
|
+
def self.cd(expression, binding, nav_stack)
|
|
37
|
+
if expression == ".."
|
|
38
|
+
if nav_stack.empty?
|
|
39
|
+
return { type: :error, message: "Already at top level" }
|
|
40
|
+
end
|
|
41
|
+
prev = nav_stack.pop
|
|
42
|
+
return { type: :success, data: { binding: prev[:binding], label: prev[:label] } }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
begin
|
|
46
|
+
obj = binding.eval(expression)
|
|
47
|
+
nav_stack.push({ binding: binding, label: binding.eval("self").class.name })
|
|
48
|
+
# Create a new binding inside the object's context
|
|
49
|
+
new_binding = obj.instance_eval { binding }
|
|
50
|
+
label = "#{obj.class.name}(#{safe_inspect_short(obj)})"
|
|
51
|
+
{ type: :success, data: { binding: new_binding, label: label } }
|
|
52
|
+
rescue => e
|
|
53
|
+
{ type: :error, message: "#{e.class}: #{e.message}" }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Show source code of a method.
|
|
58
|
+
def self.source(method_name, binding)
|
|
59
|
+
receiver = binding.eval("self")
|
|
60
|
+
meth = if receiver.respond_to?(method_name.to_sym)
|
|
61
|
+
receiver.method(method_name.to_sym)
|
|
62
|
+
elsif receiver.class.method_defined?(method_name.to_sym)
|
|
63
|
+
receiver.class.instance_method(method_name.to_sym)
|
|
64
|
+
else
|
|
65
|
+
return { type: :error, message: "Method '#{method_name}' not found" }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
file, line = meth.source_location
|
|
69
|
+
unless file && File.exist?(file)
|
|
70
|
+
return { type: :error, message: "Source not available (native method or no source location)" }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
lines = File.readlines(file)
|
|
74
|
+
# Show context: method line +/- 10
|
|
75
|
+
start = [line - 1, 0].max
|
|
76
|
+
finish = [line + 19, lines.size - 1].min
|
|
77
|
+
source_lines = lines[start..finish].each_with_index.map do |l, i|
|
|
78
|
+
num = start + i + 1
|
|
79
|
+
marker = num == line ? "→ " : " "
|
|
80
|
+
"#{marker}#{num.to_s.rjust(4)}: #{l.rstrip}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
{ type: :data, data: { file: file, line: line, source: source_lines.join("\n") } }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Query documentation via Mana Knowledge.
|
|
87
|
+
def self.doc(method_name, binding)
|
|
88
|
+
receiver = binding.eval("self")
|
|
89
|
+
klass = receiver.is_a?(Class) ? receiver.name : receiver.class.name
|
|
90
|
+
query = "#{klass}##{method_name}"
|
|
91
|
+
|
|
92
|
+
result = Claw::Knowledge.query(query)
|
|
93
|
+
{ type: :data, data: { topic: query, doc: result } }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Find methods matching a pattern.
|
|
97
|
+
def self.find(pattern, binding)
|
|
98
|
+
receiver = binding.eval("self")
|
|
99
|
+
regex = begin
|
|
100
|
+
Regexp.new(pattern, Regexp::IGNORECASE)
|
|
101
|
+
rescue RegexpError
|
|
102
|
+
return { type: :error, message: "Invalid pattern: #{pattern}" }
|
|
103
|
+
end
|
|
104
|
+
matches = receiver.methods.select { |m| m.to_s.match?(regex) }.sort
|
|
105
|
+
|
|
106
|
+
if matches.empty?
|
|
107
|
+
{ type: :info, message: "No methods matching '#{pattern}'" }
|
|
108
|
+
else
|
|
109
|
+
{ type: :data, data: { pattern: pattern, matches: matches.map(&:to_s) } }
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Show current source location.
|
|
114
|
+
def self.whereami(binding)
|
|
115
|
+
file = binding.eval("__FILE__") rescue "(unknown)"
|
|
116
|
+
line = binding.eval("__LINE__") rescue 0
|
|
117
|
+
receiver = binding.eval("self")
|
|
118
|
+
{ type: :data, data: { file: file, line: line, receiver: receiver.class.name } }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# --- Helpers ---
|
|
122
|
+
|
|
123
|
+
def self.safe_inspect(val)
|
|
124
|
+
str = val.inspect
|
|
125
|
+
str.length > 60 ? "#{str[0, 57]}..." : str
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def self.safe_inspect_short(val)
|
|
129
|
+
str = val.inspect
|
|
130
|
+
str.length > 20 ? "#{str[0, 17]}..." : str
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private_class_method :safe_inspect, :safe_inspect_short
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Claw
|
|
4
|
+
module TUI
|
|
5
|
+
# Top status bar: version | model | snapshot id | token usage | mode
|
|
6
|
+
module StatusBar
|
|
7
|
+
def self.render(model, width)
|
|
8
|
+
parts = []
|
|
9
|
+
parts << "ruby-claw v#{Claw::VERSION}"
|
|
10
|
+
parts << Mana.config.model
|
|
11
|
+
parts << "snap: ##{model.last_snapshot_id}"
|
|
12
|
+
parts << "#{model.token_display}"
|
|
13
|
+
parts << "mode: #{model.mode}" if model.mode != :normal
|
|
14
|
+
|
|
15
|
+
state = model.runtime&.state
|
|
16
|
+
case state
|
|
17
|
+
when :thinking
|
|
18
|
+
parts << "#{model.spinner_view} thinking..."
|
|
19
|
+
when :executing_tool
|
|
20
|
+
step = model.runtime&.current_step
|
|
21
|
+
label = step ? "#{model.spinner_view} #{step.tool_name}" : "#{model.spinner_view} executing..."
|
|
22
|
+
parts << label
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
text = parts.join(" | ")
|
|
26
|
+
Styles::STATUS_BAR.width(width).render(text)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|