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,1434 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'stringio'
5
+ require 'pry'
6
+
7
+ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- public launch, terminal lifecycle and deterministic worker integration
8
+ include_context 'pwn tmp sandbox'
9
+
10
+ before do
11
+ @history_path = File.join(@tmp, 'pwn_history')
12
+ @request_history = Pry::History.new(file_path: @history_path)
13
+ allow(Pry).to receive(:history).and_return(@request_history)
14
+ allow(PWN::AI::Agent::PromptBuilder).to receive(:build).and_return('offline console test')
15
+ allow(PWN::AI::Agent::TaskSummarizer).to receive(:enabled?).and_return(false)
16
+ allow(PWN::AI::Agent::Loop).to receive(:should_auto_introspect?).and_return(false)
17
+ allow(PWN::AI::Agent::Loop).to receive(:may_finalize?).and_return(true)
18
+ end
19
+
20
+ def launch(keys, &tick)
21
+ require 'pty'
22
+ require 'timeout'
23
+ master, input = PTY.open
24
+ @paint = []
25
+ owner = Thread.current
26
+ window = double('screen')
27
+ allow(window).to receive_messages(erase: nil, refresh: nil, setpos: nil)
28
+ frame = []
29
+ position = [0, 0]
30
+ allow(window).to receive(:erase) { frame = [] }
31
+ allow(window).to receive(:refresh) { @rendered = frame.dup }
32
+ @positions = []
33
+ allow(window).to receive(:setpos) do |*coords|
34
+ position = coords
35
+ @positions << coords
36
+ end
37
+ allow(window).to receive(:addstr) { |text|
38
+ expect(Thread.current).to eq(owner)
39
+ @paint << text
40
+ frame << [*position, text]
41
+ }
42
+ allow(window).to receive(:attron) { |_attribute, &block| block.call }
43
+ screen = double('curses', init_screen: window, raw: nil, noecho: nil, curs_set: nil, close_screen: nil,
44
+ lines: 30, cols: 110, stdscr: window, has_colors?: false, resizeterm: nil)
45
+ @pry = Pry.new
46
+ @pry.config.pwn_ai_session_id = PWN::Sessions.create(title: 'console')[:id]
47
+ @pry.config.pwn_ai = true
48
+ @screen = screen
49
+ output = double('terminal', tty?: true)
50
+ previous = [$stdin, $stdout, $stderr]
51
+ Timeout.timeout(25, Class.new(Exception)) do # rubocop:disable Lint/InheritException
52
+ PWN::Plugins::REPL::AIConsole.run(pry: @pry, input: input, output: output, curses: screen,
53
+ getch: -> { keys.empty? ? tick.call : keys.shift })
54
+ end
55
+ expect([$stdin, $stdout]).to eq(previous.take(2))
56
+ expect(PWN::Plugins::Log.raw_stderr).to equal(previous[2])
57
+ expect(input).to be_tty
58
+ expect(screen).to have_received(:close_screen)
59
+ ensure
60
+ master&.close
61
+ input&.close
62
+ end
63
+
64
+ it 'edits system role in an isolated multiline modal and preserves mission cursor on save and cancel' do
65
+ PWN::Env[:ai] = { active: 'openai', openai: { system_role_content: "first\nsecond" } }
66
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: Curses, getch: nil)
67
+ editor = console.instance_variable_get(:@editor)
68
+ editor.place('mission draft', 4)
69
+ console.submit('/system-role')
70
+ expect(console.instance_variable_get(:@role_editor).text).to eq("first\nsecond")
71
+ console.handle("\u0015")
72
+ "new\nrole".chars.each { |key| console.handle(key) }
73
+ ["\u0012", "\u0007", "\u000f", "\u000c"].each { |key| console.handle(key) }
74
+ expect(editor.text).to eq('mission draft')
75
+ expect(editor.cursor).to eq(4)
76
+ expect(PWN::Plugins::REPL).to receive(:pwn_ai_apply_system_role).with(engine: 'openai', content: "new\nrole").and_return(:saved)
77
+ console.handle("\u0013")
78
+ expect(console.instance_variable_get(:@role_editor)).to be_nil
79
+ console.submit('/system-role')
80
+ console.handle('x')
81
+ console.handle("\e")
82
+ expect(editor.text).to eq('mission draft')
83
+ expect(editor.cursor).to eq(4)
84
+ expect(PWN::Env.dig(:ai, :openai, :system_role_content)).to eq("first\nsecond")
85
+ end
86
+
87
+ it 'saves system role from curses to an encrypted vault and the next provider payload' do
88
+ previous_driver_opts = PWN::Env[:driver_opts]
89
+ path = File.join(@tmp, 'role.yaml')
90
+ key = Base64.strict_encode64('k' * 32)
91
+ iv = Base64.strict_encode64('i' * 16)
92
+ PWN::Env[:ai] = { active: 'openai', openai: { model: 'gpt-4o', system_role_content: 'original role' } }
93
+ File.write(path, YAML.dump(ai: PWN::Env[:ai]))
94
+ File.write("#{path}.decryptor", YAML.dump(key: key, iv: iv))
95
+ PWN::Plugins::Vault.encrypt(file: path, key: key, iv: iv)
96
+ ciphertext = File.binread(path)
97
+ PWN::Env[:driver_opts] = { pwn_env_path: path, pwn_dec_path: "#{path}.decryptor" }
98
+ allow(PWN::AI::Agent::PromptBuilder).to receive(:build).and_wrap_original do |method, opts|
99
+ method.call(opts.merge(thin: true))
100
+ end
101
+ expect(PWN::AI::OpenAI).to receive(:open_ai_rest_call) do |opts|
102
+ expect(opts[:http_body][:messages].find { |message| message[:role] == 'system' }[:content]).to start_with("Saved role\nsecond line")
103
+ { choices: [{ message: { role: 'assistant', content: 'Role forwarded.', tool_calls: [] } }] }.to_json
104
+ end
105
+ keys = "/system-role\n".chars
106
+ phase = :cancel
107
+ launch(keys) do
108
+ case phase
109
+ when :cancel
110
+ expect(@paint.join).to include('SYSTEM ROLE CONTENT', 'original role', 'Ctrl+S Save', 'Esc Cancel')
111
+ keys.concat("\u0015discarded\e".chars)
112
+ phase = :reopen
113
+ nil
114
+ when :reopen
115
+ expect(File.binread(path)).to eq(ciphertext)
116
+ expect(PWN::Env.dig(:ai, :openai, :system_role_content)).to eq('original role')
117
+ keys.concat("\n\u0015Saved role\nsecond line\u0013".chars)
118
+ phase = :request
119
+ nil
120
+ when :request
121
+ expect(rendered_header).to include('Saved role second line')
122
+ keys.concat("\u0015what color is a lemon?\n".chars)
123
+ phase = :done
124
+ nil
125
+ else
126
+ @paint.any? { |row| row.include?('Role forwarded.') } ? "\u0004" : Thread.pass && nil
127
+ end
128
+ end
129
+ expect(File.binread(path)).not_to include('Saved role')
130
+ PWN::Plugins::Vault.decrypt(file: path, key: key, iv: iv)
131
+ expect(YAML.load_file(path, symbolize_names: true).dig(:ai, :openai, :system_role_content)).to eq("Saved role\nsecond line")
132
+ ensure
133
+ PWN::Env[:driver_opts] = previous_driver_opts
134
+ end
135
+
136
+ it 'keeps failed system role edits open and blocks request and swarm context mutation' do
137
+ PWN::Env[:ai] = { active: 'openai', openai: { system_role_content: 'original' } }
138
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: Curses, getch: nil)
139
+ console.instance_variable_set(:@worker, double(alive?: true))
140
+ console.submit('/system-role')
141
+ expect(console.instance_variable_get(:@role_editor)).to be_nil
142
+ console.instance_variable_set(:@worker, nil)
143
+ console.instance_variable_set(:@swarm, double(busy?: true))
144
+ console.submit('/system-role')
145
+ expect(console.instance_variable_get(:@role_editor)).to be_nil
146
+ console.instance_variable_set(:@swarm, nil)
147
+ console.submit('/system-role')
148
+ allow(PWN::Plugins::REPL).to receive(:persist_ai_selection).and_return(false)
149
+ console.handle('x')
150
+ console.handle("\u0013")
151
+ expect(console.instance_variable_get(:@role_editor).text).to eq('originalx')
152
+ expect(console.instance_variable_get(:@role_error)).to include('Not saved')
153
+ expect(PWN::Env.dig(:ai, :openai, :system_role_content)).to eq('original')
154
+ end
155
+
156
+ it 'moves the role cursor between multiline grapheme columns without history recall' do
157
+ PWN::Env[:ai] = { active: 'openai', openai: { system_role_content: "αβ\nx\nlast" } }
158
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: Curses, getch: nil)
159
+ console.submit('/system-role')
160
+ role = console.instance_variable_get(:@role_editor)
161
+ console.handle(:up)
162
+ expect(role.cursor).to eq(4)
163
+ console.handle(:up)
164
+ expect(role.cursor).to eq(1)
165
+ console.handle(:down)
166
+ expect(role.cursor).to eq(4)
167
+ console.handle(:left)
168
+ console.handle(:delete)
169
+ console.handle('新')
170
+ expect(role.text).to eq("αβ\n新\nlast")
171
+ end
172
+
173
+ it 'runs the real Loop from the public launch, steering a blocked model without a second input reader' do
174
+ loop_module = PWN::AI::Agent::Loop
175
+ entered = Queue.new
176
+ calls = []
177
+ Thread.current[:pwn_console_probe] = :copied
178
+ allow(loop_module).to receive(:call_engine) do |opts|
179
+ expect(Thread.current[:pwn_console_probe]).to eq(:copied)
180
+ expect(Thread.current[:pwn_steering_input].reader).to be_nil
181
+ calls << opts[:messages].map(&:dup)
182
+ if calls.length == 1
183
+ entered << true
184
+ Queue.new.pop
185
+ end
186
+ { role: 'assistant', content: 'Yellow.', tool_calls: [] }
187
+ end
188
+ keys = "what color is a passion fruit?\n".chars
189
+ steered = false
190
+ launch(keys) do
191
+ if !steered && !entered.empty?
192
+ entered.pop
193
+ steered = true
194
+ keys.concat("/steer what color is a lemon?\n".chars)
195
+ elsif @paint.any? { |row| row.include?('Yellow.') }
196
+ "\u0004"
197
+ else
198
+ Thread.pass
199
+ nil
200
+ end
201
+ end
202
+ expect(calls.length).to eq(2)
203
+ expect(calls.last.select { |row| row[:role] == 'user' }.map { |row| row[:content] }).to include('what color is a lemon?')
204
+ expect(@paint.join).to include('pwn-ai', 'ASSISTANT', 'MISSION', 'steering')
205
+ ensure
206
+ Thread.current[:pwn_console_probe] = nil
207
+ end
208
+
209
+ it 'opens a draft-preserving swarm workspace and sends only after explicit confirmation' do
210
+ allow(PWN::AI::Agent::Swarm).to receive(:personas).and_return(scout: { role: 'Inspect evidence', engine: 'ollama', model: 'fixture' })
211
+ allow(PWN::AI::Agent::Swarm).to receive(:create).and_return(swarm_id: 'fixture')
212
+ allow(PWN::AI::Agent::Swarm).to receive(:ask).and_return(reply: 'Workspace result')
213
+ keys = "mission draft\u0007a".chars
214
+ stage = 0
215
+ launch(keys) do
216
+ case stage
217
+ when 0
218
+ expect(PWN::AI::Agent::Swarm).not_to have_received(:ask)
219
+ expect(@paint.join).to include('SWARM WORKSPACE', 'Inspect evidence', 'fixture', 'Confirm')
220
+ stage = 1
221
+ "\r"
222
+ when 1
223
+ if @paint.join.include?('Workspace result')
224
+ stage = 2
225
+ "\e"
226
+ end
227
+ when 2
228
+ expect(@paint.last(100).join).to include('mission draft')
229
+ "\u0004"
230
+ end
231
+ end
232
+ expect(PWN::AI::Agent::Swarm).to have_received(:ask).with(include(name: 'scout', request: 'mission draft')).once
233
+ end
234
+
235
+ it 'cancels a blocked model on Ctrl+C, returns idle, and does not kill another worker' do
236
+ entered = Queue.new
237
+ foreign = Thread.new { sleep }
238
+ allow(PWN::AI::Agent::Loop).to receive(:call_engine) {
239
+ entered << true
240
+ Queue.new.pop
241
+ }
242
+ keys = "what color is a lemon?\n".chars
243
+ cancelled = false
244
+ launch(keys) do
245
+ if !cancelled && !entered.empty?
246
+ cancelled = true
247
+ "\u0003"
248
+ elsif @paint.any? { |row| row.include?('Request cancelled at a safe boundary') }
249
+ "\u0004"
250
+ else
251
+ Thread.pass
252
+ nil
253
+ end
254
+ end
255
+ expect(foreign).to be_alive
256
+ ensure
257
+ foreign&.kill
258
+ foreign&.join
259
+ end
260
+
261
+ it 'forwards tool input, preserves completed evidence before steering, and locks settings while running' do
262
+ entered = Queue.new
263
+ calls = 0
264
+ observed = nil
265
+ allow(PWN::AI::Agent::Loop).to receive(:call_engine) do
266
+ calls += 1
267
+ if calls == 1
268
+ { role: 'assistant', tool_calls: %w[first stale].map { |id| { id: id, type: 'function', function: { name: 'shell', arguments: '{}' } } } }
269
+ else
270
+ { role: 'assistant', content: 'Yellow.', tool_calls: [] }
271
+ end
272
+ end
273
+ expect(PWN::AI::Agent::Dispatch).to receive(:call).once do
274
+ entered << true
275
+ observed = $stdin.gets
276
+ JSON.generate(success: true, result: { stdout: observed, exitstatus: 0 })
277
+ end
278
+ expect(PWN::Plugins::REPL).not_to receive(:pwn_ai_run_model)
279
+ keys = "what color is a passion fruit?\n".chars
280
+ sent = false
281
+ launch(keys) do
282
+ if !sent && !entered.empty?
283
+ sent = true
284
+ keys.concat("/model openai wrong\n/steer what color is a lemon?\n/input tool answer\n".chars)
285
+ nil
286
+ elsif @paint.any? { |row| row.include?('Yellow.') }
287
+ "\u0004"
288
+ else
289
+ Thread.pass
290
+ nil
291
+ end
292
+ end
293
+ expect(observed).to eq("tool answer\n")
294
+ expect(@paint.join).to include('TOOL', 'RESULT', 'tool answer', 'Settings and new requests wait')
295
+ expect(@paint.join).to include('tools 1')
296
+ end
297
+
298
+ it 'closes the tool prompt pipe on busy exit and joins rather than orphaning the active request' do
299
+ entered = Queue.new
300
+ finished = Queue.new
301
+ allow(PWN::AI::Agent::Loop).to receive(:call_engine).and_return(
302
+ { role: 'assistant', tool_calls: [{ id: 'prompt', type: 'function', function: { name: 'shell', arguments: '{}' } }] }
303
+ )
304
+ allow(PWN::AI::Agent::Dispatch).to receive(:call) do
305
+ entered << true
306
+ answer = $stdin.gets
307
+ finished << answer
308
+ JSON.generate(success: true, result: { stdout: 'prompt completed', exitstatus: 0 })
309
+ end
310
+ keys = "what color is a lemon?\n".chars
311
+ launch(keys) { entered.empty? ? nil : "\u0004" }
312
+ expect(finished.pop).to be_nil
313
+ end
314
+
315
+ it 'wraps menu selection without changing the draft or persistent history, then recalls after Escape' do
316
+ stored = "first request\nsecond request\n"
317
+ File.write(@history_path, stored)
318
+ @request_history.load
319
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
320
+ editor = console.instance_variable_get(:@editor)
321
+ console.seed_request_history
322
+ '/verbose '.each_char { |key| console.handle(key) }
323
+ menu = console.instance_variable_get(:@menu)
324
+ expect(menu.map { |item| item[:label] }).to eq(%w[on off])
325
+ draft = [editor.text.dup, editor.cursor]
326
+ console.handle(:up)
327
+ expect(console.instance_variable_get(:@menu_index)).to eq(1)
328
+ console.handle(:down)
329
+ expect(console.instance_variable_get(:@menu_index)).to eq(0)
330
+ console.handle("\u0010")
331
+ expect(console.instance_variable_get(:@menu_index)).to eq(1)
332
+ console.handle("\u000e")
333
+ expect(console.instance_variable_get(:@menu_index)).to eq(0)
334
+ expect(console.instance_variable_get(:@menu)).to eq(menu)
335
+ expect([editor.text, editor.cursor]).to eq(draft)
336
+ expect(File.read(@history_path)).to eq(stored)
337
+ console.handle("\e")
338
+ expect(console.instance_variable_get(:@menu)).to be_nil
339
+ console.handle(:up)
340
+ expect(editor.text).to eq('second request')
341
+ console.handle(:up)
342
+ expect(editor.text).to eq('first request')
343
+ console.handle(:down)
344
+ expect(editor.text).to eq('second request')
345
+ console.handle(:down)
346
+ expect([editor.text, editor.cursor]).to eq(draft)
347
+ expect(File.read(@history_path)).to eq(stored)
348
+ end
349
+
350
+ it 'keeps typing filters live after arrow selection and accepts the selected completion with Tab' do
351
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
352
+ editor = console.instance_variable_get(:@editor)
353
+ '/verbose '.each_char { |key| console.handle(key) }
354
+ console.handle(:down)
355
+ console.handle("\t")
356
+ expect(editor.text.strip).to eq('/verbose off')
357
+ console.handle("\u0015")
358
+ '/verbose '.each_char { |key| console.handle(key) }
359
+ console.handle(:up)
360
+ 'on'.each_char { |key| console.handle(key) }
361
+ expect(console.instance_variable_get(:@menu).map { |item| item[:label] }).to eq(['on'])
362
+ expect(console.instance_variable_get(:@menu_index)).to eq(0)
363
+ console.handle("\t")
364
+ expect(editor.text.strip).to eq('/verbose on')
365
+ end
366
+
367
+ it 'loads prior operator requests and keeps session event colors distinct' do
368
+ pry = Pry.new
369
+ pry.config.pwn_ai_session_id = 'color-history'
370
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: pry, input: StringIO.new, curses: nil, getch: nil)
371
+ @request_history << 'prior mission'
372
+ expect(PWN::Sessions).not_to receive(:load)
373
+ console.send(:seed_request_history)
374
+ console.handle(:up)
375
+ expect(console.instance_variable_get(:@editor).text).to eq('prior mission')
376
+ console.add(:operator, 'look here')
377
+ console.add(:assistant, 'answer')
378
+ console.add(:task, 'plan')
379
+ console.add(:tool, 'shell')
380
+ console.add(:result, 'evidence')
381
+ colors = console.timeline_rows(40).each_with_object({}) do |(color, text), found|
382
+ found[:operator_label] = color if text.start_with?('OPERATOR')
383
+ found[:operator_body] = color if text.include?('look here')
384
+ found[:assistant] = color if text.include?('answer')
385
+ found[:task] = color if text.include?('plan')
386
+ found[:tool] = color if text.include?('shell')
387
+ found[:result] = color if text.include?('evidence')
388
+ end
389
+ expect(colors).to eq(operator_label: 4, operator_body: 5, assistant: 5, task: 2, tool: 1, result: 3)
390
+ end
391
+
392
+ it 'uses template colors for every unconfigured role without mutating defaults or overrides' do
393
+ defaults = PWN::Config.env_template.dig(:ai, :tui, :theme)
394
+ [nil, {}, 'invalid'].each do |configured|
395
+ expect(PWN::Plugins::REPL::AIConsole.theme(theme: configured)).to eq(defaults)
396
+ end
397
+ overrides = defaults.transform_values { 'blue' }.freeze
398
+ expect(PWN::Plugins::REPL::AIConsole.theme(theme: overrides)).to eq(overrides)
399
+ expect(overrides.values.uniq).to eq(['blue'])
400
+ PWN::Env[:ai] = {}
401
+ expect(PWN::Plugins::REPL::AIConsole.theme).to eq(defaults)
402
+ expect(PWN::Config.env_template.dig(:ai, :tui, :theme)).to eq(defaults)
403
+ end
404
+
405
+ it 'applies ai.tui.theme and ignores unknown color names' do
406
+ previous = PWN::Env[:ai]
407
+ PWN::Env[:ai] = { tui: { theme: { assistant: 'blue', border: 'nope' } } }
408
+ theme = PWN::Plugins::REPL::AIConsole.theme
409
+ expect(theme[:assistant]).to eq('blue')
410
+ expect(theme[:border]).to eq('black')
411
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
412
+ console.add(:assistant, 'themed answer')
413
+ color = console.timeline_rows(40).find { |_pair, text| text.include?('themed answer') }&.first
414
+ expect(color).to eq(6)
415
+ console.instance_variable_set(:@height, 20)
416
+ console.instance_variable_set(:@width, 80)
417
+ painted = []
418
+ allow(console).to receive(:put) { |row, column, text, pair| painted << [row, column, text, pair] }
419
+ console.box(0, 0, 4, 24, 'MISSION')
420
+ expect(painted.first[3]).to eq(console.tone(:border))
421
+ title = painted.find { |row, _column, text, _pair| row.zero? && text.include?('MISSION') }
422
+ expect(title[3]).to eq(console.tone(:title))
423
+ expect(title[3]).not_to eq(painted.first[3])
424
+ ensure
425
+ PWN::Env[:ai] = previous
426
+ end
427
+
428
+ it 'selects one mini-banner per session and advances only from monotonic elapsed time' do
429
+ pry = Pry.new
430
+ pry.config.pwn_ai_session_id = 'banner-one'
431
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: pry, input: StringIO.new, curses: nil, getch: nil)
432
+ choices = PWN::Banner.mini_names.first(2)
433
+ allow(PWN::Banner).to receive(:mini_names).and_return(choices)
434
+ allow(choices).to receive(:sample).and_return(*choices)
435
+ now = 100.0
436
+ allow(Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC) { now }
437
+ first = console.banner_frame(8)
438
+ expect(first).to eq(PWN::Banner.mini_frame(name: choices.first, frame: 0, width: 8, height: 8))
439
+ now += PWN::Banner::MINI_FRAME_SECONDS / 2
440
+ expect(console.banner_frame(8)).to eq(first)
441
+ now += PWN::Banner::MINI_FRAME_SECONDS * 0.75
442
+ expect(console.banner_frame(8)).to eq(PWN::Banner.mini_frame(name: choices.first, frame: 1, width: 8, height: 8))
443
+ now += PWN::Banner::MINI_FRAME_SECONDS * PWN::Banner::MINI_FRAME_COUNT
444
+ expect(console.banner_frame(12)).to eq(PWN::Banner.mini_frame(name: choices.first, frame: 1, width: 12, height: 12))
445
+ expect(choices).to have_received(:sample).once
446
+ pry.config.pwn_ai_session_id = 'banner-two'
447
+ expect(console.banner_frame(8)).to eq(PWN::Banner.mini_frame(name: choices.last, frame: 0, width: 8, height: 8))
448
+ expect(choices).to have_received(:sample).twice
449
+ end
450
+
451
+ it 'centers capped retro artwork inside a larger cell-square canvas' do
452
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
453
+ allow(PWN::Banner).to receive(:mini_names).and_return([:pong])
454
+ allow(Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC).and_return(100.0)
455
+ size = 24
456
+ canvas = console.banner_frame(size)
457
+ art = PWN::Banner.mini_frame(name: :pong, frame: 0, width: size, height: size)
458
+ expect(canvas.length).to eq(size)
459
+ expect(canvas.map(&:length)).to all(eq(size))
460
+ top = (size - art.length) / 2
461
+ left = (size - art.first.length) / 2
462
+ expect(canvas.slice(top, art.length).map { |row| row[left, art.first.length] }).to eq(art)
463
+ expect(canvas.first(top)).to all(eq(' ' * size))
464
+ end
465
+
466
+ it 'paints falling blocks against the actual pane floor even beyond sixteen cells' do
467
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
468
+ allow(PWN::Banner).to receive(:mini_names).and_return([:falling_blocks])
469
+ now = 100.0
470
+ allow(Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC) { now }
471
+ console.banner_frame(20, cells: true)
472
+ seed = console.instance_variable_get(:@banner_seed)
473
+ frames = PWN::Banner.send(:mini_replay, name: :falling_blocks, width: 20, height: 20, seed: seed)
474
+ tick = frames.index { |frame| frame[:event] == :lock }
475
+ now += (tick + 0.1) * PWN::Banner::MINI_FRAME_SECONDS
476
+ calls = []
477
+ allow(console).to receive(:put) { |*args| calls << args }
478
+ console.draw_banner(20)
479
+ expect(calls.select { |y, _x, text| y == 20 && text.strip != '' }).not_to be_empty
480
+ expect(calls).to include([1, 9, 'P', nil], [1, 10, 'W', nil], [1, 11, 'N', nil])
481
+ expect(calls).to all(satisfy { |y, x, text| y.between?(1, 20) && x >= 1 && x + text.length <= 21 })
482
+ end
483
+
484
+ it 'carves a themed cell-square banner pane only when all settings still fit, keeping bounds and draft' do
485
+ PWN::Env[:ai] = { active: :ollama, tui: { theme: { category: 'magenta', header: 'white', border: 'blue', title: 'red' } },
486
+ ollama: { model: 'fixture', system_role_content: 'Complete functional settings.', reasoning_effort: 'high' } }
487
+ screen = double(erase: nil, refresh: nil, setpos: nil, addstr: nil)
488
+ curses = double(lines: 36, cols: 120)
489
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: curses, getch: nil)
490
+ console.instance_variable_set(:@screen, screen)
491
+ console.instance_variable_get(:@editor).place('retained draft', 4)
492
+ painted = []
493
+ rectangles = []
494
+ allow(console).to receive(:put) { |*args| painted << args }
495
+ allow(console).to receive(:box).and_wrap_original do |method, *args|
496
+ rectangles << args
497
+ method.call(*args)
498
+ end
499
+ allow(PWN::Banner).to receive(:mini_cells).and_call_original
500
+ console.draw
501
+ expect(rectangles).to include([0, 0, 10, 10, 'PWN'])
502
+ expect(rectangles).to include([0, 10, 10, 109, "pwn-ai v#{PWN::VERSION}"])
503
+ expect(console.instance_variable_get(:@header_text_column)).to eq(12)
504
+ expect(painted).to include([2, 12, 'PROVIDER (ENGINE):', console.tone(:category)])
505
+ expect(painted.any? { |y, x, text, color| y == 0 && x == 0 && text.include?('╭') && color == console.tone(:border) }).to be(true)
506
+ expect(painted.any? { |y, x, text, color| y == 0 && x == 2 && text.include?('PWN') && color == console.tone(:title) }).to be(true)
507
+ expect(painted.select { |y, x, text, _color| y.between?(1, 8) && x == 1 && text == ' ' * 8 }.length).to eq(8)
508
+ expect(painted.select { |y, _x, _text, _color| y < 10 }).to all(satisfy { |y, x, text, _color| y >= 0 && x >= 0 && x + console.width(text) <= 120 })
509
+ expect(PWN::Banner).to have_received(:mini_cells).with(hash_including(width: 8, height: 8)).once
510
+ [[80, 36], [120, 24]].each do |columns, rows|
511
+ allow(curses).to receive_messages(cols: columns, lines: rows)
512
+ rectangles.clear
513
+ console.draw
514
+ expect(rectangles.map(&:last)).not_to include('PWN')
515
+ expect(console.instance_variable_get(:@header_text_column)).to eq(2)
516
+ end
517
+ allow(curses).to receive_messages(cols: 120, lines: 26)
518
+ PWN::Env[:ai][:ollama][:system_role_content] = 'operator ' * 110
519
+ console.draw
520
+ expect(console.instance_variable_get(:@header_text_column)).to eq(2)
521
+ expect(console.header_lines('ollama', 'fixture', 115).length).to be <= 13
522
+ expect(PWN::Banner).to have_received(:mini_cells).once
523
+ expect([console.instance_variable_get(:@editor).text, console.instance_variable_get(:@editor).cursor]).to eq(['retained draft', 4])
524
+ end
525
+
526
+ it 'grows both game canvas dimensions with the header without recursively shrinking settings' do
527
+ PWN::Env[:ai] = { active: :ollama, ollama: { model: 'fixture', system_role_content: 'operator ' * 100, reasoning_effort: 'high' } }
528
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
529
+ console.instance_variable_set(:@width, 120)
530
+ console.instance_variable_set(:@height, 40)
531
+ allow(console).to receive(:put)
532
+ allow(console).to receive(:box)
533
+ allow(PWN::Banner).to receive(:mini_cells).and_call_original
534
+ height = console.draw_header('ollama', 'fixture')
535
+ expect(height).to be > 10
536
+ expect(console).to have_received(:box).with(0, 0, height, height, 'PWN')
537
+ expect(PWN::Banner).to have_received(:mini_cells).with(hash_including(width: height - 2, height: height - 2))
538
+ expect(console.instance_variable_get(:@header_spans).length + 3).to be <= height
539
+ expect(console.header_lines('ollama', 'fixture', console.instance_variable_get(:@header_text_width)).join).to include('REASONING EFFORT: high')
540
+ end
541
+
542
+ it 'paints centered colored block cells inside the square without reusing theme pairs' do
543
+ curses = double(has_colors?: true, start_color: nil, use_default_colors: nil, init_pair: nil, color_pairs: 256)
544
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: curses, getch: nil)
545
+ allow(ENV).to receive(:key?).with('NO_COLOR').and_return(false)
546
+ console.setup_colors
547
+ expect(curses).to have_received(:init_pair).with(9, Curses::COLOR_CYAN, Curses::COLOR_BLACK)
548
+ expect(curses).to have_received(:init_pair).with(16, Curses::COLOR_BLACK, Curses::COLOR_BLACK)
549
+ allow(console).to receive(:put)
550
+ allow(console).to receive(:banner_frame).with(8, cells: true).and_return([[{ glyph: '█', foreground: :cyan, background: :black }]])
551
+ console.draw_banner(8)
552
+ expect(console).to have_received(:put).with(4, 4, '█', 9)
553
+ expect(console).to have_received(:put).with(8, 1, ' ' * 8, 16)
554
+ oversized = Array.new(10) { Array.new(10) { { glyph: '▀', foreground: :yellow, background: :black } } }
555
+ allow(console).to receive(:banner_frame).with(8, cells: true).and_return(oversized)
556
+ calls = []
557
+ allow(console).to receive(:put) { |*args| calls << args }
558
+ console.draw_banner(8)
559
+ expect(calls).to all(satisfy { |y, x, text, _pair| y.between?(1, 8) && x >= 1 && x + text.length <= 9 })
560
+ end
561
+
562
+ it 'paints both colors of touching blocks and preserves solid occupancy without color' do
563
+ [256, 32, 16].each do |count|
564
+ curses = double(has_colors?: true, start_color: nil, use_default_colors: nil, init_pair: nil, color_pairs: count)
565
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: curses, getch: nil)
566
+ allow(ENV).to receive(:key?).with('NO_COLOR').and_return(false)
567
+ console.setup_colors
568
+ allow(console).to receive(:put)
569
+ allow(console).to receive(:banner_frame).with(8, cells: true).and_return([[{ glyph: '▀', foreground: :cyan, background: :red }]])
570
+ console.draw_banner(8)
571
+ if count == 256
572
+ palette = PWN::Plugins::REPL::AIConsole::Console::PALETTE
573
+ pair = 17 + (palette.reject { |color| color == 'black' }.index('red') * 8)
574
+ expect(curses).to have_received(:init_pair).with(pair, Curses::COLOR_CYAN, Curses::COLOR_RED)
575
+ expect(console).to have_received(:put).with(4, 4, '▀', pair)
576
+ else
577
+ expect(console).to have_received(:put).with(4, 4, '█', nil)
578
+ end
579
+ end
580
+ end
581
+
582
+ it 'retains block glyphs without allocating color pairs under NO_COLOR or on limited palettes' do
583
+ [true, false].each do |no_color|
584
+ curses = double(has_colors?: true, start_color: nil, use_default_colors: nil, init_pair: nil, color_pairs: 16)
585
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: curses, getch: nil)
586
+ allow(ENV).to receive(:key?).with('NO_COLOR').and_return(no_color)
587
+ console.setup_colors
588
+ expect(curses).not_to have_received(:init_pair).with(9, anything, anything)
589
+ allow(console).to receive(:put)
590
+ allow(console).to receive(:banner_frame).with(8, cells: true).and_return([[{ glyph: '█', foreground: :green, background: :black }]])
591
+ console.draw_banner(8)
592
+ expect(console).to have_received(:put).with(4, 4, '█', nil)
593
+ end
594
+ end
595
+
596
+ it 'uses the same monotonic cadence and session selection for colored frames' do
597
+ pry = Pry.new
598
+ pry.config.pwn_ai_session_id = 'colored-session'
599
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: pry, input: StringIO.new, curses: nil, getch: nil)
600
+ names = [:snake]
601
+ allow(PWN::Banner).to receive(:mini_names).and_return(names)
602
+ expect(names).to receive(:sample).twice.and_return(:snake)
603
+ now = 100.0
604
+ allow(Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC) { now }
605
+ console.banner_frame(8, cells: true)
606
+ seed = console.instance_variable_get(:@banner_seed)
607
+ expect(seed).to be_a(Integer)
608
+ expect(console.banner_frame(8, cells: true)).to eq(PWN::Banner.mini_cells(name: :snake, frame: 0, width: 8, height: 8, seed: seed))
609
+ now += 0.21
610
+ expect(console.banner_frame(8, cells: true)).to eq(PWN::Banner.mini_cells(name: :snake, frame: 2, width: 8, height: 8, seed: seed))
611
+ expect(console.banner_frame(12, cells: true)).to eq(PWN::Banner.mini_cells(name: :snake, frame: 2, width: 12, height: 12, seed: seed))
612
+ expect(console.instance_variable_get(:@banner_seed)).to eq(seed)
613
+ pry.config.pwn_ai_session_id = 'another-colored-session'
614
+ console.banner_frame(8, cells: true)
615
+ expect(console.instance_variable_get(:@banner_seed)).not_to eq(seed)
616
+ end
617
+
618
+ it 'samples Asteroids or Pong once and caches their faster cadence outside the key path' do
619
+ allow(PWN::Banner).to receive(:mini_cells).and_call_original
620
+ %i[pong asteroids].each do |name|
621
+ pry = Pry.new
622
+ pry.config.pwn_ai_session_id = 'fast-colored-session'
623
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: pry, input: StringIO.new, curses: nil, getch: nil)
624
+ names = PWN::Banner.mini_names
625
+ allow(PWN::Banner).to receive(:mini_names).and_return(names)
626
+ expect(names).to receive(:sample).once.and_return(name)
627
+ expect(PWN::Banner).to receive(:mini_frame_seconds).with(name: name).once.and_call_original
628
+ now = 100.0
629
+ allow(Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC) { now }
630
+ console.banner_frame(8, cells: true)
631
+ seed = console.instance_variable_get(:@banner_seed)
632
+ now += 1.21
633
+ expect(PWN::Banner).to receive(:mini_cells).with(name: name, frame: 24, width: 8, height: 8, seed: seed).twice.and_call_original
634
+ 2.times { console.banner_frame(8, cells: true) }
635
+ end
636
+ end
637
+
638
+ it 'wraps every header line and labels the system role' do
639
+ previous = PWN::Env[:ai]
640
+ PWN::Env[:ai] = { active: :ollama, ollama: { system_role_content: 'ethical operator ' * 12, temp: 0.2 } }
641
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
642
+ console.instance_variable_set(:@width, 36)
643
+ lines = console.header_lines('ollama', 'qwen')
644
+ expect(lines.length).to be > 2
645
+ joined = lines.join(' ').gsub(/\s+/, ' ')
646
+ expect(joined).to include('SYSTEM ROLE CONTENT: ethical operator')
647
+ expect(joined).to include('PROVIDER (ENGINE): ollama MODEL: qwen')
648
+ expect(joined).not_to match(/\brole ethical/)
649
+ ensure
650
+ PWN::Env[:ai] = previous
651
+ end
652
+
653
+ it 'refreshes text and banner geometry when a model string is changed in place' do
654
+ PWN::Env[:ai] = { active: +'ollama', ollama: { model: +'before' } }
655
+ screen = double(erase: nil, refresh: nil, setpos: nil, addstr: nil)
656
+ curses = double(lines: 50, cols: 120)
657
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: curses, getch: nil)
658
+ console.instance_variable_set(:@screen, screen)
659
+ console.draw
660
+ old_height = console.instance_variable_get(:@header_pane_height)
661
+ PWN::Env[:ai][:ollama][:model].replace('new-model-' * 90)
662
+ console.draw
663
+ expect(console.instance_variable_get(:@header_lines).join).to include('new-model-')
664
+ expect(console.instance_variable_get(:@header_pane_height)).to be > old_height
665
+ end
666
+
667
+ it 'reuses wrapped settings until the width or values change' do
668
+ PWN::Env[:ai] = { active: :ollama, ollama: { system_role_content: 'long role ' * 500 } }
669
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
670
+ console.instance_variable_set(:@width, 80)
671
+ lines = console.header_lines('ollama', 'fixture')
672
+ expect(console.header_lines('ollama', 'fixture')).to equal(lines)
673
+ expect(console.header_lines('ollama', 'fixture', 40)).not_to equal(lines)
674
+ PWN::Env[:ai][:ollama][:temp] = 0.3
675
+ expect(console.header_lines('ollama', 'fixture').join).to include('TEMP: 0.3')
676
+ end
677
+
678
+ it 'draws the complete settings by growing the header and preserves label colors across wraps and resize' do
679
+ PWN::Env[:ai] = { active: :ollama, tui: { theme: { category: 'magenta', header: 'white' } },
680
+ ollama: { system_role_content: 'operator ' * 35, temp: 0.2, max_tokens: 1024,
681
+ max_prompt_length: 2048, reasoning_effort: 'high' } }
682
+ screen = double(erase: nil, refresh: nil, setpos: nil, addstr: nil)
683
+ curses = double(lines: 48, cols: 80)
684
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: curses, getch: nil)
685
+ console.instance_variable_set(:@screen, screen)
686
+ painted = []
687
+ allow(console).to receive(:put).and_wrap_original do |method, *args|
688
+ painted << args
689
+ method.call(*args)
690
+ end
691
+ [[80, 48], [48, 60], [160, 48]].each do |columns, rows|
692
+ allow(curses).to receive_messages(lines: rows, cols: columns)
693
+ painted.clear
694
+ console.draw
695
+ lines = console.header_lines('ollama', PWN::Plugins::REPL.pwn_ai_engine_model(engine: 'ollama'), console.instance_variable_get(:@header_text_width))
696
+ text_column = console.instance_variable_get(:@header_text_column)
697
+ header = painted.select { |y, x, _text, _color| y >= 2 && y < lines.length + 2 && x >= text_column && x < columns - 2 }
698
+ actual = header.group_by(&:first).values.map { |row| row.map { |entry| entry[2] }.join }
699
+ expect(actual).to eq(lines)
700
+ expect(header.select { |entry| entry[3] == console.tone(:category) }.map { |entry| entry[2] }.join).to include('SYSTEM ROLE CONTENT:', 'MAX PROMPT LENGTH:', 'REASONING EFFORT:')
701
+ expect(header.select { |entry| entry[3] == console.tone(:header) }.map { |entry| entry[2] }.join).to include('operator', '1024', 'high')
702
+ if columns == 48
703
+ labels = header.select { |entry| entry[3] == console.tone(:category) }.map { |entry| entry[2] }
704
+ expect(labels.join).to include('MAX PROMPT LENGTH:')
705
+ expect(labels).not_to include(a_string_including('MAX PROMPT LENGTH:'))
706
+ end
707
+ expect(console.instance_variable_get(:@page_size)).to be >= 1
708
+ end
709
+ console.handle("\u000f")
710
+ console.draw
711
+ console.handle(:end)
712
+ painted.clear
713
+ console.draw
714
+ expect(painted.select { |entry| entry[3] == console.tone(:category) }.map { |entry| entry[2] }.join).to include('REQUEST', 'TOKENS', 'SYSTEM ROLE CONTENT:', 'REASONING EFFORT:')
715
+ expect(painted.select { |entry| entry[3] == console.tone(:header) }.map { |entry| entry[2] }.join).to include('1024', 'high')
716
+ end
717
+
718
+ it 'bounds long settings at every usable size and exposes the full text without sacrificing the draft' do
719
+ PWN::Env[:ai] = { active: :ollama, ollama: { system_role_content: "#{'operator ' * 500}ROLE END", temp: 0.2 } }
720
+ screen = double(erase: nil, refresh: nil, setpos: nil, addstr: nil)
721
+ curses = double(lines: 24, cols: 80)
722
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: curses, getch: nil)
723
+ console.instance_variable_set(:@screen, screen)
724
+ editor = console.instance_variable_get(:@editor)
725
+ editor.replace('retained draft')
726
+ rectangles = []
727
+ allow(console).to receive(:box).and_wrap_original do |method, *args|
728
+ rectangles << args
729
+ method.call(*args)
730
+ end
731
+ [[160, 48], [80, 24], [48, 14]].each do |columns, rows|
732
+ allow(curses).to receive_messages(lines: rows, cols: columns)
733
+ rectangles.clear
734
+ console.draw
735
+ expect(console.instance_variable_get(:@page_size)).to be >= 1
736
+ expect(rectangles).to all(satisfy { |top, _left, height| height >= 3 && top + height <= rows - 1 })
737
+ end
738
+ console.add(:assistant, 'latest evidence')
739
+ expect(console.timeline_rows(40).last[1]).to include('latest evidence')
740
+ console.handle("\u000f")
741
+ console.draw
742
+ expect(screen).to have_received(:addstr).with(a_string_including('STATUS'))
743
+ console.handle(:end)
744
+ console.draw
745
+ expect(screen).to have_received(:addstr).with(a_string_including('ROLE END')).at_least(:once)
746
+ console.handle('x')
747
+ console.handle("\r")
748
+ console.handle("\e")
749
+ expect(editor.text).to eq('retained draft')
750
+ end
751
+
752
+ it 'persists requests through Pry history and searches the same file in a fresh console' do
753
+ File.write(@history_path, "existing request\n/input legacy hidden\n")
754
+ @request_history.load
755
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
756
+ console.seed_request_history
757
+ allow(console).to receive(:start_request)
758
+ console.submit('durable alpha request')
759
+ expect(File.read(@history_path)).to include("durable alpha request\n")
760
+ console.submit('durable alpha request')
761
+ Pry.history << 'durable alpha request'
762
+ console.submit('/input excluded secret')
763
+ console.submit(' /input excluded secret')
764
+ expected = "existing request\n/input legacy hidden\ndurable alpha request\n"
765
+ expect(File.read(@history_path)).to eq(expected)
766
+ reloaded = Pry::History.new(file_path: @history_path)
767
+ reloaded.load
768
+ allow(Pry).to receive(:history).and_return(reloaded)
769
+ fresh = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
770
+ fresh.seed_request_history
771
+ fresh.instance_variable_get(:@editor).place('unsent draft', 2)
772
+ fresh.handle(:up)
773
+ expect(fresh.instance_variable_get(:@editor).text).to eq('durable alpha request')
774
+ fresh.handle(:up)
775
+ expect(fresh.instance_variable_get(:@editor).text).to eq('existing request')
776
+ fresh.handle(:down)
777
+ fresh.handle(:down)
778
+ expect([fresh.instance_variable_get(:@editor).text, fresh.instance_variable_get(:@editor).cursor]).to eq(['unsent draft', 2])
779
+ fresh.handle("\u0012")
780
+ 'alpha'.each_char { |char| fresh.handle(char) }
781
+ fresh.handle("\r")
782
+ expect(fresh.instance_variable_get(:@editor).text).to eq('durable alpha request')
783
+ fresh.handle("\u000c")
784
+ expect(File.read(@history_path)).to eq(expected)
785
+ fresh.handle("\u0012")
786
+ 'hidden'.each_char { |char| fresh.handle(char) }
787
+ expect(fresh.instance_variable_get(:@editor).search[:match]).to be_nil
788
+ end
789
+
790
+ it 'selects broadcast and debate targets explicitly, escapes missions, and restores the exact draft cursor' do
791
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
792
+ swarm = PWN::Plugins::REPL::AISwarm::Controller.new(session_id: 'fixture')
793
+ console.instance_variable_set(:@swarm, swarm)
794
+ allow(PWN::AI::Agent::Swarm).to receive(:personas).and_return(scout: { role: 'Inspect' }, critic: { role: 'Critique' })
795
+ allow(swarm).to receive(:execute).and_return(ok: true, job_id: 'fixture')
796
+ editor = console.instance_variable_get(:@editor)
797
+ editor.place("--names\nquoted 'mission'", 4)
798
+ console.handle("\u0007")
799
+ console.handle('d')
800
+ expect(console.instance_variable_get(:@workspace)[:notice]).to include('at least 2')
801
+ [' ', 'j', ' ', 'b'].each { |key| console.handle(key) }
802
+ expect(swarm).not_to have_received(:execute)
803
+ expect(console.swarm_content.join).to include("--names\nquoted 'mission'")
804
+ console.handle("\r")
805
+ expect(swarm).to have_received(:execute).with(include(line: Shellwords.join(['/swarm', 'broadcast', '--names', 'scout,critic', '--', editor.text])))
806
+ console.handle("\t")
807
+ console.handle('d')
808
+ console.handle("\r")
809
+ expect(swarm).to have_received(:execute).with(include(line: Shellwords.join(['/swarm', 'debate', 'scout,critic', '--', editor.text])))
810
+ console.handle("\u0007")
811
+ expect([editor.text, editor.cursor]).to eq(["--names\nquoted 'mission'", 4])
812
+ ensure
813
+ swarm&.close
814
+ end
815
+
816
+ it 'handles empty rosters, new persona prompts, errors and no-job navigation without submitting a mission' do
817
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
818
+ swarm = PWN::Plugins::REPL::AISwarm::Controller.new(session_id: 'fixture')
819
+ console.instance_variable_set(:@swarm, swarm)
820
+ allow(PWN::AI::Agent::Swarm).to receive(:personas).and_return({})
821
+ allow(swarm).to receive(:execute).and_return(ok: false, error: 'fixture spawn failed')
822
+ console.handle("\u0007")
823
+ expect(console.swarm_content.join).to include('No agents')
824
+ "nscout\rInspect evidence\r".each_char { |key| console.handle(key) }
825
+ expect(swarm).not_to have_received(:execute)
826
+ console.handle("\r")
827
+ expect(console.instance_variable_get(:@workspace)[:notice]).to eq('fixture spawn failed')
828
+ console.handle("\t")
829
+ expect(console.swarm_content.join).to include('No jobs yet')
830
+ ['s', 'c', "\r", "\e", "\e"].each { |key| console.handle(key) }
831
+ expect(console.instance_variable_get(:@workspace)).to be_nil
832
+ expect(swarm).to have_received(:execute).once
833
+ ensure
834
+ swarm&.close
835
+ end
836
+
837
+ it 'clears only the displayed session on Ctrl+L even while busy' do
838
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
839
+ editor = console.instance_variable_get(:@editor)
840
+ editor.preload(['retained request'])
841
+ editor.replace('draft')
842
+ console.add(:assistant, 'visible evidence')
843
+ allow(console).to receive(:busy?).and_return(true)
844
+ allow(console).to receive(:clear_view).and_call_original
845
+ console.instance_variable_get(:@usage).record(usage: { input_tokens: 123, output_tokens: 45 })
846
+ usage = console.instance_variable_get(:@usage).snapshot
847
+ expect(PWN::Sessions).not_to receive(:append)
848
+ console.handle("\u000c")
849
+ expect(console).to have_received(:clear_view)
850
+ expect(console.timeline_rows(60).join).not_to include('visible evidence')
851
+ expect(console.instance_variable_get(:@usage).snapshot).to eq(usage)
852
+ expect(editor.text).to eq('draft')
853
+ console.handle(:up)
854
+ expect(editor.text).to eq('retained request')
855
+ end
856
+
857
+ it 'searches local request history incrementally, accepts without sending and cancels with cursor restored' do
858
+ screen = double(erase: nil, refresh: nil, setpos: nil, addstr: nil)
859
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: double(lines: 30, cols: 100), getch: nil)
860
+ console.instance_variable_set(:@screen, screen)
861
+ editor = console.instance_variable_get(:@editor)
862
+ editor.preload(['older alpha', '/input hidden alpha', 'newer alpha', 'beta'])
863
+ editor.place('original draft', 3)
864
+ expect(console).not_to receive(:submit)
865
+ console.handle("\u0012")
866
+ 'alpha'.each_char { |char| console.handle(char) }
867
+ console.draw
868
+ expect(screen).to have_received(:addstr).with(a_string_including('alpha')).at_least(:once)
869
+ expect(screen).to have_received(:addstr).with(a_string_including('newer alpha')).at_least(:once)
870
+ console.handle("\u0012")
871
+ console.handle("\r")
872
+ expect(editor.text).to eq('older alpha')
873
+ console.handle(:up)
874
+ expect(editor.text).to eq('beta')
875
+ console.handle(:down)
876
+ expect(editor.text).to eq('older alpha')
877
+ editor.place('original draft', 3)
878
+ console.handle("\u0012")
879
+ 'missing'.each_char { |char| console.handle(char) }
880
+ console.draw
881
+ expect(screen).to have_received(:addstr).with(a_string_including('no match')).at_least(:once)
882
+ console.handle("\u007f")
883
+ console.handle("\e")
884
+ expect([editor.text, editor.cursor]).to eq(['original draft', 3])
885
+ end
886
+
887
+ it 'reads application and kitty arrow sequences as history keys' do
888
+ reader, writer = IO.pipe
889
+ writer.write("\eOA\e[1;1B")
890
+ writer.close
891
+ keyboard = PWN::Plugins::REPL::AIConsole::Keyboard.new(reader)
892
+ expect(keyboard.call).to eq(:up)
893
+ expect(keyboard.call).to eq(:down)
894
+ ensure
895
+ reader&.close
896
+ end
897
+
898
+ it 'does not retain tool input in recall history' do
899
+ editor = PWN::Plugins::REPL::AIConsole::Editor.new
900
+ editor.replace('previous task')
901
+ editor.submit
902
+ editor.replace('/input secret tool response')
903
+ editor.submit
904
+ editor.recall(:up)
905
+ expect(editor.text).to eq('previous task')
906
+ end
907
+
908
+ it 'flushes an unterminated dependency prompt through the event queue without terminal control bytes' do
909
+ queue = Queue.new
910
+ output = PWN::Plugins::REPL::AIConsole::EventIO.new(queue)
911
+ output.write('Tool asks: value? ')
912
+ output.flush
913
+ expect(queue.size).to eq(1)
914
+ end
915
+
916
+ it 'pages above the current viewport on the first PgUp and pins that position for incoming output' do
917
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
918
+ console.instance_variable_set(:@total_rows, 100)
919
+ console.instance_variable_set(:@page_size, 20)
920
+ console.handle(:page_up)
921
+ expect(console.instance_variable_get(:@scroll)).to eq(60)
922
+ console.add(:notice, 'new output')
923
+ expect(console.instance_variable_get(:@scroll)).to eq(60)
924
+ console.handle(:page_down)
925
+ expect(console.instance_variable_get(:@scroll)).to be_nil
926
+ end
927
+
928
+ it 'wraps at words without discarding code whitespace or splitting graphemes' do
929
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
930
+ expect(console.wrap('some evidence', 12)).to eq(['some ', 'evidence'])
931
+ text = " code evidence\n界e\u0301🙂abcdef "
932
+ rows = console.wrap(text, 8)
933
+ expect(rows.join).to eq(text.delete("\n"))
934
+ expect(rows).to all(satisfy { |row| console.width(row) <= 8 })
935
+ expect(rows.join.scan(/\X/)).to eq(text.delete("\n").scan(/\X/))
936
+ expect(console.wrap('abcdefghij', 4)).to eq(%w[abcd efgh ij])
937
+ expect(console.wrap("a\n\n", 4)).to eq(['a', '', ''])
938
+ expect(console.wrap('👩‍💻界', 2)).to eq(['👩‍💻', '界'])
939
+ expect(console.width('👩‍💻')).to eq(2)
940
+ end
941
+
942
+ it 'shows observed elapsed time, completed tools, event timestamps and pinned new output' do
943
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
944
+ allow(Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC).and_return(10.0, 12.5, 14.0)
945
+ allow(Time).to receive(:now).and_return(Time.utc(2026, 1, 2, 3, 4, 5))
946
+ console.begin_request_metrics
947
+ console.add(:task, 'task summary, not a completed tool')
948
+ console.add(:tool, "shell\n{}")
949
+ console.add(:result, 'observed result')
950
+ expect(console.elapsed).to eq('2.5s')
951
+ expect(console.operation_lines.join(' ')).to include('Completed tools 1', 'Events 3', 'LAST TOOL', 'shell')
952
+ expect(console.timeline_rows(40).map(&:last).join).to include('03:04:05', 'TOOL')
953
+ console.instance_variable_set(:@total_rows, 100)
954
+ console.instance_variable_set(:@page_size, 20)
955
+ console.handle(:page_up)
956
+ console.add(:notice, 'incoming')
957
+ expect(console.scroll_status).to include('61–80/100', '1 new')
958
+ console.handle(:page_down)
959
+ expect(console.scroll_status).to eq('live')
960
+ console.finish_request_metrics
961
+ allow(Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC).and_return(100.0)
962
+ expect(console.elapsed).to eq('4.0s')
963
+ console.begin_request_metrics
964
+ expect(console.operation_lines.join(' ')).to include('Completed tools 0', 'not observed')
965
+ end
966
+
967
+ it 'keeps tokens, cost and last tool in a short operations sidebar' do
968
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
969
+ console.instance_variable_set(:@width, 120)
970
+ console.instance_variable_set(:@last_tool, 'shell')
971
+ allow(console).to receive(:box)
972
+ painted = []
973
+ allow(console).to receive(:put) { |_row, _column, text, _color| painted << text }
974
+ console.draw_sidebar(6, 94, 11)
975
+ expect(painted.join(' ')).to include('TOKENS', 'Cost', 'LAST TOOL', 'shell')
976
+ end
977
+
978
+ it 'bounds giant timeline events explicitly and caches wrapping until width or events change' do
979
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
980
+ console.add(:result, 'evidence ' * 20_000)
981
+ expect(console.instance_variable_get(:@timeline).first[1].bytesize).to be < 17_000
982
+ rows = console.timeline_rows(40)
983
+ expect(rows.map(&:last).join).to include('[display truncated')
984
+ expect(console).not_to receive(:wrap)
985
+ expect(console.timeline_rows(40)).to equal(rows)
986
+ end
987
+
988
+ it 'edits graphemes and restores the full-history draft and cursor' do
989
+ editor = PWN::Plugins::REPL::AIConsole::Editor.new
990
+ 105.times do |index|
991
+ editor.replace("task #{index}")
992
+ editor.submit
993
+ end
994
+ editor.replace("界e\u0301🙂 draft")
995
+ editor.edit(:left)
996
+ cursor = editor.cursor
997
+ editor.recall(:up)
998
+ editor.edit("\b")
999
+ editor.recall(:down)
1000
+ expect(editor.text).to eq("界e\u0301🙂 draft")
1001
+ expect(editor.cursor).to eq(cursor)
1002
+ 110.times { editor.recall(:up) }
1003
+ expect(editor.text).to eq('task 0')
1004
+ editor.replace("界e\u0301🙂")
1005
+ editor.edit(:home)
1006
+ editor.edit(:right)
1007
+ editor.edit(:delete)
1008
+ expect(editor.text).to eq('界🙂')
1009
+ end
1010
+
1011
+ it 'restores IO and terminal state if rendering fails' do
1012
+ original = [$stdin, $stdout, $stderr]
1013
+ keys = []
1014
+ expect do
1015
+ launch(keys) do
1016
+ allow(@screen.stdscr).to receive(:addstr).and_raise(IOError, 'paint failed')
1017
+ nil
1018
+ end
1019
+ end.to raise_error(IOError, 'paint failed')
1020
+ expect([$stdin, $stdout, $stderr]).to eq(original)
1021
+ expect(@screen).to have_received(:close_screen)
1022
+ end
1023
+
1024
+ it 'keeps cancellation and exit hints visible at the minimum supported width' do
1025
+ resized = false
1026
+ launch([]) do
1027
+ if resized
1028
+ expect(@paint.last).to include('^C cancel', '^D back')
1029
+ expect(Unicode::DisplayWidth.of(@paint.last)).to be <= 46
1030
+ "\u0004"
1031
+ else
1032
+ allow(@screen).to receive_messages(lines: 14, cols: 48)
1033
+ resized = true
1034
+ nil
1035
+ end
1036
+ end
1037
+ end
1038
+
1039
+ it 'renders a visible slash menu and safely handles a tiny resize' do
1040
+ keys = "/\n".chars
1041
+ stage = 0
1042
+ launch(keys) do
1043
+ if stage.zero? && @paint.any? { |row| row.include?('COMMANDS') }
1044
+ stage = 1
1045
+ allow(@screen).to receive_messages(lines: 7, cols: 35)
1046
+ "\e"
1047
+ elsif stage == 1 && @paint.any? { |row| row.include?('terminal too small') }
1048
+ stage = 2
1049
+ allow(@screen).to receive_messages(lines: 30, cols: 125)
1050
+ nil
1051
+ elsif stage == 2 && @paint.any? { |row| row.include?('OPERATIONS') }
1052
+ "\u0004"
1053
+ end
1054
+ end
1055
+ expect(@paint.join).to include('/model', 'terminal too small', 'OPERATIONS')
1056
+ end
1057
+
1058
+ it 'blocks hidden composer input during tiny resize, keeps its draft and still accepts cancellation and exit' do
1059
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: double(lines: 7, cols: 35), getch: nil)
1060
+ screen = double(erase: nil, refresh: nil, setpos: nil, addstr: nil)
1061
+ console.instance_variable_set(:@screen, screen)
1062
+ editor = console.instance_variable_get(:@editor)
1063
+ editor.replace('saved draft')
1064
+ console.draw
1065
+ expect(console).not_to receive(:submit)
1066
+ ['x', "\r", :paste_start, :newline, :up, "\t", :paste_end].each { |key| console.handle(key) }
1067
+ expect(editor.text).to eq('saved draft')
1068
+ expect(console).to receive(:cancel).once
1069
+ console.handle("\u0003")
1070
+ console.handle("\u0004")
1071
+ expect(console.instance_variable_get(:@leaving)).to be true
1072
+ expect(editor.text).to eq('saved draft')
1073
+ end
1074
+
1075
+ it 'adds console-only commands to completion without changing the global dispatcher' do
1076
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
1077
+ allow(PWN::Plugins::REPL).to receive(:pwn_ai_complete).and_return(['/model'])
1078
+ console.instance_variable_get(:@editor).replace('/')
1079
+ console.complete
1080
+ expect(console.instance_variable_get(:@menu).map { |item| item[:label] }).to include('/input', '/menu', '/model')
1081
+ console.instance_variable_get(:@editor).replace('/in')
1082
+ console.complete
1083
+ expect(console.instance_variable_get(:@menu).map { |item| item[:label] }).to include('/input')
1084
+ expect(console.instance_variable_get(:@menu).map { |item| item[:label] }).not_to include('/menu')
1085
+ console.instance_variable_get(:@editor).replace('/menu')
1086
+ console.complete
1087
+ console.instance_variable_set(:@menu_index, console.instance_variable_get(:@menu).index { |item| item[:label] == '/menu' })
1088
+ console.handle("\r")
1089
+ expect(PWN::Plugins::REPL).not_to receive(:pwn_ai_dispatch_slash!)
1090
+ console.handle("\r")
1091
+ expect(console.instance_variable_get(:@menu).map { |item| item[:label] }).to include('/menu', '/input')
1092
+ end
1093
+
1094
+ it 'keeps slash parameter completion live across spaces and clears stale menus on submit' do
1095
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
1096
+ '/verbose '.chars.each { |key| console.handle(key) }
1097
+ expect(console.instance_variable_get(:@menu).map { |item| item[:label] }).to eq(%w[on off])
1098
+ console.handle('o')
1099
+ console.handle('f')
1100
+ console.handle('f')
1101
+ console.handle("\r")
1102
+ expect(console.instance_variable_get(:@menu)).to be_nil
1103
+ end
1104
+
1105
+ it 'clears every cell behind the completion popup and reverses the selected row even without colors' do
1106
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
1107
+ screen = double('popup screen')
1108
+ paint = []
1109
+ position = nil
1110
+ reversed = false
1111
+ allow(screen).to receive(:setpos) { |*pos| position = pos }
1112
+ allow(screen).to receive(:addstr) { |text| paint << [position, text, reversed] }
1113
+ allow(screen).to receive(:attron) do |attribute, &block|
1114
+ expect(attribute).to eq(Curses::A_REVERSE)
1115
+ reversed = true
1116
+ block.call(0) # ncurses yields the wattron result
1117
+ reversed = false
1118
+ end
1119
+ console.instance_variable_set(:@screen, screen)
1120
+ console.instance_variable_set(:@width, 80)
1121
+ console.instance_variable_set(:@height, 30)
1122
+ console.instance_variable_set(:@menu, ['/界', '/menu'])
1123
+ console.instance_variable_set(:@menu_index, 0)
1124
+ console.draw_menu
1125
+ expect(paint.take(4).map { |position_and_text| position_and_text[1] }).to eq([' ' * 65] * 4)
1126
+ selected = paint.find { |_pos, text, reverse| reverse && text.include?('/界') }
1127
+ expect(selected).not_to be_nil
1128
+ expect(console.width(selected[1])).to eq(61)
1129
+ end
1130
+
1131
+ it 'keeps the terminal cursor inside the multiline composer after painting the footer' do
1132
+ launch(['界', :newline, 'é']) { "\u0004" }
1133
+ expect(@positions.last).to eq([25, 5])
1134
+ end
1135
+
1136
+ it 'keeps the editing cursor in the composer while completion is open' do
1137
+ launch(['/']) { "\u0004" }
1138
+ expect(@positions.last).to eq([24, 5])
1139
+ end
1140
+
1141
+ it 'keeps debug output queued and restores the debug tee after leaving curses' do
1142
+ log = PWN::Plugins::Log
1143
+ allow(log).to receive(:debug_dir).and_return(File.join(@tmp, 'debug'))
1144
+ previous = $stdout
1145
+ allow(PWN::AI::Agent::Loop).to receive(:call_engine).and_return(role: 'assistant', content: 'Debug final.', tool_calls: [])
1146
+ PWN::Plugins::REPL.add_commands
1147
+ keys = "/debug\nwhat color is a lemon?\n".chars
1148
+ launch(keys) { @paint.any? { |row| row.include?('Debug final.') } ? "\u0004" : nil }
1149
+ expect(log.instance_variable_get(:@debug_tee)).to equal(previous)
1150
+ expect(log.raw_stderr).not_to be_a(PWN::Plugins::REPL::AIConsole::EventIO)
1151
+ path = log.debug_log_path
1152
+ expect(File.read(path)).to include('Debug final.', 'footer iter=') if path
1153
+ ensure
1154
+ log&.stop_debug
1155
+ end
1156
+
1157
+ it 'cancels the reasoning overlay with the original draft and cursor intact' do
1158
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
1159
+ editor = console.instance_variable_get(:@editor)
1160
+ selection = { engine: 'grok', model: 'grok-4.6', efforts: %w[low medium high], default: 'medium' }
1161
+ original = Marshal.dump(PWN::Env[:ai])
1162
+ console.instance_variable_set(:@model_prompt, selection)
1163
+ console.instance_variable_set(:@model_index, 1)
1164
+ console.instance_variable_set(:@model_draft, ['/model grok grok-4.6', 7])
1165
+ expect(PWN::Plugins::REPL).not_to receive(:persist_ai_selection)
1166
+ console.handle(:down)
1167
+ expect(console.instance_variable_get(:@model_index)).to eq(2)
1168
+ console.handle("\e")
1169
+ expect(editor.text).to eq('/model grok grok-4.6')
1170
+ expect(editor.cursor).to eq(7)
1171
+ expect(Marshal.dump(PWN::Env[:ai])).to eq(original)
1172
+ expect(console.instance_variable_get(:@model_prompt)).to be_nil
1173
+ end
1174
+
1175
+ def rendered_header
1176
+ boundary = @rendered.find { |_row, _column, text| text.start_with?(' SESSION ') }&.first || 0
1177
+ @rendered.select { |row, _column, _text| row < boundary }.map(&:last).join
1178
+ end
1179
+
1180
+ it 'repaints an asynchronous model command with all active settings before another key' do
1181
+ PWN::Env[:ai][:openai] = { model: 'old', system_role_content: 'new operator role', temp: 0.37,
1182
+ max_tokens: 1234, max_prompt_length: 5678, reasoning_effort: 'fixture-effort' }
1183
+ allow(PWN::Plugins::REPL).to receive(:persist_ai_selection).and_return(false)
1184
+ keys = "/model openai header-fixture\n".chars
1185
+ launch(keys) do
1186
+ header = rendered_header
1187
+ if header.include?('header-fixture')
1188
+ expect(header).to include('openai', 'new operator role', '0.37', '1234', '5678', 'fixture-effort')
1189
+ "\u0004"
1190
+ else
1191
+ Thread.pass
1192
+ nil
1193
+ end
1194
+ end
1195
+ end
1196
+
1197
+ it 'repaints settings and theme changed by a command worker without worker drawing or a wakeup key' do
1198
+ PWN::Env[:ai] = { active: :ollama, ollama: { model: 'settings-fixture' } }
1199
+ owner = Thread.current
1200
+ painted = []
1201
+ allow_any_instance_of(PWN::Plugins::REPL::AIConsole::Console).to receive(:put).and_wrap_original do |method, *args|
1202
+ painted << args
1203
+ method.call(*args)
1204
+ end
1205
+ allow(PWN::Cron).to receive(:run) do
1206
+ expect(Thread.current).not_to eq(owner)
1207
+ slot = PWN::Env[:ai][PWN::Env[:ai][:active].to_sym]
1208
+ slot[:system_role_content] = 'worker settings role'
1209
+ slot[:temp] = 0.42
1210
+ slot[:max_tokens] = 4321
1211
+ slot[:max_prompt_length] = 8765
1212
+ slot[:reasoning_effort] = 'worker-effort'
1213
+ PWN::Env[:ai][:tui] = { theme: { category: 'magenta', header: 'blue' } }
1214
+ 'offline settings fixture completed'
1215
+ end
1216
+ keys = "/cron run settings-fixture\n".chars
1217
+ launch(keys) do
1218
+ if rendered_header.include?('worker-effort')
1219
+ expect(rendered_header).to include('worker settings role', '0.42', '4321', '8765')
1220
+ expect(painted).to include([a_kind_of(Integer), a_kind_of(Integer), 'MODEL:', 7])
1221
+ expect(painted).to include([a_kind_of(Integer), a_kind_of(Integer), ' worker-effort', 6])
1222
+ "\u0004"
1223
+ else
1224
+ Thread.pass
1225
+ nil
1226
+ end
1227
+ end
1228
+ end
1229
+
1230
+ it 'repaints accepted reasoning before another key even when persistence is session only' do
1231
+ PWN::Env[:ai][:openai] = { model: 'old', reasoning_effort: 'medium' }
1232
+ allow(PWN::Plugins::REPL).to receive(:persist_ai_selection).and_return(false)
1233
+ allow(PWN::AI::OpenAI).to receive(:get_models).and_return(data: [{ id: 'gpt-6-astra', supported_reasoning_levels: [{ effort: 'medium' }, { effort: 'high' }] }])
1234
+ keys = "/model openai gpt-6-astra\n".chars
1235
+ accepted = false
1236
+ launch(keys) do
1237
+ if !accepted && @rendered.any? { |row| row.last.include?('REASONING EFFORT · SELECT') }
1238
+ accepted = true
1239
+ keys.push(:down, "\n")
1240
+ nil
1241
+ elsif accepted && rendered_header.include?('gpt-6-astra')
1242
+ expect(rendered_header).to include('REASONING EFFORT:', 'high')
1243
+ "\u0004"
1244
+ else
1245
+ Thread.pass
1246
+ nil
1247
+ end
1248
+ end
1249
+ end
1250
+
1251
+ ["\e", "\u0003"].each do |cancel_key|
1252
+ it "keeps the rendered header and banner unchanged after reasoning cancellation #{cancel_key.inspect}" do
1253
+ allow(PWN::Plugins::REPL).to receive(:persist_ai_selection).and_return(false)
1254
+ expect(PWN::Plugins::REPL).not_to receive(:pwn_ai_apply_model)
1255
+ original = Marshal.dump(PWN::Env[:ai])
1256
+ keys = "/model grok grok-4.6\n".chars
1257
+ cancelled = false
1258
+ baseline = nil
1259
+ allow(PWN::Banner).to receive(:mini_frame).and_return(['stable artwork'])
1260
+ allow(PWN::Banner).to receive(:mini_cells).and_return([[{ glyph: '█', foreground: :cyan, background: :black }]])
1261
+ launch(keys) do
1262
+ if !cancelled && @rendered.any? { |row| row.last.include?('REASONING EFFORT · SELECT') }
1263
+ baseline = rendered_header
1264
+ cancelled = true
1265
+ cancel_key
1266
+ elsif cancelled
1267
+ expect(Marshal.dump(PWN::Env[:ai])).to eq(original)
1268
+ expect(rendered_header).to eq(baseline)
1269
+ "\u0004"
1270
+ else
1271
+ Thread.pass
1272
+ nil
1273
+ end
1274
+ end
1275
+ end
1276
+ end
1277
+
1278
+ it 'renders a resumed session and reselects artwork only for an actual session change' do
1279
+ sid = PWN::Sessions.create(title: 'header resume')[:id]
1280
+ names = PWN::Banner.mini_names
1281
+ allow(PWN::Banner).to receive(:mini_names).and_return(names)
1282
+ expect(names).to receive(:sample).twice.and_return(names.first)
1283
+ keys = "/sessions resume #{sid}\n".chars
1284
+ repeated = false
1285
+ launch(keys) do
1286
+ if @rendered.any? { |row| row.last.include?("SESSION #{sid}") }
1287
+ if repeated
1288
+ "\u0004"
1289
+ else
1290
+ repeated = true
1291
+ keys.concat("/sessions resume #{sid}\n".chars)
1292
+ nil
1293
+ end
1294
+ end
1295
+ end
1296
+ end
1297
+
1298
+ it 'prompts in curses and forwards the accepted effort to the next provider request' do
1299
+ PWN::Env[:ai][:openai] = { model: 'old', reasoning_effort: 'medium' }
1300
+ allow(PWN::Plugins::REPL).to receive(:persist_ai_selection).and_return(false)
1301
+ allow(PWN::AI::OpenAI).to receive(:get_models).and_return(data: [{ id: 'gpt-6-astra', supported_reasoning_levels: [{ effort: 'low' }, { effort: 'medium' }, { effort: 'high' }] }])
1302
+ expect(PWN::AI::OpenAI).to receive(:chat_with_tools).with(hash_including(reasoning_effort: 'high')) do
1303
+ { choices: [{ message: { role: 'assistant', content: 'Effort accepted.', tool_calls: [] } }] }
1304
+ end
1305
+ keys = "/model openai gpt-6-astra\n".chars
1306
+ accepted = false
1307
+ launch(keys) do
1308
+ if !accepted && @paint.any? { |row| row.include?('REASONING EFFORT · SELECT') }
1309
+ accepted = true
1310
+ keys.concat([:down, "\n"] + "what color is a lemon?\n".chars)
1311
+ nil
1312
+ elsif @paint.any? { |row| row.include?('Effort accepted.') }
1313
+ "\u0004"
1314
+ else
1315
+ Thread.pass
1316
+ nil
1317
+ end
1318
+ end
1319
+ expect(PWN::Env.dig(:ai, :openai, :reasoning_effort)).to eq('high')
1320
+ end
1321
+
1322
+ it 'honors locally selected model and resumed session in the actual provider call' do
1323
+ sid = PWN::Sessions.create(title: 'resumed console')[:id]
1324
+ allow(PWN::Plugins::REPL).to receive(:persist_ai_selection).and_return(false)
1325
+ expect(PWN::AI::OpenAI).to receive(:chat_with_tools) do
1326
+ expect(PWN::Env.dig(:ai, :openai, :model)).to eq('console-fixture')
1327
+ { choices: [{ message: { role: 'assistant', content: 'Selected route.', tool_calls: [] } }] }
1328
+ end
1329
+ keys = "/model openai console-fixture\n/sessions resume #{sid}\nwhat color is a lemon?\n".chars
1330
+ launch(keys) { @paint.any? { |row| row.include?('Selected route.') } ? "\u0004" : nil }
1331
+ expect(@pry.config.pwn_ai_session_id).to eq(sid)
1332
+ expect(PWN::Sessions.load(session_id: sid).map { |row| row[:content] }.join).to include('Selected route.')
1333
+ end
1334
+
1335
+ it 'strips spinner controls, redacts queued secrets, and accepts binary dependency warnings' do
1336
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
1337
+ console.add(:notice, "\e[?25h\e[0m")
1338
+ expect(console.instance_variable_get(:@timeline)).to be_empty
1339
+ console.add(:warning, "api_key=super-secret-fixture\n\e[31mwarning\e[0m")
1340
+ expect(console.instance_variable_get(:@timeline).inspect).not_to include('super-secret-fixture', "\e")
1341
+ expect { console.add(:warning, "warning\xff".b) }.not_to raise_error
1342
+ end
1343
+
1344
+ it 'decodes terminal keys and Unicode without a competing stdin reader' do
1345
+ require 'pty'
1346
+ master, slave = PTY.open
1347
+ slave.raw do
1348
+ reader = PWN::Plugins::REPL::AIConsole::Keyboard.new(slave)
1349
+ master.write("界\e[D\e[13;2u\e[3~\e[5~")
1350
+ expect(Array.new(5) { reader.call }).to eq(['界', :left, :newline, :delete, :page_up])
1351
+ end
1352
+ ensure
1353
+ master&.close
1354
+ slave&.close
1355
+ end
1356
+
1357
+ it 'consumes valid UTF-8 prefixes immediately and recovers from malformed bytes' do
1358
+ source = double('incremental terminal', wait_readable: false)
1359
+ reader = PWN::Plugins::REPL::AIConsole::Keyboard.new(source)
1360
+ buffer = reader.instance_variable_get(:@buffer)
1361
+ buffer << "a\xe7".b
1362
+ expect(reader.call).to eq('a')
1363
+ expect(reader.call).to be_nil
1364
+ buffer << "\x95\x8c".b
1365
+ expect(reader.call).to eq('界')
1366
+ buffer << "\xff\xe0\x80b\xe7c".b
1367
+ expect(Array.new(6) { reader.call }).to eq(['�', '�', '�', 'b', '�', 'c'])
1368
+ buffer << "\xf0\x9f".b
1369
+ expect(reader.call).to be_nil
1370
+ buffer << "\x99\x82".b
1371
+ expect(reader.call).to eq('🙂')
1372
+ end
1373
+
1374
+ it 'keeps the grapheme cursor valid when combining characters arrive as separate terminal keys' do
1375
+ editor = PWN::Plugins::REPL::AIConsole::Editor.new
1376
+ editor.insert('e')
1377
+ editor.insert("\u0301")
1378
+ expect(editor.cursor).to eq(1)
1379
+ editor.edit("\b")
1380
+ expect(editor.text).to eq('')
1381
+ end
1382
+
1383
+ it 'exposes steering and cancellation as operational states rather than simulated progress' do
1384
+ control = PWN::Plugins::REPL::AIConsole::Control.new(input: StringIO.new, output: StringIO.new)
1385
+ control.submit('revised task')
1386
+ expect(control.phase.to_s).to include('steering')
1387
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
1388
+ console.instance_variable_set(:@worker, Thread.current)
1389
+ console.instance_variable_set(:@cancelling, true)
1390
+ expect(console.state).to include('cancelling')
1391
+ end
1392
+
1393
+ it 'keeps network slash commands off the event thread so tool prompts can receive explicit input' do
1394
+ entered = Queue.new
1395
+ expect(PWN::AI::Agent::Loop).not_to receive(:run)
1396
+ expect(PWN::Plugins::REPL).to receive(:pwn_ai_dispatch_slash!).with(request: '/mcp call fixture', pry: anything) do
1397
+ entered << true
1398
+ puts "local command received: #{$stdin.gets}"
1399
+ true
1400
+ end
1401
+ keys = "/mcp call fixture\n".chars
1402
+ sent = false
1403
+ launch(keys) do
1404
+ if !sent && !entered.empty?
1405
+ sent = true
1406
+ keys.concat("/input ready\n".chars)
1407
+ nil
1408
+ elsif @paint.any? { |row| row.include?('local command received: ready') }
1409
+ "\u0004"
1410
+ end
1411
+ end
1412
+ end
1413
+
1414
+ it 'routes the actual pwn-ai command into the default fullscreen console and leaves the mode on return' do
1415
+ repl = PWN::Plugins::REPL
1416
+ repl.add_commands
1417
+ pi = Pry.new
1418
+ pi.config.pwn_ai_startup_session_id = 'prepared-console-session'
1419
+ allow(PWN::ModuleSkills).to receive(:install)
1420
+ allow(PWN::Config).to receive(:load_skills)
1421
+ allow(PWN::Config).to receive(:load_memory)
1422
+ allow(PWN::Memory).to receive(:load).and_return({})
1423
+ allow(PWN::Cron).to receive(:list).and_return({})
1424
+ expect(PWN::Plugins::REPL::AIConsole).to receive(:run).with(pry: pi).and_return(:closed)
1425
+ expect(repl).to receive(:leave_special_mode!).with(pry: pi)
1426
+ Pry::Commands.find_command('pwn-ai').new(pry_instance: pi, output: StringIO.new).process
1427
+ expect(pi.config.pwn_ai_session_id).to eq('prepared-console-session')
1428
+ end
1429
+
1430
+ it 'provides a public curses console with an honest non-terminal fallback' do
1431
+ expect(PWN::Plugins::REPL.const_defined?(:AIConsole)).to be true
1432
+ expect(PWN::Plugins::REPL::AIConsole.run(pry: Pry.new, input: StringIO.new, output: StringIO.new)).to eq(:unavailable)
1433
+ end
1434
+ end