npcsh 1.1.20__py3-none-any.whl → 1.1.21__py3-none-any.whl

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 (166) hide show
  1. npcsh/_state.py +5 -71
  2. npcsh/diff_viewer.py +3 -3
  3. npcsh/npc_team/jinxs/lib/core/compress.jinx +373 -85
  4. npcsh/npc_team/jinxs/lib/core/search/db_search.jinx +17 -6
  5. npcsh/npc_team/jinxs/lib/core/search/file_search.jinx +17 -6
  6. npcsh/npc_team/jinxs/lib/core/search/kg_search.jinx +19 -8
  7. npcsh/npc_team/jinxs/lib/core/search/web_search.jinx +52 -14
  8. npcsh/npc_team/jinxs/{bin → lib/utils}/benchmark.jinx +2 -2
  9. npcsh/npc_team/jinxs/{bin → lib/utils}/jinxs.jinx +12 -12
  10. npcsh/npc_team/jinxs/{bin → lib/utils}/models.jinx +7 -7
  11. npcsh/npc_team/jinxs/{bin → lib/utils}/setup.jinx +6 -6
  12. npcsh/npc_team/jinxs/modes/alicanto.jinx +1573 -296
  13. npcsh/npc_team/jinxs/modes/arxiv.jinx +5 -5
  14. npcsh/npc_team/jinxs/modes/config_tui.jinx +300 -0
  15. npcsh/npc_team/jinxs/modes/corca.jinx +3 -3
  16. npcsh/npc_team/jinxs/modes/git.jinx +795 -0
  17. {npcsh-1.1.20.data/data/npcsh/npc_team → npcsh/npc_team/jinxs/modes}/kg.jinx +13 -13
  18. npcsh/npc_team/jinxs/modes/memories.jinx +414 -0
  19. npcsh/npc_team/jinxs/{bin → modes}/nql.jinx +10 -21
  20. npcsh/npc_team/jinxs/modes/papers.jinx +578 -0
  21. npcsh/npc_team/jinxs/modes/plonk.jinx +490 -304
  22. npcsh/npc_team/jinxs/modes/reattach.jinx +3 -3
  23. npcsh/npc_team/jinxs/modes/spool.jinx +3 -3
  24. npcsh/npc_team/jinxs/{bin → modes}/team.jinx +12 -12
  25. npcsh/npc_team/jinxs/modes/vixynt.jinx +388 -0
  26. npcsh/npc_team/jinxs/modes/wander.jinx +454 -181
  27. npcsh/npc_team/jinxs/modes/yap.jinx +10 -3
  28. npcsh/npcsh.py +112 -47
  29. npcsh/routes.py +4 -1
  30. npcsh/salmon_simulation.py +0 -0
  31. npcsh-1.1.21.data/data/npcsh/npc_team/alicanto.jinx +1633 -0
  32. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/arxiv.jinx +5 -5
  33. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/benchmark.jinx +2 -2
  34. npcsh-1.1.21.data/data/npcsh/npc_team/compress.jinx +428 -0
  35. npcsh-1.1.21.data/data/npcsh/npc_team/config_tui.jinx +300 -0
  36. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/corca.jinx +3 -3
  37. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/db_search.jinx +17 -6
  38. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/file_search.jinx +17 -6
  39. npcsh-1.1.21.data/data/npcsh/npc_team/git.jinx +795 -0
  40. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/jinxs.jinx +12 -12
  41. {npcsh/npc_team/jinxs/bin → npcsh-1.1.21.data/data/npcsh/npc_team}/kg.jinx +13 -13
  42. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/kg_search.jinx +19 -8
  43. npcsh-1.1.21.data/data/npcsh/npc_team/memories.jinx +414 -0
  44. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/models.jinx +7 -7
  45. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/nql.jinx +10 -21
  46. npcsh-1.1.21.data/data/npcsh/npc_team/papers.jinx +578 -0
  47. npcsh-1.1.21.data/data/npcsh/npc_team/plonk.jinx +565 -0
  48. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/reattach.jinx +3 -3
  49. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/setup.jinx +6 -6
  50. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/spool.jinx +3 -3
  51. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/team.jinx +12 -12
  52. npcsh-1.1.21.data/data/npcsh/npc_team/vixynt.jinx +388 -0
  53. npcsh-1.1.21.data/data/npcsh/npc_team/wander.jinx +728 -0
  54. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/web_search.jinx +52 -14
  55. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/yap.jinx +10 -3
  56. {npcsh-1.1.20.dist-info → npcsh-1.1.21.dist-info}/METADATA +2 -2
  57. {npcsh-1.1.20.dist-info → npcsh-1.1.21.dist-info}/RECORD +145 -150
  58. npcsh-1.1.21.dist-info/entry_points.txt +11 -0
  59. npcsh/npc_team/jinxs/bin/config_tui.jinx +0 -300
  60. npcsh/npc_team/jinxs/bin/memories.jinx +0 -317
  61. npcsh/npc_team/jinxs/bin/vixynt.jinx +0 -122
  62. npcsh/npc_team/jinxs/lib/core/search/mem_review.jinx +0 -73
  63. npcsh/npc_team/jinxs/lib/core/search/mem_search.jinx +0 -388
  64. npcsh/npc_team/jinxs/lib/research/paper_search.jinx +0 -412
  65. npcsh/npc_team/jinxs/lib/research/semantic_scholar.jinx +0 -386
  66. npcsh/npc_team/plonkjr.npc +0 -23
  67. npcsh-1.1.20.data/data/npcsh/npc_team/alicanto.jinx +0 -356
  68. npcsh-1.1.20.data/data/npcsh/npc_team/compress.jinx +0 -140
  69. npcsh-1.1.20.data/data/npcsh/npc_team/config_tui.jinx +0 -300
  70. npcsh-1.1.20.data/data/npcsh/npc_team/mem_review.jinx +0 -73
  71. npcsh-1.1.20.data/data/npcsh/npc_team/mem_search.jinx +0 -388
  72. npcsh-1.1.20.data/data/npcsh/npc_team/memories.jinx +0 -317
  73. npcsh-1.1.20.data/data/npcsh/npc_team/paper_search.jinx +0 -412
  74. npcsh-1.1.20.data/data/npcsh/npc_team/plonk.jinx +0 -379
  75. npcsh-1.1.20.data/data/npcsh/npc_team/plonkjr.npc +0 -23
  76. npcsh-1.1.20.data/data/npcsh/npc_team/semantic_scholar.jinx +0 -386
  77. npcsh-1.1.20.data/data/npcsh/npc_team/vixynt.jinx +0 -122
  78. npcsh-1.1.20.data/data/npcsh/npc_team/wander.jinx +0 -455
  79. npcsh-1.1.20.dist-info/entry_points.txt +0 -25
  80. /npcsh/npc_team/jinxs/lib/{orchestration → core}/convene.jinx +0 -0
  81. /npcsh/npc_team/jinxs/lib/{orchestration → core}/delegate.jinx +0 -0
  82. /npcsh/npc_team/jinxs/{bin → lib/core}/sample.jinx +0 -0
  83. /npcsh/npc_team/jinxs/{bin → lib/utils}/sync.jinx +0 -0
  84. /npcsh/npc_team/jinxs/{bin → modes}/roll.jinx +0 -0
  85. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/add_tab.jinx +0 -0
  86. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/alicanto.npc +0 -0
  87. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/alicanto.png +0 -0
  88. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/browser_action.jinx +0 -0
  89. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/browser_screenshot.jinx +0 -0
  90. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/build.jinx +0 -0
  91. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/chat.jinx +0 -0
  92. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/click.jinx +0 -0
  93. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/close_browser.jinx +0 -0
  94. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/close_pane.jinx +0 -0
  95. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/close_tab.jinx +0 -0
  96. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/cmd.jinx +0 -0
  97. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/compile.jinx +0 -0
  98. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/confirm.jinx +0 -0
  99. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/convene.jinx +0 -0
  100. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/corca.npc +0 -0
  101. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/corca.png +0 -0
  102. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/corca_example.png +0 -0
  103. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/delegate.jinx +0 -0
  104. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/edit_file.jinx +0 -0
  105. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/focus_pane.jinx +0 -0
  106. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/frederic.npc +0 -0
  107. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/frederic4.png +0 -0
  108. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/guac.jinx +0 -0
  109. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/guac.npc +0 -0
  110. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/guac.png +0 -0
  111. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/help.jinx +0 -0
  112. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/incognide.jinx +0 -0
  113. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/init.jinx +0 -0
  114. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/kadiefa.npc +0 -0
  115. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/kadiefa.png +0 -0
  116. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/key_press.jinx +0 -0
  117. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/launch_app.jinx +0 -0
  118. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/list_panes.jinx +0 -0
  119. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/load_file.jinx +0 -0
  120. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/navigate.jinx +0 -0
  121. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/notify.jinx +0 -0
  122. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/npcsh.ctx +0 -0
  123. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/npcsh_sibiji.png +0 -0
  124. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/open_browser.jinx +0 -0
  125. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/open_pane.jinx +0 -0
  126. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/ots.jinx +0 -0
  127. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/paste.jinx +0 -0
  128. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/plonk.npc +0 -0
  129. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/plonk.png +0 -0
  130. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/plonkjr.png +0 -0
  131. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/pti.jinx +0 -0
  132. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/python.jinx +0 -0
  133. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/read_pane.jinx +0 -0
  134. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/roll.jinx +0 -0
  135. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/run_terminal.jinx +0 -0
  136. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/sample.jinx +0 -0
  137. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/screenshot.jinx +0 -0
  138. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/search.jinx +0 -0
  139. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/send_message.jinx +0 -0
  140. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/serve.jinx +0 -0
  141. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/set.jinx +0 -0
  142. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/sh.jinx +0 -0
  143. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/shh.jinx +0 -0
  144. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/sibiji.npc +0 -0
  145. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/sibiji.png +0 -0
  146. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/sleep.jinx +0 -0
  147. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/split_pane.jinx +0 -0
  148. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/spool.png +0 -0
  149. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/sql.jinx +0 -0
  150. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/switch.jinx +0 -0
  151. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/switch_npc.jinx +0 -0
  152. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/switch_tab.jinx +0 -0
  153. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/switches.jinx +0 -0
  154. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/sync.jinx +0 -0
  155. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/teamviz.jinx +0 -0
  156. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/trigger.jinx +0 -0
  157. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/type_text.jinx +0 -0
  158. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/usage.jinx +0 -0
  159. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/verbose.jinx +0 -0
  160. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/wait.jinx +0 -0
  161. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/write_file.jinx +0 -0
  162. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/yap.png +0 -0
  163. {npcsh-1.1.20.data → npcsh-1.1.21.data}/data/npcsh/npc_team/zen_mode.jinx +0 -0
  164. {npcsh-1.1.20.dist-info → npcsh-1.1.21.dist-info}/WHEEL +0 -0
  165. {npcsh-1.1.20.dist-info → npcsh-1.1.21.dist-info}/licenses/LICENSE +0 -0
  166. {npcsh-1.1.20.dist-info → npcsh-1.1.21.dist-info}/top_level.txt +0 -0
@@ -1,288 +1,163 @@
1
1
  jinx_name: plonk
2
- description: Vision-based GUI automation - use vision model to interact with screen elements
2
+ description: Vision-based GUI automation TUI - visual task management and action automation
3
+ interactive: true
3
4
  inputs:
4
5
  - task: null
5
6
  - vmodel: null
6
7
  - vprovider: null
7
8
  - max_iterations: 10
8
- - debug: true
9
- - browse: false
9
+ - debug: false
10
10
 
11
11
  steps:
12
- - name: plonk_execute
12
+ - name: plonk_tui
13
13
  engine: python
14
14
  code: |
15
15
  import os
16
16
  import sys
17
17
  import tty
18
18
  import termios
19
+ import select
19
20
  import time
21
+ import json
20
22
  import platform
21
- from termcolor import colored
23
+ from datetime import datetime
22
24
 
23
25
  from npcpy.llm_funcs import get_llm_response
24
- from npcpy.data.image import capture_screenshot
25
- from npcpy.work.desktop import perform_action
26
+
27
+ try:
28
+ from npcpy.data.image import capture_screenshot
29
+ from npcpy.work.desktop import perform_action
30
+ VISION_AVAILABLE = True
31
+ except ImportError:
32
+ VISION_AVAILABLE = False
26
33
 
27
34
  npc = context.get('npc')
28
35
  team = context.get('team')
29
36
  messages = context.get('messages', [])
30
37
 
31
- # Resolve npc if it's a string (npc name) rather than NPC object
32
38
  if isinstance(npc, str) and team:
33
39
  npc = team.get(npc) if hasattr(team, 'get') else None
34
40
  elif isinstance(npc, str):
35
41
  npc = None
36
42
 
37
- # ========== TUI Helper Functions ==========
38
- def get_terminal_size():
43
+ vision_model = context.get('vmodel') or (npc.model if npc and hasattr(npc, 'model') else None)
44
+ vision_provider = context.get('vprovider') or (npc.provider if npc and hasattr(npc, 'provider') else None)
45
+ max_iterations = int(context.get('max_iterations', 10))
46
+
47
+ system_name = platform.system()
48
+ if system_name == "Windows":
49
+ app_examples = "start firefox, notepad, calc"
50
+ elif system_name == "Darwin":
51
+ app_examples = "open -a Firefox, open -a TextEdit"
52
+ else:
53
+ app_examples = "firefox &, gedit &, gnome-calculator &"
54
+
55
+ json_schema_example = '{"action":"click","x":50,"y":30,"reason":"Click the URL bar"}'
56
+
57
+ # ========== State ==========
58
+ class PlonkState:
59
+ def __init__(self):
60
+ self.tasks = [] # [{"text":str, "status":"pending|running|done|failed", "actions":[]}]
61
+ self.sel = 0
62
+ self.scroll = 0
63
+ self.panel = 0 # 0=tasks, 1=actions
64
+ self.mode = 'idle' # idle, input, running, paused, step
65
+ self.input_buf = ""
66
+ self.input_cursor = 0
67
+ self.current_task = -1
68
+ self.iteration = 0
69
+ self.max_iter = max_iterations
70
+ self.status = "Ready"
71
+ self.action_scroll = 0
72
+ self.last_screenshot = ""
73
+
74
+ ui = PlonkState()
75
+
76
+ # ========== Helpers ==========
77
+ def get_size():
39
78
  try:
40
- size = os.get_terminal_size()
41
- return size.columns, size.lines
79
+ s = os.get_terminal_size()
80
+ return s.columns, s.lines
42
81
  except:
43
82
  return 80, 24
44
83
 
45
- def plonk_tui_browser(summary, click_history):
46
- """Interactive TUI browser for plonk action history"""
47
- if not summary and not click_history:
48
- print(colored("No actions recorded.", "yellow"))
84
+ def add_task(text):
85
+ ui.tasks.append({"text": text.strip(), "status": "pending", "actions": []})
86
+
87
+ def delete_task():
88
+ if ui.tasks and ui.mode == 'idle':
89
+ del ui.tasks[ui.sel]
90
+ if ui.sel >= len(ui.tasks):
91
+ ui.sel = max(0, len(ui.tasks) - 1)
92
+
93
+ def get_current_actions():
94
+ if ui.panel == 1 and 0 <= ui.sel < len(ui.tasks):
95
+ return ui.tasks[ui.sel].get('actions', [])
96
+ if ui.current_task >= 0 and ui.current_task < len(ui.tasks):
97
+ return ui.tasks[ui.current_task].get('actions', [])
98
+ return []
99
+
100
+ # ========== Automation ==========
101
+ def run_one_step():
102
+ if ui.current_task < 0 or ui.current_task >= len(ui.tasks):
103
+ ui.mode = 'idle'
104
+ ui.status = "No task"
49
105
  return
50
106
 
51
- width, height = get_terminal_size()
52
- selected = 0
53
- scroll = 0
54
- list_height = height - 5
55
- mode = 'summary' # summary or history
56
-
57
- fd = sys.stdin.fileno()
58
- old_settings = termios.tcgetattr(fd)
59
-
60
- try:
61
- tty.setcbreak(fd)
62
- sys.stdout.write('\033[?25l')
63
- sys.stdout.write('\033[2J\033[H')
64
-
65
- while True:
66
- width, height = get_terminal_size()
67
- list_height = height - 5
68
-
69
- items = summary if mode == 'summary' else click_history
70
-
71
- if not items:
72
- items = [{'action': 'none', 'reason': 'No actions'}]
73
-
74
- if selected >= len(items):
75
- selected = max(0, len(items) - 1)
76
-
77
- if selected < scroll:
78
- scroll = selected
79
- elif selected >= scroll + list_height:
80
- scroll = selected - list_height + 1
81
-
82
- sys.stdout.write('\033[H')
107
+ task = ui.tasks[ui.current_task]
83
108
 
84
- # Tab bar
85
- summary_tab = '\033[44;37;1m Summary \033[0m' if mode == 'summary' else '\033[90m Summary \033[0m'
86
- history_tab = '\033[44;37;1m History \033[0m' if mode == 'history' else '\033[90m History \033[0m'
87
- header = f" PLONK ACTION BROWSER {summary_tab} {history_tab} "
88
- sys.stdout.write(f'\033[44;37;1m{" PLONK ACTION BROWSER".ljust(width)}\033[0m\n')
89
-
90
- if mode == 'summary':
91
- col_header = f' {"STEP":<6} {"ACTION":<12} {"COORDS":<15} {"REASON":<40}'
92
- else:
93
- col_header = f' {"#":<4} {"ACTION":<10} {"X":<6} {"Y":<6} {"DETAILS":<40}'
94
- sys.stdout.write(f'\033[90m{col_header[:width]}\033[0m\n')
95
-
96
- for i in range(list_height):
97
- idx = scroll + i
98
- sys.stdout.write(f'\033[{3+i};1H\033[K')
99
- if idx >= len(items):
100
- continue
101
-
102
- item = items[idx]
103
-
104
- if mode == 'summary':
105
- step = item.get('iteration', idx + 1)
106
- action = item.get('action', '?')[:12]
107
- coords = item.get('last_click_coords', 'N/A')[:15]
108
- reason = item.get('reason', item.get('error', ''))[:40]
109
- line = f" {step:<6} {action:<12} {coords:<15} {reason}"
110
- else:
111
- action = item.get('action', '?')[:10]
112
- x = str(item.get('x', '-'))[:6]
113
- y = str(item.get('y', '-'))[:6]
114
- details = item.get('reason', item.get('text', item.get('key', item.get('command', ''))))[:40]
115
- line = f" {idx+1:<4} {action:<10} {x:<6} {y:<6} {details}"
116
-
117
- line = line[:width-1]
118
-
119
- # Color by action type
120
- action_type = item.get('action', '')
121
- if action_type == 'done':
122
- color = '\033[32m' # green
123
- elif action_type == 'fail':
124
- color = '\033[31m' # red
125
- elif action_type == 'click':
126
- color = '\033[33m' # yellow
127
- elif action_type == 'type':
128
- color = '\033[36m' # cyan
129
- else:
130
- color = ''
131
-
132
- if idx == selected:
133
- sys.stdout.write(f'\033[7;1m>{line.ljust(width-2)}\033[0m')
134
- elif color:
135
- sys.stdout.write(f'{color}{line}\033[0m')
136
- else:
137
- sys.stdout.write(f' {line}')
138
-
139
- # Status bar
140
- sys.stdout.write(f'\033[{height-2};1H\033[K\033[90m{"─" * width}\033[0m')
141
- stats = f"Actions: {len(summary)} | Clicks: {len([h for h in click_history if h.get('action') == 'click'])}"
142
- sys.stdout.write(f'\033[{height-1};1H\033[K {stats}'.ljust(width)[:width])
143
- sys.stdout.write(f'\033[{height};1H\033[K\033[44;37m j/k:Nav Tab:Toggle Enter:Details q:Quit [{selected+1}/{len(items)}] \033[0m')
144
-
145
- sys.stdout.flush()
146
-
147
- c = sys.stdin.read(1)
148
-
149
- if c == '\x1b':
150
- c2 = sys.stdin.read(1)
151
- if c2 == '[':
152
- c3 = sys.stdin.read(1)
153
- if c3 == 'A' and selected > 0:
154
- selected -= 1
155
- elif c3 == 'B' and selected < len(items) - 1:
156
- selected += 1
157
- else:
158
- return
159
- continue
160
-
161
- if c == 'q' or c == '\x03':
162
- return
163
- elif c == 'k' and selected > 0:
164
- selected -= 1
165
- elif c == 'j' and selected < len(items) - 1:
166
- selected += 1
167
- elif c == '\t': # Tab to toggle
168
- mode = 'history' if mode == 'summary' else 'summary'
169
- selected = 0
170
- scroll = 0
171
- elif c in ('\r', '\n'):
172
- # Show full details
173
- item = items[selected]
174
- details = "\n".join(f"{k}: {v}" for k, v in item.items())
175
- print(f'\033[2J\033[H{details}\n\nPress any key to continue...')
176
- sys.stdout.flush()
177
- sys.stdin.read(1)
178
- sys.stdout.write('\033[2J\033[H')
179
-
180
- finally:
181
- termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
182
- sys.stdout.write('\033[?25h')
183
- sys.stdout.write('\033[2J\033[H')
184
- sys.stdout.flush()
185
-
186
- task = context.get('task')
187
- vision_model = context.get('vmodel') or (npc.model if npc and hasattr(npc, 'model') else 'gpt-4o')
188
- vision_provider = context.get('vprovider') or (npc.provider if npc and hasattr(npc, 'provider') else 'openai')
189
- max_iterations = int(context.get('max_iterations', 10))
190
- debug = context.get('debug', True)
191
-
192
- if not task:
193
- context['output'] = """Usage: /plonk <task description>
194
-
195
- Options:
196
- --vmodel MODEL Vision model to use (default: gpt-4o)
197
- --vprovider PROV Vision provider (default: openai)
198
- --max-iterations N Max steps (default: 10)
109
+ if ui.iteration >= ui.max_iter:
110
+ task['status'] = 'failed'
111
+ task['actions'].append({"action": "fail", "reason": "Max iterations reached"})
112
+ advance_to_next_task()
113
+ return
199
114
 
200
- Example: /plonk Open Firefox and navigate to google.com"""
201
- context['messages'] = messages
202
- exit()
203
-
204
- print(f"""
205
- ██████╗ ██╗ ██████╗ ███╗ ██╗██╗ ██╗
206
- ██╔══██╗██║ ██╔═══██╗████╗ ██║██║ ██╔╝
207
- ██████╔╝██║ ██║ ██║██╔██╗ ██║█████╔╝
208
- ██╔═══╝ ██║ ██║ ██║██║╚██╗██║██╔═██╗
209
- ██║ ███████╗╚██████╔╝██║ ╚████║██║ ██╗
210
- ╚═╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═══╝╚═╝ ╚═╝
211
-
212
- Vision GUI Automation
213
- Task: {task}
214
- Model: {vision_model} | Max iterations: {max_iterations}
215
- """)
216
-
217
- # System-specific examples
218
- system = platform.system()
219
- if system == "Windows":
220
- app_examples = "start firefox, notepad, calc"
221
- elif system == "Darwin":
222
- app_examples = "open -a Firefox, open -a TextEdit"
223
- else:
224
- app_examples = "firefox &, gedit &, gnome-calculator &"
115
+ if not VISION_AVAILABLE:
116
+ task['status'] = 'failed'
117
+ task['actions'].append({"action": "fail", "reason": "Vision/desktop modules not available"})
118
+ advance_to_next_task()
119
+ return
225
120
 
226
- # Action types
227
- ACTION_SCHEMA = {
228
- "type": "object",
229
- "properties": {
230
- "action": {
231
- "type": "string",
232
- "enum": ["click", "type", "key", "launch", "wait", "done", "fail"],
233
- "description": "Action to perform"
234
- },
235
- "x": {"type": "number", "description": "X coordinate (0-100 percentage)"},
236
- "y": {"type": "number", "description": "Y coordinate (0-100 percentage)"},
237
- "text": {"type": "string", "description": "Text to type or key to press"},
238
- "command": {"type": "string", "description": "Command to launch"},
239
- "duration": {"type": "number", "description": "Wait duration in seconds"},
240
- "reason": {"type": "string", "description": "Explanation of action"}
241
- },
242
- "required": ["action", "reason"]
243
- }
244
-
245
- click_history = []
246
- summary = []
247
-
248
- for iteration in range(max_iterations):
249
- print(colored(f"\n--- Iteration {iteration + 1}/{max_iterations} ---", "cyan"))
250
-
251
- # Capture screenshot
252
- ss = capture_screenshot()
253
- if not ss or 'file_path' not in ss:
254
- print(colored("Failed to capture screenshot", "red"))
255
- break
256
-
257
- screenshot_path = ss['file_path']
258
- if debug:
259
- print(colored(f"Screenshot: {screenshot_path}", "gray"))
260
-
261
- # Build context from history
262
- history_context = ""
263
- if click_history:
264
- history_context = f"\nPrevious actions ({len(click_history)}):\n"
265
- for i, click in enumerate(click_history[-5:], 1):
266
- history_context += f" {i}. {click.get('action', 'unknown')} at ({click.get('x', '?')}, {click.get('y', '?')}) - {click.get('reason', '')}\n"
267
-
268
- prompt = f"""You are a GUI automation assistant. Analyze this screenshot and determine the next action to complete the task.
269
-
270
- TASK: {task}
271
-
272
- {history_context}
273
-
274
- Available actions:
275
- - click: Click at x,y coordinates (0-100 percentage of screen)
276
- - type: Type text
277
- - key: Press key (enter, tab, escape, etc.)
278
- - launch: Launch application ({app_examples})
279
- - wait: Wait for duration seconds
280
- - done: Task completed successfully
281
- - fail: Task cannot be completed
282
-
283
- Respond with JSON: {{"action": "...", "x": N, "y": N, "text": "...", "command": "...", "duration": N, "reason": "..."}}"""
121
+ ui.iteration += 1
122
+ ui.status = "Capturing screen..."
123
+ render_screen()
284
124
 
285
125
  try:
126
+ ss = capture_screenshot()
127
+ if not ss or 'file_path' not in ss:
128
+ task['actions'].append({"action": "fail", "reason": "Screenshot failed"})
129
+ task['status'] = 'failed'
130
+ advance_to_next_task()
131
+ return
132
+
133
+ screenshot_path = ss['file_path']
134
+ ui.last_screenshot = screenshot_path
135
+
136
+ history_context = ""
137
+ if task['actions']:
138
+ history_context = "\nPrevious actions:\n"
139
+ for i, act in enumerate(task['actions'][-5:], 1):
140
+ history_context += " " + str(i) + ". " + act.get('action', '?')
141
+ if act.get('x'):
142
+ history_context += " at (" + str(act.get('x', '?')) + ", " + str(act.get('y', '?')) + ")"
143
+ history_context += " - " + act.get('reason', '') + "\n"
144
+
145
+ prompt = "You are a GUI automation assistant. Analyze this screenshot and determine the next action.\n\n"
146
+ prompt += "TASK: " + task['text'] + "\n"
147
+ prompt += history_context + "\n"
148
+ prompt += "Available actions:\n"
149
+ prompt += "- click: Click at x,y coordinates (0-100 percentage of screen)\n"
150
+ prompt += "- type: Type text (use 'text' field)\n"
151
+ prompt += "- key: Press key like enter, tab, escape (use 'text' field)\n"
152
+ prompt += "- launch: Launch application (use 'command' field, e.g. " + app_examples + ")\n"
153
+ prompt += "- wait: Wait for 'duration' seconds\n"
154
+ prompt += "- done: Task completed successfully\n"
155
+ prompt += "- fail: Task cannot be completed\n\n"
156
+ prompt += "Respond with JSON, e.g.: " + json_schema_example
157
+
158
+ ui.status = "Thinking... (iter " + str(ui.iteration) + "/" + str(ui.max_iter) + ")"
159
+ render_screen()
160
+
286
161
  resp = get_llm_response(
287
162
  prompt,
288
163
  model=vision_model,
@@ -294,86 +169,397 @@ steps:
294
169
 
295
170
  action_response = resp.get('response', {})
296
171
  if isinstance(action_response, str):
297
- import json
298
172
  try:
299
173
  action_response = json.loads(action_response)
300
174
  except:
301
- print(colored(f"Invalid JSON response: {action_response[:100]}", "red"))
302
- continue
175
+ task['actions'].append({"action": "error", "reason": "Invalid JSON from model"})
176
+ ui.status = "Bad response, retrying..."
177
+ return
303
178
 
304
179
  action = action_response.get('action', 'fail')
305
- reason = action_response.get('reason', 'No reason provided')
306
-
307
- print(colored(f"Action: {action} - {reason}", "yellow"))
180
+ reason = action_response.get('reason', '')
308
181
 
309
182
  if action == 'done':
310
- print(colored("Task completed successfully!", "green"))
311
- summary.append({"iteration": iteration + 1, "action": "done", "reason": reason})
312
- break
183
+ task['status'] = 'done'
184
+ task['actions'].append({"action": "done", "reason": reason})
185
+ advance_to_next_task()
186
+ return
313
187
 
314
188
  if action == 'fail':
315
- print(colored(f"Task failed: {reason}", "red"))
316
- summary.append({"iteration": iteration + 1, "action": "fail", "reason": reason})
317
- break
189
+ task['status'] = 'failed'
190
+ task['actions'].append({"action": "fail", "reason": reason})
191
+ advance_to_next_task()
192
+ return
193
+
194
+ # Execute the action
195
+ act_record = {"action": action, "reason": reason}
318
196
 
319
- # Execute action
320
197
  if action == 'click':
321
198
  x, y = action_response.get('x', 50), action_response.get('y', 50)
322
199
  perform_action('click', x=x, y=y)
323
- click_history.append({"action": "click", "x": x, "y": y, "reason": reason})
324
- print(colored(f"Clicked at ({x}, {y})", "green"))
325
-
200
+ act_record['x'] = x
201
+ act_record['y'] = y
326
202
  elif action == 'type':
327
- text = action_response.get('text', '')
328
- perform_action('type', text=text)
329
- click_history.append({"action": "type", "text": text[:20], "reason": reason})
330
- print(colored(f"Typed: {text[:30]}...", "green"))
331
-
203
+ txt = action_response.get('text', '')
204
+ perform_action('type', text=txt)
205
+ act_record['text'] = txt
332
206
  elif action == 'key':
333
207
  key = action_response.get('text', 'enter')
334
208
  perform_action('key', key=key)
335
- click_history.append({"action": "key", "key": key, "reason": reason})
336
- print(colored(f"Pressed key: {key}", "green"))
337
-
209
+ act_record['key'] = key
338
210
  elif action == 'launch':
339
211
  cmd = action_response.get('command', '')
340
212
  perform_action('launch', command=cmd)
341
- click_history.append({"action": "launch", "command": cmd, "reason": reason})
342
- print(colored(f"Launched: {cmd}", "green"))
343
- time.sleep(2) # Wait for app to open
344
-
213
+ act_record['command'] = cmd
214
+ time.sleep(2)
345
215
  elif action == 'wait':
346
- duration = action_response.get('duration', 1)
347
- time.sleep(duration)
348
- click_history.append({"action": "wait", "duration": duration, "reason": reason})
349
- print(colored(f"Waited {duration}s", "green"))
216
+ dur = action_response.get('duration', 1)
217
+ time.sleep(dur)
218
+ act_record['duration'] = dur
350
219
 
351
- summary.append({
352
- "iteration": iteration + 1,
353
- "action": action,
354
- "last_click_coords": f"({click_history[-1].get('x', 'N/A')}, {click_history[-1].get('y', 'N/A')})" if click_history else "N/A",
355
- "reason": reason
356
- })
220
+ task['actions'].append(act_record)
221
+ ui.status = action + " - " + reason[:40]
222
+ time.sleep(0.3)
357
223
 
358
- time.sleep(0.5) # Brief pause between actions
224
+ if ui.mode == 'step':
225
+ ui.mode = 'paused'
226
+ ui.status = "Paused (step done)"
359
227
 
360
228
  except Exception as e:
361
- print(colored(f"Error in iteration {iteration + 1}: {e}", "red"))
362
- summary.append({"iteration": iteration + 1, "error": str(e)})
363
-
364
- # Generate summary
365
- print("\n" + "="*50)
366
- print(colored("PLONK SESSION SUMMARY", "cyan", attrs=['bold']))
367
- print("="*50)
368
- for s in summary:
369
- print(f" Step {s.get('iteration', '?')}: {s.get('action', 'unknown')} - {s.get('reason', s.get('error', ''))[:60]}")
370
-
371
- context['output'] = f"Plonk completed with {len(summary)} actions"
372
- context['messages'] = messages
373
- context['plonk_summary'] = summary
374
- context['click_history'] = click_history
375
-
376
- # Launch interactive browser automatically
377
- if summary:
378
- print(colored("\nLaunching action browser...", "cyan"))
379
- plonk_tui_browser(summary, click_history)
229
+ task['actions'].append({"action": "error", "reason": str(e)})
230
+ ui.status = "Error: " + str(e)[:40]
231
+
232
+ def advance_to_next_task():
233
+ # Find next pending task
234
+ for i in range(len(ui.tasks)):
235
+ if ui.tasks[i]['status'] == 'pending':
236
+ start_task(i)
237
+ return
238
+ # All done
239
+ ui.mode = 'idle'
240
+ ui.current_task = -1
241
+ done_count = sum(1 for t in ui.tasks if t['status'] == 'done')
242
+ fail_count = sum(1 for t in ui.tasks if t['status'] == 'failed')
243
+ ui.status = "Complete: " + str(done_count) + " done, " + str(fail_count) + " failed"
244
+
245
+ def start_task(idx):
246
+ ui.current_task = idx
247
+ ui.tasks[idx]['status'] = 'running'
248
+ ui.iteration = 0
249
+ ui.mode = 'running'
250
+ ui.status = "Running: " + ui.tasks[idx]['text'][:30]
251
+
252
+ def start_selected():
253
+ if not ui.tasks:
254
+ return
255
+ if ui.tasks[ui.sel]['status'] in ('pending', 'failed'):
256
+ ui.tasks[ui.sel]['status'] = 'pending'
257
+ ui.tasks[ui.sel]['actions'] = []
258
+ start_task(ui.sel)
259
+
260
+ def run_all():
261
+ if not ui.tasks:
262
+ return
263
+ for t in ui.tasks:
264
+ if t['status'] != 'done':
265
+ t['status'] = 'pending'
266
+ t['actions'] = []
267
+ # Find first pending
268
+ for i, t in enumerate(ui.tasks):
269
+ if t['status'] == 'pending':
270
+ start_task(i)
271
+ return
272
+
273
+ # ========== Rendering ==========
274
+ def render_screen():
275
+ width, height = get_size()
276
+ out = []
277
+ out.append("\033[H")
278
+
279
+ # Header
280
+ mode_str = ui.mode.upper()
281
+ if ui.mode == 'running' and ui.current_task >= 0:
282
+ mode_str = "RUNNING [" + str(ui.iteration) + "/" + str(ui.max_iter) + "]"
283
+ header = " PLONK - Visual Task Automation "
284
+ mode_display = " [" + mode_str + "] "
285
+ out.append("\033[1;1H\033[7;1m" + header.ljust(width) + "\033[0m")
286
+ out.append("\033[1;" + str(width - len(mode_display) - 1) + "H\033[33;1m" + mode_display + "\033[0m")
287
+
288
+ # Split: top = tasks, bottom = actions
289
+ split = max(6, (height - 4) // 2)
290
+ task_h = split - 2
291
+ action_h = height - split - 4
292
+
293
+ # ── Tasks panel ──
294
+ task_label = " Tasks (" + str(len(ui.tasks)) + ") "
295
+ if ui.panel == 0:
296
+ out.append("\033[3;1H\033[36;1m" + task_label + "\033[90m" + ("-" * (width - len(task_label))) + "\033[0m")
297
+ else:
298
+ out.append("\033[3;1H\033[90m" + task_label + ("-" * (width - len(task_label))) + "\033[0m")
299
+
300
+ if ui.mode == 'input':
301
+ # Show input line
302
+ out.append("\033[4;1H\033[K New task: \033[7m " + ui.input_buf + " \033[0m")
303
+ for i in range(1, task_h):
304
+ out.append("\033[" + str(4+i) + ";1H\033[K")
305
+ elif not ui.tasks:
306
+ out.append("\033[4;1H\033[K\033[90m No tasks. Press 'a' to add a task.\033[0m")
307
+ for i in range(1, task_h):
308
+ out.append("\033[" + str(4+i) + ";1H\033[K")
309
+ else:
310
+ if ui.sel < ui.scroll:
311
+ ui.scroll = ui.sel
312
+ elif ui.sel >= ui.scroll + task_h:
313
+ ui.scroll = ui.sel - task_h + 1
314
+
315
+ for i in range(task_h):
316
+ idx = ui.scroll + i
317
+ row = 4 + i
318
+ out.append("\033[" + str(row) + ";1H\033[K")
319
+ if idx >= len(ui.tasks):
320
+ continue
321
+
322
+ t = ui.tasks[idx]
323
+ status = t['status']
324
+ icon = {"pending": "\033[90m-", "running": "\033[33m>", "done": "\033[32m+", "failed": "\033[31mx"}.get(status, " ")
325
+ action_count = str(len(t.get('actions', [])))
326
+ text = t['text'][:width - 25]
327
+
328
+ line = " " + icon + "\033[0m " + text + " \033[90m[" + status + "] (" + action_count + " acts)\033[0m"
329
+
330
+ if idx == ui.sel and ui.panel == 0:
331
+ out.append("\033[7m>" + line + "\033[0m")
332
+ else:
333
+ out.append(" " + line)
334
+
335
+ # ── Actions panel ──
336
+ action_row = 3 + split
337
+ acts = get_current_actions()
338
+ act_label = " Actions (" + str(len(acts)) + ") "
339
+ if ui.panel == 1:
340
+ out.append("\033[" + str(action_row) + ";1H\033[36;1m" + act_label + "\033[90m" + ("-" * (width - len(act_label))) + "\033[0m")
341
+ else:
342
+ out.append("\033[" + str(action_row) + ";1H\033[90m" + act_label + ("-" * (width - len(act_label))) + "\033[0m")
343
+
344
+ if not acts:
345
+ out.append("\033[" + str(action_row+1) + ";1H\033[K\033[90m No actions yet.\033[0m")
346
+ for i in range(1, action_h):
347
+ out.append("\033[" + str(action_row+1+i) + ";1H\033[K")
348
+ else:
349
+ if ui.action_scroll < 0:
350
+ ui.action_scroll = 0
351
+
352
+ for i in range(action_h):
353
+ idx = ui.action_scroll + i
354
+ row = action_row + 1 + i
355
+ out.append("\033[" + str(row) + ";1H\033[K")
356
+ if idx >= len(acts):
357
+ continue
358
+
359
+ a = acts[idx]
360
+ action = a.get('action', '?')
361
+ reason = a.get('reason', '')[:width - 35]
362
+ coords = ""
363
+ if a.get('x') is not None:
364
+ coords = "(" + str(a.get('x', '')) + "," + str(a.get('y', '')) + ") "
365
+ elif a.get('text'):
366
+ coords = '"' + str(a['text'])[:15] + '" '
367
+ elif a.get('key'):
368
+ coords = '[' + str(a['key']) + '] '
369
+ elif a.get('command'):
370
+ coords = str(a['command'])[:20] + ' '
371
+
372
+ act_color = {"click": "\033[33m", "type": "\033[36m", "key": "\033[35m",
373
+ "launch": "\033[34m", "done": "\033[32m", "fail": "\033[31m",
374
+ "error": "\033[31m", "wait": "\033[90m"}.get(action, "")
375
+
376
+ line = " " + str(idx+1) + ". " + act_color + action + "\033[0m " + coords + "\033[90m" + reason + "\033[0m"
377
+
378
+ if idx == ui.action_scroll and ui.panel == 1:
379
+ out.append("\033[7m " + line + "\033[0m")
380
+ else:
381
+ out.append(" " + line)
382
+
383
+ # ── Status bar ──
384
+ out.append("\033[" + str(height-2) + ";1H\033[K\033[90m" + ("-" * width) + "\033[0m")
385
+ status_line = " " + ui.status + " | Model: " + str(vision_model) + " | Max: " + str(ui.max_iter)
386
+ out.append("\033[" + str(height-1) + ";1H\033[K" + status_line[:width])
387
+
388
+ # ── Footer ──
389
+ if ui.mode == 'input':
390
+ footer = " Type task, Enter:Confirm Esc:Cancel "
391
+ elif ui.mode in ('running', 'step'):
392
+ footer = " p:Pause s:Step Q:Abort Tab:Panel j/k:Scroll "
393
+ elif ui.mode == 'paused':
394
+ footer = " r:Resume s:Step Q:Abort Tab:Panel j/k:Scroll "
395
+ else:
396
+ footer = " a:Add d:Delete Enter:Run R:RunAll Tab:Panel j/k:Nav q:Quit "
397
+ out.append("\033[" + str(height) + ";1H\033[K\033[7m" + footer.ljust(width) + "\033[0m")
398
+
399
+ sys.stdout.write(''.join(out))
400
+ sys.stdout.flush()
401
+
402
+ # ========== Input Handling ==========
403
+ def handle_input(c, fd):
404
+ if ui.mode == 'input':
405
+ return handle_input_mode(c, fd)
406
+
407
+ # Escape sequences
408
+ if c == '\x1b':
409
+ if select.select([fd], [], [], 0.05)[0]:
410
+ c2 = os.read(fd, 1).decode('latin-1')
411
+ if c2 == '[':
412
+ c3 = os.read(fd, 1).decode('latin-1')
413
+ if c3 == 'A': # Up
414
+ nav_up()
415
+ elif c3 == 'B': # Down
416
+ nav_down()
417
+ else:
418
+ # Bare Esc
419
+ if ui.mode == 'paused':
420
+ ui.mode = 'idle'
421
+ ui.status = "Aborted"
422
+ if ui.current_task >= 0 and ui.current_task < len(ui.tasks):
423
+ ui.tasks[ui.current_task]['status'] = 'failed'
424
+ ui.current_task = -1
425
+ return True
426
+
427
+ if c == 'q' and ui.mode == 'idle':
428
+ return False
429
+ if c == 'Q':
430
+ # Abort running
431
+ if ui.mode in ('running', 'paused', 'step'):
432
+ ui.mode = 'idle'
433
+ if ui.current_task >= 0 and ui.current_task < len(ui.tasks):
434
+ ui.tasks[ui.current_task]['status'] = 'failed'
435
+ ui.current_task = -1
436
+ ui.status = "Aborted"
437
+ elif ui.mode == 'idle':
438
+ return False
439
+ return True
440
+
441
+ if c == 'j':
442
+ nav_down()
443
+ elif c == 'k':
444
+ nav_up()
445
+ elif c == '\t':
446
+ ui.panel = 1 - ui.panel
447
+ ui.action_scroll = 0
448
+ elif c == 'a' and ui.mode == 'idle':
449
+ ui.mode = 'input'
450
+ ui.input_buf = ""
451
+ ui.input_cursor = 0
452
+ elif c == 'd' and ui.mode == 'idle':
453
+ delete_task()
454
+ elif c in ('\r', '\n') and ui.mode == 'idle':
455
+ start_selected()
456
+ elif c == 'R' and ui.mode == 'idle':
457
+ run_all()
458
+ elif c == 'p' and ui.mode == 'running':
459
+ ui.mode = 'paused'
460
+ ui.status = "Paused"
461
+ elif c == 'r' and ui.mode == 'paused':
462
+ ui.mode = 'running'
463
+ ui.status = "Resumed"
464
+ elif c == 's' and ui.mode in ('paused', 'idle'):
465
+ if ui.current_task >= 0:
466
+ ui.mode = 'step'
467
+ elif ui.tasks and ui.tasks[ui.sel]['status'] in ('pending', 'failed'):
468
+ ui.tasks[ui.sel]['actions'] = []
469
+ start_task(ui.sel)
470
+ ui.mode = 'step'
471
+
472
+ return True
473
+
474
+ def handle_input_mode(c, fd):
475
+ if c == '\x1b':
476
+ # Cancel input
477
+ if select.select([fd], [], [], 0.05)[0]:
478
+ os.read(fd, 2) # consume rest of escape seq
479
+ ui.mode = 'idle'
480
+ ui.input_buf = ""
481
+ return True
482
+
483
+ if c in ('\r', '\n'):
484
+ if ui.input_buf.strip():
485
+ add_task(ui.input_buf)
486
+ ui.sel = len(ui.tasks) - 1
487
+ ui.mode = 'idle'
488
+ ui.input_buf = ""
489
+ return True
490
+
491
+ if c == '\x7f' or c == '\x08': # Backspace
492
+ if ui.input_cursor > 0:
493
+ ui.input_buf = ui.input_buf[:ui.input_cursor-1] + ui.input_buf[ui.input_cursor:]
494
+ ui.input_cursor -= 1
495
+ elif c >= ' ' and c <= '~':
496
+ ui.input_buf = ui.input_buf[:ui.input_cursor] + c + ui.input_buf[ui.input_cursor:]
497
+ ui.input_cursor += 1
498
+
499
+ return True
500
+
501
+ def nav_up():
502
+ if ui.panel == 0:
503
+ ui.sel = max(0, ui.sel - 1)
504
+ else:
505
+ ui.action_scroll = max(0, ui.action_scroll - 1)
506
+
507
+ def nav_down():
508
+ if ui.panel == 0:
509
+ ui.sel = min(max(0, len(ui.tasks) - 1), ui.sel + 1)
510
+ else:
511
+ acts = get_current_actions()
512
+ ui.action_scroll = min(max(0, len(acts) - 1), ui.action_scroll + 1)
513
+
514
+ # ========== Auto-add task from CLI ==========
515
+ task_arg = context.get('task')
516
+ if task_arg:
517
+ add_task(str(task_arg))
518
+
519
+ # ========== Main Loop ==========
520
+ if not sys.stdin.isatty():
521
+ context['output'] = "Plonk requires an interactive terminal."
522
+ else:
523
+ fd = sys.stdin.fileno()
524
+ old_settings = termios.tcgetattr(fd)
525
+
526
+ try:
527
+ tty.setcbreak(fd)
528
+ sys.stdout.write('\033[?25l')
529
+ sys.stdout.write('\033[2J')
530
+ render_screen()
531
+
532
+ running = True
533
+ while running:
534
+ if ui.mode in ('running', 'step'):
535
+ # Non-blocking check for user input
536
+ if select.select([fd], [], [], 0.05)[0]:
537
+ c = os.read(fd, 1).decode('latin-1')
538
+ running = handle_input(c, fd)
539
+ else:
540
+ run_one_step()
541
+ else:
542
+ # Blocking wait for input
543
+ c = os.read(fd, 1).decode('latin-1')
544
+ running = handle_input(c, fd)
545
+
546
+ render_screen()
547
+
548
+ finally:
549
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
550
+ sys.stdout.write('\033[?25h')
551
+ sys.stdout.write('\033[2J\033[H')
552
+ sys.stdout.flush()
553
+
554
+ # Summary output
555
+ if ui.tasks:
556
+ lines = ["PLONK SESSION SUMMARY", "=" * 40]
557
+ for i, t in enumerate(ui.tasks):
558
+ lines.append(str(i+1) + ". [" + t['status'] + "] " + t['text'])
559
+ for j, a in enumerate(t.get('actions', [])):
560
+ lines.append(" " + str(j+1) + ". " + a.get('action', '?') + " - " + a.get('reason', '')[:50])
561
+ context['output'] = "\n".join(lines)
562
+ else:
563
+ context['output'] = "Plonk session ended."
564
+
565
+ context['messages'] = messages