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.
- {mlx_code-0.0.15 → mlx_code-0.0.17}/PKG-INFO +1 -1
- {mlx_code-0.0.15 → mlx_code-0.0.17}/mlx_code/gits.py +53 -1
- {mlx_code-0.0.15 → mlx_code-0.0.17}/mlx_code/repl.py +66 -34
- {mlx_code-0.0.15 → mlx_code-0.0.17}/mlx_code.egg-info/PKG-INFO +1 -1
- {mlx_code-0.0.15 → mlx_code-0.0.17}/setup.py +1 -1
- {mlx_code-0.0.15 → mlx_code-0.0.17}/LICENSE +0 -0
- {mlx_code-0.0.15 → mlx_code-0.0.17}/README.md +0 -0
- {mlx_code-0.0.15 → mlx_code-0.0.17}/mlx_code/__init__.py +0 -0
- {mlx_code-0.0.15 → mlx_code-0.0.17}/mlx_code/apis.py +0 -0
- {mlx_code-0.0.15 → mlx_code-0.0.17}/mlx_code/lsp_tool.py +0 -0
- {mlx_code-0.0.15 → mlx_code-0.0.17}/mlx_code/main.py +0 -0
- {mlx_code-0.0.15 → mlx_code-0.0.17}/mlx_code/mcb.py +0 -0
- {mlx_code-0.0.15 → mlx_code-0.0.17}/mlx_code/mcb_tool.py +0 -0
- {mlx_code-0.0.15 → mlx_code-0.0.17}/mlx_code/stream_log.py +0 -0
- {mlx_code-0.0.15 → mlx_code-0.0.17}/mlx_code/tools.py +0 -0
- {mlx_code-0.0.15 → mlx_code-0.0.17}/mlx_code/util.py +0 -0
- {mlx_code-0.0.15 → mlx_code-0.0.17}/mlx_code/view_git.py +0 -0
- {mlx_code-0.0.15 → mlx_code-0.0.17}/mlx_code/view_log.py +0 -0
- {mlx_code-0.0.15 → mlx_code-0.0.17}/mlx_code.egg-info/SOURCES.txt +0 -0
- {mlx_code-0.0.15 → mlx_code-0.0.17}/mlx_code.egg-info/dependency_links.txt +0 -0
- {mlx_code-0.0.15 → mlx_code-0.0.17}/mlx_code.egg-info/entry_points.txt +0 -0
- {mlx_code-0.0.15 → mlx_code-0.0.17}/mlx_code.egg-info/requires.txt +0 -0
- {mlx_code-0.0.15 → mlx_code-0.0.17}/mlx_code.egg-info/top_level.txt +0 -0
- {mlx_code-0.0.15 → mlx_code-0.0.17}/setup.cfg +0 -0
- {mlx_code-0.0.15 → mlx_code-0.0.17}/tests/__init__.py +0 -0
- {mlx_code-0.0.15 → mlx_code-0.0.17}/tests/test.py +0 -0
|
@@ -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]
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
632
|
+
if '--as-worktree' in prompt:
|
|
631
633
|
as_worktree = True
|
|
632
|
-
prompt =
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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) ->
|
|
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')
|
|
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
|
|
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
|
|
File without changes
|