Initial commit: NexusOS project + desktop theme baseline
Captures the known-good state after theme consolidation and consistency reconciliation: - NexusOS GTK/xfwm4/icon theme assets (assets/themes, management/Mint-Y-Nexus) - Tightened right-click menus, visible separators, no menu icons - assets/themes/install-theme.sh: idempotent restore of all wiring (symlinks, xfconf xsettings+xfwm4, GTK 3/4 settings.ini) - .gitignore excludes venv/ollama/models/runtime/db Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,532 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Nexus Chat — interactive TUI for the Synapse backend."""
|
||||
|
||||
import curses
|
||||
import httpx
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
NEXUS_ROOT = os.path.expanduser("~/nexus-core")
|
||||
API_BASE = "http://localhost:8000"
|
||||
MEMORY_BASE = "http://localhost:8001"
|
||||
PID_FILE = os.path.join(NEXUS_ROOT, "runtime/pids/backend.pid")
|
||||
LOG_FILE = os.path.join(NEXUS_ROOT, "runtime/backend.log")
|
||||
VENV_PY = os.path.join(NEXUS_ROOT, "Promethean/bin/python3")
|
||||
BACKEND_CMD = [
|
||||
"uvicorn", "synapse.main:sio_app",
|
||||
"--host", "0.0.0.0", "--port", "8000",
|
||||
]
|
||||
|
||||
|
||||
# ─── backend helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
def backend_running() -> bool:
|
||||
try:
|
||||
return httpx.get(f"{API_BASE}/", timeout=2.0).status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def start_backend() -> int | None:
|
||||
"""Start the backend and return its PID, or None on failure."""
|
||||
os.makedirs(os.path.dirname(PID_FILE), exist_ok=True)
|
||||
python = VENV_PY if os.path.exists(VENV_PY) else sys.executable
|
||||
cmd = [python, "-m"] + BACKEND_CMD
|
||||
with open(LOG_FILE, "a") as log:
|
||||
proc = subprocess.Popen(
|
||||
cmd, cwd=NEXUS_ROOT, stdout=log, stderr=log, start_new_session=True
|
||||
)
|
||||
with open(PID_FILE, "w") as f:
|
||||
f.write(str(proc.pid))
|
||||
for _ in range(30):
|
||||
time.sleep(1)
|
||||
if backend_running():
|
||||
return proc.pid
|
||||
return None
|
||||
|
||||
|
||||
def stop_backend(pid: int):
|
||||
"""Gracefully stop the backend process we started."""
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
return
|
||||
for _ in range(10):
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
break
|
||||
else:
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
try:
|
||||
os.remove(PID_FILE)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def fetch_models() -> tuple[list[str], str]:
|
||||
"""Return (model_list, selected_model)."""
|
||||
try:
|
||||
r = httpx.get(f"{API_BASE}/models", timeout=2.0)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
models = data.get("models", [])
|
||||
selected = data.get("selected") or (models[0] if models else "mistral")
|
||||
return models, selected
|
||||
except Exception:
|
||||
pass
|
||||
return ["mistral"], "mistral"
|
||||
|
||||
|
||||
# ─── TUI ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
SHORTCUTS = [
|
||||
("^X", "Exit"),
|
||||
("^R", "Regen"),
|
||||
("^N", "New"),
|
||||
("^L", "Clear"),
|
||||
("^T", "Model"),
|
||||
("PgUp", "Up"),
|
||||
("PgDn", "Down"),
|
||||
("/remember", "Save fact"),
|
||||
]
|
||||
|
||||
# color pair indices
|
||||
CP_HEADER = 1 # header bar
|
||||
CP_SHORTBAR = 2 # shortcut bar bg
|
||||
CP_USER = 3 # "You" label
|
||||
CP_NEXUS = 4 # "Nexus" label
|
||||
CP_DIVIDER = 5 # divider line
|
||||
CP_STREAMING = 6 # streaming indicator
|
||||
CP_STATS = 7 # token stats line
|
||||
|
||||
|
||||
class NexusChat:
|
||||
def __init__(self, stdscr):
|
||||
self.scr = stdscr
|
||||
self.conv_id = str(uuid.uuid4())
|
||||
self.history = [] # [{"role": ..., "content": ..., "stats": {...}|None}]
|
||||
self.input_buf = ""
|
||||
self.scroll = 0 # lines scrolled up from bottom
|
||||
self.streaming = False
|
||||
self.stream_buf = ""
|
||||
self._last_meta: dict | None = None
|
||||
self._memory_saved: dict | None = None # set when Mistral saves a fact
|
||||
self.models, self.model = fetch_models()
|
||||
self.model_idx = self.models.index(self.model) if self.model in self.models else 0
|
||||
self._lock = threading.Lock()
|
||||
|
||||
curses.start_color()
|
||||
curses.use_default_colors()
|
||||
curses.init_pair(CP_HEADER, curses.COLOR_BLACK, curses.COLOR_CYAN)
|
||||
curses.init_pair(CP_SHORTBAR, curses.COLOR_BLACK, curses.COLOR_WHITE)
|
||||
curses.init_pair(CP_USER, curses.COLOR_CYAN, -1)
|
||||
curses.init_pair(CP_NEXUS, curses.COLOR_MAGENTA, -1)
|
||||
curses.init_pair(CP_DIVIDER, curses.COLOR_CYAN, -1)
|
||||
curses.init_pair(CP_STREAMING,curses.COLOR_YELLOW, -1)
|
||||
curses.init_pair(CP_STATS, curses.COLOR_WHITE, -1)
|
||||
|
||||
curses.curs_set(1)
|
||||
self.scr.keypad(True)
|
||||
self.scr.timeout(50)
|
||||
|
||||
# ── geometry ──────────────────────────────────────────────────────────────
|
||||
|
||||
def dims(self):
|
||||
return self.scr.getmaxyx() # (h, w)
|
||||
|
||||
def msg_rows(self, h):
|
||||
# row 0 = header, row h-3 = divider, row h-2 = input, row h-1 = shortbar
|
||||
return range(1, h - 3)
|
||||
|
||||
# ── drawing ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _put(self, row, col, text, attr=0):
|
||||
h, w = self.dims()
|
||||
if row < 0 or row >= h:
|
||||
return
|
||||
try:
|
||||
self.scr.addstr(row, col, text[:max(0, w - col)], attr)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
def draw_header(self):
|
||||
h, w = self.dims()
|
||||
left = " NEXUS CHAT "
|
||||
right = f" model: {self.model} "
|
||||
if self.streaming:
|
||||
mid = " streaming… "
|
||||
mid_attr = curses.color_pair(CP_STREAMING) | curses.A_BOLD
|
||||
else:
|
||||
mid = ""
|
||||
mid_attr = 0
|
||||
gap = max(0, w - len(left) - len(right) - len(mid))
|
||||
bar = left + " " * (gap // 2) + mid + " " * (gap - gap // 2) + right
|
||||
|
||||
self.scr.attron(curses.color_pair(CP_HEADER) | curses.A_BOLD)
|
||||
self._put(0, 0, bar.ljust(w)[:w])
|
||||
self.scr.attroff(curses.color_pair(CP_HEADER) | curses.A_BOLD)
|
||||
|
||||
def draw_shortbar(self):
|
||||
h, w = self.dims()
|
||||
# Fill bg
|
||||
self.scr.attron(curses.color_pair(CP_SHORTBAR))
|
||||
self._put(h - 1, 0, " " * (w - 1))
|
||||
self.scr.attroff(curses.color_pair(CP_SHORTBAR))
|
||||
# Draw key/label pairs
|
||||
x = 1
|
||||
for key, label in SHORTCUTS:
|
||||
if x + len(key) + len(label) + 2 >= w:
|
||||
break
|
||||
self.scr.attron(curses.A_REVERSE)
|
||||
self._put(h - 1, x, key)
|
||||
self.scr.attroff(curses.A_REVERSE)
|
||||
x += len(key)
|
||||
self.scr.attron(curses.color_pair(CP_SHORTBAR))
|
||||
self._put(h - 1, x, f" {label} ")
|
||||
self.scr.attroff(curses.color_pair(CP_SHORTBAR))
|
||||
x += len(label) + 3
|
||||
|
||||
def draw_divider(self):
|
||||
h, w = self.dims()
|
||||
self.scr.attron(curses.color_pair(CP_DIVIDER))
|
||||
self._put(h - 3, 0, "─" * (w - 1))
|
||||
self.scr.attroff(curses.color_pair(CP_DIVIDER))
|
||||
|
||||
def draw_input(self):
|
||||
h, w = self.dims()
|
||||
prompt = " › " # › prompt
|
||||
avail = w - len(prompt) - 1
|
||||
display = self.input_buf[-avail:] if len(self.input_buf) > avail else self.input_buf
|
||||
try:
|
||||
self.scr.move(h - 2, 0)
|
||||
self.scr.clrtoeol()
|
||||
except curses.error:
|
||||
pass
|
||||
self._put(h - 2, 0, prompt)
|
||||
self._put(h - 2, len(prompt), display)
|
||||
|
||||
def _build_lines(self, w):
|
||||
"""Render history (+ live stream) into a flat list of (text, attr) tuples."""
|
||||
lines = []
|
||||
with self._lock:
|
||||
history = list(self.history)
|
||||
sbuf = self.stream_buf
|
||||
in_stream = self.streaming
|
||||
|
||||
for msg in history:
|
||||
role = msg["role"]
|
||||
content = msg["content"]
|
||||
if role == "user":
|
||||
label = " You "
|
||||
attr = curses.color_pair(CP_USER) | curses.A_BOLD
|
||||
else:
|
||||
label = " Nexus "
|
||||
attr = curses.color_pair(CP_NEXUS) | curses.A_BOLD
|
||||
lines.append((label, attr))
|
||||
for wl in textwrap.wrap(content, w - 4) or [""]:
|
||||
lines.append((f" {wl}", 0))
|
||||
if role == "assistant":
|
||||
stats = msg.get("stats")
|
||||
if stats:
|
||||
tok_s = f" {stats['tokens']} tok · {stats['elapsed_s']}s · {stats['tokens_per_s']} t/s"
|
||||
else:
|
||||
est = max(1, len(content) // 4)
|
||||
tok_s = f" ~{est} tok (est)"
|
||||
lines.append((tok_s, curses.color_pair(CP_STATS)))
|
||||
mem = msg.get("memory")
|
||||
if mem:
|
||||
mem_s = f" + memory saved [{mem['section']}]: {mem['text'][:60]}"
|
||||
lines.append((mem_s, curses.color_pair(CP_STREAMING) | curses.A_BOLD))
|
||||
lines.append(("", 0))
|
||||
|
||||
if in_stream:
|
||||
lines.append((" Nexus ", curses.color_pair(CP_NEXUS) | curses.A_BOLD))
|
||||
for wl in textwrap.wrap(sbuf, w - 4) or ["…"]:
|
||||
lines.append((f" {wl}", 0))
|
||||
|
||||
return lines
|
||||
|
||||
def draw_messages(self):
|
||||
h, w = self.dims()
|
||||
rows = self.msg_rows(h)
|
||||
height = len(rows)
|
||||
if height <= 0:
|
||||
return
|
||||
|
||||
lines = self._build_lines(w)
|
||||
total = len(lines)
|
||||
|
||||
if self.scroll == 0:
|
||||
start = max(0, total - height)
|
||||
else:
|
||||
start = max(0, total - height - self.scroll)
|
||||
|
||||
visible = lines[start:start + height]
|
||||
|
||||
for i, row in enumerate(rows):
|
||||
try:
|
||||
self.scr.move(row, 0)
|
||||
self.scr.clrtoeol()
|
||||
except curses.error:
|
||||
pass
|
||||
if i < len(visible):
|
||||
text, attr = visible[i]
|
||||
if attr:
|
||||
self.scr.attron(attr)
|
||||
self._put(row, 0, text)
|
||||
if attr:
|
||||
self.scr.attroff(attr)
|
||||
|
||||
def draw(self):
|
||||
h, w = self.dims()
|
||||
self.draw_header()
|
||||
self.draw_messages()
|
||||
self.draw_divider()
|
||||
self.draw_input()
|
||||
self.draw_shortbar()
|
||||
# cursor to end of input
|
||||
prompt_len = 4 # " › "
|
||||
avail = w - prompt_len - 1
|
||||
cx = prompt_len + min(len(self.input_buf), avail)
|
||||
try:
|
||||
self.scr.move(h - 2, cx)
|
||||
except curses.error:
|
||||
pass
|
||||
self.scr.refresh()
|
||||
|
||||
# ── actions ───────────────────────────────────────────────────────────────
|
||||
|
||||
def send(self, message: str):
|
||||
with self._lock:
|
||||
self.history.append({"role": "user", "content": message})
|
||||
prior = [m for m in self.history[:-1]]
|
||||
self.streaming = True
|
||||
self.stream_buf = ""
|
||||
self.scroll = 0
|
||||
|
||||
def _stream():
|
||||
capture_meta = False
|
||||
capture_memory = False
|
||||
try:
|
||||
payload = {
|
||||
"message": message,
|
||||
"model": self.model,
|
||||
"conversation_id": self.conv_id,
|
||||
"history": prior,
|
||||
}
|
||||
with httpx.Client(timeout=180.0) as client:
|
||||
with client.stream("POST", f"{API_BASE}/chat/stream",
|
||||
json=payload) as resp:
|
||||
for line in resp.iter_lines():
|
||||
if line == "event: meta":
|
||||
capture_meta = True
|
||||
continue
|
||||
if line == "event: memory":
|
||||
capture_memory = True
|
||||
continue
|
||||
if capture_meta:
|
||||
capture_meta = False
|
||||
if line.startswith("data: "):
|
||||
try:
|
||||
with self._lock:
|
||||
self._last_meta = json.loads(line[6:])
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
if capture_memory:
|
||||
capture_memory = False
|
||||
if line.startswith("data: "):
|
||||
try:
|
||||
with self._lock:
|
||||
self._memory_saved = json.loads(line[6:])
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
if line.startswith("data: "):
|
||||
with self._lock:
|
||||
self.stream_buf += line[6:]
|
||||
except Exception as e:
|
||||
with self._lock:
|
||||
self.stream_buf += f"\n[Error: {e}]"
|
||||
finally:
|
||||
with self._lock:
|
||||
if self.stream_buf:
|
||||
self.history.append({
|
||||
"role": "assistant",
|
||||
"content": self.stream_buf,
|
||||
"stats": self._last_meta,
|
||||
"memory": self._memory_saved,
|
||||
})
|
||||
self.streaming = False
|
||||
self.stream_buf = ""
|
||||
self._last_meta = None
|
||||
self._memory_saved = None
|
||||
|
||||
threading.Thread(target=_stream, daemon=True).start()
|
||||
|
||||
def regenerate(self):
|
||||
if self.streaming:
|
||||
return
|
||||
with self._lock:
|
||||
if not self.history:
|
||||
return
|
||||
if self.history[-1]["role"] == "assistant":
|
||||
self.history.pop()
|
||||
last_user = None
|
||||
for i in range(len(self.history) - 1, -1, -1):
|
||||
if self.history[i]["role"] == "user":
|
||||
last_user = self.history.pop(i)["content"]
|
||||
break
|
||||
if last_user:
|
||||
self.send(last_user)
|
||||
|
||||
def cycle_model(self):
|
||||
if self.streaming or not self.models:
|
||||
return
|
||||
self.model_idx = (self.model_idx + 1) % len(self.models)
|
||||
self.model = self.models[self.model_idx]
|
||||
|
||||
def new_conversation(self):
|
||||
if self.streaming:
|
||||
return
|
||||
with self._lock:
|
||||
self.history = []
|
||||
self.conv_id = str(uuid.uuid4())
|
||||
self.stream_buf = ""
|
||||
self.scroll = 0
|
||||
|
||||
def _cmd_remember(self, text: str):
|
||||
"""Write a fact directly to the memory service, bypassing the LLM curator.
|
||||
|
||||
Syntax: /remember <text>
|
||||
/remember [Section] <text>
|
||||
"""
|
||||
import re as _re
|
||||
section = "General"
|
||||
m = _re.match(r'^\[([^\]]+)\]\s*(.+)', text)
|
||||
if m:
|
||||
section, text = m.group(1).strip(), m.group(2).strip()
|
||||
if not text:
|
||||
return
|
||||
try:
|
||||
r = httpx.post(
|
||||
f"{MEMORY_BASE}/memories",
|
||||
json={"section": section, "text": text},
|
||||
timeout=5.0,
|
||||
)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
saved = {"section": data["section"], "text": data["text"]}
|
||||
with self._lock:
|
||||
self.history.append({
|
||||
"role": "assistant",
|
||||
"content": f"Saved to memory [{saved['section']}]: {saved['text']}",
|
||||
"stats": None,
|
||||
"memory": saved,
|
||||
})
|
||||
else:
|
||||
with self._lock:
|
||||
self.history.append({
|
||||
"role": "assistant",
|
||||
"content": f"Memory service returned {r.status_code}",
|
||||
"stats": None,
|
||||
"memory": None,
|
||||
})
|
||||
except Exception as e:
|
||||
with self._lock:
|
||||
self.history.append({
|
||||
"role": "assistant",
|
||||
"content": f"Memory service unreachable: {e}",
|
||||
"stats": None,
|
||||
"memory": None,
|
||||
})
|
||||
|
||||
# ── main loop ─────────────────────────────────────────────────────────────
|
||||
|
||||
def run(self):
|
||||
while True:
|
||||
self.draw()
|
||||
try:
|
||||
key = self.scr.getch()
|
||||
except KeyboardInterrupt:
|
||||
break
|
||||
if key == -1:
|
||||
continue
|
||||
|
||||
if key == 24: # ^X exit
|
||||
break
|
||||
elif key == 18 and not self.streaming: # ^R regen
|
||||
self.regenerate()
|
||||
elif key == 14: # ^N new conversation
|
||||
self.new_conversation()
|
||||
elif key == 12: # ^L clear (alias new)
|
||||
self.new_conversation()
|
||||
elif key == 20 and not self.streaming: # ^T cycle model
|
||||
self.cycle_model()
|
||||
elif key in (10, 13): # Enter send
|
||||
msg = self.input_buf.strip()
|
||||
if msg and not self.streaming:
|
||||
self.input_buf = ""
|
||||
if msg.lower().startswith("/remember "):
|
||||
self._cmd_remember(msg[10:].strip())
|
||||
else:
|
||||
self.send(msg)
|
||||
elif key in (127, curses.KEY_BACKSPACE, 8):
|
||||
self.input_buf = self.input_buf[:-1]
|
||||
elif key == curses.KEY_UP:
|
||||
self.scroll += 1
|
||||
elif key == curses.KEY_DOWN:
|
||||
self.scroll = max(0, self.scroll - 1)
|
||||
elif key == curses.KEY_PPAGE: # PgUp
|
||||
h, _ = self.dims()
|
||||
self.scroll += max(1, h - 6)
|
||||
elif key == curses.KEY_NPAGE: # PgDn
|
||||
h, _ = self.dims()
|
||||
self.scroll = max(0, self.scroll - max(1, h - 6))
|
||||
elif key == curses.KEY_HOME:
|
||||
self.scroll = 9999 # jump to top
|
||||
elif key == curses.KEY_END:
|
||||
self.scroll = 0 # jump to bottom
|
||||
elif 32 <= key <= 126:
|
||||
self.input_buf += chr(key)
|
||||
|
||||
|
||||
# ─── entry point ──────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
print("Checking Nexus backend…", flush=True)
|
||||
started_pid = None
|
||||
if not backend_running():
|
||||
print("Synapse is not running — starting backend…", flush=True)
|
||||
started_pid = start_backend()
|
||||
if started_pid is None:
|
||||
print(f"ERROR: backend failed to start. Logs: {LOG_FILE}")
|
||||
sys.exit(1)
|
||||
print("Backend ready.", flush=True)
|
||||
else:
|
||||
print("Backend is running.", flush=True)
|
||||
|
||||
time.sleep(0.2)
|
||||
try:
|
||||
curses.wrapper(lambda s: NexusChat(s).run())
|
||||
finally:
|
||||
if started_pid is not None:
|
||||
print("\nShutting down Synapse backend…", flush=True)
|
||||
stop_backend(started_pid)
|
||||
print("Done.", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user