pwn 0.5.749 → 0.5.751
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/Gemfile +2 -2
- data/documentation/Blockchain.md +107 -9
- data/documentation/pwn-ai-Agent.md +310 -4
- data/documentation/pwn_silent_help_learn_demo.gif +0 -0
- data/etc/default_skills/pwn/banner/SKILL.md +7 -3
- data/etc/default_skills/pwn/blockchain/btc/SKILL.md +8 -5
- data/etc/default_skills/pwn/blockchain/eth/SKILL.md +12 -3
- data/etc/default_skills/pwn/blockchain/eth/references/urls.md +1 -1
- data/etc/default_skills/pwn/plugins/repl/SKILL.md +5 -0
- data/etc/default_skills/pwn/plugins/repl/ai/SKILL.md +4 -0
- data/etc/default_skills/pwn/plugins/repl/ai/references/urls.md +5 -0
- data/etc/default_skills/pwn/plugins/repl/ai_console/SKILL.md +48 -0
- data/etc/default_skills/pwn/plugins/repl/ai_console_commands/SKILL.md +47 -0
- data/etc/default_skills/pwn/plugins/repl/ai_console_usage/SKILL.md +48 -0
- data/etc/default_skills/pwn/plugins/repl/ai_swarm/SKILL.md +46 -0
- data/lib/pwn/ai/agent/loop.rb +287 -18
- data/lib/pwn/ai/agent/swarm.rb +28 -4
- data/lib/pwn/ai/open_ai.rb +4 -2
- data/lib/pwn/banner.rb +743 -2
- data/lib/pwn/blockchain/btc.rb +494 -196
- data/lib/pwn/blockchain/eth.rb +440 -81
- data/lib/pwn/blockchain.rb +6 -0
- data/lib/pwn/config.rb +22 -0
- data/lib/pwn/migrate.rb +9 -1
- data/lib/pwn/plugins/repl/ai.rb +182 -17
- data/lib/pwn/plugins/repl/ai_console.rb +1581 -0
- data/lib/pwn/plugins/repl/ai_console_commands.rb +282 -0
- data/lib/pwn/plugins/repl/ai_console_usage.rb +144 -0
- data/lib/pwn/plugins/repl/ai_swarm.rb +375 -0
- data/lib/pwn/plugins/repl.rb +25 -3
- data/lib/pwn/version.rb +1 -1
- data/spec/lib/pwn/ai/agent/loop_spec.rb +258 -1
- data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +3 -2
- data/spec/lib/pwn/ai/open_ai_spec.rb +5 -0
- data/spec/lib/pwn/banner_spec.rb +643 -0
- data/spec/lib/pwn/blockchain/btc_spec.rb +372 -1
- data/spec/lib/pwn/blockchain/eth_spec.rb +205 -0
- data/spec/lib/pwn/blockchain_spec.rb +6 -0
- data/spec/lib/pwn/config_spec.rb +10 -0
- data/spec/lib/pwn/migrate_spec.rb +40 -3
- data/spec/lib/pwn/plugins/repl/ai_console_commands_spec.rb +65 -0
- data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +1434 -0
- data/spec/lib/pwn/plugins/repl/ai_console_usage_spec.rb +29 -0
- data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +127 -0
- data/spec/lib/pwn/plugins/repl_pwn_vault_spec.rb +5 -3
- data/spec/lib/pwn/plugins/repl_spec.rb +167 -1
- data/third_party/pwn_rdoc.jsonl +229 -10
- metadata +18 -6
- data/etc/default_skills/pwn/blockchain/btc/references/urls.md +0 -3
|
@@ -0,0 +1,1581 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'curses'
|
|
4
|
+
require 'io/console'
|
|
5
|
+
require 'io/wait'
|
|
6
|
+
require 'fcntl'
|
|
7
|
+
require 'unicode/display_width'
|
|
8
|
+
|
|
9
|
+
module PWN
|
|
10
|
+
module Plugins
|
|
11
|
+
module REPL
|
|
12
|
+
# Single-owner fullscreen agent console. Workers never paint the terminal.
|
|
13
|
+
module AIConsole
|
|
14
|
+
public_class_method def self.run(opts = {})
|
|
15
|
+
input = opts[:input] || $stdin
|
|
16
|
+
output = opts[:output] || $stdout
|
|
17
|
+
return :unavailable unless input.tty? && output.tty? && ENV['TERM'] != 'dumb'
|
|
18
|
+
|
|
19
|
+
Console.new(pry: opts[:pry], input: input, curses: opts[:curses] || Curses, getch: opts[:getch]).run
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Ruby output is queued, never written across the curses screen.
|
|
23
|
+
class EventIO
|
|
24
|
+
attr_accessor :sync
|
|
25
|
+
|
|
26
|
+
def initialize(queue)
|
|
27
|
+
@queue = queue
|
|
28
|
+
@buffer = +''
|
|
29
|
+
@lock = Mutex.new
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def write(value)
|
|
33
|
+
text = value.to_s
|
|
34
|
+
@lock.synchronize do
|
|
35
|
+
@buffer << text
|
|
36
|
+
while (line = @buffer.slice!(/.*?\n/m))
|
|
37
|
+
@queue << [:notice, line]
|
|
38
|
+
end
|
|
39
|
+
if @buffer.bytesize > 16_384
|
|
40
|
+
@queue << [:notice, '[output withheld: unterminated stream exceeds 16 KiB]']
|
|
41
|
+
@buffer.clear
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
text.bytesize
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def puts(*values)
|
|
48
|
+
values = [''] if values.empty?
|
|
49
|
+
values.each { |value| write("#{value.to_s.chomp}\n") }
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def print(*values)
|
|
54
|
+
values.each { |value| write(value) }
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def flush
|
|
59
|
+
@lock.synchronize do
|
|
60
|
+
unless @buffer.empty?
|
|
61
|
+
@queue << [:notice, @buffer.dup]
|
|
62
|
+
@buffer.clear
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
self
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def tty?
|
|
69
|
+
false
|
|
70
|
+
end
|
|
71
|
+
alias isatty tty?
|
|
72
|
+
|
|
73
|
+
def close
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def closed?
|
|
78
|
+
false
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Reuse the model-window interrupt protection and evidence checkpoints.
|
|
83
|
+
# Cancellation never raises asynchronously into a tool.
|
|
84
|
+
class Control < PWN::AI::Agent::Loop::Steering
|
|
85
|
+
class Stopped < Exception; end # rubocop:disable Lint/InheritException
|
|
86
|
+
|
|
87
|
+
def stop
|
|
88
|
+
@stopping = true
|
|
89
|
+
submit('Stop the active request.')
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def checkpoint(messages:, phase: :boundary, &block)
|
|
93
|
+
if @stopping
|
|
94
|
+
block&.call
|
|
95
|
+
raise Stopped, 'Request cancelled at a safe boundary; completed work is not undone.'
|
|
96
|
+
end
|
|
97
|
+
super
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def phase
|
|
101
|
+
@mutex.synchronize { @pending.empty? ? @phase : "steering queued · #{@phase}" }
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Read a duplicated terminal rather than stdin, which belongs to tools.
|
|
106
|
+
class Keyboard
|
|
107
|
+
KEYS = {
|
|
108
|
+
"\e[A" => :up, "\e[B" => :down, "\e[C" => :right, "\e[D" => :left,
|
|
109
|
+
"\eOA" => :up, "\eOB" => :down, "\eOC" => :right, "\eOD" => :left,
|
|
110
|
+
"\e[H" => :home, "\e[F" => :end, "\eOH" => :home, "\eOF" => :end,
|
|
111
|
+
"\e[1~" => :home, "\e[4~" => :end, "\e[3~" => :delete,
|
|
112
|
+
"\e[5~" => :page_up, "\e[6~" => :page_down,
|
|
113
|
+
"\e[13;2u" => :newline, "\e[27;2;13~" => :newline, "\e[200~" => :paste_start, "\e[201~" => :paste_end
|
|
114
|
+
}.freeze
|
|
115
|
+
|
|
116
|
+
def initialize(source)
|
|
117
|
+
@source = source
|
|
118
|
+
@buffer = +''.b
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def call
|
|
122
|
+
if @source.wait_readable(@buffer.empty? ? 0.04 : 0)
|
|
123
|
+
chunk = @source.read_nonblock(4096, exception: false)
|
|
124
|
+
return :eof if chunk.nil? && @buffer.empty?
|
|
125
|
+
|
|
126
|
+
@buffer << chunk if chunk.is_a?(String)
|
|
127
|
+
end
|
|
128
|
+
return if @buffer.empty?
|
|
129
|
+
|
|
130
|
+
match = KEYS.keys.find { |key| @buffer.start_with?(key) }
|
|
131
|
+
return KEYS.fetch(@buffer.slice!(0, match.bytesize)) if match
|
|
132
|
+
|
|
133
|
+
cursor = cursor_key
|
|
134
|
+
return cursor unless cursor == :pending
|
|
135
|
+
return if KEYS.keys.any? { |key| key.start_with?(@buffer) } && @source.wait_readable(0.04)
|
|
136
|
+
|
|
137
|
+
decode_character
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def cursor_key
|
|
141
|
+
match = @buffer.match(/\A\e\[([0-9;]*)([ABCDHF])/)
|
|
142
|
+
return :pending unless match
|
|
143
|
+
return :pending if KEYS.keys.any? { |key| key.start_with?(match[0]) && key != match[0] }
|
|
144
|
+
|
|
145
|
+
@buffer.slice!(0, match[0].bytesize)
|
|
146
|
+
modifier = match[1].split(';')[1].to_i
|
|
147
|
+
return if modifier > 1
|
|
148
|
+
|
|
149
|
+
{ 'A' => :up, 'B' => :down, 'C' => :right, 'D' => :left, 'H' => :home, 'F' => :end }[match[2]]
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def decode_character
|
|
153
|
+
lead = @buffer.getbyte(0)
|
|
154
|
+
size = case lead
|
|
155
|
+
when 0..127 then 1
|
|
156
|
+
when 194..223 then 2
|
|
157
|
+
when 224..239 then 3
|
|
158
|
+
when 240..244 then 4
|
|
159
|
+
else 0
|
|
160
|
+
end
|
|
161
|
+
prefix = @buffer.bytes.take(size)
|
|
162
|
+
valid = size.positive? && prefix.drop(1).all? { |byte| byte.between?(128, 191) }
|
|
163
|
+
second = prefix[1]
|
|
164
|
+
valid &&= !(second && ((lead == 224 && second < 160) || (lead == 237 && second > 159) ||
|
|
165
|
+
(lead == 240 && second < 144) || (lead == 244 && second > 143)))
|
|
166
|
+
if valid
|
|
167
|
+
return if @buffer.bytesize < size
|
|
168
|
+
|
|
169
|
+
return @buffer.slice!(0, size).force_encoding(Encoding::UTF_8)
|
|
170
|
+
end
|
|
171
|
+
@buffer.slice!(0, 1)
|
|
172
|
+
'�'
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Unicode grapheme editing and request recall from shared Pry history.
|
|
177
|
+
class Editor
|
|
178
|
+
attr_reader :text, :cursor, :search
|
|
179
|
+
|
|
180
|
+
def initialize
|
|
181
|
+
@text = +''
|
|
182
|
+
@cursor = 0
|
|
183
|
+
@history = []
|
|
184
|
+
@index = 0
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def preload(lines)
|
|
188
|
+
Array(lines).each do |line|
|
|
189
|
+
next if line.to_s.strip.empty? || line.match?(%r{\A\s*/input(?:\s|\z)}) || @history.last == line
|
|
190
|
+
|
|
191
|
+
@history << line.dup
|
|
192
|
+
end
|
|
193
|
+
@index = @history.length
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def insert(text)
|
|
197
|
+
chars = @text.scan(/\X/)
|
|
198
|
+
prefix = chars.take(@cursor).join + text
|
|
199
|
+
@text = prefix + chars.drop(@cursor).join
|
|
200
|
+
@cursor = prefix.scan(/\X/).length
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def replace(text)
|
|
204
|
+
@text = text.dup
|
|
205
|
+
@cursor = @text.scan(/\X/).length
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def edit(key)
|
|
209
|
+
chars = @text.scan(/\X/)
|
|
210
|
+
case key
|
|
211
|
+
when :left then @cursor = [@cursor - 1, 0].max
|
|
212
|
+
when :right then @cursor = [@cursor + 1, chars.length].min
|
|
213
|
+
when :home, "\u0001" then @cursor = 0
|
|
214
|
+
when :end, "\u0005" then @cursor = chars.length
|
|
215
|
+
when :delete then chars.delete_at(@cursor)
|
|
216
|
+
when "\u007f", "\b"
|
|
217
|
+
chars.delete_at(@cursor -= 1) if @cursor.positive?
|
|
218
|
+
when "\u0015" then chars.clear
|
|
219
|
+
@cursor = 0
|
|
220
|
+
end
|
|
221
|
+
@text = chars.join
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def recall(direction)
|
|
225
|
+
return if @history.empty?
|
|
226
|
+
return if direction == :up && @index.zero?
|
|
227
|
+
return if direction == :down && @index == @history.length
|
|
228
|
+
|
|
229
|
+
@draft = [@text.dup, @cursor] if @index == @history.length
|
|
230
|
+
@index += direction == :up ? -1 : 1
|
|
231
|
+
if @index == @history.length
|
|
232
|
+
@text, @cursor = @draft
|
|
233
|
+
@text = @text.dup
|
|
234
|
+
else
|
|
235
|
+
replace(@history[@index])
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def search_history(query = '', older: false)
|
|
240
|
+
start = older && @search ? @search[:index] : @history.length
|
|
241
|
+
index = (start - 1).downto(0).find { |position| @history[position].include?(query) }
|
|
242
|
+
@search = { query: query, index: index || start, match: index ? @history[index].dup : nil }
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def finish_search(accept: false)
|
|
246
|
+
if accept && @search&.dig(:match)
|
|
247
|
+
replace(@search[:match])
|
|
248
|
+
@index = @history.length
|
|
249
|
+
end
|
|
250
|
+
@search = nil
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def place(text, cursor)
|
|
254
|
+
replace(text)
|
|
255
|
+
@cursor = cursor.to_i.clamp(0, @text.scan(/\X/).length)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def submit
|
|
259
|
+
line = @text.dup
|
|
260
|
+
@history << line.dup unless line.strip.empty? || @history.last == line || line.match?(%r{\A\s*/input(?:\s|\z)})
|
|
261
|
+
@index = @history.length
|
|
262
|
+
replace('')
|
|
263
|
+
line
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Own the terminal, event pump and the lifetime of exactly one request.
|
|
268
|
+
class Console # rubocop:disable Metrics/ClassLength -- cohesive single-owner terminal state machine
|
|
269
|
+
COLORS = { operator: :operator, task: :task, tool: :tool, result: :result, assistant: :assistant, notice: :notice, warning: :warning }.freeze
|
|
270
|
+
PALETTE = %w[cyan green yellow red white blue magenta black].freeze
|
|
271
|
+
|
|
272
|
+
def initialize(pry:, input:, curses:, getch:)
|
|
273
|
+
@pry = pry
|
|
274
|
+
@input = input
|
|
275
|
+
@curses = curses
|
|
276
|
+
@getch = getch
|
|
277
|
+
@events = Queue.new
|
|
278
|
+
@output = EventIO.new(@events)
|
|
279
|
+
@editor = Editor.new
|
|
280
|
+
@timeline = []
|
|
281
|
+
@scroll = nil
|
|
282
|
+
@event_count = @completed_tools = @unseen = 0
|
|
283
|
+
@verbose = true
|
|
284
|
+
@usage = AIConsoleUsage::Tracker.new
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def run
|
|
288
|
+
previous = [$stdin, $stdout, PWN::Plugins::Log.raw_stderr, @pry.config.output]
|
|
289
|
+
flags = @input.fcntl(Fcntl::F_GETFL)
|
|
290
|
+
source = @input.dup
|
|
291
|
+
@source = source
|
|
292
|
+
source.raw do
|
|
293
|
+
enable_shift_enter
|
|
294
|
+
@swarm = AISwarm::Controller.new(session_id: @pry.config.pwn_ai_session_id)
|
|
295
|
+
@screen = @curses.init_screen
|
|
296
|
+
@curses.raw
|
|
297
|
+
@curses.noecho
|
|
298
|
+
@curses.curs_set(1)
|
|
299
|
+
setup_colors
|
|
300
|
+
# Curses only paints; Keyboard owns the duplicated terminal.
|
|
301
|
+
forwarded, @tool_input = IO.pipe
|
|
302
|
+
@input.reopen(forwarded)
|
|
303
|
+
forwarded.close
|
|
304
|
+
$stdin = @input
|
|
305
|
+
$stdout = $stderr = @output
|
|
306
|
+
@pry.config.output = @output
|
|
307
|
+
if PWN::Plugins::Log.debug_enabled?
|
|
308
|
+
PWN::Plugins::Log.send(:remove_stderr_tee!)
|
|
309
|
+
PWN::Plugins::Log.send(:install_stderr_tee!)
|
|
310
|
+
PWN::Plugins::Log.start_debug(tee: @output, session_id: @pry.config.pwn_ai_session_id)
|
|
311
|
+
end
|
|
312
|
+
@getch ||= Keyboard.new(source)
|
|
313
|
+
seed_request_history
|
|
314
|
+
add(:notice, 'Ready. Submit a task · / or Tab opens commands · /steer redirects while busy.')
|
|
315
|
+
add(:notice, 'Tool prompts: /input TEXT forwards one line. Raw/fullscreen tools and direct /dev/tty readers are unsupported.')
|
|
316
|
+
event_loop
|
|
317
|
+
ensure
|
|
318
|
+
begin
|
|
319
|
+
stop_worker
|
|
320
|
+
ensure
|
|
321
|
+
begin
|
|
322
|
+
@input.reopen(source)
|
|
323
|
+
@input.fcntl(Fcntl::F_SETFL, flags)
|
|
324
|
+
@tool_input&.close unless @tool_input&.closed?
|
|
325
|
+
ensure
|
|
326
|
+
@curses.close_screen if @screen
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
:closed
|
|
331
|
+
ensure
|
|
332
|
+
restore_streams(previous) if previous
|
|
333
|
+
disable_shift_enter
|
|
334
|
+
source&.close
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def enable_shift_enter
|
|
338
|
+
return unless $stdout.tty?
|
|
339
|
+
|
|
340
|
+
system('tmux', 'set', '-s', 'extended-keys', 'on', out: File::NULL, err: File::NULL) unless ENV['TMUX'].to_s.empty?
|
|
341
|
+
$stdout.write("\e[>4;1m\e[>1u")
|
|
342
|
+
$stdout.flush
|
|
343
|
+
@shift_enter = true
|
|
344
|
+
rescue StandardError
|
|
345
|
+
nil
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def disable_shift_enter
|
|
349
|
+
return unless @shift_enter && $stdout.tty?
|
|
350
|
+
|
|
351
|
+
$stdout.write("\e[<u\e[>4;0m")
|
|
352
|
+
$stdout.flush
|
|
353
|
+
rescue StandardError
|
|
354
|
+
nil
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def restore_streams(previous)
|
|
358
|
+
log = PWN::Plugins::Log
|
|
359
|
+
rebind_stderr = log.debug_enabled? && log.raw_stderr.equal?(@output)
|
|
360
|
+
# Keep the open RN file and trace state, but never leave a logger
|
|
361
|
+
# referencing the now-closed console's event queue.
|
|
362
|
+
log.send(:remove_stderr_tee!) if rebind_stderr
|
|
363
|
+
$stdin, $stdout, $stderr, @pry.config.output = previous
|
|
364
|
+
return unless log.debug_enabled?
|
|
365
|
+
|
|
366
|
+
log.start_debug(tee: previous[1], session_id: @pry.config.pwn_ai_session_id)
|
|
367
|
+
log.send(:install_stderr_tee!) if rebind_stderr
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def setup_colors
|
|
371
|
+
@color = !ENV.key?('NO_COLOR') && @curses.has_colors?
|
|
372
|
+
return unless @color
|
|
373
|
+
|
|
374
|
+
@curses.start_color
|
|
375
|
+
@curses.use_default_colors
|
|
376
|
+
PALETTE.each_with_index do |name, index|
|
|
377
|
+
@curses.init_pair(index + 1, Curses.const_get("COLOR_#{name.upcase}"), -1)
|
|
378
|
+
end
|
|
379
|
+
# Artwork owns complete foreground/background pairs, separate from
|
|
380
|
+
# semantic theme colors. Never silently drop a colored background.
|
|
381
|
+
@banner_colors = @curses.color_pairs > PALETTE.length * 2
|
|
382
|
+
return unless @banner_colors
|
|
383
|
+
|
|
384
|
+
PALETTE.each_with_index do |name, index|
|
|
385
|
+
@curses.init_pair(PALETTE.length + index + 1, Curses.const_get("COLOR_#{name.upcase}"), Curses::COLOR_BLACK)
|
|
386
|
+
end
|
|
387
|
+
@banner_two_colors = @curses.color_pairs > PALETTE.length + (PALETTE.length**2)
|
|
388
|
+
return unless @banner_two_colors
|
|
389
|
+
|
|
390
|
+
PALETTE.reject { |name| name == 'black' }.each_with_index do |background, bg_index|
|
|
391
|
+
PALETTE.each_with_index do |foreground, fg_index|
|
|
392
|
+
@curses.init_pair(17 + (bg_index * 8) + fg_index, Curses.const_get("COLOR_#{foreground.upcase}"), Curses.const_get("COLOR_#{background.upcase}"))
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def event_loop
|
|
398
|
+
loop do
|
|
399
|
+
drain
|
|
400
|
+
break if @leaving && !busy?
|
|
401
|
+
|
|
402
|
+
draw
|
|
403
|
+
handle(@getch.call)
|
|
404
|
+
rescue Interrupt
|
|
405
|
+
cancel
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def busy?
|
|
410
|
+
request_busy = @worker && (@worker.alive? || @running)
|
|
411
|
+
request_busy || @swarm&.busy?
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def drain
|
|
415
|
+
200.times do
|
|
416
|
+
type, text = @events.pop(true)
|
|
417
|
+
if type == :model_prompt
|
|
418
|
+
next if @cancelling || @leaving
|
|
419
|
+
|
|
420
|
+
@model_prompt = text
|
|
421
|
+
@model_index = text[:efforts].index(text[:default]) || 0
|
|
422
|
+
@menu = nil
|
|
423
|
+
elsif type == :done
|
|
424
|
+
@worker.join
|
|
425
|
+
finish_request_metrics
|
|
426
|
+
@running = false
|
|
427
|
+
@control = nil
|
|
428
|
+
else
|
|
429
|
+
add(type, text)
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
rescue ThreadError
|
|
433
|
+
nil
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def add(type, text)
|
|
437
|
+
clean = PWN::Redaction.redact(value: text.to_s)
|
|
438
|
+
clean = clean.gsub(/\e\][^\a]*(?:\a|\e\\)/, '').gsub(%r{\e\[[0-?]*[ -/]*[@-~]}, '').gsub(/[\x00-\x08\x0b-\x1f\x7f]/, '')
|
|
439
|
+
return if clean.strip.empty?
|
|
440
|
+
|
|
441
|
+
@event_count += 1
|
|
442
|
+
@unseen += 1 if @scroll
|
|
443
|
+
if type == :tool
|
|
444
|
+
@completed_tools += 1
|
|
445
|
+
@last_tool = fit(clean.lines.first.to_s.strip, 80)
|
|
446
|
+
end
|
|
447
|
+
@timeline << [type, bounded_event(clean), Time.now.strftime('%Y-%m-%d %H:%M:%S%z'), {}]
|
|
448
|
+
@rows = nil
|
|
449
|
+
if @timeline.length > 2000
|
|
450
|
+
removed = @timeline.shift
|
|
451
|
+
@scroll = [@scroll - removed[3].fetch(:rows, []).length, 0].max if @scroll
|
|
452
|
+
end
|
|
453
|
+
PWN::Plugins::Log.mirror_tui!(msg: "#{type.to_s.upcase}\n#{clean}") if @pry.config.pwn_ai_debug && type != :notice
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def start_request(request, command: false)
|
|
457
|
+
@locals = Thread.current.keys.select { |key| key.to_s.start_with?('pwn_') }.to_h { |key| [key, Thread.current[key]] }
|
|
458
|
+
@thread_variables = Thread.current.thread_variables.select { |key| key.to_s.start_with?('pwn_') }.to_h { |key| [key, Thread.current.thread_variable_get(key)] }
|
|
459
|
+
# A fresh forwarding pipe prevents unread input crossing requests.
|
|
460
|
+
@tool_input.close unless @tool_input.closed?
|
|
461
|
+
forwarded, @tool_input = IO.pipe
|
|
462
|
+
@input.reopen(forwarded)
|
|
463
|
+
forwarded.close
|
|
464
|
+
@cancelling = false
|
|
465
|
+
@command = command
|
|
466
|
+
begin_request_metrics
|
|
467
|
+
add(:operator, request)
|
|
468
|
+
@running = true
|
|
469
|
+
ready = Queue.new
|
|
470
|
+
gate = Queue.new
|
|
471
|
+
@worker = Thread.new do
|
|
472
|
+
@locals.each { |key, value| Thread.current[key] = value }
|
|
473
|
+
@thread_variables.each { |key, value| Thread.current.thread_variable_set(key, value) }
|
|
474
|
+
control = Control.new(input: @input, output: @output)
|
|
475
|
+
Thread.current[:pwn_steering_input] = control
|
|
476
|
+
Thread.current[:pwn_usage_observer] = @usage.method(:record)
|
|
477
|
+
ready << control
|
|
478
|
+
gate.pop
|
|
479
|
+
on_tool = lambda do |name, args, result|
|
|
480
|
+
if name.to_s == 'task'
|
|
481
|
+
@events << [:task, args.to_s]
|
|
482
|
+
else
|
|
483
|
+
@events << [:tool, "#{name}\n#{args.is_a?(String) ? args : args.inspect}"]
|
|
484
|
+
@events << [:result, result.to_s]
|
|
485
|
+
end
|
|
486
|
+
end
|
|
487
|
+
if command
|
|
488
|
+
control.checkpoint(messages: [], phase: :tool)
|
|
489
|
+
if request.match?(%r{\A/model(?:\s|$)})
|
|
490
|
+
REPL.pwn_ai_run_model(args: request.split.drop(1), prompt: lambda { |selection|
|
|
491
|
+
control.checkpoint(messages: [])
|
|
492
|
+
@events << [:model_prompt, selection]
|
|
493
|
+
:deferred
|
|
494
|
+
})
|
|
495
|
+
else
|
|
496
|
+
REPL.pwn_ai_dispatch_slash!(request: request, pry: @pry)
|
|
497
|
+
end
|
|
498
|
+
control.checkpoint(messages: [])
|
|
499
|
+
else
|
|
500
|
+
final = PWN::AI::Agent::Loop.run(
|
|
501
|
+
request: request, session_id: @pry.config.pwn_ai_session_id,
|
|
502
|
+
enabled_toolsets: PWN::Env.dig(:ai, :agent, :toolsets),
|
|
503
|
+
on_tool: on_tool, steering: control, debug: @pry.config.pwn_ai_debug, debug_tee: @output
|
|
504
|
+
)
|
|
505
|
+
@events << [:assistant, final.to_s]
|
|
506
|
+
end
|
|
507
|
+
rescue Control::Stopped => e
|
|
508
|
+
@events << [:warning, e.message]
|
|
509
|
+
rescue StandardError, Interrupt => e
|
|
510
|
+
Thread.current[:pwn_log_progress] = false
|
|
511
|
+
PWN::Plugins::Log.note_exception!(error: e, where: 'curses agent', which_self: AIConsole)
|
|
512
|
+
@events << [:warning, "#{e.class}: #{e.message}"]
|
|
513
|
+
ensure
|
|
514
|
+
@events << [:done, nil]
|
|
515
|
+
end
|
|
516
|
+
@worker.report_on_exception = false
|
|
517
|
+
@control = ready.pop
|
|
518
|
+
gate << true
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
def cancel
|
|
522
|
+
if busy?
|
|
523
|
+
return if @cancelling
|
|
524
|
+
|
|
525
|
+
@cancelling = true
|
|
526
|
+
@control&.stop
|
|
527
|
+
@swarm&.cancel_all
|
|
528
|
+
@tool_input.close unless @tool_input.closed?
|
|
529
|
+
add(:warning, 'Cancellation requested. Waiting for the active tool to finish; work already started is not undone.')
|
|
530
|
+
else
|
|
531
|
+
@editor.replace('')
|
|
532
|
+
end
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
def stop_worker
|
|
536
|
+
return unless @worker&.alive? || @swarm&.busy?
|
|
537
|
+
|
|
538
|
+
@control&.stop
|
|
539
|
+
@swarm&.cancel_all
|
|
540
|
+
@tool_input.close unless @tool_input.closed?
|
|
541
|
+
@worker&.join
|
|
542
|
+
@swarm&.close
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
def submit(line)
|
|
546
|
+
return if line.strip.empty?
|
|
547
|
+
|
|
548
|
+
# Pry owns the existing pwn_history format, append and deduplication.
|
|
549
|
+
# Curses bypasses Pry's input evaluator, so record once here, not on recall.
|
|
550
|
+
Pry.history << line unless line.match?(%r{\A\s*/input(?:\s|\z)})
|
|
551
|
+
|
|
552
|
+
if %w[back /back].include?(line.strip)
|
|
553
|
+
@leaving = true
|
|
554
|
+
cancel if busy?
|
|
555
|
+
elsif line.strip == '/clear'
|
|
556
|
+
clear_view
|
|
557
|
+
elsif line.strip == '/status'
|
|
558
|
+
@details = 0
|
|
559
|
+
@menu = nil
|
|
560
|
+
elsif line.match?(%r{\A/verbose(?:\s|$)})
|
|
561
|
+
toggle_verbose(line)
|
|
562
|
+
elsif line.start_with?('/swarm')
|
|
563
|
+
run_swarm(line)
|
|
564
|
+
elsif ['/', '/menu'].include?(line.strip)
|
|
565
|
+
@editor.replace('/')
|
|
566
|
+
complete
|
|
567
|
+
elsif busy?
|
|
568
|
+
busy_command(line)
|
|
569
|
+
elsif line.strip == '/system-role'
|
|
570
|
+
open_system_role
|
|
571
|
+
elsif line.match?(%r{\A/(?:mcp(?:\s|$)|cron\s+run(?:\s|$)|model(?:\s|$))})
|
|
572
|
+
start_request(line, command: true)
|
|
573
|
+
elsif line.start_with?('/') || line.start_with?('ai.profile ', 'ai.memory ')
|
|
574
|
+
if line.start_with?('/sessions resume ')
|
|
575
|
+
return add(:warning, 'Cannot switch sessions while swarm jobs are active.') if @swarm&.busy?
|
|
576
|
+
|
|
577
|
+
sid = line.split.last
|
|
578
|
+
PWN::Sessions.to_response_history(session_id: sid)
|
|
579
|
+
@pry.config.pwn_ai_session_id = sid
|
|
580
|
+
add(:notice, "Session resumed: #{sid}")
|
|
581
|
+
elsif !REPL.pwn_ai_dispatch_slash!(request: line, pry: @pry)
|
|
582
|
+
add(:warning, 'Unknown command. Use / or Tab for commands.')
|
|
583
|
+
end
|
|
584
|
+
else
|
|
585
|
+
start_request(line)
|
|
586
|
+
end
|
|
587
|
+
rescue StandardError => e
|
|
588
|
+
add(:warning, "#{e.class}: #{e.message}")
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
def busy_command(line)
|
|
592
|
+
if line.match?(%r{\A/steer(?:\s|$)})
|
|
593
|
+
if @control.nil?
|
|
594
|
+
add(:warning, 'Choose a swarm job with Ctrl+G, then s to steer it.')
|
|
595
|
+
elsif @command
|
|
596
|
+
add(:warning, '/steer applies to model requests, not local commands. Use /input for a prompt or Ctrl+C to cancel after this command returns.')
|
|
597
|
+
else
|
|
598
|
+
@control.submit(line.sub(%r{\A/steer\s*}, ''))
|
|
599
|
+
end
|
|
600
|
+
elsif line.start_with?('/input ')
|
|
601
|
+
send_tool_input(line)
|
|
602
|
+
elsif line.strip == '/clear'
|
|
603
|
+
clear_view
|
|
604
|
+
elsif line.match?(%r{\A/verbose(?:\s|$)})
|
|
605
|
+
toggle_verbose(line)
|
|
606
|
+
elsif line.start_with?('/swarm') && %w[status tail steer cancel help roster].include?(line.split[1])
|
|
607
|
+
run_swarm(line)
|
|
608
|
+
else
|
|
609
|
+
add(:warning, 'Request running. Settings and new requests wait until idle. Use /steer, /input, /swarm status|steer|cancel, /clear, or /verbose.')
|
|
610
|
+
end
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
def clear_view
|
|
614
|
+
@timeline = []
|
|
615
|
+
@rows = nil
|
|
616
|
+
@scroll = nil
|
|
617
|
+
@event_count = 0
|
|
618
|
+
add(:notice, 'Session view cleared. Stored conversation was not deleted.')
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
def toggle_verbose(line)
|
|
622
|
+
argument = line.split[1]
|
|
623
|
+
@verbose = argument.nil? ? !@verbose : argument == 'on'
|
|
624
|
+
@rows = nil
|
|
625
|
+
add(:notice, @verbose ? 'Verbose view on.' : 'Compact view on. Tool output is hidden until /verbose on.')
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
def run_swarm(line)
|
|
629
|
+
return open_swarm if ['/swarm', '/swarm dashboard'].include?(line.strip)
|
|
630
|
+
|
|
631
|
+
result = @swarm.execute(line: line, on_event: ->(type, text) { @events << [type, text] }, usage_observer: @usage.method(:record))
|
|
632
|
+
text = result[:job_id] ? "Swarm job #{result[:job_id]} queued (#{result[:state]})." : (result[:usage] || result[:error] || result.inspect)
|
|
633
|
+
add(result[:ok] == false ? :warning : :notice, text)
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
def send_tool_input(line)
|
|
637
|
+
text = "#{line.delete_prefix('/input ')}\n"
|
|
638
|
+
written = @tool_input.write_nonblock(text, exception: false)
|
|
639
|
+
if text.bytesize > 4096 || written == :wait_writable
|
|
640
|
+
add(:warning, 'Tool input not sent: pipe full or line exceeds 4096 bytes.')
|
|
641
|
+
else
|
|
642
|
+
add(:notice, 'Input sent to tool (not echoed).')
|
|
643
|
+
end
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
def complete
|
|
647
|
+
result = AIConsoleCommands.complete(line: @editor.text, cursor: @editor.cursor, pry: @pry, swarm: @swarm)
|
|
648
|
+
@menu = result[:items]
|
|
649
|
+
@menu_hint = result[:hint]
|
|
650
|
+
@menu_index = [@menu_index.to_i, @menu.length - 1].min
|
|
651
|
+
@menu_index = 0 if @menu_index.negative?
|
|
652
|
+
@menu = nil if @menu.empty?
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
def handle(key)
|
|
656
|
+
return if key.nil?
|
|
657
|
+
return handle_system_role(key) if @role_editor && (!@too_small || ["\e", "\u0003", "\u0004", :eof].include?(key))
|
|
658
|
+
|
|
659
|
+
return if @too_small && ![:eof, "\u0003", "\u0004"].include?(key)
|
|
660
|
+
return cancel if @too_small && key == "\u0003" && !@model_prompt
|
|
661
|
+
|
|
662
|
+
return handle_model_prompt(key) if @model_prompt
|
|
663
|
+
|
|
664
|
+
return clear_view if key == "\u000c"
|
|
665
|
+
|
|
666
|
+
if key == "\u0007"
|
|
667
|
+
@workspace ? @workspace = nil : open_swarm
|
|
668
|
+
return
|
|
669
|
+
end
|
|
670
|
+
return handle_swarm(key) if @workspace && ![:eof, "\u0004"].include?(key)
|
|
671
|
+
|
|
672
|
+
if key == "\u000f"
|
|
673
|
+
@editor.finish_search
|
|
674
|
+
@details = @details ? nil : 0
|
|
675
|
+
@menu = nil
|
|
676
|
+
return
|
|
677
|
+
end
|
|
678
|
+
return handle_details(key) if @details && ![:eof, "\u0003", "\u0004"].include?(key)
|
|
679
|
+
return handle_search(key) if @editor.search && ![:eof, "\u0003", "\u0004"].include?(key)
|
|
680
|
+
|
|
681
|
+
if key == "\u0012"
|
|
682
|
+
@menu = nil
|
|
683
|
+
@paste = false
|
|
684
|
+
@editor.search_history
|
|
685
|
+
return
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
if key == "\t" && @menu
|
|
689
|
+
accept_completion
|
|
690
|
+
return
|
|
691
|
+
end
|
|
692
|
+
if @menu && key == "\e"
|
|
693
|
+
@menu = nil
|
|
694
|
+
return
|
|
695
|
+
end
|
|
696
|
+
case key
|
|
697
|
+
when :eof, "\u0004"
|
|
698
|
+
@leaving = true
|
|
699
|
+
cancel if busy?
|
|
700
|
+
when "\u0003" then cancel
|
|
701
|
+
when :paste_start then @paste = true
|
|
702
|
+
when :paste_end then @paste = false
|
|
703
|
+
when :newline then @editor.insert("\n")
|
|
704
|
+
when "\r", "\n"
|
|
705
|
+
if @paste
|
|
706
|
+
@editor.insert("\n")
|
|
707
|
+
elsif @editor.text.end_with?('\\')
|
|
708
|
+
@editor.edit("\b")
|
|
709
|
+
@editor.insert("\n")
|
|
710
|
+
else
|
|
711
|
+
@menu = @menu_hint = nil
|
|
712
|
+
return submit(@editor.text) if @editor.text.strip == '/system-role'
|
|
713
|
+
|
|
714
|
+
@model_draft = [@editor.text.dup, @editor.cursor] if @editor.text.match?(%r{\A/model(?:\s|$)})
|
|
715
|
+
submit(@editor.submit)
|
|
716
|
+
end
|
|
717
|
+
when :up, :down, "\u0010", "\u000e"
|
|
718
|
+
direction = [:up, "\u0010"].include?(key) ? :up : :down
|
|
719
|
+
if @menu
|
|
720
|
+
@menu_index = (@menu_index + (direction == :up ? -1 : 1)) % @menu.length
|
|
721
|
+
else
|
|
722
|
+
@editor.recall(direction)
|
|
723
|
+
end
|
|
724
|
+
when :page_up then @scroll = [(@scroll || (@total_rows.to_i - @page_size.to_i)) - @page_size.to_i, 0].max
|
|
725
|
+
when :page_down
|
|
726
|
+
@scroll = [@scroll.to_i + @page_size.to_i, @total_rows.to_i].min
|
|
727
|
+
@scroll = nil if @scroll >= [@total_rows.to_i - @page_size.to_i, 0].max
|
|
728
|
+
@unseen = 0 unless @scroll
|
|
729
|
+
when "\t" then accept_completion
|
|
730
|
+
when :left, :right, :home, :end, :delete, "\b", "\u007f", "\u0001", "\u0005", "\u0015"
|
|
731
|
+
@editor.edit(key)
|
|
732
|
+
refresh_completion
|
|
733
|
+
else
|
|
734
|
+
if key.is_a?(String) && key.ord >= 32
|
|
735
|
+
@editor.insert(key)
|
|
736
|
+
refresh_completion
|
|
737
|
+
end
|
|
738
|
+
end
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
def open_system_role
|
|
742
|
+
return add(:warning, 'System role cannot change while a request or swarm job is running.') if busy?
|
|
743
|
+
|
|
744
|
+
@role_engine = PWN::Env.dig(:ai, :active).to_s
|
|
745
|
+
raise 'Select an active engine with /model first.' unless PWN::Env.dig(:ai, @role_engine.to_sym).is_a?(Hash)
|
|
746
|
+
|
|
747
|
+
@role_editor = Editor.new
|
|
748
|
+
@role_editor.replace(PWN::Env.dig(:ai, @role_engine.to_sym, :system_role_content).to_s)
|
|
749
|
+
@role_error = nil
|
|
750
|
+
@role_paste = false
|
|
751
|
+
@menu = nil
|
|
752
|
+
@paste = false
|
|
753
|
+
end
|
|
754
|
+
|
|
755
|
+
def handle_system_role(key)
|
|
756
|
+
case key
|
|
757
|
+
when "\e", "\u0003", "\u0004", :eof
|
|
758
|
+
@role_editor = nil
|
|
759
|
+
add(:notice, 'System role cancelled; configuration unchanged.')
|
|
760
|
+
@leaving = true if [:eof, "\u0004"].include?(key)
|
|
761
|
+
when "\u0013"
|
|
762
|
+
return if @role_paste
|
|
763
|
+
raise 'System role cannot change while a request or swarm job is running.' if busy?
|
|
764
|
+
|
|
765
|
+
REPL.pwn_ai_apply_system_role(engine: @role_engine, content: @role_editor.text)
|
|
766
|
+
@role_editor = nil
|
|
767
|
+
add(:notice, 'System role saved to encrypted pwn.yaml; effective for the next request.')
|
|
768
|
+
when :paste_start then @role_paste = true
|
|
769
|
+
when :paste_end then @role_paste = false
|
|
770
|
+
when "\r", "\n", :newline then @role_editor.insert("\n")
|
|
771
|
+
when :up, :down then move_role_cursor(key)
|
|
772
|
+
when :left, :right, :home, :end, :delete, "\b", "\u007f", "\u0001", "\u0005", "\u0015"
|
|
773
|
+
@role_editor.edit(key)
|
|
774
|
+
else
|
|
775
|
+
@role_editor.insert(key) if key.is_a?(String) && key.ord >= 32
|
|
776
|
+
end
|
|
777
|
+
rescue StandardError => e
|
|
778
|
+
@role_error = "Not saved: #{e.message}"
|
|
779
|
+
add(:warning, @role_error)
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
def move_role_cursor(direction)
|
|
783
|
+
chars = @role_editor.text.scan(/\X/)
|
|
784
|
+
cursor = @role_editor.cursor
|
|
785
|
+
start = (chars.take(cursor).rindex("\n") || -1) + 1
|
|
786
|
+
column = cursor - start
|
|
787
|
+
if direction == :up
|
|
788
|
+
return if start.zero?
|
|
789
|
+
|
|
790
|
+
target = (chars.take(start - 1).rindex("\n") || -1) + 1
|
|
791
|
+
position = target + [column, start - target - 1].min
|
|
792
|
+
else
|
|
793
|
+
finish = chars.index.with_index { |char, index| index >= cursor && char == "\n" }
|
|
794
|
+
return unless finish
|
|
795
|
+
|
|
796
|
+
target = finish + 1
|
|
797
|
+
length = chars.drop(target).take_while { |char| char != "\n" }.length
|
|
798
|
+
position = target + [column, length].min
|
|
799
|
+
end
|
|
800
|
+
@role_editor.place(@role_editor.text, position)
|
|
801
|
+
end
|
|
802
|
+
|
|
803
|
+
def draw_system_role
|
|
804
|
+
height = @height - 4
|
|
805
|
+
pane_width = @width - 4
|
|
806
|
+
top = 2
|
|
807
|
+
height.times { |row| put(top + row, 2, ' ' * pane_width) }
|
|
808
|
+
box(top, 2, height, pane_width, 'SYSTEM ROLE CONTENT')
|
|
809
|
+
put(top + 1, 4, fit("#{@role_engine} · edits are not saved until Ctrl+S", pane_width - 4), tone(:category))
|
|
810
|
+
limit = pane_width - 6
|
|
811
|
+
rows = wrap(@role_editor.text, limit, words: false)
|
|
812
|
+
prefix = wrap(@role_editor.text.scan(/\X/).take(@role_editor.cursor).join, limit, words: false)
|
|
813
|
+
cursor_row = prefix.length - 1
|
|
814
|
+
visible = height - 5
|
|
815
|
+
first = [cursor_row - visible + 1, 0].max
|
|
816
|
+
rows.slice(first, visible).to_a.each_with_index { |row, index| put(top + 2 + index, 4, row, tone(:composer)) }
|
|
817
|
+
put(top + height - 3, 4, fit(@role_error || 'Arrows move · Home/End · Ctrl+U clear', pane_width - 4), tone(@role_error ? :warning : :footer))
|
|
818
|
+
put(top + height - 2, 4, fit('Ctrl+S Save · Esc Cancel · Enter newline', pane_width - 4), tone(:selection))
|
|
819
|
+
@cursor_position = [top + 2 + cursor_row - first, [4 + width(prefix.last), @width - 5].min]
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
def handle_model_prompt(key)
|
|
823
|
+
case key
|
|
824
|
+
when :up, "\u0010", 'k'
|
|
825
|
+
@model_index = (@model_index - 1) % @model_prompt[:efforts].length
|
|
826
|
+
when :down, "\u000e", 'j'
|
|
827
|
+
@model_index = (@model_index + 1) % @model_prompt[:efforts].length
|
|
828
|
+
when "\r", "\n"
|
|
829
|
+
selection = @model_prompt.merge(reasoning_effort: @model_prompt[:efforts][@model_index])
|
|
830
|
+
REPL.pwn_ai_apply_model(selection: selection)
|
|
831
|
+
@model_prompt = @model_draft = nil
|
|
832
|
+
when "\e", "\u0003", :eof, "\u0004"
|
|
833
|
+
if @model_draft
|
|
834
|
+
@editor.replace(@model_draft[0])
|
|
835
|
+
@editor.edit(:left) while @editor.cursor > @model_draft[1]
|
|
836
|
+
end
|
|
837
|
+
@model_prompt = @model_draft = nil
|
|
838
|
+
add(:notice, 'Model selection cancelled; configuration unchanged.')
|
|
839
|
+
@leaving = true if [:eof, "\u0004"].include?(key)
|
|
840
|
+
end
|
|
841
|
+
end
|
|
842
|
+
|
|
843
|
+
def draw_model_prompt
|
|
844
|
+
height = [@model_prompt[:efforts].length + 5, @height - 2].min
|
|
845
|
+
menu_width = [@width - 4, 72].min
|
|
846
|
+
top = [(@height - height) / 2, 0].max
|
|
847
|
+
height.times { |row| put(top + row, 2, ' ' * menu_width) }
|
|
848
|
+
box(top, 2, height, menu_width, 'REASONING EFFORT · SELECT')
|
|
849
|
+
put(top + 1, 4, fit("#{@model_prompt[:engine]} / #{@model_prompt[:model]}", menu_width - 4))
|
|
850
|
+
start = [@model_index - (height - 5), 0].max
|
|
851
|
+
@model_prompt[:efforts].slice(start, height - 4).each_with_index do |effort, index|
|
|
852
|
+
selected = start + index == @model_index
|
|
853
|
+
text = "#{selected ? '▶ ' : ' '}#{effort}"
|
|
854
|
+
text += ' (current/default)' if effort == @model_prompt[:default]
|
|
855
|
+
paint = -> { put(top + 2 + index, 4, fit(text, menu_width - 4), tone(selected ? :selection : :notice)) }
|
|
856
|
+
selected ? @screen.attron(Curses::A_REVERSE) { paint.call } : paint.call
|
|
857
|
+
end
|
|
858
|
+
put(top + height - 2, 4, fit('↑↓ select · Enter accept · Esc cancel', menu_width - 4))
|
|
859
|
+
end
|
|
860
|
+
|
|
861
|
+
def handle_search(key)
|
|
862
|
+
query = @editor.search[:query]
|
|
863
|
+
case key
|
|
864
|
+
when "\e" then @editor.finish_search
|
|
865
|
+
when "\r", "\n" then @editor.finish_search(accept: true)
|
|
866
|
+
when "\u0012" then @editor.search_history(query, older: true)
|
|
867
|
+
when "\b", "\u007f" then @editor.search_history(query.scan(/\X/)[0...-1].join)
|
|
868
|
+
when "\u0015" then @editor.search_history
|
|
869
|
+
else
|
|
870
|
+
@editor.search_history(query + key) if key.is_a?(String) && key.ord >= 32
|
|
871
|
+
end
|
|
872
|
+
end
|
|
873
|
+
|
|
874
|
+
def refresh_completion
|
|
875
|
+
token = @editor.text.scan(/\X/).take(@editor.cursor).join[/(?:^|\s)(\S*)\z/, 1].to_s
|
|
876
|
+
if @editor.text.start_with?('/') || token.start_with?('PWN') || token.match?(%r{\A(?:~|\./|\.\./|/)})
|
|
877
|
+
complete
|
|
878
|
+
else
|
|
879
|
+
@menu = nil
|
|
880
|
+
end
|
|
881
|
+
end
|
|
882
|
+
|
|
883
|
+
def handle_details(key)
|
|
884
|
+
case key
|
|
885
|
+
when "\e" then @details = nil
|
|
886
|
+
when :page_up then @details = [@details - @details_page, 0].max
|
|
887
|
+
when :page_down then @details += @details_page
|
|
888
|
+
when :up then @details = [@details - 1, 0].max
|
|
889
|
+
when :down then @details += 1
|
|
890
|
+
when :home then @details = 0
|
|
891
|
+
when :end then @details = @details_total
|
|
892
|
+
end
|
|
893
|
+
end
|
|
894
|
+
|
|
895
|
+
def accept_completion
|
|
896
|
+
refresh_completion if @menu.nil?
|
|
897
|
+
item = @menu && @menu[@menu_index]
|
|
898
|
+
return complete unless item
|
|
899
|
+
|
|
900
|
+
@editor.place(item[:text], item[:cursor])
|
|
901
|
+
complete
|
|
902
|
+
end
|
|
903
|
+
|
|
904
|
+
def width(text)
|
|
905
|
+
Unicode::DisplayWidth.of(text, ambiguous: 1, emoji: :all)
|
|
906
|
+
end
|
|
907
|
+
|
|
908
|
+
def bounded_event(text)
|
|
909
|
+
return text if text.bytesize <= 16_384
|
|
910
|
+
|
|
911
|
+
clipped = +''
|
|
912
|
+
text.each_grapheme_cluster do |grapheme|
|
|
913
|
+
break if clipped.bytesize + grapheme.bytesize > 16_384
|
|
914
|
+
|
|
915
|
+
clipped << grapheme
|
|
916
|
+
end
|
|
917
|
+
"#{clipped}\n[display truncated at 16 KiB; full event is not shown]"
|
|
918
|
+
end
|
|
919
|
+
|
|
920
|
+
def timeline_rows(limit)
|
|
921
|
+
theme = AIConsole.theme
|
|
922
|
+
return @rows if @rows && @rows_width == limit && @rows_verbose == @verbose && @rows_theme == theme
|
|
923
|
+
|
|
924
|
+
@rows_width = limit
|
|
925
|
+
@rows_verbose = @verbose
|
|
926
|
+
@rows_theme = theme
|
|
927
|
+
@rows = @timeline.flat_map do |type, text, stamp, cache|
|
|
928
|
+
next [] if !@verbose && %i[tool result notice].include?(type)
|
|
929
|
+
|
|
930
|
+
unless cache[:width] == limit && cache[:verbose] == @verbose && cache[:theme] == theme
|
|
931
|
+
cache[:verbose] = @verbose
|
|
932
|
+
cache[:width] = limit
|
|
933
|
+
cache[:theme] = theme
|
|
934
|
+
role = COLORS.fetch(type, :result)
|
|
935
|
+
label_color = type == :operator ? tone(:operator) : tone(role)
|
|
936
|
+
body_color = type == :operator ? tone(:request) : label_color
|
|
937
|
+
cache[:rows] = [[label_color, "#{fit(type.to_s.upcase, 12)} · #{stamp}"]] +
|
|
938
|
+
wrap(text, limit).map { |row| [body_color, " #{row}"] } + [[nil, '']]
|
|
939
|
+
end
|
|
940
|
+
cache[:rows]
|
|
941
|
+
end
|
|
942
|
+
@rows.pop if @rows.last&.last == ''
|
|
943
|
+
@rows
|
|
944
|
+
end
|
|
945
|
+
|
|
946
|
+
def begin_request_metrics
|
|
947
|
+
@started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
948
|
+
@finished_at = @last_tool = nil
|
|
949
|
+
@completed_tools = 0
|
|
950
|
+
end
|
|
951
|
+
|
|
952
|
+
def finish_request_metrics
|
|
953
|
+
@finished_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) if @started_at
|
|
954
|
+
end
|
|
955
|
+
|
|
956
|
+
def elapsed
|
|
957
|
+
return '—' unless @started_at
|
|
958
|
+
|
|
959
|
+
format('%.1fs', (@finished_at || Process.clock_gettime(Process::CLOCK_MONOTONIC)) - @started_at)
|
|
960
|
+
end
|
|
961
|
+
|
|
962
|
+
def scroll_status
|
|
963
|
+
return 'live' unless @scroll
|
|
964
|
+
|
|
965
|
+
"#{@scroll + 1}–#{[@scroll + @page_size.to_i, @total_rows.to_i].min}/#{@total_rows} · #{@unseen} new"
|
|
966
|
+
end
|
|
967
|
+
|
|
968
|
+
def operation_lines
|
|
969
|
+
usage = @usage.snapshot
|
|
970
|
+
cost = usage[:estimated_cost_usd] ? format('$%.4f est.', usage[:estimated_cost_usd]) : usage[:cost_status]
|
|
971
|
+
swarm = @swarm&.snapshot || {}
|
|
972
|
+
active = Array(swarm[:jobs]).count { |job| %w[queued running steering cancelling].include?(job[:state]) }
|
|
973
|
+
['REQUEST', state, "Elapsed #{elapsed}", "Completed tools #{@completed_tools}",
|
|
974
|
+
"Events #{@event_count}", '', 'TOKENS', "#{usage[:input_tokens]} in · #{usage[:output_tokens]} out",
|
|
975
|
+
"cached #{usage[:cached_tokens]} · calls #{usage[:calls]}", "Cost #{cost}",
|
|
976
|
+
'', 'SWARM', swarm[:swarm_id] || 'none', "#{active} active · ^G swarm",
|
|
977
|
+
'', 'VIEW', @verbose ? 'verbose' : 'compact · /verbose', scroll_status,
|
|
978
|
+
'', 'LAST TOOL', @last_tool || 'not observed']
|
|
979
|
+
end
|
|
980
|
+
|
|
981
|
+
def fit(text, limit)
|
|
982
|
+
result = +''
|
|
983
|
+
used = 0
|
|
984
|
+
text.scan(/\X/).each do |char|
|
|
985
|
+
used += width(char)
|
|
986
|
+
break if used > limit
|
|
987
|
+
|
|
988
|
+
result << char
|
|
989
|
+
end
|
|
990
|
+
result
|
|
991
|
+
end
|
|
992
|
+
|
|
993
|
+
def wrap(text, limit, words: true)
|
|
994
|
+
return [''] if text.empty?
|
|
995
|
+
|
|
996
|
+
text.split("\n", -1).flat_map do |line|
|
|
997
|
+
rows = [+'']
|
|
998
|
+
used = 0
|
|
999
|
+
tokens = words ? line.scan(/\s+|\S+/) : [line]
|
|
1000
|
+
tokens.each do |token|
|
|
1001
|
+
if words && !token.match?(/\A\s/) && used.positive? && width(token) <= limit && used + width(token) > limit
|
|
1002
|
+
rows << +''
|
|
1003
|
+
used = 0
|
|
1004
|
+
end
|
|
1005
|
+
token.scan(/\X/).each do |char|
|
|
1006
|
+
cells = width(char)
|
|
1007
|
+
if used + cells > limit && !rows.last.empty?
|
|
1008
|
+
rows << +''
|
|
1009
|
+
used = 0
|
|
1010
|
+
end
|
|
1011
|
+
rows[-1] << char
|
|
1012
|
+
used += cells
|
|
1013
|
+
end
|
|
1014
|
+
end
|
|
1015
|
+
rows
|
|
1016
|
+
end
|
|
1017
|
+
end
|
|
1018
|
+
|
|
1019
|
+
def tone(role)
|
|
1020
|
+
name = AIConsole.theme[role].to_s
|
|
1021
|
+
index = PALETTE.index(name)
|
|
1022
|
+
index ? index + 1 : 1
|
|
1023
|
+
end
|
|
1024
|
+
|
|
1025
|
+
def put(row, column, text, color = nil)
|
|
1026
|
+
return if row.negative? || row >= @height || column >= @width
|
|
1027
|
+
|
|
1028
|
+
@screen.setpos(row, column)
|
|
1029
|
+
string = fit(text, [@width - column - 1, 0].max)
|
|
1030
|
+
if @color && color
|
|
1031
|
+
@screen.attron(@curses.color_pair(color)) { @screen.addstr(string) }
|
|
1032
|
+
else
|
|
1033
|
+
@screen.addstr(string)
|
|
1034
|
+
end
|
|
1035
|
+
end
|
|
1036
|
+
|
|
1037
|
+
def box(top, left, height, box_width, title, color = nil) # rubocop:disable Metrics/ParameterLists -- screen rectangle plus appearance
|
|
1038
|
+
color ||= tone(:border)
|
|
1039
|
+
put(top, left, "╭#{'─' * (box_width - 2)}╮", color)
|
|
1040
|
+
(1...(height - 1)).each do |row|
|
|
1041
|
+
put(top + row, left, '│', color)
|
|
1042
|
+
put(top + row, left + box_width - 1, '│', color)
|
|
1043
|
+
end
|
|
1044
|
+
put(top + height - 1, left, "╰#{'─' * (box_width - 2)}╯", color)
|
|
1045
|
+
put(top, left + 2, fit(" #{title} ", box_width - 4), tone(:title))
|
|
1046
|
+
end
|
|
1047
|
+
|
|
1048
|
+
def seed_request_history
|
|
1049
|
+
# REPL configures ~/.pwn/pwn_history and Pry loads it at startup.
|
|
1050
|
+
# Reuse that owner rather than loading twice or opening another writer.
|
|
1051
|
+
@editor.preload(Pry.history.to_a)
|
|
1052
|
+
rescue StandardError
|
|
1053
|
+
nil
|
|
1054
|
+
end
|
|
1055
|
+
|
|
1056
|
+
def state
|
|
1057
|
+
return 'closing · waiting for safe boundary' if @leaving && busy?
|
|
1058
|
+
return 'cancelling · waiting for safe boundary' if @cancelling && busy?
|
|
1059
|
+
return 'idle' unless busy?
|
|
1060
|
+
return 'swarm running · ^G jobs / steering' unless @worker && (@worker.alive? || @running)
|
|
1061
|
+
return 'running · local command · /input available' if @command
|
|
1062
|
+
|
|
1063
|
+
"running · #{@control&.phase || 'starting'} · /steer available"
|
|
1064
|
+
end
|
|
1065
|
+
|
|
1066
|
+
def engine_settings
|
|
1067
|
+
engine = PWN::Env.dig(:ai, :active).to_s
|
|
1068
|
+
cfg = PWN::Env.dig(:ai, engine.to_sym)
|
|
1069
|
+
cfg = {} unless cfg.is_a?(Hash)
|
|
1070
|
+
role = cfg[:system_role_content].to_s.gsub(/\s+/, ' ').strip
|
|
1071
|
+
parts = []
|
|
1072
|
+
parts << "SYSTEM ROLE CONTENT: #{role}" unless role.empty?
|
|
1073
|
+
parts << "\nTEMP: #{cfg[:temp]}" unless cfg[:temp].nil?
|
|
1074
|
+
parts << " MAX TOKENS: #{cfg[:max_tokens]}" unless cfg[:max_tokens].nil?
|
|
1075
|
+
parts << " MAX PROMPT LENGTH: #{cfg[:max_prompt_length]}" unless cfg[:max_prompt_length].nil?
|
|
1076
|
+
parts << "\nREASONING EFFORT: #{cfg[:reasoning_effort]}" unless cfg[:reasoning_effort].to_s.empty?
|
|
1077
|
+
parts.join(' · ')
|
|
1078
|
+
rescue StandardError
|
|
1079
|
+
''
|
|
1080
|
+
end
|
|
1081
|
+
|
|
1082
|
+
def header_lines(engine, model, limit = [@width.to_i - 5, 1].max)
|
|
1083
|
+
settings = engine_settings
|
|
1084
|
+
# Env strings can be edited in place; cache values, not live aliases.
|
|
1085
|
+
key = [engine.dup, model.dup, settings, limit]
|
|
1086
|
+
return @header_lines if @header_key == key
|
|
1087
|
+
|
|
1088
|
+
@header_key = key
|
|
1089
|
+
text = "PROVIDER (ENGINE): #{engine}\nMODEL: #{model}"
|
|
1090
|
+
text += "\n#{settings}" unless settings.empty?
|
|
1091
|
+
@header_spans = styled_settings(text, limit)
|
|
1092
|
+
@header_lines = @header_spans.map { |spans| spans.map(&:last).join }
|
|
1093
|
+
end
|
|
1094
|
+
|
|
1095
|
+
# Mark labels before wrapping so even a split label retains its role.
|
|
1096
|
+
def styled_settings(text, limit)
|
|
1097
|
+
text.split("\n", -1).flat_map do |line|
|
|
1098
|
+
roles = Array.new(line.length, :header)
|
|
1099
|
+
line.to_enum(:scan, /(?:\A|(?<=· ))\s*(?:PROVIDER \(ENGINE\)|MODEL|SYSTEM ROLE CONTENT|TEMP|MAX TOKENS|MAX PROMPT LENGTH|REASONING EFFORT):/).each do
|
|
1100
|
+
match = Regexp.last_match
|
|
1101
|
+
roles.fill(:category, match.begin(0)...match.end(0))
|
|
1102
|
+
end
|
|
1103
|
+
offset = 0
|
|
1104
|
+
wrap(line, limit).map do |row|
|
|
1105
|
+
spans = []
|
|
1106
|
+
row.each_char do |char|
|
|
1107
|
+
role = roles[offset]
|
|
1108
|
+
offset += 1
|
|
1109
|
+
if spans.last&.first == role
|
|
1110
|
+
spans.last[1] << char
|
|
1111
|
+
else
|
|
1112
|
+
spans << [role, +char]
|
|
1113
|
+
end
|
|
1114
|
+
end
|
|
1115
|
+
spans
|
|
1116
|
+
end
|
|
1117
|
+
end
|
|
1118
|
+
end
|
|
1119
|
+
|
|
1120
|
+
def put_spans(row, column, spans)
|
|
1121
|
+
spans.each do |role, text|
|
|
1122
|
+
put(row, column, text, tone(role))
|
|
1123
|
+
column += width(text)
|
|
1124
|
+
end
|
|
1125
|
+
end
|
|
1126
|
+
|
|
1127
|
+
# Artwork has no worker, IO, or request-progress meaning. The existing
|
|
1128
|
+
# event-loop repaint supplies its clock, including while requests run.
|
|
1129
|
+
def banner_frame(size, cells: false)
|
|
1130
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
1131
|
+
session = @pry.config.pwn_ai_session_id.to_s
|
|
1132
|
+
if @banner_session != session
|
|
1133
|
+
@banner_session = session
|
|
1134
|
+
@banner_name = PWN::Banner.mini_names.sample
|
|
1135
|
+
@banner_frame_seconds = PWN::Banner.mini_frame_seconds(name: @banner_name)
|
|
1136
|
+
@banner_seed = session.bytes.reduce(PWN::Banner::MINI_SEED) { |seed, byte| ((seed * 33) ^ byte) & 0xffffffff }
|
|
1137
|
+
@banner_started = now
|
|
1138
|
+
end
|
|
1139
|
+
cadence = cells ? @banner_frame_seconds : PWN::Banner::MINI_FRAME_SECONDS
|
|
1140
|
+
frame = ((now - @banner_started) / cadence).floor % PWN::Banner::MINI_FRAME_COUNT
|
|
1141
|
+
return PWN::Banner.mini_cells(name: @banner_name, frame: frame, width: size, height: size, seed: @banner_seed) if cells
|
|
1142
|
+
|
|
1143
|
+
art = PWN::Banner.mini_frame(name: @banner_name, frame: frame, width: size, height: size)
|
|
1144
|
+
canvas = Array.new(size) { ' ' * size }
|
|
1145
|
+
top = (size - art.length) / 2
|
|
1146
|
+
art.each_with_index { |row, index| canvas[top + index] = row.center(size) }
|
|
1147
|
+
canvas
|
|
1148
|
+
end
|
|
1149
|
+
|
|
1150
|
+
def draw_banner(size)
|
|
1151
|
+
cells = banner_frame(size, cells: true)
|
|
1152
|
+
top = [(size - cells.length) / 2, 0].max
|
|
1153
|
+
# Clear padding too when the bounded artwork is smaller than the pane.
|
|
1154
|
+
size.times { |row| put(row + 1, 1, ' ' * size, @banner_colors ? 16 : nil) }
|
|
1155
|
+
cells.first(size).each_with_index do |row, y|
|
|
1156
|
+
left = [(size - row.length) / 2, 0].max
|
|
1157
|
+
row.first(size).each_with_index do |cell, x|
|
|
1158
|
+
index = PALETTE.index(cell[:foreground].to_s)
|
|
1159
|
+
background = PALETTE.index(cell[:background].to_s)
|
|
1160
|
+
colors = @banner_colors && (@banner_name != :falling_blocks || @banner_two_colors)
|
|
1161
|
+
pair = if colors && index && background && (background == 7 || @banner_two_colors)
|
|
1162
|
+
background == 7 ? 9 + index : 17 + (background * 8) + index
|
|
1163
|
+
end
|
|
1164
|
+
# Two occupied halves must remain filled in monochrome too.
|
|
1165
|
+
glyph = !pair && cell[:background] != :black ? '█' : cell[:glyph]
|
|
1166
|
+
put(top + y + 1, left + x + 1, glyph, pair)
|
|
1167
|
+
end
|
|
1168
|
+
end
|
|
1169
|
+
end
|
|
1170
|
+
|
|
1171
|
+
def header_layout(engine, model)
|
|
1172
|
+
key = [engine.dup, model.dup, engine_settings, @width, @height]
|
|
1173
|
+
return if @header_layout_key == key
|
|
1174
|
+
|
|
1175
|
+
@header_layout_key = key
|
|
1176
|
+
@header_text_column = 2
|
|
1177
|
+
@header_text_width = @width - 5
|
|
1178
|
+
@header_pane_height = nil
|
|
1179
|
+
return if @width < 100 || @height < 26
|
|
1180
|
+
|
|
1181
|
+
# A framed cell-square is as wide as the complete header is tall.
|
|
1182
|
+
# Solve monotonically within physical bounds, never recurse or clip
|
|
1183
|
+
# settings to make room for decoration. Eight interior cells is the
|
|
1184
|
+
# smallest useful game canvas; larger settings grow both dimensions.
|
|
1185
|
+
height = 10
|
|
1186
|
+
while height <= @height - 10 && @width - height - 5 >= 60
|
|
1187
|
+
limit = @width - height - 5
|
|
1188
|
+
needed = header_lines(engine, model, limit).length + 3
|
|
1189
|
+
if needed <= height
|
|
1190
|
+
@header_pane_height = height
|
|
1191
|
+
@header_text_column = height + 2
|
|
1192
|
+
@header_text_width = limit
|
|
1193
|
+
return
|
|
1194
|
+
end
|
|
1195
|
+
height = needed
|
|
1196
|
+
end
|
|
1197
|
+
end
|
|
1198
|
+
|
|
1199
|
+
def draw_header(engine, model)
|
|
1200
|
+
header_layout(engine, model)
|
|
1201
|
+
content = header_lines(engine, model, @header_text_width)
|
|
1202
|
+
# Reserve only the physical minimum: timeline (3), composer (6), footer (1).
|
|
1203
|
+
visible = @header_spans.first([@height - 13, 1].max)
|
|
1204
|
+
overflow = visible.length < content.length ? ' · ^O full settings' : ''
|
|
1205
|
+
height = @header_pane_height || (visible.length + 3)
|
|
1206
|
+
left = @header_pane_height || 0
|
|
1207
|
+
box(0, left, height, @width - left - 1, "pwn-ai v#{PWN::VERSION}#{overflow}")
|
|
1208
|
+
put(1, @header_text_column, fit("#{state} · #{elapsed} · #{scroll_status}", @header_text_width), tone(:status))
|
|
1209
|
+
visible.each_with_index { |spans, index| put_spans(2 + index, @header_text_column, spans) }
|
|
1210
|
+
if @header_pane_height
|
|
1211
|
+
box(0, 0, height, height, 'PWN')
|
|
1212
|
+
draw_banner(height - 2)
|
|
1213
|
+
end
|
|
1214
|
+
height
|
|
1215
|
+
end
|
|
1216
|
+
|
|
1217
|
+
def draw_details(engine, model)
|
|
1218
|
+
@screen.erase
|
|
1219
|
+
lines = ["Session: #{@pry.config.pwn_ai_session_id}"] + operation_lines + ['SETTINGS']
|
|
1220
|
+
rows = lines.flat_map do |line|
|
|
1221
|
+
role = ['REQUEST', 'TOKENS', 'SWARM', 'VIEW', 'LAST TOOL', 'SETTINGS'].include?(line) ? :category : :value
|
|
1222
|
+
wrap(line, @width - 7).map { |row| [[role, row]] }
|
|
1223
|
+
end
|
|
1224
|
+
header_lines(engine, model, @width - 7)
|
|
1225
|
+
rows.concat(@header_spans)
|
|
1226
|
+
@details_page = @height - 5
|
|
1227
|
+
@details_total = rows.length
|
|
1228
|
+
@details = @details.clamp(0, [rows.length - @details_page, 0].max)
|
|
1229
|
+
box(0, 0, @height - 1, @width - 1, "STATUS / SETTINGS · #{@details + 1}/#{rows.length}")
|
|
1230
|
+
rows.slice(@details, @details_page).each_with_index { |spans, index| put_spans(index + 1, 3, spans) }
|
|
1231
|
+
put(@height - 3, 3, 'PgUp/PgDn Home/End · Esc/^O close', tone(:footer))
|
|
1232
|
+
put(@height - 1, 1, '^C cancel ^D back ^O close', tone(:footer))
|
|
1233
|
+
end
|
|
1234
|
+
|
|
1235
|
+
def draw
|
|
1236
|
+
if !@getch.is_a?(Proc) && @source.respond_to?(:winsize)
|
|
1237
|
+
rows, columns = @source.winsize
|
|
1238
|
+
@curses.resizeterm(rows, columns) if rows.positive? && columns.positive? && [rows, columns] != [@curses.lines, @curses.cols]
|
|
1239
|
+
end
|
|
1240
|
+
@height = @curses.lines
|
|
1241
|
+
@width = @curses.cols
|
|
1242
|
+
@screen.erase
|
|
1243
|
+
@too_small = @height < 14 || @width < 48
|
|
1244
|
+
if @too_small
|
|
1245
|
+
@menu = nil
|
|
1246
|
+
@paste = false
|
|
1247
|
+
put(0, 0, 'pwn-ai · terminal too small (48 × 14 minimum)', tone(:status))
|
|
1248
|
+
put(1, 0, state, tone(:status))
|
|
1249
|
+
put(2, 0, 'Ctrl+C cancel · Ctrl+D back · resize to continue')
|
|
1250
|
+
@screen.refresh
|
|
1251
|
+
return
|
|
1252
|
+
end
|
|
1253
|
+
engine = PWN::Env.dig(:ai, :active).to_s
|
|
1254
|
+
model = REPL.pwn_ai_engine_model(engine: engine)
|
|
1255
|
+
header_height = draw_header(engine, model)
|
|
1256
|
+
sidebar = @width >= 100 ? 27 : 0
|
|
1257
|
+
pane_width = @width - sidebar - 2
|
|
1258
|
+
compose_height = 6
|
|
1259
|
+
timeline_height = @height - compose_height - 1 - header_height
|
|
1260
|
+
@page_size = timeline_height - 2
|
|
1261
|
+
rows = timeline_rows(pane_width - 6)
|
|
1262
|
+
@total_rows = rows.length
|
|
1263
|
+
@scroll = @scroll.clamp(0, [rows.length - @page_size, 0].max) if @scroll
|
|
1264
|
+
session_id = @pry.config.pwn_ai_session_id.to_s
|
|
1265
|
+
box(header_height, 0, timeline_height, pane_width, "SESSION #{session_id}")
|
|
1266
|
+
start = @scroll || [rows.length - @page_size, 0].max
|
|
1267
|
+
rows.slice(start, @page_size).to_a.each_with_index { |(color, row), index| put(header_height + 1 + index, 2, row, color) }
|
|
1268
|
+
draw_sidebar(header_height, pane_width + 1, timeline_height) if sidebar.positive?
|
|
1269
|
+
compose_y = @height - compose_height - 1
|
|
1270
|
+
box(compose_y, 0, compose_height, @width - 1, 'MISSION CONTROL')
|
|
1271
|
+
draw_composer(compose_y)
|
|
1272
|
+
hint = @menu ? '↑↓/^P/^N select · Tab accept · Esc close' : '↑↓ history · PgUp/PgDn scroll'
|
|
1273
|
+
put(@height - 1, 1, "^C cancel ^D back · #{hint} · ^G swarm ^L clear ^R search ^O status · Enter send", tone(:footer))
|
|
1274
|
+
draw_menu if @menu
|
|
1275
|
+
draw_model_prompt if @model_prompt
|
|
1276
|
+
draw_details(engine, model) if @details
|
|
1277
|
+
draw_swarm if @workspace
|
|
1278
|
+
draw_system_role if @role_editor
|
|
1279
|
+
@screen.setpos(*@cursor_position) if @role_editor || !(@details || @workspace)
|
|
1280
|
+
@screen.refresh
|
|
1281
|
+
end
|
|
1282
|
+
|
|
1283
|
+
def draw_composer(top)
|
|
1284
|
+
return draw_search(top) if @editor.search
|
|
1285
|
+
|
|
1286
|
+
chars = @editor.text.scan(/\X/)
|
|
1287
|
+
before = chars.take(@editor.cursor).join
|
|
1288
|
+
rows = wrap(@editor.text, @width - 8, words: false)
|
|
1289
|
+
prefix = wrap(before, @width - 8, words: false)
|
|
1290
|
+
cursor_row = prefix.length - 1
|
|
1291
|
+
first = [cursor_row - 2, 0].max
|
|
1292
|
+
put(top + 1, 2, '›', tone(:prompt)) if first.zero?
|
|
1293
|
+
rows.slice(first, 3).to_a.each_with_index { |row, index| put(top + 1 + index, 4, row, tone(:composer)) }
|
|
1294
|
+
hint = @menu ? 'Tab accept · ↑↓/^P/^N select · Esc close' : '^G swarm · Shift+Enter newline · / commands · ^O status'
|
|
1295
|
+
put(top + 4, 4, fit(hint, @width - 7), tone(:footer))
|
|
1296
|
+
@cursor_position = [top + 1 + cursor_row - first, [4 + width(prefix.last), @width - 3].min]
|
|
1297
|
+
end
|
|
1298
|
+
|
|
1299
|
+
def draw_search(top)
|
|
1300
|
+
search = @editor.search
|
|
1301
|
+
query = "reverse search: #{search[:query]}"
|
|
1302
|
+
query_rows = wrap(query, @width - 8, words: false)
|
|
1303
|
+
put(top + 1, 4, query_rows.last, tone(:category))
|
|
1304
|
+
match = search[:match] || '(no match)'
|
|
1305
|
+
wrap(match, @width - 8).first(2).each_with_index { |row, index| put(top + 2 + index, 4, row, tone(:composer)) }
|
|
1306
|
+
put(top + 4, 4, '^R older · Enter accept · Esc cancel', tone(:footer))
|
|
1307
|
+
@cursor_position = [top + 1, [4 + width(query_rows.last), @width - 3].min]
|
|
1308
|
+
end
|
|
1309
|
+
|
|
1310
|
+
def draw_sidebar(top, left, height)
|
|
1311
|
+
box(top, left, height, @width - left - 1, 'OPERATIONS')
|
|
1312
|
+
lines = operation_lines
|
|
1313
|
+
if height < 26
|
|
1314
|
+
lines = lines.select { |line| line.start_with?('Elapsed', 'Completed tools', 'Cost') || line.match?(/\A\d+ in ·/) || line == 'TOKENS' } +
|
|
1315
|
+
['LAST TOOL', @last_tool || 'not observed', '^O full status']
|
|
1316
|
+
end
|
|
1317
|
+
lines.first(height - 2).each_with_index do |line, index|
|
|
1318
|
+
category = ['REQUEST', 'TOKENS', 'SWARM', 'VIEW', 'LAST TOOL'].include?(line)
|
|
1319
|
+
put(top + 1 + index, left + 2, fit(line, @width - left - 5), category ? tone(:category) : tone(:value))
|
|
1320
|
+
end
|
|
1321
|
+
end
|
|
1322
|
+
|
|
1323
|
+
# This overlay owns only navigation state; the mission editor never changes.
|
|
1324
|
+
def open_swarm
|
|
1325
|
+
@editor.finish_search
|
|
1326
|
+
@menu = @details = nil
|
|
1327
|
+
@workspace = { tab: :roster, index: 0, selected: (@swarm_selection ||= []), offset: 0 }
|
|
1328
|
+
refresh_swarm
|
|
1329
|
+
end
|
|
1330
|
+
|
|
1331
|
+
def refresh_swarm
|
|
1332
|
+
@workspace[:roster] = @swarm.roster
|
|
1333
|
+
@workspace[:selected].select! { |name| @workspace[:roster].any? { |row| row[:name] == name } }
|
|
1334
|
+
rescue StandardError => e
|
|
1335
|
+
@workspace[:roster] = []
|
|
1336
|
+
@workspace[:notice] = "Roster unavailable: #{e.message}"
|
|
1337
|
+
end
|
|
1338
|
+
|
|
1339
|
+
def swarm_items
|
|
1340
|
+
@workspace[:tab] == :roster ? @workspace[:roster] : @swarm.snapshot[:jobs].reverse
|
|
1341
|
+
end
|
|
1342
|
+
|
|
1343
|
+
def handle_swarm(key)
|
|
1344
|
+
view = @workspace
|
|
1345
|
+
return handle_swarm_prompt(key) if view[:prompt]
|
|
1346
|
+
|
|
1347
|
+
if view[:confirm]
|
|
1348
|
+
case key
|
|
1349
|
+
when "\r", "\n" then execute_swarm_action
|
|
1350
|
+
when "\e" then view.delete(:confirm)
|
|
1351
|
+
when :page_up then view[:offset] = [view[:offset] - 5, 0].max
|
|
1352
|
+
when :page_down then view[:offset] += 5
|
|
1353
|
+
when :home then view[:offset] = 0
|
|
1354
|
+
when :end then view[:offset] = 1_000_000
|
|
1355
|
+
end
|
|
1356
|
+
return
|
|
1357
|
+
end
|
|
1358
|
+
item = swarm_items[view[:index]]
|
|
1359
|
+
case key
|
|
1360
|
+
when "\e" then view[:detail] ? view.delete(:detail) : @workspace = nil
|
|
1361
|
+
when "\t"
|
|
1362
|
+
view[:tab] = view[:tab] == :roster ? :jobs : :roster
|
|
1363
|
+
view[:index] = view[:offset] = 0
|
|
1364
|
+
view.delete(:detail)
|
|
1365
|
+
when 'j', 'k'
|
|
1366
|
+
view[:index] = (view[:index] + (key == 'j' ? 1 : -1)).clamp(0, [swarm_items.length - 1, 0].max)
|
|
1367
|
+
view[:offset] = 0
|
|
1368
|
+
when ' '
|
|
1369
|
+
return unless view[:tab] == :roster && item
|
|
1370
|
+
|
|
1371
|
+
view[:selected].include?(item[:name]) ? view[:selected].delete(item[:name]) : view[:selected] << item[:name]
|
|
1372
|
+
when "\r", "\n" then view[:detail] = true
|
|
1373
|
+
when :page_up then view[:offset] = [view[:offset] - 5, 0].max
|
|
1374
|
+
when :page_down then view[:offset] += 5
|
|
1375
|
+
when :home then view[:offset] = 0
|
|
1376
|
+
when :end then view[:offset] = 1_000_000
|
|
1377
|
+
when 'r' then refresh_swarm
|
|
1378
|
+
when 'a', 'b', 'd' then prepare_swarm_mission(key, item)
|
|
1379
|
+
when 'c', "\u0003"
|
|
1380
|
+
return unless view[:tab] == :jobs && item
|
|
1381
|
+
|
|
1382
|
+
view[:confirm] = ['cancel', item[:id]]
|
|
1383
|
+
when 's'
|
|
1384
|
+
return unless view[:tab] == :jobs && item
|
|
1385
|
+
|
|
1386
|
+
view[:prompt] = { action: 'steer', id: item[:id], editor: Editor.new, label: 'Steer instruction' }
|
|
1387
|
+
when 'n'
|
|
1388
|
+
view[:prompt] = { action: 'spawn', editor: Editor.new, label: 'New agent name' }
|
|
1389
|
+
end
|
|
1390
|
+
end
|
|
1391
|
+
|
|
1392
|
+
def prepare_swarm_mission(key, item)
|
|
1393
|
+
view = @workspace
|
|
1394
|
+
return view[:notice] = 'Return with Esc and write a mission draft first.' if @editor.text.strip.empty?
|
|
1395
|
+
return view[:notice] = 'Choose agents on the roster tab first.' unless view[:tab] == :roster
|
|
1396
|
+
|
|
1397
|
+
names = key == 'a' ? [item&.dig(:name)].compact : view[:selected]
|
|
1398
|
+
minimum = key == 'd' ? 2 : 1
|
|
1399
|
+
return view[:notice] = "Select at least #{minimum} agent(s) with Space." if names.length < minimum
|
|
1400
|
+
|
|
1401
|
+
command = { 'a' => 'ask', 'b' => 'broadcast', 'd' => 'debate' }.fetch(key)
|
|
1402
|
+
view[:confirm] = case command
|
|
1403
|
+
when 'ask' then [command, names.first, @editor.text]
|
|
1404
|
+
when 'broadcast' then [command, '--names', names.join(','), '--', @editor.text]
|
|
1405
|
+
else [command, names.join(','), '--', @editor.text]
|
|
1406
|
+
end
|
|
1407
|
+
end
|
|
1408
|
+
|
|
1409
|
+
def handle_swarm_prompt(key)
|
|
1410
|
+
prompt = @workspace[:prompt]
|
|
1411
|
+
editor = prompt[:editor]
|
|
1412
|
+
case key
|
|
1413
|
+
when "\e" then @workspace.delete(:prompt)
|
|
1414
|
+
when "\r", "\n"
|
|
1415
|
+
return if editor.text.strip.empty?
|
|
1416
|
+
|
|
1417
|
+
if prompt[:action] == 'spawn' && !prompt[:name]
|
|
1418
|
+
prompt[:name] = editor.text.dup
|
|
1419
|
+
prompt[:label] = 'Agent role (engine/model inherit; /swarm spawn offers overrides)'
|
|
1420
|
+
editor.replace('')
|
|
1421
|
+
else
|
|
1422
|
+
@workspace[:confirm] = prompt[:action] == 'spawn' ? ['spawn', prompt[:name], '--', editor.text] : ['steer', prompt[:id], editor.text]
|
|
1423
|
+
@workspace.delete(:prompt)
|
|
1424
|
+
end
|
|
1425
|
+
when :left, :right, :home, :end, :delete, "\b", "\u007f", "\u0001", "\u0005", "\u0015" then editor.edit(key)
|
|
1426
|
+
else
|
|
1427
|
+
editor.insert(key) if key.is_a?(String) && key.ord >= 32
|
|
1428
|
+
end
|
|
1429
|
+
end
|
|
1430
|
+
|
|
1431
|
+
def execute_swarm_action
|
|
1432
|
+
args = @workspace.delete(:confirm)
|
|
1433
|
+
result = @swarm.execute(line: Shellwords.join(['/swarm'] + args), on_event: ->(type, text) { @events << [type, text] }, usage_observer: @usage.method(:record))
|
|
1434
|
+
@workspace[:notice] = result[:error] || "#{args.first}: accepted#{" · job #{result[:job_id]}" if result[:job_id]}"
|
|
1435
|
+
add(result[:ok] == false ? :warning : :notice, @workspace[:notice])
|
|
1436
|
+
refresh_swarm if args.first == 'spawn'
|
|
1437
|
+
@workspace.merge!(tab: :jobs, index: 0, offset: 0, detail: true) if result[:ok] && %w[ask broadcast debate].include?(args.first)
|
|
1438
|
+
end
|
|
1439
|
+
|
|
1440
|
+
def swarm_content
|
|
1441
|
+
view = @workspace
|
|
1442
|
+
if view[:confirm]
|
|
1443
|
+
args = view[:confirm]
|
|
1444
|
+
target = args.first == 'broadcast' ? args[2] : args[1]
|
|
1445
|
+
return [[false, "ACTION: #{args.first}\nTARGET: #{target}\n#{args.first == 'cancel' ? 'Cancel at a safe boundary; completed work is not undone.' : args.last}"]]
|
|
1446
|
+
end
|
|
1447
|
+
items = swarm_items
|
|
1448
|
+
item = items[view[:index]]
|
|
1449
|
+
rows = []
|
|
1450
|
+
if view[:detail] && item
|
|
1451
|
+
item.each { |key, value| rows << [false, "#{key.to_s.upcase}: #{value}"] }
|
|
1452
|
+
elsif items.empty?
|
|
1453
|
+
rows << [false, view[:tab] == :roster ? 'No agents. Press n to add a local persona (no model call).' : 'No jobs yet. Tab to roster; a sends your draft after confirmation.']
|
|
1454
|
+
else
|
|
1455
|
+
items.each_with_index do |entry, index|
|
|
1456
|
+
label = if view[:tab] == :roster
|
|
1457
|
+
"#{view[:selected].include?(entry[:name]) ? '[x]' : '[ ]'} #{entry[:name]} · #{entry[:engine]} / #{entry[:model]}\n #{entry[:role]}"
|
|
1458
|
+
else
|
|
1459
|
+
"#{entry[:id]} · #{entry[:state]} · #{entry[:command]} → #{entry[:agent]}"
|
|
1460
|
+
end
|
|
1461
|
+
rows << [index == view[:index], label]
|
|
1462
|
+
end
|
|
1463
|
+
end
|
|
1464
|
+
rows
|
|
1465
|
+
end
|
|
1466
|
+
|
|
1467
|
+
def draw_swarm
|
|
1468
|
+
@screen.erase
|
|
1469
|
+
view = @workspace
|
|
1470
|
+
box(0, 0, @height - 1, @width - 1, "SWARM WORKSPACE · #{view[:tab].to_s.upcase}")
|
|
1471
|
+
swarm_put(1, 'Tab roster/jobs · j/k move · Enter details', :footer)
|
|
1472
|
+
swarm_put(2, 'Space pick · a ask · b broadcast · d debate', :footer)
|
|
1473
|
+
swarm_put(3, 'n new · r refresh · s steer · c cancel', :footer)
|
|
1474
|
+
swarm_put(4, 'PgUp/Dn Home/End scroll · Esc back', :footer)
|
|
1475
|
+
rows = swarm_content.flat_map { |selected, text| wrap(swarm_safe(text), @width - 7).map { |row| [selected, row] } }
|
|
1476
|
+
page = @height - 10
|
|
1477
|
+
anchor = rows.index(&:first) || 0
|
|
1478
|
+
start = (anchor + view[:offset]).clamp(0, [rows.length - page, 0].max)
|
|
1479
|
+
view[:offset] = [start - anchor, 0].max
|
|
1480
|
+
rows.slice(start, page).to_a.each_with_index do |(selected, text), index|
|
|
1481
|
+
paint = -> { put(5 + index, 3, fit(text, @width - 7).ljust(@width - 7), tone(:value)) }
|
|
1482
|
+
selected ? @screen.attron(Curses::A_REVERSE) { paint.call } : paint.call
|
|
1483
|
+
end
|
|
1484
|
+
bottom = @height - 5
|
|
1485
|
+
message = view[:notice] || "#{view[:selected].length} selected · draft preserved · no work until confirmed"
|
|
1486
|
+
swarm_put(bottom, message, :notice)
|
|
1487
|
+
if view[:confirm]
|
|
1488
|
+
args = view[:confirm]
|
|
1489
|
+
target = args.first == 'broadcast' ? args[2] : args[1]
|
|
1490
|
+
swarm_put(bottom + 1, "Confirm #{args.first} → #{target}", :warning)
|
|
1491
|
+
swarm_put(bottom + 2, 'Enter execute · Esc abort · draft unchanged', :footer)
|
|
1492
|
+
elsif view[:prompt]
|
|
1493
|
+
prompt = view[:prompt]
|
|
1494
|
+
swarm_put(bottom + 1, prompt[:label], :category)
|
|
1495
|
+
editor = prompt[:editor]
|
|
1496
|
+
chars = editor.text.scan(/\X/)
|
|
1497
|
+
first = [editor.cursor - ((@width - 7) / 2), 0].max
|
|
1498
|
+
text = fit(chars.drop(first).join, @width - 7)
|
|
1499
|
+
put(bottom + 2, 3, text, tone(:composer))
|
|
1500
|
+
cursor = [bottom + 2, [3 + width(chars[first...editor.cursor].join), @width - 3].min]
|
|
1501
|
+
else
|
|
1502
|
+
swarm_put(bottom + 1, "DRAFT: #{@editor.text}", :composer)
|
|
1503
|
+
swarm_put(bottom + 2, 'Esc/^G return to unchanged mission draft', :footer)
|
|
1504
|
+
end
|
|
1505
|
+
put(@height - 1, 1, '^D back · ^G close · actions require Enter', tone(:footer))
|
|
1506
|
+
@screen.setpos(*(cursor || [0, 2]))
|
|
1507
|
+
end
|
|
1508
|
+
|
|
1509
|
+
def swarm_safe(text)
|
|
1510
|
+
PWN::Redaction.redact(value: text.to_s).gsub(%r{\e\[[0-?]*[ -/]*[@-~]}, '').gsub(/[\x00-\x08\x0b-\x1f\x7f]/, '')
|
|
1511
|
+
end
|
|
1512
|
+
|
|
1513
|
+
def swarm_put(row, text, role)
|
|
1514
|
+
put(row, 2, fit(swarm_safe(text).gsub(/\s+/, ' '), @width - 5), tone(role))
|
|
1515
|
+
end
|
|
1516
|
+
|
|
1517
|
+
def draw_menu
|
|
1518
|
+
height = [@menu.length + 2, @height - 10].min
|
|
1519
|
+
top = @height - 7 - height
|
|
1520
|
+
menu_width = [@width - 5, 65].min
|
|
1521
|
+
height.times { |row| put(top + row, 2, ' ' * menu_width) }
|
|
1522
|
+
box(top, 2, height, menu_width, "COMMANDS · #{@menu_hint}")
|
|
1523
|
+
start = [@menu_index - height + 3, 0].max
|
|
1524
|
+
@menu.slice(start, height - 2).each_with_index do |item, index|
|
|
1525
|
+
selected = start + index == @menu_index
|
|
1526
|
+
prefix = selected ? '› ' : ' '
|
|
1527
|
+
label = item.is_a?(Hash) ? item[:label].to_s : item.to_s
|
|
1528
|
+
text = fit(prefix + label, menu_width - 4)
|
|
1529
|
+
text += ' ' * (menu_width - 4 - width(text))
|
|
1530
|
+
paint = -> { put(top + index + 1, 4, text, tone(:selection)) }
|
|
1531
|
+
selected ? @screen.attron(Curses::A_REVERSE) { paint.call } : paint.call
|
|
1532
|
+
end
|
|
1533
|
+
end
|
|
1534
|
+
end
|
|
1535
|
+
|
|
1536
|
+
# Resolve pwn-ai TUI colors from PWN::Env[:ai][:tui][:theme].
|
|
1537
|
+
public_class_method def self.theme(opts = {})
|
|
1538
|
+
configured = opts.key?(:theme) ? opts[:theme] : PWN::Env.dig(:ai, :tui, :theme)
|
|
1539
|
+
defaults = PWN::Config.env_template.dig(:ai, :tui, :theme)
|
|
1540
|
+
merged = defaults.is_a?(Hash) ? defaults.dup : {}
|
|
1541
|
+
return merged unless configured.is_a?(Hash)
|
|
1542
|
+
|
|
1543
|
+
configured.each do |key, value|
|
|
1544
|
+
role = key.to_s.to_sym
|
|
1545
|
+
name = value.to_s.downcase
|
|
1546
|
+
next unless merged.key?(role) && Console::PALETTE.include?(name)
|
|
1547
|
+
|
|
1548
|
+
merged[role] = name
|
|
1549
|
+
end
|
|
1550
|
+
merged
|
|
1551
|
+
end
|
|
1552
|
+
|
|
1553
|
+
public_class_method def self.authors
|
|
1554
|
+
'AUTHOR(S): 0day Inc. <support@0dayinc.com>'
|
|
1555
|
+
end
|
|
1556
|
+
|
|
1557
|
+
public_class_method def self.help
|
|
1558
|
+
puts "USAGE:
|
|
1559
|
+
# Ctrl+L clears only the session pane, including while busy.
|
|
1560
|
+
# Up/Down select in menus, otherwise recall ~/.pwn/pwn_history; Ctrl+R searches history.
|
|
1561
|
+
# Search: Enter accepts without sending; Esc restores the draft.
|
|
1562
|
+
# Launch the single-owner curses console; non-terminals return unavailable.
|
|
1563
|
+
#{self}.run(
|
|
1564
|
+
pry: 'required - active Pry instance with session settings',
|
|
1565
|
+
input: 'optional - terminal input, defaults to stdin',
|
|
1566
|
+
output: 'optional - terminal output, defaults to stdout',
|
|
1567
|
+
curses: 'optional - curses implementation for terminal testing',
|
|
1568
|
+
getch: 'optional - key callback for deterministic testing'
|
|
1569
|
+
)
|
|
1570
|
+
# Resolve named TUI colors from ~/.pwn/pwn.yaml ai.tui.theme.
|
|
1571
|
+
#{self}.theme(
|
|
1572
|
+
theme: 'optional - role to color-name map; unknown names keep the default'
|
|
1573
|
+
)
|
|
1574
|
+
# Return module authors.
|
|
1575
|
+
#{self}.authors
|
|
1576
|
+
"
|
|
1577
|
+
end
|
|
1578
|
+
end
|
|
1579
|
+
end
|
|
1580
|
+
end
|
|
1581
|
+
end
|