mlx-code 0.0.16__tar.gz → 0.0.18__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.16 → mlx_code-0.0.18}/PKG-INFO +2 -1
- {mlx_code-0.0.16 → mlx_code-0.0.18}/mlx_code/gits.py +43 -9
- {mlx_code-0.0.16 → mlx_code-0.0.18}/mlx_code/repl.py +175 -11
- {mlx_code-0.0.16 → mlx_code-0.0.18}/mlx_code.egg-info/PKG-INFO +2 -1
- {mlx_code-0.0.16 → mlx_code-0.0.18}/mlx_code.egg-info/requires.txt +1 -0
- {mlx_code-0.0.16 → mlx_code-0.0.18}/setup.py +2 -1
- {mlx_code-0.0.16 → mlx_code-0.0.18}/LICENSE +0 -0
- {mlx_code-0.0.16 → mlx_code-0.0.18}/README.md +0 -0
- {mlx_code-0.0.16 → mlx_code-0.0.18}/mlx_code/__init__.py +0 -0
- {mlx_code-0.0.16 → mlx_code-0.0.18}/mlx_code/apis.py +0 -0
- {mlx_code-0.0.16 → mlx_code-0.0.18}/mlx_code/lsp_tool.py +0 -0
- {mlx_code-0.0.16 → mlx_code-0.0.18}/mlx_code/main.py +0 -0
- {mlx_code-0.0.16 → mlx_code-0.0.18}/mlx_code/mcb.py +0 -0
- {mlx_code-0.0.16 → mlx_code-0.0.18}/mlx_code/mcb_tool.py +0 -0
- {mlx_code-0.0.16 → mlx_code-0.0.18}/mlx_code/stream_log.py +0 -0
- {mlx_code-0.0.16 → mlx_code-0.0.18}/mlx_code/tools.py +0 -0
- {mlx_code-0.0.16 → mlx_code-0.0.18}/mlx_code/util.py +0 -0
- {mlx_code-0.0.16 → mlx_code-0.0.18}/mlx_code/view_git.py +0 -0
- {mlx_code-0.0.16 → mlx_code-0.0.18}/mlx_code/view_log.py +0 -0
- {mlx_code-0.0.16 → mlx_code-0.0.18}/mlx_code.egg-info/SOURCES.txt +0 -0
- {mlx_code-0.0.16 → mlx_code-0.0.18}/mlx_code.egg-info/dependency_links.txt +0 -0
- {mlx_code-0.0.16 → mlx_code-0.0.18}/mlx_code.egg-info/entry_points.txt +0 -0
- {mlx_code-0.0.16 → mlx_code-0.0.18}/mlx_code.egg-info/top_level.txt +0 -0
- {mlx_code-0.0.16 → mlx_code-0.0.18}/setup.cfg +0 -0
- {mlx_code-0.0.16 → mlx_code-0.0.18}/tests/__init__.py +0 -0
- {mlx_code-0.0.16 → mlx_code-0.0.18}/tests/test.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mlx-code
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.18
|
|
4
4
|
Summary: Coding Agent for Mac
|
|
5
5
|
Home-page: https://josefalbers.github.io/mlx-code/
|
|
6
6
|
Author: J Joe
|
|
@@ -20,6 +20,7 @@ Requires-Dist: rich>=15.0.0
|
|
|
20
20
|
Provides-Extra: all
|
|
21
21
|
Requires-Dist: python-lsp-server[all]; extra == "all"
|
|
22
22
|
Requires-Dist: GitPython; extra == "all"
|
|
23
|
+
Requires-Dist: pygments; extra == "all"
|
|
23
24
|
Dynamic: author
|
|
24
25
|
Dynamic: author-email
|
|
25
26
|
Dynamic: description
|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
import json
|
|
3
3
|
import logging
|
|
4
4
|
import os
|
|
5
|
+
import re
|
|
5
6
|
import shutil
|
|
6
7
|
import subprocess
|
|
7
8
|
import uuid
|
|
@@ -11,6 +12,7 @@ logger = logging.getLogger(__name__)
|
|
|
11
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']
|
|
12
13
|
|
|
13
14
|
class GitError(RuntimeError):
|
|
15
|
+
pass
|
|
14
16
|
|
|
15
17
|
def _git(cwd: str, *args: str, check: bool=True) -> str:
|
|
16
18
|
try:
|
|
@@ -24,9 +26,10 @@ def _git(cwd: str, *args: str, check: bool=True) -> str:
|
|
|
24
26
|
def _make_commit_message(messages) -> str:
|
|
25
27
|
if isinstance(messages, str):
|
|
26
28
|
return messages
|
|
27
|
-
|
|
29
|
+
filtered = [m for m in messages if m.get('role') != 'commit']
|
|
30
|
+
last_user = next((m['content'] for m in reversed(filtered) if m.get('role') == 'user'), None)
|
|
28
31
|
title = last_user.replace('\n', ' ').strip()[:30] if isinstance(last_user, str) else ''
|
|
29
|
-
return f'{title}\n\n{json.dumps(
|
|
32
|
+
return f'{title}\n\n{json.dumps(filtered, indent=2, ensure_ascii=False)}'
|
|
30
33
|
|
|
31
34
|
def _parse_messages_from_commit(raw: str) -> list[dict]:
|
|
32
35
|
if not raw or raw in ('snapshot', 'update'):
|
|
@@ -88,17 +91,18 @@ def create_worktree(repo_dir: str, *, worktree_dir: str | None=None, ref: str='H
|
|
|
88
91
|
logger.exception('create_worktree failed: %s', e)
|
|
89
92
|
return None
|
|
90
93
|
|
|
91
|
-
def commit_worktree(point: LedgerPoint | None, message: str='update') -> LedgerPoint | None:
|
|
94
|
+
def commit_worktree(point: LedgerPoint | None, message: str='update') -> tuple[LedgerPoint | None, str]:
|
|
92
95
|
if point is None:
|
|
93
|
-
return None
|
|
96
|
+
return (None, '')
|
|
94
97
|
try:
|
|
95
98
|
git_add_filtered(point.worktree)
|
|
96
99
|
if not _git(point.worktree, 'status', '--porcelain'):
|
|
97
100
|
sha = _git(point.worktree, 'rev-parse', 'HEAD', check=False) or point.commit
|
|
98
|
-
return LedgerPoint(branch=point.branch, commit=sha, worktree=point.worktree)
|
|
99
|
-
|
|
101
|
+
return (LedgerPoint(branch=point.branch, commit=sha, worktree=point.worktree), '')
|
|
102
|
+
diff_stat = ''
|
|
100
103
|
try:
|
|
101
104
|
stat = _git(point.worktree, 'diff', '--stat', 'HEAD')
|
|
105
|
+
diff_stat = stat
|
|
102
106
|
patch = _git(point.worktree, 'diff', 'HEAD')
|
|
103
107
|
diff_info = f'\n--- DIFF STAT ---\n{stat}\n--- PATCH ---\n{patch}'
|
|
104
108
|
except Exception as de:
|
|
@@ -107,10 +111,10 @@ def commit_worktree(point: LedgerPoint | None, message: str='update') -> LedgerP
|
|
|
107
111
|
new_sha = _git(point.worktree, 'rev-parse', 'HEAD')
|
|
108
112
|
new_point = LedgerPoint(branch=point.branch, commit=new_sha, worktree=point.worktree)
|
|
109
113
|
logger.info('%s%s', new_point, diff_info)
|
|
110
|
-
return new_point
|
|
114
|
+
return (new_point, diff_stat)
|
|
111
115
|
except Exception as e:
|
|
112
116
|
logger.exception('commit_worktree failed: %s', e)
|
|
113
|
-
return None
|
|
117
|
+
return (None, '')
|
|
114
118
|
|
|
115
119
|
def cleanup_worktree(point: LedgerPoint, *, remove_branch: bool=False) -> None:
|
|
116
120
|
root = _git(point.worktree, 'rev-parse', '--show-toplevel', check=False) or None
|
|
@@ -278,4 +282,34 @@ def find_rev_commit(worktree: str, n: int) -> str | None:
|
|
|
278
282
|
return chosen
|
|
279
283
|
except Exception as e:
|
|
280
284
|
logger.warning('find_rev_commit failed: %s', e)
|
|
281
|
-
return None
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
def get_diff_between_refs(worktree: str, ref1: str='HEAD~1', ref2: str='HEAD') -> str:
|
|
288
|
+
try:
|
|
289
|
+
return _git(worktree, 'diff', ref1, ref2)
|
|
290
|
+
except GitError:
|
|
291
|
+
try:
|
|
292
|
+
return _git(worktree, 'diff', '4b825dc642cb6eb9a060e54bf899d15d4a0d0e1d', ref2)
|
|
293
|
+
except GitError as e2:
|
|
294
|
+
raise GitError(f'Could not diff {ref1}..{ref2}: {e2}') from e2
|
|
295
|
+
|
|
296
|
+
def resolve_ref_short(worktree: str, ref: str) -> str:
|
|
297
|
+
try:
|
|
298
|
+
return _git(worktree, 'rev-parse', '--short', ref, check=False)
|
|
299
|
+
except GitError:
|
|
300
|
+
return ''
|
|
301
|
+
|
|
302
|
+
def get_branch_base_sha(worktree: str) -> str | None:
|
|
303
|
+
branch = _git(worktree, 'symbolic-ref', '--short', 'HEAD', check=False)
|
|
304
|
+
if branch:
|
|
305
|
+
match = re.search('--([0-9a-f]{12})', branch)
|
|
306
|
+
if match:
|
|
307
|
+
sha_part = match.group(1)
|
|
308
|
+
try:
|
|
309
|
+
full_sha = _git(worktree, 'rev-parse', sha_part, check=False)
|
|
310
|
+
if full_sha:
|
|
311
|
+
return full_sha
|
|
312
|
+
except GitError:
|
|
313
|
+
pass
|
|
314
|
+
root_sha = _git(worktree, 'rev-list', '--max-parents=0', 'HEAD', check=False)
|
|
315
|
+
return root_sha or None
|
|
@@ -13,7 +13,7 @@ import time
|
|
|
13
13
|
import logging
|
|
14
14
|
import urllib.parse
|
|
15
15
|
from typing import Any, Callable, Literal
|
|
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
|
+
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, get_diff_between_refs, get_branch_base_sha, resolve_ref_short
|
|
17
17
|
from .tools import Tool, validate_tool_call, DEFAULT_TOOLS
|
|
18
18
|
from .apis import resolve_api
|
|
19
19
|
logger = logging.getLogger(__name__)
|
|
@@ -26,6 +26,7 @@ from textual.widgets import ContentSwitcher, Static, TextArea
|
|
|
26
26
|
from rich.text import Text as RichText
|
|
27
27
|
from rich.table import Table
|
|
28
28
|
from rich.markup import escape
|
|
29
|
+
from rich.markdown import Markdown
|
|
29
30
|
|
|
30
31
|
class Agent:
|
|
31
32
|
|
|
@@ -101,7 +102,8 @@ class Agent:
|
|
|
101
102
|
final: dict = {'role': 'assistant', 'content': [], 'stop_reason': 'error', 'error_message': 'no turns ran', 'usage': {'input': 0, 'output': 0, 'cache_read': 0, 'cache_write': 0}}
|
|
102
103
|
while True:
|
|
103
104
|
await self._emit({'type': 'turn_start', 'payload': {}})
|
|
104
|
-
|
|
105
|
+
api_messages = [m for m in self.messages if m.get('role') != 'commit']
|
|
106
|
+
es = await self.api.stream(api_messages, self.system, self.tools)
|
|
105
107
|
async for event in es:
|
|
106
108
|
if event['type'] in ('text_delta', 'thinking_delta', 'error'):
|
|
107
109
|
await self._emit({'type': event['type'], 'payload': event['payload']})
|
|
@@ -133,7 +135,12 @@ class Agent:
|
|
|
133
135
|
r['content'].insert(0, {'type': 'text', 'text': warn})
|
|
134
136
|
self.messages.extend(results)
|
|
135
137
|
await self._emit({'type': 'tool_results_ready', 'payload': {}})
|
|
136
|
-
|
|
138
|
+
new_gwt, diff_stat = commit_worktree(self.ctx['gwt'], self.messages)
|
|
139
|
+
self.ctx['gwt'] = new_gwt
|
|
140
|
+
if diff_stat:
|
|
141
|
+
sha = new_gwt.commit[:8] if new_gwt else ''
|
|
142
|
+
self.messages.append({'role': 'commit', 'content': f'[{sha}]\n{diff_stat}', 'sha': sha})
|
|
143
|
+
await self._emit({'type': 'commit', 'payload': {'diff_stat': diff_stat, 'sha': sha}})
|
|
137
144
|
if self._signal and self._signal.is_set():
|
|
138
145
|
final['stop_reason'] = 'aborted'
|
|
139
146
|
break
|
|
@@ -168,6 +175,81 @@ def _branch_index_title(parent_path: tuple[int, ...], existing_tabs: list) -> tu
|
|
|
168
175
|
def _clean_block_text(text: str) -> str:
|
|
169
176
|
return text.lstrip('\n').rstrip()
|
|
170
177
|
|
|
178
|
+
def _render_commit_diff(text: str) -> RichText:
|
|
179
|
+
rt = RichText()
|
|
180
|
+
lines = text.split('\n')
|
|
181
|
+
for i, line in enumerate(lines):
|
|
182
|
+
if not line.strip():
|
|
183
|
+
continue
|
|
184
|
+
if line.lstrip().startswith('[') and ']' in line:
|
|
185
|
+
rt.append(line, style='bold cyan')
|
|
186
|
+
elif 'files changed' in line or 'file changed' in line:
|
|
187
|
+
rt.append(line, style='dim')
|
|
188
|
+
elif '|' in line:
|
|
189
|
+
pipe_idx = line.index('|')
|
|
190
|
+
rt.append(line[:pipe_idx + 1])
|
|
191
|
+
for ch in line[pipe_idx + 1:]:
|
|
192
|
+
if ch == '+':
|
|
193
|
+
rt.append(ch, style='green')
|
|
194
|
+
elif ch == '-':
|
|
195
|
+
rt.append(ch, style='red')
|
|
196
|
+
else:
|
|
197
|
+
rt.append(ch, style='dim')
|
|
198
|
+
else:
|
|
199
|
+
rt.append(line, style='dim')
|
|
200
|
+
if i < len(lines) - 1:
|
|
201
|
+
rt.append('\n')
|
|
202
|
+
return rt
|
|
203
|
+
|
|
204
|
+
def _render_split_diff(diff_text: str, ref1_label: str='HEAD~1', ref2_label: str='HEAD') -> Table:
|
|
205
|
+
tbl = Table(show_header=True, box=None, expand=True, padding=0, show_lines=False, pad_edge=False)
|
|
206
|
+
tbl.add_column(f' ◇ {ref1_label} ', style='red', ratio=1, width=1)
|
|
207
|
+
tbl.add_column(f' ◇ {ref2_label} ', style='green', ratio=1, width=1)
|
|
208
|
+
|
|
209
|
+
def _flush(left, right):
|
|
210
|
+
maxlen = max(len(left), len(right))
|
|
211
|
+
left += [''] * (maxlen - len(left))
|
|
212
|
+
right += [''] * (maxlen - len(right))
|
|
213
|
+
for l, r in zip(left, right):
|
|
214
|
+
l_style = 'bold red' if l.startswith('-') else 'dim' if l else ''
|
|
215
|
+
r_style = 'bold green' if r.startswith('+') else 'dim' if r else ''
|
|
216
|
+
tbl.add_row(RichText(escape(l), style=l_style), RichText(escape(r), style=r_style))
|
|
217
|
+
left.clear()
|
|
218
|
+
right.clear()
|
|
219
|
+
left_lines = []
|
|
220
|
+
right_lines = []
|
|
221
|
+
in_hunk = False
|
|
222
|
+
for line in diff_text.split('\n'):
|
|
223
|
+
if line.startswith('diff --git'):
|
|
224
|
+
_flush(left_lines, right_lines)
|
|
225
|
+
in_hunk = False
|
|
226
|
+
match = re.search('b/(.*)', line)
|
|
227
|
+
fname = match.group(1) if match else line
|
|
228
|
+
tbl.add_row(RichText(f'--- a/{fname}', style='bold cyan'), RichText(f'+++ b/{fname}', style='bold cyan'))
|
|
229
|
+
elif line.startswith('@@'):
|
|
230
|
+
_flush(left_lines, right_lines)
|
|
231
|
+
in_hunk = True
|
|
232
|
+
tbl.add_row(RichText(escape(line), style='bold yellow'), RichText(escape(line), style='bold yellow'))
|
|
233
|
+
elif not in_hunk:
|
|
234
|
+
if line.startswith('---') or line.startswith('+++'):
|
|
235
|
+
continue
|
|
236
|
+
_flush(left_lines, right_lines)
|
|
237
|
+
if line.strip():
|
|
238
|
+
tbl.add_row(RichText(escape(line), style='dim cyan'), RichText('', style='dim'))
|
|
239
|
+
elif line.startswith('-'):
|
|
240
|
+
left_lines.append(line)
|
|
241
|
+
elif line.startswith('+'):
|
|
242
|
+
right_lines.append(line)
|
|
243
|
+
elif line.startswith(' '):
|
|
244
|
+
_flush(left_lines, right_lines)
|
|
245
|
+
tbl.add_row(RichText(escape(line), style='dim'), RichText(escape(line), style='dim'))
|
|
246
|
+
else:
|
|
247
|
+
_flush(left_lines, right_lines)
|
|
248
|
+
if line.strip():
|
|
249
|
+
tbl.add_row(RichText(escape(line), style='dim'), RichText(escape(line), style='dim'))
|
|
250
|
+
_flush(left_lines, right_lines)
|
|
251
|
+
return tbl
|
|
252
|
+
|
|
171
253
|
def render_history(messages: list[dict]) -> Table:
|
|
172
254
|
tbl = Table(show_header=False, show_lines=False, box=None, pad_edge=False, expand=True, padding=0)
|
|
173
255
|
tbl.add_column(width=2)
|
|
@@ -176,7 +258,9 @@ def render_history(messages: list[dict]) -> Table:
|
|
|
176
258
|
role = msg.get('role')
|
|
177
259
|
content = msg.get('content')
|
|
178
260
|
is_error = msg.get('is_error', False)
|
|
179
|
-
if isinstance(content,
|
|
261
|
+
if isinstance(content, (Table, RichText)):
|
|
262
|
+
blocks = [{'type': 'renderable', 'renderable': content}]
|
|
263
|
+
elif isinstance(content, str):
|
|
180
264
|
blocks = [{'type': 'text', 'text': content}]
|
|
181
265
|
elif isinstance(content, list):
|
|
182
266
|
blocks = content
|
|
@@ -186,8 +270,11 @@ def render_history(messages: list[dict]) -> Table:
|
|
|
186
270
|
b_type = block.get('type', 'text')
|
|
187
271
|
text = block.get('text', '') or block.get('thinking', '')
|
|
188
272
|
prefix, style = ('', '')
|
|
273
|
+
render_as_md = False
|
|
189
274
|
if is_error:
|
|
190
275
|
prefix, style = ('✗', 'bold red')
|
|
276
|
+
elif role == 'commit':
|
|
277
|
+
prefix, style = ('◇', 'cyan')
|
|
191
278
|
elif role == 'user':
|
|
192
279
|
prefix, style = ('≫', 'bold green')
|
|
193
280
|
elif role == 'system':
|
|
@@ -198,6 +285,7 @@ def render_history(messages: list[dict]) -> Table:
|
|
|
198
285
|
prefix, style = ('◌', 'dim italic')
|
|
199
286
|
elif role == 'toolResult':
|
|
200
287
|
prefix, style = ('→', 'dim yellow')
|
|
288
|
+
render_as_md = True
|
|
201
289
|
elif b_type == 'toolCall':
|
|
202
290
|
prefix, style = ('⚙', 'yellow')
|
|
203
291
|
args = block.get('arguments', {})
|
|
@@ -207,12 +295,26 @@ def render_history(messages: list[dict]) -> Table:
|
|
|
207
295
|
elif role == 'assistant':
|
|
208
296
|
prefix, style = ('○', '')
|
|
209
297
|
text = re.sub('\\s*<tool_call>.*?</tool_call>\\s*', '', text, flags=re.DOTALL).strip()
|
|
298
|
+
render_as_md = True
|
|
210
299
|
text = _clean_block_text(text)
|
|
300
|
+
if b_type == 'renderable':
|
|
301
|
+
prefix, style = ('✓', 'blue')
|
|
302
|
+
body = block.get('renderable')
|
|
303
|
+
tbl.add_row(RichText(prefix, style=style), body)
|
|
304
|
+
continue
|
|
211
305
|
if not text:
|
|
212
306
|
continue
|
|
213
|
-
|
|
307
|
+
if role == 'commit':
|
|
308
|
+
body = _render_commit_diff(text)
|
|
309
|
+
tbl.add_row(RichText('◇', style='cyan'), body)
|
|
310
|
+
continue
|
|
311
|
+
if render_as_md:
|
|
312
|
+
body = Markdown(text)
|
|
313
|
+
else:
|
|
314
|
+
body = RichText(escape(text), style=style)
|
|
315
|
+
tbl.add_row(RichText(prefix, style=style), body)
|
|
214
316
|
return tbl
|
|
215
|
-
REPL_HELP = '
|
|
317
|
+
REPL_HELP = '\nCommands:\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/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] [--as-worktree] [prompt]\n open a branch tab; optional prompt runs immediately\n/abort abort the running agent\n/export [path] export session to JSON\n/exit /quit close branch tab, or exit the app\n!command run shell command in worktree (output captured in TUI)\n!!command run interactive shell command (TUI suspends, terminal handed to process)\n e.g. !ls !git diff !cat file.py\n !!vim file.py !!yazi !!less log.txt\nKeys:\nEnter submit\nCtrl-J insert newline in editor\nAlt-1 … Alt-9 jump directly to tab N\nTab / Shift-Tab cycle through tabs\nCtrl-C abort running agent\nCtrl-D close branch tab, or exit app\nCtrl-R recall last prompt into editor\n'
|
|
216
318
|
|
|
217
319
|
class InputBox(TextArea):
|
|
218
320
|
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)]
|
|
@@ -317,6 +419,8 @@ class Tab(Vertical):
|
|
|
317
419
|
self.refresh_cache()
|
|
318
420
|
self._stream_blocks = []
|
|
319
421
|
self.refresh_stream()
|
|
422
|
+
elif et == 'commit':
|
|
423
|
+
self.refresh_cache()
|
|
320
424
|
elif et == 'error':
|
|
321
425
|
err = str(payload.get('error', payload))
|
|
322
426
|
self._stream_blocks.append({'type': 'text', 'text': err, 'is_error': True})
|
|
@@ -339,7 +443,8 @@ class Tab(Vertical):
|
|
|
339
443
|
self._cache_count = len(self.agent.messages)
|
|
340
444
|
self.query_one('#cache', Static).update(render_history(self.agent.messages))
|
|
341
445
|
scroll = self.query_one('#scroll', VerticalScroll)
|
|
342
|
-
|
|
446
|
+
if scroll.max_scroll_y - scroll.scroll_y < 3:
|
|
447
|
+
self.app.call_after_refresh(scroll.scroll_end, animate=False)
|
|
343
448
|
|
|
344
449
|
def refresh_stream(self) -> None:
|
|
345
450
|
msgs = []
|
|
@@ -350,7 +455,8 @@ class Tab(Vertical):
|
|
|
350
455
|
if msgs:
|
|
351
456
|
self.query_one('#stream', Static).update(render_history(msgs))
|
|
352
457
|
scroll = self.query_one('#scroll', VerticalScroll)
|
|
353
|
-
|
|
458
|
+
if scroll.max_scroll_y - scroll.scroll_y < 3:
|
|
459
|
+
self.app.call_after_refresh(scroll.scroll_end, animate=False)
|
|
354
460
|
else:
|
|
355
461
|
self.query_one('#stream', Static).update('')
|
|
356
462
|
|
|
@@ -422,7 +528,7 @@ class StatusBar(Static):
|
|
|
422
528
|
class HelpBar(Static):
|
|
423
529
|
|
|
424
530
|
def __init__(self, **kwargs):
|
|
425
|
-
self._idle_text = RichText('/help Ctrl-J newline Alt-1…9 tabs Ctrl-C abort Ctrl-D exit
|
|
531
|
+
self._idle_text = RichText('/help !cmd !!interactive Ctrl-J newline Alt-1…9 tabs Ctrl-C abort Ctrl-D exit', style='dim')
|
|
426
532
|
super().__init__(self._idle_text, **kwargs)
|
|
427
533
|
|
|
428
534
|
def show_idle(self) -> None:
|
|
@@ -490,7 +596,7 @@ class ReplApp(App[None]):
|
|
|
490
596
|
|
|
491
597
|
async def on_event(event: dict) -> None:
|
|
492
598
|
tab.apply_event(event)
|
|
493
|
-
if event['type'] in ('agent_start', 'agent_end', 'turn_end'):
|
|
599
|
+
if event['type'] in ('agent_start', 'agent_end', 'turn_end', 'commit'):
|
|
494
600
|
self._refresh_chrome()
|
|
495
601
|
self._unsubscribers[key] = tab.agent.subscribe(on_event)
|
|
496
602
|
|
|
@@ -515,6 +621,12 @@ class ReplApp(App[None]):
|
|
|
515
621
|
tab.errors.clear()
|
|
516
622
|
tab.last_error = ''
|
|
517
623
|
self.query_one('#helpbar', HelpBar).show_idle()
|
|
624
|
+
if text.startswith('!'):
|
|
625
|
+
command = text[1:].strip()
|
|
626
|
+
if not command:
|
|
627
|
+
return
|
|
628
|
+
await self._run_shell_command(tab, command)
|
|
629
|
+
return
|
|
518
630
|
if text.startswith('/'):
|
|
519
631
|
await self._handle_command(tab, text)
|
|
520
632
|
return
|
|
@@ -540,6 +652,25 @@ class ReplApp(App[None]):
|
|
|
540
652
|
tab.status = 'idle'
|
|
541
653
|
self._refresh_chrome()
|
|
542
654
|
|
|
655
|
+
async def _run_shell_command(self, tab: Tab, command: str) -> None:
|
|
656
|
+
if not command:
|
|
657
|
+
return
|
|
658
|
+
if command.startswith('cd ') or command == 'cd':
|
|
659
|
+
tab.show_command(f'!{command}', 'Not allowed — use /branch or set cwd in context')
|
|
660
|
+
return
|
|
661
|
+
gwt = tab.agent.ctx.get('gwt')
|
|
662
|
+
cwd = gwt.worktree if gwt and getattr(gwt, 'worktree', None) else tab.agent.ctx.get('cwd') or os.getcwd()
|
|
663
|
+
env = tab.agent.ctx.get('env')
|
|
664
|
+
try:
|
|
665
|
+
proc = await asyncio.create_subprocess_shell(command, cwd=cwd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, env=env if env else None)
|
|
666
|
+
stdout, _ = await proc.communicate()
|
|
667
|
+
output = stdout.decode(errors='replace').rstrip('\n')
|
|
668
|
+
if proc.returncode and proc.returncode != 0:
|
|
669
|
+
output += f'\n[exit {proc.returncode}]'
|
|
670
|
+
tab.show_command(f'!{command}', output or '(no output)')
|
|
671
|
+
except Exception as e:
|
|
672
|
+
tab.show_command(f'!{command}', f'Error: {e}')
|
|
673
|
+
|
|
543
674
|
async def _handle_command(self, tab: Tab, text: str) -> None:
|
|
544
675
|
cmd, _, arg = text.partition(' ')
|
|
545
676
|
cmd = cmd.lower().strip()
|
|
@@ -603,9 +734,42 @@ class ReplApp(App[None]):
|
|
|
603
734
|
line += f'\n {file_lines}'
|
|
604
735
|
lines.append(line)
|
|
605
736
|
tab.show_command(text, '\n'.join(lines))
|
|
737
|
+
elif cmd == '/diff':
|
|
738
|
+
gwt = tab.agent.ctx.get('gwt')
|
|
739
|
+
if not gwt or not getattr(gwt, 'worktree', None):
|
|
740
|
+
self.query_one('#helpbar', HelpBar).show_error('No git worktree available for this tab.')
|
|
741
|
+
return
|
|
742
|
+
ref1 = 'HEAD~1'
|
|
743
|
+
ref2 = 'HEAD'
|
|
744
|
+
is_all = '--all' in arg
|
|
745
|
+
if is_all:
|
|
746
|
+
base = get_branch_base_sha(gwt.worktree)
|
|
747
|
+
if base:
|
|
748
|
+
ref1 = base
|
|
749
|
+
else:
|
|
750
|
+
self.query_one('#helpbar', HelpBar).show_error('Could not determine base commit for --all.')
|
|
751
|
+
return
|
|
752
|
+
ref1_short = resolve_ref_short(gwt.worktree, ref1)
|
|
753
|
+
ref2_short = resolve_ref_short(gwt.worktree, ref2)
|
|
754
|
+
if is_all:
|
|
755
|
+
ref1_label = f'base ({ref1_short})' if ref1_short else 'base'
|
|
756
|
+
else:
|
|
757
|
+
ref1_label = f'HEAD~1 ({ref1_short})' if ref1_short else 'HEAD~1'
|
|
758
|
+
ref2_label = f'HEAD ({ref2_short})' if ref2_short else 'HEAD'
|
|
759
|
+
try:
|
|
760
|
+
diff_text = get_diff_between_refs(gwt.worktree, ref1, ref2)
|
|
761
|
+
except GitError as e:
|
|
762
|
+
self.query_one('#helpbar', HelpBar).show_error(f'Git diff failed: {e}')
|
|
763
|
+
return
|
|
764
|
+
if not diff_text.strip():
|
|
765
|
+
tab.show_command(text, f'No differences between {ref1_label} and {ref2_label}.')
|
|
766
|
+
return
|
|
767
|
+
renderable = _render_split_diff(diff_text, ref1_label, ref2_label)
|
|
768
|
+
tab._command_blocks.extend([{'role': 'user', 'content': text}, {'role': 'command', 'content': renderable}])
|
|
769
|
+
tab.refresh_stream()
|
|
606
770
|
elif cmd == '/errors':
|
|
607
771
|
if not tab.errors:
|
|
608
|
-
tab.show_command('No errors recorded.')
|
|
772
|
+
tab.show_command(text, 'No errors recorded.')
|
|
609
773
|
else:
|
|
610
774
|
lines = [f'{ts} {msg}' for ts, msg in tab.errors[-30:]]
|
|
611
775
|
tab.show_command(text, 'Error log\n' + '\n'.join(lines))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mlx-code
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.18
|
|
4
4
|
Summary: Coding Agent for Mac
|
|
5
5
|
Home-page: https://josefalbers.github.io/mlx-code/
|
|
6
6
|
Author: J Joe
|
|
@@ -20,6 +20,7 @@ Requires-Dist: rich>=15.0.0
|
|
|
20
20
|
Provides-Extra: all
|
|
21
21
|
Requires-Dist: python-lsp-server[all]; extra == "all"
|
|
22
22
|
Requires-Dist: GitPython; extra == "all"
|
|
23
|
+
Requires-Dist: pygments; extra == "all"
|
|
23
24
|
Dynamic: author
|
|
24
25
|
Dynamic: author-email
|
|
25
26
|
Dynamic: description
|
|
@@ -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.
|
|
14
|
+
version="0.0.18",
|
|
15
15
|
readme="README.md",
|
|
16
16
|
description="Coding Agent for Mac",
|
|
17
17
|
long_description=open("README.md").read(),
|
|
@@ -28,6 +28,7 @@ setup(
|
|
|
28
28
|
extras_require={"all": [
|
|
29
29
|
"python-lsp-server[all]",
|
|
30
30
|
"GitPython",
|
|
31
|
+
"pygments",
|
|
31
32
|
]},
|
|
32
33
|
packages=find_packages(),
|
|
33
34
|
entry_points={
|
|
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
|