systemr-cli 1.1.4__tar.gz → 2.0.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-1.1.4 → systemr_cli-2.0.0}/PKG-INFO +1 -1
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/neo/__init__.py +1 -1
- systemr_cli-2.0.0/neo/chat_runner.py +293 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/neo/commands/chat_commands.py +68 -161
- systemr_cli-2.0.0/neo/tui.py +283 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/pyproject.toml +1 -1
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/.gitignore +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/DESIGN.md +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/README.md +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/clawhub/systemr-trading/SKILL.md +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/clawhub/systemr-trading/scripts/mcp_stdio_proxy.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/neo/__main__.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/neo/auth.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/neo/cli.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/neo/client.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/neo/commands/__init__.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/neo/commands/auth_commands.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/neo/commands/cron_commands.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/neo/commands/doctor_command.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/neo/commands/eval_commands.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/neo/commands/journal_commands.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/neo/commands/plan_commands.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/neo/commands/risk_commands.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/neo/commands/scan_commands.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/neo/commands/size_commands.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/neo/config.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/neo/confirmation.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/neo/credits.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/neo/cron.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/neo/display/__init__.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/neo/display/chat_renderer.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/neo/display/formatters.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/neo/display/tables.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/neo/display/theme.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/neo/hooks.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/neo/logging.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/neo/model_failover.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/neo/orchestrator.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/neo/profile.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/neo/store.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/neo/streaming.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/neo/tool_router.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/neo/types.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/tests/__init__.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/tests/test_auth.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/tests/test_chat_helpers.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/tests/test_chat_renderer.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/tests/test_cli.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/tests/test_config.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/tests/test_confirmation.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/tests/test_credits.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/tests/test_cron.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/tests/test_doctor.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/tests/test_formatters.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/tests/test_hooks.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/tests/test_logging.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/tests/test_model_failover.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/tests/test_orchestrator.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/tests/test_profile.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/tests/test_store.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/tests/test_streaming.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/tests/test_tool_router.py +0 -0
- {systemr_cli-1.1.4 → systemr_cli-2.0.0}/tests/test_types.py +0 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Chat runner — TUI-based chat loop with fixed layout.
|
|
2
|
+
|
|
3
|
+
Manages the full-screen TUI application while processing
|
|
4
|
+
chat events (streaming, tools, confirmations) in the background.
|
|
5
|
+
The TUI stays responsive during all operations.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import structlog
|
|
15
|
+
|
|
16
|
+
from neo.credits import SessionCredits
|
|
17
|
+
from neo.hooks import HookEvent, fire_hook
|
|
18
|
+
from neo.profile import (
|
|
19
|
+
append_daily_log,
|
|
20
|
+
auto_save_rule_violation,
|
|
21
|
+
load_daily_context,
|
|
22
|
+
load_profile,
|
|
23
|
+
load_rules,
|
|
24
|
+
load_standing_orders,
|
|
25
|
+
profile_exists,
|
|
26
|
+
search_memory,
|
|
27
|
+
)
|
|
28
|
+
from neo.streaming import (
|
|
29
|
+
ChatRequest,
|
|
30
|
+
call_tool,
|
|
31
|
+
chat_blocking,
|
|
32
|
+
send_confirmation,
|
|
33
|
+
stream_chat,
|
|
34
|
+
)
|
|
35
|
+
from neo.tool_router import match_tool
|
|
36
|
+
from neo.tui import ChatTUI
|
|
37
|
+
|
|
38
|
+
logger = structlog.get_logger(module="chat_runner")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def run_chat_tui(
|
|
42
|
+
tui: ChatTUI,
|
|
43
|
+
access_token: str,
|
|
44
|
+
credits: SessionCredits,
|
|
45
|
+
store: Any,
|
|
46
|
+
db_session_id: int,
|
|
47
|
+
messages: list[dict[str, str]],
|
|
48
|
+
model: str | None = None,
|
|
49
|
+
research: bool = False,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Run the chat loop inside the TUI.
|
|
52
|
+
|
|
53
|
+
Alternates between waiting for user input and processing
|
|
54
|
+
the response (streaming, tool calls, etc.). The TUI handles
|
|
55
|
+
all display.
|
|
56
|
+
"""
|
|
57
|
+
from neo.commands.chat_commands import (
|
|
58
|
+
COMPACTION_THRESHOLD,
|
|
59
|
+
_compact_history,
|
|
60
|
+
_handle_slash_command,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
current_model = model
|
|
64
|
+
|
|
65
|
+
while True:
|
|
66
|
+
user_input = await tui.wait_for_input()
|
|
67
|
+
if user_input is None:
|
|
68
|
+
break
|
|
69
|
+
if not user_input:
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
# Slash commands
|
|
73
|
+
if user_input.startswith("/"):
|
|
74
|
+
if user_input.strip() in ("/q", "/exit", "/quit"):
|
|
75
|
+
break
|
|
76
|
+
if user_input.strip() == "/clear":
|
|
77
|
+
messages.clear()
|
|
78
|
+
tui.clear_conversation()
|
|
79
|
+
tui.add_line("#3ECF8E", " ✓ Conversation cleared.")
|
|
80
|
+
continue
|
|
81
|
+
if user_input.strip() == "/help":
|
|
82
|
+
_show_help(tui)
|
|
83
|
+
continue
|
|
84
|
+
if user_input.strip() == "/credits":
|
|
85
|
+
s = credits.summary()
|
|
86
|
+
tui.add_line("#A1A1AA", f"\n Credits: ${s['credits_used']:.3f} used │ {s['tools_called']} tools │ {s['duration_min']}m")
|
|
87
|
+
continue
|
|
88
|
+
if user_input.startswith("/remember "):
|
|
89
|
+
text = user_input[10:].strip()
|
|
90
|
+
if text:
|
|
91
|
+
from neo.profile import auto_save_explicit
|
|
92
|
+
auto_save_explicit(text)
|
|
93
|
+
tui.add_line("#3ECF8E", f" ✓ Saved to memory.")
|
|
94
|
+
continue
|
|
95
|
+
if user_input.startswith("/memory "):
|
|
96
|
+
query = user_input[8:].strip()
|
|
97
|
+
if query:
|
|
98
|
+
results = search_memory(query)
|
|
99
|
+
if results:
|
|
100
|
+
for r in results[:5]:
|
|
101
|
+
tui.add_line("#A1A1AA", f" {r}")
|
|
102
|
+
else:
|
|
103
|
+
tui.add_line("#52525B", " No memories found.")
|
|
104
|
+
continue
|
|
105
|
+
# Other slash commands: pass to backend as regular chat
|
|
106
|
+
tui.add_line("#52525B", f" Unknown command. /help for available commands.")
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
store.add_chat_message(db_session_id, "user", user_input)
|
|
110
|
+
messages.append({"role": "user", "content": user_input})
|
|
111
|
+
|
|
112
|
+
# Tool pre-routing
|
|
113
|
+
tool_match_result = match_tool(user_input)
|
|
114
|
+
if tool_match_result:
|
|
115
|
+
tui.add_tool_running(tool_match_result.tool_name)
|
|
116
|
+
try:
|
|
117
|
+
tool_result = await call_tool(
|
|
118
|
+
tool_match_result.tool_name, tool_match_result.arguments
|
|
119
|
+
)
|
|
120
|
+
result_data = tool_result.get("result", {})
|
|
121
|
+
cost = tool_result.get("cost_usdc") or (
|
|
122
|
+
result_data.get("cost_usdc", "") if isinstance(result_data, dict) else ""
|
|
123
|
+
)
|
|
124
|
+
tui.add_tool_done(
|
|
125
|
+
tool_match_result.tool_name,
|
|
126
|
+
f"${cost}" if cost else "",
|
|
127
|
+
)
|
|
128
|
+
if isinstance(result_data, dict):
|
|
129
|
+
tui.add_text("", "\n")
|
|
130
|
+
for k, v in result_data.items():
|
|
131
|
+
if k == "cost_usdc":
|
|
132
|
+
continue
|
|
133
|
+
if v is not None and str(v).strip():
|
|
134
|
+
tui.add_line("#A1A1AA", f" {k}: ")
|
|
135
|
+
tui.add_text("#F5F5F5", str(v))
|
|
136
|
+
tui.add_text("", "\n")
|
|
137
|
+
credits.add_response(credits_used=cost, tools=1)
|
|
138
|
+
reply = f"Tool {tool_match_result.tool_name}: {str(result_data)[:200]}"
|
|
139
|
+
store.add_chat_message(db_session_id, "assistant", reply)
|
|
140
|
+
messages.append({"role": "assistant", "content": reply})
|
|
141
|
+
except Exception as e:
|
|
142
|
+
err = str(e)
|
|
143
|
+
if "402" in err:
|
|
144
|
+
tui.add_tool_error(tool_match_result.tool_name, "insufficient credits")
|
|
145
|
+
elif "401" in err or "403" in err:
|
|
146
|
+
tui.add_tool_error(tool_match_result.tool_name, "auth required")
|
|
147
|
+
else:
|
|
148
|
+
tui.add_tool_error(tool_match_result.tool_name, err[:60])
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
# Compact history
|
|
152
|
+
if len(messages) > COMPACTION_THRESHOLD:
|
|
153
|
+
messages = _compact_history(messages)
|
|
154
|
+
|
|
155
|
+
# Build chat request
|
|
156
|
+
rules_text = load_rules() or ""
|
|
157
|
+
standing = load_standing_orders()
|
|
158
|
+
if standing and standing not in rules_text:
|
|
159
|
+
rules_text = rules_text + "\n\n" + standing if rules_text else standing
|
|
160
|
+
|
|
161
|
+
request = ChatRequest(
|
|
162
|
+
user_input=user_input,
|
|
163
|
+
session_id=str(db_session_id),
|
|
164
|
+
model=current_model,
|
|
165
|
+
history=messages,
|
|
166
|
+
profile=load_profile() or None,
|
|
167
|
+
rules=rules_text or None,
|
|
168
|
+
daily_context=load_daily_context() or None,
|
|
169
|
+
research_mode=research,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Stream response
|
|
173
|
+
reply = await _stream_to_tui(tui, request, access_token, credits)
|
|
174
|
+
|
|
175
|
+
if reply:
|
|
176
|
+
store.add_chat_message(db_session_id, "assistant", reply)
|
|
177
|
+
messages.append({"role": "assistant", "content": reply})
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def _stream_to_tui(
|
|
181
|
+
tui: ChatTUI,
|
|
182
|
+
request: ChatRequest,
|
|
183
|
+
access_token: str,
|
|
184
|
+
credits: SessionCredits,
|
|
185
|
+
) -> str:
|
|
186
|
+
"""Stream chat response into the TUI."""
|
|
187
|
+
collected_text = ""
|
|
188
|
+
tool_count = 0
|
|
189
|
+
has_trade = False
|
|
190
|
+
streaming_started = False
|
|
191
|
+
start_time = time.time()
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
async for event in stream_chat(request, access_token):
|
|
195
|
+
if event.event == "thinking":
|
|
196
|
+
text = event.parsed.get("text", event.parsed.get("status", ""))
|
|
197
|
+
if text:
|
|
198
|
+
tui.add_thinking(text[:70])
|
|
199
|
+
|
|
200
|
+
elif event.event == "action":
|
|
201
|
+
tool = event.parsed.get("action", event.parsed.get("tool", ""))
|
|
202
|
+
params = event.parsed.get("parameters", {})
|
|
203
|
+
tool_count += 1
|
|
204
|
+
if tool:
|
|
205
|
+
fire_hook(HookEvent.BEFORE_TOOL_CALL, context={"tool": tool, "args": params})
|
|
206
|
+
tui.add_tool_running(tool)
|
|
207
|
+
if params:
|
|
208
|
+
try:
|
|
209
|
+
result = await call_tool(tool, params)
|
|
210
|
+
data = result.get("result", {})
|
|
211
|
+
cost = result.get("cost_usdc") or (data.get("cost_usdc", "") if isinstance(data, dict) else "")
|
|
212
|
+
tui.add_tool_done(tool, f"${cost}" if cost else "")
|
|
213
|
+
if isinstance(data, dict):
|
|
214
|
+
tui.add_text("", "\n")
|
|
215
|
+
for k, v in data.items():
|
|
216
|
+
if k == "cost_usdc":
|
|
217
|
+
continue
|
|
218
|
+
if v is not None and str(v).strip():
|
|
219
|
+
tui.add_text("#A1A1AA", f" {k}: ")
|
|
220
|
+
tui.add_text("#F5F5F5", f"{v}\n")
|
|
221
|
+
except Exception as e:
|
|
222
|
+
tui.add_tool_error(tool, str(e)[:60])
|
|
223
|
+
|
|
224
|
+
elif event.event == "text_delta":
|
|
225
|
+
text = event.parsed.get("text", event.parsed.get("delta", ""))
|
|
226
|
+
if text and text != "null":
|
|
227
|
+
if not streaming_started:
|
|
228
|
+
tui.start_response()
|
|
229
|
+
streaming_started = True
|
|
230
|
+
tui.stream_text(text)
|
|
231
|
+
collected_text += text
|
|
232
|
+
|
|
233
|
+
elif event.event == "done":
|
|
234
|
+
if streaming_started:
|
|
235
|
+
elapsed = time.time() - start_time
|
|
236
|
+
tui.end_response(elapsed)
|
|
237
|
+
elif not collected_text:
|
|
238
|
+
full = event.parsed.get("response", event.parsed.get("content", ""))
|
|
239
|
+
if full:
|
|
240
|
+
collected_text = full
|
|
241
|
+
tui.start_response()
|
|
242
|
+
tui.stream_text(full)
|
|
243
|
+
tui.end_response(time.time() - start_time)
|
|
244
|
+
|
|
245
|
+
credits_used = event.parsed.get("credits_used")
|
|
246
|
+
balance = event.parsed.get("balance")
|
|
247
|
+
credits.add_response(
|
|
248
|
+
credits_used=credits_used, balance=balance,
|
|
249
|
+
tools=tool_count, trade=has_trade,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
elif event.event == "error":
|
|
253
|
+
raw = event.parsed.get("message", event.parsed.get("error", ""))
|
|
254
|
+
msg = str(raw)
|
|
255
|
+
if "Classification backend unavailable" in msg:
|
|
256
|
+
msg = "AI model temporarily unavailable. Try again."
|
|
257
|
+
elif "AccessDeniedException" in msg:
|
|
258
|
+
msg = "AI model access error."
|
|
259
|
+
elif len(msg) > 120:
|
|
260
|
+
msg = msg[:120] + "..."
|
|
261
|
+
tui.add_error(msg)
|
|
262
|
+
|
|
263
|
+
except Exception as exc:
|
|
264
|
+
tui.add_error(f"Connection error: {str(exc)[:60]}")
|
|
265
|
+
logger.error("stream_error", error=str(exc))
|
|
266
|
+
|
|
267
|
+
return collected_text
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _show_help(tui: ChatTUI) -> None:
|
|
271
|
+
"""Show help in the TUI."""
|
|
272
|
+
tui.add_text("", "\n")
|
|
273
|
+
tui.add_line("#3ECF8E", " Chat")
|
|
274
|
+
tui.add_line("#52525B", " /morning Morning briefing")
|
|
275
|
+
tui.add_line("#52525B", " /eod End of day review")
|
|
276
|
+
tui.add_line("#52525B", " /plan Plan today's trades")
|
|
277
|
+
tui.add_line("#52525B", " /portfolio Open positions")
|
|
278
|
+
tui.add_line("#52525B", " /risk Risk dashboard")
|
|
279
|
+
tui.add_text("", "\n")
|
|
280
|
+
tui.add_line("#3ECF8E", " Memory")
|
|
281
|
+
tui.add_line("#52525B", " /remember Save a memory")
|
|
282
|
+
tui.add_line("#52525B", " /memory Search memories")
|
|
283
|
+
tui.add_line("#52525B", " /sessions Recent sessions")
|
|
284
|
+
tui.add_text("", "\n")
|
|
285
|
+
tui.add_line("#3ECF8E", " Settings")
|
|
286
|
+
tui.add_line("#52525B", " /model Show or switch model")
|
|
287
|
+
tui.add_line("#52525B", " /permissions Safety profile")
|
|
288
|
+
tui.add_line("#52525B", " /credits Credit usage")
|
|
289
|
+
tui.add_text("", "\n")
|
|
290
|
+
tui.add_line("#3ECF8E", " Session")
|
|
291
|
+
tui.add_line("#52525B", " /clear Clear conversation")
|
|
292
|
+
tui.add_line("#52525B", " /q Exit")
|
|
293
|
+
tui.add_text("", "\n")
|
|
@@ -204,7 +204,8 @@ def chat(ctx: click.Context, model_name: str | None, research: bool, resume: boo
|
|
|
204
204
|
db_session_id = store.create_chat_session()
|
|
205
205
|
|
|
206
206
|
from neo.config import get_api_key
|
|
207
|
-
from neo.
|
|
207
|
+
from neo.tui import ChatTUI
|
|
208
|
+
from neo.chat_runner import run_chat_tui
|
|
208
209
|
|
|
209
210
|
# Only print banner if not already shown by _show_home_and_chat
|
|
210
211
|
banner_shown = (ctx.obj or {}).get("banner_shown", False)
|
|
@@ -212,180 +213,58 @@ def chat(ctx: click.Context, model_name: str | None, research: bool, resume: boo
|
|
|
212
213
|
print_banner()
|
|
213
214
|
console.print(f" [{GREEN}]● connected[/]")
|
|
214
215
|
|
|
215
|
-
# Session state
|
|
216
|
-
failover = load_failover_chain()
|
|
217
|
-
if model_name:
|
|
218
|
-
failover.pinned_model = model_name
|
|
219
|
-
current_model = model_name
|
|
220
216
|
credits = SessionCredits()
|
|
221
|
-
history_file = SYSTEMR_HOME / "chat_history"
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
prompt_session: PromptSession[str] = PromptSession(
|
|
225
|
-
history=FileHistory(str(history_file)),
|
|
226
|
-
auto_suggest=AutoSuggestFromHistory(),
|
|
227
|
-
)
|
|
228
|
-
|
|
229
|
-
# Broker state (will be dynamic when broker connection is implemented)
|
|
230
|
-
connected_broker: str | None = None
|
|
231
|
-
display_model = current_model or "sonnet"
|
|
217
|
+
history_file = str(SYSTEMR_HOME / "chat_history")
|
|
218
|
+
display_model = model_name or "sonnet"
|
|
232
219
|
|
|
233
220
|
# Fire session start hook
|
|
234
221
|
fire_hook(HookEvent.PRE_SESSION, context={"model": model_name})
|
|
235
222
|
logger.info("session_started", model=model_name, resume=resume)
|
|
236
223
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
console.print()
|
|
244
|
-
console.print(f" [{DIM}]Session closed.[/]")
|
|
245
|
-
break
|
|
246
|
-
|
|
247
|
-
# Bottom separator + status bar
|
|
248
|
-
print_separator()
|
|
249
|
-
print_prompt_bar(model=display_model, broker=connected_broker)
|
|
250
|
-
|
|
251
|
-
if not user_input:
|
|
252
|
-
continue
|
|
253
|
-
|
|
254
|
-
# Handle slash commands
|
|
255
|
-
if user_input.startswith("/"):
|
|
256
|
-
result = _handle_slash_command(
|
|
257
|
-
user_input, messages, current_model,
|
|
258
|
-
access_token=token.access_token, credits=credits,
|
|
259
|
-
)
|
|
260
|
-
if result == "exit":
|
|
261
|
-
console.print(f"\n [{GREEN_DIM}]Session closed.[/]")
|
|
262
|
-
break
|
|
263
|
-
if result == "clear":
|
|
264
|
-
messages.clear()
|
|
265
|
-
print_success("Conversation cleared.")
|
|
266
|
-
continue
|
|
267
|
-
if result is not None:
|
|
268
|
-
if result.startswith("model:"):
|
|
269
|
-
current_model = result.split(":", 1)[1]
|
|
270
|
-
failover.pinned_model = current_model
|
|
271
|
-
print_success(f"Model: {current_model}")
|
|
272
|
-
continue
|
|
273
|
-
|
|
274
|
-
store.add_chat_message(db_session_id, "user", user_input)
|
|
275
|
-
messages.append({"role": "user", "content": user_input})
|
|
276
|
-
|
|
277
|
-
# Tool pre-routing: detect tool-invocable requests and execute directly
|
|
278
|
-
tool_match = match_tool(user_input)
|
|
279
|
-
if tool_match:
|
|
280
|
-
render_action(tool_match.tool_name, status="running", result="")
|
|
281
|
-
try:
|
|
282
|
-
tool_result = loop.run_until_complete(
|
|
283
|
-
call_tool(tool_match.tool_name, tool_match.arguments)
|
|
284
|
-
)
|
|
285
|
-
result_data = tool_result.get("result", {})
|
|
286
|
-
cost = tool_result.get("cost_usdc") or (
|
|
287
|
-
result_data.get("cost_usdc", "") if isinstance(result_data, dict) else ""
|
|
288
|
-
)
|
|
289
|
-
render_action(
|
|
290
|
-
tool_match.tool_name, status="done",
|
|
291
|
-
result=f"${cost}" if cost else "done",
|
|
292
|
-
)
|
|
224
|
+
# Create TUI and run
|
|
225
|
+
tui = ChatTUI(
|
|
226
|
+
model=display_model,
|
|
227
|
+
broker=None,
|
|
228
|
+
history_file=history_file,
|
|
229
|
+
)
|
|
293
230
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
credits.add_response(credits_used=cost, tools=1)
|
|
309
|
-
reply = f"Tool {tool_match.tool_name} executed. Result: {json.dumps(result_data)[:200]}"
|
|
310
|
-
store.add_chat_message(db_session_id, "assistant", reply)
|
|
311
|
-
messages.append({"role": "assistant", "content": reply})
|
|
312
|
-
|
|
313
|
-
except Exception as tool_err:
|
|
314
|
-
err_msg = str(tool_err)
|
|
315
|
-
if "402" in err_msg:
|
|
316
|
-
render_error("Insufficient credits. Run `systemr pay` to add credits.")
|
|
317
|
-
elif "401" in err_msg or "403" in err_msg:
|
|
318
|
-
render_error("Auth required. Run `systemr configure --api-key`.")
|
|
319
|
-
else:
|
|
320
|
-
render_error(f"Tool failed: {err_msg[:80]}")
|
|
321
|
-
reply = ""
|
|
322
|
-
|
|
323
|
-
continue # Skip chat stream — tool handled it
|
|
324
|
-
|
|
325
|
-
# Compact if over threshold
|
|
326
|
-
if len(messages) > COMPACTION_THRESHOLD:
|
|
327
|
-
messages = _compact_history(messages)
|
|
328
|
-
logger.info("context_compacted", messages=len(messages))
|
|
329
|
-
|
|
330
|
-
# Build request with profile + rules + standing orders + daily context
|
|
331
|
-
# Only send model if user explicitly chose one (via --model or /model)
|
|
332
|
-
# Backend handles default model selection via REASONING_MODEL env var
|
|
333
|
-
effective_model = current_model # None = let backend decide
|
|
334
|
-
rules_text = load_rules() or ""
|
|
335
|
-
standing = load_standing_orders()
|
|
336
|
-
if standing and standing not in rules_text:
|
|
337
|
-
rules_text = rules_text + "\n\n" + standing if rules_text else standing
|
|
338
|
-
request = ChatRequest(
|
|
339
|
-
user_input=user_input,
|
|
340
|
-
session_id=str(db_session_id),
|
|
341
|
-
model=effective_model,
|
|
342
|
-
history=messages,
|
|
343
|
-
profile=load_profile() or None,
|
|
344
|
-
rules=rules_text or None,
|
|
345
|
-
daily_context=load_daily_context() or None,
|
|
346
|
-
research_mode=research,
|
|
231
|
+
async def _run() -> None:
|
|
232
|
+
# Start TUI in background
|
|
233
|
+
tui_task = asyncio.create_task(tui.run_async())
|
|
234
|
+
# Run chat loop
|
|
235
|
+
try:
|
|
236
|
+
await run_chat_tui(
|
|
237
|
+
tui=tui,
|
|
238
|
+
access_token=token.access_token,
|
|
239
|
+
credits=credits,
|
|
240
|
+
store=store,
|
|
241
|
+
db_session_id=db_session_id,
|
|
242
|
+
messages=messages,
|
|
243
|
+
model=model_name,
|
|
244
|
+
research=research,
|
|
347
245
|
)
|
|
348
|
-
|
|
246
|
+
finally:
|
|
247
|
+
tui.exit()
|
|
349
248
|
try:
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
except Exception as exc:
|
|
354
|
-
# On failure, retry once with no model override (let backend decide)
|
|
355
|
-
if effective_model:
|
|
356
|
-
logger.info("model_failover_retry", from_model=effective_model)
|
|
357
|
-
request.model = None
|
|
358
|
-
try:
|
|
359
|
-
reply = loop.run_until_complete(
|
|
360
|
-
_do_chat(request, token.access_token, credits)
|
|
361
|
-
)
|
|
362
|
-
except Exception as exc2:
|
|
363
|
-
render_error("Something went wrong. Try again or run `systemr doctor`.")
|
|
364
|
-
logger.error("chat_failover_error", error=str(exc2))
|
|
365
|
-
fire_hook(HookEvent.ERROR, context={"message": str(exc2)})
|
|
366
|
-
reply = ""
|
|
367
|
-
else:
|
|
368
|
-
render_error("Something went wrong. Try again or run `systemr doctor`.")
|
|
369
|
-
logger.error("chat_error", error=str(exc))
|
|
370
|
-
fire_hook(HookEvent.ERROR, context={"message": str(exc)})
|
|
371
|
-
reply = ""
|
|
372
|
-
|
|
373
|
-
if reply:
|
|
374
|
-
store.add_chat_message(db_session_id, "assistant", reply)
|
|
375
|
-
messages.append({"role": "assistant", "content": reply})
|
|
249
|
+
await tui_task
|
|
250
|
+
except Exception:
|
|
251
|
+
pass
|
|
376
252
|
|
|
253
|
+
try:
|
|
254
|
+
asyncio.run(_run())
|
|
255
|
+
except (KeyboardInterrupt, EOFError):
|
|
256
|
+
pass
|
|
377
257
|
finally:
|
|
378
|
-
loop.close()
|
|
379
258
|
_save_session(db_session_id, messages)
|
|
380
259
|
fire_hook(HookEvent.POST_SESSION, context=credits.summary())
|
|
381
260
|
logger.info("session_ended", **credits.summary())
|
|
382
261
|
|
|
383
|
-
# Show session summary
|
|
262
|
+
# Show session summary
|
|
384
263
|
if credits.messages_sent > 0:
|
|
385
264
|
console.print()
|
|
386
265
|
console.print(f" [{GRAY}]Session summary[/]")
|
|
387
266
|
s = credits.summary()
|
|
388
|
-
console.print(f" [{WHITE}]{s['credits_used']:.1f}[/] [{DIM}]credits[/] "
|
|
267
|
+
console.print(f" [{WHITE}]{s['credits_used']:.1f}[/] [{DIM}]credits[/] "
|
|
389
268
|
f"[{WHITE}]{s['tools_called']}[/] [{DIM}]tools[/] "
|
|
390
269
|
f"[{WHITE}]{s['duration_min']}m[/] [{DIM}]duration[/]")
|
|
391
270
|
console.print()
|
|
@@ -409,22 +288,33 @@ async def _do_chat(
|
|
|
409
288
|
Returns:
|
|
410
289
|
The collected assistant response text.
|
|
411
290
|
"""
|
|
291
|
+
import time as _time
|
|
292
|
+
import sys
|
|
293
|
+
|
|
412
294
|
collected_text = ""
|
|
413
295
|
tool_count = 0
|
|
414
296
|
has_trade = False
|
|
297
|
+
streaming_started = False
|
|
298
|
+
start_time = _time.time()
|
|
415
299
|
|
|
416
300
|
try:
|
|
417
301
|
async for event in stream_chat(request, access_token):
|
|
418
302
|
if event.event == "thinking":
|
|
419
|
-
text = event.parsed.get("text", event.parsed.get("content", ""))
|
|
303
|
+
text = event.parsed.get("text", event.parsed.get("status", event.parsed.get("content", "")))
|
|
420
304
|
if text:
|
|
421
|
-
|
|
305
|
+
# Clear line and show thinking status
|
|
306
|
+
sys.stdout.write(f"\r \033[2m◐ {text[:70]}\033[0m\033[K")
|
|
307
|
+
sys.stdout.flush()
|
|
422
308
|
|
|
423
309
|
elif event.event == "action":
|
|
424
310
|
tool = event.parsed.get("action", event.parsed.get("tool", event.parsed.get("name", "")))
|
|
425
311
|
params = event.parsed.get("parameters", event.parsed.get("args", {}))
|
|
426
312
|
tool_count += 1
|
|
427
313
|
|
|
314
|
+
# Clear thinking line before showing tool
|
|
315
|
+
sys.stdout.write("\r\033[K")
|
|
316
|
+
sys.stdout.flush()
|
|
317
|
+
|
|
428
318
|
# Fire BEFORE_TOOL_CALL hook — can inspect/log tool calls
|
|
429
319
|
if tool:
|
|
430
320
|
fire_hook(HookEvent.BEFORE_TOOL_CALL, context={
|
|
@@ -495,12 +385,29 @@ async def _do_chat(
|
|
|
495
385
|
elif event.event == "text_delta":
|
|
496
386
|
text = event.parsed.get("text", event.parsed.get("delta", event.data))
|
|
497
387
|
if text and text != "null":
|
|
388
|
+
# Start green border on first chunk
|
|
389
|
+
if not streaming_started:
|
|
390
|
+
sys.stdout.write("\r\033[K") # Clear thinking line
|
|
391
|
+
sys.stdout.write(f"\n \033[38;2;62;207;142m┃\033[0m ")
|
|
392
|
+
streaming_started = True
|
|
393
|
+
# Stream text in real-time, handle newlines with border
|
|
394
|
+
for ch in text:
|
|
395
|
+
if ch == "\n":
|
|
396
|
+
sys.stdout.write(f"\n \033[38;2;62;207;142m┃\033[0m ")
|
|
397
|
+
else:
|
|
398
|
+
sys.stdout.write(ch)
|
|
399
|
+
sys.stdout.flush()
|
|
498
400
|
collected_text += text
|
|
499
401
|
|
|
500
402
|
elif event.event == "done":
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
403
|
+
# Close the streaming block
|
|
404
|
+
if streaming_started:
|
|
405
|
+
elapsed = _time.time() - start_time
|
|
406
|
+
sys.stdout.write(f"\n \033[38;2;62;207;142m┃\033[0m")
|
|
407
|
+
sys.stdout.write(f"\n\n \033[2m✦ {elapsed:.1f}s\033[0m")
|
|
408
|
+
sys.stdout.write("\n")
|
|
409
|
+
sys.stdout.flush()
|
|
410
|
+
elif not collected_text.strip():
|
|
504
411
|
full = event.parsed.get(
|
|
505
412
|
"response", event.parsed.get("content", ""),
|
|
506
413
|
)
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Terminal UI Application — fixed-layout chat interface.
|
|
2
|
+
|
|
3
|
+
Uses prompt_toolkit's Application to create a layout with:
|
|
4
|
+
- Scrollable conversation area (top)
|
|
5
|
+
- Fixed separator + input + separator + status bar (bottom)
|
|
6
|
+
|
|
7
|
+
This replaces the Rich console.print() approach with a proper
|
|
8
|
+
TUI that supports streaming text, fixed bars, and live updates.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
from typing import Any, Callable
|
|
17
|
+
|
|
18
|
+
from prompt_toolkit import Application
|
|
19
|
+
from prompt_toolkit.buffer import Buffer
|
|
20
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
21
|
+
from prompt_toolkit.layout import (
|
|
22
|
+
HSplit,
|
|
23
|
+
Window,
|
|
24
|
+
FormattedTextControl,
|
|
25
|
+
BufferControl,
|
|
26
|
+
ScrollablePane,
|
|
27
|
+
)
|
|
28
|
+
from prompt_toolkit.layout.layout import Layout
|
|
29
|
+
from prompt_toolkit.layout.dimension import Dimension
|
|
30
|
+
from prompt_toolkit.formatted_text import FormattedText, HTML
|
|
31
|
+
from prompt_toolkit.history import FileHistory
|
|
32
|
+
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
|
33
|
+
|
|
34
|
+
# Colors matching theme.py
|
|
35
|
+
GREEN = "#3ECF8E"
|
|
36
|
+
GREEN_DIM = "#34B87A"
|
|
37
|
+
RED = "#EF4444"
|
|
38
|
+
DIM = "#52525B"
|
|
39
|
+
MUTED = "#3F3F46"
|
|
40
|
+
GRAY = "#A1A1AA"
|
|
41
|
+
WHITE = "#F5F5F5"
|
|
42
|
+
AMBER = "#F59E0B"
|
|
43
|
+
BG = "#09090B"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ChatTUI:
|
|
47
|
+
"""Fixed-layout terminal chat interface.
|
|
48
|
+
|
|
49
|
+
Layout:
|
|
50
|
+
┌──────────────────────────────────────┐
|
|
51
|
+
│ [Scrollable conversation] │
|
|
52
|
+
│ ◐ Thinking... │
|
|
53
|
+
│ ┃ Streaming response... │
|
|
54
|
+
│ ✓ tool_name $0.003 │
|
|
55
|
+
├──────────────────────────────────────┤
|
|
56
|
+
│ ❯ user input │
|
|
57
|
+
├──────────────────────────────────────┤
|
|
58
|
+
│ ● sonnet │ ○ broker │ /help commands │
|
|
59
|
+
└──────────────────────────────────────┘
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
model: str = "sonnet",
|
|
65
|
+
broker: str | None = None,
|
|
66
|
+
history_file: str = "",
|
|
67
|
+
on_submit: Callable[[str], None] | None = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
self._model = model
|
|
70
|
+
self._broker = broker
|
|
71
|
+
self._on_submit = on_submit
|
|
72
|
+
self._conversation_lines: list[tuple[str, str]] = [] # (style, text) pairs
|
|
73
|
+
self._running = True
|
|
74
|
+
self._input_ready = asyncio.Event()
|
|
75
|
+
self._last_input = ""
|
|
76
|
+
|
|
77
|
+
# Conversation display
|
|
78
|
+
self._conversation_control = FormattedTextControl(
|
|
79
|
+
text=self._get_conversation_text,
|
|
80
|
+
focusable=False,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Input buffer
|
|
84
|
+
self._input_buffer = Buffer(
|
|
85
|
+
name="input",
|
|
86
|
+
history=FileHistory(history_file) if history_file else None,
|
|
87
|
+
auto_suggest=AutoSuggestFromHistory(),
|
|
88
|
+
accept_handler=self._on_accept,
|
|
89
|
+
multiline=False,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# Separator bar
|
|
93
|
+
sep_text = FormattedText([(MUTED, "─" * 200)])
|
|
94
|
+
|
|
95
|
+
# Status bar
|
|
96
|
+
self._status_control = FormattedTextControl(
|
|
97
|
+
text=self._get_status_text,
|
|
98
|
+
focusable=False,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Key bindings
|
|
102
|
+
kb = KeyBindings()
|
|
103
|
+
|
|
104
|
+
@kb.add("c-d")
|
|
105
|
+
def _exit(event: Any) -> None:
|
|
106
|
+
self._running = False
|
|
107
|
+
event.app.exit(result=None)
|
|
108
|
+
|
|
109
|
+
@kb.add("c-c")
|
|
110
|
+
def _cancel(event: Any) -> None:
|
|
111
|
+
self._running = False
|
|
112
|
+
event.app.exit(result=None)
|
|
113
|
+
|
|
114
|
+
# Layout
|
|
115
|
+
self._layout = Layout(
|
|
116
|
+
HSplit([
|
|
117
|
+
# Scrollable conversation
|
|
118
|
+
ScrollablePane(
|
|
119
|
+
Window(
|
|
120
|
+
content=self._conversation_control,
|
|
121
|
+
wrap_lines=True,
|
|
122
|
+
),
|
|
123
|
+
),
|
|
124
|
+
# Top separator
|
|
125
|
+
Window(
|
|
126
|
+
content=FormattedTextControl(text=sep_text),
|
|
127
|
+
height=1,
|
|
128
|
+
),
|
|
129
|
+
# Input line
|
|
130
|
+
Window(
|
|
131
|
+
content=BufferControl(buffer=self._input_buffer),
|
|
132
|
+
height=1,
|
|
133
|
+
get_line_prefix=lambda lineno, wrap: [("bold " + WHITE, " ❯ ")],
|
|
134
|
+
),
|
|
135
|
+
# Bottom separator
|
|
136
|
+
Window(
|
|
137
|
+
content=FormattedTextControl(text=sep_text),
|
|
138
|
+
height=1,
|
|
139
|
+
),
|
|
140
|
+
# Status bar
|
|
141
|
+
Window(
|
|
142
|
+
content=self._status_control,
|
|
143
|
+
height=1,
|
|
144
|
+
),
|
|
145
|
+
]),
|
|
146
|
+
focused_element=self._input_buffer,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# Application
|
|
150
|
+
self._app: Application[str | None] = Application(
|
|
151
|
+
layout=self._layout,
|
|
152
|
+
key_bindings=kb,
|
|
153
|
+
full_screen=True,
|
|
154
|
+
mouse_support=False,
|
|
155
|
+
style=None,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def _on_accept(self, buffer: Buffer) -> bool:
|
|
159
|
+
"""Handle Enter press in input buffer."""
|
|
160
|
+
self._last_input = buffer.text.strip()
|
|
161
|
+
self._input_ready.set()
|
|
162
|
+
return False # Don't keep text in buffer
|
|
163
|
+
|
|
164
|
+
def _get_conversation_text(self) -> FormattedText:
|
|
165
|
+
"""Render conversation lines as FormattedText."""
|
|
166
|
+
if not self._conversation_lines:
|
|
167
|
+
return FormattedText([(DIM, " Type your message below. /help for commands.\n")])
|
|
168
|
+
return FormattedText(self._conversation_lines)
|
|
169
|
+
|
|
170
|
+
def _get_status_text(self) -> FormattedText:
|
|
171
|
+
"""Render status bar."""
|
|
172
|
+
parts: list[tuple[str, str]] = [("", " ")]
|
|
173
|
+
parts.append((GREEN, "● "))
|
|
174
|
+
parts.append((DIM, self._model))
|
|
175
|
+
parts.append((MUTED, " │ "))
|
|
176
|
+
if self._broker:
|
|
177
|
+
parts.append((GREEN, "● "))
|
|
178
|
+
parts.append((DIM, self._broker))
|
|
179
|
+
else:
|
|
180
|
+
parts.append((RED, "○ "))
|
|
181
|
+
parts.append((DIM, "broker not connected"))
|
|
182
|
+
parts.append((MUTED, " │ "))
|
|
183
|
+
parts.append((DIM, "/help commands"))
|
|
184
|
+
return FormattedText(parts)
|
|
185
|
+
|
|
186
|
+
# ── Public API for streaming content ──
|
|
187
|
+
|
|
188
|
+
def add_line(self, style: str, text: str) -> None:
|
|
189
|
+
"""Add a line to the conversation area."""
|
|
190
|
+
self._conversation_lines.append((style, text + "\n"))
|
|
191
|
+
self._invalidate()
|
|
192
|
+
|
|
193
|
+
def add_text(self, style: str, text: str) -> None:
|
|
194
|
+
"""Add inline text (no newline) for streaming."""
|
|
195
|
+
self._conversation_lines.append((style, text))
|
|
196
|
+
self._invalidate()
|
|
197
|
+
|
|
198
|
+
def add_thinking(self, text: str) -> None:
|
|
199
|
+
"""Show thinking status."""
|
|
200
|
+
self.add_line(DIM, f" ◐ {text}")
|
|
201
|
+
|
|
202
|
+
def add_tool_running(self, name: str) -> None:
|
|
203
|
+
"""Show tool starting."""
|
|
204
|
+
self.add_line(DIM, f" ◐ {name}...")
|
|
205
|
+
|
|
206
|
+
def add_tool_done(self, name: str, cost: str = "") -> None:
|
|
207
|
+
"""Show tool completed."""
|
|
208
|
+
suffix = f" {cost}" if cost else ""
|
|
209
|
+
self.add_line(GREEN, f" ✓ {name}{suffix}")
|
|
210
|
+
|
|
211
|
+
def add_tool_error(self, name: str, error: str = "") -> None:
|
|
212
|
+
"""Show tool error."""
|
|
213
|
+
suffix = f" {error}" if error else ""
|
|
214
|
+
self.add_line(RED, f" ✗ {name}{suffix}")
|
|
215
|
+
|
|
216
|
+
def add_error(self, msg: str) -> None:
|
|
217
|
+
"""Show error message."""
|
|
218
|
+
self.add_line(RED, f" ✗ {msg}")
|
|
219
|
+
|
|
220
|
+
def start_response(self) -> None:
|
|
221
|
+
"""Start a new response block with green border."""
|
|
222
|
+
self.add_text(GREEN, "\n ┃ ")
|
|
223
|
+
|
|
224
|
+
def stream_text(self, text: str) -> None:
|
|
225
|
+
"""Stream text within a response block."""
|
|
226
|
+
for ch in text:
|
|
227
|
+
if ch == "\n":
|
|
228
|
+
self.add_text(GREEN, "\n ┃ ")
|
|
229
|
+
else:
|
|
230
|
+
self.add_text("", ch)
|
|
231
|
+
self._invalidate()
|
|
232
|
+
|
|
233
|
+
def end_response(self, elapsed: float) -> None:
|
|
234
|
+
"""End response block with timing."""
|
|
235
|
+
self.add_text(GREEN, "\n ┃")
|
|
236
|
+
self.add_line(DIM, f"\n ✦ {elapsed:.1f}s\n")
|
|
237
|
+
|
|
238
|
+
def add_result_row(self, key: str, value: str) -> None:
|
|
239
|
+
"""Add a key-value result row."""
|
|
240
|
+
self.add_line("", f" {key}: ")
|
|
241
|
+
self.add_text(WHITE, value)
|
|
242
|
+
|
|
243
|
+
def set_model(self, model: str) -> None:
|
|
244
|
+
"""Update model in status bar."""
|
|
245
|
+
self._model = model
|
|
246
|
+
self._invalidate()
|
|
247
|
+
|
|
248
|
+
def set_broker(self, broker: str | None) -> None:
|
|
249
|
+
"""Update broker in status bar."""
|
|
250
|
+
self._broker = broker
|
|
251
|
+
self._invalidate()
|
|
252
|
+
|
|
253
|
+
def clear_conversation(self) -> None:
|
|
254
|
+
"""Clear conversation area."""
|
|
255
|
+
self._conversation_lines.clear()
|
|
256
|
+
self._invalidate()
|
|
257
|
+
|
|
258
|
+
def _invalidate(self) -> None:
|
|
259
|
+
"""Request UI redraw."""
|
|
260
|
+
if self._app and self._app.is_running:
|
|
261
|
+
self._app.invalidate()
|
|
262
|
+
|
|
263
|
+
async def wait_for_input(self) -> str | None:
|
|
264
|
+
"""Wait for user to submit input. Returns None on exit."""
|
|
265
|
+
self._input_ready.clear()
|
|
266
|
+
self._input_buffer.text = ""
|
|
267
|
+
self._invalidate()
|
|
268
|
+
|
|
269
|
+
await self._input_ready.wait()
|
|
270
|
+
|
|
271
|
+
if not self._running:
|
|
272
|
+
return None
|
|
273
|
+
return self._last_input
|
|
274
|
+
|
|
275
|
+
async def run_async(self) -> None:
|
|
276
|
+
"""Start the TUI application."""
|
|
277
|
+
await self._app.run_async()
|
|
278
|
+
|
|
279
|
+
def exit(self) -> None:
|
|
280
|
+
"""Exit the application."""
|
|
281
|
+
self._running = False
|
|
282
|
+
if self._app.is_running:
|
|
283
|
+
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
|