systemr-cli 2.1.1__tar.gz → 2.2.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.1.1 → systemr_cli-2.2.0}/PKG-INFO +1 -1
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/__init__.py +1 -1
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/commands/chat_commands.py +156 -35
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/pyproject.toml +1 -1
- systemr_cli-2.1.1/neo/chat_runner.py +0 -302
- systemr_cli-2.1.1/neo/tui.py +0 -394
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/.gitignore +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/DESIGN.md +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/README.md +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/clawhub/systemr-trading/SKILL.md +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/clawhub/systemr-trading/scripts/mcp_stdio_proxy.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/__main__.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/auth.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/cli.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/client.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/commands/__init__.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/commands/auth_commands.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/commands/cron_commands.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/commands/doctor_command.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/commands/eval_commands.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/commands/journal_commands.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/commands/plan_commands.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/commands/risk_commands.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/commands/scan_commands.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/commands/size_commands.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/config.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/confirmation.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/credits.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/cron.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/display/__init__.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/display/chat_renderer.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/display/formatters.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/display/tables.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/display/theme.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/hooks.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/logging.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/model_failover.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/orchestrator.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/profile.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/store.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/streaming.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/tool_router.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/types.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/tests/__init__.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/tests/test_auth.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/tests/test_chat_helpers.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/tests/test_chat_renderer.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/tests/test_cli.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/tests/test_config.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/tests/test_confirmation.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/tests/test_credits.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/tests/test_cron.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/tests/test_doctor.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/tests/test_formatters.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/tests/test_hooks.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/tests/test_logging.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/tests/test_model_failover.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/tests/test_orchestrator.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/tests/test_profile.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/tests/test_store.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/tests/test_streaming.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/tests/test_tool_router.py +0 -0
- {systemr_cli-2.1.1 → systemr_cli-2.2.0}/tests/test_types.py +0 -0
|
@@ -204,8 +204,7 @@ 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.
|
|
208
|
-
from neo.chat_runner import run_chat_tui
|
|
207
|
+
from neo.display.theme import print_prompt_bar
|
|
209
208
|
|
|
210
209
|
# Only print banner if not already shown by _show_home_and_chat
|
|
211
210
|
banner_shown = (ctx.obj or {}).get("banner_shown", False)
|
|
@@ -213,58 +212,180 @@ def chat(ctx: click.Context, model_name: str | None, research: bool, resume: boo
|
|
|
213
212
|
print_banner()
|
|
214
213
|
console.print(f" [{GREEN}]● connected[/]")
|
|
215
214
|
|
|
215
|
+
# Session state
|
|
216
|
+
failover = load_failover_chain()
|
|
217
|
+
if model_name:
|
|
218
|
+
failover.pinned_model = model_name
|
|
219
|
+
current_model = model_name
|
|
216
220
|
credits = SessionCredits()
|
|
217
|
-
history_file =
|
|
218
|
-
|
|
221
|
+
history_file = SYSTEMR_HOME / "chat_history"
|
|
222
|
+
loop = asyncio.new_event_loop()
|
|
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"
|
|
219
232
|
|
|
220
233
|
# Fire session start hook
|
|
221
234
|
fire_hook(HookEvent.PRE_SESSION, context={"model": model_name})
|
|
222
235
|
logger.info("session_started", model=model_name, resume=resume)
|
|
223
236
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
237
|
+
try:
|
|
238
|
+
while True:
|
|
239
|
+
print_separator()
|
|
240
|
+
try:
|
|
241
|
+
user_input = prompt_session.prompt(" ❯ ").strip()
|
|
242
|
+
except (EOFError, KeyboardInterrupt):
|
|
243
|
+
console.print()
|
|
244
|
+
console.print(f" [{DIM}]Session closed.[/]")
|
|
245
|
+
break
|
|
230
246
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
+
)
|
|
293
|
+
|
|
294
|
+
# Display results
|
|
295
|
+
if isinstance(result_data, dict):
|
|
296
|
+
display_items: list[str] = []
|
|
297
|
+
for k, v in result_data.items():
|
|
298
|
+
if k in ("cost_usdc",):
|
|
299
|
+
continue
|
|
300
|
+
if v is not None and str(v).strip():
|
|
301
|
+
display_items.append(f" [{GRAY}]{k}:[/] [{WHITE}]{v}[/]")
|
|
302
|
+
if display_items:
|
|
303
|
+
console.print()
|
|
304
|
+
for item in display_items[:15]:
|
|
305
|
+
console.print(item)
|
|
306
|
+
console.print()
|
|
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,
|
|
245
347
|
)
|
|
246
|
-
|
|
247
|
-
tui.exit()
|
|
348
|
+
|
|
248
349
|
try:
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
350
|
+
reply = loop.run_until_complete(
|
|
351
|
+
_do_chat(request, token.access_token, credits)
|
|
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})
|
|
252
376
|
|
|
253
|
-
try:
|
|
254
|
-
asyncio.run(_run())
|
|
255
|
-
except (KeyboardInterrupt, EOFError):
|
|
256
|
-
pass
|
|
257
377
|
finally:
|
|
378
|
+
loop.close()
|
|
258
379
|
_save_session(db_session_id, messages)
|
|
259
380
|
fire_hook(HookEvent.POST_SESSION, context=credits.summary())
|
|
260
381
|
logger.info("session_ended", **credits.summary())
|
|
261
382
|
|
|
262
|
-
# Show session summary
|
|
383
|
+
# Show session summary if there was activity
|
|
263
384
|
if credits.messages_sent > 0:
|
|
264
385
|
console.print()
|
|
265
386
|
console.print(f" [{GRAY}]Session summary[/]")
|
|
266
387
|
s = credits.summary()
|
|
267
|
-
console.print(f" [{WHITE}]{s['credits_used']:.1f}[/] [{DIM}]credits[/] "
|
|
388
|
+
console.print(f" [{WHITE}]{s['credits_used']:.1f}[/] [{DIM}]credits[/] " # type: ignore[str-format]
|
|
268
389
|
f"[{WHITE}]{s['tools_called']}[/] [{DIM}]tools[/] "
|
|
269
390
|
f"[{WHITE}]{s['duration_min']}m[/] [{DIM}]duration[/]")
|
|
270
391
|
console.print()
|
|
@@ -1,302 +0,0 @@
|
|
|
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
|
-
tui.set_processing(True)
|
|
194
|
-
|
|
195
|
-
try:
|
|
196
|
-
async for event in stream_chat(request, access_token):
|
|
197
|
-
if event.event == "thinking":
|
|
198
|
-
text = event.parsed.get("text", event.parsed.get("status", ""))
|
|
199
|
-
if text:
|
|
200
|
-
tui.add_thinking(text[:70])
|
|
201
|
-
|
|
202
|
-
elif event.event == "action":
|
|
203
|
-
tool = event.parsed.get("action", event.parsed.get("tool", ""))
|
|
204
|
-
params = event.parsed.get("parameters", {})
|
|
205
|
-
tool_count += 1
|
|
206
|
-
if tool:
|
|
207
|
-
fire_hook(HookEvent.BEFORE_TOOL_CALL, context={"tool": tool, "args": params})
|
|
208
|
-
tui.add_tool_running(tool)
|
|
209
|
-
if params:
|
|
210
|
-
try:
|
|
211
|
-
result = await call_tool(tool, params)
|
|
212
|
-
data = result.get("result", {})
|
|
213
|
-
cost = result.get("cost_usdc") or (data.get("cost_usdc", "") if isinstance(data, dict) else "")
|
|
214
|
-
tui.add_tool_done(tool, f"${cost}" if cost else "")
|
|
215
|
-
if isinstance(data, dict):
|
|
216
|
-
tui.add_text("", "\n")
|
|
217
|
-
for k, v in data.items():
|
|
218
|
-
if k == "cost_usdc":
|
|
219
|
-
continue
|
|
220
|
-
if v is not None and str(v).strip():
|
|
221
|
-
tui.add_text("#A1A1AA", f" {k}: ")
|
|
222
|
-
tui.add_text("#F5F5F5", f"{v}\n")
|
|
223
|
-
except Exception as e:
|
|
224
|
-
tui.add_tool_error(tool, str(e)[:60])
|
|
225
|
-
|
|
226
|
-
elif event.event == "text_delta":
|
|
227
|
-
text = event.parsed.get("text", event.parsed.get("delta", ""))
|
|
228
|
-
if text and text != "null":
|
|
229
|
-
if not streaming_started:
|
|
230
|
-
tui.start_response()
|
|
231
|
-
streaming_started = True
|
|
232
|
-
tui.stream_text(text)
|
|
233
|
-
collected_text += text
|
|
234
|
-
|
|
235
|
-
elif event.event == "done":
|
|
236
|
-
if streaming_started:
|
|
237
|
-
elapsed = time.time() - start_time
|
|
238
|
-
tui.end_response(elapsed)
|
|
239
|
-
elif not collected_text:
|
|
240
|
-
full = event.parsed.get("response", event.parsed.get("content", ""))
|
|
241
|
-
if full:
|
|
242
|
-
collected_text = full
|
|
243
|
-
tui.start_response()
|
|
244
|
-
tui.stream_text(full)
|
|
245
|
-
tui.end_response(time.time() - start_time)
|
|
246
|
-
|
|
247
|
-
credits_used = event.parsed.get("credits_used")
|
|
248
|
-
balance = event.parsed.get("balance")
|
|
249
|
-
credits.add_response(
|
|
250
|
-
credits_used=credits_used, balance=balance,
|
|
251
|
-
tools=tool_count, trade=has_trade,
|
|
252
|
-
)
|
|
253
|
-
if credits_used:
|
|
254
|
-
try:
|
|
255
|
-
tui.add_credits(float(credits_used))
|
|
256
|
-
except (ValueError, TypeError):
|
|
257
|
-
pass
|
|
258
|
-
|
|
259
|
-
elif event.event == "error":
|
|
260
|
-
raw = event.parsed.get("message", event.parsed.get("error", ""))
|
|
261
|
-
msg = str(raw)
|
|
262
|
-
if "Classification backend unavailable" in msg:
|
|
263
|
-
msg = "AI model temporarily unavailable. Try again."
|
|
264
|
-
elif "AccessDeniedException" in msg:
|
|
265
|
-
msg = "AI model access error."
|
|
266
|
-
elif len(msg) > 120:
|
|
267
|
-
msg = msg[:120] + "..."
|
|
268
|
-
tui.add_error(msg)
|
|
269
|
-
|
|
270
|
-
except Exception as exc:
|
|
271
|
-
tui.add_error(f"Connection error: {str(exc)[:60]}")
|
|
272
|
-
logger.error("stream_error", error=str(exc))
|
|
273
|
-
finally:
|
|
274
|
-
tui.set_processing(False)
|
|
275
|
-
|
|
276
|
-
return collected_text
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
def _show_help(tui: ChatTUI) -> None:
|
|
280
|
-
"""Show help in the TUI."""
|
|
281
|
-
tui.add_text("", "\n")
|
|
282
|
-
tui.add_line("#3ECF8E", " Chat")
|
|
283
|
-
tui.add_line("#52525B", " /morning Morning briefing")
|
|
284
|
-
tui.add_line("#52525B", " /eod End of day review")
|
|
285
|
-
tui.add_line("#52525B", " /plan Plan today's trades")
|
|
286
|
-
tui.add_line("#52525B", " /portfolio Open positions")
|
|
287
|
-
tui.add_line("#52525B", " /risk Risk dashboard")
|
|
288
|
-
tui.add_text("", "\n")
|
|
289
|
-
tui.add_line("#3ECF8E", " Memory")
|
|
290
|
-
tui.add_line("#52525B", " /remember Save a memory")
|
|
291
|
-
tui.add_line("#52525B", " /memory Search memories")
|
|
292
|
-
tui.add_line("#52525B", " /sessions Recent sessions")
|
|
293
|
-
tui.add_text("", "\n")
|
|
294
|
-
tui.add_line("#3ECF8E", " Settings")
|
|
295
|
-
tui.add_line("#52525B", " /model Show or switch model")
|
|
296
|
-
tui.add_line("#52525B", " /permissions Safety profile")
|
|
297
|
-
tui.add_line("#52525B", " /credits Credit usage")
|
|
298
|
-
tui.add_text("", "\n")
|
|
299
|
-
tui.add_line("#3ECF8E", " Session")
|
|
300
|
-
tui.add_line("#52525B", " /clear Clear conversation")
|
|
301
|
-
tui.add_line("#52525B", " /q Exit")
|
|
302
|
-
tui.add_text("", "\n")
|
systemr_cli-2.1.1/neo/tui.py
DELETED
|
@@ -1,394 +0,0 @@
|
|
|
1
|
-
"""Terminal UI Application — fixed-layout chat interface.
|
|
2
|
-
|
|
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
|
|
15
|
-
"""
|
|
16
|
-
|
|
17
|
-
from __future__ import annotations
|
|
18
|
-
|
|
19
|
-
import asyncio
|
|
20
|
-
import time
|
|
21
|
-
from typing import Any, Callable
|
|
22
|
-
|
|
23
|
-
from prompt_toolkit import Application
|
|
24
|
-
from prompt_toolkit.buffer import Buffer
|
|
25
|
-
from prompt_toolkit.key_binding import KeyBindings
|
|
26
|
-
from prompt_toolkit.layout import (
|
|
27
|
-
HSplit,
|
|
28
|
-
Window,
|
|
29
|
-
FormattedTextControl,
|
|
30
|
-
BufferControl,
|
|
31
|
-
ScrollablePane,
|
|
32
|
-
)
|
|
33
|
-
from prompt_toolkit.layout.layout import Layout
|
|
34
|
-
from prompt_toolkit.layout.dimension import Dimension
|
|
35
|
-
from prompt_toolkit.formatted_text import FormattedText
|
|
36
|
-
from prompt_toolkit.history import FileHistory
|
|
37
|
-
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
|
38
|
-
from prompt_toolkit.styles import Style
|
|
39
|
-
|
|
40
|
-
# ── Colors (match theme.py) ──
|
|
41
|
-
GREEN = "#3ECF8E"
|
|
42
|
-
GREEN_DIM = "#34B87A"
|
|
43
|
-
RED = "#EF4444"
|
|
44
|
-
DIM = "#52525B"
|
|
45
|
-
MUTED = "#3F3F46"
|
|
46
|
-
GRAY = "#A1A1AA"
|
|
47
|
-
WHITE = "#F5F5F5"
|
|
48
|
-
AMBER = "#F59E0B"
|
|
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
|
-
})
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
class ChatTUI:
|
|
72
|
-
"""Fixed-layout terminal chat interface.
|
|
73
|
-
|
|
74
|
-
Layout:
|
|
75
|
-
┌──────────────────────────────────────┐
|
|
76
|
-
│ [Scrollable conversation] │
|
|
77
|
-
│ ◐ Thinking... │
|
|
78
|
-
│ ┃ Streaming response... │
|
|
79
|
-
│ ✓ tool_name $0.003 │
|
|
80
|
-
├──────────────────────────────────────┤
|
|
81
|
-
│ ❯ user input │
|
|
82
|
-
├──────────────────────────────────────┤
|
|
83
|
-
│ ● sonnet │ ○ broker │ /help │ 2m 3s │
|
|
84
|
-
└──────────────────────────────────────┘
|
|
85
|
-
"""
|
|
86
|
-
|
|
87
|
-
def __init__(
|
|
88
|
-
self,
|
|
89
|
-
model: str = "sonnet",
|
|
90
|
-
broker: str | None = None,
|
|
91
|
-
history_file: str = "",
|
|
92
|
-
) -> None:
|
|
93
|
-
self._model = model
|
|
94
|
-
self._broker = broker
|
|
95
|
-
self._conversation_lines: list[tuple[str, str]] = []
|
|
96
|
-
self._running = True
|
|
97
|
-
self._input_ready = asyncio.Event()
|
|
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
|
|
103
|
-
|
|
104
|
-
# Conversation display
|
|
105
|
-
self._conversation_control = FormattedTextControl(
|
|
106
|
-
text=self._get_conversation_text,
|
|
107
|
-
focusable=False,
|
|
108
|
-
)
|
|
109
|
-
|
|
110
|
-
# Scrollable conversation window
|
|
111
|
-
self._conversation_window = Window(
|
|
112
|
-
content=self._conversation_control,
|
|
113
|
-
wrap_lines=True,
|
|
114
|
-
)
|
|
115
|
-
|
|
116
|
-
# Input buffer
|
|
117
|
-
self._input_buffer = Buffer(
|
|
118
|
-
name="input",
|
|
119
|
-
history=FileHistory(history_file) if history_file else None,
|
|
120
|
-
auto_suggest=AutoSuggestFromHistory(),
|
|
121
|
-
accept_handler=self._on_accept,
|
|
122
|
-
multiline=False,
|
|
123
|
-
)
|
|
124
|
-
|
|
125
|
-
# Separator
|
|
126
|
-
self._sep_control = FormattedTextControl(
|
|
127
|
-
text=lambda: FormattedText([(MUTED, "─" * 300)]),
|
|
128
|
-
focusable=False,
|
|
129
|
-
)
|
|
130
|
-
|
|
131
|
-
# Status bar
|
|
132
|
-
self._status_control = FormattedTextControl(
|
|
133
|
-
text=self._get_status_text,
|
|
134
|
-
focusable=False,
|
|
135
|
-
)
|
|
136
|
-
|
|
137
|
-
# Key bindings
|
|
138
|
-
kb = KeyBindings()
|
|
139
|
-
|
|
140
|
-
@kb.add("c-d")
|
|
141
|
-
def _exit(event: Any) -> None:
|
|
142
|
-
self._running = False
|
|
143
|
-
self._input_ready.set()
|
|
144
|
-
event.app.exit(result=None)
|
|
145
|
-
|
|
146
|
-
@kb.add("c-c")
|
|
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 = ""
|
|
164
|
-
|
|
165
|
-
# Layout
|
|
166
|
-
self._layout = Layout(
|
|
167
|
-
HSplit([
|
|
168
|
-
# Scrollable conversation (takes all available space)
|
|
169
|
-
ScrollablePane(
|
|
170
|
-
self._conversation_window,
|
|
171
|
-
),
|
|
172
|
-
# Top separator
|
|
173
|
-
Window(content=self._sep_control, height=1),
|
|
174
|
-
# Input line with ❯ prefix
|
|
175
|
-
Window(
|
|
176
|
-
content=BufferControl(buffer=self._input_buffer),
|
|
177
|
-
height=1,
|
|
178
|
-
get_line_prefix=lambda lineno, wrap: FormattedText(
|
|
179
|
-
[("bold " + WHITE, " ❯ ")]
|
|
180
|
-
),
|
|
181
|
-
),
|
|
182
|
-
# Bottom separator
|
|
183
|
-
Window(content=self._sep_control, height=1),
|
|
184
|
-
# Status bar
|
|
185
|
-
Window(content=self._status_control, height=1),
|
|
186
|
-
]),
|
|
187
|
-
focused_element=self._input_buffer,
|
|
188
|
-
)
|
|
189
|
-
|
|
190
|
-
# Application
|
|
191
|
-
self._app: Application[str | None] = Application(
|
|
192
|
-
layout=self._layout,
|
|
193
|
-
key_bindings=kb,
|
|
194
|
-
full_screen=True,
|
|
195
|
-
mouse_support=True,
|
|
196
|
-
style=TUI_STYLE,
|
|
197
|
-
)
|
|
198
|
-
|
|
199
|
-
# ── Internal handlers ──
|
|
200
|
-
|
|
201
|
-
def _on_accept(self, buffer: Buffer) -> bool:
|
|
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
|
|
208
|
-
self._input_ready.set()
|
|
209
|
-
return False
|
|
210
|
-
|
|
211
|
-
def _get_conversation_text(self) -> FormattedText:
|
|
212
|
-
"""Render conversation area."""
|
|
213
|
-
if not self._conversation_lines:
|
|
214
|
-
return FormattedText([
|
|
215
|
-
(DIM, "\n"),
|
|
216
|
-
(DIM, " Type your message below.\n"),
|
|
217
|
-
(DIM, " /help for commands. Ctrl+D to exit.\n"),
|
|
218
|
-
])
|
|
219
|
-
return FormattedText(self._conversation_lines)
|
|
220
|
-
|
|
221
|
-
def _get_status_text(self) -> FormattedText:
|
|
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
|
-
|
|
227
|
-
parts: list[tuple[str, str]] = [("", " ")]
|
|
228
|
-
|
|
229
|
-
# Model
|
|
230
|
-
parts.append((GREEN, "● "))
|
|
231
|
-
parts.append((DIM, self._model))
|
|
232
|
-
parts.append((MUTED, " │ "))
|
|
233
|
-
|
|
234
|
-
# Broker
|
|
235
|
-
if self._broker:
|
|
236
|
-
parts.append((GREEN, "● "))
|
|
237
|
-
parts.append((DIM, self._broker))
|
|
238
|
-
else:
|
|
239
|
-
parts.append((RED, "○ "))
|
|
240
|
-
parts.append((DIM, "no broker"))
|
|
241
|
-
parts.append((MUTED, " │ "))
|
|
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
|
-
|
|
259
|
-
return FormattedText(parts)
|
|
260
|
-
|
|
261
|
-
# ── Public API ──
|
|
262
|
-
|
|
263
|
-
def add_line(self, style: str, text: str) -> None:
|
|
264
|
-
"""Add a line to conversation."""
|
|
265
|
-
self._conversation_lines.append((style, text + "\n"))
|
|
266
|
-
self._scroll_to_bottom()
|
|
267
|
-
|
|
268
|
-
def add_text(self, style: str, text: str) -> None:
|
|
269
|
-
"""Add inline text (no newline) for streaming."""
|
|
270
|
-
self._conversation_lines.append((style, text))
|
|
271
|
-
self._scroll_to_bottom()
|
|
272
|
-
|
|
273
|
-
def add_thinking(self, text: str) -> None:
|
|
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()
|
|
288
|
-
|
|
289
|
-
def add_tool_running(self, name: str) -> None:
|
|
290
|
-
"""Show tool starting."""
|
|
291
|
-
self.clear_thinking()
|
|
292
|
-
self.add_line(DIM, f" ◐ {name}...")
|
|
293
|
-
|
|
294
|
-
def add_tool_done(self, name: str, cost: str = "") -> None:
|
|
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()
|
|
300
|
-
suffix = f" {cost}" if cost else ""
|
|
301
|
-
self.add_line(GREEN, f" ✓ {name}{suffix}")
|
|
302
|
-
self._tools_called += 1
|
|
303
|
-
|
|
304
|
-
def add_tool_error(self, name: str, error: str = "") -> None:
|
|
305
|
-
"""Show tool error."""
|
|
306
|
-
if (self._conversation_lines and
|
|
307
|
-
"◐" in self._conversation_lines[-1][1]):
|
|
308
|
-
self._conversation_lines.pop()
|
|
309
|
-
suffix = f" {error}" if error else ""
|
|
310
|
-
self.add_line(RED, f" ✗ {name}{suffix}")
|
|
311
|
-
|
|
312
|
-
def add_error(self, msg: str) -> None:
|
|
313
|
-
"""Show error message."""
|
|
314
|
-
self.clear_thinking()
|
|
315
|
-
self.add_line(RED, f" ✗ {msg}")
|
|
316
|
-
|
|
317
|
-
def start_response(self) -> None:
|
|
318
|
-
"""Start a new response block with green border."""
|
|
319
|
-
self.clear_thinking()
|
|
320
|
-
self.add_text(GREEN, "\n ┃ ")
|
|
321
|
-
|
|
322
|
-
def stream_text(self, text: str) -> None:
|
|
323
|
-
"""Stream text within a response block, char by char."""
|
|
324
|
-
for ch in text:
|
|
325
|
-
if ch == "\n":
|
|
326
|
-
self._conversation_lines.append((GREEN, "\n ┃ "))
|
|
327
|
-
else:
|
|
328
|
-
self._conversation_lines.append(("", ch))
|
|
329
|
-
self._scroll_to_bottom()
|
|
330
|
-
|
|
331
|
-
def end_response(self, elapsed: float) -> None:
|
|
332
|
-
"""End response block with timing."""
|
|
333
|
-
self.add_text(GREEN, "\n ┃")
|
|
334
|
-
self.add_line(DIM, f"\n ✦ {elapsed:.1f}s")
|
|
335
|
-
self.add_text("", "\n")
|
|
336
|
-
|
|
337
|
-
def add_credits(self, amount: float) -> None:
|
|
338
|
-
"""Track credits used (updates status bar)."""
|
|
339
|
-
self._credits_used += amount
|
|
340
|
-
self._invalidate()
|
|
341
|
-
|
|
342
|
-
def set_model(self, model: str) -> None:
|
|
343
|
-
"""Update model in status bar."""
|
|
344
|
-
self._model = model
|
|
345
|
-
self._invalidate()
|
|
346
|
-
|
|
347
|
-
def set_broker(self, broker: str | None) -> None:
|
|
348
|
-
"""Update broker in status bar."""
|
|
349
|
-
self._broker = broker
|
|
350
|
-
self._invalidate()
|
|
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
|
-
|
|
357
|
-
def clear_conversation(self) -> None:
|
|
358
|
-
"""Clear conversation area."""
|
|
359
|
-
self._conversation_lines.clear()
|
|
360
|
-
self._invalidate()
|
|
361
|
-
|
|
362
|
-
def _scroll_to_bottom(self) -> None:
|
|
363
|
-
"""Scroll conversation to bottom and redraw."""
|
|
364
|
-
self._invalidate()
|
|
365
|
-
|
|
366
|
-
def _invalidate(self) -> None:
|
|
367
|
-
"""Request UI redraw."""
|
|
368
|
-
if self._app and self._app.is_running:
|
|
369
|
-
self._app.invalidate()
|
|
370
|
-
|
|
371
|
-
async def wait_for_input(self) -> str | None:
|
|
372
|
-
"""Wait for user to submit input. Returns None on exit."""
|
|
373
|
-
self._input_ready.clear()
|
|
374
|
-
self._input_buffer.text = ""
|
|
375
|
-
self._processing = False
|
|
376
|
-
self._invalidate()
|
|
377
|
-
|
|
378
|
-
await self._input_ready.wait()
|
|
379
|
-
|
|
380
|
-
if not self._running:
|
|
381
|
-
return None
|
|
382
|
-
self._processing = True
|
|
383
|
-
return self._last_input
|
|
384
|
-
|
|
385
|
-
async def run_async(self) -> None:
|
|
386
|
-
"""Start the TUI application."""
|
|
387
|
-
await self._app.run_async()
|
|
388
|
-
|
|
389
|
-
def exit(self) -> None:
|
|
390
|
-
"""Exit the application."""
|
|
391
|
-
self._running = False
|
|
392
|
-
self._input_ready.set()
|
|
393
|
-
if self._app.is_running:
|
|
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
|