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.
Files changed (26) hide show
  1. {mlx_code-0.0.16 → mlx_code-0.0.18}/PKG-INFO +2 -1
  2. {mlx_code-0.0.16 → mlx_code-0.0.18}/mlx_code/gits.py +43 -9
  3. {mlx_code-0.0.16 → mlx_code-0.0.18}/mlx_code/repl.py +175 -11
  4. {mlx_code-0.0.16 → mlx_code-0.0.18}/mlx_code.egg-info/PKG-INFO +2 -1
  5. {mlx_code-0.0.16 → mlx_code-0.0.18}/mlx_code.egg-info/requires.txt +1 -0
  6. {mlx_code-0.0.16 → mlx_code-0.0.18}/setup.py +2 -1
  7. {mlx_code-0.0.16 → mlx_code-0.0.18}/LICENSE +0 -0
  8. {mlx_code-0.0.16 → mlx_code-0.0.18}/README.md +0 -0
  9. {mlx_code-0.0.16 → mlx_code-0.0.18}/mlx_code/__init__.py +0 -0
  10. {mlx_code-0.0.16 → mlx_code-0.0.18}/mlx_code/apis.py +0 -0
  11. {mlx_code-0.0.16 → mlx_code-0.0.18}/mlx_code/lsp_tool.py +0 -0
  12. {mlx_code-0.0.16 → mlx_code-0.0.18}/mlx_code/main.py +0 -0
  13. {mlx_code-0.0.16 → mlx_code-0.0.18}/mlx_code/mcb.py +0 -0
  14. {mlx_code-0.0.16 → mlx_code-0.0.18}/mlx_code/mcb_tool.py +0 -0
  15. {mlx_code-0.0.16 → mlx_code-0.0.18}/mlx_code/stream_log.py +0 -0
  16. {mlx_code-0.0.16 → mlx_code-0.0.18}/mlx_code/tools.py +0 -0
  17. {mlx_code-0.0.16 → mlx_code-0.0.18}/mlx_code/util.py +0 -0
  18. {mlx_code-0.0.16 → mlx_code-0.0.18}/mlx_code/view_git.py +0 -0
  19. {mlx_code-0.0.16 → mlx_code-0.0.18}/mlx_code/view_log.py +0 -0
  20. {mlx_code-0.0.16 → mlx_code-0.0.18}/mlx_code.egg-info/SOURCES.txt +0 -0
  21. {mlx_code-0.0.16 → mlx_code-0.0.18}/mlx_code.egg-info/dependency_links.txt +0 -0
  22. {mlx_code-0.0.16 → mlx_code-0.0.18}/mlx_code.egg-info/entry_points.txt +0 -0
  23. {mlx_code-0.0.16 → mlx_code-0.0.18}/mlx_code.egg-info/top_level.txt +0 -0
  24. {mlx_code-0.0.16 → mlx_code-0.0.18}/setup.cfg +0 -0
  25. {mlx_code-0.0.16 → mlx_code-0.0.18}/tests/__init__.py +0 -0
  26. {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.16
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
- last_user = next((m['content'] for m in reversed(messages) if m.get('role') == 'user'), None)
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(messages, indent=2, ensure_ascii=False)}'
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
- diff_info = ''
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
- es = await self.api.stream(self.messages, self.system, self.tools)
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
- self.ctx['gwt'] = commit_worktree(self.ctx['gwt'], self.messages)
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, str):
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
- tbl.add_row(RichText(prefix, style=style), RichText(escape(text), style=style))
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 = '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'
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
- self.app.call_after_refresh(scroll.scroll_end, animate=False)
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
- self.app.call_after_refresh(scroll.scroll_end, animate=False)
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 Ctrl-R recall', style='dim')
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.16
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
@@ -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.16",
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