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
npcsh/_state.py CHANGED
@@ -190,6 +190,8 @@ class ShellState:
190
190
  edit_approval: str = NPCSH_EDIT_APPROVAL
191
191
  # Pending file edits for approval
192
192
  pending_edits: Dict[str, Dict[str, str]] = field(default_factory=dict)
193
+ # Command history for jinx execution logging
194
+ command_history: Optional[Any] = None
193
195
 
194
196
  def get_model_for_command(self, model_type: str = "chat"):
195
197
  if model_type == "chat":
@@ -278,6 +280,9 @@ CONFIG_KEY_MAP = {
278
280
  "buildkg": "NPCSH_BUILD_KG",
279
281
  "editapproval": "NPCSH_EDIT_APPROVAL",
280
282
  "approval": "NPCSH_EDIT_APPROVAL",
283
+ "ttsengine": "NPCSH_TTS_ENGINE",
284
+ "ttsvoice": "NPCSH_TTS_VOICE",
285
+ "yapsetup": "NPCSH_YAP_SETUP_DONE",
281
286
  }
282
287
 
283
288
 
@@ -1671,10 +1676,19 @@ def _input_with_hint_below(prompt: str, state=None, router=None, token_hint: str
1671
1676
  try:
1672
1677
  import shutil
1673
1678
  term_width = shutil.get_terminal_size().columns
1674
- except json.JSONDecodeError:
1679
+ except Exception:
1675
1680
  term_width = 80
1676
1681
 
1682
+ # Track how many hint lines were drawn last time (for clearing on redraw)
1683
+ _prev_hint_lines = 1
1684
+
1685
+ # Tab completion state
1686
+ _tab_matches = []
1687
+ _tab_index = -1
1688
+ _tab_prefix = ""
1689
+
1677
1690
  def draw():
1691
+ nonlocal _prev_hint_lines
1678
1692
  # Calculate how many lines the input takes
1679
1693
  total_len = prompt_visible_len + len(buf)
1680
1694
  num_lines = (total_len // term_width) + 1
@@ -1688,18 +1702,21 @@ def _input_with_hint_below(prompt: str, state=None, router=None, token_hint: str
1688
1702
  for _ in range(num_lines - 1):
1689
1703
  sys.stdout.write('\033[A')
1690
1704
 
1691
- # Clear from cursor to end of screen (clears all wrapped lines + hint)
1705
+ # Clear from cursor to end of screen (clears all wrapped lines + all hint lines below)
1692
1706
  sys.stdout.write('\033[J')
1693
1707
 
1694
1708
  # Print prompt and buffer
1695
1709
  sys.stdout.write(prompt + buf)
1696
1710
 
1697
- # Print hint on next line
1698
- sys.stdout.write('\n\033[K' + current_hint())
1711
+ # Print hint on next line(s) - hint may contain newlines
1712
+ hint = current_hint()
1713
+ hint_line_count = hint.count('\n') + 1 if hint else 1
1714
+ _prev_hint_lines = hint_line_count
1715
+ sys.stdout.write('\n' + hint)
1699
1716
 
1700
1717
  # Now position cursor back to correct spot
1701
1718
  # Go back up to the line where cursor should be
1702
- lines_after_cursor = (total_len // term_width) - (cursor_total // term_width) + 1 # +1 for hint line
1719
+ lines_after_cursor = (total_len // term_width) - (cursor_total // term_width) + hint_line_count
1703
1720
  for _ in range(lines_after_cursor):
1704
1721
  sys.stdout.write('\033[A')
1705
1722
 
@@ -1924,6 +1941,9 @@ def _input_with_hint_below(prompt: str, state=None, router=None, token_hint: str
1924
1941
  if pos > 0:
1925
1942
  buf = buf[:pos-1] + buf[pos:]
1926
1943
  pos -= 1
1944
+ _tab_matches = []
1945
+ _tab_index = -1
1946
+ _tab_prefix = ""
1927
1947
  draw()
1928
1948
 
1929
1949
  elif c == '\x03': # Ctrl-C
@@ -1941,10 +1961,26 @@ def _input_with_hint_below(prompt: str, state=None, router=None, token_hint: str
1941
1961
  buf = ""
1942
1962
  pos = 0
1943
1963
  pasted_content = None
1944
- sys.stdout.write('\r\033[K') # Clear current line
1964
+ _tab_matches = []
1965
+ _tab_index = -1
1966
+ _tab_prefix = ""
1967
+ # Clear all previous hint lines + current input
1968
+ sys.stdout.write('\r')
1969
+ for _ in range(_prev_hint_lines):
1970
+ sys.stdout.write('\033[B')
1971
+ sys.stdout.write('\033[J') # Clear from cursor to end
1972
+ for _ in range(_prev_hint_lines):
1973
+ sys.stdout.write('\033[A')
1974
+ sys.stdout.write('\r\033[K')
1945
1975
  sys.stdout.write('^C\n')
1946
- # Redraw prompt
1947
- sys.stdout.write(prompt + '\n' + current_hint() + '\033[A\r')
1976
+ # Redraw prompt with fresh hint
1977
+ hint = current_hint()
1978
+ _prev_hint_lines = hint.count('\n') + 1 if hint else 1
1979
+ sys.stdout.write(prompt + '\n' + hint)
1980
+ # Go back up past all hint lines
1981
+ for _ in range(_prev_hint_lines):
1982
+ sys.stdout.write('\033[A')
1983
+ sys.stdout.write('\r')
1948
1984
  if prompt_visible_len > 0:
1949
1985
  sys.stdout.write('\033[' + str(prompt_visible_len) + 'C')
1950
1986
  sys.stdout.flush()
@@ -1981,8 +2017,55 @@ def _input_with_hint_below(prompt: str, state=None, router=None, token_hint: str
1981
2017
  pos -= 1
1982
2018
  draw()
1983
2019
 
1984
- elif c == '\t': # Tab - do nothing for now
1985
- pass
2020
+ elif c == '\t': # Tab - inline completion
2021
+ try:
2022
+ if _tab_matches and _tab_prefix == buf:
2023
+ # Subsequent Tab: cycle through matches
2024
+ _tab_index = (_tab_index + 1) % len(_tab_matches)
2025
+ buf = _tab_matches[_tab_index]
2026
+ pos = len(buf)
2027
+ draw()
2028
+ else:
2029
+ # First Tab: compute matches
2030
+ matches = []
2031
+ if buf.startswith('/'):
2032
+ cmds = _get_slash_commands_set(state, router, buf)
2033
+ matches = ['/' + c for c in sorted(cmds)]
2034
+ elif buf.startswith('@'):
2035
+ npcs = _get_npc_names_set(state, buf)
2036
+ matches = ['@' + n for n in sorted(npcs)]
2037
+ else:
2038
+ # File path completion for non-prefix input
2039
+ import glob as _glob
2040
+ pattern = buf + '*'
2041
+ matches = sorted(_glob.glob(pattern))
2042
+
2043
+ if len(matches) == 1:
2044
+ # Exact single match - autocomplete with trailing space
2045
+ buf = matches[0] + ' '
2046
+ pos = len(buf)
2047
+ _tab_matches = []
2048
+ _tab_index = -1
2049
+ _tab_prefix = ""
2050
+ draw()
2051
+ elif len(matches) > 1:
2052
+ # Find longest common prefix
2053
+ common = matches[0]
2054
+ for m in matches[1:]:
2055
+ while not m.startswith(common):
2056
+ common = common[:-1]
2057
+ if len(common) > len(buf):
2058
+ # Complete common prefix
2059
+ buf = common
2060
+ pos = len(buf)
2061
+ # Set up cycling state
2062
+ _tab_matches = matches
2063
+ _tab_index = -1
2064
+ _tab_prefix = buf
2065
+ draw()
2066
+ except Exception:
2067
+ pass
2068
+ continue
1986
2069
 
1987
2070
  elif c == '\x0f': # Ctrl-O - show last tool call args
1988
2071
  try:
@@ -2022,6 +2105,9 @@ def _input_with_hint_below(prompt: str, state=None, router=None, token_hint: str
2022
2105
  elif c and ord(c) >= 32: # Printable
2023
2106
  buf = buf[:pos] + c + buf[pos:]
2024
2107
  pos += 1
2108
+ _tab_matches = []
2109
+ _tab_index = -1
2110
+ _tab_prefix = ""
2025
2111
  draw()
2026
2112
 
2027
2113
  finally:
@@ -2030,42 +2116,66 @@ def _input_with_hint_below(prompt: str, state=None, router=None, token_hint: str
2030
2116
  termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
2031
2117
 
2032
2118
 
2033
- def _get_slash_hints(state, router, prefix='/') -> str:
2034
- """Slash command hints - fits terminal width."""
2035
- cmds = {'help', 'set', 'agent', 'chat', 'cmd', 'sq', 'quit', 'exit', 'clear', 'npc'}
2119
+ def _get_slash_commands_set(state, router, prefix='/') -> set:
2120
+ """Return the set of matching slash command names (without /)."""
2121
+ # Computer-use commands hidden from hints/tab-complete (still callable)
2122
+ _HIDDEN_CMDS = {
2123
+ 'browser_action', 'browser_screenshot', 'click', 'close_browser',
2124
+ 'key_press', 'launch_app', 'open_browser', 'screenshot',
2125
+ 'trigger', 'type_text', 'wait',
2126
+ }
2127
+ cmds = {'help', 'set', 'agent', 'chat', 'cmd', 'sq', 'quit', 'exit', 'clear', 'npc', 'reattach'}
2036
2128
  if state and state.team and hasattr(state.team, 'jinxs_dict'):
2037
2129
  cmds.update(state.team.jinxs_dict.keys())
2038
2130
  if router and hasattr(router, 'jinx_routes'):
2039
2131
  cmds.update(router.jinx_routes.keys())
2132
+ cmds -= _HIDDEN_CMDS
2040
2133
  if len(prefix) > 1:
2041
2134
  f = prefix[1:].lower()
2042
2135
  cmds = {c for c in cmds if c.lower().startswith(f)}
2136
+ return cmds
2137
+
2138
+
2139
+ def _layout_hints_multirow(items, term_width, style_fn) -> str:
2140
+ """Lay out hint items across multiple rows that fit terminal width."""
2141
+ if not items:
2142
+ return ""
2143
+ rows = []
2144
+ current_row = []
2145
+ current_len = 2 # leading spaces
2146
+ for item in items:
2147
+ needed = len(item) + 2 # item + spacing
2148
+ if current_len + needed > term_width - 2 and current_row:
2149
+ rows.append(current_row)
2150
+ current_row = [item]
2151
+ current_len = 2 + len(item) + 2
2152
+ else:
2153
+ current_row.append(item)
2154
+ current_len += needed
2155
+ if current_row:
2156
+ rows.append(current_row)
2157
+ return '\n'.join(style_fn(' ' + ' '.join(row)) for row in rows)
2158
+
2159
+
2160
+ def _get_slash_hints(state, router, prefix='/') -> str:
2161
+ """Slash command hints - shows ALL commands across multiple rows."""
2162
+ cmds = _get_slash_commands_set(state, router, prefix)
2043
2163
  if cmds:
2044
- # Get terminal width, default 80
2045
2164
  try:
2046
2165
  import shutil
2047
2166
  term_width = shutil.get_terminal_size().columns
2048
2167
  except Exception:
2049
2168
  term_width = 80
2050
-
2051
- # Build hint string that fits in terminal
2052
- sorted_cmds = sorted(cmds)
2053
- hint_parts = []
2054
- current_len = 2 # leading spaces
2055
- for c in sorted_cmds:
2056
- item = '/' + c
2057
- if current_len + len(item) + 2 > term_width - 5: # leave margin
2058
- break
2059
- hint_parts.append(item)
2060
- current_len += len(item) + 2
2061
-
2062
- if hint_parts:
2063
- return colored(' ' + ' '.join(hint_parts), 'white', attrs=['dark'])
2169
+ sorted_items = ['/' + c for c in sorted(cmds)]
2170
+ return _layout_hints_multirow(
2171
+ sorted_items, term_width,
2172
+ lambda s: colored(s, 'white', attrs=['dark'])
2173
+ )
2064
2174
  return ""
2065
2175
 
2066
2176
 
2067
- def _get_npc_hints(state, prefix='@') -> str:
2068
- """NPC hints."""
2177
+ def _get_npc_names_set(state, prefix='@') -> set:
2178
+ """Return the set of matching NPC names (without @)."""
2069
2179
  npcs = set()
2070
2180
  if state and state.team:
2071
2181
  if hasattr(state.team, 'npcs') and state.team.npcs:
@@ -2077,8 +2187,23 @@ def _get_npc_hints(state, prefix='@') -> str:
2077
2187
  if len(prefix) > 1:
2078
2188
  f = prefix[1:].lower()
2079
2189
  npcs = {n for n in npcs if n.lower().startswith(f)}
2190
+ return npcs
2191
+
2192
+
2193
+ def _get_npc_hints(state, prefix='@') -> str:
2194
+ """NPC hints - shows all NPCs across multiple rows."""
2195
+ npcs = _get_npc_names_set(state, prefix)
2080
2196
  if npcs:
2081
- return colored(' ' + ' '.join('@' + n for n in sorted(npcs)), 'cyan')
2197
+ try:
2198
+ import shutil
2199
+ term_width = shutil.get_terminal_size().columns
2200
+ except Exception:
2201
+ term_width = 80
2202
+ sorted_items = ['@' + n for n in sorted(npcs)]
2203
+ return _layout_hints_multirow(
2204
+ sorted_items, term_width,
2205
+ lambda s: colored(s, 'cyan')
2206
+ )
2082
2207
  return ""
2083
2208
 
2084
2209
 
@@ -2374,55 +2499,28 @@ def parse_generic_command_flags(parts: List[str]) -> Tuple[Dict[str, Any], List[
2374
2499
 
2375
2500
  return parsed_kwargs, positional_args
2376
2501
 
2377
- def _ollama_supports_tools(model: str) -> Optional[bool]:
2378
- """
2379
- Best-effort check for tool-call support on an Ollama model by inspecting its template/metadata.
2380
- Mirrors the lightweight check used in the Flask serve path.
2381
- """
2382
-
2383
-
2384
- try:
2385
- details = ollama.show(model)
2386
- template = details.get("template") or ""
2387
- metadata = details.get("metadata") or {}
2388
- if any(token in template for token in ["{{- if .Tools", "{{- range .Tools", "{{- if .ToolCalls"]):
2389
- return True
2390
- if metadata.get("tools") or metadata.get("tool_calls"):
2391
- return True
2392
- return False
2393
- except Exception:
2394
- return None
2395
-
2396
-
2397
2502
  def model_supports_tool_calls(model: Optional[str], provider: Optional[str]) -> bool:
2398
- """
2399
- Decide whether to attempt tool-calling for the given model/provider.
2400
- Uses Ollama template inspection when possible and falls back to name heuristics.
2401
- """
2503
+ """Check whether a model supports tool/function calling."""
2402
2504
  if not model:
2403
2505
  return False
2404
2506
 
2405
2507
  provider = (provider or "").lower()
2406
- model_lower = model.lower()
2407
2508
 
2509
+ # Ollama: use the capabilities field from the model metadata
2408
2510
  if provider == "ollama":
2409
- ollama_support = _ollama_supports_tools(model)
2410
- if ollama_support is not None:
2411
- return ollama_support
2412
-
2413
- toolish_markers = [
2414
- "gpt",
2415
- "claude",
2416
- "qwen",
2417
- "mistral",
2418
- "llama-3.1",
2419
- "llama3.1",
2420
- "llama-3.2",
2421
- "llama3.2",
2422
- "gemini",
2423
- "tool",
2424
- ]
2425
- return any(marker in model_lower for marker in toolish_markers)
2511
+ try:
2512
+ details = ollama.show(model)
2513
+ caps = getattr(details, "capabilities", None) or []
2514
+ return "tools" in caps
2515
+ except Exception:
2516
+ pass
2517
+
2518
+ # API providers: always support tools
2519
+ if provider in ("anthropic", "openai", "gemini", "google", "deepseek", "groq", "openrouter"):
2520
+ return True
2521
+
2522
+ # Unknown provider: assume yes
2523
+ return True
2426
2524
 
2427
2525
 
2428
2526
  def wrap_tool_with_display(tool_name: str, tool_func: Callable, state: ShellState) -> Callable:
@@ -2698,15 +2796,45 @@ def execute_slash_command(command: str,
2698
2796
  'vmodel': state.vision_model, 'vprovider': state.vision_provider, 'rmodel': state.reasoning_model,
2699
2797
  'rprovider': state.reasoning_provider, 'state': state
2700
2798
  }
2799
+ import time as _time
2800
+ _start = _time.monotonic()
2801
+ _status = "success"
2802
+ _error = None
2701
2803
  try:
2702
2804
  result = handler(command=command, **handler_kwargs)
2703
- if isinstance(result, dict):
2805
+ if isinstance(result, dict):
2704
2806
  state.messages = result.get("messages", state.messages)
2705
- return state, result
2706
2807
  except Exception as e:
2707
2808
  import traceback
2708
2809
  traceback.print_exc()
2709
- return state, {"output": colored(f"Error executing slash command '{command_name}': {e}", "red"), "messages": state.messages}
2810
+ _status = "error"
2811
+ _error = str(e)
2812
+ result = {"output": colored(f"Error executing slash command '{command_name}': {e}", "red"), "messages": state.messages}
2813
+
2814
+ _duration_ms = int((_time.monotonic() - _start) * 1000)
2815
+
2816
+ # Log jinx execution to jinx_execution_log
2817
+ if hasattr(state, 'command_history') and state.command_history is not None and hasattr(state.command_history, 'save_jinx_execution'):
2818
+ try:
2819
+ _npc_name = state.npc.name if isinstance(state.npc, NPC) else "npcsh"
2820
+ _team_name = state.team.name if state.team else "npcsh"
2821
+ _args = ' '.join(all_command_parts[1:]) if len(all_command_parts) > 1 else ''
2822
+ state.command_history.save_jinx_execution(
2823
+ triggering_message_id=None,
2824
+ conversation_id=state.conversation_id,
2825
+ npc_name=_npc_name,
2826
+ jinx_name=command_name,
2827
+ jinx_inputs={"command": command, "args": _args},
2828
+ jinx_output=result.get('output', '') if isinstance(result, dict) else str(result)[:2000],
2829
+ status=_status,
2830
+ team_name=_team_name,
2831
+ error_message=_error,
2832
+ duration_ms=_duration_ms,
2833
+ )
2834
+ except Exception:
2835
+ pass # Don't fail command execution due to logging error
2836
+
2837
+ return state, result
2710
2838
 
2711
2839
  # Fallback for switching NPC by name
2712
2840
  if state.team and command_name in state.team.npcs:
@@ -2755,46 +2883,13 @@ def process_pipeline_command(
2755
2883
  exec_provider = provider_override or npc_provider or state.chat_provider
2756
2884
 
2757
2885
  if cmd_to_process.startswith("/"):
2758
- command_name = cmd_to_process.split()[0].lstrip('/')
2759
-
2760
- # Check if this is an interactive mode
2761
- is_interactive_mode = False
2762
-
2763
- # Check if the jinx declares interactive: true
2764
- if router.is_interactive(command_name):
2765
- is_interactive_mode = True
2766
-
2767
- # Also check modes/ directory (legacy)
2768
- if not is_interactive_mode:
2769
- global_modes_jinx = os.path.expanduser(f'~/.npcsh/npc_team/jinxs/modes/{command_name}.jinx')
2770
- if os.path.exists(global_modes_jinx):
2771
- is_interactive_mode = True
2772
-
2773
- if not is_interactive_mode and state.team and state.team.team_path:
2774
- team_modes_jinx = os.path.join(state.team.team_path, 'jinxs', 'modes', f'{command_name}.jinx')
2775
- if os.path.exists(team_modes_jinx):
2776
- is_interactive_mode = True
2777
-
2778
- if is_interactive_mode:
2779
- result = execute_slash_command(
2780
- cmd_to_process,
2781
- stdin_input,
2782
- state,
2783
- stream_final,
2784
- router
2785
- )
2786
- else:
2787
- with SpinnerContext(
2788
- f"Routing to {cmd_to_process.split()[0]}",
2789
- style="arrow"
2790
- ):
2791
- result = execute_slash_command(
2792
- cmd_to_process,
2793
- stdin_input,
2794
- state,
2795
- stream_final,
2796
- router
2797
- )
2886
+ result = execute_slash_command(
2887
+ cmd_to_process,
2888
+ stdin_input,
2889
+ state,
2890
+ stream_final,
2891
+ router
2892
+ )
2798
2893
  return result
2799
2894
  cmd_parts = parse_command_safely(cmd_to_process)
2800
2895
  if not cmd_parts:
@@ -2954,10 +3049,22 @@ def process_pipeline_command(
2954
3049
  iteration = 0
2955
3050
  max_iterations = 50 # Safety limit to prevent infinite loops
2956
3051
  total_usage = {"input_tokens": 0, "output_tokens": 0}
3052
+ state._agent_nudges = 0 # Track continuation nudges
3053
+
3054
+ tool_calls_count = 0 # Track how many tool calls have been made
2957
3055
 
2958
3056
  while iteration < max_iterations:
2959
3057
  iteration += 1
2960
3058
 
3059
+ # After a tool has been called, force the model to generate a
3060
+ # text response on the NEXT iteration by setting tool_choice='none'.
3061
+ # This prevents models from endlessly repeating tool calls when
3062
+ # they already have the answer. For multi-step tasks, delegate
3063
+ # and convene jinxs have their own internal loops.
3064
+ iter_kwargs = dict(llm_kwargs)
3065
+ if tool_calls_count > 0:
3066
+ iter_kwargs["tool_choice"] = 'none'
3067
+
2961
3068
  llm_result = get_llm_response(
2962
3069
  full_llm_cmd if iteration == 1 else None, # Only pass prompt on first call
2963
3070
  model=exec_model,
@@ -2968,7 +3075,7 @@ def process_pipeline_command(
2968
3075
  stream=False, # Don't stream intermediate calls
2969
3076
  attachments=state.attachments if iteration == 1 else None,
2970
3077
  context=info if iteration == 1 else None,
2971
- **llm_kwargs,
3078
+ **iter_kwargs,
2972
3079
  )
2973
3080
 
2974
3081
  # Accumulate usage
@@ -2999,12 +3106,34 @@ def process_pipeline_command(
2999
3106
  else:
3000
3107
  render_markdown(tool_content)
3001
3108
 
3002
- # Check if LLM made tool calls - if not, it's done
3109
+ # Check if LLM made tool calls - if not, consider re-prompting
3003
3110
  tool_calls_made = isinstance(llm_result, dict) and llm_result.get("tool_calls")
3004
3111
  if not tool_calls_made:
3112
+ # In agent mode, nudge to use tools — but ONLY if no tools
3113
+ # have been called yet (model hasn't started working).
3114
+ # Once tools have been called and returned results,
3115
+ # the model should synthesize an answer, not call more tools.
3116
+ if (state.current_mode == 'agent'
3117
+ and tool_calls_count == 0
3118
+ and iteration < max_iterations
3119
+ and state._agent_nudges < 3):
3120
+ state._agent_nudges += 1
3121
+ state.messages.append({
3122
+ "role": "user",
3123
+ "content": (
3124
+ "You have not yet completed the task. "
3125
+ "Continue working by calling tools. "
3126
+ "Use the function calling interface to invoke tools - "
3127
+ "do NOT write tool calls as text."
3128
+ )
3129
+ })
3130
+ continue
3005
3131
  # LLM is done - no more tool calls
3006
3132
  break
3007
3133
 
3134
+ # Track tool calls and reset nudge counter
3135
+ tool_calls_count += 1
3136
+ state._agent_nudges = 0
3008
3137
  # Clear the prompt for continuation calls - context is in messages
3009
3138
  full_llm_cmd = None
3010
3139
 
@@ -3493,8 +3622,31 @@ def setup_shell() -> Tuple[CommandHistory, Team, Optional[NPC]]:
3493
3622
 
3494
3623
  forenpc_path = os.path.join(team_dir, f"{forenpc_name}.npc")
3495
3624
 
3496
- team = Team(team_path=team_dir, db_conn=command_history.engine)
3497
-
3625
+ try:
3626
+ team = Team(team_path=team_dir, db_conn=command_history.engine)
3627
+ except FileNotFoundError as e:
3628
+ print(f"Warning: Team compilation failed - {e}")
3629
+ print("Auto-refreshing npc_team files from package...")
3630
+
3631
+ global_team_path = os.path.expanduser(DEFAULT_NPC_TEAM_PATH)
3632
+ if team_dir == global_team_path:
3633
+ user_jinxs_dir = os.path.join(global_team_path, "jinxs")
3634
+ if os.path.exists(user_jinxs_dir):
3635
+ print(f"Removing stale jinxs directory: {user_jinxs_dir}")
3636
+ shutil.rmtree(user_jinxs_dir)
3637
+ initialize_base_npcs_if_needed(db_path)
3638
+ print("npc_team files refreshed. Retrying team initialization...")
3639
+ try:
3640
+ team = Team(team_path=team_dir, db_conn=command_history.engine)
3641
+ except FileNotFoundError as retry_e:
3642
+ print(f"Error: Team initialization still failed after refresh: {retry_e}")
3643
+ print("This may indicate corrupted NPC files. Try: rm -rf ~/.npcsh/npc_team && npcsh")
3644
+ raise
3645
+ else:
3646
+ print(f"Error: Project team at '{team_dir}' has compilation errors.")
3647
+ print("Please check your .npc files and jinx references.")
3648
+ raise
3649
+
3498
3650
  forenpc_obj = team.forenpc if hasattr(team, 'forenpc') and team.forenpc else None
3499
3651
 
3500
3652
  for npc_name, npc_obj in team.npcs.items():