mlx-code 0.0.29__tar.gz → 0.0.31__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.29 → mlx_code-0.0.31}/PKG-INFO +19 -10
  2. {mlx_code-0.0.29 → mlx_code-0.0.31}/README.md +14 -3
  3. {mlx_code-0.0.29 → mlx_code-0.0.31}/mlx_code/apis.py +2 -2
  4. {mlx_code-0.0.29 → mlx_code-0.0.31}/mlx_code/bats.py +20 -0
  5. {mlx_code-0.0.29 → mlx_code-0.0.31}/mlx_code/gits.py +32 -5
  6. {mlx_code-0.0.29 → mlx_code-0.0.31}/mlx_code/main.py +22 -42
  7. {mlx_code-0.0.29 → mlx_code-0.0.31}/mlx_code/repl.py +100 -36
  8. {mlx_code-0.0.29 → mlx_code-0.0.31}/mlx_code/tools.py +13 -0
  9. {mlx_code-0.0.29 → mlx_code-0.0.31}/mlx_code/tui.py +0 -1
  10. mlx_code-0.0.31/mlx_code/view_git.py +995 -0
  11. {mlx_code-0.0.29 → mlx_code-0.0.31}/mlx_code/view_log.py +1 -1
  12. {mlx_code-0.0.29 → mlx_code-0.0.31}/mlx_code/web.py +38 -75
  13. {mlx_code-0.0.29 → mlx_code-0.0.31}/mlx_code.egg-info/PKG-INFO +19 -10
  14. {mlx_code-0.0.29 → mlx_code-0.0.31}/mlx_code.egg-info/requires.txt +4 -6
  15. {mlx_code-0.0.29 → mlx_code-0.0.31}/setup.py +11 -11
  16. mlx_code-0.0.29/mlx_code/view_git.py +0 -824
  17. {mlx_code-0.0.29 → mlx_code-0.0.31}/LICENSE +0 -0
  18. {mlx_code-0.0.29 → mlx_code-0.0.31}/mlx_code/__init__.py +0 -0
  19. {mlx_code-0.0.29 → mlx_code-0.0.31}/mlx_code/bare.py +0 -0
  20. {mlx_code-0.0.29 → mlx_code-0.0.31}/mlx_code/lsp_tool.py +0 -0
  21. {mlx_code-0.0.29 → mlx_code-0.0.31}/mlx_code/mcb.py +0 -0
  22. {mlx_code-0.0.29 → mlx_code-0.0.31}/mlx_code/mcb_tool.py +0 -0
  23. {mlx_code-0.0.29 → mlx_code-0.0.31}/mlx_code/stream_log.py +0 -0
  24. {mlx_code-0.0.29 → mlx_code-0.0.31}/mlx_code/util.py +0 -0
  25. {mlx_code-0.0.29 → mlx_code-0.0.31}/mlx_code.egg-info/SOURCES.txt +0 -0
  26. {mlx_code-0.0.29 → mlx_code-0.0.31}/mlx_code.egg-info/dependency_links.txt +0 -0
  27. {mlx_code-0.0.29 → mlx_code-0.0.31}/mlx_code.egg-info/entry_points.txt +0 -0
  28. {mlx_code-0.0.29 → mlx_code-0.0.31}/mlx_code.egg-info/top_level.txt +0 -0
  29. {mlx_code-0.0.29 → mlx_code-0.0.31}/setup.cfg +0 -0
  30. {mlx_code-0.0.29 → mlx_code-0.0.31}/tests/__init__.py +0 -0
  31. {mlx_code-0.0.29 → mlx_code-0.0.31}/tests/test.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mlx-code
3
- Version: 0.0.29
3
+ Version: 0.0.31
4
4
  Summary: Coding Agent for Mac
5
5
  Home-page: https://josefalbers.github.io/mlx-code/
6
6
  Author: J Joe
@@ -15,13 +15,11 @@ License-File: LICENSE
15
15
  Requires-Dist: mlx-lm>=0.31.3; platform_system == "Darwin"
16
16
  Requires-Dist: httpx
17
17
  Requires-Dist: pydantic
18
- Requires-Dist: textual>=8.2.7
19
- Requires-Dist: rich>=15.0.0
20
- Requires-Dist: starlette
21
- Requires-Dist: uvicorn
22
18
  Provides-Extra: all
23
- Requires-Dist: python-lsp-server[all]; extra == "all"
24
- Requires-Dist: GitPython; extra == "all"
19
+ Requires-Dist: starlette; extra == "all"
20
+ Requires-Dist: uvicorn; extra == "all"
21
+ Requires-Dist: textual>=8.2.7; extra == "all"
22
+ Requires-Dist: rich>=15.0.0; extra == "all"
25
23
  Requires-Dist: pygments; extra == "all"
26
24
  Dynamic: author
27
25
  Dynamic: author-email
@@ -76,7 +74,7 @@ Agents: ├───────────────────
76
74
  │ │ Gemini │ │ Edit Bash │ │
77
75
  │ │ Claude │ │ Grep Find │ │
78
76
  │ │ Codex │ │ Ls Skill │ │
79
- │ │ DeepSeek │ │ Agent ─────────┼──┼───► Recursively spawns sub-Agents
77
+ │ │ DeepSeek │ │ Agent ────────────────► Recursively spawns sub-Agents
80
78
  │ └────────────────┘ └────────────────┘ │
81
79
  │ Git worktree │
82
80
  │ (isolation + session state) │
@@ -108,10 +106,10 @@ result = await agent.run('refactor utils.py to use dataclasses')
108
106
 
109
107
  ```bash
110
108
  # ephemeral run (no installation)
111
- uvx --from mlx-code mlc
109
+ uvx --from mlx-code[all] mlc
112
110
 
113
111
  # or install into the current environment
114
- pip install mlx-code
112
+ pip install mlx-code[all]
115
113
 
116
114
  # launch
117
115
  mlc # with a local MLX model
@@ -483,6 +481,7 @@ agent = Agent(extra_tool_classes=[LiveDBTool], tool_names=["QueryDB"])
483
481
  | `/abort` | Abort the running agent |
484
482
  | `/errors` | Show timestamped error log for the current tab |
485
483
  | `/export [path]` | Export session to JSON |
484
+ | `/merge` | Merge current branch into parent tab |
486
485
  | `/exit [--all]` | Close branch tab, or exit the app |
487
486
  | `/help` | Show command reference |
488
487
  | `!command` | Run a shell command; output captured in the TUI (eg, `ls`, `cat hello.c`) |
@@ -548,8 +547,18 @@ mlc --leash claude # claude code
548
547
  - subdomain: jjoe
549
548
  - domain: mlx-code.com
550
549
  - service url: http://host.containers.internal:8080
550
+
551
+ [protect & connect]-[zero trust]-[access controls]-[applications]-[create new application]-[self-hosted and private]:
552
+ - subdomain: jjoe
553
+ - domain: mlx-code.com
554
+ - [create new policy]
555
+ - policy name: Kaputt
556
+ - action: Allow
557
+ - [policy rules]-[(or) include]-[selector is]
558
+
551
559
  mlc --host 0.0.0.0 --engine batch --web &
552
560
  podman run cloudflare/cloudflared:latest tunnel --no-autoupdate run --token $JJ_CFD_TOKEN
561
+
553
562
  phone http://jjoe.mlx-code.com
554
563
  ```
555
564
 
@@ -38,7 +38,7 @@ Agents: ├───────────────────
38
38
  │ │ Gemini │ │ Edit Bash │ │
39
39
  │ │ Claude │ │ Grep Find │ │
40
40
  │ │ Codex │ │ Ls Skill │ │
41
- │ │ DeepSeek │ │ Agent ─────────┼──┼───► Recursively spawns sub-Agents
41
+ │ │ DeepSeek │ │ Agent ────────────────► Recursively spawns sub-Agents
42
42
  │ └────────────────┘ └────────────────┘ │
43
43
  │ Git worktree │
44
44
  │ (isolation + session state) │
@@ -70,10 +70,10 @@ result = await agent.run('refactor utils.py to use dataclasses')
70
70
 
71
71
  ```bash
72
72
  # ephemeral run (no installation)
73
- uvx --from mlx-code mlc
73
+ uvx --from mlx-code[all] mlc
74
74
 
75
75
  # or install into the current environment
76
- pip install mlx-code
76
+ pip install mlx-code[all]
77
77
 
78
78
  # launch
79
79
  mlc # with a local MLX model
@@ -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': {}})
@@ -294,6 +294,26 @@ def make_batch_app(model_name: str, cache_dir: str='.cache'):
294
294
  n_cached = sum((1 for _ in pc.cache_dir.glob('*.safetensors')))
295
295
  return JSONResponse({'status': 'ok', 'model': model_name, 'active_sequences': len(state['active']), 'prefix_cache_files': n_cached})
296
296
  return Starlette(routes=[Route('/v1/models', list_models, methods=['GET']), Route('/v1/messages/count_tokens', count_tokens, methods=['POST']), Route('/v1/chat/completions', generate_endpoint, methods=['POST']), Route('/v1/messages', generate_endpoint, methods=['POST']), Route('/v1/responses', generate_endpoint, methods=['POST']), Route('/v1beta/models/{rest:path}', generate_endpoint, methods=['POST']), Route('/generate', simple_generate, methods=['POST']), Route('/health', health, methods=['GET'])], lifespan=lifespan)
297
+
298
+ class BatchServer:
299
+ import uvicorn
300
+
301
+ def __init__(self, app, host: str, port: int):
302
+ config = uvicorn.Config(app, host=host, port=port, loop='asyncio', log_level='warning')
303
+ self._server = uvicorn.Server(config)
304
+ self.host = host
305
+ self.port = port
306
+
307
+ def serve_forever(self):
308
+ self._server.run()
309
+
310
+ @property
311
+ def started(self) -> bool:
312
+ return self._server.started
313
+
314
+ def make_batch_server(host: str, port: int, model, cache_dir: str='.cache') -> BatchServer:
315
+ app = make_batch_app(model, cache_dir=cache_dir)
316
+ return BatchServer(app, host, port)
297
317
  if __name__ == '__main__':
298
318
  import uvicorn
299
319
  uvicorn.run(make_batch_app('mlx-community/Qwen3.5-4B-OptiQ-4bit'), host='0.0.0.0', port=8000)
@@ -9,7 +9,6 @@ import uuid
9
9
  from dataclasses import dataclass
10
10
  from pathlib import Path
11
11
  logger = logging.getLogger(__name__)
12
- _ADD_EXCLUDES = ['_*', '*.bin', '*.gguf', '*.safetensors', '*.pt', '*.pth', '.cache/', '.log.json', '*.egg-info/', '.eggs/', 'build/', 'dist/', '__pycache__/', '*.pyc', '*.pyo', '*.pyd', '.pytest_cache/', '.tox/', '.nox/', '.coverage', 'htmlcov/', '.venv/', 'venv/', 'env/', '.DS_Store', 'Thumbs.db']
13
12
 
14
13
  class GitError(RuntimeError):
15
14
  pass
@@ -57,11 +56,22 @@ def _count_user_turns(commit_body: str) -> int:
57
56
  if messages:
58
57
  return sum((1 for m in messages if m.get('role') == 'user'))
59
58
  return 0
59
+ _ADD_EXCLUDES = ['_*', '_*/', '*.bin', '*.gguf', '*.safetensors', '*.pt', '*.pth', '.cache/', '.log.json', '*.egg-info/', '.eggs/', 'build/', 'dist/', '__pycache__/', '*.pyc', '*.pyo', '*.pyd', '.pytest_cache/', '.tox/', '.nox/', '.coverage', 'htmlcov/', '.venv/', 'venv/', 'env/', '.DS_Store', 'Thumbs.db']
60
+
61
+ def _exclude_pathspecs(patterns: list[str]) -> list[str]:
62
+ specs = []
63
+ for p in patterns:
64
+ if p.endswith('/'):
65
+ name = p[:-1]
66
+ specs.append(f':(exclude,glob)**/{name}/**')
67
+ else:
68
+ specs.append(f':(exclude,glob)**/{p}')
69
+ return specs
60
70
 
61
71
  def git_add_filtered(cwd: str) -> None:
62
- excludes = [f':(exclude){p}' for p in _ADD_EXCLUDES]
72
+ excludes = _exclude_pathspecs(_ADD_EXCLUDES)
63
73
  try:
64
- _git(cwd, 'add', '-A', '--', '.', *excludes)
74
+ _git(cwd, '-c', 'advice.addIgnoredFile=false', 'add', '-A', '--', '.', *excludes)
65
75
  except GitError as e:
66
76
  logger.warning('git add warning (ignored): %s', e)
67
77
 
@@ -83,7 +93,7 @@ def create_worktree(repo_dir: str, *, worktree_dir: str | None=None, ref: str='H
83
93
  root = repo_dir
84
94
  gi = os.path.join(root, '.gitignore')
85
95
  if not os.path.exists(gi):
86
- Path(gi).write_text('\n'.join(['_log.json']))
96
+ Path(gi).write_text('\n'.join(['.log.json']))
87
97
  if not _git(root, 'config', 'user.email', check=False):
88
98
  _git(root, 'config', 'user.email', 'agent@local')
89
99
  if not _git(root, 'config', 'user.name', check=False):
@@ -349,4 +359,21 @@ def get_branch_base_sha(worktree: str) -> str | None:
349
359
  except GitError:
350
360
  pass
351
361
  root_sha = _git(worktree, 'rev-list', '--max-parents=0', 'HEAD', check=False)
352
- return root_sha or None
362
+ return root_sha or None
363
+
364
+ def merge_branch_into_worktree(parent_gwt, child_gwt) -> tuple[bool, str]:
365
+ if parent_gwt is None or child_gwt is None:
366
+ out = f'Not git: parent_gwt={parent_gwt!r} child_gwt={child_gwt!r}'
367
+ logger.debug(out)
368
+ return (False, out)
369
+ try:
370
+ out = _git(parent_gwt.worktree, 'merge', '--no-edit', '--no-ff', child_gwt.branch)
371
+ logger.info(out)
372
+ return (True, out)
373
+ except GitError as exc:
374
+ logger.error('Failed merge')
375
+ try:
376
+ _git(parent_gwt.worktree, 'merge', '--abort', check=False)
377
+ except Exception as _exc:
378
+ logger.error('Failed merge --abort')
379
+ 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
@@ -889,11 +890,12 @@ def _serve_cache(host, port, model, cache, system, tools, skips, *, fixed_port=F
889
890
  raise
890
891
 
891
892
  def _serve_batch(host, port, model, cache_dir='.cache', *, fixed_port=False):
892
- import uvicorn
893
- from .bats import make_batch_app
893
+ try:
894
+ from .bats import make_batch_server
895
+ except ImportError:
896
+ print('[warning] uvicorn not installed — batch engine requires: pip install mlx-code[all]')
897
+ return _serve_cache(host, port, model, cache_dir, None, None, None, fixed_port=fixed_port)
894
898
  import socket
895
- import time
896
- app = make_batch_app(model, cache_dir=cache_dir)
897
899
  while True:
898
900
  try:
899
901
  with socket.socket() as s:
@@ -908,24 +910,10 @@ def _serve_batch(host, port, model, cache_dir='.cache', *, fixed_port=False):
908
910
  raise
909
911
  else:
910
912
  break
911
- config = uvicorn.Config(app, host=host, port=port, loop='asyncio', log_level='warning')
912
- uv_server = uvicorn.Server(config)
913
- t = threading.Thread(target=uv_server.run, daemon=True)
914
- t.start()
915
- start_time = time.time()
916
- notified = False
917
- while True:
918
- try:
919
- with socket.create_connection((host, port), timeout=0.1):
920
- break
921
- except OSError:
922
- if not notified and time.time() - start_time > 3.0:
923
- logger.info('Waiting for batch server to start (model may be downloading)...')
924
- notified = True
925
- time.sleep(0.2)
913
+ server = make_batch_server(host, port, model, cache_dir=cache_dir)
926
914
  url = f'http://{host}:{port}'
927
915
  logger.debug(f'Batch server bound to {url}')
928
- return (uv_server, url)
916
+ return (server, url)
929
917
 
930
918
  def main():
931
919
  parser = argparse.ArgumentParser(description='mlx-code MAIN')
@@ -966,28 +954,20 @@ def main():
966
954
  else:
967
955
  server, url = _serve_cache(host=args.host, port=port, model=args.model, cache=cache, system=None if args.leash in ('none', 'noapi') else args.system, tools=args.tools, skips=args.skips, fixed_port=fixed_port, gwt=gwt)
968
956
  if args.leash == 'none':
969
- if args.engine == 'batch':
970
- try:
971
- threading.Event().wait()
972
- except KeyboardInterrupt:
973
- print('\nShutting down server...')
974
- else:
975
- try:
976
- server.serve_forever()
977
- except KeyboardInterrupt:
978
- print('\nShutting down server...')
979
- server.server_close()
957
+ try:
958
+ server.serve_forever()
959
+ except KeyboardInterrupt:
960
+ print('\nShutting down server...')
961
+ server.server_close()
980
962
  else:
981
- if args.engine == 'cache':
982
- threading.Thread(target=server.serve_forever, daemon=True).start()
963
+ threading.Thread(target=server.serve_forever, daemon=True).start()
964
+ while not getattr(server, 'started', True):
965
+ time.sleep(0.05)
983
966
  if args.leash == 'noapi':
984
- if args.web:
985
- from .web import run_web
986
- web_port = args.web_port if args.web_port is not None else port + 80
987
- run_web(base_url=url, api=args.leash, repo=cwd, env=env, system=args.system, tool_names=args.tools, sdir=args.skill, init_prompt=args.prompt, resume=args.resume, stream=args.stream, host=args.host, port=web_port)
988
- else:
989
- from .repl import run_repl
990
- run_repl(base_url=url, api=args.leash, repo=cwd, env=env, system=args.system, tool_names=args.tools, sdir=args.skill, init_prompt=args.prompt, resume=args.resume, stream=args.stream, bare=args.bare)
967
+ ui_mode = 'web' if args.web else 'bare' if args.bare else 'tui'
968
+ web_port = args.web_port if args.web_port is not None else port + 80
969
+ from .repl import run_repl
970
+ run_repl(base_url=url, api=args.leash, repo=cwd, env=env, system=args.system, tool_names=args.tools, sdir=args.skill, init_prompt=args.prompt, resume=args.resume, stream=args.stream, ui_mode=ui_mode, web_host=args.host, web_port=web_port)
991
971
  else:
992
972
  env['GOOGLE_GEMINI_BASE_URL'] = url
993
973
  env['GEMINI_API_KEY'] = 'mc'
@@ -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
@@ -693,9 +742,9 @@ _AGENT_ENV_ALLOWLIST: re.Pattern = re.compile('\n ^(\n PATH\n | MANPATH
693
742
  def _make_agent_env(base: dict[str, str]) -> dict[str, str]:
694
743
  return {k: v for k, v in base.items() if _AGENT_ENV_ALLOWLIST.match(k)}
695
744
 
696
- async def repl(engine: CommandEngine, init_prompt=None, bare=False):
745
+ async def repl(engine: CommandEngine, init_prompt=None, ui_mode='tui'):
697
746
  is_tty = sys.stdin.isatty() and sys.stdout.isatty()
698
- if bare and is_tty:
747
+ if ui_mode == 'bare' and is_tty:
699
748
  from .bare import BareRepl
700
749
  r = BareRepl(engine, init_prompt=init_prompt)
701
750
  await r.run()
@@ -705,12 +754,21 @@ async def repl(engine: CommandEngine, init_prompt=None, bare=False):
705
754
  if user_input:
706
755
  await _stream_to_stdout(engine.active_tab.agent, user_input)
707
756
  return None
708
- from .tui import ReplApp
709
- app = ReplApp(engine, init_prompt=init_prompt)
710
- await app.run_async()
711
- return app
712
-
713
- def run_repl(*, base_url=None, model=None, api: Literal['claude', 'codex', 'gemini', 'deepseek', 'noapi']='noapi', system='', sdir=None, skills=None, env=None, tool_names=None, extra_tool_classes=None, api_key=None, gwt=None, ctx=None, init_prompt=None, resume_messages=None, repo=None, resume=None, stream=None, bare=False):
757
+ if ui_mode == 'tui':
758
+ try:
759
+ from .tui import ReplApp
760
+ except ImportError:
761
+ print('[warning] textual/rich not installed — falling back to bare.\n Install TUI deps with: pip install mlx-code[all]')
762
+ from .bare import BareRepl
763
+ r = BareRepl(engine, init_prompt=init_prompt)
764
+ await r.run()
765
+ return None
766
+ app = ReplApp(engine, init_prompt=init_prompt)
767
+ await app.run_async()
768
+ return app
769
+ return None
770
+
771
+ def run_repl(*, base_url=None, model=None, api='noapi', system='', sdir=None, skills=None, env=None, tool_names=None, extra_tool_classes=None, api_key=None, gwt=None, ctx=None, init_prompt=None, resume_messages=None, repo=None, resume=None, stream=None, ui_mode='tui', web_host='127.0.0.1', web_port=8080):
714
772
  repo = os.path.abspath(repo or os.getcwd())
715
773
  with tempfile.TemporaryDirectory(dir=tempfile.gettempdir()) as _home:
716
774
  if gwt is None:
@@ -736,9 +794,6 @@ def run_repl(*, base_url=None, model=None, api: Literal['claude', 'codex', 'gemi
736
794
  system = '\n\n'.join(filter(None, [system, skill_prompt]))
737
795
  merged_ctx = {'cwd': cwd, 'user_cwd': user_cwd, 'skills': skills, 'gwt': gwt, 'env': agent_env, **(ctx or {})}
738
796
  agent = Agent(system=system, api=api, model=model, tool_names=tool_names, extra_tool_classes=extra_tool_classes, api_key=api_key, base_url=base_url, ctx=merged_ctx)
739
- engine = CommandEngine()
740
- main_tab = TabModel(agent, title='main', is_main=True)
741
- engine.tabs = [main_tab]
742
797
  log_fp = None
743
798
  if stream is not None:
744
799
  from .stream_log import StreamLogger
@@ -751,26 +806,35 @@ def run_repl(*, base_url=None, model=None, api: Literal['claude', 'codex', 'gemi
751
806
  agent.messages = list(resume_messages)
752
807
  print(f'[resumed {len(resume_messages)} messages from checkpoint]')
753
808
  try:
754
- asyncio.run(repl(engine, init_prompt=init_prompt, bare=bare))
809
+ if ui_mode == 'web':
810
+ try:
811
+ from .web import run_web
812
+ except ImportError:
813
+ print('[warning] starlette/uvicorn not installed — falling back to bare.\n Install web deps with: pip install mlx-code[all]')
814
+ engine = CommandEngine()
815
+ main_tab = TabModel(agent, title='main', is_main=True)
816
+ engine.tabs = [main_tab]
817
+ asyncio.run(repl(engine, init_prompt=init_prompt, ui_mode='bare'))
818
+ else:
819
+ run_web(agent=agent, init_prompt=init_prompt, web_host=web_host, web_port=web_port)
820
+ else:
821
+ engine = CommandEngine()
822
+ main_tab = TabModel(agent, title='main', is_main=True)
823
+ engine.tabs = [main_tab]
824
+ asyncio.run(repl(engine, init_prompt=init_prompt, ui_mode=ui_mode))
755
825
  finally:
756
826
  if log_fp:
757
827
  log_fp.close()
758
- cleaned: set[str] = set()
759
- for tab in engine.tabs:
760
- gwt_ref = tab.agent.ctx.get('gwt')
761
- if gwt_ref and getattr(gwt_ref, 'worktree', None) and (gwt_ref.worktree not in cleaned):
762
- cleaned.add(gwt_ref.worktree)
763
- try:
764
- cleanup_worktree(gwt_ref)
765
- except Exception:
766
- pass
767
- if engine.exit_summary:
768
- print('\n--- Session Exit Summary ---')
769
- for item in engine.exit_summary:
770
- title = item['title']
771
- branch = item['branch'] or '(no branch)'
772
- marker = ' * <-- exit origin' if item['is_exit_tab'] else ''
773
- print(f' {title} ({branch}){marker}')
828
+ if 'engine' in locals() and hasattr(engine, 'tabs'):
829
+ cleaned: set[str] = set()
830
+ for tab in engine.tabs:
831
+ gwt_ref = tab.agent.ctx.get('gwt')
832
+ if gwt_ref and getattr(gwt_ref, 'worktree', None) and (gwt_ref.worktree not in cleaned):
833
+ cleaned.add(gwt_ref.worktree)
834
+ try:
835
+ cleanup_worktree(gwt_ref)
836
+ except Exception:
837
+ pass
774
838
 
775
839
  def main():
776
840
  import argparse
@@ -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'):