mlx-code 0.0.12__tar.gz → 0.0.13__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.13}/PKG-INFO +1 -1
  2. {mlx_code-0.0.12 → mlx_code-0.0.13}/mlx_code/gits.py +1 -1
  3. {mlx_code-0.0.12 → mlx_code-0.0.13}/mlx_code/repl.py +223 -384
  4. {mlx_code-0.0.12 → mlx_code-0.0.13}/mlx_code/view_git.py +16 -1
  5. {mlx_code-0.0.12 → mlx_code-0.0.13}/mlx_code.egg-info/PKG-INFO +1 -1
  6. {mlx_code-0.0.12 → mlx_code-0.0.13}/setup.py +1 -1
  7. {mlx_code-0.0.12 → mlx_code-0.0.13}/tests/test.py +5 -6
  8. {mlx_code-0.0.12 → mlx_code-0.0.13}/LICENSE +0 -0
  9. {mlx_code-0.0.12 → mlx_code-0.0.13}/README.md +0 -0
  10. {mlx_code-0.0.12 → mlx_code-0.0.13}/mlx_code/__init__.py +0 -0
  11. {mlx_code-0.0.12 → mlx_code-0.0.13}/mlx_code/apis.py +0 -0
  12. {mlx_code-0.0.12 → mlx_code-0.0.13}/mlx_code/lsp_tool.py +0 -0
  13. {mlx_code-0.0.12 → mlx_code-0.0.13}/mlx_code/main.py +0 -0
  14. {mlx_code-0.0.12 → mlx_code-0.0.13}/mlx_code/mcb.py +0 -0
  15. {mlx_code-0.0.12 → mlx_code-0.0.13}/mlx_code/mcb_tool.py +0 -0
  16. {mlx_code-0.0.12 → mlx_code-0.0.13}/mlx_code/stream_log.py +0 -0
  17. {mlx_code-0.0.12 → mlx_code-0.0.13}/mlx_code/tools.py +0 -0
  18. {mlx_code-0.0.12 → mlx_code-0.0.13}/mlx_code/util.py +0 -0
  19. {mlx_code-0.0.12 → mlx_code-0.0.13}/mlx_code/view_log.py +0 -0
  20. {mlx_code-0.0.12 → mlx_code-0.0.13}/mlx_code.egg-info/SOURCES.txt +0 -0
  21. {mlx_code-0.0.12 → mlx_code-0.0.13}/mlx_code.egg-info/dependency_links.txt +0 -0
  22. {mlx_code-0.0.12 → mlx_code-0.0.13}/mlx_code.egg-info/entry_points.txt +0 -0
  23. {mlx_code-0.0.12 → mlx_code-0.0.13}/mlx_code.egg-info/requires.txt +0 -0
  24. {mlx_code-0.0.12 → mlx_code-0.0.13}/mlx_code.egg-info/top_level.txt +0 -0
  25. {mlx_code-0.0.12 → mlx_code-0.0.13}/setup.cfg +0 -0
  26. {mlx_code-0.0.12 → mlx_code-0.0.13}/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.13
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):
@@ -1,19 +1,16 @@
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
15
  from .gits import create_worktree, commit_worktree, resume_worktree, cleanup_worktree, git_new_branch, git_switch_branch, GitError
19
16
  from .tools import Tool, validate_tool_call, DEFAULT_TOOLS
@@ -21,15 +18,13 @@ 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,124 @@ 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
+ for i, m in enumerate(user_msgs, 1):
565
+ content = m.get('content', '')
566
+ if isinstance(content, list):
567
+ content = ''.join((b.get('text', '') for b in content if isinstance(b, dict) and b.get('type') == 'text'))
568
+ content = re.sub('\\s+', ' ', content).strip()
569
+ if len(content) > 100:
570
+ content = content[:100] + '…'
571
+ lines.append(f'{i}. {content}')
572
+ tab.show_command(text, '\n'.join(lines))
688
573
  elif cmd == '/errors':
689
574
  if not tab.errors:
690
- tab.ephemeral_messages.append({'role': '?', 'content': 'No errors recorded in this tab.'})
575
+ tab.show_command('No errors recorded.')
691
576
  else:
692
577
  lines = [f'{ts} {msg}' for ts, msg in tab.errors[-30:]]
693
- tab.ephemeral_messages.append({'role': '?', 'content': 'Error log\n' + '\n'.join(lines)})
578
+ tab.show_command(text, 'Error log\n' + '\n'.join(lines))
694
579
  elif cmd == '/tools':
695
580
  tools = tab.agent.tools
696
581
  if not tools:
697
- tab.ephemeral_messages.append({'role': '?', 'content': 'No tools enabled.'})
582
+ tab.show_command(text, 'No tools enabled.')
698
583
  else:
699
584
  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}'})
585
+ tab.show_command(text, f'Active tools ({len(tools)})\n{body}')
701
586
  elif cmd == '/abort':
702
587
  if tab.is_running:
703
588
  tab.agent.abort()
704
589
  tab.status = 'aborting…'
705
590
  self._refresh_chrome()
706
591
  else:
707
- self._show_error(tab, 'Nothing is running.')
592
+ self.query_one('#helpbar', HelpBar).show_error('Nothing is running.')
708
593
  elif cmd == '/branch':
709
- await self._open_branch(arg)
594
+ as_worktree = False
595
+ prompt = arg
596
+ if '--as-worktree' in arg:
597
+ as_worktree = True
598
+ prompt = arg.replace('--as-worktree', '').strip()
599
+ parent = self.active_tab
600
+ child = parent.agent.branch()
601
+ index_path, title = _branch_index_title(parent.index_path, self.tabs)
602
+ owns_worktree = False
603
+ gwt = child.ctx.get('gwt')
604
+ if as_worktree:
605
+ repo_dir = gwt.worktree if gwt else child.ctx.get('cwd', os.getcwd())
606
+ new_gwt = create_worktree(repo_dir, prefix=title)
607
+ if new_gwt is None:
608
+ self.query_one('#helpbar', HelpBar).show_error(f'git worktree creation failed for {title!r}')
609
+ return
610
+ child.ctx['gwt'] = new_gwt
611
+ child.ctx['cwd'] = new_gwt.worktree
612
+ if 'env' in child.ctx:
613
+ child.ctx['env']['PWD'] = new_gwt.worktree
614
+ owns_worktree = True
615
+ elif gwt:
616
+ try:
617
+ new_gwt = git_new_branch(gwt.worktree, title)
618
+ child.ctx['gwt'] = new_gwt
619
+ except GitError as exc:
620
+ logger.warning('git_new_branch failed for tab %r: %s', title, exc)
621
+ new_tab = Tab(title, child, owns_worktree=owns_worktree, index_path=index_path)
622
+ self.tabs.append(new_tab)
623
+ self._attach_agent(new_tab)
624
+ switcher = self.query_one(ContentSwitcher)
625
+ await switcher.mount(new_tab)
626
+ self._switch_to(len(self.tabs) - 1)
627
+ if prompt:
628
+ await self._run_submit(prompt)
710
629
  elif cmd == '/export':
711
- self._export_tab(tab, arg)
630
+ ts = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
631
+ path = arg or os.path.join(os.getcwd(), f'session_{ts}.json')
632
+ data = {'version': 1, 'exported_at': ts, 'system': tab.agent.system, 'messages': tab.agent.messages}
633
+ try:
634
+ with open(path, 'w', encoding='utf-8') as f:
635
+ json.dump(data, f, indent=2, ensure_ascii=False)
636
+ tab.status = f'exported → {path}'
637
+ self._refresh_chrome()
638
+ except OSError as exc:
639
+ self.query_one('#helpbar', HelpBar).show_error(f'Export failed: {exc}')
712
640
  elif cmd in {'/exit', '/quit'}:
713
641
  self._do_close_or_exit()
714
642
  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)
643
+ self.query_one('#helpbar', HelpBar).show_error(f'Unknown command: {cmd!r} — try /help')
757
644
 
758
645
  def _switch_to(self, idx: int) -> None:
759
646
  if not 0 <= idx < len(self.tabs):
@@ -769,33 +656,18 @@ class ReplApp(App[None]):
769
656
  next_tab.agent.ctx['gwt'] = updated
770
657
  except GitError as exc:
771
658
  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
659
+ self.query_one('#helpbar', HelpBar).show_error(f'git switch failed: {exc}')
776
660
  self.active_index = idx
777
661
  self._confirm_close = False
778
662
  try:
779
- self.query_one(ContentSwitcher).current = f'panel-{id(self.tabs[idx]):x}'
663
+ self.query_one(ContentSwitcher).current = self.tabs[idx].id
780
664
  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()
665
+ pass
666
+ tab = self.tabs[idx]
667
+ tab.refresh_cache()
668
+ self.query_one(InputBox).focus()
786
669
  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)
670
+ self.query_one('#helpbar', HelpBar).show_idle()
799
671
 
800
672
  def on_tab_bar_switch_to(self, msg: TabBar.SwitchTo) -> None:
801
673
  self._switch_to(msg.index)
@@ -817,10 +689,7 @@ class ReplApp(App[None]):
817
689
  if tab.is_running:
818
690
  if not self._confirm_close:
819
691
  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)
692
+ self.query_one('#helpbar', HelpBar).show_confirm('Agent is running — press Ctrl-D again to force close.')
824
693
  return
825
694
  tab.agent.abort()
826
695
  if tab.running_task:
@@ -830,10 +699,7 @@ class ReplApp(App[None]):
830
699
  self.exit()
831
700
  return
832
701
  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)
702
+ self.query_one('#helpbar', HelpBar).show_error('Close all branch tabs first, then Ctrl-D to exit.')
837
703
  return
838
704
  if (unsub := self._unsubscribers.pop(id(tab.agent), None)):
839
705
  unsub()
@@ -844,9 +710,7 @@ class ReplApp(App[None]):
844
710
  cleanup_worktree(gwt)
845
711
  except Exception as exc:
846
712
  logger.warning('cleanup_worktree failed for %r: %s', tab.title, exc)
847
- panel = self._panel_for_tab(tab)
848
- if panel:
849
- panel.remove()
713
+ tab.remove()
850
714
  self.tabs.pop(self.active_index)
851
715
  self.active_index = max(0, min(self.active_index, len(self.tabs) - 1))
852
716
  self._switch_to(self.active_index)
@@ -858,31 +722,6 @@ class ReplApp(App[None]):
858
722
  tab.status = 'aborting…'
859
723
  self._refresh_chrome()
860
724
 
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
725
  async def _stream_to_stdout(agent: Agent, user_input: str) -> None:
887
726
  result = await agent.run(user_input)
888
727
  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.13
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.13",
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