systemr-cli 1.2.0__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.
Files changed (63) hide show
  1. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/PKG-INFO +1 -1
  2. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/neo/__init__.py +1 -1
  3. systemr_cli-2.0.0/neo/chat_runner.py +293 -0
  4. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/neo/commands/chat_commands.py +35 -156
  5. systemr_cli-2.0.0/neo/tui.py +283 -0
  6. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/pyproject.toml +1 -1
  7. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/.gitignore +0 -0
  8. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/DESIGN.md +0 -0
  9. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/README.md +0 -0
  10. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/clawhub/systemr-trading/SKILL.md +0 -0
  11. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/clawhub/systemr-trading/scripts/mcp_stdio_proxy.py +0 -0
  12. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/neo/__main__.py +0 -0
  13. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/neo/auth.py +0 -0
  14. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/neo/cli.py +0 -0
  15. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/neo/client.py +0 -0
  16. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/neo/commands/__init__.py +0 -0
  17. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/neo/commands/auth_commands.py +0 -0
  18. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/neo/commands/cron_commands.py +0 -0
  19. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/neo/commands/doctor_command.py +0 -0
  20. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/neo/commands/eval_commands.py +0 -0
  21. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/neo/commands/journal_commands.py +0 -0
  22. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/neo/commands/plan_commands.py +0 -0
  23. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/neo/commands/risk_commands.py +0 -0
  24. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/neo/commands/scan_commands.py +0 -0
  25. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/neo/commands/size_commands.py +0 -0
  26. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/neo/config.py +0 -0
  27. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/neo/confirmation.py +0 -0
  28. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/neo/credits.py +0 -0
  29. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/neo/cron.py +0 -0
  30. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/neo/display/__init__.py +0 -0
  31. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/neo/display/chat_renderer.py +0 -0
  32. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/neo/display/formatters.py +0 -0
  33. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/neo/display/tables.py +0 -0
  34. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/neo/display/theme.py +0 -0
  35. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/neo/hooks.py +0 -0
  36. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/neo/logging.py +0 -0
  37. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/neo/model_failover.py +0 -0
  38. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/neo/orchestrator.py +0 -0
  39. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/neo/profile.py +0 -0
  40. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/neo/store.py +0 -0
  41. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/neo/streaming.py +0 -0
  42. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/neo/tool_router.py +0 -0
  43. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/neo/types.py +0 -0
  44. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/tests/__init__.py +0 -0
  45. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/tests/test_auth.py +0 -0
  46. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/tests/test_chat_helpers.py +0 -0
  47. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/tests/test_chat_renderer.py +0 -0
  48. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/tests/test_cli.py +0 -0
  49. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/tests/test_config.py +0 -0
  50. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/tests/test_confirmation.py +0 -0
  51. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/tests/test_credits.py +0 -0
  52. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/tests/test_cron.py +0 -0
  53. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/tests/test_doctor.py +0 -0
  54. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/tests/test_formatters.py +0 -0
  55. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/tests/test_hooks.py +0 -0
  56. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/tests/test_logging.py +0 -0
  57. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/tests/test_model_failover.py +0 -0
  58. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/tests/test_orchestrator.py +0 -0
  59. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/tests/test_profile.py +0 -0
  60. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/tests/test_store.py +0 -0
  61. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/tests/test_streaming.py +0 -0
  62. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/tests/test_tool_router.py +0 -0
  63. {systemr_cli-1.2.0 → systemr_cli-2.0.0}/tests/test_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: systemr-cli
3
- Version: 1.2.0
3
+ Version: 2.0.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__ = "1.2.0"
3
+ __version__ = "2.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.display.theme import print_prompt_bar
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
- 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"
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
- 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
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
- # 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,
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
- 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})
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 if there was activity
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[/] " # type: ignore[str-format]
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()
@@ -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()
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "systemr-cli"
7
- version = "1.2.0"
7
+ version = "2.0.0"
8
8
  description = "System R AI — trading operating system for agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
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