npcsh 1.1.18__py3-none-any.whl → 1.1.19__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 (139) hide show
  1. npcsh/_state.py +8 -0
  2. npcsh/benchmark/npcsh_agent.py +47 -16
  3. npcsh/config.py +1 -0
  4. npcsh/diff_viewer.py +452 -0
  5. npcsh/npc_team/jinxs/bin/config_tui.jinx +299 -0
  6. npcsh/npc_team/jinxs/bin/memories.jinx +316 -0
  7. npcsh/npc_team/jinxs/bin/setup.jinx +240 -0
  8. npcsh/npc_team/jinxs/bin/sync.jinx +143 -150
  9. npcsh/npc_team/jinxs/bin/team_tui.jinx +327 -0
  10. npcsh/npc_team/jinxs/incognide/add_tab.jinx +1 -1
  11. npcsh/npc_team/jinxs/incognide/close_pane.jinx +1 -1
  12. npcsh/npc_team/jinxs/incognide/close_tab.jinx +1 -1
  13. npcsh/npc_team/jinxs/incognide/confirm.jinx +1 -1
  14. npcsh/npc_team/jinxs/incognide/focus_pane.jinx +1 -1
  15. npcsh/npc_team/jinxs/incognide/list_panes.jinx +1 -1
  16. npcsh/npc_team/jinxs/incognide/navigate.jinx +1 -1
  17. npcsh/npc_team/jinxs/incognide/notify.jinx +1 -1
  18. npcsh/npc_team/jinxs/incognide/open_pane.jinx +1 -1
  19. npcsh/npc_team/jinxs/incognide/read_pane.jinx +1 -1
  20. npcsh/npc_team/jinxs/incognide/run_terminal.jinx +1 -1
  21. npcsh/npc_team/jinxs/incognide/send_message.jinx +1 -1
  22. npcsh/npc_team/jinxs/incognide/split_pane.jinx +1 -1
  23. npcsh/npc_team/jinxs/incognide/switch_npc.jinx +1 -1
  24. npcsh/npc_team/jinxs/incognide/switch_tab.jinx +1 -1
  25. npcsh/npc_team/jinxs/incognide/write_file.jinx +1 -1
  26. npcsh/npc_team/jinxs/incognide/zen_mode.jinx +1 -1
  27. npcsh/npc_team/jinxs/modes/guac.jinx +0 -2
  28. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/add_tab.jinx +1 -1
  29. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/close_pane.jinx +1 -1
  30. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/close_tab.jinx +1 -1
  31. npcsh-1.1.19.data/data/npcsh/npc_team/config_tui.jinx +299 -0
  32. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/confirm.jinx +1 -1
  33. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/focus_pane.jinx +1 -1
  34. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/guac.jinx +0 -2
  35. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/list_panes.jinx +1 -1
  36. npcsh-1.1.19.data/data/npcsh/npc_team/memories.jinx +316 -0
  37. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/navigate.jinx +1 -1
  38. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/notify.jinx +1 -1
  39. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/open_pane.jinx +1 -1
  40. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/read_pane.jinx +1 -1
  41. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/run_terminal.jinx +1 -1
  42. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/send_message.jinx +1 -1
  43. npcsh-1.1.19.data/data/npcsh/npc_team/setup.jinx +240 -0
  44. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/split_pane.jinx +1 -1
  45. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/switch_npc.jinx +1 -1
  46. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/switch_tab.jinx +1 -1
  47. npcsh-1.1.19.data/data/npcsh/npc_team/sync.jinx +223 -0
  48. npcsh-1.1.19.data/data/npcsh/npc_team/team_tui.jinx +327 -0
  49. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/write_file.jinx +1 -1
  50. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/zen_mode.jinx +1 -1
  51. {npcsh-1.1.18.dist-info → npcsh-1.1.19.dist-info}/METADATA +21 -14
  52. {npcsh-1.1.18.dist-info → npcsh-1.1.19.dist-info}/RECORD +138 -129
  53. {npcsh-1.1.18.dist-info → npcsh-1.1.19.dist-info}/entry_points.txt +4 -0
  54. npcsh-1.1.18.data/data/npcsh/npc_team/sync.jinx +0 -230
  55. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/alicanto.jinx +0 -0
  56. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/alicanto.npc +0 -0
  57. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/alicanto.png +0 -0
  58. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/arxiv.jinx +0 -0
  59. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/benchmark.jinx +0 -0
  60. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/browser_action.jinx +0 -0
  61. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/browser_screenshot.jinx +0 -0
  62. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/build.jinx +0 -0
  63. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/chat.jinx +0 -0
  64. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/click.jinx +0 -0
  65. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/close_browser.jinx +0 -0
  66. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/cmd.jinx +0 -0
  67. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/compile.jinx +0 -0
  68. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/compress.jinx +0 -0
  69. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/convene.jinx +0 -0
  70. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/corca.jinx +0 -0
  71. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/corca.npc +0 -0
  72. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/corca.png +0 -0
  73. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/corca_example.png +0 -0
  74. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/db_search.jinx +0 -0
  75. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/delegate.jinx +0 -0
  76. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/edit_file.jinx +0 -0
  77. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/file_search.jinx +0 -0
  78. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/frederic.npc +0 -0
  79. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/frederic4.png +0 -0
  80. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/guac.npc +0 -0
  81. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/guac.png +0 -0
  82. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/help.jinx +0 -0
  83. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/incognide.jinx +0 -0
  84. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/init.jinx +0 -0
  85. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/jinxs.jinx +0 -0
  86. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/kadiefa.npc +0 -0
  87. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/kadiefa.png +0 -0
  88. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/key_press.jinx +0 -0
  89. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/kg_search.jinx +0 -0
  90. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/launch_app.jinx +0 -0
  91. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/load_file.jinx +0 -0
  92. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/mem_review.jinx +0 -0
  93. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/mem_search.jinx +0 -0
  94. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/npcsh.ctx +0 -0
  95. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/npcsh_sibiji.png +0 -0
  96. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/nql.jinx +0 -0
  97. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/open_browser.jinx +0 -0
  98. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/ots.jinx +0 -0
  99. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/paper_search.jinx +0 -0
  100. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/paste.jinx +0 -0
  101. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/plonk.jinx +0 -0
  102. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/plonk.npc +0 -0
  103. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/plonk.png +0 -0
  104. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/plonkjr.npc +0 -0
  105. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/plonkjr.png +0 -0
  106. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/pti.jinx +0 -0
  107. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/python.jinx +0 -0
  108. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/reattach.jinx +0 -0
  109. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/roll.jinx +0 -0
  110. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/sample.jinx +0 -0
  111. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/screenshot.jinx +0 -0
  112. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/search.jinx +0 -0
  113. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/semantic_scholar.jinx +0 -0
  114. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/serve.jinx +0 -0
  115. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/set.jinx +0 -0
  116. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/sh.jinx +0 -0
  117. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/shh.jinx +0 -0
  118. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/sibiji.npc +0 -0
  119. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/sibiji.png +0 -0
  120. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/sleep.jinx +0 -0
  121. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/spool.jinx +0 -0
  122. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/spool.png +0 -0
  123. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/sql.jinx +0 -0
  124. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/switch.jinx +0 -0
  125. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/switches.jinx +0 -0
  126. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/teamviz.jinx +0 -0
  127. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/trigger.jinx +0 -0
  128. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/type_text.jinx +0 -0
  129. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/usage.jinx +0 -0
  130. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/verbose.jinx +0 -0
  131. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/vixynt.jinx +0 -0
  132. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/wait.jinx +0 -0
  133. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/wander.jinx +0 -0
  134. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/web_search.jinx +0 -0
  135. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/yap.jinx +0 -0
  136. {npcsh-1.1.18.data → npcsh-1.1.19.data}/data/npcsh/npc_team/yap.png +0 -0
  137. {npcsh-1.1.18.dist-info → npcsh-1.1.19.dist-info}/WHEEL +0 -0
  138. {npcsh-1.1.18.dist-info → npcsh-1.1.19.dist-info}/licenses/LICENSE +0 -0
  139. {npcsh-1.1.18.dist-info → npcsh-1.1.19.dist-info}/top_level.txt +0 -0
npcsh/_state.py CHANGED
@@ -125,6 +125,7 @@ from .config import (
125
125
  NPCSH_API_URL,
126
126
  NPCSH_SEARCH_PROVIDER,
127
127
  NPCSH_BUILD_KG,
128
+ NPCSH_EDIT_APPROVAL,
128
129
  setup_npcsh_config,
129
130
  is_npcsh_initialized,
130
131
  set_npcsh_initialized,
@@ -185,6 +186,10 @@ class ShellState:
185
186
  session_start_time: float = field(default_factory=lambda: __import__('time').time())
186
187
  # Logging level: "silent", "normal", "verbose"
187
188
  log_level: str = "normal"
189
+ # Edit approval mode: "off", "interactive", "auto"
190
+ edit_approval: str = NPCSH_EDIT_APPROVAL
191
+ # Pending file edits for approval
192
+ pending_edits: Dict[str, Dict[str, str]] = field(default_factory=dict)
188
193
 
189
194
  def get_model_for_command(self, model_type: str = "chat"):
190
195
  if model_type == "chat":
@@ -271,6 +276,8 @@ CONFIG_KEY_MAP = {
271
276
  "stream": "NPCSH_STREAM_OUTPUT",
272
277
  "apiurl": "NPCSH_API_URL",
273
278
  "buildkg": "NPCSH_BUILD_KG",
279
+ "editapproval": "NPCSH_EDIT_APPROVAL",
280
+ "approval": "NPCSH_EDIT_APPROVAL",
274
281
  }
275
282
 
276
283
 
@@ -315,6 +322,7 @@ def set_npcsh_config_value(key: str, value: str):
315
322
  "NPCSH_BUILD_KG": "build_kg",
316
323
  "NPCSH_API_URL": "api_url",
317
324
  "NPCSH_STREAM_OUTPUT": "stream_output",
325
+ "NPCSH_EDIT_APPROVAL": "edit_approval",
318
326
  }
319
327
  if env_key in field_map:
320
328
  setattr(ShellState, field_map[env_key], parsed_val)
@@ -53,14 +53,26 @@ class NpcshAgent(BaseInstalledAgent):
53
53
  Returns:
54
54
  List of ExecInput commands to execute
55
55
  """
56
- # Wrap the instruction with explicit jinx usage directions
56
+ # Wrap the instruction with explicit jinx usage directions and retry logic
57
57
  tool_instruction = f"""You have access to jinxs including edit_file (for writing/creating files), sh (for running shell commands), and python (for running Python code).
58
58
 
59
- IMPORTANT: You MUST use these jinxs to complete the task. Do NOT just output code as text - use the edit_file jinx to actually write files to disk.
59
+ IMPORTANT RULES:
60
+ 1. You MUST use these jinxs to complete the task. Do NOT just output code as text - use the edit_file jinx to actually write files to disk.
61
+ 2. After implementing a solution, you MUST verify it works by running any provided test scripts.
62
+ 3. If a test fails or produces an error, you MUST try a DIFFERENT approach. Do not give up.
63
+ 4. Keep trying different approaches until you succeed or have tried at least 10 different solutions.
64
+ 5. NEVER assume success - always check the actual output of test commands.
60
65
 
61
66
  Task: {instruction}
62
67
 
63
- Remember: Use edit_file to write any code files. Use sh to run shell commands like gcc, make, etc."""
68
+ WORKFLOW:
69
+ 1. Implement your solution using edit_file and sh
70
+ 2. Run any test scripts mentioned in the task
71
+ 3. Check the output carefully - look for "PASS", "SUCCESS", "OK" or similar
72
+ 4. If the test failed, analyze why and try a completely different approach
73
+ 5. Repeat until the test passes
74
+
75
+ Remember: Use edit_file to write code files. Use sh to run commands. VERIFY your solution works before concluding."""
64
76
 
65
77
  escaped_instruction = shlex.quote(tool_instruction)
66
78
  model_name = self.model_name
@@ -90,18 +102,25 @@ Remember: Use edit_file to write any code files. Use sh to run shell commands li
90
102
  # Build environment variables for API keys
91
103
  env_vars = []
92
104
  api_key_map = {
93
- "anthropic": "ANTHROPIC_API_KEY",
94
- "openai": "OPENAI_API_KEY",
95
- "gemini": "GOOGLE_API_KEY",
96
- "google": "GOOGLE_API_KEY",
97
- "deepseek": "DEEPSEEK_API_KEY",
98
- "groq": "GROQ_API_KEY",
99
- "openrouter": "OPENROUTER_API_KEY",
105
+ "anthropic": ["ANTHROPIC_API_KEY"],
106
+ "openai": ["OPENAI_API_KEY"],
107
+ "gemini": ["GOOGLE_API_KEY", "GEMINI_API_KEY"],
108
+ "google": ["GOOGLE_API_KEY", "GEMINI_API_KEY"],
109
+ "deepseek": ["DEEPSEEK_API_KEY"],
110
+ "groq": ["GROQ_API_KEY"],
111
+ "openrouter": ["OPENROUTER_API_KEY"],
100
112
  }
101
113
 
102
- for prov, env_key in api_key_map.items():
103
- if env_key in os.environ:
104
- env_vars.append(f'{env_key}="{os.environ[env_key]}"')
114
+ added_keys = set()
115
+ for prov, env_keys in api_key_map.items():
116
+ for env_key in env_keys:
117
+ if env_key in os.environ:
118
+ # For Gemini, always pass as GOOGLE_API_KEY (what litellm expects)
119
+ target_key = "GOOGLE_API_KEY" if env_key == "GEMINI_API_KEY" else env_key
120
+ if target_key not in added_keys:
121
+ env_vars.append(f'{target_key}="{os.environ[env_key]}"')
122
+ added_keys.add(target_key)
123
+ break
105
124
 
106
125
  env_prefix = " ".join(env_vars) + " " if env_vars else ""
107
126
 
@@ -215,14 +234,26 @@ class NpcshAgentWithNpc(NpcshAgent):
215
234
 
216
235
  def create_run_agent_commands(self, instruction: str) -> list:
217
236
  """Create commands using a specific NPC."""
218
- # Wrap the instruction with explicit jinx usage directions
237
+ # Wrap the instruction with explicit jinx usage directions and retry logic
219
238
  tool_instruction = f"""You have access to jinxs including edit_file (for writing/creating files), sh (for running shell commands), and python (for running Python code).
220
239
 
221
- IMPORTANT: You MUST use these jinxs to complete the task. Do NOT just output code as text - use the edit_file jinx to actually write files to disk.
240
+ IMPORTANT RULES:
241
+ 1. You MUST use these jinxs to complete the task. Do NOT just output code as text - use the edit_file jinx to actually write files to disk.
242
+ 2. After implementing a solution, you MUST verify it works by running any provided test scripts.
243
+ 3. If a test fails or produces an error, you MUST try a DIFFERENT approach. Do not give up.
244
+ 4. Keep trying different approaches until you succeed or have tried at least 10 different solutions.
245
+ 5. NEVER assume success - always check the actual output of test commands.
222
246
 
223
247
  Task: {instruction}
224
248
 
225
- Remember: Use edit_file to write any code files. Use sh to run shell commands like gcc, make, etc."""
249
+ WORKFLOW:
250
+ 1. Implement your solution using edit_file and sh
251
+ 2. Run any test scripts mentioned in the task
252
+ 3. Check the output carefully - look for "PASS", "SUCCESS", "OK" or similar
253
+ 4. If the test failed, analyze why and try a completely different approach
254
+ 5. Repeat until the test passes
255
+
256
+ Remember: Use edit_file to write code files. Use sh to run commands. VERIFY your solution works before concluding."""
226
257
 
227
258
  escaped_instruction = shlex.quote(tool_instruction)
228
259
  model_name = self.model_name
npcsh/config.py CHANGED
@@ -43,6 +43,7 @@ NPCSH_STREAM_OUTPUT = os.environ.get("NPCSH_STREAM_OUTPUT", "0") == "1"
43
43
  NPCSH_API_URL = os.environ.get("NPCSH_API_URL", None)
44
44
  NPCSH_SEARCH_PROVIDER = os.environ.get("NPCSH_SEARCH_PROVIDER", "duckduckgo")
45
45
  NPCSH_BUILD_KG = os.environ.get("NPCSH_BUILD_KG", "1") != "0"
46
+ NPCSH_EDIT_APPROVAL = os.environ.get("NPCSH_EDIT_APPROVAL", "off") # off, interactive, auto
46
47
 
47
48
 
48
49
  def get_shell_config_file() -> str:
npcsh/diff_viewer.py ADDED
@@ -0,0 +1,452 @@
1
+ """
2
+ Git-diff approval TUI for npcsh.
3
+ Provides interactive diff viewing with approve/reject functionality.
4
+ """
5
+ import os
6
+ import sys
7
+ import difflib
8
+ from dataclasses import dataclass, field
9
+ from typing import List, Dict, Optional, Tuple
10
+ from enum import Enum
11
+
12
+ # Platform-specific imports
13
+ try:
14
+ import tty
15
+ import termios
16
+ import select
17
+ HAS_TTY = True
18
+ except ImportError:
19
+ HAS_TTY = False
20
+
21
+
22
+ class HunkDecision(Enum):
23
+ PENDING = "pending"
24
+ APPROVED = "approved"
25
+ REJECTED = "rejected"
26
+
27
+
28
+ @dataclass
29
+ class DiffHunk:
30
+ """Represents a single diff hunk"""
31
+ start_original: int
32
+ count_original: int
33
+ start_modified: int
34
+ count_modified: int
35
+ lines: List[str]
36
+ header: str
37
+
38
+
39
+ @dataclass
40
+ class DiffViewerState:
41
+ """State for the diff viewer TUI"""
42
+ file_path: str
43
+ original: str
44
+ modified: str
45
+ hunks: List[DiffHunk] = field(default_factory=list)
46
+ decisions: Dict[int, HunkDecision] = field(default_factory=dict)
47
+ selected_hunk: int = 0
48
+ scroll_offset: int = 0
49
+ mode: str = "normal" # normal, help
50
+
51
+
52
+ def compute_diff_hunks(original: str, modified: str) -> List[DiffHunk]:
53
+ """Compute diff hunks between original and modified content."""
54
+ original_lines = original.splitlines(keepends=True)
55
+ modified_lines = modified.splitlines(keepends=True)
56
+
57
+ diff = list(difflib.unified_diff(
58
+ original_lines,
59
+ modified_lines,
60
+ lineterm=''
61
+ ))
62
+
63
+ hunks = []
64
+ current_hunk_lines = []
65
+ current_header = ""
66
+ start_orig = 0
67
+ count_orig = 0
68
+ start_mod = 0
69
+ count_mod = 0
70
+
71
+ for line in diff[2:]: # Skip the --- and +++ headers
72
+ if line.startswith('@@'):
73
+ # Save previous hunk if exists
74
+ if current_hunk_lines:
75
+ hunks.append(DiffHunk(
76
+ start_original=start_orig,
77
+ count_original=count_orig,
78
+ start_modified=start_mod,
79
+ count_modified=count_mod,
80
+ lines=current_hunk_lines,
81
+ header=current_header
82
+ ))
83
+
84
+ # Parse new hunk header
85
+ current_header = line.strip()
86
+ current_hunk_lines = []
87
+
88
+ # Parse @@ -start,count +start,count @@
89
+ try:
90
+ parts = line.split('@@')[1].strip().split()
91
+ orig_part = parts[0] # -start,count
92
+ mod_part = parts[1] # +start,count
93
+
94
+ if ',' in orig_part:
95
+ start_orig, count_orig = map(int, orig_part[1:].split(','))
96
+ else:
97
+ start_orig = int(orig_part[1:])
98
+ count_orig = 1
99
+
100
+ if ',' in mod_part:
101
+ start_mod, count_mod = map(int, mod_part[1:].split(','))
102
+ else:
103
+ start_mod = int(mod_part[1:])
104
+ count_mod = 1
105
+ except (IndexError, ValueError):
106
+ start_orig = count_orig = start_mod = count_mod = 0
107
+ else:
108
+ current_hunk_lines.append(line)
109
+
110
+ # Save last hunk
111
+ if current_hunk_lines:
112
+ hunks.append(DiffHunk(
113
+ start_original=start_orig,
114
+ count_original=count_orig,
115
+ start_modified=start_mod,
116
+ count_modified=count_mod,
117
+ lines=current_hunk_lines,
118
+ header=current_header
119
+ ))
120
+
121
+ return hunks
122
+
123
+
124
+ def get_terminal_size() -> Tuple[int, int]:
125
+ """Get terminal size (width, height)."""
126
+ try:
127
+ size = os.get_terminal_size()
128
+ return size.columns, size.lines
129
+ except:
130
+ return 80, 24
131
+
132
+
133
+ class DiffViewer:
134
+ """Interactive diff viewer with approve/reject functionality."""
135
+
136
+ def __init__(self, file_path: str, original: str, modified: str):
137
+ self.state = DiffViewerState(
138
+ file_path=file_path,
139
+ original=original,
140
+ modified=modified
141
+ )
142
+ self.state.hunks = compute_diff_hunks(original, modified)
143
+
144
+ # Initialize all hunks as pending
145
+ for i in range(len(self.state.hunks)):
146
+ self.state.decisions[i] = HunkDecision.PENDING
147
+
148
+ def render_screen(self):
149
+ """Render the diff viewer screen."""
150
+ width, height = get_terminal_size()
151
+ out = []
152
+
153
+ # Clear screen and move to top
154
+ out.append("\033[2J\033[H")
155
+
156
+ # Header
157
+ header = f" File Edit: {self.state.file_path} "
158
+ if len(header) > width - 4:
159
+ header = f" ...{self.state.file_path[-width+15:]} "
160
+ out.append(f"\033[1;1H\033[44;37;1m{'=' * width}\033[0m")
161
+ out.append(f"\033[1;2H\033[44;37;1m{header}\033[0m")
162
+
163
+ # Stats line
164
+ approved = sum(1 for d in self.state.decisions.values() if d == HunkDecision.APPROVED)
165
+ rejected = sum(1 for d in self.state.decisions.values() if d == HunkDecision.REJECTED)
166
+ pending = sum(1 for d in self.state.decisions.values() if d == HunkDecision.PENDING)
167
+ stats = f"Hunks: {len(self.state.hunks)} | Approved: {approved} | Rejected: {rejected} | Pending: {pending}"
168
+ out.append(f"\033[2;1H\033[90m{stats.center(width)}\033[0m")
169
+
170
+ # Diff content area
171
+ content_start = 4
172
+ content_height = height - 6
173
+
174
+ if not self.state.hunks:
175
+ out.append(f"\033[{content_start};2H\033[33mNo differences found.\033[0m")
176
+ else:
177
+ # Render current hunk
178
+ hunk = self.state.hunks[self.state.selected_hunk]
179
+ decision = self.state.decisions[self.state.selected_hunk]
180
+
181
+ # Hunk header with decision indicator
182
+ decision_indicator = {
183
+ HunkDecision.PENDING: "\033[33m[?]\033[0m",
184
+ HunkDecision.APPROVED: "\033[32m[+]\033[0m",
185
+ HunkDecision.REJECTED: "\033[31m[-]\033[0m"
186
+ }[decision]
187
+
188
+ hunk_header = f"{decision_indicator} Hunk {self.state.selected_hunk + 1}/{len(self.state.hunks)}: {hunk.header}"
189
+ out.append(f"\033[3;1H\033[90m{'-' * width}\033[0m")
190
+ out.append(f"\033[3;2H{hunk_header[:width-4]}")
191
+
192
+ # Render diff lines
193
+ visible_lines = hunk.lines[self.state.scroll_offset:self.state.scroll_offset + content_height]
194
+
195
+ for i, line in enumerate(visible_lines):
196
+ row = content_start + i
197
+ if row >= height - 2:
198
+ break
199
+
200
+ # Color based on line type
201
+ if line.startswith('+'):
202
+ color = "\033[32m" # Green for additions
203
+ elif line.startswith('-'):
204
+ color = "\033[31m" # Red for deletions
205
+ else:
206
+ color = "\033[0m" # Default for context
207
+
208
+ # Truncate long lines
209
+ display_line = line.rstrip()[:width-2]
210
+ out.append(f"\033[{row};1H{color}{display_line}\033[0m")
211
+
212
+ # Scroll indicator
213
+ if len(hunk.lines) > content_height:
214
+ scroll_pct = (self.state.scroll_offset / (len(hunk.lines) - content_height)) * 100
215
+ scroll_info = f"[{int(scroll_pct)}%]"
216
+ out.append(f"\033[{content_start};{width-len(scroll_info)-1}H\033[90m{scroll_info}\033[0m")
217
+
218
+ # Footer with keybindings
219
+ footer_y = height - 1
220
+ out.append(f"\033[{footer_y};1H\033[90m{'-' * width}\033[0m")
221
+
222
+ keys = "[a] Approve [r] Reject [A] Approve All [R] Reject All [j/k] Hunks [q] Done [?] Help"
223
+ out.append(f"\033[{height};1H\033[90m{keys[:width]}\033[0m")
224
+
225
+ sys.stdout.write(''.join(out))
226
+ sys.stdout.flush()
227
+
228
+ def handle_input(self, c: str) -> bool:
229
+ """Handle input character. Returns False to exit."""
230
+ if c == 'q':
231
+ return False
232
+
233
+ elif c == 'a': # Approve current hunk
234
+ self.state.decisions[self.state.selected_hunk] = HunkDecision.APPROVED
235
+ if self.state.selected_hunk < len(self.state.hunks) - 1:
236
+ self.state.selected_hunk += 1
237
+ self.state.scroll_offset = 0
238
+
239
+ elif c == 'r': # Reject current hunk
240
+ self.state.decisions[self.state.selected_hunk] = HunkDecision.REJECTED
241
+ if self.state.selected_hunk < len(self.state.hunks) - 1:
242
+ self.state.selected_hunk += 1
243
+ self.state.scroll_offset = 0
244
+
245
+ elif c == 'A': # Approve all
246
+ for i in range(len(self.state.hunks)):
247
+ self.state.decisions[i] = HunkDecision.APPROVED
248
+
249
+ elif c == 'R': # Reject all
250
+ for i in range(len(self.state.hunks)):
251
+ self.state.decisions[i] = HunkDecision.REJECTED
252
+
253
+ elif c == 'j' or c == '\x1b': # Down/next hunk (or escape sequence)
254
+ if c == '\x1b':
255
+ # Handle escape sequences
256
+ if HAS_TTY and select.select([sys.stdin], [], [], 0.05)[0]:
257
+ c2 = sys.stdin.read(1)
258
+ if c2 == '[':
259
+ c3 = sys.stdin.read(1)
260
+ if c3 == 'B': # Down arrow
261
+ if self.state.selected_hunk < len(self.state.hunks) - 1:
262
+ self.state.selected_hunk += 1
263
+ self.state.scroll_offset = 0
264
+ elif c3 == 'A': # Up arrow
265
+ if self.state.selected_hunk > 0:
266
+ self.state.selected_hunk -= 1
267
+ self.state.scroll_offset = 0
268
+ else:
269
+ if self.state.selected_hunk < len(self.state.hunks) - 1:
270
+ self.state.selected_hunk += 1
271
+ self.state.scroll_offset = 0
272
+
273
+ elif c == 'k': # Up/previous hunk
274
+ if self.state.selected_hunk > 0:
275
+ self.state.selected_hunk -= 1
276
+ self.state.scroll_offset = 0
277
+
278
+ elif c == 'n': # Next hunk (same as j)
279
+ if self.state.selected_hunk < len(self.state.hunks) - 1:
280
+ self.state.selected_hunk += 1
281
+ self.state.scroll_offset = 0
282
+
283
+ elif c == 'p': # Previous hunk (same as k)
284
+ if self.state.selected_hunk > 0:
285
+ self.state.selected_hunk -= 1
286
+ self.state.scroll_offset = 0
287
+
288
+ elif c == ' ': # Scroll down within hunk
289
+ if self.state.hunks:
290
+ hunk = self.state.hunks[self.state.selected_hunk]
291
+ _, height = get_terminal_size()
292
+ content_height = height - 6
293
+ max_scroll = max(0, len(hunk.lines) - content_height)
294
+ self.state.scroll_offset = min(self.state.scroll_offset + 5, max_scroll)
295
+
296
+ elif c == 'b': # Scroll up within hunk
297
+ self.state.scroll_offset = max(0, self.state.scroll_offset - 5)
298
+
299
+ return True
300
+
301
+ def apply_decisions(self) -> str:
302
+ """Apply decisions and return the resulting content."""
303
+ if not self.state.hunks:
304
+ return self.state.modified
305
+
306
+ # If all hunks approved, return modified
307
+ if all(d == HunkDecision.APPROVED for d in self.state.decisions.values()):
308
+ return self.state.modified
309
+
310
+ # If all hunks rejected, return original
311
+ if all(d == HunkDecision.REJECTED for d in self.state.decisions.values()):
312
+ return self.state.original
313
+
314
+ # Partial application - reconstruct from decisions
315
+ # This is complex - for now, we'll use a simple approach:
316
+ # If any hunk is rejected, we need to carefully reconstruct
317
+
318
+ result_lines = self.state.original.splitlines(keepends=True)
319
+ offset = 0 # Track line number offset from applied changes
320
+
321
+ for i, hunk in enumerate(self.state.hunks):
322
+ if self.state.decisions[i] == HunkDecision.APPROVED:
323
+ # Apply this hunk
324
+ start = hunk.start_original - 1 + offset
325
+
326
+ # Count removals and additions in this hunk
327
+ removals = [l[1:] for l in hunk.lines if l.startswith('-')]
328
+ additions = [l[1:] for l in hunk.lines if l.startswith('+')]
329
+
330
+ # Remove old lines
331
+ del result_lines[start:start + len(removals)]
332
+
333
+ # Insert new lines
334
+ for j, line in enumerate(additions):
335
+ if not line.endswith('\n'):
336
+ line += '\n'
337
+ result_lines.insert(start + j, line)
338
+
339
+ # Update offset
340
+ offset += len(additions) - len(removals)
341
+
342
+ return ''.join(result_lines)
343
+
344
+ def run(self) -> Dict[str, any]:
345
+ """Run the interactive diff viewer. Returns approval decisions."""
346
+ if not HAS_TTY:
347
+ print("TTY not available - cannot run interactive diff viewer")
348
+ return {
349
+ "approved": False,
350
+ "decisions": {},
351
+ "content": self.state.original
352
+ }
353
+
354
+ if not self.state.hunks:
355
+ return {
356
+ "approved": True,
357
+ "decisions": {},
358
+ "content": self.state.modified
359
+ }
360
+
361
+ fd = sys.stdin.fileno()
362
+ old_settings = termios.tcgetattr(fd)
363
+
364
+ try:
365
+ tty.setcbreak(fd)
366
+ sys.stdout.write('\033[?25l') # Hide cursor
367
+
368
+ self.render_screen()
369
+
370
+ while True:
371
+ c = sys.stdin.read(1)
372
+ if not self.handle_input(c):
373
+ break
374
+ self.render_screen()
375
+
376
+ finally:
377
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
378
+ sys.stdout.write('\033[?25h') # Show cursor
379
+ sys.stdout.write('\033[2J\033[H') # Clear screen
380
+ sys.stdout.flush()
381
+
382
+ # Determine if approved
383
+ all_approved = all(d == HunkDecision.APPROVED for d in self.state.decisions.values())
384
+ any_approved = any(d == HunkDecision.APPROVED for d in self.state.decisions.values())
385
+
386
+ return {
387
+ "approved": any_approved,
388
+ "all_approved": all_approved,
389
+ "decisions": {i: d.value for i, d in self.state.decisions.items()},
390
+ "content": self.apply_decisions()
391
+ }
392
+
393
+
394
+ def show_diff_approval(file_path: str, original: str, modified: str) -> Dict[str, any]:
395
+ """
396
+ Show an interactive diff approval dialog.
397
+
398
+ Args:
399
+ file_path: Path to the file being edited
400
+ original: Original file content
401
+ modified: Modified file content
402
+
403
+ Returns:
404
+ Dict with:
405
+ - approved: bool - whether any changes were approved
406
+ - all_approved: bool - whether all changes were approved
407
+ - content: str - the resulting content after applying decisions
408
+ - decisions: dict - per-hunk decisions
409
+ """
410
+ viewer = DiffViewer(file_path, original, modified)
411
+ return viewer.run()
412
+
413
+
414
+ def quick_diff_preview(original: str, modified: str, max_lines: int = 20) -> str:
415
+ """
416
+ Generate a quick text-based diff preview (non-interactive).
417
+
418
+ Args:
419
+ original: Original content
420
+ modified: Modified content
421
+ max_lines: Maximum lines to show
422
+
423
+ Returns:
424
+ Colored diff string
425
+ """
426
+ original_lines = original.splitlines(keepends=True)
427
+ modified_lines = modified.splitlines(keepends=True)
428
+
429
+ diff = list(difflib.unified_diff(
430
+ original_lines,
431
+ modified_lines,
432
+ lineterm=''
433
+ ))
434
+
435
+ if not diff:
436
+ return "No changes"
437
+
438
+ result = []
439
+ for line in diff[:max_lines]:
440
+ if line.startswith('+') and not line.startswith('+++'):
441
+ result.append(f"\033[32m{line.rstrip()}\033[0m")
442
+ elif line.startswith('-') and not line.startswith('---'):
443
+ result.append(f"\033[31m{line.rstrip()}\033[0m")
444
+ elif line.startswith('@@'):
445
+ result.append(f"\033[36m{line.rstrip()}\033[0m")
446
+ else:
447
+ result.append(line.rstrip())
448
+
449
+ if len(diff) > max_lines:
450
+ result.append(f"\033[90m... ({len(diff) - max_lines} more lines)\033[0m")
451
+
452
+ return '\n'.join(result)