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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +2 -2
  3. data/documentation/Blockchain.md +107 -9
  4. data/documentation/pwn-ai-Agent.md +310 -4
  5. data/documentation/pwn_silent_help_learn_demo.gif +0 -0
  6. data/etc/default_skills/pwn/banner/SKILL.md +7 -3
  7. data/etc/default_skills/pwn/blockchain/btc/SKILL.md +8 -5
  8. data/etc/default_skills/pwn/blockchain/eth/SKILL.md +12 -3
  9. data/etc/default_skills/pwn/blockchain/eth/references/urls.md +1 -1
  10. data/etc/default_skills/pwn/plugins/repl/SKILL.md +5 -0
  11. data/etc/default_skills/pwn/plugins/repl/ai/SKILL.md +4 -0
  12. data/etc/default_skills/pwn/plugins/repl/ai/references/urls.md +5 -0
  13. data/etc/default_skills/pwn/plugins/repl/ai_console/SKILL.md +48 -0
  14. data/etc/default_skills/pwn/plugins/repl/ai_console_commands/SKILL.md +47 -0
  15. data/etc/default_skills/pwn/plugins/repl/ai_console_usage/SKILL.md +48 -0
  16. data/etc/default_skills/pwn/plugins/repl/ai_swarm/SKILL.md +46 -0
  17. data/lib/pwn/ai/agent/loop.rb +287 -18
  18. data/lib/pwn/ai/agent/swarm.rb +28 -4
  19. data/lib/pwn/ai/open_ai.rb +4 -2
  20. data/lib/pwn/banner.rb +743 -2
  21. data/lib/pwn/blockchain/btc.rb +494 -196
  22. data/lib/pwn/blockchain/eth.rb +440 -81
  23. data/lib/pwn/blockchain.rb +6 -0
  24. data/lib/pwn/config.rb +22 -0
  25. data/lib/pwn/migrate.rb +9 -1
  26. data/lib/pwn/plugins/repl/ai.rb +182 -17
  27. data/lib/pwn/plugins/repl/ai_console.rb +1581 -0
  28. data/lib/pwn/plugins/repl/ai_console_commands.rb +282 -0
  29. data/lib/pwn/plugins/repl/ai_console_usage.rb +144 -0
  30. data/lib/pwn/plugins/repl/ai_swarm.rb +375 -0
  31. data/lib/pwn/plugins/repl.rb +25 -3
  32. data/lib/pwn/version.rb +1 -1
  33. data/spec/lib/pwn/ai/agent/loop_spec.rb +258 -1
  34. data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +3 -2
  35. data/spec/lib/pwn/ai/open_ai_spec.rb +5 -0
  36. data/spec/lib/pwn/banner_spec.rb +643 -0
  37. data/spec/lib/pwn/blockchain/btc_spec.rb +372 -1
  38. data/spec/lib/pwn/blockchain/eth_spec.rb +205 -0
  39. data/spec/lib/pwn/blockchain_spec.rb +6 -0
  40. data/spec/lib/pwn/config_spec.rb +10 -0
  41. data/spec/lib/pwn/migrate_spec.rb +40 -3
  42. data/spec/lib/pwn/plugins/repl/ai_console_commands_spec.rb +65 -0
  43. data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +1434 -0
  44. data/spec/lib/pwn/plugins/repl/ai_console_usage_spec.rb +29 -0
  45. data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +127 -0
  46. data/spec/lib/pwn/plugins/repl_pwn_vault_spec.rb +5 -3
  47. data/spec/lib/pwn/plugins/repl_spec.rb +167 -1
  48. data/third_party/pwn_rdoc.jsonl +229 -10
  49. metadata +18 -6
  50. 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