mlx-code 0.0.15__tar.gz → 0.0.17__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.17}/PKG-INFO +1 -1
  2. {mlx_code-0.0.15 → mlx_code-0.0.17}/mlx_code/gits.py +53 -1
  3. {mlx_code-0.0.15 → mlx_code-0.0.17}/mlx_code/repl.py +66 -34
  4. {mlx_code-0.0.15 → mlx_code-0.0.17}/mlx_code.egg-info/PKG-INFO +1 -1
  5. {mlx_code-0.0.15 → mlx_code-0.0.17}/setup.py +1 -1
  6. {mlx_code-0.0.15 → mlx_code-0.0.17}/LICENSE +0 -0
  7. {mlx_code-0.0.15 → mlx_code-0.0.17}/README.md +0 -0
  8. {mlx_code-0.0.15 → mlx_code-0.0.17}/mlx_code/__init__.py +0 -0
  9. {mlx_code-0.0.15 → mlx_code-0.0.17}/mlx_code/apis.py +0 -0
  10. {mlx_code-0.0.15 → mlx_code-0.0.17}/mlx_code/lsp_tool.py +0 -0
  11. {mlx_code-0.0.15 → mlx_code-0.0.17}/mlx_code/main.py +0 -0
  12. {mlx_code-0.0.15 → mlx_code-0.0.17}/mlx_code/mcb.py +0 -0
  13. {mlx_code-0.0.15 → mlx_code-0.0.17}/mlx_code/mcb_tool.py +0 -0
  14. {mlx_code-0.0.15 → mlx_code-0.0.17}/mlx_code/stream_log.py +0 -0
  15. {mlx_code-0.0.15 → mlx_code-0.0.17}/mlx_code/tools.py +0 -0
  16. {mlx_code-0.0.15 → mlx_code-0.0.17}/mlx_code/util.py +0 -0
  17. {mlx_code-0.0.15 → mlx_code-0.0.17}/mlx_code/view_git.py +0 -0
  18. {mlx_code-0.0.15 → mlx_code-0.0.17}/mlx_code/view_log.py +0 -0
  19. {mlx_code-0.0.15 → mlx_code-0.0.17}/mlx_code.egg-info/SOURCES.txt +0 -0
  20. {mlx_code-0.0.15 → mlx_code-0.0.17}/mlx_code.egg-info/dependency_links.txt +0 -0
  21. {mlx_code-0.0.15 → mlx_code-0.0.17}/mlx_code.egg-info/entry_points.txt +0 -0
  22. {mlx_code-0.0.15 → mlx_code-0.0.17}/mlx_code.egg-info/requires.txt +0 -0
  23. {mlx_code-0.0.15 → mlx_code-0.0.17}/mlx_code.egg-info/top_level.txt +0 -0
  24. {mlx_code-0.0.15 → mlx_code-0.0.17}/setup.cfg +0 -0
  25. {mlx_code-0.0.15 → mlx_code-0.0.17}/tests/__init__.py +0 -0
  26. {mlx_code-0.0.15 → mlx_code-0.0.17}/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.17
4
4
  Summary: Coding Agent for Mac
5
5
  Home-page: https://josefalbers.github.io/mlx-code/
6
6
  Author: J Joe
@@ -164,6 +164,18 @@ def git_new_branch(worktree: str, branch_name: str) -> LedgerPoint:
164
164
  logger.info('Created branch %s at %s', full_name, sha[:8])
165
165
  return point
166
166
 
167
+ def git_new_branch_at(worktree: str, branch_name: str, ref: str) -> LedgerPoint:
168
+ git_add_filtered(worktree)
169
+ if _git(worktree, 'status', '--porcelain'):
170
+ _git(worktree, 'commit', '--allow-empty', '-m', 'snapshot')
171
+ sha = _git(worktree, 'rev-parse', ref)
172
+ full_name = f'{branch_name}--{sha[:12]}'
173
+ _git(worktree, 'branch', full_name, sha)
174
+ _git(worktree, 'switch', full_name)
175
+ point = LedgerPoint(branch=full_name, commit=sha, worktree=worktree)
176
+ logger.info('Created branch %s at %s (from ref %s)', full_name, sha[:8], ref)
177
+ return point
178
+
167
179
  def git_switch_branch(worktree: str, branch_name: str) -> LedgerPoint:
168
180
  git_add_filtered(worktree)
169
181
  if _git(worktree, 'status', '--porcelain'):
@@ -227,4 +239,44 @@ def get_commit_history_with_stats(worktree: str, limit: int=50) -> list[dict]:
227
239
  return commits
228
240
  except Exception as e:
229
241
  logger.warning(f'get_commit_history_with_stats failed: {e}')
230
- return []
242
+ return []
243
+
244
+ def find_rev_commit(worktree: str, n: int) -> str | None:
245
+ try:
246
+ result = subprocess.run(['git', 'log', '--format=COMMIT:%H%n%b%nEND_BODY', '-n', '500'], cwd=worktree, capture_output=True, text=True, check=True)
247
+ output = result.stdout
248
+ best_exact: str | None = None
249
+ best_below: str | None = None
250
+ current_sha: str | None = None
251
+ body_lines: list[str] = []
252
+ for line in output.splitlines():
253
+ if line.startswith('COMMIT:'):
254
+ if current_sha is not None:
255
+ body = '\n'.join(body_lines)
256
+ count = body.count('"role": "user"')
257
+ if count == n and best_exact is None:
258
+ best_exact = current_sha
259
+ if count < n and best_below is None:
260
+ best_below = current_sha
261
+ current_sha = line[7:]
262
+ body_lines = []
263
+ elif line == 'END_BODY':
264
+ pass
265
+ else:
266
+ body_lines.append(line)
267
+ if current_sha is not None:
268
+ body = '\n'.join(body_lines)
269
+ count = body.count('"role": "user"')
270
+ if count == n and best_exact is None:
271
+ best_exact = current_sha
272
+ if count < n and best_below is None:
273
+ best_below = current_sha
274
+ chosen = best_exact or best_below
275
+ if chosen:
276
+ logger.info('find_rev_commit(n=%d): chose %s (exact=%s, below=%s)', n, chosen[:8], bool(best_exact), bool(best_below))
277
+ else:
278
+ logger.warning('find_rev_commit(n=%d): no suitable commit found', n)
279
+ return chosen
280
+ except Exception as e:
281
+ logger.warning('find_rev_commit failed: %s', e)
282
+ 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)]
@@ -339,20 +339,20 @@ class Tab(Vertical):
339
339
  self._cache_count = len(self.agent.messages)
340
340
  self.query_one('#cache', Static).update(render_history(self.agent.messages))
341
341
  scroll = self.query_one('#scroll', VerticalScroll)
342
- self.app.call_after_refresh(scroll.scroll_end, animate=False)
342
+ if scroll.max_scroll_y - scroll.scroll_y < 3:
343
+ self.app.call_after_refresh(scroll.scroll_end, animate=False)
343
344
 
344
345
  def refresh_stream(self) -> None:
345
346
  msgs = []
346
347
  if self._stream_blocks:
347
348
  msgs.append({'role': 'assistant', 'content': self._stream_blocks})
348
- if self._command_blocks and False:
349
- msgs.extend(self._command_blocks)
350
349
  if not msgs:
351
350
  msgs.extend(self._command_blocks)
352
351
  if msgs:
353
352
  self.query_one('#stream', Static).update(render_history(msgs))
354
353
  scroll = self.query_one('#scroll', VerticalScroll)
355
- self.app.call_after_refresh(scroll.scroll_end, animate=False)
354
+ if scroll.max_scroll_y - scroll.scroll_y < 3:
355
+ self.app.call_after_refresh(scroll.scroll_end, animate=False)
356
356
  else:
357
357
  self.query_one('#stream', Static).update('')
358
358
 
@@ -453,6 +453,7 @@ class ReplApp(App[None]):
453
453
  self._unsubscribers: dict[int, Callable] = {}
454
454
  self._pending_init = init_prompt.strip() if init_prompt else None
455
455
  self._confirm_close = False
456
+ self._exit_summary: list[dict] | None = None
456
457
  self._attach_agent(self.tabs[0])
457
458
 
458
459
  def compose(self) -> ComposeResult:
@@ -626,18 +627,41 @@ class ReplApp(App[None]):
626
627
  self.query_one('#helpbar', HelpBar).show_error('Nothing is running.')
627
628
  elif cmd == '/branch':
628
629
  as_worktree = False
630
+ rev_n: int | None = None
629
631
  prompt = arg
630
- if '--as-worktree' in arg:
632
+ if '--as-worktree' in prompt:
631
633
  as_worktree = True
632
- prompt = arg.replace('--as-worktree', '').strip()
634
+ prompt = prompt.replace('--as-worktree', '').strip()
635
+ rev_match = re.search('--rev\\s+(\\d+)', prompt)
636
+ if rev_match:
637
+ rev_n = int(rev_match.group(1))
638
+ prompt = (prompt[:rev_match.start()] + prompt[rev_match.end():]).strip()
633
639
  parent = self.active_tab
640
+ all_msgs = parent.agent.messages
641
+ user_indices = [i for i, m in enumerate(all_msgs) if m.get('role') == 'user']
642
+ if rev_n is not None:
643
+ if rev_n < 1 or rev_n > len(user_indices):
644
+ 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 ''))
645
+ return
646
+ cut_at = user_indices[rev_n - 1]
647
+ sliced_messages = copy.deepcopy(all_msgs[:cut_at])
648
+ else:
649
+ sliced_messages = copy.deepcopy(all_msgs)
634
650
  child = parent.agent.branch()
651
+ child.messages = sliced_messages
635
652
  index_path, title = _branch_index_title(parent.index_path, self.tabs)
636
653
  owns_worktree = False
637
654
  gwt = child.ctx.get('gwt')
638
655
  if as_worktree:
639
656
  repo_dir = gwt.worktree if gwt else child.ctx.get('cwd', os.getcwd())
640
- new_gwt = create_worktree(repo_dir, prefix=title)
657
+ ref = 'HEAD'
658
+ if rev_n is not None and gwt:
659
+ target_sha = find_rev_commit(gwt.worktree, rev_n - 1)
660
+ if target_sha:
661
+ ref = target_sha
662
+ else:
663
+ self.query_one('#helpbar', HelpBar).show_error(f'--rev {rev_n}: no matching commit found; file state will be HEAD')
664
+ new_gwt = create_worktree(repo_dir, prefix=title, ref=ref)
641
665
  if new_gwt is None:
642
666
  self.query_one('#helpbar', HelpBar).show_error(f'git worktree creation failed for {title!r}')
643
667
  return
@@ -648,7 +672,15 @@ class ReplApp(App[None]):
648
672
  owns_worktree = True
649
673
  elif gwt:
650
674
  try:
651
- new_gwt = git_new_branch(gwt.worktree, title)
675
+ if rev_n is not None:
676
+ target_sha = find_rev_commit(gwt.worktree, rev_n - 1)
677
+ if target_sha:
678
+ new_gwt = git_new_branch_at(gwt.worktree, title, target_sha)
679
+ else:
680
+ self.query_one('#helpbar', HelpBar).show_error(f'--rev {rev_n}: no matching commit found; file state will be HEAD')
681
+ new_gwt = git_new_branch(gwt.worktree, title)
682
+ else:
683
+ new_gwt = git_new_branch(gwt.worktree, title)
652
684
  child.ctx['gwt'] = new_gwt
653
685
  except GitError as exc:
654
686
  logger.warning('git_new_branch failed for tab %r: %s', title, exc)
@@ -672,10 +704,18 @@ class ReplApp(App[None]):
672
704
  except OSError as exc:
673
705
  self.query_one('#helpbar', HelpBar).show_error(f'Export failed: {exc}')
674
706
  elif cmd in {'/exit', '/quit'}:
675
- self._do_close_or_exit()
707
+ self._exit_with_summary(tab)
676
708
  else:
677
709
  self.query_one('#helpbar', HelpBar).show_error(f'Unknown command: {cmd!r} — try /help')
678
710
 
711
+ def _exit_with_summary(self, exit_tab: Tab) -> None:
712
+ summary = []
713
+ for t in self.tabs:
714
+ gwt = t.agent.ctx.get('gwt')
715
+ 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})
716
+ self._exit_summary = summary
717
+ self.exit()
718
+
679
719
  def _switch_to(self, idx: int) -> None:
680
720
  if not 0 <= idx < len(self.tabs):
681
721
  return
@@ -729,25 +769,7 @@ class ReplApp(App[None]):
729
769
  if tab.running_task:
730
770
  tab.running_task.cancel()
731
771
  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)
772
+ self._exit_with_summary(tab)
751
773
 
752
774
  def action_abort_agent(self) -> None:
753
775
  tab = self.active_tab
@@ -762,15 +784,16 @@ async def _stream_to_stdout(agent: Agent, user_input: str) -> None:
762
784
  if text:
763
785
  print(text)
764
786
 
765
- async def repl(agent, init_prompt=None) -> None:
787
+ async def repl(agent, init_prompt=None) -> ReplApp:
766
788
  is_tty = sys.stdin.isatty() and sys.stdout.isatty()
767
789
  if not is_tty:
768
790
  user_input = (init_prompt if init_prompt is not None else sys.stdin.read()).strip()
769
791
  if user_input:
770
792
  await _stream_to_stdout(agent, user_input)
771
- return
793
+ return None
772
794
  app = ReplApp(agent, init_prompt=init_prompt)
773
795
  await app.run_async()
796
+ return app
774
797
 
775
798
  def collect_skills(skills_dir, skills=None):
776
799
  skills = [] if skills is None else skills
@@ -836,8 +859,9 @@ def run_repl(*, base_url=None, model=None, api: Literal['claude', 'codex', 'gemi
836
859
  if resume_messages:
837
860
  agent.messages = list(resume_messages)
838
861
  print(f'[resumed {len(resume_messages)} messages from checkpoint]')
862
+ app_instance = None
839
863
  try:
840
- asyncio.run(repl(agent, init_prompt=init_prompt))
864
+ app_instance = asyncio.run(repl(agent, init_prompt=init_prompt))
841
865
  finally:
842
866
  if log_fp:
843
867
  log_fp.close()
@@ -847,8 +871,16 @@ def run_repl(*, base_url=None, model=None, api: Literal['claude', 'codex', 'gemi
847
871
  logger.warning('Could not restore original cwd %r: %s', _orig_cwd, exc)
848
872
  os.environ.clear()
849
873
  os.environ.update(_orig_environ)
874
+ if app_instance and hasattr(app_instance, '_exit_summary') and app_instance._exit_summary:
875
+ print('\n--- Session Exit Summary ---')
876
+ for item in app_instance._exit_summary:
877
+ title = item['title']
878
+ branch = item['branch'] or '(no branch)'
879
+ marker = ' * <-- exit origin' if item['is_exit_tab'] else ''
880
+ print(f' {title} ({branch}){marker}')
850
881
 
851
882
  def main():
883
+ import argparse
852
884
  from .util import setup_logger
853
885
  setup_logger(log_file='.log.json')
854
886
  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.17
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.17",
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