mlx-code 0.0.14__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.
- {mlx_code-0.0.14 → mlx_code-0.0.16}/PKG-INFO +1 -1
- {mlx_code-0.0.14 → mlx_code-0.0.16}/mlx_code/gits.py +53 -1
- {mlx_code-0.0.14 → mlx_code-0.0.16}/mlx_code/repl.py +62 -32
- {mlx_code-0.0.14 → mlx_code-0.0.16}/mlx_code.egg-info/PKG-INFO +1 -1
- {mlx_code-0.0.14 → mlx_code-0.0.16}/setup.py +1 -1
- {mlx_code-0.0.14 → mlx_code-0.0.16}/LICENSE +0 -0
- {mlx_code-0.0.14 → mlx_code-0.0.16}/README.md +0 -0
- {mlx_code-0.0.14 → mlx_code-0.0.16}/mlx_code/__init__.py +0 -0
- {mlx_code-0.0.14 → mlx_code-0.0.16}/mlx_code/apis.py +0 -0
- {mlx_code-0.0.14 → mlx_code-0.0.16}/mlx_code/lsp_tool.py +0 -0
- {mlx_code-0.0.14 → mlx_code-0.0.16}/mlx_code/main.py +0 -0
- {mlx_code-0.0.14 → mlx_code-0.0.16}/mlx_code/mcb.py +0 -0
- {mlx_code-0.0.14 → mlx_code-0.0.16}/mlx_code/mcb_tool.py +0 -0
- {mlx_code-0.0.14 → mlx_code-0.0.16}/mlx_code/stream_log.py +0 -0
- {mlx_code-0.0.14 → mlx_code-0.0.16}/mlx_code/tools.py +0 -0
- {mlx_code-0.0.14 → mlx_code-0.0.16}/mlx_code/util.py +0 -0
- {mlx_code-0.0.14 → mlx_code-0.0.16}/mlx_code/view_git.py +0 -0
- {mlx_code-0.0.14 → mlx_code-0.0.16}/mlx_code/view_log.py +0 -0
- {mlx_code-0.0.14 → mlx_code-0.0.16}/mlx_code.egg-info/SOURCES.txt +0 -0
- {mlx_code-0.0.14 → mlx_code-0.0.16}/mlx_code.egg-info/dependency_links.txt +0 -0
- {mlx_code-0.0.14 → mlx_code-0.0.16}/mlx_code.egg-info/entry_points.txt +0 -0
- {mlx_code-0.0.14 → mlx_code-0.0.16}/mlx_code.egg-info/requires.txt +0 -0
- {mlx_code-0.0.14 → mlx_code-0.0.16}/mlx_code.egg-info/top_level.txt +0 -0
- {mlx_code-0.0.14 → mlx_code-0.0.16}/setup.cfg +0 -0
- {mlx_code-0.0.14 → mlx_code-0.0.16}/tests/__init__.py +0 -0
- {mlx_code-0.0.14 → mlx_code-0.0.16}/tests/test.py +0 -0
|
@@ -163,6 +163,18 @@ def git_new_branch(worktree: str, branch_name: str) -> LedgerPoint:
|
|
|
163
163
|
logger.info('Created branch %s at %s', full_name, sha[:8])
|
|
164
164
|
return point
|
|
165
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
|
+
|
|
166
178
|
def git_switch_branch(worktree: str, branch_name: str) -> LedgerPoint:
|
|
167
179
|
git_add_filtered(worktree)
|
|
168
180
|
if _git(worktree, 'status', '--porcelain'):
|
|
@@ -226,4 +238,44 @@ def get_commit_history_with_stats(worktree: str, limit: int=50) -> list[dict]:
|
|
|
226
238
|
return commits
|
|
227
239
|
except Exception as e:
|
|
228
240
|
logger.warning(f'get_commit_history_with_stats failed: {e}')
|
|
229
|
-
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]
|
|
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
|
|
630
|
+
if '--as-worktree' in prompt:
|
|
631
631
|
as_worktree = True
|
|
632
|
-
prompt =
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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) ->
|
|
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')
|
|
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
|