mlx-code 0.0.15__tar.gz → 0.0.16__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 (26) hide show
  1. {mlx_code-0.0.15 → mlx_code-0.0.16}/PKG-INFO +1 -1
  2. {mlx_code-0.0.15 → mlx_code-0.0.16}/mlx_code/gits.py +53 -2
  3. {mlx_code-0.0.15 → mlx_code-0.0.16}/mlx_code/repl.py +62 -32
  4. {mlx_code-0.0.15 → mlx_code-0.0.16}/mlx_code.egg-info/PKG-INFO +1 -1
  5. {mlx_code-0.0.15 → mlx_code-0.0.16}/setup.py +1 -1
  6. {mlx_code-0.0.15 → mlx_code-0.0.16}/LICENSE +0 -0
  7. {mlx_code-0.0.15 → mlx_code-0.0.16}/README.md +0 -0
  8. {mlx_code-0.0.15 → mlx_code-0.0.16}/mlx_code/__init__.py +0 -0
  9. {mlx_code-0.0.15 → mlx_code-0.0.16}/mlx_code/apis.py +0 -0
  10. {mlx_code-0.0.15 → mlx_code-0.0.16}/mlx_code/lsp_tool.py +0 -0
  11. {mlx_code-0.0.15 → mlx_code-0.0.16}/mlx_code/main.py +0 -0
  12. {mlx_code-0.0.15 → mlx_code-0.0.16}/mlx_code/mcb.py +0 -0
  13. {mlx_code-0.0.15 → mlx_code-0.0.16}/mlx_code/mcb_tool.py +0 -0
  14. {mlx_code-0.0.15 → mlx_code-0.0.16}/mlx_code/stream_log.py +0 -0
  15. {mlx_code-0.0.15 → mlx_code-0.0.16}/mlx_code/tools.py +0 -0
  16. {mlx_code-0.0.15 → mlx_code-0.0.16}/mlx_code/util.py +0 -0
  17. {mlx_code-0.0.15 → mlx_code-0.0.16}/mlx_code/view_git.py +0 -0
  18. {mlx_code-0.0.15 → mlx_code-0.0.16}/mlx_code/view_log.py +0 -0
  19. {mlx_code-0.0.15 → mlx_code-0.0.16}/mlx_code.egg-info/SOURCES.txt +0 -0
  20. {mlx_code-0.0.15 → mlx_code-0.0.16}/mlx_code.egg-info/dependency_links.txt +0 -0
  21. {mlx_code-0.0.15 → mlx_code-0.0.16}/mlx_code.egg-info/entry_points.txt +0 -0
  22. {mlx_code-0.0.15 → mlx_code-0.0.16}/mlx_code.egg-info/requires.txt +0 -0
  23. {mlx_code-0.0.15 → mlx_code-0.0.16}/mlx_code.egg-info/top_level.txt +0 -0
  24. {mlx_code-0.0.15 → mlx_code-0.0.16}/setup.cfg +0 -0
  25. {mlx_code-0.0.15 → mlx_code-0.0.16}/tests/__init__.py +0 -0
  26. {mlx_code-0.0.15 → mlx_code-0.0.16}/tests/test.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mlx-code
3
- Version: 0.0.15
3
+ Version: 0.0.16
4
4
  Summary: Coding Agent for Mac
5
5
  Home-page: https://josefalbers.github.io/mlx-code/
6
6
  Author: J Joe
@@ -11,7 +11,6 @@ logger = logging.getLogger(__name__)
11
11
  _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']
12
12
 
13
13
  class GitError(RuntimeError):
14
- pass
15
14
 
16
15
  def _git(cwd: str, *args: str, check: bool=True) -> str:
17
16
  try:
@@ -164,6 +163,18 @@ def git_new_branch(worktree: str, branch_name: str) -> LedgerPoint:
164
163
  logger.info('Created branch %s at %s', full_name, sha[:8])
165
164
  return point
166
165
 
166
+ def git_new_branch_at(worktree: str, branch_name: str, ref: str) -> LedgerPoint:
167
+ git_add_filtered(worktree)
168
+ if _git(worktree, 'status', '--porcelain'):
169
+ _git(worktree, 'commit', '--allow-empty', '-m', 'snapshot')
170
+ sha = _git(worktree, 'rev-parse', ref)
171
+ full_name = f'{branch_name}--{sha[:12]}'
172
+ _git(worktree, 'branch', full_name, sha)
173
+ _git(worktree, 'switch', full_name)
174
+ point = LedgerPoint(branch=full_name, commit=sha, worktree=worktree)
175
+ logger.info('Created branch %s at %s (from ref %s)', full_name, sha[:8], ref)
176
+ return point
177
+
167
178
  def git_switch_branch(worktree: str, branch_name: str) -> LedgerPoint:
168
179
  git_add_filtered(worktree)
169
180
  if _git(worktree, 'status', '--porcelain'):
@@ -227,4 +238,44 @@ def get_commit_history_with_stats(worktree: str, limit: int=50) -> list[dict]:
227
238
  return commits
228
239
  except Exception as e:
229
240
  logger.warning(f'get_commit_history_with_stats failed: {e}')
230
- return []
241
+ return []
242
+
243
+ def find_rev_commit(worktree: str, n: int) -> str | None:
244
+ try:
245
+ result = subprocess.run(['git', 'log', '--format=COMMIT:%H%n%b%nEND_BODY', '-n', '500'], cwd=worktree, capture_output=True, text=True, check=True)
246
+ output = result.stdout
247
+ best_exact: str | None = None
248
+ best_below: str | None = None
249
+ current_sha: str | None = None
250
+ body_lines: list[str] = []
251
+ for line in output.splitlines():
252
+ if line.startswith('COMMIT:'):
253
+ if current_sha is not None:
254
+ body = '\n'.join(body_lines)
255
+ count = body.count('"role": "user"')
256
+ if count == n and best_exact is None:
257
+ best_exact = current_sha
258
+ if count < n and best_below is None:
259
+ best_below = current_sha
260
+ current_sha = line[7:]
261
+ body_lines = []
262
+ elif line == 'END_BODY':
263
+ pass
264
+ else:
265
+ body_lines.append(line)
266
+ if current_sha is not None:
267
+ body = '\n'.join(body_lines)
268
+ count = body.count('"role": "user"')
269
+ if count == n and best_exact is None:
270
+ best_exact = current_sha
271
+ if count < n and best_below is None:
272
+ best_below = current_sha
273
+ chosen = best_exact or best_below
274
+ if chosen:
275
+ logger.info('find_rev_commit(n=%d): chose %s (exact=%s, below=%s)', n, chosen[:8], bool(best_exact), bool(best_below))
276
+ else:
277
+ logger.warning('find_rev_commit(n=%d): no suitable commit found', n)
278
+ return chosen
279
+ except Exception as e:
280
+ logger.warning('find_rev_commit failed: %s', e)
281
+ return None
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
  import asyncio
3
+ import copy
3
4
  import datetime
4
5
  import json
5
6
  import os
@@ -12,7 +13,7 @@ import time
12
13
  import logging
13
14
  import urllib.parse
14
15
  from typing import Any, Callable, Literal
15
- from .gits import create_worktree, commit_worktree, resume_worktree, cleanup_worktree, git_new_branch, git_switch_branch, GitError, get_commit_history_with_stats
16
+ from .gits import create_worktree, commit_worktree, resume_worktree, cleanup_worktree, git_new_branch, git_new_branch_at, git_switch_branch, GitError, get_commit_history_with_stats, find_rev_commit
16
17
  from .tools import Tool, validate_tool_call, DEFAULT_TOOLS
17
18
  from .apis import resolve_api
18
19
  logger = logging.getLogger(__name__)
@@ -54,7 +55,6 @@ class Agent:
54
55
  return Agent(**kwargs)
55
56
 
56
57
  def branch(self) -> 'Agent':
57
- import copy
58
58
  child = self.spawn(tool_names=[t.name for t in self.tools])
59
59
  child.messages = copy.deepcopy(self.messages)
60
60
  return child
@@ -212,7 +212,7 @@ def render_history(messages: list[dict]) -> Table:
212
212
  continue
213
213
  tbl.add_row(RichText(prefix, style=style), RichText(escape(text), style=style))
214
214
  return tbl
215
- REPL_HELP = 'Commands:\n /help show this message\n /clear clear conversation (transcript + agent memory)\n /history show full conversation transcript\n /history --raw show raw API message log (debug)\n /errors show timestamped error log for this tab\n /tools list active tools\n /branch [prompt] open a branch tab; optional prompt runs immediately\n /branch --as-worktree [prompt] same but in a private worktree dir\n /abort abort the running agent\n /export [path] export session to JSON\n /exit /quit close branch tab, or exit the app\n\nKeys:\n Enter submit\n Ctrl-J insert newline in editor\n Alt-1 … Alt-9 jump directly to tab N\n Tab / Shift-Tab cycle through tabs\n Ctrl-C abort running agent\n Ctrl-D close branch tab, or exit app\n Ctrl-R recall last prompt into editor\n'
215
+ REPL_HELP = 'Commands:\n /help show this message\n /clear clear conversation (transcript + agent memory)\n /history show full conversation transcript\n /history --raw show raw API message log (debug)\n /errors show timestamped error log for this tab\n /tools list active tools\n /branch [--rev N] [--as-worktree] [prompt]\n open a branch tab; optional prompt runs immediately\n --rev N branch from just before the N-th user turn;\n messages 1…N-1 are kept, N onward are discarded.\n if a prompt is given it becomes the new turn N.\n file state is restored to the nearest git checkpoint\n at or before turn N-1 (best-effort).\n --as-worktree give the branch its own private worktree directory\n /abort abort the running agent\n /export [path] export session to JSON\n /exit /quit close branch tab, or exit the app\n\nKeys:\n Enter submit\n Ctrl-J insert newline in editor\n Alt-1 … Alt-9 jump directly to tab N\n Tab / Shift-Tab cycle through tabs\n Ctrl-C abort running agent\n Ctrl-D close branch tab, or exit app\n Ctrl-R recall last prompt into editor\n'
216
216
 
217
217
  class InputBox(TextArea):
218
218
  BINDINGS = [Binding('ctrl+j', 'insert_newline', 'New line', show=False), Binding('enter', 'submit_text', 'Submit', show=False, priority=True), Binding('ctrl+r', 'recall_last', 'Recall', show=False), Binding('ctrl+d', 'request_close', 'Exit', show=False, priority=True)]
@@ -345,8 +345,6 @@ class Tab(Vertical):
345
345
  msgs = []
346
346
  if self._stream_blocks:
347
347
  msgs.append({'role': 'assistant', 'content': self._stream_blocks})
348
- if self._command_blocks and False:
349
- msgs.extend(self._command_blocks)
350
348
  if not msgs:
351
349
  msgs.extend(self._command_blocks)
352
350
  if msgs:
@@ -453,6 +451,7 @@ class ReplApp(App[None]):
453
451
  self._unsubscribers: dict[int, Callable] = {}
454
452
  self._pending_init = init_prompt.strip() if init_prompt else None
455
453
  self._confirm_close = False
454
+ self._exit_summary: list[dict] | None = None
456
455
  self._attach_agent(self.tabs[0])
457
456
 
458
457
  def compose(self) -> ComposeResult:
@@ -626,18 +625,41 @@ class ReplApp(App[None]):
626
625
  self.query_one('#helpbar', HelpBar).show_error('Nothing is running.')
627
626
  elif cmd == '/branch':
628
627
  as_worktree = False
628
+ rev_n: int | None = None
629
629
  prompt = arg
630
- if '--as-worktree' in arg:
630
+ if '--as-worktree' in prompt:
631
631
  as_worktree = True
632
- prompt = arg.replace('--as-worktree', '').strip()
632
+ prompt = prompt.replace('--as-worktree', '').strip()
633
+ rev_match = re.search('--rev\\s+(\\d+)', prompt)
634
+ if rev_match:
635
+ rev_n = int(rev_match.group(1))
636
+ prompt = (prompt[:rev_match.start()] + prompt[rev_match.end():]).strip()
633
637
  parent = self.active_tab
638
+ all_msgs = parent.agent.messages
639
+ user_indices = [i for i, m in enumerate(all_msgs) if m.get('role') == 'user']
640
+ if rev_n is not None:
641
+ if rev_n < 1 or rev_n > len(user_indices):
642
+ self.query_one('#helpbar', HelpBar).show_error(f'--rev {rev_n}: must be between 1 and {len(user_indices)}' + (' (no user turns yet)' if not user_indices else ''))
643
+ return
644
+ cut_at = user_indices[rev_n - 1]
645
+ sliced_messages = copy.deepcopy(all_msgs[:cut_at])
646
+ else:
647
+ sliced_messages = copy.deepcopy(all_msgs)
634
648
  child = parent.agent.branch()
649
+ child.messages = sliced_messages
635
650
  index_path, title = _branch_index_title(parent.index_path, self.tabs)
636
651
  owns_worktree = False
637
652
  gwt = child.ctx.get('gwt')
638
653
  if as_worktree:
639
654
  repo_dir = gwt.worktree if gwt else child.ctx.get('cwd', os.getcwd())
640
- new_gwt = create_worktree(repo_dir, prefix=title)
655
+ ref = 'HEAD'
656
+ if rev_n is not None and gwt:
657
+ target_sha = find_rev_commit(gwt.worktree, rev_n - 1)
658
+ if target_sha:
659
+ ref = target_sha
660
+ else:
661
+ self.query_one('#helpbar', HelpBar).show_error(f'--rev {rev_n}: no matching commit found; file state will be HEAD')
662
+ new_gwt = create_worktree(repo_dir, prefix=title, ref=ref)
641
663
  if new_gwt is None:
642
664
  self.query_one('#helpbar', HelpBar).show_error(f'git worktree creation failed for {title!r}')
643
665
  return
@@ -648,7 +670,15 @@ class ReplApp(App[None]):
648
670
  owns_worktree = True
649
671
  elif gwt:
650
672
  try:
651
- new_gwt = git_new_branch(gwt.worktree, title)
673
+ if rev_n is not None:
674
+ target_sha = find_rev_commit(gwt.worktree, rev_n - 1)
675
+ if target_sha:
676
+ new_gwt = git_new_branch_at(gwt.worktree, title, target_sha)
677
+ else:
678
+ self.query_one('#helpbar', HelpBar).show_error(f'--rev {rev_n}: no matching commit found; file state will be HEAD')
679
+ new_gwt = git_new_branch(gwt.worktree, title)
680
+ else:
681
+ new_gwt = git_new_branch(gwt.worktree, title)
652
682
  child.ctx['gwt'] = new_gwt
653
683
  except GitError as exc:
654
684
  logger.warning('git_new_branch failed for tab %r: %s', title, exc)
@@ -672,10 +702,18 @@ class ReplApp(App[None]):
672
702
  except OSError as exc:
673
703
  self.query_one('#helpbar', HelpBar).show_error(f'Export failed: {exc}')
674
704
  elif cmd in {'/exit', '/quit'}:
675
- self._do_close_or_exit()
705
+ self._exit_with_summary(tab)
676
706
  else:
677
707
  self.query_one('#helpbar', HelpBar).show_error(f'Unknown command: {cmd!r} — try /help')
678
708
 
709
+ def _exit_with_summary(self, exit_tab: Tab) -> None:
710
+ summary = []
711
+ for t in self.tabs:
712
+ gwt = t.agent.ctx.get('gwt')
713
+ summary.append({'title': t.title, 'branch': gwt.branch if gwt else None, 'worktree': gwt.worktree if gwt else None, 'is_exit_tab': t is exit_tab})
714
+ self._exit_summary = summary
715
+ self.exit()
716
+
679
717
  def _switch_to(self, idx: int) -> None:
680
718
  if not 0 <= idx < len(self.tabs):
681
719
  return
@@ -729,25 +767,7 @@ class ReplApp(App[None]):
729
767
  if tab.running_task:
730
768
  tab.running_task.cancel()
731
769
  self._confirm_close = False
732
- if tab.is_main and len(self.tabs) == 1:
733
- self.exit()
734
- return
735
- if tab.is_main:
736
- self.query_one('#helpbar', HelpBar).show_error('Close all branch tabs first, then Ctrl-D to exit.')
737
- return
738
- if (unsub := self._unsubscribers.pop(id(tab.agent), None)):
739
- unsub()
740
- if tab.owns_worktree:
741
- gwt = tab.agent.ctx.get('gwt')
742
- if gwt:
743
- try:
744
- cleanup_worktree(gwt)
745
- except Exception as exc:
746
- logger.warning('cleanup_worktree failed for %r: %s', tab.title, exc)
747
- tab.remove()
748
- self.tabs.pop(self.active_index)
749
- self.active_index = max(0, min(self.active_index, len(self.tabs) - 1))
750
- self._switch_to(self.active_index)
770
+ self._exit_with_summary(tab)
751
771
 
752
772
  def action_abort_agent(self) -> None:
753
773
  tab = self.active_tab
@@ -762,15 +782,16 @@ async def _stream_to_stdout(agent: Agent, user_input: str) -> None:
762
782
  if text:
763
783
  print(text)
764
784
 
765
- async def repl(agent, init_prompt=None) -> None:
785
+ async def repl(agent, init_prompt=None) -> ReplApp:
766
786
  is_tty = sys.stdin.isatty() and sys.stdout.isatty()
767
787
  if not is_tty:
768
788
  user_input = (init_prompt if init_prompt is not None else sys.stdin.read()).strip()
769
789
  if user_input:
770
790
  await _stream_to_stdout(agent, user_input)
771
- return
791
+ return None
772
792
  app = ReplApp(agent, init_prompt=init_prompt)
773
793
  await app.run_async()
794
+ return app
774
795
 
775
796
  def collect_skills(skills_dir, skills=None):
776
797
  skills = [] if skills is None else skills
@@ -836,8 +857,9 @@ def run_repl(*, base_url=None, model=None, api: Literal['claude', 'codex', 'gemi
836
857
  if resume_messages:
837
858
  agent.messages = list(resume_messages)
838
859
  print(f'[resumed {len(resume_messages)} messages from checkpoint]')
860
+ app_instance = None
839
861
  try:
840
- asyncio.run(repl(agent, init_prompt=init_prompt))
862
+ app_instance = asyncio.run(repl(agent, init_prompt=init_prompt))
841
863
  finally:
842
864
  if log_fp:
843
865
  log_fp.close()
@@ -847,8 +869,16 @@ def run_repl(*, base_url=None, model=None, api: Literal['claude', 'codex', 'gemi
847
869
  logger.warning('Could not restore original cwd %r: %s', _orig_cwd, exc)
848
870
  os.environ.clear()
849
871
  os.environ.update(_orig_environ)
872
+ if app_instance and hasattr(app_instance, '_exit_summary') and app_instance._exit_summary:
873
+ print('\n--- Session Exit Summary ---')
874
+ for item in app_instance._exit_summary:
875
+ title = item['title']
876
+ branch = item['branch'] or '(no branch)'
877
+ marker = ' * <-- exit origin' if item['is_exit_tab'] else ''
878
+ print(f' {title} ({branch}){marker}')
850
879
 
851
880
  def main():
881
+ import argparse
852
882
  from .util import setup_logger
853
883
  setup_logger(log_file='.log.json')
854
884
  parser = argparse.ArgumentParser(description='mlx-code REPL')
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mlx-code
3
- Version: 0.0.15
3
+ Version: 0.0.16
4
4
  Summary: Coding Agent for Mac
5
5
  Home-page: https://josefalbers.github.io/mlx-code/
6
6
  Author: J Joe
@@ -11,7 +11,7 @@ setup(
11
11
  author_email="albersj66@gmail.com",
12
12
  author="J Joe",
13
13
  license="Apache-2.0",
14
- version="0.0.15",
14
+ version="0.0.16",
15
15
  readme="README.md",
16
16
  description="Coding Agent for Mac",
17
17
  long_description=open("README.md").read(),
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes