mlx-code 0.0.17__tar.gz → 0.0.19__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.17 → mlx_code-0.0.19}/PKG-INFO +3 -2
- {mlx_code-0.0.17 → mlx_code-0.0.19}/README.md +1 -1
- {mlx_code-0.0.17 → mlx_code-0.0.19}/mlx_code/gits.py +93 -29
- {mlx_code-0.0.17 → mlx_code-0.0.19}/mlx_code/repl.py +236 -36
- {mlx_code-0.0.17 → mlx_code-0.0.19}/mlx_code/tools.py +1 -1
- {mlx_code-0.0.17 → mlx_code-0.0.19}/mlx_code.egg-info/PKG-INFO +3 -2
- {mlx_code-0.0.17 → mlx_code-0.0.19}/mlx_code.egg-info/requires.txt +1 -0
- {mlx_code-0.0.17 → mlx_code-0.0.19}/setup.py +2 -1
- {mlx_code-0.0.17 → mlx_code-0.0.19}/LICENSE +0 -0
- {mlx_code-0.0.17 → mlx_code-0.0.19}/mlx_code/__init__.py +0 -0
- {mlx_code-0.0.17 → mlx_code-0.0.19}/mlx_code/apis.py +0 -0
- {mlx_code-0.0.17 → mlx_code-0.0.19}/mlx_code/lsp_tool.py +0 -0
- {mlx_code-0.0.17 → mlx_code-0.0.19}/mlx_code/main.py +0 -0
- {mlx_code-0.0.17 → mlx_code-0.0.19}/mlx_code/mcb.py +0 -0
- {mlx_code-0.0.17 → mlx_code-0.0.19}/mlx_code/mcb_tool.py +0 -0
- {mlx_code-0.0.17 → mlx_code-0.0.19}/mlx_code/stream_log.py +0 -0
- {mlx_code-0.0.17 → mlx_code-0.0.19}/mlx_code/util.py +0 -0
- {mlx_code-0.0.17 → mlx_code-0.0.19}/mlx_code/view_git.py +0 -0
- {mlx_code-0.0.17 → mlx_code-0.0.19}/mlx_code/view_log.py +0 -0
- {mlx_code-0.0.17 → mlx_code-0.0.19}/mlx_code.egg-info/SOURCES.txt +0 -0
- {mlx_code-0.0.17 → mlx_code-0.0.19}/mlx_code.egg-info/dependency_links.txt +0 -0
- {mlx_code-0.0.17 → mlx_code-0.0.19}/mlx_code.egg-info/entry_points.txt +0 -0
- {mlx_code-0.0.17 → mlx_code-0.0.19}/mlx_code.egg-info/top_level.txt +0 -0
- {mlx_code-0.0.17 → mlx_code-0.0.19}/setup.cfg +0 -0
- {mlx_code-0.0.17 → mlx_code-0.0.19}/tests/__init__.py +0 -0
- {mlx_code-0.0.17 → mlx_code-0.0.19}/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.19
|
|
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
|
|
@@ -37,7 +38,7 @@ Dynamic: summary
|
|
|
37
38
|
|
|
38
39
|
A lightweight coding agent built on Apple's MLX framework.
|
|
39
40
|
|
|
40
|
-
|
|
41
|
+
https://github.com/user-attachments/assets/0569d101-8d0a-4e67-9e82-fce84a5ef3f0
|
|
41
42
|
|
|
42
43
|
---
|
|
43
44
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
A lightweight coding agent built on Apple's MLX framework.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
https://github.com/user-attachments/assets/0569d101-8d0a-4e67-9e82-fce84a5ef3f0
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -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
|
|
@@ -18,27 +19,43 @@ def _git(cwd: str, *args: str, check: bool=True) -> str:
|
|
|
18
19
|
r = subprocess.run(['git', *args], cwd=cwd, capture_output=True, text=True, check=check)
|
|
19
20
|
return r.stdout.strip()
|
|
20
21
|
except subprocess.CalledProcessError as exc:
|
|
21
|
-
|
|
22
|
+
args_str = repr(' '.join(args))
|
|
23
|
+
raise GitError(f'git {args_str} failed in {cwd!r}: {exc.stderr.strip()}') from exc
|
|
22
24
|
except FileNotFoundError as exc:
|
|
23
25
|
raise GitError('git executable not found in PATH') from exc
|
|
24
26
|
|
|
25
27
|
def _make_commit_message(messages) -> str:
|
|
26
28
|
if isinstance(messages, str):
|
|
27
29
|
return messages
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
filtered = [m for m in messages if m.get('role') != 'commit']
|
|
31
|
+
last_user = next((m['content'] for m in reversed(filtered) if m.get('role') == 'user'), None)
|
|
32
|
+
title = last_user.replace('\n', ' ').strip()[:60] if isinstance(last_user, str) else 'update'
|
|
33
|
+
body = json.dumps(filtered, indent=2, ensure_ascii=False)
|
|
34
|
+
return f'{title}\n\n--- BEGIN MESSAGES ---\n{body}'
|
|
31
35
|
|
|
32
36
|
def _parse_messages_from_commit(raw: str) -> list[dict]:
|
|
33
37
|
if not raw or raw in ('snapshot', 'update'):
|
|
34
38
|
return []
|
|
35
|
-
|
|
39
|
+
marker = '--- BEGIN MESSAGES ---\n'
|
|
40
|
+
idx = raw.find(marker)
|
|
41
|
+
if idx != -1:
|
|
42
|
+
payload = raw[idx + len(marker):]
|
|
43
|
+
else:
|
|
36
44
|
parts = raw.split('\n\n', 1)
|
|
37
|
-
|
|
45
|
+
payload = parts[1] if len(parts) == 2 else parts[0]
|
|
46
|
+
try:
|
|
47
|
+
msgs = json.loads(payload)
|
|
38
48
|
return msgs if isinstance(msgs, list) else []
|
|
39
|
-
except
|
|
49
|
+
except json.JSONDecodeError:
|
|
50
|
+
logger.warning('_parse_messages_from_commit: could not parse JSON from commit body')
|
|
40
51
|
return []
|
|
41
52
|
|
|
53
|
+
def _count_user_turns(commit_body: str) -> int:
|
|
54
|
+
messages = _parse_messages_from_commit(commit_body)
|
|
55
|
+
if messages:
|
|
56
|
+
return sum((1 for m in messages if m.get('role') == 'user'))
|
|
57
|
+
return 0
|
|
58
|
+
|
|
42
59
|
def git_add_filtered(cwd: str) -> None:
|
|
43
60
|
excludes = [f':(exclude){p}' for p in _ADD_EXCLUDES]
|
|
44
61
|
try:
|
|
@@ -89,17 +106,18 @@ def create_worktree(repo_dir: str, *, worktree_dir: str | None=None, ref: str='H
|
|
|
89
106
|
logger.exception('create_worktree failed: %s', e)
|
|
90
107
|
return None
|
|
91
108
|
|
|
92
|
-
def commit_worktree(point: LedgerPoint | None, message: str='update') -> LedgerPoint | None:
|
|
109
|
+
def commit_worktree(point: LedgerPoint | None, message: str='update') -> tuple[LedgerPoint | None, str]:
|
|
93
110
|
if point is None:
|
|
94
|
-
return None
|
|
111
|
+
return (None, '')
|
|
95
112
|
try:
|
|
96
113
|
git_add_filtered(point.worktree)
|
|
97
114
|
if not _git(point.worktree, 'status', '--porcelain'):
|
|
98
115
|
sha = _git(point.worktree, 'rev-parse', 'HEAD', check=False) or point.commit
|
|
99
|
-
return LedgerPoint(branch=point.branch, commit=sha, worktree=point.worktree)
|
|
100
|
-
|
|
116
|
+
return (LedgerPoint(branch=point.branch, commit=sha, worktree=point.worktree), '')
|
|
117
|
+
diff_stat = ''
|
|
101
118
|
try:
|
|
102
119
|
stat = _git(point.worktree, 'diff', '--stat', 'HEAD')
|
|
120
|
+
diff_stat = stat
|
|
103
121
|
patch = _git(point.worktree, 'diff', 'HEAD')
|
|
104
122
|
diff_info = f'\n--- DIFF STAT ---\n{stat}\n--- PATCH ---\n{patch}'
|
|
105
123
|
except Exception as de:
|
|
@@ -108,28 +126,43 @@ def commit_worktree(point: LedgerPoint | None, message: str='update') -> LedgerP
|
|
|
108
126
|
new_sha = _git(point.worktree, 'rev-parse', 'HEAD')
|
|
109
127
|
new_point = LedgerPoint(branch=point.branch, commit=new_sha, worktree=point.worktree)
|
|
110
128
|
logger.info('%s%s', new_point, diff_info)
|
|
111
|
-
return new_point
|
|
129
|
+
return (new_point, diff_stat)
|
|
112
130
|
except Exception as e:
|
|
113
131
|
logger.exception('commit_worktree failed: %s', e)
|
|
114
|
-
return None
|
|
132
|
+
return (None, '')
|
|
115
133
|
|
|
116
134
|
def cleanup_worktree(point: LedgerPoint, *, remove_branch: bool=False) -> None:
|
|
117
|
-
root
|
|
118
|
-
if root:
|
|
119
|
-
try:
|
|
120
|
-
_git(root, 'worktree', 'remove', '--force', point.worktree)
|
|
121
|
-
except Exception as e:
|
|
122
|
-
logger.warning('git worktree remove failed: %s', e)
|
|
135
|
+
root: str | None = None
|
|
123
136
|
if os.path.exists(point.worktree):
|
|
137
|
+
root = _git(point.worktree, 'rev-parse', '--show-toplevel', check=False) or None
|
|
138
|
+
if root:
|
|
139
|
+
try:
|
|
140
|
+
_git(root, 'worktree', 'remove', '--force', point.worktree)
|
|
141
|
+
except Exception as e:
|
|
142
|
+
logger.warning('git worktree remove failed: %s', e)
|
|
124
143
|
try:
|
|
125
144
|
shutil.rmtree(point.worktree, ignore_errors=True)
|
|
126
145
|
except Exception as e:
|
|
127
146
|
logger.warning('Filesystem cleanup failed for %s: %s', point.worktree, e)
|
|
128
|
-
|
|
147
|
+
else:
|
|
148
|
+
logger.warning('cleanup_worktree: worktree path %r no longer exists; skipping removal', point.worktree)
|
|
149
|
+
try:
|
|
150
|
+
root = _git(os.path.dirname(point.worktree), 'rev-parse', '--show-toplevel', check=False) or None
|
|
151
|
+
except Exception:
|
|
152
|
+
pass
|
|
153
|
+
if root:
|
|
129
154
|
try:
|
|
130
|
-
_git(root, '
|
|
155
|
+
_git(root, 'worktree', 'prune')
|
|
131
156
|
except Exception as e:
|
|
132
|
-
logger.warning('git
|
|
157
|
+
logger.warning('git worktree prune failed: %s', e)
|
|
158
|
+
if remove_branch:
|
|
159
|
+
if root:
|
|
160
|
+
try:
|
|
161
|
+
_git(root, 'branch', '-D', point.branch)
|
|
162
|
+
except Exception as e:
|
|
163
|
+
logger.warning('git branch deletion failed for %r: %s', point.branch, e)
|
|
164
|
+
else:
|
|
165
|
+
logger.warning('cleanup_worktree: could not locate repo root; branch %r was NOT deleted', point.branch)
|
|
133
166
|
|
|
134
167
|
def resume_worktree(repo_dir: str, commit: str, *, worktree_dir: str | None=None, prefix: str='resume') -> tuple[LedgerPoint | None, list[dict]]:
|
|
135
168
|
try:
|
|
@@ -205,7 +238,7 @@ def get_commit_history_with_stats(worktree: str, limit: int=50) -> list[dict]:
|
|
|
205
238
|
if line.startswith('COMMIT:'):
|
|
206
239
|
if current_commit:
|
|
207
240
|
body_text = '\n'.join(body_lines)
|
|
208
|
-
current_commit['user_turns'] = body_text
|
|
241
|
+
current_commit['user_turns'] = _count_user_turns(body_text)
|
|
209
242
|
commits.append(current_commit)
|
|
210
243
|
current_commit = {'sha': line[7:], 'short_sha': '', 'refs': '', 'subject': '', 'files': []}
|
|
211
244
|
in_body = False
|
|
@@ -234,16 +267,17 @@ def get_commit_history_with_stats(worktree: str, limit: int=50) -> list[dict]:
|
|
|
234
267
|
current_commit['files'].append(line)
|
|
235
268
|
if current_commit:
|
|
236
269
|
body_text = '\n'.join(body_lines)
|
|
237
|
-
current_commit['user_turns'] = body_text
|
|
270
|
+
current_commit['user_turns'] = _count_user_turns(body_text)
|
|
238
271
|
commits.append(current_commit)
|
|
239
272
|
return commits
|
|
240
273
|
except Exception as e:
|
|
241
274
|
logger.warning(f'get_commit_history_with_stats failed: {e}')
|
|
242
275
|
return []
|
|
243
276
|
|
|
244
|
-
def find_rev_commit(worktree: str, n: int) -> str | None:
|
|
277
|
+
def find_rev_commit(worktree: str, n: int, *, limit: int=0) -> str | None:
|
|
278
|
+
limit_args = ['-n', str(limit)] if limit > 0 else []
|
|
245
279
|
try:
|
|
246
|
-
result = subprocess.run(['git', 'log', '--format=COMMIT:%H%n%b%nEND_BODY',
|
|
280
|
+
result = subprocess.run(['git', 'log', '--format=COMMIT:%H%n%b%nEND_BODY', *limit_args], cwd=worktree, capture_output=True, text=True, check=True)
|
|
247
281
|
output = result.stdout
|
|
248
282
|
best_exact: str | None = None
|
|
249
283
|
best_below: str | None = None
|
|
@@ -253,7 +287,7 @@ def find_rev_commit(worktree: str, n: int) -> str | None:
|
|
|
253
287
|
if line.startswith('COMMIT:'):
|
|
254
288
|
if current_sha is not None:
|
|
255
289
|
body = '\n'.join(body_lines)
|
|
256
|
-
count = body
|
|
290
|
+
count = _count_user_turns(body)
|
|
257
291
|
if count == n and best_exact is None:
|
|
258
292
|
best_exact = current_sha
|
|
259
293
|
if count < n and best_below is None:
|
|
@@ -266,7 +300,7 @@ def find_rev_commit(worktree: str, n: int) -> str | None:
|
|
|
266
300
|
body_lines.append(line)
|
|
267
301
|
if current_sha is not None:
|
|
268
302
|
body = '\n'.join(body_lines)
|
|
269
|
-
count = body
|
|
303
|
+
count = _count_user_turns(body)
|
|
270
304
|
if count == n and best_exact is None:
|
|
271
305
|
best_exact = current_sha
|
|
272
306
|
if count < n and best_below is None:
|
|
@@ -279,4 +313,34 @@ def find_rev_commit(worktree: str, n: int) -> str | None:
|
|
|
279
313
|
return chosen
|
|
280
314
|
except Exception as e:
|
|
281
315
|
logger.warning('find_rev_commit failed: %s', e)
|
|
282
|
-
return None
|
|
316
|
+
return None
|
|
317
|
+
|
|
318
|
+
def get_diff_between_refs(worktree: str, ref1: str='HEAD~1', ref2: str='HEAD') -> str:
|
|
319
|
+
try:
|
|
320
|
+
return _git(worktree, 'diff', ref1, ref2)
|
|
321
|
+
except GitError:
|
|
322
|
+
try:
|
|
323
|
+
return _git(worktree, 'diff', '4b825dc642cb6eb9a060e54bf899d15d4a0d0e1d', ref2)
|
|
324
|
+
except GitError as e2:
|
|
325
|
+
raise GitError(f'Could not diff {ref1}..{ref2}: {e2}') from e2
|
|
326
|
+
|
|
327
|
+
def resolve_ref_short(worktree: str, ref: str) -> str:
|
|
328
|
+
try:
|
|
329
|
+
return _git(worktree, 'rev-parse', '--short', ref, check=False)
|
|
330
|
+
except GitError:
|
|
331
|
+
return ''
|
|
332
|
+
|
|
333
|
+
def get_branch_base_sha(worktree: str) -> str | None:
|
|
334
|
+
branch = _git(worktree, 'symbolic-ref', '--short', 'HEAD', check=False)
|
|
335
|
+
if branch:
|
|
336
|
+
match = re.search('--([0-9a-f]{12})', branch)
|
|
337
|
+
if match:
|
|
338
|
+
sha_part = match.group(1)
|
|
339
|
+
try:
|
|
340
|
+
full_sha = _git(worktree, 'rev-parse', sha_part, check=False)
|
|
341
|
+
if full_sha:
|
|
342
|
+
return full_sha
|
|
343
|
+
except GitError:
|
|
344
|
+
pass
|
|
345
|
+
root_sha = _git(worktree, 'rev-list', '--max-parents=0', 'HEAD', check=False)
|
|
346
|
+
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,8 @@ 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
|
|
30
|
+
from rich.cells import cell_len
|
|
29
31
|
|
|
30
32
|
class Agent:
|
|
31
33
|
|
|
@@ -50,7 +52,7 @@ class Agent:
|
|
|
50
52
|
self.tools = tools
|
|
51
53
|
|
|
52
54
|
def spawn(self, **overrides) -> 'Agent':
|
|
53
|
-
kwargs = {'
|
|
55
|
+
kwargs = {'system': self.system, 'extra_tool_classes': self._extra_tool_classes, 'model': self.model, 'api_key': self.api_key, 'base_url': self.base_url, 'ctx': {k: v for k, v in self.ctx.items() if k != 'agent'}}
|
|
54
56
|
kwargs.update(overrides)
|
|
55
57
|
return Agent(**kwargs)
|
|
56
58
|
|
|
@@ -62,6 +64,8 @@ class Agent:
|
|
|
62
64
|
async def run(self, prompt: str) -> dict:
|
|
63
65
|
await self._wait()
|
|
64
66
|
self._signal = None
|
|
67
|
+
self._last_result_sig = None
|
|
68
|
+
self._same_result_count = 0
|
|
65
69
|
self.messages.append({'role': 'user', 'content': prompt})
|
|
66
70
|
return await self._loop()
|
|
67
71
|
|
|
@@ -101,7 +105,8 @@ class Agent:
|
|
|
101
105
|
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
106
|
while True:
|
|
103
107
|
await self._emit({'type': 'turn_start', 'payload': {}})
|
|
104
|
-
|
|
108
|
+
api_messages = [m for m in self.messages if m.get('role') != 'commit']
|
|
109
|
+
es = await self.api.stream(api_messages, self.system, self.tools)
|
|
105
110
|
async for event in es:
|
|
106
111
|
if event['type'] in ('text_delta', 'thinking_delta', 'error'):
|
|
107
112
|
await self._emit({'type': event['type'], 'payload': event['payload']})
|
|
@@ -133,7 +138,12 @@ class Agent:
|
|
|
133
138
|
r['content'].insert(0, {'type': 'text', 'text': warn})
|
|
134
139
|
self.messages.extend(results)
|
|
135
140
|
await self._emit({'type': 'tool_results_ready', 'payload': {}})
|
|
136
|
-
|
|
141
|
+
new_gwt, diff_stat = commit_worktree(self.ctx['gwt'], self.messages)
|
|
142
|
+
self.ctx['gwt'] = new_gwt
|
|
143
|
+
if diff_stat:
|
|
144
|
+
sha = new_gwt.commit[:8] if new_gwt else ''
|
|
145
|
+
self.messages.append({'role': 'commit', 'content': f'[{sha}]\n{diff_stat}', 'sha': sha})
|
|
146
|
+
await self._emit({'type': 'commit', 'payload': {'diff_stat': diff_stat, 'sha': sha}})
|
|
137
147
|
if self._signal and self._signal.is_set():
|
|
138
148
|
final['stop_reason'] = 'aborted'
|
|
139
149
|
break
|
|
@@ -168,15 +178,95 @@ def _branch_index_title(parent_path: tuple[int, ...], existing_tabs: list) -> tu
|
|
|
168
178
|
def _clean_block_text(text: str) -> str:
|
|
169
179
|
return text.lstrip('\n').rstrip()
|
|
170
180
|
|
|
171
|
-
def
|
|
181
|
+
def _render_commit_diff(text: str) -> RichText:
|
|
182
|
+
rt = RichText()
|
|
183
|
+
lines = text.split('\n')
|
|
184
|
+
for i, line in enumerate(lines):
|
|
185
|
+
if not line.strip():
|
|
186
|
+
continue
|
|
187
|
+
if line.lstrip().startswith('[') and ']' in line:
|
|
188
|
+
rt.append(line, style='bold cyan')
|
|
189
|
+
elif 'files changed' in line or 'file changed' in line:
|
|
190
|
+
rt.append(line, style='dim')
|
|
191
|
+
elif '|' in line:
|
|
192
|
+
pipe_idx = line.index('|')
|
|
193
|
+
rt.append(line[:pipe_idx + 1])
|
|
194
|
+
for ch in line[pipe_idx + 1:]:
|
|
195
|
+
if ch == '+':
|
|
196
|
+
rt.append(ch, style='green')
|
|
197
|
+
elif ch == '-':
|
|
198
|
+
rt.append(ch, style='red')
|
|
199
|
+
else:
|
|
200
|
+
rt.append(ch, style='dim')
|
|
201
|
+
else:
|
|
202
|
+
rt.append(line, style='dim')
|
|
203
|
+
if i < len(lines) - 1:
|
|
204
|
+
rt.append('\n')
|
|
205
|
+
return rt
|
|
206
|
+
|
|
207
|
+
def _render_split_diff(diff_text: str, ref1_label: str='HEAD~1', ref2_label: str='HEAD') -> Table:
|
|
208
|
+
tbl = Table(show_header=True, box=None, expand=True, padding=0, show_lines=False, pad_edge=False)
|
|
209
|
+
tbl.add_column(f' ◇ {ref1_label} ', style='red', ratio=1, width=1)
|
|
210
|
+
tbl.add_column(f' ◇ {ref2_label} ', style='green', ratio=1, width=1)
|
|
211
|
+
|
|
212
|
+
def _flush(left, right):
|
|
213
|
+
maxlen = max(len(left), len(right))
|
|
214
|
+
left += [''] * (maxlen - len(left))
|
|
215
|
+
right += [''] * (maxlen - len(right))
|
|
216
|
+
for l, r in zip(left, right):
|
|
217
|
+
l_style = 'bold red' if l.startswith('-') else 'dim' if l else ''
|
|
218
|
+
r_style = 'bold green' if r.startswith('+') else 'dim' if r else ''
|
|
219
|
+
tbl.add_row(RichText(escape(l), style=l_style), RichText(escape(r), style=r_style))
|
|
220
|
+
left.clear()
|
|
221
|
+
right.clear()
|
|
222
|
+
left_lines = []
|
|
223
|
+
right_lines = []
|
|
224
|
+
in_hunk = False
|
|
225
|
+
for line in diff_text.split('\n'):
|
|
226
|
+
if line.startswith('diff --git'):
|
|
227
|
+
_flush(left_lines, right_lines)
|
|
228
|
+
in_hunk = False
|
|
229
|
+
match = re.search('b/(.*)', line)
|
|
230
|
+
fname = match.group(1) if match else line
|
|
231
|
+
tbl.add_row(RichText(f'--- a/{fname}', style='bold cyan'), RichText(f'+++ b/{fname}', style='bold cyan'))
|
|
232
|
+
elif line.startswith('@@'):
|
|
233
|
+
_flush(left_lines, right_lines)
|
|
234
|
+
in_hunk = True
|
|
235
|
+
tbl.add_row(RichText(escape(line), style='bold yellow'), RichText(escape(line), style='bold yellow'))
|
|
236
|
+
elif not in_hunk:
|
|
237
|
+
if line.startswith('---') or line.startswith('+++'):
|
|
238
|
+
continue
|
|
239
|
+
_flush(left_lines, right_lines)
|
|
240
|
+
if line.strip():
|
|
241
|
+
tbl.add_row(RichText(escape(line), style='dim cyan'), RichText('', style='dim'))
|
|
242
|
+
elif line.startswith('-'):
|
|
243
|
+
left_lines.append(line)
|
|
244
|
+
elif line.startswith('+'):
|
|
245
|
+
right_lines.append(line)
|
|
246
|
+
elif line.startswith(' '):
|
|
247
|
+
_flush(left_lines, right_lines)
|
|
248
|
+
tbl.add_row(RichText(escape(line), style='dim'), RichText(escape(line), style='dim'))
|
|
249
|
+
else:
|
|
250
|
+
_flush(left_lines, right_lines)
|
|
251
|
+
if line.strip():
|
|
252
|
+
tbl.add_row(RichText(escape(line), style='dim'), RichText(escape(line), style='dim'))
|
|
253
|
+
_flush(left_lines, right_lines)
|
|
254
|
+
return tbl
|
|
255
|
+
|
|
256
|
+
def _make_empty_history_table() -> Table:
|
|
172
257
|
tbl = Table(show_header=False, show_lines=False, box=None, pad_edge=False, expand=True, padding=0)
|
|
173
258
|
tbl.add_column(width=2)
|
|
174
259
|
tbl.add_column(ratio=1)
|
|
260
|
+
return tbl
|
|
261
|
+
|
|
262
|
+
def append_to_history_table(tbl: Table, messages: list[dict]) -> None:
|
|
175
263
|
for msg in messages:
|
|
176
264
|
role = msg.get('role')
|
|
177
265
|
content = msg.get('content')
|
|
178
266
|
is_error = msg.get('is_error', False)
|
|
179
|
-
if isinstance(content,
|
|
267
|
+
if isinstance(content, (Table, RichText)):
|
|
268
|
+
blocks = [{'type': 'renderable', 'renderable': content}]
|
|
269
|
+
elif isinstance(content, str):
|
|
180
270
|
blocks = [{'type': 'text', 'text': content}]
|
|
181
271
|
elif isinstance(content, list):
|
|
182
272
|
blocks = content
|
|
@@ -186,8 +276,11 @@ def render_history(messages: list[dict]) -> Table:
|
|
|
186
276
|
b_type = block.get('type', 'text')
|
|
187
277
|
text = block.get('text', '') or block.get('thinking', '')
|
|
188
278
|
prefix, style = ('', '')
|
|
279
|
+
render_as_md = False
|
|
189
280
|
if is_error:
|
|
190
281
|
prefix, style = ('✗', 'bold red')
|
|
282
|
+
elif role == 'commit':
|
|
283
|
+
prefix, style = ('◇', 'cyan')
|
|
191
284
|
elif role == 'user':
|
|
192
285
|
prefix, style = ('≫', 'bold green')
|
|
193
286
|
elif role == 'system':
|
|
@@ -198,6 +291,7 @@ def render_history(messages: list[dict]) -> Table:
|
|
|
198
291
|
prefix, style = ('◌', 'dim italic')
|
|
199
292
|
elif role == 'toolResult':
|
|
200
293
|
prefix, style = ('→', 'dim yellow')
|
|
294
|
+
render_as_md = True
|
|
201
295
|
elif b_type == 'toolCall':
|
|
202
296
|
prefix, style = ('⚙', 'yellow')
|
|
203
297
|
args = block.get('arguments', {})
|
|
@@ -207,12 +301,30 @@ def render_history(messages: list[dict]) -> Table:
|
|
|
207
301
|
elif role == 'assistant':
|
|
208
302
|
prefix, style = ('○', '')
|
|
209
303
|
text = re.sub('\\s*<tool_call>.*?</tool_call>\\s*', '', text, flags=re.DOTALL).strip()
|
|
304
|
+
render_as_md = True
|
|
210
305
|
text = _clean_block_text(text)
|
|
306
|
+
if b_type == 'renderable':
|
|
307
|
+
prefix, style = ('✓', 'blue')
|
|
308
|
+
body = block.get('renderable')
|
|
309
|
+
tbl.add_row(RichText(prefix, style=style), body)
|
|
310
|
+
continue
|
|
211
311
|
if not text:
|
|
212
312
|
continue
|
|
213
|
-
|
|
313
|
+
if role == 'commit':
|
|
314
|
+
body = _render_commit_diff(text)
|
|
315
|
+
tbl.add_row(RichText('◇', style='cyan'), body)
|
|
316
|
+
continue
|
|
317
|
+
if render_as_md:
|
|
318
|
+
body = Markdown(text)
|
|
319
|
+
else:
|
|
320
|
+
body = RichText(escape(text), style=style)
|
|
321
|
+
tbl.add_row(RichText(prefix, style=style), body)
|
|
322
|
+
|
|
323
|
+
def render_history(messages: list[dict]) -> Table:
|
|
324
|
+
tbl = _make_empty_history_table()
|
|
325
|
+
append_to_history_table(tbl, messages)
|
|
214
326
|
return tbl
|
|
215
|
-
REPL_HELP = '
|
|
327
|
+
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
328
|
|
|
217
329
|
class InputBox(TextArea):
|
|
218
330
|
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)]
|
|
@@ -266,6 +378,7 @@ class Tab(Vertical):
|
|
|
266
378
|
self._stream_blocks: list[dict] = []
|
|
267
379
|
self._command_blocks: list[dict] = []
|
|
268
380
|
self._cache_count: int = -1
|
|
381
|
+
self._rendered_cache: Table | None = None
|
|
269
382
|
|
|
270
383
|
@property
|
|
271
384
|
def is_running(self) -> bool:
|
|
@@ -317,6 +430,8 @@ class Tab(Vertical):
|
|
|
317
430
|
self.refresh_cache()
|
|
318
431
|
self._stream_blocks = []
|
|
319
432
|
self.refresh_stream()
|
|
433
|
+
elif et == 'commit':
|
|
434
|
+
self.refresh_cache()
|
|
320
435
|
elif et == 'error':
|
|
321
436
|
err = str(payload.get('error', payload))
|
|
322
437
|
self._stream_blocks.append({'type': 'text', 'text': err, 'is_error': True})
|
|
@@ -335,12 +450,19 @@ class Tab(Vertical):
|
|
|
335
450
|
self.status = 'idle'
|
|
336
451
|
|
|
337
452
|
def refresh_cache(self) -> None:
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
453
|
+
new_count = len(self.agent.messages)
|
|
454
|
+
if new_count == self._cache_count:
|
|
455
|
+
return
|
|
456
|
+
if self._rendered_cache is None or new_count < self._cache_count:
|
|
457
|
+
self._rendered_cache = render_history(self.agent.messages)
|
|
458
|
+
else:
|
|
459
|
+
new_msgs = self.agent.messages[self._cache_count:]
|
|
460
|
+
append_to_history_table(self._rendered_cache, new_msgs)
|
|
461
|
+
self._cache_count = new_count
|
|
462
|
+
self.query_one('#cache', Static).update(self._rendered_cache)
|
|
463
|
+
scroll = self.query_one('#scroll', VerticalScroll)
|
|
464
|
+
if scroll.max_scroll_y - scroll.scroll_y < 3:
|
|
465
|
+
self.app.call_after_refresh(scroll.scroll_end, animate=False)
|
|
344
466
|
|
|
345
467
|
def refresh_stream(self) -> None:
|
|
346
468
|
msgs = []
|
|
@@ -364,6 +486,7 @@ class Tab(Vertical):
|
|
|
364
486
|
self.query_one('#cache', Static).update('')
|
|
365
487
|
self.query_one('#stream', Static).update('')
|
|
366
488
|
self._cache_count = 0
|
|
489
|
+
self._rendered_cache = None
|
|
367
490
|
self._command_blocks = []
|
|
368
491
|
self._stream_blocks = []
|
|
369
492
|
|
|
@@ -386,8 +509,9 @@ class TabBar(Static):
|
|
|
386
509
|
for i, tab in enumerate(tabs):
|
|
387
510
|
marker = '●' if tab.is_running else ' '
|
|
388
511
|
label = f' {marker}{i + 1}:{tab.title} '
|
|
389
|
-
|
|
390
|
-
x
|
|
512
|
+
width = cell_len(label)
|
|
513
|
+
self._ranges.append((x, x + width, i))
|
|
514
|
+
x += width
|
|
391
515
|
if i == active_index:
|
|
392
516
|
t.append(label, style='bold green reverse')
|
|
393
517
|
elif tab.is_running:
|
|
@@ -424,7 +548,7 @@ class StatusBar(Static):
|
|
|
424
548
|
class HelpBar(Static):
|
|
425
549
|
|
|
426
550
|
def __init__(self, **kwargs):
|
|
427
|
-
self._idle_text = RichText('/help Ctrl-J newline Alt-1…9 tabs Ctrl-C abort Ctrl-D exit
|
|
551
|
+
self._idle_text = RichText('/help !cmd !!interactive Ctrl-J newline Alt-1…9 tabs Ctrl-C abort Ctrl-D exit', style='dim')
|
|
428
552
|
super().__init__(self._idle_text, **kwargs)
|
|
429
553
|
|
|
430
554
|
def show_idle(self) -> None:
|
|
@@ -492,7 +616,7 @@ class ReplApp(App[None]):
|
|
|
492
616
|
|
|
493
617
|
async def on_event(event: dict) -> None:
|
|
494
618
|
tab.apply_event(event)
|
|
495
|
-
if event['type'] in ('agent_start', 'agent_end', 'turn_end'):
|
|
619
|
+
if event['type'] in ('agent_start', 'agent_end', 'turn_end', 'commit'):
|
|
496
620
|
self._refresh_chrome()
|
|
497
621
|
self._unsubscribers[key] = tab.agent.subscribe(on_event)
|
|
498
622
|
|
|
@@ -517,6 +641,17 @@ class ReplApp(App[None]):
|
|
|
517
641
|
tab.errors.clear()
|
|
518
642
|
tab.last_error = ''
|
|
519
643
|
self.query_one('#helpbar', HelpBar).show_idle()
|
|
644
|
+
if text.startswith('!!'):
|
|
645
|
+
command = text[2:].strip()
|
|
646
|
+
if command:
|
|
647
|
+
await self._run_interactive_command(tab, command)
|
|
648
|
+
return
|
|
649
|
+
if text.startswith('!'):
|
|
650
|
+
command = text[1:].strip()
|
|
651
|
+
if not command:
|
|
652
|
+
return
|
|
653
|
+
await self._run_shell_command(tab, command)
|
|
654
|
+
return
|
|
520
655
|
if text.startswith('/'):
|
|
521
656
|
await self._handle_command(tab, text)
|
|
522
657
|
return
|
|
@@ -542,6 +677,38 @@ class ReplApp(App[None]):
|
|
|
542
677
|
tab.status = 'idle'
|
|
543
678
|
self._refresh_chrome()
|
|
544
679
|
|
|
680
|
+
async def _run_shell_command(self, tab: Tab, command: str) -> None:
|
|
681
|
+
if not command:
|
|
682
|
+
return
|
|
683
|
+
if command.startswith('cd ') or command == 'cd':
|
|
684
|
+
tab.show_command(f'!{command}', 'Not allowed — use /branch or set cwd in context')
|
|
685
|
+
return
|
|
686
|
+
gwt = tab.agent.ctx.get('gwt')
|
|
687
|
+
cwd = gwt.worktree if gwt and getattr(gwt, 'worktree', None) else tab.agent.ctx.get('cwd') or os.getcwd()
|
|
688
|
+
env = tab.agent.ctx.get('env')
|
|
689
|
+
try:
|
|
690
|
+
proc = await asyncio.create_subprocess_shell(command, cwd=cwd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env=env if env else None)
|
|
691
|
+
stdout, stderr = await proc.communicate()
|
|
692
|
+
out = stdout.decode(errors='replace').rstrip('\n')
|
|
693
|
+
err = stderr.decode(errors='replace').rstrip('\n')
|
|
694
|
+
body = out
|
|
695
|
+
if err:
|
|
696
|
+
body = (body + '\n' if body else '') + f'[stderr]\n{err}'
|
|
697
|
+
if proc.returncode and proc.returncode != 0:
|
|
698
|
+
body += f'\n[exit {proc.returncode}]'
|
|
699
|
+
tab.show_command(f'!{command}', body or '(no output)')
|
|
700
|
+
except Exception as e:
|
|
701
|
+
tab.show_command(f'!{command}', f'Error: {e}')
|
|
702
|
+
|
|
703
|
+
async def _run_interactive_command(self, tab: Tab, command: str) -> None:
|
|
704
|
+
gwt = tab.agent.ctx.get('gwt')
|
|
705
|
+
cwd = gwt.worktree if gwt and getattr(gwt, 'worktree', None) else tab.agent.ctx.get('cwd') or os.getcwd()
|
|
706
|
+
env = tab.agent.ctx.get('env')
|
|
707
|
+
with self.suspend():
|
|
708
|
+
proc = await asyncio.create_subprocess_shell(command, cwd=cwd, stdin=None, stdout=None, stderr=None, env=env if env else None)
|
|
709
|
+
returncode = await proc.wait()
|
|
710
|
+
tab.show_command(f'!!{command}', f'[exited {returncode}]')
|
|
711
|
+
|
|
545
712
|
async def _handle_command(self, tab: Tab, text: str) -> None:
|
|
546
713
|
cmd, _, arg = text.partition(' ')
|
|
547
714
|
cmd = cmd.lower().strip()
|
|
@@ -605,9 +772,42 @@ class ReplApp(App[None]):
|
|
|
605
772
|
line += f'\n {file_lines}'
|
|
606
773
|
lines.append(line)
|
|
607
774
|
tab.show_command(text, '\n'.join(lines))
|
|
775
|
+
elif cmd == '/diff':
|
|
776
|
+
gwt = tab.agent.ctx.get('gwt')
|
|
777
|
+
if not gwt or not getattr(gwt, 'worktree', None):
|
|
778
|
+
self.query_one('#helpbar', HelpBar).show_error('No git worktree available for this tab.')
|
|
779
|
+
return
|
|
780
|
+
ref1 = 'HEAD~1'
|
|
781
|
+
ref2 = 'HEAD'
|
|
782
|
+
is_all = '--all' in arg
|
|
783
|
+
if is_all:
|
|
784
|
+
base = get_branch_base_sha(gwt.worktree)
|
|
785
|
+
if base:
|
|
786
|
+
ref1 = base
|
|
787
|
+
else:
|
|
788
|
+
self.query_one('#helpbar', HelpBar).show_error('Could not determine base commit for --all.')
|
|
789
|
+
return
|
|
790
|
+
ref1_short = resolve_ref_short(gwt.worktree, ref1)
|
|
791
|
+
ref2_short = resolve_ref_short(gwt.worktree, ref2)
|
|
792
|
+
if is_all:
|
|
793
|
+
ref1_label = f'base ({ref1_short})' if ref1_short else 'base'
|
|
794
|
+
else:
|
|
795
|
+
ref1_label = f'HEAD~1 ({ref1_short})' if ref1_short else 'HEAD~1'
|
|
796
|
+
ref2_label = f'HEAD ({ref2_short})' if ref2_short else 'HEAD'
|
|
797
|
+
try:
|
|
798
|
+
diff_text = get_diff_between_refs(gwt.worktree, ref1, ref2)
|
|
799
|
+
except GitError as e:
|
|
800
|
+
self.query_one('#helpbar', HelpBar).show_error(f'Git diff failed: {e}')
|
|
801
|
+
return
|
|
802
|
+
if not diff_text.strip():
|
|
803
|
+
tab.show_command(text, f'No differences between {ref1_label} and {ref2_label}.')
|
|
804
|
+
return
|
|
805
|
+
renderable = _render_split_diff(diff_text, ref1_label, ref2_label)
|
|
806
|
+
tab._command_blocks.extend([{'role': 'user', 'content': text}, {'role': 'command', 'content': renderable}])
|
|
807
|
+
tab.refresh_stream()
|
|
608
808
|
elif cmd == '/errors':
|
|
609
809
|
if not tab.errors:
|
|
610
|
-
tab.show_command('No errors recorded.')
|
|
810
|
+
tab.show_command(text, 'No errors recorded.')
|
|
611
811
|
else:
|
|
612
812
|
lines = [f'{ts} {msg}' for ts, msg in tab.errors[-30:]]
|
|
613
813
|
tab.show_command(text, 'Error log\n' + '\n'.join(lines))
|
|
@@ -694,7 +894,11 @@ class ReplApp(App[None]):
|
|
|
694
894
|
await self._run_submit(prompt)
|
|
695
895
|
elif cmd == '/export':
|
|
696
896
|
ts = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
697
|
-
|
|
897
|
+
user_cwd = tab.agent.ctx.get('user_cwd', os.getcwd())
|
|
898
|
+
if arg:
|
|
899
|
+
path = arg if os.path.isabs(arg) else os.path.join(user_cwd, arg)
|
|
900
|
+
else:
|
|
901
|
+
path = os.path.join(user_cwd, f'session_{ts}.json')
|
|
698
902
|
data = {'version': 1, 'exported_at': ts, 'system': tab.agent.system, 'messages': tab.agent.messages}
|
|
699
903
|
try:
|
|
700
904
|
with open(path, 'w', encoding='utf-8') as f:
|
|
@@ -819,14 +1023,14 @@ def collect_skills(skills_dir, skills=None):
|
|
|
819
1023
|
skills.append({'name': name, 'description': description, 'content': text})
|
|
820
1024
|
skill_prompt = 'Available skills (use Skill to load full instructions when needed):\n' + '\n'.join((f'- {s['name']}: {s['description']}' for s in skills)) if skills else ''
|
|
821
1025
|
return (skills, skill_prompt)
|
|
1026
|
+
_AGENT_ENV_ALLOWLIST: re.Pattern = re.compile('\n ^(\n # ── Execution paths ────────────────────────────────────────────────────\n PATH\n | MANPATH | INFOPATH\n\n # ── Python / conda / virtualenv ────────────────────────────────────────\n | CONDA_PREFIX | CONDA_DEFAULT_ENV | CONDA_EXE | CONDA_PYTHON_EXE\n | CONDA_SHLVL | CONDA_PROMPT_MODIFIER\n | MAMBA_ROOT_PREFIX | MAMBA_EXE\n | VIRTUAL_ENV | VIRTUAL_ENV_PROMPT\n | PYTHONPATH | PYTHONHOME | PYTHONSTARTUP\n | PYTHONDONTWRITEBYTECODE | PYTHONUNBUFFERED\n | PYTHONFAULTHANDLER | PYTHONUTF8\n | PIP_INDEX_URL | PIP_EXTRA_INDEX_URL # private PyPI mirrors (no auth tokens)\n | PIPENV_PIPFILE | POETRY_VIRTUALENVS_IN_PROJECT\n\n # ── Native / compiled libs ─────────────────────────────────────────────\n | LD_LIBRARY_PATH | LD_PRELOAD\n | DYLD_LIBRARY_PATH | DYLD_FALLBACK_LIBRARY_PATH # macOS\n | PKG_CONFIG_PATH\n | CMAKE_PREFIX_PATH | CMAKE_BUILD_TYPE\n\n # ── CUDA / GPU ─────────────────────────────────────────────────────────\n | CUDA_HOME | CUDA_PATH | CUDA_VISIBLE_DEVICES\n | NVIDIA_VISIBLE_DEVICES | NVIDIA_DRIVER_CAPABILITIES\n | HIP_PATH | ROCR_VISIBLE_DEVICES # AMD\n | METAL_DEVICE_WRAPPER_TYPE # Apple\n\n # ── Locale / encoding ──────────────────────────────────────────────────\n | LANG | LANGUAGE | LC_ALL | LC_CTYPE | LC_MESSAGES\n | LC_NUMERIC | LC_TIME | LC_COLLATE\n | PYTHONUTF8\n\n # ── Terminal (needed by tools that check if output is a tty) ───────────\n | TERM | TERM_PROGRAM | COLORTERM\n | NO_COLOR | CLICOLOR | CLICOLOR_FORCE\n | COLUMNS | LINES\n\n # ── Process identity (non-secret) ──────────────────────────────────────\n | HOME # overridden to temp dir by run_repl — safe\n | SHELL\n | TMPDIR | TEMP | TMP\n | XDG_RUNTIME_DIR | XDG_CACHE_HOME | XDG_CONFIG_HOME | XDG_DATA_HOME\n\n # ── Toolchain / build (no secrets) ─────────────────────────────────────\n | CC | CXX | AR | LD | FC\n | CFLAGS | CXXFLAGS | LDFLAGS | MAKEFLAGS\n | JAVA_HOME | GRADLE_HOME | MAVEN_HOME\n | GOPATH | GOROOT | GOMODCACHE\n | CARGO_HOME | RUSTUP_HOME\n | NODE_PATH # not NODE_AUTH_TOKEN\n | GEM_HOME | GEM_PATH | BUNDLE_PATH\n )$\n', re.VERBOSE)
|
|
1027
|
+
|
|
1028
|
+
def _make_agent_env(base: dict[str, str]) -> dict[str, str]:
|
|
1029
|
+
return {k: v for k, v in base.items() if _AGENT_ENV_ALLOWLIST.match(k)}
|
|
822
1030
|
|
|
823
1031
|
def run_repl(*, base_url=None, model=None, api: Literal['claude', 'codex', 'gemini', 'deepseek', 'noapi']='noapi', system='', sdir=None, skills=None, env=None, tool_names=None, extra_tool_classes=None, api_key=None, gwt=None, ctx=None, init_prompt=None, resume_messages=None, repo=None, resume=None, stream=None, verbose_transcript=False):
|
|
824
1032
|
repo = os.path.abspath(repo or os.getcwd())
|
|
825
1033
|
with tempfile.TemporaryDirectory(dir=tempfile.gettempdir()) as _home:
|
|
826
|
-
if env is None:
|
|
827
|
-
env = os.environ.copy()
|
|
828
|
-
env['HOME'] = _home
|
|
829
|
-
env.setdefault('SHELL', '/bin/bash')
|
|
830
1034
|
if gwt is None:
|
|
831
1035
|
if resume:
|
|
832
1036
|
result = resume_worktree(repo, resume, worktree_dir=os.path.join(_home, 'workspace'))
|
|
@@ -838,15 +1042,17 @@ def run_repl(*, base_url=None, model=None, api: Literal['claude', 'codex', 'gemi
|
|
|
838
1042
|
else:
|
|
839
1043
|
gwt = create_worktree(repo, worktree_dir=os.path.join(_home, 'workspace'))
|
|
840
1044
|
cwd = gwt.worktree if gwt else repo
|
|
841
|
-
env
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
1045
|
+
if env is None:
|
|
1046
|
+
env = os.environ.copy()
|
|
1047
|
+
env.setdefault('SHELL', '/bin/bash')
|
|
1048
|
+
agent_env = _make_agent_env(env)
|
|
1049
|
+
agent_env['HOME'] = _home
|
|
1050
|
+
agent_env['PWD'] = cwd
|
|
1051
|
+
user_cwd = os.path.abspath(os.getcwd())
|
|
846
1052
|
sdir = os.path.abspath(sdir or cwd)
|
|
847
1053
|
skills, skill_prompt = collect_skills(sdir, skills)
|
|
848
1054
|
system = '\n\n'.join(filter(None, [system, skill_prompt]))
|
|
849
|
-
merged_ctx = {'cwd': cwd, 'skills': skills, 'gwt': gwt, 'env':
|
|
1055
|
+
merged_ctx = {'cwd': cwd, 'user_cwd': user_cwd, 'skills': skills, 'gwt': gwt, 'env': agent_env, **(ctx or {})}
|
|
850
1056
|
agent = Agent(system=system, api=api, model=model, tool_names=tool_names, extra_tool_classes=extra_tool_classes, api_key=api_key, base_url=base_url, ctx=merged_ctx)
|
|
851
1057
|
log_fp = None
|
|
852
1058
|
if stream is not None:
|
|
@@ -865,12 +1071,6 @@ def run_repl(*, base_url=None, model=None, api: Literal['claude', 'codex', 'gemi
|
|
|
865
1071
|
finally:
|
|
866
1072
|
if log_fp:
|
|
867
1073
|
log_fp.close()
|
|
868
|
-
try:
|
|
869
|
-
os.chdir(_orig_cwd)
|
|
870
|
-
except OSError as exc:
|
|
871
|
-
logger.warning('Could not restore original cwd %r: %s', _orig_cwd, exc)
|
|
872
|
-
os.environ.clear()
|
|
873
|
-
os.environ.update(_orig_environ)
|
|
874
1074
|
if app_instance and hasattr(app_instance, '_exit_summary') and app_instance._exit_summary:
|
|
875
1075
|
print('\n--- Session Exit Summary ---')
|
|
876
1076
|
for item in app_instance._exit_summary:
|
|
@@ -213,7 +213,7 @@ class BashTool(Tool):
|
|
|
213
213
|
parameters = BashParams
|
|
214
214
|
|
|
215
215
|
async def execute(self, params: BashParams, signal=None) -> dict:
|
|
216
|
-
proc_env =
|
|
216
|
+
proc_env = self.ctx.get('env')
|
|
217
217
|
proc = await asyncio.create_subprocess_shell(params.command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, cwd=self.ctx['cwd'], env=proc_env)
|
|
218
218
|
communicate_task = asyncio.create_task(proc.communicate())
|
|
219
219
|
abort_task: asyncio.Task | None = None
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mlx-code
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.19
|
|
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
|
|
@@ -37,7 +38,7 @@ Dynamic: summary
|
|
|
37
38
|
|
|
38
39
|
A lightweight coding agent built on Apple's MLX framework.
|
|
39
40
|
|
|
40
|
-
|
|
41
|
+
https://github.com/user-attachments/assets/0569d101-8d0a-4e67-9e82-fce84a5ef3f0
|
|
41
42
|
|
|
42
43
|
---
|
|
43
44
|
|
|
@@ -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.19",
|
|
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
|