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.
Files changed (63) hide show
  1. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/PKG-INFO +1 -1
  2. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/__init__.py +1 -1
  3. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/commands/chat_commands.py +156 -35
  4. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/pyproject.toml +1 -1
  5. systemr_cli-2.1.1/neo/chat_runner.py +0 -302
  6. systemr_cli-2.1.1/neo/tui.py +0 -394
  7. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/.gitignore +0 -0
  8. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/DESIGN.md +0 -0
  9. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/README.md +0 -0
  10. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/clawhub/systemr-trading/SKILL.md +0 -0
  11. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/clawhub/systemr-trading/scripts/mcp_stdio_proxy.py +0 -0
  12. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/__main__.py +0 -0
  13. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/auth.py +0 -0
  14. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/cli.py +0 -0
  15. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/client.py +0 -0
  16. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/commands/__init__.py +0 -0
  17. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/commands/auth_commands.py +0 -0
  18. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/commands/cron_commands.py +0 -0
  19. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/commands/doctor_command.py +0 -0
  20. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/commands/eval_commands.py +0 -0
  21. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/commands/journal_commands.py +0 -0
  22. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/commands/plan_commands.py +0 -0
  23. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/commands/risk_commands.py +0 -0
  24. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/commands/scan_commands.py +0 -0
  25. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/commands/size_commands.py +0 -0
  26. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/config.py +0 -0
  27. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/confirmation.py +0 -0
  28. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/credits.py +0 -0
  29. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/cron.py +0 -0
  30. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/display/__init__.py +0 -0
  31. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/display/chat_renderer.py +0 -0
  32. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/display/formatters.py +0 -0
  33. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/display/tables.py +0 -0
  34. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/display/theme.py +0 -0
  35. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/hooks.py +0 -0
  36. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/logging.py +0 -0
  37. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/model_failover.py +0 -0
  38. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/orchestrator.py +0 -0
  39. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/profile.py +0 -0
  40. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/store.py +0 -0
  41. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/streaming.py +0 -0
  42. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/tool_router.py +0 -0
  43. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/neo/types.py +0 -0
  44. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/tests/__init__.py +0 -0
  45. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/tests/test_auth.py +0 -0
  46. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/tests/test_chat_helpers.py +0 -0
  47. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/tests/test_chat_renderer.py +0 -0
  48. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/tests/test_cli.py +0 -0
  49. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/tests/test_config.py +0 -0
  50. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/tests/test_confirmation.py +0 -0
  51. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/tests/test_credits.py +0 -0
  52. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/tests/test_cron.py +0 -0
  53. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/tests/test_doctor.py +0 -0
  54. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/tests/test_formatters.py +0 -0
  55. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/tests/test_hooks.py +0 -0
  56. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/tests/test_logging.py +0 -0
  57. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/tests/test_model_failover.py +0 -0
  58. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/tests/test_orchestrator.py +0 -0
  59. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/tests/test_profile.py +0 -0
  60. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/tests/test_store.py +0 -0
  61. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/tests/test_streaming.py +0 -0
  62. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/tests/test_tool_router.py +0 -0
  63. {systemr_cli-2.1.1 → systemr_cli-2.2.0}/tests/test_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: systemr-cli
3
- Version: 2.1.1
3
+ Version: 2.2.0
4
4
  Summary: System R AI — trading operating system for agents
5
5
  Author-email: System R AI <ashim@systemr.ai>
6
6
  License-Expression: MIT
@@ -1,3 +1,3 @@
1
1
  """System R CLI — trading operating system in your terminal."""
2
2
 
3
- __version__ = "2.1.1"
3
+ __version__ = "2.2.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.tui import ChatTUI
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 = str(SYSTEMR_HOME / "chat_history")
218
- display_model = model_name or "sonnet"
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
- # Create TUI and run
225
- tui = ChatTUI(
226
- model=display_model,
227
- broker=None,
228
- history_file=history_file,
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
- 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,
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
- finally:
247
- tui.exit()
348
+
248
349
  try:
249
- await tui_task
250
- except Exception:
251
- pass
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()
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "systemr-cli"
7
- version = "2.1.1"
7
+ version = "2.2.0"
8
8
  description = "System R AI — trading operating system for agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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")
@@ -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