systemr-cli 2.0.0__tar.gz → 2.1.0__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.
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/PKG-INFO +1 -1
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/neo/__init__.py +1 -1
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/neo/chat_runner.py +9 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/neo/tui.py +173 -62
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/pyproject.toml +1 -1
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/.gitignore +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/DESIGN.md +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/README.md +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/clawhub/systemr-trading/SKILL.md +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/clawhub/systemr-trading/scripts/mcp_stdio_proxy.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/neo/__main__.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/neo/auth.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/neo/cli.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/neo/client.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/neo/commands/__init__.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/neo/commands/auth_commands.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/neo/commands/chat_commands.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/neo/commands/cron_commands.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/neo/commands/doctor_command.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/neo/commands/eval_commands.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/neo/commands/journal_commands.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/neo/commands/plan_commands.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/neo/commands/risk_commands.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/neo/commands/scan_commands.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/neo/commands/size_commands.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/neo/config.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/neo/confirmation.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/neo/credits.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/neo/cron.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/neo/display/__init__.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/neo/display/chat_renderer.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/neo/display/formatters.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/neo/display/tables.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/neo/display/theme.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/neo/hooks.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/neo/logging.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/neo/model_failover.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/neo/orchestrator.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/neo/profile.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/neo/store.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/neo/streaming.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/neo/tool_router.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/neo/types.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/tests/__init__.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/tests/test_auth.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/tests/test_chat_helpers.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/tests/test_chat_renderer.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/tests/test_cli.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/tests/test_config.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/tests/test_confirmation.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/tests/test_credits.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/tests/test_cron.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/tests/test_doctor.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/tests/test_formatters.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/tests/test_hooks.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/tests/test_logging.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/tests/test_model_failover.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/tests/test_orchestrator.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/tests/test_profile.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/tests/test_store.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/tests/test_streaming.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/tests/test_tool_router.py +0 -0
- {systemr_cli-2.0.0 → systemr_cli-2.1.0}/tests/test_types.py +0 -0
|
@@ -190,6 +190,8 @@ async def _stream_to_tui(
|
|
|
190
190
|
streaming_started = False
|
|
191
191
|
start_time = time.time()
|
|
192
192
|
|
|
193
|
+
tui.set_processing(True)
|
|
194
|
+
|
|
193
195
|
try:
|
|
194
196
|
async for event in stream_chat(request, access_token):
|
|
195
197
|
if event.event == "thinking":
|
|
@@ -248,6 +250,11 @@ async def _stream_to_tui(
|
|
|
248
250
|
credits_used=credits_used, balance=balance,
|
|
249
251
|
tools=tool_count, trade=has_trade,
|
|
250
252
|
)
|
|
253
|
+
if credits_used:
|
|
254
|
+
try:
|
|
255
|
+
tui.add_credits(float(credits_used))
|
|
256
|
+
except (ValueError, TypeError):
|
|
257
|
+
pass
|
|
251
258
|
|
|
252
259
|
elif event.event == "error":
|
|
253
260
|
raw = event.parsed.get("message", event.parsed.get("error", ""))
|
|
@@ -263,6 +270,8 @@ async def _stream_to_tui(
|
|
|
263
270
|
except Exception as exc:
|
|
264
271
|
tui.add_error(f"Connection error: {str(exc)[:60]}")
|
|
265
272
|
logger.error("stream_error", error=str(exc))
|
|
273
|
+
finally:
|
|
274
|
+
tui.set_processing(False)
|
|
266
275
|
|
|
267
276
|
return collected_text
|
|
268
277
|
|
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
"""Terminal UI Application — fixed-layout chat interface.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
- Scrollable conversation area (top)
|
|
5
|
-
- Fixed separator + input
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
Full-screen prompt_toolkit Application with:
|
|
4
|
+
- Scrollable conversation area (top) with auto-scroll
|
|
5
|
+
- Fixed separator + input prompt (middle)
|
|
6
|
+
- Fixed separator + live status bar (bottom)
|
|
7
|
+
|
|
8
|
+
Keyboard shortcuts:
|
|
9
|
+
- Enter: submit message
|
|
10
|
+
- Ctrl+D / Ctrl+C: exit
|
|
11
|
+
- Ctrl+L: clear conversation
|
|
12
|
+
- Page Up/Down: scroll conversation
|
|
13
|
+
- Up/Down: input history
|
|
14
|
+
- Escape: cancel current input
|
|
9
15
|
"""
|
|
10
16
|
|
|
11
17
|
from __future__ import annotations
|
|
12
18
|
|
|
13
19
|
import asyncio
|
|
14
|
-
import sys
|
|
15
20
|
import time
|
|
16
21
|
from typing import Any, Callable
|
|
17
22
|
|
|
@@ -27,11 +32,12 @@ from prompt_toolkit.layout import (
|
|
|
27
32
|
)
|
|
28
33
|
from prompt_toolkit.layout.layout import Layout
|
|
29
34
|
from prompt_toolkit.layout.dimension import Dimension
|
|
30
|
-
from prompt_toolkit.formatted_text import FormattedText
|
|
35
|
+
from prompt_toolkit.formatted_text import FormattedText
|
|
31
36
|
from prompt_toolkit.history import FileHistory
|
|
32
37
|
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
|
38
|
+
from prompt_toolkit.styles import Style
|
|
33
39
|
|
|
34
|
-
# Colors
|
|
40
|
+
# ── Colors (match theme.py) ──
|
|
35
41
|
GREEN = "#3ECF8E"
|
|
36
42
|
GREEN_DIM = "#34B87A"
|
|
37
43
|
RED = "#EF4444"
|
|
@@ -41,6 +47,25 @@ GRAY = "#A1A1AA"
|
|
|
41
47
|
WHITE = "#F5F5F5"
|
|
42
48
|
AMBER = "#F59E0B"
|
|
43
49
|
BG = "#09090B"
|
|
50
|
+
BG_INPUT = "#111114"
|
|
51
|
+
|
|
52
|
+
# ── Prompt toolkit style ──
|
|
53
|
+
TUI_STYLE = Style.from_dict({
|
|
54
|
+
# Conversation area
|
|
55
|
+
"conversation": f"bg:{BG} {GRAY}",
|
|
56
|
+
# Input area
|
|
57
|
+
"input-prompt": f"bold {WHITE}",
|
|
58
|
+
# Separators
|
|
59
|
+
"separator": MUTED,
|
|
60
|
+
# Status bar
|
|
61
|
+
"status": f"bg:{BG_INPUT} {DIM}",
|
|
62
|
+
"status.green": GREEN,
|
|
63
|
+
"status.red": RED,
|
|
64
|
+
"status.dim": DIM,
|
|
65
|
+
"status.muted": MUTED,
|
|
66
|
+
# Auto-suggest
|
|
67
|
+
"auto-suggest": DIM,
|
|
68
|
+
})
|
|
44
69
|
|
|
45
70
|
|
|
46
71
|
class ChatTUI:
|
|
@@ -55,7 +80,7 @@ class ChatTUI:
|
|
|
55
80
|
├──────────────────────────────────────┤
|
|
56
81
|
│ ❯ user input │
|
|
57
82
|
├──────────────────────────────────────┤
|
|
58
|
-
│ ● sonnet │ ○ broker │ /help
|
|
83
|
+
│ ● sonnet │ ○ broker │ /help │ 2m 3s │
|
|
59
84
|
└──────────────────────────────────────┘
|
|
60
85
|
"""
|
|
61
86
|
|
|
@@ -64,15 +89,17 @@ class ChatTUI:
|
|
|
64
89
|
model: str = "sonnet",
|
|
65
90
|
broker: str | None = None,
|
|
66
91
|
history_file: str = "",
|
|
67
|
-
on_submit: Callable[[str], None] | None = None,
|
|
68
92
|
) -> None:
|
|
69
93
|
self._model = model
|
|
70
94
|
self._broker = broker
|
|
71
|
-
self.
|
|
72
|
-
self._conversation_lines: list[tuple[str, str]] = [] # (style, text) pairs
|
|
95
|
+
self._conversation_lines: list[tuple[str, str]] = []
|
|
73
96
|
self._running = True
|
|
74
97
|
self._input_ready = asyncio.Event()
|
|
75
98
|
self._last_input = ""
|
|
99
|
+
self._credits_used: float = 0.0
|
|
100
|
+
self._tools_called: int = 0
|
|
101
|
+
self._start_time = time.time()
|
|
102
|
+
self._processing = False
|
|
76
103
|
|
|
77
104
|
# Conversation display
|
|
78
105
|
self._conversation_control = FormattedTextControl(
|
|
@@ -80,6 +107,12 @@ class ChatTUI:
|
|
|
80
107
|
focusable=False,
|
|
81
108
|
)
|
|
82
109
|
|
|
110
|
+
# Scrollable conversation window
|
|
111
|
+
self._conversation_window = Window(
|
|
112
|
+
content=self._conversation_control,
|
|
113
|
+
wrap_lines=True,
|
|
114
|
+
)
|
|
115
|
+
|
|
83
116
|
# Input buffer
|
|
84
117
|
self._input_buffer = Buffer(
|
|
85
118
|
name="input",
|
|
@@ -89,8 +122,11 @@ class ChatTUI:
|
|
|
89
122
|
multiline=False,
|
|
90
123
|
)
|
|
91
124
|
|
|
92
|
-
# Separator
|
|
93
|
-
|
|
125
|
+
# Separator
|
|
126
|
+
self._sep_control = FormattedTextControl(
|
|
127
|
+
text=lambda: FormattedText([(MUTED, "─" * 300)]),
|
|
128
|
+
focusable=False,
|
|
129
|
+
)
|
|
94
130
|
|
|
95
131
|
# Status bar
|
|
96
132
|
self._status_control = FormattedTextControl(
|
|
@@ -104,44 +140,49 @@ class ChatTUI:
|
|
|
104
140
|
@kb.add("c-d")
|
|
105
141
|
def _exit(event: Any) -> None:
|
|
106
142
|
self._running = False
|
|
143
|
+
self._input_ready.set()
|
|
107
144
|
event.app.exit(result=None)
|
|
108
145
|
|
|
109
146
|
@kb.add("c-c")
|
|
110
|
-
def
|
|
111
|
-
self.
|
|
112
|
-
|
|
147
|
+
def _interrupt(event: Any) -> None:
|
|
148
|
+
if self._processing:
|
|
149
|
+
# During processing: just signal (future: cancel stream)
|
|
150
|
+
pass
|
|
151
|
+
else:
|
|
152
|
+
self._running = False
|
|
153
|
+
self._input_ready.set()
|
|
154
|
+
event.app.exit(result=None)
|
|
155
|
+
|
|
156
|
+
@kb.add("c-l")
|
|
157
|
+
def _clear_screen(event: Any) -> None:
|
|
158
|
+
self._conversation_lines.clear()
|
|
159
|
+
self._invalidate()
|
|
160
|
+
|
|
161
|
+
@kb.add("escape")
|
|
162
|
+
def _cancel_input(event: Any) -> None:
|
|
163
|
+
self._input_buffer.text = ""
|
|
113
164
|
|
|
114
165
|
# Layout
|
|
115
166
|
self._layout = Layout(
|
|
116
167
|
HSplit([
|
|
117
|
-
# Scrollable conversation
|
|
168
|
+
# Scrollable conversation (takes all available space)
|
|
118
169
|
ScrollablePane(
|
|
119
|
-
|
|
120
|
-
content=self._conversation_control,
|
|
121
|
-
wrap_lines=True,
|
|
122
|
-
),
|
|
170
|
+
self._conversation_window,
|
|
123
171
|
),
|
|
124
172
|
# Top separator
|
|
125
|
-
Window(
|
|
126
|
-
|
|
127
|
-
height=1,
|
|
128
|
-
),
|
|
129
|
-
# Input line
|
|
173
|
+
Window(content=self._sep_control, height=1),
|
|
174
|
+
# Input line with ❯ prefix
|
|
130
175
|
Window(
|
|
131
176
|
content=BufferControl(buffer=self._input_buffer),
|
|
132
177
|
height=1,
|
|
133
|
-
get_line_prefix=lambda lineno, wrap:
|
|
178
|
+
get_line_prefix=lambda lineno, wrap: FormattedText(
|
|
179
|
+
[("bold " + WHITE, " ❯ ")]
|
|
180
|
+
),
|
|
134
181
|
),
|
|
135
182
|
# Bottom separator
|
|
136
|
-
Window(
|
|
137
|
-
content=FormattedTextControl(text=sep_text),
|
|
138
|
-
height=1,
|
|
139
|
-
),
|
|
183
|
+
Window(content=self._sep_control, height=1),
|
|
140
184
|
# Status bar
|
|
141
|
-
Window(
|
|
142
|
-
content=self._status_control,
|
|
143
|
-
height=1,
|
|
144
|
-
),
|
|
185
|
+
Window(content=self._status_control, height=1),
|
|
145
186
|
]),
|
|
146
187
|
focused_element=self._input_buffer,
|
|
147
188
|
)
|
|
@@ -151,94 +192,152 @@ class ChatTUI:
|
|
|
151
192
|
layout=self._layout,
|
|
152
193
|
key_bindings=kb,
|
|
153
194
|
full_screen=True,
|
|
154
|
-
mouse_support=
|
|
155
|
-
style=
|
|
195
|
+
mouse_support=True,
|
|
196
|
+
style=TUI_STYLE,
|
|
156
197
|
)
|
|
157
198
|
|
|
199
|
+
# ── Internal handlers ──
|
|
200
|
+
|
|
158
201
|
def _on_accept(self, buffer: Buffer) -> bool:
|
|
159
|
-
"""Handle Enter press
|
|
160
|
-
|
|
202
|
+
"""Handle Enter press."""
|
|
203
|
+
text = buffer.text.strip()
|
|
204
|
+
if text:
|
|
205
|
+
# Echo user input in conversation
|
|
206
|
+
self._conversation_lines.append((WHITE, f"\n ❯ {text}\n"))
|
|
207
|
+
self._last_input = text
|
|
161
208
|
self._input_ready.set()
|
|
162
|
-
return False
|
|
209
|
+
return False
|
|
163
210
|
|
|
164
211
|
def _get_conversation_text(self) -> FormattedText:
|
|
165
|
-
"""Render conversation
|
|
212
|
+
"""Render conversation area."""
|
|
166
213
|
if not self._conversation_lines:
|
|
167
|
-
return FormattedText([
|
|
214
|
+
return FormattedText([
|
|
215
|
+
(DIM, "\n"),
|
|
216
|
+
(DIM, " Type your message below.\n"),
|
|
217
|
+
(DIM, " /help for commands. Ctrl+D to exit.\n"),
|
|
218
|
+
])
|
|
168
219
|
return FormattedText(self._conversation_lines)
|
|
169
220
|
|
|
170
221
|
def _get_status_text(self) -> FormattedText:
|
|
171
|
-
"""Render status bar."""
|
|
222
|
+
"""Render status bar with live data."""
|
|
223
|
+
elapsed = int(time.time() - self._start_time)
|
|
224
|
+
mins, secs = divmod(elapsed, 60)
|
|
225
|
+
time_str = f"{mins}m {secs}s" if mins else f"{secs}s"
|
|
226
|
+
|
|
172
227
|
parts: list[tuple[str, str]] = [("", " ")]
|
|
228
|
+
|
|
229
|
+
# Model
|
|
173
230
|
parts.append((GREEN, "● "))
|
|
174
231
|
parts.append((DIM, self._model))
|
|
175
232
|
parts.append((MUTED, " │ "))
|
|
233
|
+
|
|
234
|
+
# Broker
|
|
176
235
|
if self._broker:
|
|
177
236
|
parts.append((GREEN, "● "))
|
|
178
237
|
parts.append((DIM, self._broker))
|
|
179
238
|
else:
|
|
180
239
|
parts.append((RED, "○ "))
|
|
181
|
-
parts.append((DIM, "broker
|
|
240
|
+
parts.append((DIM, "no broker"))
|
|
182
241
|
parts.append((MUTED, " │ "))
|
|
183
|
-
|
|
242
|
+
|
|
243
|
+
# Stats
|
|
244
|
+
if self._tools_called > 0:
|
|
245
|
+
parts.append((DIM, f"{self._tools_called} tools"))
|
|
246
|
+
parts.append((MUTED, " │ "))
|
|
247
|
+
|
|
248
|
+
if self._credits_used > 0:
|
|
249
|
+
parts.append((DIM, f"${self._credits_used:.3f}"))
|
|
250
|
+
parts.append((MUTED, " │ "))
|
|
251
|
+
|
|
252
|
+
# Timer
|
|
253
|
+
parts.append((DIM, time_str))
|
|
254
|
+
parts.append((MUTED, " │ "))
|
|
255
|
+
|
|
256
|
+
# Help hint
|
|
257
|
+
parts.append((DIM, "/help"))
|
|
258
|
+
|
|
184
259
|
return FormattedText(parts)
|
|
185
260
|
|
|
186
|
-
# ── Public API
|
|
261
|
+
# ── Public API ──
|
|
187
262
|
|
|
188
263
|
def add_line(self, style: str, text: str) -> None:
|
|
189
|
-
"""Add a line to
|
|
264
|
+
"""Add a line to conversation."""
|
|
190
265
|
self._conversation_lines.append((style, text + "\n"))
|
|
191
|
-
self.
|
|
266
|
+
self._scroll_to_bottom()
|
|
192
267
|
|
|
193
268
|
def add_text(self, style: str, text: str) -> None:
|
|
194
269
|
"""Add inline text (no newline) for streaming."""
|
|
195
270
|
self._conversation_lines.append((style, text))
|
|
196
|
-
self.
|
|
271
|
+
self._scroll_to_bottom()
|
|
197
272
|
|
|
198
273
|
def add_thinking(self, text: str) -> None:
|
|
199
|
-
"""Show thinking status."""
|
|
200
|
-
|
|
274
|
+
"""Show thinking status. Replaces previous thinking line."""
|
|
275
|
+
# Remove previous thinking line if exists
|
|
276
|
+
if (self._conversation_lines and
|
|
277
|
+
self._conversation_lines[-1][1].startswith(" ◐")):
|
|
278
|
+
self._conversation_lines.pop()
|
|
279
|
+
self._conversation_lines.append((DIM, f" ◐ {text}\n"))
|
|
280
|
+
self._scroll_to_bottom()
|
|
281
|
+
|
|
282
|
+
def clear_thinking(self) -> None:
|
|
283
|
+
"""Remove the current thinking line."""
|
|
284
|
+
if (self._conversation_lines and
|
|
285
|
+
self._conversation_lines[-1][1].strip().startswith("◐")):
|
|
286
|
+
self._conversation_lines.pop()
|
|
287
|
+
self._invalidate()
|
|
201
288
|
|
|
202
289
|
def add_tool_running(self, name: str) -> None:
|
|
203
290
|
"""Show tool starting."""
|
|
291
|
+
self.clear_thinking()
|
|
204
292
|
self.add_line(DIM, f" ◐ {name}...")
|
|
205
293
|
|
|
206
294
|
def add_tool_done(self, name: str, cost: str = "") -> None:
|
|
207
295
|
"""Show tool completed."""
|
|
296
|
+
# Replace running line
|
|
297
|
+
if (self._conversation_lines and
|
|
298
|
+
"◐" in self._conversation_lines[-1][1]):
|
|
299
|
+
self._conversation_lines.pop()
|
|
208
300
|
suffix = f" {cost}" if cost else ""
|
|
209
301
|
self.add_line(GREEN, f" ✓ {name}{suffix}")
|
|
302
|
+
self._tools_called += 1
|
|
210
303
|
|
|
211
304
|
def add_tool_error(self, name: str, error: str = "") -> None:
|
|
212
305
|
"""Show tool error."""
|
|
306
|
+
if (self._conversation_lines and
|
|
307
|
+
"◐" in self._conversation_lines[-1][1]):
|
|
308
|
+
self._conversation_lines.pop()
|
|
213
309
|
suffix = f" {error}" if error else ""
|
|
214
310
|
self.add_line(RED, f" ✗ {name}{suffix}")
|
|
215
311
|
|
|
216
312
|
def add_error(self, msg: str) -> None:
|
|
217
313
|
"""Show error message."""
|
|
314
|
+
self.clear_thinking()
|
|
218
315
|
self.add_line(RED, f" ✗ {msg}")
|
|
219
316
|
|
|
220
317
|
def start_response(self) -> None:
|
|
221
318
|
"""Start a new response block with green border."""
|
|
319
|
+
self.clear_thinking()
|
|
222
320
|
self.add_text(GREEN, "\n ┃ ")
|
|
223
321
|
|
|
224
322
|
def stream_text(self, text: str) -> None:
|
|
225
|
-
"""Stream text within a response block."""
|
|
323
|
+
"""Stream text within a response block, char by char."""
|
|
226
324
|
for ch in text:
|
|
227
325
|
if ch == "\n":
|
|
228
|
-
self.
|
|
326
|
+
self._conversation_lines.append((GREEN, "\n ┃ "))
|
|
229
327
|
else:
|
|
230
|
-
self.
|
|
231
|
-
self.
|
|
328
|
+
self._conversation_lines.append(("", ch))
|
|
329
|
+
self._scroll_to_bottom()
|
|
232
330
|
|
|
233
331
|
def end_response(self, elapsed: float) -> None:
|
|
234
332
|
"""End response block with timing."""
|
|
235
333
|
self.add_text(GREEN, "\n ┃")
|
|
236
|
-
self.add_line(DIM, f"\n ✦ {elapsed:.1f}s
|
|
334
|
+
self.add_line(DIM, f"\n ✦ {elapsed:.1f}s")
|
|
335
|
+
self.add_text("", "\n")
|
|
237
336
|
|
|
238
|
-
def
|
|
239
|
-
"""
|
|
240
|
-
self.
|
|
241
|
-
self.
|
|
337
|
+
def add_credits(self, amount: float) -> None:
|
|
338
|
+
"""Track credits used (updates status bar)."""
|
|
339
|
+
self._credits_used += amount
|
|
340
|
+
self._invalidate()
|
|
242
341
|
|
|
243
342
|
def set_model(self, model: str) -> None:
|
|
244
343
|
"""Update model in status bar."""
|
|
@@ -250,11 +349,20 @@ class ChatTUI:
|
|
|
250
349
|
self._broker = broker
|
|
251
350
|
self._invalidate()
|
|
252
351
|
|
|
352
|
+
def set_processing(self, active: bool) -> None:
|
|
353
|
+
"""Set processing state (affects Ctrl+C behavior)."""
|
|
354
|
+
self._processing = active
|
|
355
|
+
self._invalidate()
|
|
356
|
+
|
|
253
357
|
def clear_conversation(self) -> None:
|
|
254
358
|
"""Clear conversation area."""
|
|
255
359
|
self._conversation_lines.clear()
|
|
256
360
|
self._invalidate()
|
|
257
361
|
|
|
362
|
+
def _scroll_to_bottom(self) -> None:
|
|
363
|
+
"""Scroll conversation to bottom and redraw."""
|
|
364
|
+
self._invalidate()
|
|
365
|
+
|
|
258
366
|
def _invalidate(self) -> None:
|
|
259
367
|
"""Request UI redraw."""
|
|
260
368
|
if self._app and self._app.is_running:
|
|
@@ -264,12 +372,14 @@ class ChatTUI:
|
|
|
264
372
|
"""Wait for user to submit input. Returns None on exit."""
|
|
265
373
|
self._input_ready.clear()
|
|
266
374
|
self._input_buffer.text = ""
|
|
375
|
+
self._processing = False
|
|
267
376
|
self._invalidate()
|
|
268
377
|
|
|
269
378
|
await self._input_ready.wait()
|
|
270
379
|
|
|
271
380
|
if not self._running:
|
|
272
381
|
return None
|
|
382
|
+
self._processing = True
|
|
273
383
|
return self._last_input
|
|
274
384
|
|
|
275
385
|
async def run_async(self) -> None:
|
|
@@ -279,5 +389,6 @@ class ChatTUI:
|
|
|
279
389
|
def exit(self) -> None:
|
|
280
390
|
"""Exit the application."""
|
|
281
391
|
self._running = False
|
|
392
|
+
self._input_ready.set()
|
|
282
393
|
if self._app.is_running:
|
|
283
394
|
self._app.exit()
|
|
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
|
|
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
|
|
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
|
|
File without changes
|