mlx-code 0.0.28__tar.gz → 0.0.30__tar.gz

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 (31) hide show
  1. {mlx_code-0.0.28 → mlx_code-0.0.30}/PKG-INFO +12 -1
  2. {mlx_code-0.0.28 → mlx_code-0.0.30}/README.md +11 -0
  3. {mlx_code-0.0.28 → mlx_code-0.0.30}/mlx_code/apis.py +2 -2
  4. {mlx_code-0.0.28 → mlx_code-0.0.30}/mlx_code/bats.py +1 -2
  5. {mlx_code-0.0.28 → mlx_code-0.0.30}/mlx_code/gits.py +18 -1
  6. {mlx_code-0.0.28 → mlx_code-0.0.30}/mlx_code/main.py +3 -2
  7. {mlx_code-0.0.28 → mlx_code-0.0.30}/mlx_code/repl.py +57 -8
  8. {mlx_code-0.0.28 → mlx_code-0.0.30}/mlx_code/tools.py +13 -0
  9. {mlx_code-0.0.28 → mlx_code-0.0.30}/mlx_code/tui.py +0 -1
  10. {mlx_code-0.0.28 → mlx_code-0.0.30}/mlx_code/view_log.py +1 -1
  11. mlx_code-0.0.30/mlx_code/web.py +485 -0
  12. {mlx_code-0.0.28 → mlx_code-0.0.30}/mlx_code.egg-info/PKG-INFO +12 -1
  13. {mlx_code-0.0.28 → mlx_code-0.0.30}/setup.py +1 -1
  14. mlx_code-0.0.28/mlx_code/web.py +0 -485
  15. {mlx_code-0.0.28 → mlx_code-0.0.30}/LICENSE +0 -0
  16. {mlx_code-0.0.28 → mlx_code-0.0.30}/mlx_code/__init__.py +0 -0
  17. {mlx_code-0.0.28 → mlx_code-0.0.30}/mlx_code/bare.py +0 -0
  18. {mlx_code-0.0.28 → mlx_code-0.0.30}/mlx_code/lsp_tool.py +0 -0
  19. {mlx_code-0.0.28 → mlx_code-0.0.30}/mlx_code/mcb.py +0 -0
  20. {mlx_code-0.0.28 → mlx_code-0.0.30}/mlx_code/mcb_tool.py +0 -0
  21. {mlx_code-0.0.28 → mlx_code-0.0.30}/mlx_code/stream_log.py +0 -0
  22. {mlx_code-0.0.28 → mlx_code-0.0.30}/mlx_code/util.py +0 -0
  23. {mlx_code-0.0.28 → mlx_code-0.0.30}/mlx_code/view_git.py +0 -0
  24. {mlx_code-0.0.28 → mlx_code-0.0.30}/mlx_code.egg-info/SOURCES.txt +0 -0
  25. {mlx_code-0.0.28 → mlx_code-0.0.30}/mlx_code.egg-info/dependency_links.txt +0 -0
  26. {mlx_code-0.0.28 → mlx_code-0.0.30}/mlx_code.egg-info/entry_points.txt +0 -0
  27. {mlx_code-0.0.28 → mlx_code-0.0.30}/mlx_code.egg-info/requires.txt +0 -0
  28. {mlx_code-0.0.28 → mlx_code-0.0.30}/mlx_code.egg-info/top_level.txt +0 -0
  29. {mlx_code-0.0.28 → mlx_code-0.0.30}/setup.cfg +0 -0
  30. {mlx_code-0.0.28 → mlx_code-0.0.30}/tests/__init__.py +0 -0
  31. {mlx_code-0.0.28 → mlx_code-0.0.30}/tests/test.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mlx-code
3
- Version: 0.0.28
3
+ Version: 0.0.30
4
4
  Summary: Coding Agent for Mac
5
5
  Home-page: https://josefalbers.github.io/mlx-code/
6
6
  Author: J Joe
@@ -483,6 +483,7 @@ agent = Agent(extra_tool_classes=[LiveDBTool], tool_names=["QueryDB"])
483
483
  | `/abort` | Abort the running agent |
484
484
  | `/errors` | Show timestamped error log for the current tab |
485
485
  | `/export [path]` | Export session to JSON |
486
+ | `/merge` | Merge current branch into parent tab |
486
487
  | `/exit [--all]` | Close branch tab, or exit the app |
487
488
  | `/help` | Show command reference |
488
489
  | `!command` | Run a shell command; output captured in the TUI (eg, `ls`, `cat hello.c`) |
@@ -548,8 +549,18 @@ mlc --leash claude # claude code
548
549
  - subdomain: jjoe
549
550
  - domain: mlx-code.com
550
551
  - service url: http://host.containers.internal:8080
552
+
553
+ [protect & connect]-[zero trust]-[access controls]-[applications]-[create new application]-[self-hosted and private]:
554
+ - subdomain: jjoe
555
+ - domain: mlx-code.com
556
+ - [create new policy]
557
+ - policy name: Kaputt
558
+ - action: Allow
559
+ - [policy rules]-[(or) include]-[selector is]
560
+
551
561
  mlc --host 0.0.0.0 --engine batch --web &
552
562
  podman run cloudflare/cloudflared:latest tunnel --no-autoupdate run --token $JJ_CFD_TOKEN
563
+
553
564
  phone http://jjoe.mlx-code.com
554
565
  ```
555
566
 
@@ -445,6 +445,7 @@ agent = Agent(extra_tool_classes=[LiveDBTool], tool_names=["QueryDB"])
445
445
  | `/abort` | Abort the running agent |
446
446
  | `/errors` | Show timestamped error log for the current tab |
447
447
  | `/export [path]` | Export session to JSON |
448
+ | `/merge` | Merge current branch into parent tab |
448
449
  | `/exit [--all]` | Close branch tab, or exit the app |
449
450
  | `/help` | Show command reference |
450
451
  | `!command` | Run a shell command; output captured in the TUI (eg, `ls`, `cat hello.c`) |
@@ -510,8 +511,18 @@ mlc --leash claude # claude code
510
511
  - subdomain: jjoe
511
512
  - domain: mlx-code.com
512
513
  - service url: http://host.containers.internal:8080
514
+
515
+ [protect & connect]-[zero trust]-[access controls]-[applications]-[create new application]-[self-hosted and private]:
516
+ - subdomain: jjoe
517
+ - domain: mlx-code.com
518
+ - [create new policy]
519
+ - policy name: Kaputt
520
+ - action: Allow
521
+ - [policy rules]-[(or) include]-[selector is]
522
+
513
523
  mlc --host 0.0.0.0 --engine batch --web &
514
524
  podman run cloudflare/cloudflared:latest tunnel --no-autoupdate run --token $JJ_CFD_TOKEN
525
+
515
526
  phone http://jjoe.mlx-code.com
516
527
  ```
517
528
 
@@ -75,7 +75,7 @@ class ClaudeChat:
75
75
  out.append(blk)
76
76
  elif t == 'thinking':
77
77
  if b['redacted']:
78
- out.append({'type': 'redacted_thinking', 'data': b['thinking']})
78
+ out.append({'type': 'redacted_thinking', 'data': b.get('thinking', b.get('redacted_data', ''))})
79
79
  else:
80
80
  out.append({'type': 'thinking', 'thinking': b['thinking'], 'signature': b.get('signature') or ''})
81
81
  elif t == 'toolCall':
@@ -156,7 +156,7 @@ class ClaudeChat:
156
156
  msg['content'].append({'type': 'thinking', 'thinking': '', 'signature': None, 'redacted': False})
157
157
  elif bt == 'redacted_thinking':
158
158
  _idx[idx] = len(msg['content'])
159
- msg['content'].append({'type': 'thinking', 'thinking': '', 'signature': None, 'redacted': True})
159
+ msg['content'].append({'type': 'thinking', 'redacted_data': blk.get('data', ''), 'signature': None, 'redacted': True})
160
160
  elif bt == 'tool_use':
161
161
  _idx[idx] = len(msg['content'])
162
162
  msg['content'].append({'type': 'toolCall', 'id': blk['id'], 'name': blk['name'], 'arguments': {}})
@@ -1,4 +1,3 @@
1
- _UI_HTML = '<!DOCTYPE html>\n<html lang="en">\n<head>\n<meta charset="UTF-8">\n<meta name="viewport" content="width=device-width, initial-scale=1.0">\n<title>MLX Code</title>\n<style>\n*{margin:0;padding:0;box-sizing:border-box}\nbody{font-family:system-ui,-apple-system,sans-serif;background:#0d1117;color:#c9d1d9;height:100vh;display:flex;flex-direction:column}\n#hdr{padding:8px 16px;background:#161b22;border-bottom:1px solid #30363d;display:flex;justify-content:space-between;align-items:center;flex-shrink:0}\n#hdr h1{font-size:14px;font-weight:600}\n#hdr .info{display:flex;gap:8px;align-items:center;font-size:12px;color:#8b949e}\n#hdr button{background:transparent;color:#8b949e;border:1px solid #30363d;border-radius:6px;padding:4px 10px;cursor:pointer;font-size:12px}\n#hdr button:hover{color:#c9d1d9;border-color:#8b949e}\n#chat{flex:1;overflow-y:auto;padding:16px}\n.chat-inner{max-width:920px;margin:0 auto}\n.msg{margin-bottom:14px}\n.msg-role{font-size:12px;color:#8b949e;margin-bottom:3px}\n.msg-body{padding:10px 14px;border-radius:8px;line-height:1.6;white-space:pre-wrap;word-break:break-word;font-size:14px}\n.msg-user .msg-body{background:#1c2128;border:1px solid #30363d}\n.msg-assistant .msg-body{background:#161b22;border:1px solid #30363d}\n.msg-thinking .msg-body{color:#6e7681;font-style:italic;background:rgba(136,144,150,0.05);border-left:2px solid #30363d;font-size:13px}\n.msg-tool .msg-body{background:rgba(210,153,34,0.08);border-left:2px solid #d29922;font-family:ui-monospace,SFMono-Regular,monospace;font-size:13px}\n.msg-error .msg-body{color:#f85149}\n.cursor{display:inline-block;width:7px;height:15px;background:#58a6ff;animation:blink 1s steps(2) infinite;vertical-align:text-bottom;margin-left:2px;border-radius:1px}\n@keyframes blink{50%{opacity:0}}\n#input-area{padding:12px 16px;background:#161b22;border-top:1px solid #30363d;flex-shrink:0}\n.input-inner{max-width:920px;margin:0 auto;display:flex;gap:8px}\n#input{flex:1;background:#0d1117;color:#c9d1d9;border:1px solid #30363d;border-radius:8px;padding:10px 14px;font-family:inherit;font-size:14px;resize:none;height:44px;max-height:200px;line-height:1.5}\n#input:focus{outline:none;border-color:#58a6ff}\n#send{background:#238636;color:#fff;border:none;border-radius:8px;padding:0 20px;cursor:pointer;font-size:14px;font-weight:500;white-space:nowrap}\n#send:hover{background:#2ea043}\n#send:disabled{background:#21262d;color:#484f58;cursor:not-allowed}\n</style>\n</head>\n<body>\n<div id="hdr">\n <h1>⚡ MLX Code</h1>\n <div class="info">\n <span id="status">connecting...</span>\n <button onclick="clearChat()">Clear</button>\n </div>\n</div>\n<div id="chat"><div class="chat-inner" id="chatInner"></div></div>\n<div id="input-area">\n <div class="input-inner">\n <textarea id="input" placeholder="Send a message... (Enter=send, Shift+Enter=newline)" rows="1"></textarea>\n <button id="send" onclick="send()">Send</button>\n </div>\n</div>\n<script>\nconst chatEl=document.getElementById(\'chat\');\nconst innerEl=document.getElementById(\'chatInner\');\nconst inputEl=document.getElementById(\'input\');\nconst sendBtn=document.getElementById(\'send\');\nconst statusEl=document.getElementById(\'status\');\nconst SYSTEM_PROMPT = \'You are a helpful assistant. You are running in a web chat mode with no tool execution capabilities. Answer the user directly and concisely.\';\nlet messages=[{role:\'system\',content:SYSTEM_PROMPT}];\nlet streaming=false;\n\ninputEl.addEventListener(\'keydown\',e=>{\n if(e.key===\'Enter\'&&!e.shiftKey){e.preventDefault();send();}\n});\ninputEl.addEventListener(\'input\',()=>{\n inputEl.style.height=\'auto\';\n inputEl.style.height=Math.min(inputEl.scrollHeight,200)+\'px\';\n});\n\nfunction scrollBottom(){chatEl.scrollTop=chatEl.scrollHeight;}\n\nfunction addMsg(role,label){\n const d=document.createElement(\'div\');\n d.className=\'msg msg-\'+role;\n const r=document.createElement(\'div\');r.className=\'msg-role\';r.textContent=label;\n const b=document.createElement(\'div\');b.className=\'msg-body\';\n d.appendChild(r);d.appendChild(b);\n innerEl.appendChild(d);scrollBottom();return b;\n}\n\nfunction clearChat(){\n if(streaming)return;\n messages=[{role:\'system\',content:SYSTEM_PROMPT}];innerEl.innerHTML=\'\';inputEl.focus();\n}\n\nfunction stripToolXml(text){\n // Remove complete <tool_call>...</tool_call> blocks\n text=text.replace(/<tool_call>[\\s\\S]*?<\\/tool_call>/g,\'\');\n // Handle incomplete <tool_call> at end (closing tag not yet received)\n const idx=text.lastIndexOf(\'<tool_call>\');\n if(idx!==-1&&text.indexOf(\'</tool_call>\',idx)===-1)return text.substring(0,idx);\n // Handle partial opening tag at end (e.g. "<tool", "<tool_c")\n const tag=\'<tool_call>\';\n for(let i=tag.length-1;i>0;i--){\n if(text.endsWith(tag.substring(0,i)))return text.substring(0,text.length-i);\n }\n return text;\n}\n\nfunction checkHealth(){\n fetch(\'/health\').then(r=>r.json()).then(d=>{\n statusEl.textContent=d.model||\'ready\';\n }).catch(()=>{statusEl.textContent=\'offline\';});\n}\n\nasync function send(){\n const text=inputEl.value.trim();\n if(!text||streaming)return;\n inputEl.value=\'\';inputEl.style.height=\'auto\';\n messages.push({role:\'user\',content:text});\n addMsg(\'user\',\'≫ You\').textContent=text;\n streaming=true;sendBtn.disabled=true;statusEl.textContent=\'generating...\';\n\n const aBody=addMsg(\'assistant\',\'○ Assistant\');\n let tBody=null,displayText=\'\',thinkText=\'\',rawText=\'\',toolCalls=[];\n const cursor=document.createElement(\'span\');cursor.className=\'cursor\';aBody.appendChild(cursor);\n\n try{\n const resp=await fetch(\'/v1/chat/completions\',{\n method:\'POST\',\n headers:{\'Content-Type\':\'application/json\'},\n body:JSON.stringify({messages,max_tokens:8192})\n });\n\n if(!resp.ok){\n cursor.remove();\n let msg=\'HTTP \'+resp.status;\n try{const e=await resp.json();msg+=\': \'+(e.error||JSON.stringify(e));}catch(_){try{msg+=\': \'+await resp.text();}catch(_){}}\n aBody.textContent=\'✗ \'+msg;\n aBody.parentElement.classList.add(\'msg-error\');\n messages.pop();return;\n }\n\n const reader=resp.body.getReader();\n const dec=new TextDecoder();\n let buf=\'\';\n\n while(true){\n const{done,value}=await reader.read();\n if(done)break;\n buf+=dec.decode(value,{stream:true});\n const lines=buf.split(\'\\n\');\n buf=lines.pop(); // keep partial line in buffer\n\n for(const line of lines){\n if(!line.startsWith(\'data: \'))continue;\n const data=line.slice(6).trim();\n if(!data||data===\'[DONE]\')continue;\n let ch;try{ch=JSON.parse(data);}catch(_){continue;}\n const delta=ch.choices&&ch.choices[0]&&ch.choices[0].delta;\n if(!delta)continue;\n\n if(delta.reasoning_content){\n if(!tBody){\n cursor.remove();\n tBody=addMsg(\'thinking\',\'◌ Thinking\');\n tBody.appendChild(cursor.cloneNode());\n }\n thinkText+=delta.reasoning_content;\n const c=tBody.querySelector(\'.cursor\');if(c)c.remove();\n tBody.textContent=thinkText;\n tBody.appendChild(cursor.cloneNode());\n scrollBottom();\n }\n\n if(delta.content){\n if(tBody){\n const c=tBody.querySelector(\'.cursor\');if(c)c.remove();\n tBody=null;aBody.appendChild(cursor);\n }\n rawText+=delta.content;\n displayText=stripToolXml(rawText);\n cursor.remove();aBody.textContent=displayText;aBody.appendChild(cursor);\n scrollBottom();\n }\n\n if(delta.tool_calls){\n cursor.remove();\n for(const tc of delta.tool_calls){\n const fn=tc.function||{};\n if(fn.name){\n toolCalls.push({name:fn.name,args:\'\'});\n addMsg(\'tool\',\'⚙ \'+fn.name).textContent=fn.name;\n }\n if(fn.arguments&&toolCalls.length>0){\n toolCalls[toolCalls.length-1].args+=fn.arguments;\n const tbs=innerEl.querySelectorAll(\'.msg-tool .msg-body\');\n if(tbs.length>0){\n let disp=toolCalls[toolCalls.length-1].name+\'\\n\';\n try{disp+=JSON.stringify(JSON.parse(toolCalls[toolCalls.length-1].args),null,2);}\n catch(_){disp+=toolCalls[toolCalls.length-1].args;}\n tbs[tbs.length-1].textContent=disp;\n }\n }\n }\n scrollBottom();\n }\n }\n }\n\n cursor.remove();\n if(displayText.trim()){\n aBody.textContent=displayText;\n messages.push({role:\'assistant\',content:displayText});\n }else{\n aBody.textContent=thinkText?\'(thinking only)\':\'(no output)\';\n messages.push({role:\'assistant\',content:displayText});\n }\n if(toolCalls.length>0){\n addMsg(\'tool\',\'⚠ Note\').textContent=\'Tool calls cannot be executed in the web UI. Use the terminal REPL (--bare) for full tool support.\';\n }\n }catch(e){\n cursor.remove();\n aBody.textContent=\'✗ \'+e.message;\n aBody.parentElement.classList.add(\'msg-error\');\n if(messages.length>0&&messages[messages.length-1].role===\'user\')messages.pop();\n }finally{\n streaming=false;sendBtn.disabled=false;\n checkHealth();inputEl.focus();\n }\n}\n\ncheckHealth();inputEl.focus();\n</script>\n</body>\n</html>'
2
1
  import asyncio
3
2
  import json
4
3
  import queue as _queue
@@ -12,7 +11,7 @@ from pathlib import Path
12
11
  import mlx.core as mx
13
12
  from starlette.applications import Starlette
14
13
  from starlette.requests import Request
15
- from starlette.responses import StreamingResponse, JSONResponse, HTMLResponse
14
+ from starlette.responses import StreamingResponse, JSONResponse
16
15
  from starlette.routing import Route
17
16
  import logging
18
17
  logger = logging.getLogger(__name__)
@@ -349,4 +349,21 @@ def get_branch_base_sha(worktree: str) -> str | None:
349
349
  except GitError:
350
350
  pass
351
351
  root_sha = _git(worktree, 'rev-list', '--max-parents=0', 'HEAD', check=False)
352
- return root_sha or None
352
+ return root_sha or None
353
+
354
+ def merge_branch_into_worktree(parent_gwt, child_gwt) -> tuple[bool, str]:
355
+ if parent_gwt is None or child_gwt is None:
356
+ out = f'Not git: parent_gwt={parent_gwt!r} child_gwt={child_gwt!r}'
357
+ logger.debug(out)
358
+ return (False, out)
359
+ try:
360
+ out = _git(parent_gwt.worktree, 'merge', '--no-edit', '--no-ff', child_gwt.branch)
361
+ logger.info(out)
362
+ return (True, out)
363
+ except GitError as exc:
364
+ logger.error('Failed merge')
365
+ try:
366
+ _git(parent_gwt.worktree, 'merge', '--abort', check=False)
367
+ except Exception as _exc:
368
+ logger.error('Failed merge --abort')
369
+ return (False, str(exc))
@@ -387,10 +387,11 @@ def is_stuck(tokens, pattern_size=100, min_repeats=3):
387
387
  if len(tokens) < pattern_size * min_repeats:
388
388
  return False
389
389
  pattern = tuple(tokens[-pattern_size:])
390
+ window = tokens[-(pattern_size * min_repeats + pattern_size):]
390
391
  positions = []
391
- limit = len(tokens) - pattern_size
392
+ limit = len(window) - pattern_size
392
393
  for i in range(limit):
393
- if tuple(tokens[i:i + pattern_size]) == pattern:
394
+ if tuple(window[i:i + pattern_size]) == pattern:
394
395
  positions.append(i)
395
396
  if len(positions) < min_repeats:
396
397
  return False
@@ -148,16 +148,17 @@ class Agent:
148
148
  r['content'].insert(0, {'type': 'text', 'text': warn})
149
149
  self.messages.extend(results)
150
150
  await self._emit({'type': 'tool_results_ready', 'payload': {}})
151
- new_gwt, diff_stat = commit_worktree(self.ctx['gwt'], self.messages)
152
- self.ctx['gwt'] = new_gwt
153
- if diff_stat:
154
- sha = new_gwt.commit[:8] if new_gwt else ''
155
- self.messages.append({'role': 'commit', 'content': f'[{sha}]\n{diff_stat}', 'sha': sha})
156
- await self._emit({'type': 'commit', 'payload': {'diff_stat': diff_stat, 'sha': sha}})
157
151
  if self._signal and self._signal.is_set():
158
152
  final['stop_reason'] = 'aborted'
159
153
  break
154
+ new_gwt, diff_stat = commit_worktree(self.ctx['gwt'], self.messages)
155
+ self.ctx['gwt'] = new_gwt
156
+ if diff_stat:
157
+ sha = new_gwt.commit[:8] if new_gwt else ''
158
+ self.messages.append({'role': 'commit', 'content': f'[{sha}]\n{diff_stat}', 'sha': sha})
159
+ await self._emit({'type': 'commit', 'payload': {'diff_stat': diff_stat, 'sha': sha}})
160
160
  await self._emit({'type': 'agent_end', 'payload': {'message': final}})
161
+ logger.debug(json.dumps(self.messages, indent=2, ensure_ascii=False))
161
162
  return final
162
163
 
163
164
  async def _execute_tools(self, calls: list[dict]) -> list[dict]:
@@ -237,7 +238,7 @@ class UIAdapter(Protocol):
237
238
 
238
239
  def exit_app(self, summary: list[dict]) -> None:
239
240
  ...
240
- HELP_TEXT = '\nCommands:\n/help show this message\n/clear [--config F] clear conversation; --config reconfigures agent from YAML/JSON\n/history show full conversation transcript\n/history --raw show raw API message log (debug)\n/diff [--all] show side-by-side diff of changes\n/errors show timestamped error log for this tab\n/tools list active tools\n/branch [--rev N] [--no-worktree] [prompt]\n open a branch tab; optional prompt runs immediately\n/branches list all tabs/branches\n/abort abort the running agent\n/export [path] export session to JSON\n/verbose toggle verbose mode (show raw tool calls and output)\n/exit /quit [--all] close branch tab, or exit the app\n!command run shell command in worktree (output captured)\n$command run interactive shell command (terminal handed to process)\n e.g. !ls !git diff !cat file.py\n $vim file.py $yazi $less log.txt\n\nKeys (TUI only):\nEnter submit\nCtrl-J insert newline in editor\nCtrl-1 … Ctrl-9 jump directly to tab N\nCtrl-, / Ctrl-. cycle through tabs\nCtrl-C abort running agent\nCtrl-D close branch tab (exit app if last tab)\nCtrl-R recall last prompt into editor\n'
241
+ HELP_TEXT = '\nCommands:\n/help show this message\n/clear [--config F] clear conversation; --config reconfigures agent from YAML/JSON\n/history show full conversation transcript\n/history --raw show raw API message log (debug)\n/diff [--all] show side-by-side diff of changes\n/errors show timestamped error log for this tab\n/tools list active tools\n/branch [--rev N] [--no-worktree] [prompt]\n open a branch tab; optional prompt runs immediately\n/branches list all tabs/branches\n/abort abort the running agent\n/export [path] export session to JSON\n/verbose toggle verbose mode (show raw tool calls and output)\n/merge merge this branch into its parent tab, then close\n/exit /quit [--all] close branch tab, or exit the app\n!command run shell command in worktree (output captured)\n$command run interactive shell command (terminal handed to process)\n e.g. !ls !git diff !cat file.py\n $vim file.py $yazi $less log.txt\n\nKeys (TUI only):\nEnter submit\nCtrl-J insert newline in editor\nCtrl-1 … Ctrl-9 jump directly to tab N\nCtrl-, / Ctrl-. cycle through tabs\nCtrl-C abort running agent\nCtrl-D close branch tab (exit app if last tab)\nCtrl-R recall last prompt into editor\n'
241
242
 
242
243
  class CommandEngine:
243
244
 
@@ -289,6 +290,15 @@ class CommandEngine:
289
290
  self._unsubscribers[key]()
290
291
  del self._unsubscribers[key]
291
292
 
293
+ def _find_parent_tab(self, tab: TabModel) -> TabModel | None:
294
+ if not tab.index_path:
295
+ return None
296
+ parent_path = tab.index_path[:-1]
297
+ for t in self.tabs:
298
+ if t.index_path == parent_path:
299
+ return t
300
+ return None
301
+
292
302
  async def handle_input(self, text: str) -> None:
293
303
  text = text.strip()
294
304
  if not text:
@@ -310,7 +320,7 @@ class CommandEngine:
310
320
  cmd, _, arg = text.partition(' ')
311
321
  cmd = cmd.lower().strip()
312
322
  arg = arg.strip()
313
- handlers = {'/help': self._cmd_help, '/clear': self._cmd_clear, '/history': self._cmd_history, '/diff': self._cmd_diff, '/errors': self._cmd_errors, '/tools': self._cmd_tools, '/abort': self._cmd_abort, '/branch': self._cmd_branch, '/branches': self._cmd_branches, '/tab': self._cmd_tab, '/export': self._cmd_export, '/verbose': self._cmd_verbose, '/exit': self._cmd_exit, '/quit': self._cmd_exit}
323
+ handlers = {'/help': self._cmd_help, '/clear': self._cmd_clear, '/history': self._cmd_history, '/diff': self._cmd_diff, '/errors': self._cmd_errors, '/tools': self._cmd_tools, '/abort': self._cmd_abort, '/branch': self._cmd_branch, '/branches': self._cmd_branches, '/tab': self._cmd_tab, '/export': self._cmd_export, '/verbose': self._cmd_verbose, '/merge': self._cmd_merge, '/exit': self._cmd_exit, '/quit': self._cmd_exit}
314
324
  handler = handlers.get(cmd)
315
325
  if handler:
316
326
  await handler(arg)
@@ -330,6 +340,9 @@ class CommandEngine:
330
340
  self.ui.show_error(f'Config error: {e}')
331
341
  return
332
342
  tab = self.active_tab
343
+ if tab.is_running:
344
+ self.ui.show_error('Agent is running — /abort first before /clear --config.')
345
+ return
333
346
  old = tab.agent
334
347
  new_ctx = {k: v for k, v in old.ctx.items() if k != 'agent'}
335
348
  new_agent = Agent(system=cfg.get('system'), api=cfg.get('api'), model=cfg.get('model'), api_key=cfg.get('api_key'), base_url=cfg.get('base_url'), tool_names=cfg.get('tools'), extra_tool_classes=old._extra_tool_classes, ctx=new_ctx)
@@ -557,6 +570,36 @@ class CommandEngine:
557
570
  state = 'on' if self.verbose else 'off'
558
571
  self.ui.show_command_result('/verbose', f'Verbose mode {state}.')
559
572
 
573
+ async def _cmd_merge(self, arg: str) -> None:
574
+ tab = self.active_tab
575
+ if tab.is_main:
576
+ self.ui.show_error('Cannot /merge the main tab — it has no parent.')
577
+ return
578
+ if tab.is_running:
579
+ self.ui.show_error('Agent is running — /abort first.')
580
+ return
581
+ parent = self._find_parent_tab(tab)
582
+ if parent is None:
583
+ self.ui.show_error(f'Cannot find parent tab for {tab.title!r}.')
584
+ return
585
+ child_gwt = tab.agent.ctx.get('gwt')
586
+ parent_gwt = parent.agent.ctx.get('gwt')
587
+ if child_gwt is None or parent_gwt is None:
588
+ self.ui.show_error('Both tabs need git worktrees to merge.')
589
+ return
590
+ commit_worktree(child_gwt, tab.agent.messages)
591
+ from .gits import merge_branch_into_worktree
592
+ success, msg = merge_branch_into_worktree(parent_gwt, child_gwt)
593
+ if not success:
594
+ self.ui.show_error(f'Merge failed: {msg}')
595
+ return
596
+ new_parent_gwt, diff_stat = commit_worktree(parent_gwt, parent.agent.messages)
597
+ parent.agent.ctx['gwt'] = new_parent_gwt
598
+ self.ui.show_command_result('/merge', f'Merged {tab.title!r} into {parent.title!r}.\n' + (f'{diff_stat}' if diff_stat else '(no changes)'))
599
+ self._do_close_or_exit()
600
+ parent_idx = self.tabs.index(parent)
601
+ self.switch_tab(parent_idx)
602
+
560
603
  async def _cmd_exit(self, arg: str) -> None:
561
604
  if arg == '--all':
562
605
  summary = self._build_exit_summary()
@@ -636,6 +679,12 @@ class CommandEngine:
636
679
  tab = self.active_tab
637
680
  removed_index = self.active_index
638
681
  self.detach_agent(tab)
682
+ gwt_ref = tab.agent.ctx.get('gwt')
683
+ if gwt_ref and getattr(gwt_ref, 'worktree', None):
684
+ try:
685
+ cleanup_worktree(gwt_ref, remove_branch=True)
686
+ except Exception:
687
+ logger.error('Failed worktree cleanup')
639
688
  self.tabs.pop(removed_index)
640
689
  if self.active_index >= len(self.tabs):
641
690
  self.active_index = len(self.tabs) - 1
@@ -386,6 +386,7 @@ class AgentTool(Tool):
386
386
  parameters = AgentParams
387
387
 
388
388
  async def execute(self, params: AgentParams, signal=None) -> dict:
389
+ from gits import create_worktree, commit_worktree, merge_branch_into_worktree
389
390
  parent = self.ctx['agent']
390
391
  overrides = {}
391
392
  if params.api is not None:
@@ -394,11 +395,23 @@ class AgentTool(Tool):
394
395
  overrides['system'] = params.system
395
396
  overrides['tool_names'] = params.tools
396
397
  child = parent.spawn(**overrides)
398
+ parent_gwt = parent.ctx.get('gwt')
399
+ parent_cwd = parent.ctx.get('cwd', os.getcwd())
400
+ repo_dir = parent_gwt.worktree if parent_gwt else parent_cwd
401
+ child_gwt = create_worktree(repo_dir, prefix='subagent')
402
+ if child_gwt is None:
403
+ return tout('Agent failed: could not create isolated worktree. Check .log.json for git errors.', True)
404
+ child.ctx['gwt'] = child_gwt
405
+ child.ctx['cwd'] = child_gwt.worktree
406
+ if 'env' in child.ctx:
407
+ child.ctx['env']['PWD'] = child_gwt.worktree
397
408
  if '_stream_log_fp' in parent.ctx:
398
409
  from .stream_log import StreamLogger
399
410
  StreamLogger.attach_to_child(child, parent.ctx, tool_name=f'sub-agent-{''.join(random.choices(string.ascii_letters + string.digits, k=2))}')
400
411
  try:
401
412
  result = await child.run(params.task)
413
+ commit_worktree(child_gwt, child.messages)
414
+ merge_branch_into_worktree(parent_gwt, child_gwt)
402
415
  texts = [b.get('text', '') for b in result['content'] if b.get('type') == 'text']
403
416
  combined = ''.join(texts).strip()
404
417
  if not combined:
@@ -234,7 +234,6 @@ class Tab(Vertical):
234
234
  self._stream_blocks.append({'type': 'thinking', 'text': delta})
235
235
  self.refresh_stream()
236
236
  elif et == 'tool_start':
237
- self._stream_blocks.append({'type': 'toolCall', 'name': payload.get('name', 'tool'), 'arguments': payload.get('args', {}), 'id': 'streaming'})
238
237
  self.refresh_stream()
239
238
  elif et == 'tool_end':
240
239
  if payload.get('is_error'):
@@ -597,7 +597,7 @@ def tui(stdscr, entries, log_file, initial_filter='', initial_visible=None):
597
597
  def main():
598
598
  parser = argparse.ArgumentParser(description='TUI viewer for JSON log files')
599
599
  parser.add_argument('logfile', nargs='?', default='.log.json', help='Path to log file (default: .log.json)')
600
- parser.add_argument('-f', '--filter', default=f'lvl:10;file:main,bats,repl,bare,gits,apis,tools', help='Initial filter string (same syntax as in UI)')
600
+ parser.add_argument('-f', '--filter', default=f'lvl:10;file:main,bats,repl,bare,tui,gits,apis,tools', help='Initial filter string (same syntax as in UI)')
601
601
  parser.add_argument('-o', '--out', dest='out', metavar='FILE', help='Write marked entries to FILE (JSON lines format) instead of stdout')
602
602
  args = parser.parse_args()
603
603
  log_path = args.logfile