mlx-code 0.0.12__tar.gz → 0.0.14__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.12 → mlx_code-0.0.14}/PKG-INFO +1 -1
  2. {mlx_code-0.0.12 → mlx_code-0.0.14}/mlx_code/gits.py +51 -2
  3. {mlx_code-0.0.12 → mlx_code-0.0.14}/mlx_code/repl.py +258 -385
  4. {mlx_code-0.0.12 → mlx_code-0.0.14}/mlx_code/view_git.py +16 -1
  5. {mlx_code-0.0.12 → mlx_code-0.0.14}/mlx_code.egg-info/PKG-INFO +1 -1
  6. {mlx_code-0.0.12 → mlx_code-0.0.14}/setup.py +1 -1
  7. {mlx_code-0.0.12 → mlx_code-0.0.14}/tests/test.py +5 -6
  8. {mlx_code-0.0.12 → mlx_code-0.0.14}/LICENSE +0 -0
  9. {mlx_code-0.0.12 → mlx_code-0.0.14}/README.md +0 -0
  10. {mlx_code-0.0.12 → mlx_code-0.0.14}/mlx_code/__init__.py +0 -0
  11. {mlx_code-0.0.12 → mlx_code-0.0.14}/mlx_code/apis.py +0 -0
  12. {mlx_code-0.0.12 → mlx_code-0.0.14}/mlx_code/lsp_tool.py +0 -0
  13. {mlx_code-0.0.12 → mlx_code-0.0.14}/mlx_code/main.py +0 -0
  14. {mlx_code-0.0.12 → mlx_code-0.0.14}/mlx_code/mcb.py +0 -0
  15. {mlx_code-0.0.12 → mlx_code-0.0.14}/mlx_code/mcb_tool.py +0 -0
  16. {mlx_code-0.0.12 → mlx_code-0.0.14}/mlx_code/stream_log.py +0 -0
  17. {mlx_code-0.0.12 → mlx_code-0.0.14}/mlx_code/tools.py +0 -0
  18. {mlx_code-0.0.12 → mlx_code-0.0.14}/mlx_code/util.py +0 -0
  19. {mlx_code-0.0.12 → mlx_code-0.0.14}/mlx_code/view_log.py +0 -0
  20. {mlx_code-0.0.12 → mlx_code-0.0.14}/mlx_code.egg-info/SOURCES.txt +0 -0
  21. {mlx_code-0.0.12 → mlx_code-0.0.14}/mlx_code.egg-info/dependency_links.txt +0 -0
  22. {mlx_code-0.0.12 → mlx_code-0.0.14}/mlx_code.egg-info/entry_points.txt +0 -0
  23. {mlx_code-0.0.12 → mlx_code-0.0.14}/mlx_code.egg-info/requires.txt +0 -0
  24. {mlx_code-0.0.12 → mlx_code-0.0.14}/mlx_code.egg-info/top_level.txt +0 -0
  25. {mlx_code-0.0.12 → mlx_code-0.0.14}/setup.cfg +0 -0
  26. {mlx_code-0.0.12 → mlx_code-0.0.14}/tests/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mlx-code
3
- Version: 0.0.12
3
+ Version: 0.0.14
4
4
  Summary: Coding Agent for Mac
5
5
  Home-page: https://josefalbers.github.io/mlx-code/
6
6
  Author: J Joe
@@ -63,7 +63,7 @@ def create_worktree(repo_dir: str, *, worktree_dir: str | None=None, ref: str='H
63
63
  root = repo_dir
64
64
  gi = os.path.join(root, '.gitignore')
65
65
  if not os.path.exists(gi):
66
- Path(gi).write_text('\n'.join(['__pycache__/', '*.pyc', '.DS_Store', '*.bin', '*.safetensors', '*.gguf', '*.pt', '*.pth', '.cache/', '_*']))
66
+ Path(gi).write_text('\n'.join(['_log.json']))
67
67
  if not _git(root, 'config', 'user.email', check=False):
68
68
  _git(root, 'config', 'user.email', 'agent@local')
69
69
  if not _git(root, 'config', 'user.name', check=False):
@@ -177,4 +177,53 @@ def current_point(worktree_dir: str) -> LedgerPoint:
177
177
  worktree_dir = os.path.abspath(worktree_dir)
178
178
  branch = _git(worktree_dir, 'symbolic-ref', '--short', 'HEAD', check=False) or 'DETACHED'
179
179
  sha = _git(worktree_dir, 'rev-parse', 'HEAD', check=False)
180
- return LedgerPoint(branch=branch, commit=sha, worktree=worktree_dir)
180
+ return LedgerPoint(branch=branch, commit=sha, worktree=worktree_dir)
181
+
182
+ def get_commit_history_with_stats(worktree: str, limit: int=50) -> list[dict]:
183
+ try:
184
+ cmd = ['git', 'log', '--name-status', '--format=COMMIT:%H%nSHORT:%h%nREFS:%d%nSUBJECT:%s%nBODY_START%n%b%nBODY_END', f'-n{limit}']
185
+ result = subprocess.run(cmd, cwd=worktree, capture_output=True, text=True, check=True)
186
+ output = result.stdout.strip()
187
+ commits = []
188
+ current_commit = None
189
+ in_body = False
190
+ body_lines = []
191
+ for line in output.split('\n'):
192
+ if line.startswith('COMMIT:'):
193
+ if current_commit:
194
+ body_text = '\n'.join(body_lines)
195
+ current_commit['user_turns'] = body_text.count('"role": "user"')
196
+ commits.append(current_commit)
197
+ current_commit = {'sha': line[7:], 'short_sha': '', 'refs': '', 'subject': '', 'files': []}
198
+ in_body = False
199
+ body_lines = []
200
+ elif line.startswith('SHORT:'):
201
+ if current_commit:
202
+ current_commit['short_sha'] = line[6:]
203
+ elif line.startswith('REFS:'):
204
+ if current_commit:
205
+ current_commit['refs'] = line[5:].strip()
206
+ elif line.startswith('SUBJECT:'):
207
+ if current_commit:
208
+ current_commit['subject'] = line[8:]
209
+ elif line == 'BODY_START':
210
+ in_body = True
211
+ elif line == 'BODY_END':
212
+ in_body = False
213
+ elif current_commit:
214
+ if in_body:
215
+ body_lines.append(line)
216
+ elif line.strip():
217
+ parts = line.split('\t', 1)
218
+ if len(parts) == 2:
219
+ current_commit['files'].append(f'{parts[0]} {parts[1]}')
220
+ else:
221
+ current_commit['files'].append(line)
222
+ if current_commit:
223
+ body_text = '\n'.join(body_lines)
224
+ current_commit['user_turns'] = body_text.count('"role": "user"')
225
+ commits.append(current_commit)
226
+ return commits
227
+ except Exception as e:
228
+ logger.warning(f'get_commit_history_with_stats failed: {e}')
229
+ return []
@@ -1,35 +1,30 @@
1
1
  from __future__ import annotations
2
2
  import asyncio
3
- import argparse
4
3
  import datetime
5
4
  import json
6
5
  import os
7
6
  import pathlib
8
7
  import re
9
- import shutil
10
8
  import socket
11
9
  import sys
12
10
  import tempfile
13
11
  import time
14
12
  import logging
15
13
  import urllib.parse
16
- from dataclasses import dataclass, field
17
14
  from typing import Any, Callable, Literal
18
- from .gits import create_worktree, commit_worktree, resume_worktree, cleanup_worktree, git_new_branch, git_switch_branch, GitError
15
+ from .gits import create_worktree, commit_worktree, resume_worktree, cleanup_worktree, git_new_branch, git_switch_branch, GitError, get_commit_history_with_stats
19
16
  from .tools import Tool, validate_tool_call, DEFAULT_TOOLS
20
17
  from .apis import resolve_api
21
18
  logger = logging.getLogger(__name__)
22
19
  from textual.app import App, ComposeResult
23
20
  from textual.binding import Binding
24
- from textual.containers import VerticalScroll
21
+ from textual.containers import VerticalScroll, Vertical
25
22
  from textual import events
26
23
  from textual.message import Message
27
24
  from textual.widgets import ContentSwitcher, Static, TextArea
28
25
  from rich.text import Text as RichText
29
26
  from rich.table import Table
30
27
  from rich.markup import escape
31
- _XML_SUPPRESS_TAG_OPEN = '<anreCommand'
32
- _XML_SUPPRESS_TAG_CLOSE = '</anreCommand>'
33
28
 
34
29
  class Agent:
35
30
 
@@ -84,7 +79,6 @@ class Agent:
84
79
  host, port = (parsed.hostname, parsed.port)
85
80
  if port is None:
86
81
  return
87
- logger.debug('Checking backend %s:%s …', host, port)
88
82
  start = time.monotonic()
89
83
  loop = asyncio.get_running_loop()
90
84
  while time.monotonic() - start < timeout:
@@ -112,7 +106,6 @@ class Agent:
112
106
  if event['type'] in ('text_delta', 'thinking_delta', 'error'):
113
107
  await self._emit({'type': event['type'], 'payload': event['payload']})
114
108
  final = await es.result()
115
- logger.debug(final)
116
109
  self.messages.append(final)
117
110
  await self._emit({'type': 'turn_end', 'payload': {'message': final}})
118
111
  if final['stop_reason'] in ('error', 'aborted'):
@@ -131,7 +124,6 @@ class Agent:
131
124
  self._last_result_sig = result_sig
132
125
  self._same_result_count = 0
133
126
  if self._same_result_count >= 2:
134
- logger.warning('Doom-loop: same result %dx', self._same_result_count)
135
127
  warn = f'\n\n[SYSTEM WARNING: Same tool result {self._same_result_count}x in a row. You are likely in a loop. Change your strategy.]\n\n'
136
128
  for r in results:
137
129
  if r.get('content') and isinstance(r['content'], list):
@@ -140,6 +132,7 @@ class Agent:
140
132
  else:
141
133
  r['content'].insert(0, {'type': 'text', 'text': warn})
142
134
  self.messages.extend(results)
135
+ await self._emit({'type': 'tool_results_ready', 'payload': {}})
143
136
  self.ctx['gwt'] = commit_worktree(self.ctx['gwt'], self.messages)
144
137
  if self._signal and self._signal.is_set():
145
138
  final['stop_reason'] = 'aborted'
@@ -165,18 +158,6 @@ class Agent:
165
158
  await self._emit({'type': 'tool_end', 'payload': {'name': call['name'], 'is_error': result['is_error'], 'result': msg}})
166
159
  return msg
167
160
 
168
- def _truncate(text: str, n: int=50) -> str:
169
- text = ' '.join(text.split())
170
- return text if len(text) <= n else text[:max(0, n - 1)] + '…'
171
-
172
- def _message_text(message: dict) -> str:
173
- content = message.get('content', '')
174
- if isinstance(content, str):
175
- return content
176
- if isinstance(content, list):
177
- return ''.join((str(b.get('text', '')) for b in content if isinstance(b, dict) and b.get('type') == 'text'))
178
- return str(content)
179
-
180
161
  def _branch_index_title(parent_path: tuple[int, ...], existing_tabs: list) -> tuple[tuple[int, ...], str]:
181
162
  depth = len(parent_path) + 1
182
163
  child_count = sum((1 for t in existing_tabs if len(t.index_path) == depth and t.index_path[:-1] == parent_path))
@@ -184,131 +165,6 @@ def _branch_index_title(parent_path: tuple[int, ...], existing_tabs: list) -> tu
184
165
  title = 'branch-' + '-'.join((str(i) for i in index_path))
185
166
  return (index_path, title)
186
167
 
187
- def _tool_result_preview(result: dict | None) -> str:
188
- if not result:
189
- return ''
190
- content = result.get('content') or []
191
- if isinstance(content, list):
192
- text = ''.join((str(b.get('text', '')) for b in content if isinstance(b, dict) and b.get('type') == 'text'))
193
- else:
194
- text = str(content)
195
- return _truncate(text, 200)
196
- REPL_HELP = "Commands:\n /help show this message\n /clear clear conversation (transcript + agent memory)\n /clear --soft clear transcript only — keep agent memory in context\n /history show full conversation transcript\n /history --raw show raw API message log (debug)\n /errors show timestamped error log for this tab\n /tools list active tools\n /branch [prompt] open a branch tab (git switch -c); optional prompt runs immediately\n /branch --as-worktree [prompt] same but in a private worktree dir — safe for parallel runs\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 (git switch to that tab's branch)\n Ctrl-C abort running agent (never exits the app)\n Ctrl-D close branch tab, or exit app (confirms if running)\n Ctrl-R recall last prompt into editor\n"
197
-
198
- class Tab:
199
-
200
- def __init__(self, title: str, agent: Agent, is_main: bool=False, owns_worktree: bool=False, index_path: tuple[int, ...]=()):
201
- self.agent = agent
202
- self.title = title
203
- self.is_main = is_main
204
- self.owns_worktree = owns_worktree
205
- self.index_path = index_path
206
- self.panel: TabPanel | None = None
207
- self.status: str = 'idle'
208
- self.running_task: asyncio.Task | None = None
209
- self.errors: list[tuple[str, str]] = []
210
- self.last_error: str = ''
211
- self.ephemeral_messages: list[dict] = []
212
- self._stream_msg: dict | None = None
213
- self._suppress_xml: bool = False
214
- self._xml_tail: str = ''
215
-
216
- @property
217
- def is_running(self) -> bool:
218
- return self.running_task is not None and (not self.running_task.done())
219
-
220
- def apply_event(self, event: dict) -> None:
221
- et = event.get('type')
222
- payload = event.get('payload', {})
223
- if et == 'agent_start':
224
- self.status = 'running'
225
- self._stream_msg = {'role': 'assistant', 'content': []}
226
- elif et == 'text_delta' and self._stream_msg:
227
- delta = self._filter_xml(payload.get('delta', ''))
228
- if delta:
229
- content = self._stream_msg['content']
230
- if content and content[-1].get('type') == 'text' and (not content[-1].get('is_error')):
231
- content[-1]['text'] += delta
232
- else:
233
- content.append({'type': 'text', 'text': delta})
234
- elif et == 'thinking_delta' and self._stream_msg:
235
- delta = payload.get('delta', '')
236
- if delta:
237
- content = self._stream_msg['content']
238
- if content and content[-1].get('type') == 'thinking':
239
- content[-1]['text'] += delta
240
- else:
241
- content.append({'type': 'thinking', 'text': delta})
242
- elif et == 'tool_start' and self._stream_msg:
243
- name = payload.get('name', 'tool')
244
- args = payload.get('args') or {}
245
- if isinstance(args, dict):
246
- args = json.dumps(args, ensure_ascii=False)
247
- self._stream_msg['content'].append({'type': 'toolCall', 'name': name, 'arguments': args, 'id': 'streaming'})
248
- elif et == 'tool_end' and self._stream_msg:
249
- if payload.get('is_error'):
250
- self._stream_msg['content'].append({'type': 'text', 'text': payload.get('name', '') + ' failed', 'is_error': True})
251
- else:
252
- preview = _tool_result_preview(payload.get('result'))
253
- if preview:
254
- self._stream_msg['content'].append({'type': 'text', 'text': preview})
255
- elif et == 'error':
256
- err = str(payload.get('error', payload))
257
- if self._stream_msg:
258
- self._stream_msg['content'].append({'type': 'text', 'text': err, 'is_error': True})
259
- self.add_error(err)
260
- elif et == 'agent_end':
261
- if self._stream_msg and self._xml_tail and (not self._suppress_xml):
262
- content = self._stream_msg['content']
263
- if content and content[-1].get('type') == 'text':
264
- content[-1]['text'] += self._xml_tail
265
- else:
266
- content.append({'type': 'text', 'text': self._xml_tail})
267
- self._stream_msg = None
268
- self._suppress_xml = False
269
- self._xml_tail = ''
270
- self.status = 'idle'
271
-
272
- def _filter_xml(self, delta: str) -> str:
273
- start_tag, end_tag = (_XML_SUPPRESS_TAG_OPEN, _XML_SUPPRESS_TAG_CLOSE)
274
- data = self._xml_tail + delta
275
- self._xml_tail = ''
276
- out: list[str] = []
277
- while data:
278
- if self._suppress_xml:
279
- end = data.find(end_tag)
280
- if end == -1:
281
- return ''
282
- data = data[end + len(end_tag):]
283
- self._suppress_xml = False
284
- continue
285
- start = data.find(start_tag)
286
- if start != -1:
287
- out.append(data[:start])
288
- data = data[start + len(start_tag):]
289
- self._suppress_xml = True
290
- continue
291
- keep = 0
292
- for i in range(1, len(start_tag)):
293
- if data.endswith(start_tag[:i]):
294
- keep = i
295
- if keep:
296
- out.append(data[:-keep])
297
- self._xml_tail = data[-keep:]
298
- else:
299
- out.append(data)
300
- break
301
- return ''.join(out)
302
-
303
- def add_error(self, msg: str) -> None:
304
- ts = datetime.datetime.now().strftime('%H:%M:%S')
305
- self.errors.append((ts, msg))
306
- self.last_error = msg
307
-
308
- def clear_errors(self) -> None:
309
- self.errors.clear()
310
- self.last_error = ''
311
-
312
168
  def _clean_block_text(text: str) -> str:
313
169
  return text.lstrip('\n').rstrip()
314
170
 
@@ -336,60 +192,27 @@ def render_history(messages: list[dict]) -> Table:
336
192
  prefix, style = ('≫', 'bold green')
337
193
  elif role == 'system':
338
194
  prefix, style = ('·', 'dim')
339
- elif role == '?':
340
- prefix, style = ('·', 'dim')
195
+ elif role == 'command':
196
+ prefix, style = ('', 'blue')
197
+ elif b_type == 'thinking':
198
+ prefix, style = ('◌', 'dim italic')
199
+ elif role == 'toolResult':
200
+ prefix, style = ('→', 'dim yellow')
341
201
  elif b_type == 'toolCall':
342
202
  prefix, style = ('⚙', 'yellow')
343
203
  args = block.get('arguments', {})
344
204
  if isinstance(args, dict):
345
205
  args = json.dumps(args, ensure_ascii=False)
346
206
  text = block.get('name', '') + ' ' + str(args)
347
- elif b_type == 'thinking':
348
- prefix, style = ('', 'dim italic')
349
- elif role == 'toolResult':
350
- prefix, style = ('→', 'dim yellow')
207
+ elif role == 'assistant':
208
+ prefix, style = ('', '')
209
+ text = re.sub('\\s*<tool_call>.*?</tool_call>\\s*', '', text, flags=re.DOTALL).strip()
351
210
  text = _clean_block_text(text)
352
- if not text and (not prefix):
211
+ if not text:
353
212
  continue
354
213
  tbl.add_row(RichText(prefix, style=style), RichText(escape(text), style=style))
355
214
  return tbl
356
-
357
- class ChatLog(VerticalScroll):
358
-
359
- def __init__(self, **kwargs):
360
- super().__init__(**kwargs)
361
- self._cached_msg_count: int = -1
362
- self._stream_was_empty: bool = True
363
-
364
- def compose(self) -> ComposeResult:
365
- yield Static(id='cache')
366
- yield Static(id='stream')
367
-
368
- def refresh_stream(self, tab: Tab) -> None:
369
- if tab._stream_msg:
370
- self.query_one('#stream', Static).update(render_history([tab._stream_msg]))
371
- if self._stream_was_empty:
372
- self.scroll_end(animate=False)
373
- self._stream_was_empty = False
374
- else:
375
- self.query_one('#stream', Static).update('')
376
- self._stream_was_empty = True
377
- if self.max_scroll_y - self.scroll_y < 3:
378
- self.scroll_end(animate=False)
379
-
380
- def refresh_cache(self, tab: Tab) -> None:
381
- self._cached_msg_count = len(tab.agent.messages)
382
- self.query_one('#cache', Static).update(render_history(tab.agent.messages + tab.ephemeral_messages))
383
- self.scroll_end(animate=False)
384
-
385
- def check_cache(self, tab: Tab) -> None:
386
- if len(tab.agent.messages) != self._cached_msg_count:
387
- self.refresh_cache(tab)
388
-
389
- def clear_log(self) -> None:
390
- self.query_one('#cache', Static).update('')
391
- self.query_one('#stream', Static).update('')
392
- self._cached_msg_count = 0
215
+ REPL_HELP = 'Commands:\n /help show this message\n /clear clear conversation (transcript + agent memory)\n /history show full conversation transcript\n /history --raw show raw API message log (debug)\n /errors show timestamped error log for this tab\n /tools list active tools\n /branch [prompt] open a branch tab; optional prompt runs immediately\n /branch --as-worktree [prompt] same but in a private worktree dir\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'
393
216
 
394
217
  class InputBox(TextArea):
395
218
  BINDINGS = [Binding('ctrl+j', 'insert_newline', 'New line', show=False), Binding('enter', 'submit_text', 'Submit', show=False, priority=True), Binding('ctrl+r', 'recall_last', 'Recall', show=False), Binding('ctrl+d', 'request_close', 'Exit', show=False, priority=True)]
@@ -426,23 +249,123 @@ class InputBox(TextArea):
426
249
  lines = text.split('\n')
427
250
  self.move_cursor((len(lines) - 1, len(lines[-1])))
428
251
 
429
- class TabPanel(Static):
252
+ class Tab(Vertical):
253
+ CSS = '\n Tab > VerticalScroll {\n height: 1fr;\n }\n #cache, #stream {\n height: auto;\n padding: 0 1;\n }\n #stream { min-height: 1; }\n '
430
254
 
431
- def __init__(self, tab: Tab, **kwargs):
432
- super().__init__(id=f'panel-{id(tab):x}', **kwargs)
433
- self.tab = tab
255
+ def __init__(self, title: str, agent: Agent, is_main: bool=False, owns_worktree: bool=False, index_path: tuple[int, ...]=()):
256
+ super().__init__(id=f'tab-{id(agent):x}')
257
+ self.title = title
258
+ self.agent = agent
259
+ self.is_main = is_main
260
+ self.owns_worktree = owns_worktree
261
+ self.index_path = index_path
262
+ self.status: str = 'idle'
263
+ self.running_task: asyncio.Task | None = None
264
+ self.errors: list[tuple[str, str]] = []
265
+ self.last_error: str = ''
266
+ self._stream_blocks: list[dict] = []
267
+ self._command_blocks: list[dict] = []
268
+ self._cache_count: int = -1
269
+
270
+ @property
271
+ def is_running(self) -> bool:
272
+ return self.running_task is not None and (not self.running_task.done())
434
273
 
435
274
  def compose(self) -> ComposeResult:
436
- yield ChatLog()
437
- yield InputBox()
275
+ with VerticalScroll(id='scroll'):
276
+ yield Static(id='cache')
277
+ yield Static(id='stream')
438
278
 
439
- @property
440
- def chat_log(self) -> ChatLog:
441
- return self.query_one(ChatLog)
279
+ def apply_event(self, event: dict) -> None:
280
+ et = event.get('type')
281
+ payload = event.get('payload', {})
282
+ if et == 'agent_start':
283
+ self.status = 'running'
284
+ self._stream_blocks = []
285
+ self._command_blocks = []
286
+ self.refresh_cache()
287
+ self.refresh_stream()
288
+ elif et == 'turn_start':
289
+ self._stream_blocks = []
290
+ self._command_blocks = []
291
+ self.refresh_stream()
292
+ elif et == 'text_delta':
293
+ delta = payload.get('delta', '')
294
+ if delta:
295
+ if self._stream_blocks and self._stream_blocks[-1].get('type') == 'text' and (not self._stream_blocks[-1].get('is_error')):
296
+ self._stream_blocks[-1]['text'] += delta
297
+ else:
298
+ self._stream_blocks.append({'type': 'text', 'text': delta})
299
+ self.refresh_stream()
300
+ elif et == 'thinking_delta':
301
+ delta = payload.get('delta', '')
302
+ if delta:
303
+ if self._stream_blocks and self._stream_blocks[-1].get('type') == 'thinking':
304
+ self._stream_blocks[-1]['text'] += delta
305
+ else:
306
+ self._stream_blocks.append({'type': 'thinking', 'text': delta})
307
+ self.refresh_stream()
308
+ elif et == 'tool_start':
309
+ name = payload.get('name', 'tool')
310
+ self._stream_blocks.append({'type': 'toolCall', 'name': name, 'arguments': '…', 'id': 'streaming'})
311
+ self.refresh_stream()
312
+ elif et == 'tool_end':
313
+ if payload.get('is_error'):
314
+ self._stream_blocks.append({'type': 'text', 'text': f'{payload.get('name', 'tool')} failed', 'is_error': True})
315
+ self.refresh_stream()
316
+ elif et == 'tool_results_ready':
317
+ self.refresh_cache()
318
+ self._stream_blocks = []
319
+ self.refresh_stream()
320
+ elif et == 'error':
321
+ err = str(payload.get('error', payload))
322
+ self._stream_blocks.append({'type': 'text', 'text': err, 'is_error': True})
323
+ ts = datetime.datetime.now().strftime('%H:%M:%S')
324
+ self.errors.append((ts, err))
325
+ self.last_error = err
326
+ self.refresh_stream()
327
+ elif et == 'turn_end':
328
+ self._stream_blocks = []
329
+ self.refresh_stream()
330
+ self.refresh_cache()
331
+ elif et == 'agent_end':
332
+ self._stream_blocks = []
333
+ self.refresh_stream()
334
+ self.refresh_cache()
335
+ self.status = 'idle'
442
336
 
443
- @property
444
- def input_box(self) -> InputBox:
445
- return self.query_one(InputBox)
337
+ 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
+ self.app.call_after_refresh(scroll.scroll_end, animate=False)
343
+
344
+ def refresh_stream(self) -> None:
345
+ msgs = []
346
+ if self._stream_blocks:
347
+ msgs.append({'role': 'assistant', 'content': self._stream_blocks})
348
+ if self._command_blocks and False:
349
+ msgs.extend(self._command_blocks)
350
+ if not msgs:
351
+ msgs.extend(self._command_blocks)
352
+ if msgs:
353
+ self.query_one('#stream', Static).update(render_history(msgs))
354
+ scroll = self.query_one('#scroll', VerticalScroll)
355
+ self.app.call_after_refresh(scroll.scroll_end, animate=False)
356
+ else:
357
+ self.query_one('#stream', Static).update('')
358
+
359
+ def show_command(self, cmd, text: str) -> None:
360
+ self._command_blocks.extend([{'role': 'user', 'content': cmd}, {'role': 'command', 'content': text}])
361
+ self.refresh_stream()
362
+
363
+ def clear_log(self) -> None:
364
+ self.query_one('#cache', Static).update('')
365
+ self.query_one('#stream', Static).update('')
366
+ self._cache_count = 0
367
+ self._command_blocks = []
368
+ self._stream_blocks = []
446
369
 
447
370
  class TabBar(Static):
448
371
 
@@ -481,9 +404,6 @@ class TabBar(Static):
481
404
 
482
405
  class StatusBar(Static):
483
406
 
484
- def __init__(self, **kwargs):
485
- super().__init__('', **kwargs)
486
-
487
407
  def render_status(self, tab: Tab, model: str | None) -> None:
488
408
  t = RichText(no_wrap=True, overflow='ellipsis')
489
409
  t.append(f' {tab.title}', style='bold')
@@ -507,15 +427,15 @@ class HelpBar(Static):
507
427
  self._idle_text = RichText('/help Ctrl-J newline Alt-1…9 tabs Ctrl-C abort Ctrl-D exit Ctrl-R recall', style='dim')
508
428
  super().__init__(self._idle_text, **kwargs)
509
429
 
430
+ def show_idle(self) -> None:
431
+ self.update(self._idle_text)
432
+
510
433
  def show_error(self, msg: str) -> None:
511
434
  t = RichText()
512
435
  t.append('✗ ', style='bold red')
513
436
  t.append(escape(msg), style='red')
514
437
  self.update(t)
515
438
 
516
- def show_idle(self) -> None:
517
- self.update(self._idle_text)
518
-
519
439
  def show_confirm(self, msg: str) -> None:
520
440
  t = RichText()
521
441
  t.append('⚠ ', style='bold yellow')
@@ -523,8 +443,8 @@ class HelpBar(Static):
523
443
  self.update(t)
524
444
 
525
445
  class ReplApp(App[None]):
526
- CSS = '\n ReplApp {\n layout: vertical;\n background: $background;\n }\n ContentSwitcher {\n height: 1fr;\n }\n ChatLog {\n height: 1fr;\n padding: 0 1;\n }\n\n ChatLog > Static {\n text-align: left;\n align-horizontal: left;\n }\n\n #stream {\n min-height: 1;\n }\n InputBox {\n height: auto;\n min-height: 3;\n max-height: 8;\n border: none;\n border-top: tall $panel-darken-1;\n background: $panel;\n padding: 0 2;\n color: $text;\n }\n InputBox:focus {\n border-top: tall $accent;\n }\n TabBar {\n height: 1;\n background: $panel;\n padding: 0 1;\n }\n StatusBar {\n height: 1;\n background: $panel;\n color: $text-muted;\n padding: 0 1;\n }\n HelpBar {\n height: 1;\n color: $text-muted;\n padding: 0 1;\n }\n '
527
- BINDINGS = [Binding('ctrl+c', 'abort_agent', 'Abort', priority=True, show=False), Binding('ctrl+d', 'close_or_exit', 'Exit', show=False), Binding('tab', 'next_tab', 'Next tab', show=False), Binding('shift+tab', 'prev_tab', 'Prev tab', show=False), Binding('alt+1', 'switch_tab(1)', 'Tab 1', show=False), Binding('alt+2', 'switch_tab(2)', 'Tab 2', show=False), Binding('alt+3', 'switch_tab(3)', 'Tab 3', show=False), Binding('alt+4', 'switch_tab(4)', 'Tab 4', show=False), Binding('alt+5', 'switch_tab(5)', 'Tab 5', show=False), Binding('alt+6', 'switch_tab(6)', 'Tab 6', show=False), Binding('alt+7', 'switch_tab(7)', 'Tab 7', show=False), Binding('alt+8', 'switch_tab(8)', 'Tab 8', show=False), Binding('alt+9', 'switch_tab(9)', 'Tab 9', show=False)]
446
+ CSS = '\n ReplApp { layout: vertical; background: $background; }\n ContentSwitcher { height: 1fr; }\n InputBox {\n height: auto;\n min-height: 3;\n max-height: 8;\n border: none;\n border-top: tall $panel-darken-1;\n background: $panel;\n padding: 0 2;\n color: $text;\n }\n InputBox:focus { border-top: tall $accent; }\n TabBar { height: 1; background: $panel; padding: 0 1; }\n StatusBar { height: 1; background: $panel; color: $text-muted; padding: 0 1; }\n HelpBar { height: 1; color: $text-muted; padding: 0 1; }\n '
447
+ BINDINGS = [Binding('ctrl+c', 'abort_agent', 'Abort', priority=True, show=False), Binding('ctrl+d', 'close_or_exit', 'Exit', show=False), Binding('tab', 'next_tab', 'Next tab', show=False), Binding('shift+tab', 'prev_tab', 'Prev tab', show=False), Binding('ctrl+1', 'switch_tab(1)', 'Tab 1', show=False), Binding('ctrl+2', 'switch_tab(2)', 'Tab 2', show=False), Binding('ctrl+3', 'switch_tab(3)', 'Tab 3', show=False), Binding('ctrl+4', 'switch_tab(4)', 'Tab 4', show=False), Binding('ctrl+5', 'switch_tab(5)', 'Tab 5', show=False), Binding('ctrl+6', 'switch_tab(6)', 'Tab 6', show=False), Binding('ctrl+7', 'switch_tab(7)', 'Tab 7', show=False), Binding('ctrl+8', 'switch_tab(8)', 'Tab 8', show=False), Binding('ctrl+9', 'switch_tab(9)', 'Tab 9', show=False)]
528
448
 
529
449
  def __init__(self, agent: Agent, init_prompt: str | None=None) -> None:
530
450
  super().__init__()
@@ -533,19 +453,17 @@ class ReplApp(App[None]):
533
453
  self._unsubscribers: dict[int, Callable] = {}
534
454
  self._pending_init = init_prompt.strip() if init_prompt else None
535
455
  self._confirm_close = False
536
- self._dirty: set[int] = set()
537
456
  self._attach_agent(self.tabs[0])
538
457
 
539
458
  def compose(self) -> ComposeResult:
540
459
  yield TabBar(id='tabbar')
541
- switcher = ContentSwitcher(TabPanel(self.tabs[0]), initial=f'panel-{id(self.tabs[0]):x}')
542
- yield switcher
460
+ yield ContentSwitcher(self.tabs[0], initial=self.tabs[0].id)
461
+ yield InputBox()
543
462
  yield StatusBar(id='statusbar')
544
463
  yield HelpBar(id='helpbar')
545
464
 
546
465
  def on_mount(self) -> None:
547
466
  self._refresh_chrome()
548
- self.set_interval(1 / 20, self._flush_dirty)
549
467
  self._switch_to(0)
550
468
  if self._pending_init:
551
469
  self.set_timer(0.05, self._run_pending_init)
@@ -555,44 +473,17 @@ class ReplApp(App[None]):
555
473
  prompt, self._pending_init = (self._pending_init, None)
556
474
  asyncio.create_task(self._run_submit(prompt))
557
475
 
558
- def _flush_dirty(self) -> None:
559
- if not self._dirty:
560
- return
561
- tab_by_id = {id(t): (i, t) for i, t in enumerate(self.tabs)}
562
- for tab_id in list(self._dirty):
563
- entry = tab_by_id.get(tab_id)
564
- if entry is None:
565
- continue
566
- idx, tab = entry
567
- panel = self._panel_for_index(idx)
568
- if panel:
569
- panel.chat_log.check_cache(tab)
570
- panel.chat_log.refresh_stream(tab)
571
- self._dirty.clear()
572
- self._refresh_chrome()
573
-
574
476
  def _refresh_chrome(self) -> None:
575
477
  try:
576
478
  self.query_one('#tabbar', TabBar).render_tabs(self.tabs, self.active_index)
577
479
  self.query_one('#statusbar', StatusBar).render_status(self.active_tab, self.active_tab.agent.model)
578
480
  except Exception:
579
- logger.debug('_refresh_chrome: widget not available', exc_info=True)
481
+ pass
580
482
 
581
483
  @property
582
484
  def active_tab(self) -> Tab:
583
485
  return self.tabs[self.active_index]
584
486
 
585
- def _panel_for_tab(self, tab: Tab) -> TabPanel | None:
586
- try:
587
- return self.query_one(f'#panel-{id(tab):x}', TabPanel)
588
- except Exception:
589
- return None
590
-
591
- def _panel_for_index(self, idx: int) -> TabPanel | None:
592
- if 0 <= idx < len(self.tabs):
593
- return self._panel_for_tab(self.tabs[idx])
594
- return None
595
-
596
487
  def _attach_agent(self, tab: Tab) -> None:
597
488
  key = id(tab.agent)
598
489
  if key in self._unsubscribers:
@@ -600,8 +491,8 @@ class ReplApp(App[None]):
600
491
 
601
492
  async def on_event(event: dict) -> None:
602
493
  tab.apply_event(event)
603
- if tab in self.tabs:
604
- self._dirty.add(id(tab))
494
+ if event['type'] in ('agent_start', 'agent_end', 'turn_end'):
495
+ self._refresh_chrome()
605
496
  self._unsubscribers[key] = tab.agent.subscribe(on_event)
606
497
 
607
498
  def on_input_box_submit(self, message: InputBox.Submit) -> None:
@@ -609,12 +500,9 @@ class ReplApp(App[None]):
609
500
 
610
501
  def on_input_box_recall_last(self, _msg: InputBox.RecallLast) -> None:
611
502
  tab = self.active_tab
612
- panel = self._panel_for_tab(tab)
613
- if not panel:
614
- return
615
503
  real = [m for m in tab.agent.messages if m.get('role') == 'user']
616
504
  if real:
617
- panel.input_box.set_text_and_end(real[-1].get('content', ''))
505
+ self.query_one(InputBox).set_text_and_end(real[-1].get('content', ''))
618
506
 
619
507
  def on_input_box_close_request(self, _msg: InputBox.CloseRequest) -> None:
620
508
  self._do_close_or_exit()
@@ -625,9 +513,9 @@ class ReplApp(App[None]):
625
513
  return
626
514
  self._confirm_close = False
627
515
  tab = self.active_tab
628
- tab.clear_errors()
629
- tab.ephemeral_messages.clear()
630
- self._show_help_idle()
516
+ tab.errors.clear()
517
+ tab.last_error = ''
518
+ self.query_one('#helpbar', HelpBar).show_idle()
631
519
  if text.startswith('/'):
632
520
  await self._handle_command(tab, text)
633
521
  return
@@ -635,125 +523,158 @@ class ReplApp(App[None]):
635
523
  self._do_close_or_exit()
636
524
  return
637
525
  if tab.is_running:
638
- self._show_error(tab, 'Agent is running — /abort first, or switch tabs (Alt-N).')
526
+ self.query_one('#helpbar', HelpBar).show_error('Agent is running — /abort first.')
639
527
  return
640
528
  tab.running_task = asyncio.create_task(self._run_agent(tab, text))
641
- self._dirty.add(id(tab))
642
529
 
643
530
  async def _run_agent(self, tab: Tab, text: str) -> None:
644
531
  try:
645
532
  await tab.agent.run(text)
646
533
  except Exception as exc:
647
- tab.add_error(str(exc))
534
+ tab.errors.append((datetime.datetime.now().strftime('%H:%M:%S'), str(exc)))
535
+ tab.last_error = str(exc)
648
536
  tab.status = 'error'
649
- try:
650
- self.query_one('#helpbar', HelpBar).show_error(str(exc))
651
- except Exception:
652
- logger.debug('_run_agent: helpbar not available', exc_info=True)
537
+ self.query_one('#helpbar', HelpBar).show_error(str(exc))
653
538
  finally:
539
+ tab.running_task = None
654
540
  if not tab.last_error:
655
541
  tab.status = 'idle'
656
- if tab in self.tabs:
657
- self._dirty.add(id(tab))
542
+ self._refresh_chrome()
658
543
 
659
544
  async def _handle_command(self, tab: Tab, text: str) -> None:
660
545
  cmd, _, arg = text.partition(' ')
661
546
  cmd = cmd.lower().strip()
662
547
  arg = arg.strip()
663
- panel = self._panel_for_tab(tab)
664
548
  if cmd == '/help':
665
- tab.ephemeral_messages.append({'role': '?', 'content': REPL_HELP})
549
+ tab.show_command(text, REPL_HELP)
666
550
  elif cmd == '/clear':
667
- tab.ephemeral_messages.clear()
668
551
  tab.agent.messages.clear()
669
- tab.status = 'cleared'
670
- if panel:
671
- panel.chat_log.clear_log()
552
+ tab.clear_log()
672
553
  self._refresh_chrome()
673
554
  elif cmd == '/history':
674
555
  if arg == '--raw':
675
- tab.ephemeral_messages.append({'role': '?', 'content': json.dumps(tab.agent.messages, indent=2)})
556
+ raw_json = json.dumps(tab.agent.messages, indent=2, default=str)
557
+ tab.show_command(text, raw_json)
676
558
  else:
677
- real = [m for m in tab.agent.messages if m.get('role') in {'user', 'assistant'}]
678
- if not real:
679
- tab.ephemeral_messages.append({'role': '?', 'content': 'No conversation yet.'})
559
+ user_msgs = [m for m in tab.agent.messages if m.get('role') == 'user']
560
+ if not user_msgs:
561
+ tab.show_command(text, 'No prompts yet.')
680
562
  else:
681
- parts = []
682
- for i, m in enumerate(real):
683
- if m['role'] == 'user':
684
- parts.append(f'Turn {i + 1} {_message_text(m)}')
685
- elif m['role'] == 'assistant':
686
- parts.append(f' {_message_text(m)}')
687
- tab.ephemeral_messages.append({'role': '?', 'content': 'Conversation transcript\n' + '\n'.join(parts)})
563
+ lines = []
564
+ gwt = tab.agent.ctx.get('gwt')
565
+ commit_stats = []
566
+ if gwt and getattr(gwt, 'worktree', None):
567
+ try:
568
+ commit_stats = get_commit_history_with_stats(gwt.worktree, limit=len(user_msgs) + 5)
569
+ except Exception as e:
570
+ logger.warning(f'Failed to get commit stats: {e}')
571
+ turn_commits = {}
572
+ for c in commit_stats:
573
+ turn = c.get('user_turns', 0)
574
+ if turn > 0:
575
+ if turn not in turn_commits:
576
+ turn_commits[turn] = []
577
+ turn_commits[turn].append(c)
578
+ for i, m in enumerate(user_msgs, 1):
579
+ content = m.get('content', '')
580
+ if isinstance(content, list):
581
+ content = ' '.join((b.get('text', '') for b in content if isinstance(b, dict) and b.get('type') == 'text'))
582
+ content = re.sub('\\s+', ' ', content).strip()
583
+ if len(content) > 100:
584
+ content = content[:100] + '…'
585
+ line = f'{i}. {content}'
586
+ if i in turn_commits:
587
+ for c in turn_commits[i]:
588
+ ref_str = c.get('refs', '').strip(' ()')
589
+ if ref_str.startswith('HEAD -> '):
590
+ ref_str = ref_str[8:]
591
+ ref_str = ref_str.replace('HEAD, ', '').strip()
592
+ hash_str = f'[{c['short_sha']}]'
593
+ if ref_str:
594
+ hash_str += f' on {ref_str}'
595
+ else:
596
+ hash_str += ' (detached)'
597
+ line += f'\n ↪ Commit {hash_str}'
598
+ seen = set()
599
+ unique_files = [f for f in c['files'] if not (f in seen or seen.add(f))]
600
+ if unique_files:
601
+ file_lines = '\n '.join(unique_files[:8])
602
+ if len(unique_files) > 8:
603
+ file_lines += f'\n ... and {len(unique_files) - 8} more'
604
+ line += f'\n {file_lines}'
605
+ lines.append(line)
606
+ tab.show_command(text, '\n'.join(lines))
688
607
  elif cmd == '/errors':
689
608
  if not tab.errors:
690
- tab.ephemeral_messages.append({'role': '?', 'content': 'No errors recorded in this tab.'})
609
+ tab.show_command('No errors recorded.')
691
610
  else:
692
611
  lines = [f'{ts} {msg}' for ts, msg in tab.errors[-30:]]
693
- tab.ephemeral_messages.append({'role': '?', 'content': 'Error log\n' + '\n'.join(lines)})
612
+ tab.show_command(text, 'Error log\n' + '\n'.join(lines))
694
613
  elif cmd == '/tools':
695
614
  tools = tab.agent.tools
696
615
  if not tools:
697
- tab.ephemeral_messages.append({'role': '?', 'content': 'No tools enabled.'})
616
+ tab.show_command(text, 'No tools enabled.')
698
617
  else:
699
618
  body = '\n'.join((f'{t.name} {t.description}' for t in tools))
700
- tab.ephemeral_messages.append({'role': '?', 'content': f'Active tools ({len(tools)})\n{body}'})
619
+ tab.show_command(text, f'Active tools ({len(tools)})\n{body}')
701
620
  elif cmd == '/abort':
702
621
  if tab.is_running:
703
622
  tab.agent.abort()
704
623
  tab.status = 'aborting…'
705
624
  self._refresh_chrome()
706
625
  else:
707
- self._show_error(tab, 'Nothing is running.')
626
+ self.query_one('#helpbar', HelpBar).show_error('Nothing is running.')
708
627
  elif cmd == '/branch':
709
- await self._open_branch(arg)
628
+ as_worktree = False
629
+ prompt = arg
630
+ if '--as-worktree' in arg:
631
+ as_worktree = True
632
+ prompt = arg.replace('--as-worktree', '').strip()
633
+ parent = self.active_tab
634
+ child = parent.agent.branch()
635
+ index_path, title = _branch_index_title(parent.index_path, self.tabs)
636
+ owns_worktree = False
637
+ gwt = child.ctx.get('gwt')
638
+ if as_worktree:
639
+ repo_dir = gwt.worktree if gwt else child.ctx.get('cwd', os.getcwd())
640
+ new_gwt = create_worktree(repo_dir, prefix=title)
641
+ if new_gwt is None:
642
+ self.query_one('#helpbar', HelpBar).show_error(f'git worktree creation failed for {title!r}')
643
+ return
644
+ child.ctx['gwt'] = new_gwt
645
+ child.ctx['cwd'] = new_gwt.worktree
646
+ if 'env' in child.ctx:
647
+ child.ctx['env']['PWD'] = new_gwt.worktree
648
+ owns_worktree = True
649
+ elif gwt:
650
+ try:
651
+ new_gwt = git_new_branch(gwt.worktree, title)
652
+ child.ctx['gwt'] = new_gwt
653
+ except GitError as exc:
654
+ logger.warning('git_new_branch failed for tab %r: %s', title, exc)
655
+ new_tab = Tab(title, child, owns_worktree=owns_worktree, index_path=index_path)
656
+ self.tabs.append(new_tab)
657
+ self._attach_agent(new_tab)
658
+ switcher = self.query_one(ContentSwitcher)
659
+ await switcher.mount(new_tab)
660
+ self._switch_to(len(self.tabs) - 1)
661
+ if prompt:
662
+ await self._run_submit(prompt)
710
663
  elif cmd == '/export':
711
- self._export_tab(tab, arg)
664
+ ts = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
665
+ path = arg or os.path.join(os.getcwd(), f'session_{ts}.json')
666
+ data = {'version': 1, 'exported_at': ts, 'system': tab.agent.system, 'messages': tab.agent.messages}
667
+ try:
668
+ with open(path, 'w', encoding='utf-8') as f:
669
+ json.dump(data, f, indent=2, ensure_ascii=False)
670
+ tab.status = f'exported → {path}'
671
+ self._refresh_chrome()
672
+ except OSError as exc:
673
+ self.query_one('#helpbar', HelpBar).show_error(f'Export failed: {exc}')
712
674
  elif cmd in {'/exit', '/quit'}:
713
675
  self._do_close_or_exit()
714
676
  else:
715
- self._show_error(tab, f'Unknown command: {cmd!r} — try /help')
716
- if panel:
717
- panel.chat_log.refresh_cache(tab)
718
-
719
- async def _open_branch(self, arg: str='') -> None:
720
- as_worktree = False
721
- prompt = arg
722
- if '--as-worktree' in arg:
723
- as_worktree = True
724
- prompt = arg.replace('--as-worktree', '').strip()
725
- parent = self.active_tab
726
- child = parent.agent.branch()
727
- index_path, title = _branch_index_title(parent.index_path, self.tabs)
728
- owns_worktree = False
729
- gwt = child.ctx.get('gwt')
730
- if as_worktree:
731
- repo_dir = gwt.worktree if gwt else child.ctx.get('cwd', os.getcwd())
732
- new_gwt = create_worktree(repo_dir, prefix=title)
733
- if new_gwt is None:
734
- self._show_error(parent, f'git worktree creation failed for {title!r}')
735
- return
736
- child.ctx['gwt'] = new_gwt
737
- child.ctx['cwd'] = new_gwt.worktree
738
- if 'env' in child.ctx:
739
- child.ctx['env']['PWD'] = new_gwt.worktree
740
- owns_worktree = True
741
- elif gwt:
742
- try:
743
- new_gwt = git_new_branch(gwt.worktree, title)
744
- child.ctx['gwt'] = new_gwt
745
- except GitError as exc:
746
- logger.warning('git_new_branch failed for tab %r: %s', title, exc)
747
- new_tab = Tab(title, child, owns_worktree=owns_worktree, index_path=index_path)
748
- self.tabs.append(new_tab)
749
- self._attach_agent(new_tab)
750
- panel = TabPanel(new_tab)
751
- new_tab.panel = panel
752
- switcher = self.query_one(ContentSwitcher)
753
- await switcher.mount(panel)
754
- self._switch_to(len(self.tabs) - 1)
755
- if prompt:
756
- await self._run_submit(prompt)
677
+ self.query_one('#helpbar', HelpBar).show_error(f'Unknown command: {cmd!r} — try /help')
757
678
 
758
679
  def _switch_to(self, idx: int) -> None:
759
680
  if not 0 <= idx < len(self.tabs):
@@ -769,33 +690,18 @@ class ReplApp(App[None]):
769
690
  next_tab.agent.ctx['gwt'] = updated
770
691
  except GitError as exc:
771
692
  logger.warning('git switch to %r failed: %s', next_gwt.branch, exc)
772
- try:
773
- self.query_one('#helpbar', HelpBar).show_error(f'git switch failed: {exc}')
774
- except Exception:
775
- pass
693
+ self.query_one('#helpbar', HelpBar).show_error(f'git switch failed: {exc}')
776
694
  self.active_index = idx
777
695
  self._confirm_close = False
778
696
  try:
779
- self.query_one(ContentSwitcher).current = f'panel-{id(self.tabs[idx]):x}'
697
+ self.query_one(ContentSwitcher).current = self.tabs[idx].id
780
698
  except Exception:
781
- logger.debug('_switch_to: ContentSwitcher not available', exc_info=True)
782
- panel = self._panel_for_index(idx)
783
- if panel:
784
- panel.chat_log.check_cache(self.tabs[idx])
785
- panel.input_box.focus()
699
+ pass
700
+ tab = self.tabs[idx]
701
+ tab.refresh_cache()
702
+ self.query_one(InputBox).focus()
786
703
  self._refresh_chrome()
787
- self._sync_help_bar()
788
-
789
- def _sync_help_bar(self) -> None:
790
- tab = self.active_tab
791
- try:
792
- hb = self.query_one('#helpbar', HelpBar)
793
- if tab.last_error:
794
- hb.show_error(tab.last_error)
795
- else:
796
- hb.show_idle()
797
- except Exception:
798
- logger.debug('_sync_help_bar: helpbar not available', exc_info=True)
704
+ self.query_one('#helpbar', HelpBar).show_idle()
799
705
 
800
706
  def on_tab_bar_switch_to(self, msg: TabBar.SwitchTo) -> None:
801
707
  self._switch_to(msg.index)
@@ -817,10 +723,7 @@ class ReplApp(App[None]):
817
723
  if tab.is_running:
818
724
  if not self._confirm_close:
819
725
  self._confirm_close = True
820
- try:
821
- self.query_one('#helpbar', HelpBar).show_confirm('Agent is running — press Ctrl-D again to force close.')
822
- except Exception:
823
- logger.debug('_do_close_or_exit: helpbar not available', exc_info=True)
726
+ self.query_one('#helpbar', HelpBar).show_confirm('Agent is running — press Ctrl-D again to force close.')
824
727
  return
825
728
  tab.agent.abort()
826
729
  if tab.running_task:
@@ -830,10 +733,7 @@ class ReplApp(App[None]):
830
733
  self.exit()
831
734
  return
832
735
  if tab.is_main:
833
- try:
834
- self.query_one('#helpbar', HelpBar).show_error('Close all branch tabs first, then Ctrl-D to exit.')
835
- except Exception:
836
- logger.debug('_do_close_or_exit: helpbar not available', exc_info=True)
736
+ self.query_one('#helpbar', HelpBar).show_error('Close all branch tabs first, then Ctrl-D to exit.')
837
737
  return
838
738
  if (unsub := self._unsubscribers.pop(id(tab.agent), None)):
839
739
  unsub()
@@ -844,9 +744,7 @@ class ReplApp(App[None]):
844
744
  cleanup_worktree(gwt)
845
745
  except Exception as exc:
846
746
  logger.warning('cleanup_worktree failed for %r: %s', tab.title, exc)
847
- panel = self._panel_for_tab(tab)
848
- if panel:
849
- panel.remove()
747
+ tab.remove()
850
748
  self.tabs.pop(self.active_index)
851
749
  self.active_index = max(0, min(self.active_index, len(self.tabs) - 1))
852
750
  self._switch_to(self.active_index)
@@ -858,31 +756,6 @@ class ReplApp(App[None]):
858
756
  tab.status = 'aborting…'
859
757
  self._refresh_chrome()
860
758
 
861
- def _export_tab(self, tab: Tab, out_path: str) -> None:
862
- ts = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
863
- path = out_path or os.path.join(os.getcwd(), f'session_{ts}.json')
864
- data = {'version': 1, 'exported_at': ts, 'system': tab.agent.system, 'messages': tab.agent.messages}
865
- try:
866
- with open(path, 'w', encoding='utf-8') as f:
867
- json.dump(data, f, indent=2, ensure_ascii=False)
868
- tab.status = f'exported → {path}'
869
- self._refresh_chrome()
870
- except OSError as exc:
871
- self._show_error(tab, f'Export failed: {exc}')
872
-
873
- def _show_error(self, tab: Tab, msg: str) -> None:
874
- tab.add_error(msg)
875
- try:
876
- self.query_one('#helpbar', HelpBar).show_error(msg)
877
- except Exception:
878
- logger.debug('_show_error: helpbar not available', exc_info=True)
879
-
880
- def _show_help_idle(self) -> None:
881
- try:
882
- self.query_one('#helpbar', HelpBar).show_idle()
883
- except Exception:
884
- logger.debug('_show_help_idle: helpbar not available', exc_info=True)
885
-
886
759
  async def _stream_to_stdout(agent: Agent, user_input: str) -> None:
887
760
  result = await agent.run(user_input)
888
761
  text = ''.join((block.get('text', '') for block in result.get('content', []) if block.get('type') == 'text'))
@@ -311,7 +311,22 @@ def draw_tree(win, state: State, h: int, w: int):
311
311
  toggle = ' '
312
312
  indent = ' ' * node.depth
313
313
  if node.kind == 'base':
314
- content = f'● {node.committed_dt} {node.summary}'
314
+ branch_prefixes = []
315
+ for child in node.children:
316
+ if child.branch_name:
317
+ if '--' in child.branch_name:
318
+ prefix = child.branch_name.split('--', 1)[0]
319
+ elif '/' in child.branch_name:
320
+ prefix = child.branch_name.split('/', 1)[0]
321
+ else:
322
+ prefix = child.branch_name
323
+ if prefix not in branch_prefixes:
324
+ branch_prefixes.append(prefix)
325
+ branch_str = ' '.join(branch_prefixes)
326
+ if branch_str:
327
+ content = f'● {node.committed_dt} {branch_str} {node.summary}'
328
+ else:
329
+ content = f'● {node.committed_dt} {node.summary}'
315
330
  elif node.kind == 'branch_label':
316
331
  inner = node.branch_name.split('/', 1)[1] if node.branch_name and '/' in node.branch_name else node.branch_name or ''
317
332
  seg = inner.split('-', 1)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mlx-code
3
- Version: 0.0.12
3
+ Version: 0.0.14
4
4
  Summary: Coding Agent for Mac
5
5
  Home-page: https://josefalbers.github.io/mlx-code/
6
6
  Author: J Joe
@@ -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.12",
14
+ version="0.0.14",
15
15
  readme="README.md",
16
16
  description="Coding Agent for Mac",
17
17
  long_description=open("README.md").read(),
@@ -171,7 +171,7 @@ class TestReplHelpers(unittest.TestCase):
171
171
  tab.apply_event({'type': 'error', 'payload': {'error': 'something broke'}})
172
172
  self.assertEqual(tab.last_error, 'something broke')
173
173
  error_blocks = [b for b in tab._stream_msg['content'] if b.get('is_error')]
174
- self.assertTrue(any('something broke' in b.get('text', '') for b in error_blocks))
174
+ self.assertTrue(any(('something broke' in b.get('text', '') for b in error_blocks)))
175
175
 
176
176
  def test_tab_apply_event_tool_start_and_end(self):
177
177
  tab = Tab('main', Agent(api=EchoChat(), tool_names=[]))
@@ -181,15 +181,15 @@ class TestReplHelpers(unittest.TestCase):
181
181
  self.assertEqual(len(tool_calls), 1)
182
182
  self.assertEqual(tool_calls[0]['name'], 'Read')
183
183
  tab.apply_event({'type': 'tool_end', 'payload': {'name': 'Read', 'is_error': False, 'result': {'content': [{'type': 'text', 'text': 'file contents'}]}}})
184
- text_blocks = [b for b in tab._stream_msg['content'] if b.get('type') == 'text' and not b.get('is_error')]
185
- self.assertTrue(any('file contents' in b.get('text', '') for b in text_blocks))
184
+ text_blocks = [b for b in tab._stream_msg['content'] if b.get('type') == 'text' and (not b.get('is_error'))]
185
+ self.assertTrue(any(('file contents' in b.get('text', '') for b in text_blocks)))
186
186
 
187
187
  def test_tab_apply_event_tool_end_error_appends_failure_text(self):
188
188
  tab = Tab('main', Agent(api=EchoChat(), tool_names=[]))
189
189
  tab.apply_event({'type': 'agent_start', 'payload': {}})
190
190
  tab.apply_event({'type': 'tool_end', 'payload': {'name': 'Write', 'is_error': True, 'result': None}})
191
191
  error_blocks = [b for b in tab._stream_msg['content'] if b.get('is_error')]
192
- self.assertTrue(any('Write failed' in b.get('text', '') for b in error_blocks))
192
+ self.assertTrue(any(('Write failed' in b.get('text', '') for b in error_blocks)))
193
193
 
194
194
  def test_echo_chat_outputs_final_text_only_to_stdout(self):
195
195
 
@@ -214,6 +214,5 @@ class TestReplHelpers(unittest.TestCase):
214
214
  await repl(agent)
215
215
  return fake_stdout.getvalue()
216
216
  self.assertIn('echo: piped input', asyncio.run(run()))
217
-
218
217
  if __name__ == '__main__':
219
- unittest.main()
218
+ unittest.main()
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