npcsh 1.1.22__py3-none-any.whl → 1.1.23__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 (172) hide show
  1. npcsh/_state.py +272 -120
  2. npcsh/benchmark/npcsh_agent.py +77 -240
  3. npcsh/benchmark/templates/install-npcsh.sh.j2 +12 -4
  4. npcsh/config.py +5 -2
  5. npcsh/npc_team/alicanto.npc +4 -8
  6. npcsh/npc_team/corca.npc +5 -11
  7. npcsh/npc_team/frederic.npc +4 -6
  8. npcsh/npc_team/guac.npc +4 -4
  9. npcsh/npc_team/jinxs/lib/core/delegate.jinx +1 -1
  10. npcsh/npc_team/jinxs/lib/core/edit_file.jinx +1 -1
  11. npcsh/npc_team/jinxs/lib/core/sh.jinx +1 -1
  12. npcsh/npc_team/jinxs/lib/core/skill.jinx +59 -0
  13. npcsh/npc_team/jinxs/lib/utils/help.jinx +194 -10
  14. npcsh/npc_team/jinxs/lib/utils/init.jinx +528 -37
  15. npcsh/npc_team/jinxs/lib/utils/jinxs.jinx +0 -1
  16. npcsh/npc_team/jinxs/lib/utils/serve.jinx +938 -21
  17. npcsh-1.1.22.data/data/npcsh/npc_team/config_tui.jinx → npcsh/npc_team/jinxs/modes/config.jinx +1 -1
  18. npcsh/npc_team/jinxs/modes/convene.jinx +76 -3
  19. npcsh/npc_team/jinxs/modes/crond.jinx +818 -0
  20. npcsh/npc_team/jinxs/modes/plonk.jinx +76 -14
  21. npcsh/npc_team/jinxs/modes/roll.jinx +368 -55
  22. npcsh/npc_team/jinxs/modes/skills.jinx +621 -0
  23. npcsh/npc_team/jinxs/modes/yap.jinx +504 -30
  24. npcsh/npc_team/jinxs/skills/code-review/SKILL.md +45 -0
  25. npcsh/npc_team/jinxs/skills/debugging/SKILL.md +44 -0
  26. npcsh/npc_team/jinxs/skills/git-workflow.jinx +44 -0
  27. npcsh/npc_team/kadiefa.npc +4 -5
  28. npcsh/npc_team/npcsh.ctx +16 -0
  29. npcsh/npc_team/plonk.npc +5 -9
  30. npcsh/npc_team/sibiji.npc +13 -5
  31. npcsh/npcsh.py +1 -0
  32. npcsh/routes.py +0 -4
  33. npcsh/yap.py +22 -4
  34. npcsh-1.1.23.data/data/npcsh/npc_team/SKILL.md +44 -0
  35. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/alicanto.npc +4 -8
  36. npcsh/npc_team/jinxs/modes/config_tui.jinx → npcsh-1.1.23.data/data/npcsh/npc_team/config.jinx +1 -1
  37. npcsh-1.1.23.data/data/npcsh/npc_team/convene.jinx +670 -0
  38. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/corca.npc +5 -11
  39. npcsh-1.1.23.data/data/npcsh/npc_team/crond.jinx +818 -0
  40. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/delegate.jinx +1 -1
  41. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/edit_file.jinx +1 -1
  42. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/frederic.npc +4 -6
  43. npcsh-1.1.23.data/data/npcsh/npc_team/git-workflow.jinx +44 -0
  44. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/guac.npc +4 -4
  45. npcsh-1.1.23.data/data/npcsh/npc_team/help.jinx +236 -0
  46. npcsh-1.1.23.data/data/npcsh/npc_team/init.jinx +532 -0
  47. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/jinxs.jinx +0 -1
  48. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/kadiefa.npc +4 -5
  49. npcsh-1.1.23.data/data/npcsh/npc_team/npcsh.ctx +34 -0
  50. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/plonk.jinx +76 -14
  51. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/plonk.npc +5 -9
  52. npcsh-1.1.23.data/data/npcsh/npc_team/roll.jinx +378 -0
  53. npcsh-1.1.23.data/data/npcsh/npc_team/serve.jinx +943 -0
  54. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/sh.jinx +1 -1
  55. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/sibiji.npc +13 -5
  56. npcsh-1.1.23.data/data/npcsh/npc_team/skill.jinx +59 -0
  57. npcsh-1.1.23.data/data/npcsh/npc_team/skills.jinx +621 -0
  58. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/yap.jinx +504 -30
  59. {npcsh-1.1.22.dist-info → npcsh-1.1.23.dist-info}/METADATA +168 -7
  60. npcsh-1.1.23.dist-info/RECORD +216 -0
  61. npcsh/npc_team/jinxs/incognide/add_tab.jinx +0 -11
  62. npcsh/npc_team/jinxs/incognide/close_pane.jinx +0 -9
  63. npcsh/npc_team/jinxs/incognide/close_tab.jinx +0 -10
  64. npcsh/npc_team/jinxs/incognide/confirm.jinx +0 -10
  65. npcsh/npc_team/jinxs/incognide/focus_pane.jinx +0 -9
  66. npcsh/npc_team/jinxs/incognide/list_panes.jinx +0 -8
  67. npcsh/npc_team/jinxs/incognide/navigate.jinx +0 -10
  68. npcsh/npc_team/jinxs/incognide/notify.jinx +0 -10
  69. npcsh/npc_team/jinxs/incognide/open_pane.jinx +0 -13
  70. npcsh/npc_team/jinxs/incognide/read_pane.jinx +0 -9
  71. npcsh/npc_team/jinxs/incognide/run_terminal.jinx +0 -10
  72. npcsh/npc_team/jinxs/incognide/send_message.jinx +0 -10
  73. npcsh/npc_team/jinxs/incognide/split_pane.jinx +0 -12
  74. npcsh/npc_team/jinxs/incognide/switch_npc.jinx +0 -10
  75. npcsh/npc_team/jinxs/incognide/switch_tab.jinx +0 -10
  76. npcsh/npc_team/jinxs/incognide/write_file.jinx +0 -11
  77. npcsh/npc_team/jinxs/incognide/zen_mode.jinx +0 -9
  78. npcsh/npc_team/jinxs/lib/core/convene.jinx +0 -232
  79. npcsh-1.1.22.data/data/npcsh/npc_team/add_tab.jinx +0 -11
  80. npcsh-1.1.22.data/data/npcsh/npc_team/close_pane.jinx +0 -9
  81. npcsh-1.1.22.data/data/npcsh/npc_team/close_tab.jinx +0 -10
  82. npcsh-1.1.22.data/data/npcsh/npc_team/confirm.jinx +0 -10
  83. npcsh-1.1.22.data/data/npcsh/npc_team/convene.jinx +0 -232
  84. npcsh-1.1.22.data/data/npcsh/npc_team/focus_pane.jinx +0 -9
  85. npcsh-1.1.22.data/data/npcsh/npc_team/help.jinx +0 -52
  86. npcsh-1.1.22.data/data/npcsh/npc_team/init.jinx +0 -41
  87. npcsh-1.1.22.data/data/npcsh/npc_team/list_panes.jinx +0 -8
  88. npcsh-1.1.22.data/data/npcsh/npc_team/navigate.jinx +0 -10
  89. npcsh-1.1.22.data/data/npcsh/npc_team/notify.jinx +0 -10
  90. npcsh-1.1.22.data/data/npcsh/npc_team/npcsh.ctx +0 -18
  91. npcsh-1.1.22.data/data/npcsh/npc_team/open_pane.jinx +0 -13
  92. npcsh-1.1.22.data/data/npcsh/npc_team/read_pane.jinx +0 -9
  93. npcsh-1.1.22.data/data/npcsh/npc_team/roll.jinx +0 -65
  94. npcsh-1.1.22.data/data/npcsh/npc_team/run_terminal.jinx +0 -10
  95. npcsh-1.1.22.data/data/npcsh/npc_team/send_message.jinx +0 -10
  96. npcsh-1.1.22.data/data/npcsh/npc_team/serve.jinx +0 -26
  97. npcsh-1.1.22.data/data/npcsh/npc_team/split_pane.jinx +0 -12
  98. npcsh-1.1.22.data/data/npcsh/npc_team/switch_npc.jinx +0 -10
  99. npcsh-1.1.22.data/data/npcsh/npc_team/switch_tab.jinx +0 -10
  100. npcsh-1.1.22.data/data/npcsh/npc_team/write_file.jinx +0 -11
  101. npcsh-1.1.22.data/data/npcsh/npc_team/zen_mode.jinx +0 -9
  102. npcsh-1.1.22.dist-info/RECORD +0 -240
  103. /npcsh/npc_team/jinxs/{incognide → lib/utils}/incognide.jinx +0 -0
  104. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/alicanto.jinx +0 -0
  105. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/alicanto.png +0 -0
  106. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/arxiv.jinx +0 -0
  107. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/benchmark.jinx +0 -0
  108. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/browser_action.jinx +0 -0
  109. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/browser_screenshot.jinx +0 -0
  110. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/build.jinx +0 -0
  111. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/chat.jinx +0 -0
  112. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/click.jinx +0 -0
  113. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/close_browser.jinx +0 -0
  114. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/cmd.jinx +0 -0
  115. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/compile.jinx +0 -0
  116. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/compress.jinx +0 -0
  117. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/corca.jinx +0 -0
  118. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/corca.png +0 -0
  119. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/corca_example.png +0 -0
  120. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/db_search.jinx +0 -0
  121. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/file_search.jinx +0 -0
  122. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/frederic4.png +0 -0
  123. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/git.jinx +0 -0
  124. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/guac.jinx +0 -0
  125. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/guac.png +0 -0
  126. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/incognide.jinx +0 -0
  127. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/kadiefa.png +0 -0
  128. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/key_press.jinx +0 -0
  129. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/kg.jinx +0 -0
  130. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/launch_app.jinx +0 -0
  131. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/load_file.jinx +0 -0
  132. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/memories.jinx +0 -0
  133. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/models.jinx +0 -0
  134. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/npcsh_sibiji.png +0 -0
  135. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/nql.jinx +0 -0
  136. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/open_browser.jinx +0 -0
  137. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/ots.jinx +0 -0
  138. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/papers.jinx +0 -0
  139. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/paste.jinx +0 -0
  140. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/plonk.png +0 -0
  141. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/plonkjr.png +0 -0
  142. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/pti.jinx +0 -0
  143. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/python.jinx +0 -0
  144. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/reattach.jinx +0 -0
  145. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/sample.jinx +0 -0
  146. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/screenshot.jinx +0 -0
  147. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/set.jinx +0 -0
  148. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/setup.jinx +0 -0
  149. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/shh.jinx +0 -0
  150. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/sibiji.png +0 -0
  151. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/sleep.jinx +0 -0
  152. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/spool.jinx +0 -0
  153. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/spool.png +0 -0
  154. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/sql.jinx +0 -0
  155. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/switch.jinx +0 -0
  156. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/switches.jinx +0 -0
  157. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/sync.jinx +0 -0
  158. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/team.jinx +0 -0
  159. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/teamviz.jinx +0 -0
  160. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/trigger.jinx +0 -0
  161. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/type_text.jinx +0 -0
  162. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/usage.jinx +0 -0
  163. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/verbose.jinx +0 -0
  164. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/vixynt.jinx +0 -0
  165. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/wait.jinx +0 -0
  166. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/wander.jinx +0 -0
  167. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/web_search.jinx +0 -0
  168. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/yap.png +0 -0
  169. {npcsh-1.1.22.dist-info → npcsh-1.1.23.dist-info}/WHEEL +0 -0
  170. {npcsh-1.1.22.dist-info → npcsh-1.1.23.dist-info}/entry_points.txt +0 -0
  171. {npcsh-1.1.22.dist-info → npcsh-1.1.23.dist-info}/licenses/LICENSE +0 -0
  172. {npcsh-1.1.22.dist-info → npcsh-1.1.23.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,818 @@
1
+ jinx_name: crond
2
+ description: System task manager TUI - cron jobs, daemons, and processes
3
+ interactive: true
4
+ inputs: []
5
+ steps:
6
+ - name: crond_tui
7
+ engine: python
8
+ code: |
9
+ import os
10
+ import sys
11
+ import tty
12
+ import termios
13
+ import select
14
+ import subprocess
15
+ import signal
16
+
17
+ if not sys.stdin.isatty():
18
+ context['output'] = "Crond requires an interactive terminal."
19
+
20
+ else:
21
+ # ── TUI state ────────────────────────────────────────
22
+ class TUIState:
23
+ def __init__(self):
24
+ self.tab = 0
25
+ self.tabs = ['Cron', 'Daemons', 'Processes']
26
+ self.sel = 0
27
+ self.scroll = 0
28
+ self.items = []
29
+ self.search_mode = False
30
+ self.search_buf = ""
31
+ self.search_query = ""
32
+ self.detail = False
33
+ self.detail_lines = []
34
+ self.detail_scroll = 0
35
+ self.status = ""
36
+ self.confirm_action = None # (action_name, callback)
37
+ self.input_mode = False
38
+ self.input_buf = ""
39
+ self.input_label = ""
40
+ self.input_callback = None
41
+ self.sort_key = 'cpu' # for processes tab
42
+
43
+ ui = TUIState()
44
+
45
+ def term_size():
46
+ try:
47
+ s = os.get_terminal_size()
48
+ return s.columns, s.lines
49
+ except:
50
+ return 80, 24
51
+
52
+ def run_cmd(cmd):
53
+ try:
54
+ r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=10)
55
+ return r.stdout.strip(), r.stderr.strip(), r.returncode
56
+ except Exception as e:
57
+ return "", str(e), 1
58
+
59
+ # ── data loading ─────────────────────────────────────
60
+ def load_cron():
61
+ out, err, rc = run_cmd("crontab -l 2>/dev/null")
62
+ items = []
63
+ if rc == 0 and out:
64
+ for i, line in enumerate(out.splitlines()):
65
+ line = line.strip()
66
+ if not line or line.startswith('#'):
67
+ continue
68
+ parts = line.split(None, 5)
69
+ if len(parts) >= 6:
70
+ sched = ' '.join(parts[:5])
71
+ cmd = parts[5]
72
+ else:
73
+ sched = ''
74
+ cmd = line
75
+ is_npcsh = '/.npcsh/jobs/' in line or 'npcsh' in line.lower()
76
+ items.append({
77
+ 'idx': i,
78
+ 'raw': line,
79
+ 'schedule': sched,
80
+ 'command': cmd,
81
+ 'npcsh': is_npcsh,
82
+ })
83
+ return items
84
+
85
+ def load_daemons():
86
+ out, err, rc = run_cmd("systemctl --user list-units --type=service --all --no-pager --no-legend 2>/dev/null")
87
+ items = []
88
+ if rc == 0 and out:
89
+ for line in out.splitlines():
90
+ parts = line.split(None, 4)
91
+ if len(parts) < 4:
92
+ continue
93
+ name = parts[0].replace('.service', '')
94
+ load_state = parts[1]
95
+ active = parts[2]
96
+ sub = parts[3]
97
+ desc = parts[4] if len(parts) > 4 else ''
98
+ is_npcsh = name.startswith('npcsh-')
99
+ items.append({
100
+ 'name': name,
101
+ 'load': load_state,
102
+ 'active': active,
103
+ 'sub': sub,
104
+ 'description': desc,
105
+ 'npcsh': is_npcsh,
106
+ })
107
+ return items
108
+
109
+ def load_processes():
110
+ out, err, rc = run_cmd("ps aux --no-headers 2>/dev/null")
111
+ items = []
112
+ if rc == 0 and out:
113
+ for line in out.splitlines():
114
+ parts = line.split(None, 10)
115
+ if len(parts) < 11:
116
+ continue
117
+ try:
118
+ cpu = float(parts[2])
119
+ mem = float(parts[3])
120
+ except:
121
+ cpu = 0.0
122
+ mem = 0.0
123
+ items.append({
124
+ 'user': parts[0],
125
+ 'pid': parts[1],
126
+ 'cpu': cpu,
127
+ 'mem': mem,
128
+ 'vsz': parts[4],
129
+ 'rss': parts[5],
130
+ 'tt': parts[6],
131
+ 'stat': parts[7],
132
+ 'start': parts[8],
133
+ 'time': parts[9],
134
+ 'command': parts[10],
135
+ 'name': os.path.basename(parts[10].split()[0]) if parts[10] else '',
136
+ })
137
+ return sort_processes(items)
138
+
139
+ def sort_processes(items):
140
+ if ui.sort_key == 'cpu':
141
+ items.sort(key=lambda x: x['cpu'], reverse=True)
142
+ elif ui.sort_key == 'mem':
143
+ items.sort(key=lambda x: x['mem'], reverse=True)
144
+ elif ui.sort_key == 'pid':
145
+ items.sort(key=lambda x: int(x['pid']))
146
+ elif ui.sort_key == 'name':
147
+ items.sort(key=lambda x: x['name'].lower())
148
+ return items
149
+
150
+ def load_tab_data():
151
+ if ui.tab == 0:
152
+ ui.items = load_cron()
153
+ elif ui.tab == 1:
154
+ ui.items = load_daemons()
155
+ elif ui.tab == 2:
156
+ ui.items = load_processes()
157
+ ui.sel = min(ui.sel, max(0, len(get_filtered()) - 1))
158
+
159
+ def get_filtered():
160
+ items = ui.items
161
+ if ui.search_query:
162
+ q = ui.search_query.lower()
163
+ if ui.tab == 0:
164
+ items = [x for x in items if q in x['command'].lower() or q in x['schedule'].lower()]
165
+ elif ui.tab == 1:
166
+ items = [x for x in items if q in x['name'].lower() or q in x['description'].lower()]
167
+ elif ui.tab == 2:
168
+ items = [x for x in items if q in x['name'].lower() or q in x['command'].lower() or q in x['user'].lower()]
169
+ return items
170
+
171
+ # ── rendering ────────────────────────────────────────
172
+ def wline(row, text):
173
+ return "\033[" + str(row) + ";1H\033[K" + text
174
+
175
+ def render():
176
+ W, H = term_size()
177
+ out = []
178
+ out.append("\033[H")
179
+
180
+ # ── header ──
181
+ hdr = " CROND - System Task Manager "
182
+ out.append(wline(1, "\033[7;1m" + hdr.ljust(W) + "\033[0m"))
183
+
184
+ # ── tabs ──
185
+ tb = ""
186
+ for i, t in enumerate(ui.tabs):
187
+ if i == ui.tab:
188
+ tb += "\033[7;1m [" + t + "] \033[0m"
189
+ else:
190
+ tb += " " + t + " "
191
+ out.append(wline(2, " " + tb))
192
+
193
+ # ── separator + count ──
194
+ out.append(wline(3, "\033[90m" + ("-" * W) + "\033[0m"))
195
+
196
+ filtered = get_filtered()
197
+ total = len(ui.items)
198
+ count = len(filtered)
199
+ if ui.search_query:
200
+ count_text = " " + str(count) + " matching (of " + str(total) + ') | search: "' + ui.search_query + '"'
201
+ else:
202
+ count_text = " " + str(count) + " " + ui.tabs[ui.tab].lower()
203
+
204
+ if ui.tab == 2:
205
+ count_text += " [sort: " + ui.sort_key + "]"
206
+
207
+ out.append(wline(4, count_text))
208
+ out.append(wline(5, "\033[90m" + ("-" * W) + "\033[0m"))
209
+
210
+ # ── body ──
211
+ body_start = 6
212
+ body_end = H - 3
213
+ body_h = max(1, body_end - body_start + 1)
214
+
215
+ if ui.detail:
216
+ render_detail(out, W, body_start, body_h)
217
+ elif ui.input_mode:
218
+ render_input(out, W, body_start, body_h)
219
+ else:
220
+ if ui.tab == 0:
221
+ render_cron_list(out, W, body_start, body_h, filtered)
222
+ elif ui.tab == 1:
223
+ render_daemon_list(out, W, body_start, body_h, filtered)
224
+ elif ui.tab == 2:
225
+ render_process_list(out, W, body_start, body_h, filtered)
226
+
227
+ # ── separator ──
228
+ out.append(wline(H - 2, "\033[90m" + ("-" * W) + "\033[0m"))
229
+
230
+ # ── status / search / confirm ──
231
+ if ui.confirm_action:
232
+ out.append(wline(H - 1, " \033[33m" + ui.confirm_action[0] + " (y/n)?\033[0m"))
233
+ elif ui.search_mode:
234
+ out.append(wline(H - 1, " \033[33m/\033[0m\033[1m" + ui.search_buf + "\033[0m\033[90m_\033[0m"))
235
+ elif ui.status:
236
+ out.append(wline(H - 1, " \033[33m" + ui.status[:W-2] + "\033[0m"))
237
+ else:
238
+ out.append(wline(H - 1, ""))
239
+
240
+ # ── footer ──
241
+ if ui.confirm_action:
242
+ foot = " [y] Confirm [n] Cancel "
243
+ elif ui.search_mode:
244
+ foot = " [Enter] Apply [Esc] Cancel "
245
+ elif ui.input_mode:
246
+ foot = " Type text, [Enter] Submit [Esc] Cancel "
247
+ elif ui.detail:
248
+ foot = " [j/k] Scroll [q/Esc] Back "
249
+ elif ui.tab == 0:
250
+ foot = " [Tab] Switch [j/k] Nav [Enter] Detail [n] New [e] Edit [d] Delete [/] Search [q] Quit "
251
+ elif ui.tab == 1:
252
+ foot = " [Tab] Switch [j/k] Nav [Enter] Detail [n] New [s/S] Start/Stop [r] Restart [d] Delete [l] Logs [/] Search [q] Quit "
253
+ elif ui.tab == 2:
254
+ foot = " [Tab] Switch [j/k] Nav [s] Sort [K] Kill [R] Refresh [/] Search [q] Quit "
255
+ out.append(wline(H, "\033[7m" + foot[:W].ljust(W) + "\033[0m"))
256
+
257
+ sys.stdout.write(''.join(out))
258
+ sys.stdout.flush()
259
+
260
+ def render_cron_list(out, W, start, body_h, filtered):
261
+ vis = filtered[ui.scroll:ui.scroll + body_h]
262
+ for r in range(body_h):
263
+ row = start + r
264
+ idx = r + ui.scroll
265
+ if r >= len(vis):
266
+ out.append(wline(row, ""))
267
+ continue
268
+ item = vis[r]
269
+ tag = "\033[35m[npc]\033[0m " if item['npcsh'] else " "
270
+ sched = item['schedule'][:20].ljust(20)
271
+ cmd = item['command'][:W - 32]
272
+ if idx == ui.sel:
273
+ line = " > " + sched + " " + cmd
274
+ out.append(wline(row, "\033[7m" + line[:W].ljust(W) + "\033[0m"))
275
+ else:
276
+ out.append(wline(row, " " + tag + sched + " \033[90m" + cmd + "\033[0m"))
277
+ if not filtered:
278
+ out.append(wline(start, " \033[90mNo cron jobs found.\033[0m"))
279
+ for r in range(1, body_h):
280
+ out.append(wline(start + r, ""))
281
+
282
+ def render_daemon_list(out, W, start, body_h, filtered):
283
+ vis = filtered[ui.scroll:ui.scroll + body_h]
284
+ for r in range(body_h):
285
+ row = start + r
286
+ idx = r + ui.scroll
287
+ if r >= len(vis):
288
+ out.append(wline(row, ""))
289
+ continue
290
+ item = vis[r]
291
+ tag = "\033[35m[npc]\033[0m " if item['npcsh'] else " "
292
+ # Color active state
293
+ if item['active'] == 'active':
294
+ state_color = "\033[32m"
295
+ elif item['active'] == 'failed':
296
+ state_color = "\033[31m"
297
+ else:
298
+ state_color = "\033[90m"
299
+ state_str = state_color + item['active'].ljust(10) + "\033[0m"
300
+ name = item['name'][:25].ljust(25)
301
+ desc = item['description'][:W - 50]
302
+ if idx == ui.sel:
303
+ line = " > " + item['name'][:25].ljust(25) + " " + item['active'].ljust(10) + " " + desc
304
+ out.append(wline(row, "\033[7m" + line[:W].ljust(W) + "\033[0m"))
305
+ else:
306
+ out.append(wline(row, " " + tag + name + " " + state_str + " \033[90m" + desc + "\033[0m"))
307
+ if not filtered:
308
+ out.append(wline(start, " \033[90mNo daemons found.\033[0m"))
309
+ for r in range(1, body_h):
310
+ out.append(wline(start + r, ""))
311
+
312
+ def render_process_list(out, W, start, body_h, filtered):
313
+ # Header row
314
+ hdr = " PID Name User CPU% MEM% Status"
315
+ out.append(wline(start, "\033[1m" + hdr[:W] + "\033[0m"))
316
+
317
+ vis = filtered[ui.scroll:ui.scroll + body_h - 1]
318
+ for r in range(body_h - 1):
319
+ row = start + 1 + r
320
+ idx = r + ui.scroll
321
+ if r >= len(vis):
322
+ out.append(wline(row, ""))
323
+ continue
324
+ item = vis[r]
325
+ pid = item['pid'][:8].ljust(8)
326
+ name = item['name'][:20].ljust(20)
327
+ user = item['user'][:12].ljust(12)
328
+ cpu = str(item['cpu'])[:6].ljust(6)
329
+ mem = str(item['mem'])[:6].ljust(6)
330
+ stat = item['stat'][:8]
331
+
332
+ # Color coding for CPU/MEM
333
+ cpu_color = "\033[31m" if item['cpu'] > 50 else ("\033[33m" if item['cpu'] > 20 else "")
334
+ mem_color = "\033[31m" if item['mem'] > 50 else ("\033[33m" if item['mem'] > 20 else "")
335
+ cpu_str = cpu_color + cpu + ("\033[0m" if cpu_color else "")
336
+ mem_str = mem_color + mem + ("\033[0m" if mem_color else "")
337
+
338
+ if idx == ui.sel:
339
+ line = " > " + pid + " " + name + " " + user + " " + str(item['cpu'])[:6].ljust(6) + " " + str(item['mem'])[:6].ljust(6) + " " + stat
340
+ out.append(wline(row, "\033[7m" + line[:W].ljust(W) + "\033[0m"))
341
+ else:
342
+ out.append(wline(row, " " + pid + " " + name + " " + user + " " + cpu_str + " " + mem_str + " \033[90m" + stat + "\033[0m"))
343
+ if not filtered:
344
+ out.append(wline(start + 1, " \033[90mNo processes found.\033[0m"))
345
+ for r in range(2, body_h):
346
+ out.append(wline(start + r, ""))
347
+
348
+ def render_detail(out, W, start, body_h):
349
+ vis = ui.detail_lines[ui.detail_scroll:ui.detail_scroll + body_h]
350
+ for r in range(body_h):
351
+ row = start + r
352
+ if r < len(vis):
353
+ out.append(wline(row, " " + vis[r][:W-4]))
354
+ else:
355
+ out.append(wline(row, ""))
356
+
357
+ def render_input(out, W, start, body_h):
358
+ out.append(wline(start, ""))
359
+ out.append(wline(start + 1, " \033[1m" + ui.input_label + "\033[0m"))
360
+ out.append(wline(start + 2, ""))
361
+ out.append(wline(start + 3, " > \033[7m " + ui.input_buf + " \033[0m"))
362
+ for r in range(4, body_h):
363
+ out.append(wline(start + r, ""))
364
+
365
+ # ── actions ──────────────────────────────────────────
366
+ def show_cron_detail():
367
+ filtered = get_filtered()
368
+ if not filtered:
369
+ return
370
+ item = filtered[ui.sel]
371
+ ui.detail_lines = [
372
+ "\033[1mCron Job Detail\033[0m",
373
+ "",
374
+ "\033[1mSchedule:\033[0m " + item['schedule'],
375
+ "\033[1mCommand:\033[0m " + item['command'],
376
+ "\033[1mRaw:\033[0m " + item['raw'],
377
+ "\033[1mNPCSH:\033[0m " + ("Yes" if item['npcsh'] else "No"),
378
+ ]
379
+ # Check for log file
380
+ if item['npcsh'] and '/.npcsh/jobs/' in item['command']:
381
+ import re
382
+ m = re.search(r'(~?/[^\s]+\.log)', item['command'])
383
+ if m:
384
+ log_path = os.path.expanduser(m.group(1))
385
+ ui.detail_lines.append("")
386
+ ui.detail_lines.append("\033[1mLog file:\033[0m " + log_path)
387
+ if os.path.isfile(log_path):
388
+ try:
389
+ with open(log_path) as lf:
390
+ tail = lf.readlines()[-20:]
391
+ ui.detail_lines.append("")
392
+ ui.detail_lines.append("\033[1mRecent log output:\033[0m")
393
+ for l in tail:
394
+ ui.detail_lines.append(" " + l.rstrip())
395
+ except:
396
+ pass
397
+ ui.detail = True
398
+ ui.detail_scroll = 0
399
+
400
+ def show_daemon_detail():
401
+ filtered = get_filtered()
402
+ if not filtered:
403
+ return
404
+ item = filtered[ui.sel]
405
+ out_text, _, _ = run_cmd("systemctl --user status " + item['name'] + ".service 2>/dev/null")
406
+ ui.detail_lines = [
407
+ "\033[1mDaemon Detail: " + item['name'] + "\033[0m",
408
+ "",
409
+ ]
410
+ for line in out_text.splitlines():
411
+ ui.detail_lines.append(line)
412
+ ui.detail = True
413
+ ui.detail_scroll = 0
414
+
415
+ def show_daemon_logs():
416
+ filtered = get_filtered()
417
+ if not filtered:
418
+ return
419
+ item = filtered[ui.sel]
420
+ out_text, _, _ = run_cmd("journalctl --user -u " + item['name'] + ".service -n 50 --no-pager 2>/dev/null")
421
+ ui.detail_lines = [
422
+ "\033[1mLogs: " + item['name'] + "\033[0m",
423
+ "",
424
+ ]
425
+ for line in out_text.splitlines():
426
+ ui.detail_lines.append(line)
427
+ ui.detail = True
428
+ ui.detail_scroll = 0
429
+
430
+ def delete_cron_job():
431
+ filtered = get_filtered()
432
+ if not filtered:
433
+ return
434
+ item = filtered[ui.sel]
435
+ out_text, _, rc = run_cmd("crontab -l 2>/dev/null")
436
+ if rc != 0:
437
+ ui.status = "Failed to read crontab"
438
+ return
439
+ lines = out_text.splitlines()
440
+ new_lines = [l for i, l in enumerate(lines) if i != item['idx']]
441
+ new_crontab = '\n'.join(new_lines) + '\n' if new_lines else ''
442
+ proc = subprocess.run("crontab -", shell=True, input=new_crontab, capture_output=True, text=True)
443
+ if proc.returncode == 0:
444
+ ui.status = "Deleted cron job"
445
+ load_tab_data()
446
+ else:
447
+ ui.status = "Failed to delete: " + proc.stderr[:40]
448
+
449
+ def edit_cron_job():
450
+ filtered = get_filtered()
451
+ if not filtered:
452
+ return
453
+ item = filtered[ui.sel]
454
+ ui.input_mode = True
455
+ ui.input_buf = item['raw']
456
+ ui.input_label = "Edit cron line:"
457
+ def on_submit(val):
458
+ out_text, _, rc = run_cmd("crontab -l 2>/dev/null")
459
+ if rc != 0:
460
+ ui.status = "Failed to read crontab"
461
+ return
462
+ lines = out_text.splitlines()
463
+ if item['idx'] < len(lines):
464
+ lines[item['idx']] = val
465
+ new_crontab = '\n'.join(lines) + '\n'
466
+ proc = subprocess.run("crontab -", shell=True, input=new_crontab, capture_output=True, text=True)
467
+ if proc.returncode == 0:
468
+ ui.status = "Updated cron job"
469
+ load_tab_data()
470
+ else:
471
+ ui.status = "Failed to update: " + proc.stderr[:40]
472
+ ui.input_callback = on_submit
473
+
474
+ def new_cron_job():
475
+ ui.input_mode = True
476
+ ui.input_buf = ""
477
+ ui.input_label = "Describe the cron job in natural language:"
478
+ def on_submit(val):
479
+ try:
480
+ from npcpy.work.plan import execute_plan_command
481
+ npc_obj = context.get('npc')
482
+ result = execute_plan_command(
483
+ "Create a cron job that: " + val,
484
+ npc_obj,
485
+ context.get('messages', []),
486
+ )
487
+ if isinstance(result, dict):
488
+ ui.status = result.get('output', 'Cron job created')[:60]
489
+ else:
490
+ ui.status = str(result)[:60]
491
+ load_tab_data()
492
+ except Exception as e:
493
+ ui.status = "Error: " + str(e)[:50]
494
+ ui.input_callback = on_submit
495
+
496
+ def new_daemon():
497
+ ui.input_mode = True
498
+ ui.input_buf = ""
499
+ ui.input_label = "Describe the daemon in natural language:"
500
+ def on_submit(val):
501
+ try:
502
+ from npcpy.work.trigger import execute_trigger_command
503
+ npc_obj = context.get('npc')
504
+ result = execute_trigger_command(
505
+ "Create a daemon that: " + val,
506
+ npc_obj,
507
+ context.get('messages', []),
508
+ )
509
+ if isinstance(result, dict):
510
+ ui.status = result.get('output', 'Daemon created')[:60]
511
+ else:
512
+ ui.status = str(result)[:60]
513
+ load_tab_data()
514
+ except Exception as e:
515
+ ui.status = "Error: " + str(e)[:50]
516
+ ui.input_callback = on_submit
517
+
518
+ def daemon_start():
519
+ filtered = get_filtered()
520
+ if not filtered:
521
+ return
522
+ name = filtered[ui.sel]['name']
523
+ _, err, rc = run_cmd("systemctl --user start " + name + ".service")
524
+ ui.status = ("Started " + name) if rc == 0 else ("Failed: " + err[:40])
525
+ load_tab_data()
526
+
527
+ def daemon_stop():
528
+ filtered = get_filtered()
529
+ if not filtered:
530
+ return
531
+ name = filtered[ui.sel]['name']
532
+ _, err, rc = run_cmd("systemctl --user stop " + name + ".service")
533
+ ui.status = ("Stopped " + name) if rc == 0 else ("Failed: " + err[:40])
534
+ load_tab_data()
535
+
536
+ def daemon_restart():
537
+ filtered = get_filtered()
538
+ if not filtered:
539
+ return
540
+ name = filtered[ui.sel]['name']
541
+ _, err, rc = run_cmd("systemctl --user restart " + name + ".service")
542
+ ui.status = ("Restarted " + name) if rc == 0 else ("Failed: " + err[:40])
543
+ load_tab_data()
544
+
545
+ def delete_daemon():
546
+ filtered = get_filtered()
547
+ if not filtered:
548
+ return
549
+ name = filtered[ui.sel]['name']
550
+ run_cmd("systemctl --user stop " + name + ".service")
551
+ run_cmd("systemctl --user disable " + name + ".service")
552
+ svc_path = os.path.expanduser("~/.config/systemd/user/" + name + ".service")
553
+ if os.path.isfile(svc_path):
554
+ os.remove(svc_path)
555
+ run_cmd("systemctl --user daemon-reload")
556
+ ui.status = "Deleted daemon: " + name
557
+ load_tab_data()
558
+
559
+ def kill_process():
560
+ filtered = get_filtered()
561
+ if not filtered:
562
+ return
563
+ pid = filtered[ui.sel]['pid']
564
+ try:
565
+ os.kill(int(pid), signal.SIGTERM)
566
+ ui.status = "Sent SIGTERM to PID " + pid
567
+ except Exception as e:
568
+ ui.status = "Kill failed: " + str(e)[:40]
569
+ load_tab_data()
570
+
571
+ # ── input handling ─────────────────────────────────────
572
+ def handle(c):
573
+ if ui.confirm_action:
574
+ return handle_confirm(c)
575
+ if ui.search_mode:
576
+ return handle_search(c)
577
+ if ui.input_mode:
578
+ return handle_text_input(c)
579
+ if ui.detail:
580
+ return handle_detail(c)
581
+ if c == '\x1b':
582
+ return handle_esc()
583
+
584
+ if c == 'q':
585
+ return False
586
+ elif c == '\t':
587
+ ui.tab = (ui.tab + 1) % len(ui.tabs)
588
+ ui.sel = 0
589
+ ui.scroll = 0
590
+ ui.detail = False
591
+ ui.search_query = ""
592
+ ui.status = ""
593
+ load_tab_data()
594
+ elif c == 'j':
595
+ nav_down()
596
+ elif c == 'k':
597
+ nav_up()
598
+ elif c in ('\r', '\n'):
599
+ do_enter()
600
+ elif c == '/':
601
+ ui.search_mode = True
602
+ ui.search_buf = ui.search_query
603
+ ui.status = ""
604
+ # Tab-specific keys
605
+ elif ui.tab == 0:
606
+ handle_cron_key(c)
607
+ elif ui.tab == 1:
608
+ handle_daemon_key(c)
609
+ elif ui.tab == 2:
610
+ handle_process_key(c)
611
+ return True
612
+
613
+ def handle_cron_key(c):
614
+ if c == 'n':
615
+ new_cron_job()
616
+ elif c == 'e':
617
+ edit_cron_job()
618
+ elif c == 'd':
619
+ filtered = get_filtered()
620
+ if filtered:
621
+ ui.confirm_action = ("Delete cron job: " + filtered[ui.sel]['command'][:30], delete_cron_job)
622
+
623
+ def handle_daemon_key(c):
624
+ if c == 'n':
625
+ new_daemon()
626
+ elif c == 's':
627
+ daemon_start()
628
+ elif c == 'S':
629
+ daemon_stop()
630
+ elif c == 'r':
631
+ daemon_restart()
632
+ elif c == 'l':
633
+ show_daemon_logs()
634
+ elif c == 'd':
635
+ filtered = get_filtered()
636
+ if filtered:
637
+ ui.confirm_action = ("Delete daemon: " + filtered[ui.sel]['name'], delete_daemon)
638
+
639
+ def handle_process_key(c):
640
+ if c == 's':
641
+ cycle = ['cpu', 'mem', 'pid', 'name']
642
+ idx = cycle.index(ui.sort_key) if ui.sort_key in cycle else 0
643
+ ui.sort_key = cycle[(idx + 1) % len(cycle)]
644
+ ui.items = sort_processes(ui.items)
645
+ ui.status = "Sorted by " + ui.sort_key
646
+ elif c == 'K':
647
+ filtered = get_filtered()
648
+ if filtered:
649
+ ui.confirm_action = ("Kill PID " + filtered[ui.sel]['pid'] + " (" + filtered[ui.sel]['name'] + ")", kill_process)
650
+ elif c == 'R':
651
+ load_tab_data()
652
+ ui.status = "Refreshed"
653
+
654
+ def handle_esc():
655
+ if select.select([fd], [], [], 0.05)[0]:
656
+ c2 = os.read(fd, 1).decode('latin-1')
657
+ if c2 == '[':
658
+ c3 = os.read(fd, 1).decode('latin-1')
659
+ if c3 == 'A':
660
+ nav_up()
661
+ elif c3 == 'B':
662
+ nav_down()
663
+ else:
664
+ if ui.search_query:
665
+ ui.search_query = ""
666
+ ui.sel = 0
667
+ ui.scroll = 0
668
+ ui.status = "Search cleared"
669
+ return True
670
+
671
+ def handle_detail(c):
672
+ if c == '\x1b':
673
+ if select.select([fd], [], [], 0.05)[0]:
674
+ c2 = os.read(fd, 1).decode('latin-1')
675
+ if c2 == '[':
676
+ c3 = os.read(fd, 1).decode('latin-1')
677
+ if c3 == 'A':
678
+ ui.detail_scroll = max(0, ui.detail_scroll - 1)
679
+ elif c3 == 'B':
680
+ ui.detail_scroll += 1
681
+ else:
682
+ ui.detail = False
683
+ ui.detail_scroll = 0
684
+ return True
685
+ if c == 'q':
686
+ ui.detail = False
687
+ ui.detail_scroll = 0
688
+ elif c == 'j':
689
+ ui.detail_scroll += 1
690
+ elif c == 'k':
691
+ ui.detail_scroll = max(0, ui.detail_scroll - 1)
692
+ return True
693
+
694
+ def handle_confirm(c):
695
+ if c == 'y':
696
+ cb = ui.confirm_action[1]
697
+ ui.confirm_action = None
698
+ cb()
699
+ elif c == 'n' or c == '\x1b':
700
+ ui.confirm_action = None
701
+ ui.status = "Cancelled"
702
+ return True
703
+
704
+ def handle_search(c):
705
+ if c == '\x1b':
706
+ if select.select([fd], [], [], 0.05)[0]:
707
+ c2 = os.read(fd, 1).decode('latin-1')
708
+ if c2 == '[':
709
+ os.read(fd, 1).decode('latin-1')
710
+ else:
711
+ ui.search_mode = False
712
+ ui.search_buf = ""
713
+ ui.status = "Search cancelled"
714
+ elif c in ('\r', '\n'):
715
+ ui.search_mode = False
716
+ ui.search_query = ui.search_buf
717
+ ui.search_buf = ""
718
+ ui.sel = 0
719
+ ui.scroll = 0
720
+ filtered = get_filtered()
721
+ if ui.search_query:
722
+ ui.status = 'Filter: "' + ui.search_query + '" (' + str(len(filtered)) + " results)"
723
+ else:
724
+ ui.status = "Search cleared"
725
+ elif c in ('\x7f', '\x08'):
726
+ ui.search_buf = ui.search_buf[:-1]
727
+ elif c == '\x15':
728
+ ui.search_buf = ""
729
+ elif 32 <= ord(c) <= 126:
730
+ ui.search_buf += c
731
+ return True
732
+
733
+ def handle_text_input(c):
734
+ if c == '\x1b':
735
+ if select.select([fd], [], [], 0.05)[0]:
736
+ c2 = os.read(fd, 1).decode('latin-1')
737
+ if c2 == '[':
738
+ os.read(fd, 1).decode('latin-1')
739
+ else:
740
+ ui.input_mode = False
741
+ ui.input_buf = ""
742
+ ui.input_callback = None
743
+ ui.status = "Cancelled"
744
+ elif c in ('\r', '\n'):
745
+ val = ui.input_buf
746
+ cb = ui.input_callback
747
+ ui.input_mode = False
748
+ ui.input_buf = ""
749
+ ui.input_callback = None
750
+ if cb and val.strip():
751
+ cb(val)
752
+ elif c in ('\x7f', '\x08'):
753
+ ui.input_buf = ui.input_buf[:-1]
754
+ elif c == '\x15':
755
+ ui.input_buf = ""
756
+ elif 32 <= ord(c) <= 126:
757
+ ui.input_buf += c
758
+ return True
759
+
760
+ def nav_up():
761
+ if ui.sel > 0:
762
+ ui.sel -= 1
763
+ if ui.sel < ui.scroll:
764
+ ui.scroll = ui.sel
765
+ ui.status = ""
766
+
767
+ def nav_down():
768
+ _, H = term_size()
769
+ body_h = max(1, H - 8)
770
+ if ui.tab == 2:
771
+ body_h -= 1 # account for header row
772
+ filtered = get_filtered()
773
+ mx = max(0, len(filtered) - 1)
774
+ if ui.sel < mx:
775
+ ui.sel += 1
776
+ if ui.sel >= ui.scroll + body_h:
777
+ ui.scroll = ui.sel - body_h + 1
778
+ ui.status = ""
779
+
780
+ def do_enter():
781
+ if ui.tab == 0:
782
+ show_cron_detail()
783
+ elif ui.tab == 1:
784
+ show_daemon_detail()
785
+
786
+ # ── main loop ──────────────────────────────────────────
787
+ load_tab_data()
788
+
789
+ fd = sys.stdin.fileno()
790
+ old_attrs = termios.tcgetattr(fd)
791
+
792
+ try:
793
+ tty.setcbreak(fd)
794
+ sys.stdout.write('\033[?25l')
795
+ sys.stdout.write('\033[2J\033[H')
796
+ sys.stdout.flush()
797
+ render()
798
+ while True:
799
+ # Auto-refresh on processes tab using select timeout
800
+ if ui.tab == 2 and not ui.detail and not ui.search_mode and not ui.input_mode and not ui.confirm_action:
801
+ ready, _, _ = select.select([fd], [], [], 2.0)
802
+ if not ready:
803
+ load_tab_data()
804
+ render()
805
+ continue
806
+ else:
807
+ ready, _, _ = select.select([fd], [], [], None)
808
+
809
+ c = os.read(fd, 1).decode('latin-1')
810
+ if not handle(c):
811
+ break
812
+ render()
813
+ finally:
814
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_attrs)
815
+ sys.stdout.write('\033[?25h\033[2J\033[H')
816
+ sys.stdout.flush()
817
+
818
+ context['output'] = "Crond closed."