pwn 0.5.759 → 0.5.761

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.
@@ -86,6 +86,7 @@ module PWN
86
86
 
87
87
  def stop
88
88
  @stopping = true
89
+ request_model_cancel
89
90
  submit('Stop the active request.')
90
91
  end
91
92
 
@@ -104,6 +105,8 @@ module PWN
104
105
 
105
106
  # Read a duplicated terminal rather than stdin, which belongs to tools.
106
107
  class Keyboard
108
+ attr_writer :interval
109
+
107
110
  KEYS = {
108
111
  "\e[A" => :up, "\e[B" => :down, "\e[C" => :right, "\e[D" => :left,
109
112
  "\eOA" => :up, "\eOB" => :down, "\eOC" => :right, "\eOD" => :left,
@@ -119,7 +122,7 @@ module PWN
119
122
  end
120
123
 
121
124
  def call
122
- if @source.wait_readable(@buffer.empty? ? 0.04 : 0)
125
+ if @source.wait_readable(@buffer.empty? ? (@interval || 0.04) : 0)
123
126
  chunk = @source.read_nonblock(4096, exception: false)
124
127
  return :eof if chunk.nil? && @buffer.empty?
125
128
 
@@ -401,6 +404,7 @@ module PWN
401
404
  break if @leaving && !busy?
402
405
 
403
406
  draw
407
+ @getch.interval = @focus == :animation && %i[asteroids galaga frogger].include?(@banner_name) ? 0.01 : 0.04 if @getch.is_a?(Keyboard)
404
408
  handle(@getch.call)
405
409
  rescue Interrupt
406
410
  cancel
@@ -524,13 +528,12 @@ module PWN
524
528
 
525
529
  def cancel
526
530
  if busy?
527
- return if @cancelling
528
-
531
+ first = !@cancelling
529
532
  @cancelling = true
530
533
  @control&.stop
531
534
  @swarm&.cancel_all
532
535
  @tool_input.close unless @tool_input.closed?
533
- add(:warning, 'Cancellation requested. Waiting for the active tool to finish; work already started is not undone.')
536
+ add(:warning, 'Cancellation requested. Waiting for a safe boundary; work already started is not undone.') if first
534
537
  else
535
538
  @editor.replace('')
536
539
  end
@@ -681,6 +684,7 @@ module PWN
681
684
  def handle(key)
682
685
  return if key.nil?
683
686
  return handle_system_role(key) if @role_editor && (!@too_small || ["\e", "\u0003", "\u0004", :eof].include?(key))
687
+ return cancel if key == "\u0003" && busy?
684
688
 
685
689
  return if @too_small && ![:eof, "\u0003", "\u0004"].include?(key)
686
690
  return cancel if @too_small && key == "\u0003" && !@model_prompt
@@ -689,7 +693,7 @@ module PWN
689
693
 
690
694
  return clear_view if key == "\u000c"
691
695
 
692
- if ["\u0007", "\u0013"].include?(key)
696
+ if key == "\u0013" || (key == "\u0007" && (@focus != :animation || @workspace))
693
697
  @workspace ? @workspace = nil : open_swarm
694
698
  return
695
699
  end
@@ -714,7 +718,10 @@ module PWN
714
718
 
715
719
  if ["\u0018", "\u0014"].include?(key)
716
720
  @menu = nil
717
- @focus = @focus == :session ? :mission : :session
721
+ panes = @header_pane_height ? %i[mission session animation] : %i[mission session]
722
+ @focus = panes[(panes.index(@focus).to_i + 1) % panes.length]
723
+ @banner_game&.controls
724
+ @banner_game_tick = nil
718
725
  return
719
726
  end
720
727
 
@@ -726,6 +733,8 @@ module PWN
726
733
  @menu = nil
727
734
  return
728
735
  end
736
+ return handle_animation(key) if @focus == :animation && ![:eof, "\u0003", "\u0004"].include?(key)
737
+
729
738
  case key
730
739
  when :eof, "\u0004"
731
740
  @leaving = true
@@ -794,6 +803,34 @@ module PWN
794
803
  end
795
804
  end
796
805
 
806
+ def handle_animation(key)
807
+ if key == "\u0007"
808
+ names = PWN::Banner.mini_names
809
+ @banner_name = names[(names.index(@banner_name).to_i + 1) % names.length]
810
+ @banner_frame_seconds = PWN::Banner.mini_frame_seconds(name: @banner_name)
811
+ @banner_started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
812
+ @banner_game = @banner_game_key = @banner_game_tick = nil
813
+ elsif key == :paste_start
814
+ @focus = :mission
815
+ @paste = true
816
+ @banner_game&.controls
817
+ elsif [:up, :down, :left, :right, ' '].include?(key)
818
+ return if %i[asteroids galaga frogger].include?(@banner_name) && @banner_game_frame&.dig(:state, :explosion).to_i.positive?
819
+
820
+ @banner_game&.key(key)
821
+ # Paint a discrete control immediately, not after the next idle tick.
822
+ @banner_game_tick = nil if %i[asteroids galaga frogger].include?(@banner_name)
823
+ elsif ["\r", "\n", "\e", :newline].include?(key)
824
+ @focus = :mission
825
+ @banner_game&.controls
826
+ elsif key.is_a?(String) && key.ord >= 32
827
+ @focus = :mission
828
+ @banner_game&.controls
829
+ @editor.insert(key)
830
+ refresh_completion
831
+ end
832
+ end
833
+
797
834
  def open_system_role
798
835
  return add(:warning, 'System role cannot change while a request or swarm job is running.') if busy?
799
836
 
@@ -1029,7 +1066,7 @@ module PWN
1029
1066
  ['REQUEST', state, "Elapsed #{elapsed}", "Completed tools #{@completed_tools}",
1030
1067
  "Events #{@event_count}", '', 'TOKENS', "#{usage[:input_tokens]} in · #{usage[:output_tokens]} out",
1031
1068
  "cached #{usage[:cached_tokens]} · calls #{usage[:calls]}", "Cost #{cost}",
1032
- '', 'SWARM', swarm[:swarm_id] || 'none', "#{active} active · ^G swarm",
1069
+ '', 'SWARM', swarm[:swarm_id] || 'none', "#{active} active · ^S swarm",
1033
1070
  '', 'VIEW', @verbose ? 'verbose' : 'compact · /verbose', scroll_status,
1034
1071
  '', 'LAST TOOL', @last_tool || 'not observed']
1035
1072
  end
@@ -1201,6 +1238,8 @@ module PWN
1201
1238
  end
1202
1239
  cadence = cells ? @banner_frame_seconds : PWN::Banner::MINI_FRAME_SECONDS
1203
1240
  frame = ((now - @banner_started) / cadence).floor % PWN::Banner::MINI_FRAME_COUNT
1241
+ return playable_banner(size, now) if cells && @focus == :animation
1242
+ return automatic_banner(size, now) if cells && size >= 5 && %i[pacman galaga frogger].include?(@banner_name)
1204
1243
  return PWN::Banner.mini_cells(name: @banner_name, frame: frame, width: size, height: size, seed: @banner_seed, branding: false) if cells
1205
1244
 
1206
1245
  art = PWN::Banner.mini_frame(name: @banner_name, frame: frame, width: size, height: size, branding: false)
@@ -1210,6 +1249,43 @@ module PWN
1210
1249
  canvas
1211
1250
  end
1212
1251
 
1252
+ def automatic_banner(size, now)
1253
+ key = [@banner_session, @banner_name, size, @banner_seed]
1254
+ if @banner_demo_key != key
1255
+ @banner_demo_key = key
1256
+ @banner_demo = PWN::Banner::MiniGame.new(name: @banner_name, width: size, height: size, seed: @banner_seed, demo: true)
1257
+ @banner_demo.step
1258
+ @banner_demo_tick = nil
1259
+ end
1260
+ tick = (now / @banner_frame_seconds).floor
1261
+ if @banner_demo_tick != tick
1262
+ # Advance one frame, never build 1800 or catch up after a modal.
1263
+ @banner_demo_frame = @banner_demo.step
1264
+ @banner_demo_cells = @banner_demo.cells(@banner_demo_frame)
1265
+ @banner_demo_tick = tick
1266
+ end
1267
+ @banner_demo_cells
1268
+ end
1269
+
1270
+ def playable_banner(size, now)
1271
+ key = [@banner_session, @banner_name, size, @banner_seed]
1272
+ if @banner_game_key != key
1273
+ @banner_game_key = key
1274
+ @banner_game = PWN::Banner::MiniGame.new(name: @banner_name, width: size, height: size, seed: @banner_seed)
1275
+ @banner_game_tick = nil
1276
+ end
1277
+ # Human Snake input needs a full reaction interval on its tiny board;
1278
+ # decorative replay cadence is independent of player movement.
1279
+ cadence = @banner_name == :snake ? 0.2 : @banner_frame_seconds
1280
+ tick = (now / cadence).floor
1281
+ if @banner_game_tick != tick
1282
+ @banner_game_frame = @banner_game.step
1283
+ @banner_game_cells = @banner_game.cells(@banner_game_frame)
1284
+ @banner_game_tick = tick
1285
+ end
1286
+ @banner_game_cells
1287
+ end
1288
+
1213
1289
  def draw_banner(size)
1214
1290
  cells = banner_frame(size, cells: true)
1215
1291
  top = [(size - cells.length) / 2, 0].max
@@ -1267,6 +1343,12 @@ module PWN
1267
1343
 
1268
1344
  def footer_text
1269
1345
  text = '/ menu · ^C=cancel · ^D=back · ^L=clear · ^T=toggle pane · ^S=swarm · ^R=search ^O=operations · ↑↓ history · Shift+Enter=newline · Enter=send'
1346
+ if @focus == :animation
1347
+ controls = { pacman: '↑↓←→ start/steer · ● power', snake: '↑↓←→ start/steer', falling_blocks: '←→ move · ↓ faster · Space=rotate',
1348
+ asteroids: '←→ turn · ↑↓ thrust/reverse · Space=fire', galaga: '↑↓←→ move · Space=fire',
1349
+ frogger: '↑↓←→ hop · logs → goals' }.fetch(@banner_name, '↑↓←→ play')
1350
+ return text.sub('↑↓ history', "^G=next game · #{controls}").sub('Shift+Enter=newline · Enter=send', 'Enter/Esc=draft')
1351
+ end
1270
1352
  @focus == :session ? text.sub('↑↓ history', '↑↓ scroll · HOME · PGUP · PGDN · END') : text
1271
1353
  end
1272
1354
 
@@ -1295,6 +1377,7 @@ module PWN
1295
1377
  visible.each_with_index { |spans, index| put_spans(2 + index, @header_text_column, spans) }
1296
1378
  if @header_pane_height
1297
1379
  box(0, 0, height, height, '')
1380
+ mark_active(0, 0, height, 'PLAY') if @focus == :animation
1298
1381
  draw_banner(height - 2)
1299
1382
  end
1300
1383
  height
@@ -1338,6 +1421,8 @@ module PWN
1338
1421
  end
1339
1422
  engine = PWN::Env.dig(:ai, :active).to_s
1340
1423
  model = REPL.pwn_ai_engine_model(engine: engine)
1424
+ header_layout(engine, model)
1425
+ @focus = :mission if @focus == :animation && !@header_pane_height
1341
1426
  footer, compose_height, footer_height = layout_chrome
1342
1427
  @compose_height = compose_height
1343
1428
  @footer_height = footer_height
@@ -1359,9 +1444,9 @@ module PWN
1359
1444
  rows.slice(start, @page_size).to_a.each_with_index { |(color, row), index| put(header_height + 1 + index, 2, row, color) }
1360
1445
  draw_sidebar(header_height, pane_width + 1, timeline_height) if sidebar.positive?
1361
1446
  compose_y = @height - compose_height - footer_height
1362
- mission_title = @focus == :session ? 'MISSION CONTROL' : 'MISSION CONTROL · active'
1447
+ mission_title = @focus == :mission ? 'MISSION CONTROL · active' : 'MISSION CONTROL'
1363
1448
  box(compose_y, 0, compose_height, @width - 1, mission_title)
1364
- mark_active(compose_y, 0, @width - 1, mission_title) if @focus != :session
1449
+ mark_active(compose_y, 0, @width - 1, mission_title) if @focus == :mission
1365
1450
  draw_composer(compose_y, height: compose_height)
1366
1451
  footer_top = @height - footer_height
1367
1452
  footer.first(footer_height).each_with_index { |line, index| put(footer_top + index, 1, line, tone(:footer)) }
@@ -1370,7 +1455,9 @@ module PWN
1370
1455
  draw_details(engine, model) if @details
1371
1456
  draw_swarm if @workspace
1372
1457
  draw_system_role if @role_editor
1373
- if @focus == :session && !(@details || @workspace || @role_editor)
1458
+ if @focus == :animation && !(@details || @workspace || @role_editor || @model_prompt)
1459
+ @screen.setpos(1, 1)
1460
+ elsif @focus == :session && !(@details || @workspace || @role_editor)
1374
1461
  @screen.setpos(header_height + 1, 2)
1375
1462
  elsif @role_editor || !(@details || @workspace)
1376
1463
  @screen.setpos(*@cursor_position)
@@ -15,6 +15,7 @@ module PWN
15
15
 
16
16
  def stop
17
17
  @stopping = true
18
+ request_model_cancel
18
19
  submit('Stop this swarm job.')
19
20
  end
20
21
 
data/lib/pwn/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PWN
4
- VERSION = '0.5.759'
4
+ VERSION = '0.5.761'
5
5
  end
@@ -228,7 +228,7 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
228
228
  calls = []
229
229
  owner = Thread.current
230
230
  allow(described_class).to receive(:call_engine) do |opts|
231
- expect(Thread.current).to eq(owner)
231
+ expect(Thread.current).not_to eq(owner)
232
232
  calls << Marshal.load(Marshal.dump(opts[:messages]))
233
233
  if calls.length == 1
234
234
  entered << true