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.
Files changed (26) hide show
  1. {mlx_code-0.0.17 → mlx_code-0.0.19}/PKG-INFO +3 -2
  2. {mlx_code-0.0.17 → mlx_code-0.0.19}/README.md +1 -1
  3. {mlx_code-0.0.17 → mlx_code-0.0.19}/mlx_code/gits.py +93 -29
  4. {mlx_code-0.0.17 → mlx_code-0.0.19}/mlx_code/repl.py +236 -36
  5. {mlx_code-0.0.17 → mlx_code-0.0.19}/mlx_code/tools.py +1 -1
  6. {mlx_code-0.0.17 → mlx_code-0.0.19}/mlx_code.egg-info/PKG-INFO +3 -2
  7. {mlx_code-0.0.17 → mlx_code-0.0.19}/mlx_code.egg-info/requires.txt +1 -0
  8. {mlx_code-0.0.17 → mlx_code-0.0.19}/setup.py +2 -1
  9. {mlx_code-0.0.17 → mlx_code-0.0.19}/LICENSE +0 -0
  10. {mlx_code-0.0.17 → mlx_code-0.0.19}/mlx_code/__init__.py +0 -0
  11. {mlx_code-0.0.17 → mlx_code-0.0.19}/mlx_code/apis.py +0 -0
  12. {mlx_code-0.0.17 → mlx_code-0.0.19}/mlx_code/lsp_tool.py +0 -0
  13. {mlx_code-0.0.17 → mlx_code-0.0.19}/mlx_code/main.py +0 -0
  14. {mlx_code-0.0.17 → mlx_code-0.0.19}/mlx_code/mcb.py +0 -0
  15. {mlx_code-0.0.17 → mlx_code-0.0.19}/mlx_code/mcb_tool.py +0 -0
  16. {mlx_code-0.0.17 → mlx_code-0.0.19}/mlx_code/stream_log.py +0 -0
  17. {mlx_code-0.0.17 → mlx_code-0.0.19}/mlx_code/util.py +0 -0
  18. {mlx_code-0.0.17 → mlx_code-0.0.19}/mlx_code/view_git.py +0 -0
  19. {mlx_code-0.0.17 → mlx_code-0.0.19}/mlx_code/view_log.py +0 -0
  20. {mlx_code-0.0.17 → mlx_code-0.0.19}/mlx_code.egg-info/SOURCES.txt +0 -0
  21. {mlx_code-0.0.17 → mlx_code-0.0.19}/mlx_code.egg-info/dependency_links.txt +0 -0
  22. {mlx_code-0.0.17 → mlx_code-0.0.19}/mlx_code.egg-info/entry_points.txt +0 -0
  23. {mlx_code-0.0.17 → mlx_code-0.0.19}/mlx_code.egg-info/top_level.txt +0 -0
  24. {mlx_code-0.0.17 → mlx_code-0.0.19}/setup.cfg +0 -0
  25. {mlx_code-0.0.17 → mlx_code-0.0.19}/tests/__init__.py +0 -0
  26. {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.17
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
- [![demo](https://raw.githubusercontent.com/JosefAlbers/mlx-code/main/assets/mlx-code.gif)](https://youtu.be/0lkY7YQCyCo)
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
- [![demo](https://raw.githubusercontent.com/JosefAlbers/mlx-code/main/assets/mlx-code.gif)](https://youtu.be/0lkY7YQCyCo)
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
- raise GitError(f'git {' '.join(args)!r} failed in {cwd!r}: {exc.stderr.strip()}') from exc
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
- last_user = next((m['content'] for m in reversed(messages) if m.get('role') == 'user'), None)
29
- title = last_user.replace('\n', ' ').strip()[:30] if isinstance(last_user, str) else ''
30
- return f'{title}\n\n{json.dumps(messages, indent=2, ensure_ascii=False)}'
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
- try:
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
- msgs = json.loads(parts[1] if len(parts) == 2 else parts[0])
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 (json.JSONDecodeError, IndexError):
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
- diff_info = ''
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 = _git(point.worktree, 'rev-parse', '--show-toplevel', check=False) or None
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
- if remove_branch and root:
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, 'branch', '-D', point.branch)
155
+ _git(root, 'worktree', 'prune')
131
156
  except Exception as e:
132
- logger.warning('git branch deletion failed: %s', e)
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.count('"role": "user"')
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.count('"role": "user"')
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', '-n', '500'], cwd=worktree, capture_output=True, text=True, check=True)
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.count('"role": "user"')
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.count('"role": "user"')
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 = {'api': self.api, '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'}}
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
- es = await self.api.stream(self.messages, self.system, self.tools)
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
- self.ctx['gwt'] = commit_worktree(self.ctx['gwt'], self.messages)
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 render_history(messages: list[dict]) -> Table:
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, str):
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
- tbl.add_row(RichText(prefix, style=style), RichText(escape(text), style=style))
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 = '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'
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
- if len(self.agent.messages) != self._cache_count:
339
- self._cache_count = len(self.agent.messages)
340
- self.query_one('#cache', Static).update(render_history(self.agent.messages))
341
- scroll = self.query_one('#scroll', VerticalScroll)
342
- if scroll.max_scroll_y - scroll.scroll_y < 3:
343
- self.app.call_after_refresh(scroll.scroll_end, animate=False)
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
- self._ranges.append((x, x + len(label), i))
390
- x += len(label)
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 Ctrl-R recall', style='dim')
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
- path = arg or os.path.join(os.getcwd(), f'session_{ts}.json')
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['PWD'] = cwd
842
- _orig_cwd = os.getcwd()
843
- _orig_environ = os.environ.copy()
844
- os.environ.update(env)
845
- os.chdir(cwd)
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': env, **(ctx or {})}
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 = {**os.environ, **self.ctx.get('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.17
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
- [![demo](https://raw.githubusercontent.com/JosefAlbers/mlx-code/main/assets/mlx-code.gif)](https://youtu.be/0lkY7YQCyCo)
41
+ https://github.com/user-attachments/assets/0569d101-8d0a-4e67-9e82-fce84a5ef3f0
41
42
 
42
43
  ---
43
44
 
@@ -9,3 +9,4 @@ mlx-lm>=0.31.3
9
9
  [all]
10
10
  python-lsp-server[all]
11
11
  GitPython
12
+ pygments
@@ -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.17",
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