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.
- {mlx_code-0.0.12 → mlx_code-0.0.13}/PKG-INFO +1 -1
- {mlx_code-0.0.12 → mlx_code-0.0.13}/mlx_code/gits.py +1 -1
- {mlx_code-0.0.12 → mlx_code-0.0.13}/mlx_code/repl.py +223 -384
- {mlx_code-0.0.12 → mlx_code-0.0.13}/mlx_code/view_git.py +16 -1
- {mlx_code-0.0.12 → mlx_code-0.0.13}/mlx_code.egg-info/PKG-INFO +1 -1
- {mlx_code-0.0.12 → mlx_code-0.0.13}/setup.py +1 -1
- {mlx_code-0.0.12 → mlx_code-0.0.13}/tests/test.py +5 -6
- {mlx_code-0.0.12 → mlx_code-0.0.13}/LICENSE +0 -0
- {mlx_code-0.0.12 → mlx_code-0.0.13}/README.md +0 -0
- {mlx_code-0.0.12 → mlx_code-0.0.13}/mlx_code/__init__.py +0 -0
- {mlx_code-0.0.12 → mlx_code-0.0.13}/mlx_code/apis.py +0 -0
- {mlx_code-0.0.12 → mlx_code-0.0.13}/mlx_code/lsp_tool.py +0 -0
- {mlx_code-0.0.12 → mlx_code-0.0.13}/mlx_code/main.py +0 -0
- {mlx_code-0.0.12 → mlx_code-0.0.13}/mlx_code/mcb.py +0 -0
- {mlx_code-0.0.12 → mlx_code-0.0.13}/mlx_code/mcb_tool.py +0 -0
- {mlx_code-0.0.12 → mlx_code-0.0.13}/mlx_code/stream_log.py +0 -0
- {mlx_code-0.0.12 → mlx_code-0.0.13}/mlx_code/tools.py +0 -0
- {mlx_code-0.0.12 → mlx_code-0.0.13}/mlx_code/util.py +0 -0
- {mlx_code-0.0.12 → mlx_code-0.0.13}/mlx_code/view_log.py +0 -0
- {mlx_code-0.0.12 → mlx_code-0.0.13}/mlx_code.egg-info/SOURCES.txt +0 -0
- {mlx_code-0.0.12 → mlx_code-0.0.13}/mlx_code.egg-info/dependency_links.txt +0 -0
- {mlx_code-0.0.12 → mlx_code-0.0.13}/mlx_code.egg-info/entry_points.txt +0 -0
- {mlx_code-0.0.12 → mlx_code-0.0.13}/mlx_code.egg-info/requires.txt +0 -0
- {mlx_code-0.0.12 → mlx_code-0.0.13}/mlx_code.egg-info/top_level.txt +0 -0
- {mlx_code-0.0.12 → mlx_code-0.0.13}/setup.cfg +0 -0
- {mlx_code-0.0.12 → mlx_code-0.0.13}/tests/__init__.py +0 -0
|
@@ -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(['
|
|
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 = ('
|
|
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
|
|
348
|
-
prefix, style = ('
|
|
349
|
-
|
|
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
|
|
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
|
|
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,
|
|
432
|
-
super().__init__(id=f'
|
|
433
|
-
self.
|
|
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
|
-
|
|
437
|
-
|
|
275
|
+
with VerticalScroll(id='scroll'):
|
|
276
|
+
yield Static(id='cache')
|
|
277
|
+
yield Static(id='stream')
|
|
438
278
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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 {
|
|
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('
|
|
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
|
-
|
|
542
|
-
yield
|
|
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
|
-
|
|
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
|
|
604
|
-
self.
|
|
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
|
-
|
|
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.
|
|
629
|
-
tab.
|
|
630
|
-
self.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
556
|
+
raw_json = json.dumps(tab.agent.messages, indent=2, default=str)
|
|
557
|
+
tab.show_command(text, raw_json)
|
|
676
558
|
else:
|
|
677
|
-
|
|
678
|
-
if not
|
|
679
|
-
tab.
|
|
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
|
-
|
|
682
|
-
for i, m in enumerate(
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
592
|
+
self.query_one('#helpbar', HelpBar).show_error('Nothing is running.')
|
|
708
593
|
elif cmd == '/branch':
|
|
709
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
663
|
+
self.query_one(ContentSwitcher).current = self.tabs[idx].id
|
|
780
664
|
except Exception:
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|